@cohortapp/agent-sdk 2.11.15 → 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/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 +24 -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/responder.mjs +51 -40
- 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
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
* prompt under schedules/triggers/<name>.md.
|
|
17
17
|
* Unknown cadences with a prompt on disk default to escalate; without
|
|
18
18
|
* a prompt they go straight to dlq with a clear error.
|
|
19
|
+
* FRONT DOOR (design §3.4): when the seat's main session is live, an
|
|
20
|
+
* escalate/guarded tick is HANDED to it (state/session/handoffs/<id>.json
|
|
21
|
+
* + the rendered prompt) and processed as "handed-to-session" instead of
|
|
22
|
+
* spawning; a handoff un-acked past its deadline is re-enqueued with
|
|
23
|
+
* metadata.handoffTimedOut=true and then spawned on the legacy lane.
|
|
24
|
+
* A deferred tick (circuit, governance, concurrency, per-cadence
|
|
25
|
+
* in-flight guard) is re-keyed to the TAIL and the drain keeps scanning.
|
|
19
26
|
* 4. Respects .emergency-stop: while present, the loop logs a heartbeat
|
|
20
27
|
* but never spawns a sub-session and never processes events. Existing
|
|
21
28
|
* claims remain on disk so they can be resumed once the stop is lifted.
|
|
@@ -49,7 +56,12 @@
|
|
|
49
56
|
* logger optional fn({ ts, level, …rest }) → void for tests.
|
|
50
57
|
* now injectable clock fn() → ms (tests); used when re-stamping
|
|
51
58
|
* in-flight claim mtimes so the stale-claim sweep can't sweep
|
|
52
|
-
* a live escalate (audit L6).
|
|
59
|
+
* a live escalate (audit L6), and for handoff deadlines.
|
|
60
|
+
* frontDoorState injected fn() → { frontDoor, sessionLive } (tests);
|
|
61
|
+
* defaults to lib/session/frontdoor#readFrontDoorState over
|
|
62
|
+
* config/session.yaml + state/session/heartbeat.json.
|
|
63
|
+
* handoffDeadlineMs how long a handed-off tick may sit un-acked before it
|
|
64
|
+
* is re-enqueued on the legacy lane (default 30 min).
|
|
53
65
|
*/
|
|
54
66
|
|
|
55
67
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, openSync, closeSync, statSync, unlinkSync, utimesSync } from "node:fs";
|
|
@@ -63,6 +75,7 @@ import {
|
|
|
63
75
|
completeTick,
|
|
64
76
|
failTick,
|
|
65
77
|
requeueTick,
|
|
78
|
+
enqueueTick,
|
|
66
79
|
recoverStaleClaims,
|
|
67
80
|
sweepRetention,
|
|
68
81
|
writeHealth,
|
|
@@ -70,6 +83,17 @@ import {
|
|
|
70
83
|
logBusEvent,
|
|
71
84
|
busDepth,
|
|
72
85
|
} from "../../lib/cadence-bus.mjs";
|
|
86
|
+
// FRONT DOOR (design §3.4). When the seat's main session is live, an
|
|
87
|
+
// escalate/guarded tick is HANDED to it (a handoff file + the rendered prompt
|
|
88
|
+
// on disk) instead of spawning a `claude --print` sub-session; the session acks
|
|
89
|
+
// with `maestro session ack <tickId>`. A handoff past its deadline is expired
|
|
90
|
+
// and the tick re-enqueued with metadata.handoffTimedOut=true, which is never
|
|
91
|
+
// handed off again — so a wedged session cannot starve a cadence. Not live →
|
|
92
|
+
// the legacy spawn, unchanged. The decision is pure; the state reader is the
|
|
93
|
+
// one edge and it fails open to "not live".
|
|
94
|
+
import { readFrontDoorState, shouldHandOffTick } from "../../lib/session/frontdoor.mjs";
|
|
95
|
+
import { writeHandoff, listHandoffs, expireHandoffs, pruneHandoffs, handoffPaths } from "../../lib/session/handoffs.mjs";
|
|
96
|
+
import { writeFileAtomic } from "../../lib/fs-atomic.mjs";
|
|
73
97
|
import { resolveClaudeBin as sharedResolveClaude, augmentedPath, daemonClaudeArgs } from "../../lib/claude-bin.mjs";
|
|
74
98
|
import { getCadenceDef } from "./cadence-handlers.mjs";
|
|
75
99
|
import { obligationAllowedUnderPosture } from "../../lib/plan/compile.mjs";
|
|
@@ -549,6 +573,43 @@ export function startConsumer(opts = {}) {
|
|
|
549
573
|
const governor = opts.governor || resourceGovernor;
|
|
550
574
|
const rateGuard = opts.rateGuard || rateGuardModule;
|
|
551
575
|
const budgetGuard = opts.budgetGuard || budgetGuardModule;
|
|
576
|
+
const handoffDeadlineMs = opts.handoffDeadlineMs;
|
|
577
|
+
const frontDoorStateImpl = typeof opts.frontDoorState === "function"
|
|
578
|
+
? opts.frontDoorState
|
|
579
|
+
: () => readFrontDoorState(agentRoot, { now: nowMs() });
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* The front-door state for THIS decision. A throwing reader means "not
|
|
583
|
+
* live" — the legacy lane runs, nothing is dropped.
|
|
584
|
+
*/
|
|
585
|
+
function frontDoorState() {
|
|
586
|
+
try {
|
|
587
|
+
const st = frontDoorStateImpl();
|
|
588
|
+
return st && typeof st === "object" ? st : { frontDoor: "daemon", sessionLive: false };
|
|
589
|
+
} catch {
|
|
590
|
+
return { frontDoor: "daemon", sessionLive: false, reason: "reader-error" };
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Render a cadence prompt the way realSpawnSession does ({{agent.*}} /
|
|
596
|
+
* {{company.*}} tokens from config/agent.json + config/company.json) and
|
|
597
|
+
* land it under state/session/handoffs/prompts/<tickId>.md so the session
|
|
598
|
+
* reads exactly what a sub-session would have been given.
|
|
599
|
+
*/
|
|
600
|
+
function renderHandoffPrompt(tickId, promptPath) {
|
|
601
|
+
const fullPrompt = join(agentRoot, promptPath);
|
|
602
|
+
let body = readFileSync(fullPrompt, "utf-8");
|
|
603
|
+
try {
|
|
604
|
+
const agentCfg = JSON.parse(readFileSync(join(agentRoot, "config/agent.json"), "utf-8"));
|
|
605
|
+
let companyCfg = {};
|
|
606
|
+
try { companyCfg = JSON.parse(readFileSync(join(agentRoot, "config/company.json"), "utf-8")); } catch { /* optional */ }
|
|
607
|
+
body = renderTemplate(body, buildContext(agentCfg, null, companyCfg));
|
|
608
|
+
} catch { /* no/invalid config/agent.json — leave body verbatim */ }
|
|
609
|
+
const out = join(handoffPaths(agentRoot).prompts, `${tickId}.md`);
|
|
610
|
+
writeFileAtomic(out, body);
|
|
611
|
+
return out;
|
|
612
|
+
}
|
|
552
613
|
|
|
553
614
|
/**
|
|
554
615
|
* Admission gate for an escalation. Folds the shared 429 breaker + the
|
|
@@ -635,6 +696,11 @@ export function startConsumer(opts = {}) {
|
|
|
635
696
|
dlq: 0,
|
|
636
697
|
retries: 0,
|
|
637
698
|
spawn_failures: 0,
|
|
699
|
+
// Front door (design §3.4): ticks handed to the live main session, handoffs
|
|
700
|
+
// that timed out and were re-enqueued, and deferrals re-keyed to the tail.
|
|
701
|
+
handed_off: 0,
|
|
702
|
+
handoff_timeouts: 0,
|
|
703
|
+
deferred: 0,
|
|
638
704
|
last_event_id: null,
|
|
639
705
|
last_decision: null,
|
|
640
706
|
};
|
|
@@ -660,10 +726,94 @@ export function startConsumer(opts = {}) {
|
|
|
660
726
|
// change recoverStaleClaims' signature (it lives in lib/cadence-bus.mjs,
|
|
661
727
|
// owned by another change-set).
|
|
662
728
|
const inFlightClaimIds = new Set();
|
|
729
|
+
// Per-cadence in-flight guard (audit F9: the same cadence spawned twice
|
|
730
|
+
// back-to-back). A cadence whose sub-session is still running, or whose
|
|
731
|
+
// handoff is still open with the session, must not start again — the second
|
|
732
|
+
// tick is deferred to the tail instead. Names, not ids: two ticks of one
|
|
733
|
+
// cadence are the duplicate this guards against.
|
|
734
|
+
const inFlightCadences = new Set();
|
|
663
735
|
|
|
664
736
|
// Injectable clock so tests can drive the stale window deterministically.
|
|
665
737
|
const nowMs = typeof opts.now === "function" ? opts.now : Date.now;
|
|
666
738
|
|
|
739
|
+
/** Is a cadence in flight — running here, or open as a handoff with the session? */
|
|
740
|
+
function cadenceInFlight(cadence) {
|
|
741
|
+
if (inFlightCadences.has(cadence)) return "sub-session";
|
|
742
|
+
try {
|
|
743
|
+
if (listHandoffs(agentRoot).some((h) => h.cadence === cadence)) return "handoff";
|
|
744
|
+
} catch { /* unreadable handoff dir → not a reason to block */ }
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Re-queue a tick that cannot run YET (an upstream gate, never a per-event
|
|
750
|
+
* failure) to the TAIL under a new id (audit F8). Attempts untouched.
|
|
751
|
+
* `count:false` (the circuit/backoff skip) re-keys without touching the
|
|
752
|
+
* retry/deferral counters or last_decision — a held-back cadence was never
|
|
753
|
+
* attempted, and the skip is already logged and counted as skipped_*; a
|
|
754
|
+
* 30 s poll inside a 60 min circuit window must not read as 120 retries.
|
|
755
|
+
*/
|
|
756
|
+
function deferTick(event, reason, extra = {}, { count = true } = {}) {
|
|
757
|
+
const r = requeueTick(agentRoot, event, { defer: true });
|
|
758
|
+
if (count) {
|
|
759
|
+
stats.retries += 1;
|
|
760
|
+
stats.deferred += 1;
|
|
761
|
+
stats.last_decision = "deferred";
|
|
762
|
+
log({ level: "info", stage: "escalate_deferred", reason, id: event.id, requeued_as: r && r.id, cadence: event.cadence, ...extra });
|
|
763
|
+
}
|
|
764
|
+
return { ok: false, decision: "deferred", reason };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Ledger hygiene runs from the sweep at most this often.
|
|
768
|
+
const HANDOFF_PRUNE_EVERY_MS = 60 * 60_000;
|
|
769
|
+
let lastHandoffPruneAt = 0;
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Expire handoffs past their deadline and re-enqueue each tick on the legacy
|
|
773
|
+
* lane with metadata.handoffTimedOut=true (never handed off again). The
|
|
774
|
+
* re-enqueue runs BEFORE the handoff is retired (expireHandoffs#onExpire):
|
|
775
|
+
* if the bus is unwritable the handoff stays open and the next sweep
|
|
776
|
+
* retries, so the cadence run is never lost. Cheap: one readdir of a small
|
|
777
|
+
* directory. Never throws.
|
|
778
|
+
*/
|
|
779
|
+
function sweepHandoffTimeouts() {
|
|
780
|
+
const now = nowMs();
|
|
781
|
+
let out;
|
|
782
|
+
try {
|
|
783
|
+
out = expireHandoffs(agentRoot, {
|
|
784
|
+
now,
|
|
785
|
+
deadlineMs: handoffDeadlineMs,
|
|
786
|
+
onExpire: (h) => {
|
|
787
|
+
const meta = h.metadata && typeof h.metadata === "object" ? h.metadata : {};
|
|
788
|
+
const r = enqueueTick({
|
|
789
|
+
cadence: h.cadence,
|
|
790
|
+
source: "handoff-timeout",
|
|
791
|
+
agentRoot,
|
|
792
|
+
metadata: { ...meta, handoffTimedOut: true, handoffTickId: h.tickId, reason: `handoff to session timed out (${h.deadlineAt || "no deadline"})` },
|
|
793
|
+
});
|
|
794
|
+
// enqueueTick swallows an unwritable inbox (fallbackOnly, audit row
|
|
795
|
+
// only) — that is NOT back on the bus: keep the handoff open.
|
|
796
|
+
if (!r || !r.path) throw new Error(`tick not enqueued (${r && r.fallbackOnly ? "inbox unwritable" : "no path"})`);
|
|
797
|
+
stats.handoff_timeouts += 1;
|
|
798
|
+
log({ level: "warn", stage: "handoff_timed_out", id: h.tickId, cadence: h.cadence, requeued_as: r.id, deadline_at: h.deadlineAt || null });
|
|
799
|
+
return true;
|
|
800
|
+
},
|
|
801
|
+
});
|
|
802
|
+
} catch { return 0; }
|
|
803
|
+
for (const f of out.failed) {
|
|
804
|
+
log({ level: "error", stage: "handoff_requeue_failed", id: f.tickId, error: f.error, note: "handoff kept open; retried next sweep" });
|
|
805
|
+
}
|
|
806
|
+
if (now - lastHandoffPruneAt >= HANDOFF_PRUNE_EVERY_MS) {
|
|
807
|
+
lastHandoffPruneAt = now;
|
|
808
|
+
try {
|
|
809
|
+
const pr = pruneHandoffs(agentRoot, { now });
|
|
810
|
+
const n = pr.removed.done.length + pr.removed.prompts.length;
|
|
811
|
+
if (n > 0) log({ level: "info", stage: "handoffs_pruned", done: pr.removed.done.length, prompts: pr.removed.prompts.length });
|
|
812
|
+
} catch { /* hygiene is never a reason to skip the tick */ }
|
|
813
|
+
}
|
|
814
|
+
return out.expired.length;
|
|
815
|
+
}
|
|
816
|
+
|
|
667
817
|
/**
|
|
668
818
|
* Re-stamp the mtime of every in-flight claim file to the current time so
|
|
669
819
|
* the next mtime-based stale sweep treats it as fresh. Best-effort: a missing
|
|
@@ -793,56 +943,22 @@ export function startConsumer(opts = {}) {
|
|
|
793
943
|
});
|
|
794
944
|
if (gate.reason === "circuit-open") stats.skipped_circuit_open += 1;
|
|
795
945
|
else stats.skipped_backoff += 1;
|
|
796
|
-
// Put the event back
|
|
797
|
-
// single-sourced in failTick (audit M5), so we
|
|
798
|
-
//
|
|
799
|
-
|
|
800
|
-
// a bare writeFileSync so a crash can't leave a half-written inbox file.
|
|
801
|
-
requeueTick(agentRoot, event);
|
|
946
|
+
// Put the event back — to the TAIL (F8). Attempt accounting is
|
|
947
|
+
// single-sourced in failTick (audit M5), so we never touch it here: a
|
|
948
|
+
// held-back-by-circuit re-queue is not a failed attempt, nor a retry.
|
|
949
|
+
deferTick(event, gate.reason, { retry_at: new Date(gate.retry_at).toISOString() }, { count: false });
|
|
802
950
|
return { ok: false, decision: gate.reason };
|
|
803
951
|
}
|
|
804
952
|
|
|
805
953
|
// WS4 governance gate — beside the concurrency cap. If the host is under
|
|
806
954
|
// memory/load pressure, the 429 breaker is open, or the daily budget is
|
|
807
|
-
// exhausted (essential-only), DEFER this cadence tick: requeue it
|
|
808
|
-
// and DO NOT call failTick (a governor/rate deferral is an upstream
|
|
809
|
-
// not a per-event failure — burning retry budget here would
|
|
810
|
-
// a perfectly good cadence just because the box was busy).
|
|
955
|
+
// exhausted (essential-only), DEFER this cadence tick: requeue it to the
|
|
956
|
+
// tail and DO NOT call failTick (a governor/rate deferral is an upstream
|
|
957
|
+
// gate, not a per-event failure — burning retry budget here would
|
|
958
|
+
// eventually DLQ a perfectly good cadence just because the box was busy).
|
|
811
959
|
{
|
|
812
960
|
const gov = governanceGate(event.cadence);
|
|
813
|
-
if (!gov.admit)
|
|
814
|
-
log({
|
|
815
|
-
level: "info",
|
|
816
|
-
stage: "escalate_deferred",
|
|
817
|
-
reason: gov.reason,
|
|
818
|
-
id: event.id,
|
|
819
|
-
cadence: event.cadence,
|
|
820
|
-
});
|
|
821
|
-
requeueTick(agentRoot, event);
|
|
822
|
-
stats.retries += 1;
|
|
823
|
-
stats.last_decision = "deferred";
|
|
824
|
-
return { ok: false, decision: "deferred" };
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
if (activeSubSessions >= MAX_CONCURRENT_SUB_SESSIONS) {
|
|
829
|
-
// Re-queue and try again next tick. Single-owner cadence consumer
|
|
830
|
-
// means this can only happen when a prior tick is still running —
|
|
831
|
-
// queue depth is the right back-pressure signal.
|
|
832
|
-
log({
|
|
833
|
-
level: "info",
|
|
834
|
-
stage: "escalate_deferred",
|
|
835
|
-
id: event.id,
|
|
836
|
-
cadence: event.cadence,
|
|
837
|
-
active_subsessions: activeSubSessions,
|
|
838
|
-
});
|
|
839
|
-
// Re-queue unchanged — concurrent-spawn isn't a per-event failure, so
|
|
840
|
-
// it must not touch the attempt count (single-sourced in failTick,
|
|
841
|
-
// audit M5). Atomic requeueTick (audit M6) replaces the bare
|
|
842
|
-
// writeFileSync that could leave a half-written inbox file.
|
|
843
|
-
requeueTick(agentRoot, event);
|
|
844
|
-
stats.retries += 1;
|
|
845
|
-
return { ok: false, decision: "deferred" };
|
|
961
|
+
if (!gov.admit) return deferTick(event, gov.reason);
|
|
846
962
|
}
|
|
847
963
|
|
|
848
964
|
const def = getCadenceDef(event.cadence);
|
|
@@ -861,7 +977,61 @@ export function startConsumer(opts = {}) {
|
|
|
861
977
|
}
|
|
862
978
|
}
|
|
863
979
|
|
|
980
|
+
// Per-cadence in-flight guard (F9): never the same cadence twice at once —
|
|
981
|
+
// neither as two sub-sessions nor as a sub-session beside an open handoff.
|
|
982
|
+
{
|
|
983
|
+
const why = cadenceInFlight(event.cadence);
|
|
984
|
+
if (why) return deferTick(event, `cadence-in-flight:${why}`);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// FRONT DOOR (§3.4): a live main session takes this tick instead of a
|
|
988
|
+
// sub-session. Handoff = rendered prompt on disk + the ledger file; the
|
|
989
|
+
// tick is processed here with decision "handed-to-session". A tick whose
|
|
990
|
+
// previous handoff timed out (metadata.handoffTimedOut) falls through to
|
|
991
|
+
// the legacy spawn — shouldHandOffTick is pure and owns that rule.
|
|
992
|
+
{
|
|
993
|
+
const fd = frontDoorState();
|
|
994
|
+
const verdict = shouldHandOffTick({ ...fd, mode: def?.mode, metadata: event.metadata });
|
|
995
|
+
if (verdict.handOff) {
|
|
996
|
+
try {
|
|
997
|
+
const rendered = renderHandoffPrompt(event.id, promptPath);
|
|
998
|
+
const h = writeHandoff(agentRoot, {
|
|
999
|
+
tickId: event.id,
|
|
1000
|
+
cadence: event.cadence,
|
|
1001
|
+
mode: def?.mode || "escalate",
|
|
1002
|
+
promptPath: rendered,
|
|
1003
|
+
metadata: { ...(event.metadata || {}), sourcePrompt: promptPath },
|
|
1004
|
+
}, { now: nowMs(), deadlineMs: handoffDeadlineMs });
|
|
1005
|
+
if (!h.ok) throw new Error(h.error || "handoff write failed");
|
|
1006
|
+
completeTick(agentRoot, event.id, {
|
|
1007
|
+
decision: "handed-to-session",
|
|
1008
|
+
cadence: event.cadence,
|
|
1009
|
+
prompt: promptPath,
|
|
1010
|
+
handoff: h.path,
|
|
1011
|
+
deadline_at: h.handoff && h.handoff.deadlineAt,
|
|
1012
|
+
});
|
|
1013
|
+
stats.handed_off += 1;
|
|
1014
|
+
stats.last_decision = "handed-to-session";
|
|
1015
|
+
log({ level: "info", stage: "handed_to_session", id: event.id, cadence: event.cadence, prompt: rendered, deadline_at: h.handoff && h.handoff.deadlineAt });
|
|
1016
|
+
return { ok: true, decision: "handed-to-session" };
|
|
1017
|
+
} catch (err) {
|
|
1018
|
+
// A handoff we could not write is not a reason to lose the tick —
|
|
1019
|
+
// fall through to the legacy spawn, loudly.
|
|
1020
|
+
log({ level: "warn", stage: "handoff_failed_spawning_instead", id: event.id, cadence: event.cadence, error: err && err.message });
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
if (activeSubSessions >= MAX_CONCURRENT_SUB_SESSIONS) {
|
|
1026
|
+
// Re-queue (to the tail) and try again next tick. Single-owner cadence
|
|
1027
|
+
// consumer means this can only happen when a prior tick is still
|
|
1028
|
+
// running — queue depth is the right back-pressure signal. Not a
|
|
1029
|
+
// per-event failure: the attempt count (failTick, audit M5) is untouched.
|
|
1030
|
+
return deferTick(event, "concurrency", { active_subsessions: activeSubSessions });
|
|
1031
|
+
}
|
|
1032
|
+
|
|
864
1033
|
activeSubSessions += 1;
|
|
1034
|
+
inFlightCadences.add(event.cadence);
|
|
865
1035
|
// Audit L6: mark this claim in-flight so the periodic stale-claim sweep
|
|
866
1036
|
// (which can run concurrently with a long-running sub-session) does not
|
|
867
1037
|
// treat its claimed/<id>.json as crashed and re-queue it under us.
|
|
@@ -878,6 +1048,7 @@ export function startConsumer(opts = {}) {
|
|
|
878
1048
|
});
|
|
879
1049
|
} finally {
|
|
880
1050
|
activeSubSessions -= 1;
|
|
1051
|
+
inFlightCadences.delete(event.cadence);
|
|
881
1052
|
inFlightClaimIds.delete(event.id);
|
|
882
1053
|
}
|
|
883
1054
|
|
|
@@ -910,14 +1081,11 @@ export function startConsumer(opts = {}) {
|
|
|
910
1081
|
const rec = rateGuard.recordUsageLimit(RATE_PROVIDER, cadenceUl.resetAt, { agentRoot });
|
|
911
1082
|
log({ level: "warn", stage: "subsession_usage_limited", id: event.id, cadence: event.cadence, open_until: rec.openUntil, reset_at: rec.resetAt });
|
|
912
1083
|
} catch { /* */ }
|
|
913
|
-
// Same
|
|
914
|
-
//
|
|
915
|
-
//
|
|
916
|
-
//
|
|
917
|
-
|
|
918
|
-
stats.retries += 1;
|
|
919
|
-
stats.last_decision = "deferred";
|
|
920
|
-
return { ok: false, decision: "deferred" };
|
|
1084
|
+
// Same handling as a 429: a window-usage limit is a shared, provider-side
|
|
1085
|
+
// gate — not evidence THIS cadence is broken — so requeue (to the tail)
|
|
1086
|
+
// without failTick or a circuit trip; the shared breaker (held until
|
|
1087
|
+
// reset) gates re-escalation.
|
|
1088
|
+
return deferTick(event, "usage-limited");
|
|
921
1089
|
}
|
|
922
1090
|
if (rateGuard.classifyStderr(cadenceOut)) {
|
|
923
1091
|
try {
|
|
@@ -929,12 +1097,9 @@ export function startConsumer(opts = {}) {
|
|
|
929
1097
|
// NOT evidence that THIS cadence is broken, so we must NOT call
|
|
930
1098
|
// recordSubsessionFailure here: doing so would advance the per-cadence
|
|
931
1099
|
// circuit toward open and leave a perfectly-healthy cadence circuit-open
|
|
932
|
-
// even after the shared breaker clears. Requeue
|
|
1100
|
+
// even after the shared breaker clears. Requeue to the tail (no failTick,
|
|
933
1101
|
// no circuit trip); the shared breaker gates re-escalation.
|
|
934
|
-
|
|
935
|
-
stats.retries += 1;
|
|
936
|
-
stats.last_decision = "deferred";
|
|
937
|
-
return { ok: false, decision: "deferred" };
|
|
1102
|
+
return deferTick(event, "rate-limited");
|
|
938
1103
|
}
|
|
939
1104
|
|
|
940
1105
|
// Failure path: log + cap retries low. The exact stderr tail comes
|
|
@@ -1040,40 +1205,49 @@ export function startConsumer(opts = {}) {
|
|
|
1040
1205
|
// Routed through the guarded wrapper so an in-flight claim is never swept
|
|
1041
1206
|
// (audit L6).
|
|
1042
1207
|
recoverStaleClaimsGuarded();
|
|
1208
|
+
// Front door: a handed-off tick the session never acked comes back to the
|
|
1209
|
+
// legacy lane here, ahead of the drain, so it is claimable in this tick.
|
|
1210
|
+
sweepHandoffTimeouts();
|
|
1043
1211
|
|
|
1044
1212
|
let processed = 0;
|
|
1045
|
-
let
|
|
1046
|
-
//
|
|
1047
|
-
//
|
|
1048
|
-
//
|
|
1049
|
-
//
|
|
1213
|
+
let spawnedThisTick = 0;
|
|
1214
|
+
// Head-of-line (F8): a deferred tick is re-keyed to the tail, so the loop
|
|
1215
|
+
// KEEPS SCANNING past it to the next cadence instead of breaking. This set
|
|
1216
|
+
// (keyed by the tick's original id, which a re-key preserves) is how the
|
|
1217
|
+
// loop notices it has come back round to a tick it already deferred in
|
|
1218
|
+
// this very tick — it puts that one back untouched and stops, so a queue
|
|
1219
|
+
// holding only un-runnable ticks costs one visit per tick, not sixteen.
|
|
1220
|
+
const seenThisTick = new Set();
|
|
1050
1221
|
while (!stopping) {
|
|
1051
1222
|
const claim = claimNextTick(agentRoot);
|
|
1052
1223
|
if (!claim) break;
|
|
1053
1224
|
const event = claim.event;
|
|
1225
|
+
const key = (event.metadata && event.metadata.originalId) || event.id;
|
|
1226
|
+
if (seenThisTick.has(key)) {
|
|
1227
|
+
requeueTick(agentRoot, event);
|
|
1228
|
+
break;
|
|
1229
|
+
}
|
|
1230
|
+
seenThisTick.add(key);
|
|
1054
1231
|
activeTick = event.id;
|
|
1055
|
-
let
|
|
1232
|
+
let didSpawn = false;
|
|
1056
1233
|
try {
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
//
|
|
1060
|
-
//
|
|
1061
|
-
|
|
1062
|
-
// signal that an actual sub-session ran this iteration.
|
|
1063
|
-
const before = stats.escalated + stats.spawn_failures + stats.skipped_circuit_open + stats.skipped_backoff;
|
|
1234
|
+
// Did a sub-session actually run (or fail to spawn) this iteration? A
|
|
1235
|
+
// deferral — circuit/backoff skip, governance, concurrency, in-flight
|
|
1236
|
+
// guard — is NOT a spawn and must not end the scan (that was the
|
|
1237
|
+
// head-of-line block). A handoff is not a spawn either: it is cheap.
|
|
1238
|
+
const before = stats.escalated + stats.spawn_failures;
|
|
1064
1239
|
await processEvent(event);
|
|
1065
|
-
const after = stats.escalated + stats.spawn_failures
|
|
1066
|
-
if (after > before)
|
|
1067
|
-
// Silence unused var warning.
|
|
1068
|
-
void willEscalate;
|
|
1240
|
+
const after = stats.escalated + stats.spawn_failures;
|
|
1241
|
+
if (after > before) didSpawn = true;
|
|
1069
1242
|
} finally {
|
|
1070
1243
|
activeTick = null;
|
|
1071
1244
|
}
|
|
1072
1245
|
processed += 1;
|
|
1073
|
-
if (
|
|
1074
|
-
// Hard cap: at most ONE sub-session spawn per tick
|
|
1075
|
-
//
|
|
1076
|
-
|
|
1246
|
+
if (didSpawn) spawnedThisTick += 1;
|
|
1247
|
+
// Hard cap: at most ONE sub-session spawn per tick so a fast-failing
|
|
1248
|
+
// cadence can't burn a whole minute's worth of retries inside a single
|
|
1249
|
+
// poll. Inline ticks and handoffs keep draining freely (they're cheap).
|
|
1250
|
+
if (spawnedThisTick >= 1) break;
|
|
1077
1251
|
if (processed >= 16) break; // soft batch cap
|
|
1078
1252
|
}
|
|
1079
1253
|
return { processed };
|
|
@@ -1216,6 +1390,8 @@ export function startConsumer(opts = {}) {
|
|
|
1216
1390
|
_recoverStaleClaimsGuarded: recoverStaleClaimsGuarded,
|
|
1217
1391
|
_markInFlight: (id) => inFlightClaimIds.add(id),
|
|
1218
1392
|
_clearInFlight: (id) => inFlightClaimIds.delete(id),
|
|
1393
|
+
// Front door: the open handoffs this consumer has written for the session.
|
|
1394
|
+
_handoffs: () => listHandoffs(agentRoot),
|
|
1219
1395
|
};
|
|
1220
1396
|
}
|
|
1221
1397
|
|
|
@@ -679,6 +679,33 @@ const MESSAGING_CURSOR_REL = join("state", "messaging", "inbound-cursor.json");
|
|
|
679
679
|
* (re)install starts at "now" and replays nothing. A real, persisted cursor
|
|
680
680
|
* (always > 0 in practice — see writeMessagingCursor) is returned as-is.
|
|
681
681
|
*/
|
|
682
|
+
/**
|
|
683
|
+
* The seat's own member cuid, as resolved by `agent-daemon.resolveSelfMemberId`
|
|
684
|
+
* and persisted to `config/agent.json`. This is the `actor` hq stamps on every
|
|
685
|
+
* event the seat emits, so it is the ONLY id the wide reader's own-echo and
|
|
686
|
+
* mention joins can match against.
|
|
687
|
+
*
|
|
688
|
+
* Returns "" when unresolved. Also returns "" when the field holds the SLUG
|
|
689
|
+
* (COHORT_AGENT_ID) rather than a cuid — a bad writer putting the slug in the
|
|
690
|
+
* cuid field would otherwise make this fix mask the very bug it fixes, since a
|
|
691
|
+
* slug read from disk is exactly as broken as a slug read from env.
|
|
692
|
+
*
|
|
693
|
+
* @param {string} agentRoot
|
|
694
|
+
* @returns {string} member cuid, or "" if unresolved
|
|
695
|
+
*/
|
|
696
|
+
function persistedMemberId(agentRoot) {
|
|
697
|
+
try {
|
|
698
|
+
const c = JSON.parse(readFileSync(join(agentRoot, "config", "agent.json"), "utf-8"));
|
|
699
|
+
const mid = c && typeof c.memberId === "string" ? c.memberId.trim() : "";
|
|
700
|
+
if (!mid) return "";
|
|
701
|
+
const slug = String(process.env.COHORT_AGENT_ID || "").trim();
|
|
702
|
+
if (slug && mid === slug) return "";
|
|
703
|
+
return mid;
|
|
704
|
+
} catch {
|
|
705
|
+
return "";
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
682
709
|
function readMessagingCursor(agentRoot) {
|
|
683
710
|
try {
|
|
684
711
|
const p = join(agentRoot, MESSAGING_CURSOR_REL);
|
|
@@ -813,11 +840,36 @@ async function guardMessagingInbound({ event, agentRoot, log }, opts = {}) {
|
|
|
813
840
|
// skipped, and the agent re-ingests its own replies as fresh inbound (a
|
|
814
841
|
// conversation with itself). config/org.yaml does not carry an agentId, so
|
|
815
842
|
// env is the reliable source on a real machine.
|
|
843
|
+
//
|
|
844
|
+
// MUST BE THE MEMBER CUID, NOT THE SLUG. hq names the actor on every
|
|
845
|
+
// /v1/events row by member cuid ("cmqh0te…"); COHORT_AGENT_ID is a SLUG
|
|
846
|
+
// ("A028"). Every directedness join in the wide reader — own_echo, mention,
|
|
847
|
+
// assignee, decision proposer, doc owner — compares `me` against a
|
|
848
|
+
// cuid-shaped payload field, so handing it the slug does not throw: it
|
|
849
|
+
// silently answers "no" to every identity question. own_echo then never
|
|
850
|
+
// fires and the seat re-ingests its own outbound, which produces a holding
|
|
851
|
+
// note, which is itself re-ingested — a self-sustaining loop that burns a
|
|
852
|
+
// sub-session per cycle and posts into real rooms.
|
|
853
|
+
//
|
|
854
|
+
// agent-daemon.resolveSelfMemberId already resolves the cuid via whoami and
|
|
855
|
+
// persists it to config/agent.json; prefer that, and fall back to the slug
|
|
856
|
+
// (degraded, but better than no id) only until it has been resolved once.
|
|
816
857
|
const agentId =
|
|
817
858
|
opts.agentId ||
|
|
859
|
+
persistedMemberId(agentRoot) ||
|
|
818
860
|
process.env.COHORT_AGENT_ID ||
|
|
819
861
|
(cfg && cfg.org && cfg.org.cohort && cfg.org.cohort.agentId) ||
|
|
820
862
|
undefined;
|
|
863
|
+
// Belt and braces on top of the id above: hand the reader BOTH namespaces so
|
|
864
|
+
// own-echo suppression cannot silently lapse if `agentId` resolves to the
|
|
865
|
+
// slug after all (persistedMemberId returns "" when no memberId is on record,
|
|
866
|
+
// or when it equals the slug). resolveDirected uses these ONLY to widen the
|
|
867
|
+
// own-echo drop — never to widen what the agent is entitled to read.
|
|
868
|
+
const meAliases = [
|
|
869
|
+
cfg && cfg.memberId,
|
|
870
|
+
process.env.COHORT_AGENT_ID,
|
|
871
|
+
cfg && cfg.org && cfg.org.cohort && cfg.org.cohort.agentId,
|
|
872
|
+
].filter((v) => typeof v === "string" && v.trim());
|
|
821
873
|
// Display names for prose-mention matching ("Isla, can you…" with no @).
|
|
822
874
|
// The wide reader only ever uses these to match text it was ALREADY entitled
|
|
823
875
|
// to read, so this cannot widen the aperture.
|
|
@@ -835,6 +887,7 @@ async function guardMessagingInbound({ event, agentRoot, log }, opts = {}) {
|
|
|
835
887
|
const res = await pull({
|
|
836
888
|
cfg,
|
|
837
889
|
agentId,
|
|
890
|
+
meAliases,
|
|
838
891
|
cursor,
|
|
839
892
|
fetchImpl: opts.fetchImpl,
|
|
840
893
|
myNames,
|
|
@@ -196,7 +196,7 @@ const CLAUDE_CLI_TIMEOUT_MS = 30_000;
|
|
|
196
196
|
* it was a hardcoded example-company literal sitting between two interpolated
|
|
197
197
|
* fields. This
|
|
198
198
|
* is not cosmetic: the classifier's `summary` is carried verbatim into
|
|
199
|
-
*
|
|
199
|
+
* board-mirror's board row title and the needs-attention escalation record,
|
|
200
200
|
* so a fabricated employer in its frame of reference leaks into the org record.
|
|
201
201
|
*
|
|
202
202
|
* @param {{name?:string, role?:string, company?:string, principal?:object}} id
|