Skip to content

fix: peer-conversation reliability — reachable, answerable, loop-free A2A messaging - #91

Merged
androidand merged 42 commits into
devfrom
fix/peer-conversation-reliability
Sep 19, 2026
Merged

androidand merged 42 commits into
devfrom
fix/peer-conversation-reliability

Conversation

@androidand

Copy link
Copy Markdown
Owner

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.

  • Return address + machine-wide roster: send_peer_message can 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.
  • Correlation envelope (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.
  • Duplicate-send guard (peer/recent-ids.ts, peer/repeat-guard.ts): the same inbound frame retried or replayed is delivered once.
  • Busy-target queueing (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.
  • Step-boundary loss window fix: a prompt that lands right as its turn ends now reliably starts a fresh run instead of being silently dropped.
  • Event-journal retention sweep: was implemented but never actually scheduled — now runs on startup and hourly.
  • PTY corruption fix + regression coverage: inbound peer delivery was forking on the default Effect runtime, leaking structured log lines to stdout and corrupting the TUI frame. Fixed (runForkWith) with a source-level guard test that fails if the fork strategy regresses.
  • Assorted smaller fixes bundled from the same work: sidecar exit-signal reporting, a local-model context-fit retry loop, a spec-queue branch-aware tasks.md reader, and TUI model-fit helpers (fitLabel, recommendedModelID, etc.) that a prior commit's tests were written against but had no implementation.

Test plan

  • bun typecheck clean across all 30 packages (turbo, full graph)
  • bun run script/fork-verify.ts clean (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 suites
  • Every commit's own test claims verified individually during a 47-commit rebase onto current origin/dev (this branch was originally built on a different, since-diverged local history)

Notes for review

  • This branch carries real fleet-hardware test fixtures that referenced actual private hostnames (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.

androidand and others added 30 commits September 19, 2026 22:58
…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>
androidand and others added 12 commits September 19, 2026 23:01
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.
@github-actions

Copy link
Copy Markdown

Thanks for your contribution!

This PR doesn't have a linked issue. All PRs must reference an existing issue.

Please:

  1. Open an issue describing the bug/feature (if one doesn't exist)
  2. Add Fixes #<number> or Closes #<number> to this PR description

See CONTRIBUTING.md for details.

@github-actions

Copy link
Copy Markdown

This PR doesn't fully meet our contributing guidelines and PR template.

What needs to be fixed:

  • PR description is missing required template sections. Please use the PR template.

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.

@androidand
androidand merged commit c2feec6 into dev Sep 19, 2026
3 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant