This repository was archived by the owner on Aug 10, 2026. It is now read-only.
fix(agent): pending-inbound queue + replay (#121) and thread-as-container working-ensure - #122
Merged
Conversation
…ead-as-container working-ensure Two coherent parts. PART A — pending-inbound queue + replay-on-register (fix #121; agent-side, no vault change) The daemon must OWN an inbound it can't yet process, never drop it. Before this, if ctx.emit found no live programmatic agent for a channel, the inbound fell through to a 0-subscriber SSE/MCP push and was silently dropped — yet the handler still returned 200, which the vault trigger acks as done (stamps `_rendered_at`) and NEVER retries. Permanent loss: 0 turns, 0 threads, no reply. ProgrammaticAgentRegistry now holds a per-channel PENDING-INBOUND buffer (FIFO, capped at PENDING_INBOUND_CAP=50, oldest-evicted past the cap with a loud log) gated by an expected-channels set: - expectChannel(channel) — the def-instantiation path (buildInstantiateDeps.ensureChannel) marks a channel EXPECTED before bringing the channel transport live, closing the desync window where the channel is live but the agent isn't yet register()ed. - queuePending(channel, msg) → "queued" (expected; buffered, owned, will replay) or "unknown" (nothing maps here; caller logs + falls through to the push path, still 200). - register() drains the channel's pending buffer through the NORMAL enqueue path in arrival order, then clears the expected mark (the live byChannel index is the truth). - unexpectChannel(channel) clears a stale mark + stranded buffer on teardown (deregister). ctx.emit wires this: no live programmatic agent → queuePending; "queued" returns (200, owned); "unknown" falls through to the existing push (a genuine telegram/bridge channel still works). The handler keeps returning 200 throughout (a 4xx/5xx would strand the vault trigger in `_pending_at`). In-memory is acceptable for v1: a daemon restart loses pending, but the durable inbound notes still exist and loadAll + the 60s def-poll reconverge. PART B — thread-as-container (definition → thread → message) The #agent/thread note is now a CONTAINER ensured when the agent STARTS processing a message, then UPDATED by the turn — not a post-turn by-product. drain() writes a working thread note (status:working, input shown, NO reply) BEFORE deliver(); the existing recordThread after the turn updates it to ok/error. A new `phase: "start" | "end"` field on ThreadRecord/ThreadNote carries the lifecycle: - phase:start (working-ensure) — status working; turn_count UNCHANGED (single: prior; multi: 0); last_turn_at NOT advanced. - phase:end (default when absent — back-compat) — the final record; turn_count counted here (single increments unless sameTurn; multi=1); last_turn_at advanced. So turn_count is counted EXACTLY ONCE per turn (on end) — never double-counted across the start+end pair. The start-ensure and end-record share the per-turn threadId, so single-threaded UPSERTS its deterministic note and multi-threaded targets the SAME per-fire note (no duplicate). buildThreadSummaryBody handles the working state (no fake reply). buildWriteThread now forwards threadId + sameTurn + phase (it previously dropped threadId and sameTurn — a latent gap that would have duplicated the multi-threaded re-record note and double-counted single-threaded sameTurn through the real daemon path). Message↔thread link: the OUTBOUND #agent/message note is stamped with metadata.thread (the per-turn thread id, threaded WriteOutbound → reply() via meta.thread). For multi-threaded this IS the per-fire note leaf. INBOUND-note stamping is DEFERRED (externally written before the turn knows its thread); single-threaded outbound→note-by-stable-path is a follow-up. PART C — desync finding agent-defs loadAll re-instantiates EVERY enabled def on boot AND the 60s poll (instantiate always calls ensureChannel + setupAndRegister; register() is idempotent-replace). So any channel/agent desync self-heals within one poll cycle; the reload webhook is the fast path. Part A's expect+replay closes the residual window (inbound landing between channel-live and agent-register). No code change needed beyond Part A's expectChannel wiring. Tests: full gate green — `bun run test` (typecheck + bun test ./src) = 994 pass, 0 fail. Existing thread/drain/outbound-failure tests updated to be phase-aware (assert the FINAL records via an ends()/starts() phase filter) so they still verify their substance with the added working-ensure. New tests cover: pending queue + FIFO replay + cap + unknown-channel + daemon 200-not-drop; working-ensure-before-deliver ordering; turn_count no-double-count across start+end (single turns 1→2, multi 0→1); same-note targeting; working body has no fake reply; outbound thread-id stamp. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…anup + working-body wording - register() channel-move path now also drops the OLD channel's expectedChannels mark + pending buffer alongside byChannel/queues/draining (was a latent leak if a def's wake channel ever changes between polls). Adds a regression test. - buildThreadSummaryBody working state no longer prints a confusing "0 turns, currently working" — it reads "working on the first turn" / "next turn" instead. Gate: bun run test = 995 pass, 0 fail. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…g + full-lifecycle turn_count test - deregister() now clears expectedChannels + pending for the channel (was only done by the daemon teardown wrapper) — closes the latent footgun for direct registry callers: a pending message after deregister had nothing to drain into. - Add the one missing transport test: the full start→end(ok)→end(error,sameTurn) lifecycle, asserting turn_count goes 0→1→1 (start never counts; sameTurn never re-counts) — the phase+FIX-1 combination the prior tests didn't exercise. Gates: bun run test 996 pass / 0 fail; typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
unforced
added a commit
that referenced
this pull request
Jun 20, 2026
…r substrate (#123) * feat(agent): agent-to-agent callback routing (reply_to) — orchestrator substrate Request/response between agent threads. An inbound #agent/message/inbound note MAY carry metadata.reply_to (the sender's channel), plus optional correlation_id and delegation_depth. When the recipient's programmatic turn finishes (BOTH ok and error), the daemon delivers a lightweight CALLBACK back to the reply_to channel — a brief notification + LINK (source_thread / source_message) the orchestrator pulls the full result from, NOT the duplicated reply. Loop safety (3 layers): the callback note never carries reply_to (terminal, structural); delegation_depth ceiling (MAX_DELEGATION_DEPTH=8) bounds runaway chains; an unknown reply_to channel reuses the #122 own-it-don't-strand posture (log + no throw). Concurrency: N callbacks returning to one orchestrator channel drain FIFO via the existing per-channel serial drain (#122) — never concurrent, none lost; the orchestrator's --resume session carries state across them. - registry.ts: QueuedMessage gains replyTo/correlationId/delegationDepth; WriteCallback seam + CallbackMeta contract + MAX_DELEGATION_DEPTH; maybeDeliverCallback at all four drain terminal points; WriteOutbound return widened to surface the outbound note id for source_message (back-compat — void is in the union). - daemon.ts: callbackFieldsFromMeta (extract + string->int coerce); contextFor.emit threads the fields onto the enqueue + pending paths; buildWriteCallback (resolve reply_to channel transport, own-it on unknown); buildWriteOutbound returns the note id. - transport.ts: Transport.writeCallback? + CallbackMetadata. - vault.ts: VaultTransport.writeCallback (writes a callback inbound note, strips any stray reply_to); writeInbound gains optional extraMeta. - design/2026-06-20-agent-callbacks.md: the model, metadata contract, loop safety, concurrency story, summary+link rationale, deferred delegate MCP tool. Gate: bun run test (typecheck + bun test ./src) green — 1017 pass, 0 fail. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(agent): clarify depth-0 omission + concurrency-test scope (reviewer nits) Comment-only: callbackFieldsFromMeta notes that a literal "0" is omitted (the drain's ?? 0 fallback handles depth 0); the concurrency test comment clarifies it exercises the drain-side FIFO property, not the real vault-IPC delivery path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(agent): accurate source_thread caveat (review nit) + follow-up #124 source_thread is resolvable for multi-threaded (per-fire note leaf) but is a per-turn correlation id for single-threaded (NOT the deterministic note leaf), so source_message is the reliable pull-link for single-threaded recipients. Fixed the CallbackMeta doc, the metadata-contract table, and the deferred-notes; the proper fix (widen the writeThread seam so source_thread is the written note id for both modes) is tracked as #124. No logic change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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.
Two coherent parts plus a supporting finding. Central, subtle code — heavily commented, especially the turn_count and pending-replay logic. No version bump.
Part A — pending-inbound queue + replay-on-register (fixes #121; agent-side, NO vault change)
The bug. In
daemon.tscontextFor.emit, an inbound for a channel with no live programmatic agent (programmatic.hasChannel(channel) === false— a just-defined agent not yet instantiated, or a brief channel/agent desync) fell through to a 0-subscriber SSE/MCP push and was silently dropped, while/api/vault/inboundstill returned 200. The vault trigger acks 200 as done (stamps<trigger>_rendered_at) and never retries → permanent loss (0 turns, 0 threads, no reply).The fix. The daemon now OWNS an inbound it can't yet process:
ProgrammaticAgentRegistrygains a per-channel pending-inbound buffer (FIFO, capped atPENDING_INBOUND_CAP = 50; past the cap the oldest is evicted with a loud log — bounded loss beats unbounded growth, and the durable inbound notes still exist in the vault), gated by anexpectedChannelsset.expectChannel/unexpectChannel/queuePending(channel,msg) → "queued"|"unknown"/pendingCount/isExpected.register()drains the channel's pending buffer through the normalenqueuepath (FIFO, arrival order) once the agent is live, then clears the expected mark (the livebyChannelindex is the truth).emit(daemon.ts) wires it: no live agent →queuePending;"queued"returns (200, owned);"unknown"falls through to the existing push (a genuine telegram/bridge channel still works). The handler always 200s — a 4xx/5xx would strand the trigger in_pending_at.buildInstantiateDeps.ensureChannelcallsexpectChannelbefore bringing the channel live (closing the desync window);deregistercallsunexpectChannel.In-memory is acceptable for v1: a daemon restart loses pending, but the durable inbound notes exist and
loadAll+ the 60s def-poll reconverge.Part B — thread-as-container (definition → thread → message)
The
#agent/threadnote is now a CONTAINER ensured when the agent starts processing a message, then UPDATED by the turn — not a post-turn by-product.API change —
phase: "start" | "end"onThreadRecord(transport.ts) /ThreadNote(registry.ts):drainwrites a working-ensure (status: "working", input shown, NO reply) withphase: "start"BEFOREdeliver().recordThreadwrites the final record withphase: "end"(the default when absent — back-compat).turn_count counted EXACTLY ONCE (on
end), never double-counted across start+end — inVaultTransport.writeThread:phase: "start"→turn_count = prior(single) /0(multi);last_turn_atNOT advanced;started_atpreserved/initialized.phase: "end"→ single incrementsprior + 1(unlesssameTurn, the outbound-failure re-record); multi = 1;last_turn_atadvanced.The start-ensure and end-record share the per-turn
threadId, so single-threaded UPSERTS its deterministic note and multi-threaded targets the SAME per-fire note (no duplicate).buildThreadSummaryBodyhandles theworkingstate (no fake reply).Latent-bug fix:
buildWriteThread(daemon.ts) previously droppedthreadIdandsameTurn— so through the real daemon path a multi-threaded re-record would mint a duplicate note and single-threadedsameTurnwould double-count. It now forwardsthreadId+sameTurn+phase.Message ↔ thread linkage: the OUTBOUND
#agent/messagenote is stamped withmetadata.thread(the per-turn thread id), threadedWriteOutbound→reply()viameta.thread. For multi-threaded this IS the per-fire note leaf. Deferred: INBOUND-note stamping (those notes are written externally before the turn knows its thread); single-threaded outbound→note-by-stable-path. Not blocking.Part C — desync finding (supporting)
agent-defs.ts loadAllre-instantiates every enabled def on boot AND the 60s poll (instantiatealways callsensureChannel+setupAndRegister;register()is idempotent-replace). So any channel/agent desync self-heals within one poll cycle; the reload webhook is the fast path. Part A's expect+replay closes the residual window (inbound landing between channel-live and agent-register). No code change needed beyond Part A'sexpectChannelwiring.Tests & gate
bun run test(typecheck +bun test ./src) = 995 pass, 0 fail.register()drains FIFO + runs turns in order; cap enforced; unknown channel logs + 200s (no crash); channel-move clears old marks/buffer.deliver(gated-turn assertion); status working→ok/error; turn_count no-double-count at the transport (single 1→2, multi 0→1, sameTurn stays 1); start+end target the same note; working body has no fake reply; outbound stamped with thread id.ends()/starts()filter — they still verify their substance with the added working-ensure.Review
Independent reviewer pass: LGTM with nits, no critical issues. Both substantive nits fixed in the second commit (channel-move expected/pending leak + working-body wording); the turn_count invariant trace was independently verified.