Skip to content

notifications: non-interactive notify setup always fails to activate the daemon, persists the settings anyway, and reports "Unable to persist" #3761

Description

@yazzang-homelab

Version: gjc/0.12.7 (installed), Linux, source-mode Bun runtime.

Summary

gjc notify setup --token … --chat-id … (non-interactive) fails at daemon activation:

Token validated. Message your bot now from the private Telegram chat to pair notifications.
Warning: Telegram Threaded Mode capability is OFF for this bot. Setup will be saved because this run is non-interactive.
Using provided chat id <redacted> (non-interactive).
Error: Unable to persist and activate Telegram notification settings:
       Telegram daemon provisional ownership could not be retired safely

Two defects here, and they are separable:

  1. The settings are persisted despite the "Unable to persist" wording. gjc notify status
    immediately afterwards reports enabled: true, telegram.configured: yes, fingerprint stored.
    Outbound notifications work from that moment (gjc notify testOK — delivered).
  2. The daemon never becomes ready, so inbound (button/reply) handling is dead, and every run
    leaves telegram-daemon.{lock,steal,state.json} behind.

This is not the 409 / competing-poller case

First observed on a bot token that another process was polling, so a 409 story looked plausible.
Verified false. Reproduced on a brand-new bot created minutes earlier, with no other poller:

getMe      → ok: True, username <fresh bot>
getUpdates → ok: True, error: None, updates: 0     # no 409, no competitor
sendMessage→ ok: True                              # chat reachable
gjc notify setup --token … --chat-id … --redact
           → Error: Unable to persist and activate … provisional ownership could not be retired safely
gjc notify status → enabled: true, telegram.configured: yes
gjc notify test   → OK — delivered to chat …

So the failure is unconditional on this host, not contention-dependent.

Post-failure state

// notifications/telegram-daemon.state.json
{ "pid": 111629, "ownershipPhase": "provisional",
  "startedAt": 1785710585165, "heartbeatAt": 1785710585165,   // never advanced
  "generation": 38, "servingEpoch": 4 }
  • notifications/daemon.log0 bytes; the child never logged anything.
  • No daemon-internal process alive.
  • notify healthdaemon owner pid 111629 is not alive; run recovery to clear the stale lock.

gjc notify daemon-internal --owner-id <live-pid>-probe --agent-dir … also exits immediately with
status 0 and no output on either stream, so the daemon entrypoint has at least one silent-exit path
with no diagnostic (telegram-daemon-cli.ts runDaemonInternal, e.g. the ownerProcessIsAlive
early return only calls logger.warn, which goes nowhere for a detached child whose log file stays
empty).

Message/rollback contract

packages/coding-agent/src/cli/notify-cli.ts:470-536:

const receipt = await settings.commitAtomicBatch(patches);   // :477 values land here
settingsCommitted = true;                                    // :478
const activation = await reconcileCommittedTelegramConfiguration({});  // :485
if (activation.status === "blocked_identity") {  await activation.restore();  }  // :511 only rollback
if (activation.status === "activation_failed") { receipt.discard(); throw new Error(activation.message); }
} catch (error) {
    throw new Error(
        settingsCommitted
            ? `Telegram notification settings were saved, but activation or recovery failed: ${detail}`
            : `Unable to persist and activate Telegram notification settings: ${detail}`,   // :534 ← printed
    );
}

The settingsCommitted === false branch printed while the values are on disk, so the flag does not
describe durable state on this path. Only blocked_identity restores anything; a thrown activation
leaves the config armed.

Compare the interactive pairing path, which guarantees the opposite for its failure class
(packages/coding-agent/src/sdk/bus/telegram-setup.ts:459-463):

"Telegram setup stopped because another poller owns this bot. No configuration was saved."

Why it matters

The operator reads "Unable to persist", assumes nothing happened, and moves on. Telegram is in fact
enabled. If the token belongs to a bot another product already polls, the next gjc session starts a
daemon that competes for getUpdates on it — the #3587 failure mode, reached silently through setup.

Suggested fix (two independent slices)

Slice 1 — message/rollback contract (small, testable now).
Derive the failure wording from durable state observed after the failure, and roll back the committed
patches for every non-success activation outcome, not just blocked_identity. "Unable to persist"
must be impossible to print while notify status reports configured.

Slice 2 — daemon activation on a source-mode runtime (needs maintainer input).
The daemon child never reaches ready and leaves ownership provisional. One candidate:
buildTelegramDaemonSpawnArgs / spawnTelegramDaemonOwner
(packages/coding-agent/src/sdk/bus/telegram-daemon.ts:3182-3209) only substitutes an opaque
daemon-<uuid> owner id for runtimeInfo.mode === "source" && platform === "win32", with the comment

// On Windows, a source-linked Bun/Node detached child can begin after its
// short-lived CLI parent has exited. Keep the owner id opaque so the
// daemon-internal launcher does not mistake that parent PID for its owner;

The same short-lived-parent shape exists for a source-mode Bun CLI on Linux (notify setup exits
right after spawning), where the guard does not apply. This is a hypothesis, not a verified root
cause
— the silent-exit path makes it unobservable from the outside, which is itself worth fixing.

Tests worth adding

  • non-interactive setup + activation failure → notifications.telegram.botToken /
    notifications.enabled unchanged.
  • invariant: the thrown wording matches what notify status reports afterwards.
  • runDaemonInternal early-exit paths write a durable diagnostic to notifications/daemon.log
    instead of a logger sink that a detached child discards.

Related: #3587, #2956.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions