@cohortapp/agent-sdk 2.18.4 → 2.18.6
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/lib/collective/capture-slots.mjs +234 -0
- package/lib/collective/config.mjs +2 -0
- package/lib/collective/loop-guard.mjs +155 -0
- package/lib/org/inbound/directedness.mjs +90 -0
- package/lib/org/inbound/index.mjs +23 -1
- package/lib/org/ui-parity.mjs +13 -1
- package/lib/runtime/adapter.mjs +16 -6
- package/lib/telemetry/collect.mjs +21 -2
- package/package.json +1 -1
- package/scaffold/config/collective.yaml +7 -0
- package/scripts/ci/check-durable-write-seam.mjs +3 -1
- package/scripts/collective/hook-runner.mjs +116 -20
- package/scripts/fleet/rollout.mjs +63 -6
- package/scripts/local-triggers/autoupdate.sh +18 -4
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/collective/capture-slots.mjs — bound the capture workers a machine runs.
|
|
3
|
+
*
|
|
4
|
+
* Every Stop/PreCompact on the machine spawns a detached capture worker, and
|
|
5
|
+
* each worker runs one or two `claude --print` passes (hundreds of MB of RSS
|
|
6
|
+
* apiece). With no ceiling, a burst of session ends — or any loop that feeds
|
|
7
|
+
* the hook — becomes an unbounded fan-out of model processes. Two locks keep it
|
|
8
|
+
* bounded:
|
|
9
|
+
*
|
|
10
|
+
* - SLOT locks — `slot-<i>.lock` for i < max. A worker must hold one to run
|
|
11
|
+
* its passes, so at most `max` capture workers do model work machine-wide.
|
|
12
|
+
* - SESSION locks — `session-<id>.lock`. One session is captured by at most
|
|
13
|
+
* one worker at a time (a PreCompact and the Stop that follows it must not
|
|
14
|
+
* distil the same transcript twice in parallel).
|
|
15
|
+
*
|
|
16
|
+
* Both are O_EXCL files (`{flag:"wx"}` — the lock idiom registered in
|
|
17
|
+
* scripts/ci/check-durable-write-seam.mjs) under ONE machine-wide directory, so
|
|
18
|
+
* two agent installs on a machine share the ceiling. A lock records its holder's
|
|
19
|
+
* pid; a lock whose pid is dead (or which is older than `maxAgeMs`, covering
|
|
20
|
+
* pid reuse) is reclaimed. Reclaim re-reads the lock and deletes it only if it
|
|
21
|
+
* still holds the exact bytes judged stale, so a lock freshly re-taken by
|
|
22
|
+
* another reclaimer is not deleted; the residual window between that read and
|
|
23
|
+
* the unlink can at worst admit one extra worker, never lose the ceiling.
|
|
24
|
+
*
|
|
25
|
+
* Expected failure is a returned `{ok:false, error}`; nothing here throws.
|
|
26
|
+
*
|
|
27
|
+
* @module lib/collective/capture-slots
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
"use strict";
|
|
31
|
+
|
|
32
|
+
import { mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
33
|
+
import { homedir } from "node:os";
|
|
34
|
+
import { dirname, join } from "node:path";
|
|
35
|
+
import { randomBytes } from "node:crypto";
|
|
36
|
+
|
|
37
|
+
/** Default machine-wide ceiling on capture workers doing model work. */
|
|
38
|
+
export const DEFAULT_MAX_CONCURRENT = 2;
|
|
39
|
+
/** Default time a worker waits for a slot before recording a skipped capture. */
|
|
40
|
+
export const DEFAULT_QUEUE_WAIT_MS = 30_000;
|
|
41
|
+
/** Poll interval while queued for a slot. */
|
|
42
|
+
export const DEFAULT_POLL_MS = 1_000;
|
|
43
|
+
/**
|
|
44
|
+
* A lock older than this is stale even if its pid is alive (the pid was
|
|
45
|
+
* reused). Well above a worker's worst case: distil (60s) + reflect (90s) +
|
|
46
|
+
* index + queue wait.
|
|
47
|
+
*/
|
|
48
|
+
export const DEFAULT_LOCK_MAX_AGE_MS = 15 * 60_000;
|
|
49
|
+
/** A lock with unreadable content younger than this is a holder mid-write, not a corpse. */
|
|
50
|
+
const UNREADABLE_GRACE_MS = 10_000;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The machine-wide lock directory. `MAESTRO_CAPTURE_LOCK_DIR` overrides it
|
|
54
|
+
* (tests, and a machine that wants the locks elsewhere).
|
|
55
|
+
* @param {Record<string, string|undefined>} [env]
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function captureLockDir(env = process.env) {
|
|
59
|
+
const override = env && env.MAESTRO_CAPTURE_LOCK_DIR;
|
|
60
|
+
return override && String(override).trim() ? String(override) : join(homedir(), ".claude", "maestro-capture-locks");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The configured ceiling, clamped to a positive integer. Absent/invalid → default.
|
|
65
|
+
* @param {object} [cfg] collective config
|
|
66
|
+
* @returns {number}
|
|
67
|
+
*/
|
|
68
|
+
export function maxConcurrentFrom(cfg) {
|
|
69
|
+
const n = Number(cfg && cfg.captureMaxConcurrent);
|
|
70
|
+
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : DEFAULT_MAX_CONCURRENT;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The configured queue wait in ms, clamped to >= 0. Absent/invalid → default.
|
|
75
|
+
* @param {object} [cfg] collective config
|
|
76
|
+
* @returns {number}
|
|
77
|
+
*/
|
|
78
|
+
export function queueWaitFrom(cfg) {
|
|
79
|
+
const n = Number(cfg && cfg.captureQueueWaitMs);
|
|
80
|
+
return Number.isFinite(n) && n >= 0 ? n : DEFAULT_QUEUE_WAIT_MS;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Is `pid` a live process? EPERM means alive (someone else's process). */
|
|
84
|
+
export function pidAlive(pid) {
|
|
85
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
86
|
+
try {
|
|
87
|
+
process.kill(pid, 0);
|
|
88
|
+
return true;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
return !!err && err.code === "EPERM";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function sanitize(id) {
|
|
95
|
+
const s = String(id || "").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 128);
|
|
96
|
+
return s || "unknown";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Path of the session lock for `sessionId`. */
|
|
100
|
+
export function sessionLockPath(dir, sessionId) {
|
|
101
|
+
return join(dir, `session-${sanitize(sessionId)}.lock`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Path of slot `i`. */
|
|
105
|
+
export function slotLockPath(dir, i) {
|
|
106
|
+
return join(dir, `slot-${i}.lock`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Is the lock whose raw bytes are `raw` (mtime `mtimeMs`) stale at `nowMs`?
|
|
111
|
+
* @returns {boolean}
|
|
112
|
+
*/
|
|
113
|
+
function isStale(raw, mtimeMs, nowMs, deps) {
|
|
114
|
+
let holder = null;
|
|
115
|
+
try { holder = JSON.parse(raw); } catch { holder = null; }
|
|
116
|
+
if (!holder || !Number.isInteger(holder.pid)) {
|
|
117
|
+
// Empty/corrupt: a holder between open(wx) and write, or a crashed one.
|
|
118
|
+
return nowMs - mtimeMs > UNREADABLE_GRACE_MS;
|
|
119
|
+
}
|
|
120
|
+
const acquired = Date.parse(holder.acquiredAt || "");
|
|
121
|
+
const ageMs = Number.isFinite(acquired) ? nowMs - acquired : nowMs - mtimeMs;
|
|
122
|
+
if (ageMs > deps.maxAgeMs) return true;
|
|
123
|
+
return !deps.isAlive(holder.pid);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Try once to take the O_EXCL lock at `lockPath`, reclaiming a stale one.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} lockPath
|
|
130
|
+
* @param {{pid?:number, sessionId?:string}} [owner]
|
|
131
|
+
* @param {{now?:()=>number, isAlive?:(pid:number)=>boolean, maxAgeMs?:number}} [deps]
|
|
132
|
+
* @returns {{ok:true, lockPath:string, release:()=>boolean, reclaimed:boolean} |
|
|
133
|
+
* {ok:false, error:{code:"held"|"io", message:string, holder?:object}}}
|
|
134
|
+
*/
|
|
135
|
+
export function tryAcquireLock(lockPath, owner = {}, deps = {}) {
|
|
136
|
+
const d = {
|
|
137
|
+
now: typeof deps.now === "function" ? deps.now : Date.now,
|
|
138
|
+
isAlive: typeof deps.isAlive === "function" ? deps.isAlive : pidAlive,
|
|
139
|
+
maxAgeMs: Number.isFinite(deps.maxAgeMs) ? deps.maxAgeMs : DEFAULT_LOCK_MAX_AGE_MS,
|
|
140
|
+
};
|
|
141
|
+
const pid = Number.isInteger(owner.pid) ? owner.pid : process.pid;
|
|
142
|
+
const token = randomBytes(8).toString("hex");
|
|
143
|
+
const dir = dirname(lockPath);
|
|
144
|
+
try { mkdirSync(dir, { recursive: true }); } catch (err) {
|
|
145
|
+
return { ok: false, error: { code: "io", message: `lock dir: ${err && err.message}` } };
|
|
146
|
+
}
|
|
147
|
+
let reclaimed = false;
|
|
148
|
+
// Two attempts: the first may find a stale lock and reclaim it; the second
|
|
149
|
+
// takes the freed name (or loses it to a concurrent reclaimer, which is "held").
|
|
150
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
151
|
+
try {
|
|
152
|
+
writeFileSync(lockPath, JSON.stringify({ pid, sessionId: owner.sessionId || "", token, acquiredAt: new Date(d.now()).toISOString() }), { flag: "wx" });
|
|
153
|
+
const release = () => {
|
|
154
|
+
try {
|
|
155
|
+
const cur = JSON.parse(readFileSync(lockPath, "utf8"));
|
|
156
|
+
if (!cur || cur.token !== token) return false; // reclaimed from us after we went stale — not ours to delete
|
|
157
|
+
unlinkSync(lockPath);
|
|
158
|
+
return true;
|
|
159
|
+
} catch {
|
|
160
|
+
return false; // already gone (reclaimed or cleaned) — nothing to release
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
return { ok: true, lockPath, release, reclaimed };
|
|
164
|
+
} catch (err) {
|
|
165
|
+
if (!err || err.code !== "EEXIST") {
|
|
166
|
+
return { ok: false, error: { code: "io", message: `lock ${lockPath}: ${err && err.message}` } };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
let raw = "";
|
|
170
|
+
let mtimeMs = 0;
|
|
171
|
+
try {
|
|
172
|
+
raw = readFileSync(lockPath, "utf8");
|
|
173
|
+
mtimeMs = statSync(lockPath).mtimeMs;
|
|
174
|
+
} catch {
|
|
175
|
+
continue; // the holder released between our create and read — retry the create
|
|
176
|
+
}
|
|
177
|
+
if (!isStale(raw, mtimeMs, d.now(), d)) {
|
|
178
|
+
let holder = null;
|
|
179
|
+
try { holder = JSON.parse(raw); } catch { holder = null; }
|
|
180
|
+
return { ok: false, error: { code: "held", message: `lock ${lockPath} is held`, holder: holder || undefined } };
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
// Delete only the exact stale bytes we judged; a lock re-taken since is left alone.
|
|
184
|
+
if (readFileSync(lockPath, "utf8") === raw) { unlinkSync(lockPath); reclaimed = true; }
|
|
185
|
+
} catch { /* someone else reclaimed it first — the retry below races for the name fairly */ }
|
|
186
|
+
}
|
|
187
|
+
return { ok: false, error: { code: "held", message: `lock ${lockPath} is held (lost the reclaim race)` } };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Take any free slot among `max`, once.
|
|
192
|
+
* @param {string} dir
|
|
193
|
+
* @param {number} max
|
|
194
|
+
* @param {object} [owner]
|
|
195
|
+
* @param {object} [deps] as {@link tryAcquireLock}
|
|
196
|
+
* @returns {{ok:true, slot:number, release:()=>boolean, reclaimed:boolean} |
|
|
197
|
+
* {ok:false, error:{code:"no-slot"|"io", message:string}}}
|
|
198
|
+
*/
|
|
199
|
+
export function tryAcquireSlot(dir, max, owner = {}, deps = {}) {
|
|
200
|
+
let ioError = null;
|
|
201
|
+
for (let i = 0; i < max; i++) {
|
|
202
|
+
const r = tryAcquireLock(slotLockPath(dir, i), owner, deps);
|
|
203
|
+
if (r.ok) return { ok: true, slot: i, release: r.release, reclaimed: r.reclaimed };
|
|
204
|
+
if (r.error.code === "io") ioError = r.error;
|
|
205
|
+
}
|
|
206
|
+
if (ioError) return { ok: false, error: ioError };
|
|
207
|
+
return { ok: false, error: { code: "no-slot", message: `all ${max} capture slots are held` } };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Wait up to `waitMs` for a slot, polling. Resolves `{ok:false, error:{code:"no-slot"}}`
|
|
212
|
+
* when the wait runs out — the caller records the skipped capture.
|
|
213
|
+
* @param {string} dir
|
|
214
|
+
* @param {number} max
|
|
215
|
+
* @param {object} [owner]
|
|
216
|
+
* @param {{waitMs?:number, pollMs?:number, sleep?:(ms:number)=>Promise<void>, now?:()=>number}} [opts]
|
|
217
|
+
* plus the {@link tryAcquireLock} deps
|
|
218
|
+
* @returns {Promise<{ok:true, slot:number, release:()=>boolean, waitedMs:number} |
|
|
219
|
+
* {ok:false, error:{code:string, message:string}, waitedMs:number}>}
|
|
220
|
+
*/
|
|
221
|
+
export async function acquireSlotQueued(dir, max, owner = {}, opts = {}) {
|
|
222
|
+
const now = typeof opts.now === "function" ? opts.now : Date.now;
|
|
223
|
+
const sleep = typeof opts.sleep === "function" ? opts.sleep : (ms) => new Promise((r) => setTimeout(r, ms));
|
|
224
|
+
const waitMs = Number.isFinite(opts.waitMs) ? Math.max(0, opts.waitMs) : DEFAULT_QUEUE_WAIT_MS;
|
|
225
|
+
const pollMs = Number.isFinite(opts.pollMs) && opts.pollMs > 0 ? opts.pollMs : DEFAULT_POLL_MS;
|
|
226
|
+
const start = now();
|
|
227
|
+
for (;;) {
|
|
228
|
+
const r = tryAcquireSlot(dir, max, owner, opts);
|
|
229
|
+
if (r.ok) return { ...r, waitedMs: now() - start };
|
|
230
|
+
if (r.error.code !== "no-slot") return { ...r, waitedMs: now() - start };
|
|
231
|
+
if (now() - start >= waitMs) return { ...r, waitedMs: now() - start };
|
|
232
|
+
await sleep(pollMs);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -27,6 +27,8 @@ export const DEFAULTS = Object.freeze({
|
|
|
27
27
|
captureOn: ["Stop", "PreCompact"],
|
|
28
28
|
minTurnsToCapture: 2,
|
|
29
29
|
maxCardsPerSession: 12,
|
|
30
|
+
captureMaxConcurrent: 2,
|
|
31
|
+
captureQueueWaitMs: 30000,
|
|
30
32
|
repoDenylist: [],
|
|
31
33
|
digestBudgetTokens: 1500,
|
|
32
34
|
recencyHalfLifeDays: 30,
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/collective/loop-guard.mjs — keep the collective layer from capturing itself.
|
|
3
|
+
*
|
|
4
|
+
* The global hooks (`maestro global-setup`) fire on EVERY Claude Code session on
|
|
5
|
+
* the machine, and the capture worker's distiller, the reflect reviewer and the
|
|
6
|
+
* skill curator are themselves `claude --print` sessions. Without an exemption
|
|
7
|
+
* each of those children's Stop hook captures ITS OWN transcript — whose content
|
|
8
|
+
* is the capture prompt — which spawns another worker, whose children stop and
|
|
9
|
+
* capture again. That is a fork-bomb: nothing in the chain terminates it.
|
|
10
|
+
*
|
|
11
|
+
* Two independent signals mark a session as the collective layer's own child:
|
|
12
|
+
*
|
|
13
|
+
* 1. ENV — every collective spawn carries {@link COLLECTIVE_CHILD_ENV}=1
|
|
14
|
+
* (set by the runtime adapter for the capture/reflect/curator lanes, and by
|
|
15
|
+
* the capture worker for its whole process tree). The hook classifies such
|
|
16
|
+
* a session as kind `collective` and does nothing for it.
|
|
17
|
+
* 2. CONTENT — if the env did not propagate (an older runtime, a wrapper that
|
|
18
|
+
* scrubs the environment), the transcript's FIRST user message is one of
|
|
19
|
+
* the collective prompts. Those prompts are this module's own text, so the
|
|
20
|
+
* opener is a reliable fingerprint; a human session does not open with it.
|
|
21
|
+
*
|
|
22
|
+
* Pure except {@link readTranscriptHead}, which reads a bounded prefix of a file.
|
|
23
|
+
*
|
|
24
|
+
* @module lib/collective/loop-guard
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
"use strict";
|
|
28
|
+
|
|
29
|
+
import { openSync, readSync, closeSync } from "node:fs";
|
|
30
|
+
|
|
31
|
+
/** The env marker every `claude` process spawned by the collective layer carries. */
|
|
32
|
+
export const COLLECTIVE_CHILD_ENV = "MAESTRO_COLLECTIVE_CHILD";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The opening line of each collective prompt. A drift test pins each one to its
|
|
36
|
+
* prompt builder (capture.buildDistillPrompt, reflect.buildReviewPrompt,
|
|
37
|
+
* curator.buildConsolidationPrompt), so a reworded prompt cannot silently disarm
|
|
38
|
+
* the content fallback.
|
|
39
|
+
*/
|
|
40
|
+
export const COLLECTIVE_PROMPT_SENTINELS = Object.freeze([
|
|
41
|
+
"You distil a finished work session into durable MEMORY CARDS",
|
|
42
|
+
"You are the self-improvement reviewer for an autonomous AI agent",
|
|
43
|
+
"You are the skill CURATOR for an autonomous AI agent",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* How much of a transcript the content fallback reads. The prompt is the first
|
|
48
|
+
* user input; SessionStart attachments (skill listings, digests) can precede it
|
|
49
|
+
* by tens of KB, so the window is generous — and one bounded read per stop.
|
|
50
|
+
*/
|
|
51
|
+
export const TRANSCRIPT_HEAD_BYTES = 512 * 1024;
|
|
52
|
+
|
|
53
|
+
/** How far into the first user message a sentinel may start (tolerates a harness preamble). */
|
|
54
|
+
const SENTINEL_WINDOW_CHARS = 2000;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Does this environment mark a collective child?
|
|
58
|
+
* @param {Record<string, string|undefined>} [env]
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
export function isCollectiveChildEnv(env = process.env) {
|
|
62
|
+
return !!env && String(env[COLLECTIVE_CHILD_ENV] || "").trim() === "1";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The session kind the hooks act on. `collective` wins over `daemon`: a
|
|
67
|
+
* collective child spawned from a daemon session inherits MAESTRO_DAEMON=1 and
|
|
68
|
+
* must still be exempt from capture.
|
|
69
|
+
* @param {{source?:string}} [input] the hook's stdin payload
|
|
70
|
+
* @param {Record<string, string|undefined>} [env]
|
|
71
|
+
* @returns {"collective"|"daemon"|"interactive"}
|
|
72
|
+
*/
|
|
73
|
+
export function classifyKind(input = {}, env = process.env) {
|
|
74
|
+
if (isCollectiveChildEnv(env)) return "collective";
|
|
75
|
+
const src = String((input && input.source) || "").toLowerCase();
|
|
76
|
+
if (src.includes("daemon") || (env && env.MAESTRO_DAEMON === "1")) return "daemon";
|
|
77
|
+
return "interactive";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function messageText(obj) {
|
|
81
|
+
const c = obj.content ?? (obj.message && obj.message.content);
|
|
82
|
+
if (typeof c === "string") return c;
|
|
83
|
+
if (Array.isArray(c)) {
|
|
84
|
+
return c.map((p) => (typeof p === "string" ? p : p && typeof p.text === "string" ? p.text : "")).filter(Boolean).join(" ");
|
|
85
|
+
}
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The text of the first user message in a JSONL transcript prefix, or "".
|
|
91
|
+
* A truncated last line is simply skipped.
|
|
92
|
+
* @param {string} head
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
export function firstUserMessage(head) {
|
|
96
|
+
if (!head) return "";
|
|
97
|
+
for (const raw of String(head).split("\n")) {
|
|
98
|
+
const line = raw.trim();
|
|
99
|
+
if (!line.startsWith("{")) continue;
|
|
100
|
+
let obj;
|
|
101
|
+
try { obj = JSON.parse(line); } catch { continue; /* a truncated/partial line is not a message */ }
|
|
102
|
+
// A `--print` prompt is recorded twice: first as the queued input
|
|
103
|
+
// (`queue-operation`/`enqueue`, ahead of any attachment lines), then as the
|
|
104
|
+
// first `user` message. Either one is the session's first user input.
|
|
105
|
+
const queued = obj.type === "queue-operation" && obj.operation === "enqueue";
|
|
106
|
+
const role = (obj.message && obj.message.role) || obj.role || obj.type || "";
|
|
107
|
+
if (role !== "user" && !queued) continue;
|
|
108
|
+
const text = messageText(obj);
|
|
109
|
+
if (text) return text;
|
|
110
|
+
}
|
|
111
|
+
return "";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Is this transcript a collective child's (its first user turn is a collective prompt)?
|
|
116
|
+
* @param {string} head a prefix of the transcript JSONL
|
|
117
|
+
* @returns {boolean}
|
|
118
|
+
*/
|
|
119
|
+
export function isCollectivePromptTranscript(head) {
|
|
120
|
+
const first = firstUserMessage(head).slice(0, SENTINEL_WINDOW_CHARS);
|
|
121
|
+
if (!first) return false;
|
|
122
|
+
return COLLECTIVE_PROMPT_SENTINELS.some((s) => first.includes(s));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Read at most `bytes` from the start of a file. "" when absent/unreadable.
|
|
127
|
+
* @param {string} path
|
|
128
|
+
* @param {number} [bytes]
|
|
129
|
+
* @returns {string}
|
|
130
|
+
*/
|
|
131
|
+
export function readTranscriptHead(path, bytes = TRANSCRIPT_HEAD_BYTES) {
|
|
132
|
+
if (!path) return "";
|
|
133
|
+
let fd = null;
|
|
134
|
+
try {
|
|
135
|
+
fd = openSync(path, "r");
|
|
136
|
+
const buf = Buffer.alloc(bytes);
|
|
137
|
+
const n = readSync(fd, buf, 0, bytes, 0);
|
|
138
|
+
return buf.subarray(0, n).toString("utf8");
|
|
139
|
+
} catch {
|
|
140
|
+
return ""; // no transcript → the content fallback has nothing to say; the env signal still governs
|
|
141
|
+
} finally {
|
|
142
|
+
if (fd !== null) { try { closeSync(fd); } catch { /* the read already completed or failed */ } }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Why a stop/capture event must NOT spawn a capture worker, or null when it may.
|
|
148
|
+
* @param {{kind:string, transcriptHead?:string}} o
|
|
149
|
+
* @returns {null|"collective-child-env"|"collective-child-transcript"}
|
|
150
|
+
*/
|
|
151
|
+
export function captureExemption({ kind, transcriptHead = "" } = {}) {
|
|
152
|
+
if (kind === "collective") return "collective-child-env";
|
|
153
|
+
if (isCollectivePromptTranscript(transcriptHead)) return "collective-child-transcript";
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
@@ -939,4 +939,94 @@ export default {
|
|
|
939
939
|
matchesMyName,
|
|
940
940
|
taskIdFromFileKey,
|
|
941
941
|
surfaceDef,
|
|
942
|
+
isMissedHumanMessage,
|
|
943
|
+
missedHumanRecord,
|
|
942
944
|
};
|
|
945
|
+
|
|
946
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
947
|
+
// A PERSON'S MESSAGE THAT THIS SEAT DROPPED
|
|
948
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Drop reasons that are NOT a person going unanswered, even when a human wrote
|
|
952
|
+
* the message. Each one says the room was never this seat's to read or the
|
|
953
|
+
* item was never a message at all, so counting it would bury the reasons that
|
|
954
|
+
* matter under noise every tick produces.
|
|
955
|
+
*
|
|
956
|
+
* `own_message` is here for the obvious reason; the membership/visibility ones
|
|
957
|
+
* because a room the seat cannot see is not a room it declined to answer in.
|
|
958
|
+
*/
|
|
959
|
+
const NOT_A_MISSED_PERSON = Object.freeze([
|
|
960
|
+
"own_message",
|
|
961
|
+
"channel_not_visible",
|
|
962
|
+
"not_a_member",
|
|
963
|
+
"membership_unknown",
|
|
964
|
+
"channel_kind_unknown",
|
|
965
|
+
"surface_disabled",
|
|
966
|
+
]);
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* PURE. Did this seat just drop a message a PERSON wrote in a room the seat
|
|
970
|
+
* belongs to?
|
|
971
|
+
*
|
|
972
|
+
* ── WHY THIS EXISTS ──────────────────────────────────────────────────────────
|
|
973
|
+
* `pullWideInbound` counts every drop into `stats.dropped[reason]` — a tally,
|
|
974
|
+
* by reason, with no identity and, unless something else happened that tick, no
|
|
975
|
+
* log line at all. That tally is why the 2026-09-21 #general roll-call took a
|
|
976
|
+
* reconstruction to explain: thirteen seats each incremented
|
|
977
|
+
* `dropped.no_mentions_not_threaded` by one and none of them said WHICH message
|
|
978
|
+
* or that a human had written it. A counter cannot be audited after the fact
|
|
979
|
+
* and cannot be correlated across seats.
|
|
980
|
+
*
|
|
981
|
+
* A person's message that this seat decided not to answer is a different class
|
|
982
|
+
* of event from ambient chatter it correctly ignored, and it is the only class
|
|
983
|
+
* anyone ever asks about afterwards. So it is named individually, once, at
|
|
984
|
+
* WARN — the same treatment `hydrate` already gives a body it could not read.
|
|
985
|
+
*
|
|
986
|
+
* Deliberately narrow, for the same reason the surface list is: this fires only
|
|
987
|
+
* for a MESSAGE-topic candidate, authored by a member the DIRECTORY says is
|
|
988
|
+
* HUMAN (never assumed from the absence of evidence), in a room whose
|
|
989
|
+
* membership this seat has PROVEN. Everything else is either not a person, not
|
|
990
|
+
* a room, or not knowable — and an over-broad warn line is a line operators
|
|
991
|
+
* learn to skip.
|
|
992
|
+
*
|
|
993
|
+
* @param {Candidate} cand
|
|
994
|
+
* @param {{directed:boolean, reason?:string}} verdict the resolved verdict
|
|
995
|
+
* @param {import("./facts.mjs").Facts} facts
|
|
996
|
+
* @param {string} me this seat's member id
|
|
997
|
+
* @returns {boolean}
|
|
998
|
+
*/
|
|
999
|
+
export function isMissedHumanMessage(cand, verdict, facts, me) {
|
|
1000
|
+
if (!cand || !verdict || verdict.directed) return false;
|
|
1001
|
+
if (cand.topic !== "message") return false;
|
|
1002
|
+
const reason = String(verdict.reason || "");
|
|
1003
|
+
if (NOT_A_MISSED_PERSON.includes(reason)) return false;
|
|
1004
|
+
const channelId = cand.ids && cand.ids.channelId;
|
|
1005
|
+
if (!channelId) return false;
|
|
1006
|
+
// A room whose membership is PROVEN, not merely visible: a public channel the
|
|
1007
|
+
// seat can read but has not joined does not address it.
|
|
1008
|
+
const members = asSet(facts && facts.memberChannelIds);
|
|
1009
|
+
if (!has(members, channelId)) return false;
|
|
1010
|
+
const author = cand.actor;
|
|
1011
|
+
if (!author || (me && String(author) === String(me))) return false;
|
|
1012
|
+
// Proven human. `authorKindOf` returns "" when the directory read degraded,
|
|
1013
|
+
// and an unknown author is NOT reported as a missed person — a degraded tick
|
|
1014
|
+
// would otherwise warn about every message in every room.
|
|
1015
|
+
return authorKindOf(facts, author) === "HUMAN";
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* PURE. The redaction-safe record of one missed person's message. No body, no
|
|
1020
|
+
* prose: the ids an operator needs to go and look, plus the reason this seat
|
|
1021
|
+
* gave. Mirrors the payload shape hq's `responder.silence` rows carry, so the
|
|
1022
|
+
* two planes describe the same event in the same words.
|
|
1023
|
+
*/
|
|
1024
|
+
export function missedHumanRecord(cand, verdict) {
|
|
1025
|
+
return {
|
|
1026
|
+
seq: cand && cand.seq != null ? cand.seq : null,
|
|
1027
|
+
messageId: (cand && cand.ids && cand.ids.messageId) || cand?.entityId || null,
|
|
1028
|
+
channelId: (cand && cand.ids && cand.ids.channelId) || null,
|
|
1029
|
+
author: (cand && cand.actor) || null,
|
|
1030
|
+
reason: String((verdict && verdict.reason) || "unknown"),
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
@@ -55,7 +55,12 @@
|
|
|
55
55
|
"use strict";
|
|
56
56
|
|
|
57
57
|
import { read as clientRead } from "../client.mjs";
|
|
58
|
-
import {
|
|
58
|
+
import {
|
|
59
|
+
classifyEvent,
|
|
60
|
+
resolveDirected,
|
|
61
|
+
isMissedHumanMessage,
|
|
62
|
+
missedHumanRecord,
|
|
63
|
+
} from "./directedness.mjs";
|
|
59
64
|
import { resolveFacts, DEFAULT_LIMITS } from "./facts.mjs";
|
|
60
65
|
import { hydrate } from "./hydrate.mjs";
|
|
61
66
|
import { toMessageEvent } from "./project.mjs";
|
|
@@ -106,6 +111,11 @@ export async function pullWideInbound(o = {}) {
|
|
|
106
111
|
delivered: 0,
|
|
107
112
|
bySurface: {},
|
|
108
113
|
dropped: {},
|
|
114
|
+
// EVERY person's message this tick decided not to answer, named. See
|
|
115
|
+
// `directedness.mjs#isMissedHumanMessage`: `dropped` is a tally by reason
|
|
116
|
+
// and cannot say WHICH message or that a human wrote it, which is the only
|
|
117
|
+
// question anyone asks after a room goes quiet.
|
|
118
|
+
unanswered: [],
|
|
109
119
|
degraded: [],
|
|
110
120
|
calls: 0,
|
|
111
121
|
reads: 0,
|
|
@@ -195,6 +205,18 @@ export async function pullWideInbound(o = {}) {
|
|
|
195
205
|
const verdict = resolveDirected(cand, me, facts, { enabled, meAliases });
|
|
196
206
|
if (!verdict.directed) {
|
|
197
207
|
stats.dropped[verdict.reason] = (stats.dropped[verdict.reason] || 0) + 1;
|
|
208
|
+
// A PERSON's message this seat is dropping is not ambient chatter, and a
|
|
209
|
+
// counter is not a record of it. Name it — once, at WARN, with the ids —
|
|
210
|
+
// so "I posted and nobody replied" is answerable from this machine's own
|
|
211
|
+
// log instead of by reasoning backwards from thirteen tallies.
|
|
212
|
+
if (isMissedHumanMessage(cand, verdict, facts, me)) {
|
|
213
|
+
const rec = missedHumanRecord(cand, verdict);
|
|
214
|
+
stats.unanswered.push(rec);
|
|
215
|
+
log(
|
|
216
|
+
"warn",
|
|
217
|
+
`[inbound] NOT answering a person: message ${rec.messageId || cand.seq} in channel ${rec.channelId} from ${rec.author} — ${rec.reason}`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
198
220
|
continue;
|
|
199
221
|
}
|
|
200
222
|
directed.push({ cand, verdict });
|
package/lib/org/ui-parity.mjs
CHANGED
|
@@ -3317,7 +3317,19 @@ export function messagingSearch(params, o = {}) {
|
|
|
3317
3317
|
* message ({ messageId }) or a channel over a window ({ channelId,
|
|
3318
3318
|
* windowDays? }); one of the two is required. Each row carries the operator
|
|
3319
3319
|
* reason (human_mentioned | addressed_to_human | nobody_elected |
|
|
3320
|
-
* no_responders) and its text
|
|
3320
|
+
* no_responders | handed_to_daemons) and its text; a `handed_to_daemons` row
|
|
3321
|
+
* also carries `daemonDriven`, the seats hq stood down for — every eligible
|
|
3322
|
+
* colleague in the room was answering from its OWN machine, so hq deliberately
|
|
3323
|
+
* said nothing and a room that then heard nothing is a fault on the DAEMON
|
|
3324
|
+
* plane, with the seat list already in hand.
|
|
3325
|
+
*
|
|
3326
|
+
* The same sentence is NOT repeated in `protocol.mjs`: that file is pinned by
|
|
3327
|
+
* `protocol.checksum` to hq's vendored copy, so even a comment there is a
|
|
3328
|
+
* cross-repo re-vendor (`scripts/sync-protocol.mjs`). The vocabulary's source
|
|
3329
|
+
* of truth is hq `src/server/llm-responder/silence-verdict.ts`; this JSDoc is
|
|
3330
|
+
* the agent-facing restatement of it.
|
|
3331
|
+
*
|
|
3332
|
+
* The agent-plane twin of the "Unanswered
|
|
3321
3333
|
* messages" panel on /settings/ai — same reader, same decoder.
|
|
3322
3334
|
* @param {object} params - { channelId?, messageId?, windowDays?, limit? }
|
|
3323
3335
|
* @param {object} o - { base, token, fetchImpl? }
|
package/lib/runtime/adapter.mjs
CHANGED
|
@@ -118,6 +118,7 @@ import { buildClaudeArgs } from "../session/launch-args.mjs";
|
|
|
118
118
|
import { cohortTierFor } from "../model-router/catalog.mjs";
|
|
119
119
|
import { DEFAULT_LLM_BASE_URL, apiKeyHelperCommand } from "../org/llm-token.mjs";
|
|
120
120
|
import { COHORT_BACKEND } from "../rate-guard.mjs";
|
|
121
|
+
import { COLLECTIVE_CHILD_ENV } from "../collective/loop-guard.mjs";
|
|
121
122
|
|
|
122
123
|
/** Engines a seat can name in `runtime.engine`. */
|
|
123
124
|
export const ENGINES = Object.freeze(["claude", "cohort"]);
|
|
@@ -201,6 +202,10 @@ export function subscriptionAuth(env) {
|
|
|
201
202
|
* fragments `x = {perm, mcp, knobs}`; `stdin` says whether the prompt is
|
|
202
203
|
* written to the child's stdin instead of argv; `posture` names the env shape;
|
|
203
204
|
* `retarget` says whether a router retarget is legal on the lane.
|
|
205
|
+
* `collectiveChild` marks the collective layer's own model passes: their env
|
|
206
|
+
* carries MAESTRO_COLLECTIVE_CHILD=1 so the global Stop/PreCompact hook does not
|
|
207
|
+
* capture the child's transcript (lib/collective/loop-guard.mjs) — without it,
|
|
208
|
+
* every capture spawns a session whose end spawns another capture.
|
|
204
209
|
*/
|
|
205
210
|
const LANES = Object.freeze({
|
|
206
211
|
dispatcher: {
|
|
@@ -232,15 +237,15 @@ const LANES = Object.freeze({
|
|
|
232
237
|
argv: (i) => ["--print", "--model", i.model, "-p", i.prompt],
|
|
233
238
|
},
|
|
234
239
|
capture: {
|
|
235
|
-
posture: "path", stdin: true, retarget: false,
|
|
240
|
+
posture: "path", stdin: true, retarget: false, collectiveChild: true,
|
|
236
241
|
argv: (i, x) => ["--print", "--model", i.model, ...x.mcp],
|
|
237
242
|
},
|
|
238
243
|
reflect: {
|
|
239
|
-
posture: "path", stdin: true, retarget: false,
|
|
244
|
+
posture: "path", stdin: true, retarget: false, collectiveChild: true,
|
|
240
245
|
argv: (i, x) => ["--print", "--model", i.model, ...x.mcp],
|
|
241
246
|
},
|
|
242
247
|
curator: {
|
|
243
|
-
posture: "path", stdin: true, retarget: false,
|
|
248
|
+
posture: "path", stdin: true, retarget: false, collectiveChild: true,
|
|
244
249
|
argv: (i, x) => ["--print", "--model", i.model, ...x.mcp],
|
|
245
250
|
},
|
|
246
251
|
enrich: {
|
|
@@ -394,7 +399,12 @@ export function buildSpawn(i = {}, deps = {}) {
|
|
|
394
399
|
daemonArgs: deps.daemonArgs || daemonClaudeArgs,
|
|
395
400
|
augmentedPath: deps.augmentedPath || augmentedPath,
|
|
396
401
|
};
|
|
397
|
-
|
|
402
|
+
// A collective lane's marker rides extraEnv so both engines set it after
|
|
403
|
+
// every merge; a caller cannot drop it by passing its own extraEnv.
|
|
404
|
+
const extraEnv = spec.collectiveChild
|
|
405
|
+
? { ...(i.extraEnv && typeof i.extraEnv === "object" ? i.extraEnv : {}), [COLLECTIVE_CHILD_ENV]: "1" }
|
|
406
|
+
: i.extraEnv;
|
|
407
|
+
const input = { ...i, env: i.env === undefined ? process.env : i.env, extraEnv };
|
|
398
408
|
|
|
399
409
|
if (engine === "cohort") return buildCohortEngineSpawn(spec, input, d, deps, fail);
|
|
400
410
|
|
|
@@ -411,8 +421,8 @@ export function buildSpawn(i = {}, deps = {}) {
|
|
|
411
421
|
const argv = spec.argv(input, x);
|
|
412
422
|
|
|
413
423
|
const env = envResult.env;
|
|
414
|
-
if (
|
|
415
|
-
for (const [k, v] of Object.entries(
|
|
424
|
+
if (input.extraEnv && typeof input.extraEnv === "object") {
|
|
425
|
+
for (const [k, v] of Object.entries(input.extraEnv)) if (v !== undefined && v !== null) env[k] = String(v);
|
|
416
426
|
}
|
|
417
427
|
|
|
418
428
|
return {
|
|
@@ -84,7 +84,8 @@
|
|
|
84
84
|
* // Also on `machine` (open record), so hq's fleet view can show it next
|
|
85
85
|
* // to sdkVersion without a schema change. `ok` is the attempt's verdict:
|
|
86
86
|
* // true = healthy on `to`; false = install failed or rolled back to `from`.
|
|
87
|
-
* upgrade?: { at: ISO8601, from: string, to: string, ok: boolean
|
|
87
|
+
* upgrade?: { at: ISO8601, from: string, to: string, ok: boolean,
|
|
88
|
+
* reason?: string, healthy?: boolean }, // reason: WHY a failure failed
|
|
88
89
|
* // THE BEATING DAEMON — which process is emitting this beat, what code
|
|
89
90
|
* // it is actually running, and the last health-gate verdict on it.
|
|
90
91
|
* // Also on `machine` (open record). `sdkVersion` ABOVE is what is
|
|
@@ -1367,7 +1368,25 @@ export function upgradeSummary(last) {
|
|
|
1367
1368
|
const at = typeof last.at === "string" ? last.at : "";
|
|
1368
1369
|
const to = typeof last.to === "string" ? last.to : "";
|
|
1369
1370
|
if (!at || !to) return null;
|
|
1370
|
-
|
|
1371
|
+
// `reason` and `healthy` are the two fields autoupdate.sh has always written
|
|
1372
|
+
// to state/autoupdate/last.json and this projection used to drop. Without
|
|
1373
|
+
// them a failed attempt reaches the org as a bare `ok:false` — which is the
|
|
1374
|
+
// state three seats were in on 2026-09-24: provably behind, provably having
|
|
1375
|
+
// tried, and no way to learn WHY short of reading a file on a machine nobody
|
|
1376
|
+
// could reach. "install-failed" and "unhealthy-rolled-back" are different
|
|
1377
|
+
// problems with different fixes and they looked identical from here.
|
|
1378
|
+
//
|
|
1379
|
+
// Bounded on the way out: a reason is a short token plus at most a trimmed
|
|
1380
|
+
// error line, and the beat is not a log shipper.
|
|
1381
|
+
const reason = typeof last.reason === "string" ? last.reason.trim().slice(0, 300) : "";
|
|
1382
|
+
return {
|
|
1383
|
+
at,
|
|
1384
|
+
from: typeof last.from === "string" ? last.from : "",
|
|
1385
|
+
to,
|
|
1386
|
+
ok: last.ok === true,
|
|
1387
|
+
...(reason ? { reason } : {}),
|
|
1388
|
+
...(typeof last.healthy === "boolean" ? { healthy: last.healthy } : {}),
|
|
1389
|
+
};
|
|
1371
1390
|
}
|
|
1372
1391
|
|
|
1373
1392
|
// ---------------------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cohortapp/agent-sdk",
|
|
3
|
-
"version": "2.18.
|
|
3
|
+
"version": "2.18.6",
|
|
4
4
|
"description": "Cohort Agent SDK — autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,6 +19,13 @@ captureOn: [Stop, PreCompact]
|
|
|
19
19
|
minTurnsToCapture: 2
|
|
20
20
|
# Hard cap on cards distilled from a single session (anti-bloat + cost guard).
|
|
21
21
|
maxCardsPerSession: 12
|
|
22
|
+
# Machine-wide ceiling on capture workers doing model work at once. Each worker
|
|
23
|
+
# runs one or two `claude --print` passes; the ceiling keeps a burst of session
|
|
24
|
+
# ends from fanning out into unbounded model processes.
|
|
25
|
+
captureMaxConcurrent: 2
|
|
26
|
+
# How long a worker waits for a free slot before it gives up and records a
|
|
27
|
+
# skipped capture (counter `collective.capture_skipped`, reason `no-slot`).
|
|
28
|
+
captureQueueWaitMs: 30000
|
|
22
29
|
# Repos/paths whose sessions must NOT contribute to memory (substring or glob,
|
|
23
30
|
# matched against the session cwd). Client work, secrets, anything ring-fenced.
|
|
24
31
|
# Information-barrier domains are always excluded regardless of this list.
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
* `eslint.config.mjs`/`authz-drift.test.ts` use in the Cohort repo.
|
|
43
43
|
*
|
|
44
44
|
* Set equality also makes this guard self-canarying: the register is non-empty,
|
|
45
|
-
* so a regex broken by a refactor yields zero matches and fails LOUDLY as
|
|
45
|
+
* so a regex broken by a refactor yields zero matches and fails LOUDLY as
|
|
46
46
|
* stale entries, rather than passing vacuously.
|
|
47
47
|
*
|
|
48
48
|
* Pure, dependency-light: Node builtins only. ESM.
|
|
@@ -71,6 +71,8 @@ export const SANCTIONED_DIRECT_WRITES = {
|
|
|
71
71
|
"acquireScheduleLock: reclaim of a lock already proven stale by mtime; must land in place under the same name.",
|
|
72
72
|
"lib/cadence-bus.mjs:328":
|
|
73
73
|
"acquireScheduleLock: second O_EXCL attempt after the stale holder vanished.",
|
|
74
|
+
"lib/collective/capture-slots.mjs:152":
|
|
75
|
+
"tryAcquireLock: O_EXCL create of a capture slot/session lock (the machine-wide capture ceiling). A lock published by rename is not a lock — two workers could both hold one slot.",
|
|
74
76
|
};
|
|
75
77
|
|
|
76
78
|
/** Argument spellings that are already safe and must never be flagged. */
|
|
@@ -14,6 +14,15 @@
|
|
|
14
14
|
* this isn't an agent machine or the layer is disabled. Capture runs in a
|
|
15
15
|
* DETACHED child so it never blocks session exit.
|
|
16
16
|
*
|
|
17
|
+
* Loop guard: the capture worker's own model passes are Claude Code sessions,
|
|
18
|
+
* so their hooks land here too. A session of kind `collective` (env
|
|
19
|
+
* MAESTRO_COLLECTIVE_CHILD=1), or whose transcript opens with a collective
|
|
20
|
+
* prompt, is exempt from every event — no presence, no primer, no capture —
|
|
21
|
+
* so a capture can never spawn a capture (lib/collective/loop-guard.mjs).
|
|
22
|
+
* Workers are further bounded machine-wide by slot + per-session O_EXCL locks
|
|
23
|
+
* (lib/collective/capture-slots.mjs); a worker that cannot get a slot records a
|
|
24
|
+
* skipped capture rather than running.
|
|
25
|
+
*
|
|
17
26
|
* The agent repo this feeds is the runner's own repo, overridable with
|
|
18
27
|
* MAESTRO_COLLECTIVE_ROOT (used by tests + multi-repo setups).
|
|
19
28
|
*
|
|
@@ -26,6 +35,7 @@ import { fileURLToPath } from "node:url";
|
|
|
26
35
|
import { dirname, join, basename } from "node:path";
|
|
27
36
|
import { existsSync, readFileSync } from "node:fs";
|
|
28
37
|
import { spawn } from "node:child_process";
|
|
38
|
+
import { classifyKind, captureExemption, readTranscriptHead, COLLECTIVE_CHILD_ENV } from "../../lib/collective/loop-guard.mjs";
|
|
29
39
|
|
|
30
40
|
const THIS_FILE = fileURLToPath(import.meta.url);
|
|
31
41
|
const SELF_ROOT = process.env.MAESTRO_COLLECTIVE_ROOT || join(dirname(THIS_FILE), "..", "..");
|
|
@@ -60,6 +70,7 @@ async function main() {
|
|
|
60
70
|
const cwd = input.cwd || process.cwd();
|
|
61
71
|
const sessionId = input.session_id || input.sessionId || `sess-${process.pid}`;
|
|
62
72
|
const transcriptPath = input.transcript_path || input.transcriptPath || "";
|
|
73
|
+
const kind = classifyKind(input, process.env);
|
|
63
74
|
|
|
64
75
|
let config, presence, isEnabled, isRepoDenied;
|
|
65
76
|
try {
|
|
@@ -75,8 +86,15 @@ async function main() {
|
|
|
75
86
|
const denied = isRepoDenied(cwd, config);
|
|
76
87
|
const repo = basename(cwd || "");
|
|
77
88
|
|
|
89
|
+
// The collective layer's own children: nothing on any event. A capture/
|
|
90
|
+
// reflect/curator `claude --print` must not register presence, be primed, or
|
|
91
|
+
// — above all — be captured (that is the recursion). Counted, never silent.
|
|
92
|
+
if (kind === "collective") {
|
|
93
|
+
if (event === "stop" || event === "capture") await recordExemption("collective-child-env", sessionId);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
78
97
|
if (event === "prime") {
|
|
79
|
-
const kind = classifyKind(input);
|
|
80
98
|
try { presence.register(SELF_ROOT, { sessionId, cwd, repo, kind, ...ownerFields(presence) }); } catch { /* */ }
|
|
81
99
|
if (denied) return; // never inject agent memory (or the persona) into a ring-fenced/client session
|
|
82
100
|
const parts = [];
|
|
@@ -118,7 +136,7 @@ async function main() {
|
|
|
118
136
|
let beaten = false;
|
|
119
137
|
try { beaten = presence.heartbeat(SELF_ROOT, sessionId); } catch { beaten = false; }
|
|
120
138
|
if (!beaten) {
|
|
121
|
-
try { presence.register(SELF_ROOT, { sessionId, cwd, repo, kind
|
|
139
|
+
try { presence.register(SELF_ROOT, { sessionId, cwd, repo, kind, ...ownerFields(presence) }); } catch { /* */ }
|
|
122
140
|
}
|
|
123
141
|
// WS3: nudge the self-learning loop's tool-use counter. Cheap, fail-open,
|
|
124
142
|
// and only when the layer is enabled + the repo isn't denylisted.
|
|
@@ -133,14 +151,51 @@ async function main() {
|
|
|
133
151
|
|
|
134
152
|
if (event === "stop") {
|
|
135
153
|
try { presence.deregister(SELF_ROOT, sessionId); } catch { /* */ }
|
|
136
|
-
if (!denied)
|
|
154
|
+
if (!denied) await maybeSpawnCaptureWorker({ kind, transcriptPath, cwd, sessionId });
|
|
137
155
|
return;
|
|
138
156
|
}
|
|
139
157
|
|
|
140
158
|
if (event === "capture") {
|
|
141
|
-
if (!denied)
|
|
159
|
+
if (!denied) await maybeSpawnCaptureWorker({ kind, transcriptPath, cwd, sessionId });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Spawn a capture worker unless this session is the collective layer's own
|
|
166
|
+
* child. The env marker was handled before dispatch; this catches a child whose
|
|
167
|
+
* env did not propagate, by its transcript (the first user input is one of the
|
|
168
|
+
* collective prompts).
|
|
169
|
+
*/
|
|
170
|
+
async function maybeSpawnCaptureWorker({ kind, transcriptPath, cwd, sessionId }) {
|
|
171
|
+
const exempt = captureExemption({ kind, transcriptHead: readTranscriptHead(transcriptPath) });
|
|
172
|
+
if (exempt) {
|
|
173
|
+
await recordExemption(exempt, sessionId);
|
|
142
174
|
return;
|
|
143
175
|
}
|
|
176
|
+
spawnCaptureWorker({ transcriptPath, cwd, sessionId });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Count a capture the loop guard refused to start. Never rejects. */
|
|
180
|
+
function recordExemption(reason, sessionId) {
|
|
181
|
+
return countCapture("collective.capture_exempt", reason, sessionId);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Count a capture a worker gave up on (lock contention). Never rejects. */
|
|
185
|
+
function recordSkip(reason, sessionId, extra = {}) {
|
|
186
|
+
return countCapture("collective.capture_skipped", reason, sessionId, extra);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* One durable counter row per refused/skipped capture, in the agent's
|
|
191
|
+
* logs/diagnostics/counters stream (what doctor reads). Dynamic import so a repo
|
|
192
|
+
* that predates lib/diagnostics stays silent rather than failing the hook.
|
|
193
|
+
*/
|
|
194
|
+
async function countCapture(name, reason, sessionId, extra = {}) {
|
|
195
|
+
try {
|
|
196
|
+
const counters = await import("../../lib/diagnostics/counters.mjs");
|
|
197
|
+
counters.bump(name, { reason, sessionId: sessionId || "", ...extra }, { agentRoot: SELF_ROOT });
|
|
198
|
+
} catch { /* a counter must never break a session exit */ }
|
|
144
199
|
}
|
|
145
200
|
|
|
146
201
|
/** The identity block + status line for `prime`. "" when there is nothing to say. */
|
|
@@ -178,13 +233,6 @@ function ownerFields(presence) {
|
|
|
178
233
|
}
|
|
179
234
|
}
|
|
180
235
|
|
|
181
|
-
function classifyKind(input) {
|
|
182
|
-
const src = String(input.source || "").toLowerCase();
|
|
183
|
-
if (src.includes("daemon") || process.env.MAESTRO_DAEMON === "1") return "daemon";
|
|
184
|
-
if (src.includes("resume")) return "interactive";
|
|
185
|
-
return "interactive";
|
|
186
|
-
}
|
|
187
|
-
|
|
188
236
|
/** Spawn the detached capture pass and return immediately. */
|
|
189
237
|
function spawnCaptureWorker({ transcriptPath, cwd, sessionId }) {
|
|
190
238
|
try {
|
|
@@ -197,6 +245,9 @@ function spawnCaptureWorker({ transcriptPath, cwd, sessionId }) {
|
|
|
197
245
|
MAESTRO_CAPTURE_TRANSCRIPT_PATH: transcriptPath || "",
|
|
198
246
|
MAESTRO_CAPTURE_CWD: cwd || "",
|
|
199
247
|
MAESTRO_CAPTURE_SESSION: sessionId || "",
|
|
248
|
+
// The worker's whole process tree is the collective layer: every
|
|
249
|
+
// `claude` it spawns inherits this, so their hooks exempt themselves.
|
|
250
|
+
[COLLECTIVE_CHILD_ENV]: "1",
|
|
200
251
|
},
|
|
201
252
|
});
|
|
202
253
|
child.unref();
|
|
@@ -214,23 +265,68 @@ function spawnCaptureWorker({ transcriptPath, cwd, sessionId }) {
|
|
|
214
265
|
*
|
|
215
266
|
* Both passes are independently fail-open: a throw in either is swallowed, the
|
|
216
267
|
* worker still exits 0, and the session was never blocked.
|
|
268
|
+
*
|
|
269
|
+
* Before either pass the worker takes two O_EXCL locks (lib/collective/
|
|
270
|
+
* capture-slots.mjs): the SESSION lock (one worker per session at a time — a
|
|
271
|
+
* PreCompact and the Stop after it never distil in parallel) and then one of
|
|
272
|
+
* `captureMaxConcurrent` machine-wide SLOTS, queueing up to
|
|
273
|
+
* `captureQueueWaitMs`. Either refusal ends the worker with a counted skip
|
|
274
|
+
* (`collective.capture_skipped`); an unwritable lock dir fails CLOSED the same
|
|
275
|
+
* way, because an unbounded capture is the failure this exists to prevent.
|
|
217
276
|
*/
|
|
218
277
|
async function doCaptureWorker() {
|
|
278
|
+
const transcriptPath = process.env.MAESTRO_CAPTURE_TRANSCRIPT_PATH || "";
|
|
279
|
+
const cwd = process.env.MAESTRO_CAPTURE_CWD || "";
|
|
280
|
+
const sessionId = process.env.MAESTRO_CAPTURE_SESSION || "";
|
|
281
|
+
let cfg, cap, text;
|
|
219
282
|
try {
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
283
|
+
const cfgMod = await import("../../lib/collective/config.mjs");
|
|
284
|
+
cfg = await cfgMod.loadConfig(SELF_ROOT);
|
|
285
|
+
if (!cfgMod.isEnabled(cfg)) return;
|
|
223
286
|
let raw = "";
|
|
224
287
|
if (transcriptPath && existsSync(transcriptPath)) {
|
|
225
288
|
try { raw = readFileSync(transcriptPath, "utf8"); } catch { raw = ""; }
|
|
226
289
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (!text) return;
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
290
|
+
cap = await import("../../lib/collective/capture.mjs");
|
|
291
|
+
text = cap.transcriptToText(raw);
|
|
292
|
+
if (!text) return; // nothing to distil → no lock, no slot
|
|
293
|
+
} catch {
|
|
294
|
+
return; // libs unavailable / not an agent repo → nothing to capture into
|
|
295
|
+
}
|
|
296
|
+
let slots;
|
|
297
|
+
try {
|
|
298
|
+
slots = await import("../../lib/collective/capture-slots.mjs");
|
|
299
|
+
} catch {
|
|
300
|
+
await recordSkip("lock-unavailable", sessionId);
|
|
301
|
+
return; // no lock module → no ceiling → do not run
|
|
302
|
+
}
|
|
303
|
+
const dir = slots.captureLockDir(process.env);
|
|
304
|
+
const owner = { pid: process.pid, sessionId };
|
|
305
|
+
const session = slots.tryAcquireLock(slots.sessionLockPath(dir, sessionId), owner);
|
|
306
|
+
if (!session.ok) {
|
|
307
|
+
await recordSkip(session.error.code === "held" ? "session-busy" : "lock-io", sessionId);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
const max = slots.maxConcurrentFrom(cfg);
|
|
312
|
+
const slot = await slots.acquireSlotQueued(dir, max, owner, { waitMs: slots.queueWaitFrom(cfg) });
|
|
313
|
+
if (!slot.ok) {
|
|
314
|
+
await recordSkip(slot.error.code === "no-slot" ? "no-slot" : "lock-io", sessionId, { max, waitedMs: slot.waitedMs });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
await runCapturePasses({ cfg, cap, text, transcriptPath, cwd, sessionId });
|
|
319
|
+
} finally {
|
|
320
|
+
slot.release();
|
|
321
|
+
}
|
|
322
|
+
} finally {
|
|
323
|
+
session.release();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
233
326
|
|
|
327
|
+
/** Pass A + Pass B over one read of the transcript. Runs only under both locks. */
|
|
328
|
+
async function runCapturePasses({ cfg, cap, text, transcriptPath, cwd, sessionId }) {
|
|
329
|
+
try {
|
|
234
330
|
// ── Pass A — collective memory cards (existing behaviour, unchanged). ──
|
|
235
331
|
const res = await cap.captureSession({ agentRoot: SELF_ROOT, sessionId, cwd, transcriptText: text, cfg });
|
|
236
332
|
if (res.written > 0) {
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
|
|
99
99
|
"use strict";
|
|
100
100
|
|
|
101
|
-
import { readFileSync } from "node:fs";
|
|
101
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
102
102
|
import { spawnSync } from "node:child_process";
|
|
103
103
|
import { dirname, join, resolve as resolvePath } from "node:path";
|
|
104
104
|
import { fileURLToPath } from "node:url";
|
|
@@ -712,9 +712,19 @@ export function npmWhoami(deps) {
|
|
|
712
712
|
return { ok: false, error: (err || `exit ${r.code}`).trim() };
|
|
713
713
|
}
|
|
714
714
|
|
|
715
|
-
/**
|
|
715
|
+
/**
|
|
716
|
+
* `npm view <pkg> version` → {ok,version,error?}. Needs no auth.
|
|
717
|
+
*
|
|
718
|
+
* `--prefer-online` is load-bearing, not tidiness. npm caches registry metadata
|
|
719
|
+
* and revalidates it lazily, so straight after a successful publish this read
|
|
720
|
+
* kept answering with the PREVIOUS version from the local cache: 2.18.4 went to
|
|
721
|
+
* the registry, and all ten read-lag retries reported 2.17.0 from ~/.npm while
|
|
722
|
+
* a fresh shell already saw 2.18.4. The run then declared the publish stage
|
|
723
|
+
* failed on a package it had just published correctly. The retry loop cannot
|
|
724
|
+
* out-wait a cache that is never re-fetched.
|
|
725
|
+
*/
|
|
716
726
|
export function npmLatest(pkg, deps) {
|
|
717
|
-
const r = deps.exec("npm", ["view", pkg, "version"]);
|
|
727
|
+
const r = deps.exec("npm", ["view", "--prefer-online", pkg, "version"]);
|
|
718
728
|
if (r.ok && r.stdout) return { ok: true, version: r.stdout.split("\n").pop().trim() };
|
|
719
729
|
return { ok: false, version: null, error: ((r.stderr || r.stdout || `exit ${r.code}`).split("\n")[0] || "").trim() };
|
|
720
730
|
}
|
|
@@ -748,16 +758,57 @@ export function gitTag(version, deps) {
|
|
|
748
758
|
* @param {object} o - { agentRoot, client?, seatWindowMs? }
|
|
749
759
|
* @returns {Promise<{ok:boolean, error?:string, seats:object[], nonSeats:object[]}>}
|
|
750
760
|
*/
|
|
761
|
+
/**
|
|
762
|
+
* Which enrolled agent directory this run reads the org through.
|
|
763
|
+
*
|
|
764
|
+
* This script lives in the SDK repo, which is NOT an agent seat: it has no
|
|
765
|
+
* config/org.yaml. Run from ~/maestro with nothing set, `loadOrgConfig(undefined)`
|
|
766
|
+
* threw `The "path" argument must be of type string`, the verify loop treated
|
|
767
|
+
* that as a transient read failure, and it retried ninety times over two hours
|
|
768
|
+
* before reporting nothing — about a fleet that had in fact taken the release.
|
|
769
|
+
*
|
|
770
|
+
* Order: an explicit --agent-root / AGENT_ROOT / AGENT_DIR, then the cwd if it
|
|
771
|
+
* is itself a seat, then the one enrolled directory under $HOME. Several
|
|
772
|
+
* candidates is ambiguity, not a default: it says so and asks for the flag.
|
|
773
|
+
*
|
|
774
|
+
* @returns {{ok:true, root:string, how:string} | {ok:false, error:string}}
|
|
775
|
+
*/
|
|
776
|
+
export function resolveAgentRoot(explicit, deps = {}) {
|
|
777
|
+
const exists = deps.exists || ((p) => existsSync(p));
|
|
778
|
+
const home = deps.home || process.env.HOME || "";
|
|
779
|
+
const cwd = deps.cwd || process.cwd();
|
|
780
|
+
const isSeat = (dir) => Boolean(dir) && exists(join(dir, "config", "org.yaml"));
|
|
781
|
+
|
|
782
|
+
if (explicit) {
|
|
783
|
+
if (isSeat(explicit)) return { ok: true, root: explicit, how: "given" };
|
|
784
|
+
return { ok: false, error: `--agent-root ${explicit} has no config/org.yaml — that is not an enrolled agent directory.` };
|
|
785
|
+
}
|
|
786
|
+
if (isSeat(cwd)) return { ok: true, root: cwd, how: "cwd" };
|
|
787
|
+
|
|
788
|
+
const found = (deps.listHome || (() => { try { return readdirSync(home); } catch { return []; } }))()
|
|
789
|
+
.map((name) => join(home, name))
|
|
790
|
+
.filter(isSeat);
|
|
791
|
+
if (found.length === 1) return { ok: true, root: found[0], how: "discovered under $HOME" };
|
|
792
|
+
if (found.length === 0) {
|
|
793
|
+
return { ok: false, error: `no enrolled agent directory found (looked at ${cwd} and $HOME/*/config/org.yaml) — pass --agent-root <dir>.` };
|
|
794
|
+
}
|
|
795
|
+
return { ok: false, error: `several enrolled agent directories (${found.join(", ")}) — pass --agent-root <dir> to say which one reads the fleet.` };
|
|
796
|
+
}
|
|
797
|
+
|
|
751
798
|
export async function readFleet(o = {}) {
|
|
752
799
|
const client = o.client || (await import("../../lib/org/client.mjs"));
|
|
800
|
+
const root = resolveAgentRoot(o.agentRoot);
|
|
801
|
+
// `fatal` means polling cannot help: the fault is in how this run was invoked,
|
|
802
|
+
// not in a momentarily unreachable server. The loop stops on it.
|
|
803
|
+
if (!root.ok) return { ok: false, fatal: true, error: root.error, seats: [], nonSeats: [] };
|
|
753
804
|
let cfg;
|
|
754
805
|
try {
|
|
755
|
-
cfg = client.loadOrgConfig(
|
|
806
|
+
cfg = client.loadOrgConfig(root.root);
|
|
756
807
|
} catch (err) {
|
|
757
|
-
return { ok: false, error: `org config unreadable (${err && err.message})`, seats: [], nonSeats: [] };
|
|
808
|
+
return { ok: false, fatal: true, error: `org config unreadable at ${root.root} (${err && err.message})`, seats: [], nonSeats: [] };
|
|
758
809
|
}
|
|
759
810
|
if (!client.isEnabled(cfg)) {
|
|
760
|
-
return { ok: false, error:
|
|
811
|
+
return { ok: false, fatal: true, error: `org integration is not enabled for ${root.root} — cannot read the fleet`, seats: [], nonSeats: [] };
|
|
761
812
|
}
|
|
762
813
|
let r;
|
|
763
814
|
try {
|
|
@@ -899,6 +950,12 @@ export async function verifyPropagation(o) {
|
|
|
899
950
|
client: o.client,
|
|
900
951
|
seatWindowMs: o.seatWindowMs,
|
|
901
952
|
});
|
|
953
|
+
if (!fleet.ok && fleet.fatal) {
|
|
954
|
+
// Not transient: retrying changes nothing and the deadline would only
|
|
955
|
+
// delay the answer. Say what to do and stop.
|
|
956
|
+
deps.log(`\nfleet read cannot succeed as invoked — ${fleet.error}`);
|
|
957
|
+
return { ok: false, summary: null, rounds: round, fatal: true, error: fleet.error };
|
|
958
|
+
}
|
|
902
959
|
if (!fleet.ok) {
|
|
903
960
|
deps.log(`round ${round}: fleet read failed — ${fleet.error}`);
|
|
904
961
|
} else {
|
|
@@ -212,12 +212,17 @@ J=$(( RANDOM % (JITTER_MAX + 1) )); log "wake; jitter ${J}s"; [ "$J" -gt 0 ] &&
|
|
|
212
212
|
# ── retry helper (npm view / install) ────────────────────────────────────────
|
|
213
213
|
RETRIES=3
|
|
214
214
|
RETRY_SLEEP="${MAESTRO_AUTOUPDATE_RETRY_SLEEP:-20}"
|
|
215
|
+
# The last error line a retried command printed, for the reason the org reads.
|
|
216
|
+
# Set on every failed attempt, cleared on success, and deliberately the LAST
|
|
217
|
+
# npm/git line rather than the whole log: the beat is not a log shipper.
|
|
218
|
+
LAST_ERROR=""
|
|
215
219
|
retry(){ # $1 = label; rest = command (stdout+stderr → LOG)
|
|
216
220
|
local label="$1"; shift
|
|
217
221
|
local attempt=1 wait
|
|
218
222
|
while :; do
|
|
219
|
-
if "$@" >> "$LOG" 2>&1; then return 0; fi
|
|
220
|
-
|
|
223
|
+
if "$@" >> "$LOG" 2>&1; then LAST_ERROR=""; return 0; fi
|
|
224
|
+
LAST_ERROR="$(grep -aE "npm (error|ERR!)|^Error|EACCES|EEXIST|ENOTEMPTY|ENOSPC|ETARGET|E404|E401|ENOENT" "$LOG" 2>/dev/null | tail -1 | cut -c1-200)"
|
|
225
|
+
if [ "$attempt" -ge "$RETRIES" ]; then log "$label: failed after $attempt attempt(s)${LAST_ERROR:+ — $LAST_ERROR}"; return 1; fi
|
|
221
226
|
wait=$(( RETRY_SLEEP * attempt ))
|
|
222
227
|
log "$label: attempt $attempt failed; retrying in ${wait}s"
|
|
223
228
|
sleep "$wait"; attempt=$(( attempt + 1 ))
|
|
@@ -598,7 +603,12 @@ failed_hold_reason(){ # prints "<reason> at <at>" when LATEST failed health with
|
|
|
598
603
|
try {
|
|
599
604
|
const j = JSON.parse(require("fs").readFileSync(file, "utf8"));
|
|
600
605
|
const at = Date.parse(j.at);
|
|
601
|
-
|
|
606
|
+
// Match the reason TOKEN, not the whole string: since 2.18.5 a reason may
|
|
607
|
+
// carry a trailing ": <npm error line>", and an error line that happened
|
|
608
|
+
// to contain the word "unhealthy" must not put a seat into a day-long
|
|
609
|
+
// hold it did not earn.
|
|
610
|
+
const token = String(j.reason).split(": ")[0];
|
|
611
|
+
if (j.to === latest && j.ok === false && /unhealthy/.test(token) && Number.isFinite(at) && Date.now() - at < Number(holdS) * 1000) process.stdout.write(`${j.reason} at ${j.at}`);
|
|
602
612
|
} catch {}
|
|
603
613
|
' "$LAST_JSON" "$LATEST" "$FAILED_HOLD_S" 2>/dev/null
|
|
604
614
|
}
|
|
@@ -646,7 +656,11 @@ migrate_poller_plist(){ # audit F6 follow-through: an older install left SLACK_U
|
|
|
646
656
|
|
|
647
657
|
if ! apply_version "$LATEST"; then
|
|
648
658
|
log "npm install FAILED; aborting, staying on $CUR"
|
|
649
|
-
|
|
659
|
+
# Name WHAT failed, not merely that something did. Three seats sat on
|
|
660
|
+
# `ok:false` with no reason for hours on 2026-09-24 and the difference between
|
|
661
|
+
# a disk-full box, a permissions problem and an unreachable registry is the
|
|
662
|
+
# difference between three different fixes.
|
|
663
|
+
write_last "$CUR" "$LATEST" false false "install-failed${LAST_ERROR:+: $LAST_ERROR}"
|
|
650
664
|
exit 1
|
|
651
665
|
fi
|
|
652
666
|
restart_daemon
|