@cohortapp/agent-sdk 2.8.0 → 2.9.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/lib/telemetry/collect.mjs +56 -8
- package/lib/telemetry/collect.test.mjs +77 -1
- package/package.json +1 -1
- package/scripts/daemon/agent-daemon.mjs +7 -0
- package/scripts/daemon/assurance.mjs +54 -1
- package/scripts/daemon/board-mirror.mjs +142 -0
- package/scripts/daemon/board-mirror.test.mjs +107 -0
- package/scripts/daemon/prompt-builder.mjs +29 -3
- package/scripts/daemon/prompt-builder.test.mjs +41 -0
|
@@ -241,6 +241,47 @@ function humanActivity(intent, taskClass) {
|
|
|
241
241
|
* uptimeDays?:number, spend24h?:number, device?:string, ram?:string, cores?:number
|
|
242
242
|
* }>}
|
|
243
243
|
*/
|
|
244
|
+
/**
|
|
245
|
+
* Percentage of memory genuinely unavailable.
|
|
246
|
+
*
|
|
247
|
+
* On macOS this is `100 - (memory_pressure's System-wide memory free
|
|
248
|
+
* percentage)`. That is the same source `scripts/watchdog/memory-watchdog.sh`
|
|
249
|
+
* thresholds on, so the number in the Fleet view and the number the watchdog
|
|
250
|
+
* acts on can no longer disagree — they used to disagree by ~66 points, which
|
|
251
|
+
* is how a healthy machine came to show a standing CRITICAL.
|
|
252
|
+
*
|
|
253
|
+
* Anywhere else (or if memory_pressure yields nothing) falls back to
|
|
254
|
+
* `1 - free/total`, which on Linux does mean what it says.
|
|
255
|
+
*
|
|
256
|
+
* @param {object} o collector options; `o.execFile` is the test seam
|
|
257
|
+
* @param {object} osImpl
|
|
258
|
+
* @returns {Promise<number>}
|
|
259
|
+
*/
|
|
260
|
+
async function collectMemUsedPct(o, osImpl) {
|
|
261
|
+
const fallback = () => {
|
|
262
|
+
try {
|
|
263
|
+
const total = Number(osImpl.totalmem());
|
|
264
|
+
const free = Number(osImpl.freemem());
|
|
265
|
+
if (Number.isFinite(total) && total > 0 && Number.isFinite(free)) {
|
|
266
|
+
return pct((1 - free / total) * 100);
|
|
267
|
+
}
|
|
268
|
+
} catch { /* fall through */ }
|
|
269
|
+
return 0;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const platform = o.platform || process.platform;
|
|
273
|
+
if (platform !== "darwin" && !o.execFile) return fallback();
|
|
274
|
+
|
|
275
|
+
const execFileImpl = o.execFile || execFile;
|
|
276
|
+
const out = await execFileSafe(execFileImpl, "/usr/bin/memory_pressure", [], 4000);
|
|
277
|
+
const m = /System-wide memory free percentage:\s*([\d.]+)%/.exec(out || "");
|
|
278
|
+
if (!m) return fallback();
|
|
279
|
+
|
|
280
|
+
const freePct = Number(m[1]);
|
|
281
|
+
if (!Number.isFinite(freePct) || freePct < 0 || freePct > 100) return fallback();
|
|
282
|
+
return pct(100 - freePct);
|
|
283
|
+
}
|
|
284
|
+
|
|
244
285
|
export async function collectMachine(o = {}) {
|
|
245
286
|
const osImpl = o.os || os;
|
|
246
287
|
const machine = { loadavg: [0, 0, 0], memUsedPct: 0 };
|
|
@@ -253,14 +294,21 @@ export async function collectMachine(o = {}) {
|
|
|
253
294
|
}
|
|
254
295
|
} catch { /* keep [0,0,0] */ }
|
|
255
296
|
|
|
256
|
-
// memUsedPct
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
297
|
+
// memUsedPct — how much memory is genuinely UNAVAILABLE, not how much is
|
|
298
|
+
// merely occupied.
|
|
299
|
+
//
|
|
300
|
+
// This used to be `1 - os.freemem()/os.totalmem()`, which is not a pressure
|
|
301
|
+
// measure on macOS. os.freemem() counts only entirely-free pages, and macOS
|
|
302
|
+
// deliberately keeps almost all RAM occupied with file cache, purgeable and
|
|
303
|
+
// compressed pages that are reclaimable on demand. Measured on a healthy
|
|
304
|
+
// idle Mac mini M4 Pro at the same instant: os.freemem() → 99.2% "used",
|
|
305
|
+
// `memory_pressure` → 68% free. The old number sat permanently near 100 and
|
|
306
|
+
// fired CRITICAL alerts on machines with nothing wrong with them, while the
|
|
307
|
+
// memory watchdog — which reads memory_pressure — correctly stayed quiet.
|
|
308
|
+
//
|
|
309
|
+
// Prefer macOS's own answer; fall back to the free/total ratio only where
|
|
310
|
+
// memory_pressure does not exist (Linux, where the figure means what it says).
|
|
311
|
+
machine.memUsedPct = await collectMemUsedPct(o, osImpl);
|
|
264
312
|
|
|
265
313
|
// cpuPct — coarse instantaneous estimate from loadavg/cpus when powermetrics
|
|
266
314
|
// is unavailable. powermetrics (if it yields) overrides below.
|
|
@@ -173,7 +173,11 @@ test("detectClaudeAuth: scans recent log files under agentRoot", () => {
|
|
|
173
173
|
});
|
|
174
174
|
|
|
175
175
|
test("collectMachine: derives memUsedPct + loadavg without powermetrics/df", async () => {
|
|
176
|
-
|
|
176
|
+
// memUsedPct now prefers macOS's memory_pressure, so "no external tools" has
|
|
177
|
+
// to include that one too — otherwise this test reads the REAL machine and
|
|
178
|
+
// asserts against the fake os, which is how it started failing.
|
|
179
|
+
const noTools = (cmd, args, opts, cb) => { cb(new Error("ENOENT")); return { on() {} }; };
|
|
180
|
+
const m = await collectMachine({ os: fakeOs({ totalmem: 10e9, freemem: 2e9, loadavg: [4, 2, 1], cpus: 4 }), execFile: noTools, powermetrics: false, disk: false });
|
|
177
181
|
assert.equal(m.memUsedPct, 80);
|
|
178
182
|
assert.deepEqual(m.loadavg, [4, 2, 1]);
|
|
179
183
|
assert.equal(m.cpuPct, 100); // load 4 / 4 cpus = 100%
|
|
@@ -662,3 +666,75 @@ test("collectStatus ships the FULL machine vocabulary the Fleet row reads", asyn
|
|
|
662
666
|
rmSync(root, { recursive: true, force: true });
|
|
663
667
|
}
|
|
664
668
|
});
|
|
669
|
+
|
|
670
|
+
// ---------------------------------------------------------------------------
|
|
671
|
+
// memUsedPct — pressure, not occupancy
|
|
672
|
+
//
|
|
673
|
+
// This was `1 - os.freemem()/os.totalmem()`, which is not a pressure measure on
|
|
674
|
+
// macOS: os.freemem() counts only entirely-free pages, and macOS keeps nearly
|
|
675
|
+
// all RAM occupied with file cache, purgeable and compressed pages that are
|
|
676
|
+
// reclaimable on demand. Measured on a healthy idle Mac mini M4 Pro at one
|
|
677
|
+
// instant: os.freemem() → 99.2% "used", memory_pressure → 68% free. The seat
|
|
678
|
+
// showed a standing CRITICAL while the watchdog, reading memory_pressure,
|
|
679
|
+
// correctly did nothing.
|
|
680
|
+
// ---------------------------------------------------------------------------
|
|
681
|
+
|
|
682
|
+
test("memUsedPct comes from memory_pressure, not from free/total", async () => {
|
|
683
|
+
const seen = [];
|
|
684
|
+
const execFile = (cmd, args, opts, cb) => {
|
|
685
|
+
seen.push(cmd);
|
|
686
|
+
if (cmd === "/usr/bin/memory_pressure") {
|
|
687
|
+
cb(null, "System-wide memory free percentage: 68%\n");
|
|
688
|
+
} else {
|
|
689
|
+
cb(new Error("not stubbed"));
|
|
690
|
+
}
|
|
691
|
+
return { on() {} };
|
|
692
|
+
};
|
|
693
|
+
// An os whose free/total ratio would say 99% used — the old, wrong answer.
|
|
694
|
+
const osImpl = {
|
|
695
|
+
loadavg: () => [1, 1, 1],
|
|
696
|
+
totalmem: () => 24 * 1024 ** 3,
|
|
697
|
+
freemem: () => 0.2 * 1024 ** 3,
|
|
698
|
+
cpus: () => [],
|
|
699
|
+
uptime: () => 3600,
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
const m = await collectMachine({ execFile, os: osImpl, platform: "darwin" });
|
|
703
|
+
|
|
704
|
+
assert.equal(m.memUsedPct, 32, "100 - 68 free, i.e. what the watchdog thresholds on");
|
|
705
|
+
assert.notEqual(m.memUsedPct, 99, "the free/total ratio must not be the answer on darwin");
|
|
706
|
+
assert.ok(seen.includes("/usr/bin/memory_pressure"), "memory_pressure is consulted");
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
test("memUsedPct falls back to free/total when memory_pressure yields nothing", async () => {
|
|
710
|
+
// Linux, or a macOS where the binary is missing/erroring. There the ratio
|
|
711
|
+
// does mean what it says, so a fallback is right — silence is not.
|
|
712
|
+
const execFile = (cmd, args, opts, cb) => { cb(new Error("ENOENT")); return { on() {} }; };
|
|
713
|
+
const osImpl = {
|
|
714
|
+
loadavg: () => [0, 0, 0],
|
|
715
|
+
totalmem: () => 1000,
|
|
716
|
+
freemem: () => 250,
|
|
717
|
+
cpus: () => [],
|
|
718
|
+
uptime: () => 1,
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
const m = await collectMachine({ execFile, os: osImpl, platform: "darwin" });
|
|
722
|
+
assert.equal(m.memUsedPct, 75, "1 - 250/1000");
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test("memUsedPct ignores an out-of-range memory_pressure reading", async () => {
|
|
726
|
+
const execFile = (cmd, args, opts, cb) => {
|
|
727
|
+
cb(null, "System-wide memory free percentage: 8000%\n");
|
|
728
|
+
return { on() {} };
|
|
729
|
+
};
|
|
730
|
+
const osImpl = {
|
|
731
|
+
loadavg: () => [0, 0, 0],
|
|
732
|
+
totalmem: () => 1000,
|
|
733
|
+
freemem: () => 400,
|
|
734
|
+
cpus: () => [],
|
|
735
|
+
uptime: () => 1,
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
const m = await collectMachine({ execFile, os: osImpl, platform: "darwin" });
|
|
739
|
+
assert.equal(m.memUsedPct, 60, "a nonsense percentage falls back rather than propagating");
|
|
740
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cohortapp/agent-sdk",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.0",
|
|
4
4
|
"description": "Cohort Agent SDK \u2014 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": {
|
|
@@ -582,6 +582,11 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
|
|
|
582
582
|
// assurance sweep retries it — the old code caught that failure, logged it,
|
|
583
583
|
// and left the human staring at a typing indicator.
|
|
584
584
|
let holdingText = null;
|
|
585
|
+
// Whether the acknowledgement actually LANDED, which is a different fact
|
|
586
|
+
// from whether one was composed. openAndAcknowledge returns ackText on both
|
|
587
|
+
// paths; passing only the text to buildPrompt made every failed ack read to
|
|
588
|
+
// the session as a delivered one.
|
|
589
|
+
let holdingDelivered = false;
|
|
585
590
|
let obligationKeyForItem = null;
|
|
586
591
|
const ackVerdict = shouldAcknowledge({ willSpawnSession: true, item, source: "inbox" });
|
|
587
592
|
try {
|
|
@@ -603,6 +608,7 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
|
|
|
603
608
|
});
|
|
604
609
|
obligationKeyForItem = opened.key;
|
|
605
610
|
holdingText = opened.ackText;
|
|
611
|
+
holdingDelivered = Boolean(opened.acked);
|
|
606
612
|
if (opened.acked) {
|
|
607
613
|
updateLock(itemId, { holdingSent: true });
|
|
608
614
|
} else if (ackVerdict.ack) {
|
|
@@ -641,6 +647,7 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
|
|
|
641
647
|
const prompt = await buildPrompt(item, classResult, {
|
|
642
648
|
type: "inbox",
|
|
643
649
|
holdingMessage: holdingText,
|
|
650
|
+
holdingSent: holdingDelivered,
|
|
644
651
|
});
|
|
645
652
|
// F1/H2: record a DURABLE in-flight admission and DEFER markProcessed() to
|
|
646
653
|
// the dispatch onClose SUCCESS path. The previous code marked the item
|
|
@@ -55,6 +55,46 @@ import { deliver, deliverWithRetry, replyTargetOf, canDeliverTo } from "./delive
|
|
|
55
55
|
import { spokeFor } from "../../lib/comms/receipts.mjs";
|
|
56
56
|
import { resultTextFromStdout } from "./session-outcomes.mjs";
|
|
57
57
|
|
|
58
|
+
// ── Board mirror seams ───────────────────────────────────────────────────────
|
|
59
|
+
// Imported lazily so a board module problem can never stop the daemon booting,
|
|
60
|
+
// and so tests can swap the implementation without a live org credential.
|
|
61
|
+
let _boardMirror = null;
|
|
62
|
+
async function boardMirror() {
|
|
63
|
+
if (_boardMirror) return _boardMirror;
|
|
64
|
+
try { _boardMirror = await import("./board-mirror.mjs"); }
|
|
65
|
+
catch (err) {
|
|
66
|
+
console.warn(`[assurance] board mirror unavailable: ${err.message}`);
|
|
67
|
+
_boardMirror = { openBoardItem: async () => ({ mirrored: false }), closeBoardItem: async () => ({ mirrored: false }) };
|
|
68
|
+
}
|
|
69
|
+
return _boardMirror;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Test seam: replace the board mirror wholesale. */
|
|
73
|
+
export function _setBoardMirror(impl) { _boardMirror = impl; }
|
|
74
|
+
|
|
75
|
+
function mirrorOpen(rec, a = {}) {
|
|
76
|
+
const inject = a.deps && a.deps.boardMirror;
|
|
77
|
+
const run = async () => {
|
|
78
|
+
const m = inject || (await boardMirror());
|
|
79
|
+
return m.openBoardItem({ rec, agentRoot: AGENT_REPO_DIR, deps: a.deps });
|
|
80
|
+
};
|
|
81
|
+
return run().catch((err) => {
|
|
82
|
+
console.warn(`[assurance] board mirror open failed: ${err.message}`);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function mirrorClose(rec, o = {}) {
|
|
87
|
+
const inject = o.deps && o.deps.boardMirror;
|
|
88
|
+
const run = async () => {
|
|
89
|
+
const m = inject || (await boardMirror());
|
|
90
|
+
return m.closeBoardItem({ rec, outcome: rec.state, agentRoot: AGENT_REPO_DIR, deps: o.deps });
|
|
91
|
+
};
|
|
92
|
+
return run().catch((err) => {
|
|
93
|
+
console.warn(`[assurance] board mirror close failed: ${err.message}`);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
58
98
|
const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
|
|
59
99
|
|
|
60
100
|
// ---------------------------------------------------------------------------
|
|
@@ -493,6 +533,13 @@ export async function openAndAcknowledge(a = {}) {
|
|
|
493
533
|
if (rec.deliverable === undefined) rec.deliverable = canDeliverTo(item);
|
|
494
534
|
writeRecord(rec);
|
|
495
535
|
|
|
536
|
+
// Mirror the accepted work onto the board, so "what is this colleague on?"
|
|
537
|
+
// has an answer a human can read. Deliberately NOT awaited: the board is
|
|
538
|
+
// bookkeeping and the acknowledgement is the product, so a slow or
|
|
539
|
+
// unreachable board must not add a millisecond to the reply. board-mirror
|
|
540
|
+
// never rejects, so the floating promise is safe.
|
|
541
|
+
mirrorOpen(rec, a);
|
|
542
|
+
|
|
496
543
|
if (!wantAck || !rec.deliverable) {
|
|
497
544
|
// The debt is on the books and the sweep will not try to speak into a
|
|
498
545
|
// channel that does not exist (see sweepObligations branch (b)). What it
|
|
@@ -601,7 +648,13 @@ export function closeObligation(key, o = {}) {
|
|
|
601
648
|
rec.state = o.outcome || "answered";
|
|
602
649
|
rec.closedAt = Number.isFinite(o.now) ? o.now : Date.now();
|
|
603
650
|
if (o.note) rec.closeNote = o.note;
|
|
604
|
-
|
|
651
|
+
const wrote = writeRecord(rec);
|
|
652
|
+
// Every close path in this module funnels through here — answered, silent
|
|
653
|
+
// success, failed, undeliverable, stale — so this is the one place the
|
|
654
|
+
// board row can be retired without the seven call sites drifting apart.
|
|
655
|
+
// Fire-and-forget for the same reason as the open hook.
|
|
656
|
+
mirrorClose(rec, o);
|
|
657
|
+
return wrote;
|
|
605
658
|
}
|
|
606
659
|
|
|
607
660
|
/**
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── THE BOARD MIRROR ─────────────────────────────────────────────────────────
|
|
3
|
+
*
|
|
4
|
+
* Every piece of work this agent takes on becomes a row on its board, and moves
|
|
5
|
+
* as the work moves.
|
|
6
|
+
*
|
|
7
|
+
* `lib/org/board.mjs` has been complete for a long time and had exactly ONE
|
|
8
|
+
* caller — `lib/goals/collaborate.mjs`, the self-directed goal path. Nothing on
|
|
9
|
+
* the responder path touched it. So an agent could spend forty minutes on an
|
|
10
|
+
* ask that arrived by DM and leave no trace on the board at all: the human
|
|
11
|
+
* asking "what are you on?" saw whatever seed rows the workspace shipped with.
|
|
12
|
+
* On one live seat those were three tasks from 22 June, and the product's "On
|
|
13
|
+
* now" banner had been faithfully rendering one of them ever since.
|
|
14
|
+
*
|
|
15
|
+
* The hook point is the OBLIGATION, not the session. An obligation is already
|
|
16
|
+
* the daemon's record of "a human is owed something and I have started" — it
|
|
17
|
+
* opens when work is accepted and closes when the answer lands, it survives a
|
|
18
|
+
* crash, and it is keyed stably. Mirroring it needs no new lifecycle and cannot
|
|
19
|
+
* drift from the one that already governs replies.
|
|
20
|
+
*
|
|
21
|
+
* EVERYTHING HERE FAILS OPEN. A board that is unreachable, unauthorised or slow
|
|
22
|
+
* must never delay or break a reply — the work and the answer matter, the row
|
|
23
|
+
* is bookkeeping. Every function resolves; none throws; failures warn once.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createItem, completeItem } from "../../lib/org/board.mjs";
|
|
27
|
+
import { loadOrgConfig } from "../../lib/org/client.mjs";
|
|
28
|
+
|
|
29
|
+
/** Board item ids are derived from the obligation key, so re-entry is idempotent. */
|
|
30
|
+
export function boardItemId(obligationKey) {
|
|
31
|
+
if (!obligationKey) return null;
|
|
32
|
+
return `ob-${String(obligationKey).replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 96)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A board title from the obligation's own summary.
|
|
37
|
+
*
|
|
38
|
+
* The summary is the topic clause the acknowledgement already speaks aloud
|
|
39
|
+
* ("compiling the regulatory filing calendar"), so the row and the reply say
|
|
40
|
+
* the same thing — which is the point of a board a human reads next to a
|
|
41
|
+
* conversation. Sentence case, no trailing stop, bounded length.
|
|
42
|
+
*/
|
|
43
|
+
export function boardTitle(rec) {
|
|
44
|
+
const raw =
|
|
45
|
+
(rec && (rec.summary || (rec.item && rec.item.subject) || (rec.item && rec.item.content))) || "";
|
|
46
|
+
const one = String(raw).replace(/\s+/g, " ").trim().replace(/[.\s]+$/, "");
|
|
47
|
+
if (!one) return "Responding to an inbound request";
|
|
48
|
+
const capped = one.length > 120 ? `${one.slice(0, 117)}…` : one;
|
|
49
|
+
return capped.charAt(0).toUpperCase() + capped.slice(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function connOpts(agentRoot, deps) {
|
|
53
|
+
const load = (deps && deps.loadOrgConfig) || loadOrgConfig;
|
|
54
|
+
const cfg = load(agentRoot) || {};
|
|
55
|
+
return { base: cfg.base, token: cfg.token, cfg, agentRoot };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Open a `running` board row for an obligation the agent has just accepted.
|
|
60
|
+
*
|
|
61
|
+
* Idempotent: the id is derived from the obligation key, so a re-delivery or a
|
|
62
|
+
* daemon restart re-creates the same row rather than a duplicate.
|
|
63
|
+
*
|
|
64
|
+
* @returns {Promise<{mirrored:boolean, itemId:string|null, reason?:string}>}
|
|
65
|
+
*/
|
|
66
|
+
export async function openBoardItem(a = {}) {
|
|
67
|
+
const rec = a.rec || {};
|
|
68
|
+
const itemId = boardItemId(rec.key);
|
|
69
|
+
if (!itemId) return { mirrored: false, itemId: null, reason: "no-key" };
|
|
70
|
+
|
|
71
|
+
const create = (a.deps && a.deps.createItem) || createItem;
|
|
72
|
+
try {
|
|
73
|
+
const opts = connOpts(a.agentRoot, a.deps);
|
|
74
|
+
if (!opts.base || !opts.token) {
|
|
75
|
+
return { mirrored: false, itemId, reason: "no-org-credential" };
|
|
76
|
+
}
|
|
77
|
+
const res = await create(
|
|
78
|
+
{
|
|
79
|
+
id: itemId,
|
|
80
|
+
title: boardTitle(rec),
|
|
81
|
+
// `running` is what the product's "On now" reads. A row parked in
|
|
82
|
+
// `todo` would be invisible there, which defeats the whole exercise.
|
|
83
|
+
status: "running",
|
|
84
|
+
col: "running",
|
|
85
|
+
priority: rec.priority || undefined,
|
|
86
|
+
detail: rec.sender ? `Requested by ${rec.sender}.` : undefined,
|
|
87
|
+
obligationKey: rec.key,
|
|
88
|
+
},
|
|
89
|
+
{ ...opts, idempotencyKey: itemId }
|
|
90
|
+
);
|
|
91
|
+
if (res && res.ok === false) {
|
|
92
|
+
console.warn(`[board-mirror] board.create refused ${itemId}: ${describe(res)}`);
|
|
93
|
+
return { mirrored: false, itemId, reason: "refused" };
|
|
94
|
+
}
|
|
95
|
+
return { mirrored: true, itemId };
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.warn(`[board-mirror] could not open ${itemId}: ${err.message}`);
|
|
98
|
+
return { mirrored: false, itemId, reason: "error" };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Close the board row when the obligation settles.
|
|
104
|
+
*
|
|
105
|
+
* `outcome` mirrors the obligation's own verdict, so a row that ended in a
|
|
106
|
+
* failure notice does not read as delivered work.
|
|
107
|
+
*/
|
|
108
|
+
export async function closeBoardItem(a = {}) {
|
|
109
|
+
const rec = a.rec || {};
|
|
110
|
+
const itemId = boardItemId(rec.key);
|
|
111
|
+
if (!itemId) return { mirrored: false, itemId: null, reason: "no-key" };
|
|
112
|
+
|
|
113
|
+
const complete = (a.deps && a.deps.completeItem) || completeItem;
|
|
114
|
+
try {
|
|
115
|
+
const opts = connOpts(a.agentRoot, a.deps);
|
|
116
|
+
if (!opts.base || !opts.token) {
|
|
117
|
+
return { mirrored: false, itemId, reason: "no-org-credential" };
|
|
118
|
+
}
|
|
119
|
+
const res = await complete(
|
|
120
|
+
{
|
|
121
|
+
itemId,
|
|
122
|
+
proof: a.outcome ? `outcome:${a.outcome}` : undefined,
|
|
123
|
+
obligationKey: rec.key,
|
|
124
|
+
},
|
|
125
|
+
{ ...opts, idempotencyKey: `${itemId}-done` }
|
|
126
|
+
);
|
|
127
|
+
if (res && res.ok === false) {
|
|
128
|
+
console.warn(`[board-mirror] board.complete refused ${itemId}: ${describe(res)}`);
|
|
129
|
+
return { mirrored: false, itemId, reason: "refused" };
|
|
130
|
+
}
|
|
131
|
+
return { mirrored: true, itemId };
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.warn(`[board-mirror] could not close ${itemId}: ${err.message}`);
|
|
134
|
+
return { mirrored: false, itemId, reason: "error" };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function describe(res) {
|
|
139
|
+
const e = res && res.error;
|
|
140
|
+
if (!e) return "unknown error";
|
|
141
|
+
return `${e.code || "ERROR"} — ${e.message || "no message"}`;
|
|
142
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
boardItemId,
|
|
6
|
+
boardTitle,
|
|
7
|
+
openBoardItem,
|
|
8
|
+
closeBoardItem,
|
|
9
|
+
} from "./board-mirror.mjs";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// The board mirror exists because lib/org/board.mjs had exactly ONE caller —
|
|
13
|
+
// the self-directed goal path — and nothing on the responder path touched it.
|
|
14
|
+
// An agent could spend forty minutes on a DM ask and leave the board showing
|
|
15
|
+
// whatever seed rows the workspace shipped with.
|
|
16
|
+
//
|
|
17
|
+
// Its cardinal rule is that it FAILS OPEN: the work and the answer matter, the
|
|
18
|
+
// row is bookkeeping. Most of these tests are about it staying out of the way.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
const CFG = { base: "https://hq.example", token: "t" };
|
|
22
|
+
const okCfg = { loadOrgConfig: () => CFG };
|
|
23
|
+
|
|
24
|
+
test("board item ids are derived from the obligation key, so re-entry is idempotent", () => {
|
|
25
|
+
const a = boardItemId("cohort:dm:abc123:7");
|
|
26
|
+
const b = boardItemId("cohort:dm:abc123:7");
|
|
27
|
+
assert.equal(a, b, "the same obligation must map to the same row");
|
|
28
|
+
assert.match(a, /^ob-/);
|
|
29
|
+
assert.ok(!/[^a-zA-Z0-9_-]/.test(a.slice(3)), "id is transport-safe");
|
|
30
|
+
assert.equal(boardItemId(null), null);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("the title is the topic the acknowledgement already speaks aloud", () => {
|
|
34
|
+
assert.equal(
|
|
35
|
+
boardTitle({ summary: "compiling the regulatory filing calendar." }),
|
|
36
|
+
"Compiling the regulatory filing calendar"
|
|
37
|
+
);
|
|
38
|
+
// Falls back through the item, then to something honest.
|
|
39
|
+
assert.equal(boardTitle({ item: { subject: "Q3 numbers" } }), "Q3 numbers");
|
|
40
|
+
assert.equal(boardTitle({}), "Responding to an inbound request");
|
|
41
|
+
assert.ok(boardTitle({ summary: "x".repeat(400) }).length <= 120);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("opens the row in `running`, which is what the product's On now reads", async () => {
|
|
45
|
+
let sent = null;
|
|
46
|
+
const r = await openBoardItem({
|
|
47
|
+
rec: { key: "k1", summary: "drafting a reply to Karen", sender: "Karen" },
|
|
48
|
+
agentRoot: "/x",
|
|
49
|
+
deps: { ...okCfg, createItem: async (item) => { sent = item; return { ok: true }; } },
|
|
50
|
+
});
|
|
51
|
+
assert.equal(r.mirrored, true);
|
|
52
|
+
assert.equal(sent.status, "running");
|
|
53
|
+
assert.equal(sent.col, "running", "a row parked in todo is invisible to the banner");
|
|
54
|
+
assert.equal(sent.title, "Drafting a reply to Karen");
|
|
55
|
+
assert.equal(sent.obligationKey, "k1");
|
|
56
|
+
assert.match(sent.detail, /Karen/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a missing org credential is a no-op, not a throw", async () => {
|
|
60
|
+
const r = await openBoardItem({
|
|
61
|
+
rec: { key: "k1" },
|
|
62
|
+
deps: { loadOrgConfig: () => ({}), createItem: async () => { throw new Error("must not be called"); } },
|
|
63
|
+
});
|
|
64
|
+
assert.equal(r.mirrored, false);
|
|
65
|
+
assert.equal(r.reason, "no-org-credential");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("a refusing board does not throw — the reply must still go out", async () => {
|
|
69
|
+
const r = await openBoardItem({
|
|
70
|
+
rec: { key: "k1", summary: "x" },
|
|
71
|
+
deps: { ...okCfg, createItem: async () => ({ ok: false, error: { code: "FORBIDDEN", message: "no" } }) },
|
|
72
|
+
});
|
|
73
|
+
assert.equal(r.mirrored, false);
|
|
74
|
+
assert.equal(r.reason, "refused");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("a throwing transport does not throw — same reason", async () => {
|
|
78
|
+
const r = await openBoardItem({
|
|
79
|
+
rec: { key: "k1", summary: "x" },
|
|
80
|
+
deps: { ...okCfg, createItem: async () => { throw new Error("socket hang up"); } },
|
|
81
|
+
});
|
|
82
|
+
assert.equal(r.mirrored, false);
|
|
83
|
+
assert.equal(r.reason, "error");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("closing carries the obligation's own verdict, so a failure does not read as delivered", async () => {
|
|
87
|
+
let sent = null;
|
|
88
|
+
const r = await closeBoardItem({
|
|
89
|
+
rec: { key: "k1" },
|
|
90
|
+
outcome: "failed",
|
|
91
|
+
deps: { ...okCfg, completeItem: async (p) => { sent = p; return { ok: true }; } },
|
|
92
|
+
});
|
|
93
|
+
assert.equal(r.mirrored, true);
|
|
94
|
+
assert.equal(sent.proof, "outcome:failed");
|
|
95
|
+
assert.equal(sent.itemId, boardItemId("k1"));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("close is as fail-open as open", async () => {
|
|
99
|
+
for (const deps of [
|
|
100
|
+
{ ...okCfg, completeItem: async () => ({ ok: false, error: { code: "GONE" } }) },
|
|
101
|
+
{ ...okCfg, completeItem: async () => { throw new Error("boom"); } },
|
|
102
|
+
{ loadOrgConfig: () => ({}), completeItem: async () => { throw new Error("nope"); } },
|
|
103
|
+
]) {
|
|
104
|
+
const r = await closeBoardItem({ rec: { key: "k1" }, outcome: "answered", deps });
|
|
105
|
+
assert.equal(r.mirrored, false);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
@@ -616,7 +616,11 @@ function buildBacklogContext(queueItem) {
|
|
|
616
616
|
* @returns {string} Prompt string ready for claude --print
|
|
617
617
|
*/
|
|
618
618
|
export async function buildPrompt(item, classResult, options = {}) {
|
|
619
|
-
const { type = "inbox", queueItem, holdingMessage } = options;
|
|
619
|
+
const { type = "inbox", queueItem, holdingMessage, holdingSent } = options;
|
|
620
|
+
// Only `false` — an explicit "the send failed" from the caller — flips the
|
|
621
|
+
// framing. Callers that pass no flag keep the historical assertion, so this
|
|
622
|
+
// cannot silently downgrade a genuinely delivered acknowledgement.
|
|
623
|
+
const holdingDelivered = holdingSent !== false;
|
|
620
624
|
const preamble = loadPreamble();
|
|
621
625
|
const action = classResult.action || "respond";
|
|
622
626
|
const actionBlock = ACTION_INSTRUCTIONS[action] || ACTION_INSTRUCTIONS.respond;
|
|
@@ -652,7 +656,7 @@ export async function buildPrompt(item, classResult, options = {}) {
|
|
|
652
656
|
// 1a. Holding message warning — TOP OF PROMPT so Claude sees it before action instructions.
|
|
653
657
|
// This is the most critical instruction in the prompt: prevents double-replies.
|
|
654
658
|
// We repeat it at section 7a as well, immediately before the action block.
|
|
655
|
-
if (holdingMessage) {
|
|
659
|
+
if (holdingMessage && holdingDelivered) {
|
|
656
660
|
parts.push("===== STOP — READ THIS FIRST =====");
|
|
657
661
|
parts.push(`A HOLDING MESSAGE has ALREADY been sent to the sender by the daemon. The exact text was:`);
|
|
658
662
|
parts.push(` "${holdingMessage}"`);
|
|
@@ -664,6 +668,25 @@ export async function buildPrompt(item, classResult, options = {}) {
|
|
|
664
668
|
parts.push("- If after investigation you still cannot deliver a substantive response and need more time, send a SECOND-LEVEL UPDATE (specific blocker, ETA, what you need from the user) — never a generic 'still looking into it'.");
|
|
665
669
|
parts.push("===== END WARNING =====");
|
|
666
670
|
parts.push("");
|
|
671
|
+
} else if (holdingMessage) {
|
|
672
|
+
// The acknowledgement was COMPOSED but never landed (openAndAcknowledge
|
|
673
|
+
// returns ackText on the failure path too, so the session can see what the
|
|
674
|
+
// sender would have read). Asserting "already sent" here is the bug this
|
|
675
|
+
// branch exists to prevent: a session told a human had been acknowledged
|
|
676
|
+
// opens mid-conversation at a human who has heard nothing at all, and — if
|
|
677
|
+
// the item is one this session decides needs no reply — the ask lands
|
|
678
|
+
// nowhere with no trace the sender can see.
|
|
679
|
+
parts.push("===== STOP — READ THIS FIRST =====");
|
|
680
|
+
parts.push(`An acknowledgement was composed for this item but DELIVERY FAILED. The sender has received NOTHING — they do not know this item was seen. The text that did not go out was:`);
|
|
681
|
+
parts.push(` "${holdingMessage}"`);
|
|
682
|
+
parts.push("");
|
|
683
|
+
parts.push("Plan around that, do not paper over it.");
|
|
684
|
+
parts.push("- Do NOT write as though contact has already been made — no 'as I mentioned', no 'following up on my earlier note'.");
|
|
685
|
+
parts.push("- The reply channel is unresolved or unreachable. Before composing anything, establish whether you actually have a working channel to this sender.");
|
|
686
|
+
parts.push("- If you DO have one, send a single self-contained message: the acknowledgement and the substantive answer together.");
|
|
687
|
+
parts.push("- If you do NOT, do not let the item evaporate. Record the undeliverable ask and escalate it to the operator — silence plus a closed inbox item is how a request disappears.");
|
|
688
|
+
parts.push("===== END WARNING =====");
|
|
689
|
+
parts.push("");
|
|
667
690
|
}
|
|
668
691
|
|
|
669
692
|
// 2. Session context
|
|
@@ -724,9 +747,12 @@ export async function buildPrompt(item, classResult, options = {}) {
|
|
|
724
747
|
// 5. Action instructions
|
|
725
748
|
// If a holding message was already sent, prepend a second reminder so the
|
|
726
749
|
// action block is unambiguous about not re-acknowledging.
|
|
727
|
-
if (holdingMessage) {
|
|
750
|
+
if (holdingMessage && holdingDelivered) {
|
|
728
751
|
parts.push("REMINDER: A holding message was already sent (see top of prompt). The action below describes WHAT to do — but you must NOT begin your reply with another acknowledgment. Open with substance.");
|
|
729
752
|
parts.push("");
|
|
753
|
+
} else if (holdingMessage) {
|
|
754
|
+
parts.push("REMINDER: The acknowledgement for this item FAILED to send (see top of prompt) — the sender has heard nothing. The action below describes WHAT to do; carry it out knowing this is still first contact, and escalate rather than close the item silently if you cannot reach them.");
|
|
755
|
+
parts.push("");
|
|
730
756
|
}
|
|
731
757
|
parts.push(actionBlock);
|
|
732
758
|
parts.push("");
|
|
@@ -211,3 +211,44 @@ test("buildPrompt caps the number of injected org facts (bounded)", async () =>
|
|
|
211
211
|
assert.ok(bullets > 0 && bullets <= 6, `expected <=6 injected facts, got ${bullets}`);
|
|
212
212
|
});
|
|
213
213
|
});
|
|
214
|
+
|
|
215
|
+
test("buildPrompt states the acknowledgement was sent when it actually was", async () => {
|
|
216
|
+
const prompt = await buildPrompt(ITEM, CLASS, {
|
|
217
|
+
type: "inbox",
|
|
218
|
+
holdingMessage: "Understood — let me dig into this.",
|
|
219
|
+
holdingSent: true,
|
|
220
|
+
});
|
|
221
|
+
assert.match(prompt, /A HOLDING MESSAGE has ALREADY been sent/);
|
|
222
|
+
assert.ok(prompt.includes("Understood — let me dig into this."));
|
|
223
|
+
assert.ok(!/DELIVERY FAILED/.test(prompt), "no failure framing on a delivered ack");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("buildPrompt does NOT claim delivery when the acknowledgement failed to send", async () => {
|
|
227
|
+
const prompt = await buildPrompt(ITEM, CLASS, {
|
|
228
|
+
type: "inbox",
|
|
229
|
+
holdingMessage: "Understood — let me dig into this.",
|
|
230
|
+
holdingSent: false,
|
|
231
|
+
});
|
|
232
|
+
// The lie: telling the session a human already heard from us when they did not.
|
|
233
|
+
assert.ok(
|
|
234
|
+
!/has ALREADY been sent/.test(prompt),
|
|
235
|
+
"must not assert delivery of an acknowledgement that never left the process"
|
|
236
|
+
);
|
|
237
|
+
assert.ok(
|
|
238
|
+
!/they already received the holding note/.test(prompt),
|
|
239
|
+
"must not assert receipt in the second-reminder block either"
|
|
240
|
+
);
|
|
241
|
+
// And it must say so positively, so the session plans around it.
|
|
242
|
+
assert.match(prompt, /DELIVERY FAILED/);
|
|
243
|
+
assert.match(prompt, /received NOTHING/);
|
|
244
|
+
// The composed text is still shown — it is what the sender would have seen.
|
|
245
|
+
assert.ok(prompt.includes("Understood — let me dig into this."));
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("buildPrompt treats an unspecified holdingSent as delivered (back-compat)", async () => {
|
|
249
|
+
const prompt = await buildPrompt(ITEM, CLASS, {
|
|
250
|
+
type: "inbox",
|
|
251
|
+
holdingMessage: "Understood — let me dig into this.",
|
|
252
|
+
});
|
|
253
|
+
assert.match(prompt, /A HOLDING MESSAGE has ALREADY been sent/);
|
|
254
|
+
});
|