switchroom 0.14.23 → 0.14.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/switchroom.js
CHANGED
|
@@ -49420,8 +49420,8 @@ var {
|
|
|
49420
49420
|
} = import__.default;
|
|
49421
49421
|
|
|
49422
49422
|
// src/build-info.ts
|
|
49423
|
-
var VERSION = "0.14.
|
|
49424
|
-
var COMMIT_SHA = "
|
|
49423
|
+
var VERSION = "0.14.24";
|
|
49424
|
+
var COMMIT_SHA = "2711d052";
|
|
49425
49425
|
|
|
49426
49426
|
// src/cli/agent.ts
|
|
49427
49427
|
init_source();
|
package/package.json
CHANGED
|
@@ -49225,6 +49225,7 @@ var DEFAULT_RESCAN_MS = 1000;
|
|
|
49225
49225
|
var DEFAULT_STALL_THRESHOLD_MS = 60000;
|
|
49226
49226
|
var DEFAULT_SILENT_SYNTHESIS_STALL_THRESHOLD_MS = 300000;
|
|
49227
49227
|
var DEFAULT_SILENT_STALL_TERMINAL_MS = 300000;
|
|
49228
|
+
var DEFAULT_INFLIGHT_PROMOTE_MAX_AGE_MS = 15 * 60000;
|
|
49228
49229
|
var SUBAGENT_RESULT_TEXT_MAX = 3000;
|
|
49229
49230
|
function parseEnvMs(varName) {
|
|
49230
49231
|
const raw = process.env[varName];
|
|
@@ -49415,6 +49416,8 @@ function startSubagentWatcher(config) {
|
|
|
49415
49416
|
const stallThresholdMs = config.stallThresholdMs ?? parseEnvMs("SWITCHROOM_SUBAGENT_STALL_MS") ?? DEFAULT_STALL_THRESHOLD_MS;
|
|
49416
49417
|
const silentSynthesisStallThresholdMs = config.silentSynthesisStallThresholdMs ?? parseEnvMs("SWITCHROOM_SUBAGENT_SILENT_SYNTH_STALL_MS") ?? DEFAULT_SILENT_SYNTHESIS_STALL_THRESHOLD_MS;
|
|
49417
49418
|
const silentStallTerminalMs = config.silentStallTerminalMs ?? parseEnvMs("SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS") ?? DEFAULT_SILENT_STALL_TERMINAL_MS;
|
|
49419
|
+
const inflightPromoteMaxAgeMs = config.inflightPromoteMaxAgeMs ?? parseEnvMs("SWITCHROOM_SUBAGENT_INFLIGHT_MAX_AGE_MS") ?? DEFAULT_INFLIGHT_PROMOTE_MAX_AGE_MS;
|
|
49420
|
+
const bootPromoteEnabled = config.bootPromoteEnabled ?? process.env.SWITCHROOM_SUBAGENT_BOOT_PROMOTE !== "0";
|
|
49418
49421
|
const reaperTtlMs = config.reaperTtlMs ?? DEFAULT_REAPER_TTL_MS;
|
|
49419
49422
|
const reaperIntervalMs = config.reaperIntervalMs ?? DEFAULT_REAPER_INTERVAL_MS;
|
|
49420
49423
|
const rescanMs = config.rescanMs ?? DEFAULT_RESCAN_MS;
|
|
@@ -49497,13 +49500,25 @@ function startSubagentWatcher(config) {
|
|
|
49497
49500
|
log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`);
|
|
49498
49501
|
}, fs2, log, db2, parentStateDir, config.onUnstall, undefined, config.onProgress);
|
|
49499
49502
|
if (isHistorical && entry.state === "running") {
|
|
49500
|
-
|
|
49501
|
-
|
|
49502
|
-
|
|
49503
|
-
|
|
49504
|
-
|
|
49505
|
-
|
|
49506
|
-
|
|
49503
|
+
let fileAgeMs = Infinity;
|
|
49504
|
+
try {
|
|
49505
|
+
const st = fs2.statSync(filePath);
|
|
49506
|
+
if (typeof st.mtimeMs === "number")
|
|
49507
|
+
fileAgeMs = n - st.mtimeMs;
|
|
49508
|
+
} catch {}
|
|
49509
|
+
if (!bootPromoteEnabled) {
|
|
49510
|
+
log?.(`subagent-watcher: ${agentId} running at boot but promotion disabled (SWITCHROOM_SUBAGENT_BOOT_PROMOTE=0) \u2014 leaving historical`);
|
|
49511
|
+
} else if (fileAgeMs > inflightPromoteMaxAgeMs) {
|
|
49512
|
+
log?.(`subagent-watcher: ${agentId} running at boot but stale (last write ${Math.round(fileAgeMs / 1000)}s ago > ${Math.round(inflightPromoteMaxAgeMs / 1000)}s) \u2014 leaving historical (dead prior-session worker, not in-flight)`);
|
|
49513
|
+
} else {
|
|
49514
|
+
entry.historical = false;
|
|
49515
|
+
log?.(`subagent-watcher: ${agentId} was in-flight at boot \u2014 promoting to live (last write ${Math.round(fileAgeMs / 1000)}s ago; user still awaiting handback)`);
|
|
49516
|
+
if (db2 != null) {
|
|
49517
|
+
try {
|
|
49518
|
+
backfillJsonlAgentId(db2, filePath, agentId, log);
|
|
49519
|
+
} catch (err) {
|
|
49520
|
+
log?.(`subagent-watcher: backfill error for ${agentId}: ${err.message}`);
|
|
49521
|
+
}
|
|
49507
49522
|
}
|
|
49508
49523
|
}
|
|
49509
49524
|
}
|
|
@@ -51442,10 +51457,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
51442
51457
|
}
|
|
51443
51458
|
|
|
51444
51459
|
// ../src/build-info.ts
|
|
51445
|
-
var VERSION = "0.14.
|
|
51446
|
-
var COMMIT_SHA = "
|
|
51447
|
-
var COMMIT_DATE = "2026-05-31T22:
|
|
51448
|
-
var LATEST_PR =
|
|
51460
|
+
var VERSION = "0.14.24";
|
|
51461
|
+
var COMMIT_SHA = "2711d052";
|
|
51462
|
+
var COMMIT_DATE = "2026-05-31T22:59:44Z";
|
|
51463
|
+
var LATEST_PR = 2033;
|
|
51449
51464
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
51450
51465
|
|
|
51451
51466
|
// gateway/boot-version.ts
|
|
@@ -208,6 +208,23 @@ export interface SubagentWatcherConfig {
|
|
|
208
208
|
* synthesis; tests use a tiny value to exercise the path.
|
|
209
209
|
*/
|
|
210
210
|
silentStallTerminalMs?: number
|
|
211
|
+
/**
|
|
212
|
+
* Freshness window (ms) for promoting a running-at-boot worker file to
|
|
213
|
+
* live. A file whose last write (mtime) is older than this is treated as
|
|
214
|
+
* a dead prior-session worker and stays historical/suppressed, NOT
|
|
215
|
+
* promoted. Default 15 min (DEFAULT_INFLIGHT_PROMOTE_MAX_AGE_MS); env
|
|
216
|
+
* override `SWITCHROOM_SUBAGENT_INFLIGHT_MAX_AGE_MS`. Guards the v0.14.23
|
|
217
|
+
* stale-handback replay regression.
|
|
218
|
+
*/
|
|
219
|
+
inflightPromoteMaxAgeMs?: number
|
|
220
|
+
/**
|
|
221
|
+
* Kill-switch for the boot-scan promotion path. When false, a
|
|
222
|
+
* running-at-boot worker is never promoted — the watcher reverts to the
|
|
223
|
+
* pre-v0.14.23 behaviour of leaving every boot-scan file historical
|
|
224
|
+
* (suppressed). Default true; env `SWITCHROOM_SUBAGENT_BOOT_PROMOTE=0`
|
|
225
|
+
* disables it fleet-wide without a code change (emergency lever).
|
|
226
|
+
*/
|
|
227
|
+
bootPromoteEnabled?: boolean
|
|
211
228
|
/**
|
|
212
229
|
* Reaper TTL (ms): background rows in `status='running'` whose
|
|
213
230
|
* `last_activity_at` (or `started_at` if liveness never wrote) is older
|
|
@@ -382,6 +399,29 @@ const DEFAULT_SILENT_SYNTHESIS_STALL_THRESHOLD_MS = 300_000
|
|
|
382
399
|
*/
|
|
383
400
|
const DEFAULT_SILENT_STALL_TERMINAL_MS = 300_000
|
|
384
401
|
|
|
402
|
+
/**
|
|
403
|
+
* Freshness window for the boot-scan "in-flight at boot → promote to
|
|
404
|
+
* live" path. A worker file still in `running` state at boot is only
|
|
405
|
+
* promoted (un-suppressed) if its last write (file mtime) is within this
|
|
406
|
+
* window of now. The signal cleanly separates the two populations:
|
|
407
|
+
*
|
|
408
|
+
* - A worker genuinely in-flight across a restart / fleet rollout was
|
|
409
|
+
* writing right up until the container was recreated, so its mtime is
|
|
410
|
+
* seconds-to-minutes before the new gateway boots — well inside the
|
|
411
|
+
* window. The user is still awaiting it; promote it.
|
|
412
|
+
* - A worker that died in a PRIOR session without writing a terminal
|
|
413
|
+
* `turn_end` is also `running` in the file, but its mtime is hours-to-
|
|
414
|
+
* weeks old. These accumulate by the dozen-to-hundred in a long-lived
|
|
415
|
+
* agent's subagents dir. Promoting them replays stale handbacks
|
|
416
|
+
* (often `failed`, from old error lines) on every boot — the v0.14.23
|
|
417
|
+
* regression. Leave them historical/suppressed, exactly as before.
|
|
418
|
+
*
|
|
419
|
+
* 15 min is generous for any plausible restart gap (container recreate +
|
|
420
|
+
* image pull) yet far below the staleness of a dead prior-session file.
|
|
421
|
+
* Override with `SWITCHROOM_SUBAGENT_INFLIGHT_MAX_AGE_MS`.
|
|
422
|
+
*/
|
|
423
|
+
const DEFAULT_INFLIGHT_PROMOTE_MAX_AGE_MS = 15 * 60_000
|
|
424
|
+
|
|
385
425
|
/**
|
|
386
426
|
* Cap on the result text retained per sub-agent (`entry.lastResultText`)
|
|
387
427
|
* and carried to the gateway via `onFinish`. The gateway feeds this into
|
|
@@ -810,6 +850,14 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
810
850
|
config.silentStallTerminalMs
|
|
811
851
|
?? parseEnvMs('SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS')
|
|
812
852
|
?? DEFAULT_SILENT_STALL_TERMINAL_MS
|
|
853
|
+
const inflightPromoteMaxAgeMs =
|
|
854
|
+
config.inflightPromoteMaxAgeMs
|
|
855
|
+
?? parseEnvMs('SWITCHROOM_SUBAGENT_INFLIGHT_MAX_AGE_MS')
|
|
856
|
+
?? DEFAULT_INFLIGHT_PROMOTE_MAX_AGE_MS
|
|
857
|
+
// Kill-switch: not parseEnvMs (which rejects `0`) — an explicit `=0`
|
|
858
|
+
// here MUST disable promotion (revert to pre-v0.14.23 suppression).
|
|
859
|
+
const bootPromoteEnabled =
|
|
860
|
+
config.bootPromoteEnabled ?? (process.env.SWITCHROOM_SUBAGENT_BOOT_PROMOTE !== '0')
|
|
813
861
|
const reaperTtlMs = config.reaperTtlMs ?? DEFAULT_REAPER_TTL_MS
|
|
814
862
|
const reaperIntervalMs = config.reaperIntervalMs ?? DEFAULT_REAPER_INTERVAL_MS
|
|
815
863
|
const rescanMs = config.rescanMs ?? DEFAULT_RESCAN_MS
|
|
@@ -961,18 +1009,40 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
961
1009
|
// already `done` at boot stays historical and is short-circuited just
|
|
962
1010
|
// below — it finished before this session.)
|
|
963
1011
|
if (isHistorical && entry.state === 'running') {
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
//
|
|
967
|
-
//
|
|
968
|
-
//
|
|
969
|
-
//
|
|
970
|
-
//
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
1012
|
+
// Freshness gate (v0.14.24): only promote a file whose LAST WRITE is
|
|
1013
|
+
// recent. A genuinely in-flight-across-a-restart worker was writing
|
|
1014
|
+
// until the container was recreated (mtime seconds-to-minutes old); a
|
|
1015
|
+
// dead prior-session worker that never wrote a terminal turn_end is
|
|
1016
|
+
// also `running` but hours-to-weeks stale. Promoting the latter
|
|
1017
|
+
// replayed stale `failed` handbacks on every boot (the v0.14.23
|
|
1018
|
+
// fleet-wide regression). Unreadable mtime → treat as stale (suppress
|
|
1019
|
+
// rather than risk re-spamming). The kill-switch reverts to pre-fix
|
|
1020
|
+
// suppression entirely.
|
|
1021
|
+
let fileAgeMs = Infinity
|
|
1022
|
+
try {
|
|
1023
|
+
const st = fs.statSync(filePath)
|
|
1024
|
+
if (typeof st.mtimeMs === 'number') fileAgeMs = n - st.mtimeMs
|
|
1025
|
+
} catch {
|
|
1026
|
+
/* unreadable → Infinity → treated as stale below */
|
|
1027
|
+
}
|
|
1028
|
+
if (!bootPromoteEnabled) {
|
|
1029
|
+
log?.(`subagent-watcher: ${agentId} running at boot but promotion disabled (SWITCHROOM_SUBAGENT_BOOT_PROMOTE=0) — leaving historical`)
|
|
1030
|
+
} else if (fileAgeMs > inflightPromoteMaxAgeMs) {
|
|
1031
|
+
log?.(`subagent-watcher: ${agentId} running at boot but stale (last write ${Math.round(fileAgeMs / 1000)}s ago > ${Math.round(inflightPromoteMaxAgeMs / 1000)}s) — leaving historical (dead prior-session worker, not in-flight)`)
|
|
1032
|
+
} else {
|
|
1033
|
+
entry.historical = false
|
|
1034
|
+
log?.(`subagent-watcher: ${agentId} was in-flight at boot — promoting to live (last write ${Math.round(fileAgeMs / 1000)}s ago; user still awaiting handback)`)
|
|
1035
|
+
// The prior gateway life's registration normally linked
|
|
1036
|
+
// jsonl_agent_id already, but re-run the backfill idempotently in
|
|
1037
|
+
// case that life crashed before the link persisted — the handback's
|
|
1038
|
+
// isBackground lookup is keyed on jsonl_agent_id, and an unlinked row
|
|
1039
|
+
// would mis-resolve the worker as foreground and drop the handback.
|
|
1040
|
+
if (db != null) {
|
|
1041
|
+
try {
|
|
1042
|
+
backfillJsonlAgentId(db, filePath, agentId, log)
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
log?.(`subagent-watcher: backfill error for ${agentId}: ${(err as Error).message}`)
|
|
1045
|
+
}
|
|
976
1046
|
}
|
|
977
1047
|
}
|
|
978
1048
|
}
|
|
@@ -80,6 +80,14 @@ function makeHarness(opts: {
|
|
|
80
80
|
stallThresholdMs?: number
|
|
81
81
|
silentStallTerminalMs?: number
|
|
82
82
|
rescanMs?: number
|
|
83
|
+
/** How long ago (ms) the boot file was last written, i.e. its mtime is
|
|
84
|
+
* `currentTime - bootFileAgeMs` at registration. Default 0 (fresh, so the
|
|
85
|
+
* freshness gate promotes it). Set large to simulate a dead prior-session
|
|
86
|
+
* worker that must NOT be promoted. */
|
|
87
|
+
bootFileAgeMs?: number
|
|
88
|
+
/** Kill-switch passthrough; default true (promotion enabled). */
|
|
89
|
+
bootPromoteEnabled?: boolean
|
|
90
|
+
inflightPromoteMaxAgeMs?: number
|
|
83
91
|
}): Harness {
|
|
84
92
|
const {
|
|
85
93
|
agentId = 'gap-agent',
|
|
@@ -87,6 +95,9 @@ function makeHarness(opts: {
|
|
|
87
95
|
stallThresholdMs = 60_000,
|
|
88
96
|
silentStallTerminalMs = 300_000,
|
|
89
97
|
rescanMs = 500,
|
|
98
|
+
bootFileAgeMs = 0,
|
|
99
|
+
bootPromoteEnabled = true,
|
|
100
|
+
inflightPromoteMaxAgeMs,
|
|
90
101
|
} = opts
|
|
91
102
|
|
|
92
103
|
let currentTime = 1000
|
|
@@ -104,6 +115,10 @@ function makeHarness(opts: {
|
|
|
104
115
|
|
|
105
116
|
const fileContents = new Map<string, Buffer>()
|
|
106
117
|
fileContents.set(jsonlPath, Buffer.from(buildJSONL(...bootLines), 'utf-8'))
|
|
118
|
+
// Per-file mtime (ms). The boot file's last write is `bootFileAgeMs` in the
|
|
119
|
+
// past; appends bump it to currentTime. The freshness gate reads this.
|
|
120
|
+
const fileMtimes = new Map<string, number>()
|
|
121
|
+
fileMtimes.set(jsonlPath, 1000 - bootFileAgeMs)
|
|
107
122
|
|
|
108
123
|
let lastOpenedPath: string | null = null
|
|
109
124
|
const mockFs = {
|
|
@@ -121,7 +136,7 @@ function makeHarness(opts: {
|
|
|
121
136
|
if (ps === subagentsDir) return [`agent-${agentId}.jsonl`]
|
|
122
137
|
return []
|
|
123
138
|
}) as unknown as typeof fs.readdirSync,
|
|
124
|
-
statSync: ((p: fs.PathLike) => ({ size: fileContents.get(String(p))?.length ?? 0 }) as fs.Stats) as typeof fs.statSync,
|
|
139
|
+
statSync: ((p: fs.PathLike) => ({ size: fileContents.get(String(p))?.length ?? 0, mtimeMs: fileMtimes.get(String(p)) ?? currentTime }) as fs.Stats) as typeof fs.statSync,
|
|
125
140
|
openSync: ((p: fs.PathLike) => {
|
|
126
141
|
lastOpenedPath = String(p)
|
|
127
142
|
return 42
|
|
@@ -153,6 +168,8 @@ function makeHarness(opts: {
|
|
|
153
168
|
silentSynthesisStallThresholdMs: stallThresholdMs,
|
|
154
169
|
silentStallTerminalMs,
|
|
155
170
|
rescanMs,
|
|
171
|
+
bootPromoteEnabled,
|
|
172
|
+
...(inflightPromoteMaxAgeMs != null ? { inflightPromoteMaxAgeMs } : {}),
|
|
156
173
|
onStallTerminal: (id) => stallTerminalCalls.push({ agentId: id }),
|
|
157
174
|
onFinish: ({ agentId: id, outcome, resultText }) =>
|
|
158
175
|
finishCalls.push({ agentId: id, outcome, resultText }),
|
|
@@ -186,6 +203,7 @@ function makeHarness(opts: {
|
|
|
186
203
|
const cur = fileContents.get(jsonlPath) ?? Buffer.alloc(0)
|
|
187
204
|
const more = buildJSONL(...lines)
|
|
188
205
|
fileContents.set(jsonlPath, Buffer.concat([cur, Buffer.from(more, 'utf-8')]))
|
|
206
|
+
fileMtimes.set(jsonlPath, currentTime)
|
|
189
207
|
}
|
|
190
208
|
|
|
191
209
|
return { stallTerminalCalls, finishCalls, logs, advance, watcher, fileContents, jsonlPath, append }
|
|
@@ -245,6 +263,75 @@ describe('Gap 1 — background worker in-flight across a gateway restart', () =>
|
|
|
245
263
|
})
|
|
246
264
|
})
|
|
247
265
|
|
|
266
|
+
describe('Gap 1 freshness gate — v0.14.24 stale-replay regression', () => {
|
|
267
|
+
// The v0.14.23 regression: promoting EVERY running-at-boot file replayed
|
|
268
|
+
// weeks-old dead prior-session workers as handbacks (often `failed`, from
|
|
269
|
+
// old error lines) on every boot, spamming the whole fleet. The gate
|
|
270
|
+
// promotes only files whose last write is recent.
|
|
271
|
+
|
|
272
|
+
it('a STALE running-at-boot worker (weeks-old mtime) is NOT promoted — no handback, no stall', () => {
|
|
273
|
+
const h = makeHarness({
|
|
274
|
+
agentId: 'gap1-stale-running',
|
|
275
|
+
bootLines: [subAgentUserMsg('bg task from weeks ago')], // running: no turn_end
|
|
276
|
+
bootFileAgeMs: 21 * 24 * 60 * 60_000, // 21 days old — clearly dead
|
|
277
|
+
silentStallTerminalMs: 120_000,
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
h.advance(600)
|
|
281
|
+
h.advance(600_000) // far past every stall/synthesis window
|
|
282
|
+
expect(h.finishCalls).toHaveLength(0) // pre-fix: a spurious (often failed) handback
|
|
283
|
+
expect(h.stallTerminalCalls).toHaveLength(0)
|
|
284
|
+
expect(h.logs.some((l) => l.includes('stale') && l.includes('leaving historical'))).toBe(true)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('a FRESH running-at-boot worker (recent mtime) IS still promoted and hands back', () => {
|
|
288
|
+
// Preserve the genuine Gap 1 fix: a worker in-flight across a restart
|
|
289
|
+
// (wrote moments before the bounce) must still get promoted + handed back.
|
|
290
|
+
const h = makeHarness({
|
|
291
|
+
agentId: 'gap1-fresh-running',
|
|
292
|
+
bootLines: [subAgentUserMsg('bg task')],
|
|
293
|
+
bootFileAgeMs: 30_000, // 30s old — in-flight across a quick restart
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
h.append(subAgentText('Finished the migration'), subAgentTurnEnd())
|
|
297
|
+
h.advance(600)
|
|
298
|
+
|
|
299
|
+
expect(h.finishCalls).toHaveLength(1)
|
|
300
|
+
expect(h.finishCalls[0].outcome).toBe('completed')
|
|
301
|
+
expect(h.logs.some((l) => l.includes('promoting to live'))).toBe(true)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('kill-switch (bootPromoteEnabled=false) suppresses even a fresh running-at-boot worker', () => {
|
|
305
|
+
const h = makeHarness({
|
|
306
|
+
agentId: 'gap1-killswitch',
|
|
307
|
+
bootLines: [subAgentUserMsg('bg task')],
|
|
308
|
+
bootFileAgeMs: 5_000, // fresh — would normally promote
|
|
309
|
+
bootPromoteEnabled: false,
|
|
310
|
+
silentStallTerminalMs: 120_000,
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
h.advance(600)
|
|
314
|
+
h.advance(600_000)
|
|
315
|
+
expect(h.finishCalls).toHaveLength(0)
|
|
316
|
+
expect(h.logs.some((l) => l.includes('promotion disabled'))).toBe(true)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('a worker just past the freshness window is NOT promoted (boundary)', () => {
|
|
320
|
+
const h = makeHarness({
|
|
321
|
+
agentId: 'gap1-boundary',
|
|
322
|
+
bootLines: [subAgentUserMsg('bg task')],
|
|
323
|
+
inflightPromoteMaxAgeMs: 60_000, // 60s window
|
|
324
|
+
bootFileAgeMs: 90_000, // 90s old → just stale
|
|
325
|
+
silentStallTerminalMs: 120_000,
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
h.advance(600)
|
|
329
|
+
h.advance(600_000)
|
|
330
|
+
expect(h.finishCalls).toHaveLength(0)
|
|
331
|
+
expect(h.logs.some((l) => l.includes('stale'))).toBe(true)
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
|
|
248
335
|
describe('Gap 2 — failure honesty', () => {
|
|
249
336
|
it('a terminal error line flips the outcome to failed and carries the detail', () => {
|
|
250
337
|
const h = makeHarness({ agentId: 'gap2-failed', bootLines: [subAgentUserMsg('bg task')] })
|