gizmoduck: row 271 (264d) design draft 1 in the packet: the threadIdle fence as the drain, the idle stop as the only signal, a session_ops journal and message_cancels in one schema step; Q1-Q4 for Sage and Mos

This commit is contained in:
jason.woltje committed 2026-10-22 11:53:59 -05:00
1 parent 2998b2a4e8
commit 3105de8892
1 file changed
+94
+94
View File
@@ -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: `<old name>-2`, `-3` and so on. Or should clear take a name?