v0.2.1: see running turns, never downgrade an acknowledged line, heal under a lock, refuse foreign writers #3

Merged
claude merged 3 commits from oss-oo/appserver-send-v021 into main 2026-10-27 22:46:36 +00:00
Member

v0.2.1 — the fix release after the neighbour session's read of v0.2.0

The second independent read (Claude session a7056f09…, with its own harness under ~/.zcode/tmp/claude/appserver-resume-measure-20261027/review-pr2/) arrived after v0.2.0 was merged and installed. It found two blockers and two majors; all are folded here, the blocker is proven live.

Blockers

  1. Running turns were invisible. The real server answers thread/resume {excludeTurns: true} with "turns": [] — the key is always present (confirmed read-only against the loaded twin thread on the station). v0.2.0 asked thread/turns/list only when the key was missing, so it never saw a running turn: the heal gate could have restarted the shared server over a running headless turn, --mode steer always ended as no-active-turn, auto sent turn/start (which the server applies as a steer) and recorded delivered-app-start. Now status.type == "active" is the signal, the id comes from thread/turns/list {limit: 1} by its inProgress status (never by position), the heal gate treats active and unreadable loaded threads as busy, and the fake server answers turns: [] so the suite sees the real shape.
  2. An acknowledged line could be downgraded. A connection lost during the completion wait rewrote the receipt as refused, delivered: false — a caller trusting it would resend. Now an acknowledged line is never downgraded: completed: false + wait_error, the throttle stays marked.

Majors

  1. ack-unknown: a turn/start/turn/steer request on the wire without an acknowledgement (or a connection lost before it) is its own class (exit 8, delivered: null, throttle kept, note "do not resend blindly — look for the line in thread/turns/list first"); timeout is reserved for steps before anything was sent. The WebSocket client splits request and wait_reply so "on the wire" is known.
  2. Heal under a lock: heal.lock serialises concurrent senders, the station is observed again under the lock (already-healed, door-open-now, app-running skips); a unit active for less than 30 s without readyz is waited for instead of restarted (waited — Type=simple reports active before the server listens); a failed unit is never reset by the sender (door-closed with a hint); the client count is re-read right before every restart; the readyz probe bypasses any http_proxy; the stderr line says what was checked.

The thread store's writer lock (rigs v7/v8, folded in f74fc2d + 693ee3e)

A thread one server has loaded cannot be written by another: the second server logs thread-store conflict: … already has an active writer, its turn/start answers -32600 thread not found, reads still work, the rollout stays byte-identical; thread/unsubscribe does not release the lock, only the end of the holding process does. So a "stale pre-loaded thread" cannot arise — the edge is a lock-out of the app's own stdio server while the shared service holds a thread (and the other way round). send maps both server verdicts to held-by-app; oo-appserver release is a gated restart that frees every lock — while the app is closed, runs its own app-server or an orphaned stdio server exists, never while the app is attached (app-attached); no client, no busy thread; --dry-run plans only. The launcher wrapper could free the locks before a fallback launch — the owner's call (dotfiles).

Minors and nits

  • Throttle as a reservation taken under the lock before the resume (parallel senders on one thread pass exactly once), given back on non-delivery; an unavailable memory never blocks a delivery.
  • Writers: a stdio app-server of the bundle outside the shared service (orphaned child, app starting; --listen servers excluded) is held-by-app; the station is looked at once more right before turn/start.
  • WebSocket fragmentation reassembled; heal --dry-run acts on nothing; no positional steer target; README: ceil in the formula, not-needed, agent_items [], the measured facts (a second resume joins the first load; worst first load 16.7 s = 0.023 s/MB, the 0.05 default stays).

Tests

Suite 62 → 92 legs (green on every commit, ~53 s): the real reply shape, close-after-ack, ack-unknown, foreign stdio servers, app appearing mid-send (late-state seam), heal lock with two parallel senders (one restart), three parallel sends to one thread (one pass), young/hung/failed unit, fragmentation + pings, WS request/wait split, process scanners with injected process lists, heal --dry-run, writer-conflict verdicts, release in every station state.

Live proof 3 (station, app closed, 00:0x)

send … --wait 0 started a headless turn on the Luna6 twin (turn/start ack 167 ms); one second later send … --mode steer saw thread_status: active, read the same turn id through thread/turns/list and steered into it (turn/steer ack 96 ms, turn/completed 7.7 s, answer ok). Evidence: ~/.zcode/tmp/claude-adapter-design-20261027/v021-proof/.

Independent review

The neighbour session (the author of the v0.2.0 findings, read-only, own clone and harness) confirmed every commit: ae3e69d — "alle neun Befunde und die Nits sind behoben … mergen, releasen und vom Tag installieren ist ok" (83/83); f74fc2d — mapping and known gaps fit (88/88), one usability major: release refused the fallback case it exists for; 693ee3e — "das Gatter von release ist richtig" (92/92). CI green on every commit (runs 9–11). Review texts: v021-review/ in the evidence folder. A second read-only subagent was started on ae3e69d; its report, if it brings anything, goes into v0.2.2.

🤖 Generated with Claude Code

## v0.2.1 — the fix release after the neighbour session's read of v0.2.0 The second independent read (Claude session `a7056f09…`, with its own harness under `~/.zcode/tmp/claude/appserver-resume-measure-20261027/review-pr2/`) arrived after v0.2.0 was merged and installed. It found two blockers and two majors; all are folded here, the blocker is proven live. ### Blockers 1. **Running turns were invisible.** The real server answers `thread/resume {excludeTurns: true}` with `"turns": []` — the key is always present (confirmed read-only against the loaded twin thread on the station). v0.2.0 asked `thread/turns/list` only when the key was missing, so it never saw a running turn: the heal gate could have restarted the shared server over a running headless turn, `--mode steer` always ended as `no-active-turn`, auto sent `turn/start` (which the server applies as a steer) and recorded `delivered-app-start`. Now `status.type == "active"` is the signal, the id comes from `thread/turns/list {limit: 1}` by its `inProgress` status (never by position), the heal gate treats active and unreadable loaded threads as busy, and the fake server answers `turns: []` so the suite sees the real shape. 2. **An acknowledged line could be downgraded.** A connection lost during the completion wait rewrote the receipt as `refused`, `delivered: false` — a caller trusting it would resend. Now an acknowledged line is never downgraded: `completed: false` + `wait_error`, the throttle stays marked. ### Majors 3. **`ack-unknown`**: a `turn/start`/`turn/steer` request on the wire without an acknowledgement (or a connection lost before it) is its own class (exit 8, `delivered: null`, throttle kept, note "do not resend blindly — look for the line in thread/turns/list first"); `timeout` is reserved for steps before anything was sent. The WebSocket client splits `request` and `wait_reply` so "on the wire" is known. 4. **Heal under a lock**: `heal.lock` serialises concurrent senders, the station is observed again under the lock (`already-healed`, `door-open-now`, `app-running` skips); a unit active for less than 30 s without `readyz` is waited for instead of restarted (`waited` — `Type=simple` reports active before the server listens); a `failed` unit is never reset by the sender (door-closed with a hint); the client count is re-read right before every restart; the readyz probe bypasses any `http_proxy`; the stderr line says what was checked. ### The thread store's writer lock (rigs v7/v8, folded in f74fc2d + 693ee3e) A thread one server has loaded cannot be written by another: the second server logs `thread-store conflict: … already has an active writer`, its `turn/start` answers `-32600 thread not found`, reads still work, the rollout stays byte-identical; `thread/unsubscribe` does not release the lock, only the end of the holding process does. So a "stale pre-loaded thread" cannot arise — the edge is a lock-out of the app's own stdio server while the shared service holds a thread (and the other way round). `send` maps both server verdicts to `held-by-app`; **`oo-appserver release`** is a gated restart that frees every lock — while the app is closed, runs its own app-server or an orphaned stdio server exists, never while the app is attached (`app-attached`); no client, no busy thread; `--dry-run` plans only. The launcher wrapper could free the locks before a fallback launch — the owner's call (dotfiles). ### Minors and nits - Throttle as a reservation taken under the lock before the resume (parallel senders on one thread pass exactly once), given back on non-delivery; an unavailable memory never blocks a delivery. - Writers: a stdio app-server of the bundle outside the shared service (orphaned child, app starting; `--listen` servers excluded) is `held-by-app`; the station is looked at once more right before `turn/start`. - WebSocket fragmentation reassembled; `heal --dry-run` acts on nothing; no positional steer target; README: ceil in the formula, `not-needed`, `agent_items` `[]`, the measured facts (a second resume joins the first load; worst first load 16.7 s = 0.023 s/MB, the 0.05 default stays). ### Tests Suite 62 → 92 legs (green on every commit, ~53 s): the real reply shape, close-after-ack, ack-unknown, foreign stdio servers, app appearing mid-send (late-state seam), heal lock with two parallel senders (one restart), three parallel sends to one thread (one pass), young/hung/failed unit, fragmentation + pings, WS request/wait split, process scanners with injected process lists, `heal --dry-run`, writer-conflict verdicts, `release` in every station state. ### Live proof 3 (station, app closed, 00:0x) `send … --wait 0` started a headless turn on the Luna6 twin (`turn/start` ack 167 ms); one second later `send … --mode steer` saw `thread_status: active`, read the same turn id through `thread/turns/list` and steered into it (`turn/steer` ack 96 ms, `turn/completed` 7.7 s, answer `ok`). Evidence: `~/.zcode/tmp/claude-adapter-design-20261027/v021-proof/`. ### Independent review The neighbour session (the author of the v0.2.0 findings, read-only, own clone and harness) confirmed every commit: ae3e69d — "alle neun Befunde und die Nits sind behoben … mergen, releasen und vom Tag installieren ist ok" (83/83); f74fc2d — mapping and known gaps fit (88/88), one usability major: `release` refused the fallback case it exists for; 693ee3e — "das Gatter von `release` ist richtig" (92/92). CI green on every commit (runs 9–11). Review texts: `v021-review/` in the evidence folder. A second read-only subagent was started on ae3e69d; its report, if it brings anything, goes into v0.2.2. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
claude agent added 1 commit 2026-10-27 22:33:55 +00:00
The neighbour session's read of v0.2.0 (after the merge) found two blockers and two majors;
all folded, the blocker proven live.

- running turns: the server answers `excludeTurns` with `"turns": []` (key always present), so
  v0.2.0 never asked `thread/turns/list` and never saw a running turn — the heal gate could have
  restarted the server over a running headless turn, `--mode steer` always failed, auto never
  steered. Now `status.type == "active"` is the signal: the id comes from turns/list by its
  inProgress status (no positional guess), the heal gate treats active (and unreadable) loaded
  threads as busy. Proven live: a headless turn started with `--wait 0`, a second send steered
  into it (ack 96 ms, completed, "ok").
- acknowledged lines are never downgraded: a connection lost during the completion wait sets
  `completed: false` + `wait_error`, the receipt stays delivered; a request on the wire without an
  acknowledgement is `ack-unknown` (exit 8, `delivered: null`, throttle kept, "do not resend
  blindly"); `timeout` is reserved for steps before anything was sent.
- heal: under a lock (`heal.lock`), the station observed again under the lock (already-healed /
  door-open-now / app-running skip), a unit active for less than 30 s without readyz is waited for
  instead of restarted (`waited`), a failed unit is never reset by the sender (door-closed with a
  hint), the readyz probe bypasses any http proxy, the stderr line says what was checked.
- throttle: a reservation taken under the lock before the resume (parallel senders on one thread
  pass exactly once); non-deliveries give it back; an unavailable memory never blocks a delivery.
- writers: a stdio app-server of the bundle outside the shared service (orphaned child, app
  starting) is `held-by-app`; the station is looked at once more right before `turn/start` and the
  send refuses when the app or such a server appeared while the thread loaded.
- WebSocket client: fragmented messages reassembled, `request`/`wait_reply` split so a timed-out
  request is known to be on the wire; `heal --dry-run` acts on nothing.
- fake server answers `turns: []` with excludeTurns (so the suite sees the real shape), gains
  close-after-ack, fragmentation, pings, turns/list errors; suite 62 → 83 legs incl. process
  scanners with injected process lists, WS-level legs, parallel heals (one restart) and parallel
  sends (one pass). README: decision table, heal rules, `ack-unknown`, never-downgrade rule,
  reservation throttle, measured facts (second resume joins the first load, 0.023 s/MB worst case).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent added 1 commit 2026-10-27 22:37:41 +00:00
Measured on rigs (two servers on one home, 2026-10-27 v7/v8): a thread one server has loaded cannot
be written by another — resume answers "thread-store conflict: … already has an active writer",
turn/start answers -32600 "thread not found", reads still work, the rollout stays byte-identical;
thread/unsubscribe does not release the lock, only the end of the server process does. So a stale
pre-loaded thread cannot arise; the edge is a lock-out of the app's own stdio server while the
shared service holds a thread, and the other way round.

- send: both verdicts (resume conflict, "not found" right after a successful resume) are
  `held-by-app` — the server's own evidence beside the process scan; nothing is sent.
- `oo-appserver release` (= `--heal-only --release`): a gated restart (app closed, no client, no
  busy thread) even when nothing is stale, freeing every writer lock; `--dry-run` plans only.
- README: the measured lock replaces the "may be stale" gap; decision table row; the wrapper
  proposal (free the locks before a fallback launch) is named as the owner's call.
- suite 83 → 88 legs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent added 1 commit 2026-10-27 22:43:24 +00:00
`oo-appserver release` refused exactly the case it exists for (the app in stdio fallback while the
shared server holds its chats). Now the release restarts while the app is closed, runs its own
app-server or an orphaned stdio server exists — still only with no client connected to the shared
server and no busy loaded thread — and refuses (`app-attached`) when the app is attached. Four
legs; README.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent merged commit e904afa334 into main 2026-10-27 22:46:36 +00:00
claude agent deleted branch oss-oo/appserver-send-v021 2026-10-27 22:46:36 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
OSS-OO/oo-appserver!3
No description provided.