dsh-switchman 1.3.1 → 1.6.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/README.de.md +56 -34
- package/README.es.md +53 -31
- package/README.fr.md +52 -30
- package/README.it.md +47 -25
- package/README.ja.md +54 -32
- package/README.ko.md +55 -33
- package/README.md +53 -31
- package/README.pt.md +47 -25
- package/README.ru.md +52 -30
- package/README.zh-TW.md +52 -30
- package/README.zh.md +46 -24
- package/client.js +2344 -40
- package/cordis.patch.yml +24 -8
- package/host/config.js +31 -0
- package/host/context-watch.js +424 -84
- package/host/dispatch.js +107 -6
- package/host/handover-state.js +38 -0
- package/host/lang.js +211 -36
- package/host/lib/dsh-home.js +18 -0
- package/host/routes.js +143 -8
- package/host/subagent-model.js +147 -0
- package/host/subagents.js +160 -0
- package/host/teams.js +583 -0
- package/host/ui-locale.js +35 -0
- package/host/vision.js +367 -0
- package/index.js +40 -8
- package/package.json +1 -1
- package/skills/db-query/package.json +1 -1
- package/skills/db-query/scripts/lib/cli.js +23 -23
- package/skills/db-query/scripts/lib/config.js +78 -61
- package/skills/db-query/scripts/lib/mysql-readonly.js +167 -158
- package/skills/db-query/scripts/lib/output.js +56 -55
- package/skills/db-query/scripts/lib/redis-readonly.js +116 -111
- package/skills/db-query/scripts/mysql-query.js +46 -42
- package/skills/db-query/scripts/redis-query.js +32 -29
- package/skills/db-query/scripts/setup.sh +16 -16
package/host/context-watch.js
CHANGED
|
@@ -18,14 +18,24 @@
|
|
|
18
18
|
* compaction only inside each preset's isolated per-agent group
|
|
19
19
|
* (isolate: { compaction: true }), so no root-level inject or lookup can
|
|
20
20
|
* reach it — the borrowed handler's closure carries the right composition.
|
|
21
|
-
*
|
|
22
|
-
* plus a 10-minute cooldown
|
|
23
|
-
* flow: fork a dormant backup
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
21
|
+
* Every handover trigger — the force-tier automation (fire-and-forget,
|
|
22
|
+
* session-level in-flight flag plus a 10-minute cooldown) and manual
|
|
23
|
+
* /ctx-handover — shares one four-step flow: fork a dormant backup
|
|
24
|
+
* session (subagents' fork provider, economy pool model); then, on
|
|
25
|
+
* every compaction attempt, re-enumerate the live dispatched
|
|
26
|
+
* subagents and refresh the handover document as the very last
|
|
27
|
+
* action before compacting via the borrowed per-agent /compact
|
|
28
|
+
* (retried on a growing backoff until the agent goes idle, because
|
|
29
|
+
* the automation fires mid-turn); after compaction settles, diff the
|
|
30
|
+
* enumeration once more and append anything that materialized during
|
|
31
|
+
* the window; finally wake this session (agent.followup +
|
|
32
|
+
* sessions.flush, the schedule service's delivery channel) to fill in
|
|
33
|
+
* the document and continue the unfinished task, with two post-wake
|
|
34
|
+
* rechecks announcing late-materializing dispatches.
|
|
35
|
+
* The live handover phase feeds the force-tier banner so progress is
|
|
36
|
+
* visible instead of silent. /ctx-pause and /ctx-resume toggle
|
|
37
|
+
* process-lifetime pause state (a restart resumes enforcement — the
|
|
38
|
+
* safe side).
|
|
29
39
|
*
|
|
30
40
|
* All session state is in-process Maps; every path is fail-open (a failing
|
|
31
41
|
* measurement or missing service degrades the banner to "" or the gate to
|
|
@@ -33,8 +43,10 @@
|
|
|
33
43
|
*/
|
|
34
44
|
|
|
35
45
|
import { randomUUID } from "node:crypto";
|
|
36
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
37
|
-
import { join } from "node:path";
|
|
46
|
+
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
47
|
+
import { dirname, join } from "node:path";
|
|
48
|
+
import { clearHandover, handoverOf, recordHandover } from "./handover-state.js";
|
|
49
|
+
import { enumerateLiveSubagents, sessionRecordPath, sessionRecordsDir } from "./subagents.js";
|
|
38
50
|
|
|
39
51
|
/** Tools whose result text is accounted against the per-turn read budget. */
|
|
40
52
|
const ACCOUNT_TOOLS = new Set(["read", "glob", "grep", "bash"]);
|
|
@@ -140,8 +152,9 @@ function measureNow(ctx, agent) {
|
|
|
140
152
|
|
|
141
153
|
// ---- banner rendering (pure given state + measurement) ---------------------
|
|
142
154
|
|
|
143
|
-
/** Root-session banner: byte-stable anchor line + tier advice lines
|
|
144
|
-
|
|
155
|
+
/** Root-session banner: byte-stable anchor line + tier advice lines plus
|
|
156
|
+
* the live handover progress (phase/attempt) and auto-cooldown state. */
|
|
157
|
+
function renderRootBanner(level, used, th, status) {
|
|
145
158
|
const head =
|
|
146
159
|
`[SWITCHMAN:WATERMARK] level=${level} used=${kilo(used)}` +
|
|
147
160
|
` budget=${kilo(th.soft)}/${kilo(th.hard)}/${kilo(th.force)}`;
|
|
@@ -153,11 +166,22 @@ function renderRootBanner(level, used, th, autoHandover) {
|
|
|
153
166
|
if (level === "hard") lines.push(`hard tier: stop broad reading (read/glob/grep are budget-limited this turn) and start wrapping up.`);
|
|
154
167
|
if (level === "force") {
|
|
155
168
|
lines.push(`force tier: MANDATORY wrap-up now — produce the final answer from what you already hold.`);
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
169
|
+
const phase = status?.phase;
|
|
170
|
+
if (phase === "backup") {
|
|
171
|
+
lines.push(`context handover running: forking the backup session and writing the handover document…`);
|
|
172
|
+
} else if (phase === "compacting") {
|
|
173
|
+
lines.push(`context handover running: compaction attempt ${status?.attempt ?? "?"} (retries while the turn is still running)…`);
|
|
174
|
+
} else if (phase === "continuation") {
|
|
175
|
+
lines.push(`context handover completing: waking this session to fill in the document and continue the task…`);
|
|
176
|
+
} else if (status?.autoHandover) {
|
|
177
|
+
lines.push(
|
|
178
|
+
status?.cooldownMin > 0
|
|
179
|
+
? `automatic handover cooldown: ${status.cooldownMin} min left; run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to force one now.`
|
|
180
|
+
: `an automatic context handover (backup + document + compaction + continuation) is armed and will fire at the next step boundary${status?.tool ? "; calling the ctx_handover tool triggers it immediately" : ""}.`,
|
|
181
|
+
);
|
|
182
|
+
} else {
|
|
183
|
+
lines.push(`run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to trigger a manual context handover.`);
|
|
184
|
+
}
|
|
161
185
|
}
|
|
162
186
|
return lines.join("\n");
|
|
163
187
|
}
|
|
@@ -199,7 +223,15 @@ function renderBanner(ctx, config, state, agent) {
|
|
|
199
223
|
return renderSubBanner(used, subTh);
|
|
200
224
|
}
|
|
201
225
|
const level = state.paused.has(sessionId) ? "paused" : levelOf(used, th);
|
|
202
|
-
|
|
226
|
+
const live = handoverOf(sessionId);
|
|
227
|
+
const elapsed = Date.now() - (state.lastHandover.get(sessionId) ?? 0);
|
|
228
|
+
return renderRootBanner(level, used, th, {
|
|
229
|
+
autoHandover: readBool(config.wmAutoHandover, true),
|
|
230
|
+
phase: live?.phase,
|
|
231
|
+
attempt: live?.attempt,
|
|
232
|
+
cooldownMin: Math.max(0, Math.ceil((HANDOVER_COOLDOWN_MS - elapsed) / 60_000)),
|
|
233
|
+
tool: state.handoverTool ?? undefined,
|
|
234
|
+
});
|
|
203
235
|
} catch {
|
|
204
236
|
return "";
|
|
205
237
|
}
|
|
@@ -260,6 +292,7 @@ function applyContextWatch(ctx, config) {
|
|
|
260
292
|
const state = {
|
|
261
293
|
paused: new Set(),
|
|
262
294
|
inflight: new Set(),
|
|
295
|
+
handoverTool: null,
|
|
263
296
|
lastHandover: new Map(),
|
|
264
297
|
readUsed: new Map(),
|
|
265
298
|
};
|
|
@@ -282,46 +315,32 @@ function applyContextWatch(ctx, config) {
|
|
|
282
315
|
// ctx.commands.find(agent, "compact") merges that agent's scoped layer and
|
|
283
316
|
// returns the definition whose handler closure already carries the right
|
|
284
317
|
// composition context with compaction injected.
|
|
285
|
-
|
|
286
|
-
|
|
318
|
+
/** Find the agent's scoped /compact definition without invoking it. */
|
|
319
|
+
const compactDefinition = (agent) => {
|
|
287
320
|
try {
|
|
288
|
-
definition = ctx.commands?.find?.(agent, "compact");
|
|
321
|
+
const definition = ctx.commands?.find?.(agent, "compact");
|
|
322
|
+
return typeof definition?.handler === "function" ? definition : undefined;
|
|
289
323
|
} catch {
|
|
290
|
-
|
|
324
|
+
return undefined;
|
|
291
325
|
}
|
|
292
|
-
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const sessionCompact = (agent, commandId, signal) => {
|
|
329
|
+
const definition = compactDefinition(agent);
|
|
330
|
+
if (definition === undefined) return undefined;
|
|
293
331
|
const invocation = Object.freeze({
|
|
294
332
|
commandId,
|
|
295
333
|
agent,
|
|
296
334
|
rawInput: "",
|
|
297
335
|
attachments: Object.freeze([]),
|
|
298
|
-
|
|
336
|
+
// The borrowed /compact handler (and compactNow behind it) touches
|
|
337
|
+
// signal.throwIfAborted()/aborted unconditionally; the auto path
|
|
338
|
+
// has no real signal, so always carry a fresh un-aborted one.
|
|
339
|
+
signal: signal ?? new AbortController().signal,
|
|
299
340
|
});
|
|
300
341
|
return Promise.resolve(definition.handler(invocation));
|
|
301
342
|
};
|
|
302
343
|
|
|
303
|
-
const startHandover = (agent, sessionId) => {
|
|
304
|
-
state.inflight.add(sessionId);
|
|
305
|
-
let promise;
|
|
306
|
-
try {
|
|
307
|
-
promise = sessionCompact(agent, `switchman-auto-${sessionId}`, undefined);
|
|
308
|
-
if (promise === undefined)
|
|
309
|
-
warn(`auto handover found no per-agent /compact command for session ${sessionId} — nothing to do`);
|
|
310
|
-
} catch (error) {
|
|
311
|
-
warn(`handover failed to start: ${error?.message ?? error}`);
|
|
312
|
-
promise = undefined;
|
|
313
|
-
}
|
|
314
|
-
const settle = () => state.inflight.delete(sessionId);
|
|
315
|
-
if (promise !== undefined && typeof promise.then === "function") {
|
|
316
|
-
promise.then(
|
|
317
|
-
() => ctx.logger?.info?.(`dsh-switchman: context handover settled for session ${sessionId}`),
|
|
318
|
-
(error) => warn(`context handover failed for session ${sessionId}: ${error?.message ?? error}`),
|
|
319
|
-
).then(settle, settle);
|
|
320
|
-
} else {
|
|
321
|
-
settle();
|
|
322
|
-
}
|
|
323
|
-
};
|
|
324
|
-
|
|
325
344
|
/** First {provider, model} route of a volatile pool field, or null. */
|
|
326
345
|
const firstPoolRoute = (field) => {
|
|
327
346
|
const raw = readValue(field);
|
|
@@ -374,29 +393,67 @@ function applyContextWatch(ctx, config) {
|
|
|
374
393
|
}
|
|
375
394
|
};
|
|
376
395
|
|
|
377
|
-
/**
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
|
|
381
|
-
* session exposes no usable cwd or the write fails. */
|
|
382
|
-
const writeHandoverSkeleton = (agent, sessionId, backupId, used) => {
|
|
396
|
+
/** One handover-document path per handover run (stamp frozen at run
|
|
397
|
+
* start so refreshes overwrite the same file). Null when the session
|
|
398
|
+
* exposes no usable cwd. */
|
|
399
|
+
const handoverDocPath = (agent, sessionId) => {
|
|
383
400
|
const cwd = agent?.session?.header?.cwd;
|
|
384
401
|
if (typeof cwd !== "string" || cwd === "") return null;
|
|
385
402
|
const stamp = new Date().toISOString().replace(/[:.]/gu, "-");
|
|
386
|
-
const directory = join(cwd, ".dsh-switchman", "handover");
|
|
387
403
|
const short = sessionId.replace(/^session-/u, "").slice(0, 8) || sessionId;
|
|
388
|
-
|
|
404
|
+
return join(cwd, ".dsh-switchman", "handover", `${stamp}-${short}.md`);
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
/** Write (or refresh) the handover document as the LAST action before
|
|
408
|
+
* a compaction attempt — enumerate, write, compact, nothing mutating
|
|
409
|
+
* in between. The old flow wrote the skeleton once, up front, and
|
|
410
|
+
* then sat through the idle-retry loop while the triggering turn
|
|
411
|
+
* kept executing: dispatches issued in that window MATERIALIZED into
|
|
412
|
+
* the catalog only around the post-compaction resume, invisible to
|
|
413
|
+
* both the old snapshot and the compacted agent, which then
|
|
414
|
+
* re-dispatched live work (incident 2026-10-05). Refreshing on every
|
|
415
|
+
* attempt keeps the document truthful at the moment compaction
|
|
416
|
+
* actually lands while still capturing state strictly BEFORE
|
|
417
|
+
* compaction destroys it. `live` entries are { id, label, mode,
|
|
418
|
+
* state } from enumerateLiveSubagents. Returns the path on success,
|
|
419
|
+
* null on failure (fail-open: never blocks compaction). */
|
|
420
|
+
const writeHandoverDoc = (path, sessionId, backupId, used, live) => {
|
|
421
|
+
if (path === null) return null;
|
|
422
|
+
const liveSection =
|
|
423
|
+
Array.isArray(live) && live.length > 0
|
|
424
|
+
? [
|
|
425
|
+
`## Live background subagents (captured immediately before the compaction attempt — still alive)`,
|
|
426
|
+
``,
|
|
427
|
+
...live.map(
|
|
428
|
+
(child) =>
|
|
429
|
+
`- \`${child.id}\`${child.label ? ` — ${child.label}` : ""}${child.mode ? ` (${child.mode})` : ""} — ${child.state === "running" ? "RUNNING" : "PENDING (dispatched, no open step yet)"}. Collect its report when it settles (in-session completion notice, or read its session record at ${sessionRecordPath(child.id)}) before re-dispatching anything similar — do not duplicate live work.`,
|
|
430
|
+
),
|
|
431
|
+
`How to address and re-check them:`,
|
|
432
|
+
``,
|
|
433
|
+
`- These are subagent children, not teammates. While the Agent-Teams bundle is active, send_message({ target }) resolves teammate names only — it will always answer "active teammate ... not found" for these ids. That error says nothing about the child's health: do not conclude it is dead, and do not re-dispatch while it is alive.`,
|
|
434
|
+
`- Re-check liveness against the child's record JSON (exact paths — note the leading record.): running iff record.rows.sessionStats.val.openStep !== null; settled (nothing pending) iff record.rows.turnBoundary.val.lastStepBoundary.kind === "end"; anything else with an existing record is pending. One-liner: python3 -c 'import json;d=json.load(open("<DSH_HOME>/storages/session_projcache/sessions/<childId>.json"))["record"]["rows"];print("running" if (d.get("sessionStats") or {}).get("val",{}).get("openStep") else ("settled" if (((d.get("turnBoundary") or {}).get("val") or {}).get("lastStepBoundary") or {}).get("kind")=="end" else "pending"))'`,
|
|
435
|
+
`- The catalog record.rows.subagentCatalog.val.head.values of THIS session lists every child ever dispatched from it (ids, labels, childCreatedAt timestamps). Entries younger than the handover time are dispatches the pre-compaction captures could not see — treat them as your own live work, never as candidates for re-dispatch.`,
|
|
436
|
+
`- Report tail: <DSH_HOME>/sessions/<workspace-slug>/<childId>/session.v4.jsonl.zstd (zstd-compressed JSONL; the workspace slug is the dash-encoded session cwd). The last assistant/message records hold interim and final reports.`,
|
|
437
|
+
``,
|
|
438
|
+
]
|
|
439
|
+
: [
|
|
440
|
+
`## Live background subagents`,
|
|
441
|
+
``,
|
|
442
|
+
`- None live at the last capture. If the compaction summary mentions dispatched subagents anyway, they materialized after it: re-check the session catalog (record.rows.subagentCatalog) before re-dispatching anything.`,
|
|
443
|
+
``,
|
|
444
|
+
];
|
|
389
445
|
try {
|
|
390
|
-
mkdirSync(
|
|
446
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
391
447
|
writeFileSync(
|
|
392
448
|
path,
|
|
393
449
|
[
|
|
394
450
|
`# SWITCHMAN handover — ${sessionId}`,
|
|
395
451
|
``,
|
|
396
|
-
`- handed over at: ${new Date().toISOString()}`,
|
|
452
|
+
`- handed over at: ${new Date().toISOString()} (document refreshed before every compaction attempt)`,
|
|
397
453
|
`- context used before compaction: ~${used ?? "unknown"} tokens`,
|
|
398
454
|
`- backup fork session: ${backupId ?? "(fork failed — the full history remains in this session's append-only log)"}`,
|
|
399
455
|
``,
|
|
456
|
+
...liveSection,
|
|
400
457
|
`## Task state (filled in by the agent right after the handover)`,
|
|
401
458
|
``,
|
|
402
459
|
`- goal / objective in flight:`,
|
|
@@ -415,24 +472,106 @@ function applyContextWatch(ctx, config) {
|
|
|
415
472
|
}
|
|
416
473
|
};
|
|
417
474
|
|
|
418
|
-
/**
|
|
475
|
+
/** One-shot delayed re-checks after the continuation is armed. A
|
|
476
|
+
* dispatch from the pre-handover turn can materialize in the very
|
|
477
|
+
* instants around the post-compaction resume — after the final
|
|
478
|
+
* enumeration but before the woken agent looks. Two cheap probes
|
|
479
|
+
* (20 s / 75 s) compare the catalog against everything already
|
|
480
|
+
* announced; anything new is appended to the handover document AND
|
|
481
|
+
* pushed to the session as a followup so the agent learns its
|
|
482
|
+
* deferred dispatch exists before it can re-dispatch. Best-effort:
|
|
483
|
+
* process-lifetime timers, fail-open, never throws. */
|
|
484
|
+
const scheduleMaterializationRecheck = (agent, sessionId, backupId, docFile, known) => {
|
|
485
|
+
const knownIds = new Set([backupId ?? "", ...(known ?? []).map((child) => child.id)]);
|
|
486
|
+
const check = async (tag) => {
|
|
487
|
+
try {
|
|
488
|
+
const fresh = (await enumerateLiveSubagents(ctx, sessionId)).filter((child) => !knownIds.has(child.id));
|
|
489
|
+
if (fresh.length === 0) return;
|
|
490
|
+
for (const child of fresh) knownIds.add(child.id);
|
|
491
|
+
const lines = fresh.map(
|
|
492
|
+
(child) =>
|
|
493
|
+
`${child.id}${child.label ? ` (${child.label})` : ""} [${child.state}] → record ${sessionRecordPath(child.id)}`,
|
|
494
|
+
);
|
|
495
|
+
if (docFile !== null) {
|
|
496
|
+
try {
|
|
497
|
+
appendFileSync(
|
|
498
|
+
docFile,
|
|
499
|
+
[
|
|
500
|
+
``,
|
|
501
|
+
`## Post-wake refresh (${new Date().toISOString()})`,
|
|
502
|
+
``,
|
|
503
|
+
`- ${fresh.length} dispatched subagent(s) materialized AFTER the wake-time enumeration:`,
|
|
504
|
+
...lines.map((line) => `- ${line}`),
|
|
505
|
+
`- These are pre-handover dispatches of this same session, now alive. Collect their results; do NOT re-dispatch.`,
|
|
506
|
+
``,
|
|
507
|
+
].join("\n"),
|
|
508
|
+
"utf8",
|
|
509
|
+
);
|
|
510
|
+
} catch {
|
|
511
|
+
/* document append is best-effort */
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
agent.followup({
|
|
515
|
+
id: randomUUID(),
|
|
516
|
+
role: "user",
|
|
517
|
+
content: [
|
|
518
|
+
{
|
|
519
|
+
type: "text",
|
|
520
|
+
text: `[SWITCHMAN:HANDOVER-REFRESH] ${fresh.length} dispatched subagent(s) materialized after the handover snapshot — dispatched before the wake or by the just-woken agent itself — either way live work of this session the handover document could not have listed: ${lines.join("; ")}. They are ALIVE. Collect their results (each record path is listed; a "running" child will send a completion notice, a "pending" one has not started its first turn yet); do NOT re-dispatch this work.`,
|
|
521
|
+
},
|
|
522
|
+
],
|
|
523
|
+
source: { kind: "user" },
|
|
524
|
+
});
|
|
525
|
+
await ctx.sessions?.flush?.(agent.session);
|
|
526
|
+
} catch (error) {
|
|
527
|
+
warn(`handover materialization recheck (${tag}) failed: ${error?.message ?? error}`);
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
setTimeout(() => void check("20s"), 20_000);
|
|
531
|
+
setTimeout(() => void check("75s"), 75_000);
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
/** Manual handover final step — wake the freshly compacted session: one
|
|
419
535
|
* followup user message (the same delivery channel the schedule service
|
|
420
|
-
* uses: agent.followup + sessions.flush)
|
|
536
|
+
* uses: agent.followup + sessions.flush) instructs the agent to fill
|
|
421
537
|
* in the handover document from the compaction summary now in context
|
|
422
|
-
* and continue the unfinished task.
|
|
423
|
-
|
|
538
|
+
* and continue the unfinished task. `live` is the post-compaction
|
|
539
|
+
* enumeration; `appeared` are its members that were NOT in the last
|
|
540
|
+
* pre-compaction capture — dispatches from the triggering turn that
|
|
541
|
+
* only materialized during the compaction window, called out so the
|
|
542
|
+
* agent can never mistake them for un-dispatched work. */
|
|
543
|
+
const armContinuation = async (agent, backupId, docFile, live, appeared) => {
|
|
424
544
|
if (typeof agent?.followup !== "function") {
|
|
425
545
|
warn("handover continuation skipped: agent exposes no followup()");
|
|
426
546
|
return;
|
|
427
547
|
}
|
|
548
|
+
const liveCount = Array.isArray(live) ? live.length : 0;
|
|
549
|
+
const appearedCount = Array.isArray(appeared) ? appeared.length : 0;
|
|
550
|
+
const liveSentence =
|
|
551
|
+
liveCount === 0
|
|
552
|
+
? `No dispatched background subagents were live when the compaction landed.`
|
|
553
|
+
: docFile !== null
|
|
554
|
+
? `${liveCount} dispatched background subagent(s) are LIVE and listed in the handover document — collect their results (in-session completion notices, or their session records) instead of re-dispatching duplicate work. The document explains the exact liveness re-check (record.rows paths plus a paste-ready one-liner) and each transcript tail; a send_message "active teammate not found" error is NOT evidence of death.`
|
|
555
|
+
: `${liveCount} dispatched background subagent(s) are LIVE (the handover document could not be written; inline list): ${live
|
|
556
|
+
.map(
|
|
557
|
+
(child) =>
|
|
558
|
+
`${child.id}${child.label ? ` (${child.label})` : ""} [${child.state}] → record ${sessionRecordPath(child.id)}`,
|
|
559
|
+
)
|
|
560
|
+
.join("; ")} — collect their results instead of re-dispatching duplicate work (records live under ${sessionRecordsDir()}/<childId>.json).`;
|
|
561
|
+
const appearedSentence =
|
|
562
|
+
appearedCount > 0
|
|
563
|
+
? `${appearedCount} of them were dispatched before the handover but only MATERIALIZED during the compaction window (catalog entries stamped late) — they are this session's own deferred dispatches, alive; collect them, never re-dispatch their tasks.`
|
|
564
|
+
: `If the compaction summary mentions dispatched subagents that are NOT listed above, they materialized after this enumeration — a post-wake recheck will announce them within seconds; do not re-dispatch anything before it fires.`;
|
|
428
565
|
const instruction = [
|
|
429
566
|
"[SWITCHMAN:HANDOVER] A context handover just completed for this session.",
|
|
430
567
|
backupId !== null
|
|
431
568
|
? `A dormant fork backup holding the full pre-handover state is session ${backupId}.`
|
|
432
569
|
: `No fork backup was created; the append-only session log still holds the full history.`,
|
|
433
|
-
|
|
434
|
-
? `The handover document
|
|
570
|
+
docFile !== null
|
|
571
|
+
? `The handover document is at ${docFile}.`
|
|
435
572
|
: `Create the handover document under .dsh-switchman/handover/ in the working directory.`,
|
|
573
|
+
liveSentence,
|
|
574
|
+
appearedSentence,
|
|
436
575
|
"Using the compaction summary now in your context, fill in the handover document (goal, completed, in progress, next steps, files), then continue the unfinished task — do not wait for further instructions.",
|
|
437
576
|
].join(" ");
|
|
438
577
|
try {
|
|
@@ -448,46 +587,188 @@ function applyContextWatch(ctx, config) {
|
|
|
448
587
|
}
|
|
449
588
|
};
|
|
450
589
|
|
|
451
|
-
/**
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
|
|
590
|
+
/** The shared four-step handover (see the header comment), used by
|
|
591
|
+
* manual /ctx-handover and the force-tier automation alike. The
|
|
592
|
+
* compaction step retries on a growing backoff: both triggers usually
|
|
593
|
+
* fire mid-turn, the runMaintenance behind the borrowed /compact only
|
|
594
|
+
* accepts an IDLE agent, and "busy" arrives as a RESOLVED
|
|
595
|
+
* {kind:"error"} (not a rejection) — so both branches funnel into
|
|
596
|
+
* retryOrFail. Once the agent goes idle the compact lands before the
|
|
597
|
+
* next turn's driver (maintenance latches wake requests by design).
|
|
598
|
+
* The per-session phase map feeds the force-tier banner so the wait is
|
|
599
|
+
* visible instead of silent. */
|
|
600
|
+
const runHandover = async (agent, sessionId, commandId, signal, source = "manual") => {
|
|
460
601
|
if (sessionId === null || agent?.session === undefined)
|
|
461
602
|
return { kind: "error", text: "/ctx-handover: no active session" };
|
|
462
603
|
if (state.inflight.has(sessionId))
|
|
463
604
|
return { kind: "success", text: "[SWITCHMAN:WATERMARK] a handover is already in progress for this session." };
|
|
464
|
-
|
|
465
|
-
if (borrowed === undefined)
|
|
605
|
+
if (compactDefinition(agent) === undefined)
|
|
466
606
|
return {
|
|
467
607
|
kind: "error",
|
|
468
608
|
text: "/ctx-handover: this session's preset exposes no /compact command, so no compaction backend is reachable.",
|
|
469
609
|
};
|
|
470
610
|
state.inflight.add(sessionId);
|
|
471
611
|
try {
|
|
612
|
+
recordHandover(sessionId, { phase: "backup", source, at: Date.now() });
|
|
472
613
|
const backup = await forkBackup(agent, sessionId, signal);
|
|
473
614
|
const backupId = backup.childId;
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
615
|
+
// One document path per handover run (the stamp freezes here so
|
|
616
|
+
// refreshes overwrite the same file). The document itself is
|
|
617
|
+
// written as the LAST action before every compaction attempt —
|
|
618
|
+
// see attempt() below. Writing it here and then sitting through
|
|
619
|
+
// the idle-retry loop is exactly how the 2026-10-05 incident
|
|
620
|
+
// lost deferred dispatches: the triggering turn kept executing
|
|
621
|
+
// after the snapshot, and its subagent dispatches materialized
|
|
622
|
+
// into the catalog only around the post-compaction resume.
|
|
623
|
+
const docPath = handoverDocPath(agent, sessionId);
|
|
624
|
+
let docFile = null; // last successful document write
|
|
625
|
+
let lastLive = []; // last pre-compaction enumeration
|
|
626
|
+
const delays = [5_000, 15_000, 30_000, 60_000, 120_000, 300_000];
|
|
627
|
+
const outcome = await new Promise((resolve) => {
|
|
628
|
+
let attempts = 0;
|
|
629
|
+
let attempting = false;
|
|
630
|
+
let timer = null;
|
|
631
|
+
let watchdog = null;
|
|
632
|
+
let finished = false;
|
|
633
|
+
let offStatus = null;
|
|
634
|
+
const finish = (ok, reason) => {
|
|
635
|
+
if (finished) return;
|
|
636
|
+
finished = true;
|
|
637
|
+
if (timer !== null) clearTimeout(timer);
|
|
638
|
+
if (watchdog !== null) clearTimeout(watchdog);
|
|
639
|
+
if (offStatus !== null) {
|
|
640
|
+
try {
|
|
641
|
+
offStatus();
|
|
642
|
+
} catch {
|
|
643
|
+
/* best-effort listener dispose */
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
resolve({ ok, reason });
|
|
647
|
+
};
|
|
648
|
+
const attempt = async () => {
|
|
649
|
+
if (finished || attempting) return;
|
|
650
|
+
attempting = true;
|
|
651
|
+
attempts += 1;
|
|
652
|
+
if (timer !== null) {
|
|
653
|
+
clearTimeout(timer);
|
|
654
|
+
timer = null;
|
|
655
|
+
}
|
|
656
|
+
recordHandover(sessionId, { phase: "compacting", source, attempt: attempts, at: Date.now() });
|
|
657
|
+
// The LAST pre-compaction action: re-enumerate and (re)write
|
|
658
|
+
// the handover document, then immediately compact — nothing
|
|
659
|
+
// mutating in between. On retries this re-captures anything
|
|
660
|
+
// the triggering turn did while the loop waited for the
|
|
661
|
+
// agent to go idle; the document is therefore truthful at
|
|
662
|
+
// the exact moment compaction lands, yet still strictly
|
|
663
|
+
// pre-compaction state.
|
|
664
|
+
try {
|
|
665
|
+
lastLive = await enumerateLiveSubagents(ctx, sessionId);
|
|
666
|
+
docFile = writeHandoverDoc(docPath, sessionId, backupId, measureNow(ctx, agent), lastLive) ?? docFile;
|
|
667
|
+
} catch {
|
|
668
|
+
/* enumeration and the document must never block compaction */
|
|
669
|
+
}
|
|
670
|
+
let promise;
|
|
671
|
+
try {
|
|
672
|
+
promise = sessionCompact(agent, commandId ?? `switchman-${source}-${sessionId}`, signal);
|
|
673
|
+
} catch (error) {
|
|
674
|
+
attempting = false;
|
|
675
|
+
finish(false, `handover failed to start: ${error?.message ?? error}`);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (promise === undefined) {
|
|
679
|
+
attempting = false;
|
|
680
|
+
finish(false, "the per-agent /compact command disappeared");
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const settleAttempt = (ok, reason) => {
|
|
684
|
+
attempting = false;
|
|
685
|
+
if (ok) {
|
|
686
|
+
finish(true);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (attempts <= delays.length) timer = setTimeout(attempt, delays[attempts - 1]);
|
|
690
|
+
else finish(false, String(reason));
|
|
691
|
+
};
|
|
692
|
+
promise.then(
|
|
693
|
+
(value) => {
|
|
694
|
+
if (value !== null && typeof value === "object" && value.kind === "error") {
|
|
695
|
+
settleAttempt(false, value.text ?? "compact returned an error result");
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
settleAttempt(true);
|
|
699
|
+
},
|
|
700
|
+
(error) => settleAttempt(false, error?.message ?? String(error)),
|
|
701
|
+
);
|
|
702
|
+
};
|
|
703
|
+
// Promptness: the agent/status idle transition (the same event
|
|
704
|
+
// the official compaction layer uses for overflow-retry
|
|
705
|
+
// cleanup) attempts the moment the turn really ends; the
|
|
706
|
+
// timers above stay as the backstop for missed or filtered
|
|
707
|
+
// events. Status reads "idle" externally while our own
|
|
708
|
+
// maintenance runs, and the `attempting` latch collapses
|
|
709
|
+
// those re-entrant events.
|
|
710
|
+
try {
|
|
711
|
+
offStatus = ctx.on("agent/status", (payload) => {
|
|
712
|
+
if (payload?.agent === agent && payload?.status === "idle") attempt();
|
|
713
|
+
});
|
|
714
|
+
} catch {
|
|
715
|
+
/* timer-only fallback */
|
|
716
|
+
}
|
|
717
|
+
// Watchdog: a borrowed /compact that never settles would hold
|
|
718
|
+
// the in-flight latch (and this listener) forever; 20 min is
|
|
719
|
+
// far beyond any real compaction (~2 min observed) yet finite.
|
|
720
|
+
watchdog = setTimeout(
|
|
721
|
+
() => finish(false, "handover watchdog timeout — the borrowed /compact never settled"),
|
|
722
|
+
20 * 60_000,
|
|
723
|
+
);
|
|
724
|
+
attempt();
|
|
725
|
+
});
|
|
726
|
+
if (!outcome.ok)
|
|
477
727
|
return {
|
|
478
728
|
kind: "error",
|
|
479
|
-
text: `/ctx-handover: compaction failed after backup ${backupId ?? "n/a"} — ${
|
|
729
|
+
text: `/ctx-handover: compaction failed after backup ${backupId ?? "n/a"} — ${outcome.reason}`,
|
|
480
730
|
};
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
731
|
+
recordHandover(sessionId, { phase: "continuation", source, at: Date.now() });
|
|
732
|
+
// Final enumeration right after compaction settled: anything that
|
|
733
|
+
// materialized during the compaction window (the deferred-dispatch
|
|
734
|
+
// race the document refresh cannot fully close) is announced in
|
|
735
|
+
// the wake instruction and appended to the document.
|
|
736
|
+
const live = await enumerateLiveSubagents(ctx, sessionId);
|
|
737
|
+
const knownIds = new Set([...lastLive.map((child) => child.id), backupId ?? ""]);
|
|
738
|
+
const appeared = live.filter((child) => !knownIds.has(child.id));
|
|
739
|
+
if (docFile !== null && appeared.length > 0) {
|
|
740
|
+
try {
|
|
741
|
+
appendFileSync(
|
|
742
|
+
docFile,
|
|
743
|
+
[
|
|
744
|
+
``,
|
|
745
|
+
`## Materialized during the compaction window (${new Date().toISOString()})`,
|
|
746
|
+
``,
|
|
747
|
+
`- ${appeared.length} dispatched subagent(s) appeared after the last pre-compaction capture:`,
|
|
748
|
+
...appeared.map(
|
|
749
|
+
(child) =>
|
|
750
|
+
`- \`${child.id}\`${child.label ? ` — ${child.label}` : ""} — ${child.state === "running" ? "RUNNING" : "PENDING"} → record ${sessionRecordPath(child.id)}`,
|
|
751
|
+
),
|
|
752
|
+
`- Dispatched before the handover, materialized late — this session's own work, alive. Collect their results; do NOT re-dispatch.`,
|
|
753
|
+
``,
|
|
754
|
+
].join("\n"),
|
|
755
|
+
"utf8",
|
|
756
|
+
);
|
|
757
|
+
} catch {
|
|
758
|
+
/* append is best-effort; the wake instruction lists them too */
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
await armContinuation(agent, backupId, docFile, live, appeared);
|
|
762
|
+
scheduleMaterializationRecheck(agent, sessionId, backupId, docFile, live);
|
|
763
|
+
const backupText = backupId ?? `n/a (${backup.reason ?? "log retains full history"})`;
|
|
484
764
|
return {
|
|
485
765
|
kind: "success",
|
|
486
|
-
text: `[SWITCHMAN:WATERMARK] handover complete — backup ${backupText}, document ${
|
|
766
|
+
text: `[SWITCHMAN:WATERMARK] handover complete — backup ${backupText}, document ${docFile ?? "agent-created"}, compaction done, continuation armed.`,
|
|
487
767
|
};
|
|
488
768
|
} catch (error) {
|
|
489
769
|
return { kind: "error", text: `/ctx-handover failed: ${error?.message ?? error}` };
|
|
490
770
|
} finally {
|
|
771
|
+
clearHandover(sessionId);
|
|
491
772
|
state.inflight.delete(sessionId);
|
|
492
773
|
}
|
|
493
774
|
};
|
|
@@ -504,7 +785,13 @@ function applyContextWatch(ctx, config) {
|
|
|
504
785
|
const used = measureNow(ctx, agent);
|
|
505
786
|
if (used === null || levelOf(used, readThresholds(config)) !== "force") return;
|
|
506
787
|
state.lastHandover.set(sessionId, now);
|
|
507
|
-
|
|
788
|
+
runHandover(agent, sessionId, `switchman-auto-${sessionId}`, undefined, "auto").then(
|
|
789
|
+
(result) =>
|
|
790
|
+
ctx.logger?.[result?.kind === "error" ? "warn" : "info"]?.(
|
|
791
|
+
`dsh-switchman: ${result?.text ?? "handover ended"}`,
|
|
792
|
+
),
|
|
793
|
+
(error) => warn(`auto handover crashed: ${error?.message ?? error}`),
|
|
794
|
+
);
|
|
508
795
|
} catch {
|
|
509
796
|
/* fail-open: the banner still reports force */
|
|
510
797
|
}
|
|
@@ -611,10 +898,63 @@ function applyContextWatch(ctx, config) {
|
|
|
611
898
|
});
|
|
612
899
|
ctx.commands.register({
|
|
613
900
|
name: "ctx-handover",
|
|
614
|
-
description: "Trigger a
|
|
901
|
+
description: "Trigger a full context handover for this session now (backup fork + document + compaction + continuation).",
|
|
615
902
|
handler: (invocation) =>
|
|
616
|
-
|
|
903
|
+
runHandover(invocation?.agent, sessionIdOf(invocation?.agent), invocation?.commandId, invocation?.signal, "manual"),
|
|
617
904
|
});
|
|
905
|
+
|
|
906
|
+
// --- E. ctx_handover: the agent-callable front door to the same
|
|
907
|
+
// four-step handover. The definition uses the register-level shape —
|
|
908
|
+
// bare ctx.tools.register does not run defineTool's DSL compilation,
|
|
909
|
+
// so parameters is a full object-root JSON Schema — and registers
|
|
910
|
+
// best-effort: a validation rejection only skips the tool, never the
|
|
911
|
+
// plugin. execute returns
|
|
912
|
+
// fast on purpose — awaiting the compaction here would keep the agent
|
|
913
|
+
// busy and starve the idle-only /compact — so the flow runs detached
|
|
914
|
+
// and the result tells the model to end its turn immediately.
|
|
915
|
+
try {
|
|
916
|
+
ctx.tools?.register?.({
|
|
917
|
+
name: "ctx_handover",
|
|
918
|
+
description:
|
|
919
|
+
"Trigger a full dsh-switchman context handover for this session NOW: fork a dormant backup session, write the handover document, compact this session at the idle boundary, then wake it to fill in the document and continue the task. Call it when the watermark banner reaches the force tier or the user asks for a handover, then END the turn immediately.",
|
|
920
|
+
parameters: {
|
|
921
|
+
type: "object",
|
|
922
|
+
properties: {
|
|
923
|
+
reason: {
|
|
924
|
+
type: "string",
|
|
925
|
+
description: "Why the handover is triggered now (e.g. 'force tier watermark reached', 'user request'). Optional.",
|
|
926
|
+
},
|
|
927
|
+
},
|
|
928
|
+
},
|
|
929
|
+
output: {
|
|
930
|
+
schema: { type: "string" },
|
|
931
|
+
render: (_args, value) => [{ type: "text", text: value }],
|
|
932
|
+
},
|
|
933
|
+
async execute(_args, exec) {
|
|
934
|
+
const agent = exec?.agent;
|
|
935
|
+
const sessionId = sessionIdOf(agent);
|
|
936
|
+
// No exec.signal here on purpose: the compact must outlive the
|
|
937
|
+
// calling turn, and a turn-scoped AbortSignal could abort it.
|
|
938
|
+
runHandover(agent, sessionId, `switchman-tool-${sessionId ?? "session"}`, undefined, "tool").then(
|
|
939
|
+
(result) =>
|
|
940
|
+
ctx.logger?.[result?.kind === "error" ? "warn" : "info"]?.(
|
|
941
|
+
`dsh-switchman: ${result?.text ?? "handover ended"}`,
|
|
942
|
+
),
|
|
943
|
+
(error) => warn(`ctx_handover handover crashed: ${error?.message ?? error}`),
|
|
944
|
+
);
|
|
945
|
+
return "[SWITCHMAN:WATERMARK] ctx_handover scheduled: backup fork + handover document + compaction + continuation are running. END this turn now — reply briefly and call no more tools, so the compaction lands at the idle boundary. Your closing line must tell the user the handover is running in the background and will continue automatically (the session-header badge shows the live state) — otherwise the GUI looks idle during fork+compact.";
|
|
946
|
+
},
|
|
947
|
+
presentCall: (args) => ({
|
|
948
|
+
card: "generic",
|
|
949
|
+
title: "Switchman context handover",
|
|
950
|
+
kind: "other",
|
|
951
|
+
rawInput: args,
|
|
952
|
+
}),
|
|
953
|
+
});
|
|
954
|
+
state.handoverTool = "ctx_handover";
|
|
955
|
+
} catch (error) {
|
|
956
|
+
warn(`ctx_handover tool registration failed: ${error?.message ?? error}`);
|
|
957
|
+
}
|
|
618
958
|
}
|
|
619
959
|
|
|
620
960
|
export { applyContextWatch };
|