@cohortapp/agent-sdk 2.4.0 → 2.5.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/bin/maestro.mjs +9 -0
- package/lib/backlog.mjs +35 -0
- package/lib/backlog.test.mjs +36 -0
- package/lib/channels/contract.mjs +1 -0
- package/lib/channels/contract.test.mjs +2 -1
- package/lib/channels/inbox-item.mjs +54 -0
- package/lib/comms/send-gate.mjs +56 -1
- package/lib/comms/send-gate.test.mjs +56 -0
- package/lib/execution/disposition.mjs +62 -2
- package/lib/execution/disposition.test.mjs +54 -0
- package/lib/execution/drive.mjs +1 -1
- package/lib/execution/effects.mjs +282 -24
- package/lib/execution/effects.test.mjs +112 -0
- package/lib/execution/index.mjs +1 -0
- package/lib/execution/intake.mjs +43 -9
- package/lib/execution/intake.test.mjs +46 -0
- package/lib/execution/pipeline.mjs +5 -0
- package/lib/execution/surface-policy.mjs +80 -30
- package/lib/goals/classify.mjs +49 -5
- package/lib/goals/classify.test.mjs +58 -0
- package/lib/goals/collaborate.mjs +131 -17
- package/lib/goals/collaborate.test.mjs +16 -4
- package/lib/goals/loop.mjs +160 -9
- package/lib/goals/loop.test.mjs +129 -3
- package/lib/kpi-sensors.mjs +666 -0
- package/lib/kpi-sensors.test.mjs +275 -0
- package/lib/kpi.mjs +23 -0
- package/lib/mandate/audit.mjs +3 -0
- package/lib/mandate/contract.mjs +277 -0
- package/lib/mandate/contract.test.mjs +185 -0
- package/lib/mandate/derive.mjs +49 -5
- package/lib/mandate/derive.test.mjs +7 -1
- package/lib/mandate/model.mjs +10 -1
- package/lib/mandate/model.test.mjs +22 -3
- package/lib/mandate/refresh.mjs +53 -5
- package/lib/mandate/refresh.test.mjs +83 -1
- package/lib/org/doctor.mjs +66 -0
- package/lib/org/doctor.test.mjs +73 -1
- package/lib/org/inbound/directedness.mjs +119 -1
- package/lib/org/inbound/directedness.test.mjs +67 -0
- package/lib/org/inbound/facts.mjs +132 -9
- package/lib/org/inbound/facts.test.mjs +96 -0
- package/lib/org/inbound/hydrate.mjs +40 -0
- package/lib/org/inbound/index.test.mjs +83 -0
- package/lib/org/inbound/project.mjs +8 -0
- package/lib/org/inbound/surfaces.mjs +20 -0
- package/lib/org/param-contract.mjs +16 -2
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +214 -2
- package/lib/org/protocol.test.mjs +11 -2
- package/lib/org/push.mjs +213 -49
- package/lib/org/push.test.mjs +112 -10
- package/lib/plan/compile.mjs +85 -8
- package/lib/plan/compile.test.mjs +82 -0
- package/lib/plan/emit.test.mjs +6 -1
- package/lib/setup/enroll-from-cohort.mjs +22 -2
- package/lib/setup/enroll-from-cohort.test.mjs +25 -0
- package/lib/setup/sections/mandate.mjs +43 -1
- package/lib/subagents/schema.mjs +14 -2
- package/lib/subagents/schema.test.mjs +22 -0
- package/package.json +1 -1
- package/scripts/ci/check-subagent-frontmatter.mjs +139 -0
- package/scripts/ci/check-subagent-frontmatter.test.mjs +124 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/ci/conformance-org-api.mjs +16 -0
- package/scripts/ci/journey-approval-escalation.mjs +341 -0
- package/scripts/daemon/agent-daemon.mjs +582 -28
- package/scripts/daemon/cadence-handlers.mjs +273 -17
- package/scripts/daemon/cadence-handlers.test.mjs +101 -0
- package/scripts/daemon/execution-ladder.test.mjs +430 -0
- package/scripts/daemon/goal-steward-cadence.test.mjs +69 -0
- package/scripts/daemon/maestro-daemon.mjs +53 -0
- package/scripts/daemon/prompt-builder.mjs +47 -0
- package/scripts/daemon/responder.mjs +70 -3
- package/scripts/poller/imap-client.mjs +20 -1
- package/scripts/poller/inbox-scan-poller.mjs +15 -0
- package/scripts/poller/utils.mjs +51 -0
- package/scripts/setup/generate-capability.mjs +120 -11
- package/scripts/setup/generate-capability.test.mjs +134 -0
- package/scripts/setup/generate-plan.mjs +6 -1
- package/scripts/setup/repair-subagent-frontmatter.mjs +231 -0
|
@@ -59,6 +59,9 @@ import { acquireLock, releaseLock, updateLock, scanStaleLocks, acquireThreadLock
|
|
|
59
59
|
import { markDeferred } from "./inbox-deferral.mjs";
|
|
60
60
|
import { parseQueueItems, rankBacklog, resolveBacklogWeights } from "../../lib/backlog.mjs";
|
|
61
61
|
import { readLatestGaps } from "../../lib/goals/gaps.mjs";
|
|
62
|
+
import { RUNGS } from "../../lib/execution/route.mjs";
|
|
63
|
+
/** rung id → the machinery `routeRung` named for it. Built from the one table. */
|
|
64
|
+
const RUNG_MECHANISM = Object.fromEntries(RUNGS.map((r) => [r.id, r.mechanism]));
|
|
62
65
|
// Org shared-memory write-back (central store via memory.author / knowledge.append
|
|
63
66
|
// RPC). After a daemon turn completes cleanly we distil a one-line record of the
|
|
64
67
|
// work and land it in the org's shared, ACL'd store so the fleet's memory
|
|
@@ -79,6 +82,20 @@ import { mintTraceId, withTrace } from "../../lib/diagnostics/trace.mjs";
|
|
|
79
82
|
import { emitEvent, EVENT_TYPES } from "../../lib/diagnostics/events.mjs";
|
|
80
83
|
import * as counters from "../../lib/diagnostics/counters.mjs";
|
|
81
84
|
import { getHookBus } from "../../lib/hooks/bus.mjs";
|
|
85
|
+
// EXECUTION LADDER (lib/execution/**). The reactive front of the chain:
|
|
86
|
+
// intake (what surface is this, and is it mine) → match (which compiled
|
|
87
|
+
// obligation covers it) → route (which rung: protocol call, skill, plugin,
|
|
88
|
+
// Claude session, workflow, sub-agent team) → drive (run the effect) → journal
|
|
89
|
+
// (why, on disk, before the effect runs). Until now nothing in the daemon
|
|
90
|
+
// entered it; processItem went straight from item_received to the Haiku
|
|
91
|
+
// classifier, so every event was implicitly "react now, rung 3" regardless of
|
|
92
|
+
// surface, obligation or budget. It is now the FRONT of processItem: the
|
|
93
|
+
// classifier path below is the mechanism the ladder's `react` effect drives,
|
|
94
|
+
// not a parallel universe.
|
|
95
|
+
import { processOne } from "../../lib/execution/pipeline.mjs";
|
|
96
|
+
import { defaultEffects, scheduleToQueue } from "../../lib/execution/effects.mjs";
|
|
97
|
+
import { loadReactObligations } from "../../lib/execution/match.mjs";
|
|
98
|
+
import { policyFor } from "../../lib/execution/surface-policy.mjs";
|
|
82
99
|
|
|
83
100
|
// ---------------------------------------------------------------------------
|
|
84
101
|
// Configuration
|
|
@@ -250,6 +267,70 @@ export async function processItem(item, service, deps = {}) {
|
|
|
250
267
|
}
|
|
251
268
|
|
|
252
269
|
async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
|
|
270
|
+
emitEvent({
|
|
271
|
+
type: EVENT_TYPES.ITEM_RECEIVED,
|
|
272
|
+
trace_id,
|
|
273
|
+
attrs: { item_id: itemId, service, sender: item.sender || null, kind: item.kind || "message" },
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Enrich item with channel-type metadata for directed-message detection.
|
|
277
|
+
// HOISTED above the execution ladder: `intake` reads `is_dm` to tell a private
|
|
278
|
+
// room from a space, and a DM that arrived here without it would be classified
|
|
279
|
+
// as an ambient space message and parked. This is the single most
|
|
280
|
+
// regression-prone line in the file.
|
|
281
|
+
enrichItem(item, service);
|
|
282
|
+
|
|
283
|
+
// ── THE EXECUTION LADDER, at the front of the chain ──────────────────────
|
|
284
|
+
// Returns `{ handled }`. `handled:true` means the ladder took responsibility
|
|
285
|
+
// for this item — it ignored it (journalled), queued it, delegated it,
|
|
286
|
+
// escalated it, or drove the classifier path below through its `react` effect.
|
|
287
|
+
// `handled:false` means the ladder could not establish ownership and the
|
|
288
|
+
// legacy classifier path runs exactly as it did before. Never throws.
|
|
289
|
+
const gate = await runExecutionLadder(item, service, itemId, trace_id, deps);
|
|
290
|
+
if (gate.handled) return;
|
|
291
|
+
|
|
292
|
+
return answerItem(item, service, itemId, trace_id, deps, gate.decision);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Attach the channel-type metadata every downstream directedness check reads.
|
|
297
|
+
* Extracted so the execution ladder and the classifier path cannot disagree
|
|
298
|
+
* about what `is_dm` means for the same item.
|
|
299
|
+
*
|
|
300
|
+
* @param {object} item
|
|
301
|
+
* @param {string} service
|
|
302
|
+
* @returns {object} the same item, mutated
|
|
303
|
+
*/
|
|
304
|
+
export function enrichItem(item, service) {
|
|
305
|
+
const channelStr = (item.channel || "").toLowerCase();
|
|
306
|
+
const channelId = item.channel_id || "";
|
|
307
|
+
const isDm = channelStr.startsWith("dm/") || channelId.startsWith("D");
|
|
308
|
+
const myFirstName = loadAgent().firstName || "Agent";
|
|
309
|
+
const agentThreadRegex = new RegExp(`^${myFirstName}:`, "m");
|
|
310
|
+
const agentInThread = !!(item.thread_context && agentThreadRegex.test(item.thread_context));
|
|
311
|
+
item.is_dm = isDm;
|
|
312
|
+
item.agent_in_thread = agentInThread;
|
|
313
|
+
if (!item.service) item.service = service;
|
|
314
|
+
return item;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The classifier → quick-reply-or-session path. This is UNCHANGED behaviour:
|
|
319
|
+
* it is the body `processItem` used to run inline. It is now a named mechanism
|
|
320
|
+
* so the execution ladder's `react_now` disposition can drive it as an effect,
|
|
321
|
+
* and so an item the ladder cannot place still gets exactly today's handling.
|
|
322
|
+
*
|
|
323
|
+
* @param {object} item
|
|
324
|
+
* @param {string} service
|
|
325
|
+
* @param {string} itemId
|
|
326
|
+
* @param {string} trace_id
|
|
327
|
+
* @param {object} deps
|
|
328
|
+
* @param {object|null} [routed] the ladder's decision, when there was one. Its
|
|
329
|
+
* `rung` is honoured: rung 0/1 (protocol call / bounded skill) may answer via
|
|
330
|
+
* the in-process quick-reply responder; rung ≥2 requires a spawned session.
|
|
331
|
+
* @returns {Promise<{ok:boolean, path:string, reason?:string}>}
|
|
332
|
+
*/
|
|
333
|
+
export async function answerItem(item, service, itemId, trace_id, deps = {}, routed = null) {
|
|
253
334
|
// Resolve collaborators: real imports by default (production), injected fakes
|
|
254
335
|
// only when a test supplies them. Names match the imported symbols 1:1.
|
|
255
336
|
const _classify = deps.classify || classifyItem;
|
|
@@ -258,22 +339,13 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
|
|
|
258
339
|
const _sendHoldingMessage = deps.sendHoldingMessage || sendHoldingMessage;
|
|
259
340
|
const _isQuickReply = deps.isQuickReply || isQuickReply;
|
|
260
341
|
|
|
261
|
-
emitEvent({
|
|
262
|
-
type: EVENT_TYPES.ITEM_RECEIVED,
|
|
263
|
-
trace_id,
|
|
264
|
-
attrs: { item_id: itemId, service, sender: item.sender || null, kind: item.kind || "message" },
|
|
265
|
-
});
|
|
266
|
-
|
|
267
342
|
try {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const agentInThread = !!(item.thread_context && agentThreadRegex.test(item.thread_context));
|
|
275
|
-
item.is_dm = isDm;
|
|
276
|
-
item.agent_in_thread = agentInThread;
|
|
343
|
+
const isDm = item.is_dm === true;
|
|
344
|
+
// Carry the routing decision on the item so everything downstream of here —
|
|
345
|
+
// the prompt builder, the spawned session, the responder's log rows — can
|
|
346
|
+
// see which obligation and which rung this turn is serving. Without it the
|
|
347
|
+
// session has no idea it is discharging a planned obligation.
|
|
348
|
+
if (routed) item.execution = routed;
|
|
277
349
|
|
|
278
350
|
// Classify via Haiku API
|
|
279
351
|
const classResult = await _classify({
|
|
@@ -287,7 +359,7 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
|
|
|
287
359
|
subject: item.subject || "",
|
|
288
360
|
is_dm: isDm,
|
|
289
361
|
is_group: !isDm && service === "slack",
|
|
290
|
-
agent_in_thread:
|
|
362
|
+
agent_in_thread: item.agent_in_thread === true,
|
|
291
363
|
kind: item.kind || "message",
|
|
292
364
|
});
|
|
293
365
|
|
|
@@ -305,7 +377,7 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
|
|
|
305
377
|
// Skip ignored items
|
|
306
378
|
if (classResult.category === "ignore" || classResult.action === "ignore") {
|
|
307
379
|
markProcessed(item, service);
|
|
308
|
-
return;
|
|
380
|
+
return { ok: true, path: "ignored", reason: "classifier_ignore" };
|
|
309
381
|
}
|
|
310
382
|
|
|
311
383
|
// DIRECTED-MESSAGE GATE: In channels and group chats, only respond to
|
|
@@ -316,8 +388,6 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
|
|
|
316
388
|
// 1. If LLM says NOT directed → verify with rules (catch missed @mentions, CEO, DMs)
|
|
317
389
|
// 2. If LLM says directed BUT it's a non-DM channel → verify with rules (catch over-eager LLM)
|
|
318
390
|
if (service === "slack") {
|
|
319
|
-
const isDm = item.is_dm || (item.channel || "").startsWith("dm/") || (item.channel_id || "").startsWith("D");
|
|
320
|
-
|
|
321
391
|
if (!classResult.directed_at_agent) {
|
|
322
392
|
// LLM says not directed — double-check with rule-based heuristics
|
|
323
393
|
// to catch clear signals the LLM may have missed (DM, CEO, @mention)
|
|
@@ -335,7 +405,7 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
|
|
|
335
405
|
summary: classResult.summary,
|
|
336
406
|
});
|
|
337
407
|
markProcessed(item, service);
|
|
338
|
-
return;
|
|
408
|
+
return { ok: true, path: "filtered", reason: "not_directed_at_agent" };
|
|
339
409
|
}
|
|
340
410
|
// Rule-based says directed — override LLM
|
|
341
411
|
console.log(`[daemon] Directed-message override: LLM said not directed but rule-based detected direction (${item.sender} in ${item.channel})`);
|
|
@@ -364,7 +434,7 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
|
|
|
364
434
|
summary: classResult.summary,
|
|
365
435
|
});
|
|
366
436
|
markProcessed(item, service);
|
|
367
|
-
return;
|
|
437
|
+
return { ok: true, path: "filtered", reason: "llm_over_eager_directed" };
|
|
368
438
|
}
|
|
369
439
|
}
|
|
370
440
|
}
|
|
@@ -401,7 +471,7 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
|
|
|
401
471
|
deferred: deferred > 0,
|
|
402
472
|
reason: `thread_dedup: ${threadCheck.reason}`,
|
|
403
473
|
});
|
|
404
|
-
return;
|
|
474
|
+
return { ok: true, path: "deferred", reason: `thread_dedup: ${threadCheck.reason}` };
|
|
405
475
|
}
|
|
406
476
|
}
|
|
407
477
|
}
|
|
@@ -428,18 +498,27 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
|
|
|
428
498
|
summary: classResult.summary,
|
|
429
499
|
});
|
|
430
500
|
markProcessed(item, service);
|
|
431
|
-
return;
|
|
501
|
+
return { ok: true, path: "claim_denied", reason: claim.reason };
|
|
432
502
|
}
|
|
433
503
|
}
|
|
434
504
|
|
|
435
505
|
// QUICK REPLY PATH: Direct responses via API + Slack/Gmail posting.
|
|
436
506
|
// No claude --print session needed. ~4-8 seconds total.
|
|
437
|
-
|
|
507
|
+
//
|
|
508
|
+
// The ladder's RUNG is a veto here, never a promotion. `isQuickReply` is a
|
|
509
|
+
// judgement about the classifier's output; the rung is a judgement about the
|
|
510
|
+
// work the matched obligation actually needs. Rung 0/1 (a single protocol
|
|
511
|
+
// call, or a bounded skill) is what the in-process responder IS. Rung ≥2
|
|
512
|
+
// (plugin / session / workflow / team) means the answer needs machinery this
|
|
513
|
+
// path does not have, so a quick reply would be a wrong answer delivered
|
|
514
|
+
// fast. With no routing decision (an item the ladder could not place) this
|
|
515
|
+
// is exactly today's behaviour.
|
|
516
|
+
if (_isQuickReply(classResult, routed)) {
|
|
438
517
|
console.log(`[daemon] Quick reply path for ${item.sender} (${classResult.model})`);
|
|
439
|
-
const result = await _sendQuickResponse(item, classResult);
|
|
518
|
+
const result = await _sendQuickResponse(item, classResult, routed);
|
|
440
519
|
if (result.sent) {
|
|
441
520
|
markProcessed(item, service);
|
|
442
|
-
return;
|
|
521
|
+
return { ok: true, path: "quick_reply", reason: classResult.model };
|
|
443
522
|
}
|
|
444
523
|
// If quick reply failed to send or was blocked by validation, fall through to dispatch a full session
|
|
445
524
|
const reason = result.blocked ? `validation blocked: ${result.issues?.map(i => i.rule).join(", ")}` : "send failed";
|
|
@@ -509,11 +588,450 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
|
|
|
509
588
|
},
|
|
510
589
|
});
|
|
511
590
|
recordSession(false); // spawned
|
|
591
|
+
return { ok: true, path: "session", reason: classResult.action };
|
|
512
592
|
|
|
513
593
|
} catch (err) {
|
|
514
594
|
console.error(`[daemon] Failed to process item ${itemId}:`, err.message);
|
|
515
595
|
recordClassification(false);
|
|
516
596
|
emitEvent({ type: EVENT_TYPES.ERROR, trace_id, attrs: { item_id: itemId, service, stage: "process_item", error: err.message } });
|
|
597
|
+
return { ok: false, path: "error", reason: err.message };
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ---------------------------------------------------------------------------
|
|
602
|
+
// THE EXECUTION LADDER — intake → match → route → drive → journal
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
//
|
|
605
|
+
// Every inbound item now enters here BEFORE the classifier. What that buys, in
|
|
606
|
+
// order of how much it was costing us to not have it:
|
|
607
|
+
//
|
|
608
|
+
// * An `ignore` is a decision with a written reason, not an absence. Ambient
|
|
609
|
+
// channel chatter is dropped without a Haiku call and WITH a journal row
|
|
610
|
+
// saying why. An agent that answers everything is as broken as one that
|
|
611
|
+
// answers nothing, and until now nothing recorded the difference.
|
|
612
|
+
// * A board task assigned to this seat is WORK, not conversation. The surface
|
|
613
|
+
// policy says `latency: queue`, so it lands in state/queues/inbound.yaml —
|
|
614
|
+
// which the backlog sweep below already reads — instead of provoking an
|
|
615
|
+
// immediate reply into a comment thread nobody is watching.
|
|
616
|
+
// * A reply that is genuinely owed is routed to a RUNG before it is answered,
|
|
617
|
+
// so a single protocol call is not billed as a Claude session.
|
|
618
|
+
// * Ping-pong, dedupe and per-actor flood guards run off the journal, so two
|
|
619
|
+
// agents cannot spiral and one loud actor cannot monopolise the seat.
|
|
620
|
+
//
|
|
621
|
+
// FAIL-OPEN, NEVER SILENT. Any failure inside this function returns
|
|
622
|
+
// `{handled:false}` and the item takes exactly the path it took before the
|
|
623
|
+
// ladder existed. Every such fall-through logs the reason.
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Escape hatch. Read per call, not at module load, so an operator can turn the
|
|
627
|
+
* ladder off on a wedged machine without a restart. Taking it logs, loudly,
|
|
628
|
+
* exactly what is being turned off.
|
|
629
|
+
*/
|
|
630
|
+
function ladderEnabled() {
|
|
631
|
+
return process.env.DAEMON_EXECUTION_LADDER !== "0";
|
|
632
|
+
}
|
|
633
|
+
let _ladderDisabledWarned = false;
|
|
634
|
+
|
|
635
|
+
/** REACT obligations from config/plan.yaml, re-read at most every 60s. */
|
|
636
|
+
let _obligationsCache = null;
|
|
637
|
+
const OBLIGATIONS_TTL_MS = 60_000;
|
|
638
|
+
|
|
639
|
+
async function ladderObligations(log) {
|
|
640
|
+
const now = Date.now();
|
|
641
|
+
if (_obligationsCache && now - _obligationsCache.at < OBLIGATIONS_TTL_MS) {
|
|
642
|
+
return _obligationsCache.obligations;
|
|
643
|
+
}
|
|
644
|
+
const loaded = await loadReactObligations(AGENT_REPO_DIR);
|
|
645
|
+
if (loaded.degraded) {
|
|
646
|
+
// Not fatal — an un-adopted agent legitimately has no plan — but it pins the
|
|
647
|
+
// whole reactive lane at rung ≤1, so it must be visible.
|
|
648
|
+
log("warn", `[execution] no compiled REACT obligations (${loaded.reason}) — every event routes at rung ≤1`);
|
|
649
|
+
}
|
|
650
|
+
_obligationsCache = { at: now, obligations: loaded.obligations || [] };
|
|
651
|
+
return _obligationsCache.obligations;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** For tests: drop the cached plan so a fresh config/plan.yaml is re-read. */
|
|
655
|
+
export function _resetLadderCaches() {
|
|
656
|
+
_obligationsCache = null;
|
|
657
|
+
_ladderDisabledWarned = false;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** The seat's org member id — the `me` every directedness join is relative to. */
|
|
661
|
+
function ladderMemberId() {
|
|
662
|
+
const cfg = daemonOrgCfg();
|
|
663
|
+
return (
|
|
664
|
+
process.env.COHORT_AGENT_ID ||
|
|
665
|
+
(cfg && cfg.org && cfg.org.cohort && cfg.org.cohort.agentId) ||
|
|
666
|
+
""
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* The routing NEED estimate for an inbound item, from what is knowable BEFORE
|
|
672
|
+
* the classifier runs.
|
|
673
|
+
*
|
|
674
|
+
* This matters more than it looks. `baseRung` demands `paramsKnown === true` for
|
|
675
|
+
* rung 0, and with no need at all every event lands on "no lower predicate held
|
|
676
|
+
* → bounded session" — rung 3, for a two-line DM. The first live run of this
|
|
677
|
+
* wiring did exactly that: it routed every DM to rung 3, which vetoed the quick
|
|
678
|
+
* reply, which turned a 4-second answer into a spawned Claude session. That is a
|
|
679
|
+
* regression dressed as a routing decision.
|
|
680
|
+
*
|
|
681
|
+
* What the daemon honestly knows pre-classification is the ADDRESSING: for a
|
|
682
|
+
* conversational surface it already has the channel and the thread, so the reply
|
|
683
|
+
* really is one protocol method whose parameters are in hand — the only unknown
|
|
684
|
+
* is the text, and generating the text is what the mechanism at rung 0/1 does.
|
|
685
|
+
* When the target CANNOT be resolved, `paramsKnown` is false and the ladder
|
|
686
|
+
* correctly walks up to a session that can go and find it.
|
|
687
|
+
*
|
|
688
|
+
* Everything else — is this open-ended research, how many steps — is the
|
|
689
|
+
* classifier's judgement, which runs after this and is still consulted by
|
|
690
|
+
* `isQuickReply`. So this estimate is a floor, never a ceiling: the obligation's
|
|
691
|
+
* own declared need and the failure walk-up both raise it from here.
|
|
692
|
+
*
|
|
693
|
+
* @param {object} item
|
|
694
|
+
* @returns {object} a `lib/execution/route.mjs` Need
|
|
695
|
+
*/
|
|
696
|
+
export function ladderNeed(item) {
|
|
697
|
+
const target = item.channel_id || item.thread_id || item.raw_ref || item.channel || null;
|
|
698
|
+
return {
|
|
699
|
+
paramsKnown: !!target,
|
|
700
|
+
steps: 1,
|
|
701
|
+
filesystem: false,
|
|
702
|
+
openEnded: false,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Ignore reasons where the ladder is saying "this is not addressed to me".
|
|
708
|
+
*
|
|
709
|
+
* ONLY these consult the rule-based `isDirectedAtAgent` override. The other
|
|
710
|
+
* ignore reasons — `duplicate`, `own_echo`, `reply_chain_depth`, `actor_flood`,
|
|
711
|
+
* `emergency_stop`, `halt`, `mandate_stale`, `paused` — are GUARDS, deliberate
|
|
712
|
+
* decisions taken about an event the ladder understood perfectly well. Letting
|
|
713
|
+
* the rule check override those would defeat every one of them: a DM is always
|
|
714
|
+
* "directed", so `duplicate` would never hold and the agent would answer the
|
|
715
|
+
* same message on both the push and the poll delivery. The first live run of
|
|
716
|
+
* this wiring did precisely that.
|
|
717
|
+
*/
|
|
718
|
+
const DIRECTEDNESS_IGNORE_REASONS = new Set(["ambient_channel", "not_directed"]);
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Ignore reasons meaning the ladder could not UNDERSTAND the item at all. These
|
|
722
|
+
* always fall through to the classifier path — the ladder has no opinion to
|
|
723
|
+
* enforce, and dropping the item on the strength of its own confusion is how a
|
|
724
|
+
* message gets lost.
|
|
725
|
+
*/
|
|
726
|
+
const UNINTERPRETABLE_IGNORE_REASONS = new Set([
|
|
727
|
+
"not_classified",
|
|
728
|
+
"no_verdict",
|
|
729
|
+
"unknown_surface",
|
|
730
|
+
"unrecognised_shape",
|
|
731
|
+
"intake_error",
|
|
732
|
+
"not_an_object",
|
|
733
|
+
]);
|
|
734
|
+
|
|
735
|
+
/** Cooperative kill switches the disposition layer reads. */
|
|
736
|
+
function ladderRuntime() {
|
|
737
|
+
const runtime = {};
|
|
738
|
+
try {
|
|
739
|
+
if (_existsSync(join(AGENT_REPO_DIR, ".emergency-stop")) || _existsSync(join(AGENT_REPO_DIR, "state", "EMERGENCY_STOP"))) {
|
|
740
|
+
runtime.emergencyStop = true;
|
|
741
|
+
}
|
|
742
|
+
} catch (err) {
|
|
743
|
+
console.warn(`[execution] could not read the emergency-stop marker (${err.message}) — assuming not stopped`);
|
|
744
|
+
}
|
|
745
|
+
return runtime;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Thread arbitration for the ladder.
|
|
750
|
+
*
|
|
751
|
+
* The org lease (`preSendArbitration`) is the right answer for a Cohort space,
|
|
752
|
+
* where several agent seats really can see the same thread. It is the WRONG
|
|
753
|
+
* answer for Slack/Gmail, where this daemon is the only agent on the account and
|
|
754
|
+
* the real duplicate-answer risk is two of its own sessions — which the existing
|
|
755
|
+
* `acquireThreadLock` already handles inside `answerItem`. Taking the org lease
|
|
756
|
+
* here for a Slack thread would also double-lock: the ladder would win it and
|
|
757
|
+
* `answerItem` would then defer its own item.
|
|
758
|
+
*
|
|
759
|
+
* So: org surfaces arbitrate for real, channel surfaces say so out loud and
|
|
760
|
+
* leave it to the lock that already works.
|
|
761
|
+
*/
|
|
762
|
+
async function ladderArbitrate({ decision, candidate, resource }) {
|
|
763
|
+
const family = String((candidate && candidate.family) || "");
|
|
764
|
+
const orgFamilies = ["messaging", "board", "calling", "files", "calendar", "approval", "decision", "escalation", "handoff", "email"];
|
|
765
|
+
if (!orgFamilies.includes(family)) {
|
|
766
|
+
console.log(
|
|
767
|
+
`[execution] ${family || "channel"} thread ${decision.thread} is arbitrated by the daemon's own thread lock, not an org lease`,
|
|
768
|
+
);
|
|
769
|
+
return { ok: true, ref: { mayReply: true, by: "daemon_thread_lock" }, error: null };
|
|
770
|
+
}
|
|
771
|
+
const { preSendArbitration } = await import("../../lib/org/leases.mjs");
|
|
772
|
+
const ids = (candidate && candidate.ids) || {};
|
|
773
|
+
const r = await preSendArbitration({
|
|
774
|
+
platform: "cohort",
|
|
775
|
+
channel: ids.channelId || resource || decision.thread || "",
|
|
776
|
+
thread: ids.threadRootId || ids.threadId || ids.messageId || "",
|
|
777
|
+
agentRoot: AGENT_REPO_DIR,
|
|
778
|
+
});
|
|
779
|
+
return { ok: true, ref: r, error: null };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* The blast-radius gate, with the seat's own self-approval authority applied.
|
|
784
|
+
*
|
|
785
|
+
* `email` and `calendar` carry the `external` action class, so §8 wants an
|
|
786
|
+
* approval card before either runs. Both surfaces ALSO carry `selfApprove:true`
|
|
787
|
+
* — the surface policy's own statement that this seat may be the terminal
|
|
788
|
+
* authority on them. Routing every outbound email through a human card by
|
|
789
|
+
* default would take an agent that has been answering mail unsupervised for
|
|
790
|
+
* months and silently park its replies behind an approval nobody is watching for
|
|
791
|
+
* — the exact "responds to nothing" failure, arrived at through a safety
|
|
792
|
+
* feature.
|
|
793
|
+
*
|
|
794
|
+
* So: a self-approvable surface is self-approved, LOUDLY, naming the classes.
|
|
795
|
+
* A surface the policy says the seat may NOT self-approve (`selfApprove:false`)
|
|
796
|
+
* never reaches here — `decide` escalates it at step 6 instead. Set
|
|
797
|
+
* `DAEMON_LADDER_APPROVALS=1` to route self-approvable surfaces through the real
|
|
798
|
+
* org approval flow as well.
|
|
799
|
+
*/
|
|
800
|
+
async function ladderRequestApproval(args) {
|
|
801
|
+
const { decision, classes } = args;
|
|
802
|
+
const policy = policyFor(decision && decision.surface);
|
|
803
|
+
const human = process.env.DAEMON_LADDER_APPROVALS === "1";
|
|
804
|
+
if (!human && policy.selfApprove === true) {
|
|
805
|
+
console.warn(
|
|
806
|
+
`[execution] ${decision.surface} action is classed ${(classes || []).join("+")} (approval-gated by §8), but the ` +
|
|
807
|
+
`${decision.surface} surface policy grants this seat self-approval → proceeding without a human card. ` +
|
|
808
|
+
"Set DAEMON_LADDER_APPROVALS=1 to require one.",
|
|
809
|
+
);
|
|
810
|
+
counters.bump("execution.self_approved", { surface: decision.surface, classes: (classes || []).join("+") });
|
|
811
|
+
return { ok: true, ref: { granted: true, selfApproved: true, classes }, error: null };
|
|
812
|
+
}
|
|
813
|
+
const real = defaultEffects({ agentRoot: AGENT_REPO_DIR, me: ladderMemberId() }).requestApproval;
|
|
814
|
+
return real(args);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* The `schedule` effect, with ONE carve-out this daemon has to make honestly.
|
|
819
|
+
*
|
|
820
|
+
* A queue row in `state/queues/inbound.yaml` is drained by `sweepBacklog`, which
|
|
821
|
+
* dispatches a session from the row's `next_action` text. That is the right
|
|
822
|
+
* shape for WORK — a task assigned, a call invite, an RSVP, a budget-exhausted
|
|
823
|
+
* deferral: the row says what to do and nothing was lost. It is the WRONG shape
|
|
824
|
+
* for a conversation somebody is waiting on, because the row carries no thread
|
|
825
|
+
* context, no sender and no message body. Batching an email or a board comment
|
|
826
|
+
* through it would answer later AND worse.
|
|
827
|
+
*
|
|
828
|
+
* So `batch_surface` — the only schedule reason that fires on a surface the
|
|
829
|
+
* daemon can already answer live — does not queue. It hands the item back to the
|
|
830
|
+
* classifier path, and says so in the journal (`queued:false`) rather than
|
|
831
|
+
* writing a row that would look drained and never be.
|
|
832
|
+
*
|
|
833
|
+
* THIS IS A SEVERED HOP, NAMED: when the backlog drain learns to re-hydrate the
|
|
834
|
+
* originating inbox item, this carve-out should be deleted and `email` /
|
|
835
|
+
* `task_comment` should batch as their surface policy says they should.
|
|
836
|
+
*/
|
|
837
|
+
async function ladderSchedule({ decision, candidate, item }) {
|
|
838
|
+
if (decision && decision.reason === "batch_surface") {
|
|
839
|
+
console.warn(
|
|
840
|
+
`[execution] ${decision.surface} is a batch surface, but the backlog drain cannot re-hydrate the ` +
|
|
841
|
+
"originating item (no thread context in a queue row) — answering it live instead of queueing. " +
|
|
842
|
+
"Decision journalled; the batching is advisory until the drain carries the item.",
|
|
843
|
+
);
|
|
844
|
+
return { ok: true, ref: { queued: false, handedTo: "classifier_path", surface: decision.surface }, error: null };
|
|
845
|
+
}
|
|
846
|
+
// The candidate carries the entity id and the item carries the subject/body.
|
|
847
|
+
// Without both the row the sweep drains names no thing to act on — which is
|
|
848
|
+
// exactly how a real calendar invite reached a queue row that no session
|
|
849
|
+
// could RSVP. See `scheduleToQueue`.
|
|
850
|
+
return scheduleToQueue(decision, { agentRoot: AGENT_REPO_DIR, candidate, item });
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Run one inbound item through the execution ladder.
|
|
855
|
+
*
|
|
856
|
+
* @param {object} item the poller inbox item, already enriched
|
|
857
|
+
* @param {string} service
|
|
858
|
+
* @param {string} itemId
|
|
859
|
+
* @param {string} trace_id
|
|
860
|
+
* @param {object} [deps] test seams; `deps.processOne` / `deps.answerItem`
|
|
861
|
+
* replace the real pipeline / the real classifier path.
|
|
862
|
+
* @returns {Promise<{handled:boolean, decision:object|null, reason:string, answered:object|null}>}
|
|
863
|
+
*/
|
|
864
|
+
export async function runExecutionLadder(item, service, itemId, trace_id, deps = {}) {
|
|
865
|
+
const _answer = deps.answerItem || answerItem;
|
|
866
|
+
const _processOne = deps.processOne || processOne;
|
|
867
|
+
const _ruleDirected = deps.isDirectedAtAgent || isDirectedAtAgent;
|
|
868
|
+
|
|
869
|
+
if (!ladderEnabled()) {
|
|
870
|
+
if (!_ladderDisabledWarned) {
|
|
871
|
+
_ladderDisabledWarned = true;
|
|
872
|
+
console.warn(
|
|
873
|
+
"[execution] DAEMON_EXECUTION_LADDER=0 — intake/match/route/journal are OFF. " +
|
|
874
|
+
"Every item goes straight to the classifier: no directedness journal, no obligation binding, " +
|
|
875
|
+
"no rung, no queue for board work, and no record of what was ignored or why.",
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
return { handled: false, decision: null, reason: "ladder_disabled", answered: null };
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const log = (level, msg) => {
|
|
882
|
+
if (level === "error") console.error(`[execution] ${msg}`);
|
|
883
|
+
else if (level === "warn") console.warn(`[execution] ${msg}`);
|
|
884
|
+
else if (level === "debug") { /* per-rule match trace — logEvent only */ }
|
|
885
|
+
else console.log(`[execution] ${msg}`);
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
let answered = null;
|
|
889
|
+
try {
|
|
890
|
+
const me = ladderMemberId();
|
|
891
|
+
const obligations = await ladderObligations(log);
|
|
892
|
+
|
|
893
|
+
// `react_now` means: answer it, now, with the machinery the rung names. The
|
|
894
|
+
// daemon's classifier → quick-reply/session path IS that machinery, so the
|
|
895
|
+
// effect drives it rather than duplicating it. This is the hop that was
|
|
896
|
+
// missing: `defaultEffects` documents `react` as caller-supplied and every
|
|
897
|
+
// previous caller supplied nothing, so react_now was decided and dropped.
|
|
898
|
+
const react = async ({ decision }) => {
|
|
899
|
+
answered = await _answer(item, service, itemId, trace_id, deps, decision);
|
|
900
|
+
return {
|
|
901
|
+
ok: answered && answered.ok !== false,
|
|
902
|
+
ref: {
|
|
903
|
+
rung: decision.rung,
|
|
904
|
+
mechanism: decision.mechanism,
|
|
905
|
+
path: answered ? answered.path : null,
|
|
906
|
+
reason: answered ? answered.reason : null,
|
|
907
|
+
},
|
|
908
|
+
error: answered && answered.ok === false ? answered.reason || "answer failed" : null,
|
|
909
|
+
};
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
const effects = {
|
|
913
|
+
...defaultEffects({ agentRoot: AGENT_REPO_DIR, me, react }),
|
|
914
|
+
arbitrate: ladderArbitrate,
|
|
915
|
+
schedule: ladderSchedule,
|
|
916
|
+
requestApproval: ladderRequestApproval,
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
const res = await _processOne({
|
|
920
|
+
item,
|
|
921
|
+
me,
|
|
922
|
+
agentRoot: AGENT_REPO_DIR,
|
|
923
|
+
runtime: ladderRuntime(),
|
|
924
|
+
need: ladderNeed(item),
|
|
925
|
+
obligations,
|
|
926
|
+
effects,
|
|
927
|
+
traceId: trace_id,
|
|
928
|
+
log,
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
const d = res.decision || null;
|
|
932
|
+
if (!d) {
|
|
933
|
+
console.warn(`[execution] ${itemId} produced no decision — falling through to the classifier path`);
|
|
934
|
+
return { handled: false, decision: null, reason: "no_decision", answered: null };
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
logEvent("execution", {
|
|
938
|
+
item_id: itemId,
|
|
939
|
+
service,
|
|
940
|
+
trace_id,
|
|
941
|
+
origin: res.origin,
|
|
942
|
+
surface: d.surface,
|
|
943
|
+
tier: d.tier,
|
|
944
|
+
disposition: d.disposition,
|
|
945
|
+
reason: d.reason,
|
|
946
|
+
rung: d.rung,
|
|
947
|
+
mechanism: d.mechanism,
|
|
948
|
+
obligation_key: res.obligationKey,
|
|
949
|
+
proposed: res.proposed,
|
|
950
|
+
effect: res.effect,
|
|
951
|
+
ok: res.ok,
|
|
952
|
+
degraded: d.degraded,
|
|
953
|
+
why: d.why,
|
|
954
|
+
});
|
|
955
|
+
console.log(
|
|
956
|
+
`[execution] ${itemId}: ${res.origin} → ${d.surface || "?"} → ${d.disposition}` +
|
|
957
|
+
(Number.isFinite(d.rung) ? ` @rung ${d.rung} (${d.mechanism})` : "") +
|
|
958
|
+
` — ${d.reason}` +
|
|
959
|
+
(res.obligationKey ? ` [${res.obligationKey}]` : " [uncovered]"),
|
|
960
|
+
);
|
|
961
|
+
|
|
962
|
+
// ── ignore: a first-class, journalled outcome ─────────────────────────
|
|
963
|
+
if (d.disposition === "ignore") {
|
|
964
|
+
// The ladder did not understand the item. It has no opinion worth
|
|
965
|
+
// enforcing, so the classifier path decides — exactly as before.
|
|
966
|
+
if (UNINTERPRETABLE_IGNORE_REASONS.has(d.reason)) {
|
|
967
|
+
console.warn(
|
|
968
|
+
`[execution] ${itemId} could not be placed by intake (${d.reason}) — no ladder opinion, ` +
|
|
969
|
+
"falling through to the classifier path",
|
|
970
|
+
);
|
|
971
|
+
counters.bump("execution.uninterpretable", { item_id: itemId, reason: d.reason });
|
|
972
|
+
return { handled: false, decision: d, reason: `uninterpretable:${d.reason}`, answered: null };
|
|
973
|
+
}
|
|
974
|
+
// The safety interlock, scoped to the ONE question it is competent to
|
|
975
|
+
// answer: "is this addressed to me?". `isDirectedAtAgent` is the
|
|
976
|
+
// rule-based check the classifier path has always applied, and it knows
|
|
977
|
+
// things intake does not — the CEO privilege flag, the agent's own display
|
|
978
|
+
// name in free text, "this arrived in my mailbox". Where the two disagree
|
|
979
|
+
// about ADDRESSING we do not gag the agent; we fall through, loudly.
|
|
980
|
+
//
|
|
981
|
+
// It is deliberately NOT consulted for the guard reasons. See
|
|
982
|
+
// DIRECTEDNESS_IGNORE_REASONS.
|
|
983
|
+
if (DIRECTEDNESS_IGNORE_REASONS.has(d.reason) && _ruleDirected(item)) {
|
|
984
|
+
console.warn(
|
|
985
|
+
`[execution] directedness disagreement on ${itemId}: ladder says ignore (${d.reason}), ` +
|
|
986
|
+
"rule-based check says this IS directed at me → falling through to the classifier path",
|
|
987
|
+
);
|
|
988
|
+
counters.bump("execution.directedness_disagreement", { item_id: itemId, reason: d.reason });
|
|
989
|
+
return { handled: false, decision: d, reason: "rule_override", answered: null };
|
|
990
|
+
}
|
|
991
|
+
markProcessed(item, service);
|
|
992
|
+
return { handled: true, decision: d, reason: `ignore:${d.reason}`, answered: null };
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// ── react_now: the classifier path already ran inside the effect ──────
|
|
996
|
+
if (answered) {
|
|
997
|
+
return { handled: true, decision: d, reason: `answered:${answered.path}`, answered };
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// ── a batch surface the drain cannot re-hydrate: answer it live ──────
|
|
1001
|
+
if (d.disposition === "schedule" && res.ref && res.ref.handedTo === "classifier_path") {
|
|
1002
|
+
return { handled: false, decision: d, reason: "batch_surface_handed_back", answered: null };
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// ── the effect ran and did NOT reach the classifier path ─────────────
|
|
1006
|
+
// schedule (queued to state/queues/inbound.yaml, which sweepBacklog reads),
|
|
1007
|
+
// delegate (handoff), escalate — or a react_now that a gate stood down
|
|
1008
|
+
// (approval pending, another agent holds the thread). All are complete
|
|
1009
|
+
// decisions with a journal row. The item is done for this pass.
|
|
1010
|
+
if (res.ok !== false) {
|
|
1011
|
+
markProcessed(item, service);
|
|
1012
|
+
return { handled: true, decision: d, reason: `${d.disposition}:${res.effect || "none"}`, answered: null };
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// The effect FAILED. Do not silently drop a directed event because a queue
|
|
1016
|
+
// write or a handoff RPC broke — fall through so the classifier path still
|
|
1017
|
+
// gives the sender an answer.
|
|
1018
|
+
console.error(
|
|
1019
|
+
`[execution] ${d.disposition} effect failed for ${itemId} (${res.error || "unknown"}) — ` +
|
|
1020
|
+
"falling through to the classifier path so the item is not lost",
|
|
1021
|
+
);
|
|
1022
|
+
return { handled: false, decision: d, reason: `effect_failed:${res.error || "unknown"}`, answered: null };
|
|
1023
|
+
} catch (err) {
|
|
1024
|
+
// A bug in the ladder must never cost the user their message.
|
|
1025
|
+
console.error(
|
|
1026
|
+
`[execution] ladder threw on ${itemId} (${err && err.message ? err.message : err}) — ` +
|
|
1027
|
+
"falling through to the classifier path",
|
|
1028
|
+
);
|
|
1029
|
+
emitEvent({
|
|
1030
|
+
type: EVENT_TYPES.ERROR,
|
|
1031
|
+
trace_id,
|
|
1032
|
+
attrs: { item_id: itemId, service, stage: "execution_ladder", error: err && err.message ? err.message : String(err) },
|
|
1033
|
+
});
|
|
1034
|
+
return { handled: false, decision: null, reason: "ladder_threw", answered: null };
|
|
517
1035
|
}
|
|
518
1036
|
}
|
|
519
1037
|
|
|
@@ -897,14 +1415,50 @@ async function sweepBacklog() {
|
|
|
897
1415
|
let dispatched = 0;
|
|
898
1416
|
|
|
899
1417
|
for (const queueItem of toDispatch) {
|
|
1418
|
+
// ── THE RUNG REACHES THE EXECUTOR ────────────────────────────────────
|
|
1419
|
+
// The goal steward routes every self-directed item through
|
|
1420
|
+
// `lib/execution/route.routeRung`, audits the decision, and writes
|
|
1421
|
+
// `rung:` into `state/queues/goals.yaml`. Nothing downstream read it: the
|
|
1422
|
+
// sweep sized every session off `priority` alone, so a rung-5
|
|
1423
|
+
// subagent-fanout and a rung-0 single tool call got the identical plain
|
|
1424
|
+
// Sonnet turn. The router's cost/blast-radius decision died on disk.
|
|
1425
|
+
//
|
|
1426
|
+
// What the drain can honour today is the SIZE of the turn and the framing
|
|
1427
|
+
// the session sees. What it cannot honour is a mechanism that is not "spawn
|
|
1428
|
+
// a session" — that is named below rather than silently ignored.
|
|
1429
|
+
const rung = Number.isFinite(queueItem.rung) ? queueItem.rung : null;
|
|
1430
|
+
const mechanism = rung != null ? RUNG_MECHANISM[rung] || null : null;
|
|
1431
|
+
if (mechanism) queueItem.mechanism = mechanism; // read by buildBacklogContext
|
|
900
1432
|
const classResult = {
|
|
901
1433
|
priority: queueItem.priority,
|
|
902
|
-
|
|
903
|
-
|
|
1434
|
+
// `execute`, NOT `queue`. This row IS the queue; the sweep is what
|
|
1435
|
+
// drains it. `action:"queue"` sent every drained session the inbox-turn
|
|
1436
|
+
// instruction "this item needs tracking but not immediate action" —
|
|
1437
|
+
// so the drain politely re-filed its own backlog forever. See
|
|
1438
|
+
// ACTION_INSTRUCTIONS.execute in prompt-builder.mjs.
|
|
1439
|
+
action: "execute",
|
|
1440
|
+
// Rung 4/5 (workflow-runner, subagent-fanout) are the open-ended, many-step
|
|
1441
|
+
// rungs — those get the stronger model regardless of the declared priority.
|
|
1442
|
+
model: queueItem.priority === "critical" || (rung != null && rung >= 4) ? "opus" : "sonnet",
|
|
904
1443
|
summary: queueItem.title,
|
|
905
1444
|
category: "action_required",
|
|
1445
|
+
rung,
|
|
1446
|
+
mechanism,
|
|
906
1447
|
};
|
|
907
1448
|
|
|
1449
|
+
if (rung != null && rung <= 1) {
|
|
1450
|
+
// SEVERED HOP, NAMED: rungs 0-1 mean "one method call / one skill, params
|
|
1451
|
+
// known" — seconds of work. The backlog drain has exactly one mechanism
|
|
1452
|
+
// (spawn a session), so it overpays for these. Nothing is lost or wrong;
|
|
1453
|
+
// it is slower and dearer than the router asked for, and an operator has
|
|
1454
|
+
// to be able to see that rather than infer it from a bill.
|
|
1455
|
+
console.warn(
|
|
1456
|
+
`[daemon] Backlog "${queueItem.title}" routed to rung ${rung} (${mechanism}), but the backlog drain ` +
|
|
1457
|
+
"can only spawn a session — running it as a session anyway. The rung is honoured for framing and " +
|
|
1458
|
+
"model choice only.",
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
|
|
908
1462
|
const prompt = await buildPrompt(null, classResult, {
|
|
909
1463
|
type: "backlog",
|
|
910
1464
|
queueItem,
|