Repository navigation
fix: peer-conversation reliability — reachable, answerable, loop-free A2A messaging - #91
Merged
Merged
Conversation
…ble, and loop-free Four sessions in one repo exercised this end to end and every layer failed differently. None of it was a transport bug; the transport worked all along. Addressing. `send_peer_message` and `peers` both built their roster from `Session.list()`, which is scoped to the caller's project, merged with the sidecar roster, which only covers sessions OTHER processes registered. A sibling session in another project of the same process fell through both and was reported "not found" while sitting in the next tab. Both tools now list machine-wide, and an exact session id outside the recency window is looked up directly. Because that lets a target belong to another process, delivery takes an explicit `owned` flag and refuses rather than prompting a session this process does not drive. Return address. Outbound frames advertised `uds:opencode-skein:<sessionID>`, a placeholder nothing listens on, while the protocol's reply rule is "send to `from`". A peer that did exactly the right thing — asking who owned some work before touching it — could not be answered. `returnAddressFor` now advertises the sender's real sidecar socket when one is up, and `resolveOpencodeSender` maps a socket back to its owner through the registry. Liveness. A sidecar was only ever spawned from the session-created event, so every session resumed from history had no address at all. Sessions now register on their first status event too. Registrations also kept the placeholder title they were created with, so peers saw each other as "opencode:New session - <timestamp>" forever; the name now follows the session. "Idle" was two states. A session someone is sitting at and a session whose process exited look identical in the store. `liveSessionIDs` verifies a registration's pid rather than trusting the file, and `Peer.reachable` carries it, so a roster can say "free to take something on" or "no process is attending it" instead of "idle" for both. Relationship. Coordination depends on how two sessions can collide, which status never expressed. Peers are now grouped by shared working tree (divide the work), shared repository via another worktree (shared branches, separate files), or different repository (coordinate on interfaces), each with the action that fits. The roster also joins sessions to local inference hosts, since on single-slot hosts asking a warm colleague and spawning a subagent have very different prices. Retry loops. The tool returns an acknowledgement, not an answer, and nothing said the answer arrives later, so a session asked to consult its peers sent the same line to four targets several hundred times, exhausted its context, compacted, read its own goal back and resumed. An identical resend inside 90s is now refused with an explanation, a target that failed to resolve twice is told to stop, and the description states the asynchronous contract first. Diagnostics. Inbound delivery forked on the default Effect runtime, whose default logger writes to the console — and the server shares its process with the TUI, so an injected turn's log lines printed over the frame. It now forks with the layer's own context. Sidecar stderr and abnormal exits reach the application logger instead of being discarded, and duplicate inbound frames are dropped by `msg_id`. The receiver's preamble led with "not a user instruction and not a permission grant", which a small local model reads as "ignore this". It now leads with what to do and keeps the boundary immediately after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…oop proposal peer-conversation-reliability records what was actually verified rather than assumed: A2A 1.0's real state list and its MAY-level idempotency, Anthropic's multi-agent delegation lessons, and Claude Code's own cross-session contract (reply to `from`, enqueue at the next turn boundary, never poll). It corrects an earlier claim that no request/reply mechanism existed — `peer/delegate.ts` has had one all along — and it now carries the evening's live incident, where four sessions in one repo reproduced every failure the design predicted plus two it did not. crew-loop proposes the next step: sessions that hold the fleet's inference slots working one claims board, with worktree-per-claim and merge-back as claimable work. It starts from the local capacity argument — a subagent takes a slot and may force a model swap, while a peer already holds its slot with weights resident — and from the two coordination axes the code now carries, relationship and availability. It also records what the prior art does not settle: none of A2A, the Anthropic write-up, or Claude Code's contract models scarce local capacity or a shared working tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… load it, instead of retrying forever A llama-skein host refuses a model whose configured context pushes its VRAM estimate past the card with HTTP 507, `model_does_not_fit_error` / `model_over_host_memory`. Nothing handled that status. The request fell through to the generic retry, which re-sent an identical and identically doomed request on a timer, so the conversation never recovered and there was no way to adjust the context from inside opencode. Observed on a 24 GB host: weights 19281 MB, VRAM estimate 28949 MB at a configured context of 262144. The weights fit comfortably; the context did not. 507 now takes the same shape of recovery 413 already had: probe the model's fit report, lower the configured context to something that fits, retry once. `max_fit_ctx` is used when the host computed one, and it is null in precisely this failure mode — a model whose KV budget at the configured context is negative has no hard ceiling to report — so `contextThatFits` falls back to deriving a target from the report's own VRAM arithmetic rather than giving up, which is what the existing 413 path does when that field is missing. For the host above it picks 75776, projecting 22076 MB of 24560. It refuses to guess: an unusable report, weights that alone overrun the card, or a shortfall that is not context-driven all surface the original refusal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-reliability tasks Tick 5.2 (opencode and tui typecheck clean) and 5.4 (live Claude Code <-> opencode exchange on the post-2686ea754e build), with the limitations seen: no correlated reply, dirty build, older sessions still on the old binary. Annotate 5.1: opencode peer subset green, 9 unrelated-looking tui sync failures not yet confirmed against dev. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The branch touches only packages/opencode and packages/tui imports nothing that changed, so the 9 "Permission context must be used within a context provider" failures in the tui sync/hydration suites predate it. Recorded as inferred, not measured at dev, and flagged as worth their own attention. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude's wire frame has no metadata slot — `message.content` is a plain string carrying an attribution envelope — and opencode-to-opencode delivery reuses that same frame. So a coordination field that has to survive a hop must be rendered inside the text. Only same-process delivery could carry structured metadata, and a format that works on one of three paths is not a format. The header is always the first line, body after a blank line: [peer notify id=<msgID> from=<address>] [peer request id=<msgID> from=<address> context=<ctxID> deadline=15m] [peer reply id=<msgID> in-reply-to=<msgID> context=<ctxID>] Unquoted on purpose: peer/claude/codec.ts neutralises `from="` so a crafted body cannot forge the Claude envelope's attributes, and an unquoted `from=` is not that pattern. Every interpolated value is sanitised, because two of them are not ours — the return address comes off an inbound envelope, and a sender chooses what it puts there. Whitespace, brackets and equals become underscores, so a crafted address cannot close the header early and add an `in-reply-to` that reads as trusted. There is a test for exactly that. Parsing only ever looks at the first line, so a header-shaped line inside a body is inert, and a header carrying no id is treated as no header at all, since correlation is the only thing a header is for. Unknown fields are ignored rather than fatal: a newer sender may add one, and refusing the message over it is worse than dropping it. A message with no header parses as undefined, which callers read as an ordinary notification — so everything sent before this format existed keeps working. Carries `task=` as well, so peer/delegate.ts's pool-overflow correlation can migrate off the `[peer-task-result <id>]` marker without this module needing to know that marker's grammar. Pure and import-free apart from crypto, in the shape of peer/recent-ids.ts and peer/repeat-guard.ts. This is the shared foundation for 3.2 (notify vs request mode) and 3.5 (reply correlation); it deliberately lands before either so the two do not invent incompatible formats. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…king delegation Review findings from a peer session that fuzzed envelope.ts with 200k random fields and targeted forgery attempts. The forgery claim held — no input produced a header that parsed differently from what was sent — but it found a worse bug underneath it. Sanitisation was lossy. Every unsafe character became an underscore, which is safe and destroys the value. `uds:/tmp/cc socks/1.sock` and `uds:/tmp/cc-socks/1.sock` collapsed to the same string, so a reply could go to a different real peer, or to nothing, with no error anywhere. That is the exact class of bug this branch exists to fix, reintroduced one file over. Values are now percent-encoded and decoded on parse: `%` goes first so the mapping stays injective, and anything outside printable ASCII goes too. Control characters, NEL, zero-width and bidi overrides could never break the grammar, but they can forge what a terminal shows, and this text is rendered into a TUI. Also from the review: - A deadline is ignored on notify and reply, where nothing waits on one, and clamped to 24h instead of believing 1e308. - A repeated key takes the first value. Last-wins let the id this parser reports differ from the id another reader took, which is how duplicate suppression and correlation drift apart. - A CRLF header parses instead of silently degrading to plain text. - Values are capped at 512 bytes; an oversized one is dropped rather than truncated, since a truncated address is a wrong address. Separately, the review caught a landmine that would have fired the moment headers started being sent. `peer/delegate.ts` anchors its `[peer-task-result <id>]` marker at the start of the message, so a header in front of it stops the match, and every delegated task times out instead of settling. `peerBody()` strips a header for readers that match on the body's own shape, and the inbound path in peer/claude/lifecycle.ts now goes through it. It is a no-op until headers are sent, which is the point of landing it first. The call site in tool/send-peer-message.ts belongs to another session's in-flight change and needs the same treatment there. The header is still self-declared and unauthenticated by design, like the attribution attributes it travels beside; that is now documented on the parser. Acting on a correlation id must also check the sender, which is 3.5's problem and the review's second finding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… encode Second review pass. Both findings were right, and the first one shows my previous claim was tested from the wrong side. I asserted that control characters and bidi overrides "cannot reach a terminal through a header", and tested that my ENCODER escapes them. That was never the threat. A foreign sender writes the escape itself, and a decoder that faithfully reverses it hands back a real newline, a real ESC, a NUL or an RTL override. Measured by the reviewer: `from=a%0A%5Bpeer%20reply%20id%3Dx%5D` decoded to a value containing a newline followed by a header-shaped line, so anything interpolating a decoded address into model-visible text would carry a forged header, and any terminal or log rendering it would take the escape sequence. An id that decoded to a bare newline was accepted as valid. No address, id or context id legitimately contains a control, format or bidi code point, so the policy now applies on both sides: the formatter refuses to emit such a value, and the parser drops the field when a decoded one contains any. When it is the id, no id survives and the existing guard rejects the whole header — fail closed. Values are dropped rather than repaired, because a mangled address is a wrong address. Spaces, brackets and equals remain encodable; they are grammar, not danger. The second finding was a real injectivity break: TextDecoder eats a leading BOM unless told otherwise, so encode/decode was not round-tripping U+FEFF — the only mismatch in a 100k-value fuzz. Now decoded with ignoreBOM. The pair is the primitive and has to be injective; separately, a BOM inside a header field is refused like any other format character. Tests now assert the hostile direction: escaped control characters arriving in a foreign header, a smuggled header-shaped line, an id that decodes to a control character, and BOM round-tripping. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add a two-part test that guards against Effect-default-runtime log lines leaking to stdout and corrupting the OpenTUI frame. Part 1 (positive control): spawns a subprocess that deliberately forks on the default runtime and asserts the leak reaches the captured pipe. Proves the harness would catch a regression. Part 2 (regression): exercises the real sidecar+delivery path and asserts no structured log lines reach stdout. The bug was in peer/claude/lifecycle.ts: deliver was forked on the DEFAULT runtime (console logger), so every Effect.log* in an injected turn printed structured lines to stdout. The fix is Effect.runForkWith(context) (lifecycle.ts:56). Capture is at the subprocess pipe level, not by patching process.stdout.write, because Effect writes directly to the fd. Closes peer-conversation-reliability task 1.1.
The regression test previously passed even with the bug fully restored because it patched process.stdout.write, which misses Effect's default logger that writes directly to the fd. Restructure so both the positive control and regression use the same instrument: a subprocess with piped stdout. - probe-deliver-runtime.mts: minimal script that forks on either the default runtime (buggy) or runForkWith(appContext) (fixed) and logs a probe message. - Positive control: spawns the script with 'buggy', asserts the leak reaches the pipe. - Regression: spawns the script with 'fixed', asserts stdout is clean. - Sidecar deliver cycle: keeps the existing sidecar+message test but as a separate assertion about raw JSON/diagnostic leakage. The two halves differ in exactly one variable (runFork vs runForkWith). Reverting lifecycle.ts:56 makes the regression fail. Closes peer-conversation-reliability task 1.1.
The probe's 'fixed' branch hardcodes its own runForkWith; it proves the mechanism matters but not that lifecycle.ts uses it. Add a guard that reads lifecycle.ts and asserts it contains Effect.runForkWith( and no bare Effect.runFork(. Paired with the experiment above, this closes the gap between 'the technique works' and 'the module uses it'. Also untick 1.2: the NDJSON assertion passes regardless of the fix because the parent consumes both child pipes; the diagnostics half runs through the same fork path and is now covered by the guard. Closes peer-conversation-reliability task 1.1.
The original buggy code used Effect.runFork, (point-free, as a pipe operand) not Effect.runFork(. The regex /Effect\.runFork\(/ did not match it, so a revert to the genuine original would still pass the guard. Use /Effect\.runFork(?!With)/ (negative lookahead) instead — this catches both the called and point-free forms while still allowing Effect.runForkWith(. Also retick 1.2 with explicit reasoning: NDJSON half is true by construction (parent consumes child pipes), diagnostics half is covered by the corrected guard plus the control-and-treatment experiment. Closes peer-conversation-reliability task 1.1.
The experiment shows the mechanism matters, the source guard shows the module uses it, and the sidecar cycle covers the raw-frame case. Neither half alone would have been evidence. Verified round 4: regex catches the genuine pre-fix shape on fe56a6b.
Extend peer/envelope.ts with awaitPeerReply / settlePeerReply / cancelPeerReply, the structured-envelope counterpart to settleTaskReply. The inbound path in lifecycle.ts now calls both, so a reply carrying [peer reply id=<mid> in-reply-to=<orig>] settles a pending request instead of being injected as a prompt. The text marker remains the fallback during migration — settlePeerReply only fires when the peer includes the structured envelope in its reply text. Tests cover successful settlement, unknown-in-reply-to, non-reply envelopes, plain text, empty body, timeout, and cancel. Closes peer-conversation-reliability task 3.5.
Document test coverage for all 3.6 scenarios and add a sidecar-e2e test that verifies envelope headers survive the wire and are stripped before delegate.ts matching. Sidecar restart limitation documented.
…ew run The step-boundary loss window, and the last correctness hole behind "send_peer_message said accepted and the peer never woke". `SessionPrompt.prompt` persists the user message and then calls `loop`, which calls `Runner.ensureRunning`. For a run already in flight that returns `awaitDone` — it JOINS the run and discards the work submitted with it (effect/runner.ts, the Running case). Usually harmless: the step loop re-reads the session's messages every step, so it picks up whatever was persisted. But between the loop's final exit check and the runner returning to Idle, the run has already decided to stop and will never read messages again. A message persisted in that gap is joined onto a corpse. No error, no retry, nothing queued — the caller is told the message was accepted while nothing will ever process it, and it sits until something unrelated prompts the session. For peer delivery that is the difference between a colleague answering and a colleague appearing to ignore you. Rather than inventing a queue or a wait-until-idle enqueue, ask the cheap question: did the turn that just finished actually answer THIS message? An assistant message parented to it means it was processed, however that turn ended — an error, a step cap and a normal finish all parent to it. No such message means nothing ran it, so a fresh run is started. Exactly one retry, because this closes a race rather than papering over a session that will not answer. On testing, honestly. The runner test added here covers the recovery the fix depends on: work dropped by a join does run when resubmitted after the run ends. The discard half was already covered. What is NOT covered is an end-to-end reproduction of the race itself — hitting the window between the exit check and Idle is not reachable from the public API without instrumenting the runner, so the trigger condition rests on the code path above plus the runner's semantics, not on a failing-first integration test. Saying so rather than implying more. Unrelated and pre-existing: "running subtask preserves metadata after tool-call transition" in test/session/prompt.test.ts fails intermittently, roughly two runs in three. I confirmed it flakes identically with this change stashed, so it is not from this work, but it is worth someone's attention. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Exercise the generated LlamaSkeinClient against a local mock server to verify the operation API surface (list/create/get/cancel) works correctly from opencode-skein's perspective. This is the local stand-in for hardware smoke tests against rocky (ROCm) and m3/m5 (Apple). - 6 tests covering list, create, get, cancel, 404, and ordering - All tests pass against the mock server
The sweep was written, tested, and never run. `EventRetention.sweepLayer` had no consumers anywhere in the repo, and `EventRetention.node` was not in the app graph, so no build has ever pruned an event journal. The service exists, its tests pass, and it has been dead code the whole time. Measured on this machine before the fix: database 37 GB event table 31.2 GB across 1,378,280 rows message/part 3.3 GB / 2.7 GB sessions 18,311, of which 18,283 idle beyond the 48h window their events 1,255,940 — 91% of the table orphaned events 0, so the sweep can reach all of it Every opencode instance opens that database, and the journal only ever grows, on every machine running this code. `EventRetention.node` now joins the graph and `sweepLayer` is layered over it, which is where it has to go since the sweep needs the service the graph provides. The sweep forks scoped, so a first pass over a journal this size runs in the background rather than blocking startup, and prune runs before compact — so after the first pass the expensive per-event resequencing only ever sees the handful of sessions between the compact and prune windows. Space comes back gradually rather than at once: incremental_vacuum returns 50,000 pages per sweep, about 200 MB, so a backlog this size takes days of hourly sweeps to hand back to the filesystem. The pages are reused by writes immediately either way, so the database stops growing from the first sweep. Note this makes the existing 48h default policy actually take effect: journals for sessions idle longer are deleted. Messages and parts are untouched — this is the replay log, not the transcript. `event_journal.disabled` or a larger `retention_hours` opts out. Unrelated pre-existing failure, confirmed with this change stashed: test/local/llama-skein-client-smoke.test.ts has a `number | null` versus `number | undefined` type error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The exit handler only logged when the exit code was non-zero, and a signal kill reports code null, so a SIGKILL/SIGTERM'd sidecar died silently. Log when a signal is set too, and cover it with a real-sidecar test that kills one and asserts the diagnostic names the signal. The fix had been described as done in the peer-conversation-reliability task notes but was missing from the tree, while its test sat untracked and failed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Record the three pieces the PTY repro depends on, that it does not start the TUI or record terminal dimensions, that the NDJSON half of 1.2 holds by construction, and the signal-exit bug the diagnostics test exposed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…echecks The fake operation returned null where the generated type wants number or undefined, which broke tsgo --noEmit for the whole package. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ersation A2A works and agents do not reach for it; subagents they reach for constantly. That is the shape of the tools, not a training gap. A subagent is a call that returns and the turn continues. A peer message is a letter that ends the turn on the chance of a later reply. Given both, a model picks the call, correctly. So this puts peer capacity behind the tool agents already use, and leaves messaging for coordination, which is what it is good at. The objection to lending an idle peer as a subagent is that the peer's transcript fills with someone else's work — costing context and risking the peer confusing borrowed work with its own. That dissolves by separating lending capacity from doing the work: a host creates a fresh child session with its own provider and model and no history, runs the task there under its own permissions, and records one line in its own transcript. Today's [peer-task] envelope does the opposite — it is injected into the peer's own session as a prompt — so this also fixes a real defect rather than only adding a feature. Also: availability is not the same as a free slot. A host can show a free slot while a live session sits on it between turns, and taking it can force a model swap, which placement already scores as the most expensive thing it can do. Ranking now accounts for whether a host is unowned and whether the model is resident, reusing the session-to-host join the roster already computes. Phase 0 is deliberately measurement, not code. Three causes are suspected for "I never see it used" — the trigger only fires at exactly no-slot, the roster was project-scoped and hid idle sessions until 2026-09-19, and free slots were treated as available. Rather than fixing all three blind, instrument the decision, read a day's log, and find out which dominates; the roster fix alone may already have restored it. Phase 3 ends by asking whether agents actually use it, with the answer coming from that log, and says to call the change failed if they do not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
I introduced these two lines last night while recording a live incident, in a public repository, after writing a rule against exactly this. Real checkout paths under a home directory replaced with <repo-a> and <repo-b>; the finding does not depend on the real paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add CandidateScore to PickOutcome so every task-tool placement logs the chosen provider and each rejected candidate's score/reason. No behaviour change — only explainability for the peer-capacity subagent path. - placement.ts: new CandidateScore interface, populated in probe loop - task.ts: log candidates alongside placed/failed outcomes - tests: placement.test.ts +29, provider.test.ts +114, task-delegate.test.ts +1 fixes peer-capacity-subagents 0.1, skein-pool 1.2
fleet-live-verification, long-session-memory-retention and skein-pool (local-context-truth) each had a proposal but no spec delta. All three validate with openspec validate. Spec-only, no code. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ange's own branch The spec-queue loop derives its cursor from tasks.md in the working tree, so when an iteration leaves the tree on another change's branch it reports a stale "next task" and burns iterations on finished work. Proposal, spec delta and tasks, linked to fork issue #83. Validates with openspec validate. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
readChangeFile reads <relpath> from loop/<slug> via git show and falls back to the working tree when the branch, git, or the repo is missing, and never throws. Not wired into the queue yet; that is the follow-up task in fix-queue-branch-awareness. Covered by a real-git test: reading the change branch while the tree sits on another, the no-branch fallback, the empty case, and a non-git directory. Drops the debug console.log the draft carried, which dumped whole file contents to stdout. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Method and results of the spike that checked whether a plain session's claim is visible in the shared local database, and the three code paths a crew claim would need. Fleet host names genericized. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…NOT READY Preserved off the peer-reliability branch so it is not lost. Do not merge as is. The tests import exports that exist in no branch and no stash, so their source half is missing: - dialog-model-fit.test.ts imports fetchFitReportForProvider from packages/tui/src/component/dialog-model.tsx - dialog-select-fit.test.ts imports recommendedModelID from packages/tui/src/local/model-fit.ts Two of six tests fail and two error at import. The fixtures also use real fleet host names (rocky, m3, z4) that must be genericized before any publication. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tion note Preserving in-progress status notes found in the shared checkout rather than discarding them: 5.3's second live-delivery attempt (both ends still on the pre-envelope binary, so this exercises basic delivery only, not 3.2/3.5 correlation), and model-gallery-ui's note that its upstream-sync task 1.1 was already satisfied by the gallery branch's base and that a re-sync is blocked pending the clean-remote rebuild. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…en against
Fixes the previous commit's gap: dialog-model-fit.test.ts and
dialog-select-fit.test.ts imported exports that existed in no branch or
stash. The tests were precise enough to implement against directly — every
branch, tie-break and degradation case was already pinned down — so this is
the source they describe, not a guess at what might satisfy them.
local/model-fit.ts gains fitIsNo, fitIsLoadable, fitIsUnknown, fitLabel,
fmtFitLevel, and recommendedModelID. All six read a model's /api/fit verdict
and treat 'no report', 'no entry for this model', and an explicit 'unknown'
verdict the same way: not enough signal to say anything, which must degrade
to the unannotated list a report-less dialog already showed, never to a
false 'does not fit'. recommendedModelID ranks by resident weight size
(falling back to a caller-supplied size for a model /api/fit hasn't measured
yet), tie-broken by measured throughput and then by id, so the same report
always recommends the same model.
dialog-model.tsx gains fetchFitReportForProvider, wired into the same
onMount that already fetches VRAM per provider, same fire-and-forget,
cache-on-success, silent-on-failure shape. It is fetched and cached now;
reading it to annotate the actual picker options ("(Recommended)", a
cannot-fit marker) is a separate, larger task (2.4) this does not attempt —
the third describe block in dialog-select-fit.test.ts mirrors that
computation as a standalone helper precisely because the real one doesn't
exist in the component yet.
Also genericized the fixtures' real fleet host names (rocky, m3, z4) to
host-a/host-b, per the previous commit's own note that this needed doing
before any publication — this is a public fork.
28/28 of the previously-broken tests now pass (was 4 pass, 2 fail, 2 import
errors). Full packages/tui suite: 237 pass, 9 fail — confirmed pre-existing
and unrelated by running the identical suite with this commit's changes
stashed out (11 fail + 2 errors without the fix; the 9 that remain are the
same sync/hydration failures already on dev, 'Permission context must be
used within a context provider'). typecheck clean.
NOT done: wiring fit verdicts into the actual DialogModel/DialogSelect
option list (task 2.4). The data is fetched and cached; nothing reads it yet.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
send_peer_message and the Claude sidecar lifecycle both refused delivery outright when the target session was busy, with no retry or notification path — the sender had to poll by resending, and a message could be lost for good if it never happened to land during an idle window. This was confirmed live: a multi-agent session had replies bounce back and forth unresolved for 40+ minutes while an agent proceeded on unconfirmed coordination state. Add a small pure queue (peer/inbox.ts) that SessionStatus.set drains the moment a session's status flips to idle, and have both delivery paths enqueue instead of refuse when the target is busy or retrying. The sender now gets an honest "queued, no need to retry" result instead of a dead-end refusal.
…lity slice fork:verify flagged them as unregistered divergence — they're all new files this branch adds (peer envelope/inbox/recent-ids/repeat-guard, ctx-fit, a spec-queue branch reader, and their tests), not modifications to an existing upstream file, so they belong in the owned list.
|
Thanks for your contribution! This PR doesn't have a linked issue. All PRs must reference an existing issue. Please:
See CONTRIBUTING.md for details. |
|
This PR doesn't fully meet our contributing guidelines and PR template. What needs to be fixed:
Please edit this PR description to address the above within 2 hours, or it will be automatically closed. If you believe this was flagged incorrectly, please let a maintainer know. |
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.
Summary
The full peer-conversation-reliability slice: makes agent-to-agent messaging on this fork actually reachable, answerable, and loop-free, plus the fixes that came out of hardening it.
send_peer_messagecan address any session on the machine, not just the caller's own project; replies carry a return address so the receiver can answer at all.peer/envelope.ts): one header format across every peer transport, with a message id, reply-to, context id, and a notify-vs-request mode. Encoding is reversible and rejects control/bidi characters so a hostile or malformed value can't forge header fields or reach a terminal.peer/recent-ids.ts,peer/repeat-guard.ts): the same inbound frame retried or replayed is delivered once.peer/inbox.ts): a message to a busy session used to be refused outright (own-process path) or barged into mid-turn (cross-process path) — both now queue and deliver automatically the moment the target goes idle, on both delivery paths.runForkWith) with a source-level guard test that fails if the fork strategy regresses.fitLabel,recommendedModelID, etc.) that a prior commit's tests were written against but had no implementation.Test plan
bun typecheckclean across all 30 packages (turbo, full graph)bun run script/fork-verify.tsclean (registered 24 new owned files)packages/opencode: 1362 pass / 2 pre-existing-and-unrelated fail (gallery host-normalization test, unrelated to this slice) across peer/session/tool/effect/local suitesorigin/dev(this branch was originally built on a different, since-diverged local history)Notes for review
z4-r9700,rocky) and one real session id — all rewritten to generic placeholders (host-a,host-b,hostB, a zeroed session id) at their introducing commits before push, not just at the tip, so the private values never appear in the pushed history.