Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 78 additions & 6 deletions .ai/contexts/trigger-watcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -834,13 +834,11 @@ wired to `cliSessionState.getStatus` in `main.js`; `undefined` for a remote
session, which has no local descriptor). No new watcher: it reuses the cache
`cli-session-state.js` already keeps.

- **Readiness.** A chain step that follows a `/compact` step first waits
- **Readiness.** (Extended to every step, see the next section.) A chain step that follows a `/compact` step first waits
(`waitForCliIdleAfter`) for `status: "idle"` with a `statusUpdatedAt` later
than the compact step's send time. Bounded by
`SWITCHBOARD_CLI_READY_WAIT_MS` (default 60 000 ms) and by the step's own
deadline; on expiry the step is written anyway, with the warning `CLI not
idle after /compact within N ms, writing chain step N anyway`. Its time is
counted in the step's and the chain's `waited_ms`.
than the compact step's Enter. Bounded by the step's own deadline; on
expiry the step is not written (see the next section). Its time is counted
in the step's and the chain's `waited_ms`.
- **Proof of submission by edge.** When a descriptor with an integer
`statusUpdatedAt` is available, a submission counts when the descriptor
shows ANY status write (`busy`, `idle` or `waiting`) with a
Expand Down Expand Up @@ -885,6 +883,80 @@ named, not that the first Enter always lands.
Tests: `test/trigger-descriptor-proof.test.js` (fake timers and a fake
descriptor for the helpers; the real watcher for the chain wiring).

### Readiness before every step, and the descriptor as busy-fall authority (issues #407, #360)

Second field case (2026-10-02): step 0 of a `compact-now.sh` chain, with no
`/compact` before it, landed in the composer and its Enter became a line
break, while four background subagents had just been spawned (three still
running). The #407 readiness wait only covered the step after a `/compact`;
nothing waited before step 0.

**The mechanism is NOT established.** The working hypothesis is that text
written while the CLI is mid-turn has its Enter absorbed as a newline, but #360
shows the opposite: a step written mid-turn was enqueued and submitted
normally. What is known is only that the step was typed while the descriptor
read `busy`. The rule below stops typing in that state; it does not prove that
state was the cause.

- **The wait runs before EVERY chain step**, step 0 included
(`waitForCliIdleAfter`, after the composer-free and liveness checks, so the
descriptor is read as close to the write as possible). Before a step that
follows `/compact` the idle must also be newer than the compact's Enter
(`enterAt` from `submitWithVerify`); before any other step any `idle` counts.
The idle must hold for the busy-fall settle window
(`SWITCHBOARD_BUSY_FALL_SETTLE_MS`, 300 ms) with an unchanged
`statusUpdatedAt`, so a `busy` that follows an `idle` within the window is
not mistaken for readiness.
- **The wait is bounded by the step's own deadline only** (the per-step
`timeout_ms`, capped by the chain's). `SWITCHBOARD_CLI_READY_WAIT_MS` is gone.
A parent session keeps its descriptor `busy` for as long as a delegated
agent runs (`cli-session-state.md`), so a shorter bound would write into the
very state this rule exists for.
- **Not idle at the deadline means not written, whatever the status**:
`busy`, `waiting` (a dialog is open) and any status this code does not know
(e.g. `shell`) are all not-idle and not-writable. The step fails: result
`ok: false`, `error` `not sent` (step 0) or `chain timeout` (later steps),
`submitted` the weakest of the chain so far, the step recorded with
`submitted: "no"`, and a `reason` naming the cause (dialog open, turn still
running, never idle). A dialog is reported when `waiting` was sampled
anywhere in the final settle window, not only on the last sample.
- **A step typed but not confirmed, with the recovery Enter withheld** (the
descriptor reads `busy` or `waiting` and showed no reaction to our Enter)
stops the chain: `ok: false`, `error` `step not confirmed`, nothing more is
typed into that composer. The step's text may be sitting there. The
recovery Enter is also withheld when input of the user's own is pending in
the composer (`waitForComposerFree`), which stops the chain the same way.
- **No usable descriptor at the FIRST read of the wait** (the wait owns this decision: there is no separate precheck, so a descriptor read once and lost at the next sample is "not idle", never the legacy path) (`getCliStatus` absent,
`undefined`, or a `statusUpdatedAt` that is not an integer): no wait, today's
behaviour (`available: false`). A descriptor lost AFTER it was read (the CLI
rewriting its file, a failed pid probe, a momentary bad timestamp) is not the
same: it may reappear, so the wait goes on, counted as not idle, until the
deadline, then fails like any not-idle case. Nothing is typed on the strength
of a descriptor that merely vanished.
- **Never ready past the deadline, never written past it.** A settle that
completes at or after the deadline is a timeout, not readiness, and the
deadline is checked again immediately before the write (a step timeout of 0
or a settle of 0 included): the step fails with `not sent`/`chain timeout`
and the reason "the step deadline passed before it could be written".
- **`waitForCliIdleAfter` return shape**: `{ ready, available, timedOut,
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.
- **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
older than the Enter proves nothing (the Enter may have been absorbed) and
leaves the `_cliBusy` logic in charge, as it does when no usable descriptor
exists. Not measured as fixed for sessions with background agents: the
descriptor stays `busy` until the last agent ends, so the busy-fall still
waits for it.

Tests: `test/trigger-every-step-readiness.test.js` (the real watcher with a
fake descriptor, plus the wait helpers under mocked timers).

### 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
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ What changes for you in each release of Switchboard. How to write an entry: [doc
### Changed
- After three failed refreshes of a remote host in a row, a row that would have attached opens its transcript and says why in its tooltip, instead of failing when clicked. Stop is never disabled: it runs its own ssh. (#218)
### Fixed
- A step of a trigger chain, the first one included, is no longer typed while the CLI reads busy or waiting on a dialog: it waits for the CLI to be at its prompt, up to the step's deadline, then fails cleanly with a reason instead of being written; a step whose Enter did not start a turn is retried once, or stops the chain when that retry is withheld because the CLI is busy or waiting on a dialog, or you have typed input pending, and is reported as "not confirmed submitted" instead of "sent". Without a readable CLI descriptor a step is still written as before, but no longer once its own deadline has passed. Single triggers are not covered. (#407, #360)
- After an upgrade, schedules keep running in a project that has settings of its own and in a git checkout that already holds a schedule; any other project, opened before the upgrade or not, runs no schedule until you open a session in it or add it. (#385)
- A step of a trigger chain that follows `/compact` now waits for the CLI to be back at its prompt before it is written, and a step whose Enter did not start a turn is retried once and then reported as "not confirmed submitted" in the log and the result instead of "sent". (#407)
- Stopping a terminal twice in quick succession, or resizing it while it is being stopped, no longer closes the Windows pseudo console twice, which could kill the whole app with no error. (#405)
- A sandboxed session, or a sandboxed schedule, whose Additional Directories include a `.claude` or `.git` directory, or a path inside one, is now refused instead of binding it read-write over its read-only protection; add the project directory instead. A session started in a `.claude` or `.git` directory is refused too, except below `.claude/worktrees`, and Additional Directories naming your home directory or a parent of it are refused however the path is written. A relative `add-dirs` entry in a schedule is taken from the schedule's directory. (#385)
- A session that has exited no longer keeps a busy dot in the sidebar, and the status bar's running count drops as soon as the session ends instead of waiting for the next refresh. (#375)
Expand Down
3 changes: 2 additions & 1 deletion docs/automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,6 @@ spends the same budget.
| `SWITCHBOARD_TRIGGER_MAX_AGE_MS` | The staleness limit | 300 000 |
| `SWITCHBOARD_SUBMIT_ENTER_DELAY_MS` | Delay between the text and its Enter | 50 |
| `SWITCHBOARD_SUBMIT_VERIFY_MS` | How long a submission is watched for a turn | 2 000 |
| `SWITCHBOARD_CLI_READY_WAIT_MS` | How long a chain step after `/compact` waits for the CLI to report idle | 60 000 |
| `SWITCHBOARD_BUSY_FALL_SETTLE_MS` | How long "not busy" must hold between chain steps | 300 |

The triggers directory does not move with `SWITCHBOARD_DATA_DIR`: an instance
Expand Down Expand Up @@ -435,6 +434,7 @@ never in `error`: `not sent: input pending` is not `not sent`.
|---|---|---|
| `not sent` | **not one byte reached the session**: no idle came, politeness never allowed a write, or the trigger was refused before any write (stale, bad `wait`, bad `expectedCwd`, target guard) | nothing happened; it is safe to send again |
| `chain timeout` | at least one step **was written**, and the expected effect was not observed before the deadline | assume the written steps landed |
| `step not confirmed` | a chain step **was written**, its submission was not confirmed by the CLI's descriptor, and the recovery Enter was withheld (the descriptor reads `busy` or `waiting`, or input of your own is pending in the composer); the chain stopped there and nothing more was typed | the step may sit unsubmitted in the composer: look before sending again |
| anything else | free text: `session not found`, `target process not running`, `missing required field`, `invalid timeout_ms`, `command and chain are mutually exclusive`, `trigger too large (max 64 KB)`, `command too long (max 4 KB)`, `trigger must be a regular file`, `pty write failed: …` | read `submitted` to know whether anything landed |

The two reserved values mean opposite things:
Expand All @@ -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 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`).
- A session that exits during that initial wait reports `submitted: "no"` and a
`reason` saying nothing was written (`partial: false` on a chain).

Expand Down
55 changes: 24 additions & 31 deletions test/trigger-descriptor-proof.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -263,14 +263,14 @@ function chainSession(sessionId, { log, onEnter }) {
return { ctx, written, desc, setBusy(v) { busy = v; } };
}

async function runChain(chain, session, uuid) {
async function runChain(chain, session, uuid, timeoutMs = 20000) {
const tmp = mkTmp();
process.env.SWITCHBOARD_TRIGGERS_DIR = tmp;
process.env.SWITCHBOARD_TRIGGER_IDLE_TIMEOUT_MS = '2000';
const watcher = start(session.ctx);
try {
fs.writeFileSync(path.join(tmp, uuid + '.json'),
JSON.stringify({ sessionId: uuid, wait: 'idle', chain, timeout_ms: 20000 }), 'utf8');
JSON.stringify({ sessionId: uuid, wait: 'idle', chain, timeout_ms: timeoutMs }), 'utf8');
const resultPath = path.join(tmp, 'processed', uuid + '.result.json');
const deadline = Date.now() + 15000;
while (!fs.existsSync(resultPath)) {
Expand Down Expand Up @@ -321,38 +321,33 @@ test('chain: the step after /compact is held until the descriptor is idle after
assert.ok(!log.lines.some((l) => /Chain step 1 sent/.test(l.text)));
});

test('chain: a CLI that never goes idle after /compact -> bounded wait, warning, step still written', async () => {
process.env.SWITCHBOARD_CLI_READY_WAIT_MS = '300';
try {
const uuid = 'sess-desc-timeout-' + Date.now();
const log = recordingLog();
const session = chainSession(uuid, {
log,
onEnter(n, desc) {
if (n === 1) {
desc.status = 'busy'; desc.statusUpdatedAt = Date.now();
setTimeout(() => session.setBusy(false), 60);
}
},
});
session.setBusy(false);
test('chain: a CLI that never goes idle after /compact -> held to the step deadline, step never written, chain fails', async () => {
const uuid = 'sess-desc-timeout-' + Date.now();
const log = recordingLog();
const session = chainSession(uuid, {
log,
onEnter(n, desc) {
if (n === 1) {
desc.status = 'busy'; desc.statusUpdatedAt = Date.now();
setTimeout(() => session.setBusy(false), 60);
}
},
});
session.setBusy(false);

const started = Date.now();
const result = await runChain([{ command: '/compact' }, { command: 'resume the work' }], session, uuid);
const started = Date.now();
const result = await runChain([{ command: '/compact' }, { command: 'resume the work' }], session, uuid, 2500);

const nextText = session.written.find((w) => w.data === 'resume the work');
assert.ok(nextText, 'the step must still be written after the bounded wait');
assert.ok(log.lines.some((l) => l.level === 'warn' && /CLI not idle after \/compact/.test(l.text)));
assert.ok(nextText.at - started >= 300, 'the readiness wait must have been honoured up to its bound');
assert.equal(result.ok, true);
} finally {
delete process.env.SWITCHBOARD_CLI_READY_WAIT_MS;
}
assert.ok(!session.written.some((w) => w.data === 'resume the work'), 'a step must never be typed while the CLI reads busy');
assert.ok(Date.now() - started >= 2000, 'the wait must run to the step deadline');
assert.equal(result.ok, false);
assert.equal(result.error, 'chain timeout');
assert.match(result.reason, /busy/);
assert.equal(result.steps_completed, 1);
});

test('chain: an Enter that never starts a turn is reported "not confirmed submitted", never "sent"', async () => {
process.env.SWITCHBOARD_CLI_READY_WAIT_MS = '200';
try {
{
const uuid = 'sess-desc-unconfirmed-' + Date.now();
const log = recordingLog();
const session = chainSession(uuid, {
Expand All @@ -374,7 +369,5 @@ test('chain: an Enter that never starts a turn is reported "not confirmed submit
assert.equal(result.steps[1].submitted, 'assumed');
assert.deepEqual(result.unconfirmed_steps, [1]);
assert.equal(result.submitted, 'assumed');
} finally {
delete process.env.SWITCHBOARD_CLI_READY_WAIT_MS;
}
});
Loading
Loading