feat(mesh): add run tools and minimal dispatcher - #11236
Conversation
Step 5a. The ambient binding is an AsyncLocalStorage frame established per turn, not per lifetime: a mesh body works many threads in sequence, so a frame wrapped around the launch would pin every later turn to the first thread. Nesting a different run throws rather than shadowing, because that can only mean the frame was established at the wrong level. The prompt envelope restates thread identity, title, body, status and a bounded recent window on every turn, because auto-compaction or a transcript-backed cold revive may have removed the previous frame. A delta is additional context after the agent's committed watermark, never the sole context; retention loss and a replayed delivery are labelled rather than left for the model to infer. Post text is indented past column zero so author-controlled content cannot forge a section header — that bounds structure spoofing only, not the instructions inside a post (§9.1). resolveTargets' third parameter becomes required: defaulting it to message.mentions.length > 0 re-encoded the unknown-mention fallback that the admission foundation fixed, since an unknown @name resolves to no id yet must still suppress the assignee.
Several agents work one thread. If each stamped the status when its own run ended, the last to finish would decide: an agent reviewing its part would hide another still working, and a blocker raised by one would be erased by another's clean exit. No run writes the status now. Each leaves a durable close obligation and the status is recomputed from the ones outstanding, so no ordering of concurrent completions can leave a stale state behind. Three rules exist because a review found each missing, and each failure left a thread nobody could clear. A same-thread wait is discharged by any later close, so 'A waits for B, B reviews without @-ing A' reports in_review rather than blocked. Any later successful booking discharges an earlier failure or unclosed return, so one launch failure no longer pins the thread after another agent did the work. A quiescent thread whose last admission booked nothing becomes blocked instead of sitting in in_progress with no live run and no explanation. Whether a human reply should discharge a blocker raised by an agent it did not address is 9.11 and stays open; until it is decided the selector discharges everything, and narrowing it is a change to that predicate rather than to its callers.
Closing is two writes because a closing tool is called mid-turn and cannot mark its own still-executing runtime finished. The tool records what the run is closing as and moves it to finishing, which ends the turn; the runtime callback records the terminal state, and only there is the thread's status recomputed. A crash between the two leaves a finishing run with a closeKind, which is a complete instruction for restart reconciliation — a status written before the runtime actually stopped would be a lie the next reader cannot detect. A wait is refused when nothing could wake it, and the dependency is walked over parentThreadId rather than rootThreadId so a sibling sub-thread does not count as this thread's delegation. Any close discharges peers' waits on the same thread. A clean exit that never called a closing tool is recorded as unclosed rather than as implicit success. finishRun now delegates here so a run has exactly one way to end, and postMessage discharges outstanding obligations when it books work before applying the aggregate status. Without those two producers the resolver's rules had no writer: an obsolete failure kept the thread blocked after later work succeeded, and a post that booked nothing left it in in_progress with no live run and no explanation.
…xports A structured assignment or parent report is system-authored but must keep the run or human action that caused it, so it is charged as unattended work without being suppressed as an ordinary self-authored post. PostMessageInput now carries authorKind, sourceRunId and triggerKind; all three are derived by the server and none is accepted from a model. Removes three exports from run-lifecycle that had no reader: the booking acknowledger duplicated what postMessage already calls directly, and the live run listing and re-exported author id were never read.
… codex/mesh-step-5b-tools # Conflicts: # docs/plans/2026-09-06-multi-agent-board-collaboration.md # docs/plans/2026-09-07-mesh-implementation-acceptance.md
No mutating tool accepts a thread, author, run, or idempotency id from the model. A mesh agent is one long-lived body working many threads in sequence, so an id in a tool argument is a value the model reconstructs from memory that may have been compacted, or copied from another thread's frame. Multica hit the same class of bug with resumed sessions carrying a previous turn's parent id and fixed it server-side rather than trusting the argument. Here identity comes from the ambient run frame, and every mutating call re-reads the store to confirm that frame still names a running run of that agent on that thread — the frame says what the dispatcher intended, the store says what is still true, and they diverge after a cancellation or a replay. thread_read is the one tool that takes an id, because it only reads; what it returns is still other participants' text rather than instructions. thread_create builds the sub-thread and its assignment trigger in one transaction. Two would leave a crash window in which an assigned sub-thread exists with nothing scheduled to work it. The trigger goes through ordinary admission, so assigning cannot bypass budgets, the queue limit, or the outcome model.
Admission decides whether a run exists; this decides when it starts and on which body. The split matters because a booked run is a durable fact that stays true until something changes it under the lock, while 'this agent is free right now' expires the moment it is read. So capacity backpressure and a runtime that reports the agent busy both leave the run queued with its attempt unspent, and the next pass re-reads them rather than persisting a decision that was already stale when written. Selection is by the lock-issued queueSequence, never queuedAt and never file order: posts arrive from processes whose wall clocks can disagree, and directory order would starve one thread behind another purely because of how its id sorts. The four runtime entry points stay four operations rather than one with a flag, because a resident continuation reuses the live chat, a resume restarts a paused entry, a revive rebuilds from the transcript, and a launch builds the persona from scratch. A failed or unavailable start is terminal with a typed failureStage and releases the queue slot; leaving it queued would make one broken agent definition look like an agent that is merely slow. Parent reports drain with the event id as the idempotency key. Outbox reconciliation now takes a filter so an event kind whose consumer does not exist yet stays visibly pending instead of being acknowledged into silence.
Writing this adapter corrected the dispatcher's idle path from four branches to three. Whether a completed body still has a resident runtime is not a choice the dispatcher can make: only the registry knows, and it already reports its own fallback as a typed outcome. A dispatcher picking between 'continue resident' and 'cold revive' would be guessing at state it cannot see, and would cold-revive a body that was still live. The three entry points it does choose between are genuinely distinct — build the persona, restart a paused entry the revive path would reject, or continue a completed one. Capacity is reported before any mutation, so a saturated registry costs a run nothing; an entry that changed state under the adapter is left for the next pass rather than forced; and a thrown runtime error becomes a typed failure instead of a start nobody performed. This is the single place that knows how a Qwen Code body is started, which is also where a non-local runtime would be substituted.
Thread status here is an aggregate over outstanding obligations, not a state somebody set. The obvious UI — a chat log with a status badge — would hide the one thing this system knows that Slack or Linear do not: who owes what, and what the thread is waiting on. So the thread view is a ledger of obligations with the conversation as evidence underneath it: the header is the resolver's own reason sentence, each agent that worked the thread gets a lane, and system triggers read as ledger entries rather than as someone talking. Inherits Web Shell's existing tokens and adds no colour or typeface. A downloaded display face would cost startup, break offline use, and clash with every neighbouring panel in a local-first tool. Monospace appears only where characters must align in a column or be copied exactly. blocked and in_review deliberately share one attention treatment. They are opposite in valence but they are the same query for the reader — this is waiting on me — and two colours would split that scan in two. They are told apart by the sentence, not the hue.
Read against multica@7a438bd5b rather than imagined. Four of their decisions are adopted outright: runs live in a side panel with active pinned and past collapsed, and the row carries no availability dot because the run's status is the story; the live working signal sits in the header, not a body card that competes with content and scrolls away; a list beats a minimap rail for finding; and the composer previews routing before sending, lit for will-trigger and dimmed for suppressed, with unknown mentions named rather than failing silently after send. That last one transfers exactly because decideDispatch is pure — the composer can run the real rules on a draft. What Multica cannot give us is the aggregate. Its issue status is set by a person and its runs are per-(agent, issue), so it never has to answer 'one agent submitted a summary while another is still working'. Ours does, and three round-two defects were threads stuck in a state nobody could explain. So the design budget goes there: the header is the resolver's own reason sentence, and every run row carries its close obligation where Multica carries a task status. Also borrowed: a label must not assert a cause the reason code does not carry. They keep runtime_offline, agent_runtime_required and runtime_unusable apart because the fix differs, and conflating them sent people to reconnect a machine that was already connected. Our nine skip reasons get the same treatment, each naming its own fix, shared between the composer preview and the post-send result.
'mesh' is this subsystem's module name, inherited from the branch it landed on. It had leaked into two places an agent actually reads: the run envelope's header and the shell refusal reasons. The design's own naming rule is that users and models see agents and threads; the codename stays in file paths. The envelope header now says what the block is for — this part is authenticated by the runtime, the rest is not — rather than naming a subsystem the reader has no way to know about.
Three properties live on the server because a browser cannot hold them. The thread's state is resolved on read and travels with the sentence explaining it, so the client never derives a second status word that would drift from the resolver and win by being the one on screen. The composer's routing preview runs the real parseMentions and decideDispatch — the same pure functions admission runs — and writes nothing, so looking at the consequences of a reply does not cost what accepting them costs. A post's author comes from the authenticated surface; there is no body field that can claim to be an agent. The UI follows Multica's shipped shape where it earned it: runs in a side panel with live pinned and past collapsed behind a count, the live signal in the header rather than a body card that scrolls away, and the routing preview before sending rather than a surprise after. Where Multica has nothing to copy is the aggregate — its issue status is set by a person — so that is where this design spends: the header is the resolver's own sentence and every run row carries its close obligation. The thread list groups by what each thread needs rather than by recency, which buries the two that need a person under twenty that do not. blocked and in_review share one attention treatment because they are the same reader query; the sentence tells them apart. Each refusal names its own fix, and reasons whose fixes differ are never merged into one label.
Step 9 added: REST surface and the two Web Shell pagesThe UI was designed against Multica's shipped implementation rather than imagined — Adopted from Multica: runs in a side panel with live pinned and past collapsed behind a count, the row carrying no availability dot because the run's own state is the story; the live "working" signal in the header rather than a body card that competes with content and scrolls away; the composer previews routing before sending — this transfers exactly because Where Multica has nothing to copy is the aggregate: its issue status is set by a person, so it never answers "one agent submitted a summary while another is still working". That is where this design spends — the header is the resolver's own sentence and every run row carries its close obligation. Landed: Also in this push: the internal codename stopped being taught to the model. Not done in step 9: the pages are not mounted in |
A shared-thread body works many threads in sequence, so the turn seam has to be told which one this turn is for. AgentMeta carries the binding; the dispatcher rewrites it immediately before each non-launch start, and on the launch path it travels with the launch options into the first record, because a turn that began before a patch would have run with no frame at all. Both real seams re-read it once, synchronously, and open the AsyncLocalStorage frame around the turn body. Re-reading per turn is the whole point: a frame opened around the lifetime would pin the body to whichever thread it started on, and a process-global variable would leak across concurrent turns. The six thread tools are registered process-wide so there is one place that knows a tool exists, and hidden from every agent not on a thread. They already refuse without a frame, but a refusal the model can only discover by calling costs a turn and teaches a schema that lies.
registerMeshRoutes is mounted beside the goals surface, so the daemon actually answers what the pages ask for. ThreadsRoute owns the loading. Its one non-obvious rule is that every preview response is stamped with the draft it answered and discarded if the draft has moved on: the whole value of previewing routing is that it describes the post about to be sent, so a slow answer to an older draft overwriting the current one would make it lie at exactly the moment someone relies on it. The preview is debounced on a settled draft rather than fired per keypress. An unreachable daemon says what failed and what to do about it rather than rendering an empty list that looks like a workspace with no threads. Still not mounted into App.tsx's panel switch: the way to open the page is the absorbed sidebar entry from #11140, and adding a second one here would collide with it.
The two items I had handed off, doneI had listed three things as "next"; two of them were mine to do and I should not have passed them on. Turn-seam binding (unblocks step 7). Tool registration. The six thread tools are registered process-wide so one place knows a tool exists, and hidden from every agent not on a thread. They already refuse without a frame, but a refusal the model can only discover by calling costs a turn and teaches a schema that lies. Two tests pin both directions. Also landed: One regression I caught and fixed while doing this: the seam change initially broke Observed: core Still genuinely not mine to do: step 7's live slice needs a machine that can run a model; Playwright visuals need a browser; |
|
⏸️ Triage could not complete a full review — the PR is being rewritten while it is read. Deferring rather than publishing findings against a tree that no longer exists. The head moved six times during this single run: Three things below are worth acting on now, because I re-verified each at 1. The build is broken — this is a hard blocker
The cause is in const groups: ThreadGroup[] = [ /* …the four entries… */ ];
return groups.filter((group) => group.threads.length > 0);( 2.
|
c129e4d
into
codex/multi-agent-mesh-foundation
What this PR does
Implements mesh steps 5 and 6: per-turn run identity, prompt framing, aggregate thread status, explicit run closing, six thread tools, FIFO dispatch, and parent-report delivery.
The dispatcher now claims a queued run before starting the fire-and-forget runtime, then binds the returned session. Capacity backpressure releases the claim without spending an attempt. Every mutating thread tool verifies the workspace, thread, agent, run, and attempt in the same storage transaction as its write.
Later runtime wiring and Web Shell work that had briefly landed on this branch were reverted so the stacked delivery order remains: merge these core steps first, then land the shared-runtime seam, then run the live-model vertical slice.
Why it's needed
One long-lived agent can work across many threads, so identity cannot come from model-authored arguments or remembered transcript state. The runtime frame supplies identity, the store confirms that the exact attempt is still active, and thread status is derived from all outstanding work instead of whichever agent wrote last.
The minimal dispatcher deliberately contains no recovery loop. It only establishes the smallest durable path needed for the live demo: book work, claim it once, start the agent, bind the session, close explicitly, and deliver a child report to its parent.
Reviewer Test Plan
How to verify
Review that two concurrent dispatch passes cannot start the same queued run, a stale attempt cannot mutate a thread, capacity backpressure restores the queued run without consuming an attempt, and replaying parent-report delivery does not duplicate the post.
The next stacked PR supplies the real runtime seam and the following step runs the two-agent live-model demo. Those are intentionally not claimed by this PR.
Evidence (Before & After)
N/A — no UI surface in this step.
Tested on
Risk & Scope
Linked Issues
Parent delivery PR: #11206. This PR supersedes the draft review branches #11229, #11230, #11234, and #11235; they remain open as drafts for review history.