@cohortapp/agent-sdk 2.11.15 → 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/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 +24 -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/responder.mjs +51 -40
- 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
package/scripts/ci/check.mjs
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* - check-no-build-artifacts : no tracked .pyc/symlinks reach the tarball
|
|
21
21
|
* - check-docs-accuracy : README links resolve; counts/agent-refs WARN
|
|
22
22
|
* - check-subagent-frontmatter: every agents/*.md passes the registry validator
|
|
23
|
+
* - check-durable-write-seam : durable JSON writes under lib/ go through fs-atomic
|
|
23
24
|
*
|
|
24
25
|
* Usage: `node scripts/ci/check.mjs`
|
|
25
26
|
* exit 0 → all checks passed; exit 1 → one or more failed.
|
|
@@ -38,6 +39,7 @@ import { run as runNoResidualIdentity } from "./check-no-residual-identity.mjs";
|
|
|
38
39
|
import { run as runNoBuildArtifacts } from "./check-no-build-artifacts.mjs";
|
|
39
40
|
import { run as runDocsAccuracy } from "./check-docs-accuracy.mjs";
|
|
40
41
|
import { run as runSubagentFrontmatter } from "./check-subagent-frontmatter.mjs";
|
|
42
|
+
import { run as runDurableWriteSeam } from "./check-durable-write-seam.mjs";
|
|
41
43
|
|
|
42
44
|
/**
|
|
43
45
|
* The ordered list of guards this aggregator runs.
|
|
@@ -54,6 +56,7 @@ export const CHECKS = [
|
|
|
54
56
|
{ name: "check-no-build-artifacts", run: runNoBuildArtifacts },
|
|
55
57
|
{ name: "check-docs-accuracy", run: runDocsAccuracy },
|
|
56
58
|
{ name: "check-subagent-frontmatter", run: runSubagentFrontmatter },
|
|
59
|
+
{ name: "check-durable-write-seam", run: runDurableWriteSeam },
|
|
57
60
|
];
|
|
58
61
|
|
|
59
62
|
/**
|
|
@@ -76,8 +76,10 @@ async function main() {
|
|
|
76
76
|
const repo = basename(cwd || "");
|
|
77
77
|
|
|
78
78
|
if (event === "prime") {
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
const kind = classifyKind(input);
|
|
80
|
+
try { presence.register(SELF_ROOT, { sessionId, cwd, repo, kind }); } catch { /* */ }
|
|
81
|
+
if (denied) return; // never inject agent memory (or the persona) into a ring-fenced/client session
|
|
82
|
+
const parts = [];
|
|
81
83
|
try {
|
|
82
84
|
const recall = await import("../../lib/collective/recall.mjs");
|
|
83
85
|
const digest = recall.buildDigest(SELF_ROOT, {
|
|
@@ -86,8 +88,25 @@ async function main() {
|
|
|
86
88
|
halfLifeDays: config.recencyHalfLifeDays,
|
|
87
89
|
staleSec: config.presence && config.presence.staleSec,
|
|
88
90
|
});
|
|
89
|
-
|
|
90
|
-
} catch { /* */ }
|
|
91
|
+
if (digest) parts.push(digest);
|
|
92
|
+
} catch { /* the digest is best-effort; the primer below still runs */ }
|
|
93
|
+
// Front-door session (2026-09): the identity block + a one-line session
|
|
94
|
+
// status (main session liveness, peers, open handoffs, board-mine count
|
|
95
|
+
// from state/org/board-mine.json when the daemon has cached it). Any
|
|
96
|
+
// session on the machine — a home-dir `claude`, a peer, the main session
|
|
97
|
+
// itself — learns who it is from this. Silent when the repo has no
|
|
98
|
+
// identity yet (config/agent.json absent or unconfigured), and skipped
|
|
99
|
+
// for the daemon's own `claude --print` children (MAESTRO_DAEMON=1): their
|
|
100
|
+
// prompt-builder preamble already carries the identity, so the block
|
|
101
|
+
// would be ~400 duplicate tokens on every dispatch.
|
|
102
|
+
if (kind !== "daemon") {
|
|
103
|
+
try {
|
|
104
|
+
const primer = await buildFrontDoorPrimer(SELF_ROOT);
|
|
105
|
+
if (primer) parts.push(primer);
|
|
106
|
+
} catch { /* an unreadable state dir must never break a session start */ }
|
|
107
|
+
}
|
|
108
|
+
// ONE frame: Claude Code reads a single JSON object from a hook's stdout.
|
|
109
|
+
emitContext(parts.join("\n\n"));
|
|
91
110
|
return;
|
|
92
111
|
}
|
|
93
112
|
|
|
@@ -116,6 +135,22 @@ async function main() {
|
|
|
116
135
|
}
|
|
117
136
|
}
|
|
118
137
|
|
|
138
|
+
/** The identity block + status line for `prime`. "" when there is nothing to say. */
|
|
139
|
+
function buildFrontDoorPrimer(agentRoot) {
|
|
140
|
+
let agentJson = null;
|
|
141
|
+
try {
|
|
142
|
+
const p = join(agentRoot, "config", "agent.json");
|
|
143
|
+
if (existsSync(p)) agentJson = JSON.parse(readFileSync(p, "utf8"));
|
|
144
|
+
} catch {
|
|
145
|
+
agentJson = null; // an unconfigured/corrupt agent.json is "no identity yet"
|
|
146
|
+
}
|
|
147
|
+
if (!agentJson || !(agentJson.fullName || agentJson.firstName)) return "";
|
|
148
|
+
// Dynamic import so a hook on a repo that predates lib/session stays silent
|
|
149
|
+
// instead of failing the session start.
|
|
150
|
+
return import("../../lib/session/status-summary.mjs").then((mod) =>
|
|
151
|
+
mod.buildPrimerContext({ agentJson, agentRoot, status: mod.readSessionStatus(agentRoot, { now: Date.now() }) }));
|
|
152
|
+
}
|
|
153
|
+
|
|
119
154
|
function classifyKind(input) {
|
|
120
155
|
const src = String(input.source || "").toLowerCase();
|
|
121
156
|
if (src.includes("daemon") || process.env.MAESTRO_DAEMON === "1") return "daemon";
|
|
@@ -24,11 +24,14 @@ function tmpRoot() {
|
|
|
24
24
|
mkdirSync(join(root, "config"), { recursive: true });
|
|
25
25
|
return root;
|
|
26
26
|
}
|
|
27
|
-
function run(event, input, root) {
|
|
27
|
+
function run(event, input, root, extraEnv = {}) {
|
|
28
|
+
const env = { ...process.env, MAESTRO_COLLECTIVE_ROOT: root };
|
|
29
|
+
delete env.MAESTRO_DAEMON; // never inherit a daemon marker from the runner's own environment
|
|
30
|
+
Object.assign(env, extraEnv);
|
|
28
31
|
return spawnSync(process.execPath, [RUNNER, event], {
|
|
29
32
|
input: JSON.stringify(input),
|
|
30
33
|
encoding: "utf8",
|
|
31
|
-
env
|
|
34
|
+
env,
|
|
32
35
|
});
|
|
33
36
|
}
|
|
34
37
|
|
|
@@ -88,3 +91,83 @@ test("disabled config → fully silent no-op", () => {
|
|
|
88
91
|
assert.equal(r.stdout.trim(), "");
|
|
89
92
|
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
90
93
|
});
|
|
94
|
+
|
|
95
|
+
// ── WP-M4: the front-door primer (identity block + session status) ─────────
|
|
96
|
+
|
|
97
|
+
test("prime: injects the identity block + one-line session status + handoff/board counts when the repo has an identity", () => {
|
|
98
|
+
const root = tmpRoot();
|
|
99
|
+
try {
|
|
100
|
+
writeFileSync(join(root, "config", "agent.json"), JSON.stringify({
|
|
101
|
+
firstName: "Alex", fullName: "Alex Rivera", title: "Chief of Staff", company: "Acme",
|
|
102
|
+
principal: { fullName: "Jordan Lee", title: "CEO" }, repoSlug: "alex-ai",
|
|
103
|
+
}));
|
|
104
|
+
mkdirSync(join(root, "state", "session", "handoffs"), { recursive: true });
|
|
105
|
+
mkdirSync(join(root, "state", "org"), { recursive: true });
|
|
106
|
+
writeFileSync(join(root, "state", "session", "heartbeat.json"), JSON.stringify({ pid: 1, name: "alex-main", ts: Date.now() }));
|
|
107
|
+
writeFileSync(join(root, "state", "session", "handoffs", "t1.json"), "{}");
|
|
108
|
+
writeFileSync(join(root, "state", "session", "handoffs", "t2.json"), "{}");
|
|
109
|
+
writeFileSync(join(root, "state", "org", "board-mine.json"), JSON.stringify({ items: [{ itemId: "i1" }], fetchedAt: new Date().toISOString() }));
|
|
110
|
+
const r = run("prime", { session_id: "me", cwd: "/x/maestro" }, root);
|
|
111
|
+
assert.equal(r.status, 0);
|
|
112
|
+
const payload = JSON.parse(r.stdout);
|
|
113
|
+
const ctx = payload.hookSpecificOutput.additionalContext;
|
|
114
|
+
assert.match(ctx, /maestro:identity/);
|
|
115
|
+
assert.match(ctx, /Alex Rivera/);
|
|
116
|
+
assert.match(ctx, /Chief of Staff/);
|
|
117
|
+
assert.match(ctx, /`alex-main`/);
|
|
118
|
+
assert.match(ctx, /Session status: main session alex-main live/);
|
|
119
|
+
assert.match(ctx, /2 open handoffs/);
|
|
120
|
+
assert.match(ctx, /1 board item/);
|
|
121
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("prime: identity primer and memory digest are emitted together in ONE hook frame", () => {
|
|
125
|
+
const root = tmpRoot();
|
|
126
|
+
try {
|
|
127
|
+
writeFileSync(join(root, "config", "agent.json"), JSON.stringify({ firstName: "Alex", fullName: "Alex Rivera" }));
|
|
128
|
+
register(root, { sessionId: "other", cwd: "/x/openclaw", intent: "research" });
|
|
129
|
+
appendCards(root, [{ kind: "decision", title: "Shipped billing", salience: 0.9, ts: new Date().toISOString() }], { sessionId: "prev" });
|
|
130
|
+
const r = run("prime", { session_id: "me", cwd: "/x/maestro" }, root);
|
|
131
|
+
assert.equal(r.status, 0);
|
|
132
|
+
const frames = r.stdout.trim().split("\n").filter(Boolean);
|
|
133
|
+
assert.equal(frames.length, 1, "exactly one JSON frame on stdout");
|
|
134
|
+
const ctx = JSON.parse(frames[0]).hookSpecificOutput.additionalContext;
|
|
135
|
+
assert.match(ctx, /maestro:collective-digest/);
|
|
136
|
+
assert.match(ctx, /Shipped billing/);
|
|
137
|
+
assert.match(ctx, /maestro:identity/);
|
|
138
|
+
assert.match(ctx, /Session status: main session not running/);
|
|
139
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("prime: a denylisted cwd gets neither memory nor identity", () => {
|
|
143
|
+
const root = tmpRoot();
|
|
144
|
+
try {
|
|
145
|
+
writeFileSync(join(root, "config", "collective.yaml"), "repoDenylist: [client-secret]\n");
|
|
146
|
+
writeFileSync(join(root, "config", "agent.json"), JSON.stringify({ firstName: "Alex", fullName: "Alex Rivera" }));
|
|
147
|
+
const r = run("prime", { session_id: "me", cwd: "/work/client-secret" }, root);
|
|
148
|
+
assert.equal(r.status, 0);
|
|
149
|
+
assert.equal(r.stdout.trim(), "");
|
|
150
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("prime: the daemon's own `claude --print` children (MAESTRO_DAEMON=1) get the memory digest but NOT the identity primer", () => {
|
|
154
|
+
const root = tmpRoot();
|
|
155
|
+
try {
|
|
156
|
+
writeFileSync(join(root, "config", "agent.json"), JSON.stringify({ firstName: "Alex", fullName: "Alex Rivera" }));
|
|
157
|
+
// No digest material → a daemon child is fully silent.
|
|
158
|
+
const quiet = run("prime", { session_id: "d1", cwd: "/x/maestro" }, root, { MAESTRO_DAEMON: "1" });
|
|
159
|
+
assert.equal(quiet.status, 0);
|
|
160
|
+
assert.equal(quiet.stdout.trim(), "", "no identity block for a daemon child");
|
|
161
|
+
// With digest material → the digest still flows, the identity block still does not.
|
|
162
|
+
register(root, { sessionId: "other", cwd: "/x/openclaw", intent: "research" });
|
|
163
|
+
appendCards(root, [{ kind: "decision", title: "Shipped billing", salience: 0.9, ts: new Date().toISOString() }], { sessionId: "prev" });
|
|
164
|
+
const r = run("prime", { session_id: "d2", cwd: "/x/maestro" }, root, { MAESTRO_DAEMON: "1" });
|
|
165
|
+
assert.equal(r.status, 0);
|
|
166
|
+
const ctx = JSON.parse(r.stdout).hookSpecificOutput.additionalContext;
|
|
167
|
+
assert.match(ctx, /Shipped billing/);
|
|
168
|
+
assert.ok(!/maestro:identity/.test(ctx), "identity primer is skipped for daemon children");
|
|
169
|
+
// An interactive session on the same repo still gets it.
|
|
170
|
+
const i = run("prime", { session_id: "i1", cwd: "/x/maestro" }, root);
|
|
171
|
+
assert.match(JSON.parse(i.stdout).hookSpecificOutput.additionalContext, /maestro:identity/);
|
|
172
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
173
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-daemon-board-mine.test.mjs — the daemon caches hq `board.mine` to
|
|
3
|
+
* state/org/board-mine.json every 5 minutes (design §3.5, WP-M6).
|
|
4
|
+
*
|
|
5
|
+
* Drives the REAL refreshBoardMineCache with an injected listMine and a fixed
|
|
6
|
+
* clock under a tmp AGENT_DIR. Same `imapflow` loader stub as the other
|
|
7
|
+
* daemon tests.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { test, before } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { register } from "node:module";
|
|
13
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
|
|
17
|
+
const loaderSrc = `
|
|
18
|
+
export async function resolve(specifier, context, next) {
|
|
19
|
+
if (specifier === "imapflow") return { url: "imapflow-stub:main", shortCircuit: true };
|
|
20
|
+
return next(specifier, context);
|
|
21
|
+
}
|
|
22
|
+
export async function load(url, context, next) {
|
|
23
|
+
if (url === "imapflow-stub:main") return { format: "module", shortCircuit: true, source: "export class ImapFlow {}" };
|
|
24
|
+
return next(url, context);
|
|
25
|
+
}
|
|
26
|
+
`;
|
|
27
|
+
register(`data:text/javascript,${encodeURIComponent(loaderSrc)}`);
|
|
28
|
+
|
|
29
|
+
const AGENT_DIR = mkdtempSync(join(tmpdir(), "maestro-daemon-board-mine-"));
|
|
30
|
+
process.env.AGENT_DIR = AGENT_DIR;
|
|
31
|
+
process.env.MAESTRO_DAEMON_NO_AUTOSTART = "1";
|
|
32
|
+
|
|
33
|
+
const NOW = Date.parse("2026-09-08T12:00:00Z");
|
|
34
|
+
const CACHE = join(AGENT_DIR, "state", "org", "board-mine.json");
|
|
35
|
+
|
|
36
|
+
let daemon;
|
|
37
|
+
before(async () => {
|
|
38
|
+
mkdirSync(join(AGENT_DIR, "config"), { recursive: true });
|
|
39
|
+
mkdirSync(join(AGENT_DIR, "state", "sessions", "locks"), { recursive: true });
|
|
40
|
+
writeFileSync(join(AGENT_DIR, "config", "org.yaml"), "org:\n cohort:\n enabled: true\n base: https://os.example.test\n token: nlk_test_token\n orgId: org_test\n");
|
|
41
|
+
writeFileSync(join(AGENT_DIR, "config", "agent.json"), JSON.stringify({ firstName: "Nova", lastName: "Reyes" }));
|
|
42
|
+
const url = new URL("./agent-daemon.mjs", import.meta.url);
|
|
43
|
+
daemon = await import(`${url.href}?boardmine=${Math.random()}`);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("first call writes state/org/board-mine.json as {ts, items} from listMine; a call inside 5 min is a no-op; one at 5 min refreshes", async () => {
|
|
47
|
+
const calls = [];
|
|
48
|
+
const listMineImpl = async (o) => { calls.push(o); return [{ itemId: "i1", title: "Do X", col: "todo" }, null, "junk"]; };
|
|
49
|
+
const first = await daemon.refreshBoardMineCache({ now: NOW, listMineImpl });
|
|
50
|
+
assert.deepEqual(first, { refreshed: true, reason: "written", count: 1 });
|
|
51
|
+
assert.ok(existsSync(CACHE));
|
|
52
|
+
const doc = JSON.parse(readFileSync(CACHE, "utf8"));
|
|
53
|
+
assert.deepEqual(doc, { ts: "2026-09-08T12:00:00.000Z", items: [{ itemId: "i1", title: "Do X", col: "todo" }] });
|
|
54
|
+
assert.equal(calls.length, 1);
|
|
55
|
+
assert.equal(calls[0].base, "https://os.example.test", "the daemon's org.yaml connection is what listMine gets");
|
|
56
|
+
assert.equal(calls[0].orgId, "org_test");
|
|
57
|
+
|
|
58
|
+
const soon = await daemon.refreshBoardMineCache({ now: NOW + 4 * 60_000, listMineImpl });
|
|
59
|
+
assert.deepEqual(soon, { refreshed: false, reason: "fresh" });
|
|
60
|
+
assert.equal(calls.length, 1, "no hq call inside the interval");
|
|
61
|
+
|
|
62
|
+
const later = await daemon.refreshBoardMineCache({ now: NOW + 5 * 60_000, listMineImpl: async () => ({ ok: true, items: [] }) });
|
|
63
|
+
assert.deepEqual(later, { refreshed: true, reason: "written", count: 0 });
|
|
64
|
+
assert.deepEqual(JSON.parse(readFileSync(CACHE, "utf8")), { ts: "2026-09-08T12:05:00.000Z", items: [] }, "an empty board is a real answer");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("hq unreachable for one tick: the last known good items are KEPT and the document is marked stale with itemsAt; the next good read clears it", async () => {
|
|
68
|
+
await daemon.refreshBoardMineCache({ now: NOW + 10 * 60_000, listMineImpl: async () => [{ itemId: "keep-me" }] });
|
|
69
|
+
const failed = await daemon.refreshBoardMineCache({ now: NOW + 15 * 60_000, listMineImpl: async () => ({ ok: false, items: [], error: "http 503" }) });
|
|
70
|
+
assert.deepEqual(failed, { refreshed: true, reason: "kept-previous", count: 1 });
|
|
71
|
+
const doc = JSON.parse(readFileSync(CACHE, "utf8"));
|
|
72
|
+
assert.deepEqual(doc, { ts: "2026-09-08T12:15:00.000Z", items: [{ itemId: "keep-me" }], stale: true, itemsAt: "2026-09-08T12:10:00.000Z", error: "http 503" });
|
|
73
|
+
const ok = await daemon.refreshBoardMineCache({ now: NOW + 20 * 60_000, listMineImpl: async () => ({ ok: true, items: [{ itemId: "fresh" }] }) });
|
|
74
|
+
assert.deepEqual(ok, { refreshed: true, reason: "written", count: 1 });
|
|
75
|
+
assert.deepEqual(JSON.parse(readFileSync(CACHE, "utf8")), { ts: "2026-09-08T12:20:00.000Z", items: [{ itemId: "fresh" }] });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("a corrupt cache is refreshed; force bypasses the interval; the tick-loop call never throws when listMine rejects", async () => {
|
|
79
|
+
writeFileSync(CACHE, "{not json");
|
|
80
|
+
const r = await daemon.refreshBoardMineCache({ now: NOW + 30 * 60_000, listMineImpl: async () => [{ itemId: "z" }] });
|
|
81
|
+
assert.equal(r.refreshed, true);
|
|
82
|
+
const forced = await daemon.refreshBoardMineCache({ now: NOW + 30 * 60_000 + 1, force: true, listMineImpl: async () => [{ itemId: "y" }] });
|
|
83
|
+
assert.equal(forced.refreshed, true);
|
|
84
|
+
assert.deepEqual(JSON.parse(readFileSync(CACHE, "utf8")).items, [{ itemId: "y" }]);
|
|
85
|
+
await assert.rejects(daemon.refreshBoardMineCache({ now: NOW + 40 * 60_000, listMineImpl: async () => { throw new Error("boom"); } }), /boom/);
|
|
86
|
+
assert.deepEqual(JSON.parse(readFileSync(CACHE, "utf8")).items, [{ itemId: "y" }], "a failed refresh leaves the previous cache in place");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("an un-enrolled seat (org disabled) writes nothing", async () => {
|
|
90
|
+
writeFileSync(join(AGENT_DIR, "config", "org.yaml"), "org:\n cohort:\n enabled: false\n");
|
|
91
|
+
daemon._resetDaemonOrgCfgCache();
|
|
92
|
+
rmSync(CACHE, { force: true });
|
|
93
|
+
const r = await daemon.refreshBoardMineCache({ now: NOW + 60 * 60_000, listMineImpl: async () => [{ itemId: "no" }] });
|
|
94
|
+
assert.deepEqual(r, { refreshed: false, reason: "org-disabled" });
|
|
95
|
+
assert.ok(!existsSync(CACHE));
|
|
96
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-daemon-frontdoor.test.mjs — the daemon steps back from the Cohort
|
|
3
|
+
* inbox while the main session is live (design §3.1).
|
|
4
|
+
*
|
|
5
|
+
* Drives the REAL fast cohort scan (pollCohortFast → pollService) with a live
|
|
6
|
+
* heartbeat planted under the tmp AGENT_DIR and a fresh inbox item: the item
|
|
7
|
+
* must be left exactly as it arrived (no lock, no rename, no classification).
|
|
8
|
+
* The not-live branch is the pre-existing dispatch path, which would reach the
|
|
9
|
+
* Haiku classifier — it is covered by the pure gate tests instead.
|
|
10
|
+
*
|
|
11
|
+
* Same `imapflow` loader stub as agent-daemon.test.mjs.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { test, before } from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { register } from "node:module";
|
|
17
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
|
|
21
|
+
const loaderSrc = `
|
|
22
|
+
export async function resolve(specifier, context, next) {
|
|
23
|
+
if (specifier === "imapflow") return { url: "imapflow-stub:main", shortCircuit: true };
|
|
24
|
+
return next(specifier, context);
|
|
25
|
+
}
|
|
26
|
+
export async function load(url, context, next) {
|
|
27
|
+
if (url === "imapflow-stub:main") return { format: "module", shortCircuit: true, source: "export class ImapFlow {}" };
|
|
28
|
+
return next(url, context);
|
|
29
|
+
}
|
|
30
|
+
`;
|
|
31
|
+
register(`data:text/javascript,${encodeURIComponent(loaderSrc)}`);
|
|
32
|
+
|
|
33
|
+
const AGENT_DIR = mkdtempSync(join(tmpdir(), "maestro-daemon-frontdoor-"));
|
|
34
|
+
process.env.AGENT_DIR = AGENT_DIR;
|
|
35
|
+
process.env.MAESTRO_DAEMON_NO_AUTOSTART = "1";
|
|
36
|
+
|
|
37
|
+
let daemon;
|
|
38
|
+
before(async () => {
|
|
39
|
+
mkdirSync(join(AGENT_DIR, "config"), { recursive: true });
|
|
40
|
+
mkdirSync(join(AGENT_DIR, "state", "inbox", "cohort"), { recursive: true });
|
|
41
|
+
mkdirSync(join(AGENT_DIR, "state", "session"), { recursive: true });
|
|
42
|
+
mkdirSync(join(AGENT_DIR, "state", "sessions", "locks"), { recursive: true });
|
|
43
|
+
writeFileSync(join(AGENT_DIR, "config", "org.yaml"), "org:\n cohort:\n enabled: true\n");
|
|
44
|
+
writeFileSync(join(AGENT_DIR, "config", "agent.json"), JSON.stringify({ firstName: "Nova", lastName: "Reyes" }));
|
|
45
|
+
const url = new URL("./agent-daemon.mjs", import.meta.url);
|
|
46
|
+
daemon = await import(`${url.href}?frontdoor=${Math.random()}`);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("live main session: pollCohortFast leaves a fresh cohort item untouched for the session", async () => {
|
|
50
|
+
writeFileSync(join(AGENT_DIR, "state", "session", "heartbeat.json"), JSON.stringify({ pid: process.pid, ts: new Date().toISOString() }));
|
|
51
|
+
const name = "2026-09-08T10-00-00-000Z-cohort-fd1.yaml";
|
|
52
|
+
writeFileSync(join(AGENT_DIR, "state", "inbox", "cohort", name), [
|
|
53
|
+
'id: "cohort-fd1"', 'service: "cohort"', 'channel: "dm/olivia"', 'channel_id: "ch1"', 'channel_kind: "DM"',
|
|
54
|
+
'sender: "Olivia Chen"', 'timestamp: "2026-09-08T10:00:00.000Z"', 'subject: ""', "content: |", " hello", 'raw_ref: "cohort-fd1"', "",
|
|
55
|
+
].join("\n"));
|
|
56
|
+
const n = await daemon.pollCohortFast();
|
|
57
|
+
assert.equal(n, 0, "nothing dispatched by the daemon");
|
|
58
|
+
assert.deepEqual(readdirSync(join(AGENT_DIR, "state", "inbox", "cohort")), [name], "the item is still a fresh .yaml");
|
|
59
|
+
assert.deepEqual(readdirSync(join(AGENT_DIR, "state", "sessions", "locks")), [], "no per-item lock taken");
|
|
60
|
+
});
|
|
@@ -42,6 +42,12 @@ function loadAgent() {
|
|
|
42
42
|
return _agent;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// Self-echo guard (25/27/30 Aug recurrences): the poll lane re-ingested this
|
|
46
|
+
// agent's OWN outbound sends as fresh inbound and fired holding acks at them.
|
|
47
|
+
// The old inline check compared `sender` against "isla-roselli" while real
|
|
48
|
+
// events carry "Isla Roselli" — it never matched. The match now lives in one
|
|
49
|
+
// tested module and every drop is audited to logs/daemon/self-echo-drops.jsonl.
|
|
50
|
+
import { isSelfSender, logSelfEchoDrop } from "./lib/self-echo.mjs";
|
|
45
51
|
import { pollSlack } from "../poller/slack-poller.mjs";
|
|
46
52
|
import { pollGmail } from "../poller/gmail-poller.mjs";
|
|
47
53
|
import { pollCalendar } from "../poller/calendar-poller.mjs";
|
|
@@ -71,6 +77,7 @@ import {
|
|
|
71
77
|
openObligations,
|
|
72
78
|
escalate,
|
|
73
79
|
closeObligation,
|
|
80
|
+
isPidAlive,
|
|
74
81
|
} from "./assurance.mjs";
|
|
75
82
|
import { recordPoll, recordClassification, recordSession, writeHealthDashboard } from "./health.mjs";
|
|
76
83
|
import { acquireLock, releaseLock, updateLock, scanStaleLocks, acquireThreadLock, claimRequest, hasActiveClaim, sweepStaleItemClaims, sanitiseItemId } from "./session-lock.mjs";
|
|
@@ -85,7 +92,12 @@ const RUNG_MECHANISM = Object.fromEntries(RUNGS.map((r) => [r.id, r.mechanism]))
|
|
|
85
92
|
// work and land it in the org's shared, ACL'd store so the fleet's memory
|
|
86
93
|
// compounds. Fail-open + best-effort — never blocks or throws into the hot path;
|
|
87
94
|
// local SQLite/interaction logs remain the fail-open cache.
|
|
88
|
-
import { isEnabled as orgEnabled, loadOrgConfig, configFromAgent, electResponder, fetchSelfProfile } from "../../lib/org/client.mjs";
|
|
95
|
+
import { isEnabled as orgEnabled, loadOrgConfig, configFromAgent, electResponder, fetchSelfProfile, listMineResult as orgListMineResult } from "../../lib/org/client.mjs";
|
|
96
|
+
// board.mine cache (design §3.5, WP-M6): the daemon writes state/org/board-mine.json
|
|
97
|
+
// every 5 min; the SessionStart primer, session_status and `maestro board mine`
|
|
98
|
+
// read it. The when/what is pure (lib/org/board-mine-cache.mjs); the I/O is here.
|
|
99
|
+
import { shouldRefresh as boardMineShouldRefresh, renderRefresh as renderBoardMineRefresh, cacheTs as boardMineCacheTs, BOARD_MINE_REL } from "../../lib/org/board-mine-cache.mjs";
|
|
100
|
+
import { writeJsonAtomic as writeJsonAtomicFile } from "../../lib/fs-atomic.mjs";
|
|
89
101
|
import { remember as orgRemember } from "../../lib/org/knowledge.mjs";
|
|
90
102
|
import { sweepSessionOutcomes, resultTextFromStdout } from "./session-outcomes.mjs";
|
|
91
103
|
import { recordWorkStep as orgRecordWorkStep, cohortChannelId, cohortMessageId } from "../../lib/org/work-ledger.mjs";
|
|
@@ -117,6 +129,13 @@ import { getHookBus } from "../../lib/hooks/bus.mjs";
|
|
|
117
129
|
// classifier path below is the mechanism the ladder's `react` effect drives,
|
|
118
130
|
// not a parallel universe.
|
|
119
131
|
import { processOne } from "../../lib/execution/pipeline.mjs";
|
|
132
|
+
// FRONT DOOR (design §3.1/§3.3). When the seat's main session is live, the
|
|
133
|
+
// daemon leaves Cohort inbox items in place for the session to claim instead
|
|
134
|
+
// of spawning `claude --print`; not live → today's dispatch, unchanged. The
|
|
135
|
+
// gate is pure (lib/session/frontdoor.mjs); the assurance sweep reopens a
|
|
136
|
+
// session claim that was neither replied nor done within 20 min.
|
|
137
|
+
import { makeFrontDoorGate } from "../../lib/session/frontdoor.mjs";
|
|
138
|
+
import { sweepSessionInboxForDaemon } from "../../lib/session/inbox-claims.mjs";
|
|
120
139
|
import { defaultEffects, scheduleToQueue } from "../../lib/execution/effects.mjs";
|
|
121
140
|
import { loadReactObligations } from "../../lib/execution/match.mjs";
|
|
122
141
|
import { policyFor } from "../../lib/execution/surface-policy.mjs";
|
|
@@ -227,8 +246,13 @@ async function poll() {
|
|
|
227
246
|
* cohort path can run on its own fast interval (pollCohortFast) without waiting
|
|
228
247
|
* on the 60s full poll. Returns the count of new items processed. Never throws.
|
|
229
248
|
*/
|
|
249
|
+
const _frontDoorGate = makeFrontDoorGate({ agentRoot: AGENT_REPO_DIR });
|
|
230
250
|
async function pollService(svc) {
|
|
231
251
|
try {
|
|
252
|
+
// Front door: a live main session owns this service's inbox — leave it.
|
|
253
|
+
const fd = _frontDoorGate.check(svc.name);
|
|
254
|
+
if (fd.changed) console.log(`[daemon] front door for ${svc.name}: ${fd.dispatch ? "daemon dispatches" : "main session owns intake"} (${fd.reason})`);
|
|
255
|
+
if (!fd.dispatch) return 0;
|
|
232
256
|
const result = await svc.fn();
|
|
233
257
|
// Dedup by raw_ref first: the same Slack message arrives via multiple paths
|
|
234
258
|
// (channel scan, thread scan, events-server JSON) with different item IDs.
|
|
@@ -236,12 +260,18 @@ async function pollService(svc) {
|
|
|
236
260
|
// once regardless of path.
|
|
237
261
|
const seenRefs = new Set();
|
|
238
262
|
const newItems = result.items.filter((item) => {
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
263
|
+
// SELF-ECHO DROP (ingestion guard #1). The agent's own outbound, echoed
|
|
264
|
+
// back by a lane, is not inbound. Dropped LOUDLY (audit log) and the inbox
|
|
265
|
+
// file is retired — without the rename the same echo was re-scanned and
|
|
266
|
+
// re-considered every tick forever. The old check here compared `sender`
|
|
267
|
+
// against `fullName` with spaces folded to dashes, which never matched the
|
|
268
|
+
// real event shape ("Isla Roselli") — the exact reason the 25/27/30 Aug
|
|
269
|
+
// self-reply loops got past it.
|
|
270
|
+
if (isSelfSender(item)) {
|
|
271
|
+
logSelfEchoDrop(item, { stage: "poll", service: svc.name });
|
|
272
|
+
markProcessed(item, svc.name);
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
245
275
|
|
|
246
276
|
const lockKey = item.raw_ref || item.id || `${svc.name}-${Date.now()}`;
|
|
247
277
|
if (seenRefs.has(lockKey)) return false;
|
|
@@ -278,7 +308,7 @@ async function pollService(svc) {
|
|
|
278
308
|
// against the 60s poll's own cohort scan (kept as a safety net).
|
|
279
309
|
let _cohortPoller = null;
|
|
280
310
|
let _cohortFastRunning = false;
|
|
281
|
-
async function pollCohortFast() {
|
|
311
|
+
export async function pollCohortFast() {
|
|
282
312
|
if (_cohortFastRunning) return 0;
|
|
283
313
|
if (!_existsSync(join(AGENT_REPO_DIR, "config/org.yaml"))) return 0;
|
|
284
314
|
if (!_cohortPoller) _cohortPoller = makeInboxScanPoller("cohort", { agentRoot: AGENT_REPO_DIR });
|
|
@@ -339,6 +369,18 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
|
|
|
339
369
|
// regression-prone line in the file.
|
|
340
370
|
enrichItem(item, service);
|
|
341
371
|
|
|
372
|
+
// ── SELF-ECHO DROP (guard #2, the choke point) ───────────────────────────
|
|
373
|
+
// Every entry path — the 60s poll, the fast cohort scan, crash recovery —
|
|
374
|
+
// crosses this line, so an echo that slipped past a lane's own filter is
|
|
375
|
+
// still stopped before it can be classified, elected on, acked or answered.
|
|
376
|
+
// Retiring the file is part of the drop: an un-retired echo is re-delivered
|
|
377
|
+
// next poll and re-dropped forever, which is noise pretending to be safety.
|
|
378
|
+
if (isSelfSender(item)) {
|
|
379
|
+
logSelfEchoDrop(item, { stage: "process", service });
|
|
380
|
+
markProcessed(item, service);
|
|
381
|
+
return { ok: true, path: "filtered", reason: "self_echo" };
|
|
382
|
+
}
|
|
383
|
+
|
|
342
384
|
// ── THE EXECUTION LADDER, at the front of the chain ──────────────────────
|
|
343
385
|
// Returns `{ handled }`. `handled:true` means the ladder took responsibility
|
|
344
386
|
// for this item — it ignored it (journalled), queued it, delegated it,
|
|
@@ -899,6 +941,20 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
|
|
|
899
941
|
const ackVerdict = _claudeAvailable()
|
|
900
942
|
? shouldAcknowledge({ willSpawnSession: true, item, source: "inbox" })
|
|
901
943
|
: { ack: false, reason: "claude-unavailable" };
|
|
944
|
+
// ── DISPATCH GATE: emitter-class inbound spawns NOTHING ─────────────────
|
|
945
|
+
// `emitterClass: true` means the gate read the inbound and recognised output
|
|
946
|
+
// this seat's own machinery class produces — a peer agent's ack-shaped
|
|
947
|
+
// one-liner, or another daemon's narrator line (progress/failure/terminal).
|
|
948
|
+
// Suppressing only the ack while still spawning would keep the expensive
|
|
949
|
+
// half of the 25/27/30 Aug loop: a 15-45 minute session burned answering a
|
|
950
|
+
// reflex. The gate already audited the refusal
|
|
951
|
+
// (logs/daemon/dispatch-gate-drops.jsonl); retire the item and stop here.
|
|
952
|
+
if (!ackVerdict.ack && ackVerdict.emitterClass) {
|
|
953
|
+
console.log(`[daemon] dispatch gate: no ack, no session for ${itemId} (${ackVerdict.reason})`);
|
|
954
|
+
counters.bump("dispatch.gate_refused", { service, reason: ackVerdict.reason });
|
|
955
|
+
markProcessed(item, service);
|
|
956
|
+
return { ok: true, path: "filtered", reason: ackVerdict.reason };
|
|
957
|
+
}
|
|
902
958
|
try {
|
|
903
959
|
// The DEBT is opened whether or not we can speak. Those are two different
|
|
904
960
|
// questions and conflating them is what made a whole class of ask
|
|
@@ -1712,6 +1768,32 @@ export function _resetDaemonOrgCfgCache() {
|
|
|
1712
1768
|
_cachedDaemonOrgCfg = undefined;
|
|
1713
1769
|
}
|
|
1714
1770
|
|
|
1771
|
+
/**
|
|
1772
|
+
* Refresh state/org/board-mine.json from hq `board.mine` when the cache is
|
|
1773
|
+
* 5 min old (or absent). Fail-open end to end: an un-enrolled seat writes
|
|
1774
|
+
* nothing; a FAILED read (hq unreachable) keeps the previous items and marks
|
|
1775
|
+
* the document `stale: true` with `itemsAt` (lib/org/board-mine-cache.mjs
|
|
1776
|
+
* renderRefresh) — one bad tick must not blank the primer, session_status
|
|
1777
|
+
* and `maestro board mine` for five minutes. `listMineImpl` may return the
|
|
1778
|
+
* `{ok, items}` shape of listMineResult or a bare array (= ok).
|
|
1779
|
+
* @param {{now?:number, listMineImpl?:Function, force?:boolean}} [deps]
|
|
1780
|
+
* @returns {Promise<{refreshed:boolean, reason:string, count?:number}>}
|
|
1781
|
+
*/
|
|
1782
|
+
export async function refreshBoardMineCache(deps = {}) {
|
|
1783
|
+
const now = Number.isFinite(deps.now) ? deps.now : Date.now();
|
|
1784
|
+
const path = join(AGENT_REPO_DIR, BOARD_MINE_REL);
|
|
1785
|
+
let cur = null;
|
|
1786
|
+
try { cur = JSON.parse(readFileSync(path, "utf8")); } catch { cur = null; /* absent or corrupt → refresh */ }
|
|
1787
|
+
if (!deps.force && !boardMineShouldRefresh(boardMineCacheTs(cur), now)) return { refreshed: false, reason: "fresh" };
|
|
1788
|
+
const cfg = daemonOrgCfg();
|
|
1789
|
+
if (!orgEnabled(cfg)) return { refreshed: false, reason: "org-disabled" };
|
|
1790
|
+
const conn = configFromAgent(cfg) || {};
|
|
1791
|
+
const result = await (deps.listMineImpl || orgListMineResult)({ base: conn.base, token: conn.token, orgId: conn.orgId });
|
|
1792
|
+
const doc = renderBoardMineRefresh(result, cur, now);
|
|
1793
|
+
writeJsonAtomicFile(path, doc);
|
|
1794
|
+
return { refreshed: true, reason: doc.stale ? "kept-previous" : "written", count: doc.items.length };
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1715
1797
|
/** Longest completion note we will put on a board comment. */
|
|
1716
1798
|
const COMPLETION_NOTE_CHARS = 1200;
|
|
1717
1799
|
|
|
@@ -2179,6 +2261,38 @@ async function sweepBacklog() {
|
|
|
2179
2261
|
// Main
|
|
2180
2262
|
// ---------------------------------------------------------------------------
|
|
2181
2263
|
|
|
2264
|
+
/**
|
|
2265
|
+
* Daemon singleton ADVISORY — a pidfile check, deliberately not a lock.
|
|
2266
|
+
*
|
|
2267
|
+
* Two live daemons on one AGENT_DIR (the operator's manual run next to the
|
|
2268
|
+
* launchd one) both poll, both sweep, and both narrate; the per-obligation
|
|
2269
|
+
* ownership guard in assurance.sweepObligations bounds the damage, but the
|
|
2270
|
+
* condition itself deserves a LOUD line the moment it starts. This only logs
|
|
2271
|
+
* and counts — it never refuses to start and never exits — because launchd's
|
|
2272
|
+
* KeepAlive restart may race the old process's teardown, and a pidfile that
|
|
2273
|
+
* can veto startup under KeepAlive is a daemon that stays down. A recycled pid
|
|
2274
|
+
* can false-positive; that costs one loud log line, nothing more.
|
|
2275
|
+
*/
|
|
2276
|
+
function advisePidfileSingleton() {
|
|
2277
|
+
const pidPath = join(AGENT_REPO_DIR, "state", "daemon.pid");
|
|
2278
|
+
try {
|
|
2279
|
+
let prior = null;
|
|
2280
|
+
try { prior = parseInt(readFileSync(pidPath, "utf-8"), 10); } catch { /* no pidfile yet */ }
|
|
2281
|
+
if (Number.isFinite(prior) && prior > 0 && prior !== process.pid && isPidAlive(prior)) {
|
|
2282
|
+
console.error(
|
|
2283
|
+
`[daemon] ⚠ ANOTHER DAEMON MAY BE LIVE: pid ${prior} (from ${pidPath}) is running and also holds ${AGENT_REPO_DIR}. ` +
|
|
2284
|
+
`Two daemons on one AGENT_DIR double-poll and race the obligation ledger — stop one unless this is deliberate.`,
|
|
2285
|
+
);
|
|
2286
|
+
counters.bump("daemon.singleton_advisory", { prior_pid: prior });
|
|
2287
|
+
}
|
|
2288
|
+
mkdirSync(join(AGENT_REPO_DIR, "state"), { recursive: true });
|
|
2289
|
+
writeFileSync(pidPath, `${process.pid}\n`);
|
|
2290
|
+
} catch (err) {
|
|
2291
|
+
// Advisory only — an unwritable pidfile must never stop the daemon booting.
|
|
2292
|
+
console.warn(`[daemon] pidfile advisory unavailable: ${err.message}`);
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2182
2296
|
async function main() {
|
|
2183
2297
|
console.log("╔══════════════════════════════════════════════════════════╗");
|
|
2184
2298
|
console.log(`║ ${(loadAgent().firstName || "Agent").padEnd(8)} Daemon — Reactive Event Processor ║`);
|
|
@@ -2205,6 +2319,11 @@ async function main() {
|
|
|
2205
2319
|
// No emergency stop — continue
|
|
2206
2320
|
}
|
|
2207
2321
|
|
|
2322
|
+
// Two-daemon advisory: log loudly if another live daemon already holds this
|
|
2323
|
+
// AGENT_DIR (pidfile check; never blocks startup — see the function's note on
|
|
2324
|
+
// launchd KeepAlive semantics), then record this process as the holder.
|
|
2325
|
+
advisePidfileSingleton();
|
|
2326
|
+
|
|
2208
2327
|
// Clear orphaned state from prior daemon instances
|
|
2209
2328
|
const staleCleared = scanStaleLocks();
|
|
2210
2329
|
if (staleCleared > 0) {
|
|
@@ -2242,6 +2361,9 @@ async function main() {
|
|
|
2242
2361
|
|
|
2243
2362
|
// Initial poll immediately
|
|
2244
2363
|
await poll();
|
|
2364
|
+
// First board.mine cache write, so the primer has something before the
|
|
2365
|
+
// first health tick (fail-open; an un-enrolled seat writes nothing).
|
|
2366
|
+
refreshBoardMineCache().catch((err) => console.error("[daemon] board-mine cache error:", err.message));
|
|
2245
2367
|
|
|
2246
2368
|
// Poll loop
|
|
2247
2369
|
setInterval(async () => {
|
|
@@ -2285,6 +2407,12 @@ async function main() {
|
|
|
2285
2407
|
if (claimsSwept > 0) {
|
|
2286
2408
|
console.log(`[daemon] Swept ${claimsSwept} stale item claims`);
|
|
2287
2409
|
}
|
|
2410
|
+
// Front door: reopen session claims older than 20 min, due deferrals and
|
|
2411
|
+
// (while the session is live) the daemon's own orphaned .dispatched files.
|
|
2412
|
+
const reopened = sweepSessionInboxForDaemon(AGENT_REPO_DIR).reopened;
|
|
2413
|
+
if (reopened.length > 0) console.log(`[daemon] reopened ${reopened.length} session-claimed inbox item(s): ${reopened.map((r) => `${r.id} (${r.reason})`).join(", ")}`);
|
|
2414
|
+
// board.mine cache — the pure gate says "no" for four of every five ticks.
|
|
2415
|
+
refreshBoardMineCache().catch((err) => console.error("[daemon] board-mine cache error:", err.message));
|
|
2288
2416
|
} catch (err) {
|
|
2289
2417
|
console.error("[daemon] Health write error:", err.message);
|
|
2290
2418
|
}
|
|
@@ -2300,18 +2428,21 @@ async function main() {
|
|
|
2300
2428
|
}, HEALTH_INTERVAL);
|
|
2301
2429
|
|
|
2302
2430
|
// Graceful shutdown — clean up active.json so next startup doesn't see stale sessions
|
|
2431
|
+
// Exit honours a pre-set process.exitCode: the org-mesh "restart" directive
|
|
2432
|
+
// sets 1 before signalling so launchd's KeepAlive {SuccessfulExit:false}
|
|
2433
|
+
// relaunches the daemon; a plain SIGTERM still exits 0 (stays down).
|
|
2303
2434
|
process.on("SIGTERM", () => {
|
|
2304
2435
|
console.log("[daemon] SIGTERM received, shutting down gracefully");
|
|
2305
2436
|
resetActiveSessions();
|
|
2306
2437
|
writeHealthDashboard();
|
|
2307
|
-
process.exit(0);
|
|
2438
|
+
process.exit(process.exitCode || 0);
|
|
2308
2439
|
});
|
|
2309
2440
|
|
|
2310
2441
|
process.on("SIGINT", () => {
|
|
2311
2442
|
console.log("[daemon] SIGINT received, shutting down");
|
|
2312
2443
|
resetActiveSessions();
|
|
2313
2444
|
writeHealthDashboard();
|
|
2314
|
-
process.exit(0);
|
|
2445
|
+
process.exit(process.exitCode || 0);
|
|
2315
2446
|
});
|
|
2316
2447
|
|
|
2317
2448
|
console.log("[daemon] Running. Ctrl+C to stop.");
|