dsh-switchman 1.8.1 → 1.9.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.
@@ -0,0 +1,168 @@
1
+ /** dsh-switchman context-trim fork-state inheritance.
2
+ *
3
+ * When a session is forked (subagent_fork tool, or the handover's backup
4
+ * fork through the subagents service), the child conversation STARTS as a
5
+ * content copy of the parent's history — including every range the parent
6
+ * already folded. Without inheritance the child re-derives its ledger from
7
+ * scratch and re-folds (or never folds) ranges the parent already settled,
8
+ * and its first outbound view shows raw history.
9
+ *
10
+ * Copy-on-fork, in two halves:
11
+ *
12
+ * - Linkage (fork time): the parent→child edge is recorded the moment the
13
+ * fork is observed, into <stateDir>/fork-links.json (one bounded JSON
14
+ * map, atomic tmp+rename writes). Two call sites observe forks: the
15
+ * tools/post-execute listener on the subagent_fork tool (index.js) and
16
+ * the handover backup fork (context-watch.js forkBackup). Refs are
17
+ * CONTENT-anchored (view.js refOf), so a link only stores session ids.
18
+ * - Inheritance (child first sight): when the child's history first flows
19
+ * through the llm/stream view builder, the parent ledger is cloned into
20
+ * the child's ledger — but ONLY when the parent ledger exists AND the
21
+ * child's history actually resolves the parent's frontier ref. A stale
22
+ * inherited frontier that resolves nothing is worse than no inheritance
23
+ * (the frontier anchors fold monotonicity), so a failed guard starts the
24
+ * child clean. One inheritance per child: the adopted ledger carries an
25
+ * inheritedFrom/inheritedAt marker and adoptLedger (state.js) refuses to
26
+ * overwrite a ledger that already has blocks or a marker — idempotent
27
+ * across restarts because the marker persists with the ledger.
28
+ *
29
+ * Fail-open everywhere: a lost link, an unreadable map, or a rejected
30
+ * guard only downgrades to a clean start (or a later attempt inside the
31
+ * bounded window) — never a broken session start.
32
+ */
33
+
34
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
35
+ import { join } from "node:path";
36
+ import { dshHome } from "../lib/dsh-home.js";
37
+ import { LEDGER_VERSION } from "./state.js";
38
+ import { refPositions } from "./view.js";
39
+
40
+ /** Current fork-links file format version. */
41
+ export const FORK_LINKS_VERSION = 1;
42
+
43
+ /** The link map stays bounded: at most this many child entries. */
44
+ export const FORK_LINKS_MAX = 500;
45
+
46
+ /** Link entries older than this are pruned on write. */
47
+ export const FORK_LINKS_MAX_AGE_MS = 30 * 24 * 3_600_000;
48
+
49
+ /** How long a child stays eligible for inheritance while no link (or no
50
+ * parent folds) has appeared yet — covers the fork race where the child's
51
+ * first model call beats the parent's post-execute bookkeeping or the
52
+ * parent's first idle-boundary fold. */
53
+ export const INHERIT_WINDOW_MS = 60_000;
54
+
55
+ /** Default state dir (same resolution as createStore in state.js). */
56
+ function defaultStateDir() {
57
+ return join(dshHome(), ".dsh-switchman", "state", "trim");
58
+ }
59
+
60
+ /** Config field reader for trimStateDir (plain string or {get()} accessor,
61
+ * matching the volatile-settings pattern). Null when unset. */
62
+ export function trimStateDirOf(config) {
63
+ try {
64
+ const value = config?.trimStateDir;
65
+ const got = value !== null && typeof value === "object" && typeof value.get === "function" ? value.get() : value;
66
+ if (typeof got === "string" && got !== "") return got;
67
+ } catch {
68
+ /* fall through */
69
+ }
70
+ return null;
71
+ }
72
+
73
+ function linksFileOf(stateDir) {
74
+ return join(stateDir ?? defaultStateDir(), "fork-links.json");
75
+ }
76
+
77
+ /** Load the fork-links map (tolerant: any structural surprise yields an
78
+ * empty map — a lost link only downgrades to a clean start). */
79
+ export function loadForkLinks(stateDir, warn = () => {}) {
80
+ const file = linksFileOf(stateDir);
81
+ try {
82
+ if (!existsSync(file)) return { version: FORK_LINKS_VERSION, links: {} };
83
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
84
+ if (parsed === null || typeof parsed !== "object" || parsed.version !== FORK_LINKS_VERSION || parsed.links === null || typeof parsed.links !== "object") {
85
+ warn("[context-trim] fork-links map has an unknown format — ignoring");
86
+ return { version: FORK_LINKS_VERSION, links: {} };
87
+ }
88
+ const links = {};
89
+ for (const [childId, entry] of Object.entries(parsed.links)) {
90
+ if (typeof childId !== "string" || childId === "") continue;
91
+ if (entry === null || typeof entry !== "object" || typeof entry.parent !== "string" || entry.parent === "") continue;
92
+ links[childId] = { parent: entry.parent, at: Number.isFinite(entry.at) ? entry.at : 0 };
93
+ }
94
+ return { version: FORK_LINKS_VERSION, links };
95
+ } catch (error) {
96
+ warn(`[context-trim] fork-links load failed (${error?.message ?? error}) — ignoring`);
97
+ return { version: FORK_LINKS_VERSION, links: {} };
98
+ }
99
+ }
100
+
101
+ /** Record one parent→child fork edge (merge + prune + cap + atomic write).
102
+ * Returns true when the edge is durably recorded; false on any failure.
103
+ * Never throws — the fork itself already succeeded and must not be
104
+ * invalidated by bookkeeping. */
105
+ export function recordForkLink(parentId, childId, options = {}) {
106
+ const warn = typeof options.warn === "function" ? options.warn : () => {};
107
+ const now = Number.isFinite(options.now) ? options.now : Date.now();
108
+ if (typeof parentId !== "string" || parentId === "" || typeof childId !== "string" || childId === "" || parentId === childId) return false;
109
+ try {
110
+ const map = loadForkLinks(options.stateDir, warn);
111
+ map.links[childId] = { parent: parentId, at: now };
112
+ // Prune by age, then cap by count (oldest dropped first).
113
+ const entries = Object.entries(map.links).filter(([, entry]) => now - entry.at < FORK_LINKS_MAX_AGE_MS);
114
+ entries.sort((a, b) => a[1].at - b[1].at);
115
+ while (entries.length > FORK_LINKS_MAX) entries.shift();
116
+ const links = {};
117
+ for (const [keptId, entry] of entries) links[keptId] = entry;
118
+ const dir = options.stateDir ?? defaultStateDir();
119
+ mkdirSync(dir, { recursive: true });
120
+ const file = linksFileOf(dir);
121
+ const tmp = `${file}.tmp`;
122
+ writeFileSync(tmp, JSON.stringify({ version: FORK_LINKS_VERSION, links }));
123
+ renameSync(tmp, file);
124
+ return true;
125
+ } catch (error) {
126
+ warn(`[context-trim] fork-link recording failed (${error?.message ?? error})`);
127
+ return false;
128
+ }
129
+ }
130
+
131
+ /** The fork edge recorded for one child, or null. */
132
+ export function forkLinkOf(map, childId) {
133
+ try {
134
+ const entry = map?.links?.[childId];
135
+ if (entry === null || typeof entry !== "object" || typeof entry.parent !== "string" || entry.parent === "") return null;
136
+ return entry;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ /** Validate + build the child ledger that inherits the parent's fold state.
143
+ * Pure. The guard requires the child's history to resolve the parent's
144
+ * FRONTIER ref (content-anchored): a frontier that resolves nothing would
145
+ * silently pin every future fold decision to a wrong boundary, so a
146
+ * mismatching child starts clean. Returns { ok: true, ledger } or
147
+ * { ok: false, reason }. */
148
+ export function resolveInheritance(parentLedger, parentId, childId, childMessages, now = Date.now()) {
149
+ const blocks = Array.isArray(parentLedger?.blocks) ? parentLedger.blocks : [];
150
+ if (blocks.length === 0) return { ok: false, reason: "parent-ledger-empty" };
151
+ if (!Array.isArray(childMessages) || childMessages.length === 0) return { ok: false, reason: "no-child-history" };
152
+ const frontier = typeof parentLedger.frontier === "string" && parentLedger.frontier !== "" ? parentLedger.frontier : null;
153
+ if (frontier === null || !refPositions(childMessages).has(frontier)) return { ok: false, reason: "frontier-not-found" };
154
+ // Detached copy: blocks are shared CONTENT but the ledgers must never
155
+ // alias each other (two levels deep covers block + refRange).
156
+ const ledger = {
157
+ version: LEDGER_VERSION,
158
+ sessionId: childId,
159
+ blocks: blocks.map((block) => ({ ...block, refRange: { ...block.refRange } })),
160
+ frontier,
161
+ lastFoldViewTokens: Number.isFinite(parentLedger.lastFoldViewTokens) && parentLedger.lastFoldViewTokens > 0 ? Math.round(parentLedger.lastFoldViewTokens) : 0,
162
+ lastFoldAt: Number.isFinite(parentLedger.lastFoldAt) ? parentLedger.lastFoldAt : 0,
163
+ foldsThisHour: Array.isArray(parentLedger.foldsThisHour) ? parentLedger.foldsThisHour.filter((ts) => Number.isFinite(ts)) : [],
164
+ inheritedFrom: typeof parentId === "string" && parentId !== "" ? parentId : "unknown",
165
+ inheritedAt: now,
166
+ };
167
+ return { ok: true, ledger };
168
+ }