diff --git a/agents/gizmoduck/work/queue-271/BUILD.md b/agents/gizmoduck/work/queue-271/BUILD.md index c7090f58b..b1e0cd03b 100644 --- a/agents/gizmoduck/work/queue-271/BUILD.md +++ b/agents/gizmoduck/work/queue-271/BUILD.md @@ -70,3 +70,97 @@ default where it is. On a base without 264d, `session.clear` refuses `unknown-verb`, and the rows have no `state`, `revision` or `successor`. + +## Design (draft 1, for Duckworth's early look before code) + +Measured on 2d0d407ba, which already holds row 194 (ab7377bcf). Nothing has been run. + +### What exists and what doesn't + +- No per-session lifecycle state. `sessions` is append-only + (schema.sql:370-372). The nearest per-session state is the latest `thread_claims` row (op and + generation). Row 194's lifecycle is per instance, as `action.allowed` events keyed by a random + request id (broker.mjs:622-666). +- No drain request in the runner. Its only stop is SIGTERM, which aborts a turn in progress + (runner.mjs:407-423). +- There is a fence. `threadIdle` (broker.mjs:865-877) rechecks `pending` and `unread` + for the run's own session and releases the claim in one synchronous call, only when both are 0. + After that, the runner's next `message.receive` gets `not-holder` and it exits 22 by itself + (runner.mjs:55, :524-530). `unread` 0 means every message delivered to that holder was read, and + the runner reads a message only after its turn's RESULT (runner.mjs:570-577). So a release + happens only at a completed turn boundary. +- The launcher's idle stop (launcher.mjs:683-701) is threadIdle, then a start-time-checked + SIGTERM to an idle runner. Exit proof is the run's `close` handler: `endRun`, registry remove, + and the workspace lock release (:600-641). +- No cancelled state for an undelivered message. `deliveries.op` is a CHECK list on an append-only + table. + +### Clear, stage by stage + +1. **Accept** (`session.clear`, the human's write). One transaction: + - check owner and grant (the session.create path), the revision, and processing not halted + - append journal stage `requested`, with a successor id bound now (`randomUUID`) and a payload + digest for the repeat/conflict check + Answer `clearing`. From here: + - `thread.send` / `thread.open` naming the session refuse `session-clearing`. A thread-only + send whose default is that session refuses the same way. + - `threadsState` reports the session as `clearing`, so `threadPass` never starts it. + - `message.receive` for a run of that session returns `[]`, which fences the next-turn dequeue. +2. **Drain** (the launcher, each tick, for each open clear). A trusted op `sessionClearDrain + {business, op}` runs in one transaction: + - Cancel every undelivered message to the session, with a receipt row per message (new table + `message_cancels`). The cancelled messages stop counting as pending. + - If the session has a holder: run threadIdle's check. If `unread > 0`, the turn is still + running. Answer `{drained: false}` and change nothing else. Otherwise release the claim, + generation-bound (`#claimEnd`), and append `drained` with the released run and generation. + - If the session has no holder: append `drained` with run null. + After `drained` with a run, the launcher sends the idle stop's start-time-checked SIGTERM, the + same reviewed path, to an idle runner whose dequeue is already fenced. Even without the signal, + the runner exits on its next receive. No KILL, no stopRun, and no signal before `drained`. +3. **Proof.** The released run's `close` handler (endRun, registry remove, lock release) is the + proof. + - The runner is pid 1 of its own pid namespace (sandbox `--unshare-pid --as-pid-1`), so its exit + ends every process in it. **To verify in the build** (bwrap's pid-1 behaviour), not assumed. + - With no run, the proof is that the launcher takes `lockSessionWorkspace` for the session (no + writer alive) and the registry has no run for it. + - After 30 s with no proof, append `uncertain` once. Keep waiting, with capacity still held by + the live run and no second signal. A launcher restart finds the run among its row 269 + survivors and keeps waiting on its identity. +4. **Retire** (trusted op `sessionClearRetire {business, op, run}`), one transaction: + - check the run ended (`#ended`) or that there was none + - append `retired` + - insert the successor `sessions` row: `predecessor` the old id, `op` the clear op, `name` the + old name plus a suffix (`UNIQUE(thread,name)`) + - move the default with a `session_defaults` row, only if the old session was the default + - insert a `thread_opens` row for the successor + The old workspace stays at its path, untouched. The successor gets its own store and workspace + from row 269's per-session layout and a fresh approved checkout. Nothing is copied. +5. **Start** (D8). `threadPass` starts the successor from its `thread_opens` row through the same + `check()`/`start()` as any session. The launcher reports the outcome with `sessionClearStart + {business, op, start, refusal}`, appending `start-held`, `start-started` or `start-refused`. + A held start keeps its op and spends no attempt. + +`failed`: a refusal at retire (for example the thread's owner lost the grant) appends `failed` +with the code. No retirement happens and the old session goes back to `active`. Human cancel is a +later row unless Sage wants it in 271. + +### Schema step (new, label by landing order) + +- `session_ops(seq, at, business, thread, session, op, stage, successor, run, generation, + digest, refusal)`. Append-only, with `UNIQUE(thread, op, stage)` and a CHECK on `stage`. A + trigger refuses a second open op per session. The envelope is derived from the latest stage. + `revision` is the count of this session's rows. +- `message_cancels(seq, at, message UNIQUE, session, op)`. Append-only. +- `threadRows`'s `pending` gains `AND NOT EXISTS (cancel)`. Each row gains `state`. + +### Questions for Sage (and Mos where marked) + +- Q1 (Mos, mos-1292). A real `3l→3m` step collides with comment-migration.test.mjs:76-82, which + fabricates a future `3l→3m`. That pre-existing test has to move to `3m→3n`, and the `'3l'` pins + in the account, gitea, grant and name migration tests and sessions.test.mjs:435 move too. Does + that go to Mos as one change list? And does 271 take `3m`, or whatever label landing order + gives it after row 206? +- Q2. Stop: fence plus idle-stop SIGTERM, as above, rather than a new `stopRun` reason. That adds + no KILL path. OK? +- Q3. Human cancel of a clear: out of 271? +- Q4. Successor name: `-2`, `-3` and so on. Or should clear take a name?