@cohortapp/agent-sdk 2.3.1 → 2.4.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/bin/maestro.mjs +37 -50
- 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/channels/inbox-item.mjs +20 -0
- 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 +79 -25
- package/lib/org/client.test.mjs +54 -1
- package/lib/org/doctor.mjs +64 -0
- package/lib/org/doctor.test.mjs +31 -2
- 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 +45 -2
- package/lib/org/mesh.test.mjs +55 -0
- package/lib/org/messaging.mjs +180 -15
- package/lib/org/messaging.test.mjs +117 -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 +84 -16
- package/lib/setup/enroll-from-cohort.test.mjs +43 -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 +9 -4
- 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 +70 -11
- package/scripts/daemon/cadence-handlers.mjs +187 -5
- 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/maestro-daemon.mjs +23 -0
- package/scripts/daemon/prompt-builder.mjs +41 -1
- package/scripts/daemon/responder.mjs +56 -0
- 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/poller/inbox-scan-poller.mjs +26 -1
- package/scripts/poller/inbox-scan-poller.test.mjs +64 -0
- package/scripts/poller/slack-cloud-relay-client.mjs +5 -0
- package/scripts/poller/slack-poller.mjs +32 -0
- package/scripts/poller/slack-socket-mode.mjs +27 -1
- package/scripts/poller/slack-socket-mode.test.mjs +52 -0
- package/scripts/poller/utils.mjs +47 -0
- 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,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
|
+
};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* inbox-wake.test.mjs — the daemon's "something landed" wake signal.
|
|
3
|
+
*
|
|
4
|
+
* Exercised against a REAL temp filesystem (fs.watch semantics are the thing
|
|
5
|
+
* under test), plus one injected-watch case that proves the scan floor carries
|
|
6
|
+
* the load when every watcher is dead — the property that keeps a missed event
|
|
7
|
+
* from turning into a silently late agent.
|
|
8
|
+
*
|
|
9
|
+
* Run: node --test scripts/daemon/inbox-wake.test.mjs
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { test } from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { mkdtempSync, mkdirSync, writeFileSync, renameSync, rmSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
waitForInboxWake,
|
|
20
|
+
signalInbox,
|
|
21
|
+
findLiveInboxService,
|
|
22
|
+
isLiveItemName,
|
|
23
|
+
resetInboxWake,
|
|
24
|
+
DEFAULT_SERVICES,
|
|
25
|
+
} from "./inbox-wake.mjs";
|
|
26
|
+
|
|
27
|
+
function makeRoot() {
|
|
28
|
+
const root = mkdtempSync(join(tmpdir(), "inbox-wake-"));
|
|
29
|
+
mkdirSync(join(root, "state", "inbox", "cohort"), { recursive: true });
|
|
30
|
+
return root;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeItem(root, service, name, body = "id: x\n") {
|
|
34
|
+
writeFileSync(join(root, "state", "inbox", service, name), body);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// --- the predicate ----------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
test("isLiveItemName: only un-drained, non-dot files count as work", () => {
|
|
40
|
+
assert.equal(isLiveItemName("2026-08-11-abc.yaml"), true);
|
|
41
|
+
assert.equal(isLiveItemName("2026-08-11-abc.yaml.processed"), false);
|
|
42
|
+
assert.equal(isLiveItemName("2026-08-11-abc.yaml.deferred"), false);
|
|
43
|
+
assert.equal(isLiveItemName("2026-08-11-abc.yaml.processed-bundled"), false);
|
|
44
|
+
assert.equal(isLiveItemName(".DS_Store"), false);
|
|
45
|
+
assert.equal(isLiveItemName(""), false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("findLiveInboxService: names the service holding work, else null", () => {
|
|
49
|
+
const root = makeRoot();
|
|
50
|
+
try {
|
|
51
|
+
assert.equal(findLiveInboxService({ agentRoot: root }), null);
|
|
52
|
+
writeItem(root, "cohort", "a.yaml.processed");
|
|
53
|
+
assert.equal(findLiveInboxService({ agentRoot: root }), null, "a drained item is not work");
|
|
54
|
+
writeItem(root, "cohort", "b.yaml");
|
|
55
|
+
assert.equal(findLiveInboxService({ agentRoot: root }), "cohort");
|
|
56
|
+
} finally {
|
|
57
|
+
rmSync(root, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("the cohort + orgmail dirs are in the default service set (the ones that were missed)", () => {
|
|
62
|
+
assert.ok(DEFAULT_SERVICES.includes("cohort"));
|
|
63
|
+
assert.ok(DEFAULT_SERVICES.includes("orgmail"));
|
|
64
|
+
assert.ok(DEFAULT_SERVICES.includes("telegram"));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// --- the wake ---------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
test("returns immediately when work is already waiting (never sleep on a full inbox)", async () => {
|
|
70
|
+
const root = makeRoot();
|
|
71
|
+
try {
|
|
72
|
+
writeItem(root, "cohort", "a.yaml");
|
|
73
|
+
const r = await waitForInboxWake({ agentRoot: root, timeoutMs: 5_000 });
|
|
74
|
+
assert.equal(r.woke, true);
|
|
75
|
+
assert.equal(r.reason, "pending");
|
|
76
|
+
assert.equal(r.service, "cohort");
|
|
77
|
+
assert.equal(r.waitedMs, 0);
|
|
78
|
+
} finally {
|
|
79
|
+
rmSync(root, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("wakes on a file written by another writer, far inside the 60s poll interval", async () => {
|
|
84
|
+
const root = makeRoot();
|
|
85
|
+
try {
|
|
86
|
+
const started = Date.now();
|
|
87
|
+
const waiting = waitForInboxWake({ agentRoot: root, timeoutMs: 8_000 });
|
|
88
|
+
setTimeout(() => writeItem(root, "cohort", "late.yaml"), 50);
|
|
89
|
+
const r = await waiting;
|
|
90
|
+
assert.equal(r.woke, true);
|
|
91
|
+
assert.ok(["watch", "scan"].includes(r.reason), `unexpected reason ${r.reason}`);
|
|
92
|
+
assert.equal(r.service, "cohort");
|
|
93
|
+
assert.ok(Date.now() - started < 3_000, "a wake must not take seconds");
|
|
94
|
+
} finally {
|
|
95
|
+
rmSync(root, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("does NOT wake when the only change is an item being drained", async () => {
|
|
100
|
+
const root = makeRoot();
|
|
101
|
+
try {
|
|
102
|
+
writeItem(root, "cohort", "a.yaml");
|
|
103
|
+
// Drain it, then wait: renaming to `.processed` raises watch events for BOTH
|
|
104
|
+
// names, so an event-driven wake would spin here.
|
|
105
|
+
renameSync(
|
|
106
|
+
join(root, "state", "inbox", "cohort", "a.yaml"),
|
|
107
|
+
join(root, "state", "inbox", "cohort", "a.yaml.processed")
|
|
108
|
+
);
|
|
109
|
+
const r = await waitForInboxWake({ agentRoot: root, timeoutMs: 400 });
|
|
110
|
+
assert.equal(r.woke, false);
|
|
111
|
+
assert.equal(r.reason, "timeout");
|
|
112
|
+
} finally {
|
|
113
|
+
rmSync(root, { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("times out cleanly with an empty inbox (the loop shape is unchanged)", async () => {
|
|
118
|
+
const root = makeRoot();
|
|
119
|
+
try {
|
|
120
|
+
const r = await waitForInboxWake({ agentRoot: root, timeoutMs: 200 });
|
|
121
|
+
assert.deepEqual(
|
|
122
|
+
{ woke: r.woke, reason: r.reason, service: r.service },
|
|
123
|
+
{ woke: false, reason: "timeout", service: null }
|
|
124
|
+
);
|
|
125
|
+
assert.ok(r.waitedMs >= 150);
|
|
126
|
+
} finally {
|
|
127
|
+
rmSync(root, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("signalInbox wakes an in-process waiter (the dispatcher's promote path)", async () => {
|
|
132
|
+
const root = makeRoot();
|
|
133
|
+
resetInboxWake();
|
|
134
|
+
try {
|
|
135
|
+
const waiting = waitForInboxWake({ agentRoot: root, timeoutMs: 5_000, scanMs: 60_000 });
|
|
136
|
+
setTimeout(() => {
|
|
137
|
+
// Simulate promoteDeferred: the file becomes live, then we nudge.
|
|
138
|
+
writeItem(root, "cohort", "promoted.yaml");
|
|
139
|
+
signalInbox("promote");
|
|
140
|
+
}, 20);
|
|
141
|
+
const r = await waiting;
|
|
142
|
+
assert.equal(r.woke, true);
|
|
143
|
+
assert.equal(r.service, "cohort");
|
|
144
|
+
} finally {
|
|
145
|
+
resetInboxWake();
|
|
146
|
+
rmSync(root, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("signalInbox with no work does NOT wake (a spurious nudge is free)", async () => {
|
|
151
|
+
const root = makeRoot();
|
|
152
|
+
resetInboxWake();
|
|
153
|
+
try {
|
|
154
|
+
const waiting = waitForInboxWake({ agentRoot: root, timeoutMs: 400, scanMs: 60_000 });
|
|
155
|
+
setTimeout(() => signalInbox("noise"), 20);
|
|
156
|
+
const r = await waiting;
|
|
157
|
+
assert.equal(r.woke, false);
|
|
158
|
+
} finally {
|
|
159
|
+
resetInboxWake();
|
|
160
|
+
rmSync(root, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("with EVERY watcher dead the scan floor still wakes it — and says so once", async () => {
|
|
165
|
+
const root = makeRoot();
|
|
166
|
+
const logs = [];
|
|
167
|
+
try {
|
|
168
|
+
const watchImpl = () => {
|
|
169
|
+
throw new Error("EMFILE: watcher limit reached");
|
|
170
|
+
};
|
|
171
|
+
const waiting = waitForInboxWake({
|
|
172
|
+
agentRoot: root,
|
|
173
|
+
timeoutMs: 6_000,
|
|
174
|
+
scanMs: 100,
|
|
175
|
+
watchImpl,
|
|
176
|
+
log: (m) => logs.push(m),
|
|
177
|
+
});
|
|
178
|
+
setTimeout(() => writeItem(root, "cohort", "floor.yaml"), 30);
|
|
179
|
+
const r = await waiting;
|
|
180
|
+
assert.equal(r.woke, true);
|
|
181
|
+
assert.equal(r.reason, "scan", "correctness must not depend on fs.watch");
|
|
182
|
+
assert.equal(logs.length, 1, "a dead watcher is reported — fail-open, never silent");
|
|
183
|
+
assert.match(logs[0], /falling back to the 100ms rescan/);
|
|
184
|
+
} finally {
|
|
185
|
+
resetInboxWake();
|
|
186
|
+
rmSync(root, { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("a missing service dir is not an error (an un-enrolled agent just times out)", async () => {
|
|
191
|
+
const root = mkdtempSync(join(tmpdir(), "inbox-wake-bare-"));
|
|
192
|
+
try {
|
|
193
|
+
const r = await waitForInboxWake({ agentRoot: root, timeoutMs: 200 });
|
|
194
|
+
assert.equal(r.woke, false);
|
|
195
|
+
assert.equal(r.reason, "timeout");
|
|
196
|
+
} finally {
|
|
197
|
+
rmSync(root, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
@@ -33,6 +33,29 @@ const AGENT_DIR = process.env.AGENT_DIR || resolve(__dirname, "../..");
|
|
|
33
33
|
process.env.AGENT_DIR = AGENT_DIR;
|
|
34
34
|
process.env.AGENT_ROOT = AGENT_DIR;
|
|
35
35
|
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// 0. Environment — BEFORE anything reads a credential
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// The core daemon (agent-daemon.mjs, step 4) loads .env itself, but the org
|
|
40
|
+
// mesh connects at step 3b' — EARLIER in this file. Under launchd the process
|
|
41
|
+
// environment is bare, so the mesh was reading COHORT_API_KEY before anything
|
|
42
|
+
// had put it there.
|
|
43
|
+
//
|
|
44
|
+
// That failed SILENTLY and looked healthy: `enabled` comes from config/org.yaml
|
|
45
|
+
// (which should NOT carry the token — it is version-controlled), so the mesh
|
|
46
|
+
// started, logged "connected", and then 401'd on every beat forever. An agent
|
|
47
|
+
// that never beats also never receives the kill switch.
|
|
48
|
+
//
|
|
49
|
+
// Loading .env here, before any org work, is the fix. Idempotent: dotenv does
|
|
50
|
+
// not override variables already set, so a launchd/plist-provided value still
|
|
51
|
+
// wins. Fail-open — dotenv is absent in some CI/unit contexts.
|
|
52
|
+
try {
|
|
53
|
+
const { config } = await import("dotenv");
|
|
54
|
+
config({ path: join(AGENT_DIR, ".env") });
|
|
55
|
+
} catch {
|
|
56
|
+
// No dotenv (CI/dev): rely on the ambient environment.
|
|
57
|
+
}
|
|
58
|
+
|
|
36
59
|
// Never die silently (P0-6): a stray unhandled rejection / uncaught exception is
|
|
37
60
|
// logged + counted, not a silent process death. Rejections keep the daemon
|
|
38
61
|
// running; an uncaught exception logs+flushes then lets launchd restart us.
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { readFileSync, readdirSync } from "fs";
|
|
7
7
|
import { join } from "path";
|
|
8
8
|
import { compileContext } from "./context-compiler.mjs";
|
|
9
|
+
import { loadPersonaBlock } from "../../lib/identity/persona.mjs";
|
|
9
10
|
import { wrapExternalContent } from "../../lib/security/external-content.mjs";
|
|
10
11
|
import { isEnabled as orgEnabled } from "../../lib/org/client.mjs";
|
|
11
12
|
import { recall as orgRecall } from "../../lib/org/knowledge.mjs";
|
|
@@ -118,6 +119,27 @@ async function buildOrgKnowledgeBlock(item, classResult, queueItem) {
|
|
|
118
119
|
// Cached identity preamble — extracted from CLAUDE.md on first call
|
|
119
120
|
let cachedPreamble = null;
|
|
120
121
|
|
|
122
|
+
// Cached persona block — rendered from config/agent.json (the SoT, kept in sync
|
|
123
|
+
// with the Cohort server) on first call. This leads every prompt and is
|
|
124
|
+
// AUTHORITATIVE over the CLAUDE.md-scraped preamble that follows it: a scaffolded
|
|
125
|
+
// repo still carries "your identity has not yet been configured" prose in
|
|
126
|
+
// CLAUDE.md long after the org record is populated, and without an explicit
|
|
127
|
+
// precedence rule the model would take that placeholder at face value and fall
|
|
128
|
+
// back to a generic-assistant register.
|
|
129
|
+
let cachedPersona = null;
|
|
130
|
+
|
|
131
|
+
/** Render (once per process) the persona block from config/agent.json. */
|
|
132
|
+
function loadPersona() {
|
|
133
|
+
if (cachedPersona !== null) return cachedPersona;
|
|
134
|
+
cachedPersona = loadPersonaBlock(AGENT_REPO_DIR) || "";
|
|
135
|
+
return cachedPersona;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** For tests: drop the cached persona so a fresh config/agent.json is re-read. */
|
|
139
|
+
export function _resetPersonaCache() {
|
|
140
|
+
cachedPersona = null;
|
|
141
|
+
}
|
|
142
|
+
|
|
121
143
|
function loadPreamble() {
|
|
122
144
|
if (cachedPreamble) return cachedPreamble;
|
|
123
145
|
|
|
@@ -185,7 +207,10 @@ function buildFallbackPreamble() {
|
|
|
185
207
|
const principal = a.principal || {};
|
|
186
208
|
const principalName = principal.fullName || "the principal";
|
|
187
209
|
const principalTitle = principal.title || "principal";
|
|
188
|
-
|
|
210
|
+
// "<title> to <principal>" only reads correctly for assistant-shaped titles;
|
|
211
|
+
// for a functional seat ("SVP AI Systems & Agent Platform") it turns the role
|
|
212
|
+
// into a service relationship. State the seat, then the reporting line.
|
|
213
|
+
return `You are ${a.fullName}, ${a.title} at ${a.company}. You report to ${principalName}, ${principalTitle}.
|
|
189
214
|
You operate as the autonomous executive command layer for the company.
|
|
190
215
|
|
|
191
216
|
${a.companyDescription || a.company}.
|
|
@@ -552,6 +577,21 @@ export async function buildPrompt(item, classResult, options = {}) {
|
|
|
552
577
|
|
|
553
578
|
const parts = [];
|
|
554
579
|
|
|
580
|
+
// 1. Persona block (cached) — rendered from config/agent.json, which the
|
|
581
|
+
// Cohort enrollment pull keeps current. Leads the prompt and outranks the
|
|
582
|
+
// CLAUDE.md preamble below, because a scaffolded CLAUDE.md carries
|
|
583
|
+
// "identity not yet configured" placeholder prose that would otherwise
|
|
584
|
+
// contradict a perfectly good org record.
|
|
585
|
+
const persona = loadPersona();
|
|
586
|
+
if (persona) {
|
|
587
|
+
parts.push(persona);
|
|
588
|
+
parts.push("");
|
|
589
|
+
parts.push(
|
|
590
|
+
"The section above is your identity, resolved from your organisation's record of you. Where anything below conflicts with it, the section above wins.",
|
|
591
|
+
);
|
|
592
|
+
parts.push("");
|
|
593
|
+
}
|
|
594
|
+
|
|
555
595
|
// 1. Identity preamble (cached)
|
|
556
596
|
parts.push(preamble);
|
|
557
597
|
parts.push("");
|
|
@@ -684,6 +684,52 @@ function resolveSlackChannel(item) {
|
|
|
684
684
|
*
|
|
685
685
|
* @returns {{ sent: boolean, text: string, channel: string }} result
|
|
686
686
|
*/
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Send a reply back onto Cohort (the org's own messaging app).
|
|
690
|
+
*
|
|
691
|
+
* The responder dispatched on `item.service` and only ever knew "slack" and
|
|
692
|
+
* "gmail", so a `cohort` item fell through to `{ sent: false }`: the agent
|
|
693
|
+
* COMPOSED a reply and dropped it on the floor, logging "not_sent". Observed in
|
|
694
|
+
* production — an agent quietly did that to a human's DMs three times in a row.
|
|
695
|
+
*
|
|
696
|
+
* Routing is by CHANNEL, not by person: `channel_id` is the room the inbound
|
|
697
|
+
* arrived in, which is also where the answer belongs (a DM room for a DM, the
|
|
698
|
+
* space for a space). Threading is preserved when the inbound was threaded.
|
|
699
|
+
*
|
|
700
|
+
* Goes through lib/org/messaging.sendMessage rather than the raw RPC so the
|
|
701
|
+
* outbound send-gate, the afterSend hook and cost attribution all still run —
|
|
702
|
+
* the same chokepoint every other channel uses.
|
|
703
|
+
*/
|
|
704
|
+
async function sendCohortReply(item, text) {
|
|
705
|
+
try {
|
|
706
|
+
const channel = item.channel_id || item.channel || "";
|
|
707
|
+
if (!channel) return { sent: false, via: null, error: "no channel_id on item" };
|
|
708
|
+
const { sendMessage } = await import("../../lib/org/messaging.mjs");
|
|
709
|
+
const { loadOrgConfig } = await import("../../lib/org/client.mjs");
|
|
710
|
+
const agentRoot = process.env.AGENT_ROOT || process.env.AGENT_DIR || process.cwd();
|
|
711
|
+
const frame = await sendMessage(
|
|
712
|
+
{
|
|
713
|
+
channel,
|
|
714
|
+
body: text,
|
|
715
|
+
// Stable per (message, kind) so a retry dedupes server-side instead of
|
|
716
|
+
// double-posting into a real room.
|
|
717
|
+
idempotencyId: `reply-${item.message_id || item.id || Date.now()}`,
|
|
718
|
+
...(item.thread_id ? { threadId: item.thread_id } : {}),
|
|
719
|
+
},
|
|
720
|
+
{ cfg: loadOrgConfig(agentRoot), agentRoot },
|
|
721
|
+
);
|
|
722
|
+
if (frame && frame.ok) return { sent: true, via: "cohort", channel };
|
|
723
|
+
return {
|
|
724
|
+
sent: false,
|
|
725
|
+
via: null,
|
|
726
|
+
error: (frame && frame.error && frame.error.message) || "send failed",
|
|
727
|
+
};
|
|
728
|
+
} catch (err) {
|
|
729
|
+
return { sent: false, via: null, error: err && err.message };
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
687
733
|
export async function sendQuickResponse(item, classResult) {
|
|
688
734
|
const startTime = Date.now();
|
|
689
735
|
|
|
@@ -745,6 +791,13 @@ export async function sendQuickResponse(item, classResult) {
|
|
|
745
791
|
emitEvent({ type: EVENT_TYPES.SENT, trace_id: item.trace_id || undefined, attrs: { service: "gmail", via: gmailResult.via } });
|
|
746
792
|
logInteraction(item, text);
|
|
747
793
|
}
|
|
794
|
+
} else if (item.service === "cohort") {
|
|
795
|
+
const orgResult = await sendCohortReply(item, text);
|
|
796
|
+
sendResult = { sent: orgResult.sent, via: orgResult.via, ...(orgResult.channel ? { channel: orgResult.channel } : {}), ...(orgResult.error ? { error: orgResult.error } : {}) };
|
|
797
|
+
if (orgResult.sent) {
|
|
798
|
+
emitEvent({ type: EVENT_TYPES.SENT, trace_id: item.trace_id || undefined, attrs: { service: "cohort", channel: orgResult.channel, via: "quick_reply" } });
|
|
799
|
+
logInteraction(item, text);
|
|
800
|
+
}
|
|
748
801
|
}
|
|
749
802
|
|
|
750
803
|
const duration = Date.now() - startTime;
|
|
@@ -802,6 +855,9 @@ export async function sendHoldingMessage(item, classResult) {
|
|
|
802
855
|
} else if (item.service === "gmail") {
|
|
803
856
|
const gmailResult = await sendGmailResponse(item, text);
|
|
804
857
|
sendResult = { sent: gmailResult.sent, via: gmailResult.via, ...(gmailResult.draft_path ? { draft_path: gmailResult.draft_path } : {}), ...(gmailResult.to ? { to: gmailResult.to } : {}) };
|
|
858
|
+
} else if (item.service === "cohort") {
|
|
859
|
+
const orgResult = await sendCohortReply(item, text);
|
|
860
|
+
sendResult = { sent: orgResult.sent, via: orgResult.via, ...(orgResult.error ? { error: orgResult.error } : {}) };
|
|
805
861
|
}
|
|
806
862
|
|
|
807
863
|
const duration = Date.now() - startTime;
|