Summary
Upgrading to 0.12.2 put this host into an unbounded loop that minted one permanent Telegram topic every ~5 seconds — 40 in eleven minutes, then 40 more after the bridge was restarted. Stopping the bridge does not stop it.
The upgrade is the trigger. The defect is that a runtime decline to start is never recorded, and the retry vehicle is an omp session that claims a topic before it discovers it should exit.
The chain, each step verified on the host
1. The upgrade kills the running daemon — by design.
ensureDaemon is "ensure one current-version daemon is alive" (daemon.ts:106). On a version mismatch it SIGTERMs the incumbent (daemon.ts:120-133). Installing 0.12.2 therefore terminated the live 0.12.1 poller. Correct behaviour.
2. The slot can never be refilled, because a session holds the poller lock.
Running the daemon by hand shows the decline verbatim:
$ bun src/daemon.ts
[telegram daemon] another poller (pid 2186015, conductor) holds the lock; exiting
$ echo $?
0
pid 2186015 is a live interactive omp session. Telegram permits exactly one getUpdates consumer, so declining is right.
3. The decline leaves no trace, so it is re-litigated forever.
The declining child exits 0 and never writes daemon.json:
$ ls /root/.omp/agent/telegram/daemon.json
ls: cannot access …: No such file or directory
readDaemonState() → undefined → ensureDaemon skips the alive/version branch entirely and spawns. daemonDisableReason covers only configured reasons — bridge disabled, topics off, groups configured — and returns "disabled" without spawning. A runtime reason ("a poller already owns the lock") has no equivalent, so it is discovered only by spawning a child, and the answer is thrown away.
4. The retry vehicle has a permanent, user-visible side effect.
spawnDaemon(process.execPath, [join(import.meta.dirname, "daemon.ts")], { detached: true, … })
Inside the omp binary, process.execPath is /root/.local/bin/omp — not bun. So the retry is omp <script>, which boots a full omp session: plugin activation runs ensureOwnTopic → createForumTopic (index.ts:1020) before the process reaches the lock check and exits.
Controlled single spawn, registry diffed either side:
before: ['9551']
$ timeout 8 /root/.local/bin/omp …/omp-telegram/src/daemon.ts # exit 129
after: ['9551', '10189'] #10189 conductor pid 2212517
One spawn, one permanent topic. The topic outlives the process by design; nothing reaps it (see #67).
5. It self-perpetuates and ignores the bridge.
Each spawned session itself calls ensureDaemon, spawning the next before exiting — detached, ppid 1, ~5s apart:
#10097 08:09:18 pid 2200460
#10100 08:09:23 pid 2200704 +5.0s
#10103 08:09:28 pid 2200944 +5.0s
…
Killing the whole chain at once is the only thing that stops it. Stopping the bridge daemon does not, because createForumTopic is called directly by each omp process with the bot token and never routes through the bridge — so the obvious containment does nothing, which cost real time here.
Why it surfaced only now
Before the upgrade the 0.12.1 daemon held the lock and ensureDaemon returned "alive" on the version match. The upgrade killed it, and the session that restarted moments later took the lock. From then on every spawn declines — the steady state flipped from "converges immediately" to "never converges", and the loop had always been latent behind it.
Suggested fixes, in order of value
- Read the lock before spawning, not after. The poller lock is a local file;
daemonDisableReason can consult it and return a disable reason in-process. Spawning a child to discover something already readable is what makes this expensive.
- Record the negative result. Whatever the check, a decline should persist ("declined at T because the lock is held by pid N") so the next call is a no-op rather than a fresh experiment.
- A daemon child must never claim a session topic. Pass a flag/env that suppresses interactive session activation, or gate
ensureOwnTopic on being an interactive session. A background helper owning a human-visible topic is wrong even when it succeeds.
- Bound the retry. No backoff and no failure memory turns one persistent wrong condition into unbounded permanent side effects.
- Reconsider
process.execPath. When the host binary is an agent, execPath <script> boots an agent. An explicit runtime, or an internal subcommand, says what it means.
Environment
omp-telegram 0.12.2, DM topics host, an interactive omp session holding the poller lock. Related: #67 (the orphans this produces are not reapable without deleting unrelated history).
Summary
Upgrading to 0.12.2 put this host into an unbounded loop that minted one permanent Telegram topic every ~5 seconds — 40 in eleven minutes, then 40 more after the bridge was restarted. Stopping the bridge does not stop it.
The upgrade is the trigger. The defect is that a runtime decline to start is never recorded, and the retry vehicle is an omp session that claims a topic before it discovers it should exit.
The chain, each step verified on the host
1. The upgrade kills the running daemon — by design.
ensureDaemonis "ensure one current-version daemon is alive" (daemon.ts:106). On a version mismatch it SIGTERMs the incumbent (daemon.ts:120-133). Installing 0.12.2 therefore terminated the live 0.12.1 poller. Correct behaviour.2. The slot can never be refilled, because a session holds the poller lock.
Running the daemon by hand shows the decline verbatim:
pid 2186015 is a live interactive omp session. Telegram permits exactly one
getUpdatesconsumer, so declining is right.3. The decline leaves no trace, so it is re-litigated forever.
The declining child exits 0 and never writes
daemon.json:readDaemonState()→undefined→ensureDaemonskips the alive/version branch entirely and spawns.daemonDisableReasoncovers only configured reasons —bridge disabled,topics off,groups configured— and returns"disabled"without spawning. A runtime reason ("a poller already owns the lock") has no equivalent, so it is discovered only by spawning a child, and the answer is thrown away.4. The retry vehicle has a permanent, user-visible side effect.
Inside the omp binary,
process.execPathis/root/.local/bin/omp— not bun. So the retry isomp <script>, which boots a full omp session: plugin activation runsensureOwnTopic→createForumTopic(index.ts:1020) before the process reaches the lock check and exits.Controlled single spawn, registry diffed either side:
One spawn, one permanent topic. The topic outlives the process by design; nothing reaps it (see #67).
5. It self-perpetuates and ignores the bridge.
Each spawned session itself calls
ensureDaemon, spawning the next before exiting — detached,ppid 1, ~5s apart:Killing the whole chain at once is the only thing that stops it. Stopping the bridge daemon does not, because
createForumTopicis called directly by each omp process with the bot token and never routes through the bridge — so the obvious containment does nothing, which cost real time here.Why it surfaced only now
Before the upgrade the 0.12.1 daemon held the lock and
ensureDaemonreturned"alive"on the version match. The upgrade killed it, and the session that restarted moments later took the lock. From then on every spawn declines — the steady state flipped from "converges immediately" to "never converges", and the loop had always been latent behind it.Suggested fixes, in order of value
daemonDisableReasoncan consult it and return a disable reason in-process. Spawning a child to discover something already readable is what makes this expensive.ensureOwnTopicon being an interactive session. A background helper owning a human-visible topic is wrong even when it succeeds.process.execPath. When the host binary is an agent,execPath <script>boots an agent. An explicit runtime, or an internal subcommand, says what it means.Environment
omp-telegram0.12.2, DM topics host, an interactive omp session holding the poller lock. Related: #67 (the orphans this produces are not reapable without deleting unrelated history).