switchroom 0.18.6 → 0.18.8
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/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +1 -0
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +1172 -812
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +1 -0
- package/dist/vault/broker/server.js +1 -0
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +146 -50
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2965 -862
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
- package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1618 -198
- package/telegram-plugin/gateway/inbound-spool.ts +2 -1
- package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
- package/telegram-plugin/gateway/inject-handler.ts +17 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
- package/telegram-plugin/gateway/ipc-server.ts +40 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +227 -54
- package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
- package/telegram-plugin/gateway/pending-card-store.ts +173 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
- package/telegram-plugin/hooks/hooks.json +10 -10
- package/telegram-plugin/hooks/run-hook.sh +84 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/operator-events.ts +24 -0
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/subagent-watcher.ts +222 -37
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
- package/telegram-plugin/tests/busy-ack.test.ts +121 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +179 -25
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +203 -43
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- package/telegram-plugin/tests/operator-events.test.ts +1 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
- package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
- package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
- package/telegram-plugin/tests/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
- package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
- package/telegram-plugin/tests/session-model-file.test.ts +132 -0
- package/telegram-plugin/tests/session-model-source.test.ts +67 -0
- package/telegram-plugin/tests/session-tail.test.ts +64 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
- package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
- package/telegram-plugin/tests/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
- package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
- package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
- package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
- package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
- package/telegram-plugin/voice-ondemand.ts +25 -1
- package/telegram-plugin/voice-send.ts +154 -0
- package/telegram-plugin/worker-activity-feed.ts +9 -0
|
@@ -48,7 +48,7 @@ import { sanitiseToolArg } from './fleet-state.js'
|
|
|
48
48
|
import { clipNarrative, describeToolUse } from './tool-activity-summary.js'
|
|
49
49
|
import { REPLY_TOOLS, isDraftOfReply } from './narrative-dedup.js'
|
|
50
50
|
import { truncate } from './card-format.js'
|
|
51
|
-
import { bumpSubagentActivity, recordSubagentStall, recordSubagentResume, recordSubagentEnd, reapStuckRunningRows, countRunningBackgroundSubagents, recordNestedSubagentDispatch } from './registry/subagents-schema.js'
|
|
51
|
+
import { bumpSubagentActivity, recordSubagentStall, recordSubagentResume, recordSubagentEnd, reapStuckRunningRows, countRunningBackgroundSubagents, recordNestedSubagentDispatch, recordSubagentModel } from './registry/subagents-schema.js'
|
|
52
52
|
import { touchTurnActiveMarker } from './gateway/turn-active-marker.js'
|
|
53
53
|
|
|
54
54
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
@@ -193,6 +193,44 @@ export interface WorkerEntry {
|
|
|
193
193
|
* Pre hook pending" window that froze nested cards on "starting…".
|
|
194
194
|
*/
|
|
195
195
|
lastBackfillAttemptAt?: number
|
|
196
|
+
/**
|
|
197
|
+
* Fix #5: set on a boot-scan `running` file whose mtime passed the
|
|
198
|
+
* `inflightPromoteMaxAgeMs` freshness gate but has NOT yet shown any
|
|
199
|
+
* post-boot JSONL growth. mtime freshness alone can't distinguish a
|
|
200
|
+
* worker killed moments before the restart (indistinguishable file state)
|
|
201
|
+
* from a genuinely still-running one — so promotion is deferred until
|
|
202
|
+
* `checkBootPromotionGrowth` observes the file's size actually advance
|
|
203
|
+
* past its boot-time snapshot (real proof of life), or `deadlineAt`
|
|
204
|
+
* passes with no growth (treated as historical/orphan — never promoted).
|
|
205
|
+
* `undefined` once resolved either way.
|
|
206
|
+
*/
|
|
207
|
+
bootPromotionPending?: { deadlineAt: number }
|
|
208
|
+
/**
|
|
209
|
+
* Fix #1(+#2): the `run_in_background` flag as last observed from the
|
|
210
|
+
* registry `subagents` row, carried on the entry itself rather than
|
|
211
|
+
* re-derived only at `onFinish` time. The registry row is the sole
|
|
212
|
+
* source of truth for this flag (the watcher never sees the dispatching
|
|
213
|
+
* tool_input directly), but the row is looked up by `jsonl_agent_id` —
|
|
214
|
+
* exactly the linkage that can be permanently missing (meta.json
|
|
215
|
+
* unreadable, or the fuzzy backfill found no unambiguous candidate).
|
|
216
|
+
* Once we DO observe the row (at registration or on any later liveness
|
|
217
|
+
* tick — the backfill retry can link it after the fact), we cache the
|
|
218
|
+
* flag here so a subsequent `onFinish` can fall back to it even if
|
|
219
|
+
* `getSubagentByJsonlId` fails again at finish time. `undefined` until
|
|
220
|
+
* the row has been observed at least once.
|
|
221
|
+
*/
|
|
222
|
+
background?: boolean
|
|
223
|
+
/**
|
|
224
|
+
* Live model this worker is running, as a raw resolved model id (e.g.
|
|
225
|
+
* `claude-opus-4-8`). Set from the worker's OWN transcript `message.model`
|
|
226
|
+
* (`sub_agent_model` event) — the authoritative live source — and persisted to
|
|
227
|
+
* the registry row on change (recordSubagentModel) so boot-replay / handback
|
|
228
|
+
* cards can render it without a live entry. Threaded onto the onProgress
|
|
229
|
+
* payload so the worker card's metrics line shows the model. Undefined until
|
|
230
|
+
* the worker's first assistant line lands; before that the card falls back to
|
|
231
|
+
* the dispatch-time `tool_input.model` persisted on the registry row.
|
|
232
|
+
*/
|
|
233
|
+
currentModel?: string
|
|
196
234
|
}
|
|
197
235
|
|
|
198
236
|
export interface SubagentWatcherConfig {
|
|
@@ -271,6 +309,13 @@ export interface SubagentWatcherConfig {
|
|
|
271
309
|
* stale-handback replay regression.
|
|
272
310
|
*/
|
|
273
311
|
inflightPromoteMaxAgeMs?: number
|
|
312
|
+
/**
|
|
313
|
+
* Fix #9a: override for `TERMINATED_AGENT_IDS_CAP` — the bound on the
|
|
314
|
+
* `terminatedAgentIds` re-discovery dedup guard. Exposed mainly so tests
|
|
315
|
+
* can exercise eviction without inserting thousands of entries. Defaults
|
|
316
|
+
* to `TERMINATED_AGENT_IDS_CAP` (a few thousand) in production.
|
|
317
|
+
*/
|
|
318
|
+
terminatedAgentIdsCap?: number
|
|
274
319
|
/**
|
|
275
320
|
* Kill-switch for the boot-scan promotion path. When false, a
|
|
276
321
|
* running-at-boot worker is never promoted — the watcher reverts to the
|
|
@@ -390,6 +435,15 @@ export interface SubagentWatcherConfig {
|
|
|
390
435
|
* no `sub_agent_text` line was ever observed. Feeds the
|
|
391
436
|
* `subagent_handback` inbound. */
|
|
392
437
|
resultText: string
|
|
438
|
+
/**
|
|
439
|
+
* Fix #1(+#2): the entry's own cached `background` flag (see
|
|
440
|
+
* `WorkerEntry.background`), threaded through so the gateway can fall
|
|
441
|
+
* back to it when its own registry lookup at finish time also comes up
|
|
442
|
+
* empty (unlinked `jsonl_agent_id`). `undefined` when the row was never
|
|
443
|
+
* observed by the watcher either — the gateway degrades further from
|
|
444
|
+
* there (see the onFinish handler's non-empty-resultText fallback).
|
|
445
|
+
*/
|
|
446
|
+
background: boolean | undefined
|
|
393
447
|
}) => void
|
|
394
448
|
/**
|
|
395
449
|
* #1720: fires on every `sub_agent_text` event for a running
|
|
@@ -419,6 +473,12 @@ export interface SubagentWatcherConfig {
|
|
|
419
473
|
* feed. Undefined on `sub_agent_text` ticks — the gateway falls back
|
|
420
474
|
* to `latestSummary` (the narrative line), preserving prior behavior. */
|
|
421
475
|
progressLine?: string
|
|
476
|
+
/** Live model this worker is running (raw resolved id, e.g.
|
|
477
|
+
* `claude-opus-4-8`), from `WorkerEntry.currentModel`. Threaded onto the
|
|
478
|
+
* worker/nested card's metrics line. Undefined before the worker's first
|
|
479
|
+
* assistant line — the gateway then falls back to the registry's
|
|
480
|
+
* dispatch-time model. */
|
|
481
|
+
model?: string
|
|
422
482
|
}) => void
|
|
423
483
|
/** `Date.now` override for tests. */
|
|
424
484
|
now?: () => number
|
|
@@ -572,6 +632,18 @@ const TERMINAL_CLEANUP_GRACE_MS = 30_000
|
|
|
572
632
|
*/
|
|
573
633
|
const BACKFILL_RETRY_INTERVAL_MS = 3000
|
|
574
634
|
|
|
635
|
+
/**
|
|
636
|
+
* Fix #9a: cap on `terminatedAgentIds` (the re-discovery dedup guard in
|
|
637
|
+
* `cleanupTerminalAgent` / `scanSubagentsDir`). Pre-fix the Set only grew
|
|
638
|
+
* (added on every terminal cleanup, only ever cleared wholesale in
|
|
639
|
+
* `stop()`), so a long-lived gateway with sustained sub-agent throughput
|
|
640
|
+
* accumulated ids without bound. A `Set` preserves insertion order, so once
|
|
641
|
+
* the cap is hit the OLDEST id is evicted on each new insert (simple ring
|
|
642
|
+
* buffer semantics) — recently-terminated ids (the ones actually at risk of
|
|
643
|
+
* a re-discovery race) always stay covered.
|
|
644
|
+
*/
|
|
645
|
+
const TERMINATED_AGENT_IDS_CAP = 5000
|
|
646
|
+
|
|
575
647
|
// ─── JSONL tail per sub-agent ─────────────────────────────────────────────
|
|
576
648
|
|
|
577
649
|
interface SubTail {
|
|
@@ -662,20 +734,30 @@ export function backfillJsonlAgentId(
|
|
|
662
734
|
|
|
663
735
|
// Fallback path: fuzzy (agentType, description) match for older Claude Code
|
|
664
736
|
// versions whose meta.json predates the toolUseId field.
|
|
737
|
+
//
|
|
738
|
+
// Fix #3: with N unlinked rows sharing (agent_type, description), a bare
|
|
739
|
+
// `ORDER BY started_at DESC LIMIT 1` assigns by start-order, not true
|
|
740
|
+
// correspondence — a benign FIFO for interchangeable rows, but a genuine
|
|
741
|
+
// mislink for a cross-topic identical dispatch. Read up to 2 candidates
|
|
742
|
+
// (not just 1): if more than one row shares the key, the match is
|
|
743
|
+
// ambiguous — refuse to link (leave jsonl_agent_id NULL) and let the
|
|
744
|
+
// marker/window path attribute it instead, rather than guessing.
|
|
665
745
|
if (candidateId == null && (meta.agentType || meta.description)) {
|
|
666
|
-
const
|
|
746
|
+
const fuzzyCandidates = db
|
|
667
747
|
.prepare(`
|
|
668
748
|
SELECT id FROM subagents
|
|
669
749
|
WHERE jsonl_agent_id IS NULL
|
|
670
750
|
AND agent_type IS ?
|
|
671
751
|
AND description IS ?
|
|
672
752
|
ORDER BY started_at DESC
|
|
673
|
-
LIMIT
|
|
753
|
+
LIMIT 2
|
|
674
754
|
`)
|
|
675
|
-
.
|
|
676
|
-
if (
|
|
677
|
-
candidateId =
|
|
755
|
+
.all(meta.agentType ?? null, meta.description ?? null) as { id: string }[]
|
|
756
|
+
if (fuzzyCandidates.length === 1) {
|
|
757
|
+
candidateId = fuzzyCandidates[0]!.id
|
|
678
758
|
log?.(`subagent-watcher: backfill fuzzy match ${agentId} → ${candidateId} (type=${meta.agentType} desc=${meta.description})`)
|
|
759
|
+
} else if (fuzzyCandidates.length > 1) {
|
|
760
|
+
log?.(`subagent-watcher: backfill fuzzy match refused for ${agentId} — ${fuzzyCandidates.length} ambiguous candidates share (type=${meta.agentType} desc=${meta.description}); leaving unlinked`)
|
|
679
761
|
}
|
|
680
762
|
}
|
|
681
763
|
|
|
@@ -809,6 +891,8 @@ export function readSubTail(
|
|
|
809
891
|
/** Friendly display line for THIS tick (set on tool ticks; see the
|
|
810
892
|
* SubagentWatcherConfig.onProgress doc). */
|
|
811
893
|
progressLine?: string
|
|
894
|
+
/** Live model this worker is running (see SubagentWatcherConfig.onProgress). */
|
|
895
|
+
model?: string
|
|
812
896
|
}) => void,
|
|
813
897
|
): void {
|
|
814
898
|
try {
|
|
@@ -868,6 +952,12 @@ export function readSubTail(
|
|
|
868
952
|
} else {
|
|
869
953
|
bumpSubagentActivity(db, { id: existing.id, ts: now })
|
|
870
954
|
isForeground = existing.background === 0
|
|
955
|
+
// Fix #1(+#2): keep the entry's cached background flag fresh —
|
|
956
|
+
// this is also how a NESTED worker (whose row links only via the
|
|
957
|
+
// throttled backfill retry above, after the one-shot attempt at
|
|
958
|
+
// registration lost the race) picks up its background flag before
|
|
959
|
+
// its own `onFinish` fires.
|
|
960
|
+
entry.background = existing.background === 1
|
|
871
961
|
}
|
|
872
962
|
} catch (dbErr) {
|
|
873
963
|
log?.(`subagent-watcher: liveness write error ${entry.agentId}: ${(dbErr as Error).message}`)
|
|
@@ -932,6 +1022,7 @@ export function readSubTail(
|
|
|
932
1022
|
},
|
|
933
1023
|
lastTool: entry.lastTool,
|
|
934
1024
|
toolCount: entry.toolCount,
|
|
1025
|
+
model: entry.currentModel,
|
|
935
1026
|
})
|
|
936
1027
|
return true
|
|
937
1028
|
} catch (cbErr) {
|
|
@@ -1015,6 +1106,31 @@ export function readSubTail(
|
|
|
1015
1106
|
}
|
|
1016
1107
|
log?.(`subagent-watcher: stall cleared for ${entry.agentId} (activity resumed after ${idleSecBeforeBump}s — re-arming detection)`)
|
|
1017
1108
|
}
|
|
1109
|
+
if (ev.kind === 'sub_agent_model') {
|
|
1110
|
+
// Live model capture for this worker. The projection already filtered
|
|
1111
|
+
// sentinels, so `ev.model` is a real resolved id. Record it on the
|
|
1112
|
+
// entry (transcript wins over the dispatch-time tool_input.model) and
|
|
1113
|
+
// persist to the registry row on CHANGE — update-on-change like
|
|
1114
|
+
// last_activity_at — so a boot-replay / handback card can render the
|
|
1115
|
+
// model without a live entry. No card render here: the model rides the
|
|
1116
|
+
// next onProgress tick's payload (below).
|
|
1117
|
+
if (entry.currentModel !== ev.model) {
|
|
1118
|
+
entry.currentModel = ev.model
|
|
1119
|
+
if (db != null) {
|
|
1120
|
+
try {
|
|
1121
|
+
const rowRef = db
|
|
1122
|
+
.prepare('SELECT id FROM subagents WHERE jsonl_agent_id = ?')
|
|
1123
|
+
.get(entry.agentId) as { id: string } | null
|
|
1124
|
+
if (rowRef != null) {
|
|
1125
|
+
recordSubagentModel(db, { id: rowRef.id, model: ev.model })
|
|
1126
|
+
}
|
|
1127
|
+
} catch (dbErr) {
|
|
1128
|
+
log?.(`subagent-watcher: model DB write error ${entry.agentId}: ${(dbErr as Error).message}`)
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
continue
|
|
1133
|
+
}
|
|
1018
1134
|
if (ev.kind === 'sub_agent_tool_use') {
|
|
1019
1135
|
// Narrative-dedup gate step 2: a sub_agent_text block was pending;
|
|
1020
1136
|
// this tool is the lookahead that decides it (SHOW unless it drafts
|
|
@@ -1073,6 +1189,7 @@ export function readSubTail(
|
|
|
1073
1189
|
lastTool: entry.lastTool,
|
|
1074
1190
|
toolCount: entry.toolCount,
|
|
1075
1191
|
progressLine: toolLine,
|
|
1192
|
+
model: entry.currentModel,
|
|
1076
1193
|
})
|
|
1077
1194
|
} catch (cbErr) {
|
|
1078
1195
|
log?.(`subagent-watcher: onProgress (tool) callback error ${entry.agentId}: ${(cbErr as Error).message}`)
|
|
@@ -1233,6 +1350,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1233
1350
|
config.inflightPromoteMaxAgeMs
|
|
1234
1351
|
?? parseEnvMs('SWITCHROOM_SUBAGENT_INFLIGHT_MAX_AGE_MS')
|
|
1235
1352
|
?? DEFAULT_INFLIGHT_PROMOTE_MAX_AGE_MS
|
|
1353
|
+
const terminatedAgentIdsCap = config.terminatedAgentIdsCap ?? TERMINATED_AGENT_IDS_CAP
|
|
1236
1354
|
// Kill-switch: not parseEnvMs (which rejects `0`) — an explicit `=0`
|
|
1237
1355
|
// here MUST disable promotion (revert to pre-v0.14.23 suppression).
|
|
1238
1356
|
const bootPromoteEnabled =
|
|
@@ -1361,6 +1479,17 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1361
1479
|
} catch (err) {
|
|
1362
1480
|
log?.(`subagent-watcher: backfill error for ${agentId}: ${(err as Error).message}`)
|
|
1363
1481
|
}
|
|
1482
|
+
// Fix #1(+#2): if the backfill above (or an already-linked row from a
|
|
1483
|
+
// prior registration attempt) resolved jsonl_agent_id, cache the
|
|
1484
|
+
// row's `background` flag on the entry NOW — the earliest point it
|
|
1485
|
+
// can ever be known — so it survives even if a later `onFinish`
|
|
1486
|
+
// can't re-resolve the row (see `WorkerEntry.background` doc).
|
|
1487
|
+
try {
|
|
1488
|
+
const row = db
|
|
1489
|
+
.prepare('SELECT background FROM subagents WHERE jsonl_agent_id = ?')
|
|
1490
|
+
.get(agentId) as { background: number } | null
|
|
1491
|
+
if (row != null) entry.background = row.background === 1
|
|
1492
|
+
} catch { /* best-effort — entry.background stays undefined */ }
|
|
1364
1493
|
}
|
|
1365
1494
|
|
|
1366
1495
|
const tail: SubTail = {
|
|
@@ -1409,37 +1538,16 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1409
1538
|
} else if (fileAgeMs > inflightPromoteMaxAgeMs) {
|
|
1410
1539
|
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)`)
|
|
1411
1540
|
} else {
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
//
|
|
1415
|
-
//
|
|
1416
|
-
//
|
|
1417
|
-
//
|
|
1418
|
-
//
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
} catch (err) {
|
|
1423
|
-
log?.(`subagent-watcher: backfill error for ${agentId}: ${(err as Error).message}`)
|
|
1424
|
-
}
|
|
1425
|
-
}
|
|
1426
|
-
// Historical/onProgress gate-ordering fix: the initial read above ran
|
|
1427
|
-
// while `entry.historical` was still TRUE, so every onProgress cue it
|
|
1428
|
-
// would have fired was suppressed by the `!entry.historical` guard —
|
|
1429
|
-
// and the tail cursor is now at EOF, so for a quiet worker no later
|
|
1430
|
-
// tick ever re-fires them. The card painted as a bare stub and froze
|
|
1431
|
-
// on "starting…" forever. Re-read from the start now that the entry
|
|
1432
|
-
// is live: the replayed events rebuild toolCount/lastTool/narrative
|
|
1433
|
-
// AND fire onProgress so the worker's card reaches real activity.
|
|
1434
|
-
entry.toolCount = 0
|
|
1435
|
-
entry.lastTool = null
|
|
1436
|
-
entry.pendingNarrative = null
|
|
1437
|
-
tail.cursor = 0
|
|
1438
|
-
tail.pendingPartial = ''
|
|
1439
|
-
tail.hasEmittedStart = false
|
|
1440
|
-
readSubTail(entry, tail, n, (desc) => {
|
|
1441
|
-
log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
|
|
1442
|
-
}, fs, log, db, parentStateDir, config.onUnstall, undefined, config.onProgress)
|
|
1541
|
+
// Fix #5: a fresh mtime alone does NOT confirm liveness — a worker
|
|
1542
|
+
// killed seconds before this restart looks byte-for-byte identical
|
|
1543
|
+
// to a genuinely still-running one. Defer promotion until
|
|
1544
|
+
// `checkBootPromotionGrowth` observes the file actually grow AFTER
|
|
1545
|
+
// this boot (real proof of life), bounded by the same
|
|
1546
|
+
// `inflightPromoteMaxAgeMs` window as an outer timeout. Until then
|
|
1547
|
+
// the entry stays `historical` (no stall detection, no completion
|
|
1548
|
+
// synthesis) — see checkBootPromotionGrowth / promoteBootEntry.
|
|
1549
|
+
entry.bootPromotionPending = { deadlineAt: n + inflightPromoteMaxAgeMs }
|
|
1550
|
+
log?.(`subagent-watcher: ${agentId} running at boot (last write ${Math.round(fileAgeMs / 1000)}s ago) — awaiting post-boot JSONL growth before promoting to live (mtime freshness alone can't rule out a just-killed worker)`)
|
|
1443
1551
|
}
|
|
1444
1552
|
}
|
|
1445
1553
|
|
|
@@ -1470,6 +1578,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1470
1578
|
const entry = registry.get(agentId)
|
|
1471
1579
|
const t = tails.get(agentId)
|
|
1472
1580
|
if (!entry || !t) return
|
|
1581
|
+
checkBootPromotionGrowth(entry, t, nowFn())
|
|
1473
1582
|
readSubTail(entry, t, nowFn(), (desc) => {
|
|
1474
1583
|
log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
|
|
1475
1584
|
}, fs, log, db, parentStateDir, config.onUnstall, cleanupTerminalAgent, config.onProgress)
|
|
@@ -1480,6 +1589,68 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1480
1589
|
}
|
|
1481
1590
|
}
|
|
1482
1591
|
|
|
1592
|
+
/**
|
|
1593
|
+
* Fix #5: the growth-confirmation half of boot promotion. Called on every
|
|
1594
|
+
* tick (poll + fs.watch) for an entry that's still awaiting confirmation
|
|
1595
|
+
* (`entry.bootPromotionPending` set — see the freshness-gate branch in
|
|
1596
|
+
* `registerAgent`). Promotes to live the moment the file's size advances
|
|
1597
|
+
* past `tail.cursor` (the boot-time snapshot — the initial full read in
|
|
1598
|
+
* `registerAgent` already parked the cursor at EOF-at-boot, so ANY further
|
|
1599
|
+
* growth is unambiguously post-boot). If the bounded window elapses with
|
|
1600
|
+
* no growth, gives up permanently — the entry stays `historical`, so it
|
|
1601
|
+
* can never synthesise a stale `completed` handback from pre-restart bytes
|
|
1602
|
+
* (checkStalls / onFinish both gate on `!entry.historical`).
|
|
1603
|
+
*/
|
|
1604
|
+
function checkBootPromotionGrowth(entry: WorkerEntry, tail: SubTail, n: number): void {
|
|
1605
|
+
const pending = entry.bootPromotionPending
|
|
1606
|
+
if (!pending) return
|
|
1607
|
+
let size: number | null = null
|
|
1608
|
+
try {
|
|
1609
|
+
size = fs.statSync(entry.filePath).size
|
|
1610
|
+
} catch {
|
|
1611
|
+
/* unreadable — fall through to the deadline check below */
|
|
1612
|
+
}
|
|
1613
|
+
if (size != null && size > tail.cursor) {
|
|
1614
|
+
entry.bootPromotionPending = undefined
|
|
1615
|
+
entry.historical = false
|
|
1616
|
+
log?.(`subagent-watcher: ${entry.agentId} confirmed live — observed post-boot JSONL growth (${tail.cursor} → ${size} bytes); promoting`)
|
|
1617
|
+
// The prior gateway life's registration normally linked
|
|
1618
|
+
// jsonl_agent_id already, but re-run the backfill idempotently in
|
|
1619
|
+
// case that life crashed before the link persisted — the handback's
|
|
1620
|
+
// isBackground lookup is keyed on jsonl_agent_id, and an unlinked row
|
|
1621
|
+
// would mis-resolve the worker as foreground and drop the handback.
|
|
1622
|
+
if (db != null) {
|
|
1623
|
+
try {
|
|
1624
|
+
backfillJsonlAgentId(db, entry.filePath, entry.agentId, log)
|
|
1625
|
+
} catch (err) {
|
|
1626
|
+
log?.(`subagent-watcher: backfill error for ${entry.agentId}: ${(err as Error).message}`)
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
// Historical/onProgress gate-ordering fix (carried over from the old
|
|
1630
|
+
// synchronous promotion path): the initial read in registerAgent ran
|
|
1631
|
+
// while `entry.historical` was still TRUE, so any onProgress cue it
|
|
1632
|
+
// would have fired was suppressed, and the cursor is at EOF-at-boot —
|
|
1633
|
+
// for a quiet worker no later tick would ever re-fire them. Re-read
|
|
1634
|
+
// from the start now that the entry is live: the replayed events
|
|
1635
|
+
// rebuild toolCount/lastTool/narrative AND fire onProgress so the
|
|
1636
|
+
// worker's card reaches real activity instead of a frozen stub.
|
|
1637
|
+
entry.toolCount = 0
|
|
1638
|
+
entry.lastTool = null
|
|
1639
|
+
entry.pendingNarrative = null
|
|
1640
|
+
tail.cursor = 0
|
|
1641
|
+
tail.pendingPartial = ''
|
|
1642
|
+
tail.hasEmittedStart = false
|
|
1643
|
+
readSubTail(entry, tail, n, (desc) => {
|
|
1644
|
+
log?.(`subagent-watcher: description updated for ${entry.agentId}: ${desc}`)
|
|
1645
|
+
}, fs, log, db, parentStateDir, config.onUnstall, undefined, config.onProgress)
|
|
1646
|
+
return
|
|
1647
|
+
}
|
|
1648
|
+
if (n >= pending.deadlineAt) {
|
|
1649
|
+
entry.bootPromotionPending = undefined
|
|
1650
|
+
log?.(`subagent-watcher: ${entry.agentId} never observed post-boot JSONL growth within the window — leaving historical/orphan (not promoting; avoids synthesising a stale 'completed' handback from a worker killed before this restart)`)
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1483
1654
|
// ─── State-transition notifications ─────────────────────────────────────
|
|
1484
1655
|
|
|
1485
1656
|
function maybySendStateTransition(agentId: string): void {
|
|
@@ -1522,6 +1693,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1522
1693
|
resultText: entry.errored
|
|
1523
1694
|
? entry.lastResultText || entry.errorDetail || ''
|
|
1524
1695
|
: entry.lastResultText,
|
|
1696
|
+
background: entry.background,
|
|
1525
1697
|
})
|
|
1526
1698
|
} catch (cbErr) {
|
|
1527
1699
|
log?.(`subagent-watcher: onFinish callback error ${agentId}: ${(cbErr as Error).message}`)
|
|
@@ -1543,6 +1715,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1543
1715
|
durationMs: nowFn() - entry.dispatchedAt,
|
|
1544
1716
|
description: entry.description,
|
|
1545
1717
|
resultText: entry.lastResultText,
|
|
1718
|
+
background: entry.background,
|
|
1546
1719
|
})
|
|
1547
1720
|
} catch (cbErr) {
|
|
1548
1721
|
log?.(`subagent-watcher: onFinish callback error ${agentId}: ${(cbErr as Error).message}`)
|
|
@@ -1593,6 +1766,14 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1593
1766
|
// Issue #1116 (Bug B): record that this agent has been fully
|
|
1594
1767
|
// processed so a rescan that rediscovers the still-present JSONL
|
|
1595
1768
|
// doesn't re-register and re-notify.
|
|
1769
|
+
//
|
|
1770
|
+
// Fix #9a: bound the Set so it can't grow unboundedly over a long-lived
|
|
1771
|
+
// gateway — evict the oldest id once the cap is hit (Set iteration
|
|
1772
|
+
// order is insertion order, so `.values().next().value` is the oldest).
|
|
1773
|
+
if (!terminatedAgentIds.has(agentId) && terminatedAgentIds.size >= terminatedAgentIdsCap) {
|
|
1774
|
+
const oldest = terminatedAgentIds.values().next().value
|
|
1775
|
+
if (oldest != null) terminatedAgentIds.delete(oldest)
|
|
1776
|
+
}
|
|
1596
1777
|
terminatedAgentIds.add(agentId)
|
|
1597
1778
|
log?.(`subagent-watcher: cleaned up terminal agent ${agentId}`)
|
|
1598
1779
|
}
|
|
@@ -1894,6 +2075,10 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1894
2075
|
if (entry.state !== 'running') continue
|
|
1895
2076
|
const tail = tails.get(agentId)
|
|
1896
2077
|
if (!tail) continue
|
|
2078
|
+
// Fix #5: defensive poll-loop fallback for the same growth check the
|
|
2079
|
+
// fs.watch callback runs — covers the case where fs.watch missed the
|
|
2080
|
+
// event that would otherwise have confirmed liveness.
|
|
2081
|
+
checkBootPromotionGrowth(entry, tail, n)
|
|
1897
2082
|
readSubTail(entry, tail, n, (desc) => {
|
|
1898
2083
|
log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
|
|
1899
2084
|
}, fs, log, db, parentStateDir, config.onUnstall, cleanupTerminalAgent, config.onProgress)
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
*/
|
|
31
31
|
import { describe, it, expect } from 'vitest'
|
|
32
32
|
import {
|
|
33
|
+
BOOT_UNPIN_MAX_ATTEMPTS,
|
|
33
34
|
loadActivityCards,
|
|
34
35
|
persistActivityCards,
|
|
35
36
|
writeActivityCardRecord,
|
|
@@ -314,9 +315,10 @@ describe('runActivityCardBootReaper — transport-boundary outcome tests', () =>
|
|
|
314
315
|
expect(loadActivityCards(PATH, fs)).toEqual([])
|
|
315
316
|
})
|
|
316
317
|
|
|
317
|
-
it('a failing unpin
|
|
318
|
+
it('retry-safe (#3001): a failing unpin is non-fatal, does not re-run the edit, and RETAINS the record for a next-boot unpin retry', async () => {
|
|
318
319
|
const { fs } = memFs()
|
|
319
|
-
|
|
320
|
+
const rec = card()
|
|
321
|
+
writeActivityCardRecord(PATH, fs, rec)
|
|
320
322
|
let editCalls = 0
|
|
321
323
|
const res = await runActivityCardBootReaper({
|
|
322
324
|
path: PATH,
|
|
@@ -328,9 +330,52 @@ describe('runActivityCardBootReaper — transport-boundary outcome tests', () =>
|
|
|
328
330
|
unpinCard: async () => {
|
|
329
331
|
throw new Error('message to unpin not found')
|
|
330
332
|
},
|
|
333
|
+
log: () => {},
|
|
331
334
|
})
|
|
332
335
|
expect(res).toEqual({ finalized: 1, vanished: 0, unpinned: 0, total: 1 })
|
|
333
336
|
expect(editCalls).toBe(1)
|
|
337
|
+
// The record is retained (not forfeited) so the next boot retries the
|
|
338
|
+
// idempotent unpin — flagged so it can never re-run the finalize edit.
|
|
339
|
+
expect(loadActivityCards(PATH, fs)).toEqual([
|
|
340
|
+
{ ...rec, finalizeAttempted: true, unpinAttempts: 1 },
|
|
341
|
+
])
|
|
342
|
+
|
|
343
|
+
// Second boot: the retained record retries ONLY the unpin (edit stays
|
|
344
|
+
// at-most-once); on success the record is finally dropped.
|
|
345
|
+
let secondBootEdits = 0
|
|
346
|
+
const res2 = await runActivityCardBootReaper({
|
|
347
|
+
path: PATH,
|
|
348
|
+
fs,
|
|
349
|
+
finalizeCard: async () => {
|
|
350
|
+
secondBootEdits++
|
|
351
|
+
return { ok: true }
|
|
352
|
+
},
|
|
353
|
+
unpinCard: async () => ({ ok: true }),
|
|
354
|
+
log: () => {},
|
|
355
|
+
})
|
|
356
|
+
expect(secondBootEdits).toBe(0)
|
|
357
|
+
expect(res2).toEqual({ finalized: 0, vanished: 0, unpinned: 1, total: 1 })
|
|
358
|
+
expect(loadActivityCards(PATH, fs)).toEqual([])
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it('retry-safe (#3001): the unpin retry is forfeited at BOOT_UNPIN_MAX_ATTEMPTS', async () => {
|
|
362
|
+
const { fs } = memFs()
|
|
363
|
+
writeActivityCardRecord(
|
|
364
|
+
PATH,
|
|
365
|
+
fs,
|
|
366
|
+
card({ finalizeAttempted: true, unpinAttempts: BOOT_UNPIN_MAX_ATTEMPTS - 1 }),
|
|
367
|
+
)
|
|
368
|
+
const res = await runActivityCardBootReaper({
|
|
369
|
+
path: PATH,
|
|
370
|
+
fs,
|
|
371
|
+
finalizeCard: async () => ({ ok: true }),
|
|
372
|
+
unpinCard: async () => {
|
|
373
|
+
throw new Error('still no rights')
|
|
374
|
+
},
|
|
375
|
+
log: () => {},
|
|
376
|
+
})
|
|
377
|
+
// Final attempt failed — record forfeited so it cannot re-fail every boot.
|
|
378
|
+
expect(res).toEqual({ finalized: 0, vanished: 0, unpinned: 0, total: 1 })
|
|
334
379
|
expect(loadActivityCards(PATH, fs)).toEqual([])
|
|
335
380
|
})
|
|
336
381
|
|