@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.
@@ -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 = 1 - free/total
257
- try {
258
- const total = Number(osImpl.totalmem());
259
- const free = Number(osImpl.freemem());
260
- if (Number.isFinite(total) && total > 0 && Number.isFinite(free)) {
261
- machine.memUsedPct = pct((1 - free / total) * 100);
262
- }
263
- } catch { /* keep 0 */ }
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
- const m = await collectMachine({ os: fakeOs({ totalmem: 10e9, freemem: 2e9, loadavg: [4, 2, 1], cpus: 4 }), powermetrics: false, disk: false });
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.8.0",
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
- return writeRecord(rec);
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
+ });