@cohortapp/agent-sdk 2.11.14 → 2.12.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/.env.example +37 -22
- package/README.md +2 -0
- package/bin/maestro.mjs +113 -39
- package/bin/maestro.test.mjs +175 -5
- package/docs/guides/front-door-session.md +264 -0
- package/docs/guides/mac-mini.md +100 -28
- package/docs/guides/org-onboarding.md +1 -1
- package/docs/guides/setup-wizard.md +9 -5
- package/docs/runbooks/cohort-cutover.md +11 -1
- package/docs/runbooks/mac-mini-bootstrap.md +38 -63
- package/lib/cadence-bus-requeue.test.mjs +83 -0
- package/lib/cadence-bus.mjs +43 -7
- package/lib/channels/inbox-item.mjs +59 -2
- package/lib/cli/board.mjs +285 -0
- package/lib/cli/board.test.mjs +227 -0
- package/lib/cli/doctor-checks.mjs +441 -0
- package/lib/cli/doctor-checks.test.mjs +336 -0
- package/lib/cli/global-setup-extras.mjs +410 -0
- package/lib/cli/global-setup-extras.test.mjs +367 -0
- package/lib/cli/inbox.mjs +304 -0
- package/lib/cli/inbox.test.mjs +230 -0
- package/lib/cli/session-ack.mjs +63 -0
- package/lib/cli/session-ack.test.mjs +63 -0
- package/lib/cli/session.mjs +750 -0
- package/lib/cli/session.test.mjs +602 -0
- package/lib/collective/global-config.mjs +204 -6
- package/lib/collective/global-config.test.mjs +140 -0
- package/lib/collective/global-skills.mjs +145 -0
- package/lib/collective/global-skills.test.mjs +126 -0
- package/lib/collective/presence.mjs +4 -3
- package/lib/comms/send-gate.mjs +115 -0
- package/lib/comms/send-gate.test.mjs +113 -0
- package/lib/feature-init.mjs +2 -2
- package/lib/identity/persona.mjs +29 -0
- package/lib/identity/persona.test.mjs +26 -1
- package/lib/mcp/server.test.mjs +9 -4
- package/lib/model-router/spawn.test.mjs +21 -0
- package/lib/org/board-mine-cache.mjs +99 -0
- package/lib/org/board-mine-cache.test.mjs +53 -0
- package/lib/org/board.mjs +11 -0
- package/lib/org/board.test.mjs +11 -1
- package/lib/org/client.mjs +36 -0
- package/lib/org/client.test.mjs +46 -0
- package/lib/org/inbound/directedness.mjs +18 -2
- package/lib/org/inbound/directedness.test.mjs +58 -0
- package/lib/org/inbound/index.mjs +8 -1
- package/lib/org/inbound/index.test.mjs +22 -0
- package/lib/org/mesh-directives.test.mjs +110 -0
- package/lib/org/mesh.mjs +61 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +52 -0
- package/lib/org/protocol.test.mjs +12 -1
- package/lib/org/registry.mjs +3 -2
- package/lib/org/tool-surface.mjs +120 -0
- package/lib/org/tool-surface.test.mjs +118 -5
- package/lib/security/external-content.mjs +1 -1
- package/lib/security/external-content.test.mjs +17 -0
- package/lib/session/config.mjs +137 -0
- package/lib/session/config.test.mjs +92 -0
- package/lib/session/feed-core.mjs +229 -0
- package/lib/session/feed-core.test.mjs +198 -0
- package/lib/session/first-run.mjs +126 -0
- package/lib/session/first-run.test.mjs +121 -0
- package/lib/session/frontdoor.mjs +266 -0
- package/lib/session/frontdoor.test.mjs +205 -0
- package/lib/session/handoffs.mjs +295 -0
- package/lib/session/handoffs.test.mjs +183 -0
- package/lib/session/identity.mjs +220 -0
- package/lib/session/identity.test.mjs +180 -0
- package/lib/session/inbox-claims.mjs +434 -0
- package/lib/session/inbox-claims.test.mjs +286 -0
- package/lib/session/launch-args.mjs +161 -0
- package/lib/session/launch-args.test.mjs +157 -0
- package/lib/session/liveness.mjs +174 -0
- package/lib/session/liveness.test.mjs +100 -0
- package/lib/session/status-summary.mjs +172 -0
- package/lib/session/status-summary.test.mjs +118 -0
- package/lib/session-permissions.mjs +39 -3
- package/lib/session-permissions.test.mjs +20 -0
- package/lib/setup/claude-probe.mjs +161 -24
- package/lib/setup/claude-probe.test.mjs +187 -0
- package/lib/setup/sections/learning.mjs +2 -1
- package/lib/setup/sections/model.mjs +104 -24
- package/lib/setup/sections/model.test.mjs +240 -0
- package/lib/setup/sections/org.mjs +27 -2
- package/lib/setup/sections/org.test.mjs +35 -2
- package/lib/setup/sections/verify.mjs +5 -0
- package/lib/setup/state.mjs +30 -10
- package/lib/setup/state.test.mjs +24 -1
- package/lib/singleton.js +11 -3
- package/lib/singleton.test.mjs +16 -0
- package/lib/subagents/lock.mjs +1 -1
- package/lib/telemetry/collect.mjs +270 -6
- package/lib/telemetry/collect.test.mjs +196 -1
- package/lib/upgrade/global-refresh.mjs +108 -0
- package/lib/upgrade/global-refresh.test.mjs +65 -0
- package/lib/upgrade/launchd-reconcile.mjs +327 -0
- package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
- package/lib/upgrade/post-steps.mjs +151 -0
- package/lib/upgrade/post-steps.test.mjs +200 -0
- package/lib/upgrade/verify.mjs +215 -0
- package/lib/upgrade/verify.test.mjs +164 -0
- package/lib/voice/outbound.mjs +3 -2
- package/lib/voice/post-call-brief.mjs +2 -1
- package/lib/voice/session-rotation.mjs +6 -1
- package/lib/voice/session-rotation.test.mjs +114 -0
- package/package.json +3 -3
- package/plugins/maestro-skills/plugin.json +21 -1
- package/plugins/maestro-skills/skills/board-work.md +63 -0
- package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
- package/plugins/maestro-skills/skills/main-session.md +102 -0
- package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
- package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
- package/scaffold/CLAUDE.md +34 -0
- package/scripts/ci/check-durable-write-seam.mjs +147 -0
- package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/collective/hook-runner.mjs +39 -4
- package/scripts/collective/hook-runner.test.mjs +85 -2
- package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
- package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
- package/scripts/daemon/agent-daemon.mjs +141 -10
- package/scripts/daemon/agent-daemon.test.mjs +73 -0
- package/scripts/daemon/assurance-e2e.test.mjs +141 -6
- package/scripts/daemon/assurance.mjs +461 -37
- package/scripts/daemon/assurance.test.mjs +408 -43
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +334 -0
- package/scripts/daemon/cadence-consumer.mjs +254 -78
- package/scripts/daemon/cadence-handlers.mjs +53 -0
- package/scripts/daemon/classifier.mjs +1 -1
- package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
- package/scripts/daemon/dispatcher.mjs +127 -19
- package/scripts/daemon/health.mjs +12 -1
- package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
- package/scripts/daemon/inbox-deferral.mjs +6 -0
- package/scripts/daemon/lib/self-echo.mjs +201 -0
- package/scripts/daemon/lib/self-echo.test.mjs +153 -0
- package/scripts/daemon/maestro-daemon.mjs +3 -0
- package/scripts/daemon/prompt-builder.mjs +9 -1
- package/scripts/daemon/prompt-builder.test.mjs +22 -0
- package/scripts/daemon/responder.mjs +61 -41
- package/scripts/daemon/sdk-version.mjs +51 -0
- package/scripts/daemon/sdk-version.test.mjs +31 -0
- package/scripts/hooks/pre-send-audit.sh +97 -4
- package/scripts/hooks/pre-send-audit.test.mjs +140 -1
- package/scripts/local-triggers/autoupdate.sh +243 -19
- package/scripts/local-triggers/autoupdate.test.mjs +488 -0
- package/scripts/local-triggers/generate-plists.sh +24 -1
- package/scripts/local-triggers/generate-plists.test.mjs +49 -11
- package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
- package/scripts/org/send-orgmail.mjs +27 -3
- package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
- package/scripts/poller/slack-poller.mjs +13 -1
- package/scripts/poller/utils.mjs +46 -1
- package/scripts/poller-launchd/install.sh +19 -11
- package/scripts/poller-launchd/install.test.mjs +243 -0
- package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
- package/scripts/poller-launchd/migrate.sh +66 -0
- package/scripts/poller-launchd/poller.plist.template +4 -2
- package/scripts/session/feed.mjs +237 -0
- package/scripts/session/feed.test.mjs +196 -0
- package/scripts/session/supervisor-sh.test.mjs +218 -0
- package/scripts/session/supervisor.mjs +328 -0
- package/scripts/session/supervisor.sh +141 -0
- package/scripts/session/supervisor.test.mjs +482 -0
- package/scripts/setup/configure-macos.sh +250 -55
- package/scripts/setup/configure-macos.test.mjs +306 -0
- package/scripts/setup/init-agent.sh +112 -7
- package/scripts/setup/init-agent.test.mjs +220 -1
- package/scripts/watchdog/memory-watchdog.sh +37 -1
- package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
- package/scripts/setup/boot-claude-session.sh +0 -94
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/session/handoffs.mjs — the cadence → main-session handoff ledger
|
|
3
|
+
* (design §3.4).
|
|
4
|
+
*
|
|
5
|
+
* When the main session is the live front door, an escalate/guarded cadence
|
|
6
|
+
* tick is not spawned as a `claude --print` sub-session; the consumer writes
|
|
7
|
+
* `state/session/handoffs/<tickId>.json` and marks the tick processed with
|
|
8
|
+
* `decision:"handed-to-session"`. The session's feed tails this directory,
|
|
9
|
+
* works the rendered prompt, and acks with `maestro session ack <tickId>`,
|
|
10
|
+
* which moves the file to `handoffs/done/`. The consumer's sweep expires any
|
|
11
|
+
* handoff past `deadlineAt` (default 30 min) so it can re-enqueue the tick on
|
|
12
|
+
* the legacy lane — a wedged session cannot starve a cadence.
|
|
13
|
+
*
|
|
14
|
+
* Durable writes go through lib/fs-atomic (tmp + fsync + rename). The clock
|
|
15
|
+
* and the fs are injectable; every function returns `{ok}` / data and never
|
|
16
|
+
* throws past its boundary.
|
|
17
|
+
*
|
|
18
|
+
* @module lib/session/handoffs
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { join, basename } from "node:path";
|
|
22
|
+
import * as nodeFs from "node:fs";
|
|
23
|
+
import { writeJsonAtomic as writeJsonAtomicDefault } from "../fs-atomic.mjs";
|
|
24
|
+
|
|
25
|
+
/** A handoff not acked within this window is expired and re-enqueued. */
|
|
26
|
+
export const DEFAULT_HANDOFF_DEADLINE_MS = 30 * 60_000;
|
|
27
|
+
/** done/ records and rendered prompts older than this are pruned. */
|
|
28
|
+
export const DEFAULT_HANDOFF_RETENTION_MS = 7 * 24 * 60 * 60_000;
|
|
29
|
+
/** Handoff directory (relative to the agent root). */
|
|
30
|
+
export const HANDOFFS_RELATIVE = "state/session/handoffs";
|
|
31
|
+
|
|
32
|
+
// A tick id is a bus event id (`evt-<iso>-<hex>`) or a test literal. It is a
|
|
33
|
+
// path segment, so anything that could climb out of the directory is refused.
|
|
34
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
|
|
35
|
+
function safeId(id) {
|
|
36
|
+
const s = String(id || "").trim();
|
|
37
|
+
return SAFE_ID.test(s) && !s.includes("..") ? s : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function iso(ms) { return new Date(ms).toISOString(); }
|
|
41
|
+
function nowOf(o) { return typeof o.now === "number" ? o.now : Date.now(); }
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the handoff paths under an agent root.
|
|
45
|
+
* @param {string} agentRoot
|
|
46
|
+
* @returns {{dir:string, done:string, prompts:string}}
|
|
47
|
+
*/
|
|
48
|
+
export function handoffPaths(agentRoot) {
|
|
49
|
+
const dir = join(agentRoot, HANDOFFS_RELATIVE);
|
|
50
|
+
return { dir, done: join(dir, "done"), prompts: join(dir, "prompts") };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Write a handoff. Idempotent: an existing file for the tick is left as-is
|
|
55
|
+
* and reported (`existing:true`).
|
|
56
|
+
*
|
|
57
|
+
* @param {string} agentRoot
|
|
58
|
+
* @param {{tickId:string, cadence:string, mode?:string, promptPath?:string, metadata?:object, deadlineAt?:string}} handoff
|
|
59
|
+
* @param {{now?:number, deadlineMs?:number, fs?:object, writeJson?:Function}} [deps]
|
|
60
|
+
* @returns {{ok:true, path:string, existing?:boolean, handoff:object}|{ok:false, error:string}}
|
|
61
|
+
*/
|
|
62
|
+
export function writeHandoff(agentRoot, handoff, deps = {}) {
|
|
63
|
+
const fs = deps.fs || nodeFs;
|
|
64
|
+
const writeJson = deps.writeJson || writeJsonAtomicDefault;
|
|
65
|
+
if (!handoff || typeof handoff !== "object") return { ok: false, error: "handoff object required" };
|
|
66
|
+
const tickId = safeId(handoff.tickId);
|
|
67
|
+
if (!tickId) return { ok: false, error: "tickId required" };
|
|
68
|
+
const cadence = String(handoff.cadence || "").trim();
|
|
69
|
+
if (!cadence) return { ok: false, error: "cadence required" };
|
|
70
|
+
const now = nowOf(deps);
|
|
71
|
+
const deadlineMs = Number.isFinite(deps.deadlineMs) && deps.deadlineMs > 0 ? deps.deadlineMs : DEFAULT_HANDOFF_DEADLINE_MS;
|
|
72
|
+
const paths = handoffPaths(agentRoot);
|
|
73
|
+
const path = join(paths.dir, `${tickId}.json`);
|
|
74
|
+
try {
|
|
75
|
+
if (fs.existsSync(path)) {
|
|
76
|
+
let existing = null;
|
|
77
|
+
try { existing = JSON.parse(fs.readFileSync(path, "utf-8")); } catch { existing = null; /* unreadable — still not ours to clobber */ }
|
|
78
|
+
return { ok: true, path, existing: true, handoff: existing };
|
|
79
|
+
}
|
|
80
|
+
const record = {
|
|
81
|
+
tickId,
|
|
82
|
+
cadence,
|
|
83
|
+
mode: handoff.mode || "escalate",
|
|
84
|
+
promptPath: handoff.promptPath || null,
|
|
85
|
+
enqueuedAt: handoff.enqueuedAt || iso(now),
|
|
86
|
+
deadlineAt: handoff.deadlineAt || iso(now + deadlineMs),
|
|
87
|
+
metadata: handoff.metadata && typeof handoff.metadata === "object" ? handoff.metadata : {},
|
|
88
|
+
status: "open",
|
|
89
|
+
};
|
|
90
|
+
fs.mkdirSync(paths.dir, { recursive: true });
|
|
91
|
+
writeJson(path, record);
|
|
92
|
+
return { ok: true, path, handoff: record };
|
|
93
|
+
} catch (err) {
|
|
94
|
+
return { ok: false, error: err && err.message ? err.message : String(err) };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readJson(fs, path) {
|
|
99
|
+
try { const v = JSON.parse(fs.readFileSync(path, "utf-8")); return v && typeof v === "object" ? v : null; }
|
|
100
|
+
catch { return null; /* corrupt or vanished — skipped, never fatal */ }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Open handoffs, oldest first. Corrupt files are skipped.
|
|
105
|
+
* @param {string} agentRoot
|
|
106
|
+
* @param {{fs?:object}} [deps]
|
|
107
|
+
* @returns {object[]} each record carries `path`
|
|
108
|
+
*/
|
|
109
|
+
export function listHandoffs(agentRoot, deps = {}) {
|
|
110
|
+
const fs = deps.fs || nodeFs;
|
|
111
|
+
const { dir } = handoffPaths(agentRoot);
|
|
112
|
+
let names;
|
|
113
|
+
try { names = fs.readdirSync(dir); } catch { return []; /* no handoffs yet */ }
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const n of names) {
|
|
116
|
+
if (!n.endsWith(".json")) continue;
|
|
117
|
+
const path = join(dir, n);
|
|
118
|
+
const rec = readJson(fs, path);
|
|
119
|
+
if (!rec || !rec.tickId) continue;
|
|
120
|
+
out.push({ ...rec, path });
|
|
121
|
+
}
|
|
122
|
+
out.sort((a, b) => String(a.enqueuedAt || "").localeCompare(String(b.enqueuedAt || "")) || a.tickId.localeCompare(b.tickId));
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* One open handoff by tick id, or null.
|
|
128
|
+
* @param {string} agentRoot
|
|
129
|
+
* @param {string} tickId
|
|
130
|
+
* @param {{fs?:object}} [deps]
|
|
131
|
+
* @returns {object|null}
|
|
132
|
+
*/
|
|
133
|
+
export function readHandoff(agentRoot, tickId, deps = {}) {
|
|
134
|
+
const fs = deps.fs || nodeFs;
|
|
135
|
+
const id = safeId(tickId);
|
|
136
|
+
if (!id) return null;
|
|
137
|
+
const path = join(handoffPaths(agentRoot).dir, `${id}.json`);
|
|
138
|
+
const rec = readJson(fs, path);
|
|
139
|
+
return rec ? { ...rec, path } : null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Move an open handoff into done/ with a terminal status. */
|
|
143
|
+
function retire(agentRoot, rec, patch, deps) {
|
|
144
|
+
const fs = deps.fs || nodeFs;
|
|
145
|
+
const writeJson = deps.writeJson || writeJsonAtomicDefault;
|
|
146
|
+
const paths = handoffPaths(agentRoot);
|
|
147
|
+
const src = join(paths.dir, `${rec.tickId}.json`);
|
|
148
|
+
const dst = join(paths.done, `${rec.tickId}.json`);
|
|
149
|
+
fs.mkdirSync(paths.done, { recursive: true });
|
|
150
|
+
const { path: _p, ...clean } = rec;
|
|
151
|
+
void _p;
|
|
152
|
+
writeJson(dst, { ...clean, ...patch });
|
|
153
|
+
try { fs.unlinkSync(src); } catch { /* already gone — the done/ record is what matters */ }
|
|
154
|
+
return dst;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Ack a handoff: the session finished (or consciously declined) the work.
|
|
159
|
+
* Moves the record to done/ with `status:"acked"`. Idempotent.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} agentRoot
|
|
162
|
+
* @param {string} tickId
|
|
163
|
+
* @param {{resultPath?:string, note?:string, now?:number, fs?:object, writeJson?:Function}} [o]
|
|
164
|
+
* @returns {{ok:true, path:string, already?:boolean}|{ok:false, error:string}}
|
|
165
|
+
*/
|
|
166
|
+
export function ackHandoff(agentRoot, tickId, o = {}) {
|
|
167
|
+
const fs = o.fs || nodeFs;
|
|
168
|
+
const id = safeId(tickId);
|
|
169
|
+
if (!id) return { ok: false, error: "invalid tickId" };
|
|
170
|
+
const paths = handoffPaths(agentRoot);
|
|
171
|
+
try {
|
|
172
|
+
const rec = readHandoff(agentRoot, id, { fs });
|
|
173
|
+
if (!rec) {
|
|
174
|
+
const donePath = join(paths.done, `${id}.json`);
|
|
175
|
+
if (fs.existsSync(donePath)) return { ok: true, path: donePath, already: true };
|
|
176
|
+
return { ok: false, error: "not-found" };
|
|
177
|
+
}
|
|
178
|
+
const path = retire(agentRoot, rec, {
|
|
179
|
+
status: "acked",
|
|
180
|
+
ackedAt: iso(nowOf(o)),
|
|
181
|
+
resultPath: o.resultPath || null,
|
|
182
|
+
...(o.note ? { note: String(o.note) } : {}),
|
|
183
|
+
}, o);
|
|
184
|
+
return { ok: true, path };
|
|
185
|
+
} catch (err) {
|
|
186
|
+
return { ok: false, error: err && err.message ? err.message : String(err) };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Expire every open handoff past its deadline (or, for a record without one,
|
|
192
|
+
* past enqueuedAt + default). Each is moved to done/ with `status:"expired"`
|
|
193
|
+
* and returned so the caller can re-enqueue the tick with
|
|
194
|
+
* `metadata.handoffTimedOut=true`.
|
|
195
|
+
*
|
|
196
|
+
* ORDER MATTERS. `onExpire(record)` — the caller's re-enqueue — runs BEFORE
|
|
197
|
+
* the record is retired; a hook that throws or returns `false` leaves the
|
|
198
|
+
* handoff open (reported under `failed`) so the next sweep tries again. The
|
|
199
|
+
* tick is only ever gone from the ledger once it is back on the bus.
|
|
200
|
+
*
|
|
201
|
+
* @param {string} agentRoot
|
|
202
|
+
* @param {{now?:number, deadlineMs?:number, fs?:object, writeJson?:Function, onExpire?:(rec:object)=>boolean|void}} [o]
|
|
203
|
+
* @returns {{expired:object[], failed:{tickId:string, error:string}[], scanned:number}}
|
|
204
|
+
*/
|
|
205
|
+
export function expireHandoffs(agentRoot, o = {}) {
|
|
206
|
+
const now = nowOf(o);
|
|
207
|
+
const deadlineMs = Number.isFinite(o.deadlineMs) && o.deadlineMs > 0 ? o.deadlineMs : DEFAULT_HANDOFF_DEADLINE_MS;
|
|
208
|
+
const onExpire = typeof o.onExpire === "function" ? o.onExpire : null;
|
|
209
|
+
const open = listHandoffs(agentRoot, o);
|
|
210
|
+
const expired = [];
|
|
211
|
+
const failed = [];
|
|
212
|
+
for (const rec of open) {
|
|
213
|
+
let deadline = Date.parse(rec.deadlineAt || "");
|
|
214
|
+
if (!Number.isFinite(deadline)) {
|
|
215
|
+
const enq = Date.parse(rec.enqueuedAt || "");
|
|
216
|
+
deadline = Number.isFinite(enq) ? enq + deadlineMs : now; // undated → expire now
|
|
217
|
+
}
|
|
218
|
+
if (now < deadline) continue;
|
|
219
|
+
const { path: _p, ...clean } = rec;
|
|
220
|
+
void _p;
|
|
221
|
+
if (onExpire) {
|
|
222
|
+
let ok = false;
|
|
223
|
+
let error = "onExpire returned false";
|
|
224
|
+
try { ok = onExpire(clean) !== false; } catch (err) { ok = false; error = err && err.message ? err.message : String(err); }
|
|
225
|
+
if (!ok) { failed.push({ tickId: rec.tickId, error }); continue; }
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
retire(agentRoot, rec, { status: "expired", expiredAt: iso(now) }, o);
|
|
229
|
+
expired.push(clean);
|
|
230
|
+
} catch { /* leave it for the next sweep; a failed move is not a lost tick */ }
|
|
231
|
+
}
|
|
232
|
+
return { expired, failed, scanned: open.length };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Ledger hygiene. Removes done/ records whose terminal time (ackedAt /
|
|
237
|
+
* expiredAt, else enqueuedAt, else mtime) is older than `retentionMs`
|
|
238
|
+
* (default 7 d), and rendered prompts under prompts/ older than the window
|
|
239
|
+
* that no OPEN handoff references. Never touches an open handoff or its
|
|
240
|
+
* prompt. Best-effort; returns what it removed.
|
|
241
|
+
*
|
|
242
|
+
* @param {string} agentRoot
|
|
243
|
+
* @param {{now?:number, retentionMs?:number, fs?:object}} [o]
|
|
244
|
+
* @returns {{removed:{done:string[], prompts:string[]}}}
|
|
245
|
+
*/
|
|
246
|
+
export function pruneHandoffs(agentRoot, o = {}) {
|
|
247
|
+
const fs = o.fs || nodeFs;
|
|
248
|
+
const now = nowOf(o);
|
|
249
|
+
const retentionMs = Number.isFinite(o.retentionMs) && o.retentionMs > 0 ? o.retentionMs : DEFAULT_HANDOFF_RETENTION_MS;
|
|
250
|
+
const cutoff = now - retentionMs;
|
|
251
|
+
const paths = handoffPaths(agentRoot);
|
|
252
|
+
const removed = { done: [], prompts: [] };
|
|
253
|
+
const mtimeOf = (p) => { try { return fs.statSync(p).mtimeMs; } catch { return null; } };
|
|
254
|
+
const referenced = new Set(listHandoffs(agentRoot, { fs }).map((h) => h.promptPath && basename(String(h.promptPath))).filter(Boolean));
|
|
255
|
+
const dropPrompt = (n) => {
|
|
256
|
+
if (!n || referenced.has(n) || removed.prompts.includes(n)) return;
|
|
257
|
+
try { fs.unlinkSync(join(paths.prompts, n)); removed.prompts.push(n); } catch { /* absent or raced away — fine */ }
|
|
258
|
+
};
|
|
259
|
+
// done/ — by terminal timestamp, mtime for a record that cannot be read.
|
|
260
|
+
// A pruned record takes its rendered prompt with it.
|
|
261
|
+
let names = [];
|
|
262
|
+
try { names = fs.readdirSync(paths.done); } catch { names = []; }
|
|
263
|
+
for (const n of names) {
|
|
264
|
+
if (!n.endsWith(".json")) continue;
|
|
265
|
+
const path = join(paths.done, n);
|
|
266
|
+
const rec = readJson(fs, path);
|
|
267
|
+
let at = rec ? Date.parse(rec.ackedAt || rec.expiredAt || rec.enqueuedAt || "") : NaN;
|
|
268
|
+
if (!Number.isFinite(at)) at = mtimeOf(path);
|
|
269
|
+
if (at == null || at >= cutoff) continue;
|
|
270
|
+
try { fs.unlinkSync(path); removed.done.push(n); } catch { continue; /* raced away — fine */ }
|
|
271
|
+
if (rec && rec.promptPath) dropPrompt(basename(String(rec.promptPath)));
|
|
272
|
+
}
|
|
273
|
+
// prompts/ — old orphans no open handoff still points at.
|
|
274
|
+
try { names = fs.readdirSync(paths.prompts); } catch { names = []; }
|
|
275
|
+
for (const n of names) {
|
|
276
|
+
if (referenced.has(n) || removed.prompts.includes(n)) continue;
|
|
277
|
+
const at = mtimeOf(join(paths.prompts, n));
|
|
278
|
+
if (at == null || at >= cutoff) continue;
|
|
279
|
+
dropPrompt(n);
|
|
280
|
+
}
|
|
281
|
+
return { removed };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export default {
|
|
285
|
+
DEFAULT_HANDOFF_DEADLINE_MS,
|
|
286
|
+
DEFAULT_HANDOFF_RETENTION_MS,
|
|
287
|
+
HANDOFFS_RELATIVE,
|
|
288
|
+
handoffPaths,
|
|
289
|
+
writeHandoff,
|
|
290
|
+
listHandoffs,
|
|
291
|
+
readHandoff,
|
|
292
|
+
ackHandoff,
|
|
293
|
+
expireHandoffs,
|
|
294
|
+
pruneHandoffs,
|
|
295
|
+
};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* handoffs.test.mjs — the cadence → session handoff ledger (design §3.4).
|
|
3
|
+
*
|
|
4
|
+
* `state/session/handoffs/<tickId>.json` is written by the cadence consumer
|
|
5
|
+
* when a live session takes an escalate/guarded tick, acked by
|
|
6
|
+
* `maestro session ack <tickId>` (moved to `done/`), and expired by the
|
|
7
|
+
* consumer's sweep once past `deadlineAt`. Clock injected everywhere.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync, utimesSync, readdirSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_HANDOFF_DEADLINE_MS,
|
|
18
|
+
handoffPaths,
|
|
19
|
+
writeHandoff,
|
|
20
|
+
listHandoffs,
|
|
21
|
+
readHandoff,
|
|
22
|
+
ackHandoff,
|
|
23
|
+
expireHandoffs,
|
|
24
|
+
pruneHandoffs,
|
|
25
|
+
DEFAULT_HANDOFF_RETENTION_MS,
|
|
26
|
+
} from "./handoffs.mjs";
|
|
27
|
+
|
|
28
|
+
const T0 = Date.parse("2026-09-08T10:00:00.000Z");
|
|
29
|
+
|
|
30
|
+
function root() { return mkdtempSync(join(tmpdir(), "maestro-handoffs-")); }
|
|
31
|
+
function rm(dir) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* */ } }
|
|
32
|
+
|
|
33
|
+
test("writeHandoff: lands <tickId>.json with enqueuedAt + deadlineAt (30 min default), idempotent", () => {
|
|
34
|
+
const dir = root();
|
|
35
|
+
try {
|
|
36
|
+
const r = writeHandoff(dir, { tickId: "evt-1", cadence: "goal-steward", mode: "escalate", promptPath: "/p/goal.md" }, { now: T0 });
|
|
37
|
+
assert.equal(r.ok, true);
|
|
38
|
+
assert.equal(r.path, join(handoffPaths(dir).dir, "evt-1.json"));
|
|
39
|
+
const on = JSON.parse(readFileSync(r.path, "utf-8"));
|
|
40
|
+
assert.equal(on.tickId, "evt-1");
|
|
41
|
+
assert.equal(on.cadence, "goal-steward");
|
|
42
|
+
assert.equal(on.mode, "escalate");
|
|
43
|
+
assert.equal(on.promptPath, "/p/goal.md");
|
|
44
|
+
assert.equal(on.enqueuedAt, new Date(T0).toISOString());
|
|
45
|
+
assert.equal(on.deadlineAt, new Date(T0 + DEFAULT_HANDOFF_DEADLINE_MS).toISOString());
|
|
46
|
+
// Second write for the same tick is a no-op that reports the existing file.
|
|
47
|
+
const again = writeHandoff(dir, { tickId: "evt-1", cadence: "goal-steward", mode: "escalate", promptPath: "/p/other.md" }, { now: T0 + 5 });
|
|
48
|
+
assert.equal(again.ok, true);
|
|
49
|
+
assert.equal(again.existing, true);
|
|
50
|
+
assert.equal(JSON.parse(readFileSync(r.path, "utf-8")).promptPath, "/p/goal.md");
|
|
51
|
+
} finally { rm(dir); }
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("writeHandoff: refuses a handoff without a tickId/cadence — returns {ok:false}, never throws", () => {
|
|
55
|
+
const dir = root();
|
|
56
|
+
try {
|
|
57
|
+
assert.equal(writeHandoff(dir, { cadence: "x" }).ok, false);
|
|
58
|
+
assert.equal(writeHandoff(dir, { tickId: "evt-9" }).ok, false);
|
|
59
|
+
assert.equal(writeHandoff(dir, null).ok, false);
|
|
60
|
+
// Path-traversal-shaped ids never escape the directory.
|
|
61
|
+
assert.equal(writeHandoff(dir, { tickId: "../../etc", cadence: "x", promptPath: "/p" }).ok, false);
|
|
62
|
+
} finally { rm(dir); }
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("listHandoffs / readHandoff: open handoffs sorted by enqueuedAt; corrupt files skipped; done/ excluded", () => {
|
|
66
|
+
const dir = root();
|
|
67
|
+
try {
|
|
68
|
+
writeHandoff(dir, { tickId: "evt-b", cadence: "b", mode: "escalate", promptPath: "/b" }, { now: T0 + 1000 });
|
|
69
|
+
writeHandoff(dir, { tickId: "evt-a", cadence: "a", mode: "guarded", promptPath: "/a" }, { now: T0 });
|
|
70
|
+
writeFileSync(join(handoffPaths(dir).dir, "junk.json"), "{nope");
|
|
71
|
+
mkdirSync(handoffPaths(dir).done, { recursive: true });
|
|
72
|
+
writeFileSync(join(handoffPaths(dir).done, "evt-z.json"), JSON.stringify({ tickId: "evt-z" }));
|
|
73
|
+
const open = listHandoffs(dir);
|
|
74
|
+
assert.deepEqual(open.map((h) => h.tickId), ["evt-a", "evt-b"]);
|
|
75
|
+
assert.equal(readHandoff(dir, "evt-a").cadence, "a");
|
|
76
|
+
assert.equal(readHandoff(dir, "evt-nope"), null);
|
|
77
|
+
assert.deepEqual(listHandoffs(join(dir, "does-not-exist")), []);
|
|
78
|
+
} finally { rm(dir); }
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("ackHandoff: moves the handoff to done/ with ackedAt + result; idempotent; unknown → not-found", () => {
|
|
82
|
+
const dir = root();
|
|
83
|
+
try {
|
|
84
|
+
writeHandoff(dir, { tickId: "evt-1", cadence: "c", mode: "escalate", promptPath: "/p" }, { now: T0 });
|
|
85
|
+
const r = ackHandoff(dir, "evt-1", { resultPath: "/tmp/out.md", now: T0 + 60_000 });
|
|
86
|
+
assert.equal(r.ok, true);
|
|
87
|
+
assert.equal(existsSync(join(handoffPaths(dir).dir, "evt-1.json")), false);
|
|
88
|
+
const done = JSON.parse(readFileSync(join(handoffPaths(dir).done, "evt-1.json"), "utf-8"));
|
|
89
|
+
assert.equal(done.status, "acked");
|
|
90
|
+
assert.equal(done.ackedAt, new Date(T0 + 60_000).toISOString());
|
|
91
|
+
assert.equal(done.resultPath, "/tmp/out.md");
|
|
92
|
+
assert.deepEqual(listHandoffs(dir), []);
|
|
93
|
+
const again = ackHandoff(dir, "evt-1", { now: T0 + 70_000 });
|
|
94
|
+
assert.equal(again.ok, true);
|
|
95
|
+
assert.equal(again.already, true);
|
|
96
|
+
const missing = ackHandoff(dir, "evt-404", { now: T0 });
|
|
97
|
+
assert.equal(missing.ok, false);
|
|
98
|
+
assert.equal(missing.error, "not-found");
|
|
99
|
+
assert.equal(ackHandoff(dir, "../x", { now: T0 }).ok, false);
|
|
100
|
+
} finally { rm(dir); }
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("expireHandoffs: past-deadline handoffs are moved to done/ as expired and returned; fresh ones stay", () => {
|
|
104
|
+
const dir = root();
|
|
105
|
+
try {
|
|
106
|
+
writeHandoff(dir, { tickId: "evt-old", cadence: "goal-steward", mode: "escalate", promptPath: "/p", metadata: { reason: "x" } }, { now: T0 });
|
|
107
|
+
writeHandoff(dir, { tickId: "evt-new", cadence: "inbox-processor", mode: "guarded", promptPath: "/q" }, { now: T0 + 20 * 60_000 });
|
|
108
|
+
const r = expireHandoffs(dir, { now: T0 + DEFAULT_HANDOFF_DEADLINE_MS + 1 });
|
|
109
|
+
assert.equal(r.scanned, 2);
|
|
110
|
+
assert.deepEqual(r.expired.map((h) => h.tickId), ["evt-old"]);
|
|
111
|
+
assert.equal(r.expired[0].cadence, "goal-steward");
|
|
112
|
+
assert.deepEqual(r.expired[0].metadata, { reason: "x" });
|
|
113
|
+
assert.deepEqual(listHandoffs(dir).map((h) => h.tickId), ["evt-new"]);
|
|
114
|
+
const done = JSON.parse(readFileSync(join(handoffPaths(dir).done, "evt-old.json"), "utf-8"));
|
|
115
|
+
assert.equal(done.status, "expired");
|
|
116
|
+
assert.equal(done.expiredAt, new Date(T0 + DEFAULT_HANDOFF_DEADLINE_MS + 1).toISOString());
|
|
117
|
+
// A handoff with no deadlineAt (legacy shape) falls back to enqueuedAt + default.
|
|
118
|
+
writeFileSync(join(handoffPaths(dir).dir, "evt-legacy.json"), JSON.stringify({ tickId: "evt-legacy", cadence: "c", enqueuedAt: new Date(T0).toISOString() }));
|
|
119
|
+
const r2 = expireHandoffs(dir, { now: T0 + DEFAULT_HANDOFF_DEADLINE_MS + 1 });
|
|
120
|
+
assert.deepEqual(r2.expired.map((h) => h.tickId), ["evt-legacy"]);
|
|
121
|
+
// Nothing to do on a missing directory.
|
|
122
|
+
assert.deepEqual(expireHandoffs(join(dir, "nope"), { now: T0 }), { expired: [], failed: [], scanned: 0 });
|
|
123
|
+
} finally { rm(dir); }
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("expireHandoffs: onExpire runs BEFORE the record is retired — a false/throwing hook leaves the handoff open for the next sweep", () => {
|
|
127
|
+
const dir = root();
|
|
128
|
+
try {
|
|
129
|
+
writeHandoff(dir, { tickId: "evt-old", cadence: "goal-steward", mode: "escalate", promptPath: "/p" }, { now: T0 });
|
|
130
|
+
const late = T0 + DEFAULT_HANDOFF_DEADLINE_MS + 1;
|
|
131
|
+
const seen = [];
|
|
132
|
+
const r1 = expireHandoffs(dir, { now: late, onExpire: (h) => { seen.push(h.tickId); throw new Error("bus unwritable"); } });
|
|
133
|
+
assert.deepEqual(seen, ["evt-old"]);
|
|
134
|
+
assert.deepEqual(r1.expired, [], "not retired: the re-enqueue did not happen");
|
|
135
|
+
assert.deepEqual(r1.failed.map((f) => f.tickId), ["evt-old"]);
|
|
136
|
+
assert.deepEqual(listHandoffs(dir).map((h) => h.tickId), ["evt-old"], "still open");
|
|
137
|
+
assert.equal(existsSync(join(handoffPaths(dir).done, "evt-old.json")), false);
|
|
138
|
+
const r2 = expireHandoffs(dir, { now: late, onExpire: () => false });
|
|
139
|
+
assert.deepEqual(r2.expired, []);
|
|
140
|
+
assert.deepEqual(listHandoffs(dir).map((h) => h.tickId), ["evt-old"]);
|
|
141
|
+
// The hook succeeds → retired as before.
|
|
142
|
+
const r3 = expireHandoffs(dir, { now: late, onExpire: () => true });
|
|
143
|
+
assert.deepEqual(r3.expired.map((h) => h.tickId), ["evt-old"]);
|
|
144
|
+
assert.deepEqual(listHandoffs(dir), []);
|
|
145
|
+
assert.equal(JSON.parse(readFileSync(join(handoffPaths(dir).done, "evt-old.json"), "utf-8")).status, "expired");
|
|
146
|
+
} finally { rm(dir); }
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("pruneHandoffs: done/ records and rendered prompts past retention are removed; open handoffs and their prompts are never touched", () => {
|
|
150
|
+
const dir = root();
|
|
151
|
+
try {
|
|
152
|
+
const paths = handoffPaths(dir);
|
|
153
|
+
mkdirSync(paths.prompts, { recursive: true });
|
|
154
|
+
// An old acked handoff + its prompt.
|
|
155
|
+
writeFileSync(join(paths.prompts, "evt-old.md"), "# old");
|
|
156
|
+
writeHandoff(dir, { tickId: "evt-old", cadence: "a", promptPath: join(paths.prompts, "evt-old.md") }, { now: T0 });
|
|
157
|
+
ackHandoff(dir, "evt-old", { now: T0 + 1000 });
|
|
158
|
+
// A recent acked handoff.
|
|
159
|
+
writeFileSync(join(paths.prompts, "evt-recent.md"), "# recent");
|
|
160
|
+
writeHandoff(dir, { tickId: "evt-recent", cadence: "b", promptPath: join(paths.prompts, "evt-recent.md") }, { now: T0 + DEFAULT_HANDOFF_RETENTION_MS - 60_000 });
|
|
161
|
+
ackHandoff(dir, "evt-recent", { now: T0 + DEFAULT_HANDOFF_RETENTION_MS - 60_000 });
|
|
162
|
+
// An OPEN handoff whose prompt is old on disk (never pruned while open).
|
|
163
|
+
writeFileSync(join(paths.prompts, "evt-open.md"), "# open");
|
|
164
|
+
utimesSync(join(paths.prompts, "evt-open.md"), new Date(T0 - 10 * DEFAULT_HANDOFF_RETENTION_MS), new Date(T0 - 10 * DEFAULT_HANDOFF_RETENTION_MS));
|
|
165
|
+
writeHandoff(dir, { tickId: "evt-open", cadence: "c", promptPath: join(paths.prompts, "evt-open.md") }, { now: T0 });
|
|
166
|
+
// An orphan prompt nobody references, old.
|
|
167
|
+
writeFileSync(join(paths.prompts, "evt-orphan.md"), "# orphan");
|
|
168
|
+
utimesSync(join(paths.prompts, "evt-orphan.md"), new Date(T0 - 10 * DEFAULT_HANDOFF_RETENTION_MS), new Date(T0 - 10 * DEFAULT_HANDOFF_RETENTION_MS));
|
|
169
|
+
// A corrupt done record with an old mtime.
|
|
170
|
+
writeFileSync(join(paths.done, "junk.json"), "{not json");
|
|
171
|
+
utimesSync(join(paths.done, "junk.json"), new Date(T0 - 10 * DEFAULT_HANDOFF_RETENTION_MS), new Date(T0 - 10 * DEFAULT_HANDOFF_RETENTION_MS));
|
|
172
|
+
|
|
173
|
+
const r = pruneHandoffs(dir, { now: T0 + DEFAULT_HANDOFF_RETENTION_MS + 5000 });
|
|
174
|
+
assert.deepEqual(r.removed.done.sort(), ["evt-old.json", "junk.json"]);
|
|
175
|
+
assert.deepEqual(r.removed.prompts.sort(), ["evt-old.md", "evt-orphan.md"]);
|
|
176
|
+
assert.deepEqual(readdirSync(paths.done).sort(), ["evt-recent.json"]);
|
|
177
|
+
assert.deepEqual(readdirSync(paths.prompts).sort(), ["evt-open.md", "evt-recent.md"]);
|
|
178
|
+
assert.deepEqual(listHandoffs(dir).map((h) => h.tickId), ["evt-open"]);
|
|
179
|
+
// Idempotent and safe on a missing ledger.
|
|
180
|
+
assert.deepEqual(pruneHandoffs(dir, { now: T0 + DEFAULT_HANDOFF_RETENTION_MS + 5000 }).removed, { done: [], prompts: [] });
|
|
181
|
+
assert.deepEqual(pruneHandoffs(join(dir, "nope"), { now: T0 }).removed, { done: [], prompts: [] });
|
|
182
|
+
} finally { rm(dir); }
|
|
183
|
+
});
|