@cohortapp/agent-sdk 2.11.2 → 2.11.4
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/archetypes/capabilities/executive-operator.yaml +5 -1
- package/lib/plan/compile.mjs +9 -0
- package/lib/resource-governor.mjs +70 -14
- package/lib/resource-governor.test.mjs +45 -18
- package/package.json +1 -1
- package/scripts/daemon/classifier.mjs +14 -3
- package/scripts/daemon/classifier.test.mjs +19 -0
- package/scripts/daemon/responder.mjs +8 -0
- package/scripts/daemon/typing-registry.mjs +9 -1
- package/scripts/setup/generate-cadences.mjs +9 -1
|
@@ -38,7 +38,11 @@ skills:
|
|
|
38
38
|
workflows:
|
|
39
39
|
- { id: weekly-operating-review, name: "Weekly operating review", cadence: weekly, purpose: "Run the leadership operating cadence: surface on-track/at-risk OKRs, blockers and decisions needed, then publish the review" }
|
|
40
40
|
- { id: commitment-sweep, name: "Commitment closure sweep", cadence: daily, purpose: "Sweep open action items and commitments, chase ageing/overdue owners, and keep the register clean" }
|
|
41
|
-
|
|
41
|
+
# inbox-to-outcome (cadence: continuous) removed 2026-08-25: it generated a
|
|
42
|
+
# continuous cadence + plist expecting schedules/triggers/inbox-to-outcome.md,
|
|
43
|
+
# which does not exist, so it span skipped_circuit_open forever and padded the
|
|
44
|
+
# queue. Real inbox triage is already covered by the STANDARD_CADENCES
|
|
45
|
+
# inbox-processor + messaging-inbound (both have prompts). No replacement needed.
|
|
42
46
|
- { id: board-cadence, name: "Board and investor cadence", cadence: quarterly, purpose: "Drive the board meeting cycle end to end — pre-reads, pack, decisions-sought and post-meeting action closure" }
|
|
43
47
|
- { id: quarterly-planning, name: "Quarterly planning and OKRs", cadence: quarterly, purpose: "Open and run the planning cycle: refresh strategy, set objectives, cascade owners and lock roadmap traceability" }
|
|
44
48
|
- { id: leadership-meeting-cycle, name: "Leadership meeting cycle", cadence: weekly, purpose: "Architect, pre-read, capture and follow up the recurring leadership-team and exec-staff meetings" }
|
package/lib/plan/compile.mjs
CHANGED
|
@@ -344,6 +344,15 @@ export function compilePlan(input = {}) {
|
|
|
344
344
|
for (const c of archetypeCadences) {
|
|
345
345
|
if (!c || !c.id) continue;
|
|
346
346
|
if (c.scope === "standard") continue; // already emitted verbatim above
|
|
347
|
+
// A `cadence: continuous` capability (inbox triage, SLA/obligation watch) is
|
|
348
|
+
// a standing REAL-TIME responsibility, NOT a launchd tick. It ships no
|
|
349
|
+
// schedules/triggers/<id>.md, so compiling it to a SCHEDULE fires every
|
|
350
|
+
// interval, finds no prompt, and spins `skipped_circuit_open` forever,
|
|
351
|
+
// padding the cadence-bus queue on every seat. Do NOT schedule it; the
|
|
352
|
+
// daemon's real-time loop owns the capability. The legacy producer
|
|
353
|
+
// (scripts/setup/generate-cadences.mjs) applies the SAME skip, so the two
|
|
354
|
+
// stay byte-identical (mandate.test.mjs COMPATIBILITY).
|
|
355
|
+
if (c.cadence === "continuous") continue;
|
|
347
356
|
const key = `schedule.${c.id}`;
|
|
348
357
|
if (obligations.some((o) => o.key === key)) continue;
|
|
349
358
|
const uses = resolveUses(usesForArchetypeCadence(c, reachable), key);
|
|
@@ -16,14 +16,26 @@
|
|
|
16
16
|
* never dropped — and the agent throttles instead of bricking.
|
|
17
17
|
*
|
|
18
18
|
* Dynamic ceiling (replaces the hardcoded MAX_CONCURRENT=10):
|
|
19
|
-
* dynamicMax = clamp(floor(totalmem * 0.60 / 750MB), 1, min(
|
|
19
|
+
* dynamicMax = clamp(floor(totalmem * 0.60 / 750MB), 1, min(HARD_MAX, cpus))
|
|
20
20
|
* effectiveMax = min(dynamicMax, throttleCeiling)
|
|
21
21
|
* where throttleCeiling comes from state/throttle.json (written by the memory
|
|
22
22
|
* watchdog's SOFT tier; auto-expiring). The governor only READS that file —
|
|
23
|
-
* it never writes it.
|
|
23
|
+
* it never writes it. HARD_MAX defaults to 4, not 10: the binding constraint on
|
|
24
|
+
* a subscription-auth seat is the ONE Max subscription, which cannot serve many
|
|
25
|
+
* parallel `claude --print` calls — ten concurrent sessions starved the reactive
|
|
26
|
+
* quick-reply's claude call into a 60s timeout. API/multi-key deployments raise
|
|
27
|
+
* it with GOV_HARD_MAX.
|
|
28
|
+
*
|
|
29
|
+
* The concurrency ceiling is PER SOURCE, because a dispatch slot is not the
|
|
30
|
+
* scarce resource — the subscription + host CPU are. Reactive work (inbox / a
|
|
31
|
+
* direct human or agent reply) may use the WHOLE envelope; self-directed backlog
|
|
32
|
+
* is held REACTIVE_RESERVE (default 2) slots short so a reply can always be
|
|
33
|
+
* GENERATED, not merely dispatched. This is the governor-level twin of the
|
|
34
|
+
* dispatcher's RESERVED_INBOX_SLOTS (which reserves a dispatch slot).
|
|
24
35
|
*
|
|
25
36
|
* Thresholds (env GOV_* overridable; tests inject `deps` instead):
|
|
26
|
-
* liveCount >= effectiveMax
|
|
37
|
+
* inbox liveCount >= effectiveMax → QUEUE
|
|
38
|
+
* backlog liveCount >= effectiveMax - REACTIVE_RESERVE → QUEUE
|
|
27
39
|
* freemem < totalmem*0.15 OR < 2*750MB headroom → DEFER
|
|
28
40
|
* loadavg1 > cpus*1.5 → DEFER
|
|
29
41
|
* liveRSS + 750MB > 0.60*totalmem → QUEUE
|
|
@@ -31,7 +43,9 @@
|
|
|
31
43
|
*
|
|
32
44
|
* Plus the budget ladder (`mode`, from `lib/budget-guard.dailyStatus().mode`),
|
|
33
45
|
* banded against the seat's hq-funded envelope rather than a local config cap:
|
|
34
|
-
* degraded (≥100%) source==="backlog"
|
|
46
|
+
* degraded (≥100%) source==="backlog" → THROTTLE to DEGRADED_BACKLOG_MAX
|
|
47
|
+
* (default 1) — a trickle in idle gaps,
|
|
48
|
+
* not a stop (owner instruction).
|
|
35
49
|
* suspended (≥125%) source==="backlog"|"cadence" → DEFER
|
|
36
50
|
* refused (>150%) anything that is not a direct human reply → DEFER
|
|
37
51
|
* Inbox always goes through the normal ADMIT/QUEUE path — never go dark on the
|
|
@@ -64,8 +78,29 @@ export const RAM_FRACTION = frac(process.env.GOV_RAM_FRACTION, 0.6);
|
|
|
64
78
|
export const FREEMEM_FLOOR_FRACTION = frac(process.env.GOV_FREEMEM_FLOOR, 0.15);
|
|
65
79
|
/** Load-average multiplier over cpu count that triggers a DEFER. */
|
|
66
80
|
export const LOAD_FACTOR = num(process.env.GOV_LOAD_FACTOR, 1.5);
|
|
67
|
-
/**
|
|
68
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Hard ceiling on dynamicMax regardless of RAM. The binding constraint on a
|
|
83
|
+
* subscription-auth seat is NOT host RAM — it is the ONE Max subscription, which
|
|
84
|
+
* cannot serve many parallel `claude --print` calls without throttling. Ten
|
|
85
|
+
* concurrent backlog sessions starved the reactive quick-reply's claude call
|
|
86
|
+
* into a 60s timeout (observed on A016/isla). Default is subscription-safe;
|
|
87
|
+
* API/multi-key deployments raise it with GOV_HARD_MAX.
|
|
88
|
+
*/
|
|
89
|
+
export const HARD_MAX = num(process.env.GOV_HARD_MAX, 4);
|
|
90
|
+
/**
|
|
91
|
+
* Slots the governor holds back from self-directed (backlog/cadence) work so a
|
|
92
|
+
* human/agent reply always has subscription + host headroom to GENERATE, not
|
|
93
|
+
* merely a dispatch slot. The dispatcher's RESERVED_INBOX_SLOTS reserves a
|
|
94
|
+
* dispatch slot; this reserves the scarce resource that actually timed out.
|
|
95
|
+
*/
|
|
96
|
+
export const REACTIVE_RESERVE = num(process.env.GOV_REACTIVE_RESERVE, 2);
|
|
97
|
+
/**
|
|
98
|
+
* The thin trickle of self-directed work a DEGRADED/unfunded seat still runs.
|
|
99
|
+
* Owner instruction (2026-08-25): throttle backlog on an unfunded seat, do not
|
|
100
|
+
* stop it — autonomous progress keeps ticking in idle gaps while the inbox lane
|
|
101
|
+
* gets near-all capacity.
|
|
102
|
+
*/
|
|
103
|
+
export const DEGRADED_BACKLOG_MAX = num(process.env.GOV_DEGRADED_BACKLOG_MAX, 1);
|
|
69
104
|
/** Minimum dynamicMax (always allow at least one session). */
|
|
70
105
|
const MIN_MAX = 1;
|
|
71
106
|
|
|
@@ -348,15 +383,36 @@ export function admit(req = {}, deps) {
|
|
|
348
383
|
if (mode === "suspended" && (source === "backlog" || source === "cadence")) {
|
|
349
384
|
return decide(DEFER, "budget suspended (>=125% of the seat envelope): deferring non-inbox work");
|
|
350
385
|
}
|
|
351
|
-
|
|
352
|
-
|
|
386
|
+
// NOTE: `degraded` (>=100% / unfunded) no longer hard-stops backlog. Owner
|
|
387
|
+
// instruction (2026-08-25): THROTTLE, don't stop — the seat keeps a thin
|
|
388
|
+
// trickle of self-directed work so autonomous progress never fully halts,
|
|
389
|
+
// while the inbox lane gets near-all capacity. Enforced as a lowered
|
|
390
|
+
// per-source concurrency ceiling immediately below, not a DEFER.
|
|
391
|
+
|
|
392
|
+
// (1) Concurrency ceiling → QUEUE (work re-derivable from the queue). The
|
|
393
|
+
// ceiling is PER SOURCE, because a dispatch slot is not the scarce
|
|
394
|
+
// resource — the single Max subscription + host CPU are. Reactive work
|
|
395
|
+
// (inbox / a direct human or agent reply) may use the WHOLE envelope;
|
|
396
|
+
// self-directed backlog is held REACTIVE_RESERVE slots short so a reply
|
|
397
|
+
// can always be GENERATED (10 parallel sessions once starved the
|
|
398
|
+
// quick-reply's claude call into a 60s timeout). A DEGRADED/unfunded seat
|
|
399
|
+
// throttles backlog to DEGRADED_BACKLOG_MAX — a trickle in idle gaps.
|
|
400
|
+
let sourceCeiling;
|
|
401
|
+
if (humanReply) {
|
|
402
|
+
sourceCeiling = effectiveMax;
|
|
403
|
+
} else if (mode === "degraded" && source === "backlog") {
|
|
404
|
+
sourceCeiling = Math.min(effectiveMax, DEGRADED_BACKLOG_MAX);
|
|
405
|
+
} else {
|
|
406
|
+
sourceCeiling = Math.max(1, effectiveMax - REACTIVE_RESERVE);
|
|
353
407
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
408
|
+
if (liveCount >= sourceCeiling) {
|
|
409
|
+
const why = mode === "degraded" && source === "backlog"
|
|
410
|
+
? `budget degraded: self-directed backlog throttled to ${sourceCeiling} (${liveCount} live; inbox unaffected)`
|
|
411
|
+
: throttleCeiling < dMax
|
|
412
|
+
? `at throttled ceiling (${liveCount}/${sourceCeiling}; soft-throttle active)`
|
|
413
|
+
: source === "inbox"
|
|
414
|
+
? `at concurrency ceiling (${liveCount}/${sourceCeiling})`
|
|
415
|
+
: `self-directed work yields ${REACTIVE_RESERVE} slots to the inbox (${liveCount}/${sourceCeiling})`;
|
|
360
416
|
return decide(QUEUE, why);
|
|
361
417
|
}
|
|
362
418
|
|
|
@@ -68,14 +68,16 @@ function deps(over = {}) {
|
|
|
68
68
|
// ---------------------------------------------------------------------------
|
|
69
69
|
|
|
70
70
|
test("dynamicMax sizes by RAM, clamped to min(HARD_MAX, cpus)", () => {
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
//
|
|
74
|
-
assert.equal(dynamicMax({ totalmem:
|
|
75
|
-
//
|
|
76
|
-
assert.equal(dynamicMax({ totalmem:
|
|
77
|
-
//
|
|
78
|
-
assert.equal(dynamicMax({ totalmem:
|
|
71
|
+
// HARD_MAX is 4 (subscription-safe): the single Max subscription, not host RAM,
|
|
72
|
+
// is the binding constraint, so anything with a few GB clamps to 4.
|
|
73
|
+
// 4GB: byRam=floor(4*0.6/0.75)=3 → RAM-bound, below HARD_MAX → 3.
|
|
74
|
+
assert.equal(dynamicMax({ totalmem: 4 * GB, cpus: 8 }), 3);
|
|
75
|
+
// 8GB: byRam=floor(8*0.6/0.75)=6 → clamp to min(4,8)=4 → HARD_MAX-bound.
|
|
76
|
+
assert.equal(dynamicMax({ totalmem: 8 * GB, cpus: 8 }), 4);
|
|
77
|
+
// 16GB with 16 cpus: byRam=12 → clamp to min(4,16)=4.
|
|
78
|
+
assert.equal(dynamicMax({ totalmem: 16 * GB, cpus: 16 }), 4);
|
|
79
|
+
// cpu-bound below HARD_MAX: 16GB with 2 cpus → min(4,2)=2.
|
|
80
|
+
assert.equal(dynamicMax({ totalmem: 16 * GB, cpus: 2 }), 2);
|
|
79
81
|
// Tiny box always yields at least 1.
|
|
80
82
|
assert.equal(dynamicMax({ totalmem: 1 * GB, cpus: 1 }), 1);
|
|
81
83
|
});
|
|
@@ -91,8 +93,8 @@ test("dynamicMax never returns 0 even on a starved box", () => {
|
|
|
91
93
|
test("admit: healthy box with spare capacity → ADMIT", () => {
|
|
92
94
|
const r = admit({ source: "backlog" }, deps());
|
|
93
95
|
assert.equal(r.decision, DECISIONS.ADMIT);
|
|
94
|
-
assert.equal(r.snapshot.dynamicMax,
|
|
95
|
-
assert.equal(r.snapshot.effectiveMax,
|
|
96
|
+
assert.equal(r.snapshot.dynamicMax, 4);
|
|
97
|
+
assert.equal(r.snapshot.effectiveMax, 4);
|
|
96
98
|
});
|
|
97
99
|
|
|
98
100
|
test("admit: inbox on a healthy box → ADMIT", () => {
|
|
@@ -104,9 +106,23 @@ test("admit: inbox on a healthy box → ADMIT", () => {
|
|
|
104
106
|
// QUEUE — concurrency ceiling
|
|
105
107
|
// ---------------------------------------------------------------------------
|
|
106
108
|
|
|
107
|
-
test("admit:
|
|
108
|
-
// effectiveMax =
|
|
109
|
-
|
|
109
|
+
test("admit: backlog stops REACTIVE_RESERVE slots short of the ceiling → QUEUE", () => {
|
|
110
|
+
// effectiveMax = 4; self-directed backlog may use effectiveMax - REACTIVE_RESERVE(2)
|
|
111
|
+
// = 2. With 2 already live, backlog QUEUEs — holding 2 slots for the inbox so a
|
|
112
|
+
// reply can always be GENERATED (subscription + host headroom, not just a slot).
|
|
113
|
+
const r = admit({ source: "backlog" }, deps({ liveClaude: { count: 2, rssMB: 500 } }));
|
|
114
|
+
assert.equal(r.decision, DECISIONS.QUEUE);
|
|
115
|
+
assert.match(r.reason, /yields 2 slots to the inbox/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("admit: inbox may use the whole envelope; the reactive reserve is for it", () => {
|
|
119
|
+
// 2 live (backlog's reserved cap) but INBOX still ADMITs — the reserve is its.
|
|
120
|
+
assert.equal(
|
|
121
|
+
admit({ source: "inbox" }, deps({ liveClaude: { count: 2, rssMB: 500 } })).decision,
|
|
122
|
+
DECISIONS.ADMIT,
|
|
123
|
+
);
|
|
124
|
+
// Only at the full envelope (effectiveMax=4 live) does even the inbox QUEUE.
|
|
125
|
+
const r = admit({ source: "inbox" }, deps({ liveClaude: { count: 4, rssMB: 1000 } }));
|
|
110
126
|
assert.equal(r.decision, DECISIONS.QUEUE);
|
|
111
127
|
assert.match(r.reason, /concurrency ceiling/);
|
|
112
128
|
});
|
|
@@ -125,9 +141,10 @@ test("admit: throttle ceiling clamps effectiveMax below dynamicMax → QUEUE ear
|
|
|
125
141
|
|
|
126
142
|
test("admit: projected RSS over the RAM fraction → QUEUE", () => {
|
|
127
143
|
// 16GB → RAM fraction cap = 16*0.6*1024 ≈ 9830MB. Live RSS 9500MB + 750 > cap.
|
|
128
|
-
// Keep count below
|
|
144
|
+
// Keep count below the reserved backlog ceiling (2) so concurrency passes and
|
|
145
|
+
// RSS is the trigger.
|
|
129
146
|
const r = admit({ source: "backlog" }, deps({
|
|
130
|
-
liveClaude: { count:
|
|
147
|
+
liveClaude: { count: 1, rssMB: 9500 },
|
|
131
148
|
freemem: 16 * GB * 0.5,
|
|
132
149
|
}));
|
|
133
150
|
assert.equal(r.decision, DECISIONS.QUEUE);
|
|
@@ -287,9 +304,19 @@ test("admit reads a real throttle.json from agentRoot and clamps", async () => {
|
|
|
287
304
|
// the budget ladder (banded against the seat's hq-funded envelope)
|
|
288
305
|
// ---------------------------------------------------------------------------
|
|
289
306
|
|
|
290
|
-
test("degraded (>=100%): self-directed backlog
|
|
291
|
-
|
|
292
|
-
|
|
307
|
+
test("degraded (>=100%): self-directed backlog is THROTTLED (not stopped), obligations + inbox continue", () => {
|
|
308
|
+
// Owner instruction (2026-08-25): throttle, don't stop. A degraded/unfunded seat
|
|
309
|
+
// runs DEGRADED_BACKLOG_MAX(1) backlog in idle gaps — with the box idle it ADMITs
|
|
310
|
+
// one so autonomous progress keeps ticking...
|
|
311
|
+
assert.equal(
|
|
312
|
+
admit({ source: "backlog", mode: "degraded" }, deps({ liveClaude: { count: 0, rssMB: 0 } })).decision,
|
|
313
|
+
"ADMIT",
|
|
314
|
+
);
|
|
315
|
+
// ...but with anything already live it QUEUEs — a trickle, never a flood.
|
|
316
|
+
const r = admit({ source: "backlog", mode: "degraded" }, deps({ liveClaude: { count: 1, rssMB: 300 } }));
|
|
317
|
+
assert.equal(r.decision, "QUEUE");
|
|
318
|
+
assert.match(r.reason, /throttled/);
|
|
319
|
+
// An obligation is not discretionary — it degrades, it does not stop.
|
|
293
320
|
assert.equal(admit({ source: "cadence", mode: "degraded" }, deps()).decision, "ADMIT",
|
|
294
321
|
"an obligation is not discretionary — it degrades, it does not stop");
|
|
295
322
|
assert.equal(admit({ source: "inbox", mode: "degraded" }, deps()).decision, "ADMIT");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cohortapp/agent-sdk",
|
|
3
|
-
"version": "2.11.
|
|
3
|
+
"version": "2.11.4",
|
|
4
4
|
"description": "Cohort Agent SDK — autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -384,7 +384,7 @@ function formatItemPrompt(item) {
|
|
|
384
384
|
|
|
385
385
|
// ── Parse LLM response into classification object ───────────────────────────
|
|
386
386
|
|
|
387
|
-
function parseClassification(text) {
|
|
387
|
+
export function parseClassification(text) {
|
|
388
388
|
// Strip markdown code fences if present
|
|
389
389
|
const cleaned = text.replace(/^```(?:json)?\s*/m, "").replace(/\s*```$/m, "").trim();
|
|
390
390
|
let parsed;
|
|
@@ -408,13 +408,24 @@ function parseClassification(text) {
|
|
|
408
408
|
const validModels = ["opus", "sonnet"];
|
|
409
409
|
const validCategories = ["action_required", "fyi", "ignore"];
|
|
410
410
|
|
|
411
|
+
const directedAtAgent = typeof parsed.directed_at_agent === "boolean" ? parsed.directed_at_agent : false;
|
|
412
|
+
let priority = validPriorities.includes(parsed.priority) ? parsed.priority : "normal";
|
|
413
|
+
// A message the classifier is confident is directed AT this agent (a DM or an
|
|
414
|
+
// @mention) is inherently latency-sensitive — a human or a peer agent is
|
|
415
|
+
// waiting on the reply. Floor it at "high" so the dispatcher's priority lane
|
|
416
|
+
// PREEMPTS self-directed backlog for it (dispatcher.isPriorityInbox gates on
|
|
417
|
+
// critical|high). Without this, a peer agent's routine question classified
|
|
418
|
+
// "normal" would queue behind a wall of the seat's own backlog and read as "no
|
|
419
|
+
// reply". Never downgrade critical; never touch an "ignore".
|
|
420
|
+
if (directedAtAgent && priority === "normal") priority = "high";
|
|
421
|
+
|
|
411
422
|
return {
|
|
412
|
-
priority
|
|
423
|
+
priority,
|
|
413
424
|
action: validActions.includes(parsed.action) ? parsed.action : "queue",
|
|
414
425
|
model: validModels.includes(parsed.model) ? parsed.model : "sonnet",
|
|
415
426
|
summary: typeof parsed.summary === "string" ? parsed.summary : "Unclassified message",
|
|
416
427
|
category: validCategories.includes(parsed.category) ? parsed.category : "fyi",
|
|
417
|
-
directed_at_agent:
|
|
428
|
+
directed_at_agent: directedAtAgent, // default false — don't respond unless we're confident the message is for this agent
|
|
418
429
|
// A directly-answerable question gets a quick reply instead of a session +
|
|
419
430
|
// holding ack (see responder.isQuickReply). Default false — when the field
|
|
420
431
|
// is absent or non-boolean, take the session path, never guess "answerable".
|
|
@@ -245,3 +245,22 @@ test("reaction kind short-circuits before any LLM tier (no fetch)", async () =>
|
|
|
245
245
|
assert.equal(out.action, "archive");
|
|
246
246
|
assert.equal(out.directed_at_agent, false);
|
|
247
247
|
});
|
|
248
|
+
|
|
249
|
+
test("parseClassification floors a directed-at-agent message at 'high' so it preempts backlog", () => {
|
|
250
|
+
// A directed DM/mention is latency-sensitive — someone (human OR peer agent)
|
|
251
|
+
// is waiting. Bumping normal→high lets dispatcher.isPriorityInbox PREEMPT
|
|
252
|
+
// self-directed backlog for it, so a peer's routine question isn't starved.
|
|
253
|
+
const directedNormal = classifier.parseClassification(
|
|
254
|
+
'{"priority":"normal","action":"respond","model":"sonnet","summary":"peer asks for status","category":"action_required","directed_at_agent":true,"answerable":false}'
|
|
255
|
+
);
|
|
256
|
+
assert.equal(directedNormal.priority, "high");
|
|
257
|
+
assert.equal(directedNormal.directed_at_agent, true);
|
|
258
|
+
|
|
259
|
+
// Ambient (not directed) work stays normal — no preemption for the firehose.
|
|
260
|
+
const ambient = classifier.parseClassification('{"priority":"normal","action":"archive","directed_at_agent":false}');
|
|
261
|
+
assert.equal(ambient.priority, "normal");
|
|
262
|
+
|
|
263
|
+
// Critical is never downgraded, and an "ignore" is never escalated.
|
|
264
|
+
assert.equal(classifier.parseClassification('{"priority":"critical","directed_at_agent":true}').priority, "critical");
|
|
265
|
+
assert.equal(classifier.parseClassification('{"priority":"ignore","directed_at_agent":true}').priority, "ignore");
|
|
266
|
+
});
|
|
@@ -55,6 +55,7 @@ import { rungById } from "../../lib/execution/route.mjs";
|
|
|
55
55
|
// put an exact string in front of a human with no model in the loop, which is
|
|
56
56
|
// the property the acknowledgement path needed and did not have.
|
|
57
57
|
import { deliver, deliverWithRetry, resolveSlackChannel } from "./deliver.mjs";
|
|
58
|
+
import { startTyping, stopTyping } from "./typing-registry.mjs";
|
|
58
59
|
// The acknowledgement is now COMPOSED, not generated. See assurance.mjs for why
|
|
59
60
|
// (in short: a 60s `claude --print` spawn to write "let me look into it" lost
|
|
60
61
|
// its own race 2 times in 3, and lost it silently).
|
|
@@ -855,6 +856,11 @@ export async function sendQuickResponse(item, classResult, routed = null) {
|
|
|
855
856
|
: null;
|
|
856
857
|
|
|
857
858
|
try {
|
|
859
|
+
// Show "is composing…" on the DM the instant we start generating. The quick
|
|
860
|
+
// path used to be silent — a human saw nothing until the reply landed, or
|
|
861
|
+
// (under load, when generation timed out) nothing at all. Best-effort;
|
|
862
|
+
// cleared in the finally so "composing" never outlives the turn.
|
|
863
|
+
startTyping(item);
|
|
858
864
|
const text = await _generateResponse(item, classResult);
|
|
859
865
|
|
|
860
866
|
// Validate before sending — block replies that violate critical rules
|
|
@@ -951,6 +957,8 @@ export async function sendQuickResponse(item, classResult, routed = null) {
|
|
|
951
957
|
console.error(`[responder] Quick response failed for ${item.sender}:`, err.message);
|
|
952
958
|
logResponse({ type: "quick_response_error", sender: item.sender, error: err.message });
|
|
953
959
|
return { sent: false, text: null, error: err.message };
|
|
960
|
+
} finally {
|
|
961
|
+
try { stopTyping(item); } catch { /* never throw from teardown */ }
|
|
954
962
|
}
|
|
955
963
|
}
|
|
956
964
|
|
|
@@ -114,7 +114,15 @@ export function startTyping(item) {
|
|
|
114
114
|
if (!adapter) return null;
|
|
115
115
|
const source = sourceFromItem(item);
|
|
116
116
|
if (!source || !source.chatId) return null;
|
|
117
|
-
|
|
117
|
+
const key = adapter.startTypingHeartbeat(source);
|
|
118
|
+
// Observability: a silent indicator was exactly the bug — "is it even
|
|
119
|
+
// firing?" was unanswerable from the logs. Emit one line when a heartbeat
|
|
120
|
+
// actually starts (startTypingHeartbeat is idempotent, so this is ~once per
|
|
121
|
+
// turn, not per beat).
|
|
122
|
+
if (key) {
|
|
123
|
+
try { console.log(`[typing] composing… ${service}:${source.chatId}${source.userScope === "dm" ? " (dm)" : ""}`); } catch { /* never throw from logging */ }
|
|
124
|
+
}
|
|
125
|
+
return key;
|
|
118
126
|
} catch {
|
|
119
127
|
return null;
|
|
120
128
|
}
|
|
@@ -59,7 +59,15 @@ async function main() {
|
|
|
59
59
|
// resolveArchetype validates the pair; throws if invalid.
|
|
60
60
|
await resolveArchetype(sel);
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
// A `cadence: continuous` capability (inbox triage, SLA/obligation watch) is a
|
|
63
|
+
// standing REAL-TIME responsibility, NOT a launchd tick: it ships no
|
|
64
|
+
// schedules/triggers/<id>.md, so scheduling it fires every interval, finds no
|
|
65
|
+
// prompt, and spins `skipped_circuit_open` forever, padding the cadence-bus
|
|
66
|
+
// queue. Drop it here so it never becomes a cadence/registry/plist entry; the
|
|
67
|
+
// daemon's real-time loop owns the capability. The plan compiler
|
|
68
|
+
// (lib/plan/compile.mjs §2) applies the SAME skip, so the two producers stay
|
|
69
|
+
// byte-identical (mandate.test.mjs COMPATIBILITY).
|
|
70
|
+
const cadences = (await resolveCadences(sel)).filter((c) => c.cadence !== "continuous");
|
|
63
71
|
|
|
64
72
|
mkdirSync(join(AGENT_ROOT, "config"), { recursive: true });
|
|
65
73
|
writeFileSync(join(AGENT_ROOT, "config", "cadences.yaml"),
|