From 2429515c453425e8433e653da81e4e1e4f6d5de1 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sat, 3 Oct 2026 09:29:07 +0200 Subject: [PATCH 1/4] (triggers): hold a single trigger while the CLI shows a dialog A single trigger was written without consulting the CLI descriptor, so a dialog (permission prompt, question) swallowed it. It now waits for the descriptor to read idle, like a chain step, up to its own deadline, and fails "not sent" with a dialog reason otherwise. Typed input stays as is: the terminal-input channel cannot tell a person from a driver and its keystrokes are what answer a dialog. Closes #379 --- .ai/contexts/trigger-watcher.md | 38 +++++++- CHANGELOG.md | 1 + docs/automation.md | 1 + test/trigger-single-readiness.test.js | 132 ++++++++++++++++++++++++++ trigger-watcher.js | 19 ++++ 5 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 test/trigger-single-readiness.test.js diff --git a/.ai/contexts/trigger-watcher.md b/.ai/contexts/trigger-watcher.md index 0cac351f..8e501668 100644 --- a/.ai/contexts/trigger-watcher.md +++ b/.ai/contexts/trigger-watcher.md @@ -846,7 +846,8 @@ session, which has no local descriptor). No new watcher: it reuses the cache the CLI reacted. `idle` alone covers a turn too fast for a poll to see `busy`; `waiting` is a permission dialog our Enter opened. A spinner on the level probe, or a status that began earlier, proves nothing. Otherwise the - existing recovery applies (one bare ` `, only into a free composer, same + existing recovery applies (one bare ` +`, only into a free composer, same window), and the reaction is looked for again. Still nothing: `confirmed: false`. - **The recovery Enter is never written while the descriptor reads `waiting` @@ -942,9 +943,7 @@ state was the cause. sessionExited, waited_ms, lastStatus, waitingSeen }`. `lastStatus` is the status at the last sample; `waitingSeen` is true when `waiting` was sampled within the last settle window before the end. -- **Single triggers have the same exposure and it is not addressed here.** - They keep their own `wait` field (`idle` by the level probe, or `none`) and - no descriptor wait; they can still be typed into a busy composer. +- **Single triggers: see "Readiness before a single trigger" below.** - **Busy-fall authority (#360).** `waitForBusyFall` receives the Enter's timestamp. A descriptor `idle` with `statusUpdatedAt >= enterAt`, held for the settle window, ends the wait even when `_cliBusy` is stuck true. An idle @@ -980,6 +979,37 @@ within `windowMs` (the busy-fall settle window, as in `waitForCliIdleAfter`). Tests: `test/trigger-blocked-session.test.js`. +### Readiness before a single trigger (issue #379) + +A single `command` goes through the same `waitForCliIdleAfter` as a chain +step (afterMs `-Infinity`, the busy-fall settle window, the trigger's own +deadline `timeout_ms`), after `waitForIdle` and `waitForComposerFree` and the +liveness re-check, right before `submitWithVerify`. There is no parallel +mechanism: the not-ready results map to the same reasons +(`REASON_DIALOG_OPEN`, `REASON_CLI_BUSY`, `REASON_CLI_NOT_IDLE`), `error` is +`not sent`, `submitted` `no`. + +- It applies with `wait: "none"` too, like chain step 0. This closes the hole + `waitForIdle` leaves: it samples the descriptor only while `_cliBusy` is true, + so a dialog shown while `_cliBusy` reads false was written into. +- No usable descriptor at the first read: no wait (`available: false`), as + before. +- A session whose descriptor is held `busy` by delegated agents (#360, a CLI + limit) now fails a single trigger at its deadline instead of typing into a + busy composer, like a chain. + +Typed input (`sendInput`, IPC `terminal-input`) is deliberately NOT held back. +The channel is fire-and-forget (`ipcMain.on`, no reply to carry an error), is +fed only by the renderer, and carries keystrokes, pastes, drops and the +context-menu paste on the same call with no marker telling a person from a +driver. A driver acting through the renderer (devtools, CDP) is the same call. +Holding it on `waiting` would also block the keystrokes that answer the +dialog. The only programmatic path that can be told apart is the trigger +watcher, which is held above. `handleTerminalInput` has no descriptor access +either (the descriptor is read through the trigger context). + +Tests: `test/trigger-single-readiness.test.js`. + ### Why `composerEmptyAfterWrite` cannot be made to prove submission, even by feeding it our own writes A proposal, considered and rejected 2026-09-04: since `submitToPty` writes diff --git a/CHANGELOG.md b/CHANGELOG.md index e96968b8..e0ef3908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ### New - With Debug mode on, the activity trace now records how hard each terminal is being drawn: once a second per session, how many writes reached it, how large they were and how often its glyph atlas was rebuilt, to tell a legitimately busy terminal from a runaway one. (#175) ### Changed +- A single trigger is no longer typed while the CLI reads busy or waiting on a dialog such as a permission prompt or a question, even without `wait: "idle"`: it waits for the CLI to be at its prompt, up to its `timeout_ms`, then fails `not sent` with a `reason` that says a dialog is open instead of being written into it. Without a readable CLI descriptor it is written as before. Input you type yourself in the terminal is never held back. (#379) - A trigger that gave up waiting for a session now says, in its result file's `reason`, when the session was blocked on a dialog such as a permission prompt or a question: for a single trigger, a chain's first wait, and a chain step whose turn never finished. Without a dialog the result is as before. (#379) ## v0.0.87 — 2026-10-02 diff --git a/docs/automation.md b/docs/automation.md index 15a48c79..0f5c2342 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -447,6 +447,7 @@ The two reserved values mean opposite things: `partial: false` for a `chain`. A session reports itself busy for as long as any subagent runs, so `idle` is often unreachable; `not sent` there tells the caller the payload never left. +- A single `command` is held the same way, with or without `wait`: after any `wait: "idle"` and the politeness wait, it is not written until the descriptor reads `idle`, up to `timeout_ms`. If it still reads `waiting` then, the result is `not sent` with `reason` *the CLI reports a dialog open (waiting); nothing was written into it*; `busy` gives *the CLI still reported a turn running (busy) at the deadline; nothing was written*. Without a readable descriptor at the first read nothing is waited for. Keystrokes typed in the terminal are never held back: they are how a dialog is answered. - A chain step is held until the CLI's descriptor reads `idle`, up to the step's deadline. If it still reads `busy` or `waiting` (or any status other than `idle`) then, the step is not written: `not sent` for the first step, `chain timeout` for a later one, with the cause in `reason`. A session with delegated agents running keeps the parent descriptor `busy`, so such a chain fails cleanly instead of typing into a busy composer. Without a readable descriptor at the first read nothing is waited for, but a step is never written once its own deadline has passed (it then fails `not sent` or `chain timeout`). - When the wait ends because the session never got there and the CLI's descriptor read `waiting` (a dialog is open: a permission prompt or a diff --git a/test/trigger-single-readiness.test.js b/test/trigger-single-readiness.test.js new file mode 100644 index 00000000..70f18b10 --- /dev/null +++ b/test/trigger-single-readiness.test.js @@ -0,0 +1,132 @@ +// test/trigger-single-readiness.test.js +// +// A single trigger is held by the CLI descriptor like a chain step: not written +// while it reads busy or waiting (a dialog). See +// .ai/contexts/trigger-watcher.md, "Readiness before a single trigger". +'use strict'; + +process.env.SWITCHBOARD_SUBMIT_ENTER_DELAY_MS = '1'; +process.env.SWITCHBOARD_SUBMIT_VERIFY_MS = '300'; +process.env.SWITCHBOARD_BUSY_FALL_SETTLE_MS = '50'; +process.env.SWITCHBOARD_BUSY_RISE_WAIT_MS = '100'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { start } = require('../trigger-watcher'); + +const silent = { info() {}, warn() {}, error() {}, debug() {} }; + +function session(sessionId, { withDescriptor = true } = {}) { + const written = []; + const desc = { status: 'idle', statusUpdatedAt: Date.now() - 10_000 }; + const ptyProcess = { + pid: process.pid, + write(data) { written.push({ data, at: Date.now() }); }, + }; + const ctx = { + log: silent, + getPtyForSession: (id) => (id === sessionId ? { ptyProcess } : null), + isSessionBusy: () => false, + isPtyAlive: () => true, + getComposerState: () => ({ pending: 0, lastInputAt: 0 }), + }; + if (withDescriptor) ctx.getCliStatus = (id) => (id === sessionId ? { ...desc } : undefined); + return { ctx, written, desc }; +} + +function set(desc, status) { + desc.status = status; + desc.statusUpdatedAt = Date.now(); +} + +async function run(payload, s) { + const tmp = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'sw-trigger-single-ready-'))); + process.env.SWITCHBOARD_TRIGGERS_DIR = tmp; + const watcher = start(s.ctx); + try { + fs.writeFileSync(path.join(tmp, payload.sessionId + '.json'), JSON.stringify(payload), 'utf8'); + const resultPath = path.join(tmp, 'processed', payload.sessionId + '.result.json'); + const limit = Date.now() + 15000; + while (!fs.existsSync(resultPath)) { + if (Date.now() > limit) throw new Error('no result file'); + await new Promise((r) => setTimeout(r, 20)); + } + await new Promise((r) => setTimeout(r, 20)); + return JSON.parse(fs.readFileSync(resultPath, 'utf8')); + } finally { + watcher.close(); + delete process.env.SWITCHBOARD_TRIGGERS_DIR; + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +const trig = (id, extra = {}) => ({ sessionId: id, command: 'hello', timeout_ms: 800, ...extra }); + +test('a dialog open, no wait: nothing is written and the result says a dialog is open', async () => { + const id = 'sess-single-ready-dialog-' + Date.now(); + const s = session(id); + set(s.desc, 'waiting'); + const r = await run(trig(id), s); + assert.deepEqual(s.written, []); + assert.equal(r.ok, false); + assert.equal(r.error, 'not sent'); + assert.equal(r.submitted, 'no'); + assert.match(r.reason, /dialog open \(waiting\); nothing was written into it/); +}); + +test('a dialog open while the session reads not busy, wait idle: nothing is written, dialog reason', async () => { + const id = 'sess-single-ready-dialog-idle-' + Date.now(); + const s = session(id); + set(s.desc, 'waiting'); + const r = await run(trig(id, { wait: 'idle' }), s); + assert.deepEqual(s.written, []); + assert.equal(r.error, 'not sent'); + assert.match(r.reason, /dialog open \(waiting\); nothing was written into it/); +}); + +test('a dialog that closes before the deadline: the trigger is written once the descriptor reads idle', async () => { + const id = 'sess-single-ready-closes-' + Date.now(); + const s = session(id); + set(s.desc, 'waiting'); + let idleAt = null; + setTimeout(() => { set(s.desc, 'idle'); idleAt = Date.now(); }, 300); + const r = await run(trig(id, { timeout_ms: 3000 }), s); + assert.ok(idleAt, 'the descriptor never went idle'); + assert.equal(s.written[0].data, 'hello'); + assert.ok(s.written[0].at >= idleAt, `written ${idleAt - s.written[0].at} ms before the descriptor read idle`); + assert.equal(r.ok, true); +}); + +test('descriptor busy until the deadline: nothing is written, the busy reason', async () => { + const id = 'sess-single-ready-busy-' + Date.now(); + const s = session(id); + set(s.desc, 'busy'); + const r = await run(trig(id), s); + assert.deepEqual(s.written, []); + assert.equal(r.error, 'not sent'); + assert.match(r.reason, /still reported a turn running \(busy\)/); +}); + +test('descriptor idle: written at once', async () => { + const id = 'sess-single-ready-idle-' + Date.now(); + const s = session(id); + const started = Date.now(); + const r = await run(trig(id), s); + assert.equal(s.written[0].data, 'hello'); + assert.ok(s.written[0].at - started < 700); + assert.equal(r.ok, true); +}); + +test('no descriptor: written as before, nothing waits', async () => { + const id = 'sess-single-ready-none-' + Date.now(); + const s = session(id, { withDescriptor: false }); + const started = Date.now(); + const r = await run(trig(id), s); + assert.equal(s.written[0].data, 'hello'); + assert.ok(s.written[0].at - started < 700); + assert.equal(r.ok, true); +}); diff --git a/trigger-watcher.js b/trigger-watcher.js index 6eae0277..979fe09d 100644 --- a/trigger-watcher.js +++ b/trigger-watcher.js @@ -1124,6 +1124,25 @@ async function processTriggerFile(name, ctx, triggersDir, processedDir, onEntryR return; } + // see .ai/contexts/trigger-watcher.md, "Readiness before a single trigger" + const ready = await waitForCliIdleAfter(sessionId, ctx, -Infinity, commandDeadline, getBusyFallSettleMs()); + waited_ms += ready.waited_ms; + if (ready.sessionExited) { + ctx.log.warn('[trigger-watcher] Session exited waiting for the CLI to be ready:', sessionId); + await writeResult({ ok: false, error: 'session exited during wait', sessionId, waited_ms }); + return; + } + if (!ready.available) { + ctx.log.info(`[trigger-watcher] No usable CLI descriptor for ${sessionId} at the start of the readiness wait`); + } + if (ready.timedOut) { + const dialog = ready.waitingSeen || ready.lastStatus === 'waiting'; + const reason = dialog ? REASON_DIALOG_OPEN : (ready.lastStatus === 'busy' ? REASON_CLI_BUSY : REASON_CLI_NOT_IDLE); + ctx.log.warn(`[trigger-watcher] CLI not ready (${ready.lastStatus}) after ${ready.waited_ms} ms, nothing sent:`, sessionId); + await writeResult({ ok: false, submitted: SUBMITTED_NO, error: ERROR_NOT_SENT, reason, sessionId, waited_ms }); + return; + } + // Write to PTY: text, then Enter as a discrete keypress (see submitToPty), // then verify the submission actually started a turn — retrying the Enter // once if busy is never observed (the 2026-06-04 "text stuck in From 17b2e20fbc5ce228c7aaf24f2a6cc51fce09dd95 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sat, 3 Oct 2026 10:03:25 +0200 Subject: [PATCH 2/4] (triggers): keep wait none writing now, hold only on a dialog Review of the single-trigger readiness: wait "none" keeps its write-now meaning (the CLI queues a prompt written while busy, which also covers sessions held busy by background agents) and holds only while the CLI shows a dialog. wait "idle" keeps the full readiness rule. An idle descriptor older than the settle window is ready on the first read, and the settle is capped at the time left, so a short timeout_ms on an idle session writes instead of reporting that it never went idle. A pre-write deadline guard matches the chain's. Tests cover the settle, the exited-session and lost-descriptor branches, and the deadline guard. Closes #379 --- .ai/contexts/trigger-watcher.md | 48 +++--- CHANGELOG.md | 2 +- docs/automation.md | 44 +++++- test/trigger-single-readiness.test.js | 203 ++++++++++++++++++++++---- trigger-watcher.js | 76 ++++++++-- 5 files changed, 299 insertions(+), 74 deletions(-) diff --git a/.ai/contexts/trigger-watcher.md b/.ai/contexts/trigger-watcher.md index 8e501668..3a66ac42 100644 --- a/.ai/contexts/trigger-watcher.md +++ b/.ai/contexts/trigger-watcher.md @@ -846,8 +846,7 @@ session, which has no local descriptor). No new watcher: it reuses the cache the CLI reacted. `idle` alone covers a turn too fast for a poll to see `busy`; `waiting` is a permission dialog our Enter opened. A spinner on the level probe, or a status that began earlier, proves nothing. Otherwise the - existing recovery applies (one bare ` -`, only into a free composer, same + existing recovery applies (one bare ` `, only into a free composer, same window), and the reaction is looked for again. Still nothing: `confirmed: false`. - **The recovery Enter is never written while the descriptor reads `waiting` @@ -981,22 +980,35 @@ Tests: `test/trigger-blocked-session.test.js`. ### Readiness before a single trigger (issue #379) -A single `command` goes through the same `waitForCliIdleAfter` as a chain -step (afterMs `-Infinity`, the busy-fall settle window, the trigger's own -deadline `timeout_ms`), after `waitForIdle` and `waitForComposerFree` and the -liveness re-check, right before `submitWithVerify`. There is no parallel -mechanism: the not-ready results map to the same reasons -(`REASON_DIALOG_OPEN`, `REASON_CLI_BUSY`, `REASON_CLI_NOT_IDLE`), `error` is -`not sent`, `submitted` `no`. - -- It applies with `wait: "none"` too, like chain step 0. This closes the hole - `waitForIdle` leaves: it samples the descriptor only while `_cliBusy` is true, - so a dialog shown while `_cliBusy` reads false was written into. -- No usable descriptor at the first read: no wait (`available: false`), as - before. -- A session whose descriptor is held `busy` by delegated agents (#360, a CLI - limit) now fails a single trigger at its deadline instead of typing into a - busy composer, like a chain. +The two `wait` values keep their documented meaning; only the dialog is new +for `none`. + +- **`wait: "idle"`**: the same `waitForCliIdleAfter` as a chain step (afterMs + `-Infinity`, the trigger's own deadline `timeout_ms`), after `waitForIdle`, + `waitForComposerFree` and the liveness re-check, right before + `submitWithVerify`. No parallel mechanism: the not-ready results map to the + chain reasons (`REASON_DIALOG_OPEN`, `REASON_CLI_BUSY`, `REASON_CLI_NOT_IDLE`) + plus `REASON_IDLE_UNSETTLED` when the last read was `idle` but too late to + settle, never "never reported idle" for an idle descriptor. `error` is + `not sent`, `submitted` `no`. A session whose descriptor is held `busy` by + background agents (#360, a CLI limit) fails at the deadline; `none` is the + value for it. +- **Settle.** `waitForCliIdleAfter` counts an `idle` first read from its + `statusUpdatedAt`, so one older than the settle window is ready on that read + (no flat +300 ms on every trigger; a later new stamp still counts from when + it was seen). The single path caps the settle at the time left to the + deadline, so a `timeout_ms` under the settle on an idle session writes. + Poll granularity is 100 ms, so a fresh idle with a very short deadline can + still end `REASON_IDLE_UNSETTLED`. +- **`wait: "none"`** writes now, the CLI queues a prompt written while busy. + `waitForNoDialog(sessionId, ctx, deadline)` holds only while the descriptor + reads `waiting` (a descriptor lost after it read `waiting` keeps the hold), + with no settle once it stops, and fails `not sent` + `REASON_DIALOG_OPEN` at + the deadline. `busy`, `idle` or no descriptor write at once. This closes the + hole `waitForIdle` leaves: it samples the descriptor only while `_cliBusy` is + true, so a dialog shown while `_cliBusy` reads false was written into. +- **Deadline.** Both paths refuse to write once `Date.now() >= commandDeadline` + (`REASON_DEADLINE_BEFORE_WRITE`), descriptor or not, as chains do. Typed input (`sendInput`, IPC `terminal-input`) is deliberately NOT held back. The channel is fire-and-forget (`ipcMain.on`, no reply to carry an error), is diff --git a/CHANGELOG.md b/CHANGELOG.md index e0ef3908..e7835674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ### New - With Debug mode on, the activity trace now records how hard each terminal is being drawn: once a second per session, how many writes reached it, how large they were and how often its glyph atlas was rebuilt, to tell a legitimately busy terminal from a runaway one. (#175) ### Changed -- A single trigger is no longer typed while the CLI reads busy or waiting on a dialog such as a permission prompt or a question, even without `wait: "idle"`: it waits for the CLI to be at its prompt, up to its `timeout_ms`, then fails `not sent` with a `reason` that says a dialog is open instead of being written into it. Without a readable CLI descriptor it is written as before. Input you type yourself in the terminal is never held back. (#379) +- A single trigger is no longer typed into a dialog such as a permission prompt or a question: with `wait: "none"` (write now, the default) it holds while the CLI shows a dialog, and with `wait: "idle"` until the CLI is at its prompt, up to its `timeout_ms`; then it fails `not sent` with a `reason` that says a dialog is open instead of being written into it. `wait: "none"` still writes at once while the CLI is busy. Without a readable CLI descriptor it is written as before. Input you type yourself in the terminal is never held back. (#379) - A trigger that gave up waiting for a session now says, in its result file's `reason`, when the session was blocked on a dialog such as a permission prompt or a question: for a single trigger, a chain's first wait, and a chain step whose turn never finished. Without a dialog the result is as before. (#379) ## v0.0.87 — 2026-10-02 diff --git a/docs/automation.md b/docs/automation.md index 0f5c2342..9adfc11e 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -191,11 +191,16 @@ JSON file into `~/.switchboard/triggers/` (or `SWITCHBOARD_TRIGGERS_DIR`): Any open session qualifies, plain terminals included. - `command` — written to the terminal, followed by a separate Enter keypress. At most 4 KB, and no CR, LF, NUL or ESC. -- `wait` — `"none"` (the default) does not wait for the session to stop being - busy; `"idle"` does. Neither writes into a prompt that holds unsubmitted - input — see [Politeness](#politeness-switchboard-never-types-over-you) — so - `"none"` can still wait, up to `timeout_ms`. Use `"idle"` for anything that - must not interrupt a response being written. +- `wait` — `"none"` (the default) writes now: it does not wait for the session + to stop being busy, and a prompt written while the CLI is busy is queued by + the CLI. It holds in two cases only, each up to `timeout_ms`: the prompt holds + unsubmitted input — see + [Politeness](#politeness-switchboard-never-types-over-you) — or the CLI shows + a dialog (a permission prompt, a question), which would swallow the text. At + the deadline it fails `not sent`. `"idle"` waits for the CLI to be at its + prompt first, and is the value for anything that must not interrupt a + response being written; a session held busy by background agents never gets + there, so it fails `not sent` at the deadline. - `timeout_ms` — optional bound on all the waiting: idle **and** politeness. A positive integer up to 600 000; default 300 000. On a `chain` it is the deadline for the **whole chain** — see below. @@ -344,8 +349,9 @@ sends when no turn started — on a half-typed sentence, that Enter would submit it. When politeness never allows a write, the result is `{ "ok": false, "submitted": "no", "error": "not sent", "reason": "…" }`. -**What this costs `wait: "none"`.** It does not mean "write now": against a -non-empty prompt it waits, bounded only by `timeout_ms`. All that time the +**What this costs `wait: "none"`.** It writes now unless the prompt is +non-empty or the CLI shows a dialog; in those cases it waits, bounded only by +`timeout_ms`. All that time the trigger holds one of the watcher's 8 concurrent slots (`MAX_INFLIGHT`), so a few triggers aimed at sessions whose user walked away mid-sentence can stall the queue for everyone. Give triggers that would rather give up a short @@ -447,7 +453,27 @@ The two reserved values mean opposite things: `partial: false` for a `chain`. A session reports itself busy for as long as any subagent runs, so `idle` is often unreachable; `not sent` there tells the caller the payload never left. -- A single `command` is held the same way, with or without `wait`: after any `wait: "idle"` and the politeness wait, it is not written until the descriptor reads `idle`, up to `timeout_ms`. If it still reads `waiting` then, the result is `not sent` with `reason` *the CLI reports a dialog open (waiting); nothing was written into it*; `busy` gives *the CLI still reported a turn running (busy) at the deadline; nothing was written*. Without a readable descriptor at the first read nothing is waited for. Keystrokes typed in the terminal are never held back: they are how a dialog is answered. +- A single `command` with `wait: "idle"` is held like a chain step: after the idle + wait and the politeness wait, it is not written until the CLI's descriptor + reads `idle`, up to `timeout_ms`. An `idle` stamped before the settle window + is ready at once; a more recent one settles for at most the time left. If it + still reads `waiting` then, the result is `not sent` with `reason` *the CLI + reports a dialog open (waiting); nothing was written into it*; `busy` gives + *the CLI still reported a turn running (busy) at the deadline; nothing was + written*, and a session whose background agents keep the parent descriptor + `busy` (#360) always ends so: use `wait: "none"` for it. An `idle` that + appeared only at the deadline gives *the CLI reported idle only at the + deadline, too late to settle; nothing was written*. Without a readable + descriptor at the first read nothing is waited for. +- A single `command` with `wait: "none"` keeps its write-now meaning: `busy`, + `idle` or an unreadable descriptor write at once, with no settle. The only + hold is a dialog: while the descriptor reads `waiting`, nothing is written, + and at `timeout_ms` the result is `not sent` with the dialog reason above. A + descriptor lost after it read `waiting` keeps the hold. +- Every single `command` is also never written once its `timeout_ms` has passed + (`not sent`, *the step deadline passed before it could be written; nothing + was written*). Keystrokes typed in the terminal are never held back: they are + how a dialog is answered. - A chain step is held until the CLI's descriptor reads `idle`, up to the step's deadline. If it still reads `busy` or `waiting` (or any status other than `idle`) then, the step is not written: `not sent` for the first step, `chain timeout` for a later one, with the cause in `reason`. A session with delegated agents running keeps the parent descriptor `busy`, so such a chain fails cleanly instead of typing into a busy composer. Without a readable descriptor at the first read nothing is waited for, but a step is never written once its own deadline has passed (it then fails `not sent` or `chain timeout`). - When the wait ends because the session never got there and the CLI's descriptor read `waiting` (a dialog is open: a permission prompt or a @@ -469,6 +495,8 @@ Both count every wait the trigger spent, at different scopes: - A `command` result carries `waited_ms`: the `wait: "idle"` wait (0 with `wait: "none"` or an idle session), plus the politeness wait, plus the + readiness wait (for `wait: "idle"`, the time spent until the descriptor read + idle; for `wait: "none"`, the time a dialog held it, else 0), plus the submission verification and its retry, if any. - A `chain` result carries `total_waited_ms` for the whole chain, and a `waited_ms` in each `steps[]` entry: that step's politeness wait, its diff --git a/test/trigger-single-readiness.test.js b/test/trigger-single-readiness.test.js index 70f18b10..0e9b090e 100644 --- a/test/trigger-single-readiness.test.js +++ b/test/trigger-single-readiness.test.js @@ -1,8 +1,9 @@ // test/trigger-single-readiness.test.js // -// A single trigger is held by the CLI descriptor like a chain step: not written -// while it reads busy or waiting (a dialog). See -// .ai/contexts/trigger-watcher.md, "Readiness before a single trigger". +// A single trigger and the CLI descriptor: `wait: "idle"` holds it until the +// descriptor reads idle, `wait: "none"` (write now) holds it only while a +// dialog is open. See .ai/contexts/trigger-watcher.md, "Readiness before a +// single trigger". 'use strict'; process.env.SWITCHBOARD_SUBMIT_ENTER_DELAY_MS = '1'; @@ -16,12 +17,13 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); -const { start } = require('../trigger-watcher'); +const { start, waitForCliIdleAfter } = require('../trigger-watcher'); const silent = { info() {}, warn() {}, error() {}, debug() {} }; -function session(sessionId, { withDescriptor = true } = {}) { +function session(sessionId, { withDescriptor = true, getCliStatus = null, onComposerRead = null } = {}) { const written = []; + const exited = { v: false }; const desc = { status: 'idle', statusUpdatedAt: Date.now() - 10_000 }; const ptyProcess = { pid: process.pid, @@ -29,12 +31,16 @@ function session(sessionId, { withDescriptor = true } = {}) { }; const ctx = { log: silent, - getPtyForSession: (id) => (id === sessionId ? { ptyProcess } : null), + getPtyForSession: (id) => (id === sessionId && !exited.v ? { ptyProcess } : null), isSessionBusy: () => false, isPtyAlive: () => true, - getComposerState: () => ({ pending: 0, lastInputAt: 0 }), + getComposerState: () => { + if (onComposerRead) onComposerRead(exited); + return { pending: 0, lastInputAt: 0 }; + }, }; - if (withDescriptor) ctx.getCliStatus = (id) => (id === sessionId ? { ...desc } : undefined); + if (getCliStatus) ctx.getCliStatus = (id) => (id === sessionId ? getCliStatus() : undefined); + else if (withDescriptor) ctx.getCliStatus = (id) => (id === sessionId ? { ...desc } : undefined); return { ctx, written, desc }; } @@ -65,8 +71,15 @@ async function run(payload, s) { } const trig = (id, extra = {}) => ({ sessionId: id, command: 'hello', timeout_ms: 800, ...extra }); +const DIALOG = /dialog open \(waiting\); nothing was written into it/; -test('a dialog open, no wait: nothing is written and the result says a dialog is open', async () => { +async function withSettle(ms, fn) { + const before = process.env.SWITCHBOARD_BUSY_FALL_SETTLE_MS; + process.env.SWITCHBOARD_BUSY_FALL_SETTLE_MS = String(ms); + try { return await fn(); } finally { process.env.SWITCHBOARD_BUSY_FALL_SETTLE_MS = before; } +} + +test('none, a dialog open: nothing is written and the result says a dialog is open', async () => { const id = 'sess-single-ready-dialog-' + Date.now(); const s = session(id); set(s.desc, 'waiting'); @@ -75,58 +88,184 @@ test('a dialog open, no wait: nothing is written and the result says a dialog is assert.equal(r.ok, false); assert.equal(r.error, 'not sent'); assert.equal(r.submitted, 'no'); - assert.match(r.reason, /dialog open \(waiting\); nothing was written into it/); + assert.match(r.reason, DIALOG); +}); + +test('none, a dialog that closes: written as soon as the descriptor stops reading waiting, without a settle', async () => { + await withSettle(2000, async () => { + const id = 'sess-single-ready-none-closes-' + Date.now(); + const s = session(id); + set(s.desc, 'waiting'); + let closedAt = null; + setTimeout(() => { set(s.desc, 'busy'); closedAt = Date.now(); }, 300); + const r = await run(trig(id, { timeout_ms: 5000 }), s); + assert.ok(closedAt, 'the dialog never closed'); + assert.equal(s.written[0].data, 'hello'); + assert.ok(s.written[0].at >= closedAt); + assert.ok(s.written[0].at - closedAt < 1000, 'the write waited for a settle'); + assert.equal(r.ok, true); + }); +}); + +test('none, descriptor busy: written at once, the CLI queues it', async () => { + const id = 'sess-single-ready-none-busy-' + Date.now(); + const s = session(id); + set(s.desc, 'busy'); + const started = Date.now(); + const r = await run(trig(id), s); + assert.equal(s.written[0].data, 'hello'); + assert.ok(s.written[0].at - started < 500); + assert.equal(r.ok, true); }); -test('a dialog open while the session reads not busy, wait idle: nothing is written, dialog reason', async () => { +test('none, descriptor idle: written with no settle delay', async () => { + await withSettle(2000, async () => { + const id = 'sess-single-ready-none-idle-' + Date.now(); + const s = session(id); + set(s.desc, 'idle'); + const started = Date.now(); + const r = await run(trig(id, { timeout_ms: 5000 }), s); + assert.ok(s.written[0].at - started < 1000); + assert.equal(r.ok, true); + }); +}); + +test('none, no descriptor: written as before', async () => { + const id = 'sess-single-ready-none-nodesc-' + Date.now(); + const s = session(id, { withDescriptor: false }); + const r = await run(trig(id), s); + assert.equal(s.written[0].data, 'hello'); + assert.equal(r.ok, true); +}); + +test('none, a dialog read once then the descriptor lost: still held, dialog reason at the deadline', async () => { + const id = 'sess-single-ready-none-lost-' + Date.now(); + let reads = 0; + const s = session(id, { getCliStatus: () => (reads++ === 0 ? { status: 'waiting', statusUpdatedAt: Date.now() } : undefined) }); + const r = await run(trig(id, { timeout_ms: 500 }), s); + assert.deepEqual(s.written, []); + assert.match(r.reason, DIALOG); +}); + +test('none, the session exits while a dialog holds the trigger: nothing written, session exited', async () => { + const id = 'sess-single-ready-none-exit-' + Date.now(); + const s = session(id, { onComposerRead: (e) => { e.v = true; } }); + set(s.desc, 'waiting'); + const r = await run(trig(id), s); + assert.deepEqual(s.written, []); + assert.equal(r.error, 'session exited during wait'); +}); + +test('idle, a dialog open while the session reads not busy: nothing is written, dialog reason', async () => { const id = 'sess-single-ready-dialog-idle-' + Date.now(); const s = session(id); set(s.desc, 'waiting'); const r = await run(trig(id, { wait: 'idle' }), s); assert.deepEqual(s.written, []); assert.equal(r.error, 'not sent'); - assert.match(r.reason, /dialog open \(waiting\); nothing was written into it/); + assert.match(r.reason, DIALOG); }); -test('a dialog that closes before the deadline: the trigger is written once the descriptor reads idle', async () => { +test('idle, a dialog that closes before the deadline: written once the descriptor reads idle', async () => { const id = 'sess-single-ready-closes-' + Date.now(); const s = session(id); set(s.desc, 'waiting'); let idleAt = null; setTimeout(() => { set(s.desc, 'idle'); idleAt = Date.now(); }, 300); - const r = await run(trig(id, { timeout_ms: 3000 }), s); + const r = await run(trig(id, { wait: 'idle', timeout_ms: 3000 }), s); assert.ok(idleAt, 'the descriptor never went idle'); assert.equal(s.written[0].data, 'hello'); assert.ok(s.written[0].at >= idleAt, `written ${idleAt - s.written[0].at} ms before the descriptor read idle`); assert.equal(r.ok, true); }); -test('descriptor busy until the deadline: nothing is written, the busy reason', async () => { +test('idle, descriptor busy until the deadline: nothing is written, the busy reason', async () => { const id = 'sess-single-ready-busy-' + Date.now(); const s = session(id); set(s.desc, 'busy'); - const r = await run(trig(id), s); + const r = await run(trig(id, { wait: 'idle' }), s); assert.deepEqual(s.written, []); assert.equal(r.error, 'not sent'); assert.match(r.reason, /still reported a turn running \(busy\)/); }); -test('descriptor idle: written at once', async () => { - const id = 'sess-single-ready-idle-' + Date.now(); - const s = session(id); - const started = Date.now(); - const r = await run(trig(id), s); - assert.equal(s.written[0].data, 'hello'); - assert.ok(s.written[0].at - started < 700); - assert.equal(r.ok, true); +test('idle, a status read once then lost: not idle until the deadline, nothing written', async () => { + const id = 'sess-single-ready-idle-lost-' + Date.now(); + let reads = 0; + const s = session(id, { getCliStatus: () => (reads++ === 0 ? { status: 'busy', statusUpdatedAt: Date.now() } : undefined) }); + const r = await run(trig(id, { wait: 'idle', timeout_ms: 500 }), s); + assert.deepEqual(s.written, []); + assert.equal(r.error, 'not sent'); + assert.match(r.reason, /still reported a turn running \(busy\)/); }); -test('no descriptor: written as before, nothing waits', async () => { - const id = 'sess-single-ready-none-' + Date.now(); - const s = session(id, { withDescriptor: false }); - const started = Date.now(); - const r = await run(trig(id), s); - assert.equal(s.written[0].data, 'hello'); - assert.ok(s.written[0].at - started < 700); - assert.equal(r.ok, true); +test('idle, the session exits during the readiness wait: nothing written, session exited', async () => { + const id = 'sess-single-ready-idle-exit-' + Date.now(); + const s = session(id, { onComposerRead: (e) => { e.v = true; } }); + const r = await run(trig(id, { wait: 'idle' }), s); + assert.deepEqual(s.written, []); + assert.equal(r.error, 'session exited during wait'); +}); + +test('idle, an idle descriptor read long ago is ready at once, whatever the settle', async () => { + await withSettle(2000, async () => { + const id = 'sess-single-ready-idle-old-' + Date.now(); + const s = session(id); + const started = Date.now(); + const r = await run(trig(id, { wait: 'idle', timeout_ms: 5000 }), s); + assert.ok(s.written[0].at - started < 1000, 'the write waited for a settle'); + assert.equal(r.ok, true); + }); +}); + +test('idle, timeout_ms below the settle on an idle session: written, never "never reported idle"', async () => { + await withSettle(300, async () => { + const id = 'sess-single-ready-short-' + Date.now(); + const s = session(id); + const r = await run(trig(id, { wait: 'idle', timeout_ms: 250 }), s); + assert.equal(s.written[0] && s.written[0].data, 'hello', JSON.stringify(r)); + assert.equal(r.ok, true); + }); +}); + +test('idle, an idle too recent for the settle: the settle is capped at the deadline, so it is written', async () => { + await withSettle(2000, async () => { + const id = 'sess-single-ready-cap-' + Date.now(); + const s = session(id); + s.desc.statusUpdatedAt = Date.now() - 150; + const r = await run(trig(id, { wait: 'idle', timeout_ms: 400 }), s); + assert.equal(s.written[0] && s.written[0].data, 'hello', JSON.stringify(r)); + assert.equal(r.ok, true); + }); +}); + +test('idle, an idle that keeps restarting never settles: the unsettled reason, nothing written', async () => { + const id = 'sess-single-ready-unsettled-' + Date.now(); + const s = session(id, { getCliStatus: () => ({ status: 'idle', statusUpdatedAt: Date.now() }) }); + const r = await run(trig(id, { wait: 'idle', timeout_ms: 500 }), s); + assert.deepEqual(s.written, []); + assert.equal(r.error, 'not sent'); + assert.match(r.reason, /idle only at the deadline, too late to settle/); +}); + +test('the deadline passed before the write: nothing is written, with or without a descriptor', async () => { + for (const withDescriptor of [true, false]) { + const id = 'sess-single-ready-deadline-' + withDescriptor + Date.now(); + const spin = () => { const end = Date.now() + 15; while (Date.now() < end); }; + const s = session(id, { withDescriptor, onComposerRead: spin }); + const r = await run(trig(id, { timeout_ms: 1 }), s); + assert.deepEqual(s.written, []); + assert.equal(r.error, 'not sent'); + assert.match(r.reason, /deadline passed before it could be written/); + } +}); + +test('waitForCliIdleAfter: an idle stamped before the settle window is ready on the first read', async () => { + const ctx = { + getPtyForSession: () => ({}), + getCliStatus: () => ({ status: 'idle', statusUpdatedAt: Date.now() - 5000 }), + }; + const r = await waitForCliIdleAfter('x', ctx, -Infinity, Date.now() + 5000, 2000); + assert.equal(r.ready, true); + assert.ok(r.waited_ms < 500); }); diff --git a/trigger-watcher.js b/trigger-watcher.js index 979fe09d..f2f58e89 100644 --- a/trigger-watcher.js +++ b/trigger-watcher.js @@ -94,6 +94,7 @@ const REASON_DIALOG_OPEN_AFTER_WRITE = 'the CLI reports a dialog open (waiting) const REASON_DEADLINE_BEFORE_WRITE = 'the step deadline passed before it could be written; nothing was written'; const REASON_CLI_BUSY = 'the CLI still reported a turn running (busy) at the deadline; nothing was written'; const REASON_CLI_NOT_IDLE = 'the CLI never reported idle before the deadline; nothing was written'; +const REASON_IDLE_UNSETTLED = 'the CLI reported idle only at the deadline, too late to settle; nothing was written'; const ACCEPTED_WAITS = ['idle', 'none']; @@ -377,6 +378,25 @@ function createDialogProbe(ctx, sessionId, windowMs) { }; } +// see .ai/contexts/trigger-watcher.md, "Readiness before a single trigger" +function waitForNoDialog(sessionId, ctx, deadlineMs) { + const start = Date.now(); + let held = false; + return pollLoop((resolve, scheduleNext) => { + const now = Date.now(); + const waited_ms = now - start; + if (!ctx.getPtyForSession(sessionId)) { + return resolve({ clear: false, timedOut: false, sessionExited: true, waited_ms }); + } + const s = readCliStatus(ctx, sessionId); + if (s && s.status === 'waiting') held = true; + else if (s) held = false; + if (!held) return resolve({ clear: true, timedOut: false, sessionExited: false, waited_ms }); + if (now >= deadlineMs) return resolve({ clear: false, timedOut: true, sessionExited: false, waited_ms }); + scheduleNext(); + }); +} + const CLI_REACTION_STATUSES = ['busy', 'idle', 'waiting']; function isCompactCommand(command) { @@ -401,6 +421,7 @@ function waitForCliIdleAfter(sessionId, ctx, afterMs, deadlineMs, settleMs = 0) let lastStatus = null; const dialog = createDialogProbe(ctx, sessionId, settleMs); let everRead = false; + let firstIdleRead = true; return pollLoop((resolve, scheduleNext) => { const now = Date.now(); const waited_ms = now - start; @@ -420,9 +441,10 @@ function waitForCliIdleAfter(sessionId, ctx, afterMs, deadlineMs, settleMs = 0) lastStatus = s.status; if (s.status === 'idle' && s.statusUpdatedAt > afterMs) { if (idleSince === null || idleStamp !== s.statusUpdatedAt) { - idleSince = now; + idleSince = firstIdleRead ? Math.min(now, s.statusUpdatedAt) : now; idleStamp = s.statusUpdatedAt; } + firstIdleRead = false; idleHeld = now - idleSince >= settleMs; } else { idleSince = null; @@ -1125,21 +1147,45 @@ async function processTriggerFile(name, ctx, triggersDir, processedDir, onEntryR } // see .ai/contexts/trigger-watcher.md, "Readiness before a single trigger" - const ready = await waitForCliIdleAfter(sessionId, ctx, -Infinity, commandDeadline, getBusyFallSettleMs()); - waited_ms += ready.waited_ms; - if (ready.sessionExited) { - ctx.log.warn('[trigger-watcher] Session exited waiting for the CLI to be ready:', sessionId); - await writeResult({ ok: false, error: 'session exited during wait', sessionId, waited_ms }); - return; - } - if (!ready.available) { - ctx.log.info(`[trigger-watcher] No usable CLI descriptor for ${sessionId} at the start of the readiness wait`); + if (wait === 'idle') { + const settleMs = Math.max(0, Math.min(getBusyFallSettleMs(), commandDeadline - Date.now() - 1)); + const ready = await waitForCliIdleAfter(sessionId, ctx, -Infinity, commandDeadline, settleMs); + waited_ms += ready.waited_ms; + if (ready.sessionExited) { + ctx.log.warn('[trigger-watcher] Session exited waiting for the CLI to be ready:', sessionId); + await writeResult({ ok: false, error: 'session exited during wait', sessionId, waited_ms }); + return; + } + if (!ready.available) { + ctx.log.info(`[trigger-watcher] No usable CLI descriptor for ${sessionId} at the start of the readiness wait`); + } + if (ready.timedOut) { + const dialog = ready.waitingSeen || ready.lastStatus === 'waiting'; + let reason = REASON_CLI_NOT_IDLE; + if (dialog) reason = REASON_DIALOG_OPEN; + else if (ready.lastStatus === 'busy') reason = REASON_CLI_BUSY; + else if (ready.lastStatus === 'idle') reason = REASON_IDLE_UNSETTLED; + ctx.log.warn(`[trigger-watcher] CLI not ready (${ready.lastStatus}) after ${ready.waited_ms} ms, nothing sent:`, sessionId); + await writeResult({ ok: false, submitted: SUBMITTED_NO, error: ERROR_NOT_SENT, reason, sessionId, waited_ms }); + return; + } + } else { + const clear = await waitForNoDialog(sessionId, ctx, commandDeadline); + waited_ms += clear.waited_ms; + if (clear.sessionExited) { + ctx.log.warn('[trigger-watcher] Session exited waiting for a dialog to close:', sessionId); + await writeResult({ ok: false, error: 'session exited during wait', sessionId, waited_ms }); + return; + } + if (clear.timedOut) { + ctx.log.warn('[trigger-watcher] Dialog still open at the deadline, nothing sent:', sessionId); + await writeResult({ ok: false, submitted: SUBMITTED_NO, error: ERROR_NOT_SENT, reason: REASON_DIALOG_OPEN, sessionId, waited_ms }); + return; + } } - if (ready.timedOut) { - const dialog = ready.waitingSeen || ready.lastStatus === 'waiting'; - const reason = dialog ? REASON_DIALOG_OPEN : (ready.lastStatus === 'busy' ? REASON_CLI_BUSY : REASON_CLI_NOT_IDLE); - ctx.log.warn(`[trigger-watcher] CLI not ready (${ready.lastStatus}) after ${ready.waited_ms} ms, nothing sent:`, sessionId); - await writeResult({ ok: false, submitted: SUBMITTED_NO, error: ERROR_NOT_SENT, reason, sessionId, waited_ms }); + if (Date.now() >= commandDeadline) { + ctx.log.warn('[trigger-watcher] Deadline passed before the command could be written:', sessionId); + await writeResult({ ok: false, submitted: SUBMITTED_NO, error: ERROR_NOT_SENT, reason: REASON_DEADLINE_BEFORE_WRITE, sessionId, waited_ms }); return; } From 68bc9bf8f28f25575897bcbb9e281483818a6b43 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sat, 3 Oct 2026 10:26:23 +0200 Subject: [PATCH 3/4] (triggers): keep the idle-stamp shortcut off for chain steps The first-read shortcut (an idle stamped before the settle window is ready at once) is now a parameter of waitForCliIdleAfter, off by default and turned on only by the single-trigger path, so a chain step still pays the settle on a stale idle. The unsettled reason now also covers an idle that kept restarting. Closes #379 --- .ai/contexts/trigger-watcher.md | 15 +++++++++------ docs/automation.md | 4 ++-- test/trigger-single-readiness.test.js | 17 ++++++++++++++--- trigger-watcher.js | 8 ++++---- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/.ai/contexts/trigger-watcher.md b/.ai/contexts/trigger-watcher.md index 3a66ac42..ed034166 100644 --- a/.ai/contexts/trigger-watcher.md +++ b/.ai/contexts/trigger-watcher.md @@ -988,15 +988,18 @@ for `none`. `waitForComposerFree` and the liveness re-check, right before `submitWithVerify`. No parallel mechanism: the not-ready results map to the chain reasons (`REASON_DIALOG_OPEN`, `REASON_CLI_BUSY`, `REASON_CLI_NOT_IDLE`) - plus `REASON_IDLE_UNSETTLED` when the last read was `idle` but too late to - settle, never "never reported idle" for an idle descriptor. `error` is + plus `REASON_IDLE_UNSETTLED` when the last read was `idle` but it never held + long enough to settle (a fresh idle at the deadline, or one that kept + restarting), never "never reported idle" for an idle descriptor. `error` is `not sent`, `submitted` `no`. A session whose descriptor is held `busy` by background agents (#360, a CLI limit) fails at the deadline; `none` is the value for it. -- **Settle.** `waitForCliIdleAfter` counts an `idle` first read from its - `statusUpdatedAt`, so one older than the settle window is ready on that read - (no flat +300 ms on every trigger; a later new stamp still counts from when - it was seen). The single path caps the settle at the time left to the +- **Settle.** `waitForCliIdleAfter(…, trustIdleStamp)` is off by default, so + chains behave exactly as before: a stale idle read after a step whose Enter + drew no reaction must still pay the settle (the #407 family). The single path + passes `true`: an `idle` first read counts from its `statusUpdatedAt`, so one + older than the settle window is ready on that read (no flat +300 ms on every + trigger; a later new stamp still counts from when it was seen). The single path caps the settle at the time left to the deadline, so a `timeout_ms` under the settle on an idle session writes. Poll granularity is 100 ms, so a fresh idle with a very short deadline can still end `REASON_IDLE_UNSETTLED`. diff --git a/docs/automation.md b/docs/automation.md index 9adfc11e..20d409bd 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -462,8 +462,8 @@ The two reserved values mean opposite things: *the CLI still reported a turn running (busy) at the deadline; nothing was written*, and a session whose background agents keep the parent descriptor `busy` (#360) always ends so: use `wait: "none"` for it. An `idle` that - appeared only at the deadline gives *the CLI reported idle only at the - deadline, too late to settle; nothing was written*. Without a readable + never held long enough to settle gives *the CLI was idle only briefly before + the deadline; it never held long enough to settle; nothing was written*. Without a readable descriptor at the first read nothing is waited for. - A single `command` with `wait: "none"` keeps its write-now meaning: `busy`, `idle` or an unreadable descriptor write at once, with no settle. The only diff --git a/test/trigger-single-readiness.test.js b/test/trigger-single-readiness.test.js index 0e9b090e..500f65e8 100644 --- a/test/trigger-single-readiness.test.js +++ b/test/trigger-single-readiness.test.js @@ -71,6 +71,7 @@ async function run(payload, s) { } const trig = (id, extra = {}) => ({ sessionId: id, command: 'hello', timeout_ms: 800, ...extra }); +const STALE = Date.now() - 5000; const DIALOG = /dialog open \(waiting\); nothing was written into it/; async function withSettle(ms, fn) { @@ -245,7 +246,7 @@ test('idle, an idle that keeps restarting never settles: the unsettled reason, n const r = await run(trig(id, { wait: 'idle', timeout_ms: 500 }), s); assert.deepEqual(s.written, []); assert.equal(r.error, 'not sent'); - assert.match(r.reason, /idle only at the deadline, too late to settle/); + assert.match(r.reason, /idle only briefly before the deadline; it never held long enough to settle/); }); test('the deadline passed before the write: nothing is written, with or without a descriptor', async () => { @@ -263,9 +264,19 @@ test('the deadline passed before the write: nothing is written, with or without test('waitForCliIdleAfter: an idle stamped before the settle window is ready on the first read', async () => { const ctx = { getPtyForSession: () => ({}), - getCliStatus: () => ({ status: 'idle', statusUpdatedAt: Date.now() - 5000 }), + getCliStatus: () => ({ status: 'idle', statusUpdatedAt: STALE }), }; - const r = await waitForCliIdleAfter('x', ctx, -Infinity, Date.now() + 5000, 2000); + const r = await waitForCliIdleAfter('x', ctx, -Infinity, Date.now() + 5000, 2000, true); assert.equal(r.ready, true); assert.ok(r.waited_ms < 500); }); + +test('waitForCliIdleAfter: without trustIdleStamp (chains) an old idle still pays the settle', async () => { + const ctx = { + getPtyForSession: () => ({}), + getCliStatus: () => ({ status: 'idle', statusUpdatedAt: STALE }), + }; + const r = await waitForCliIdleAfter('x', ctx, -Infinity, Date.now() + 5000, 400); + assert.equal(r.ready, true); + assert.ok(r.waited_ms >= 380, 'ready after ' + r.waited_ms + ' ms, before the settle'); +}); diff --git a/trigger-watcher.js b/trigger-watcher.js index f2f58e89..af9941be 100644 --- a/trigger-watcher.js +++ b/trigger-watcher.js @@ -94,7 +94,7 @@ const REASON_DIALOG_OPEN_AFTER_WRITE = 'the CLI reports a dialog open (waiting) const REASON_DEADLINE_BEFORE_WRITE = 'the step deadline passed before it could be written; nothing was written'; const REASON_CLI_BUSY = 'the CLI still reported a turn running (busy) at the deadline; nothing was written'; const REASON_CLI_NOT_IDLE = 'the CLI never reported idle before the deadline; nothing was written'; -const REASON_IDLE_UNSETTLED = 'the CLI reported idle only at the deadline, too late to settle; nothing was written'; +const REASON_IDLE_UNSETTLED = 'the CLI was idle only briefly before the deadline; it never held long enough to settle; nothing was written'; const ACCEPTED_WAITS = ['idle', 'none']; @@ -414,7 +414,7 @@ function cliForbidsRecoveryEnter(ctx, sessionId) { } // see .ai/contexts/trigger-watcher.md, "Readiness before every step" (return shape, descriptor loss, deadline) -function waitForCliIdleAfter(sessionId, ctx, afterMs, deadlineMs, settleMs = 0) { +function waitForCliIdleAfter(sessionId, ctx, afterMs, deadlineMs, settleMs = 0, trustIdleStamp = false) { const start = Date.now(); let idleSince = null; let idleStamp = null; @@ -441,7 +441,7 @@ function waitForCliIdleAfter(sessionId, ctx, afterMs, deadlineMs, settleMs = 0) lastStatus = s.status; if (s.status === 'idle' && s.statusUpdatedAt > afterMs) { if (idleSince === null || idleStamp !== s.statusUpdatedAt) { - idleSince = firstIdleRead ? Math.min(now, s.statusUpdatedAt) : now; + idleSince = (trustIdleStamp && firstIdleRead) ? Math.min(now, s.statusUpdatedAt) : now; idleStamp = s.statusUpdatedAt; } firstIdleRead = false; @@ -1149,7 +1149,7 @@ async function processTriggerFile(name, ctx, triggersDir, processedDir, onEntryR // see .ai/contexts/trigger-watcher.md, "Readiness before a single trigger" if (wait === 'idle') { const settleMs = Math.max(0, Math.min(getBusyFallSettleMs(), commandDeadline - Date.now() - 1)); - const ready = await waitForCliIdleAfter(sessionId, ctx, -Infinity, commandDeadline, settleMs); + const ready = await waitForCliIdleAfter(sessionId, ctx, -Infinity, commandDeadline, settleMs, true); waited_ms += ready.waited_ms; if (ready.sessionExited) { ctx.log.warn('[trigger-watcher] Session exited waiting for the CLI to be ready:', sessionId); From af2ae2553b89daa907ea0eeeea5a6744bd2ee663 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sat, 3 Oct 2026 10:36:26 +0200 Subject: [PATCH 4/4] (triggers): hold the event loop in the helper tests that await a poll The poll timer of the readiness helpers is unref'd on purpose, so a test that only awaits it was cancelled by the Node 20/22 runner. The two helper tests now keep a ref'd interval alive while they await. Closes #379 --- test/trigger-single-readiness.test.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/trigger-single-readiness.test.js b/test/trigger-single-readiness.test.js index 500f65e8..f6e29bdd 100644 --- a/test/trigger-single-readiness.test.js +++ b/test/trigger-single-readiness.test.js @@ -71,6 +71,11 @@ async function run(payload, s) { } const trig = (id, extra = {}) => ({ sessionId: id, command: 'hello', timeout_ms: 800, ...extra }); +async function holdingLoop(fn) { + const hold = setInterval(() => {}, 50); + try { return await fn(); } finally { clearInterval(hold); } +} + const STALE = Date.now() - 5000; const DIALOG = /dialog open \(waiting\); nothing was written into it/; @@ -266,7 +271,7 @@ test('waitForCliIdleAfter: an idle stamped before the settle window is ready on getPtyForSession: () => ({}), getCliStatus: () => ({ status: 'idle', statusUpdatedAt: STALE }), }; - const r = await waitForCliIdleAfter('x', ctx, -Infinity, Date.now() + 5000, 2000, true); + const r = await holdingLoop(() => waitForCliIdleAfter('x', ctx, -Infinity, Date.now() + 5000, 2000, true)); assert.equal(r.ready, true); assert.ok(r.waited_ms < 500); }); @@ -276,7 +281,7 @@ test('waitForCliIdleAfter: without trustIdleStamp (chains) an old idle still pay getPtyForSession: () => ({}), getCliStatus: () => ({ status: 'idle', statusUpdatedAt: STALE }), }; - const r = await waitForCliIdleAfter('x', ctx, -Infinity, Date.now() + 5000, 400); + const r = await holdingLoop(() => waitForCliIdleAfter('x', ctx, -Infinity, Date.now() + 5000, 400)); assert.equal(r.ready, true); assert.ok(r.waited_ms >= 380, 'ready after ' + r.waited_ms + ' ms, before the settle'); });