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.
- package/dist/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +152 -8
- package/telegram-plugin/subagent-watcher.ts +79 -5
- package/telegram-plugin/tests/subagent-registry-bugs.test.ts +7 -3
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +293 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +23 -15
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.23";
|
|
49424
|
+
var COMMIT_SHA = "8ac2987a";
|
|
49425
49425
|
|
|
49426
49426
|
// src/cli/agent.ts
|
|
49427
49427
|
init_source();
|
package/package.json
CHANGED
|
@@ -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.
|
|
51302
|
-
var COMMIT_SHA = "
|
|
51303
|
-
var COMMIT_DATE = "2026-05-
|
|
51304
|
-
var LATEST_PR =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
698
|
-
//
|
|
699
|
-
//
|
|
700
|
-
//
|
|
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
|
|
813
|
-
* Stalls and completion notifications are gated on
|
|
814
|
-
* restart with months of session history doesn't
|
|
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
|
|
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
|
-
//
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
//
|
|
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`
|