switchroom 0.14.38 → 0.14.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +2 -2
- package/package.json +3 -3
- package/telegram-plugin/dist/gateway/gateway.js +4 -4
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +41 -6
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +82 -11
- package/telegram-plugin/registry/subagents-bugs.test.ts +120 -19
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +111 -0
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +50 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -49438,8 +49438,8 @@ var {
|
|
|
49438
49438
|
} = import__.default;
|
|
49439
49439
|
|
|
49440
49440
|
// src/build-info.ts
|
|
49441
|
-
var VERSION = "0.14.
|
|
49442
|
-
var COMMIT_SHA = "
|
|
49441
|
+
var VERSION = "0.14.39";
|
|
49442
|
+
var COMMIT_SHA = "fb30b654";
|
|
49443
49443
|
|
|
49444
49444
|
// src/cli/agent.ts
|
|
49445
49445
|
init_source();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchroom",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.39",
|
|
4
4
|
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
"build": "node scripts/build.mjs",
|
|
24
24
|
"build:cli": "node scripts/build.mjs && bun build --compile --target=bun-linux-x64 --minify bin/switchroom.ts --outfile switchroom-linux-amd64",
|
|
25
25
|
"pretest": "npm run build",
|
|
26
|
-
"test": "vitest run && bun test telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/registry/api-registry.test.ts telegram-plugin/registry/turns-schema.test.ts telegram-plugin/tests/idle-footer-wiring.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
|
|
26
|
+
"test": "vitest run && bun test telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/registry/api-registry.test.ts telegram-plugin/registry/turns-schema.test.ts telegram-plugin/tests/idle-footer-wiring.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
|
|
27
27
|
"test:vitest": "vitest run",
|
|
28
|
-
"test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/server-admin-only-keys.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/approval-origin.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
|
|
28
|
+
"test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/server-admin-only-keys.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/approval-origin.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
|
|
29
29
|
"test:watch": "vitest",
|
|
30
30
|
"lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs",
|
|
31
31
|
"lint:tsc": "tsc --noEmit",
|
|
@@ -51794,10 +51794,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
51794
51794
|
}
|
|
51795
51795
|
|
|
51796
51796
|
// ../src/build-info.ts
|
|
51797
|
-
var VERSION = "0.14.
|
|
51798
|
-
var COMMIT_SHA = "
|
|
51799
|
-
var COMMIT_DATE = "2026-06-
|
|
51800
|
-
var LATEST_PR =
|
|
51797
|
+
var VERSION = "0.14.39";
|
|
51798
|
+
var COMMIT_SHA = "fb30b654";
|
|
51799
|
+
var COMMIT_DATE = "2026-06-02T05:46:22Z";
|
|
51800
|
+
var LATEST_PR = 2086;
|
|
51801
51801
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
51802
51802
|
|
|
51803
51803
|
// gateway/boot-version.ts
|
|
@@ -188,18 +188,53 @@ function toolResponseText(toolResponse) {
|
|
|
188
188
|
* parent turn, so the pretool recorded background=0 and the worker card never
|
|
189
189
|
* fired). We therefore trust this ACK over the pretool's input-derived flag.
|
|
190
190
|
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
191
|
+
* Three tiers, widest-stable-signal last, so a Claude Code wording change has
|
|
192
|
+
* to defeat ALL of them before promotion silently regresses (#2084):
|
|
193
|
+
*
|
|
194
|
+
* 1. The canonical ACK phrase "async agent launched" — the exact current
|
|
195
|
+
* claude-code wording. A foreground report is extremely unlikely to
|
|
196
|
+
* contain it.
|
|
197
|
+
* 2. Structural backstop: the background-status phrase "working in the
|
|
198
|
+
* background" + an agentId token. Survives a reworded launch verb.
|
|
199
|
+
* 3. Drift-tolerant STRUCTURAL match: `agentId: <stem>` as a bare identifier
|
|
200
|
+
* on its OWN LINE — the functional, most wording-stable core of the ACK
|
|
201
|
+
* (the parent references the worker by this id). claude-code emits it on
|
|
202
|
+
* its own line; a foreground report that merely names an agent id embeds
|
|
203
|
+
* it mid-sentence ("the agentId is X is managing …"), which the own-line
|
|
204
|
+
* anchor rejects. Paired with a launch/background/async/dispatch/notify
|
|
205
|
+
* context word so it can't trip on a foreground report that happens to
|
|
206
|
+
* print a bare id on its own line. This survives BOTH prose phrases
|
|
207
|
+
* (tiers 1 & 2) rewording in the same bump.
|
|
208
|
+
*
|
|
209
|
+
* If all three miss, promotion degrades to the pretool's input-derived flag —
|
|
210
|
+
* still correct whenever the model DID pass run_in_background, never worse than
|
|
211
|
+
* before. The exact ACK contract is pinned by drift-variant tests in
|
|
212
|
+
* subagent-tracker-hooks.test.ts ("async-launch ACK contract"); when bumping
|
|
213
|
+
* the pinned claude-code version, re-verify the live ACK against those.
|
|
214
|
+
*
|
|
215
|
+
* Known residual (accepted): an ACK reword that BOTH drops the own-line id
|
|
216
|
+
* form AND removes every context word degrades to the pretool flag; a
|
|
217
|
+
* foreground report that prints a bare `agentId: <id>` on its own line next to
|
|
218
|
+
* a launch/background word would false-promote. Both are narrow; the own-line
|
|
219
|
+
* + context-word conjunction is the balance point. Re-verify on a pin bump.
|
|
220
|
+
*
|
|
221
|
+
* ACK contract verified against claude-code 2.1.156 (the fleet pin):
|
|
222
|
+
* "Async agent launched successfully.\n
|
|
223
|
+
* agentId: <stem>\n
|
|
224
|
+
* The agent is working in the background. You will be notified
|
|
225
|
+
* automatically when it completes."
|
|
197
226
|
*/
|
|
198
227
|
function isAsyncLaunchAck(toolResponse) {
|
|
199
228
|
const t = toolResponseText(toolResponse).toLowerCase()
|
|
200
229
|
if (!t) return false
|
|
201
230
|
if (t.includes('async agent launched')) return true
|
|
202
231
|
if (t.includes('working in the background') && t.includes('agentid')) return true
|
|
232
|
+
// Tier 3 — own-line `agentid: <bare-stem>` + a context word (leading \b so
|
|
233
|
+
// reworded/derived forms like "launched"/"dispatching"/"notified" still
|
|
234
|
+
// count). `m` flag anchors $ to line end so a mid-sentence id is rejected.
|
|
235
|
+
if (/agentid:\s*[a-z0-9][\w-]*\s*$/m.test(t) && /\b(background|launch|dispatch|async|notif)/.test(t)) {
|
|
236
|
+
return true
|
|
237
|
+
}
|
|
203
238
|
return false
|
|
204
239
|
}
|
|
205
240
|
|
|
@@ -191,6 +191,24 @@ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, descr
|
|
|
191
191
|
db.exec('ALTER TABLE subagents ADD COLUMN jsonl_agent_id TEXT')
|
|
192
192
|
db.exec('CREATE INDEX IF NOT EXISTS subagents_jsonl_id ON subagents(jsonl_agent_id)')
|
|
193
193
|
}
|
|
194
|
+
// Verify the marker-derived parent_turn_key (snapParams[2]) actually has
|
|
195
|
+
// a row in the turns table before trusting it. The gateway writes the
|
|
196
|
+
// turn-active marker even when recordTurnStart's INSERT failed (the two
|
|
197
|
+
// writes have independent failure surfaces), so a marker can name a
|
|
198
|
+
// turn_key with no turns row. Stamping that phantom key would route the
|
|
199
|
+
// worker card to the operator DM AND block the watcher's NULL-guarded
|
|
200
|
+
// window backfill from recovering it. Downgrade to NULL so the backfill
|
|
201
|
+
// stays eligible — this also defends against a stale/corrupted marker.
|
|
202
|
+
if (snapParams[2] != null) {
|
|
203
|
+
let turnRow = null
|
|
204
|
+
try {
|
|
205
|
+
turnRow = db.prepare('SELECT 1 FROM turns WHERE turn_key = ? LIMIT 1').get(snapParams[2])
|
|
206
|
+
} catch {
|
|
207
|
+
// turns table may not exist yet on a brand-new agent — treat as no row.
|
|
208
|
+
turnRow = null
|
|
209
|
+
}
|
|
210
|
+
if (turnRow == null) snapParams[2] = null
|
|
211
|
+
}
|
|
194
212
|
db.prepare(snapInsertSql).run(...snapParams)
|
|
195
213
|
db.close()
|
|
196
214
|
done(null)
|
|
@@ -202,12 +220,65 @@ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, descr
|
|
|
202
220
|
}
|
|
203
221
|
|
|
204
222
|
// sqlite3 CLI fallback — two non-blocking spawns sequenced via callbacks.
|
|
223
|
+
// This legacy path (neither node:sqlite nor bun:sqlite available) can't
|
|
224
|
+
// cheaply verify the marker's turn_key against the turns table, so drop
|
|
225
|
+
// parent_turn_key and let the gateway's window backfill attribute it.
|
|
226
|
+
// Production agents use node:sqlite; bun test uses bun:sqlite — both take
|
|
227
|
+
// the verified path above.
|
|
228
|
+
params[2] = null
|
|
205
229
|
spawnSql(dbPath, SCHEMA_SQL.replace(/\n\s+/g, ' '), (err) => {
|
|
206
230
|
if (err) { done(err); return }
|
|
207
231
|
spawnSql(dbPath, fillPlaceholders(INSERT_SQL.trim(), params), done)
|
|
208
232
|
})
|
|
209
233
|
}
|
|
210
234
|
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Active-turn resolution (the parent_turn_key the row belongs to)
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Read the gateway's turn-active marker to learn the turn_key of the turn that
|
|
241
|
+
* is active *right now* — the turn whose tool call is dispatching this
|
|
242
|
+
* sub-agent. The gateway writes `<TELEGRAM_STATE_DIR>/turn-active.json`
|
|
243
|
+
* synchronously at turn-start (gateway/turn-active-marker.ts), keyed
|
|
244
|
+
* `{turnKey, chatId, threadId, startedAt}`, and removes it at turn-complete.
|
|
245
|
+
* `telegramDir` here resolves to that same `TELEGRAM_STATE_DIR` in production
|
|
246
|
+
* (verified: identical inode to the registry.db dir), so the marker is a
|
|
247
|
+
* sibling of registry.db.
|
|
248
|
+
*
|
|
249
|
+
* Stamping parent_turn_key from this marker at INSERT time — instead of
|
|
250
|
+
* leaving it NULL for the gateway to reconstruct from a started_at time-window
|
|
251
|
+
* at jsonl-link time — fixes two bugs:
|
|
252
|
+
* - #2081: the time-window backfill mis-attributes when turn windows overlap
|
|
253
|
+
* (supergroup forum topics multiplex many concurrent turns under one
|
|
254
|
+
* chat_id; `ended_at` is unreliable/batch-swept). The live marker is the
|
|
255
|
+
* ground truth for "which turn dispatched this", so there is nothing to
|
|
256
|
+
* reconstruct and no overlap to disambiguate.
|
|
257
|
+
* - #2083: the backfill only runs when a sub-agent's JSONL links; ~8% never
|
|
258
|
+
* link and were never attributed. Stamping at INSERT is independent of
|
|
259
|
+
* linking.
|
|
260
|
+
*
|
|
261
|
+
* `turnKey` equals `turns.turn_key` (both minted by chatKeyWithSuffix at
|
|
262
|
+
* turn-start), so resolveSubagentOriginChat()'s getTurnByKey() finds the exact
|
|
263
|
+
* (chat_id, thread_id) and routes the worker card to the originating topic.
|
|
264
|
+
*
|
|
265
|
+
* Best-effort: if no turn is active (no marker — e.g. a sub-agent dispatched
|
|
266
|
+
* outside a turn) or the marker is unreadable/malformed, return null and let
|
|
267
|
+
* the gateway's started_at backfill remain the fallback (today's behaviour).
|
|
268
|
+
* Never throws; never blocks the tool call.
|
|
269
|
+
*/
|
|
270
|
+
function readActiveTurnKey(telegramDir) {
|
|
271
|
+
try {
|
|
272
|
+
// Mirrors TURN_ACTIVE_MARKER_FILE in gateway/turn-active-marker.ts.
|
|
273
|
+
const raw = readFileSync(join(telegramDir, 'turn-active.json'), 'utf8')
|
|
274
|
+
const marker = JSON.parse(raw)
|
|
275
|
+
const turnKey = marker?.turnKey
|
|
276
|
+
return typeof turnKey === 'string' && turnKey.length > 0 ? turnKey : null
|
|
277
|
+
} catch {
|
|
278
|
+
return null
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
211
282
|
// ---------------------------------------------------------------------------
|
|
212
283
|
// main
|
|
213
284
|
// ---------------------------------------------------------------------------
|
|
@@ -257,22 +328,22 @@ function main() {
|
|
|
257
328
|
}
|
|
258
329
|
|
|
259
330
|
const input = event.tool_input ?? {}
|
|
331
|
+
// Resolve parent_turn_key from the live turn-active marker (the turn whose
|
|
332
|
+
// tool call is dispatching this sub-agent). Claude Code's PreToolUse payload
|
|
333
|
+
// carries only its own session id, never the gateway-minted Telegram turn_key
|
|
334
|
+
// — but the gateway writes that turn_key to <telegramDir>/turn-active.json
|
|
335
|
+
// for the duration of the turn, so we read it directly here. Stamping it at
|
|
336
|
+
// INSERT (vs leaving NULL for the gateway's started_at time-window backfill)
|
|
337
|
+
// fixes overlapping-window mis-attribution (#2081) and attributes sub-agents
|
|
338
|
+
// whose JSONL never links (#2083). NULL when no turn is active → the gateway
|
|
339
|
+
// backfill remains the fallback. See readActiveTurnKey().
|
|
340
|
+
const parentTurnKey = readActiveTurnKey(telegramDir)
|
|
260
341
|
writeRow(
|
|
261
342
|
dbPath,
|
|
262
343
|
{
|
|
263
344
|
id: event.tool_use_id ?? null,
|
|
264
345
|
parentSessionId: event.session_id ?? null,
|
|
265
|
-
|
|
266
|
-
// payload carries its own session id, not the gateway-minted Telegram
|
|
267
|
-
// turn_key (a chat+topic+turn key) the `turns` table is keyed on —
|
|
268
|
-
// `event.turn_id` is always undefined, and even if a future CLI
|
|
269
|
-
// populated it, it would not match a `turns.turn_key`. The gateway
|
|
270
|
-
// resolves parent_turn_key from the
|
|
271
|
-
// sub-agent's started_at at jsonl-link time (subagent-watcher.ts
|
|
272
|
-
// backfillJsonlAgentId), which works even after the parent turn ends.
|
|
273
|
-
// Writing a bogus value here would defeat that backfill's
|
|
274
|
-
// `parent_turn_key IS NULL` guard.
|
|
275
|
-
parentTurnKey: null,
|
|
346
|
+
parentTurnKey,
|
|
276
347
|
agentType: input.subagent_type ?? null,
|
|
277
348
|
description: input.description ?? null,
|
|
278
349
|
background: input.run_in_background === true ? 1 : 0,
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
|
15
|
-
import { mkdtempSync, mkdirSync, rmSync } from 'fs'
|
|
15
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
|
16
16
|
import { tmpdir } from 'os'
|
|
17
17
|
import { join } from 'path'
|
|
18
18
|
import { spawnSync } from 'child_process'
|
|
@@ -385,26 +385,62 @@ describe('Bug 4 — result_summary always NULL (hook integration)', () => {
|
|
|
385
385
|
})
|
|
386
386
|
})
|
|
387
387
|
|
|
388
|
-
// ─── Bug 5 — parent_turn_key
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
388
|
+
// ─── Bug 5 — parent_turn_key stamped from the live turn-active marker ─────────
|
|
389
|
+
// (#2081 / #2083) The PreToolUse hook reads <telegramDir>/turn-active.json —
|
|
390
|
+
// the gateway-written marker for the turn whose tool call is dispatching this
|
|
391
|
+
// sub-agent — and stamps parent_turn_key = marker.turnKey at INSERT. This
|
|
392
|
+
// captures the EXACT active turn (no started_at time-window reconstruction at
|
|
393
|
+
// jsonl-link time), so it can't mis-attribute under overlapping turn windows
|
|
394
|
+
// (#2081) and works even for sub-agents whose JSONL never links (#2083).
|
|
395
|
+
|
|
396
|
+
/** Write the gateway's turn-active marker into the agent's telegram dir. */
|
|
397
|
+
function writeTurnActiveMarker(turnKey: string, chatId = '12345', threadId: string | null = null) {
|
|
398
|
+
writeFileSync(
|
|
399
|
+
join(agentDir, 'telegram', 'turn-active.json'),
|
|
400
|
+
JSON.stringify({ turnKey, chatId, threadId, startedAt: Date.now() }, null, 2) + '\n',
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Seed a turns row so the hook's phantom-turn_key guard (it only stamps a
|
|
406
|
+
* marker turn_key that actually has a turns row) is satisfied. In production
|
|
407
|
+
* the gateway writes this row via recordTurnStart at turn-start.
|
|
408
|
+
*/
|
|
409
|
+
function seedTurn(turnKey: string, chatId = '12345', threadId: string | null = null) {
|
|
410
|
+
const { Database } = require('bun:sqlite') as {
|
|
411
|
+
Database: new (path: string) => {
|
|
412
|
+
prepare(sql: string): { run(...p: unknown[]): unknown }
|
|
413
|
+
exec(sql: string): void
|
|
414
|
+
close(): void
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const db = new Database(dbPath)
|
|
418
|
+
db.exec(
|
|
419
|
+
`CREATE TABLE IF NOT EXISTS turns (
|
|
420
|
+
turn_key TEXT PRIMARY KEY, chat_id TEXT, thread_id TEXT,
|
|
421
|
+
started_at INTEGER, ended_at INTEGER, created_at INTEGER, updated_at INTEGER
|
|
422
|
+
)`,
|
|
423
|
+
)
|
|
424
|
+
const now = Date.now()
|
|
425
|
+
db.prepare(
|
|
426
|
+
'INSERT OR IGNORE INTO turns (turn_key, chat_id, thread_id, started_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
|
427
|
+
).run(turnKey, chatId, threadId, now, now, now)
|
|
428
|
+
db.close()
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
describe('Bug 5 — parent_turn_key stamped from the turn-active marker', () => {
|
|
432
|
+
it('stamps parent_turn_key = marker.turnKey when a turn is active', () => {
|
|
433
|
+
// Supergroup forum-topic turn_key (chat:thread:startedAt).
|
|
434
|
+
const turnKey = '-1003831053471:4:1780370238492'
|
|
435
|
+
seedTurn(turnKey, '-1003831053471', '4')
|
|
436
|
+
writeTurnActiveMarker(turnKey, '-1003831053471', '4')
|
|
437
|
+
|
|
400
438
|
const event = {
|
|
401
439
|
session_id: 'sess-turnkey',
|
|
402
|
-
turn_id: 'turn-abc-001',
|
|
403
440
|
tool_name: 'Agent',
|
|
404
441
|
tool_use_id: 'toolu_turnkey001',
|
|
405
442
|
tool_input: { description: 'Task with turn context', run_in_background: false },
|
|
406
443
|
}
|
|
407
|
-
|
|
408
444
|
const result = runHook(PRETOOL_SCRIPT, event)
|
|
409
445
|
expect(result.status).toBe(0)
|
|
410
446
|
|
|
@@ -414,18 +450,43 @@ describe('Bug 5 — parent_turn_key backfilled by gateway, not the hook', () =>
|
|
|
414
450
|
| undefined
|
|
415
451
|
|
|
416
452
|
expect(row).toBeDefined()
|
|
417
|
-
|
|
453
|
+
expect(row!.parent_turn_key).toBe(turnKey)
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
it('downgrades to NULL when the marker names a turn_key with no turns row (phantom-marker guard)', () => {
|
|
457
|
+
// The gateway writes the marker even if recordTurnStart's INSERT failed, so
|
|
458
|
+
// a marker can point at a turn_key with no row. Stamping it would mis-route
|
|
459
|
+
// the worker card AND block the watcher backfill (NULL guard). The hook must
|
|
460
|
+
// verify the row exists and fall back to NULL.
|
|
461
|
+
seedTurn('12345:_:1780000000000') // a DIFFERENT, real turn exists…
|
|
462
|
+
writeTurnActiveMarker('12345:_:9999999999999') // …but the marker names a phantom.
|
|
463
|
+
|
|
464
|
+
const event = {
|
|
465
|
+
session_id: 'sess-phantom',
|
|
466
|
+
tool_name: 'Agent',
|
|
467
|
+
tool_use_id: 'toolu_phantom001',
|
|
468
|
+
tool_input: { description: 'Task', run_in_background: false },
|
|
469
|
+
}
|
|
470
|
+
const result = runHook(PRETOOL_SCRIPT, event)
|
|
471
|
+
expect(result.status).toBe(0)
|
|
472
|
+
|
|
473
|
+
const db = openDb()
|
|
474
|
+
const row = db.prepare('SELECT parent_turn_key FROM subagents WHERE id = ?').get('toolu_phantom001') as
|
|
475
|
+
| { parent_turn_key: string | null }
|
|
476
|
+
| undefined
|
|
477
|
+
expect(row).toBeDefined()
|
|
418
478
|
expect(row!.parent_turn_key).toBeNull()
|
|
419
479
|
})
|
|
420
480
|
|
|
421
|
-
it('
|
|
481
|
+
it('writes parent_turn_key=NULL when no turn is active (gateway backfill fallback)', () => {
|
|
482
|
+
// No marker written → no active turn → hook leaves NULL and the gateway's
|
|
483
|
+
// started_at backfill remains the fallback (today's behaviour).
|
|
422
484
|
const event = {
|
|
423
485
|
session_id: 'sess-noturnkey',
|
|
424
486
|
tool_name: 'Agent',
|
|
425
487
|
tool_use_id: 'toolu_noturn001',
|
|
426
488
|
tool_input: { description: 'Task without turn context', run_in_background: false },
|
|
427
489
|
}
|
|
428
|
-
|
|
429
490
|
runHook(PRETOOL_SCRIPT, event)
|
|
430
491
|
|
|
431
492
|
const db = openDb()
|
|
@@ -434,7 +495,47 @@ describe('Bug 5 — parent_turn_key backfilled by gateway, not the hook', () =>
|
|
|
434
495
|
| undefined
|
|
435
496
|
|
|
436
497
|
expect(row).toBeDefined()
|
|
437
|
-
|
|
498
|
+
expect(row!.parent_turn_key).toBeNull()
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
it('ignores event.turn_id — only the marker is authoritative', () => {
|
|
502
|
+
// A future CLI populating event.turn_id must NOT be trusted: it is Claude
|
|
503
|
+
// Code's session turn, never a gateway turns.turn_key. With no marker the
|
|
504
|
+
// result is NULL regardless of turn_id.
|
|
505
|
+
const event = {
|
|
506
|
+
session_id: 'sess-turnid-only',
|
|
507
|
+
turn_id: 'turn-abc-001',
|
|
508
|
+
tool_name: 'Agent',
|
|
509
|
+
tool_use_id: 'toolu_turnid001',
|
|
510
|
+
tool_input: { description: 'Task', run_in_background: false },
|
|
511
|
+
}
|
|
512
|
+
runHook(PRETOOL_SCRIPT, event)
|
|
513
|
+
|
|
514
|
+
const db = openDb()
|
|
515
|
+
const row = db.prepare('SELECT parent_turn_key FROM subagents WHERE id = ?').get('toolu_turnid001') as
|
|
516
|
+
| { parent_turn_key: string | null }
|
|
517
|
+
| undefined
|
|
518
|
+
|
|
519
|
+
expect(row).toBeDefined()
|
|
520
|
+
expect(row!.parent_turn_key).toBeNull()
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
it('a malformed marker degrades to NULL (never crashes the dispatch)', () => {
|
|
524
|
+
writeFileSync(join(agentDir, 'telegram', 'turn-active.json'), '{ not valid json')
|
|
525
|
+
const event = {
|
|
526
|
+
session_id: 'sess-badmarker',
|
|
527
|
+
tool_name: 'Agent',
|
|
528
|
+
tool_use_id: 'toolu_badmarker001',
|
|
529
|
+
tool_input: { description: 'Task', run_in_background: false },
|
|
530
|
+
}
|
|
531
|
+
const result = runHook(PRETOOL_SCRIPT, event)
|
|
532
|
+
expect(result.status).toBe(0)
|
|
533
|
+
|
|
534
|
+
const db = openDb()
|
|
535
|
+
const row = db.prepare('SELECT parent_turn_key FROM subagents WHERE id = ?').get('toolu_badmarker001') as
|
|
536
|
+
| { parent_turn_key: string | null }
|
|
537
|
+
| undefined
|
|
538
|
+
expect(row).toBeDefined()
|
|
438
539
|
expect(row!.parent_turn_key).toBeNull()
|
|
439
540
|
})
|
|
440
541
|
|
|
@@ -344,6 +344,117 @@ describe('subagent-tracker-posttool', () => {
|
|
|
344
344
|
expect(row?.background).toBe(0)
|
|
345
345
|
expect(row?.status).toBe('completed')
|
|
346
346
|
})
|
|
347
|
+
|
|
348
|
+
// ─── async-launch ACK contract — drift tolerance (#2084) ────────────────────
|
|
349
|
+
// Tier 3 of isAsyncLaunchAck keys on the functional `agentId: <stem>` token
|
|
350
|
+
// (the most wording-stable part of the ACK) so promotion survives a
|
|
351
|
+
// claude-code bump that rewords BOTH the launch verb AND the "working in the
|
|
352
|
+
// background" phrase. The context-word requirement keeps it from tripping on
|
|
353
|
+
// a foreground report that merely mentions an agentId.
|
|
354
|
+
it('promotes on reworded ACK prose when the agentId token + a context word survive', () => {
|
|
355
|
+
runHook(PRETOOL_SCRIPT, {
|
|
356
|
+
session_id: 's-drift',
|
|
357
|
+
tool_name: 'Agent',
|
|
358
|
+
tool_use_id: 'toolu_drift1',
|
|
359
|
+
tool_input: { subagent_type: 'worker', description: 'Drifted ACK' },
|
|
360
|
+
})
|
|
361
|
+
// Neither "async agent launched" nor "working in the background" — a
|
|
362
|
+
// hypothetical reworded ACK — but the agentId token + "background" remain.
|
|
363
|
+
const postResult = runHook(POSTTOOL_SCRIPT, {
|
|
364
|
+
tool_name: 'Agent',
|
|
365
|
+
tool_use_id: 'toolu_drift1',
|
|
366
|
+
tool_response: {
|
|
367
|
+
content: [{ type: 'text', text: 'Background worker started.\nagentId: drift-7f3a91\nYou will be notified when it finishes.' }],
|
|
368
|
+
},
|
|
369
|
+
})
|
|
370
|
+
expect(postResult.status).toBe(0)
|
|
371
|
+
expect(postResult.stdout).not.toContain('additionalContext')
|
|
372
|
+
|
|
373
|
+
const db = openDb()
|
|
374
|
+
const row = db.prepare('SELECT background, status, ended_at FROM subagents WHERE id = ?').get('toolu_drift1') as
|
|
375
|
+
| { background: number; status: string; ended_at: number | null }
|
|
376
|
+
| undefined
|
|
377
|
+
expect(row?.background).toBe(1)
|
|
378
|
+
expect(row?.status).toBe('running')
|
|
379
|
+
expect(row?.ended_at == null).toBe(true)
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('does NOT promote when an agentId token appears without any launch/background context word', () => {
|
|
383
|
+
// A genuine foreground report mentioning an agentId in passing — no
|
|
384
|
+
// launch/background/async/dispatch word — must terminalize, not promote.
|
|
385
|
+
runHook(PRETOOL_SCRIPT, {
|
|
386
|
+
session_id: 's-falsepos',
|
|
387
|
+
tool_name: 'Agent',
|
|
388
|
+
tool_use_id: 'toolu_falsepos1',
|
|
389
|
+
tool_input: { subagent_type: 'worker', description: 'Foreground lookup', run_in_background: false },
|
|
390
|
+
})
|
|
391
|
+
const postResult = runHook(POSTTOOL_SCRIPT, {
|
|
392
|
+
tool_name: 'Agent',
|
|
393
|
+
tool_use_id: 'toolu_falsepos1',
|
|
394
|
+
tool_response: { result: 'Done. Verified the record; agentId: svc-42 is valid and active.', is_error: false },
|
|
395
|
+
})
|
|
396
|
+
expect(postResult.status).toBe(0)
|
|
397
|
+
expect(postResult.stdout).toContain('additionalContext')
|
|
398
|
+
|
|
399
|
+
const db = openDb()
|
|
400
|
+
const row = db.prepare('SELECT background, status FROM subagents WHERE id = ?').get('toolu_falsepos1') as
|
|
401
|
+
| { background: number; status: string }
|
|
402
|
+
| undefined
|
|
403
|
+
expect(row?.background).toBe(0)
|
|
404
|
+
expect(row?.status).toBe('completed')
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
it('does NOT promote a foreground orchestrator narrative that embeds an agentId mid-sentence', () => {
|
|
408
|
+
// The #2085-review false-positive: a coordinator agent reporting on other
|
|
409
|
+
// agents. The agentId is mid-sentence (not on its own line), so tier 3's
|
|
410
|
+
// own-line anchor rejects it even though "background"/"dispatching" appear.
|
|
411
|
+
runHook(PRETOOL_SCRIPT, {
|
|
412
|
+
session_id: 's-orch',
|
|
413
|
+
tool_name: 'Agent',
|
|
414
|
+
tool_use_id: 'toolu_orch1',
|
|
415
|
+
tool_input: { subagent_type: 'worker', description: 'Coordinator', run_in_background: false },
|
|
416
|
+
})
|
|
417
|
+
const postResult = runHook(POSTTOOL_SCRIPT, {
|
|
418
|
+
tool_name: 'Agent',
|
|
419
|
+
tool_use_id: 'toolu_orch1',
|
|
420
|
+
tool_response: { result: 'agentId: coord-x is managing background work and dispatching checks.', is_error: false },
|
|
421
|
+
})
|
|
422
|
+
expect(postResult.status).toBe(0)
|
|
423
|
+
expect(postResult.stdout).toContain('additionalContext')
|
|
424
|
+
|
|
425
|
+
const db = openDb()
|
|
426
|
+
const row = db.prepare('SELECT background, status FROM subagents WHERE id = ?').get('toolu_orch1') as
|
|
427
|
+
| { background: number; status: string }
|
|
428
|
+
| undefined
|
|
429
|
+
expect(row?.background).toBe(0)
|
|
430
|
+
expect(row?.status).toBe('completed')
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it('does NOT promote an own-line agentId with no launch/background context word (boundary)', () => {
|
|
434
|
+
// Documents the accepted tier-3 boundary: an own-line bare agentId alone
|
|
435
|
+
// (no background/launch/dispatch/async/notify word) is not enough to
|
|
436
|
+
// promote — guards a foreground report that prints an id on its own line.
|
|
437
|
+
runHook(PRETOOL_SCRIPT, {
|
|
438
|
+
session_id: 's-bound',
|
|
439
|
+
tool_name: 'Agent',
|
|
440
|
+
tool_use_id: 'toolu_bound1',
|
|
441
|
+
tool_input: { subagent_type: 'worker', description: 'Infra', run_in_background: false },
|
|
442
|
+
})
|
|
443
|
+
const postResult = runHook(POSTTOOL_SCRIPT, {
|
|
444
|
+
tool_name: 'Agent',
|
|
445
|
+
tool_use_id: 'toolu_bound1',
|
|
446
|
+
tool_response: { content: [{ type: 'text', text: 'Created the worker.\nagentId: svc-99\nIt is ready.' }] },
|
|
447
|
+
})
|
|
448
|
+
expect(postResult.status).toBe(0)
|
|
449
|
+
expect(postResult.stdout).toContain('additionalContext')
|
|
450
|
+
|
|
451
|
+
const db = openDb()
|
|
452
|
+
const row = db.prepare('SELECT background, status FROM subagents WHERE id = ?').get('toolu_bound1') as
|
|
453
|
+
| { background: number; status: string }
|
|
454
|
+
| undefined
|
|
455
|
+
expect(row?.background).toBe(0)
|
|
456
|
+
expect(row?.status).toBe('completed')
|
|
457
|
+
})
|
|
347
458
|
})
|
|
348
459
|
|
|
349
460
|
describe('agent-dir resolution (RFC §Bug 2)', () => {
|
|
@@ -153,3 +153,53 @@ describe('backfillJsonlAgentId — parent_turn_key resolution', () => {
|
|
|
153
153
|
expect(row?.parent_turn_key == null).toBe(true)
|
|
154
154
|
})
|
|
155
155
|
})
|
|
156
|
+
|
|
157
|
+
// ─── #2081: overlapping windows + hook-stamped value precedence ───────────────
|
|
158
|
+
// The backfill is now only a FALLBACK — the PreToolUse hook stamps
|
|
159
|
+
// parent_turn_key from the live turn-active marker at dispatch
|
|
160
|
+
// (subagent-tracker-pretool.mjs readActiveTurnKey). These tests pin the two
|
|
161
|
+
// guarantees that make the hook fix correct end-to-end.
|
|
162
|
+
describe('backfillJsonlAgentId — overlapping windows / hook precedence (#2081)', () => {
|
|
163
|
+
it('does NOT overwrite a hook-stamped parent_turn_key, even when overlapping windows would resolve differently', () => {
|
|
164
|
+
// Supergroup: two forum topics under one chat with OVERLAPPING windows.
|
|
165
|
+
// Topic A (thread 4) started first and is still open; topic B (thread 7)
|
|
166
|
+
// started later. A sub-agent dispatched at 1500 falls inside BOTH windows.
|
|
167
|
+
insertTurn({ turnKey: '-100:4:1000', chatId: '-100', threadId: '4', startedAt: 1000, endedAt: null })
|
|
168
|
+
insertTurn({ turnKey: '-100:7:1400', chatId: '-100', threadId: '7', startedAt: 1400, endedAt: null })
|
|
169
|
+
|
|
170
|
+
// The hook already stamped the CORRECT parent (topic A) from the marker.
|
|
171
|
+
insertSub({
|
|
172
|
+
id: 'toolu_overlap',
|
|
173
|
+
agentType: 'worker',
|
|
174
|
+
description: 'Topic-A worker',
|
|
175
|
+
startedAt: 1500,
|
|
176
|
+
parentTurnKey: '-100:4:1000',
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
const jsonlPath = writeMeta('worker', 'Topic-A worker')
|
|
180
|
+
backfillJsonlAgentId(db, jsonlPath, 'agentstem_overlap')
|
|
181
|
+
|
|
182
|
+
const row = readSub('toolu_overlap')
|
|
183
|
+
expect(row?.jsonl_agent_id).toBe('agentstem_overlap')
|
|
184
|
+
// The IS NULL guard means the hook's correct value survives — NOT the
|
|
185
|
+
// window query's ORDER BY started_at DESC pick (which would be topic B).
|
|
186
|
+
expect(row?.parent_turn_key).toBe('-100:4:1000')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('fallback window-match (NULL parent) picks the latest-started overlapping turn — the documented fallback limitation', () => {
|
|
190
|
+
// When the hook left parent_turn_key NULL (no active marker at dispatch),
|
|
191
|
+
// the backfill falls back to the started_at window match. With overlapping
|
|
192
|
+
// windows it resolves to the latest-started containing turn. This is a
|
|
193
|
+
// best-effort fallback for the no-marker case — the hook path above is the
|
|
194
|
+
// correct primary. Pinned here so the fallback behaviour is explicit.
|
|
195
|
+
insertTurn({ turnKey: '-100:4:1000', chatId: '-100', threadId: '4', startedAt: 1000, endedAt: null })
|
|
196
|
+
insertTurn({ turnKey: '-100:7:1400', chatId: '-100', threadId: '7', startedAt: 1400, endedAt: null })
|
|
197
|
+
insertSub({ id: 'toolu_fallback', agentType: 'worker', description: 'No-marker worker', startedAt: 1500 })
|
|
198
|
+
|
|
199
|
+
const jsonlPath = writeMeta('worker', 'No-marker worker')
|
|
200
|
+
backfillJsonlAgentId(db, jsonlPath, 'agentstem_fallback')
|
|
201
|
+
|
|
202
|
+
const row = readSub('toolu_fallback')
|
|
203
|
+
expect(row?.parent_turn_key).toBe('-100:7:1400')
|
|
204
|
+
})
|
|
205
|
+
})
|