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
38 changes: 32 additions & 6 deletions .ai/contexts/subagent-observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,17 @@ re-sees the entire history of the session**. `detectSubagentTransitions()` only
rescans when the `subagents/` dir mtime moves — which is exactly what happens
when a *new* subagent starts. Two rules keep that rescan quiet:

- **A first sighting is only a spawn if the file is fresh.** An unknown file
whose mtime is already older than `BOOTSTRAP_LIVE_MS` (60 s) is recorded
silently as `completed: true`, with no `readSubagentMeta()` and no IPC.
- **Bootstrap never announces anything.** Every file present at a session's
first scan is recorded silently as `completed: true`, whatever its mtime.
Switchboard owns the PTYs its subagents run in — one set per Electron
process — so they all died with the previous process, and a restored session
gets a brand-new PTY whose agents write *after* bootstrap and are picked up
by the normal path. Nothing on disk at that moment can be live. The silence
is unconditional; the *verdict* is not (next bullet).
- **After bootstrap, a first sighting is only a spawn if the file is fresh.**
An unknown file whose mtime is already older than `FRESH_SIGHTING_MS` (60 s)
is recorded silently as `completed: true`, with no `readSubagentMeta()` and
no IPC.
- **That verdict is an assumption, and it is reversible.** A stale first
sighting is *not* proof the agent finished. `detectSubagentTransitions()`
runs only from `flushChanges()`, whose debounce (`main.js`) is shared across
Expand All @@ -104,9 +112,17 @@ when a *new* subagent starts. Two rules keep that rescan quiet:
it is rehabilitated** — `completed` returns to false and the withheld
`subagent-spawned` is emitted (logged `[subagent-spawn-late]`). The window
closes once the file has been seen motionless for a full `STABLE_MS`, after
which the entry is frozen and costs nothing. At bootstrap no window is
opened: the PTY belongs to Switchboard, so nothing it spawned can be
mid-flight before that session's first flush.
which the entry is frozen and costs nothing. **At bootstrap the window is
opened only for files whose mtime is fresh** — the handful that could
conceivably still be running. Everything older is frozen outright: a window
over the whole history would cost one `statSync` per historical file per
flush, exactly what the dir-mtime cache exists to avoid, and a file minutes
old cannot be the agent in question anyway. The narrow window is what keeps
the startup rule falsifiable: if the PTY-ownership argument above is ever
wrong (an orphaned process surviving a hard kill and still writing), the
agent is announced late instead of staying invisible for the whole session.
Silence at startup must not become a permanent blind spot — a visible late
spawn beats a silent disappearance.
- **`knownSubagents` forgets an agent only when its file leaves the disk.** The
earlier GC dropped completed entries after 5 minutes; because the file stayed,
the next rescan rediscovered it as unknown and announced a spawn. That was the
Expand All @@ -133,6 +149,16 @@ Guaranteed: a static historical file never produces a spawn (it does not move,
so it never enters the rehabilitation branch), and an agent still writing is
always picked up — late at worst, never lost.

Also guaranteed since 2026-08-22: **a restart is quiet**. Bootstrap used to
treat any file younger than 60 s as a live agent and emit a synthetic
`subagent-spawned` (`payload._bootstrap`). An agent that finished less than a
minute before the app was restarted therefore came back as a ghost — purple
activity glyph on the parent, green dot on the subagent group header — until
the 30 s stability window declared it complete. Observed 2026-08-22; the
synthetic bootstrap spawn is gone and `_bootstrap` is no longer emitted (the
renderer still tolerates the field). What replaced it is silence plus a
recheck window on recent files, not an irreversible verdict.

Not guaranteed: an agent that goes quiet for longer than `STABLE_MS` mid-run —
a long tool call, say — can be declared finished while it is still alive. That
is not new and not specific to the age filter: the 30 s stability window has
Expand Down
2 changes: 1 addition & 1 deletion docs/activity-trace.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ no `sent`.
| `osc.progress` | Every OSC 9;4 progress level (except `4;0`) | `level`, `payload`, `was`, `decision` |
| `osc.notify` | Every non-progress OSC 9 | `message`, `sent` |
| `busy.emit` | A `cli-busy-state` event leaves main | `busy`, `via` (`osc0` / `osc9.4`), `sent` |
| `subagent.spawned` | `subagent-spawned` is sent | `agentId`, `kind` (`spawn` / `bootstrap` / `heartbeat`), `subagentType`, `ageMs`, `sent` |
| `subagent.spawned` | `subagent-spawned` is sent | `agentId`, `kind` (`spawn` / `heartbeat`), `subagentType`, `ageMs`, `sent` |
| `subagent.assumed-finished` | An unknown transcript is recorded as already finished, **silently** — no IPC | `agentId`, `ageMs`, `bootstrap`, `recheck` |
| `subagent.rehabilitated` | An assumed-finished entry grew inside its recheck window: the withheld spawn is released | `agentId`, `withheldForMs`, `subagentType`, `sent` |
| `subagent.completed` | `subagent-completed` is sent | `agentId`, `stableForMs`, `reason`, `sent` |
Expand Down
39 changes: 22 additions & 17 deletions session-transitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,16 @@ function detectSubagentTransitions(sessionId, session, folderPath) {
// First walk for this session: pre-populate knownSubagents with every
// existing file silently so we don't flood the renderer with spawn/complete
// events for agents that already finished before Switchboard started watching.
// Files modified in the last 60s get a normal lifecycle; older ones are
// recorded as already-completed without IPC.
// No file present at this point can belong to a live agent, whatever its
// mtime — see .ai/contexts/subagent-observability.md
if (isBootstrap) {
session.knownSubagents = new Map();
}

const mainWindow = getMainWindow();
const now = Date.now();
const STABLE_MS = 30000; // 30 seconds of no mtime advance → completed
const BOOTSTRAP_LIVE_MS = 60000; // file modified in last 60s = still alive at boot
const FRESH_SIGHTING_MS = 60000; // post-bootstrap: first sighting counts as live if newer than this
// Re-emit subagent-spawned (idempotent on the renderer side — it just
// refreshes the entry's last-seen timestamp) at most every HEARTBEAT_MS
// while an agent's file keeps growing. Without this, the renderer's 60s
Expand Down Expand Up @@ -114,27 +114,34 @@ function detectSubagentTransitions(sessionId, session, folderPath) {
const mtimeMs = stat.mtimeMs;

if (!known) {
// A stale file is assumed finished and recorded silently. At bootstrap
// that is certain; afterwards the sighting may simply be late, so the
// entry keeps a recheck window instead of being frozen.
// A file that cannot be live is recorded as finished, silently. At
// bootstrap that covers every file: Switchboard owns the PTYs, so
// nothing it spawned outlived the previous process. Afterwards only a
// stale mtime says so, and the sighting may simply be late.
//
// Either way the verdict stays reversible while the file could
// conceivably be alive — a recheck window rehabilitates it if it grows.
// Only a plainly historical file seen at bootstrap is frozen outright,
// which is what keeps startup free of a per-file statSync.
// See .ai/contexts/subagent-observability.md
const looksAlive = (now - mtimeMs) < BOOTSTRAP_LIVE_MS;
const isFresh = (now - mtimeMs) < FRESH_SIGHTING_MS;
const looksAlive = !isBootstrap && isFresh;
if (!looksAlive) {
const recheck = !isBootstrap || isFresh;
session.knownSubagents.set(agentId, {
mtimeMs,
completed: true,
_completedAt: now,
subagentType: null,
description: null,
_lastHeartbeatAt: now,
_recheckStart: isBootstrap ? null : now,
_recheckStart: recheck ? now : null,
});
if (TRACE) trace('subagent.assumed-finished', sessionId, { agentId, ageMs: now - mtimeMs, bootstrap: isBootstrap, recheck: !isBootstrap });
if (TRACE) trace('subagent.assumed-finished', sessionId, { agentId, ageMs: now - mtimeMs, bootstrap: isBootstrap, recheck });
continue;
}

// First sighting of a live file: a synthetic spawn at bootstrap
// (_bootstrap), a genuine one afterwards.
// First sighting of a live file — always post-bootstrap, so a genuine spawn.
const meta = readSubagentMeta(filePath) || {};
session.knownSubagents.set(agentId, {
mtimeMs,
Expand All @@ -144,17 +151,15 @@ function detectSubagentTransitions(sessionId, session, folderPath) {
description: meta.description || null,
_lastHeartbeatAt: now,
});
log.info(`[subagent-spawn${isBootstrap ? '-bootstrap' : ''}] parent=${sessionId} agentId=${agentId} type=${meta.agentType || 'unknown'}`);
if (TRACE) trace('subagent.spawned', sessionId, { agentId, kind: isBootstrap ? 'bootstrap' : 'spawn', subagentType: meta.agentType || null, ageMs: now - mtimeMs, sent: !!(mainWindow && !mainWindow.isDestroyed()) });
log.info(`[subagent-spawn] parent=${sessionId} agentId=${agentId} type=${meta.agentType || 'unknown'}`);
if (TRACE) trace('subagent.spawned', sessionId, { agentId, kind: 'spawn', subagentType: meta.agentType || null, ageMs: now - mtimeMs, sent: !!(mainWindow && !mainWindow.isDestroyed()) });
if (mainWindow && !mainWindow.isDestroyed()) {
const payload = {
mainWindow.webContents.send('subagent-spawned', {
parentSessionId: sessionId,
agentId,
subagentType: meta.agentType || null,
description: meta.description || null,
};
if (isBootstrap) payload._bootstrap = true;
mainWindow.webContents.send('subagent-spawned', payload);
});
}
} else if (known.completed) {
// Inside the recheck window of an assumed-finished entry. A file that
Expand Down
Loading
Loading