fix: a daemon spawn either becomes a daemon or says why (#68) - #69
Merged
Conversation
Installing 0.12.2 on a host whose omp session held the poll lock produced 83 permanent Telegram topics, five seconds apart, and stopping the bridge did not help. Three defects compounded. **The spawn could never work.** `ensureDaemon` launched `process.execPath`, and inside the omp binary that is *omp* — a compiled Bun executable that ignores a script argument and boots an interactive agent session. Proof: `omp daemon.ts` and `omp /nonexistent/xyz.ts` write byte-identical output. So `daemon.ts` never ran from an omp host; every "spawn" was a fresh session that claimed a topic during plugin activation and exited. `return "spawned"` was a lie. The runtime is now resolved by name and the host binary counts only when it really is a runtime. No runtime, no spawn — because launching something that cannot parse the entrypoint is the same silent no-op in a different costume. **Nothing recorded the decline.** `runDaemon` correctly exits when another process owns the poll lock, and writes no `daemon.json` (by design — that file must always name the live poller). But `ensureDaemon` read only that file, so "no daemon" and "a daemon is unnecessary" were indistinguishable, and every caller re-ran the same experiment. The lock is a local file: it is now read before spawning, and a live foreign owner returns the new `declined`. Ordering is deliberate — the stale-version kill still happens first, or a daemon holding its own lock would be immortal. Both existing callers already do the right thing with `declined`: the session polls for itself. **And the blast radius was unbounded.** A process launched to be the daemon is not a conversation, so it now refuses to claim a topic when the launcher marks it. Belt and braces: the marker costs nothing and being wrong cost 83 topics. Separately, the same incident exposed why `/cleanup` could not clear the mess it exists for. `claimThread` was an unserialised read-modify-write over a whole file, through a *shared* `threads.json.tmp` that two writers could both rename — so concurrent claims silently lost rows. A burst that created 16 topics recorded 15. The registry is the only index of topics that exists (the Bot API cannot list them), so a lost row is a topic nobody can ever find again: 15 of the 83 had to be recovered by sweeping id ranges by hand. Writes are now per-process and serialised on the same `linkClaim` primitive the poll lock uses, bounded so a crashed holder cannot wedge startup and self-healing by age. Every test was checked against the old code first: reverting the lock precheck turns "declines instead of spawning" red, and reverting the write path turns "concurrent claims all persist" red. bun run check: 300 pass, 0 fail.
This was referenced Aug 23, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #68. Also removes the reason #67's
/cleanupcould not clear the mess it exists for.What happened
Installing 0.12.2 on a host whose omp session held the poll lock produced 83 permanent Telegram topics, ~5s apart, across three bursts. Stopping the bridge did not help. Three defects compounded.
1. The spawn could never have worked
Inside the omp binary
process.execPathis omp — a compiled Bun executable that ignores a script argument and boots an interactive agent session. Proof, on the affected host:Byte-identical to a path that does not exist.
daemon.tsnever ran from an omp host; every "spawn" was a fresh session that claimed a topic during plugin activation, then exited.return "spawned"was a lie, and the daemon only ever started from hosts whereexecPathreally was a runtime — which is why one had been running as 0.12.1 until the upgrade SIGTERMed it.Now: the runtime is resolved by name; the host binary counts only when it is one; no runtime means no spawn. Launching something that cannot parse the entrypoint is the same silent no-op in a different costume.
2. Nothing recorded the decline
runDaemoncorrectly exits when another process owns the poll lock:…and writes no
daemon.json— by design, since that file must always name the live poller. ButensureDaemonconsulted only that file, so "no daemon yet" and "a daemon is unnecessary" were indistinguishable. Every caller re-ran the same experiment, and each experiment cost a topic.daemonDisableReasonalready had the right shape for configured reasons (bridge disabled,topics off,groups configured) — returning early without spawning. A runtime reason had no equivalent.The lock is a local file, so it is now read before spawning and a live foreign owner returns a new
declined. Ordering is deliberate: the stale-version kill still happens first, or a daemon holding its own lock would be immortal — pinned by a test.Both existing call sites already do the right thing with
declined:index.ts:812falls through toacquireAndLaunch, so the session polls for itself, which is exactly correct when no daemon can run.3. The blast radius was unbounded
A process launched to be the daemon is not a conversation and has no business owning a human-visible topic. The launcher now marks the child and
ensureTopicrefuses. This should be unreachable after (1) — it is here because the marker costs nothing and being wrong cost 83 topics.And why
/cleanupcouldn't fix itclaimThreadwas an unserialised read-modify-write over a whole file, through a shared temp that two writers could both rename. Concurrent claims silently lost rows: a burst that created 16 topics recorded 15. (saveDaemonStatealready used${path}.tmp-${process.pid}; this did not.)The registry is the only index of topics that exists — the Bot API cannot list them — so a lost row is a topic nobody can find again.
/cleanupreadsstaleThreads, which reads the registry, so those topics were permanently unreachable. 15 of the 83 had to be recovered by sweeping id ranges by hand.Writes are now per-process and serialised on the same
linkClaimprimitive the poll lock uses — bounded (a caller that cannot take the lock in 2s proceeds anyway; a hung startup is worse than a lost update) and self-healing by age.Tests — each checked against the old code first
declines instead of spawning when another live process owns the poll lock"spawned")concurrent claims from separate processes all persistBoth go green with the fix. The rest: a dead lock holder must not wedge it shut; our own lock is not foreign; a stale-version daemon is still stopped before the lock is consulted; the resolved runtime is launched and the child is marked;
resolveRuntimerejects a host binary that only looks like a launcher, accepts one that is, and findsbunon PATH otherwise; a dead mutation lock self-heals; no temp files leak.Not in this PR
#67's other half —
/cleanupkeys on dead-pid alone and, in a DM host, deletes. So its only remedy for 83 topics minutes old would also have permanently destroyed an unrelated project topic from eight days earlier. That wants an age/reason predicate and a partial/cleanup go, and it is a separate change.