Skip to content

Thread-state pipeline: turns end cleanly, no hub stalls, instant and exact updates on every client - #35

Merged
i2cjak merged 15 commits into
mainfrom
backplane/adversarial-ui-harness-review
Oct 2, 2026
Merged

i2cjak merged 15 commits into
mainfrom
backplane/adversarial-ui-harness-review

Conversation

@i2cjak

@i2cjak i2cjak commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

Adversarial review of the thread-state pipeline (agent turn → hub → event store → broadcast → window, web, phones). Two Sonnet 5.5 and two GPT-6.1-Sol reviewers split the hub and client sides; each pair reached consensus; an Opus 5.5 / Sol debate produced the spec; Opus implemented it; a final Sol pass over the diff found two regressions, fixed in the last commit.

Turns finish and show state

  • A retired run ends fully: Stop, archive, delete, crash and failed start end the turn, close process-bound approvals/questions (empty answer: denies approvals, dismisses questions, never answers "deny"), clear background rows, finish a delegated task. Archiving a running thread no longer leaves it Running forever.
  • Claude lines are tagged with their pid and gated on the turn: a late result or delta after Stop no longer flips state or repopulates live text.
  • Restart carry: its own "Continuing" note no longer defeats the loop guard; archived/deleted threads are never carried.

Many projects, performance

  • Hub: no whole-log walks per user message / thread_wait poll / streamed line; lookups use the thread's own entries and stop at the first hit.
  • Phones: redraw for every paired hub (non-focused hubs were stale up to 30 s); no alert work on deltas; island text from the thread's own entries.
  • Web: no render and no full localStorage log write for another thread's streamed text.

Instant updates, devices

  • The hub never parks sending into its own inbox (deadlock).
  • A dead or stalled client is dropped by a send deadline (C/JS twins) instead of freezing the hub indefinitely. Bounded, not instant: healthy clients may wait up to the deadline (~15 s) once.
  • Clients count changes they cannot decode, so reconnect sequences stay exact.
  • Reconnect to the same hub keeps viewer choices (kind, path, diff, history, compare, Files folder) and lists kept folders again.
  • The native window receives through recv_act: outbox resent after reconnect, desktop notifications now fire.
  • Far: history epoch kept on startup feed completion, lost final pushes repaired, feed scans stop early.

Tests: scripts/check.sh (All terms check, new laws for each item), scripts/test.sh 73/73, native build, bridge.js build, new e2e test/tools/retire_e2e.ts and test/tools/stall_e2e.ts (all ok), new test/nui_recv_test.bend.

Deferred (next PR): sidebar indexes (biggest many-project win; changes the phones' kept-state key), true non-blocking sends, Codex/Grok spawn generations, live-text replay on reconnect, exit detection without stdout EOF, want_new picking another client's thread.

i2cjak added 15 commits October 1, 2026 22:22
… and task results from the thread's own entries

- Hub.has_msg is a tail walk that stops at the first hit; Hub.dup looks
  anything up only for a message the client named (Hub.turn, Hub.turnx,
  Bots.mentions.p all go through it, so they agree).
- Mcp.last stops at the newest assistant message and thread_wait reads it
  from M.State.mine; Task.last reads M.State.mine too.
- Task.finish looks the task up only once the step ends a turn
  (Task.finish.e), not on every agent line.
- Laws has_msg_unnamed_skips, mcp_last_newest, task_finish_not_ended_same.

The dedupe stays over the whole log: per-thread would falsify law
resend_stored_once, which needs the user's consent to change.
…tart)

Whenever a thread's process is retired nothing it held is left waiting:
- its open asks close (AskClosed with "") and each parked request gets the
  empty answer (deny for an approval, dismissed for a question); a plan
  card parks nothing and stays (Retire.asks, Retire.answers);
- a running turn ends Interrupted and a delegated child's task ends with
  it, so its parent hears (Retire.turn through Task.finish).
Stop (turn.interrupt and MCP thread_interrupt) goes through Retire.stop;
archive and delete through Retire.archive, which also clears background
rows; an agent exit closes its asks beside Bg.clear.

Server: a Claude process whose socket could not be duplicated is stopped
and registered so its exit goes through the usual AgentGone lifecycle (no
self-send from the hub fiber); a Grok one is stopped and reported as a
failed start.

Laws archive_running_stops, archive_idle_no_turn, archive_closes_approval,
delete_running_stops, interrupt_closes_approval, interrupt_keeps_plan,
interrupt_ends_child_task, gone_closes_approval, retire_answers_empty.
- A Claude result completes or fails only a running turn, and only a
  running turn's text streams (Claude.result.if, Claude.stream.if): the
  result Claude still writes after a Stop no longer turns Interrupted into
  Completed (a false alert, and the held queue starting), and its late
  deltas no longer refill the live text. Whole messages, tool results and
  background tasks still land.
- Claude's reader tags its lines with B.Tag.of(thread, pid), so a replaced
  or archived process is not heard (Agent.reader/got take the tag; the
  exit still names the plain thread and pid).
- Known limit: a stale result that arrives after a new turn started on the
  same process is still taken (the lines carry no turn).
- Laws claude_result_after_stop_quiet, claude_result_after_stop_interrupted,
  claude_result_running_completes, claude_delta_after_stop_quiet,
  claude_delta_running_streams.
- test/tools/retire_e2e.ts: Stop with a parked approval (one deny, ask
  closed, stays Interrupted, no late delta, queue held), archive then
  unarchive, and a model switch whose old process's late result is dropped.
- Carry.fresh looks past the "restart" note Carry.k files after its
  continue message, so a hub that dies again before the agent wrote
  anything does not carry the turn again (the loop the guard exists for).
- Carry.one also needs the thread neither archived nor deleted
  (Carry.live), so archived-running ghosts in old logs stay stopped.
- Laws carry_twice_once, carry_skips_archived (carry_once kept).
The hub fiber is the inbox's only reader, yet it sent into it itself: the
bots' reply request at a turn's end, and BotsRefresh after a PeerSet is
stored and when a signed peer request finds the directory stale. With the
4096-slot inbox full, that send parked the hub for good. These now go
through Self.post, which sends on a fiber of its own (the Rpc already
queued behind everything in the inbox, so the order is as before).
- Sock.send_until (host.c, host.js twin): send_bytes with one deadline for
  the whole frame; out of time or failed, it shuts the socket both ways.
- A client's writer sends each frame with a 10 s deadline (Conn.deadline);
  on failure it closes the client's channel first (the hub's sends to it
  then end at once instead of parking on a full queue), then the socket;
  its reader sees the end and the client leaves.
- Accepted sockets get TCP keepalive (20 s idle, 5 s x 3) and
  TCP_USER_TIMEOUT 30 s, so a half-open idle peer is found too.
- test/tools/stall_e2e.ts: a client that never reads while another streams
  a flood; the other still gets the turn's end and replies, the stalled
  one is dropped, and a fresh client gets exactly the same log. Twice.
A client older than its hub (a TestFlight build behind, a cached web page)
dropped changes with tags it does not know, and its sequence counted only
those it applied, while the hub resumes a reconnect by how many lines it
sent: every reconnect asked for the tail again and posted its messages
twice. Ui.reset and a changes message now fold through Ui.fold.items,
which ticks the read model once per unreadable item (no new event, no
change to Store.apply, so store_state_is_replay holds). Phones' kept-state
key unchanged (91f56ce99374e3e4).

Laws ui_seq_counts_unknown, ui_seq_known_unchanged,
ui_seq_log_counts_unknown.
(the hub stops a replaced process with SIGTERM; its stdin stays open while
the reader holds the socket, so waiting for EOF never ended)
Ui.reset rebuilt the info map from the hub's info plus Local.keep's fixed
list, so every reconnect (phones on each resume) dropped each thread's
viewer: kind, path, panel, the hand toggle (letting an automatic view
override the person), diff, folds, history step and comparison, the Files
folder. Ui.reset.keep copies those keys from the old map when the log's
origin is the one the client had (the same hub); results asked again
(refs:, fm.list:) and playback (hist.play:) are left out. Local.keep is
untouched: it also runs on a machine switch and a cached install, where
carrying keys would leak one machine's paths into another.

Laws reset_keeps_viewer, reset_keeps_hand, reset_new_origin_drops_viewer,
reset_drops_results; view_test (full and incremental reconnect, diff,
history, stale listing, manual protection) and hub_switch_test (colliding
thread ids across hubs, back to the cached home).
…ications)

The window received with U.Ui.recv, so the commands a message gives never
ran: the outbox was never sent again after a reconnect, and desktop
notifications never fired (U.Notify comes only from Ui.recv_act). Hear.k
now goes through Nui.recv.act (recv_act, then the shot note) and
Nui.sync.kd, which forces a frame when commands come. The hub's own
window, which adopts the read model without a socket (Shell.adopt), now
runs Nui.adopt.step, which also sends the outbox (the hub stores a resent
message once). Web and phones already did this.

test/nui_recv_test.bend: notify becomes NNotify; a log resends the
outbox once and not after the answer; adoption resends it.
- Hubs.redraw (App.shows): any message from any paired hub can change the
  merged sidebar and the island, so it builds a screen; only streamed text
  is held to Hubs.shows (unchanged, with its laws). Unfocused completions
  and approvals now show at once.
- A hub out of focus builds no thread, bot or viewer for the merged
  screen (Mob.screen.f; Hubs.screen.k reads those from the focused hub
  only), and the sidebar's groups are found once per screen.
- Alerts: streamed text skips the before/after comparison entirely
  (One.got.d); far and every other message keep it. Note.was is a tail
  walk that stops at the first hit (it was the square of the threads).
- Island and alert text read each thread's own entries
  (Note.island.st, Note.lines.st, Note.alerts.st over M.State.mine, keeping
  the thread check) instead of walking the whole log per running thread;
  the es interfaces stay for their laws.
- Laws hubs_redraw_changes_elsewhere, hubs_redraw_delta_elsewhere,
  hubs_redraw_delta_other_thread, alerts_none_for_delta,
  island_doing_mine_same, island_doing_mine_text,
  hubs_screen_unfocused_thread_null; hubs_test: unfocused completion and
  approval redraw and alert, deltas alert nothing, connecting raises no
  old alerts, an unfocused hub's screen has no thread.
…er A's stall

(a reset from the hub counts as the stalled client dropped)
- app.bend: shows (U.Ui.shows) and kind, taken before App.recv.
- host.js renders only when the message shows or gives commands, and
  round-trips a message to JSON for the kept log only for log and
  changes (every token of every agent was a full render and a parse).
- The kept log grows in place (push, no concat) and is written to
  localStorage at most every 2 s, on pagehide and when hidden; over quota
  the page keeps its copy in memory and drops only the stored one; a
  reload before a write catches up through since.

Checked in headless Chromium against a stand-in hub streaming 60 deltas
to a thread the page does not show: requestAnimationFrame calls during
the stream 40 -> 0, the thread still listed, a reload shows it.
- The startup feed (FarFeed with epoch "") no longer resets the epoch
  Far.start installed (Far.fed.k leaves it alone). Each mirror stored with
  epoch "" is pulled again once ("" to non-empty is a change), not in a
  loop.
- Each minute, a linked machine that answered is pulled through the
  existing coalesced pull even when already online (Far.here.was.k), so a
  lost last push is repaired within the minute.
- Fr.feed.after stops at the first line at or below since instead of
  walking the rest of the feed (law far_feed_after_stops, same result).
@i2cjak
i2cjak merged commit 2b03b1c into main Oct 2, 2026
2 checks passed
Snaiderbg pushed a commit to Snaiderbg/Backplane that referenced this pull request Oct 9, 2026
Snappier window and phones: parallel frames, changed pixels only, state kept across builds
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant