switchroom 0.18.6 → 0.18.7
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 +269 -56
- 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 +35 -16
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +1937 -580
- package/telegram-plugin/dist/server.js +24 -0
- 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/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1071 -130
- 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/model-command.ts +212 -51
- 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-source.ts +73 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/operator-events.ts +24 -0
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/subagent-watcher.ts +222 -37
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +202 -42
- package/telegram-plugin/tests/model-label.test.ts +64 -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/resume-inbound-builder.test.ts +286 -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/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/worker-activity-feed.test.ts +11 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- package/telegram-plugin/worker-activity-feed.ts +9 -0
|
@@ -60,6 +60,8 @@ export interface SilentEndDeps {
|
|
|
60
60
|
* never suppress on doubt.
|
|
61
61
|
*/
|
|
62
62
|
hasOutboundDeliveredSince?: (chatId: string, sinceMs: number, threadId?: number | null) => boolean
|
|
63
|
+
/** Wall-clock now, ms. Defaults to `Date.now`; injectable for tests. */
|
|
64
|
+
now?: () => number
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
/**
|
|
@@ -80,6 +82,31 @@ export interface SilentEndDeps {
|
|
|
80
82
|
*/
|
|
81
83
|
export const SILENT_END_MAX_RETRIES = 2
|
|
82
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Fix #8 (adversarial-validation progress-card fix pass): `turnKey` is the
|
|
87
|
+
* STABLE `statusKey(chatId, threadId)` — it never changes across turns on
|
|
88
|
+
* the same chat/thread — so `writeSilentEndState`'s "inherit retryCount IFF
|
|
89
|
+
* turnKey matches" rule has no per-turn discriminator on its own. Normally
|
|
90
|
+
* that's fine because `clearSilentEndState` always fires the moment a reply
|
|
91
|
+
* lands, wiping the exhausted record before the next turn starts. But if the
|
|
92
|
+
* gateway's own exhaust-read-and-clear is bypassed (crash/interrupt mid-turn,
|
|
93
|
+
* `gateway.ts` ~turn_end handler never runs), an exhausted
|
|
94
|
+
* (`retryCount >= SILENT_END_MAX_RETRIES`) record can survive into a LATER,
|
|
95
|
+
* unrelated turn. That later turn's first silent-end would then misread the
|
|
96
|
+
* old record as "this turn already exhausted its ladder" and skip its own
|
|
97
|
+
* re-prompt entirely.
|
|
98
|
+
*
|
|
99
|
+
* A record legitimately re-written within one turn's own retry ladder is
|
|
100
|
+
* always fresh (each Stop-hook block re-writes `timestamp`), so bounding by
|
|
101
|
+
* age is a safe, cheap staleness signal that doesn't require a new identity
|
|
102
|
+
* field: any record older than a turn's plausible lifetime could not still
|
|
103
|
+
* be "this turn's" in-flight retry state, and is therefore stale-carryover,
|
|
104
|
+
* not an active ladder. 30 minutes comfortably exceeds any real single-turn
|
|
105
|
+
* Stop-hook retry cycle (seconds, not minutes) while staying well inside the
|
|
106
|
+
* fleet's other staleness bounds (e.g. the 3h boot-resume window).
|
|
107
|
+
*/
|
|
108
|
+
export const SILENT_END_STALE_RECORD_MAX_AGE_MS = 30 * 60_000
|
|
109
|
+
|
|
83
110
|
/**
|
|
84
111
|
* User-facing fallback text delivered when a user-message turn ends with no
|
|
85
112
|
* final answer AND the deterministic Stop-hook re-prompt has already been
|
|
@@ -313,12 +340,30 @@ export function recordSilentTurnEnd(
|
|
|
313
340
|
// If so, the record is satisfied-but-misdetected — drop it silently
|
|
314
341
|
// and let this dark turn start its OWN fresh retry cycle instead of
|
|
315
342
|
// inheriting someone else's spent budget.
|
|
316
|
-
|
|
343
|
+
// Fix #8: age-based staleness bound. `turnKey` has no per-turn nonce
|
|
344
|
+
// (see SILENT_END_STALE_RECORD_MAX_AGE_MS doc), so a record whose
|
|
345
|
+
// `timestamp` is older than a turn's plausible lifetime cannot belong to
|
|
346
|
+
// THIS dark turn's own retry ladder even absent any delivery evidence —
|
|
347
|
+
// it's carryover from a prior turn whose gateway-side clear was
|
|
348
|
+
// bypassed (crash/interrupt). Check this before (and independent of)
|
|
349
|
+
// the delivery-based check below so a bypassed-clear record is caught
|
|
350
|
+
// even when no reply was ever delivered on the chat/thread at all.
|
|
351
|
+
const now = deps?.now?.() ?? Date.now()
|
|
352
|
+
const staleByAge = now - prev.timestamp > SILENT_END_STALE_RECORD_MAX_AGE_MS
|
|
353
|
+
if (
|
|
354
|
+
staleByAge ||
|
|
355
|
+
deps?.hasOutboundDeliveredSince?.(args.chatId, prev.timestamp, args.threadId)
|
|
356
|
+
) {
|
|
317
357
|
emitLog(
|
|
318
358
|
deps,
|
|
319
|
-
|
|
320
|
-
`
|
|
321
|
-
|
|
359
|
+
staleByAge
|
|
360
|
+
? `silent-end: stale exhausted record for turnKey=${args.turnKey} ` +
|
|
361
|
+
`(retryCount=${prev.retryCount}, age=${now - prev.timestamp}ms > ` +
|
|
362
|
+
`${SILENT_END_STALE_RECORD_MAX_AGE_MS}ms) — treating as carryover ` +
|
|
363
|
+
`from a prior turn, not exhausted\n`
|
|
364
|
+
: `silent-end: stale exhausted record for turnKey=${args.turnKey} ` +
|
|
365
|
+
`(retryCount=${prev.retryCount}) but a reply was delivered since ` +
|
|
366
|
+
`${prev.timestamp} — treating as satisfied-but-misdetected, not exhausted\n`,
|
|
322
367
|
)
|
|
323
368
|
// MUST clear before writing (adversarial review of #2892):
|
|
324
369
|
// writeSilentEndState re-inherits retryCount whenever the on-disk
|
|
@@ -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)
|