@cohortapp/agent-sdk 2.3.2 → 2.4.1
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/framework-features.json +30 -0
- package/lib/backlog.mjs +136 -0
- package/lib/cadences.mjs +63 -2
- package/lib/cadences.test.mjs +105 -0
- package/lib/capability/inventory.mjs +542 -0
- package/lib/capability/inventory.test.mjs +232 -0
- package/lib/capability/probe.mjs +255 -0
- package/lib/channels/contract.mjs +37 -1
- package/lib/channels/contract.test.mjs +25 -1
- package/lib/claude-bin.mjs +37 -3
- package/lib/claude-bin.test.mjs +42 -8
- package/lib/execution/disposition.mjs +501 -0
- package/lib/execution/disposition.test.mjs +482 -0
- package/lib/execution/drive.mjs +352 -0
- package/lib/execution/drive.test.mjs +270 -0
- package/lib/execution/effects.mjs +340 -0
- package/lib/execution/effects.test.mjs +193 -0
- package/lib/execution/index.mjs +152 -0
- package/lib/execution/intake.mjs +581 -0
- package/lib/execution/intake.test.mjs +343 -0
- package/lib/execution/journal.mjs +374 -0
- package/lib/execution/journal.test.mjs +261 -0
- package/lib/execution/match.mjs +331 -0
- package/lib/execution/match.test.mjs +235 -0
- package/lib/execution/pipeline.mjs +341 -0
- package/lib/execution/pipeline.test.mjs +389 -0
- package/lib/execution/route.mjs +332 -0
- package/lib/execution/route.test.mjs +186 -0
- package/lib/execution/surface-policy.mjs +446 -0
- package/lib/execution/surface-policy.test.mjs +162 -0
- package/lib/goals/admission.mjs +209 -0
- package/lib/goals/admission.test.mjs +139 -0
- package/lib/goals/classify.mjs +206 -0
- package/lib/goals/classify.test.mjs +109 -0
- package/lib/goals/collaborate.mjs +415 -0
- package/lib/goals/collaborate.test.mjs +324 -0
- package/lib/goals/gaps.mjs +111 -0
- package/lib/goals/gaps.test.mjs +284 -0
- package/lib/goals/loop.mjs +537 -0
- package/lib/goals/loop.test.mjs +719 -0
- package/lib/identity/persona.mjs +247 -0
- package/lib/identity/persona.test.mjs +117 -0
- package/lib/kpi.mjs +469 -0
- package/lib/kpi.test.mjs +244 -0
- package/lib/mandate/audit.mjs +168 -0
- package/lib/mandate/audit.test.mjs +195 -0
- package/lib/mandate/cache.mjs +162 -0
- package/lib/mandate/derive.mjs +317 -0
- package/lib/mandate/derive.test.mjs +224 -0
- package/lib/mandate/model.mjs +352 -0
- package/lib/mandate/model.test.mjs +145 -0
- package/lib/mandate/refresh.mjs +187 -0
- package/lib/mandate/refresh.test.mjs +293 -0
- package/lib/mcp/server.test.mjs +4 -4
- package/lib/org/approvals.mjs +14 -2
- package/lib/org/client.mjs +58 -22
- package/lib/org/client.test.mjs +3 -1
- package/lib/org/inbound/directedness.mjs +720 -0
- package/lib/org/inbound/directedness.test.mjs +543 -0
- package/lib/org/inbound/facts.mjs +501 -0
- package/lib/org/inbound/facts.test.mjs +375 -0
- package/lib/org/inbound/hydrate.mjs +535 -0
- package/lib/org/inbound/hydrate.test.mjs +326 -0
- package/lib/org/inbound/index.mjs +233 -0
- package/lib/org/inbound/index.test.mjs +324 -0
- package/lib/org/inbound/io.mjs +141 -0
- package/lib/org/inbound/project.mjs +201 -0
- package/lib/org/inbound/project.test.mjs +287 -0
- package/lib/org/inbound/surfaces.mjs +257 -0
- package/lib/org/knowledge.mjs +10 -1
- package/lib/org/knowledge.test.mjs +8 -1
- package/lib/org/leases.mjs +5 -0
- package/lib/org/mesh.mjs +17 -2
- package/lib/org/messaging.mjs +40 -4
- package/lib/org/messaging.test.mjs +40 -0
- package/lib/org/param-contract.mjs +694 -0
- package/lib/org/param-contract.test.mjs +451 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +8 -0
- package/lib/org/protocol.test.mjs +5 -1
- package/lib/org/push.mjs +1025 -0
- package/lib/org/push.test.mjs +690 -0
- package/lib/org/tool-surface.mjs +138 -38
- package/lib/org/tool-surface.test.mjs +13 -8
- package/lib/org/typing.mjs +341 -0
- package/lib/org/typing.test.mjs +291 -0
- package/lib/plan/compile.mjs +510 -0
- package/lib/plan/compile.test.mjs +286 -0
- package/lib/plan/emit.mjs +256 -0
- package/lib/plan/emit.test.mjs +246 -0
- package/lib/plan/explain.mjs +226 -0
- package/lib/plan/explain.test.mjs +188 -0
- package/lib/plan/schema.mjs +140 -0
- package/lib/resource-governor.mjs +47 -1
- package/lib/resource-governor.test.mjs +21 -1
- package/lib/setup/enroll-from-cohort.mjs +105 -17
- package/lib/setup/enroll-from-cohort.test.mjs +68 -1
- package/lib/setup/sections/identity.mjs +15 -4
- package/lib/setup/sections/identity.test.mjs +94 -0
- package/lib/setup/sections/inventory.mjs +178 -0
- package/lib/setup/sections/inventory.test.mjs +198 -0
- package/lib/setup/sections/mandate.mjs +392 -0
- package/lib/setup/sections/mandate.test.mjs +373 -0
- package/lib/setup/sections/subagents.mjs +427 -0
- package/lib/setup/sections/subagents.test.mjs +429 -0
- package/lib/setup/sections/verify.mjs +121 -0
- package/lib/setup/sections/verify.test.mjs +175 -0
- package/lib/setup/sot.mjs +2 -0
- package/lib/subagents/cli.mjs +463 -0
- package/lib/subagents/cli.test.mjs +389 -0
- package/lib/subagents/client.mjs +373 -0
- package/lib/subagents/client.test.mjs +309 -0
- package/lib/subagents/gap.mjs +268 -0
- package/lib/subagents/gap.test.mjs +234 -0
- package/lib/subagents/lock.mjs +296 -0
- package/lib/subagents/lock.test.mjs +248 -0
- package/lib/subagents/manifest.mjs +224 -0
- package/lib/subagents/manifest.test.mjs +175 -0
- package/lib/subagents/refs.mjs +274 -0
- package/lib/subagents/refs.test.mjs +204 -0
- package/lib/subagents/resolve.mjs +455 -0
- package/lib/subagents/resolve.test.mjs +422 -0
- package/lib/subagents/schema.mjs +467 -0
- package/lib/subagents/schema.test.mjs +306 -0
- package/package.json +8 -3
- package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
- package/policies/ai-disclosure.yaml +42 -2
- package/scaffold/CLAUDE.md +16 -2
- package/schedules/triggers/goal-steward.md +79 -0
- package/scripts/ci/conformance-org-api.mjs +792 -0
- package/scripts/ci/conformance-org-api.test.mjs +417 -0
- package/scripts/daemon/agent-daemon.mjs +36 -4
- package/scripts/daemon/cadence-handlers.mjs +145 -1
- package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
- package/scripts/daemon/inbox-deferral.mjs +45 -2
- package/scripts/daemon/inbox-deferral.test.mjs +56 -0
- package/scripts/daemon/inbox-wake.mjs +282 -0
- package/scripts/daemon/inbox-wake.test.mjs +199 -0
- package/scripts/daemon/prompt-builder.mjs +41 -1
- package/scripts/daemon/typing-registry.mjs +55 -2
- package/scripts/daemon/typing-registry.test.mjs +25 -0
- package/scripts/local-triggers/generate-plists.test.mjs +5 -5
- package/scripts/setup/gen-subagent-manifest.mjs +95 -0
- package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
- package/scripts/setup/generate-plan.mjs +108 -0
- package/scripts/setup/init-capability-manifest.mjs +70 -0
- package/scripts/setup/init-skill-marketplace.mjs +155 -0
- package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/daemon/goal-steward-cadence.test.mjs — the wiring that makes the
|
|
3
|
+
* self-directed loop actually RUN.
|
|
4
|
+
*
|
|
5
|
+
* Before this, `lib/goals/loop.mjs` existed and nothing ever called it: no
|
|
6
|
+
* standard cadence, no registry entry, no plist. A loop nobody triggers is a
|
|
7
|
+
* design document. These tests pin the trigger down:
|
|
8
|
+
*
|
|
9
|
+
* - `goal-steward` is a STANDARD cadence, so every agent gets a plist for it
|
|
10
|
+
* - it is GUARDED, so an agent with no adopted mandate costs ~nothing
|
|
11
|
+
* - it refreshes the mandate BEFORE measuring
|
|
12
|
+
* - it defaults to observe-only, whatever the caller forgot to pass
|
|
13
|
+
* - it escalates only when an open gap needs candidates it cannot produce
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
"use strict";
|
|
17
|
+
|
|
18
|
+
import { test } from "node:test";
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
import { CADENCE_REGISTRY, getCadenceDef, guardGoalSteward } from "./cadence-handlers.mjs";
|
|
25
|
+
import { STANDARD_CADENCES } from "../../lib/cadences.mjs";
|
|
26
|
+
|
|
27
|
+
function makeRoot() {
|
|
28
|
+
const root = mkdtempSync(join(tmpdir(), "goal-cadence-"));
|
|
29
|
+
mkdirSync(join(root, "config"), { recursive: true });
|
|
30
|
+
return root;
|
|
31
|
+
}
|
|
32
|
+
function cleanup(root) { try { rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
33
|
+
|
|
34
|
+
/** Baseline seams: no network, no real loop. */
|
|
35
|
+
function seams(over = {}) {
|
|
36
|
+
return {
|
|
37
|
+
refreshImpl: async () => ({ ok: true, source: "server", tier: "fresh" }),
|
|
38
|
+
runImpl: async () => ({ ok: true, measured: [], admitted: [], rejected: [], escalations: [], drift: [], decisions: [] }),
|
|
39
|
+
...over,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Registration
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
test("goal-steward is a STANDARD cadence, so every agent gets one", () => {
|
|
48
|
+
const c = STANDARD_CADENCES.find((x) => x.id === "goal-steward");
|
|
49
|
+
assert.ok(c, "the loop must have a trigger or it never runs");
|
|
50
|
+
assert.equal(c.scope, "standard");
|
|
51
|
+
assert.equal(c.mode, "guarded");
|
|
52
|
+
// Daily, on a calendar slot — not a hot interval.
|
|
53
|
+
assert.deepEqual(c.calendar, { hour: 7, minute: 15 });
|
|
54
|
+
assert.equal(c.prompt, "schedules/triggers/goal-steward.md");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("the daemon can route the cadence it is about to be sent", () => {
|
|
58
|
+
const def = getCadenceDef("goal-steward");
|
|
59
|
+
assert.ok(def, "an unroutable cadence DLQs every tick");
|
|
60
|
+
assert.equal(def.mode, "guarded");
|
|
61
|
+
assert.equal(typeof def.guard, "function");
|
|
62
|
+
assert.equal(CADENCE_REGISTRY["goal-steward"].guard, guardGoalSteward);
|
|
63
|
+
// Scoped tool surface: reads + collaboration primitives, no shell.
|
|
64
|
+
const tools = CADENCE_REGISTRY["goal-steward"].allowedTools;
|
|
65
|
+
assert.ok(Array.isArray(tools) && tools.length > 0);
|
|
66
|
+
assert.ok(tools.includes("approval_request"), "gated work must be able to reach the approval path");
|
|
67
|
+
assert.ok(!tools.includes("Bash"), "the goal loop has no business running shell");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// The guard
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
test("the mandate is refreshed BEFORE the loop measures anything", async () => {
|
|
75
|
+
const root = makeRoot();
|
|
76
|
+
try {
|
|
77
|
+
const order = [];
|
|
78
|
+
await guardGoalSteward({ event: { cadence: "goal-steward" }, agentRoot: root, log: () => {} }, seams({
|
|
79
|
+
refreshImpl: async () => { order.push("refresh"); return { ok: true }; },
|
|
80
|
+
runImpl: async () => { order.push("run"); return { ok: true, measured: [], admitted: [], decisions: [] }; },
|
|
81
|
+
}));
|
|
82
|
+
assert.deepEqual(order, ["refresh", "run"], "measuring against a stale mandate is worse than not measuring");
|
|
83
|
+
} finally { cleanup(root); }
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("enforcement defaults to OBSERVE when config/plan.yaml says nothing", async () => {
|
|
87
|
+
const root = makeRoot();
|
|
88
|
+
try {
|
|
89
|
+
let seen = null;
|
|
90
|
+
await guardGoalSteward({ event: {}, agentRoot: root, log: () => {} }, seams({
|
|
91
|
+
runImpl: async (d) => { seen = d.enforcement; return { ok: true, measured: [], admitted: [], decisions: [] }; },
|
|
92
|
+
}));
|
|
93
|
+
assert.equal(seen, "observe", "a loop that starts by writing is how you lose a fleet");
|
|
94
|
+
} finally { cleanup(root); }
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("enforcement:active in config/plan.yaml turns the loop on", async () => {
|
|
98
|
+
const root = makeRoot();
|
|
99
|
+
try {
|
|
100
|
+
writeFileSync(join(root, "config", "plan.yaml"), "version: 1\nenforcement: active\nobligations: []\n", "utf-8");
|
|
101
|
+
let seen = null;
|
|
102
|
+
await guardGoalSteward({ event: {}, agentRoot: root, log: () => {} }, seams({
|
|
103
|
+
runImpl: async (d) => { seen = d.enforcement; return { ok: true, measured: [], admitted: [], decisions: [] }; },
|
|
104
|
+
}));
|
|
105
|
+
assert.equal(seen, "active");
|
|
106
|
+
} finally { cleanup(root); }
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("any other enforcement value stays observe — fail-closed on the write path", async () => {
|
|
110
|
+
const root = makeRoot();
|
|
111
|
+
try {
|
|
112
|
+
writeFileSync(join(root, "config", "plan.yaml"), "enforcement: yes-please\n", "utf-8");
|
|
113
|
+
let seen = null;
|
|
114
|
+
await guardGoalSteward({ event: {}, agentRoot: root, log: () => {} }, seams({
|
|
115
|
+
runImpl: async (d) => { seen = d.enforcement; return { ok: true, measured: [], admitted: [], decisions: [] }; },
|
|
116
|
+
}));
|
|
117
|
+
assert.equal(seen, "observe");
|
|
118
|
+
} finally { cleanup(root); }
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("no adopted mandate completes INLINE — an idle seat costs one cache read", async () => {
|
|
122
|
+
const root = makeRoot();
|
|
123
|
+
try {
|
|
124
|
+
const r = await guardGoalSteward({ event: {}, agentRoot: root, log: () => {} }, seams({
|
|
125
|
+
runImpl: async () => ({ ok: false, skipped: "no mandate cache — this seat has never fetched an adopted mandate" }),
|
|
126
|
+
}));
|
|
127
|
+
assert.equal(r.ok, true, "a pre-adoption seat is not a bus failure");
|
|
128
|
+
assert.equal(r.decision, "inline");
|
|
129
|
+
assert.match(r.reason, /never fetched an adopted mandate/);
|
|
130
|
+
} finally { cleanup(root); }
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("a satisfied mandate completes inline and never spawns a session", async () => {
|
|
134
|
+
const root = makeRoot();
|
|
135
|
+
try {
|
|
136
|
+
const r = await guardGoalSteward({ event: {}, agentRoot: root, log: () => {} }, seams({
|
|
137
|
+
runImpl: async () => ({
|
|
138
|
+
ok: true, measured: [{ objectiveKey: "pipeline", value: 4 }], admitted: [], rejected: [],
|
|
139
|
+
escalations: [], drift: [], decisions: [{ objectiveKey: "pipeline", action: "none" }],
|
|
140
|
+
}),
|
|
141
|
+
}));
|
|
142
|
+
assert.equal(r.decision, "inline");
|
|
143
|
+
assert.equal(r.measured, 1);
|
|
144
|
+
assert.equal(r.admitted, 0);
|
|
145
|
+
} finally { cleanup(root); }
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("a gap the deterministic planner SERVED completes inline — the work is already queued", async () => {
|
|
149
|
+
const root = makeRoot();
|
|
150
|
+
try {
|
|
151
|
+
writeFileSync(join(root, "config", "plan.yaml"), "enforcement: active\n", "utf-8");
|
|
152
|
+
const r = await guardGoalSteward({ event: {}, agentRoot: root, log: () => {} }, seams({
|
|
153
|
+
runImpl: async () => ({
|
|
154
|
+
ok: true,
|
|
155
|
+
measured: [{ objectiveKey: "pipeline", value: 1 }],
|
|
156
|
+
admitted: [{ objectiveKey: "pipeline", title: "Book calls" }],
|
|
157
|
+
rejected: [], escalations: [], drift: [],
|
|
158
|
+
decisions: [{ objectiveKey: "pipeline", action: "create" }],
|
|
159
|
+
}),
|
|
160
|
+
}));
|
|
161
|
+
assert.equal(r.decision, "inline", "no model is needed when the work already exists");
|
|
162
|
+
assert.equal(r.admitted, 1);
|
|
163
|
+
} finally { cleanup(root); }
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("an UNSERVED open gap escalates to a planning session, naming the objectives", async () => {
|
|
167
|
+
const root = makeRoot();
|
|
168
|
+
try {
|
|
169
|
+
writeFileSync(join(root, "config", "plan.yaml"), "enforcement: active\n", "utf-8");
|
|
170
|
+
const r = await guardGoalSteward({ event: {}, agentRoot: root, log: () => {} }, seams({
|
|
171
|
+
runImpl: async () => ({
|
|
172
|
+
ok: true,
|
|
173
|
+
measured: [{ objectiveKey: "pipeline", value: 1 }],
|
|
174
|
+
admitted: [], rejected: [], escalations: [], drift: [],
|
|
175
|
+
decisions: [{ objectiveKey: "pipeline", action: "create" }],
|
|
176
|
+
}),
|
|
177
|
+
}));
|
|
178
|
+
assert.equal(r.decision, "escalate");
|
|
179
|
+
assert.deepEqual(r.objectives, ["pipeline"]);
|
|
180
|
+
} finally { cleanup(root); }
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("OBSERVE-ONLY never escalates, even with an unserved gap", async () => {
|
|
184
|
+
const root = makeRoot();
|
|
185
|
+
try {
|
|
186
|
+
const r = await guardGoalSteward({ event: {}, agentRoot: root, log: () => {} }, seams({
|
|
187
|
+
runImpl: async () => ({
|
|
188
|
+
ok: true, measured: [], admitted: [], rejected: [], escalations: [], drift: [],
|
|
189
|
+
decisions: [{ objectiveKey: "pipeline", action: "create" }],
|
|
190
|
+
}),
|
|
191
|
+
}));
|
|
192
|
+
assert.equal(r.decision, "inline", "the observe window must not spend money on planning sessions");
|
|
193
|
+
} finally { cleanup(root); }
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Failure containment
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
test("SERVER UNREACHABLE: a failed mandate refresh degrades to the cache, loudly", async () => {
|
|
201
|
+
const root = makeRoot();
|
|
202
|
+
try {
|
|
203
|
+
const logs = [];
|
|
204
|
+
const r = await guardGoalSteward({ event: {}, agentRoot: root, log: (l, m) => logs.push(`${l}:${m}`) }, seams({
|
|
205
|
+
refreshImpl: async () => ({ ok: false, reason: "call-threw:ECONNREFUSED" }),
|
|
206
|
+
}));
|
|
207
|
+
assert.equal(r.ok, true, "a partition must not stop the loop from running on cache");
|
|
208
|
+
assert.ok(logs.some((l) => /refresh failed/.test(l)));
|
|
209
|
+
} finally { cleanup(root); }
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("SERVER UNREACHABLE: a THROWING refresh is contained and logged", async () => {
|
|
213
|
+
const root = makeRoot();
|
|
214
|
+
try {
|
|
215
|
+
const logs = [];
|
|
216
|
+
const r = await guardGoalSteward({ event: {}, agentRoot: root, log: (l, m) => logs.push(`${l}:${m}`) }, seams({
|
|
217
|
+
refreshImpl: async () => { throw new Error("ECONNREFUSED"); },
|
|
218
|
+
}));
|
|
219
|
+
assert.equal(r.ok, true);
|
|
220
|
+
assert.ok(logs.some((l) => /refresh threw/.test(l)));
|
|
221
|
+
} finally { cleanup(root); }
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("a THROWING loop returns ok:false with the error — fail-open but never silent", async () => {
|
|
225
|
+
const root = makeRoot();
|
|
226
|
+
try {
|
|
227
|
+
const logs = [];
|
|
228
|
+
const r = await guardGoalSteward({ event: {}, agentRoot: root, log: (l, m) => logs.push(`${l}:${m}`) }, seams({
|
|
229
|
+
runImpl: async () => { throw new Error("loop exploded"); },
|
|
230
|
+
}));
|
|
231
|
+
assert.equal(r.ok, false);
|
|
232
|
+
assert.match(r.error, /loop exploded/);
|
|
233
|
+
assert.ok(logs.some((l) => l.startsWith("error:")), "the daemon keeps running, the failure is on the record");
|
|
234
|
+
} finally { cleanup(root); }
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("the guard never throws, whatever it is handed", async () => {
|
|
238
|
+
const r = await guardGoalSteward({ event: null, agentRoot: "/nonexistent-xyz", log: null }, seams({
|
|
239
|
+
runImpl: async () => null,
|
|
240
|
+
}));
|
|
241
|
+
assert.equal(typeof r.ok, "boolean");
|
|
242
|
+
assert.equal(r.cadence, "goal-steward");
|
|
243
|
+
});
|
|
@@ -37,7 +37,36 @@
|
|
|
37
37
|
import { readdirSync, readFileSync, renameSync, existsSync } from "node:fs";
|
|
38
38
|
import { join } from "node:path";
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
import { signalInbox } from "./inbox-wake.mjs";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Fallback service list, used only when `state/inbox/` cannot be enumerated.
|
|
44
|
+
*
|
|
45
|
+
* It used to be the ONLY list, and it was written before the channel-bus and
|
|
46
|
+
* org services existed: `cohort`, `orgmail` and `telegram` were never in it. A
|
|
47
|
+
* deferred Cohort DM (the second message of any rapid-fire burst) therefore had
|
|
48
|
+
* NO promote path at all — `promoteDeferred` looked in six directories, none of
|
|
49
|
+
* which was `state/inbox/cohort/`, found nothing, and the message stayed
|
|
50
|
+
* `.deferred` forever. The list is now derived from disk (below) so a new
|
|
51
|
+
* service can never be silently un-promotable again.
|
|
52
|
+
*/
|
|
53
|
+
const FALLBACK_SERVICE_DIRS = ["slack", "gmail", "calendar", "internal", "sms", "whatsapp"];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every service inbox that exists on disk. Derived, not enumerated — the
|
|
57
|
+
* directories ARE the registry (the daemon creates one per configured service).
|
|
58
|
+
* @param {string} agentRoot
|
|
59
|
+
* @returns {string[]}
|
|
60
|
+
*/
|
|
61
|
+
function serviceDirs(agentRoot) {
|
|
62
|
+
try {
|
|
63
|
+
return readdirSync(join(agentRoot, "state", "inbox"), { withFileTypes: true })
|
|
64
|
+
.filter((e) => e.isDirectory())
|
|
65
|
+
.map((e) => e.name);
|
|
66
|
+
} catch {
|
|
67
|
+
return FALLBACK_SERVICE_DIRS;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
41
70
|
|
|
42
71
|
/**
|
|
43
72
|
* Rename a live inbox file to its `.deferred` suffix so the scanner
|
|
@@ -142,7 +171,7 @@ export function promoteDeferred(channel, agentRoot) {
|
|
|
142
171
|
const result = { promoted: 0, bundled: 0, service: null };
|
|
143
172
|
if (!channel || !agentRoot) return result;
|
|
144
173
|
|
|
145
|
-
for (const service of
|
|
174
|
+
for (const service of serviceDirs(agentRoot)) {
|
|
146
175
|
const inboxDir = join(agentRoot, "state", "inbox", service);
|
|
147
176
|
if (!existsSync(inboxDir)) continue;
|
|
148
177
|
|
|
@@ -206,5 +235,19 @@ export function promoteDeferred(channel, agentRoot) {
|
|
|
206
235
|
}
|
|
207
236
|
}
|
|
208
237
|
|
|
238
|
+
// The promoted item is live on disk RIGHT NOW, and this runs on the
|
|
239
|
+
// dispatcher's session-close path — i.e. the moment a burst's next message
|
|
240
|
+
// becomes answerable. Without a nudge it waits for the daemon's next inbox
|
|
241
|
+
// sweep, which is the single largest term in the reply latency of a
|
|
242
|
+
// back-to-back conversation. Free and idempotent: a wake with no work costs
|
|
243
|
+
// one readdir (scripts/daemon/inbox-wake.mjs).
|
|
244
|
+
if (result.promoted > 0) {
|
|
245
|
+
try {
|
|
246
|
+
signalInbox("promote-deferred");
|
|
247
|
+
} catch {
|
|
248
|
+
/* the wake is an optimisation — never let it break the promote */
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
209
252
|
return result;
|
|
210
253
|
}
|
|
@@ -240,3 +240,59 @@ test("L7: latest-wins uses top-level timestamp, not a body `timestamp:` line", (
|
|
|
240
240
|
rmSync(root, { recursive: true, force: true });
|
|
241
241
|
}
|
|
242
242
|
});
|
|
243
|
+
|
|
244
|
+
// --- service-dir discovery (the cohort/orgmail regression) -------------------
|
|
245
|
+
|
|
246
|
+
/** Write a deferred item into ANY service dir (creating it). */
|
|
247
|
+
function writeDeferredIn(root, service, name, { channel_id, timestamp, id }) {
|
|
248
|
+
mkdirSync(join(root, "state", "inbox", service), { recursive: true });
|
|
249
|
+
const body = [
|
|
250
|
+
`id: "${id}"`,
|
|
251
|
+
`service: "${service}"`,
|
|
252
|
+
`channel_id: "${channel_id}"`,
|
|
253
|
+
`timestamp: "${timestamp}"`,
|
|
254
|
+
`content: |`,
|
|
255
|
+
` body`,
|
|
256
|
+
"",
|
|
257
|
+
].join("\n");
|
|
258
|
+
writeFileSync(join(root, "state", "inbox", service, name), body);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
test("promoteDeferred finds services the hardcoded list never knew about (cohort/orgmail)", () => {
|
|
262
|
+
const root = makeAgentRoot();
|
|
263
|
+
try {
|
|
264
|
+
writeDeferredIn(root, "cohort", "cohort-msg.yaml.deferred", {
|
|
265
|
+
channel_id: "ch_build",
|
|
266
|
+
timestamp: "2026-08-11T00:00:00Z",
|
|
267
|
+
id: "cohort-msg",
|
|
268
|
+
});
|
|
269
|
+
const r = promoteDeferred("ch_build", root);
|
|
270
|
+
assert.equal(r.promoted, 1, "a deferred Cohort message was previously un-promotable forever");
|
|
271
|
+
assert.equal(r.service, "cohort");
|
|
272
|
+
assert.deepEqual(readdirSync(join(root, "state", "inbox", "cohort")), ["cohort-msg.yaml"]);
|
|
273
|
+
} finally {
|
|
274
|
+
rmSync(root, { recursive: true, force: true });
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("promoteDeferred signals the inbox wake so the promoted item is not stuck behind the poll", async () => {
|
|
279
|
+
const { waitForInboxWake, resetInboxWake } = await import("./inbox-wake.mjs");
|
|
280
|
+
const root = makeAgentRoot();
|
|
281
|
+
resetInboxWake();
|
|
282
|
+
try {
|
|
283
|
+
writeDeferredIn(root, "cohort", "burst-2.yaml.deferred", {
|
|
284
|
+
channel_id: "ch_dm",
|
|
285
|
+
timestamp: "2026-08-11T00:00:01Z",
|
|
286
|
+
id: "burst-2",
|
|
287
|
+
});
|
|
288
|
+
// scanMs is disabled so ONLY the in-process signal can settle this.
|
|
289
|
+
const waiting = waitForInboxWake({ agentRoot: root, timeoutMs: 3_000, scanMs: 600_000 });
|
|
290
|
+
setTimeout(() => promoteDeferred("ch_dm", root), 20);
|
|
291
|
+
const woke = await waiting;
|
|
292
|
+
assert.equal(woke.woke, true);
|
|
293
|
+
assert.equal(woke.service, "cohort");
|
|
294
|
+
} finally {
|
|
295
|
+
resetInboxWake();
|
|
296
|
+
rmSync(root, { recursive: true, force: true });
|
|
297
|
+
}
|
|
298
|
+
});
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/daemon/inbox-wake.mjs — the daemon's "something landed" wake signal.
|
|
3
|
+
*
|
|
4
|
+
* ## The hop this removes
|
|
5
|
+
*
|
|
6
|
+
* The inbound ladder for a Cohort message is: hq commits → the messaging-inbound
|
|
7
|
+
* cadence pulls it → an inbox YAML file is written under
|
|
8
|
+
* `state/inbox/<service>/` → **the daemon notices on its next poll** → classify →
|
|
9
|
+
* dispatch. That last arrow was a flat `setInterval(poll, 60_000)`: a mean 30s
|
|
10
|
+
* and worst-case 60s of doing nothing while the work sat on disk, already
|
|
11
|
+
* fetched. With the push channel (`lib/org/push.mjs`) collapsing the two cadence
|
|
12
|
+
* hops above it from ~47s to ~1s, this became the single dominant term in the
|
|
13
|
+
* time-to-first-token budget.
|
|
14
|
+
*
|
|
15
|
+
* It is also the one hop that no amount of server-side work can fix, because
|
|
16
|
+
* nothing crosses the process boundary: the writer and the reader are two
|
|
17
|
+
* processes on the same box sharing a directory. So the fix belongs here.
|
|
18
|
+
*
|
|
19
|
+
* ## Design: a wake with a floor, not a watch you have to trust
|
|
20
|
+
*
|
|
21
|
+
* `waitForInboxWake()` resolves as soon as a live inbox item exists, and returns
|
|
22
|
+
* on `timeoutMs` otherwise — so the caller's loop shape is unchanged, only its
|
|
23
|
+
* sleep gets interrupted. Three independent sources feed it:
|
|
24
|
+
*
|
|
25
|
+
* 1. **fs.watch** on each service dir — near-zero latency, cross-process
|
|
26
|
+
* (it sees the cadence consumer's write from another PID).
|
|
27
|
+
* 2. **a 1s rescan** — the FLOOR. fs.watch is genuinely unreliable in the
|
|
28
|
+
* cases that matter here (a dir that does not exist yet, a network/synth
|
|
29
|
+
* FS, watcher-limit exhaustion, macOS coalescing), and a missed wake is a
|
|
30
|
+
* silently late agent — the exact failure class this pipeline keeps
|
|
31
|
+
* producing. Correctness therefore does NOT depend on the watcher: with
|
|
32
|
+
* every watcher dead this degrades to a 1s poll, not to 60s.
|
|
33
|
+
* 3. **{@link signalInbox}** — an in-process nudge for a writer in THIS
|
|
34
|
+
* process (the dispatcher promoting a deferred item on session close),
|
|
35
|
+
* which needs no filesystem round-trip at all.
|
|
36
|
+
*
|
|
37
|
+
* ## Why "a live item exists", not "an event fired"
|
|
38
|
+
*
|
|
39
|
+
* Every rename in these dirs raises watch events, including
|
|
40
|
+
* `x.yaml → x.yaml.processed` (which fires for BOTH names). Waking on raw events
|
|
41
|
+
* would spin the daemon every time it finished an item. The predicate is
|
|
42
|
+
* therefore a state check — "is there a file with no terminal suffix?" — which
|
|
43
|
+
* is false immediately after a drain and true only when there is genuine work.
|
|
44
|
+
* That makes a wake idempotent and a missed event harmless.
|
|
45
|
+
*
|
|
46
|
+
* Fail-open and LOUD: a watcher that cannot be created is logged once per
|
|
47
|
+
* directory and the scan floor carries the load.
|
|
48
|
+
*
|
|
49
|
+
* Node builtins only. ESM. Timers/fs injectable for tests.
|
|
50
|
+
*
|
|
51
|
+
* @module scripts/daemon/inbox-wake
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
"use strict";
|
|
55
|
+
|
|
56
|
+
import { existsSync, readdirSync, watch } from "node:fs";
|
|
57
|
+
import { join } from "node:path";
|
|
58
|
+
|
|
59
|
+
import { resolveAgentRoot } from "../../lib/agent-root.mjs";
|
|
60
|
+
|
|
61
|
+
/** Suffixes the daemon's scanner skips — a file with one of these is not work. */
|
|
62
|
+
const TERMINAL_SUFFIXES = [".processed", ".deferred", ".processed-bundled", ".claimed"];
|
|
63
|
+
|
|
64
|
+
/** How often the wake rescans regardless of watch events (the floor). */
|
|
65
|
+
export const DEFAULT_SCAN_MS = 1_000;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Coalescing window after a watch event before the predicate is evaluated. A
|
|
69
|
+
* write + rename burst (the cadence writer does both) must produce ONE wake.
|
|
70
|
+
*/
|
|
71
|
+
export const DEFAULT_DEBOUNCE_MS = 60;
|
|
72
|
+
|
|
73
|
+
/** The inbox services the daemon drains via a directory scan. */
|
|
74
|
+
export const DEFAULT_SERVICES = Object.freeze([
|
|
75
|
+
"cohort",
|
|
76
|
+
"orgmail",
|
|
77
|
+
"telegram",
|
|
78
|
+
"whatsapp",
|
|
79
|
+
"slack",
|
|
80
|
+
"gmail",
|
|
81
|
+
"alex-gmail",
|
|
82
|
+
"calendar",
|
|
83
|
+
"voice",
|
|
84
|
+
"internal",
|
|
85
|
+
"sms",
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
/** In-process listeners (see {@link signalInbox}). */
|
|
89
|
+
const listeners = new Set();
|
|
90
|
+
|
|
91
|
+
/** Directories we already reported an un-watchable error for (log once). */
|
|
92
|
+
const watchWarned = new Set();
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Nudge every waiter in THIS process that inbox work may now exist. Used by the
|
|
96
|
+
* dispatcher's session-close path after promoting a deferred item — the work is
|
|
97
|
+
* already on disk, and waiting for a watch event or the 1s floor is pure delay.
|
|
98
|
+
*
|
|
99
|
+
* Safe to call at any time: a wake with no work costs one predicate evaluation.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} [reason] free-form, surfaced to the waiter for logging
|
|
102
|
+
*/
|
|
103
|
+
export function signalInbox(reason = "signal") {
|
|
104
|
+
for (const fn of [...listeners]) {
|
|
105
|
+
try {
|
|
106
|
+
fn(reason);
|
|
107
|
+
} catch {
|
|
108
|
+
/* a waiter must never break the signaller */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Is `name` a live (un-drained) inbox item file? */
|
|
114
|
+
export function isLiveItemName(name) {
|
|
115
|
+
if (!name || name.startsWith(".")) return false;
|
|
116
|
+
for (const s of TERMINAL_SUFFIXES) if (name.endsWith(s)) return false;
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The wake predicate: the first service whose inbox dir holds a live item, or
|
|
122
|
+
* null. Cheap — a readdir of a handful of small directories.
|
|
123
|
+
*
|
|
124
|
+
* @param {object} o - { agentRoot?, services?, readdir? }
|
|
125
|
+
* @returns {string|null} the service name with work, else null
|
|
126
|
+
*/
|
|
127
|
+
export function findLiveInboxService(o = {}) {
|
|
128
|
+
const root = resolveAgentRoot(o.agentRoot);
|
|
129
|
+
const services = o.services || DEFAULT_SERVICES;
|
|
130
|
+
const readdirFn = o.readdir || readdirSync;
|
|
131
|
+
for (const service of services) {
|
|
132
|
+
const dir = join(root, "state", "inbox", service);
|
|
133
|
+
let names;
|
|
134
|
+
try {
|
|
135
|
+
names = readdirFn(dir);
|
|
136
|
+
} catch {
|
|
137
|
+
continue; // dir absent (service not configured) — not an error
|
|
138
|
+
}
|
|
139
|
+
for (const name of names) if (isLiveItemName(name)) return service;
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Park until inbox work exists, or `timeoutMs` elapses.
|
|
146
|
+
*
|
|
147
|
+
* @param {object} o - {
|
|
148
|
+
* agentRoot?, services?, timeoutMs?, scanMs?, debounceMs?,
|
|
149
|
+
* log?, watchImpl?, readdir?
|
|
150
|
+
* }
|
|
151
|
+
* @returns {Promise<{woke:boolean, reason:string, service:string|null, waitedMs:number}>}
|
|
152
|
+
*/
|
|
153
|
+
export async function waitForInboxWake(o = {}) {
|
|
154
|
+
const root = resolveAgentRoot(o.agentRoot);
|
|
155
|
+
const services = o.services || DEFAULT_SERVICES;
|
|
156
|
+
const timeoutMs = Number.isFinite(o.timeoutMs) ? o.timeoutMs : 60_000;
|
|
157
|
+
const scanMs = Number.isFinite(o.scanMs) ? o.scanMs : DEFAULT_SCAN_MS;
|
|
158
|
+
const debounceMs = Number.isFinite(o.debounceMs) ? o.debounceMs : DEFAULT_DEBOUNCE_MS;
|
|
159
|
+
const watchFn = o.watchImpl || watch;
|
|
160
|
+
const log =
|
|
161
|
+
o.log ||
|
|
162
|
+
((msg) => {
|
|
163
|
+
try {
|
|
164
|
+
console.warn(`[inbox-wake] ${msg}`);
|
|
165
|
+
} catch {
|
|
166
|
+
/* never throw from logging */
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
const startedAt = Date.now();
|
|
170
|
+
|
|
171
|
+
// Work already waiting: never sleep on a full inbox.
|
|
172
|
+
const immediate = findLiveInboxService({ agentRoot: root, services, readdir: o.readdir });
|
|
173
|
+
if (immediate) {
|
|
174
|
+
return { woke: true, reason: "pending", service: immediate, waitedMs: 0 };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return new Promise((resolve) => {
|
|
178
|
+
let settled = false;
|
|
179
|
+
let debounceTimer = null;
|
|
180
|
+
const watchers = [];
|
|
181
|
+
let scanTimer = null;
|
|
182
|
+
let timeoutTimer = null;
|
|
183
|
+
let listener = null;
|
|
184
|
+
|
|
185
|
+
function cleanup() {
|
|
186
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
187
|
+
if (scanTimer) clearInterval(scanTimer);
|
|
188
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
189
|
+
if (listener) listeners.delete(listener);
|
|
190
|
+
for (const w of watchers) {
|
|
191
|
+
try {
|
|
192
|
+
w.close();
|
|
193
|
+
} catch {
|
|
194
|
+
/* already closed */
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function settle(reason, service) {
|
|
200
|
+
if (settled) return;
|
|
201
|
+
settled = true;
|
|
202
|
+
cleanup();
|
|
203
|
+
resolve({
|
|
204
|
+
woke: reason !== "timeout",
|
|
205
|
+
reason,
|
|
206
|
+
service: service || null,
|
|
207
|
+
waitedMs: Date.now() - startedAt,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Evaluate the predicate; settle only when there is REAL work. */
|
|
212
|
+
function check(reason) {
|
|
213
|
+
if (settled) return;
|
|
214
|
+
const service = findLiveInboxService({ agentRoot: root, services, readdir: o.readdir });
|
|
215
|
+
if (service) settle(reason, service);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function onEvent(reason) {
|
|
219
|
+
if (settled || debounceTimer) return;
|
|
220
|
+
debounceTimer = setTimeout(() => {
|
|
221
|
+
debounceTimer = null;
|
|
222
|
+
check(reason);
|
|
223
|
+
}, debounceMs);
|
|
224
|
+
if (debounceTimer && typeof debounceTimer.unref === "function") debounceTimer.unref();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 1. fs.watch per existing service dir — the fast path.
|
|
228
|
+
for (const service of services) {
|
|
229
|
+
const dir = join(root, "state", "inbox", service);
|
|
230
|
+
if (!existsSync(dir)) continue;
|
|
231
|
+
try {
|
|
232
|
+
const w = watchFn(dir, { persistent: false }, () => onEvent("watch"));
|
|
233
|
+
if (w && typeof w.on === "function") {
|
|
234
|
+
// A watcher error must degrade to the scan floor, never reject.
|
|
235
|
+
w.on("error", (err) => {
|
|
236
|
+
if (!watchWarned.has(dir)) {
|
|
237
|
+
watchWarned.add(dir);
|
|
238
|
+
log(`watch failed for ${dir} (${err && err.message}) — falling back to the ${scanMs}ms rescan`);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
watchers.push(w);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
if (!watchWarned.has(dir)) {
|
|
245
|
+
watchWarned.add(dir);
|
|
246
|
+
log(`cannot watch ${dir} (${err && err.message}) — falling back to the ${scanMs}ms rescan`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 2. the scan floor — correctness does not depend on the watchers.
|
|
252
|
+
scanTimer = setInterval(() => check("scan"), scanMs);
|
|
253
|
+
if (scanTimer && typeof scanTimer.unref === "function") scanTimer.unref();
|
|
254
|
+
|
|
255
|
+
// 3. in-process signal.
|
|
256
|
+
listener = (reason) => onEvent(reason || "signal");
|
|
257
|
+
listeners.add(listener);
|
|
258
|
+
|
|
259
|
+
// Deliberately NOT unref'd: this promise is the caller's sleep, and while it
|
|
260
|
+
// is pending the process still owes a poll. With every timer unref'd Node
|
|
261
|
+
// would consider the loop idle and exit mid-wait (or, under the test runner,
|
|
262
|
+
// report an unsettled promise). The watchers stay `persistent:false` and the
|
|
263
|
+
// scan/debounce timers stay unref'd so THIS is the only thing holding it.
|
|
264
|
+
timeoutTimer = setTimeout(() => settle("timeout", null), timeoutMs);
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Drop in-process listener state (tests). */
|
|
269
|
+
export function resetInboxWake() {
|
|
270
|
+
listeners.clear();
|
|
271
|
+
watchWarned.clear();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export default {
|
|
275
|
+
waitForInboxWake,
|
|
276
|
+
signalInbox,
|
|
277
|
+
findLiveInboxService,
|
|
278
|
+
isLiveItemName,
|
|
279
|
+
resetInboxWake,
|
|
280
|
+
DEFAULT_SERVICES,
|
|
281
|
+
DEFAULT_SCAN_MS,
|
|
282
|
+
};
|