From 69ac2eb55da7f808bcff9b18c6dfe053229b2294 Mon Sep 17 00:00:00 2001 From: SSFSKIM Date: Sat, 17 Oct 2026 21:46:21 -0700 Subject: [PATCH 1/6] sminos bridge: a busy session reads a delivery at its next tool round; an Esc no longer loses one deliver() submitted every delivery with $.prompt.submit, a turn of its own that runs once the session is idle: a busy recipient read the message only when its turn ended (median 39 s, max 253 s on this machine), against sminos's contract of the next tool round. While the main turn runs, the delivery now goes in as a steer's row does ($.session.append, read at the loop's next request), with the steer's wake when the turn answers before reading it, and `entered` named as soon as the append returns. An Esc empties the queue of plugin prompts and refuses each new one until the human sends input (2.1.300 zBt / k_e). An idle delivery the engine drops for that reason now goes in as a row, which starts no turn; any other drop stays `refused`. An Esc before the model answered hands the prompt back and the human's resend goes in where it stood, which leaves a row given after it on a branch no request reads: each delivered row of a turn that did not answer (or appended idle) is checked against the conversation as the next turn starts, and given again when it is missing. The turn's first request carries the conversation as the turn began, so a row appended between turn.start and it no longer counts as read (steers included): a turn that answers in one request wakes once. Live: tests/mods/live-delivery-cancel-probe.py (busy, bash-esc, restore, after-esc). --- .../plans/2026-10-15-arkho-desk-c2-bridge.md | 6 +- hooks/mods/bridge-state.tsx | 12 +- hooks/mods/bridge-stream.tsx | 1 + hooks/mods/bridge.tsx | 193 ++++++++++++++-- tests/mods/bridge.test.ts | 138 ++++++++++- tests/mods/live-delivery-cancel-probe.py | 218 ++++++++++++++++++ 6 files changed, 538 insertions(+), 30 deletions(-) create mode 100755 tests/mods/live-delivery-cancel-probe.py diff --git a/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md b/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md index 156735ea4eb..7b7dd649e6a 100644 --- a/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md +++ b/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md @@ -462,11 +462,11 @@ M1 left them (with fw1/fw2). Changed rows are marked **[M2]**. | Op | Built | Arguments | Result | | --- | --- | --- | --- | -| `steer` | M2 **[M2]** **[fw1]** | `{text}` | `{row: }`. During the main turn: `$.session.append` of a user row (one text block), read at the main loop's next model request; the running tool goes on; mirrored as the transcript line `steer: ` (`$.ui.log`). Refused idle (`ok: false`, "no turn is running to steer: send it instead"). **[marker]** Refused, idle or not, for a text that read as an interrupt (`ok: false`, "that text is the interrupt marker, which would read as an interrupt: interrupt the session instead"): trimmed, either of the engine's markers whole (`[Request interrupted by user]`, `[Request interrupted by user for tool use]`), or one beginning `[Request interrupted by user from ` (the bridge's earlier record). A steer's row is kept as the interrupt's marker row is (`isMeta`, from the plugin), so it would be identical on disk and end the turn for every reader; a steer that only mentions the marker is a steer. `sminos steer` exits 1 with that reason. **[fw1]** The steer is bound to the main turn it was given in before the append is awaited. When that turn ends with `answer` and no main-loop request of it began after the row was in (or the turn ended while the append was pending, and the row then lands), the bridge submits **one wake prompt** (`asUser`) that does not repeat the steer. **[fw2]** The wake is owned by the originating turn: it is decided once the turn has ended *and* every append given in it has settled (in, refused, or failed), and is issued at most once per turn: "I added a note to the conversation while you were finishing your answer. Read it and act on it." (`STEER_WAKE`); the next request reads the row. A turn ending `aborted`/`error`/`refusal` wakes nothing. One wake per turn, however many steers | +| `steer` | M2 **[M2]** **[fw1]** | `{text}` | `{row: }`. During the main turn: `$.session.append` of a user row (one text block), read at the main loop's next model request; the running tool goes on; mirrored as the transcript line `steer: ` (`$.ui.log`). Refused idle (`ok: false`, "no turn is running to steer: send it instead"). **[marker]** Refused, idle or not, for a text that read as an interrupt (`ok: false`, "that text is the interrupt marker, which would read as an interrupt: interrupt the session instead"): trimmed, either of the engine's markers whole (`[Request interrupted by user]`, `[Request interrupted by user for tool use]`), or one beginning `[Request interrupted by user from ` (the bridge's earlier record). A steer's row is kept as the interrupt's marker row is (`isMeta`, from the plugin), so it would be identical on disk and end the turn for every reader; a steer that only mentions the marker is a steer. `sminos steer` exits 1 with that reason. **[fw1]** The steer is bound to the main turn it was given in before the append is awaited. When that turn ends with `answer` and no main-loop request of it began after the row was in (or the turn ended while the append was pending, and the row then lands), the bridge submits **one wake prompt**. **[midturn]** The turn's first request never counts: it carries the conversation as the turn began, and a row appended between `turn.start` and it is not in it (live, 2.1.302, `tests/mods/live-delivery-cancel-probe.py` `restore`). The wake prompt (`asUser`) does not repeat the steer. **[fw2]** The wake is owned by the originating turn: it is decided once the turn has ended *and* every append given in it has settled (in, refused, or failed), and is issued at most once per turn: "I added a note to the conversation while you were finishing your answer. Read it and act on it." (`STEER_WAKE`); the next request reads the row. A turn ending `aborted`/`error`/`refusal` wakes nothing. One wake per turn, however many steers | | `interrupt` | M2 **[M2]** **[fw1]** **[record]** | `{from?}` | `{turnId, stopped: [], running: []}`: `$.turn.abort` on the main turn, then `TaskStop` (`$.tool.call`) on each Bash call **attributable to that turn** and running at the abort whose result names the background task the abort moved it to (`running in background with ID: `; their results awaited at most 3 s). **[fw1]** Attributable: a main-loop call made during that turn, or a call of a foreground subagent spawned in it (nested foreground subagents inherit their parent's turn); a background subagent's calls never are. **[fw1]** `running` names each task whose TaskStop failed (an `isError` result or a rejection): the interrupt is incomplete when it is not empty. Idle: `{turnId: null, stopped: [], running: []}`. **[record]** A plugin's abort leaves the model no marker (Esc leaves `[Request interrupted by user]`), and a Bash call it moved to the background reads as still running there: so once the stops settle the interrupt appends two user rows. **[marker]** The first is Esc's marker whole, `[Request interrupted by user]`, one text block: a transcript's reader knows a turn's interrupt by a row whose whole text is the marker and by nothing else (the engine's own readers, 2.1.300 `He`/`pQn`, and MAWS's `INTERRUPT_MARKERS`), so the earlier one-row shape, `[Request interrupted by user from the desk]` with the task lines after it, read as no interrupt. The second is the note: `The interrupt came from the desk.` (`from: "desk"`, which the daemon's RPC sends) or `The interrupt came from outside the terminal.`, then a line per task: stopped, `Task , the Bash command the interrupt moved to the background, was then stopped: it is not running, and no notification will come for it.`; in `running`, `… could not be stopped: it may still be running.` The engine keeps a plugin's user row `isMeta: true` with `origin: {kind: "plugin", name}` whatever the plugin asks (2.1.300 `m6r`; it refuses any other user row): the model reads both rows, and no reader takes the note for a prompt. A marker a hook refuses is followed by no note. Both must be in before the turn ends: a next turn already queued takes its messages before it waits for any hook (2.1.300 `ir`/`cn`), so an append in `turn.complete` would miss its first request. The engine waits for a Bash call's result after a turn abort, so each Bash call of the turn being interrupted is held in the `tool.call` hook, after its result, until both rows are in (at most 10 s, `INTERRUPT_HOLD_MS`): they are kept before the moved call's result row, and the next request carries them after that result, the marker then the note (live, 2.1.302). An interrupt with no Bash call running appends at once after the abort, racing only the turn's own unwinding. A second interrupt of a turn being interrupted leaves the first's record alone (the engine refuses its abort). Each row is named by a `row-seen` notice (door `note`). The transcript mirrors the rows on one line as `interrupt: [Request interrupted by user] ` (a newline there draws as U+FFFD). Nothing for an idle interrupt or a refused abort | | `model` | M2 **[M2]** | `{model, effort?}` — `model` an id, a family alias, `"default"` or null; `effort` one of `low medium high xhigh max`, or null | `{model, effort}` now in force (null: the session's own). Rewritten on every **main-loop** `turn.step` request, subagents untouched; never `$.config.set`. A family alias (`haiku`, `sonnet`, `opus`, `fable`) is resolved through the session's `ANTHROPIC_DEFAULT__MODEL`; one it does not map is refused, naming the id to use. Emits `model`; kept by the child | | `compact` | M2 **[M2]** | `{instructions?}` | During the main turn `{queued: true, turnId}`: held, and run inside that turn's `turn.complete` (a second while one is held keeps the first). Idle `{started: true}`: run at once. Either way the outcome follows on the stream as `compact` events carrying this command's `id` | -| `deliver` | M2 **[M2]** **[fw1]** **[#229]** | `{key: {project, writer, seq}, text}` | `{submitted: true}` or `{duplicate: true}`. `text` is submitted with `$.prompt.submit({text, asUser: true})` (queued until the session is idle; not awaited). **The delivery key** is the log event's `(writer, seq)` — for a push carrying several events of one writer, the last. A key whose seq is at or below the highest already submitted for its writer is a duplicate and dropped (delivery goes per writer in seq order). The per-writer seqs are kept by the child across reloads; a /clear starts them anew (a new id is a new recipient). **[fw1]** The `delivered` event is emitted before the submission is made, so it is the next thing posted. Deduplication across a module reload is **best effort when the reload lands mid-submission**: a key submitted whose `delivered` had not reached the child is not kept, and a redelivery of it may submit again (a residual of at-least-once delivery, §3). **[#229]** The key names the log's project (its id): every project numbers a writer's events on its own, and a writer id is per session, not per project, so one writer delivering to one session from two projects reaches seqs the other project already passed — keyed by `(writer, seq)` alone, those were dropped as duplicates and settled, lost with no trace. Deduplication is per writer **within a project**, against the mark `/`; a key naming no project (a CLI older than this) is held to the bare `` mark, which is all such a CLI raised, so no kept mark is migrated, and a session whose bare mark is past a project's own count takes that project's scoped keys from the first. The CLI takes a `duplicate` as delivered (with scoped keys it was submitted before) but says it, to the sender and in `daemon.log`, naming the event and the session. A refusal (`refused`, below) keeps the key whole, project included, and the daemon reports only those naming its own project, or none | +| `deliver` | M2 **[M2]** **[fw1]** **[#229]** **[midturn]** | `{key: {project, writer, seq}, text}` | `{submitted: true}` or `{duplicate: true}`. **[midturn]** While the main turn runs, `text` goes in as a steer's does: `$.session.append` of a user row (one text block), read at the main loop's next request — the next tool round, sminos's contract for a busy recipient — bound to the turn before the append is awaited, mirrored as the transcript line `delivered: ` (newlines as spaces), and sharing the steer's wake (**[fw2]**: one wake per turn that answered with a row of it unread; none for `aborted`/`error`/`refusal`). Idle, it is submitted with `$.prompt.submit({text, asUser: true})`, a turn of its own (not awaited). A plugin prompt queued behind a turn read only at that turn's end (median 39 s, max 253 s observed), and an Esc empties the queue of plugin prompts (2.1.300 `zBt`). A prompt the engine drops because the human stopped the session — `the prompt was removed from the queue before it ran` (Esc, or the stop-agents chord, emptying the queue) or `the session was stopped and nobody has sent input since` (the engine refuses every plugin prompt after an Esc until the human sends input, 2.1.300 `k_e`) — goes in as a row instead, which starts no turn: the model reads it with the human's next prompt. Any other drop or rejection is `refused`. An Esc before the model answered hands the human's prompt back to the input box, and the prompt sent next goes in where that one stood (the same parent), so a row given after it is on a branch no request reads: each delivered row of a main turn that did not answer, or appended idle, is checked when the next main turn starts against `$.session.messages({as: "api"})` (any user text block containing the text), and one the conversation lacks is appended again in that turn (`entered` again, the new row), read from the turn's second request or by the wake when the turn answers in one; one it holds is let go, as are a turn's rows once it answers. An unreadable conversation leaves the rows for the next turn's check. Live: `tests/mods/live-delivery-cancel-probe.py`. **The delivery key** is the log event's `(writer, seq)` — for a push carrying several events of one writer, the last. A key whose seq is at or below the highest already submitted for its writer is a duplicate and dropped (delivery goes per writer in seq order). The per-writer seqs are kept by the child across reloads; a /clear starts them anew (a new id is a new recipient). **[fw1]** The `delivered` event is emitted before the submission is made, so it is the next thing posted. Deduplication across a module reload is **best effort when the reload lands mid-submission**: a key submitted whose `delivered` had not reached the child is not kept, and a redelivery of it may submit again (a residual of at-least-once delivery, §3). **[#229]** The key names the log's project (its id): every project numbers a writer's events on its own, and a writer id is per session, not per project, so one writer delivering to one session from two projects reaches seqs the other project already passed — keyed by `(writer, seq)` alone, those were dropped as duplicates and settled, lost with no trace. Deduplication is per writer **within a project**, against the mark `/`; a key naming no project (a CLI older than this) is held to the bare `` mark, which is all such a CLI raised, so no kept mark is migrated, and a session whose bare mark is past a project's own count takes that project's scoped keys from the first. The CLI takes a `duplicate` as delivered (with scoped keys it was submitted before) but says it, to the sender and in `daemon.log`, naming the event and the session. A refusal (`refused`, below) keeps the key whole, project included, and the daemon reports only those naming its own project, or none | ##### Events (the mod's posts; the stream's lines) @@ -477,7 +477,7 @@ M1 left them (with fw1/fw2). Changed rows are marked **[M2]**. | `model` | M2 **[M2]** | `model, effort` — the choice now in force (null: the session's own) | | `compact` / `start` | M2 **[M2]** | `id` — the `compact` command's id | | `compact` / `end` | M2 **[M2]** | `id`, and one of: `tokensBefore, tokensAfter` (null when the engine recorded none); `skip` (a hook vetoed it); `error` (the engine refused it, as during a turn) | -| `delivered` | M2 **[M2]** (was reserved), **[#229]** | `key: {project?, writer, seq}` (the command's key, whole), `outcome: "submitted" \| "duplicate" \| "refused"`, `reason?` — `refused` follows a `submitted` when the engine later dropped or rejected the prompt | +| `delivered` | M2 **[M2]** (was reserved), **[#229]**, **[midturn]** | `key: {project?, writer, seq}` (the command's key, whole), `outcome: "submitted" \| "duplicate" \| "refused" \| "entered"`, `reason?`, `uuid?` — `refused` follows a `submitted` when the engine later dropped or rejected the prompt, or refused the row (**[midturn]**); `entered` names the row the delivery became once it is kept (DC8 G6), and is named again with the new row when an Esc's rewind left the first on a dead branch (**[midturn]**) | | `kept` | M2 **[fw1]** **[fw2]** (the child's own) | `ok: false, error` when writing `.json` starts to fail (said once, however many batches it keeps failing; retried at the end of every batch); `ok: true` when a write works again after a failure | ##### Subscription (`GET /events`) diff --git a/hooks/mods/bridge-state.tsx b/hooks/mods/bridge-state.tsx index 016c77eddc3..fac5129103e 100644 --- a/hooks/mods/bridge-state.tsx +++ b/hooks/mods/bridge-state.tsx @@ -33,7 +33,8 @@ export type Call = { question?: unknown } /** - * A steer, bound to the main turn it was given in before its row is appended. + * A steer (or a delivery given into the turn), bound to the main turn it was + * given in before its row is appended. * `isAppended`: the row is in; `isRead`: a main-loop request began after it; * `ended`: how its turn ended, while some append of that turn is still pending. */ @@ -100,6 +101,12 @@ export type ListedAgent = { id: string; description: string; type: string; statu export type DeliveryKey = { project?: string; writer: string; seq: number } /** A log event submitted as the human's words, whose row is still to be kept. */ export type Submission = { key: DeliveryKey; text: string } +/** + * A delivery the bridge put in the conversation as a row, until the + * conversation is known to keep it: `turnId`, the main turn it was appended + * in; absent, appended while the session was idle. + */ +export type DeliveredRow = { key: DeliveryKey; text: string; turnId?: string } /** A delivery the engine dropped after it was submitted, as `delivered {outcome: "refused"}` reports it. */ export type Refusal = { key: DeliveryKey; reason: string } @@ -241,6 +248,8 @@ export type BridgeState = { agentListsInFlight: Set> /** Deliveries submitted whose prompt row has not been kept yet, oldest first (`delivered` `entered`). */ submissions: Submission[] + /** Delivered rows not yet known to stay in the conversation the next request reads (bridge.tsx, recheckDeliveries). */ + deliveredRows: DeliveredRow[] /** * Refusals no post the child answered has carried yet, oldest first: every * resync carries them, so one whose post was lost still reaches the child. @@ -273,6 +282,7 @@ const fresh = (): BridgeState => ({ conversation: 0, agentListsInFlight: new Set(), submissions: [], + deliveredRows: [], refusals: [], generation: 0, respawnMs: RESPAWN_FIRST_MS, diff --git a/hooks/mods/bridge-stream.tsx b/hooks/mods/bridge-stream.tsx index f7477605441..0cc08214228 100644 --- a/hooks/mods/bridge-stream.tsx +++ b/hooks/mods/bridge-stream.tsx @@ -187,6 +187,7 @@ export function forgetLive() { // Its agents were set aside first (bridge-agents.tsx, forgetAgents). state.agents.clear() state.submissions = [] + state.deliveredRows = [] state.refusals = [] state.delivered.clear() state.loopTurns.clear() diff --git a/hooks/mods/bridge.tsx b/hooks/mods/bridge.tsx index f88efb150a5..42949e247d1 100644 --- a/hooks/mods/bridge.tsx +++ b/hooks/mods/bridge.tsx @@ -48,9 +48,12 @@ export { AGENTS_REFRESH_MS } from './bridge-agents' * moved to the background, and before the turn ends appends Esc's marker and * a note saying so; * `model` rewrites the main loop's requests; - * `compact` runs between turns; `deliver` submits a log event as the human's - * words, once per key. What must outlive a module reload (the model chosen, - * the keys delivered) the child keeps on disk: its hello hands it back. + * `compact` runs between turns; `deliver` gives a log event as the human's + * words, once per key: into the running turn as a row, as a steer, else as a + * prompt of its own (a row when the human stopped the session), and a row an + * Esc rewound past is given again. What must outlive a module reload (the + * model chosen, the keys delivered) the child keeps on disk: its hello hands + * it back. * * Code that takes `$` stays in this file: the hooks, the session start, the * child and the session it serves, control, and the wait for a decision. The @@ -559,13 +562,28 @@ async function runCompaction($: EngineInterface, request: { id: string; instruct } /** - * A log event for this session, submitted as the human's words: queued until - * the session is idle. Delivery is at least once and goes in seq order per - * writer within a project, so a key at or below its mark (the last submitted - * for that writer in that project) is a repeat, and dropped. Every project - * numbers a writer's events on its own, so the mark is `/`; - * a key naming no project (a CLI older than the project in the key) is held - * to the writer's bare mark, which is all such a CLI ever raised. + * Why the engine drops a plugin's prompt when the human stopped the session + * (2.1.300 `zBt`, `k_e`): Esc, or the stop-agents chord, empties the queue of + * plugin prompts, and refuses each new one until the human sends input. Any + * other drop is a hook's or the engine's own decision. + */ +const STOPPED_DROPS = new Set(['the prompt was removed from the queue before it ran', 'the session was stopped and nobody has sent input since']) + +/** + * A log event for this session, as the human's words. While the main turn + * runs it goes in as a user row, read at the loop's next request (its next + * tool round), as a steer does; idle, it is submitted as a prompt, a turn of + * its own. A prompt the engine drops because the human stopped the session + * goes in as a row instead, which starts no turn: the model reads it with the + * human's next prompt. Either way `entered` names its row once it is kept, + * and `refused` follows `submitted` when nothing could be. + * + * Delivery is at least once and goes in seq order per writer within a + * project, so a key at or below its mark (the last submitted for that writer + * in that project) is a repeat, and dropped. Every project numbers a writer's + * events on its own, so the mark is `/`; a key naming no + * project (a CLI older than the project in the key) is held to the writer's + * bare mark, which is all such a CLI ever raised. */ function deliver($: EngineInterface, key: unknown, text: unknown): Answer { const { project, writer, seq } = (typeof key === 'object' && key !== null ? key : {}) as { project?: unknown; writer?: unknown; seq?: unknown } @@ -586,29 +604,143 @@ function deliver($: EngineInterface, key: unknown, text: unknown): Answer { } state.delivered.set(mark, delivery.seq) emit({ type: 'delivered', key: delivery, outcome: 'submitted' }) + if (state.mainTurn !== undefined) { + void appendDelivery($, delivery, text, state.mainTurn) + return { ok: true, result: { submitted: true } } + } // Its row, once kept, is named `entered` (bridge-rows.tsx, enteredRow). const submission = { key: delivery, text } state.submissions.push(submission) if (state.submissions.length > SUBMISSIONS_KEPT) { state.submissions.shift() } - const refused = (reason: string) => { - state.submissions = state.submissions.filter((s) => s !== submission) - $.ui.log(`bridge: the delivery ${mark}:${delivery.seq} did not enter: ${reason}`, { to: 'debug' }) - emit({ type: 'delivered', key: delivery, outcome: 'refused', reason }) - } + const forget = () => (state.submissions = state.submissions.filter((s) => s !== submission)) // Not awaited: it resolves when the prompt's turn starts, after any running turn. void $.prompt.submit({ text, asUser: true }).then( (entered) => { - if (entered.drop !== undefined) { - refused(entered.drop) + if (entered.drop === undefined) { + return + } + forget() + if (STOPPED_DROPS.has(entered.drop)) { + $.ui.log(`bridge: the delivery ${mark}:${delivery.seq} was not taken as a prompt (${entered.drop}): it goes in as a row`, { to: 'debug' }) + void appendDelivery($, delivery, text, undefined) + } else { + refuseDelivery($, delivery, entered.drop) } }, - (error: unknown) => refused(message(error)), + (error: unknown) => { + forget() + refuseDelivery($, delivery, message(error)) + }, ) return { ok: true, result: { submitted: true } } } +/** A delivery that could not enter: `refused`, after its `submitted`, with the engine's reason. */ +function refuseDelivery($: EngineInterface, delivery: DeliveryKey, reason: string) { + const mark = delivery.project === undefined ? delivery.writer : `${delivery.project}/${delivery.writer}` + $.ui.log(`bridge: the delivery ${mark}:${delivery.seq} did not enter: ${reason}`, { to: 'debug' }) + emit({ type: 'delivered', key: delivery, outcome: 'refused', reason }) +} + +/** + * A delivery put in the conversation as a user row, read at the loop's next + * request, and named `entered` once it is in. The terminal does not draw an + * appended row, so a transcript line mirrors it. In a running main turn + * (`turnId`) it is bound to that turn as a steer is (settleSteers): a turn + * that answers before any request read it wakes once. Appended while idle, + * it waits for the next turn. Either way it is held until the conversation + * is known to keep it (recheckDeliveries). + */ +async function appendDelivery($: EngineInterface, delivery: DeliveryKey, text: string, turnId: string | undefined) { + const pending: Steer | undefined = turnId === undefined ? undefined : { turnId, isAppended: false, isRead: false } + if (pending !== undefined) { + state.steers.push(pending) + } + const settled = (isIn: boolean) => { + if (pending === undefined) { + return + } + if (isIn) { + pending.isAppended = true + } else { + state.steers = state.steers.filter((s) => s !== pending) + } + settleSteers($, pending.turnId) + } + let stored: Awaited> + try { + stored = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } }) + } catch (error) { + settled(false) + refuseDelivery($, delivery, message(error)) + return + } + if (stored.deny !== undefined) { + settled(false) + refuseDelivery($, delivery, stored.deny) + return + } + state.deliveredRows.push({ key: delivery, text, ...(turnId !== undefined && { turnId }) }) + // The hook does not see the plugin's own append: named once it is in. + noticeRow({ type: 'row-seen', uuid: stored.uuid, agentId: null, door: 'note' }) + emit({ type: 'delivered', key: delivery, outcome: 'entered', uuid: stored.uuid }) + // One line: the transcript line draws a newline as U+FFFD. + $.ui.log(`delivered: ${text.replaceAll('\n', ' ')}`) + settled(true) +} + +/** + * The delivered rows that a main turn which did not answer, or the idle + * session, left behind, checked as the next main turn starts against the + * conversation its requests read. An Esc before the model answered hands the + * human's prompt back to the input box, and the prompt sent next goes in + * where that one stood: a row given after it is left on a branch no request + * reads. One the conversation lacks is appended again, in this turn (read + * from its second request, or by the wake when it answers in one); one it + * holds is let go. A row of a turn that answered is in the conversation for + * good, so its turn's end lets it go (mainTurnEnded). + */ +async function recheckDeliveries($: EngineInterface, turnId: string) { + const due = state.deliveredRows.filter((r) => r.turnId !== turnId) + if (due.length === 0) { + return + } + let conversation: unknown + try { + conversation = await $.session.messages({ as: 'api' }) + } catch (error) { + conversation = message(error) + } + // Unread, the rows wait for the next turn's check. + if (!Array.isArray(conversation)) { + $.ui.log(`bridge: the conversation was not read to check ${due.length} delivered rows: ${JSON.stringify(conversation)}`, { to: 'debug' }) + return + } + const texts = userTexts(conversation) + state.deliveredRows = state.deliveredRows.filter((r) => !due.includes(r)) + for (const r of due) { + if (!texts.some((t) => t.includes(r.text))) { + $.ui.log(`bridge: the delivery ${r.key.writer}:${r.key.seq} is not in the conversation this turn reads (an Esc rewound past it): delivered again`, { to: 'debug' }) + await appendDelivery($, r.key, r.text, turnId) + } + } +} + +/** The text blocks of a conversation's user messages, as `$.session.messages({ as: "api" })` answers it. */ +function userTexts(messages: readonly { role?: unknown; content?: unknown }[]): string[] { + return messages.flatMap((m) => + m?.role !== 'user' + ? [] + : typeof m.content === 'string' + ? [m.content] + : Array.isArray(m.content) + ? m.content.flatMap((b: { type?: unknown; text?: unknown }) => (b?.type === 'text' && typeof b.text === 'string' ? [b.text] : [])) + : [], + ) +} + /** * Polls the child for a decision while the dialog is up, in holds under the * fetch bound, until one is handed out, the terminal answered, the request @@ -652,13 +784,18 @@ async function awaitDecision($: EngineInterface, r: Request, signal: AbortSignal /** * The end of the main turn: a compaction held for it, then the turn's steers - * settled (settleSteers): when it answered before any request read one, one - * wake (the row is in the conversation already), as a prompt typed mid-turn - * would start the next turn — at once, or once the last of its pending - * appends settles. An interrupted or failed turn wakes nothing. + * and delivered rows settled (settleSteers): when it answered before any + * request read one, one wake (the row is in the conversation already), as a + * prompt typed mid-turn would start the next turn — at once, or once the + * last of its pending appends settles. An interrupted or failed turn wakes + * nothing; its delivered rows are checked when the next turn starts + * (recheckDeliveries). */ async function mainTurnEnded($: EngineInterface, turnId: string, reason: string) { state.interrupts.delete(turnId) + if (reason === 'answer') { + state.deliveredRows = state.deliveredRows.filter((r) => r.turnId !== turnId) + } for (const s of state.steers) { if (s.turnId === turnId) { s.ended = reason @@ -928,10 +1065,13 @@ export function registerBridge(on: On) { return next(e) }) - on('turn.start', { turnId: /^/ }, (_, e, next) => { + on('turn.start', { turnId: /^/ }, ($, e, next) => { if (e.agentId === undefined) { state.mainTurn = e.turnId state.isTurnUnknown = false + if (state.deliveredRows.length > 0) { + void recheckDeliveries($, e.turnId) + } } state.loopTurns.set(loopKey(e.agentId), e.turnId) // A loop whose spawn the bridge did not see (a forked skill): the list names it. @@ -998,9 +1138,12 @@ export function registerBridge(on: On) { } if (isMain) { state.steps++ - // This request carries every row of its turn appended before it. + // A request after the turn's first carries every row of its turn + // appended before it. The first carries the conversation as the turn + // began: a row appended between turn.start and it is not in it (live, + // 2.1.302), so that row waits for the second request, or the wake. for (const s of state.steers) { - if (s.isAppended && s.turnId === e.turnId) { + if (e.index > 0 && s.isAppended && s.turnId === e.turnId) { s.isRead = true } } diff --git a/tests/mods/bridge.test.ts b/tests/mods/bridge.test.ts index 4b4c0e052d0..6c156b0dbc8 100644 --- a/tests/mods/bridge.test.ts +++ b/tests/mods/bridge.test.ts @@ -157,6 +157,9 @@ function harness( const disk: Record = {} const rowReads: { loop: string | null; after: string | null; socketPath?: string }[] = [] let session = SID + /** What `$.session.messages({ as: "api" })` answers when a test set it; and how many times it was read. */ + let apiConversation: unknown[] | undefined + let apiReads = 0 let listedAgents = options.agents ?? [] /** How many times the mod read `$.agent.list()`. */ let agentLists = 0 @@ -167,7 +170,14 @@ function harness( id: async () => session, model: async () => 'claude-haiku', version: async () => ({ version: '2.1.300' }), - messages: async () => options.messages ?? [], + // As the requests read it (`as: "api"`): the mod's appended rows, unless a test rewound it. + messages: async (args?: { as?: string }) => { + if (args?.as !== 'api') { + return options.messages ?? [] + } + apiReads++ + return apiConversation ?? conversation.map((m) => ({ role: 'user', content: m.content })) + }, usage: async () => ({ startedAt: 0, context: { tokens: 5000, window: 200000, percent: 2 }, rateLimits: [] }), append: (args: unknown) => { calls.push({ op: 'append', args }) @@ -460,6 +470,8 @@ function harness( step, setSession: (id: string) => (session = id), setAgents: (agents: Record[]) => (listedAgents = agents), + setApiConversation: (messages: unknown[] | undefined) => (apiConversation = messages), + apiReads: () => apiReads, agentLists: () => agentLists, } } @@ -1534,6 +1546,130 @@ describe('deliver', () => { expect(h.calls.filter((c) => c.op === 'submit').map((c) => (c.args as { text: string }).text)).toEqual(['p2 6', 'p3 4']) }) + test("busy: the delivery goes into the running turn as a row, read at its next request; `submitted`, then `entered` with the row; mirrored; nothing submitted", async () => { + const h = harness() + await h.ready() + await h.turn('t1') + const key = { project: 'p1', writer: 'mac/s1', seq: 3 } + const text = '[sminos chat p mac/s1:3 from peer → @you]\nthe build is green' + expect(await h.command({ op: 'deliver', key, text })).toMatchObject({ ok: true, result: { submitted: true } }) + await h.settle() + expect(h.calls).toEqual([{ op: 'append', args: { message: { type: 'user', content: [{ type: 'text', text }] } } }]) + expect(h.ofType('delivered').map((e) => [e.key, e.outcome, e.uuid])).toEqual([ + [key, 'submitted', undefined], + [key, 'entered', 'row-1'], + ]) + expect(h.logs).toContain('delivered: [sminos chat p mac/s1:3 from peer → @you] the build is green') + // The next request carries it. + await h.step({ turnId: 't1', index: 1, model: 'm', messageCount: 3 }, []) + expect(h.requests.at(-1)!.map((m) => m.content[0]!.text)).toEqual([text]) + }) + + test('busy: a turn that answers before any request read the delivered row wakes the session once; one a request read wakes nothing', async () => { + const h = harness() + await h.ready() + await h.turn('t1') + await h.command({ op: 'deliver', key: { writer: 'mac/s1', seq: 1 }, text: 'unread' }) + await h.complete('t1') + expect(h.calls.filter((c) => c.op === 'submit').map((c) => c.args)).toEqual([{ text: STEER_WAKE, asUser: true }]) + await h.turn('t2') + await h.command({ op: 'deliver', key: { writer: 'mac/s1', seq: 2 }, text: 'read' }) + await h.step({ turnId: 't2', index: 1, model: 'm', messageCount: 3 }, []) + await h.complete('t2') + expect(h.calls.filter((c) => c.op === 'submit').length).toBe(1) + }) + + test('busy: an append the engine refuses is `refused` with the key whole, and wakes nothing', async () => { + const h = harness({ denyAppend: 'blocked' }) + await h.ready() + await h.turn('t1') + const key = { project: 'p1', writer: 'mac/s1', seq: 4 } + expect(await h.command({ op: 'deliver', key, text: 'blocked' })).toMatchObject({ ok: true, result: { submitted: true } }) + await h.settle() + await h.complete('t1') + expect(h.ofType('delivered').map((e) => [e.key, e.outcome, e.reason])).toEqual([ + [key, 'submitted', undefined], + [key, 'refused', 'refused by a hook'], + ]) + expect(h.calls.filter((c) => c.op === 'submit')).toEqual([]) + }) + + test("a delivered row an Esc rewound past (the prompt handed back, the next one sent where it stood) goes in again as the next turn starts, named `entered` again", async () => { + const h = harness() + await h.ready() + await h.turn('t1') + const key = { writer: 'mac/s1', seq: 5 } + await h.command({ op: 'deliver', key, text: 'the delivered words' }) + await h.complete('t1', 'aborted') + expect(h.calls.filter((c) => c.op === 'submit')).toEqual([]) + h.setApiConversation([{ role: 'user', content: [{ type: 'text', text: 'the prompt, edited' }] }]) + await h.turn('t2') + await h.settle() + expect(h.calls.filter((c) => c.op === 'append').length).toBe(2) + expect(h.ofType('delivered').map((e) => [e.outcome, e.uuid])).toEqual([ + ['submitted', undefined], + ['entered', 'row-1'], + ['entered', 'row-2'], + ]) + // Given in t2, it is t2's: a turn that answers lets it go, and the next turn reads nothing. + await h.step({ turnId: 't2', index: 1, model: 'm', messageCount: 3 }, []) + await h.complete('t2') + const reads = h.apiReads() + await h.turn('t3') + await h.settle() + expect(h.apiReads()).toBe(reads) + expect(h.calls.filter((c) => c.op === 'append').length).toBe(2) + }) + + test("a row given again as a turn starts is not in that turn's first request: a turn that answers in one request wakes once", async () => { + const h = harness() + await h.ready() + await h.turn('t1') + await h.command({ op: 'deliver', key: { writer: 'mac/s1', seq: 8 }, text: 'given again' }) + await h.complete('t1', 'aborted') + h.setApiConversation([]) + await h.turn('t2') + await h.settle() + // The first request was built as the turn began, before the row went in. + await h.step({ turnId: 't2', index: 0, model: 'm', messageCount: 2 }, []) + await h.complete('t2') + expect(h.calls.filter((c) => c.op === 'submit').map((c) => (c.args as { text: string }).text)).toEqual([STEER_WAKE]) + }) + + test('a delivered row an interrupted turn kept (Esc mid-tool) is let go at the next turn, not given again', async () => { + const h = harness() + await h.ready() + await h.turn('t1') + await h.command({ op: 'deliver', key: { writer: 'mac/s1', seq: 6 }, text: 'kept words' }) + await h.complete('t1', 'aborted') + h.setApiConversation([{ role: 'user', content: [{ type: 'text', text: 'prompt' }, { type: 'text', text: 'kept words' }] }, { role: 'user', content: 'next' }]) + await h.turn('t2') + await h.settle() + expect(h.calls.filter((c) => c.op === 'append').length).toBe(1) + await h.complete('t2', 'aborted') + await h.turn('t3') + await h.settle() + expect(h.calls.filter((c) => c.op === 'append').length).toBe(1) + expect(h.apiReads()).toBe(1) + }) + + test("idle: a prompt the engine drops because the human stopped the session goes in as a row, starting no turn; it is checked when the next turn starts", async () => { + const h = harness({ submitDrops: 'the session was stopped and nobody has sent input since' }) + await h.ready() + const key = { writer: 'mac/s1', seq: 7 } + await h.command({ op: 'deliver', key, text: 'while stopped' }) + await h.settle() + expect(h.calls.map((c) => c.op)).toEqual(['submit', 'append']) + expect(h.ofType('delivered').map((e) => [e.outcome, e.uuid])).toEqual([ + ['submitted', undefined], + ['entered', 'row-2'], + ]) + await h.turn('t1') + await h.settle() + expect(h.apiReads()).toBe(1) + expect(h.calls.map((c) => c.op)).toEqual(['submit', 'append']) + }) + test('a deliver with no key or no text is refused', async () => { const h = harness() await h.ready() diff --git a/tests/mods/live-delivery-cancel-probe.py b/tests/mods/live-delivery-cancel-probe.py new file mode 100755 index 00000000000..35271c78978 --- /dev/null +++ b/tests/mods/live-delivery-cancel-probe.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Probe (not a check): how a message the bridge delivers into a session +fares when the session is busy, and when the human stops the turn. + +Each scenario delivers a message asking the model to include a word in its +reply, and reports what became of it: the bridge's `delivered` outcomes, the +turns that started, whether the model said the word, the input box after an +Esc, the transcript's user rows (uuid, parent, origin) and queue-operation +rows. A busy delivery goes in as a row read at the next tool round; an Esc +before the model answered hands the prompt back to the input box and the +human's edited resend goes in where it stood, which leaves a row given after +it on a branch no request reads (the bridge gives it again as the next turn +starts); a delivery made after an Esc meets the engine's refusal of plugin +prompts until the human speaks (the bridge gives it as a row instead). + +It spends model calls (haiku, short prompts). Isolation is +live-bridge-check.py's. + + tests/mods/live-delivery-cancel-probe.py [--keep] [SCENARIO...] + + busy delivered mid-Bash; the turn runs on + bash-esc delivered mid-Bash, then Esc; the human asks for the word + restore delivered as the turn starts, Esc before any answer, edit, resend + after-esc Esc before any answer, delivered while the prompt is back in the box, edit, resend +""" + +import glob +import importlib.util +import json +import os +import sys +import threading +import time + +HERE = os.path.dirname(os.path.realpath(__file__)) +spec = importlib.util.spec_from_file_location("live_bridge_check", os.path.join(HERE, "live-bridge-check.py")) +LB = importlib.util.module_from_spec(spec) +spec.loader.exec_module(LB) + +SCENARIOS = {"busy": "KIWI", "bash-esc": "MANGO", "restore": "PAPAYA", "after-esc": "PEAR"} +ASK = "If an earlier message asked you to include a word, reply with that word only; otherwise reply NONE." + + +def is_main(e, kind, phase=None): + return e.get("type") == kind and e.get("agentId") is None and (phase is None or e.get("phase") == phase) + + +class Probe(LB.Live): + seq = 0 + + def setup(self): + super().setup() + with open(os.path.join(self.cfg, "settings.json"), "w") as f: + json.dump({"permissions": {"allow": ["Bash"]}}, f) + + def command(self, body): + return LB.request(self.endpoint(), "POST", "/command?wait=8000", body, timeout=15) + + def deliver(self, word): + Probe.seq += 1 + key = {"writer": "probe/w", "seq": Probe.seq} + s, body = self.command({"op": "deliver", "key": key, "text": "Include the word %s in your reply." % word}) + print(" deliver answered: %s %s" % (s, body and body.get("result")), flush=True) + return key + + def idle(self, seconds=150): + deadline = time.time() + seconds + calm = None + while time.time() < deadline: + s, body = self.command({"op": "ping"}) + if s == 200 and body["result"]["state"]["mainTurn"] is None: + calm = calm or time.time() + if time.time() - calm >= 3: + return + else: + calm = None + time.sleep(0.5) + raise TimeoutError("the session did not go idle") + + def rows(self): + out = [] + for p in glob.glob(os.path.join(self.cfg, "projects", "*", self.session + ".jsonl")): + with open(p) as f: + out += [json.loads(l) for l in f if l.strip()] + return out + + def text_since(self, t): + return "".join(e["chunk"].get("text", "") for e in self.seen( + lambda e: e.get("type") == "chunk" and e.get("agentId") is None and e["chunk"].get("kind") == "text", t)) + + def show_pane(self, word): + pane = self.pane() + print(" pane after Esc (last lines):\n" + "\n".join(" " + l for l in pane.rstrip().splitlines()[-6:]), flush=True) + print(" the input box holds the delivered text: %s" % (word in pane.rstrip().splitlines()[-3:-2][0] if len(pane.rstrip().splitlines()) > 3 else False), flush=True) + + def resend(self): + self.keys("-l", " Keep it short.") + time.sleep(0.3) + self.keys("Enter") + time.sleep(3) + + def report(self, name, t, key, word, before): + self.idle() + time.sleep(2) + outcomes = [(e.get("outcome"), e.get("reason"), (e.get("uuid") or "")[:8]) for e in self.seen( + lambda e: e.get("type") == "delivered" and e.get("key") == key, t)] + starts = [str(e.get("text"))[:60] for e in self.seen(lambda e: is_main(e, "turn", "start"), t)] + rows = self.rows()[before:] + qops = [(r.get("operation"), r.get("reason")) for r in rows if r.get("type") == "queue-operation"] + users = [] + for r in rows: + if r.get("type") != "user": + continue + c = r["message"]["content"] + text = c if isinstance(c, str) else " ".join(b.get("text", b.get("type")) for b in c) + users.append((r.get("uuid", "")[:8], (r.get("parentUuid") or "")[:8], (r.get("origin") or {}).get("kind"), text[:60])) + said = self.text_since(t) + print("== %s" % name) + print(" delivered outcomes: %s" % outcomes) + print(" main turns started: %s" % starts) + print(" model said %s: %s" % (word, word in said)) + print(" queue ops: %s" % qops) + print(" user rows (uuid, parent, origin, text):") + for u in users: + print(" %s" % (u,)) + print(" model text (end): %r" % said[-160:], flush=True) + + def bash_turn(self, t): + m = "SLEPT%d" % int(t) + self.prompt("Run exactly this Bash command, then reply in one short sentence: " + "python3 -c 'import time; time.sleep(12); print(\"%s\")'" % m) + LB.wait_for("the Bash call", 90, lambda: self.seen( + lambda e: is_main(e, "tool", "call") and e.get("tool") == "Bash" and m in json.dumps(e.get("input")), t)) + time.sleep(1.5) + + def busy(self, word): + t, before = time.time(), len(self.rows()) + self.bash_turn(t) + key = self.deliver(word) + sent = time.time() + LB.wait_for("the next request", 60, lambda: self.seen(lambda e: is_main(e, "step", "start"), sent)) + with self.lock: + at = next(w for w, e in self.events if w >= sent and is_main(e, "step", "start")) + print(" the next request began %.1f s after the delivery" % (at - sent), flush=True) + self.report("busy", t, key, word, before) + + def bash_esc(self, word): + t, before = time.time(), len(self.rows()) + self.bash_turn(t) + key = self.deliver(word) + time.sleep(1.5) + self.keys("Escape") + time.sleep(3) + self.idle() + self.prompt(ASK) + time.sleep(3) + self.report("bash-esc", t, key, word, before) + + def restore(self, word): + t, before = time.time(), len(self.rows()) + self.prompt("Write a 150-word poem about rivers. Use no tools.") + LB.wait_for("the turn's start", 30, lambda: self.seen(lambda e: is_main(e, "turn", "start"), t)) + key = self.deliver(word) + self.keys("Escape") + print(" a chunk before the Esc: %s" % bool(self.seen(lambda e: e.get("type") == "chunk" and e.get("agentId") is None, t))) + time.sleep(3) + self.show_pane(word) + self.resend() + self.report("restore", t, key, word, before) + + def after_esc(self, word): + t, before = time.time(), len(self.rows()) + self.prompt("Write a 150-word poem about lakes. Use no tools.") + LB.wait_for("the turn's start", 30, lambda: self.seen(lambda e: is_main(e, "turn", "start"), t)) + self.keys("Escape") + print(" a chunk before the Esc: %s" % bool(self.seen(lambda e: e.get("type") == "chunk" and e.get("agentId") is None, t))) + time.sleep(2) + key = self.deliver(word) + time.sleep(2) + self.show_pane(word) + self.resend() + self.report("after-esc", t, key, word, before) + + def run(self, scenarios): + self.setup() + self.start_session() + sock_dir = os.path.join(self.smh, "bridge") + found = LB.wait_for("the bridge's socket", 90, lambda: [n for n in os.listdir(sock_dir) + if n.endswith(".sock") and not n.startswith(".")] + if os.path.isdir(sock_dir) else None) + self.session = found[0][:-5] + LB.wait_for("the endpoint to answer", 20, self.status) + threading.Thread(target=self.subscribe_forever, daemon=True).start() + LB.wait_for("the subscription", 10, lambda: self.subscriptions > 0) + for s in scenarios: + getattr(self, s.replace("-", "_"))(SCENARIOS[s]) + self.idle() + + +def main(): + args = [a for a in sys.argv[1:] if a != "--keep"] + scenarios = args or list(SCENARIOS) + for s in scenarios: + if s not in SCENARIOS: + sys.exit("unknown scenario %s (one of %s)" % (s, ", ".join(SCENARIOS))) + p = Probe("--keep" in sys.argv[1:]) + print("delivery cancel probe (%s):" % p.root, flush=True) + try: + p.run(scenarios) + except Exception as e: # noqa: BLE001 + print(" the probe stopped: %s: %s" % (type(e).__name__, e)) + print(" pane:\n" + "\n".join(" " + l for l in p.pane().rstrip().splitlines()[-25:])) + finally: + p.cleanup() + + +if __name__ == "__main__": + main() From 6ab48c5f611ca9420009d9b3381cdbe29d527a77 Mon Sep 17 00:00:00 2001 From: SSFSKIM Date: Sat, 17 Oct 2026 21:56:29 -0700 Subject: [PATCH 2/6] sminos bridge: a delivery's re-check survives a compaction, a turn that ends under it, and a /clear Three review findings on the mid-turn delivery change: - A compaction of the main conversation lets every tracked delivered row go (bridge-rows.tsx, compacted: the bridge's compact, the compact tool's, and the session.compact hook all pass through it). The compacted conversation holds a row only in its summary, so the next turn's check read a row that was delivered and read as missing and gave it twice. A row a rewind left behind before the compaction is the accepted loss. - recheckDeliveries binds every due row's steer to its turn before its first await, the conversation read included, so a turn that ends while the check runs stamps them all and settleSteers owes the wake. Bound one at a time across the appends, a replay bound after the turn ended never woke. Each is let go as its row proves held, or settled as its append is. - A /clear while a check runs ends it (state.conversation, the counter a /clear moves): nothing is appended to the conversation that ended, and its steers wake nothing in the new one. An append pending across a /clear no longer records its row for the new conversation's check. Tests: one per finding in tests/mods/bridge.test.ts (deliver), each failing without its fix; the harness can now hold the conversation read. --- .../plans/2026-10-15-arkho-desk-c2-bridge.md | 2 +- hooks/mods/bridge-rows.tsx | 8 ++ hooks/mods/bridge.tsx | 66 +++++++++++--- tests/mods/bridge.test.ts | 87 +++++++++++++++++++ 4 files changed, 150 insertions(+), 13 deletions(-) diff --git a/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md b/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md index 7b7dd649e6a..957e385eef0 100644 --- a/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md +++ b/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md @@ -466,7 +466,7 @@ M1 left them (with fw1/fw2). Changed rows are marked **[M2]**. | `interrupt` | M2 **[M2]** **[fw1]** **[record]** | `{from?}` | `{turnId, stopped: [], running: []}`: `$.turn.abort` on the main turn, then `TaskStop` (`$.tool.call`) on each Bash call **attributable to that turn** and running at the abort whose result names the background task the abort moved it to (`running in background with ID: `; their results awaited at most 3 s). **[fw1]** Attributable: a main-loop call made during that turn, or a call of a foreground subagent spawned in it (nested foreground subagents inherit their parent's turn); a background subagent's calls never are. **[fw1]** `running` names each task whose TaskStop failed (an `isError` result or a rejection): the interrupt is incomplete when it is not empty. Idle: `{turnId: null, stopped: [], running: []}`. **[record]** A plugin's abort leaves the model no marker (Esc leaves `[Request interrupted by user]`), and a Bash call it moved to the background reads as still running there: so once the stops settle the interrupt appends two user rows. **[marker]** The first is Esc's marker whole, `[Request interrupted by user]`, one text block: a transcript's reader knows a turn's interrupt by a row whose whole text is the marker and by nothing else (the engine's own readers, 2.1.300 `He`/`pQn`, and MAWS's `INTERRUPT_MARKERS`), so the earlier one-row shape, `[Request interrupted by user from the desk]` with the task lines after it, read as no interrupt. The second is the note: `The interrupt came from the desk.` (`from: "desk"`, which the daemon's RPC sends) or `The interrupt came from outside the terminal.`, then a line per task: stopped, `Task , the Bash command the interrupt moved to the background, was then stopped: it is not running, and no notification will come for it.`; in `running`, `… could not be stopped: it may still be running.` The engine keeps a plugin's user row `isMeta: true` with `origin: {kind: "plugin", name}` whatever the plugin asks (2.1.300 `m6r`; it refuses any other user row): the model reads both rows, and no reader takes the note for a prompt. A marker a hook refuses is followed by no note. Both must be in before the turn ends: a next turn already queued takes its messages before it waits for any hook (2.1.300 `ir`/`cn`), so an append in `turn.complete` would miss its first request. The engine waits for a Bash call's result after a turn abort, so each Bash call of the turn being interrupted is held in the `tool.call` hook, after its result, until both rows are in (at most 10 s, `INTERRUPT_HOLD_MS`): they are kept before the moved call's result row, and the next request carries them after that result, the marker then the note (live, 2.1.302). An interrupt with no Bash call running appends at once after the abort, racing only the turn's own unwinding. A second interrupt of a turn being interrupted leaves the first's record alone (the engine refuses its abort). Each row is named by a `row-seen` notice (door `note`). The transcript mirrors the rows on one line as `interrupt: [Request interrupted by user] ` (a newline there draws as U+FFFD). Nothing for an idle interrupt or a refused abort | | `model` | M2 **[M2]** | `{model, effort?}` — `model` an id, a family alias, `"default"` or null; `effort` one of `low medium high xhigh max`, or null | `{model, effort}` now in force (null: the session's own). Rewritten on every **main-loop** `turn.step` request, subagents untouched; never `$.config.set`. A family alias (`haiku`, `sonnet`, `opus`, `fable`) is resolved through the session's `ANTHROPIC_DEFAULT__MODEL`; one it does not map is refused, naming the id to use. Emits `model`; kept by the child | | `compact` | M2 **[M2]** | `{instructions?}` | During the main turn `{queued: true, turnId}`: held, and run inside that turn's `turn.complete` (a second while one is held keeps the first). Idle `{started: true}`: run at once. Either way the outcome follows on the stream as `compact` events carrying this command's `id` | -| `deliver` | M2 **[M2]** **[fw1]** **[#229]** **[midturn]** | `{key: {project, writer, seq}, text}` | `{submitted: true}` or `{duplicate: true}`. **[midturn]** While the main turn runs, `text` goes in as a steer's does: `$.session.append` of a user row (one text block), read at the main loop's next request — the next tool round, sminos's contract for a busy recipient — bound to the turn before the append is awaited, mirrored as the transcript line `delivered: ` (newlines as spaces), and sharing the steer's wake (**[fw2]**: one wake per turn that answered with a row of it unread; none for `aborted`/`error`/`refusal`). Idle, it is submitted with `$.prompt.submit({text, asUser: true})`, a turn of its own (not awaited). A plugin prompt queued behind a turn read only at that turn's end (median 39 s, max 253 s observed), and an Esc empties the queue of plugin prompts (2.1.300 `zBt`). A prompt the engine drops because the human stopped the session — `the prompt was removed from the queue before it ran` (Esc, or the stop-agents chord, emptying the queue) or `the session was stopped and nobody has sent input since` (the engine refuses every plugin prompt after an Esc until the human sends input, 2.1.300 `k_e`) — goes in as a row instead, which starts no turn: the model reads it with the human's next prompt. Any other drop or rejection is `refused`. An Esc before the model answered hands the human's prompt back to the input box, and the prompt sent next goes in where that one stood (the same parent), so a row given after it is on a branch no request reads: each delivered row of a main turn that did not answer, or appended idle, is checked when the next main turn starts against `$.session.messages({as: "api"})` (any user text block containing the text), and one the conversation lacks is appended again in that turn (`entered` again, the new row), read from the turn's second request or by the wake when the turn answers in one; one it holds is let go, as are a turn's rows once it answers. An unreadable conversation leaves the rows for the next turn's check. Live: `tests/mods/live-delivery-cancel-probe.py`. **The delivery key** is the log event's `(writer, seq)` — for a push carrying several events of one writer, the last. A key whose seq is at or below the highest already submitted for its writer is a duplicate and dropped (delivery goes per writer in seq order). The per-writer seqs are kept by the child across reloads; a /clear starts them anew (a new id is a new recipient). **[fw1]** The `delivered` event is emitted before the submission is made, so it is the next thing posted. Deduplication across a module reload is **best effort when the reload lands mid-submission**: a key submitted whose `delivered` had not reached the child is not kept, and a redelivery of it may submit again (a residual of at-least-once delivery, §3). **[#229]** The key names the log's project (its id): every project numbers a writer's events on its own, and a writer id is per session, not per project, so one writer delivering to one session from two projects reaches seqs the other project already passed — keyed by `(writer, seq)` alone, those were dropped as duplicates and settled, lost with no trace. Deduplication is per writer **within a project**, against the mark `/`; a key naming no project (a CLI older than this) is held to the bare `` mark, which is all such a CLI raised, so no kept mark is migrated, and a session whose bare mark is past a project's own count takes that project's scoped keys from the first. The CLI takes a `duplicate` as delivered (with scoped keys it was submitted before) but says it, to the sender and in `daemon.log`, naming the event and the session. A refusal (`refused`, below) keeps the key whole, project included, and the daemon reports only those naming its own project, or none | +| `deliver` | M2 **[M2]** **[fw1]** **[#229]** **[midturn]** | `{key: {project, writer, seq}, text}` | `{submitted: true}` or `{duplicate: true}`. **[midturn]** While the main turn runs, `text` goes in as a steer's does: `$.session.append` of a user row (one text block), read at the main loop's next request — the next tool round, sminos's contract for a busy recipient — bound to the turn before the append is awaited, mirrored as the transcript line `delivered: ` (newlines as spaces), and sharing the steer's wake (**[fw2]**: one wake per turn that answered with a row of it unread; none for `aborted`/`error`/`refusal`). Idle, it is submitted with `$.prompt.submit({text, asUser: true})`, a turn of its own (not awaited). A plugin prompt queued behind a turn read only at that turn's end (median 39 s, max 253 s observed), and an Esc empties the queue of plugin prompts (2.1.300 `zBt`). A prompt the engine drops because the human stopped the session — `the prompt was removed from the queue before it ran` (Esc, or the stop-agents chord, emptying the queue) or `the session was stopped and nobody has sent input since` (the engine refuses every plugin prompt after an Esc until the human sends input, 2.1.300 `k_e`) — goes in as a row instead, which starts no turn: the model reads it with the human's next prompt. Any other drop or rejection is `refused`. An Esc before the model answered hands the human's prompt back to the input box, and the prompt sent next goes in where that one stood (the same parent), so a row given after it is on a branch no request reads: each delivered row of a main turn that did not answer, or appended idle, is checked when the next main turn starts against `$.session.messages({as: "api"})` (any user text block containing the text), and one the conversation lacks is appended again in that turn (`entered` again, the new row), read from the turn's second request or by the wake when the turn answers in one; one it holds is let go, as are a turn's rows once it answers, and every row when the main conversation is compacted (the bridge's `compact`, the compact tool's, the person's or the engine's): the conversation then holds them only in its summary, so each would read as missing and be given twice; a row a rewind left on a dead branch before the compaction is lost. A /clear lets them go too, a check still running at it included: nothing is appended to the conversation that ended. An unreadable conversation leaves the rows for the next turn's check. Live: `tests/mods/live-delivery-cancel-probe.py`. **The delivery key** is the log event's `(writer, seq)` — for a push carrying several events of one writer, the last. A key whose seq is at or below the highest already submitted for its writer is a duplicate and dropped (delivery goes per writer in seq order). The per-writer seqs are kept by the child across reloads; a /clear starts them anew (a new id is a new recipient). **[fw1]** The `delivered` event is emitted before the submission is made, so it is the next thing posted. Deduplication across a module reload is **best effort when the reload lands mid-submission**: a key submitted whose `delivered` had not reached the child is not kept, and a redelivery of it may submit again (a residual of at-least-once delivery, §3). **[#229]** The key names the log's project (its id): every project numbers a writer's events on its own, and a writer id is per session, not per project, so one writer delivering to one session from two projects reaches seqs the other project already passed — keyed by `(writer, seq)` alone, those were dropped as duplicates and settled, lost with no trace. Deduplication is per writer **within a project**, against the mark `/`; a key naming no project (a CLI older than this) is held to the bare `` mark, which is all such a CLI raised, so no kept mark is migrated, and a session whose bare mark is past a project's own count takes that project's scoped keys from the first. The CLI takes a `duplicate` as delivered (with scoped keys it was submitted before) but says it, to the sender and in `daemon.log`, naming the event and the session. A refusal (`refused`, below) keeps the key whole, project included, and the daemon reports only those naming its own project, or none | ##### Events (the mod's posts; the stream's lines) diff --git a/hooks/mods/bridge-rows.tsx b/hooks/mods/bridge-rows.tsx index fdf46f162f6..c77e67e6e5b 100644 --- a/hooks/mods/bridge-rows.tsx +++ b/hooks/mods/bridge-rows.tsx @@ -101,6 +101,14 @@ function remember(loop: string, uuid: string) { */ export function compacted(agentId: string | null, messages: readonly SessionMessage[] | undefined) { const loop = agentId ?? 'main' + // The main conversation compacted holds its rows in a summary, not + // verbatim, so the delivered rows still tracked (bridge.tsx, + // recheckDeliveries) would all read as missing and be given twice: every + // one is let go. A row a rewind left on a dead branch before the + // compaction is the accepted loss. + if (agentId === null) { + state.deliveredRows = [] + } const after = state.cut.get(loop) const summary = messages?.[0]?.handle // With no cut yet (a module that has not read its first hello) there is diff --git a/hooks/mods/bridge.tsx b/hooks/mods/bridge.tsx index 42949e247d1..4ab3e7bda6c 100644 --- a/hooks/mods/bridge.tsx +++ b/hooks/mods/bridge.tsx @@ -651,13 +651,14 @@ function refuseDelivery($: EngineInterface, delivery: DeliveryKey, reason: strin * (`turnId`) it is bound to that turn as a steer is (settleSteers): a turn * that answers before any request read it wakes once. Appended while idle, * it waits for the next turn. Either way it is held until the conversation - * is known to keep it (recheckDeliveries). + * is known to keep it (recheckDeliveries). `bound`: the steer a re-check + * bound before its first await, so its turn's end already counts it. */ -async function appendDelivery($: EngineInterface, delivery: DeliveryKey, text: string, turnId: string | undefined) { - const pending: Steer | undefined = turnId === undefined ? undefined : { turnId, isAppended: false, isRead: false } - if (pending !== undefined) { - state.steers.push(pending) - } +async function appendDelivery($: EngineInterface, delivery: DeliveryKey, text: string, turnId: string | undefined, bound?: Steer) { + const pending = bound ?? (turnId === undefined ? undefined : bindSteer(turnId)) + // A /clear while the append is pending (state.conversation moves): the row + // is the old conversation's, so the new one's check never holds it. + const conversationAt = state.conversation const settled = (isIn: boolean) => { if (pending === undefined) { return @@ -682,7 +683,9 @@ async function appendDelivery($: EngineInterface, delivery: DeliveryKey, text: s refuseDelivery($, delivery, stored.deny) return } - state.deliveredRows.push({ key: delivery, text, ...(turnId !== undefined && { turnId }) }) + if (conversationAt === state.conversation) { + state.deliveredRows.push({ key: delivery, text, ...(turnId !== undefined && { turnId }) }) + } // The hook does not see the plugin's own append: named once it is in. noticeRow({ type: 'row-seen', uuid: stored.uuid, agentId: null, door: 'note' }) emit({ type: 'delivered', key: delivery, outcome: 'entered', uuid: stored.uuid }) @@ -691,6 +694,13 @@ async function appendDelivery($: EngineInterface, delivery: DeliveryKey, text: s settled(true) } +/** A steer bound to its main turn, its append still to come. */ +function bindSteer(turnId: string): Steer { + const pending: Steer = { turnId, isAppended: false, isRead: false } + state.steers.push(pending) + return pending +} + /** * The delivered rows that a main turn which did not answer, or the idle * session, left behind, checked as the next main turn starts against the @@ -700,31 +710,63 @@ async function appendDelivery($: EngineInterface, delivery: DeliveryKey, text: s * reads. One the conversation lacks is appended again, in this turn (read * from its second request, or by the wake when it answers in one); one it * holds is let go. A row of a turn that answered is in the conversation for - * good, so its turn's end lets it go (mainTurnEnded). + * good, so its turn's end lets it go (mainTurnEnded), as a compaction lets + * every row go (bridge-rows.tsx, compacted). + * + * Every row's steer is bound to this turn before the first await: the turn + * may end while the check runs, and its end settles only the steers it has + * (settleSteers), so a replay bound after it would never wake. Each is let + * go as its row proves held, or settled as its append does. A /clear while + * the check runs ends it: nothing is appended to, or kept for, a + * conversation that is gone. */ async function recheckDeliveries($: EngineInterface, turnId: string) { const due = state.deliveredRows.filter((r) => r.turnId !== turnId) if (due.length === 0) { return } + const conversationAt = state.conversation + const steers = due.map(() => bindSteer(turnId)) + const release = (released: readonly Steer[]) => { + state.steers = state.steers.filter((s) => !released.includes(s)) + settleSteers($, turnId) + } + // The old conversation's turn wakes nothing in the new one: its steers go unsettled. + const isGone = () => { + if (conversationAt === state.conversation) { + return false + } + state.steers = state.steers.filter((s) => s.turnId !== turnId) + return true + } let conversation: unknown try { conversation = await $.session.messages({ as: 'api' }) } catch (error) { conversation = message(error) } + if (isGone()) { + return + } // Unread, the rows wait for the next turn's check. if (!Array.isArray(conversation)) { $.ui.log(`bridge: the conversation was not read to check ${due.length} delivered rows: ${JSON.stringify(conversation)}`, { to: 'debug' }) + release(steers) return } const texts = userTexts(conversation) state.deliveredRows = state.deliveredRows.filter((r) => !due.includes(r)) - for (const r of due) { - if (!texts.some((t) => t.includes(r.text))) { - $.ui.log(`bridge: the delivery ${r.key.writer}:${r.key.seq} is not in the conversation this turn reads (an Esc rewound past it): delivered again`, { to: 'debug' }) - await appendDelivery($, r.key, r.text, turnId) + const isHeld = due.map((r) => texts.some((t) => t.includes(r.text))) + release(steers.filter((_, i) => isHeld[i])) + for (const [i, r] of due.entries()) { + if (isHeld[i]) { + continue + } + if (isGone()) { + return } + $.ui.log(`bridge: the delivery ${r.key.writer}:${r.key.seq} is not in the conversation this turn reads (an Esc rewound past it): delivered again`, { to: 'debug' }) + await appendDelivery($, r.key, r.text, turnId, steers[i]) } } diff --git a/tests/mods/bridge.test.ts b/tests/mods/bridge.test.ts index 6c156b0dbc8..3aaae79ae0c 100644 --- a/tests/mods/bridge.test.ts +++ b/tests/mods/bridge.test.ts @@ -160,6 +160,8 @@ function harness( /** What `$.session.messages({ as: "api" })` answers when a test set it; and how many times it was read. */ let apiConversation: unknown[] | undefined let apiReads = 0 + /** While held (`holdApiReads`), `$.session.messages({ as: "api" })` waits. */ + let apiGate: Promise = Promise.resolve() let listedAgents = options.agents ?? [] /** How many times the mod read `$.agent.list()`. */ let agentLists = 0 @@ -176,6 +178,7 @@ function harness( return options.messages ?? [] } apiReads++ + await apiGate return apiConversation ?? conversation.map((m) => ({ role: 'user', content: m.content })) }, usage: async () => ({ startedAt: 0, context: { tokens: 5000, window: 200000, percent: 2 }, rateLimits: [] }), @@ -471,6 +474,12 @@ function harness( setSession: (id: string) => (session = id), setAgents: (agents: Record[]) => (listedAgents = agents), setApiConversation: (messages: unknown[] | undefined) => (apiConversation = messages), + /** Holds the next reads of the conversation as the requests read it until the returned function is called. */ + holdApiReads: () => { + let release: () => void = () => undefined + apiGate = new Promise((resolve) => (release = resolve)) + return release + }, apiReads: () => apiReads, agentLists: () => agentLists, } @@ -1670,6 +1679,84 @@ describe('deliver', () => { expect(h.calls.map((c) => c.op)).toEqual(['submit', 'append']) }) + test("a compaction lets every delivered row go: one read before an Esc, its words now only in the summary, is not given again (the bridge's own compaction, and the engine's)", async () => { + const compactions: [string, (h: ReturnType) => Promise][] = [ + [ + 'compact command', + async (h) => { + await h.command({ op: 'compact' }) + h.compactions[0]!.finish({ messages: [], tokensBefore: 9, tokensAfter: 1 }) + }, + ], + ['session.compact', (h) => h.hook('session.compact', { trigger: 'auto' })(h.engine, { messages: [], trigger: 'auto' }, async () => ({ messages: [] }))], + ] + for (const [way, compactNow] of compactions) { + const h = harness() + await h.ready() + await h.turn('t1') + await h.command({ op: 'deliver', key: { writer: 'mac/s1', seq: 9 }, text: 'read, then compacted' }) + await h.step({ turnId: 't1', index: 1, model: 'm', messageCount: 3 }, []) + await h.complete('t1', 'aborted') + await compactNow(h) + await h.settle() + h.setApiConversation([{ role: 'user', content: [{ type: 'text', text: 'This session is being continued from a previous conversation…' }] }]) + await h.turn('t2') + await h.settle() + expect([way, h.calls.filter((c) => c.op === 'append').length, h.apiReads()]).toEqual([way, 1, 0]) + } + }) + + test("rows given again as a turn starts are each bound to it before the check's first await: a one-request turn that ends while the first replay is pending still wakes once for the second", async () => { + const options: NonNullable[0]> = { holdAppend: true } + const h = harness(options) + await h.ready() + await h.turn('t1') + await h.command({ op: 'deliver', key: { writer: 'mac/s1', seq: 1 }, text: 'first words' }) + await h.command({ op: 'deliver', key: { writer: 'mac/s1', seq: 2 }, text: 'second words' }) + for (const release of h.appends.splice(0)) { + release() + } + await h.settle() + await h.complete('t1', 'aborted') + h.setApiConversation([]) + // The first replay is refused, so only the second can owe the wake. + options.denyAppend = 'first words' + await h.turn('t2') + await h.settle() + await h.step({ turnId: 't2', index: 0, model: 'm', messageCount: 2 }, []) + await h.complete('t2') + const wakes = () => h.calls.filter((c) => c.op === 'submit').map((c) => (c.args as { text: string }).text) + h.appends.shift()!() + await h.settle() + expect(wakes()).toEqual([]) + h.appends.shift()!() + await h.settle() + expect(h.calls.filter((c) => c.op === 'append').length).toBe(4) + expect(wakes()).toEqual([STEER_WAKE]) + }) + + test('a /clear while the re-check reads the conversation: nothing of the old conversation is given again, nor kept to be checked in the new one', async () => { + const h = harness() + await h.ready() + await h.turn('t1') + await h.command({ op: 'deliver', key: { writer: 'mac/s1', seq: 1 }, text: 'old words' }) + await h.complete('t1', 'aborted') + h.setApiConversation([]) + const release = h.holdApiReads() + await h.turn('t2') + await h.settle() + expect(h.apiReads()).toBe(1) + await h.complete('t2', 'aborted') + await h.hook('session.end', {})(h.engine, { reason: 'clear', sessionId: SID }, async () => ({})) + release() + await h.settle() + expect(h.calls.filter((c) => c.op === 'append').length).toBe(1) + await h.turn('t3') + await h.settle() + expect(h.apiReads()).toBe(1) + expect(h.calls.filter((c) => c.op === 'submit')).toEqual([]) + }) + test('a deliver with no key or no text is refused', async () => { const h = harness() await h.ready() From 3bc99893aba2137f13126a5a69d0d924fe05f626 Mon Sep 17 00:00:00 2001 From: SSFSKIM Date: Sat, 17 Oct 2026 22:15:45 -0700 Subject: [PATCH 3/6] sminos bridge: a delivery wakes a session the human stopped After an Esc the engine refuses every plugin prompt until the human sends input (k_e), so an idle delivery used to go in as a row that started no turn. A message addressed to a session is meant to wake it, and k_e does not guard the session's messaging socket, so: - an idle delivery whose prompt the engine refuses for a stop (decided at submit; the submit is raced against STOP_DROP_WAIT_MS) is answered not-ok, its mark put back and nothing emitted; sminos_control.deliver returns False and push_live sends the inbox frame, which starts a turn (one-line warning naming the stop). A stop drop after the answer goes to the child as an `inbox` event carrying the text and key. - a delivered row a stopped turn left unread emits one `inbox` wake per turn; a turn that answered with one unread, whose STEER_WAKE is stop-dropped, wakes the same way. Steers are unchanged. - the child sends `inbox` events as frames to the session's own socket from its export thread; a keyed one no socket took is kept as a refusal. Live (2.1.302, haiku): an Esc'd session delivered to through `sminos send` starts a turn and reads the message; a row delivered mid-Bash and Esc'd before any request read it wakes the session, which reads it. --- .../plans/2026-10-15-arkho-desk-c2-bridge.md | 5 +- hooks/mods/bridge-state.tsx | 10 +- hooks/mods/bridge.tsx | 163 +++++++++++++----- skills/sminos/scripts/sminos_bridge.py | 68 +++++++- skills/sminos/scripts/sminos_control.py | 13 +- tests/mods/bridge.test.ts | 92 ++++++++-- tests/mods/live-delivery-cancel-probe.py | 66 ++++++- tests/sminos/test-bridge-control.sh | 36 +++- 8 files changed, 380 insertions(+), 73 deletions(-) diff --git a/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md b/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md index 957e385eef0..be2fea1d041 100644 --- a/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md +++ b/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md @@ -466,7 +466,7 @@ M1 left them (with fw1/fw2). Changed rows are marked **[M2]**. | `interrupt` | M2 **[M2]** **[fw1]** **[record]** | `{from?}` | `{turnId, stopped: [], running: []}`: `$.turn.abort` on the main turn, then `TaskStop` (`$.tool.call`) on each Bash call **attributable to that turn** and running at the abort whose result names the background task the abort moved it to (`running in background with ID: `; their results awaited at most 3 s). **[fw1]** Attributable: a main-loop call made during that turn, or a call of a foreground subagent spawned in it (nested foreground subagents inherit their parent's turn); a background subagent's calls never are. **[fw1]** `running` names each task whose TaskStop failed (an `isError` result or a rejection): the interrupt is incomplete when it is not empty. Idle: `{turnId: null, stopped: [], running: []}`. **[record]** A plugin's abort leaves the model no marker (Esc leaves `[Request interrupted by user]`), and a Bash call it moved to the background reads as still running there: so once the stops settle the interrupt appends two user rows. **[marker]** The first is Esc's marker whole, `[Request interrupted by user]`, one text block: a transcript's reader knows a turn's interrupt by a row whose whole text is the marker and by nothing else (the engine's own readers, 2.1.300 `He`/`pQn`, and MAWS's `INTERRUPT_MARKERS`), so the earlier one-row shape, `[Request interrupted by user from the desk]` with the task lines after it, read as no interrupt. The second is the note: `The interrupt came from the desk.` (`from: "desk"`, which the daemon's RPC sends) or `The interrupt came from outside the terminal.`, then a line per task: stopped, `Task , the Bash command the interrupt moved to the background, was then stopped: it is not running, and no notification will come for it.`; in `running`, `… could not be stopped: it may still be running.` The engine keeps a plugin's user row `isMeta: true` with `origin: {kind: "plugin", name}` whatever the plugin asks (2.1.300 `m6r`; it refuses any other user row): the model reads both rows, and no reader takes the note for a prompt. A marker a hook refuses is followed by no note. Both must be in before the turn ends: a next turn already queued takes its messages before it waits for any hook (2.1.300 `ir`/`cn`), so an append in `turn.complete` would miss its first request. The engine waits for a Bash call's result after a turn abort, so each Bash call of the turn being interrupted is held in the `tool.call` hook, after its result, until both rows are in (at most 10 s, `INTERRUPT_HOLD_MS`): they are kept before the moved call's result row, and the next request carries them after that result, the marker then the note (live, 2.1.302). An interrupt with no Bash call running appends at once after the abort, racing only the turn's own unwinding. A second interrupt of a turn being interrupted leaves the first's record alone (the engine refuses its abort). Each row is named by a `row-seen` notice (door `note`). The transcript mirrors the rows on one line as `interrupt: [Request interrupted by user] ` (a newline there draws as U+FFFD). Nothing for an idle interrupt or a refused abort | | `model` | M2 **[M2]** | `{model, effort?}` — `model` an id, a family alias, `"default"` or null; `effort` one of `low medium high xhigh max`, or null | `{model, effort}` now in force (null: the session's own). Rewritten on every **main-loop** `turn.step` request, subagents untouched; never `$.config.set`. A family alias (`haiku`, `sonnet`, `opus`, `fable`) is resolved through the session's `ANTHROPIC_DEFAULT__MODEL`; one it does not map is refused, naming the id to use. Emits `model`; kept by the child | | `compact` | M2 **[M2]** | `{instructions?}` | During the main turn `{queued: true, turnId}`: held, and run inside that turn's `turn.complete` (a second while one is held keeps the first). Idle `{started: true}`: run at once. Either way the outcome follows on the stream as `compact` events carrying this command's `id` | -| `deliver` | M2 **[M2]** **[fw1]** **[#229]** **[midturn]** | `{key: {project, writer, seq}, text}` | `{submitted: true}` or `{duplicate: true}`. **[midturn]** While the main turn runs, `text` goes in as a steer's does: `$.session.append` of a user row (one text block), read at the main loop's next request — the next tool round, sminos's contract for a busy recipient — bound to the turn before the append is awaited, mirrored as the transcript line `delivered: ` (newlines as spaces), and sharing the steer's wake (**[fw2]**: one wake per turn that answered with a row of it unread; none for `aborted`/`error`/`refusal`). Idle, it is submitted with `$.prompt.submit({text, asUser: true})`, a turn of its own (not awaited). A plugin prompt queued behind a turn read only at that turn's end (median 39 s, max 253 s observed), and an Esc empties the queue of plugin prompts (2.1.300 `zBt`). A prompt the engine drops because the human stopped the session — `the prompt was removed from the queue before it ran` (Esc, or the stop-agents chord, emptying the queue) or `the session was stopped and nobody has sent input since` (the engine refuses every plugin prompt after an Esc until the human sends input, 2.1.300 `k_e`) — goes in as a row instead, which starts no turn: the model reads it with the human's next prompt. Any other drop or rejection is `refused`. An Esc before the model answered hands the human's prompt back to the input box, and the prompt sent next goes in where that one stood (the same parent), so a row given after it is on a branch no request reads: each delivered row of a main turn that did not answer, or appended idle, is checked when the next main turn starts against `$.session.messages({as: "api"})` (any user text block containing the text), and one the conversation lacks is appended again in that turn (`entered` again, the new row), read from the turn's second request or by the wake when the turn answers in one; one it holds is let go, as are a turn's rows once it answers, and every row when the main conversation is compacted (the bridge's `compact`, the compact tool's, the person's or the engine's): the conversation then holds them only in its summary, so each would read as missing and be given twice; a row a rewind left on a dead branch before the compaction is lost. A /clear lets them go too, a check still running at it included: nothing is appended to the conversation that ended. An unreadable conversation leaves the rows for the next turn's check. Live: `tests/mods/live-delivery-cancel-probe.py`. **The delivery key** is the log event's `(writer, seq)` — for a push carrying several events of one writer, the last. A key whose seq is at or below the highest already submitted for its writer is a duplicate and dropped (delivery goes per writer in seq order). The per-writer seqs are kept by the child across reloads; a /clear starts them anew (a new id is a new recipient). **[fw1]** The `delivered` event is emitted before the submission is made, so it is the next thing posted. Deduplication across a module reload is **best effort when the reload lands mid-submission**: a key submitted whose `delivered` had not reached the child is not kept, and a redelivery of it may submit again (a residual of at-least-once delivery, §3). **[#229]** The key names the log's project (its id): every project numbers a writer's events on its own, and a writer id is per session, not per project, so one writer delivering to one session from two projects reaches seqs the other project already passed — keyed by `(writer, seq)` alone, those were dropped as duplicates and settled, lost with no trace. Deduplication is per writer **within a project**, against the mark `/`; a key naming no project (a CLI older than this) is held to the bare `` mark, which is all such a CLI raised, so no kept mark is migrated, and a session whose bare mark is past a project's own count takes that project's scoped keys from the first. The CLI takes a `duplicate` as delivered (with scoped keys it was submitted before) but says it, to the sender and in `daemon.log`, naming the event and the session. A refusal (`refused`, below) keeps the key whole, project included, and the daemon reports only those naming its own project, or none | +| `deliver` | M2 **[M2]** **[fw1]** **[#229]** **[midturn]** **[wake]** | `{key: {project, writer, seq}, text}` | `{submitted: true}` or `{duplicate: true}`; **[wake]** not-ok, `error` beginning `the session was stopped, so its prompt was refused`, when the engine refused the idle prompt for a stop. **[midturn]** While the main turn runs, `text` goes in as a steer's does: `$.session.append` of a user row (one text block), read at the main loop's next request — the next tool round, sminos's contract for a busy recipient — bound to the turn before the append is awaited, mirrored as the transcript line `delivered: ` (newlines as spaces), and sharing the steer's wake (**[fw2]**: one wake per turn that answered with a row of it unread; none for `error`/`refusal`). Idle, it is submitted with `$.prompt.submit({text, asUser: true})`, a turn of its own. A plugin prompt queued behind a turn read only at that turn's end (median 39 s, max 253 s observed), and an Esc empties the queue of plugin prompts (2.1.300 `zBt`). **[wake]** A message addressed to a session is meant to wake it (sminos's contract: an idle member starts a turn), while the engine refuses a plugin's prompt after an Esc until the human sends input (2.1.300 `k_e`, flag `tengu_plugin_input_refusal`), a refusal aimed at plugins acting on their own; `k_e` guards only `$.prompt.submit` and `$.command.run`, not the session's messaging socket. So the idle submit is awaited for up to 200 ms (`STOP_DROP_WAIT_MS`; the refusal is answered at once, a prompt taken resolves only when its turn starts): a drop for a stop — `the session was stopped and nobody has sent input since`, or `the prompt was removed from the queue before it ran` (Esc, or the stop-agents chord, emptying the queue) — is answered not-ok with the mark put back to its value before this delivery (unless a later delivery moved it) and nothing emitted, neither `submitted` nor `refused` (the child keeps refusals, which the daemon reports `failed`); `sminos_control.deliver` then returns False and `push_live` goes on to the supervisor's `reply` (a human's event to a `--bg` job) or the inbox frame, which starts a turn, warning in one line that the session was stopped. Otherwise `submitted` is emitted and answered once the wait passes. A drop for a stop that comes after the answer (an Esc emptied the queue the prompt waited in) is sent to the child as an `inbox` event carrying the text and the key, and the child sends the same frame. A delivered row the main turn was stopped (`aborted`) before any request read it wakes the session the same way: one `inbox` event per such turn, carrying a fixed wake text (`DELIVERY_WAKE`), which the child sends as a frame; a turn that answered with a delivered row unread, whose wake prompt the engine drops for a stop, sends `STEER_WAKE` that way too. Steers keep their own wake (none for `aborted`). Any other drop or rejection is `refused`. An Esc before the model answered hands the human's prompt back to the input box, and the prompt sent next goes in where that one stood (the same parent), so a row given after it is on a branch no request reads: each delivered row of a main turn that did not answer is checked when the next main turn starts against `$.session.messages({as: "api"})` (any user text block containing the text), and one the conversation lacks is appended again in that turn (`entered` again, the new row), read from the turn's second request or by the wake when the turn answers in one; one it holds is let go, as are a turn's rows once it answers, and every row when the main conversation is compacted (the bridge's `compact`, the compact tool's, the person's or the engine's): the conversation then holds them only in its summary, so each would read as missing and be given twice; a row a rewind left on a dead branch before the compaction is lost. A /clear lets them go too, a check still running at it included: nothing is appended to the conversation that ended. An unreadable conversation leaves the rows for the next turn's check. Live: `tests/mods/live-delivery-cancel-probe.py`. **The delivery key** is the log event's `(writer, seq)` — for a push carrying several events of one writer, the last. A key whose seq is at or below the highest already submitted for its writer is a duplicate and dropped (delivery goes per writer in seq order). The per-writer seqs are kept by the child across reloads; a /clear starts them anew (a new id is a new recipient). **[fw1]** The `delivered` event is emitted before the row is appended, so it is the next thing posted; **[wake]** for an idle submission it is emitted once the wait for a stop's refusal has passed. Deduplication across a module reload is **best effort when the reload lands mid-submission**: a key submitted whose `delivered` had not reached the child is not kept, and a redelivery of it may submit again (a residual of at-least-once delivery, §3). **[#229]** The key names the log's project (its id): every project numbers a writer's events on its own, and a writer id is per session, not per project, so one writer delivering to one session from two projects reaches seqs the other project already passed — keyed by `(writer, seq)` alone, those were dropped as duplicates and settled, lost with no trace. Deduplication is per writer **within a project**, against the mark `/`; a key naming no project (a CLI older than this) is held to the bare `` mark, which is all such a CLI raised, so no kept mark is migrated, and a session whose bare mark is past a project's own count takes that project's scoped keys from the first. The CLI takes a `duplicate` as delivered (with scoped keys it was submitted before) but says it, to the sender and in `daemon.log`, naming the event and the session. A refusal (`refused`, below) keeps the key whole, project included, and the daemon reports only those naming its own project, or none | ##### Events (the mod's posts; the stream's lines) @@ -477,7 +477,8 @@ M1 left them (with fw1/fw2). Changed rows are marked **[M2]**. | `model` | M2 **[M2]** | `model, effort` — the choice now in force (null: the session's own) | | `compact` / `start` | M2 **[M2]** | `id` — the `compact` command's id | | `compact` / `end` | M2 **[M2]** | `id`, and one of: `tokensBefore, tokensAfter` (null when the engine recorded none); `skip` (a hook vetoed it); `error` (the engine refused it, as during a turn) | -| `delivered` | M2 **[M2]** (was reserved), **[#229]**, **[midturn]** | `key: {project?, writer, seq}` (the command's key, whole), `outcome: "submitted" \| "duplicate" \| "refused" \| "entered"`, `reason?`, `uuid?` — `refused` follows a `submitted` when the engine later dropped or rejected the prompt, or refused the row (**[midturn]**); `entered` names the row the delivery became once it is kept (DC8 G6), and is named again with the new row when an Esc's rewind left the first on a dead branch (**[midturn]**) | +| `delivered` | M2 **[M2]** (was reserved), **[#229]**, **[midturn]**, **[wake]** | `key: {project?, writer, seq}` (the command's key, whole), `outcome: "submitted" \| "duplicate" \| "refused" \| "entered"`, `reason?`, `uuid?` — `refused` follows a `submitted` when the engine later dropped or rejected the prompt for a reason other than a stop, or refused the row (**[midturn]**); `entered` names the row the delivery became once it is kept (DC8 G6), and is named again with the new row when an Esc's rewind left the first on a dead branch (**[midturn]**). **[wake]** None at all for an idle delivery the engine refused for a stop: the command's not-ok answer says it | +| `inbox` | **[wake]** | `text`, `key?` — a frame for the session's own inbox socket (its peer record's `messagingSocketPath`), which starts a turn where the engine refuses the mod's prompt (after an Esc): the wake for a delivered row a stopped turn left unread, or a delivery (`key`) whose prompt a stop dropped after `submitted`. **Consumed by the child, never handed to subscribers**: sent from the export thread with `sminos.send_frame`; nothing when no socket of the session answers, except that a `key` is then kept as a refusal, which the daemon reports `failed` | | `kept` | M2 **[fw1]** **[fw2]** (the child's own) | `ok: false, error` when writing `.json` starts to fail (said once, however many batches it keeps failing; retried at the end of every batch); `ok: true` when a write works again after a failure | ##### Subscription (`GET /events`) diff --git a/hooks/mods/bridge-state.tsx b/hooks/mods/bridge-state.tsx index fac5129103e..9b47d93498c 100644 --- a/hooks/mods/bridge-state.tsx +++ b/hooks/mods/bridge-state.tsx @@ -36,9 +36,10 @@ export type Call = { * A steer (or a delivery given into the turn), bound to the main turn it was * given in before its row is appended. * `isAppended`: the row is in; `isRead`: a main-loop request began after it; - * `ended`: how its turn ended, while some append of that turn is still pending. + * `ended`: how its turn ended, while some append of that turn is still pending; + * `isDelivery`: a delivery's row, which an interrupted turn does not leave unread. */ -export type Steer = { turnId: string; isAppended: boolean; isRead: boolean; ended?: string } +export type Steer = { turnId: string; isAppended: boolean; isRead: boolean; ended?: string; isDelivery?: boolean } /** What an interrupt did to the Bash calls it moved to the background: the tasks it stopped, and those it could not. */ export type Stops = { stopped: string[]; running: string[] } /** An interrupt of a main turn: `recorded` settles once its record is in (or it failed); the turn's Bash calls wait for it. */ @@ -103,10 +104,9 @@ export type DeliveryKey = { project?: string; writer: string; seq: number } export type Submission = { key: DeliveryKey; text: string } /** * A delivery the bridge put in the conversation as a row, until the - * conversation is known to keep it: `turnId`, the main turn it was appended - * in; absent, appended while the session was idle. + * conversation is known to keep it: `turnId`, the main turn it was appended in. */ -export type DeliveredRow = { key: DeliveryKey; text: string; turnId?: string } +export type DeliveredRow = { key: DeliveryKey; text: string; turnId: string } /** A delivery the engine dropped after it was submitted, as `delivered {outcome: "refused"}` reports it. */ export type Refusal = { key: DeliveryKey; reason: string } diff --git a/hooks/mods/bridge.tsx b/hooks/mods/bridge.tsx index 4ab3e7bda6c..352cf8cab16 100644 --- a/hooks/mods/bridge.tsx +++ b/hooks/mods/bridge.tsx @@ -50,10 +50,13 @@ export { AGENTS_REFRESH_MS } from './bridge-agents' * `model` rewrites the main loop's requests; * `compact` runs between turns; `deliver` gives a log event as the human's * words, once per key: into the running turn as a row, as a steer, else as a - * prompt of its own (a row when the human stopped the session), and a row an - * Esc rewound past is given again. What must outlive a module reload (the - * model chosen, the keys delivered) the child keeps on disk: its hello hands - * it back. + * prompt of its own. A message to the session wakes it, though the engine + * refuses a plugin's prompt after an Esc: such a delivery is answered not-ok, + * so sminos sends it as an inbox frame, and a delivered row a stopped turn + * left unread wakes the session through its inbox (the child's `inbox` + * event). A row an Esc rewound past is given again. What must outlive a + * module reload (the model chosen, the keys delivered) the child keeps on + * disk: its hello hands it back. * * Code that takes `$` stays in this file: the hooks, the session start, the * child and the session it serves, control, and the wait for a decision. The @@ -86,6 +89,18 @@ const DRAIN_TRIES = 10 * it rather than repeating it. */ export const STEER_WAKE = 'I added a note to the conversation while you were finishing your answer. Read it and act on it.' +/** + * The wake for a delivered row a stopped turn left unread, sent as an inbox + * frame (the engine refuses a plugin's prompt after an Esc, not a message to + * the session); its first line names who sends it, as an inbox frame's does. + */ +export const DELIVERY_WAKE = '[sminos bridge]\nA message for you was added to the conversation while your turn was stopped, before you read it. Read it and act on it.' +/** + * How long an idle delivery waits on its prompt for the engine's refusal + * after a stop, which `$.prompt.submit` answers at once (2.1.300 `k_e`); a + * prompt it takes resolves only when its turn starts. + */ +export const STOP_DROP_WAIT_MS = 200 /** Deliveries whose prompt row is awaited, at most (the oldest is let go). */ const SUBMISSIONS_KEPT = 100 /** After an abort, how long the Bash calls it ended have to report where they went. */ @@ -272,20 +287,36 @@ function settleSteers($: EngineInterface, turnId: string) { return } state.steers = state.steers.filter((s) => s.turnId !== turnId) - if (ended === 'answer' && mine.some((s) => !s.isRead)) { - wakeForSteer($) + const unread = mine.filter((s) => !s.isRead) + const isDeliveryUnread = unread.some((s) => s.isDelivery === true) + if (ended === 'answer' && unread.length > 0) { + wakeForSteer($, isDeliveryUnread) + } else if (ended === 'aborted' && isDeliveryUnread) { + // A stopped turn: the engine would refuse the prompt, not the inbox frame. + emit({ type: 'inbox', text: DELIVERY_WAKE }) } } /** * Wakes the session for a steer its turn answered before reading: the row is * already in the conversation, so the prompt points to it rather than - * repeating it, and the next request reads both. + * repeating it, and the next request reads both. When a delivery's row is + * among them and the engine refuses the prompt because the human stopped the + * session, the wake goes as an inbox frame instead. */ -function wakeForSteer($: EngineInterface) { - void $.prompt - .submit({ text: STEER_WAKE, asUser: true }) - .catch((error: unknown) => $.ui.log(`bridge: the wake for an unread steer was not submitted: ${message(error)}`, { to: 'debug' })) +function wakeForSteer($: EngineInterface, isDelivery: boolean) { + void $.prompt.submit({ text: STEER_WAKE, asUser: true }).then( + (entered) => { + if (entered.drop === undefined) { + return + } + $.ui.log(`bridge: the wake for an unread steer was dropped: ${entered.drop}`, { to: 'debug' }) + if (isDelivery && STOPPED_DROPS.has(entered.drop)) { + emit({ type: 'inbox', text: `[sminos bridge]\n${STEER_WAKE}` }) + } + }, + (error: unknown) => $.ui.log(`bridge: the wake for an unread steer was not submitted: ${message(error)}`, { to: 'debug' }), + ) } /** @@ -568,15 +599,23 @@ async function runCompaction($: EngineInterface, request: { id: string; instruct * other drop is a hook's or the engine's own decision. */ const STOPPED_DROPS = new Set(['the prompt was removed from the queue before it ran', 'the session was stopped and nobody has sent input since']) +/** How a delivery's not-ok answer begins when the engine refused its prompt for a stop (sminos_control.deliver reads it). */ +export const STOPPED_ERROR = 'the session was stopped, so its prompt was refused' /** * A log event for this session, as the human's words. While the main turn * runs it goes in as a user row, read at the loop's next request (its next * tool round), as a steer does; idle, it is submitted as a prompt, a turn of - * its own. A prompt the engine drops because the human stopped the session - * goes in as a row instead, which starts no turn: the model reads it with the - * human's next prompt. Either way `entered` names its row once it is kept, - * and `refused` follows `submitted` when nothing could be. + * its own. Either way `entered` names its row once it is kept, and `refused` + * follows `submitted` when nothing could be. + * + * A message to the session is meant to wake it, and after an Esc the engine + * refuses a plugin's prompt until the human sends input, at once. Such a + * delivery is answered not-ok, its mark put back and nothing emitted, so + * sminos sends it the way it did before the bridge, as an inbox frame, which + * starts a turn. One the engine drops for a stop after the answer (an Esc + * emptied the queue it waited in) goes to the child as an `inbox` event, + * which sends the same frame. * * Delivery is at least once and goes in seq order per writer within a * project, so a key at or below its mark (the last submitted for that writer @@ -585,7 +624,7 @@ const STOPPED_DROPS = new Set(['the prompt was removed from the queue before it * project (a CLI older than the project in the key) is held to the writer's * bare mark, which is all such a CLI ever raised. */ -function deliver($: EngineInterface, key: unknown, text: unknown): Answer { +async function deliver($: EngineInterface, key: unknown, text: unknown): Promise { const { project, writer, seq } = (typeof key === 'object' && key !== null ? key : {}) as { project?: unknown; writer?: unknown; seq?: unknown } if ( typeof writer !== 'string' || @@ -598,13 +637,14 @@ function deliver($: EngineInterface, key: unknown, text: unknown): Answer { } const delivery: DeliveryKey = { ...(project !== undefined && { project: project as string }), writer, seq: seq as number } const mark = project === undefined ? writer : `${project}/${writer}` - if (delivery.seq <= (state.delivered.get(mark) ?? 0)) { + const before = state.delivered.get(mark) + if (delivery.seq <= (before ?? 0)) { emit({ type: 'delivered', key: delivery, outcome: 'duplicate' }) return { ok: true, result: { duplicate: true } } } state.delivered.set(mark, delivery.seq) - emit({ type: 'delivered', key: delivery, outcome: 'submitted' }) if (state.mainTurn !== undefined) { + emit({ type: 'delivered', key: delivery, outcome: 'submitted' }) void appendDelivery($, delivery, text, state.mainTurn) return { ok: true, result: { submitted: true } } } @@ -615,26 +655,64 @@ function deliver($: EngineInterface, key: unknown, text: unknown): Answer { state.submissions.shift() } const forget = () => (state.submissions = state.submissions.filter((s) => s !== submission)) - // Not awaited: it resolves when the prompt's turn starts, after any running turn. + // Decided once: a stop's refusal, or (undefined) taken, `submitted` said + // then, so a `refused` after it follows it. True when this call decided. + let decide: (drop: string | undefined) => boolean = () => false + const decided = new Promise((resolve) => { + let isDecided = false + decide = (drop) => { + if (isDecided) { + return false + } + isDecided = true + if (drop === undefined) { + emit({ type: 'delivered', key: delivery, outcome: 'submitted' }) + } + resolve(drop) + return true + } + }) + const timer = $.clock.after(STOP_DROP_WAIT_MS, () => decide(undefined)) + // A prompt taken resolves when its turn starts, after any running turn. void $.prompt.submit({ text, asUser: true }).then( (entered) => { if (entered.drop === undefined) { + decide(undefined) return } forget() - if (STOPPED_DROPS.has(entered.drop)) { - $.ui.log(`bridge: the delivery ${mark}:${delivery.seq} was not taken as a prompt (${entered.drop}): it goes in as a row`, { to: 'debug' }) - void appendDelivery($, delivery, text, undefined) - } else { + if (!STOPPED_DROPS.has(entered.drop)) { + decide(undefined) refuseDelivery($, delivery, entered.drop) + return } + if (decide(entered.drop)) { + return + } + $.ui.log(`bridge: the delivery ${mark}:${delivery.seq} was dropped after it was taken (${entered.drop}): it goes as an inbox frame`, { to: 'debug' }) + emit({ type: 'inbox', text, key: delivery }) }, (error: unknown) => { + decide(undefined) forget() refuseDelivery($, delivery, message(error)) }, ) - return { ok: true, result: { submitted: true } } + const stopped = await decided + timer.cancel() + if (stopped === undefined) { + return { ok: true, result: { submitted: true } } + } + // Nothing entered: the key is free for the inbox frame, or a later try. + if (state.delivered.get(mark) === delivery.seq) { + if (before === undefined) { + state.delivered.delete(mark) + } else { + state.delivered.set(mark, before) + } + } + $.ui.log(`bridge: the delivery ${mark}:${delivery.seq} was refused as a prompt (${stopped}): sminos sends it as an inbox frame`, { to: 'debug' }) + return { ok: false, error: `${STOPPED_ERROR}: ${stopped}` } } /** A delivery that could not enter: `refused`, after its `submitted`, with the engine's reason. */ @@ -647,22 +725,18 @@ function refuseDelivery($: EngineInterface, delivery: DeliveryKey, reason: strin /** * A delivery put in the conversation as a user row, read at the loop's next * request, and named `entered` once it is in. The terminal does not draw an - * appended row, so a transcript line mirrors it. In a running main turn - * (`turnId`) it is bound to that turn as a steer is (settleSteers): a turn - * that answers before any request read it wakes once. Appended while idle, - * it waits for the next turn. Either way it is held until the conversation - * is known to keep it (recheckDeliveries). `bound`: the steer a re-check - * bound before its first await, so its turn's end already counts it. + * appended row, so a transcript line mirrors it. It is bound to the running + * main turn (`turnId`) as a steer is (settleSteers): a turn that ends before + * any request read it wakes the session once. It is held until the + * conversation is known to keep it (recheckDeliveries). `bound`: the steer a + * re-check bound before its first await, so its turn's end already counts it. */ -async function appendDelivery($: EngineInterface, delivery: DeliveryKey, text: string, turnId: string | undefined, bound?: Steer) { - const pending = bound ?? (turnId === undefined ? undefined : bindSteer(turnId)) +async function appendDelivery($: EngineInterface, delivery: DeliveryKey, text: string, turnId: string, bound?: Steer) { + const pending = bound ?? bindSteer(turnId) // A /clear while the append is pending (state.conversation moves): the row // is the old conversation's, so the new one's check never holds it. const conversationAt = state.conversation const settled = (isIn: boolean) => { - if (pending === undefined) { - return - } if (isIn) { pending.isAppended = true } else { @@ -684,7 +758,7 @@ async function appendDelivery($: EngineInterface, delivery: DeliveryKey, text: s return } if (conversationAt === state.conversation) { - state.deliveredRows.push({ key: delivery, text, ...(turnId !== undefined && { turnId }) }) + state.deliveredRows.push({ key: delivery, text, turnId }) } // The hook does not see the plugin's own append: named once it is in. noticeRow({ type: 'row-seen', uuid: stored.uuid, agentId: null, door: 'note' }) @@ -694,17 +768,17 @@ async function appendDelivery($: EngineInterface, delivery: DeliveryKey, text: s settled(true) } -/** A steer bound to its main turn, its append still to come. */ +/** A delivery's row bound to its main turn as a steer is, its append still to come. */ function bindSteer(turnId: string): Steer { - const pending: Steer = { turnId, isAppended: false, isRead: false } + const pending: Steer = { turnId, isAppended: false, isRead: false, isDelivery: true } state.steers.push(pending) return pending } /** - * The delivered rows that a main turn which did not answer, or the idle - * session, left behind, checked as the next main turn starts against the - * conversation its requests read. An Esc before the model answered hands the + * The delivered rows that a main turn which did not answer left behind, + * checked as the next main turn starts against the conversation its requests + * read. An Esc before the model answered hands the * human's prompt back to the input box, and the prompt sent next goes in * where that one stood: a row given after it is left on a branch no request * reads. One the conversation lacks is appended again, in this turn (read @@ -829,9 +903,10 @@ async function awaitDecision($: EngineInterface, r: Request, signal: AbortSignal * and delivered rows settled (settleSteers): when it answered before any * request read one, one wake (the row is in the conversation already), as a * prompt typed mid-turn would start the next turn — at once, or once the - * last of its pending appends settles. An interrupted or failed turn wakes - * nothing; its delivered rows are checked when the next turn starts - * (recheckDeliveries). + * last of its pending appends settles. An interrupted turn that left a + * delivered row unread wakes the session through its inbox; a steer it left + * wakes nothing, nor does a failed turn. Its delivered rows are checked when + * the next turn starts (recheckDeliveries). */ async function mainTurnEnded($: EngineInterface, turnId: string, reason: string) { state.interrupts.delete(turnId) diff --git a/skills/sminos/scripts/sminos_bridge.py b/skills/sminos/scripts/sminos_bridge.py index 3141cd5c79d..8b65735d6c0 100644 --- a/skills/sminos/scripts/sminos_bridge.py +++ b/skills/sminos/scripts/sminos_bridge.py @@ -42,6 +42,13 @@ (`refused`, the last REFUSED_KEPT), which the daemon's delivery pass reports as a `failed` outcome (sminos_outcomes.report_refused). +The mod's `inbox` event asks the child for a frame on the session's own +inbox socket (the engine's messaging socket): after an Esc the engine refuses +the mod's prompts until the human sends input, not a message to the session, +so this is how a delivered row a stopped turn left unread wakes it, and how a +delivery whose prompt a stop dropped reaches it. One carrying a delivery's +key that no frame carried is kept as a refusal, as above. + Requests from outside (DC2 M3): the mod raises a permission prompt or a question as `raised` and settles it as `settled`; the child keeps the pending set and the recent settlements for the snapshot, takes a client's decision @@ -551,6 +558,9 @@ def ingest(self, events): if e.get("type") == "export": self._export(e) continue + if e.get("type") == "inbox": + self._inbox(e) + continue self._apply(e) if e.get("type") == "resync": # The first makes the child ready; any later one replaced @@ -741,11 +751,50 @@ def _export(self, e): if not (isinstance(records, list) and all(isinstance(r, dict) for r in records) and isinstance(cwd, str) and cwd): return try: - self.exports.put_nowait((cwd, records)) + self.exports.put_nowait((self._export_now, cwd, records)) except queue.Full: sys.stderr.write("sminos bridge: an export was dropped (%d waiting); the Stop hook's export has it\n" % EXPORTS_QUEUED) + def _inbox(self, e): + """A frame the mod asks for on the session's own inbox socket (the + engine's messaging socket), which starts a turn where the engine + refuses the mod's prompt (after an Esc): the wake for a delivered row + a stopped turn left unread, or a delivery whose prompt a stop dropped + after the mod answered (`key`). Sent from the export thread, the one + that reads sminos's caches; never handed to subscribers.""" + text, key = e.get("text"), e.get("key") + if not (isinstance(text, str) and text.strip()): + return + try: + self.exports.put_nowait((self._send_inbox, text, key if isinstance(key, dict) else None)) + except queue.Full: + sys.stderr.write("sminos bridge: an inbox frame was dropped (%d waiting)\n" % EXPORTS_QUEUED) + + def _send_inbox(self, text, key): + S.forget_caches() # the registry as it is now: this child lives as long as its session + peer = S.peer_for_session(self.session) + if peer is None: + reason = "no inbox socket of the session answers" + else: + try: + S.send_frame(S.socket_path_of(peer), text) + return + except S.SendFailed as err: + if err.phase == "after": + sys.stderr.write("sminos bridge: an inbox frame may not have arrived: %s\n" % err) + return + reason = "the inbox frame did not go: %s" % err + sys.stderr.write("sminos bridge: %s\n" % reason) + if key is None: + return + # A delivery nothing carried: kept for the daemon to report `failed`. + with self.lock: + self._keep(_refused(self.kept, key, reason)) + note = self._save() + if note is not None: + self._fan_out(note) + def run_exports(self): """Appends what the mod exports through the record path's own writer (sminos.export_live), one export at a time and in order, apart from the @@ -753,16 +802,23 @@ def run_exports(self): daemon's sync), and a slow append must not hold up the session's events. A failure is the Stop hook's to make good: its export appends whatever of the same rows the log does not hold. This thread is the only one in - the child that reads sminos's caches, which each export clears.""" + the child that reads sminos's caches, which each export clears; so it + also sends the inbox frames the mod asks for (_inbox).""" while not self.closing.is_set(): try: - cwd, records = self.exports.get(timeout=0.5) + work, *args = self.exports.get(timeout=0.5) except queue.Empty: continue try: - S.export_live(cwd, self.session, records) - except (Exception, SystemExit) as err: # noqa: BLE001 — the next export must still run - sys.stderr.write("sminos bridge: export to the log failed: %s: %s\n" % (type(err).__name__, err)) + work(*args) + except (Exception, SystemExit) as err: # noqa: BLE001 — the next one must still run + sys.stderr.write("sminos bridge: %s failed: %s: %s\n" % (work.__name__.strip("_"), type(err).__name__, err)) + + def _export_now(self, cwd, records): + try: + S.export_live(cwd, self.session, records) + except (Exception, SystemExit) as err: # noqa: BLE001 — the next export must still run + sys.stderr.write("sminos bridge: export to the log failed: %s: %s\n" % (type(err).__name__, err)) def _keep(self, kept): """What is kept for the session: the mod's copy, written at the end of diff --git a/skills/sminos/scripts/sminos_control.py b/skills/sminos/scripts/sminos_control.py index 727ff490fa3..21eeffc8da7 100644 --- a/skills/sminos/scripts/sminos_control.py +++ b/skills/sminos/scripts/sminos_control.py @@ -13,6 +13,8 @@ submitted is dropped, so delivery stays at least once); else, for a human's event to a --bg job, the supervisor's `reply` (human-origin input); else the inbox frame. Each path carries the frame the inbox would, so the model reads the same addressing. +A session the human stopped (Esc) refuses the bridge's prompt, so its bridge +answers not-ok and the inbox frame, which starts a turn, carries it. """ import hashlib @@ -36,6 +38,9 @@ SHORT_RE = re.compile(r"^[a-f0-9]{8}$") KEY_RE = re.compile(r"^[0-9a-f]{32}$") EFFORTS = ("low", "medium", "high", "xhigh", "max") +# How the mod's not-ok answer to a delivery begins when the engine refused its +# prompt because the human stopped the session (bridge.tsx, STOPPED_ERROR). +STOPPED_ERROR = "the session was stopped, so its prompt was refused" class Unanswered(Exception): @@ -97,8 +102,12 @@ def deliver(session, project, event, text): return True if status == 504: raise S.SendFailed("after", "the bridge's session did not answer") - S.warn("the bridge of session %s refused a delivery (%s); it goes as an inbox frame" % ( - session[:8], (out or {}).get("error") if isinstance(out, dict) else status)) + error = out.get("error") if isinstance(out, dict) else None + if isinstance(error, str) and error.startswith(STOPPED_ERROR): + # Expected: after an Esc the engine refuses the bridge's prompt, not an inbox frame. + S.warn("session %s was stopped (Esc), so its bridge could not start a turn; the message goes as an inbox frame" % session[:8]) + return False + S.warn("the bridge of session %s refused a delivery (%s); it goes as an inbox frame" % (session[:8], error or status)) return False diff --git a/tests/mods/bridge.test.ts b/tests/mods/bridge.test.ts index 3aaae79ae0c..5e51edc7e88 100644 --- a/tests/mods/bridge.test.ts +++ b/tests/mods/bridge.test.ts @@ -5,6 +5,7 @@ import { ASK_TOOL } from '../../hooks/mods/ask' import { AGENTS_REFRESH_MS, BACKGROUND_WINDOW_MS, + DELIVERY_WAKE, HEARTBEAT_MS, INTERRUPT_HOLD_MS, RECORD_WAIT_MS, @@ -12,6 +13,8 @@ import { ROWS_POLL_MS, STEER_WAKE, STEP_END_WAIT_MS, + STOPPED_ERROR, + STOP_DROP_WAIT_MS, TRANSCRIPT_RETRY_MS, TEXT_CAP, compacted, @@ -113,6 +116,8 @@ function harness( agents?: Record[] /** `$.prompt.submit` resolves dropped, with this reason. */ submitDrops?: string + /** `$.prompt.submit` waits until the test settles it (`submits`), as a prompt taken does until its turn starts. */ + holdSubmit?: boolean } = {}, ) { const hooks: { event: string; matcher: Matcher; handler: Handler }[] = [] @@ -146,6 +151,8 @@ function harness( const compactions: { args: unknown; finish: (outcome: Record) => void; fail: (error: Error) => void }[] = [] /** Appends held by `holdAppend`, each released by the test. */ const appends: (() => void)[] = [] + /** Submits held by `holdSubmit`, each settled by the test with what `$.prompt.submit` resolves to. */ + const submits: ((entered: Record) => void)[] = [] /** The rows the mod's appends put in the main conversation, in order; and what each model request carried of them. */ const conversation: { type: string; content: { type: string; text: string }[] }[] = [] const requests: (typeof conversation)[] = [] @@ -230,6 +237,9 @@ function harness( prompt: { submit: async (args: unknown) => { calls.push({ op: 'submit', args }) + if (options.holdSubmit) { + return new Promise((resolve) => submits.push(resolve)) + } return options.submitDrops !== undefined ? { drop: options.submitDrops } : { text: String((args as { text?: unknown }).text) } }, }, @@ -457,6 +467,7 @@ function harness( calls, compactions, appends, + submits, conversation, requests, command, @@ -1662,21 +1673,80 @@ describe('deliver', () => { expect(h.apiReads()).toBe(1) }) - test("idle: a prompt the engine drops because the human stopped the session goes in as a row, starting no turn; it is checked when the next turn starts", async () => { - const h = harness({ submitDrops: 'the session was stopped and nobody has sent input since' }) + test('idle: a prompt the engine refuses because the human stopped the session is answered not-ok for sminos to send as an inbox frame: its mark put back, no `submitted`, no `refused`, no row', async () => { + const options: NonNullable[0]> = { submitDrops: 'the session was stopped and nobody has sent input since' } + const h = harness(options) await h.ready() - const key = { writer: 'mac/s1', seq: 7 } - await h.command({ op: 'deliver', key, text: 'while stopped' }) + const key = { project: 'p1', writer: 'mac/s1', seq: 7 } + const r = await h.command({ op: 'deliver', key, text: 'while stopped' }) + expect(r).toMatchObject({ ok: false }) + expect(r.error).toBe(`${STOPPED_ERROR}: the session was stopped and nobody has sent input since`) await h.settle() - expect(h.calls.map((c) => c.op)).toEqual(['submit', 'append']) - expect(h.ofType('delivered').map((e) => [e.outcome, e.uuid])).toEqual([ - ['submitted', undefined], - ['entered', 'row-2'], - ]) + expect(h.calls.map((c) => c.op)).toEqual(['submit']) + expect(h.ofType('delivered')).toEqual([]) + expect(h.ofType('inbox')).toEqual([]) + // The key is free again: once the human has sent input, the same event is taken. + options.submitDrops = undefined + expect(await h.command({ op: 'deliver', key, text: 'while stopped' })).toMatchObject({ ok: true, result: { submitted: true } }) + expect(h.ofType('delivered').map((e) => [e.key, e.outcome])).toEqual([[key, 'submitted']]) + // The mark is put back to what it was before, not cleared. + options.submitDrops = 'the prompt was removed from the queue before it ran' + expect(await h.command({ op: 'deliver', key: { ...key, seq: 8 }, text: 'eight' })).toMatchObject({ ok: false }) + expect(await h.command({ op: 'deliver', key, text: 'while stopped' })).toMatchObject({ result: { duplicate: true } }) + }) + + test('idle: a prompt taken is answered `submitted` once the wait for a stop has passed; a stop that drops it after that (an Esc emptied the queue) sends it to the child as an inbox frame, with its key', async () => { + const h = harness({ holdSubmit: true }) + await h.ready() + const key = { project: 'p1', writer: 'mac/s1', seq: 2 } + h.children[0]!.write(line({ id: 'held', op: 'deliver', key, text: 'queued words' })) + await h.settle() + expect(h.ofType('result').find((e) => e.id === 'held')).toBeUndefined() + await h.fire(STOP_DROP_WAIT_MS) + expect(h.ofType('result').find((e) => e.id === 'held')).toMatchObject({ ok: true, result: { submitted: true } }) + h.submits[0]!({ drop: 'the prompt was removed from the queue before it ran' }) + await h.settle() + expect(h.ofType('inbox')).toEqual([expect.objectContaining({ text: 'queued words', key })]) + expect(h.ofType('delivered').map((e) => e.outcome)).toEqual(['submitted']) + expect(h.calls.filter((c) => c.op === 'append')).toEqual([]) + }) + + test('a delivered row a stopped turn left unread wakes the session once through its inbox (the engine refuses the prompt then); one a request read, or a steer, wakes nothing', async () => { + const h = harness() + await h.ready() await h.turn('t1') + await h.command({ op: 'deliver', key: { writer: 'mac/s1', seq: 1 }, text: 'unread one' }) + await h.command({ op: 'deliver', key: { writer: 'mac/s1', seq: 2 }, text: 'unread two' }) + await h.complete('t1', 'aborted') + expect(h.ofType('inbox').map((e) => [e.text, e.key])).toEqual([[DELIVERY_WAKE, undefined]]) + expect(h.calls.filter((c) => c.op === 'submit')).toEqual([]) + // The rows held, the next turn lets them go; it reads a new one before its Esc. + await h.turn('t2') await h.settle() - expect(h.apiReads()).toBe(1) - expect(h.calls.map((c) => c.op)).toEqual(['submit', 'append']) + await h.command({ op: 'deliver', key: { writer: 'mac/s1', seq: 3 }, text: 'read' }) + await h.step({ turnId: 't2', index: 1, model: 'm', messageCount: 4 }, []) + await h.complete('t2', 'aborted') + await h.turn('t3') + await h.settle() + await h.command({ op: 'steer', text: 'a steer, unread' }) + await h.complete('t3', 'aborted') + expect(h.ofType('inbox').length).toBe(1) + }) + + test("a turn that answers before reading a delivered row, whose wake the engine refuses for a stop, wakes the session through its inbox; a steer's alone does not", async () => { + const h = harness({ submitDrops: 'the session was stopped and nobody has sent input since' }) + await h.ready() + await h.turn('t1') + await h.command({ op: 'steer', text: 'steer only' }) + await h.complete('t1') + await h.settle() + expect(h.ofType('inbox')).toEqual([]) + await h.turn('t2') + await h.command({ op: 'deliver', key: { writer: 'mac/s1', seq: 1 }, text: 'delivered, unread' }) + await h.complete('t2') + await h.settle() + expect(h.calls.filter((c) => c.op === 'submit').map((c) => (c.args as { text: string }).text)).toEqual([STEER_WAKE, STEER_WAKE]) + expect(h.ofType('inbox').map((e) => e.text)).toEqual([`[sminos bridge]\n${STEER_WAKE}`]) }) test("a compaction lets every delivered row go: one read before an Esc, its words now only in the summary, is not given again (the bridge's own compaction, and the engine's)", async () => { diff --git a/tests/mods/live-delivery-cancel-probe.py b/tests/mods/live-delivery-cancel-probe.py index 35271c78978..166a9866df2 100755 --- a/tests/mods/live-delivery-cancel-probe.py +++ b/tests/mods/live-delivery-cancel-probe.py @@ -11,7 +11,13 @@ human's edited resend goes in where it stood, which leaves a row given after it on a branch no request reads (the bridge gives it again as the next turn starts); a delivery made after an Esc meets the engine's refusal of plugin -prompts until the human speaks (the bridge gives it as a row instead). +prompts until the human speaks, so the bridge answers not-ok and sminos sends +it as an inbox frame, which starts a turn; a row given mid-turn that an Esc +left unread wakes the session through its inbox. + +The two wake scenarios deliver through the CLI (`sminos send`, the path a +human's message takes) and say ok or FAIL: a turn starts after the delivery +and the model says the word, with no prompt from the human. It spends model calls (haiku, short prompts). Isolation is live-bridge-check.py's. @@ -22,12 +28,15 @@ bash-esc delivered mid-Bash, then Esc; the human asks for the word restore delivered as the turn starts, Esc before any answer, edit, resend after-esc Esc before any answer, delivered while the prompt is back in the box, edit, resend + cli-after-esc Esc mid-Bash; idle, delivered through the CLI: a turn starts and reads it + esc-wake delivered through the CLI mid-Bash, Esc before a request read it: the session wakes and reads it """ import glob import importlib.util import json import os +import subprocess import sys import threading import time @@ -37,7 +46,8 @@ LB = importlib.util.module_from_spec(spec) spec.loader.exec_module(LB) -SCENARIOS = {"busy": "KIWI", "bash-esc": "MANGO", "restore": "PAPAYA", "after-esc": "PEAR"} +SCENARIOS = {"busy": "KIWI", "bash-esc": "MANGO", "restore": "PAPAYA", "after-esc": "PEAR", + "cli-after-esc": "LYCHEE", "esc-wake": "GUAVA"} ASK = "If an earlier message asked you to include a word, reply with that word only; otherwise reply NONE." @@ -181,6 +191,54 @@ def after_esc(self, word): self.resend() self.report("after-esc", t, key, word, before) + def send(self, word): + """A human's message through the CLI, as `sminos send` delivers it.""" + r = subprocess.run([self.sminos(), "send", LB.NAME, "Include the word %s in your reply." % word], + cwd=self.work, env=self.local_env(), capture_output=True, text=True, timeout=60) + print(" sminos send exited %d: %s" % (r.returncode, (r.stdout + r.stderr).strip().replace("\n", " | ")), flush=True) + return r + + def woke(self, name, sent, word, cli): + """Waits for a main turn started after `sent` that says the word, and says ok or FAIL.""" + try: + LB.wait_for("a turn that says %s" % word, 120, lambda: word in self.text_since(sent)) + except TimeoutError: + pass + self.idle() + starts = [str(e.get("text"))[:90] for e in self.seen(lambda e: is_main(e, "turn", "start"), sent)] + said = word in self.text_since(sent) + print("== %s" % name) + print(" main turns started after the delivery: %s" % starts) + print(" %s a turn started after the delivery" % ("ok " if starts else "FAIL")) + print(" %s the model said %s, with no prompt from the human" % ("ok " if said else "FAIL", word)) + if cli is not None: + stopped = "was stopped (Esc)" in cli.stderr + cli.stdout + print(" the CLI said the session was stopped: %s" % stopped, flush=True) + self.results.append((name, bool(starts) and said, "")) + + def cli_after_esc(self, word): + t = time.time() + self.bash_turn(t) + self.keys("Escape") + time.sleep(3) + self.idle() + sent = time.time() + r = self.send(word) + self.woke("cli-after-esc", sent, word, r) + + def esc_wake(self, word): + t = time.time() + self.bash_turn(t) + sent = time.time() + r = self.send(word) + LB.wait_for("the delivered row", 20, lambda: self.seen( + lambda e: e.get("type") == "delivered" and e.get("outcome") == "entered", sent)) + self.keys("Escape") + read = self.seen(lambda e: is_main(e, "step", "start"), sent) + print(" a request began between the delivery and the Esc: %s" % bool(read), flush=True) + time.sleep(1) + self.woke("esc-wake", sent, word, r) + def run(self, scenarios): self.setup() self.start_session() @@ -212,6 +270,10 @@ def main(): print(" pane:\n" + "\n".join(" " + l for l in p.pane().rstrip().splitlines()[-25:])) finally: p.cleanup() + failed = [r[0] for r in p.results if not r[1]] + if p.results: + print("wake scenarios: %s" % ("all ok" if not failed else "FAILED: " + ", ".join(failed))) + sys.exit(1 if failed else 0) if __name__ == "__main__": diff --git a/tests/sminos/test-bridge-control.sh b/tests/sminos/test-bridge-control.sh index d4e0aac8b93..7cb1a5d6904 100755 --- a/tests/sminos/test-bridge-control.sh +++ b/tests/sminos/test-bridge-control.sh @@ -166,7 +166,8 @@ elif op == "supsock": # home → where sminos looks for that config's supe elif op == "fakemod": # sminos session lines-file mode-file # Spawns the child as the mod does, and answers its command lines as the # mod would; mode-file says whether a turn is running ("busy"), the - # session is idle ("idle"), or deliveries are refused ("refuse"). + # session is idle ("idle"), deliveries are refused ("refuse"), or the + # human stopped the session, so the engine refuses the mod's prompt ("stopped"). sminos, session, out, modefile = sys.argv[2:6] # The child's stderr is the mod's debug log: kept beside the lines. child = subprocess.Popen([sminos, "bridge", "--session", session], stdout=subprocess.PIPE, @@ -217,6 +218,9 @@ elif op == "fakemod": # sminos session lines-file mode-file mark = "%s/%s" % (key["project"], key["writer"]) if "project" in key else key["writer"] if mode == "refuse": result = {"ok": False, "error": "deliver needs key {project?, writer, seq} and text"} + elif mode == "stopped": + result = {"ok": False, "error": "the session was stopped, so its prompt was refused: " + "the session was stopped and nobody has sent input since"} elif key["seq"] <= delivered.get(mark, 0): events.append({"type": "delivered", "key": key, "outcome": "duplicate"}) result = {"ok": True, "result": {"duplicate": True}} @@ -446,7 +450,37 @@ set +e; OUT="$(cd "$R" && "$SMINOS" say "@rover second try" 2>&1)"; RC=$?; set - assert_contains "$OUT" "rover: sent" "a delivery the bridge refuses still reaches the member" until_true 100 test -s "$T/rover-inbox" || true assert_contains "$(cat "$T/rover-inbox" 2>/dev/null)" "second try" "through its inbox socket" +# A session the human stopped (Esc): the engine refuses the mod's prompt, so +# the bridge answers not-ok and the inbox frame, which starts a turn, carries it. +mode stopped +: > "$T/rover-inbox" +set +e; OUT="$(cd "$R" && "$SMINOS" say "@rover after the Esc" 2>&1)"; RC=$?; set -e +assert_rc 0 "$RC" "a say to a stopped bridged member exits 0" +assert_contains "$OUT" "rover: sent" "reporting it sent" +until_true 100 grep -q "after the Esc" "$T/rover-inbox" || true +assert_contains "$(cat "$T/rover-inbox" 2>/dev/null)" "after the Esc" "the message went as the inbox frame" +assert_contains "$OUT" "was stopped (Esc), so its bridge could not start a turn; the message goes as an inbox frame" \ + "the sender is told the session was stopped, as the expected case it is" +assert_not_contains "$OUT" "refused a delivery" "not as a refusal" mode busy +# The mod's `inbox` event: the child writes the frame to the session's own +# inbox socket (the wake for a delivered row a stopped turn left unread). +: > "$T/rover-inbox" +ctl post "$SOCK" '[{"type":"inbox","text":"[sminos bridge]\nA message for you was added"}]' >/dev/null +until_true 100 test -s "$T/rover-inbox" || true +FRAME="$(tail -1 "$T/rover-inbox" 2>/dev/null || true)" +[ -n "$FRAME" ] || FRAME='{}' +assert_equals "$(field_of "$FRAME" message.content)" "[sminos bridge] +A message for you was added" "the child sends the mod's inbox event as a frame to the session's own socket" +assert_equals "$(field_of "$FRAME" type)" "user" "as the inbox frame sminos sends" +# With no socket of the session answering, a delivery the frame should have +# carried is kept as a refusal, for the daemon to report `failed`. +mv "$HOME/.claude/sessions/rover.json" "$T/rover.json.aside" +ctl post "$SOCK" '[{"type":"inbox","text":"lost words","key":{"project":"p9","writer":"mac/s9","seq":4}}]' >/dev/null +until_true 100 grep -q '"p9"' "$KEPT" || true +assert_contains "$(cat "$KEPT")" '"refused": [{"key": {"project": "p9", "writer": "mac/s9", "seq": 4}, "reason": "no inbox socket of the session answers"}]' \ + "an inbox frame for a delivery that no socket took is kept as a refusal" +mv "$T/rover.json.aside" "$HOME/.claude/sessions/rover.json" # One writer delivering to one session from two projects (#229): every # project numbers the writer's events on its own, so the second project's # event is below what the first delivered, and arrives all the same. From eda698e113715d236ee82214966d080751781a7a Mon Sep 17 00:00:00 2001 From: SSFSKIM Date: Sat, 17 Oct 2026 22:30:30 -0700 Subject: [PATCH 4/6] sminos bridge: a stop-dropped wake checks the rows are still unread; a /clear ends pending wakes; a full queue keeps a keyed inbox frame as a refusal - A STEER_WAKE queued behind the human's prompt could be dropped by an Esc after that prompt's turn had read the delivered row, and the drop still sent the inbox wake on the turn's snapshot. The wake is now tracked (state.wakes) and marked read by the main loop's next request that carries its rows (any request, but the first of a turn already running when it was given); the inbox frame goes only if it is still unread when the drop settles. - A delivery's append pending through an Esc and a /clear settled into a steer that survived forgetLive, so DELIVERY_WAKE reached the new session's socket. forgetLive now lets every steer and pending wake go. - The child's _inbox kept a keyed frame a full export queue dropped only as a stderr line, its sender already told `submitted`. It is now kept as a refusal, as _send_inbox keeps one no socket took, so the daemon reports `failed`. Logged as debt: a late-drop keyed inbox event whose post fails before the child takes it is lost with no refusal (the resync carries no inbox work). --- docs/doperpowers/TECH-DEBT.md | 13 ++++++ .../plans/2026-10-15-arkho-desk-c2-bridge.md | 4 +- hooks/mods/bridge-state.tsx | 10 +++++ hooks/mods/bridge-stream.tsx | 4 ++ hooks/mods/bridge.tsx | 32 +++++++++++-- skills/sminos/scripts/sminos_bridge.py | 4 ++ tests/mods/bridge.test.ts | 45 +++++++++++++++++++ tests/sminos/test-bridge-control.sh | 18 ++++++++ 8 files changed, 124 insertions(+), 6 deletions(-) diff --git a/docs/doperpowers/TECH-DEBT.md b/docs/doperpowers/TECH-DEBT.md index 3af1134b340..023512bf286 100644 --- a/docs/doperpowers/TECH-DEBT.md +++ b/docs/doperpowers/TECH-DEBT.md @@ -429,3 +429,16 @@ the spec's Decision Log entry "the branch review round" Accepted at design (Decision Log, round 3). Trigger: a raw value found in the session store's copy of a `tool-results/` file, or the store gaining a reader other than the person. + +## sminos bridge: a delivery wakes a stopped session (2026-10-17, e269230e) + +- **A late stop drop can be lost with no refusal when its post fails.** A + delivery the engine drops for a stop after the mod answered `submitted` goes + to the child as a keyed `inbox` event. If the post carrying it fails before + the child takes it in, the poster replaces the batch with a gap and a resync + (bridge-stream.tsx), and neither carries inbox work. The message is then + lost, its mark stays, and the daemon is never told it `failed`. Two things + must happen together: a prompt queued in the instant the session turned + busy, then an Esc; and a failed post to the child's local socket. Trigger: + a delivery reported lost with no refusal, or the poster's resync gaining + retained work for another reason. diff --git a/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md b/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md index be2fea1d041..0dba95302c1 100644 --- a/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md +++ b/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md @@ -466,7 +466,7 @@ M1 left them (with fw1/fw2). Changed rows are marked **[M2]**. | `interrupt` | M2 **[M2]** **[fw1]** **[record]** | `{from?}` | `{turnId, stopped: [], running: []}`: `$.turn.abort` on the main turn, then `TaskStop` (`$.tool.call`) on each Bash call **attributable to that turn** and running at the abort whose result names the background task the abort moved it to (`running in background with ID: `; their results awaited at most 3 s). **[fw1]** Attributable: a main-loop call made during that turn, or a call of a foreground subagent spawned in it (nested foreground subagents inherit their parent's turn); a background subagent's calls never are. **[fw1]** `running` names each task whose TaskStop failed (an `isError` result or a rejection): the interrupt is incomplete when it is not empty. Idle: `{turnId: null, stopped: [], running: []}`. **[record]** A plugin's abort leaves the model no marker (Esc leaves `[Request interrupted by user]`), and a Bash call it moved to the background reads as still running there: so once the stops settle the interrupt appends two user rows. **[marker]** The first is Esc's marker whole, `[Request interrupted by user]`, one text block: a transcript's reader knows a turn's interrupt by a row whose whole text is the marker and by nothing else (the engine's own readers, 2.1.300 `He`/`pQn`, and MAWS's `INTERRUPT_MARKERS`), so the earlier one-row shape, `[Request interrupted by user from the desk]` with the task lines after it, read as no interrupt. The second is the note: `The interrupt came from the desk.` (`from: "desk"`, which the daemon's RPC sends) or `The interrupt came from outside the terminal.`, then a line per task: stopped, `Task , the Bash command the interrupt moved to the background, was then stopped: it is not running, and no notification will come for it.`; in `running`, `… could not be stopped: it may still be running.` The engine keeps a plugin's user row `isMeta: true` with `origin: {kind: "plugin", name}` whatever the plugin asks (2.1.300 `m6r`; it refuses any other user row): the model reads both rows, and no reader takes the note for a prompt. A marker a hook refuses is followed by no note. Both must be in before the turn ends: a next turn already queued takes its messages before it waits for any hook (2.1.300 `ir`/`cn`), so an append in `turn.complete` would miss its first request. The engine waits for a Bash call's result after a turn abort, so each Bash call of the turn being interrupted is held in the `tool.call` hook, after its result, until both rows are in (at most 10 s, `INTERRUPT_HOLD_MS`): they are kept before the moved call's result row, and the next request carries them after that result, the marker then the note (live, 2.1.302). An interrupt with no Bash call running appends at once after the abort, racing only the turn's own unwinding. A second interrupt of a turn being interrupted leaves the first's record alone (the engine refuses its abort). Each row is named by a `row-seen` notice (door `note`). The transcript mirrors the rows on one line as `interrupt: [Request interrupted by user] ` (a newline there draws as U+FFFD). Nothing for an idle interrupt or a refused abort | | `model` | M2 **[M2]** | `{model, effort?}` — `model` an id, a family alias, `"default"` or null; `effort` one of `low medium high xhigh max`, or null | `{model, effort}` now in force (null: the session's own). Rewritten on every **main-loop** `turn.step` request, subagents untouched; never `$.config.set`. A family alias (`haiku`, `sonnet`, `opus`, `fable`) is resolved through the session's `ANTHROPIC_DEFAULT__MODEL`; one it does not map is refused, naming the id to use. Emits `model`; kept by the child | | `compact` | M2 **[M2]** | `{instructions?}` | During the main turn `{queued: true, turnId}`: held, and run inside that turn's `turn.complete` (a second while one is held keeps the first). Idle `{started: true}`: run at once. Either way the outcome follows on the stream as `compact` events carrying this command's `id` | -| `deliver` | M2 **[M2]** **[fw1]** **[#229]** **[midturn]** **[wake]** | `{key: {project, writer, seq}, text}` | `{submitted: true}` or `{duplicate: true}`; **[wake]** not-ok, `error` beginning `the session was stopped, so its prompt was refused`, when the engine refused the idle prompt for a stop. **[midturn]** While the main turn runs, `text` goes in as a steer's does: `$.session.append` of a user row (one text block), read at the main loop's next request — the next tool round, sminos's contract for a busy recipient — bound to the turn before the append is awaited, mirrored as the transcript line `delivered: ` (newlines as spaces), and sharing the steer's wake (**[fw2]**: one wake per turn that answered with a row of it unread; none for `error`/`refusal`). Idle, it is submitted with `$.prompt.submit({text, asUser: true})`, a turn of its own. A plugin prompt queued behind a turn read only at that turn's end (median 39 s, max 253 s observed), and an Esc empties the queue of plugin prompts (2.1.300 `zBt`). **[wake]** A message addressed to a session is meant to wake it (sminos's contract: an idle member starts a turn), while the engine refuses a plugin's prompt after an Esc until the human sends input (2.1.300 `k_e`, flag `tengu_plugin_input_refusal`), a refusal aimed at plugins acting on their own; `k_e` guards only `$.prompt.submit` and `$.command.run`, not the session's messaging socket. So the idle submit is awaited for up to 200 ms (`STOP_DROP_WAIT_MS`; the refusal is answered at once, a prompt taken resolves only when its turn starts): a drop for a stop — `the session was stopped and nobody has sent input since`, or `the prompt was removed from the queue before it ran` (Esc, or the stop-agents chord, emptying the queue) — is answered not-ok with the mark put back to its value before this delivery (unless a later delivery moved it) and nothing emitted, neither `submitted` nor `refused` (the child keeps refusals, which the daemon reports `failed`); `sminos_control.deliver` then returns False and `push_live` goes on to the supervisor's `reply` (a human's event to a `--bg` job) or the inbox frame, which starts a turn, warning in one line that the session was stopped. Otherwise `submitted` is emitted and answered once the wait passes. A drop for a stop that comes after the answer (an Esc emptied the queue the prompt waited in) is sent to the child as an `inbox` event carrying the text and the key, and the child sends the same frame. A delivered row the main turn was stopped (`aborted`) before any request read it wakes the session the same way: one `inbox` event per such turn, carrying a fixed wake text (`DELIVERY_WAKE`), which the child sends as a frame; a turn that answered with a delivered row unread, whose wake prompt the engine drops for a stop, sends `STEER_WAKE` that way too. Steers keep their own wake (none for `aborted`). Any other drop or rejection is `refused`. An Esc before the model answered hands the human's prompt back to the input box, and the prompt sent next goes in where that one stood (the same parent), so a row given after it is on a branch no request reads: each delivered row of a main turn that did not answer is checked when the next main turn starts against `$.session.messages({as: "api"})` (any user text block containing the text), and one the conversation lacks is appended again in that turn (`entered` again, the new row), read from the turn's second request or by the wake when the turn answers in one; one it holds is let go, as are a turn's rows once it answers, and every row when the main conversation is compacted (the bridge's `compact`, the compact tool's, the person's or the engine's): the conversation then holds them only in its summary, so each would read as missing and be given twice; a row a rewind left on a dead branch before the compaction is lost. A /clear lets them go too, a check still running at it included: nothing is appended to the conversation that ended. An unreadable conversation leaves the rows for the next turn's check. Live: `tests/mods/live-delivery-cancel-probe.py`. **The delivery key** is the log event's `(writer, seq)` — for a push carrying several events of one writer, the last. A key whose seq is at or below the highest already submitted for its writer is a duplicate and dropped (delivery goes per writer in seq order). The per-writer seqs are kept by the child across reloads; a /clear starts them anew (a new id is a new recipient). **[fw1]** The `delivered` event is emitted before the row is appended, so it is the next thing posted; **[wake]** for an idle submission it is emitted once the wait for a stop's refusal has passed. Deduplication across a module reload is **best effort when the reload lands mid-submission**: a key submitted whose `delivered` had not reached the child is not kept, and a redelivery of it may submit again (a residual of at-least-once delivery, §3). **[#229]** The key names the log's project (its id): every project numbers a writer's events on its own, and a writer id is per session, not per project, so one writer delivering to one session from two projects reaches seqs the other project already passed — keyed by `(writer, seq)` alone, those were dropped as duplicates and settled, lost with no trace. Deduplication is per writer **within a project**, against the mark `/`; a key naming no project (a CLI older than this) is held to the bare `` mark, which is all such a CLI raised, so no kept mark is migrated, and a session whose bare mark is past a project's own count takes that project's scoped keys from the first. The CLI takes a `duplicate` as delivered (with scoped keys it was submitted before) but says it, to the sender and in `daemon.log`, naming the event and the session. A refusal (`refused`, below) keeps the key whole, project included, and the daemon reports only those naming its own project, or none | +| `deliver` | M2 **[M2]** **[fw1]** **[#229]** **[midturn]** **[wake]** | `{key: {project, writer, seq}, text}` | `{submitted: true}` or `{duplicate: true}`; **[wake]** not-ok, `error` beginning `the session was stopped, so its prompt was refused`, when the engine refused the idle prompt for a stop. **[midturn]** While the main turn runs, `text` goes in as a steer's does: `$.session.append` of a user row (one text block), read at the main loop's next request — the next tool round, sminos's contract for a busy recipient — bound to the turn before the append is awaited, mirrored as the transcript line `delivered: ` (newlines as spaces), and sharing the steer's wake (**[fw2]**: one wake per turn that answered with a row of it unread; none for `error`/`refusal`). Idle, it is submitted with `$.prompt.submit({text, asUser: true})`, a turn of its own. A plugin prompt queued behind a turn read only at that turn's end (median 39 s, max 253 s observed), and an Esc empties the queue of plugin prompts (2.1.300 `zBt`). **[wake]** A message addressed to a session is meant to wake it (sminos's contract: an idle member starts a turn), while the engine refuses a plugin's prompt after an Esc until the human sends input (2.1.300 `k_e`, flag `tengu_plugin_input_refusal`), a refusal aimed at plugins acting on their own; `k_e` guards only `$.prompt.submit` and `$.command.run`, not the session's messaging socket. So the idle submit is awaited for up to 200 ms (`STOP_DROP_WAIT_MS`; the refusal is answered at once, a prompt taken resolves only when its turn starts): a drop for a stop — `the session was stopped and nobody has sent input since`, or `the prompt was removed from the queue before it ran` (Esc, or the stop-agents chord, emptying the queue) — is answered not-ok with the mark put back to its value before this delivery (unless a later delivery moved it) and nothing emitted, neither `submitted` nor `refused` (the child keeps refusals, which the daemon reports `failed`); `sminos_control.deliver` then returns False and `push_live` goes on to the supervisor's `reply` (a human's event to a `--bg` job) or the inbox frame, which starts a turn, warning in one line that the session was stopped. Otherwise `submitted` is emitted and answered once the wait passes. A drop for a stop that comes after the answer (an Esc emptied the queue the prompt waited in) is sent to the child as an `inbox` event carrying the text and the key, and the child sends the same frame. A delivered row the main turn was stopped (`aborted`) before any request read it wakes the session the same way: one `inbox` event per such turn, carrying a fixed wake text (`DELIVERY_WAKE`), which the child sends as a frame; a turn that answered with a delivered row unread, whose wake prompt the engine drops for a stop, sends `STEER_WAKE` that way too, unless a main-loop request has carried the row by the time the drop settles (the wake queued behind the human's prompt, whose turn read it before an Esc emptied the queue). A /clear lets every pending steer and wake go: an append that settles after it wakes nothing in the new conversation. Steers keep their own wake (none for `aborted`). Any other drop or rejection is `refused`. An Esc before the model answered hands the human's prompt back to the input box, and the prompt sent next goes in where that one stood (the same parent), so a row given after it is on a branch no request reads: each delivered row of a main turn that did not answer is checked when the next main turn starts against `$.session.messages({as: "api"})` (any user text block containing the text), and one the conversation lacks is appended again in that turn (`entered` again, the new row), read from the turn's second request or by the wake when the turn answers in one; one it holds is let go, as are a turn's rows once it answers, and every row when the main conversation is compacted (the bridge's `compact`, the compact tool's, the person's or the engine's): the conversation then holds them only in its summary, so each would read as missing and be given twice; a row a rewind left on a dead branch before the compaction is lost. A /clear lets them go too, a check still running at it included: nothing is appended to the conversation that ended. An unreadable conversation leaves the rows for the next turn's check. Live: `tests/mods/live-delivery-cancel-probe.py`. **The delivery key** is the log event's `(writer, seq)` — for a push carrying several events of one writer, the last. A key whose seq is at or below the highest already submitted for its writer is a duplicate and dropped (delivery goes per writer in seq order). The per-writer seqs are kept by the child across reloads; a /clear starts them anew (a new id is a new recipient). **[fw1]** The `delivered` event is emitted before the row is appended, so it is the next thing posted; **[wake]** for an idle submission it is emitted once the wait for a stop's refusal has passed. Deduplication across a module reload is **best effort when the reload lands mid-submission**: a key submitted whose `delivered` had not reached the child is not kept, and a redelivery of it may submit again (a residual of at-least-once delivery, §3). **[#229]** The key names the log's project (its id): every project numbers a writer's events on its own, and a writer id is per session, not per project, so one writer delivering to one session from two projects reaches seqs the other project already passed — keyed by `(writer, seq)` alone, those were dropped as duplicates and settled, lost with no trace. Deduplication is per writer **within a project**, against the mark `/`; a key naming no project (a CLI older than this) is held to the bare `` mark, which is all such a CLI raised, so no kept mark is migrated, and a session whose bare mark is past a project's own count takes that project's scoped keys from the first. The CLI takes a `duplicate` as delivered (with scoped keys it was submitted before) but says it, to the sender and in `daemon.log`, naming the event and the session. A refusal (`refused`, below) keeps the key whole, project included, and the daemon reports only those naming its own project, or none | ##### Events (the mod's posts; the stream's lines) @@ -478,7 +478,7 @@ M1 left them (with fw1/fw2). Changed rows are marked **[M2]**. | `compact` / `start` | M2 **[M2]** | `id` — the `compact` command's id | | `compact` / `end` | M2 **[M2]** | `id`, and one of: `tokensBefore, tokensAfter` (null when the engine recorded none); `skip` (a hook vetoed it); `error` (the engine refused it, as during a turn) | | `delivered` | M2 **[M2]** (was reserved), **[#229]**, **[midturn]**, **[wake]** | `key: {project?, writer, seq}` (the command's key, whole), `outcome: "submitted" \| "duplicate" \| "refused" \| "entered"`, `reason?`, `uuid?` — `refused` follows a `submitted` when the engine later dropped or rejected the prompt for a reason other than a stop, or refused the row (**[midturn]**); `entered` names the row the delivery became once it is kept (DC8 G6), and is named again with the new row when an Esc's rewind left the first on a dead branch (**[midturn]**). **[wake]** None at all for an idle delivery the engine refused for a stop: the command's not-ok answer says it | -| `inbox` | **[wake]** | `text`, `key?` — a frame for the session's own inbox socket (its peer record's `messagingSocketPath`), which starts a turn where the engine refuses the mod's prompt (after an Esc): the wake for a delivered row a stopped turn left unread, or a delivery (`key`) whose prompt a stop dropped after `submitted`. **Consumed by the child, never handed to subscribers**: sent from the export thread with `sminos.send_frame`; nothing when no socket of the session answers, except that a `key` is then kept as a refusal, which the daemon reports `failed` | +| `inbox` | **[wake]** | `text`, `key?` — a frame for the session's own inbox socket (its peer record's `messagingSocketPath`), which starts a turn where the engine refuses the mod's prompt (after an Esc): the wake for a delivered row a stopped turn left unread, or a delivery (`key`) whose prompt a stop dropped after `submitted`. **Consumed by the child, never handed to subscribers**: sent from the export thread with `sminos.send_frame`; nothing when no socket of the session answers, or when the export thread's queue is full, except that a `key` is then kept as a refusal, which the daemon reports `failed` | | `kept` | M2 **[fw1]** **[fw2]** (the child's own) | `ok: false, error` when writing `.json` starts to fail (said once, however many batches it keeps failing; retried at the end of every batch); `ok: true` when a write works again after a failure | ##### Subscription (`GET /events`) diff --git a/hooks/mods/bridge-state.tsx b/hooks/mods/bridge-state.tsx index 9b47d93498c..2c6ec113c5f 100644 --- a/hooks/mods/bridge-state.tsx +++ b/hooks/mods/bridge-state.tsx @@ -40,6 +40,13 @@ export type Call = { * `isDelivery`: a delivery's row, which an interrupted turn does not leave unread. */ export type Steer = { turnId: string; isAppended: boolean; isRead: boolean; ended?: string; isDelivery?: boolean } +/** + * The wake a turn owes for delivered rows it answered before reading, until + * its prompt settles: `isRead`, a main-loop request began since that carries + * them; `turnAt`, the main turn already running when it was given, whose + * first request was built before the rows went in. + */ +export type Wake = { isRead: boolean; turnAt?: string } /** What an interrupt did to the Bash calls it moved to the background: the tasks it stopped, and those it could not. */ export type Stops = { stopped: string[]; running: string[] } /** An interrupt of a main turn: `recorded` settles once its record is in (or it failed); the turn's Bash calls wait for it. */ @@ -182,6 +189,8 @@ export type BridgeState = { isSeeded: boolean /** Steers given in the main turn, and those whose turn ended before their row was in. */ steers: Steer[] + /** Wakes for unread delivered rows whose prompt has not settled (bridge.tsx, wakeForSteer). */ + wakes: Wake[] /** The main turns the bridge's interrupt is ending, by turn, until each one ends. */ interrupts: Map /** For each running foreground subagent, the main turn it belongs to (its tool calls are that turn's). */ @@ -300,6 +309,7 @@ const fresh = (): BridgeState => ({ delivered: new Map(), isSeeded: false, steers: [], + wakes: [], interrupts: new Map(), foreground: new Map(), }) diff --git a/hooks/mods/bridge-stream.tsx b/hooks/mods/bridge-stream.tsx index 0cc08214228..041b71794c7 100644 --- a/hooks/mods/bridge-stream.tsx +++ b/hooks/mods/bridge-stream.tsx @@ -188,6 +188,10 @@ export function forgetLive() { state.agents.clear() state.submissions = [] state.deliveredRows = [] + // A steer or delivery whose append settles after the /clear, and a wake + // still pending, are the old conversation's: none wakes the new one. + state.steers = [] + state.wakes = [] state.refusals = [] state.delivered.clear() state.loopTurns.clear() diff --git a/hooks/mods/bridge.tsx b/hooks/mods/bridge.tsx index 352cf8cab16..f986eb34dfa 100644 --- a/hooks/mods/bridge.tsx +++ b/hooks/mods/bridge.tsx @@ -2,7 +2,7 @@ import type { EngineInterface, HttpResponse, On, PermissionRequestDecision, Sess import { ASK_TOOL, PLUGIN, observeAsks } from './ask' import { EFFORTS, RESPAWN_FIRST_MS, capped, isEffort, loopKey, message, parsed, state } from './bridge-state' -import type { Answer, Command, Decision, DeliveryKey, Override, Request, Steer, Stops } from './bridge-state' +import type { Answer, Command, Decision, DeliveryKey, Override, Request, Steer, Stops, Wake } from './bridge-state' import { adoptKept, adoptSeed, emit, endStep, forgetLive, poster, pump, releaseStepEnd, requeue, resyncEvent, stopChild } from './bridge-stream' import { CHECKS_KEPT, @@ -302,20 +302,37 @@ function settleSteers($: EngineInterface, turnId: string) { * already in the conversation, so the prompt points to it rather than * repeating it, and the next request reads both. When a delivery's row is * among them and the engine refuses the prompt because the human stopped the - * session, the wake goes as an inbox frame instead. + * session, the wake goes as an inbox frame instead, decided as the drop + * settles: not when a request has read the rows since (a human's prompt the + * wake queued behind, then an Esc that emptied the queue), nor after a /clear + * (forgetLive lets the wake go). */ function wakeForSteer($: EngineInterface, isDelivery: boolean) { + // Marked read by the main loop's requests meanwhile (turn.step). + const wake: Wake | undefined = isDelivery ? { isRead: false, ...(state.mainTurn !== undefined && { turnAt: state.mainTurn }) } : undefined + if (wake !== undefined) { + state.wakes.push(wake) + } + const isOwed = () => { + const owed = wake !== undefined && state.wakes.includes(wake) && !wake.isRead + state.wakes = state.wakes.filter((w) => w !== wake) + return owed + } void $.prompt.submit({ text: STEER_WAKE, asUser: true }).then( (entered) => { + const owed = isOwed() if (entered.drop === undefined) { return } $.ui.log(`bridge: the wake for an unread steer was dropped: ${entered.drop}`, { to: 'debug' }) - if (isDelivery && STOPPED_DROPS.has(entered.drop)) { + if (owed && STOPPED_DROPS.has(entered.drop)) { emit({ type: 'inbox', text: `[sminos bridge]\n${STEER_WAKE}` }) } }, - (error: unknown) => $.ui.log(`bridge: the wake for an unread steer was not submitted: ${message(error)}`, { to: 'debug' }), + (error: unknown) => { + isOwed() + $.ui.log(`bridge: the wake for an unread steer was not submitted: ${message(error)}`, { to: 'debug' }) + }, ) } @@ -1264,6 +1281,13 @@ export function registerBridge(on: On) { s.isRead = true } } + // A wake's rows were in before it was given: any later request carries + // them, but the first of a turn that was already running. + for (const w of state.wakes) { + if (e.index > 0 || e.turnId !== w.turnAt) { + w.isRead = true + } + } } let chunkIndex = 0 let stopReason: unknown = null diff --git a/skills/sminos/scripts/sminos_bridge.py b/skills/sminos/scripts/sminos_bridge.py index 8b65735d6c0..6008e4b0e76 100644 --- a/skills/sminos/scripts/sminos_bridge.py +++ b/skills/sminos/scripts/sminos_bridge.py @@ -770,6 +770,10 @@ def _inbox(self, e): self.exports.put_nowait((self._send_inbox, text, key if isinstance(key, dict) else None)) except queue.Full: sys.stderr.write("sminos bridge: an inbox frame was dropped (%d waiting)\n" % EXPORTS_QUEUED) + if isinstance(key, dict): + # A delivery nothing will carry, kept as _send_inbox keeps one; + # the caller (ingest, under the lock) saves it. + self._keep(_refused(self.kept, key, "the inbox frame was dropped: %d were waiting" % EXPORTS_QUEUED)) def _send_inbox(self, text, key): S.forget_caches() # the registry as it is now: this child lives as long as its session diff --git a/tests/mods/bridge.test.ts b/tests/mods/bridge.test.ts index 5e51edc7e88..d920aed11ac 100644 --- a/tests/mods/bridge.test.ts +++ b/tests/mods/bridge.test.ts @@ -1749,6 +1749,51 @@ describe('deliver', () => { expect(h.ofType('inbox').map((e) => e.text)).toEqual([`[sminos bridge]\n${STEER_WAKE}`]) }) + test("a wake queued behind the human's prompt, whose turn read the delivered row before an Esc emptied the queue, sends no inbox frame; one no request read since still does", async () => { + for (const [isReadMeanwhile, frames] of [ + [true, 0], + [false, 1], + ] as const) { + const h = harness({ holdSubmit: true }) + await h.ready() + await h.turn('t1') + await h.command({ op: 'deliver', key: { writer: 'mac/s1', seq: 1 }, text: 'delivered, unread' }) + await h.complete('t1') + expect(h.submits.length).toBe(1) + // The human's prompt runs ahead of the wake; its first request carries the row. + await h.turn('t2') + if (isReadMeanwhile) { + await h.step({ turnId: 't2', index: 0, model: 'm', messageCount: 3 }, []) + } + await h.complete('t2', 'aborted') + h.submits[0]!({ drop: 'the prompt was removed from the queue before it ran' }) + await h.settle() + expect([isReadMeanwhile, h.ofType('inbox').length]).toEqual([isReadMeanwhile, frames]) + } + }) + + test("a delivery whose append is still pending through an Esc and a /clear wakes nothing when it lands: its turn was the old conversation's", async () => { + const h = harness({ holdAppend: true }) + await h.ready() + await h.turn('t1') + await h.command({ op: 'deliver', key: { writer: 'mac/s1', seq: 1 }, text: 'old words' }) + await h.complete('t1', 'aborted') + await h.hook('session.end', {})(h.engine, { reason: 'clear', sessionId: SID }, async () => ({})) + await h.settle() + const NEW = '6c000002-0000-4000-8000-00000000006c' + const NEWSOCK = `/tmp/s/bridge/${NEW}.sock` + h.setSession(NEW) + h.timers.filter((t) => t.kind === 'after').at(-1)!.fn() + await h.settle() + h.children[1]!.write(hello(NEW, NEWSOCK)) + await h.settle() + h.appends.shift()!() + await h.settle() + expect(h.posts.filter((p) => p.socketPath === NEWSOCK).length).toBeGreaterThan(0) + expect(h.ofType('inbox')).toEqual([]) + expect(h.calls.filter((c) => c.op === 'submit')).toEqual([]) + }) + test("a compaction lets every delivered row go: one read before an Esc, its words now only in the summary, is not given again (the bridge's own compaction, and the engine's)", async () => { const compactions: [string, (h: ReturnType) => Promise][] = [ [ diff --git a/tests/sminos/test-bridge-control.sh b/tests/sminos/test-bridge-control.sh index 7cb1a5d6904..3947e717761 100755 --- a/tests/sminos/test-bridge-control.sh +++ b/tests/sminos/test-bridge-control.sh @@ -481,6 +481,24 @@ until_true 100 grep -q '"p9"' "$KEPT" || true assert_contains "$(cat "$KEPT")" '"refused": [{"key": {"project": "p9", "writer": "mac/s9", "seq": 4}, "reason": "no inbox socket of the session answers"}]' \ "an inbox frame for a delivery that no socket took is kept as a refusal" mv "$T/rover.json.aside" "$HOME/.claude/sessions/rover.json" +# The same when the export thread's queue is full: the frame never goes, so a +# keyed one is kept as a refusal (in process, its own session: a full queue). +FULL="$(python3 - "$REPO_ROOT/skills/sminos/scripts" 2>"$T/full.err" <<'PY_FULL' +import json, os, queue, sys +sys.path.insert(0, sys.argv[1]) +import sminos_bridge as B +b = B.Bridge("5b000009-0000-4000-8000-0000000000c9", "/nonexistent.sock") +os.makedirs(os.path.dirname(B.kept_path(b.session)), mode=0o700, exist_ok=True) +b.exports = queue.Queue(1) +b.exports.put_nowait(None) +b.ingest([{"type": "inbox", "text": "words", "key": {"project": "p8", "writer": "mac/s8", "seq": 7}}, + {"type": "inbox", "text": "a wake, no key"}]) +with open(B.kept_path(b.session)) as f: + print(json.dumps(json.load(f).get("refused"))) +PY_FULL +)" || FULL="python failed: $(cat "$T/full.err")" +assert_equals "$FULL" '[{"key": {"project": "p8", "writer": "mac/s8", "seq": 7}, "reason": "the inbox frame was dropped: 1000 were waiting"}]' \ + "a keyed inbox frame a full queue drops is kept as a refusal (a wake with no key is not)" # One writer delivering to one session from two projects (#229): every # project numbers the writer's events on its own, so the second project's # event is below what the first delivered, and arrives all the same. From d16d9ec75b44188fa5b12b5d055fe0eeff4fcaac Mon Sep 17 00:00:00 2001 From: SSFSKIM Date: Sun, 18 Oct 2026 02:10:41 -0700 Subject: [PATCH 5/6] sminos bridge: the steer wake opens with the bridge's line, so a transcript's reader tells it from the human's prompt --- docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md | 2 +- hooks/mods/bridge.tsx | 8 +++++--- tests/mods/bridge.test.ts | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md b/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md index 0dba95302c1..df91621b692 100644 --- a/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md +++ b/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md @@ -462,7 +462,7 @@ M1 left them (with fw1/fw2). Changed rows are marked **[M2]**. | Op | Built | Arguments | Result | | --- | --- | --- | --- | -| `steer` | M2 **[M2]** **[fw1]** | `{text}` | `{row: }`. During the main turn: `$.session.append` of a user row (one text block), read at the main loop's next model request; the running tool goes on; mirrored as the transcript line `steer: ` (`$.ui.log`). Refused idle (`ok: false`, "no turn is running to steer: send it instead"). **[marker]** Refused, idle or not, for a text that read as an interrupt (`ok: false`, "that text is the interrupt marker, which would read as an interrupt: interrupt the session instead"): trimmed, either of the engine's markers whole (`[Request interrupted by user]`, `[Request interrupted by user for tool use]`), or one beginning `[Request interrupted by user from ` (the bridge's earlier record). A steer's row is kept as the interrupt's marker row is (`isMeta`, from the plugin), so it would be identical on disk and end the turn for every reader; a steer that only mentions the marker is a steer. `sminos steer` exits 1 with that reason. **[fw1]** The steer is bound to the main turn it was given in before the append is awaited. When that turn ends with `answer` and no main-loop request of it began after the row was in (or the turn ended while the append was pending, and the row then lands), the bridge submits **one wake prompt**. **[midturn]** The turn's first request never counts: it carries the conversation as the turn began, and a row appended between `turn.start` and it is not in it (live, 2.1.302, `tests/mods/live-delivery-cancel-probe.py` `restore`). The wake prompt (`asUser`) does not repeat the steer. **[fw2]** The wake is owned by the originating turn: it is decided once the turn has ended *and* every append given in it has settled (in, refused, or failed), and is issued at most once per turn: "I added a note to the conversation while you were finishing your answer. Read it and act on it." (`STEER_WAKE`); the next request reads the row. A turn ending `aborted`/`error`/`refusal` wakes nothing. One wake per turn, however many steers | +| `steer` | M2 **[M2]** **[fw1]** | `{text}` | `{row: }`. During the main turn: `$.session.append` of a user row (one text block), read at the main loop's next model request; the running tool goes on; mirrored as the transcript line `steer: ` (`$.ui.log`). Refused idle (`ok: false`, "no turn is running to steer: send it instead"). **[marker]** Refused, idle or not, for a text that read as an interrupt (`ok: false`, "that text is the interrupt marker, which would read as an interrupt: interrupt the session instead"): trimmed, either of the engine's markers whole (`[Request interrupted by user]`, `[Request interrupted by user for tool use]`), or one beginning `[Request interrupted by user from ` (the bridge's earlier record). A steer's row is kept as the interrupt's marker row is (`isMeta`, from the plugin), so it would be identical on disk and end the turn for every reader; a steer that only mentions the marker is a steer. `sminos steer` exits 1 with that reason. **[fw1]** The steer is bound to the main turn it was given in before the append is awaited. When that turn ends with `answer` and no main-loop request of it began after the row was in (or the turn ended while the append was pending, and the row then lands), the bridge submits **one wake prompt**. **[midturn]** The turn's first request never counts: it carries the conversation as the turn began, and a row appended between `turn.start` and it is not in it (live, 2.1.302, `tests/mods/live-delivery-cancel-probe.py` `restore`). The wake prompt (`asUser`) does not repeat the steer. **[fw2]** The wake is owned by the originating turn: it is decided once the turn has ended *and* every append given in it has settled (in, refused, or failed), and is issued at most once per turn: "[sminos bridge]\nA note was added to the conversation while you were finishing your answer. Read it and act on it." (`STEER_WAKE`; **[wake]** its first line names the bridge as the sender, as `DELIVERY_WAKE`'s does, so a reader of the transcript, the desk among them, tells the wake from the human's own prompt); the next request reads the row. A turn ending `aborted`/`error`/`refusal` wakes nothing. One wake per turn, however many steers | | `interrupt` | M2 **[M2]** **[fw1]** **[record]** | `{from?}` | `{turnId, stopped: [], running: []}`: `$.turn.abort` on the main turn, then `TaskStop` (`$.tool.call`) on each Bash call **attributable to that turn** and running at the abort whose result names the background task the abort moved it to (`running in background with ID: `; their results awaited at most 3 s). **[fw1]** Attributable: a main-loop call made during that turn, or a call of a foreground subagent spawned in it (nested foreground subagents inherit their parent's turn); a background subagent's calls never are. **[fw1]** `running` names each task whose TaskStop failed (an `isError` result or a rejection): the interrupt is incomplete when it is not empty. Idle: `{turnId: null, stopped: [], running: []}`. **[record]** A plugin's abort leaves the model no marker (Esc leaves `[Request interrupted by user]`), and a Bash call it moved to the background reads as still running there: so once the stops settle the interrupt appends two user rows. **[marker]** The first is Esc's marker whole, `[Request interrupted by user]`, one text block: a transcript's reader knows a turn's interrupt by a row whose whole text is the marker and by nothing else (the engine's own readers, 2.1.300 `He`/`pQn`, and MAWS's `INTERRUPT_MARKERS`), so the earlier one-row shape, `[Request interrupted by user from the desk]` with the task lines after it, read as no interrupt. The second is the note: `The interrupt came from the desk.` (`from: "desk"`, which the daemon's RPC sends) or `The interrupt came from outside the terminal.`, then a line per task: stopped, `Task , the Bash command the interrupt moved to the background, was then stopped: it is not running, and no notification will come for it.`; in `running`, `… could not be stopped: it may still be running.` The engine keeps a plugin's user row `isMeta: true` with `origin: {kind: "plugin", name}` whatever the plugin asks (2.1.300 `m6r`; it refuses any other user row): the model reads both rows, and no reader takes the note for a prompt. A marker a hook refuses is followed by no note. Both must be in before the turn ends: a next turn already queued takes its messages before it waits for any hook (2.1.300 `ir`/`cn`), so an append in `turn.complete` would miss its first request. The engine waits for a Bash call's result after a turn abort, so each Bash call of the turn being interrupted is held in the `tool.call` hook, after its result, until both rows are in (at most 10 s, `INTERRUPT_HOLD_MS`): they are kept before the moved call's result row, and the next request carries them after that result, the marker then the note (live, 2.1.302). An interrupt with no Bash call running appends at once after the abort, racing only the turn's own unwinding. A second interrupt of a turn being interrupted leaves the first's record alone (the engine refuses its abort). Each row is named by a `row-seen` notice (door `note`). The transcript mirrors the rows on one line as `interrupt: [Request interrupted by user] ` (a newline there draws as U+FFFD). Nothing for an idle interrupt or a refused abort | | `model` | M2 **[M2]** | `{model, effort?}` — `model` an id, a family alias, `"default"` or null; `effort` one of `low medium high xhigh max`, or null | `{model, effort}` now in force (null: the session's own). Rewritten on every **main-loop** `turn.step` request, subagents untouched; never `$.config.set`. A family alias (`haiku`, `sonnet`, `opus`, `fable`) is resolved through the session's `ANTHROPIC_DEFAULT__MODEL`; one it does not map is refused, naming the id to use. Emits `model`; kept by the child | | `compact` | M2 **[M2]** | `{instructions?}` | During the main turn `{queued: true, turnId}`: held, and run inside that turn's `turn.complete` (a second while one is held keeps the first). Idle `{started: true}`: run at once. Either way the outcome follows on the stream as `compact` events carrying this command's `id` | diff --git a/hooks/mods/bridge.tsx b/hooks/mods/bridge.tsx index f986eb34dfa..d68652d91df 100644 --- a/hooks/mods/bridge.tsx +++ b/hooks/mods/bridge.tsx @@ -86,9 +86,11 @@ const DRAIN_TRIES = 10 /** * The prompt that wakes a session whose turn answered before any model request * read a steer: the steer's row is already in the conversation, so this names - * it rather than repeating it. + * it rather than repeating it. Its first line names who sends it, as + * DELIVERY_WAKE's does, so a reader of the transcript tells it from the + * human's own prompt. */ -export const STEER_WAKE = 'I added a note to the conversation while you were finishing your answer. Read it and act on it.' +export const STEER_WAKE = '[sminos bridge]\nA note was added to the conversation while you were finishing your answer. Read it and act on it.' /** * The wake for a delivered row a stopped turn left unread, sent as an inbox * frame (the engine refuses a plugin's prompt after an Esc, not a message to @@ -326,7 +328,7 @@ function wakeForSteer($: EngineInterface, isDelivery: boolean) { } $.ui.log(`bridge: the wake for an unread steer was dropped: ${entered.drop}`, { to: 'debug' }) if (owed && STOPPED_DROPS.has(entered.drop)) { - emit({ type: 'inbox', text: `[sminos bridge]\n${STEER_WAKE}` }) + emit({ type: 'inbox', text: STEER_WAKE }) } }, (error: unknown) => { diff --git a/tests/mods/bridge.test.ts b/tests/mods/bridge.test.ts index d920aed11ac..3098c12e662 100644 --- a/tests/mods/bridge.test.ts +++ b/tests/mods/bridge.test.ts @@ -1746,7 +1746,7 @@ describe('deliver', () => { await h.complete('t2') await h.settle() expect(h.calls.filter((c) => c.op === 'submit').map((c) => (c.args as { text: string }).text)).toEqual([STEER_WAKE, STEER_WAKE]) - expect(h.ofType('inbox').map((e) => e.text)).toEqual([`[sminos bridge]\n${STEER_WAKE}`]) + expect(h.ofType('inbox').map((e) => e.text)).toEqual([STEER_WAKE]) }) test("a wake queued behind the human's prompt, whose turn read the delivered row before an Esc emptied the queue, sends no inbox frame; one no request read since still does", async () => { From b82f831cff6d762bbb82d8957641d9484c5ea263 Mon Sep 17 00:00:00 2001 From: SSFSKIM Date: Sun, 18 Oct 2026 02:10:42 -0700 Subject: [PATCH 6/6] doperpowers 7.176.0: a busy session reads a delivery at its next tool round; a delivery wakes a stopped session --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5de62af9080..6331c1af32f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "doperpowers", "description": "Emerges by humans, Converges by Agent", - "version": "7.175.0", + "version": "7.176.0", "source": "./", "author": { "name": "SSFSKIM", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 0b91de8805c..92e4f18d04f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "doperpowers", "description": "Emerges by humans, Converges by Agents", - "version": "7.175.0", + "version": "7.176.0", "author": { "name": "SSFSKIM", "email": "supremekim17@gmail.com" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 79373ca9c1a..90ec05e9ad4 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "doperpowers", - "version": "7.175.0", + "version": "7.176.0", "description": "One software-development methodology for coding agents, sized by the work: brainstorm, design, spec, TDD, review, ship — one spec that carries its plan of work as milestones: one milestone runs in order from the spec, several run a fresh subagent per milestone reviewed at each dependency frontier, and the same method runs unattended on the board loop.", "author": { "name": "SSFSKIM", diff --git a/package.json b/package.json index ded667b8c9b..83fe3d4dc9e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "doperpowers", - "version": "7.175.0", + "version": "7.176.0", "description": "Doperpowers skills and runtime bootstrap for coding agents", "type": "module", "keywords": [