@cohortapp/agent-sdk 2.11.14 → 2.12.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/.env.example +37 -22
- package/README.md +2 -0
- package/bin/maestro.mjs +113 -39
- package/bin/maestro.test.mjs +175 -5
- package/docs/guides/front-door-session.md +264 -0
- package/docs/guides/mac-mini.md +100 -28
- package/docs/guides/org-onboarding.md +1 -1
- package/docs/guides/setup-wizard.md +9 -5
- package/docs/runbooks/cohort-cutover.md +11 -1
- package/docs/runbooks/mac-mini-bootstrap.md +38 -63
- package/lib/cadence-bus-requeue.test.mjs +83 -0
- package/lib/cadence-bus.mjs +43 -7
- package/lib/channels/inbox-item.mjs +59 -2
- package/lib/cli/board.mjs +285 -0
- package/lib/cli/board.test.mjs +227 -0
- package/lib/cli/doctor-checks.mjs +441 -0
- package/lib/cli/doctor-checks.test.mjs +336 -0
- package/lib/cli/global-setup-extras.mjs +410 -0
- package/lib/cli/global-setup-extras.test.mjs +367 -0
- package/lib/cli/inbox.mjs +304 -0
- package/lib/cli/inbox.test.mjs +230 -0
- package/lib/cli/session-ack.mjs +63 -0
- package/lib/cli/session-ack.test.mjs +63 -0
- package/lib/cli/session.mjs +750 -0
- package/lib/cli/session.test.mjs +602 -0
- package/lib/collective/global-config.mjs +204 -6
- package/lib/collective/global-config.test.mjs +140 -0
- package/lib/collective/global-skills.mjs +145 -0
- package/lib/collective/global-skills.test.mjs +126 -0
- package/lib/collective/presence.mjs +4 -3
- package/lib/comms/send-gate.mjs +115 -0
- package/lib/comms/send-gate.test.mjs +113 -0
- package/lib/feature-init.mjs +2 -2
- package/lib/identity/persona.mjs +29 -0
- package/lib/identity/persona.test.mjs +26 -1
- package/lib/mcp/server.test.mjs +9 -4
- package/lib/model-router/spawn.test.mjs +21 -0
- package/lib/org/board-mine-cache.mjs +99 -0
- package/lib/org/board-mine-cache.test.mjs +53 -0
- package/lib/org/board.mjs +11 -0
- package/lib/org/board.test.mjs +11 -1
- package/lib/org/client.mjs +36 -0
- package/lib/org/client.test.mjs +46 -0
- package/lib/org/inbound/directedness.mjs +18 -2
- package/lib/org/inbound/directedness.test.mjs +58 -0
- package/lib/org/inbound/index.mjs +8 -1
- package/lib/org/inbound/index.test.mjs +22 -0
- package/lib/org/mesh-directives.test.mjs +110 -0
- package/lib/org/mesh.mjs +61 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +52 -0
- package/lib/org/protocol.test.mjs +12 -1
- package/lib/org/registry.mjs +3 -2
- package/lib/org/tool-surface.mjs +120 -0
- package/lib/org/tool-surface.test.mjs +118 -5
- package/lib/security/external-content.mjs +1 -1
- package/lib/security/external-content.test.mjs +17 -0
- package/lib/session/config.mjs +137 -0
- package/lib/session/config.test.mjs +92 -0
- package/lib/session/feed-core.mjs +229 -0
- package/lib/session/feed-core.test.mjs +198 -0
- package/lib/session/first-run.mjs +126 -0
- package/lib/session/first-run.test.mjs +121 -0
- package/lib/session/frontdoor.mjs +266 -0
- package/lib/session/frontdoor.test.mjs +205 -0
- package/lib/session/handoffs.mjs +295 -0
- package/lib/session/handoffs.test.mjs +183 -0
- package/lib/session/identity.mjs +220 -0
- package/lib/session/identity.test.mjs +180 -0
- package/lib/session/inbox-claims.mjs +434 -0
- package/lib/session/inbox-claims.test.mjs +286 -0
- package/lib/session/launch-args.mjs +161 -0
- package/lib/session/launch-args.test.mjs +157 -0
- package/lib/session/liveness.mjs +174 -0
- package/lib/session/liveness.test.mjs +100 -0
- package/lib/session/status-summary.mjs +172 -0
- package/lib/session/status-summary.test.mjs +118 -0
- package/lib/session-permissions.mjs +39 -3
- package/lib/session-permissions.test.mjs +20 -0
- package/lib/setup/claude-probe.mjs +161 -24
- package/lib/setup/claude-probe.test.mjs +187 -0
- package/lib/setup/sections/learning.mjs +2 -1
- package/lib/setup/sections/model.mjs +104 -24
- package/lib/setup/sections/model.test.mjs +240 -0
- package/lib/setup/sections/org.mjs +27 -2
- package/lib/setup/sections/org.test.mjs +35 -2
- package/lib/setup/sections/verify.mjs +5 -0
- package/lib/setup/state.mjs +30 -10
- package/lib/setup/state.test.mjs +24 -1
- package/lib/singleton.js +11 -3
- package/lib/singleton.test.mjs +16 -0
- package/lib/subagents/lock.mjs +1 -1
- package/lib/telemetry/collect.mjs +270 -6
- package/lib/telemetry/collect.test.mjs +196 -1
- package/lib/upgrade/global-refresh.mjs +108 -0
- package/lib/upgrade/global-refresh.test.mjs +65 -0
- package/lib/upgrade/launchd-reconcile.mjs +327 -0
- package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
- package/lib/upgrade/post-steps.mjs +151 -0
- package/lib/upgrade/post-steps.test.mjs +200 -0
- package/lib/upgrade/verify.mjs +215 -0
- package/lib/upgrade/verify.test.mjs +164 -0
- package/lib/voice/outbound.mjs +3 -2
- package/lib/voice/post-call-brief.mjs +2 -1
- package/lib/voice/session-rotation.mjs +6 -1
- package/lib/voice/session-rotation.test.mjs +114 -0
- package/package.json +3 -3
- package/plugins/maestro-skills/plugin.json +21 -1
- package/plugins/maestro-skills/skills/board-work.md +63 -0
- package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
- package/plugins/maestro-skills/skills/main-session.md +102 -0
- package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
- package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
- package/scaffold/CLAUDE.md +34 -0
- package/scripts/ci/check-durable-write-seam.mjs +147 -0
- package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/collective/hook-runner.mjs +39 -4
- package/scripts/collective/hook-runner.test.mjs +85 -2
- package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
- package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
- package/scripts/daemon/agent-daemon.mjs +141 -10
- package/scripts/daemon/agent-daemon.test.mjs +73 -0
- package/scripts/daemon/assurance-e2e.test.mjs +141 -6
- package/scripts/daemon/assurance.mjs +461 -37
- package/scripts/daemon/assurance.test.mjs +408 -43
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +334 -0
- package/scripts/daemon/cadence-consumer.mjs +254 -78
- package/scripts/daemon/cadence-handlers.mjs +53 -0
- package/scripts/daemon/classifier.mjs +1 -1
- package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
- package/scripts/daemon/dispatcher.mjs +127 -19
- package/scripts/daemon/health.mjs +12 -1
- package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
- package/scripts/daemon/inbox-deferral.mjs +6 -0
- package/scripts/daemon/lib/self-echo.mjs +201 -0
- package/scripts/daemon/lib/self-echo.test.mjs +153 -0
- package/scripts/daemon/maestro-daemon.mjs +3 -0
- package/scripts/daemon/prompt-builder.mjs +9 -1
- package/scripts/daemon/prompt-builder.test.mjs +22 -0
- package/scripts/daemon/responder.mjs +61 -41
- package/scripts/daemon/sdk-version.mjs +51 -0
- package/scripts/daemon/sdk-version.test.mjs +31 -0
- package/scripts/hooks/pre-send-audit.sh +97 -4
- package/scripts/hooks/pre-send-audit.test.mjs +140 -1
- package/scripts/local-triggers/autoupdate.sh +243 -19
- package/scripts/local-triggers/autoupdate.test.mjs +488 -0
- package/scripts/local-triggers/generate-plists.sh +24 -1
- package/scripts/local-triggers/generate-plists.test.mjs +49 -11
- package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
- package/scripts/org/send-orgmail.mjs +27 -3
- package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
- package/scripts/poller/slack-poller.mjs +13 -1
- package/scripts/poller/utils.mjs +46 -1
- package/scripts/poller-launchd/install.sh +19 -11
- package/scripts/poller-launchd/install.test.mjs +243 -0
- package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
- package/scripts/poller-launchd/migrate.sh +66 -0
- package/scripts/poller-launchd/poller.plist.template +4 -2
- package/scripts/session/feed.mjs +237 -0
- package/scripts/session/feed.test.mjs +196 -0
- package/scripts/session/supervisor-sh.test.mjs +218 -0
- package/scripts/session/supervisor.mjs +328 -0
- package/scripts/session/supervisor.sh +141 -0
- package/scripts/session/supervisor.test.mjs +482 -0
- package/scripts/setup/configure-macos.sh +250 -55
- package/scripts/setup/configure-macos.test.mjs +306 -0
- package/scripts/setup/init-agent.sh +112 -7
- package/scripts/setup/init-agent.test.mjs +220 -1
- package/scripts/watchdog/memory-watchdog.sh +37 -1
- package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
- package/scripts/setup/boot-claude-session.sh +0 -94
|
@@ -42,6 +42,12 @@ function loadAgent() {
|
|
|
42
42
|
return _agent;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// Self-echo guard (25/27/30 Aug recurrences): the poll lane re-ingested this
|
|
46
|
+
// agent's OWN outbound sends as fresh inbound and fired holding acks at them.
|
|
47
|
+
// The old inline check compared `sender` against "isla-roselli" while real
|
|
48
|
+
// events carry "Isla Roselli" — it never matched. The match now lives in one
|
|
49
|
+
// tested module and every drop is audited to logs/daemon/self-echo-drops.jsonl.
|
|
50
|
+
import { isSelfSender, logSelfEchoDrop } from "./lib/self-echo.mjs";
|
|
45
51
|
import { pollSlack } from "../poller/slack-poller.mjs";
|
|
46
52
|
import { pollGmail } from "../poller/gmail-poller.mjs";
|
|
47
53
|
import { pollCalendar } from "../poller/calendar-poller.mjs";
|
|
@@ -71,6 +77,7 @@ import {
|
|
|
71
77
|
openObligations,
|
|
72
78
|
escalate,
|
|
73
79
|
closeObligation,
|
|
80
|
+
isPidAlive,
|
|
74
81
|
} from "./assurance.mjs";
|
|
75
82
|
import { recordPoll, recordClassification, recordSession, writeHealthDashboard } from "./health.mjs";
|
|
76
83
|
import { acquireLock, releaseLock, updateLock, scanStaleLocks, acquireThreadLock, claimRequest, hasActiveClaim, sweepStaleItemClaims, sanitiseItemId } from "./session-lock.mjs";
|
|
@@ -85,7 +92,12 @@ const RUNG_MECHANISM = Object.fromEntries(RUNGS.map((r) => [r.id, r.mechanism]))
|
|
|
85
92
|
// work and land it in the org's shared, ACL'd store so the fleet's memory
|
|
86
93
|
// compounds. Fail-open + best-effort — never blocks or throws into the hot path;
|
|
87
94
|
// local SQLite/interaction logs remain the fail-open cache.
|
|
88
|
-
import { isEnabled as orgEnabled, loadOrgConfig, configFromAgent, electResponder, fetchSelfProfile } from "../../lib/org/client.mjs";
|
|
95
|
+
import { isEnabled as orgEnabled, loadOrgConfig, configFromAgent, electResponder, fetchSelfProfile, listMineResult as orgListMineResult } from "../../lib/org/client.mjs";
|
|
96
|
+
// board.mine cache (design §3.5, WP-M6): the daemon writes state/org/board-mine.json
|
|
97
|
+
// every 5 min; the SessionStart primer, session_status and `maestro board mine`
|
|
98
|
+
// read it. The when/what is pure (lib/org/board-mine-cache.mjs); the I/O is here.
|
|
99
|
+
import { shouldRefresh as boardMineShouldRefresh, renderRefresh as renderBoardMineRefresh, cacheTs as boardMineCacheTs, BOARD_MINE_REL } from "../../lib/org/board-mine-cache.mjs";
|
|
100
|
+
import { writeJsonAtomic as writeJsonAtomicFile } from "../../lib/fs-atomic.mjs";
|
|
89
101
|
import { remember as orgRemember } from "../../lib/org/knowledge.mjs";
|
|
90
102
|
import { sweepSessionOutcomes, resultTextFromStdout } from "./session-outcomes.mjs";
|
|
91
103
|
import { recordWorkStep as orgRecordWorkStep, cohortChannelId, cohortMessageId } from "../../lib/org/work-ledger.mjs";
|
|
@@ -117,6 +129,13 @@ import { getHookBus } from "../../lib/hooks/bus.mjs";
|
|
|
117
129
|
// classifier path below is the mechanism the ladder's `react` effect drives,
|
|
118
130
|
// not a parallel universe.
|
|
119
131
|
import { processOne } from "../../lib/execution/pipeline.mjs";
|
|
132
|
+
// FRONT DOOR (design §3.1/§3.3). When the seat's main session is live, the
|
|
133
|
+
// daemon leaves Cohort inbox items in place for the session to claim instead
|
|
134
|
+
// of spawning `claude --print`; not live → today's dispatch, unchanged. The
|
|
135
|
+
// gate is pure (lib/session/frontdoor.mjs); the assurance sweep reopens a
|
|
136
|
+
// session claim that was neither replied nor done within 20 min.
|
|
137
|
+
import { makeFrontDoorGate } from "../../lib/session/frontdoor.mjs";
|
|
138
|
+
import { sweepSessionInboxForDaemon } from "../../lib/session/inbox-claims.mjs";
|
|
120
139
|
import { defaultEffects, scheduleToQueue } from "../../lib/execution/effects.mjs";
|
|
121
140
|
import { loadReactObligations } from "../../lib/execution/match.mjs";
|
|
122
141
|
import { policyFor } from "../../lib/execution/surface-policy.mjs";
|
|
@@ -227,8 +246,13 @@ async function poll() {
|
|
|
227
246
|
* cohort path can run on its own fast interval (pollCohortFast) without waiting
|
|
228
247
|
* on the 60s full poll. Returns the count of new items processed. Never throws.
|
|
229
248
|
*/
|
|
249
|
+
const _frontDoorGate = makeFrontDoorGate({ agentRoot: AGENT_REPO_DIR });
|
|
230
250
|
async function pollService(svc) {
|
|
231
251
|
try {
|
|
252
|
+
// Front door: a live main session owns this service's inbox — leave it.
|
|
253
|
+
const fd = _frontDoorGate.check(svc.name);
|
|
254
|
+
if (fd.changed) console.log(`[daemon] front door for ${svc.name}: ${fd.dispatch ? "daemon dispatches" : "main session owns intake"} (${fd.reason})`);
|
|
255
|
+
if (!fd.dispatch) return 0;
|
|
232
256
|
const result = await svc.fn();
|
|
233
257
|
// Dedup by raw_ref first: the same Slack message arrives via multiple paths
|
|
234
258
|
// (channel scan, thread scan, events-server JSON) with different item IDs.
|
|
@@ -236,12 +260,18 @@ async function pollService(svc) {
|
|
|
236
260
|
// once regardless of path.
|
|
237
261
|
const seenRefs = new Set();
|
|
238
262
|
const newItems = result.items.filter((item) => {
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
263
|
+
// SELF-ECHO DROP (ingestion guard #1). The agent's own outbound, echoed
|
|
264
|
+
// back by a lane, is not inbound. Dropped LOUDLY (audit log) and the inbox
|
|
265
|
+
// file is retired — without the rename the same echo was re-scanned and
|
|
266
|
+
// re-considered every tick forever. The old check here compared `sender`
|
|
267
|
+
// against `fullName` with spaces folded to dashes, which never matched the
|
|
268
|
+
// real event shape ("Isla Roselli") — the exact reason the 25/27/30 Aug
|
|
269
|
+
// self-reply loops got past it.
|
|
270
|
+
if (isSelfSender(item)) {
|
|
271
|
+
logSelfEchoDrop(item, { stage: "poll", service: svc.name });
|
|
272
|
+
markProcessed(item, svc.name);
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
245
275
|
|
|
246
276
|
const lockKey = item.raw_ref || item.id || `${svc.name}-${Date.now()}`;
|
|
247
277
|
if (seenRefs.has(lockKey)) return false;
|
|
@@ -278,7 +308,7 @@ async function pollService(svc) {
|
|
|
278
308
|
// against the 60s poll's own cohort scan (kept as a safety net).
|
|
279
309
|
let _cohortPoller = null;
|
|
280
310
|
let _cohortFastRunning = false;
|
|
281
|
-
async function pollCohortFast() {
|
|
311
|
+
export async function pollCohortFast() {
|
|
282
312
|
if (_cohortFastRunning) return 0;
|
|
283
313
|
if (!_existsSync(join(AGENT_REPO_DIR, "config/org.yaml"))) return 0;
|
|
284
314
|
if (!_cohortPoller) _cohortPoller = makeInboxScanPoller("cohort", { agentRoot: AGENT_REPO_DIR });
|
|
@@ -339,6 +369,18 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
|
|
|
339
369
|
// regression-prone line in the file.
|
|
340
370
|
enrichItem(item, service);
|
|
341
371
|
|
|
372
|
+
// ── SELF-ECHO DROP (guard #2, the choke point) ───────────────────────────
|
|
373
|
+
// Every entry path — the 60s poll, the fast cohort scan, crash recovery —
|
|
374
|
+
// crosses this line, so an echo that slipped past a lane's own filter is
|
|
375
|
+
// still stopped before it can be classified, elected on, acked or answered.
|
|
376
|
+
// Retiring the file is part of the drop: an un-retired echo is re-delivered
|
|
377
|
+
// next poll and re-dropped forever, which is noise pretending to be safety.
|
|
378
|
+
if (isSelfSender(item)) {
|
|
379
|
+
logSelfEchoDrop(item, { stage: "process", service });
|
|
380
|
+
markProcessed(item, service);
|
|
381
|
+
return { ok: true, path: "filtered", reason: "self_echo" };
|
|
382
|
+
}
|
|
383
|
+
|
|
342
384
|
// ── THE EXECUTION LADDER, at the front of the chain ──────────────────────
|
|
343
385
|
// Returns `{ handled }`. `handled:true` means the ladder took responsibility
|
|
344
386
|
// for this item — it ignored it (journalled), queued it, delegated it,
|
|
@@ -899,6 +941,20 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
|
|
|
899
941
|
const ackVerdict = _claudeAvailable()
|
|
900
942
|
? shouldAcknowledge({ willSpawnSession: true, item, source: "inbox" })
|
|
901
943
|
: { ack: false, reason: "claude-unavailable" };
|
|
944
|
+
// ── DISPATCH GATE: emitter-class inbound spawns NOTHING ─────────────────
|
|
945
|
+
// `emitterClass: true` means the gate read the inbound and recognised output
|
|
946
|
+
// this seat's own machinery class produces — a peer agent's ack-shaped
|
|
947
|
+
// one-liner, or another daemon's narrator line (progress/failure/terminal).
|
|
948
|
+
// Suppressing only the ack while still spawning would keep the expensive
|
|
949
|
+
// half of the 25/27/30 Aug loop: a 15-45 minute session burned answering a
|
|
950
|
+
// reflex. The gate already audited the refusal
|
|
951
|
+
// (logs/daemon/dispatch-gate-drops.jsonl); retire the item and stop here.
|
|
952
|
+
if (!ackVerdict.ack && ackVerdict.emitterClass) {
|
|
953
|
+
console.log(`[daemon] dispatch gate: no ack, no session for ${itemId} (${ackVerdict.reason})`);
|
|
954
|
+
counters.bump("dispatch.gate_refused", { service, reason: ackVerdict.reason });
|
|
955
|
+
markProcessed(item, service);
|
|
956
|
+
return { ok: true, path: "filtered", reason: ackVerdict.reason };
|
|
957
|
+
}
|
|
902
958
|
try {
|
|
903
959
|
// The DEBT is opened whether or not we can speak. Those are two different
|
|
904
960
|
// questions and conflating them is what made a whole class of ask
|
|
@@ -1712,6 +1768,32 @@ export function _resetDaemonOrgCfgCache() {
|
|
|
1712
1768
|
_cachedDaemonOrgCfg = undefined;
|
|
1713
1769
|
}
|
|
1714
1770
|
|
|
1771
|
+
/**
|
|
1772
|
+
* Refresh state/org/board-mine.json from hq `board.mine` when the cache is
|
|
1773
|
+
* 5 min old (or absent). Fail-open end to end: an un-enrolled seat writes
|
|
1774
|
+
* nothing; a FAILED read (hq unreachable) keeps the previous items and marks
|
|
1775
|
+
* the document `stale: true` with `itemsAt` (lib/org/board-mine-cache.mjs
|
|
1776
|
+
* renderRefresh) — one bad tick must not blank the primer, session_status
|
|
1777
|
+
* and `maestro board mine` for five minutes. `listMineImpl` may return the
|
|
1778
|
+
* `{ok, items}` shape of listMineResult or a bare array (= ok).
|
|
1779
|
+
* @param {{now?:number, listMineImpl?:Function, force?:boolean}} [deps]
|
|
1780
|
+
* @returns {Promise<{refreshed:boolean, reason:string, count?:number}>}
|
|
1781
|
+
*/
|
|
1782
|
+
export async function refreshBoardMineCache(deps = {}) {
|
|
1783
|
+
const now = Number.isFinite(deps.now) ? deps.now : Date.now();
|
|
1784
|
+
const path = join(AGENT_REPO_DIR, BOARD_MINE_REL);
|
|
1785
|
+
let cur = null;
|
|
1786
|
+
try { cur = JSON.parse(readFileSync(path, "utf8")); } catch { cur = null; /* absent or corrupt → refresh */ }
|
|
1787
|
+
if (!deps.force && !boardMineShouldRefresh(boardMineCacheTs(cur), now)) return { refreshed: false, reason: "fresh" };
|
|
1788
|
+
const cfg = daemonOrgCfg();
|
|
1789
|
+
if (!orgEnabled(cfg)) return { refreshed: false, reason: "org-disabled" };
|
|
1790
|
+
const conn = configFromAgent(cfg) || {};
|
|
1791
|
+
const result = await (deps.listMineImpl || orgListMineResult)({ base: conn.base, token: conn.token, orgId: conn.orgId });
|
|
1792
|
+
const doc = renderBoardMineRefresh(result, cur, now);
|
|
1793
|
+
writeJsonAtomicFile(path, doc);
|
|
1794
|
+
return { refreshed: true, reason: doc.stale ? "kept-previous" : "written", count: doc.items.length };
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1715
1797
|
/** Longest completion note we will put on a board comment. */
|
|
1716
1798
|
const COMPLETION_NOTE_CHARS = 1200;
|
|
1717
1799
|
|
|
@@ -2179,6 +2261,38 @@ async function sweepBacklog() {
|
|
|
2179
2261
|
// Main
|
|
2180
2262
|
// ---------------------------------------------------------------------------
|
|
2181
2263
|
|
|
2264
|
+
/**
|
|
2265
|
+
* Daemon singleton ADVISORY — a pidfile check, deliberately not a lock.
|
|
2266
|
+
*
|
|
2267
|
+
* Two live daemons on one AGENT_DIR (the operator's manual run next to the
|
|
2268
|
+
* launchd one) both poll, both sweep, and both narrate; the per-obligation
|
|
2269
|
+
* ownership guard in assurance.sweepObligations bounds the damage, but the
|
|
2270
|
+
* condition itself deserves a LOUD line the moment it starts. This only logs
|
|
2271
|
+
* and counts — it never refuses to start and never exits — because launchd's
|
|
2272
|
+
* KeepAlive restart may race the old process's teardown, and a pidfile that
|
|
2273
|
+
* can veto startup under KeepAlive is a daemon that stays down. A recycled pid
|
|
2274
|
+
* can false-positive; that costs one loud log line, nothing more.
|
|
2275
|
+
*/
|
|
2276
|
+
function advisePidfileSingleton() {
|
|
2277
|
+
const pidPath = join(AGENT_REPO_DIR, "state", "daemon.pid");
|
|
2278
|
+
try {
|
|
2279
|
+
let prior = null;
|
|
2280
|
+
try { prior = parseInt(readFileSync(pidPath, "utf-8"), 10); } catch { /* no pidfile yet */ }
|
|
2281
|
+
if (Number.isFinite(prior) && prior > 0 && prior !== process.pid && isPidAlive(prior)) {
|
|
2282
|
+
console.error(
|
|
2283
|
+
`[daemon] ⚠ ANOTHER DAEMON MAY BE LIVE: pid ${prior} (from ${pidPath}) is running and also holds ${AGENT_REPO_DIR}. ` +
|
|
2284
|
+
`Two daemons on one AGENT_DIR double-poll and race the obligation ledger — stop one unless this is deliberate.`,
|
|
2285
|
+
);
|
|
2286
|
+
counters.bump("daemon.singleton_advisory", { prior_pid: prior });
|
|
2287
|
+
}
|
|
2288
|
+
mkdirSync(join(AGENT_REPO_DIR, "state"), { recursive: true });
|
|
2289
|
+
writeFileSync(pidPath, `${process.pid}\n`);
|
|
2290
|
+
} catch (err) {
|
|
2291
|
+
// Advisory only — an unwritable pidfile must never stop the daemon booting.
|
|
2292
|
+
console.warn(`[daemon] pidfile advisory unavailable: ${err.message}`);
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2182
2296
|
async function main() {
|
|
2183
2297
|
console.log("╔══════════════════════════════════════════════════════════╗");
|
|
2184
2298
|
console.log(`║ ${(loadAgent().firstName || "Agent").padEnd(8)} Daemon — Reactive Event Processor ║`);
|
|
@@ -2205,6 +2319,11 @@ async function main() {
|
|
|
2205
2319
|
// No emergency stop — continue
|
|
2206
2320
|
}
|
|
2207
2321
|
|
|
2322
|
+
// Two-daemon advisory: log loudly if another live daemon already holds this
|
|
2323
|
+
// AGENT_DIR (pidfile check; never blocks startup — see the function's note on
|
|
2324
|
+
// launchd KeepAlive semantics), then record this process as the holder.
|
|
2325
|
+
advisePidfileSingleton();
|
|
2326
|
+
|
|
2208
2327
|
// Clear orphaned state from prior daemon instances
|
|
2209
2328
|
const staleCleared = scanStaleLocks();
|
|
2210
2329
|
if (staleCleared > 0) {
|
|
@@ -2242,6 +2361,9 @@ async function main() {
|
|
|
2242
2361
|
|
|
2243
2362
|
// Initial poll immediately
|
|
2244
2363
|
await poll();
|
|
2364
|
+
// First board.mine cache write, so the primer has something before the
|
|
2365
|
+
// first health tick (fail-open; an un-enrolled seat writes nothing).
|
|
2366
|
+
refreshBoardMineCache().catch((err) => console.error("[daemon] board-mine cache error:", err.message));
|
|
2245
2367
|
|
|
2246
2368
|
// Poll loop
|
|
2247
2369
|
setInterval(async () => {
|
|
@@ -2285,6 +2407,12 @@ async function main() {
|
|
|
2285
2407
|
if (claimsSwept > 0) {
|
|
2286
2408
|
console.log(`[daemon] Swept ${claimsSwept} stale item claims`);
|
|
2287
2409
|
}
|
|
2410
|
+
// Front door: reopen session claims older than 20 min, due deferrals and
|
|
2411
|
+
// (while the session is live) the daemon's own orphaned .dispatched files.
|
|
2412
|
+
const reopened = sweepSessionInboxForDaemon(AGENT_REPO_DIR).reopened;
|
|
2413
|
+
if (reopened.length > 0) console.log(`[daemon] reopened ${reopened.length} session-claimed inbox item(s): ${reopened.map((r) => `${r.id} (${r.reason})`).join(", ")}`);
|
|
2414
|
+
// board.mine cache — the pure gate says "no" for four of every five ticks.
|
|
2415
|
+
refreshBoardMineCache().catch((err) => console.error("[daemon] board-mine cache error:", err.message));
|
|
2288
2416
|
} catch (err) {
|
|
2289
2417
|
console.error("[daemon] Health write error:", err.message);
|
|
2290
2418
|
}
|
|
@@ -2300,18 +2428,21 @@ async function main() {
|
|
|
2300
2428
|
}, HEALTH_INTERVAL);
|
|
2301
2429
|
|
|
2302
2430
|
// Graceful shutdown — clean up active.json so next startup doesn't see stale sessions
|
|
2431
|
+
// Exit honours a pre-set process.exitCode: the org-mesh "restart" directive
|
|
2432
|
+
// sets 1 before signalling so launchd's KeepAlive {SuccessfulExit:false}
|
|
2433
|
+
// relaunches the daemon; a plain SIGTERM still exits 0 (stays down).
|
|
2303
2434
|
process.on("SIGTERM", () => {
|
|
2304
2435
|
console.log("[daemon] SIGTERM received, shutting down gracefully");
|
|
2305
2436
|
resetActiveSessions();
|
|
2306
2437
|
writeHealthDashboard();
|
|
2307
|
-
process.exit(0);
|
|
2438
|
+
process.exit(process.exitCode || 0);
|
|
2308
2439
|
});
|
|
2309
2440
|
|
|
2310
2441
|
process.on("SIGINT", () => {
|
|
2311
2442
|
console.log("[daemon] SIGINT received, shutting down");
|
|
2312
2443
|
resetActiveSessions();
|
|
2313
2444
|
writeHealthDashboard();
|
|
2314
|
-
process.exit(0);
|
|
2445
|
+
process.exit(process.exitCode || 0);
|
|
2315
2446
|
});
|
|
2316
2447
|
|
|
2317
2448
|
console.log("[daemon] Running. Ctrl+C to stop.");
|
|
@@ -903,3 +903,76 @@ test("fastPathClassify: a short directed question skips the classifier; work-req
|
|
|
903
903
|
assert.ok(daemon.fastPathClassify({ content: "what OS?" }, true));
|
|
904
904
|
assert.equal(daemon.fastPathClassify({ content: "what OS?" }, true, { disableFastPath: true }), null);
|
|
905
905
|
});
|
|
906
|
+
|
|
907
|
+
// ===========================================================================
|
|
908
|
+
// SELF-ECHO — the agent's own outbound, re-ingested by a poll lane, must be
|
|
909
|
+
// dropped BEFORE it can be classified, elected on, acked or answered. Recurred
|
|
910
|
+
// live 2026-08-25 / 08-27 / 08-30: the old filter normalised the agent's full
|
|
911
|
+
// name to "isla-roselli" while real events carry "Isla Roselli", so the agent
|
|
912
|
+
// said "Looking now." to itself (and, in shared DMs, to another agent's echo).
|
|
913
|
+
// ===========================================================================
|
|
914
|
+
|
|
915
|
+
test("SELF-ECHO: an echoed own message is dropped at the processItem choke point — file retired, drop audited", async () => {
|
|
916
|
+
resetState();
|
|
917
|
+
const selfEcho = await import("./lib/self-echo.mjs");
|
|
918
|
+
const { writeFileSync: wf } = await import("node:fs");
|
|
919
|
+
mkdirSync(join(AGENT_DIR, "config"), { recursive: true });
|
|
920
|
+
wf(join(AGENT_DIR, "config", "agent.json"), JSON.stringify({
|
|
921
|
+
firstName: "Isla", fullName: "Isla Roselli", memberId: "cmqh0tcml004ihhl6zev6ocb5",
|
|
922
|
+
}));
|
|
923
|
+
selfEcho._resetSelfIdentity();
|
|
924
|
+
try {
|
|
925
|
+
// The REAL echo shape: display name verbatim, spaces and all.
|
|
926
|
+
const item = { id: "MSG-SELF", raw_ref: "slack:DSELF:1", channel: "dm/candace", channel_id: "DSELF0001", sender: "Isla Roselli", content: "Both answers — and the second one settles it." };
|
|
927
|
+
const name = writeLiveItem(item);
|
|
928
|
+
|
|
929
|
+
let pipelineTouched = null;
|
|
930
|
+
const res = await daemon.processItem(item, "slack", {
|
|
931
|
+
classify: async () => { pipelineTouched = "classify"; return { priority: "high", action: "respond", model: "opus", summary: "x", category: "action_required", directed_at_agent: true }; },
|
|
932
|
+
isQuickReply: () => { pipelineTouched = "isQuickReply"; return false; },
|
|
933
|
+
sendQuickResponse: async () => { pipelineTouched = "quick_reply"; return { sent: true }; },
|
|
934
|
+
sendHoldingMessage: async () => { pipelineTouched = "holding_ack"; return { sent: true, holdingText: "x" }; },
|
|
935
|
+
dispatch: () => { pipelineTouched = "dispatch"; },
|
|
936
|
+
claudeAvailable: () => true,
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
assert.equal(res && res.path, "filtered", "the echo is filtered, not answered");
|
|
940
|
+
assert.equal(res && res.reason, "self_echo");
|
|
941
|
+
assert.equal(pipelineTouched, null, "no classify, no ack, no reply, no session — the echo never enters the pipeline");
|
|
942
|
+
assert.deepEqual(inboxFiles(), [`${name}.processed`], "the echo's inbox file is RETIRED — an un-retired echo would be re-scanned and re-dropped every tick forever");
|
|
943
|
+
const auditPath = join(AGENT_DIR, "logs", "daemon", "self-echo-drops.jsonl");
|
|
944
|
+
assert.ok(existsSync(auditPath), "every drop leaves an audit row");
|
|
945
|
+
assert.match(readFileSync(auditPath, "utf-8"), /slack:DSELF:1/, "the audit row names the dropped event");
|
|
946
|
+
} finally {
|
|
947
|
+
rmSync(join(AGENT_DIR, "config", "agent.json"), { force: true });
|
|
948
|
+
selfEcho._resetSelfIdentity();
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
test("SELF-ECHO: a genuine colleague's message is untouched by the guard", async () => {
|
|
953
|
+
resetState();
|
|
954
|
+
const selfEcho = await import("./lib/self-echo.mjs");
|
|
955
|
+
const { writeFileSync: wf } = await import("node:fs");
|
|
956
|
+
mkdirSync(join(AGENT_DIR, "config"), { recursive: true });
|
|
957
|
+
wf(join(AGENT_DIR, "config", "agent.json"), JSON.stringify({
|
|
958
|
+
firstName: "Isla", fullName: "Isla Roselli", memberId: "cmqh0tcml004ihhl6zev6ocb5",
|
|
959
|
+
}));
|
|
960
|
+
selfEcho._resetSelfIdentity();
|
|
961
|
+
try {
|
|
962
|
+
const item = { id: "MSG-COLLEAGUE", raw_ref: "slack:DCOLL:1", channel: "dm/jordan", channel_id: "DCOLL0001", is_dm: true, sender: "Jordan Blake", content: "please draft the board memo" };
|
|
963
|
+
writeLiveItem(item);
|
|
964
|
+
let dispatched = false;
|
|
965
|
+
const res = await daemon.answerItem(item, "slack", "slack:DCOLL:1", "trace-coll", {
|
|
966
|
+
classify: async () => ({ priority: "critical", action: "respond", model: "opus", summary: "draft board memo", category: "action_required", directed_at_agent: true }),
|
|
967
|
+
isQuickReply: () => false,
|
|
968
|
+
sendHoldingMessage: async () => ({ sent: true, holdingText: "Starting on the board memo now." }),
|
|
969
|
+
dispatch: (_p, _it, _cr, _s, opts) => { dispatched = true; opts.onClose({ ok: true, code: 0 }); },
|
|
970
|
+
claudeAvailable: () => true,
|
|
971
|
+
});
|
|
972
|
+
assert.equal(dispatched, true, "a real colleague's ask still dispatches");
|
|
973
|
+
assert.equal(res.path, "session");
|
|
974
|
+
} finally {
|
|
975
|
+
rmSync(join(AGENT_DIR, "config", "agent.json"), { force: true });
|
|
976
|
+
selfEcho._resetSelfIdentity();
|
|
977
|
+
}
|
|
978
|
+
});
|
|
@@ -47,6 +47,17 @@ const AGENT_DIR = mkdtempSync(join(tmpdir(), "maestro-assurance-e2e-"));
|
|
|
47
47
|
process.env.AGENT_DIR = AGENT_DIR;
|
|
48
48
|
process.env.AGENT_ROOT = AGENT_DIR;
|
|
49
49
|
process.env.MAESTRO_DAEMON_NO_AUTOSTART = "1";
|
|
50
|
+
// HERMETICITY. `receipts.recordOutbound` attributes a send to an obligation and
|
|
51
|
+
// a session from AMBIENT ENV when the caller does not name them — the seam that
|
|
52
|
+
// lets a CLI send lane inside a spawned session attribute itself. Run this file
|
|
53
|
+
// from inside a live maestro session and those variables are inherited, so the
|
|
54
|
+
// receipt SCENARIO 2 writes to prove "the session answered" is stamped with a
|
|
55
|
+
// DIFFERENT debt's key and reads back as `attributed-elsewhere`. The scenario
|
|
56
|
+
// then fails on a real machine and passes on a bare CI runner, which is the
|
|
57
|
+
// wrong way round for a test to be environment-sensitive.
|
|
58
|
+
delete process.env.MAESTRO_OBLIGATION_KEY;
|
|
59
|
+
delete process.env.MAESTRO_SESSION_ID;
|
|
60
|
+
delete process.env.AGENT_SESSION_ID;
|
|
50
61
|
|
|
51
62
|
let daemon, assurance, receipts;
|
|
52
63
|
|
|
@@ -103,10 +114,16 @@ function transport(virtualClock = () => null) {
|
|
|
103
114
|
};
|
|
104
115
|
}
|
|
105
116
|
|
|
106
|
-
/** The daemon's ack seam, shaped exactly like responder.sendHoldingMessage
|
|
107
|
-
|
|
117
|
+
/** The daemon's ack seam, shaped exactly like responder.sendHoldingMessage:
|
|
118
|
+
* the line is GENERATED (here through the REAL generateAck with an injected
|
|
119
|
+
* runner — no spawn in tests), and a failed generation means silence, never
|
|
120
|
+
* a canned fallback. */
|
|
121
|
+
function ackSeam(runImpl) {
|
|
108
122
|
return async (item, classResult) => {
|
|
109
|
-
const text = assurance.
|
|
123
|
+
const text = await assurance.generateAck(item, classResult, {
|
|
124
|
+
runImpl: runImpl || (async () => "Taking the git fixes now — push coming shortly."),
|
|
125
|
+
});
|
|
126
|
+
if (text == null) return { sent: false, holdingText: null, error: "ack_generation_failed", genFailed: true };
|
|
110
127
|
heard("ack", text);
|
|
111
128
|
return { sent: true, holdingText: text, via: "fake" };
|
|
112
129
|
};
|
|
@@ -136,6 +153,24 @@ function makeItem(id, content) {
|
|
|
136
153
|
return item;
|
|
137
154
|
}
|
|
138
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Every scenario injects both seams that would otherwise spawn: `dispatch` (no
|
|
158
|
+
* child process) and `sendHoldingMessage` (the ack line comes from an injected
|
|
159
|
+
* runner). No scenario in this file ever needs the real `claude` binary.
|
|
160
|
+
*
|
|
161
|
+
* The daemon's ack path is nevertheless gated on `claudeAvailable()` — a
|
|
162
|
+
* NECESSARY production fail-safe (never post "on it" when the CLI that would
|
|
163
|
+
* answer cannot start). Left to the real probe, that gate reads a fact about
|
|
164
|
+
* the MACHINE, not about the scenario: these tests pass on a developer box with
|
|
165
|
+
* the CLI installed and suppress every acknowledgement on a CI runner without
|
|
166
|
+
* it. That is precisely how SCENARIOS 2, 3 and 6 came to be red on main and
|
|
167
|
+
* green everywhere they were written. The premise is declared here instead.
|
|
168
|
+
*
|
|
169
|
+
* The gate's false branch is not untested by this — see "the ack is suppressed
|
|
170
|
+
* when the CLI is unavailable" at the end of this file.
|
|
171
|
+
*/
|
|
172
|
+
const CLI_PRESENT = () => true;
|
|
173
|
+
|
|
139
174
|
const COMPLEX = {
|
|
140
175
|
priority: "critical", action: "respond", model: "opus", category: "action_required",
|
|
141
176
|
directed_at_agent: true, summary: "Fix the issues you just noted and push the fixes to git",
|
|
@@ -152,6 +187,7 @@ test("SCENARIO 1 — a fast ask gets a direct answer, with no pointless holding
|
|
|
152
187
|
|
|
153
188
|
let holdingCalls = 0;
|
|
154
189
|
await daemon.processItem(item, "cohort", {
|
|
190
|
+
claudeAvailable: CLI_PRESENT,
|
|
155
191
|
classify: async () => ({ priority: "normal", action: "respond", model: "sonnet", category: "question", directed_at_agent: true, summary: "board call time" }),
|
|
156
192
|
isQuickReply: () => true,
|
|
157
193
|
sendQuickResponse: async (it) => {
|
|
@@ -181,6 +217,7 @@ test("SCENARIO 2 — a slow ask is acknowledged at once, updated while it runs,
|
|
|
181
217
|
|
|
182
218
|
let onCloseHook = null;
|
|
183
219
|
await daemon.processItem(item, "cohort", {
|
|
220
|
+
claudeAvailable: CLI_PRESENT,
|
|
184
221
|
classify: async () => COMPLEX,
|
|
185
222
|
isQuickReply: () => false,
|
|
186
223
|
sendHoldingMessage: ackSeam(),
|
|
@@ -189,9 +226,10 @@ test("SCENARIO 2 — a slow ask is acknowledged at once, updated while it runs,
|
|
|
189
226
|
|
|
190
227
|
const ack = transcript.find((m) => m.kind === "ack");
|
|
191
228
|
assert.ok(ack, "the acknowledgement must go out");
|
|
192
|
-
assert.ok(ack.ms < 1000, `acknowledged in ${ack.ms}ms
|
|
193
|
-
assert.match(ack.text, /
|
|
194
|
-
assert.
|
|
229
|
+
assert.ok(ack.ms < 1000, `acknowledged in ${ack.ms}ms`);
|
|
230
|
+
assert.match(ack.text, /git fixes/i, "the ack is bespoke to the actual ask, not a rotation pick");
|
|
231
|
+
assert.doesNotMatch(ack.text, /^(on it|looking now)[.!—]?\s*$/i, "never the retired canned lines");
|
|
232
|
+
assert.ok(ack.text.length <= 120, "the ack is one short human line, not a templated paragraph");
|
|
195
233
|
|
|
196
234
|
const key = assurance.openObligations()[0].key;
|
|
197
235
|
note("obligation opened and acknowledged; session running");
|
|
@@ -230,6 +268,7 @@ test("SCENARIO 3 — a timing-out session tells the human what happened and that
|
|
|
230
268
|
|
|
231
269
|
let onCloseHook = null;
|
|
232
270
|
await daemon.processItem(item, "cohort", {
|
|
271
|
+
claudeAvailable: CLI_PRESENT,
|
|
233
272
|
classify: async () => ({ ...COMPLEX, summary: "Reconcile July spend against forecast" }),
|
|
234
273
|
isQuickReply: () => false,
|
|
235
274
|
sendHoldingMessage: ackSeam(),
|
|
@@ -265,6 +304,7 @@ test("SCENARIO 4 — a crashing session tells the human it failed, stops, and es
|
|
|
265
304
|
|
|
266
305
|
let onCloseHook = null;
|
|
267
306
|
await daemon.processItem(item, "cohort", {
|
|
307
|
+
claudeAvailable: CLI_PRESENT,
|
|
268
308
|
classify: async () => ({ ...COMPLEX, summary: "Draft the Q3 investor update" }),
|
|
269
309
|
isQuickReply: () => false,
|
|
270
310
|
sendHoldingMessage: ackSeam(),
|
|
@@ -299,6 +339,7 @@ test("SCENARIO 5 — a session that exits 0 without replying no longer passes as
|
|
|
299
339
|
|
|
300
340
|
let onCloseHook = null;
|
|
301
341
|
await daemon.processItem(item, "cohort", {
|
|
342
|
+
claudeAvailable: CLI_PRESENT,
|
|
302
343
|
classify: async () => ({ ...COMPLEX, summary: "Check whether the migration landed cleanly" }),
|
|
303
344
|
isQuickReply: () => false,
|
|
304
345
|
sendHoldingMessage: ackSeam(),
|
|
@@ -333,6 +374,7 @@ test("SCENARIO 6 — a debt orphaned by a daemon restart is spoken to on the nex
|
|
|
333
374
|
const item = makeItem("ORPHAN-1", ask);
|
|
334
375
|
|
|
335
376
|
await daemon.processItem(item, "cohort", {
|
|
377
|
+
claudeAvailable: CLI_PRESENT,
|
|
336
378
|
classify: async () => ({ ...COMPLEX, summary: "Fix DNS on the staging domain" }),
|
|
337
379
|
isQuickReply: () => false,
|
|
338
380
|
sendHoldingMessage: ackSeam(),
|
|
@@ -357,6 +399,65 @@ test("SCENARIO 6 — a debt orphaned by a daemon restart is spoken to on the nex
|
|
|
357
399
|
note("previously: the typing dot stopped and that was the last the human ever heard", 30_000);
|
|
358
400
|
});
|
|
359
401
|
|
|
402
|
+
// ===========================================================================
|
|
403
|
+
// SCENARIO 6b — A PEER AGENT'S ACK ARRIVES AS INBOUND. The dispatch gate reads
|
|
404
|
+
// it, and NEITHER an ack goes out NOR a session spawns — the machinery half of
|
|
405
|
+
// the 25/27/30 Aug loop, closed at the gate.
|
|
406
|
+
// ===========================================================================
|
|
407
|
+
|
|
408
|
+
test("SCENARIO 6b — an ack-shaped one-liner from a peer agent seat spawns nothing and acks nothing", async () => {
|
|
409
|
+
const echoAck = "On it — taking a look now.";
|
|
410
|
+
beginScenario("SCENARIO 6b — PEER AGENT ACK (emitter-class inbound)", echoAck);
|
|
411
|
+
const item = {
|
|
412
|
+
id: "PEER-ACK-1",
|
|
413
|
+
raw_ref: "cohort:dm-a016-a039:PEER-ACK-1",
|
|
414
|
+
message_id: "PEER-ACK-1",
|
|
415
|
+
service: "cohort",
|
|
416
|
+
channel: "dm-a016-a039", // an agent↔agent DM — the structural seat signal
|
|
417
|
+
channel_id: "CDM-PEER-ACK-1",
|
|
418
|
+
is_dm: true,
|
|
419
|
+
sender: "Piotr Novak",
|
|
420
|
+
sender_id: "A039",
|
|
421
|
+
content: echoAck,
|
|
422
|
+
};
|
|
423
|
+
writeFileSync(join(AGENT_DIR, "state", "inbox", "cohort", "PEER-ACK-1.yaml"), "id: PEER-ACK-1\n");
|
|
424
|
+
|
|
425
|
+
let holdingCalls = 0;
|
|
426
|
+
let dispatched = 0;
|
|
427
|
+
await daemon.processItem(item, "cohort", {
|
|
428
|
+
claudeAvailable: CLI_PRESENT,
|
|
429
|
+
classify: async () => ({ ...COMPLEX, summary: "peer says it's on it" }),
|
|
430
|
+
isQuickReply: () => false,
|
|
431
|
+
sendHoldingMessage: async () => { holdingCalls++; return { sent: true, holdingText: "x" }; },
|
|
432
|
+
dispatch: () => { dispatched++; },
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
assert.equal(holdingCalls, 0, "no acknowledgement at machinery");
|
|
436
|
+
assert.equal(dispatched, 0, "and no session burned answering a reflex");
|
|
437
|
+
assert.equal(transcript.length, 0, "the peer seat hears nothing");
|
|
438
|
+
assert.ok(!assurance.openObligations().some((o) => o.key === item.raw_ref), "no debt opened for emitter-class inbound");
|
|
439
|
+
// The refusal is on the audit trail — same log family as the self-echo drops.
|
|
440
|
+
const { readFileSync } = await import("node:fs");
|
|
441
|
+
const drops = readFileSync(join(AGENT_DIR, "logs", "daemon", "dispatch-gate-drops.jsonl"), "utf-8")
|
|
442
|
+
.split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
443
|
+
const drop = drops.find((d) => d.raw_ref === item.raw_ref);
|
|
444
|
+
assert.ok(drop, "the gate refusal is audited");
|
|
445
|
+
assert.equal(drop.reason, "peer-ack-shaped");
|
|
446
|
+
note("gate refusal audited to logs/daemon/dispatch-gate-drops.jsonl; item retired");
|
|
447
|
+
|
|
448
|
+
// The CONTRAST: the same shape from a human sender still gets the full path.
|
|
449
|
+
const humanItem = makeItem("HUMAN-TERSE-1", "On it — will do.");
|
|
450
|
+
let humanDispatched = 0;
|
|
451
|
+
await daemon.processItem(humanItem, "cohort", {
|
|
452
|
+
claudeAvailable: CLI_PRESENT,
|
|
453
|
+
classify: async () => COMPLEX,
|
|
454
|
+
isQuickReply: () => false,
|
|
455
|
+
sendHoldingMessage: ackSeam(async () => "Got it — I'll pick this up now."),
|
|
456
|
+
dispatch: () => { humanDispatched++; },
|
|
457
|
+
});
|
|
458
|
+
assert.equal(humanDispatched, 1, "a human's terse line is still worked on");
|
|
459
|
+
});
|
|
460
|
+
|
|
360
461
|
// ===========================================================================
|
|
361
462
|
// SCENARIO 7 — TWO ASKS, ONE ROOM. The owner sends a second thing before the
|
|
362
463
|
// first is done. One session answers; the other exits 0 in silence.
|
|
@@ -420,3 +521,37 @@ test("SCENARIO 7 — a second ask in the same DM is not discharged by the first
|
|
|
420
521
|
assert.match(rescue.text, /race between the poller's lock release/, "and B's actual work reaches the human");
|
|
421
522
|
note("previously: B closed as `answered` on A's receipt — zero messages, ask gone");
|
|
422
523
|
});
|
|
524
|
+
|
|
525
|
+
// ===========================================================================
|
|
526
|
+
// THE GATE'S OTHER SIDE — the fail-safe every scenario above suppresses.
|
|
527
|
+
// ===========================================================================
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Mutation proof for `CLI_PRESENT`. Every scenario above declares the CLI
|
|
531
|
+
* present so that the ack path is exercised rather than skipped; this asserts
|
|
532
|
+
* the branch they are stepping over still behaves. Flip `CLI_PRESENT` to
|
|
533
|
+
* `() => false` and the scenarios go red; delete the `claudeAvailable` seam
|
|
534
|
+
* from THIS test and it goes red on any box with the CLI installed.
|
|
535
|
+
*/
|
|
536
|
+
test("the ack is suppressed when the CLI is unavailable — but the debt is still opened", async () => {
|
|
537
|
+
const ask = "rebuild the index and tell me when it's done";
|
|
538
|
+
beginScenario("CLI UNAVAILABLE — silence with a tracked debt, never a promise", ask);
|
|
539
|
+
const item = makeItem("NO-CLI-1", ask);
|
|
540
|
+
|
|
541
|
+
let dispatched = 0;
|
|
542
|
+
await daemon.processItem(item, "cohort", {
|
|
543
|
+
claudeAvailable: () => false,
|
|
544
|
+
classify: async () => COMPLEX,
|
|
545
|
+
isQuickReply: () => false,
|
|
546
|
+
sendHoldingMessage: ackSeam(),
|
|
547
|
+
dispatch: () => { dispatched++; },
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
assert.equal(transcript.filter((m) => m.kind === "ack").length, 0,
|
|
551
|
+
"never promise a reply the CLI that would write it cannot start");
|
|
552
|
+
const rec = assurance.openObligations().find((o) => o.key === item.raw_ref);
|
|
553
|
+
assert.ok(rec, "the debt is opened anyway — silence is compensated by the sweep, not forgotten");
|
|
554
|
+
assert.equal(rec.state, "open");
|
|
555
|
+
assert.equal(dispatched, 1, "the work is still attempted; only the promise is withheld");
|
|
556
|
+
note("no ack, obligation open: the assurance sweep owns the compensation");
|
|
557
|
+
});
|