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
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Freshness-aware source of truth for the /status session model.
|
|
3
|
+
*
|
|
4
|
+
* Two independent signals report what model the live session is running:
|
|
5
|
+
*
|
|
6
|
+
* - the TRANSCRIPT: `message.model` on each assistant line (session-tail's
|
|
7
|
+
* `model` event) — ground truth for the model that served the LAST API
|
|
8
|
+
* call, but only advances when a new assistant line lands;
|
|
9
|
+
* - the OVERRIDE: the in-memory `/model` switch record (#2982) — set the
|
|
10
|
+
* moment a switch is positively confirmed, ahead of any assistant line.
|
|
11
|
+
*
|
|
12
|
+
* Neither is unconditionally right. Preferring the transcript regresses the
|
|
13
|
+
* idle-after-switch window (#2982's core fix): a native `/model` inject while
|
|
14
|
+
* idle sets the override to the NEW model, but the transcript still holds the
|
|
15
|
+
* OLD one until the next assistant line — /status would lie. Preferring the
|
|
16
|
+
* override goes stale the other way (a relaunch carrier that never applied).
|
|
17
|
+
*
|
|
18
|
+
* The invariant is "prefer the NEWER observation": every write from either
|
|
19
|
+
* source is stamped with a shared monotonic sequence, and `resolve()` returns
|
|
20
|
+
* whichever was observed last. A fresh assistant line always reclaims the
|
|
21
|
+
* transcript as the source; a confirmed switch always beats an older
|
|
22
|
+
* transcript line. Pinned by tests/session-model-source.test.ts.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface SessionModelResolution {
|
|
26
|
+
/** The raw value from the winning source. Transcript entries are resolved
|
|
27
|
+
* model ids (`claude-opus-4-8`); override entries may be friendly labels
|
|
28
|
+
* ("Opus 4.8") or sr-* ids depending on the /model path that set them. */
|
|
29
|
+
model: string
|
|
30
|
+
source: 'transcript' | 'override'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SessionModelSource {
|
|
34
|
+
/** Record a transcript observation (an assistant line's `message.model`,
|
|
35
|
+
* already sentinel-filtered by the session-tail projection). */
|
|
36
|
+
noteTranscriptModel(model: string): void
|
|
37
|
+
/** Record an override set (a positively-confirmed /model switch), or clear
|
|
38
|
+
* it with null. Setting stamps a fresh sequence, so the override wins over
|
|
39
|
+
* every EARLIER transcript observation until a new assistant line lands. */
|
|
40
|
+
setOverride(model: string | null): void
|
|
41
|
+
/** Current override value (the #2982 in-memory record), independent of
|
|
42
|
+
* freshness — for callers that need the override itself (e.g. the model
|
|
43
|
+
* menu's "session" marker), not the /status resolution. */
|
|
44
|
+
getOverride(): string | null
|
|
45
|
+
/** The freshest observation across both sources, or null when neither has
|
|
46
|
+
* reported yet. */
|
|
47
|
+
resolve(): SessionModelResolution | null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createSessionModelSource(): SessionModelSource {
|
|
51
|
+
let seq = 0
|
|
52
|
+
let transcript: { model: string; seq: number } | null = null
|
|
53
|
+
let override: { model: string; seq: number } | null = null
|
|
54
|
+
return {
|
|
55
|
+
noteTranscriptModel(model: string): void {
|
|
56
|
+
transcript = { model, seq: ++seq }
|
|
57
|
+
},
|
|
58
|
+
setOverride(model: string | null): void {
|
|
59
|
+
override = model == null ? null : { model, seq: ++seq }
|
|
60
|
+
},
|
|
61
|
+
getOverride(): string | null {
|
|
62
|
+
return override?.model ?? null
|
|
63
|
+
},
|
|
64
|
+
resolve(): SessionModelResolution | null {
|
|
65
|
+
if (transcript == null && override == null) return null
|
|
66
|
+
if (override == null) return { model: transcript!.model, source: 'transcript' }
|
|
67
|
+
if (transcript == null || transcript.seq < override.seq) {
|
|
68
|
+
return { model: override.model, source: 'override' }
|
|
69
|
+
}
|
|
70
|
+
return { model: transcript.model, source: 'transcript' }
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -12,10 +12,13 @@
|
|
|
12
12
|
* a dead session lingers.
|
|
13
13
|
*
|
|
14
14
|
* This makes cleanup self-contained across restart: every pin claim persists
|
|
15
|
-
* here; on boot the gateway loads the persisted set and unpins each
|
|
16
|
-
* (a status pin from a PRIOR session is stale by definition
|
|
17
|
-
* represented is over or crashed),
|
|
18
|
-
*
|
|
15
|
+
* here; on boot the gateway loads the persisted set and unpins each
|
|
16
|
+
* work-scoped entry (a status pin from a PRIOR session is stale by definition
|
|
17
|
+
* — the turn it represented is over or crashed), dropping rows only after a
|
|
18
|
+
* successful unpin (failed ones are retained with an attempt counter for a
|
|
19
|
+
* next-boot retry — see runStatusPinBootCleanup). Time-scoped `tool:` rows
|
|
20
|
+
* (the `pin_message` MCP tool, #3001) survive boots until their `expiresAt`.
|
|
21
|
+
* It does NOT re-adopt or re-pin — it only cleans up.
|
|
19
22
|
*
|
|
20
23
|
* Shape choice — SNAPSHOT, not append-log, mirroring obligation-store.ts. The
|
|
21
24
|
* claim set is tiny and bounded (one entry per in-flight pinned key, normally
|
|
@@ -55,8 +58,27 @@ export interface PersistedStatusPin {
|
|
|
55
58
|
messageId: number
|
|
56
59
|
/** True while the pin API call is in-flight / unconfirmed (see above). */
|
|
57
60
|
pending?: boolean
|
|
61
|
+
/** Wall-clock ms after which this pin is stale and boot cleanup unpins it.
|
|
62
|
+
* Rows WITHOUT this field are work-scoped (fg:/wk:/banner:) — stale the
|
|
63
|
+
* moment their owning session dies, so boot cleanup unpins them
|
|
64
|
+
* unconditionally. Rows WITH it (the `tool:` pins written by the
|
|
65
|
+
* `pin_message` MCP tool, #3001) represent deliberate agent pins that have
|
|
66
|
+
* no "work finished" event: they SURVIVE restarts and are only swept once
|
|
67
|
+
* expired. */
|
|
68
|
+
expiresAt?: number
|
|
69
|
+
/** Boot-cleanup unpin retry counter (#3001). Incremented each boot the
|
|
70
|
+
* unpin fails (flood-wait exhausted / transient 5xx); the row is retained
|
|
71
|
+
* for retry until BOOT_UNPIN_MAX_ATTEMPTS, then forfeited. Absent = 0. */
|
|
72
|
+
attempts?: number
|
|
58
73
|
}
|
|
59
74
|
|
|
75
|
+
/** How many boots may retry a failing boot-cleanup unpin before the row is
|
|
76
|
+
* forfeited. Unpins are idempotent (unpinning an already-unpinned or deleted
|
|
77
|
+
* message no-ops), so retrying across boots is safe; the cap only bounds a
|
|
78
|
+
* permanently-undeliverable unpin (chat gone, bot removed) so it cannot
|
|
79
|
+
* re-fail on every boot forever. */
|
|
80
|
+
export const BOOT_UNPIN_MAX_ATTEMPTS = 5
|
|
81
|
+
|
|
60
82
|
/** Envelope version. v1 had no `pending` field; a v1 row loads as a confirmed
|
|
61
83
|
* pin (pending undefined). v2 adds the optional `pending` flag. Both load
|
|
62
84
|
* fail-open — an unknown/newer version yields []. */
|
|
@@ -74,7 +96,9 @@ function isPinRow(x: unknown): x is PersistedStatusPin {
|
|
|
74
96
|
typeof o.chatId === 'string' &&
|
|
75
97
|
o.chatId.length > 0 &&
|
|
76
98
|
typeof o.messageId === 'number' &&
|
|
77
|
-
(o.pending === undefined || typeof o.pending === 'boolean')
|
|
99
|
+
(o.pending === undefined || typeof o.pending === 'boolean') &&
|
|
100
|
+
(o.expiresAt === undefined || typeof o.expiresAt === 'number') &&
|
|
101
|
+
(o.attempts === undefined || typeof o.attempts === 'number')
|
|
78
102
|
)
|
|
79
103
|
}
|
|
80
104
|
|
|
@@ -170,16 +194,27 @@ export function pinnedMessageIsOurs(
|
|
|
170
194
|
* the ordering + best-effort contract is unit-testable against the REAL code
|
|
171
195
|
* (the gateway's thin wrapper just binds the live fs / unpin api / logger).
|
|
172
196
|
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
197
|
+
* The restart rule (#3001): a WORK-SCOPED pin persisted by a PRIOR session
|
|
198
|
+
* (fg:/wk:/banner: — any row without `expiresAt`) is stale by definition — its
|
|
199
|
+
* work ended or the session crashed before its unpin reconcile ran, so
|
|
200
|
+
* restart = reset: it is unpinned here. This includes records left `pending`
|
|
201
|
+
* (the persist-intent-first write from `reconcileAndPersistStatusPin`): a
|
|
202
|
+
* crash between the pin API call and its confirming rewrite leaves a pending
|
|
203
|
+
* record whose pin MAY have landed in Telegram, so we must treat it exactly
|
|
204
|
+
* like a confirmed one and unpin it.
|
|
205
|
+
*
|
|
206
|
+
* TIME-SCOPED rows (`tool:` pins from the `pin_message` MCP tool, carrying
|
|
207
|
+
* `expiresAt`) have no "work finished" event, so a restart does NOT reset
|
|
208
|
+
* them: an unexpired row is RETAINED untouched across boots and only unpinned
|
|
209
|
+
* once `now >= expiresAt`.
|
|
210
|
+
*
|
|
211
|
+
* RETRY-SAFETY (#3001): a row is dropped only AFTER its unpin resolves. A
|
|
212
|
+
* failing unpin (flood-wait exhausted / transient 5xx) retains the row with an
|
|
213
|
+
* incremented `attempts` counter so the NEXT boot retries, up to
|
|
214
|
+
* BOOT_UNPIN_MAX_ATTEMPTS — then the row is forfeited (a permanently-
|
|
215
|
+
* undeliverable unpin must not re-fail on every boot forever). Unpins are
|
|
216
|
+
* idempotent, so the retry can never double-unpin harmfully. We do NOT
|
|
217
|
+
* re-adopt or re-pin. Returns the counts for logging/testing.
|
|
183
218
|
*
|
|
184
219
|
* CRITICAL: the caller MUST only invoke this AFTER winning the startup mutex.
|
|
185
220
|
* The store is a shared per-agent file; on a double-boot a losing gateway
|
|
@@ -189,27 +224,52 @@ export async function runStatusPinBootCleanup(args: {
|
|
|
189
224
|
path: string
|
|
190
225
|
fs: StatusPinStoreFsSeam
|
|
191
226
|
unpin: (chatId: string, messageId: number) => Promise<unknown>
|
|
227
|
+
now?: number
|
|
192
228
|
log?: (line: string) => void
|
|
193
|
-
}): Promise<{ cleared: number; total: number }> {
|
|
229
|
+
}): Promise<{ cleared: number; retained: number; kept: number; total: number }> {
|
|
194
230
|
const log = args.log ?? ((l: string) => process.stderr.write(l))
|
|
231
|
+
const now = args.now ?? Date.now()
|
|
195
232
|
const persisted = loadStatusPins(args.path, args.fs)
|
|
196
|
-
if (persisted.length === 0) return { cleared: 0, total: 0 }
|
|
233
|
+
if (persisted.length === 0) return { cleared: 0, retained: 0, kept: 0, total: 0 }
|
|
197
234
|
let cleared = 0
|
|
235
|
+
let retained = 0
|
|
236
|
+
let kept = 0
|
|
237
|
+
const next: PersistedStatusPin[] = []
|
|
198
238
|
for (const pin of persisted) {
|
|
239
|
+
// Unexpired time-scoped row (tool: pin): deliberately survives the
|
|
240
|
+
// restart — keep it as-is, no unpin.
|
|
241
|
+
if (pin.expiresAt != null && pin.expiresAt > now) {
|
|
242
|
+
next.push(pin)
|
|
243
|
+
kept++
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
199
246
|
try {
|
|
200
247
|
await args.unpin(pin.chatId, pin.messageId)
|
|
201
248
|
cleared++
|
|
202
249
|
} catch (err) {
|
|
250
|
+
const attempts = (pin.attempts ?? 0) + 1
|
|
203
251
|
log(
|
|
204
252
|
`status-pin-store: boot cleanup unpin failed ` +
|
|
205
|
-
`(chat=${pin.chatId} msg=${pin.messageId}):
|
|
253
|
+
`(chat=${pin.chatId} msg=${pin.messageId} attempt=${attempts}): ` +
|
|
254
|
+
`${(err as Error).message}\n`,
|
|
206
255
|
)
|
|
256
|
+
if (attempts < BOOT_UNPIN_MAX_ATTEMPTS) {
|
|
257
|
+
// Retain for a retry on the next boot instead of forfeiting the
|
|
258
|
+
// orphan permanently (retry-safe boot sweep, #3001).
|
|
259
|
+
next.push({ ...pin, attempts })
|
|
260
|
+
retained++
|
|
261
|
+
} else {
|
|
262
|
+
log(
|
|
263
|
+
`status-pin-store: boot cleanup FORFEITING pin after ` +
|
|
264
|
+
`${attempts} failed unpin attempts ` +
|
|
265
|
+
`(key=${pin.pinKey} chat=${pin.chatId} msg=${pin.messageId}) — ` +
|
|
266
|
+
`will not retry again\n`,
|
|
267
|
+
)
|
|
268
|
+
}
|
|
207
269
|
}
|
|
208
270
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
persistStatusPins(args.path, args.fs, [], log)
|
|
212
|
-
return { cleared, total: persisted.length }
|
|
271
|
+
persistStatusPins(args.path, args.fs, next, log)
|
|
272
|
+
return { cleared, retained, kept, total: persisted.length }
|
|
213
273
|
}
|
|
214
274
|
|
|
215
275
|
/**
|
|
@@ -25,6 +25,15 @@ export interface WorkerFeedDispatch {
|
|
|
25
25
|
* result returns to its dispatching worker as the Task tool result).
|
|
26
26
|
*/
|
|
27
27
|
isNested: boolean
|
|
28
|
+
/**
|
|
29
|
+
* Dispatch-time / last-persisted model for the worker, from the registry
|
|
30
|
+
* row's `model` column (seeded by the pretool hook from `tool_input.model`,
|
|
31
|
+
* later updated by the watcher from the worker's transcript). The FIRST-PAINT
|
|
32
|
+
* fallback the worker card renders before the live watcher entry has observed
|
|
33
|
+
* a transcript model. Null when the row is missing or never carried a model —
|
|
34
|
+
* the card then omits the model rather than guessing from config.
|
|
35
|
+
*/
|
|
36
|
+
feedModel: string | null
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
/**
|
|
@@ -43,14 +52,28 @@ export interface WorkerFeedDispatch {
|
|
|
43
52
|
* decision again: a regression here silently reverts the feed header to
|
|
44
53
|
* "· sub-agent".
|
|
45
54
|
*/
|
|
55
|
+
/**
|
|
56
|
+
* `entryBackground` (fix #1(+#2)): the in-memory watcher entry's own cached
|
|
57
|
+
* `background` flag (`WorkerEntry.background`), passed by the gateway as a
|
|
58
|
+
* graceful-degradation fallback for when the registry row (`sub`) is
|
|
59
|
+
* missing — most often because `jsonl_agent_id` never linked (unreadable
|
|
60
|
+
* meta.json, or an ambiguous fuzzy backfill — see fix #3). Without this, a
|
|
61
|
+
* missing row hard-defaults `isBackground` to `false`, silently dropping a
|
|
62
|
+
* completed background worker's handback (the gateway's `onFinish` treats
|
|
63
|
+
* `false` as "nothing to deliver — it returns inline"). Ignored entirely
|
|
64
|
+
* when `sub` resolves — the registry row is always the authoritative
|
|
65
|
+
* source once it links.
|
|
66
|
+
*/
|
|
46
67
|
export function resolveWorkerFeedDispatch(
|
|
47
68
|
sub: Subagent | null,
|
|
48
69
|
watcherDescription: string,
|
|
70
|
+
entryBackground?: boolean,
|
|
49
71
|
): WorkerFeedDispatch {
|
|
50
72
|
return {
|
|
51
|
-
isBackground: sub?.background ?? false,
|
|
73
|
+
isBackground: sub?.background ?? entryBackground ?? false,
|
|
52
74
|
feedDescription: (sub?.description ?? '') || watcherDescription,
|
|
53
75
|
hasRow: sub != null,
|
|
54
76
|
isNested: sub?.parent_agent_id != null,
|
|
77
|
+
feedModel: sub?.model ?? null,
|
|
55
78
|
}
|
|
56
79
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worker-pin-reaper.ts — pure decision for the mid-session `wk:` pin sweep
|
|
3
|
+
* (#3001).
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: the background-worker pin (`wk:<agentId>`, pinned on the
|
|
6
|
+
* `🛠 Worker` feed message while the worker runs) is normally unpinned by the
|
|
7
|
+
* worker's completion handler (`reconcileWorkerPin(agentId, null, false)` on
|
|
8
|
+
* the watcher's onFinish). But that event can be MISSED — watcher crash, SDK
|
|
9
|
+
* subprocess SIGKILL, a dropped JSONL tail — and then nothing ever unpins the
|
|
10
|
+
* worker's message until the next gateway boot. Log evidence on one agent:
|
|
11
|
+
* ~1120 pinChatMessage vs ~1014 unpinChatMessage with zero logged failures —
|
|
12
|
+
* a long tail of stale pins glued to the top of the chat.
|
|
13
|
+
*
|
|
14
|
+
* This module is the pure half (mirrors `runActivityCardMidSessionReaper`'s
|
|
15
|
+
* decide-over-injected-seams shape): given the currently-claimed `wk:` pins,
|
|
16
|
+
* a terminality predicate over the sub-agent registry, and a TTL, it returns
|
|
17
|
+
* the pins that should be unpinned NOW. The gateway executes each reap via
|
|
18
|
+
* `reconcileStatusPin(key, chat, { pinned: false })` so the in-memory claim
|
|
19
|
+
* AND the durable store row clear together.
|
|
20
|
+
*
|
|
21
|
+
* A pin is reaped when EITHER:
|
|
22
|
+
* - `terminal` — the registry says the worker reached a terminal status
|
|
23
|
+
* (completed | failed): its work is finished, the pin must go, however
|
|
24
|
+
* young it is. (A missed onFinish is exactly this case.)
|
|
25
|
+
* - `ttl` — the pin has been held past `ttlMs` AND the registry cannot
|
|
26
|
+
* vouch for the worker (no row / never linked / `stalled` / lookup
|
|
27
|
+
* error). A registry-confirmed RUNNING row exempts the pin from the TTL
|
|
28
|
+
* entirely: a healthy 7h worker keeps its pin for the whole run instead
|
|
29
|
+
* of churning unpin→re-pin every TTL. The stall detector demotes a dead
|
|
30
|
+
* worker's row out of 'running' within ~60s, so the TTL still catches
|
|
31
|
+
* true zombies.
|
|
32
|
+
*
|
|
33
|
+
* A worker the registry can't vouch for is still never touched before the
|
|
34
|
+
* TTL — the sweep can only ever shorten a stale pin's life, not a live one's.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** Default TTL for a held worker pin: 6 hours. Rationale: worker turns are
|
|
38
|
+
* expected to run minutes-to-a-couple-of-hours (the watcher's own stall
|
|
39
|
+
* detection fires after ~60s of JSONL inactivity, and the longest sanctioned
|
|
40
|
+
* background dispatches are bounded by a single Claude session's lifetime).
|
|
41
|
+
* 6h comfortably exceeds any legitimate worker turn while bounding the
|
|
42
|
+
* stale-pin window to the same day instead of "until the next restart". */
|
|
43
|
+
export const WORKER_PIN_TTL_MS_DEFAULT = 6 * 60 * 60_000
|
|
44
|
+
|
|
45
|
+
export const WORKER_PIN_KEY_PREFIX = 'wk:'
|
|
46
|
+
|
|
47
|
+
/** One currently-claimed worker pin, flattened from the gateway's Maps. */
|
|
48
|
+
export interface WorkerPinCandidate {
|
|
49
|
+
/** Full pin key, `wk:<agentId>` shape. */
|
|
50
|
+
pinKey: string
|
|
51
|
+
/** Chat the pin lives in (from the gateway's pinKey → chatId registry). */
|
|
52
|
+
chatId: string
|
|
53
|
+
/** Wall-clock ms the claim was first taken (gateway's pinnedAt registry). */
|
|
54
|
+
pinnedAt: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface WorkerPinReap extends WorkerPinCandidate {
|
|
58
|
+
reason: 'terminal' | 'ttl'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Extract the agentId from a `wk:<agentId>` pin key, or null for any other
|
|
62
|
+
* key shape (fg:/banner:/tool: keys are never worker-reaped). */
|
|
63
|
+
export function workerAgentIdOfPinKey(pinKey: string): string | null {
|
|
64
|
+
if (!pinKey.startsWith(WORKER_PIN_KEY_PREFIX)) return null
|
|
65
|
+
const agentId = pinKey.slice(WORKER_PIN_KEY_PREFIX.length)
|
|
66
|
+
return agentId.length > 0 ? agentId : null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The registry's view of a worker, distilled for the reap decision.
|
|
70
|
+
* - 'terminal' — row exists in completed | failed: reap now.
|
|
71
|
+
* - 'running' — row exists and is still 'running': NEVER reap, not even
|
|
72
|
+
* past the TTL. A healthy 7h worker must not get unpinned mid-run only
|
|
73
|
+
* for the next feed edit to re-pin it (pin/unpin churn every TTL). The
|
|
74
|
+
* subagent-watcher's stall detection demotes a dead worker's row out of
|
|
75
|
+
* 'running' within ~60s of its JSONL going quiet, so a true zombie can
|
|
76
|
+
* only hold 'running' briefly — the TTL still catches everything the
|
|
77
|
+
* registry has lost track of.
|
|
78
|
+
* - 'unknown' — no row / never linked / 'stalled' / lookup error: the
|
|
79
|
+
* TTL gate applies (the registry can't vouch for it). */
|
|
80
|
+
export type WorkerRegistryStatus = 'terminal' | 'running' | 'unknown'
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Decide which claimed worker pins to unpin now. Pure: the registry lookup is
|
|
84
|
+
* an injected predicate (`statusOf` must return 'terminal' ONLY for a row in
|
|
85
|
+
* completed | failed, 'running' only for a live 'running' row, and 'unknown'
|
|
86
|
+
* for stalled / missing / lookup-error; a DB hiccup must degrade to 'unknown'
|
|
87
|
+
* — kept until the TTL — never to a spurious 'terminal' unpin).
|
|
88
|
+
*/
|
|
89
|
+
export function decideWorkerPinReaps(args: {
|
|
90
|
+
pins: Iterable<WorkerPinCandidate>
|
|
91
|
+
statusOf: (agentId: string) => WorkerRegistryStatus
|
|
92
|
+
ttlMs: number
|
|
93
|
+
now: number
|
|
94
|
+
}): WorkerPinReap[] {
|
|
95
|
+
const reaps: WorkerPinReap[] = []
|
|
96
|
+
for (const pin of args.pins) {
|
|
97
|
+
const agentId = workerAgentIdOfPinKey(pin.pinKey)
|
|
98
|
+
if (agentId == null) continue // not a worker pin — never ours to reap
|
|
99
|
+
if (pin.chatId.length === 0) continue // can't unpin without a chat
|
|
100
|
+
const status = args.statusOf(agentId)
|
|
101
|
+
if (status === 'terminal') {
|
|
102
|
+
reaps.push({ ...pin, reason: 'terminal' })
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
// A registry-confirmed RUNNING worker keeps its pin regardless of age —
|
|
106
|
+
// the TTL only reaps pins the registry can't vouch for (see
|
|
107
|
+
// WorkerRegistryStatus doc).
|
|
108
|
+
if (status === 'running') continue
|
|
109
|
+
if (args.now - pin.pinnedAt >= args.ttlMs) {
|
|
110
|
+
reaps.push({ ...pin, reason: 'ttl' })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return reaps
|
|
114
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"hooks": [
|
|
6
6
|
{
|
|
7
7
|
"type": "command",
|
|
8
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-guard-pretool.mjs\"",
|
|
8
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-guard-pretool.mjs\"",
|
|
9
9
|
"timeout": 10
|
|
10
10
|
}
|
|
11
11
|
]
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"hooks": [
|
|
15
15
|
{
|
|
16
16
|
"type": "command",
|
|
17
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/sentinel-reply-guard-pretool.mjs\"",
|
|
17
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/sentinel-reply-guard-pretool.mjs\"",
|
|
18
18
|
"timeout": 5
|
|
19
19
|
}
|
|
20
20
|
]
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"hooks": [
|
|
25
25
|
{
|
|
26
26
|
"type": "command",
|
|
27
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-tracker-pretool.mjs\"",
|
|
27
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-tracker-pretool.mjs\"",
|
|
28
28
|
"timeout": 10
|
|
29
29
|
}
|
|
30
30
|
]
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"hooks": [
|
|
34
34
|
{
|
|
35
35
|
"type": "command",
|
|
36
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/tool-label-pretool.mjs\"",
|
|
36
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/tool-label-pretool.mjs\"",
|
|
37
37
|
"timeout": 5
|
|
38
38
|
}
|
|
39
39
|
]
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"hooks": [
|
|
44
44
|
{
|
|
45
45
|
"type": "command",
|
|
46
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/repo-context-pretool.mjs\"",
|
|
46
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/repo-context-pretool.mjs\"",
|
|
47
47
|
"timeout": 5
|
|
48
48
|
}
|
|
49
49
|
]
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"hooks": [
|
|
56
56
|
{
|
|
57
57
|
"type": "command",
|
|
58
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-tracker-posttool.mjs\"",
|
|
58
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-tracker-posttool.mjs\"",
|
|
59
59
|
"timeout": 10
|
|
60
60
|
}
|
|
61
61
|
]
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"hooks": [
|
|
66
66
|
{
|
|
67
67
|
"type": "command",
|
|
68
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/sandbox-hint-posttool.mjs\"",
|
|
68
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/sandbox-hint-posttool.mjs\"",
|
|
69
69
|
"timeout": 3
|
|
70
70
|
}
|
|
71
71
|
]
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"hooks": [
|
|
77
77
|
{
|
|
78
78
|
"type": "command",
|
|
79
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-scrub-stop.mjs\"",
|
|
79
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-scrub-stop.mjs\"",
|
|
80
80
|
"timeout": 15,
|
|
81
81
|
"async": true
|
|
82
82
|
}
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
"hooks": [
|
|
87
87
|
{
|
|
88
88
|
"type": "command",
|
|
89
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-end-interrupt-stop.mjs\"",
|
|
89
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-end-interrupt-stop.mjs\"",
|
|
90
90
|
"timeout": 5
|
|
91
91
|
}
|
|
92
92
|
]
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
"hooks": [
|
|
96
96
|
{
|
|
97
97
|
"type": "command",
|
|
98
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/tool-label-stop.mjs\"",
|
|
98
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/tool-label-stop.mjs\"",
|
|
99
99
|
"timeout": 5,
|
|
100
100
|
"async": true
|
|
101
101
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# run-hook.sh — resilient launcher for Claude Code Node hooks (issue #2555).
|
|
3
|
+
#
|
|
4
|
+
# Under cgroup memory-ceiling pressure (the cgroup pinned at memory.max with
|
|
5
|
+
# reclaim lagging, page cache ~= cap), a freshly-spawned Node process can abort
|
|
6
|
+
# at STARTUP with exit 134 (SIGABRT) inside the libuv threadpool constructor —
|
|
7
|
+
# `Assertion failed: (0) == (uv_thread_create(...))` — BEFORE any hook code
|
|
8
|
+
# runs. It is a transient allocation failure, not a real hook error, but it
|
|
9
|
+
# surfaces a 🔴 issues card and skips the hook's work on that one tool call.
|
|
10
|
+
#
|
|
11
|
+
# This wrapper makes the invocation tolerant:
|
|
12
|
+
# 1. Shrink the libuv threadpool to 1 so Node needs the fewest possible
|
|
13
|
+
# thread-stack mmaps at startup (minimises the failure window).
|
|
14
|
+
# 2. Capture the hook payload from stdin ONCE and replay it on each attempt.
|
|
15
|
+
# The abort can land AFTER Node started draining the pipe, so a naive
|
|
16
|
+
# retry would feed the second attempt EMPTY stdin — a secret scanner would
|
|
17
|
+
# then scan nothing. Replaying the captured payload keeps the retry
|
|
18
|
+
# faithful.
|
|
19
|
+
# 3. Retry ONCE on a 134 abort after a brief backoff.
|
|
20
|
+
# 4. If it STILL aborts:
|
|
21
|
+
# - for SECURITY-CRITICAL hooks (secret-guard / secret-scrub) FAIL
|
|
22
|
+
# CLOSED — propagate 134 so the runtime cards it. 134 is a generic
|
|
23
|
+
# SIGABRT (assertion / OOM / any abort), not memory-pressure-specific,
|
|
24
|
+
# so silently returning 0 for a genuinely broken scanner would be a
|
|
25
|
+
# silent security BYPASS. Those hooks must never fail open.
|
|
26
|
+
# - for all other hooks, SKIP CLEANLY (exit 0) with a single stderr
|
|
27
|
+
# warn. A skipped label/context hook on one call is the documented,
|
|
28
|
+
# accepted degradation; a crash-card storm under memory pressure is
|
|
29
|
+
# not.
|
|
30
|
+
#
|
|
31
|
+
# Any non-134 exit status is passed through unchanged — real hook decisions
|
|
32
|
+
# (block/allow/non-zero) are never masked. `sleep 0.15` uses a fractional
|
|
33
|
+
# second (supported by GNU/BusyBox sleep, both present in the agent image).
|
|
34
|
+
#
|
|
35
|
+
# Usage (from hooks.json):
|
|
36
|
+
# sh "${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh" node "${CLAUDE_PLUGIN_ROOT}/hooks/<name>.mjs"
|
|
37
|
+
|
|
38
|
+
export UV_THREADPOOL_SIZE="${UV_THREADPOOL_SIZE:-1}"
|
|
39
|
+
|
|
40
|
+
# Identify the hook script (the .mjs argument) to decide fail-open vs closed.
|
|
41
|
+
hook_path=""
|
|
42
|
+
for a in "$@"; do
|
|
43
|
+
case "$a" in
|
|
44
|
+
*.mjs) hook_path="$a" ;;
|
|
45
|
+
esac
|
|
46
|
+
done
|
|
47
|
+
hook_name=$(basename "$hook_path" 2>/dev/null)
|
|
48
|
+
|
|
49
|
+
# Security-critical hooks MUST fail closed on a persistent abort.
|
|
50
|
+
fail_closed=0
|
|
51
|
+
case "$hook_name" in
|
|
52
|
+
secret-guard-pretool.mjs | secret-scrub-stop.mjs) fail_closed=1 ;;
|
|
53
|
+
esac
|
|
54
|
+
|
|
55
|
+
# Capture stdin once so both attempts see the SAME payload. Claude Code feeds
|
|
56
|
+
# the hook a single JSON object on stdin and closes it, so `cat` returns at
|
|
57
|
+
# EOF. (Command substitution strips trailing newlines, which JSON parsing does
|
|
58
|
+
# not care about.)
|
|
59
|
+
payload=$(cat)
|
|
60
|
+
|
|
61
|
+
run_hook() {
|
|
62
|
+
printf '%s' "$payload" | "$@"
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
run_hook "$@"
|
|
66
|
+
status=$?
|
|
67
|
+
if [ "$status" -ne 134 ]; then
|
|
68
|
+
exit "$status"
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
# Transient thread-create abort — brief backoff, then retry once with the
|
|
72
|
+
# faithfully-replayed payload.
|
|
73
|
+
sleep 0.15
|
|
74
|
+
run_hook "$@"
|
|
75
|
+
status=$?
|
|
76
|
+
if [ "$status" -eq 134 ]; then
|
|
77
|
+
if [ "$fail_closed" -eq 1 ]; then
|
|
78
|
+
echo "run-hook: security hook '$hook_name' aborted twice with exit 134 — FAILING CLOSED (not skipping) (#2555)" >&2
|
|
79
|
+
exit 134
|
|
80
|
+
fi
|
|
81
|
+
echo "run-hook: '$hook_name' aborted twice with exit 134 (uv_thread_create under memory pressure) — skipping hook cleanly (#2555)" >&2
|
|
82
|
+
exit 0
|
|
83
|
+
fi
|
|
84
|
+
exit "$status"
|
|
@@ -55,7 +55,8 @@ const SCHEMA_SQL = `
|
|
|
55
55
|
status TEXT NOT NULL,
|
|
56
56
|
result_summary TEXT,
|
|
57
57
|
jsonl_agent_id TEXT,
|
|
58
|
-
parent_agent_id TEXT
|
|
58
|
+
parent_agent_id TEXT,
|
|
59
|
+
model TEXT
|
|
59
60
|
);
|
|
60
61
|
CREATE INDEX IF NOT EXISTS subagents_turn ON subagents(parent_turn_key);
|
|
61
62
|
CREATE INDEX IF NOT EXISTS subagents_status ON subagents(status);
|
|
@@ -157,14 +158,14 @@ function spawnSql(dbPath, sql, cb) {
|
|
|
157
158
|
// DB write
|
|
158
159
|
// ---------------------------------------------------------------------------
|
|
159
160
|
|
|
160
|
-
function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, description, background, now }, done) {
|
|
161
|
+
function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, description, background, model, now }, done) {
|
|
161
162
|
const INSERT_SQL = `
|
|
162
163
|
INSERT OR IGNORE INTO subagents
|
|
163
164
|
(id, parent_session_id, parent_turn_key, agent_type, description,
|
|
164
|
-
background, started_at, last_activity_at, status)
|
|
165
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running')
|
|
165
|
+
background, started_at, last_activity_at, status, model)
|
|
166
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running', ?)
|
|
166
167
|
`
|
|
167
|
-
const params = [id, parentSessionId, parentTurnKey, agentType, description, background, now, now]
|
|
168
|
+
const params = [id, parentSessionId, parentTurnKey, agentType, description, background, now, now, model ?? null]
|
|
168
169
|
|
|
169
170
|
// Resolve a synchronous SQLite binding. Try in order:
|
|
170
171
|
// 1. node:sqlite (Node 22+, production path) — exposes DatabaseSync
|
|
@@ -207,6 +208,13 @@ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, descr
|
|
|
207
208
|
if (hasParentAgentCol == null) {
|
|
208
209
|
db.exec('ALTER TABLE subagents ADD COLUMN parent_agent_id TEXT')
|
|
209
210
|
}
|
|
211
|
+
// Migrate older DBs that pre-date the live-model column.
|
|
212
|
+
const hasModelCol = db
|
|
213
|
+
.prepare("SELECT name FROM pragma_table_info('subagents') WHERE name = 'model'")
|
|
214
|
+
.get()
|
|
215
|
+
if (hasModelCol == null) {
|
|
216
|
+
db.exec('ALTER TABLE subagents ADD COLUMN model TEXT')
|
|
217
|
+
}
|
|
210
218
|
// Verify the marker-derived parent_turn_key (snapParams[2]) actually has
|
|
211
219
|
// a row in the turns table before trusting it. The gateway writes the
|
|
212
220
|
// turn-active marker even when recordTurnStart's INSERT failed (the two
|
|
@@ -235,7 +243,7 @@ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, descr
|
|
|
235
243
|
return
|
|
236
244
|
}
|
|
237
245
|
|
|
238
|
-
// sqlite3 CLI fallback —
|
|
246
|
+
// sqlite3 CLI fallback — non-blocking spawns sequenced via callbacks.
|
|
239
247
|
// This legacy path (neither node:sqlite nor bun:sqlite available) can't
|
|
240
248
|
// cheaply verify the marker's turn_key against the turns table, so drop
|
|
241
249
|
// parent_turn_key and let the gateway's window backfill attribute it.
|
|
@@ -244,7 +252,15 @@ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, descr
|
|
|
244
252
|
params[2] = null
|
|
245
253
|
spawnSql(dbPath, SCHEMA_SQL.replace(/\n\s+/g, ' '), (err) => {
|
|
246
254
|
if (err) { done(err); return }
|
|
247
|
-
|
|
255
|
+
// Best-effort model-column migration for a legacy DB: the INSERT below
|
|
256
|
+
// references the model column, which a pre-model table lacks (CREATE TABLE
|
|
257
|
+
// IF NOT EXISTS is a no-op there, so the schema exec doesn't add it). The
|
|
258
|
+
// ALTER fails with "duplicate column name" when the column already exists —
|
|
259
|
+
// that error is EXPECTED and deliberately ignored; on any failure we still
|
|
260
|
+
// proceed to the INSERT, which surfaces a real problem via `done`.
|
|
261
|
+
spawnSql(dbPath, 'ALTER TABLE subagents ADD COLUMN model TEXT', () => {
|
|
262
|
+
spawnSql(dbPath, fillPlaceholders(INSERT_SQL.trim(), params), done)
|
|
263
|
+
})
|
|
248
264
|
})
|
|
249
265
|
}
|
|
250
266
|
|
|
@@ -363,6 +379,13 @@ function main() {
|
|
|
363
379
|
agentType: input.subagent_type ?? null,
|
|
364
380
|
description: input.description ?? null,
|
|
365
381
|
background: input.run_in_background === true ? 1 : 0,
|
|
382
|
+
// First-paint model for the worker card: the Agent tool payload carries
|
|
383
|
+
// the model the sub-agent will run under (`tool_input.model`), available
|
|
384
|
+
// BEFORE the sub-agent writes its first assistant line. Persisted so the
|
|
385
|
+
// card can render the model from dispatch; the watcher later overwrites it
|
|
386
|
+
// from the worker's own transcript (transcript wins). Only a non-empty
|
|
387
|
+
// string is stored — never guess from config.
|
|
388
|
+
model: typeof input.model === 'string' && input.model.length > 0 ? input.model : null,
|
|
366
389
|
now: Date.now(),
|
|
367
390
|
},
|
|
368
391
|
(err) => {
|