switchroom 0.14.22 → 0.14.23

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.
@@ -49420,8 +49420,8 @@ var {
49420
49420
  } = import__.default;
49421
49421
 
49422
49422
  // src/build-info.ts
49423
- var VERSION = "0.14.22";
49424
- var COMMIT_SHA = "ab2692b9";
49423
+ var VERSION = "0.14.23";
49424
+ var COMMIT_SHA = "8ac2987a";
49425
49425
 
49426
49426
  // src/cli/agent.ts
49427
49427
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.22",
3
+ "version": "0.14.23",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48827,6 +48827,67 @@ import {
48827
48827
  import { join as join21 } from "path";
48828
48828
 
48829
48829
  // operator-events.ts
48830
+ function classifyClaudeError(raw) {
48831
+ try {
48832
+ return classifyInner(raw);
48833
+ } catch {
48834
+ return "unknown-4xx";
48835
+ }
48836
+ }
48837
+ function classifyInner(raw) {
48838
+ if (raw == null)
48839
+ return "unknown-4xx";
48840
+ const obj = typeof raw === "object" ? raw : {};
48841
+ const errorType = extractString(obj, "error_type") ?? extractString(obj, "type") ?? extractString(getNestedObj(obj, "error"), "type") ?? "";
48842
+ const errorCode = extractString(obj, "code") ?? extractString(getNestedObj(obj, "error"), "code") ?? "";
48843
+ const message = extractString(obj, "message") ?? extractString(getNestedObj(obj, "error"), "message") ?? (typeof raw === "string" ? raw : "") ?? "";
48844
+ const status = extractNumber(obj, "status") ?? extractNumber(obj, "statusCode") ?? extractNumber(obj, "status_code") ?? null;
48845
+ const sdkCode = extractString(obj, "error_code") ?? "";
48846
+ if (errorType === "authentication_error" || errorCode === "authentication_error" || sdkCode === "authentication_error" || message.toLowerCase().includes("authentication_error")) {
48847
+ const msg = message.toLowerCase();
48848
+ if (msg.includes("expired") || msg.includes("refresh")) {
48849
+ return "credentials-expired";
48850
+ }
48851
+ return "credentials-invalid";
48852
+ }
48853
+ if (errorType === "invalid_api_key" || errorCode === "invalid_api_key" || sdkCode === "invalid_api_key" || message.toLowerCase().includes("invalid_api_key") || message.toLowerCase().includes("invalid api key")) {
48854
+ return "credentials-invalid";
48855
+ }
48856
+ if (errorType === "credit_balance_too_low" || errorCode === "credit_balance_too_low" || sdkCode === "credit_balance_too_low" || message.toLowerCase().includes("credit_balance_too_low") || message.toLowerCase().includes("credit balance")) {
48857
+ return "credit-exhausted";
48858
+ }
48859
+ if (errorType === "rate_limit_error" || errorCode === "rate_limit_error" || sdkCode === "rate_limit_error" || message.toLowerCase().includes("rate_limit_error") || message.toLowerCase().includes("rate limit")) {
48860
+ return "rate-limited";
48861
+ }
48862
+ if (errorType === "overloaded_error" || errorCode === "overloaded_error" || sdkCode === "overloaded_error" || message.toLowerCase().includes("overloaded_error") || message.toLowerCase().includes("overloaded")) {
48863
+ return "rate-limited";
48864
+ }
48865
+ if (errorType === "agent-crashed" || errorCode === "agent-crashed") {
48866
+ return "agent-crashed";
48867
+ }
48868
+ if (errorType === "agent-restarted-unexpectedly" || errorCode === "agent-restarted-unexpectedly") {
48869
+ return "agent-restarted-unexpectedly";
48870
+ }
48871
+ if (status != null) {
48872
+ if (status >= 400 && status < 500)
48873
+ return "unknown-4xx";
48874
+ if (status >= 500 && status < 600)
48875
+ return "unknown-5xx";
48876
+ }
48877
+ return "unknown-4xx";
48878
+ }
48879
+ function extractString(obj, key) {
48880
+ const v = obj[key];
48881
+ return typeof v === "string" && v.length > 0 ? v : null;
48882
+ }
48883
+ function extractNumber(obj, key) {
48884
+ const v = obj[key];
48885
+ return typeof v === "number" ? v : null;
48886
+ }
48887
+ function getNestedObj(obj, key) {
48888
+ const v = obj[key];
48889
+ return typeof v === "object" && v != null ? v : {};
48890
+ }
48830
48891
  var DEFAULT_OPERATOR_EVENT_COOLDOWN_MS2 = 5 * 60000;
48831
48892
  var cooldownMap2 = new Map;
48832
48893
 
@@ -48936,6 +48997,72 @@ function projectSubagentLine(line, agentId, state4) {
48936
48997
  }
48937
48998
  return [];
48938
48999
  }
49000
+ function extractRetryState(obj) {
49001
+ return {
49002
+ retryAttempt: typeof obj.retryAttempt === "number" ? obj.retryAttempt : null,
49003
+ maxRetries: typeof obj.maxRetries === "number" ? obj.maxRetries : null
49004
+ };
49005
+ }
49006
+ function detectErrorInTranscriptLine(line) {
49007
+ if (!line || line.length > 2 * 1024 * 1024)
49008
+ return null;
49009
+ let obj;
49010
+ try {
49011
+ obj = JSON.parse(line);
49012
+ } catch {
49013
+ return null;
49014
+ }
49015
+ if (typeof obj !== "object" || obj == null)
49016
+ return null;
49017
+ const type = obj.type;
49018
+ if (obj.isApiErrorMessage === true) {
49019
+ const status = typeof obj.apiErrorStatus === "number" ? obj.apiErrorStatus : null;
49020
+ const errStr = typeof obj.error === "string" ? obj.error : "";
49021
+ const text = extractAssistantText(obj);
49022
+ const kind2 = status === 429 ? "quota-exhausted" : classifyClaudeError({ type: errStr, status, message: text });
49023
+ return {
49024
+ kind: kind2,
49025
+ raw: obj,
49026
+ detail: text || errStr || "api error",
49027
+ transient: kind2 === "rate-limited",
49028
+ terminal: true
49029
+ };
49030
+ }
49031
+ const isErrorLine = type === "api_error" || type === "error";
49032
+ const embeddedError = typeof obj.error === "object" && obj.error != null ? obj.error : null;
49033
+ if (!isErrorLine && !embeddedError)
49034
+ return null;
49035
+ const raw = embeddedError ?? obj;
49036
+ const kind = classifyClaudeError(embeddedError ?? obj);
49037
+ const detail = extractDetailMessage(embeddedError) ?? extractDetailMessage(obj) ?? String(type ?? "");
49038
+ const transient = kind === "rate-limited";
49039
+ const retry = extractRetryState(obj);
49040
+ const terminal = !transient ? true : retry.retryAttempt != null && retry.maxRetries != null ? retry.retryAttempt >= retry.maxRetries : isErrorLine;
49041
+ return { kind, raw, detail, transient, terminal };
49042
+ }
49043
+ function extractDetailMessage(obj) {
49044
+ if (!obj)
49045
+ return null;
49046
+ const msg = obj.message;
49047
+ return typeof msg === "string" && msg.length > 0 ? msg : null;
49048
+ }
49049
+ function extractAssistantText(obj) {
49050
+ const message = obj.message;
49051
+ if (typeof message !== "object" || message == null)
49052
+ return "";
49053
+ const content = message.content;
49054
+ if (!Array.isArray(content))
49055
+ return "";
49056
+ const parts = [];
49057
+ for (const block of content) {
49058
+ if (typeof block === "object" && block != null && block.type === "text") {
49059
+ const t = block.text;
49060
+ if (typeof t === "string")
49061
+ parts.push(t);
49062
+ }
49063
+ }
49064
+ return parts.join(" ").trim();
49065
+ }
48939
49066
 
48940
49067
  // fleet-state.ts
48941
49068
  var SANITISE_MAX_LEN = 120;
@@ -49189,6 +49316,12 @@ function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, paren
49189
49316
  for (const line of lines) {
49190
49317
  if (!line)
49191
49318
  continue;
49319
+ const errInfo = detectErrorInTranscriptLine(line);
49320
+ if (errInfo?.terminal) {
49321
+ entry.errored = true;
49322
+ if (errInfo.detail)
49323
+ entry.errorDetail = errInfo.detail.slice(0, SUBAGENT_RESULT_TEXT_MAX);
49324
+ }
49192
49325
  const events = projectSubagentLine(line, entry.agentId, startState);
49193
49326
  for (const ev of events) {
49194
49327
  const idleSecBeforeBump = Math.round((now - entry.lastActivityAt) / 1000);
@@ -49253,7 +49386,7 @@ function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, paren
49253
49386
  recordSubagentEnd(db2, {
49254
49387
  id: rowRef.id,
49255
49388
  endedAt: now,
49256
- status: "completed"
49389
+ status: entry.errored ? "failed" : "completed"
49257
49390
  });
49258
49391
  }
49259
49392
  } catch (dbErr) {
@@ -49363,6 +49496,17 @@ function startSubagentWatcher(config) {
49363
49496
  readSubTail(entry, tail, n, (desc) => {
49364
49497
  log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`);
49365
49498
  }, fs2, log, db2, parentStateDir, config.onUnstall, undefined, config.onProgress);
49499
+ if (isHistorical && entry.state === "running") {
49500
+ entry.historical = false;
49501
+ log?.(`subagent-watcher: ${agentId} was in-flight at boot \u2014 promoting to live (predates watcher; user still awaiting handback)`);
49502
+ if (db2 != null) {
49503
+ try {
49504
+ backfillJsonlAgentId(db2, filePath, agentId, log);
49505
+ } catch (err) {
49506
+ log?.(`subagent-watcher: backfill error for ${agentId}: ${err.message}`);
49507
+ }
49508
+ }
49509
+ }
49366
49510
  if (isHistorical && entry.state === "done") {
49367
49511
  entry.completionNotified = true;
49368
49512
  scheduleTerminalCleanup(agentId);
@@ -49397,11 +49541,11 @@ function startSubagentWatcher(config) {
49397
49541
  config.onFinish({
49398
49542
  agentId,
49399
49543
  state: entry.state,
49400
- outcome: entry.historical ? "orphan" : "completed",
49544
+ outcome: entry.errored ? "failed" : entry.historical ? "orphan" : "completed",
49401
49545
  toolCount: entry.toolCount,
49402
49546
  durationMs: nowFn() - entry.dispatchedAt,
49403
49547
  description: entry.description,
49404
- resultText: entry.lastResultText
49548
+ resultText: entry.errored ? entry.lastResultText || entry.errorDetail || "" : entry.lastResultText
49405
49549
  });
49406
49550
  } catch (cbErr) {
49407
49551
  log?.(`subagent-watcher: onFinish callback error ${agentId}: ${cbErr.message}`);
@@ -49518,7 +49662,7 @@ function startSubagentWatcher(config) {
49518
49662
  recordSubagentEnd(db2, {
49519
49663
  id: rowRef.id,
49520
49664
  endedAt: n,
49521
- status: "completed"
49665
+ status: entry.errored ? "failed" : "completed"
49522
49666
  });
49523
49667
  }
49524
49668
  } catch (dbErr) {
@@ -51298,10 +51442,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51298
51442
  }
51299
51443
 
51300
51444
  // ../src/build-info.ts
51301
- var VERSION = "0.14.22";
51302
- var COMMIT_SHA = "ab2692b9";
51303
- var COMMIT_DATE = "2026-05-31T06:26:06Z";
51304
- var LATEST_PR = 2028;
51445
+ var VERSION = "0.14.23";
51446
+ var COMMIT_SHA = "8ac2987a";
51447
+ var COMMIT_DATE = "2026-05-31T22:03:26Z";
51448
+ var LATEST_PR = 2031;
51305
51449
  var COMMITS_AHEAD_OF_TAG = 0;
51306
51450
 
51307
51451
  // gateway/boot-version.ts
@@ -40,7 +40,7 @@ import {
40
40
  } from 'fs'
41
41
  import { basename, join } from 'path'
42
42
  import { homedir } from 'os'
43
- import { projectSubagentLine, sanitizeCwdToProjectName } from './session-tail.js'
43
+ import { projectSubagentLine, sanitizeCwdToProjectName, detectErrorInTranscriptLine } from './session-tail.js'
44
44
  import { sanitiseToolArg } from './fleet-state.js'
45
45
  import { escapeHtml, truncate } from './card-format.js'
46
46
  import { bumpSubagentActivity, recordSubagentStall, recordSubagentResume, recordSubagentEnd, reapStuckRunningRows } from './registry/subagents-schema.js'
@@ -142,6 +142,21 @@ export interface WorkerEntry {
142
142
  * dead, the file is just left over from a prior session.
143
143
  */
144
144
  historical: boolean
145
+ /**
146
+ * True once a TERMINAL error line — a model API failure / quota
147
+ * exhaustion / crash, NOT an in-flight retry or a routine tool-level
148
+ * `is_error` result — has been observed in this worker's own
149
+ * transcript. Drives the `failed` terminal outcome so the handback
150
+ * tells the user the delegated work did NOT complete, instead of
151
+ * dressing a dead worker up as `completed`. Classified by
152
+ * `detectErrorInTranscriptLine` (the same gate the operator-event
153
+ * path uses), so transient mid-retry errors are excluded.
154
+ */
155
+ errored?: boolean
156
+ /** Human-readable detail from the terminal error line, surfaced in the
157
+ * failed handback's "what it reported before failing" slot when the
158
+ * worker left no narrative result of its own. */
159
+ errorDetail?: string
145
160
  }
146
161
 
147
162
  export interface SubagentWatcherConfig {
@@ -611,6 +626,20 @@ export function readSubTail(
611
626
  const startState = { hasEmittedStart: tail.hasEmittedStart }
612
627
  for (const line of lines) {
613
628
  if (!line) continue
629
+ // Gap 2 (failure honesty): a terminal error line in the worker's
630
+ // OWN transcript — a model API failure, quota exhaustion, or crash —
631
+ // means the worker FAILED, not finished. Reuse the operator-event
632
+ // classifier: `terminal:true` excludes in-flight retries (a 529 mid-
633
+ // backoff is `terminal:false`), and tool-level `is_error` results
634
+ // never reach here (they parse as `sub_agent_tool_result`, which is
635
+ // routine mid-run noise, not a worker death). The flag persists on
636
+ // the entry; the terminal transition (real turn_end OR stall
637
+ // synthesis) reads it to emit `failed` instead of `completed`.
638
+ const errInfo = detectErrorInTranscriptLine(line)
639
+ if (errInfo?.terminal) {
640
+ entry.errored = true
641
+ if (errInfo.detail) entry.errorDetail = errInfo.detail.slice(0, SUBAGENT_RESULT_TEXT_MAX)
642
+ }
614
643
  const events = projectSubagentLine(line, entry.agentId, startState)
615
644
  for (const ev of events) {
616
645
  const idleSecBeforeBump = Math.round((now - entry.lastActivityAt) / 1000)
@@ -716,7 +745,10 @@ export function readSubTail(
716
745
  recordSubagentEnd(db, {
717
746
  id: rowRef.id,
718
747
  endedAt: now,
719
- status: 'completed',
748
+ // Gap 2: keep the audit row honest — a worker that hit a
749
+ // terminal transcript error is `failed`, matching the
750
+ // handback outcome computed in maybySendStateTransition.
751
+ status: entry.errored ? 'failed' : 'completed',
720
752
  })
721
753
  }
722
754
  } catch (dbErr) {
@@ -917,6 +949,34 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
917
949
  log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
918
950
  }, fs, log, db, parentStateDir, config.onUnstall, undefined, config.onProgress)
919
951
 
952
+ // Gap 1 (restart survival): a file still RUNNING at boot is a LIVE
953
+ // worker that predates this watcher — typically one dispatched in a
954
+ // prior gateway life and still in-flight across a restart / fleet
955
+ // rollout, NOT a stale already-finished file. `historical` must
956
+ // suppress replay only for done-at-boot files; an in-flight-at-boot
957
+ // worker the user is still waiting on must get full live treatment:
958
+ // progress nudges, the stall-synthesis safety net (checkStalls skips
959
+ // historical entries), and a real `completed`/`failed` handback rather
960
+ // than a dropped `orphan`. Promote it to a live entry here. (A file
961
+ // already `done` at boot stays historical and is short-circuited just
962
+ // below — it finished before this session.)
963
+ if (isHistorical && entry.state === 'running') {
964
+ entry.historical = false
965
+ log?.(`subagent-watcher: ${agentId} was in-flight at boot — promoting to live (predates watcher; user still awaiting handback)`)
966
+ // The prior gateway life's registration normally linked
967
+ // jsonl_agent_id already, but re-run the backfill idempotently in
968
+ // case that life crashed before the link persisted — the handback's
969
+ // isBackground lookup is keyed on jsonl_agent_id, and an unlinked row
970
+ // would mis-resolve the worker as foreground and drop the handback.
971
+ if (db != null) {
972
+ try {
973
+ backfillJsonlAgentId(db, filePath, agentId, log)
974
+ } catch (err) {
975
+ log?.(`subagent-watcher: backfill error for ${agentId}: ${(err as Error).message}`)
976
+ }
977
+ }
978
+ }
979
+
920
980
  // If the JSONL already contained a turn_end at registration time
921
981
  // (file written-then-watched), fire the state-transition + completion
922
982
  // notification now. Otherwise the FSWatcher callback handles it on
@@ -980,11 +1040,22 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
980
1040
  config.onFinish({
981
1041
  agentId,
982
1042
  state: entry.state,
983
- outcome: entry.historical ? 'orphan' : 'completed',
1043
+ // Gap 2: a terminal error observed in the transcript wins over
1044
+ // the completed/orphan classification — a worker that crashed
1045
+ // is `failed`, even if it later wrote a turn_end or aged into
1046
+ // stall synthesis. `orphan` remains for genuinely stale
1047
+ // done-at-boot rows (which never reach this path; see
1048
+ // registerAgent's short-circuit + Gap 1 promotion).
1049
+ outcome: entry.errored ? 'failed' : entry.historical ? 'orphan' : 'completed',
984
1050
  toolCount: entry.toolCount,
985
1051
  durationMs: nowFn() - entry.dispatchedAt,
986
1052
  description: entry.description,
987
- resultText: entry.lastResultText,
1053
+ // For a failure, fall back to the error detail when the worker
1054
+ // left no narrative of its own — so the handback's "what it
1055
+ // reported before failing" slot is never empty on a crash.
1056
+ resultText: entry.errored
1057
+ ? entry.lastResultText || entry.errorDetail || ''
1058
+ : entry.lastResultText,
988
1059
  })
989
1060
  } catch (cbErr) {
990
1061
  log?.(`subagent-watcher: onFinish callback error ${agentId}: ${(cbErr as Error).message}`)
@@ -1151,7 +1222,10 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1151
1222
  recordSubagentEnd(db, {
1152
1223
  id: rowRef.id,
1153
1224
  endedAt: n,
1154
- status: 'completed',
1225
+ // Gap 2: a worker that hit a terminal transcript error before
1226
+ // going silent is `failed`, not `completed` — keep the audit
1227
+ // row consistent with the handback outcome.
1228
+ status: entry.errored ? 'failed' : 'completed',
1155
1229
  })
1156
1230
  }
1157
1231
  } catch (dbErr) {
@@ -624,13 +624,17 @@ describe('Bug 3 — stalled-row sweeper: watcher must call recordSubagentStall i
624
624
  h.watcher.stop()
625
625
  })
626
626
 
627
- it('does not call stall for historical entries (pre-existing at boot)', () => {
627
+ it('does not call stall for historical (done-at-boot) entries', () => {
628
+ // A worker that already FINISHED before boot (turn_end present) stays
629
+ // historical and must not write stall rows. A still-RUNNING file at
630
+ // boot is a different case — Gap 1 promotes it to live so it DOES get
631
+ // the stall safety net (covered in subagent-watcher-handback-gaps).
628
632
  const agentDir = '/home/user/.switchroom/agents/myagent'
629
633
  const subagentsDir = `${agentDir}/.claude/projects/p1/session-abc/subagents`
630
634
  const jsonlStem = 'hist-agent'
631
635
  const toolUseId = 'toolu_hist001'
632
636
  const jsonlPath = `${subagentsDir}/agent-${jsonlStem}.jsonl`
633
- const content = buildJSONL(subAgentUserMsg('Old task'))
637
+ const content = buildJSONL(subAgentUserMsg('Old task'), subAgentTurnDuration())
634
638
 
635
639
  const db = makeInMemoryDb({
636
640
  [toolUseId]: { id: toolUseId, jsonl_agent_id: jsonlStem, status: 'running' },
@@ -648,7 +652,7 @@ describe('Bug 3 — stalled-row sweeper: watcher must call recordSubagentStall i
648
652
  db,
649
653
  })
650
654
 
651
- // Do NOT flip historical entry is historical by default (file at boot)
655
+ // Done-at-boot stays historical (not promoted); no stall write fires.
652
656
  h.advance(65_000)
653
657
 
654
658
  const stallDbCalls = db._calls.filter(
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Tests for the two background-worker handback gaps closed in
3
+ * `fix/subagent-handback-restart-and-failure`:
4
+ *
5
+ * Gap 1 — restart survival. A background worker that is in-flight when
6
+ * the gateway restarts is discovered by the boot scan and tagged
7
+ * `historical`. That flag is meant to suppress replay for workers that
8
+ * ALREADY finished before boot — but it was also applied to workers
9
+ * still running, which then completed with outcome `orphan`, and the
10
+ * handback gate drops `orphan`. Net: dispatched worker + any gateway
11
+ * bounce (incl. a fleet rollout) + worker finishes = user never told.
12
+ * Fix: a file still `running` at boot is promoted to a LIVE entry, so
13
+ * it gets the stall-synthesis safety net and a real `completed`/`failed`
14
+ * handback. A file already `done` at boot stays suppressed.
15
+ *
16
+ * Gap 2 — failure honesty. The `failed` outcome was dead code (no caller
17
+ * set it), so every dead worker was reported `completed`. Fix: a
18
+ * TERMINAL error line in the worker's own transcript (model API failure
19
+ * / quota exhaustion / crash — not an in-flight retry, not a routine
20
+ * tool-level is_error) flips the terminal outcome to `failed` and
21
+ * carries the error detail into the handback result.
22
+ */
23
+
24
+ import { describe, it, expect, vi } from 'vitest'
25
+ import { startSubagentWatcher } from '../subagent-watcher.js'
26
+ import * as fs from 'fs'
27
+
28
+ function buildJSONL(...lines: object[]): string {
29
+ return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
30
+ }
31
+ function subAgentUserMsg(promptText: string) {
32
+ return { type: 'user', message: { content: [{ type: 'text', text: promptText }] } }
33
+ }
34
+ function subAgentText(text: string) {
35
+ return { type: 'assistant', message: { content: [{ type: 'text', text }] } }
36
+ }
37
+ function subAgentTurnEnd() {
38
+ return { type: 'system', subtype: 'turn_duration', duration_ms: 1234 }
39
+ }
40
+ // A terminal error line in the worker's OWN transcript — the model call
41
+ // itself failed (here an invalid_request_error). `detectErrorInTranscriptLine`
42
+ // classifies an explicit `type:"error"` line with a non-rate-limit kind as
43
+ // terminal:true.
44
+ function subAgentTerminalError(message: string) {
45
+ return { type: 'error', error: { type: 'invalid_request_error', message } }
46
+ }
47
+ // A routine mid-run tool failure (e.g. a grep that found nothing). This is a
48
+ // `sub_agent_tool_result` with is_error — NOT a worker death. Must NOT trip
49
+ // the failed classification.
50
+ function subAgentToolResultError() {
51
+ return {
52
+ type: 'user',
53
+ message: {
54
+ content: [{ type: 'tool_result', tool_use_id: 'toolu_x', is_error: true, content: 'no matches found' }],
55
+ },
56
+ }
57
+ }
58
+
59
+ interface FinishCall {
60
+ agentId: string
61
+ outcome: string
62
+ resultText: string
63
+ }
64
+
65
+ interface Harness {
66
+ stallTerminalCalls: Array<{ agentId: string }>
67
+ finishCalls: FinishCall[]
68
+ logs: string[]
69
+ advance: (ms: number) => void
70
+ watcher: ReturnType<typeof startSubagentWatcher>
71
+ fileContents: Map<string, Buffer>
72
+ jsonlPath: string
73
+ append: (...lines: object[]) => void
74
+ }
75
+
76
+ function makeHarness(opts: {
77
+ agentId?: string
78
+ /** Lines present in the JSONL at boot (before the watcher starts). */
79
+ bootLines: object[]
80
+ stallThresholdMs?: number
81
+ silentStallTerminalMs?: number
82
+ rescanMs?: number
83
+ }): Harness {
84
+ const {
85
+ agentId = 'gap-agent',
86
+ bootLines,
87
+ stallThresholdMs = 60_000,
88
+ silentStallTerminalMs = 300_000,
89
+ rescanMs = 500,
90
+ } = opts
91
+
92
+ let currentTime = 1000
93
+ const stallTerminalCalls: Array<{ agentId: string }> = []
94
+ const finishCalls: FinishCall[] = []
95
+ const logs: string[] = []
96
+
97
+ const agentDir = '/home/user/.switchroom/agents/myagent'
98
+ const sessionId = 'mock-session'
99
+ const projectsRoot = `${agentDir}/.claude/projects`
100
+ const projectDir = `${projectsRoot}/mock-cwd`
101
+ const sessionDir = `${projectDir}/${sessionId}`
102
+ const subagentsDir = `${sessionDir}/subagents`
103
+ const jsonlPath = `${subagentsDir}/agent-${agentId}.jsonl`
104
+
105
+ const fileContents = new Map<string, Buffer>()
106
+ fileContents.set(jsonlPath, Buffer.from(buildJSONL(...bootLines), 'utf-8'))
107
+
108
+ let lastOpenedPath: string | null = null
109
+ const mockFs = {
110
+ existsSync: ((p: fs.PathLike) => {
111
+ const ps = String(p)
112
+ if (ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir) return true
113
+ if (fileContents.has(ps)) return true
114
+ return false
115
+ }) as typeof fs.existsSync,
116
+ readdirSync: ((p: fs.PathLike) => {
117
+ const ps = String(p)
118
+ if (ps === projectsRoot) return ['mock-cwd']
119
+ if (ps === projectDir) return [sessionId]
120
+ if (ps === sessionDir) return ['subagents']
121
+ if (ps === subagentsDir) return [`agent-${agentId}.jsonl`]
122
+ return []
123
+ }) 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,
125
+ openSync: ((p: fs.PathLike) => {
126
+ lastOpenedPath = String(p)
127
+ return 42
128
+ }) as unknown as typeof fs.openSync,
129
+ closeSync: (() => { lastOpenedPath = null }) as typeof fs.closeSync,
130
+ readSync: ((
131
+ _fd: number,
132
+ buf: NodeJS.ArrayBufferView,
133
+ offset: number,
134
+ length: number,
135
+ position: number | null,
136
+ ): number => {
137
+ const content = lastOpenedPath != null ? fileContents.get(lastOpenedPath) : undefined
138
+ if (!content) return 0
139
+ const pos = position ?? 0
140
+ const src = content.slice(pos, pos + length)
141
+ ;(src as Buffer).copy(buf as Buffer, offset)
142
+ return src.length
143
+ }) as unknown as typeof fs.readSync,
144
+ watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
145
+ }
146
+
147
+ const intervals: Array<{ fn: () => void; ms: number; ref: number; fireAt: number }> = []
148
+ let nextRef = 1
149
+
150
+ const watcher = startSubagentWatcher({
151
+ agentDir,
152
+ stallThresholdMs,
153
+ silentSynthesisStallThresholdMs: stallThresholdMs,
154
+ silentStallTerminalMs,
155
+ rescanMs,
156
+ onStallTerminal: (id) => stallTerminalCalls.push({ agentId: id }),
157
+ onFinish: ({ agentId: id, outcome, resultText }) =>
158
+ finishCalls.push({ agentId: id, outcome, resultText }),
159
+ now: () => currentTime,
160
+ setInterval: (fn, ms) => {
161
+ const ref = nextRef++
162
+ intervals.push({ fn, ms, ref, fireAt: currentTime + ms })
163
+ return { ref }
164
+ },
165
+ clearInterval: (handle) => {
166
+ const { ref } = handle as { ref: number }
167
+ const idx = intervals.findIndex((i) => i.ref === ref)
168
+ if (idx !== -1) intervals.splice(idx, 1)
169
+ },
170
+ fs: mockFs,
171
+ log: (msg) => logs.push(msg),
172
+ })
173
+
174
+ const advance = (ms: number): void => {
175
+ currentTime += ms
176
+ for (;;) {
177
+ intervals.sort((a, b) => a.fireAt - b.fireAt)
178
+ const next = intervals[0]
179
+ if (!next || next.fireAt > currentTime) break
180
+ next.fireAt += next.ms
181
+ next.fn()
182
+ }
183
+ }
184
+
185
+ const append = (...lines: object[]): void => {
186
+ const cur = fileContents.get(jsonlPath) ?? Buffer.alloc(0)
187
+ const more = buildJSONL(...lines)
188
+ fileContents.set(jsonlPath, Buffer.concat([cur, Buffer.from(more, 'utf-8')]))
189
+ }
190
+
191
+ return { stallTerminalCalls, finishCalls, logs, advance, watcher, fileContents, jsonlPath, append }
192
+ }
193
+
194
+ describe('Gap 1 — background worker in-flight across a gateway restart', () => {
195
+ it('an in-flight-at-boot worker that completes hands back as completed (not orphan)', () => {
196
+ // Boot scan finds a running worker (prompt, no turn_end yet) → tagged
197
+ // historical. The fix promotes it to live. When it finishes under our
198
+ // watch, the outcome must be `completed` so the handback delivers.
199
+ const h = makeHarness({ agentId: 'gap1-complete', bootLines: [subAgentUserMsg('bg task')] })
200
+
201
+ // The worker finishes after the restart.
202
+ h.append(subAgentText('Found the root cause in auth.ts'), subAgentTurnEnd())
203
+ h.advance(600) // one poll reads the new bytes
204
+
205
+ expect(h.finishCalls).toHaveLength(1)
206
+ expect(h.finishCalls[0].agentId).toBe('gap1-complete')
207
+ expect(h.finishCalls[0].outcome).toBe('completed') // pre-fix: 'orphan' → dropped
208
+ expect(h.finishCalls[0].resultText).toContain('root cause')
209
+ // The promotion is logged so the path is observable in prod.
210
+ expect(h.logs.some((l) => l.includes('in-flight at boot — promoting to live'))).toBe(true)
211
+ })
212
+
213
+ it('an in-flight-at-boot worker that dies silently is rescued by stall synthesis', () => {
214
+ // Pre-fix, historical entries were skipped by stall detection, so a
215
+ // worker that crossed a restart and then went silent sat running
216
+ // forever — no handback ever. After promotion it gets the safety net.
217
+ const h = makeHarness({
218
+ agentId: 'gap1-silent',
219
+ bootLines: [subAgentUserMsg('bg task')],
220
+ stallThresholdMs: 60_000,
221
+ silentStallTerminalMs: 120_000,
222
+ })
223
+
224
+ h.advance(62_000) // stall threshold crossed
225
+ expect(h.stallTerminalCalls).toHaveLength(0)
226
+ h.advance(121_000) // silent-stall terminal window elapses → synthesis
227
+ expect(h.stallTerminalCalls).toHaveLength(1)
228
+ expect(h.finishCalls).toHaveLength(1)
229
+ expect(h.finishCalls[0].outcome).toBe('completed')
230
+ })
231
+
232
+ it('a worker already DONE at boot stays suppressed (no spurious replay)', () => {
233
+ // The legitimate use of `historical`: a worker that finished in a prior
234
+ // session must NOT re-fire a handback on every restart. This is the
235
+ // regression guard for the fix.
236
+ const h = makeHarness({
237
+ agentId: 'gap1-stale',
238
+ bootLines: [subAgentUserMsg('bg task'), subAgentText('done long ago'), subAgentTurnEnd()],
239
+ })
240
+
241
+ h.advance(600)
242
+ h.advance(600_000) // well past any stall window
243
+ expect(h.finishCalls).toHaveLength(0)
244
+ expect(h.stallTerminalCalls).toHaveLength(0)
245
+ })
246
+ })
247
+
248
+ describe('Gap 2 — failure honesty', () => {
249
+ it('a terminal error line flips the outcome to failed and carries the detail', () => {
250
+ const h = makeHarness({ agentId: 'gap2-failed', bootLines: [subAgentUserMsg('bg task')] })
251
+
252
+ // The worker's model call errors out, then the transcript ends.
253
+ h.append(subAgentTerminalError('tool input rejected by the API'), subAgentTurnEnd())
254
+ h.advance(600)
255
+
256
+ expect(h.finishCalls).toHaveLength(1)
257
+ expect(h.finishCalls[0].outcome).toBe('failed')
258
+ // No narrative was emitted, so the detail backfills the result slot.
259
+ expect(h.finishCalls[0].resultText).toContain('tool input rejected')
260
+ })
261
+
262
+ it('a failed worker that went silent still synthesises terminal as failed', () => {
263
+ const h = makeHarness({
264
+ agentId: 'gap2-failed-silent',
265
+ bootLines: [subAgentUserMsg('bg task')],
266
+ stallThresholdMs: 60_000,
267
+ silentStallTerminalMs: 120_000,
268
+ })
269
+
270
+ // Error line, then the worker goes silent (no turn_end).
271
+ h.append(subAgentTerminalError('worker process crashed'))
272
+ h.advance(600) // read the error line
273
+ h.advance(62_000) // stall
274
+ h.advance(121_000) // synthesis
275
+ expect(h.stallTerminalCalls).toHaveLength(1)
276
+ expect(h.finishCalls).toHaveLength(1)
277
+ expect(h.finishCalls[0].outcome).toBe('failed')
278
+ expect(h.finishCalls[0].resultText).toContain('crashed')
279
+ })
280
+
281
+ it('a routine mid-run tool error does NOT cause a false failure', () => {
282
+ const h = makeHarness({ agentId: 'gap2-toolerr', bootLines: [subAgentUserMsg('bg task')] })
283
+
284
+ // A tool_result with is_error (e.g. grep found nothing) mid-run, then
285
+ // the worker recovers and completes normally.
286
+ h.append(subAgentToolResultError(), subAgentText('Completed after a retry'), subAgentTurnEnd())
287
+ h.advance(600)
288
+
289
+ expect(h.finishCalls).toHaveLength(1)
290
+ expect(h.finishCalls[0].outcome).toBe('completed') // NOT failed
291
+ expect(h.finishCalls[0].resultText).toContain('Completed after a retry')
292
+ })
293
+ })
@@ -693,18 +693,21 @@ describe('startSubagentWatcher', () => {
693
693
  h.watcher.stop()
694
694
  })
695
695
 
696
- it('suppresses stall notifications for historical entries', () => {
697
- // Historical entries (file existed at watcher boot) must NOT fire
698
- // stall notifications. The sub-agent process is long dead; the file
699
- // is just left over from a prior session. With many historicals
700
- // present at restart, firing stalls for each would flood the chat.
696
+ it('suppresses stall notifications for historical (done-at-boot) entries', () => {
697
+ // A worker that already FINISHED before the watcher booted (turn_end
698
+ // present in the file) stays historical and must NOT fire stall
699
+ // notifications. With months of finished session history present at
700
+ // restart, firing stalls for each would flood the chat. NOTE: a worker
701
+ // still RUNNING at boot is a different case — Gap 1 promotes it to live
702
+ // so it DOES get the stall safety net (it's an in-flight worker the
703
+ // user is still awaiting), covered in subagent-watcher-handback-gaps.
701
704
  const agentDir = '/home/user/.switchroom/agents/myagent'
702
705
  const projectsRoot = `${agentDir}/.claude/projects`
703
706
  const projectDir = `${projectsRoot}/myproject`
704
707
  const sessionDir = `${projectDir}/session-abc123`
705
708
  const subagentsDir = `${sessionDir}/subagents`
706
709
  const jsonlPath = `${subagentsDir}/agent-deadbeef.jsonl`
707
- const content = buildJSONL(subAgentUserMsg('Old task'))
710
+ const content = buildJSONL(subAgentUserMsg('Old task'), subAgentTurnDuration())
708
711
 
709
712
  const h = makeHarness({
710
713
  agentDir,
@@ -809,12 +812,15 @@ describe('startSubagentWatcher', () => {
809
812
 
810
813
  describe('historical-vs-active filter', () => {
811
814
  /**
812
- * Pre-existing JSONL files at watcher boot are tagged historical=true.
813
- * Stalls and completion notifications are gated on !historical so a
814
- * restart with months of session history doesn't flood the chat.
815
+ * Pre-existing FINISHED (done-at-boot) JSONL files are tagged
816
+ * historical=true. Stalls and completion notifications are gated on
817
+ * !historical so a restart with months of session history doesn't
818
+ * flood the chat. (A still-RUNNING file at boot is promoted to live by
819
+ * Gap 1 — see subagent-watcher-handback-gaps — so it must carry a
820
+ * turn_end here to stay historical.)
815
821
  */
816
822
 
817
- it('pre-existing JSONL files at startup are tagged historical', () => {
823
+ it('pre-existing done-at-boot JSONL files are tagged historical', () => {
818
824
  const agentDir = '/home/user/.switchroom/agents/myagent'
819
825
  const projectsRoot = `${agentDir}/.claude/projects`
820
826
  const projectDir = `${projectsRoot}/myproject`
@@ -823,7 +829,7 @@ describe('startSubagentWatcher', () => {
823
829
  const jsonlA = `${subagentsDir}/agent-hist-aaaa.jsonl`
824
830
  const jsonlB = `${subagentsDir}/agent-hist-bbbb.jsonl`
825
831
 
826
- const content = buildJSONL(subAgentUserMsg('Old task'))
832
+ const content = buildJSONL(subAgentUserMsg('Old task'), subAgentTurnDuration())
827
833
 
828
834
  const h = makeHarness({
829
835
  agentDir,
@@ -895,10 +901,12 @@ describe('startSubagentWatcher', () => {
895
901
  })
896
902
 
897
903
  it('pre-existing in-flight agent that finishes after restart fires completion', () => {
898
- // Historical at boot. Then writes turn_end. Completion notification
899
- // still fires for the state transition (the file was in-flight at
900
- // boot, so the transition is meaningful even if the entry is tagged
901
- // historical for stall-suppression purposes).
904
+ // Running at boot Gap 1 promotes it to live (historical=false),
905
+ // because it's an in-flight worker the user is still awaiting across
906
+ // the restart. When it then writes turn_end, the completion
907
+ // notification fires for the state transition. (The deeper handback
908
+ // outcome — completed, not the dropped `orphan` — is covered in
909
+ // subagent-watcher-handback-gaps.)
902
910
  const agentDir = '/home/user/.switchroom/agents/myagent'
903
911
  const projectsRoot = `${agentDir}/.claude/projects`
904
912
  const projectDir = `${projectsRoot}/myproject`