switchroom 0.16.46 → 0.17.0
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 +83 -81
- package/dist/auth-broker/index.js +104 -88
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -15
- package/dist/cli/notion-write-pretool.mjs +85 -83
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +1720 -1392
- package/dist/cli/ui/index.html +84 -12
- package/dist/host-control/main.js +209 -173
- package/dist/vault/approvals/kernel-server.js +86 -83
- package/dist/vault/broker/server.js +284 -139
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +1 -1
- package/profiles/_base/start.sh.hbs +54 -3
- package/skills/switchroom-architecture/telegram.md +8 -15
- package/skills/switchroom-cli/SKILL.md +4 -5
- package/skills/telegram-test-harness/SKILL.md +1 -1
- package/telegram-plugin/README.md +18 -29
- package/telegram-plugin/bridge/bridge.ts +1 -41
- package/telegram-plugin/bridge/tool-filter.ts +3 -4
- package/telegram-plugin/dist/bridge/bridge.js +120 -155
- package/telegram-plugin/dist/gateway/gateway.js +1127 -1029
- package/telegram-plugin/dist/server.js +168 -203
- package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
- package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
- package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
- package/telegram-plugin/gateway/gateway.ts +566 -631
- package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
- package/telegram-plugin/gateway/inbound-spool.ts +108 -10
- package/telegram-plugin/gateway/model-command.ts +51 -3
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
- package/telegram-plugin/gateway/represent-guard.ts +28 -11
- package/telegram-plugin/gateway/status-pin-store.ts +124 -45
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
- package/telegram-plugin/history.ts +5 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
- package/telegram-plugin/registry/subagents-schema.ts +126 -1
- package/telegram-plugin/registry/turns-schema.ts +65 -1
- package/telegram-plugin/session-tail.ts +26 -4
- package/telegram-plugin/slot-banner-driver.ts +42 -2
- package/telegram-plugin/status-query-telemetry.ts +100 -0
- package/telegram-plugin/stream-reply-handler.ts +15 -16
- package/telegram-plugin/subagent-watcher.ts +182 -30
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
- package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
- package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
- package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
- package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
- package/telegram-plugin/tests/model-command.test.ts +54 -1
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
- package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
- package/telegram-plugin/tests/registry-turns.test.ts +67 -0
- package/telegram-plugin/tests/represent-guard.test.ts +42 -6
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
- package/telegram-plugin/tests/session-tail.test.ts +10 -1
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
- package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
- package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
- package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
- package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
- package/telegram-plugin/tests/tool-filter.test.ts +1 -3
- package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
- package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
- package/telegram-plugin/tool-activity-summary.ts +19 -0
- package/telegram-plugin/turn-flush-safety.ts +16 -1
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
- package/telegram-plugin/worker-activity-feed.ts +75 -15
- package/vendor/hindsight-memory/CHANGELOG.md +24 -0
- package/vendor/hindsight-memory/README.md +5 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
- package/vendor/hindsight-memory/scripts/lib/content.py +4 -1
- package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +74 -1
- package/vendor/hindsight-memory/scripts/retain.py +8 -1
- package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
- package/vendor/hindsight-memory/settings.json +4 -0
- package/vendor/hindsight-memory/tests/test_client.py +130 -0
- package/vendor/hindsight-memory/tests/test_config.py +47 -0
- package/vendor/hindsight-memory/tests/test_content.py +18 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
- package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
- package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
- package/telegram-plugin/silent-reply.ts +0 -58
- package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
- package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
- package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
|
@@ -222,6 +222,79 @@ export type StatusPinPersistOp =
|
|
|
222
222
|
| { kind: 'pin'; messageId: number }
|
|
223
223
|
| { kind: 'clear' }
|
|
224
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Per-PATH async serial lock. status-pins.json has multiple concurrent writers
|
|
227
|
+
* (the fg:/wk: status-pin reconciles AND the banner:owner row), each of which
|
|
228
|
+
* is a read-modify-write against the shared file. Without serialisation two
|
|
229
|
+
* writers can interleave their load→persist and clobber each other's rows.
|
|
230
|
+
*
|
|
231
|
+
* `withStoreLock(path, fn)` chains `fn` after the current tail promise for that
|
|
232
|
+
* path and returns fn's result. The stored tail NEVER rejects (a failing op
|
|
233
|
+
* can't wedge the chain), and `fn` runs regardless of whether the prior op
|
|
234
|
+
* resolved or rejected.
|
|
235
|
+
*/
|
|
236
|
+
const storeLockTails = new Map<string, Promise<unknown>>()
|
|
237
|
+
|
|
238
|
+
export function withStoreLock<T>(
|
|
239
|
+
path: string,
|
|
240
|
+
fn: () => Promise<T>,
|
|
241
|
+
): Promise<T> {
|
|
242
|
+
const prev = storeLockTails.get(path) ?? Promise.resolve()
|
|
243
|
+
// Run fn after prev settles either way (fn ignores the settled value).
|
|
244
|
+
const run = prev.then(fn, fn)
|
|
245
|
+
// Keep a non-rejecting tail so one failed op can't poison every later one.
|
|
246
|
+
storeLockTails.set(
|
|
247
|
+
path,
|
|
248
|
+
run.then(
|
|
249
|
+
() => undefined,
|
|
250
|
+
() => undefined,
|
|
251
|
+
),
|
|
252
|
+
)
|
|
253
|
+
return run
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* READ-MODIFY-WRITE for exactly ONE pinKey's row, against the authoritative
|
|
258
|
+
* on-disk file. Loads the current snapshot from disk, drops any row for
|
|
259
|
+
* `pinKey`, appends `row` (unless null = removal), and persists atomically.
|
|
260
|
+
*
|
|
261
|
+
* Crucially the OTHER rows come from disk, never from an in-memory map that may
|
|
262
|
+
* lag disk — so a writer can never drop another key's pending/confirmed row.
|
|
263
|
+
* NOT locked itself; callers run it inside `withStoreLock`.
|
|
264
|
+
*/
|
|
265
|
+
function applyStatusPinRow(
|
|
266
|
+
path: string,
|
|
267
|
+
fs: StatusPinStoreFsSeam,
|
|
268
|
+
pinKey: string,
|
|
269
|
+
row: PersistedStatusPin | null,
|
|
270
|
+
log: (line: string) => void,
|
|
271
|
+
): void {
|
|
272
|
+
const current = loadStatusPins(path, fs)
|
|
273
|
+
const others = current.filter((p) => p.pinKey !== pinKey)
|
|
274
|
+
const next = row == null ? others : [...others, row]
|
|
275
|
+
persistStatusPins(path, fs, next, log)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Public single-key mutation, serialised through the per-path lock. Upserts (or
|
|
280
|
+
* removes, when `row` is null) ONLY `pinKey`'s row, preserving every other row
|
|
281
|
+
* as it currently is ON DISK. This is the write path for pin kinds that don't
|
|
282
|
+
* need the pending→confirm dance (e.g. the banner:owner row); the status-pin
|
|
283
|
+
* reconcile uses `reconcileAndPersistStatusPin`, which holds the same lock
|
|
284
|
+
* across its whole pending→pin→confirm sequence.
|
|
285
|
+
*/
|
|
286
|
+
export function mutateStatusPinRow(
|
|
287
|
+
path: string,
|
|
288
|
+
fs: StatusPinStoreFsSeam,
|
|
289
|
+
pinKey: string,
|
|
290
|
+
row: PersistedStatusPin | null,
|
|
291
|
+
log: (line: string) => void = (l) => process.stderr.write(l),
|
|
292
|
+
): Promise<void> {
|
|
293
|
+
return withStoreLock(path, async () => {
|
|
294
|
+
applyStatusPinRow(path, fs, pinKey, row, log)
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
|
|
225
298
|
/**
|
|
226
299
|
* Persist-ordering wrapper closing the persist-AFTER-pin leak window.
|
|
227
300
|
*
|
|
@@ -237,21 +310,26 @@ export type StatusPinPersistOp =
|
|
|
237
310
|
* pending record on disk, and boot cleanup unpins pending records too (see
|
|
238
311
|
* runStatusPinBootCleanup), so the orphan is recovered next boot.
|
|
239
312
|
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
313
|
+
* CONCURRENCY: the ENTIRE op (pending write → await applyPin → confirm/clear
|
|
314
|
+
* write) runs under the per-path `withStoreLock`, so no other writer can
|
|
315
|
+
* interleave during the applyPin await window. Each disk write is a
|
|
316
|
+
* read-modify-write (`applyStatusPinRow`) keyed on THIS pinKey against the
|
|
317
|
+
* on-disk file — the other keys' rows (including a banner row, or another
|
|
318
|
+
* concurrent key's pending row) come from disk and are always preserved. This
|
|
319
|
+
* closes the race where a snapshot rebuilt from the (lagging) in-memory map
|
|
320
|
+
* dropped an in-flight key's row.
|
|
321
|
+
*
|
|
322
|
+
* `applyPin` performs the real Telegram pin/unpin (the gateway binds
|
|
323
|
+
* `reconcilePin`) and returns the next in-memory state so the caller can update
|
|
324
|
+
* its Map. Never throws — persistence is best-effort/fail-open and pin errors
|
|
325
|
+
* are already swallowed by `applyPin` (reconcilePin).
|
|
246
326
|
*/
|
|
247
|
-
export
|
|
327
|
+
export function reconcileAndPersistStatusPin(args: {
|
|
248
328
|
path: string
|
|
249
329
|
fs: StatusPinStoreFsSeam
|
|
250
330
|
pinKey: string
|
|
251
331
|
chatId: string
|
|
252
332
|
op: StatusPinPersistOp
|
|
253
|
-
/** Live claims for every OTHER key (rewritten alongside on each step). */
|
|
254
|
-
snapshotOthers: () => PersistedStatusPin[]
|
|
255
333
|
/** Execute the real pin/unpin; returns the confirmed message id (pin) or
|
|
256
334
|
* null (cleared). Must never throw — API errors are swallowed inside. */
|
|
257
335
|
applyPin: () => Promise<{ messageId: number } | null>
|
|
@@ -260,43 +338,44 @@ export async function reconcileAndPersistStatusPin(args: {
|
|
|
260
338
|
const { path, fs, pinKey, chatId, op } = args
|
|
261
339
|
const log = args.log ?? ((l: string) => process.stderr.write(l))
|
|
262
340
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
341
|
+
// Hold the per-path lock across the WHOLE op so no other writer (a banner
|
|
342
|
+
// persist, or another key's reconcile) can rebuild/overwrite the file during
|
|
343
|
+
// the applyPin await window and drop this key's pending/confirmed row.
|
|
344
|
+
return withStoreLock(path, async () => {
|
|
345
|
+
if (op.kind === 'pin') {
|
|
346
|
+
// Persist INTENT first, marked pending — BEFORE the pin API call. If we
|
|
347
|
+
// crash after the pin lands but before the confirm rewrite, this pending
|
|
348
|
+
// record is what boot cleanup uses to unpin the orphan.
|
|
349
|
+
applyStatusPinRow(
|
|
350
|
+
path,
|
|
351
|
+
fs,
|
|
352
|
+
pinKey,
|
|
272
353
|
{ pinKey, chatId, messageId: op.messageId, pending: true },
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
[
|
|
288
|
-
...args.snapshotOthers(),
|
|
354
|
+
log,
|
|
355
|
+
)
|
|
356
|
+
const next = await args.applyPin()
|
|
357
|
+
if (next == null) {
|
|
358
|
+
// Pin failed (claim NOT taken by reconcilePin). Clear the pending
|
|
359
|
+
// record so we don't leave a phantom claim for a pin that never landed.
|
|
360
|
+
applyStatusPinRow(path, fs, pinKey, null, log)
|
|
361
|
+
return null
|
|
362
|
+
}
|
|
363
|
+
// Pin confirmed — rewrite the record without the pending flag.
|
|
364
|
+
applyStatusPinRow(
|
|
365
|
+
path,
|
|
366
|
+
fs,
|
|
367
|
+
pinKey,
|
|
289
368
|
{ pinKey, chatId, messageId: next.messageId },
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
}
|
|
369
|
+
log,
|
|
370
|
+
)
|
|
371
|
+
return next
|
|
372
|
+
}
|
|
295
373
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
374
|
+
// clear: unpin (best-effort) THEN drop the record. Ordering is safe here —
|
|
375
|
+
// if we crash after the unpin but before the rewrite, the stale record just
|
|
376
|
+
// gets unpinned again next boot (idempotent), never a lingering pin.
|
|
377
|
+
const next = await args.applyPin()
|
|
378
|
+
applyStatusPinRow(path, fs, pinKey, null, log)
|
|
379
|
+
return next
|
|
380
|
+
})
|
|
302
381
|
}
|
|
@@ -8,6 +8,23 @@ export interface WorkerFeedDispatch {
|
|
|
8
8
|
* ("🛠 Worker · <feedDescription>").
|
|
9
9
|
*/
|
|
10
10
|
feedDescription: string
|
|
11
|
+
/**
|
|
12
|
+
* True when a registry row was found for the worker. A MISSING row must
|
|
13
|
+
* not be silently read as "foreground" by callers deciding the status
|
|
14
|
+
* surface — a row-less worker is most often a nested (depth-2+) dispatch
|
|
15
|
+
* whose row hasn't linked yet, and nesting it into an unrelated live main
|
|
16
|
+
* turn (or dropping it) is exactly the depth-2+ freeze/misroute bug.
|
|
17
|
+
*/
|
|
18
|
+
hasRow: boolean
|
|
19
|
+
/**
|
|
20
|
+
* True when the row records a NESTED dispatch (spawned by another
|
|
21
|
+
* sub-agent — `parent_agent_id` set). A nested worker can never nest into
|
|
22
|
+
* the gateway's current turn (its parent is a worker, not a live turn),
|
|
23
|
+
* so callers must surface it via the worker feed regardless of its own
|
|
24
|
+
* background flag, and must not deliver a user handback for it (its
|
|
25
|
+
* result returns to its dispatching worker as the Task tool result).
|
|
26
|
+
*/
|
|
27
|
+
isNested: boolean
|
|
11
28
|
}
|
|
12
29
|
|
|
13
30
|
/**
|
|
@@ -33,5 +50,7 @@ export function resolveWorkerFeedDispatch(
|
|
|
33
50
|
return {
|
|
34
51
|
isBackground: sub?.background ?? false,
|
|
35
52
|
feedDescription: (sub?.description ?? '') || watcherDescription,
|
|
53
|
+
hasRow: sub != null,
|
|
54
|
+
isNested: sub?.parent_agent_id != null,
|
|
36
55
|
}
|
|
37
56
|
}
|
|
@@ -140,6 +140,11 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
|
|
|
140
140
|
// and survives crashes more cleanly than rollback journal.
|
|
141
141
|
db.exec('PRAGMA journal_mode = WAL')
|
|
142
142
|
db.exec('PRAGMA synchronous = NORMAL')
|
|
143
|
+
// Without a busy_timeout, bun:sqlite defaults to 0ms and a contending
|
|
144
|
+
// writer fails IMMEDIATELY with SQLITE_BUSY. History rows feed owed-reply
|
|
145
|
+
// logic (a dropped write can lose an owed reply), so wait-and-retry rather
|
|
146
|
+
// than drop. Mirrors the registry DB's pattern (turns-schema.ts).
|
|
147
|
+
db.exec('PRAGMA busy_timeout = 5000')
|
|
143
148
|
db.exec(`
|
|
144
149
|
CREATE TABLE IF NOT EXISTS messages (
|
|
145
150
|
chat_id TEXT NOT NULL,
|
|
@@ -193,8 +193,7 @@ function main() {
|
|
|
193
193
|
'This turn is ending without your final answer reaching the user. ' +
|
|
194
194
|
'If you wrote an answer as plain text (not via a tool), the user ' +
|
|
195
195
|
'cannot see it — only text sent through the reply tool is delivered. ' +
|
|
196
|
-
'Send your final answer now by calling mcp__switchroom-telegram__reply ' +
|
|
197
|
-
'(or mcp__switchroom-telegram__stream_reply with done=true). ' +
|
|
196
|
+
'Send your final answer now by calling mcp__switchroom-telegram__reply. ' +
|
|
198
197
|
'If your final answer has already reached the user, or you ' +
|
|
199
198
|
'intentionally have nothing to add, reply with exactly NO_REPLY.',
|
|
200
199
|
}),
|
|
@@ -54,7 +54,8 @@ const SCHEMA_SQL = `
|
|
|
54
54
|
ended_at INTEGER,
|
|
55
55
|
status TEXT NOT NULL,
|
|
56
56
|
result_summary TEXT,
|
|
57
|
-
jsonl_agent_id TEXT
|
|
57
|
+
jsonl_agent_id TEXT,
|
|
58
|
+
parent_agent_id TEXT
|
|
58
59
|
);
|
|
59
60
|
CREATE INDEX IF NOT EXISTS subagents_turn ON subagents(parent_turn_key);
|
|
60
61
|
CREATE INDEX IF NOT EXISTS subagents_status ON subagents(status);
|
|
@@ -199,6 +200,13 @@ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, descr
|
|
|
199
200
|
db.exec('ALTER TABLE subagents ADD COLUMN jsonl_agent_id TEXT')
|
|
200
201
|
db.exec('CREATE INDEX IF NOT EXISTS subagents_jsonl_id ON subagents(jsonl_agent_id)')
|
|
201
202
|
}
|
|
203
|
+
// Migrate older DBs that pre-date parent_agent_id (nested-worker keying).
|
|
204
|
+
const hasParentAgentCol = db
|
|
205
|
+
.prepare("SELECT name FROM pragma_table_info('subagents') WHERE name = 'parent_agent_id'")
|
|
206
|
+
.get()
|
|
207
|
+
if (hasParentAgentCol == null) {
|
|
208
|
+
db.exec('ALTER TABLE subagents ADD COLUMN parent_agent_id TEXT')
|
|
209
|
+
}
|
|
202
210
|
// Verify the marker-derived parent_turn_key (snapParams[2]) actually has
|
|
203
211
|
// a row in the turns table before trusting it. The gateway writes the
|
|
204
212
|
// turn-active marker even when recordTurnStart's INSERT failed (the two
|
|
@@ -104,6 +104,18 @@ export interface Subagent {
|
|
|
104
104
|
result_summary: string | null
|
|
105
105
|
/** JSONL filename stem (e.g. "a37ad7639ae61476c") for watcher ID linkage. */
|
|
106
106
|
jsonl_agent_id: string | null
|
|
107
|
+
/**
|
|
108
|
+
* JSONL stem of the DISPATCHING sub-agent, for a NESTED (depth-2+) worker
|
|
109
|
+
* — one spawned by another sub-agent rather than by the main session.
|
|
110
|
+
* NULL for a main-session dispatch. Written by the watcher when it
|
|
111
|
+
* observes an Agent/Task tool_use inside a worker's own JSONL
|
|
112
|
+
* (recordNestedSubagentDispatch). Enables transitive origin-chat
|
|
113
|
+
* resolution: a nested worker whose parent_turn_key can never be stamped
|
|
114
|
+
* (its dispatching context is a background worker that outlives the main
|
|
115
|
+
* turn, so turn-active.json is gone) inherits routing from its ancestor
|
|
116
|
+
* chain instead.
|
|
117
|
+
*/
|
|
118
|
+
parent_agent_id: string | null
|
|
107
119
|
}
|
|
108
120
|
|
|
109
121
|
export interface RecordSubagentStartArgs {
|
|
@@ -194,7 +206,8 @@ const SUBAGENTS_SCHEMA_SQL = `
|
|
|
194
206
|
ended_at INTEGER,
|
|
195
207
|
status TEXT NOT NULL,
|
|
196
208
|
result_summary TEXT,
|
|
197
|
-
jsonl_agent_id TEXT
|
|
209
|
+
jsonl_agent_id TEXT,
|
|
210
|
+
parent_agent_id TEXT
|
|
198
211
|
);
|
|
199
212
|
CREATE INDEX IF NOT EXISTS subagents_turn ON subagents(parent_turn_key);
|
|
200
213
|
CREATE INDEX IF NOT EXISTS subagents_status ON subagents(status);
|
|
@@ -221,6 +234,12 @@ export function applySubagentsSchema(db: SqliteDatabase): void {
|
|
|
221
234
|
if (!hasJsonlId) {
|
|
222
235
|
db.exec('ALTER TABLE subagents ADD COLUMN jsonl_agent_id TEXT')
|
|
223
236
|
}
|
|
237
|
+
// Idempotent migration for DBs created before parent_agent_id existed
|
|
238
|
+
// (nested-worker keying — see the Subagent.parent_agent_id doc).
|
|
239
|
+
const hasParentAgentId = cols.some((c) => c.name === 'parent_agent_id')
|
|
240
|
+
if (!hasParentAgentId) {
|
|
241
|
+
db.exec('ALTER TABLE subagents ADD COLUMN parent_agent_id TEXT')
|
|
242
|
+
}
|
|
224
243
|
// Always (re-)apply the index. `IF NOT EXISTS` makes this a no-op when it
|
|
225
244
|
// already exists. Splitting it from SUBAGENTS_SCHEMA_SQL is what fixes the
|
|
226
245
|
// pre-existing-table failure mode — by the time we reach this line, the
|
|
@@ -296,6 +315,7 @@ interface RawSubagentRow {
|
|
|
296
315
|
status: string
|
|
297
316
|
result_summary: string | null
|
|
298
317
|
jsonl_agent_id: string | null
|
|
318
|
+
parent_agent_id?: string | null
|
|
299
319
|
}
|
|
300
320
|
|
|
301
321
|
function mapSubagentRow(row: RawSubagentRow): Subagent {
|
|
@@ -312,6 +332,7 @@ function mapSubagentRow(row: RawSubagentRow): Subagent {
|
|
|
312
332
|
status: row.status as SubagentStatus,
|
|
313
333
|
result_summary: row.result_summary,
|
|
314
334
|
jsonl_agent_id: row.jsonl_agent_id,
|
|
335
|
+
parent_agent_id: row.parent_agent_id ?? null,
|
|
315
336
|
}
|
|
316
337
|
}
|
|
317
338
|
|
|
@@ -579,3 +600,107 @@ export function getSubagent(db: SqliteDatabase, id: string): Subagent | null {
|
|
|
579
600
|
| undefined
|
|
580
601
|
return row ? mapSubagentRow(row) : null
|
|
581
602
|
}
|
|
603
|
+
|
|
604
|
+
// ---------------------------------------------------------------------------
|
|
605
|
+
// Nested (depth-2+) worker keying — the unified progress-card fix
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
|
|
608
|
+
export interface RecordNestedSubagentDispatchArgs {
|
|
609
|
+
/** tool_use id of the nested Agent/Task dispatch — the subagents PK the
|
|
610
|
+
* child's meta.json `toolUseId` will later link against. */
|
|
611
|
+
toolUseId: string
|
|
612
|
+
/** JSONL stem of the DISPATCHING worker (the parent sub-agent). */
|
|
613
|
+
parentJsonlAgentId: string
|
|
614
|
+
agentType?: string | null
|
|
615
|
+
description?: string | null
|
|
616
|
+
/** `run_in_background` from the nested dispatch's tool_input. */
|
|
617
|
+
background: boolean
|
|
618
|
+
now: number
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Record a NESTED sub-agent dispatch observed by the watcher in a worker's
|
|
623
|
+
* own JSONL (a `sub_agent_tool_use` whose tool is Agent/Task).
|
|
624
|
+
*
|
|
625
|
+
* Why this exists: the PreToolUse tracker hook derives `parent_turn_key`
|
|
626
|
+
* from the gateway's `turn-active.json` marker, which only exists during
|
|
627
|
+
* the MAIN turn. A nested worker is dispatched by a background depth-1
|
|
628
|
+
* worker that outlives that turn, so the marker is gone → the hook stamps
|
|
629
|
+
* NULL (or, under concurrent dispatch, the hook's write can be lost
|
|
630
|
+
* entirely). The row is then missing/unattributed: origin-chat resolution
|
|
631
|
+
* returns null (card misroutes to the owner DM), `resolveWorkerFeedDispatch`
|
|
632
|
+
* defaults the worker to foreground, and the card freezes on "starting…"
|
|
633
|
+
* forever. The watcher, however, tails the parent worker's JSONL and SEES
|
|
634
|
+
* the nested dispatch — so it is the reliable recorder for depth-2+.
|
|
635
|
+
*
|
|
636
|
+
* Behaviour (idempotent, safe to call on every observation):
|
|
637
|
+
* - INSERT OR IGNORE a row keyed on the dispatch tool_use_id (harmless
|
|
638
|
+
* no-op when the pretool hook's row already landed).
|
|
639
|
+
* - Stamp `parent_agent_id` (the dispatching worker's JSONL stem) when
|
|
640
|
+
* not already set.
|
|
641
|
+
* - Inherit `parent_turn_key` transitively from the parent worker's row
|
|
642
|
+
* when NULL — the parent's row was stamped at ITS dispatch (main turn
|
|
643
|
+
* still active), so the chain bottoms out at a real turn key.
|
|
644
|
+
*/
|
|
645
|
+
export function recordNestedSubagentDispatch(
|
|
646
|
+
db: SqliteDatabase,
|
|
647
|
+
args: RecordNestedSubagentDispatchArgs,
|
|
648
|
+
): void {
|
|
649
|
+
db.prepare(`
|
|
650
|
+
INSERT OR IGNORE INTO subagents
|
|
651
|
+
(id, parent_session_id, parent_turn_key, agent_type, description,
|
|
652
|
+
background, started_at, last_activity_at, status, jsonl_agent_id,
|
|
653
|
+
parent_agent_id)
|
|
654
|
+
VALUES (?, NULL, NULL, ?, ?, ?, ?, ?, 'running', NULL, ?)
|
|
655
|
+
`).run(
|
|
656
|
+
args.toolUseId,
|
|
657
|
+
args.agentType ?? null,
|
|
658
|
+
args.description ?? null,
|
|
659
|
+
args.background ? 1 : 0,
|
|
660
|
+
args.now,
|
|
661
|
+
args.now,
|
|
662
|
+
args.parentJsonlAgentId,
|
|
663
|
+
)
|
|
664
|
+
// Repair path for a hook-inserted row: stamp nested parentage and inherit
|
|
665
|
+
// the origin turn key from the parent worker's row when missing. COALESCE
|
|
666
|
+
// keeps any value already present (hook-stamped or previously inherited).
|
|
667
|
+
db.prepare(`
|
|
668
|
+
UPDATE subagents
|
|
669
|
+
SET parent_agent_id = COALESCE(parent_agent_id, ?),
|
|
670
|
+
parent_turn_key = COALESCE(
|
|
671
|
+
parent_turn_key,
|
|
672
|
+
(SELECT p.parent_turn_key FROM subagents p
|
|
673
|
+
WHERE p.jsonl_agent_id = ? LIMIT 1)
|
|
674
|
+
)
|
|
675
|
+
WHERE id = ?
|
|
676
|
+
`).run(args.parentJsonlAgentId, args.parentJsonlAgentId, args.toolUseId)
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Resolve the ORIGIN turn key for a worker, walking the nested-parent chain.
|
|
681
|
+
*
|
|
682
|
+
* Depth-1: the row's own `parent_turn_key`. Depth-2+: when NULL, follow
|
|
683
|
+
* `parent_agent_id` (the dispatching worker's JSONL stem) up the chain until
|
|
684
|
+
* a row with a non-NULL `parent_turn_key` is found. Bounded (default 5 hops)
|
|
685
|
+
* and cycle-safe. Returns null when nothing in the chain is attributed —
|
|
686
|
+
* callers keep their existing DM fallback.
|
|
687
|
+
*/
|
|
688
|
+
export function resolveSubagentOriginTurnKey(
|
|
689
|
+
db: SqliteDatabase,
|
|
690
|
+
jsonlAgentId: string,
|
|
691
|
+
maxHops = 5,
|
|
692
|
+
): string | null {
|
|
693
|
+
const seen = new Set<string>()
|
|
694
|
+
let currentJsonlId: string | null = jsonlAgentId
|
|
695
|
+
for (let hop = 0; hop <= maxHops && currentJsonlId != null; hop++) {
|
|
696
|
+
if (seen.has(currentJsonlId)) return null
|
|
697
|
+
seen.add(currentJsonlId)
|
|
698
|
+
const row = db
|
|
699
|
+
.prepare('SELECT parent_turn_key, parent_agent_id FROM subagents WHERE jsonl_agent_id = ? LIMIT 1')
|
|
700
|
+
.get(currentJsonlId) as { parent_turn_key: string | null; parent_agent_id: string | null } | undefined
|
|
701
|
+
if (row == null) return null
|
|
702
|
+
if (row.parent_turn_key != null && row.parent_turn_key.length > 0) return row.parent_turn_key
|
|
703
|
+
currentJsonlId = row.parent_agent_id ?? null
|
|
704
|
+
}
|
|
705
|
+
return null
|
|
706
|
+
}
|
|
@@ -108,6 +108,18 @@ export interface Turn {
|
|
|
108
108
|
* cleanly-restarted (`'restart'`) orphans.
|
|
109
109
|
*/
|
|
110
110
|
interrupt_reason: string | null
|
|
111
|
+
/**
|
|
112
|
+
* Ms epoch at which a boot-resume/report inbound was durably queued for
|
|
113
|
+
* this interrupted turn (see `markTurnResumed`). It is the at-most-once
|
|
114
|
+
* ledger for resume: `findLatestTurnIfInterrupted` treats a turn with a
|
|
115
|
+
* non-null `resumed_at` as already handled and never re-fires it. This
|
|
116
|
+
* distinguishes "we already committed a resume for this turn (side
|
|
117
|
+
* effects may have run)" from "work incomplete, never resumed" — closing
|
|
118
|
+
* the accept-vs-consume double-execution window (#2793 part A) where the
|
|
119
|
+
* "latest turn not clean" proxy re-minted a fresh resume on every restart.
|
|
120
|
+
* Null for turns that were never resumed.
|
|
121
|
+
*/
|
|
122
|
+
resumed_at: number | null
|
|
111
123
|
created_at: number
|
|
112
124
|
updated_at: number
|
|
113
125
|
}
|
|
@@ -148,6 +160,7 @@ const SCHEMA_SQL = `
|
|
|
148
160
|
assistant_reply_preview TEXT,
|
|
149
161
|
tool_call_count INTEGER,
|
|
150
162
|
interrupt_reason TEXT,
|
|
163
|
+
resumed_at INTEGER,
|
|
151
164
|
created_at INTEGER NOT NULL,
|
|
152
165
|
updated_at INTEGER NOT NULL
|
|
153
166
|
);
|
|
@@ -169,6 +182,14 @@ const PHASE2_MIGRATIONS = [
|
|
|
169
182
|
`ALTER TABLE turns ADD COLUMN interrupt_reason TEXT`,
|
|
170
183
|
]
|
|
171
184
|
|
|
185
|
+
// Column added for at-most-once resume (#2793 part A). Stamped when the
|
|
186
|
+
// gateway durably queues a boot-resume inbound for an interrupted turn, so a
|
|
187
|
+
// subsequent restart cannot re-mint a fresh resume for the same turn (which
|
|
188
|
+
// would re-run side effects that already executed before the crash).
|
|
189
|
+
const PHASE3_MIGRATIONS = [
|
|
190
|
+
`ALTER TABLE turns ADD COLUMN resumed_at INTEGER`,
|
|
191
|
+
]
|
|
192
|
+
|
|
172
193
|
function applySchema(db: SqliteDatabase): void {
|
|
173
194
|
db.exec('PRAGMA journal_mode = WAL')
|
|
174
195
|
db.exec('PRAGMA synchronous = NORMAL')
|
|
@@ -185,7 +206,7 @@ function applySchema(db: SqliteDatabase): void {
|
|
|
185
206
|
// Run migrations. SQLite doesn't support "ADD COLUMN IF NOT EXISTS", so
|
|
186
207
|
// we swallow the "duplicate column" error to stay idempotent on
|
|
187
208
|
// pre-existing registry.db files.
|
|
188
|
-
for (const sql of [...PHASE1_MIGRATIONS, ...PHASE2_MIGRATIONS]) {
|
|
209
|
+
for (const sql of [...PHASE1_MIGRATIONS, ...PHASE2_MIGRATIONS, ...PHASE3_MIGRATIONS]) {
|
|
189
210
|
try {
|
|
190
211
|
db.exec(sql)
|
|
191
212
|
} catch (err) {
|
|
@@ -261,6 +282,7 @@ interface RawTurnRow {
|
|
|
261
282
|
assistant_reply_preview: string | null
|
|
262
283
|
tool_call_count: number | null
|
|
263
284
|
interrupt_reason: string | null
|
|
285
|
+
resumed_at: number | null
|
|
264
286
|
created_at: number
|
|
265
287
|
updated_at: number
|
|
266
288
|
}
|
|
@@ -281,6 +303,7 @@ function mapRow(row: RawTurnRow): Turn {
|
|
|
281
303
|
assistant_reply_preview: row.assistant_reply_preview,
|
|
282
304
|
tool_call_count: row.tool_call_count,
|
|
283
305
|
interrupt_reason: row.interrupt_reason,
|
|
306
|
+
resumed_at: row.resumed_at,
|
|
284
307
|
created_at: row.created_at,
|
|
285
308
|
updated_at: row.updated_at,
|
|
286
309
|
}
|
|
@@ -559,6 +582,38 @@ const INTERRUPTED_VIA: ReadonlySet<TurnEndedVia> = new Set<TurnEndedVia>([
|
|
|
559
582
|
'unknown',
|
|
560
583
|
])
|
|
561
584
|
|
|
585
|
+
/**
|
|
586
|
+
* Stamp `resumed_at` on an interrupted turn at the moment the gateway has
|
|
587
|
+
* DURABLY committed to resuming it (the boot-resume inbound is on the
|
|
588
|
+
* inbound spool / in-memory buffer). This is the at-most-once ledger for
|
|
589
|
+
* resume: once stamped, `findLatestTurnIfInterrupted` no longer returns the
|
|
590
|
+
* turn, so a subsequent restart cannot mint a SECOND, distinct resume for
|
|
591
|
+
* the same turn and re-run side effects that already executed before the
|
|
592
|
+
* crash (#2793 part A).
|
|
593
|
+
*
|
|
594
|
+
* Ordering matters: the caller must stamp only AFTER the resume inbound is
|
|
595
|
+
* durably spooled (synchronously, before any async delivery can ack it), so
|
|
596
|
+
* a crash between "decide to resume" and "persist the resume" can never mark
|
|
597
|
+
* a turn resumed that was never actually spooled. The spool's own
|
|
598
|
+
* at-least-once redelivery (keyed on `resume_turn_key`) still guarantees the
|
|
599
|
+
* one committed resume is delivered; this ledger only prevents re-minting.
|
|
600
|
+
*
|
|
601
|
+
* Idempotent and first-write-wins: an already-stamped turn keeps its
|
|
602
|
+
* original `resumed_at`. No-ops if `turnKey` is not found.
|
|
603
|
+
*/
|
|
604
|
+
export function markTurnResumed(
|
|
605
|
+
db: SqliteDatabase,
|
|
606
|
+
turnKey: string,
|
|
607
|
+
now: number = Date.now(),
|
|
608
|
+
): void {
|
|
609
|
+
db.prepare(`
|
|
610
|
+
UPDATE turns
|
|
611
|
+
SET resumed_at = ?,
|
|
612
|
+
updated_at = ?
|
|
613
|
+
WHERE turn_key = ? AND resumed_at IS NULL
|
|
614
|
+
`).run(now, now, turnKey)
|
|
615
|
+
}
|
|
616
|
+
|
|
562
617
|
/**
|
|
563
618
|
* Return the single most-recently-started turn IFF it was interrupted
|
|
564
619
|
* (`ended_at IS NULL`, or `ended_via` in {restart, sigterm, timeout,
|
|
@@ -576,6 +631,14 @@ const INTERRUPTED_VIA: ReadonlySet<TurnEndedVia> = new Set<TurnEndedVia>([
|
|
|
576
631
|
* Ordering uses `started_at DESC` (not `updated_at`) so the boot reaper,
|
|
577
632
|
* which mass-stamps orphans with identical timestamps, can't reorder the
|
|
578
633
|
* temporal "last turn" the user actually remembers.
|
|
634
|
+
*
|
|
635
|
+
* At-most-once (#2793 part A): a turn that already carries `resumed_at` was
|
|
636
|
+
* committed to a resume on an earlier boot. Even if it never reached a clean
|
|
637
|
+
* `'stop'` (its resume ran side effects but the process died before the
|
|
638
|
+
* follow-up turn wrote `ended_at`, or the resume inbound was accepted but
|
|
639
|
+
* never consumed), it is NOT re-fired — the "latest turn not clean" proxy
|
|
640
|
+
* would otherwise re-mint a fresh resume on every restart and double-execute
|
|
641
|
+
* side effects. Resume is at-most-once per turn.
|
|
579
642
|
*/
|
|
580
643
|
export function findLatestTurnIfInterrupted(db: SqliteDatabase): Turn | null {
|
|
581
644
|
const row = db.prepare(`
|
|
@@ -585,6 +648,7 @@ export function findLatestTurnIfInterrupted(db: SqliteDatabase): Turn | null {
|
|
|
585
648
|
`).get() as RawTurnRow | undefined
|
|
586
649
|
if (!row) return null
|
|
587
650
|
const turn = mapRow(row)
|
|
651
|
+
if (turn.resumed_at != null) return null
|
|
588
652
|
if (turn.ended_at == null) return turn
|
|
589
653
|
if (turn.ended_via != null && INTERRUPTED_VIA.has(turn.ended_via)) return turn
|
|
590
654
|
return null
|
|
@@ -124,7 +124,19 @@ export type SessionEvent =
|
|
|
124
124
|
| { kind: 'sub_agent_text'; agentId: string; text: string; blockIndex: number; lastInMessage: boolean }
|
|
125
125
|
| { kind: 'sub_agent_tool_result'; agentId: string; toolUseId: string; isError?: boolean; errorText?: string }
|
|
126
126
|
| { kind: 'sub_agent_turn_end'; agentId: string }
|
|
127
|
-
| {
|
|
127
|
+
| {
|
|
128
|
+
kind: 'sub_agent_nested_spawn'
|
|
129
|
+
agentId: string
|
|
130
|
+
/** tool_use id of the nested Agent/Task dispatch — the `subagents`
|
|
131
|
+
* registry PK the child's meta.json `toolUseId` links against. Lets
|
|
132
|
+
* the watcher record/repair the nested worker's registry row
|
|
133
|
+
* (recordNestedSubagentDispatch — the depth-2+ keying fix). */
|
|
134
|
+
toolUseId?: string | null
|
|
135
|
+
/** The dispatch tool_input (description / subagent_type /
|
|
136
|
+
* run_in_background) — registry metadata only, never rendered
|
|
137
|
+
* (design §5.5's "no recursion in rendering" rule is unchanged). */
|
|
138
|
+
input?: Record<string, unknown>
|
|
139
|
+
}
|
|
128
140
|
/**
|
|
129
141
|
* Emitted when a sub-agent JSONL has >= CAP_TOOL_USE_THRESHOLD tool_use
|
|
130
142
|
* records but no terminal record (no `type:result`, `subtype:end`, or
|
|
@@ -146,9 +158,14 @@ function parseChannelMeta(content: string): {
|
|
|
146
158
|
messageId: string | null
|
|
147
159
|
threadId: string | null
|
|
148
160
|
} {
|
|
149
|
-
// Look for `chat_id="..."` etc in the channel XML tag
|
|
161
|
+
// Look for `chat_id="..."` etc in the channel XML tag. LEFT-ANCHOR on an
|
|
162
|
+
// attribute boundary (start-of-string or a whitespace/quote before the name)
|
|
163
|
+
// so `message_id` matches ONLY the real attribute — never a same-suffix
|
|
164
|
+
// sibling like `target_message_id`, `reply_to_message_id`, or
|
|
165
|
+
// `original_message_id`. Without the boundary, grab('message_id') would
|
|
166
|
+
// match the FIRST `*_message_id` substring, mis-attributing the enqueue's id.
|
|
150
167
|
const grab = (key: string): string | null => {
|
|
151
|
-
const m = content.match(new RegExp(
|
|
168
|
+
const m = content.match(new RegExp(`(?:^|[\\s"'])${key}="([^"]+)"`))
|
|
152
169
|
return m ? m[1] : null
|
|
153
170
|
}
|
|
154
171
|
return {
|
|
@@ -480,7 +497,12 @@ export function projectSubagentLine(
|
|
|
480
497
|
// that would surface the sub-sub-agent's description and break
|
|
481
498
|
// the "no recursion in rendering" rule.
|
|
482
499
|
if (name === 'Agent' || name === 'Task') {
|
|
483
|
-
events.push({
|
|
500
|
+
events.push({
|
|
501
|
+
kind: 'sub_agent_nested_spawn',
|
|
502
|
+
agentId,
|
|
503
|
+
toolUseId: (c.id as string | undefined) ?? null,
|
|
504
|
+
input: (c.input as Record<string, unknown> | undefined) ?? undefined,
|
|
505
|
+
})
|
|
484
506
|
} else {
|
|
485
507
|
events.push({
|
|
486
508
|
kind: 'sub_agent_tool_use',
|