dsh-switchman 1.5.0 → 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 +45 -55
- package/README.es.md +21 -31
- package/README.fr.md +23 -33
- package/README.it.md +23 -33
- package/README.ja.md +42 -52
- package/README.ko.md +43 -53
- package/README.md +41 -51
- package/README.pt.md +23 -33
- package/README.ru.md +21 -31
- package/README.zh-TW.md +35 -45
- package/README.zh.md +25 -35
- package/client.js +1464 -43
- package/host/config.js +8 -0
- package/host/context-watch.js +197 -56
- package/host/routes.js +6 -2
- package/host/subagents.js +86 -70
- package/host/teams.js +160 -7
- 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/config.js
CHANGED
|
@@ -46,6 +46,14 @@ export const EffortEntrySchema = z.object({
|
|
|
46
46
|
|
|
47
47
|
/** All dsh-switchman settings. Field names are the settings mutation paths. */
|
|
48
48
|
export const Config = z.object({
|
|
49
|
+
// --- Phase 0: UI locale ------------------------------------------------
|
|
50
|
+
/** Display language for this plugin's own UI (badge, panel, settings
|
|
51
|
+
* page): "auto" follows the DSH app language; any other value forces
|
|
52
|
+
* the plugin UI into that locale (extra locales resolve
|
|
53
|
+
* client-side from the inline EXTRA_UI_DICTS table, falling back
|
|
54
|
+
* to English). */
|
|
55
|
+
uiLocale: z.union(["auto", "en", "zh", "zh-TW", "ja", "ko", "de", "es", "fr", "it", "pt", "ru"]).default("auto").volatile(),
|
|
56
|
+
|
|
49
57
|
// --- Phase 1: project language preference -----------------------------
|
|
50
58
|
/** Where the three language slots live: "global" = these profile-level
|
|
51
59
|
* fields below; "project" = each project's .switchman/lang.json (per
|
package/host/context-watch.js
CHANGED
|
@@ -21,12 +21,17 @@
|
|
|
21
21
|
* Every handover trigger — the force-tier automation (fire-and-forget,
|
|
22
22
|
* session-level in-flight flag plus a 10-minute cooldown) and manual
|
|
23
23
|
* /ctx-handover — shares one four-step flow: fork a dormant backup
|
|
24
|
-
* session (subagents' fork provider, economy pool model),
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* (
|
|
29
|
-
*
|
|
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.
|
|
30
35
|
* The live handover phase feeds the force-tier banner so progress is
|
|
31
36
|
* visible instead of silent. /ctx-pause and /ctx-resume toggle
|
|
32
37
|
* process-lifetime pause state (a restart resumes enforcement — the
|
|
@@ -38,10 +43,10 @@
|
|
|
38
43
|
*/
|
|
39
44
|
|
|
40
45
|
import { randomUUID } from "node:crypto";
|
|
41
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
42
|
-
import { join } from "node:path";
|
|
46
|
+
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
47
|
+
import { dirname, join } from "node:path";
|
|
43
48
|
import { clearHandover, handoverOf, recordHandover } from "./handover-state.js";
|
|
44
|
-
import {
|
|
49
|
+
import { enumerateLiveSubagents, sessionRecordPath, sessionRecordsDir } from "./subagents.js";
|
|
45
50
|
|
|
46
51
|
/** Tools whose result text is accounted against the per-turn read budget. */
|
|
47
52
|
const ACCOUNT_TOOLS = new Set(["read", "glob", "grep", "bash"]);
|
|
@@ -388,46 +393,67 @@ function applyContextWatch(ctx, config) {
|
|
|
388
393
|
}
|
|
389
394
|
};
|
|
390
395
|
|
|
391
|
-
/**
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
|
|
395
|
-
* session exposes no usable cwd or the write fails. Any still-running
|
|
396
|
-
* background subagents are recorded too: compaction orphans them from
|
|
397
|
-
* every plugin-visible registry, so without this section the compacted
|
|
398
|
-
* agent cannot know its dispatched work is alive (and double-dispatches).
|
|
399
|
-
* */
|
|
400
|
-
const writeHandoverSkeleton = (agent, sessionId, backupId, used, running) => {
|
|
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) => {
|
|
401
400
|
const cwd = agent?.session?.header?.cwd;
|
|
402
401
|
if (typeof cwd !== "string" || cwd === "") return null;
|
|
403
402
|
const stamp = new Date().toISOString().replace(/[:.]/gu, "-");
|
|
404
|
-
const directory = join(cwd, ".dsh-switchman", "handover");
|
|
405
403
|
const short = sessionId.replace(/^session-/u, "").slice(0, 8) || sessionId;
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
|
409
424
|
? [
|
|
410
|
-
`##
|
|
425
|
+
`## Live background subagents (captured immediately before the compaction attempt — still alive)`,
|
|
411
426
|
``,
|
|
412
|
-
...
|
|
427
|
+
...live.map(
|
|
413
428
|
(child) =>
|
|
414
|
-
`- \`${child.id}\`${child.label ? ` — ${child.label}` : ""}${child.mode ? ` (${child.mode})` : ""} —
|
|
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.`,
|
|
415
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.`,
|
|
416
437
|
``,
|
|
417
438
|
]
|
|
418
|
-
: [
|
|
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
|
+
];
|
|
419
445
|
try {
|
|
420
|
-
mkdirSync(
|
|
446
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
421
447
|
writeFileSync(
|
|
422
448
|
path,
|
|
423
449
|
[
|
|
424
450
|
`# SWITCHMAN handover — ${sessionId}`,
|
|
425
451
|
``,
|
|
426
|
-
`- handed over at: ${new Date().toISOString()}`,
|
|
452
|
+
`- handed over at: ${new Date().toISOString()} (document refreshed before every compaction attempt)`,
|
|
427
453
|
`- context used before compaction: ~${used ?? "unknown"} tokens`,
|
|
428
454
|
`- backup fork session: ${backupId ?? "(fork failed — the full history remains in this session's append-only log)"}`,
|
|
429
455
|
``,
|
|
430
|
-
...
|
|
456
|
+
...liveSection,
|
|
431
457
|
`## Task state (filled in by the agent right after the handover)`,
|
|
432
458
|
``,
|
|
433
459
|
`- goal / objective in flight:`,
|
|
@@ -446,37 +472,106 @@ function applyContextWatch(ctx, config) {
|
|
|
446
472
|
}
|
|
447
473
|
};
|
|
448
474
|
|
|
449
|
-
/**
|
|
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
|
|
450
535
|
* followup user message (the same delivery channel the schedule service
|
|
451
|
-
* uses: agent.followup + sessions.flush)
|
|
536
|
+
* uses: agent.followup + sessions.flush) instructs the agent to fill
|
|
452
537
|
* in the handover document from the compaction summary now in context
|
|
453
|
-
* and continue the unfinished task.
|
|
454
|
-
|
|
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) => {
|
|
455
544
|
if (typeof agent?.followup !== "function") {
|
|
456
545
|
warn("handover continuation skipped: agent exposes no followup()");
|
|
457
546
|
return;
|
|
458
547
|
}
|
|
459
|
-
const
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
|
466
556
|
.map(
|
|
467
557
|
(child) =>
|
|
468
|
-
`${child.id}${child.label ? ` (${child.label})` : ""} → record ${sessionRecordPath(child.id)}`,
|
|
558
|
+
`${child.id}${child.label ? ` (${child.label})` : ""} [${child.state}] → record ${sessionRecordPath(child.id)}`,
|
|
469
559
|
)
|
|
470
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.`;
|
|
471
565
|
const instruction = [
|
|
472
566
|
"[SWITCHMAN:HANDOVER] A context handover just completed for this session.",
|
|
473
567
|
backupId !== null
|
|
474
568
|
? `A dormant fork backup holding the full pre-handover state is session ${backupId}.`
|
|
475
569
|
: `No fork backup was created; the append-only session log still holds the full history.`,
|
|
476
|
-
|
|
477
|
-
? `The handover document
|
|
570
|
+
docFile !== null
|
|
571
|
+
? `The handover document is at ${docFile}.`
|
|
478
572
|
: `Create the handover document under .dsh-switchman/handover/ in the working directory.`,
|
|
479
|
-
|
|
573
|
+
liveSentence,
|
|
574
|
+
appearedSentence,
|
|
480
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.",
|
|
481
576
|
].join(" ");
|
|
482
577
|
try {
|
|
@@ -517,14 +612,17 @@ function applyContextWatch(ctx, config) {
|
|
|
517
612
|
recordHandover(sessionId, { phase: "backup", source, at: Date.now() });
|
|
518
613
|
const backup = await forkBackup(agent, sessionId, signal);
|
|
519
614
|
const backupId = backup.childId;
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
// the
|
|
526
|
-
|
|
527
|
-
|
|
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
|
|
528
626
|
const delays = [5_000, 15_000, 30_000, 60_000, 120_000, 300_000];
|
|
529
627
|
const outcome = await new Promise((resolve) => {
|
|
530
628
|
let attempts = 0;
|
|
@@ -547,7 +645,7 @@ function applyContextWatch(ctx, config) {
|
|
|
547
645
|
}
|
|
548
646
|
resolve({ ok, reason });
|
|
549
647
|
};
|
|
550
|
-
const attempt = () => {
|
|
648
|
+
const attempt = async () => {
|
|
551
649
|
if (finished || attempting) return;
|
|
552
650
|
attempting = true;
|
|
553
651
|
attempts += 1;
|
|
@@ -556,6 +654,19 @@ function applyContextWatch(ctx, config) {
|
|
|
556
654
|
timer = null;
|
|
557
655
|
}
|
|
558
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
|
+
}
|
|
559
670
|
let promise;
|
|
560
671
|
try {
|
|
561
672
|
promise = sessionCompact(agent, commandId ?? `switchman-${source}-${sessionId}`, signal);
|
|
@@ -618,11 +729,41 @@ function applyContextWatch(ctx, config) {
|
|
|
618
729
|
text: `/ctx-handover: compaction failed after backup ${backupId ?? "n/a"} — ${outcome.reason}`,
|
|
619
730
|
};
|
|
620
731
|
recordHandover(sessionId, { phase: "continuation", source, at: Date.now() });
|
|
621
|
-
|
|
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);
|
|
622
763
|
const backupText = backupId ?? `n/a (${backup.reason ?? "log retains full history"})`;
|
|
623
764
|
return {
|
|
624
765
|
kind: "success",
|
|
625
|
-
text: `[SWITCHMAN:WATERMARK] handover complete — backup ${backupText}, document ${
|
|
766
|
+
text: `[SWITCHMAN:WATERMARK] handover complete — backup ${backupText}, document ${docFile ?? "agent-created"}, compaction done, continuation armed.`,
|
|
626
767
|
};
|
|
627
768
|
} catch (error) {
|
|
628
769
|
return { kind: "error", text: `/ctx-handover failed: ${error?.message ?? error}` };
|
package/host/routes.js
CHANGED
|
@@ -81,14 +81,14 @@ const NUMBER_FIELDS = [
|
|
|
81
81
|
const BOOLEAN_FIELDS = [
|
|
82
82
|
"wmAutoHandover",
|
|
83
83
|
"wmSubagentCap",
|
|
84
|
-
//
|
|
84
|
+
// The following six mirror the pool*Manual switches in host/config.js
|
|
85
85
|
"poolEconomyManual",
|
|
86
86
|
"poolMechanicalManual",
|
|
87
87
|
"poolMainManual",
|
|
88
88
|
"poolHardManual",
|
|
89
89
|
"poolVisionManual",
|
|
90
90
|
"poolReviewManual",
|
|
91
|
-
//
|
|
91
|
+
// Teams switches (mirrors the teams* group in host/config.js)
|
|
92
92
|
"teamsMode",
|
|
93
93
|
"syncWhitelist",
|
|
94
94
|
];
|
|
@@ -102,6 +102,7 @@ const EFFORTS_FIELDS = [
|
|
|
102
102
|
"poolReviewEfforts",
|
|
103
103
|
];
|
|
104
104
|
const ENUM_FIELDS = {
|
|
105
|
+
uiLocale: ["auto", "en", "zh", "zh-TW", "ja", "ko", "de", "es", "fr", "it", "pt", "ru"],
|
|
105
106
|
langScope: ["global", "project"],
|
|
106
107
|
dispatchEnforce: ["off", "advice", "enforce"],
|
|
107
108
|
wmDenyMode: ["cap", "deny"],
|
|
@@ -246,6 +247,7 @@ export function snapshotOf(config) {
|
|
|
246
247
|
values[field] = Array.isArray(rawOf(config?.[field]))
|
|
247
248
|
? rawOf(config[field]).map(cleanEffortEntry).filter(Boolean)
|
|
248
249
|
: [];
|
|
250
|
+
values.uiLocale = ENUM_FIELDS.uiLocale.includes(rawOf(config?.uiLocale)) ? rawOf(config.uiLocale) : "auto";
|
|
249
251
|
values.langScope = ENUM_FIELDS.langScope.includes(rawOf(config?.langScope)) ? rawOf(config.langScope) : "global";
|
|
250
252
|
values.dispatchEnforce = ENUM_FIELDS.dispatchEnforce.includes(rawOf(config?.dispatchEnforce))
|
|
251
253
|
? rawOf(config.dispatchEnforce)
|
|
@@ -294,6 +296,8 @@ function coerceValues(input) {
|
|
|
294
296
|
if (efforts.some((entry) => entry === null)) return null;
|
|
295
297
|
values[field] = efforts;
|
|
296
298
|
}
|
|
299
|
+
if (!ENUM_FIELDS.uiLocale.includes(input.uiLocale)) return null;
|
|
300
|
+
values.uiLocale = input.uiLocale;
|
|
297
301
|
if (!ENUM_FIELDS.langScope.includes(input.langScope)) return null;
|
|
298
302
|
values.langScope = input.langScope;
|
|
299
303
|
if (!ENUM_FIELDS.dispatchEnforce.includes(input.dispatchEnforce)) return null;
|
package/host/subagents.js
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
|
-
/** Enumeration of this session's
|
|
1
|
+
/** Enumeration of this session's dispatched background subagents.
|
|
2
2
|
*
|
|
3
3
|
* Why this exists: a context handover (compaction) preserves dispatched
|
|
4
|
-
* background subagents — they keep running
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* background subagents — they keep running. The parent's job registries
|
|
5
|
+
* never list continuable children, and while the Agent-Teams bundle is
|
|
6
|
+
* active send_message resolves teammate names only, so subagent-child
|
|
7
|
+
* ids cannot be addressed mid-run. An agent that mistakes "not
|
|
8
|
+
* addressable" for "dead" then double-dispatches. Recording the live
|
|
9
|
+
* children — plus how to read their state — into the handover document
|
|
10
|
+
* closes that gap.
|
|
11
|
+
*
|
|
12
|
+
* Redesign after the 2026-10-05 double-dispatch incident: dispatches
|
|
13
|
+
* issued in the turn that triggered the handover MATERIALIZE only when
|
|
14
|
+
* the session machinery resumes — the catalog entry and the child's
|
|
15
|
+
* session record are stamped minutes after the dispatching tool call,
|
|
16
|
+
* which was after the handover's own snapshot in the old flow. Neither
|
|
17
|
+
* the sanctioned registry nor the disk catalog could have listed them
|
|
18
|
+
* at snapshot time; the fix is timing (context-watch.js re-enumerates
|
|
19
|
+
* after compaction settles, immediately before waking the session) plus
|
|
20
|
+
* semantics: enumeration now answers "what dispatched work exists",
|
|
21
|
+
* never filters on a single "running" flag. A dispatched child whose
|
|
22
|
+
* record exists but has neither an open step nor a definite end
|
|
23
|
+
* boundary is PENDING work (queued / provisioning / interrupted) —
|
|
24
|
+
* exactly the state the incident's children were missed in.
|
|
9
25
|
*
|
|
10
26
|
* Disk format (verified against a packaged host's live records under
|
|
11
27
|
* <DSH_HOME>/storages/session_projcache/sessions/):
|
|
@@ -14,14 +30,25 @@
|
|
|
14
30
|
* prefix) -> { record: { rows: { subagentCatalog: { val: { head:
|
|
15
31
|
* { values: [{ childId, childCreatedAt, mode, label }, ...] } } } } } }
|
|
16
32
|
* — the append-only catalog of every subagent dispatched from the
|
|
17
|
-
* session (labels are the dispatch `description`)
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
33
|
+
* session (labels are the dispatch `description`); entries appear
|
|
34
|
+
* when the child MATERIALIZES, not when the tool call is made.
|
|
35
|
+
* - Child record `<childId>.json` (bare UUID) — the same rows map:
|
|
36
|
+
* running iff record.rows.sessionStats.val.openStep is neither null
|
|
37
|
+
* nor missing;
|
|
38
|
+
* settled (delivered/aborted, nothing pending) iff
|
|
39
|
+
* record.rows.turnBoundary.val.lastStepBoundary.kind === "end";
|
|
40
|
+
* anything else with an existing record is pending.
|
|
22
41
|
*
|
|
23
42
|
* Every failure mode is fail-open to [] — enumeration is advisory
|
|
24
43
|
* context for the handover document, never a blocker. */
|
|
44
|
+
|
|
45
|
+
/** Child states, in rough order of "how much work is outstanding".
|
|
46
|
+
* - "running": an open step exists right now.
|
|
47
|
+
* - "pending": dispatched (record exists) but no definite end — covers
|
|
48
|
+
* queued/provisioning children and ones interrupted mid-flight.
|
|
49
|
+
* - "settled": last step boundary is a definite end; the child
|
|
50
|
+
* delivered (or aborted) and holds no pending work.
|
|
51
|
+
* - "gone": no record — never materialized or pruned; not live work. */
|
|
25
52
|
import { readFileSync } from "node:fs";
|
|
26
53
|
import { join } from "node:path";
|
|
27
54
|
import { dshHome } from "./lib/dsh-home.js";
|
|
@@ -61,84 +88,73 @@ function recordPaths(directory, id) {
|
|
|
61
88
|
return [...names].map((name) => join(directory, name));
|
|
62
89
|
}
|
|
63
90
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
91
|
+
/** First parseable rows map for a session id, or null. */
|
|
92
|
+
function rowsOf(directory, id) {
|
|
93
|
+
return recordPaths(directory, id).map((path) => readRows(path)).find((rows) => rows !== null) ?? null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Disk state of one child from its rows map (see the state table in the
|
|
97
|
+
* header). A null record means "gone". */
|
|
98
|
+
function childDiskState(childRows) {
|
|
99
|
+
if (childRows === null) return "gone";
|
|
100
|
+
if (childRows.sessionStats?.val?.openStep != null) return "running";
|
|
101
|
+
if (childRows.turnBoundary?.val?.lastStepBoundary?.kind === "end") return "settled";
|
|
102
|
+
return "pending";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Every cataloged child of a session with its disk state:
|
|
106
|
+
* [{ id, label, mode, childCreatedAt, state }]. The catalog is the
|
|
107
|
+
* authoritative "was ever dispatched" list; states come from each
|
|
108
|
+
* child's own record. Empty on any error — never throws. */
|
|
109
|
+
export function catalogChildrenOf(sessionId) {
|
|
70
110
|
if (typeof sessionId !== "string" || sessionId === "") return [];
|
|
71
111
|
const directory = sessionRecordsDir();
|
|
72
|
-
const parentRows =
|
|
73
|
-
|
|
74
|
-
.find((rows) => rows !== null);
|
|
75
|
-
const catalog = parentRows?.subagentCatalog?.val;
|
|
76
|
-
const entries = catalog?.head?.values;
|
|
112
|
+
const parentRows = rowsOf(directory, sessionId);
|
|
113
|
+
const entries = parentRows?.subagentCatalog?.val?.head?.values;
|
|
77
114
|
if (!Array.isArray(entries)) return [];
|
|
78
|
-
const
|
|
115
|
+
const children = [];
|
|
79
116
|
for (const entry of entries) {
|
|
80
117
|
if (entry === null || typeof entry !== "object") continue;
|
|
81
118
|
const childId = entry.childId;
|
|
82
119
|
if (typeof childId !== "string" || childId === "") continue;
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
.find((rows) => rows !== null);
|
|
86
|
-
if (childRows === null) continue; // never created, or pruned: not live work
|
|
87
|
-
if (childRows.sessionStats?.val?.openStep == null) continue; // settled
|
|
88
|
-
running.push({
|
|
120
|
+
const state = childDiskState(rowsOf(directory, childId));
|
|
121
|
+
children.push({
|
|
89
122
|
id: childId,
|
|
90
123
|
label: typeof entry.label === "string" ? entry.label : "",
|
|
91
124
|
mode: typeof entry.mode === "string" ? entry.mode : "",
|
|
125
|
+
childCreatedAt: typeof entry.childCreatedAt === "number" ? entry.childCreatedAt : null,
|
|
126
|
+
state,
|
|
92
127
|
});
|
|
93
128
|
}
|
|
94
|
-
return
|
|
129
|
+
return children;
|
|
95
130
|
}
|
|
96
131
|
|
|
97
|
-
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
export async function
|
|
132
|
+
/** Live (unresolved) dispatched children of a session as
|
|
133
|
+
* [{ id, label, mode, childCreatedAt, state }|{…, state: "running"}] —
|
|
134
|
+
* running OR pending, disk truth first. The sanctioned agents registry,
|
|
135
|
+
* when reachable, only ever UPGRADES a pending child to "running" (its
|
|
136
|
+
* live view has no flush lag); it can never demote disk truth, because
|
|
137
|
+
* a status vocabulary mismatch is precisely what missed the incident's
|
|
138
|
+
* queued children. Never throws. */
|
|
139
|
+
export async function enumerateLiveSubagents(ctx, sessionId) {
|
|
105
140
|
if (typeof sessionId !== "string" || sessionId === "") return [];
|
|
141
|
+
let live;
|
|
142
|
+
try {
|
|
143
|
+
live = catalogChildrenOf(sessionId).filter((child) => child.state === "running" || child.state === "pending");
|
|
144
|
+
} catch {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
if (live.length === 0) return [];
|
|
106
148
|
try {
|
|
107
|
-
const subagents = typeof ctx?.get === "function" ? ctx.get("subagents") : null;
|
|
108
149
|
const agents = typeof ctx?.get === "function" ? ctx.get("agents") : null;
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
typeof agents.get === "function"
|
|
114
|
-
) {
|
|
115
|
-
const candidates = new Set([sessionId]);
|
|
116
|
-
const bare = sessionId.replace(/^session-/u, "");
|
|
117
|
-
if (bare !== sessionId) candidates.add(bare);
|
|
118
|
-
for (const id of candidates) {
|
|
119
|
-
let children;
|
|
120
|
-
try {
|
|
121
|
-
children = await subagents.listChildren(id);
|
|
122
|
-
} catch {
|
|
123
|
-
continue; // this id form is unusable — probe the next one
|
|
124
|
-
}
|
|
125
|
-
if (!Array.isArray(children)) continue;
|
|
126
|
-
const running = [];
|
|
127
|
-
for (const child of children) {
|
|
128
|
-
if (child === null || typeof child !== "object") continue;
|
|
129
|
-
if (typeof child.id !== "string" || child.id === "") continue;
|
|
130
|
-
if (agents.get(child.id)?.status !== "running") continue;
|
|
131
|
-
running.push({
|
|
132
|
-
id: child.id,
|
|
133
|
-
label: typeof child.label === "string" ? child.label : "",
|
|
134
|
-
mode: typeof child.mode === "string" ? child.mode : "",
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
return running; // first id form that answers wins, even when empty
|
|
150
|
+
if (agents !== null && typeof agents.get === "function") {
|
|
151
|
+
for (const child of live) {
|
|
152
|
+
if (child.state !== "pending") continue;
|
|
153
|
+
if (agents.get(child.id)?.status === "running") child.state = "running";
|
|
138
154
|
}
|
|
139
155
|
}
|
|
140
156
|
} catch {
|
|
141
|
-
/*
|
|
157
|
+
/* registry overlay is best-effort */
|
|
142
158
|
}
|
|
143
|
-
return
|
|
159
|
+
return live;
|
|
144
160
|
}
|