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:
- 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 test → OK — delivered).
- 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/daemon.log — 0 bytes; the child never logged anything.
- No
daemon-internal process alive.
notify health → daemon 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.
Version: gjc/0.12.7 (installed), Linux, source-mode Bun runtime.
Summary
gjc notify setup --token … --chat-id …(non-interactive) fails at daemon activation:Two defects here, and they are separable:
gjc notify statusimmediately afterwards reports
enabled: true,telegram.configured: yes, fingerprint stored.Outbound notifications work from that moment (
gjc notify test→OK — delivered).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:
So the failure is unconditional on this host, not contention-dependent.
Post-failure state
notifications/daemon.log— 0 bytes; the child never logged anything.daemon-internalprocess alive.notify health→daemon 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 withstatus 0 and no output on either stream, so the daemon entrypoint has at least one silent-exit path
with no diagnostic (
telegram-daemon-cli.tsrunDaemonInternal, e.g. theownerProcessIsAliveearly return only calls
logger.warn, which goes nowhere for a detached child whose log file staysempty).
Message/rollback contract
packages/coding-agent/src/cli/notify-cli.ts:470-536:The
settingsCommitted === falsebranch printed while the values are on disk, so the flag does notdescribe durable state on this path. Only
blocked_identityrestores anything; a thrown activationleaves 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
getUpdateson 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 statusreports 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 opaquedaemon-<uuid>owner id forruntimeInfo.mode === "source" && platform === "win32", with the commentThe same short-lived-parent shape exists for a source-mode Bun CLI on Linux (
notify setupexitsright 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
notifications.telegram.botToken/notifications.enabledunchanged.notify statusreports afterwards.runDaemonInternalearly-exit paths write a durable diagnostic tonotifications/daemon.loginstead of a logger sink that a detached child discards.
Related: #3587, #2956.