@cohortapp/agent-sdk 2.18.8 → 2.18.11
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/docs/runbooks/fleet-rollout.md +30 -0
- package/lib/comms/send-gate.mjs +28 -4
- package/lib/daemon/reply-debt.mjs +105 -0
- package/lib/org/inbound/directedness.mjs +60 -0
- package/lib/org/inbound/facts.mjs +29 -4
- package/lib/org/inbound/project.mjs +38 -1
- package/lib/org/inbound/surfaces.mjs +68 -0
- package/lib/runtime/adapter.mjs +39 -4
- package/lib/session/first-run.mjs +213 -16
- package/lib/session/revive.mjs +135 -0
- package/lib/telemetry/alerts.mjs +159 -2
- package/lib/telemetry/collect.mjs +27 -1
- package/package.json +1 -1
- package/policies/ai-disclosure.yaml +47 -0
- package/scripts/daemon/agent-daemon.mjs +261 -14
- package/scripts/daemon/assurance.mjs +5 -0
- package/scripts/daemon/deliver.mjs +79 -11
- package/scripts/daemon/dispatcher.mjs +68 -26
- package/scripts/daemon/lib/session-router.mjs +21 -0
- package/scripts/daemon/responder.mjs +92 -17
- package/scripts/session/supervisor.mjs +70 -13
|
@@ -140,6 +140,8 @@ import { list as listPresence } from "../collective/presence.mjs";
|
|
|
140
140
|
import { billableUsd } from "../cost/ledger-row.mjs";
|
|
141
141
|
import { liveClaudeStats } from "../resource-governor.mjs";
|
|
142
142
|
import { agentFirstName } from "../session/identity.mjs";
|
|
143
|
+
import { snapshot as countersSnapshot } from "../diagnostics/counters.mjs";
|
|
144
|
+
import { replyDebtFromCounters } from "../daemon/reply-debt.mjs";
|
|
143
145
|
|
|
144
146
|
/** Default temperature probe ceiling — used only as a guard in alerts; here we just report. */
|
|
145
147
|
const VALID_STATES = new Set(["active", "idle", "busy", "error", "offline"]);
|
|
@@ -1226,6 +1228,7 @@ function instantMs(v) {
|
|
|
1226
1228
|
const ATTENTION_WHY = {
|
|
1227
1229
|
"no-heartbeat": "up, never beaten",
|
|
1228
1230
|
"stale-heartbeat": "no beat since this launch",
|
|
1231
|
+
"beat-stopped": "beat, then stopped",
|
|
1229
1232
|
};
|
|
1230
1233
|
|
|
1231
1234
|
/**
|
|
@@ -1938,6 +1941,24 @@ export async function collectStatus(o = {}) {
|
|
|
1938
1941
|
if (up) machine.upgrade = up;
|
|
1939
1942
|
} catch { /* no upgrade field */ }
|
|
1940
1943
|
|
|
1944
|
+
// 4c-ii. REPLIES THIS SEAT OWED AND DID NOT SEND — `machine.replyDebt`,
|
|
1945
|
+
// absent on a seat that owes nothing today. Three measured paths end the
|
|
1946
|
+
// reply path in nothing (a session-id collision, a scope the seat does not
|
|
1947
|
+
// hold, a CLI stdout that will not parse as the envelope), and all three left
|
|
1948
|
+
// their only trace in a log file and a JSON on this disk. A silence nobody
|
|
1949
|
+
// off the box can count is indistinguishable from a quiet day, which is how
|
|
1950
|
+
// a seat can stop answering people for three days without anything saying so.
|
|
1951
|
+
//
|
|
1952
|
+
// It rides `machine` for the same reason `frontDoor` does: hq's presence.beat
|
|
1953
|
+
// validator keeps `session` strict and `machine` open, so a new seat-level
|
|
1954
|
+
// fact lands without a lock-step hq deploy. Fail-open like every probe here —
|
|
1955
|
+
// an unreadable counters stream drops the field, never the beat.
|
|
1956
|
+
try {
|
|
1957
|
+
const totals = opt.counterTotals !== undefined ? opt.counterTotals : countersSnapshot({ agentRoot: opt.agentRoot });
|
|
1958
|
+
const debt = replyDebtFromCounters(totals || {}, { date: new Date(nowMs).toISOString().slice(0, 10) });
|
|
1959
|
+
if (debt) machine.replyDebt = debt;
|
|
1960
|
+
} catch { /* no replyDebt field */ }
|
|
1961
|
+
|
|
1941
1962
|
// 4d. WHO is beating — `machine.daemon` {pid, bootAt, uptimeS, sdkVersion,
|
|
1942
1963
|
// dashboardAt, healthy?, healthReason?}. This is what makes fleet version
|
|
1943
1964
|
// drift a QUERY instead of an ssh tour: `machine.sdkVersion` is what is
|
|
@@ -1968,7 +1989,12 @@ export async function collectStatus(o = {}) {
|
|
|
1968
1989
|
if (opt.withAlerts !== false) {
|
|
1969
1990
|
try {
|
|
1970
1991
|
const mod = await import("./alerts.mjs");
|
|
1971
|
-
|
|
1992
|
+
// `nowMs` is handed over, not left to the default: one rule (the front
|
|
1993
|
+
// door) is a DURATION measured against `machine.sessionNote.since`, and
|
|
1994
|
+
// it must be measured against the same instant the rest of this snapshot
|
|
1995
|
+
// was, or a slow collection could date the silence differently from the
|
|
1996
|
+
// note that describes it.
|
|
1997
|
+
status.alerts = mod.deriveAlerts(status, opt.thresholds, nowMs) || [];
|
|
1972
1998
|
} catch { status.alerts = []; }
|
|
1973
1999
|
}
|
|
1974
2000
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cohortapp/agent-sdk",
|
|
3
|
-
"version": "2.18.
|
|
3
|
+
"version": "2.18.11",
|
|
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": {
|
|
@@ -74,6 +74,27 @@ posture:
|
|
|
74
74
|
deployment's own policy requires unprompted disclosure.
|
|
75
75
|
proactive_disclosure: true
|
|
76
76
|
machine_readable_marking: true
|
|
77
|
+
# WHO THE PROACTIVE DUTY IS OWED TO. Art. 50(1) addresses informing a
|
|
78
|
+
# person that they are interacting with an AI system. A colleague inside
|
|
79
|
+
# the deploying organisation — who provisioned the agent, sees an AI badge
|
|
80
|
+
# beside its name in every surface, and is three hundred messages into a
|
|
81
|
+
# working thread with it — is not that person.
|
|
82
|
+
#
|
|
83
|
+
# Unscoped, this duty made every agent open every post with "Quick note
|
|
84
|
+
# before we get into it: I'm <name>, an AI assistant working with
|
|
85
|
+
# <principal>", including mid-thread corrections to their own numbers. That
|
|
86
|
+
# is not a transparency control; it reads as the agent disclaiming its own
|
|
87
|
+
# work, and it tells people something they already know in a way that makes
|
|
88
|
+
# them feel worse about the colleague they are talking to.
|
|
89
|
+
#
|
|
90
|
+
# The email footer learned this and was scoped (channels.email.footer_scope,
|
|
91
|
+
# first_contact_only + external_recipients_only); the identity line never
|
|
92
|
+
# was. It is the same duty and it takes the same scope.
|
|
93
|
+
#
|
|
94
|
+
# The truthfulness invariant is NOT scoped by this and never will be: a
|
|
95
|
+
# sincere question about the agent's nature is answered plainly on any
|
|
96
|
+
# message, internal or external, first or thousandth.
|
|
97
|
+
internal_recipients_exempt: true
|
|
77
98
|
|
|
78
99
|
# Do not volunteer, but never conceal and always answer truthfully (invariant).
|
|
79
100
|
disclose-on-ask:
|
|
@@ -133,10 +154,36 @@ governance:
|
|
|
133
154
|
# own voice (policies/communication-style.md governs tone — prose, no filler).
|
|
134
155
|
# ───────────────────────────────────────────────────────────────────────────
|
|
135
156
|
channels:
|
|
157
|
+
# The organisation's own workspace: channels, DMs, threads, boards. Every
|
|
158
|
+
# member here provisioned these agents or works beside them daily, and the
|
|
159
|
+
# product marks them structurally — an AI badge on the member row, the
|
|
160
|
+
# directory, the mailbox list and the composer. That marking is always on and
|
|
161
|
+
# needs no sentence from the agent.
|
|
162
|
+
cohort:
|
|
163
|
+
identity_line: >
|
|
164
|
+
{agent_name} here — I'm an AI assistant working with {principal_name}.
|
|
165
|
+
marking:
|
|
166
|
+
kind: product_badge
|
|
167
|
+
detail: >
|
|
168
|
+
Cohort labels every AI member in the UI (member rows, directory,
|
|
169
|
+
mailbox list, message headers). The marking is structural and
|
|
170
|
+
unconditional; the identity line is not.
|
|
171
|
+
identity_scope:
|
|
172
|
+
first_contact_only: true
|
|
173
|
+
external_recipients_only: true
|
|
174
|
+
notes: >
|
|
175
|
+
In practice this means the line is effectively never used inside the
|
|
176
|
+
workspace, which is correct: it is for someone meeting the agent for the
|
|
177
|
+
first time from outside, and Cohort has no such surface today. If one is
|
|
178
|
+
added, the line is here and already scoped.
|
|
179
|
+
|
|
136
180
|
slack:
|
|
137
181
|
identity_line: >
|
|
138
182
|
Quick note before we get into it — I'm {agent_name}, an AI assistant
|
|
139
183
|
working with {principal_name}.
|
|
184
|
+
identity_scope:
|
|
185
|
+
first_contact_only: true
|
|
186
|
+
external_recipients_only: true
|
|
140
187
|
marking:
|
|
141
188
|
kind: profile_metadata # bot/app badge + display-name suffix
|
|
142
189
|
detail: "Display name carries an AI marker; profile states the principal."
|
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
// =============================================================================
|
|
18
18
|
|
|
19
19
|
import { resolve, join } from "path";
|
|
20
|
+
import { homedir } from "os";
|
|
21
|
+
import { execFile as _execFileCb } from "child_process";
|
|
22
|
+
import { promisify } from "util";
|
|
23
|
+
const execFileAsync = promisify(_execFileCb);
|
|
20
24
|
import { readdirSync, readFileSync, renameSync, mkdirSync, appendFileSync, writeFileSync, unlinkSync } from "fs";
|
|
21
25
|
import { createRequire } from "module";
|
|
22
26
|
|
|
@@ -63,6 +67,9 @@ import { classifyItem, isDirectedAtAgent } from "./classifier.mjs";
|
|
|
63
67
|
// gets a deterministic classification (no LLM triage), no holding ack, and a
|
|
64
68
|
// per-seat jitter so thirteen answers do not land in the same second.
|
|
65
69
|
import { isRollCallItem, rollCallJitterMs } from "../../lib/org/inbound/broadcast.mjs";
|
|
70
|
+
import { isRoomSurface } from "../../lib/org/inbound/surfaces.mjs";
|
|
71
|
+
import { isMembershipReason } from "../../lib/org/inbound/directedness.mjs";
|
|
72
|
+
import { cohortSurfaceOf, cohortSurfaceLabel, cohortSurfaceIsDeclared } from "./deliver.mjs";
|
|
66
73
|
import { itemCollectiveIntent } from "../../lib/org/inbound/collective.mjs";
|
|
67
74
|
import { dispatch, getStatus, availableSlots, canDispatchBacklog, resetActiveSessions, yieldBacklogForReactive, linkActiveSessionBoard } from "./dispatcher.mjs";
|
|
68
75
|
import { boardItemRefFor } from "../../lib/session/current-work.mjs";
|
|
@@ -132,6 +139,9 @@ import { boardPriority } from "./board-mirror.mjs";
|
|
|
132
139
|
import { mintTraceId, withTrace } from "../../lib/diagnostics/trace.mjs";
|
|
133
140
|
import { emitEvent, EVENT_TYPES } from "../../lib/diagnostics/events.mjs";
|
|
134
141
|
import * as counters from "../../lib/diagnostics/counters.mjs";
|
|
142
|
+
// Replies this seat owed and did not send, counted where a human can see them
|
|
143
|
+
// (the presence beat) rather than only in a file on this disk.
|
|
144
|
+
import { REPLY_DEBT_COUNTERS, isScopeFault } from "../../lib/daemon/reply-debt.mjs";
|
|
135
145
|
import { getHookBus } from "../../lib/hooks/bus.mjs";
|
|
136
146
|
// EXECUTION LADDER (lib/execution/**). The reactive front of the chain:
|
|
137
147
|
// intake (what surface is this, and is it mine) → match (which compiled
|
|
@@ -149,7 +159,8 @@ import { processOne } from "../../lib/execution/pipeline.mjs";
|
|
|
149
159
|
// of spawning `claude --print`; not live → today's dispatch, unchanged. The
|
|
150
160
|
// gate is pure (lib/session/frontdoor.mjs); the assurance sweep reopens a
|
|
151
161
|
// session claim that was neither replied nor done within 20 min.
|
|
152
|
-
import {
|
|
162
|
+
import { shouldReviveFrontDoor, reviveCommand, sessionJobLabelOnDisk } from "../../lib/session/revive.mjs";
|
|
163
|
+
import { makeFrontDoorGate, sessionLiveFromHeartbeat } from "../../lib/session/frontdoor.mjs";
|
|
153
164
|
import { CADENCE_REGISTRY } from "./cadence-handlers.mjs";
|
|
154
165
|
import { sweepSessionInboxForDaemon } from "../../lib/session/inbox-claims.mjs";
|
|
155
166
|
import { defaultEffects, scheduleToQueue } from "../../lib/execution/effects.mjs";
|
|
@@ -263,11 +274,60 @@ async function poll() {
|
|
|
263
274
|
* on the 60s full poll. Returns the count of new items processed. Never throws.
|
|
264
275
|
*/
|
|
265
276
|
const _frontDoorGate = makeFrontDoorGate({ agentRoot: AGENT_REPO_DIR });
|
|
277
|
+
|
|
278
|
+
// ── REVIVING A SHUT FRONT DOOR ───────────────────────────────────────────────
|
|
279
|
+
//
|
|
280
|
+
// The daemon already reads the front-door state every poll to decide who owns
|
|
281
|
+
// the inbox. It is therefore the one process that watches a session it does not
|
|
282
|
+
// live inside — and that is exactly what a wedged front door needs.
|
|
283
|
+
//
|
|
284
|
+
// The supervisor's own watchdog (2.18.7) cannot help when the supervisor is
|
|
285
|
+
// itself parked in its launch probe waiting on a session that will never report
|
|
286
|
+
// ready: James Kirkland sat that way for 3.5 days and Isla Roselli for 15,
|
|
287
|
+
// through several SDK upgrades, because installing new code does not restart a
|
|
288
|
+
// stuck process. The hourly autoupdate job restarts it (2.18.8) but only once
|
|
289
|
+
// an hour and only on the runs that reach that far. This closes it to a minute.
|
|
290
|
+
//
|
|
291
|
+
// State is per-process and deliberately not persisted: a daemon restart is
|
|
292
|
+
// itself a change of circumstances, and a fresh budget after one is the answer
|
|
293
|
+
// we want rather than a stale count carried across it.
|
|
294
|
+
const _revive = { attempts: 0, lastAt: null };
|
|
295
|
+
async function reviveFrontDoorIfShut(state, deps = {}) {
|
|
296
|
+
const now = deps.now ? deps.now() : Date.now();
|
|
297
|
+
const live = sessionLiveFromHeartbeat(state && state.heartbeat, { now });
|
|
298
|
+
const verdict = shouldReviveFrontDoor({
|
|
299
|
+
frontDoor: state && state.frontDoor,
|
|
300
|
+
sessionLive: state && state.sessionLive,
|
|
301
|
+
silentMs: live.ageMs,
|
|
302
|
+
attempts: _revive.attempts,
|
|
303
|
+
lastAttemptAt: _revive.lastAt,
|
|
304
|
+
now,
|
|
305
|
+
reviveAfterMs: Number(process.env.MAESTRO_SESSION_STALE_S || 0) * 1000 || undefined,
|
|
306
|
+
});
|
|
307
|
+
if (!verdict.revive) return verdict;
|
|
308
|
+
const label = sessionJobLabelOnDisk(readdirSync, homedir());
|
|
309
|
+
if (!label) return { revive: false, reason: "no-session-job" };
|
|
310
|
+
_revive.attempts += 1;
|
|
311
|
+
_revive.lastAt = now;
|
|
312
|
+
const cmd = reviveCommand(label, typeof process.getuid === "function" ? process.getuid() : 0);
|
|
313
|
+
console.error(`[daemon] front door shut — ${verdict.reason}; restarting ${label}`);
|
|
314
|
+
try {
|
|
315
|
+
await (deps.execFile || execFileAsync)(cmd.file, cmd.args);
|
|
316
|
+
console.error(`[daemon] ${label} restarted; a fresh session clears whatever the old one was sitting on`);
|
|
317
|
+
} catch (err) {
|
|
318
|
+
console.error(`[daemon] could not restart ${label}: ${err && err.message ? err.message : err} — this seat needs a person`);
|
|
319
|
+
}
|
|
320
|
+
return verdict;
|
|
321
|
+
}
|
|
322
|
+
|
|
266
323
|
async function pollService(svc) {
|
|
267
324
|
try {
|
|
268
325
|
// Front door: a live main session owns this service's inbox — leave it.
|
|
269
326
|
const fd = _frontDoorGate.check(svc.name);
|
|
270
327
|
if (fd.changed) console.log(`[daemon] front door for ${svc.name}: ${fd.dispatch ? "daemon dispatches" : "main session owns intake"} (${fd.reason})`);
|
|
328
|
+
// A door that is shut is not a door that owns anything. Ask on every poll,
|
|
329
|
+
// cheap and bounded: the common answer is "answering" off a fresh beat.
|
|
330
|
+
if (svc.name === "cohort") await reviveFrontDoorIfShut(fd.state).catch(() => {});
|
|
271
331
|
if (!fd.dispatch) return 0;
|
|
272
332
|
const result = await svc.fn();
|
|
273
333
|
// Dedup by raw_ref first: the same Slack message arrives via multiple paths
|
|
@@ -499,18 +559,41 @@ function claudeAvailable() {
|
|
|
499
559
|
* `electedMe:false`. A degraded election must never fall back to respond-to-all —
|
|
500
560
|
* that is the storm this whole change exists to end.
|
|
501
561
|
*
|
|
562
|
+
* ── WHAT A REASON MEANS, AND WHY `decided` IS ON THE VERDICT ────────────────
|
|
563
|
+
* Every path below that is not an election result ends in silence too, because
|
|
564
|
+
* the fail-safe is inverted — but silence-because-hq-picked-someone-else and
|
|
565
|
+
* silence-because-the-election-could-not-run are different events, and for
|
|
566
|
+
* months the caller printed both as "not elected to respond". An operator
|
|
567
|
+
* reading that log saw a decision where there had been an outage, or a missing
|
|
568
|
+
* id, or an item that was never a message. So the verdict now carries `decided`:
|
|
569
|
+
* TRUE only when hq actually ran the election and named responders. The caller
|
|
570
|
+
* logs the two cases in different words.
|
|
571
|
+
*
|
|
502
572
|
* @param {object} item the (cohort) inbox item
|
|
503
573
|
* @param {object} [deps] { electResponder, orgCfg } injectable seams for tests
|
|
504
|
-
* @returns {Promise<{electedMe:boolean, reason:string, election?:string, mode?:string}>}
|
|
574
|
+
* @returns {Promise<{electedMe:boolean, decided:boolean, reason:string, election?:string, mode?:string}>}
|
|
505
575
|
*/
|
|
506
576
|
async function electResponderVerdict(item, deps = {}) {
|
|
507
577
|
const impl = deps.electResponder || electResponder;
|
|
508
578
|
try {
|
|
509
579
|
const cfg = deps.orgCfg !== undefined ? deps.orgCfg : daemonOrgCfg();
|
|
510
|
-
if (!orgEnabled(cfg))
|
|
580
|
+
if (!orgEnabled(cfg)) {
|
|
581
|
+
return { electedMe: false, decided: false, reason: "org integration disabled — no roster to elect against" };
|
|
582
|
+
}
|
|
511
583
|
const channelId = cohortChannelId(item);
|
|
512
584
|
const messageId = cohortMessageId(item);
|
|
513
|
-
|
|
585
|
+
// A ROOM ITEM MISSING ITS IDS IS A DEFECT, AND THE LOG MUST SAY SO. These
|
|
586
|
+
// two used to share the single reason "no-message-id", which was wrong on
|
|
587
|
+
// both counts: it named the message when the CHANNEL was the missing half,
|
|
588
|
+
// and it was the reason 23 doc comments printed in a day — items that have
|
|
589
|
+
// no channel id because they are not room traffic and never should have
|
|
590
|
+
// reached this call at all. That class is now excluded by the caller's
|
|
591
|
+
// surface gate, so anything landing here genuinely is a room item whose
|
|
592
|
+
// projection lost an id.
|
|
593
|
+
if (!channelId || !messageId) {
|
|
594
|
+
const missing = !channelId && !messageId ? "channel id and message id" : (!channelId ? "channel id" : "message id");
|
|
595
|
+
return { electedMe: false, decided: false, reason: `room item carries no ${missing} — the election cannot be asked` };
|
|
596
|
+
}
|
|
514
597
|
const conn = configFromAgent(cfg);
|
|
515
598
|
const res = await impl(
|
|
516
599
|
{ messageId, channelId },
|
|
@@ -518,9 +601,9 @@ async function electResponderVerdict(item, deps = {}) {
|
|
|
518
601
|
);
|
|
519
602
|
if (!res || res.error) {
|
|
520
603
|
const reason = res && res.error
|
|
521
|
-
?
|
|
522
|
-
: "no
|
|
523
|
-
return { electedMe: false, reason: reason.slice(0, 200) };
|
|
604
|
+
? `hq refused the election (${res.error.code || "error"}: ${res.error.message || ""})`
|
|
605
|
+
: "hq returned no election frame";
|
|
606
|
+
return { electedMe: false, decided: false, reason: reason.slice(0, 200) };
|
|
524
607
|
}
|
|
525
608
|
const out = res.result !== undefined ? res.result : res;
|
|
526
609
|
const responders = Array.isArray(out && out.responders) ? out.responders : [];
|
|
@@ -535,14 +618,40 @@ async function electResponderVerdict(item, deps = {}) {
|
|
|
535
618
|
const electedMe =
|
|
536
619
|
!!mySlug &&
|
|
537
620
|
responders.some((r) => String((r && r.slug) || "").trim().toLowerCase() === mySlug);
|
|
621
|
+
// NO SLUG ON RECORD is not an election result: hq may well have elected
|
|
622
|
+
// somebody, this seat simply cannot tell whether it was itself. Reporting it
|
|
623
|
+
// as "hq elected another seat" would be an assertion nothing checked.
|
|
624
|
+
if (!mySlug) {
|
|
625
|
+
return {
|
|
626
|
+
electedMe: false,
|
|
627
|
+
decided: false,
|
|
628
|
+
reason: "this seat has no org member slug on record — cannot tell whether it was elected",
|
|
629
|
+
election: out && out.election,
|
|
630
|
+
mode: out && out.mode,
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
const others = responders
|
|
634
|
+
.map((r) => String((r && r.slug) || "").trim())
|
|
635
|
+
.filter(Boolean)
|
|
636
|
+
.slice(0, 5)
|
|
637
|
+
.join(", ");
|
|
538
638
|
return {
|
|
539
639
|
electedMe,
|
|
540
|
-
|
|
640
|
+
decided: true,
|
|
641
|
+
reason: electedMe
|
|
642
|
+
? "hq elected this seat"
|
|
643
|
+
: others
|
|
644
|
+
? `hq elected ${others}`
|
|
645
|
+
: "hq elected nobody",
|
|
541
646
|
election: out && out.election,
|
|
542
647
|
mode: out && out.mode,
|
|
543
648
|
};
|
|
544
649
|
} catch (err) {
|
|
545
|
-
return {
|
|
650
|
+
return {
|
|
651
|
+
electedMe: false,
|
|
652
|
+
decided: false,
|
|
653
|
+
reason: `the election call threw (${err && err.message ? err.message : String(err)})`.slice(0, 200),
|
|
654
|
+
};
|
|
546
655
|
}
|
|
547
656
|
}
|
|
548
657
|
|
|
@@ -802,25 +911,136 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
|
|
|
802
911
|
// twelve seats silent on a message that named all thirteen. The inbound
|
|
803
912
|
// join proved the address (human author, my room, collective marker), so
|
|
804
913
|
// this is not ambient traffic and there is nothing to hold an election over.
|
|
914
|
+
//
|
|
915
|
+
// AND IT NEEDS A ROOM. `messaging.electResponder` takes
|
|
916
|
+
// `{messageId, channelId}`, loads that Message row and that channel's AI
|
|
917
|
+
// roster, and picks one of the seats IN THE ROOM. A document comment, a
|
|
918
|
+
// board comment, an approval or a decision names no channel and has no room
|
|
919
|
+
// roster — there is nobody to elect between. Asking the messaging election
|
|
920
|
+
// about one produced `no-message-id`, and because this gate's failure mode
|
|
921
|
+
// is inverted to fail-SILENT, "this is not a message" came out as "stay
|
|
922
|
+
// quiet": 23 doc comments dropped on this seat in a single day, each one
|
|
923
|
+
// logged as a decision not to answer a message.
|
|
924
|
+
//
|
|
925
|
+
// TWO SEPARATE QUESTIONS, AND THEY MUST NOT BE COLLAPSED INTO ONE. "Is this
|
|
926
|
+
// surface room traffic?" decides whether an election is the RIGHT mechanism;
|
|
927
|
+
// "was the address ambient?" decides whether one is NEEDED; "does the item
|
|
928
|
+
// name a channel?" decides whether one can be ASKED. A personal address on a
|
|
929
|
+
// non-room surface (I own the doc, I am the assignee, the approval is on my
|
|
930
|
+
// desk) skips the election because there is nothing to arbitrate. An AMBIENT
|
|
931
|
+
// address on a non-room surface still wants one — and gets one whenever the
|
|
932
|
+
// item names a room. `isRoomSurface` answers TRUE for an unknown name, so a
|
|
933
|
+
// new ambient channel surface keeps the storm gate by default.
|
|
805
934
|
const rollCall = isRollCallItem(item);
|
|
806
|
-
|
|
935
|
+
const surface = service === "cohort" ? cohortSurfaceOf(item) : "";
|
|
936
|
+
// The name a PERSON reads. `cohortSurfaceOf` answers for ROUTING, and its
|
|
937
|
+
// fallback for a legacy `cohort:<channelId>:<messageId>` raw_ref (or a bare
|
|
938
|
+
// `kind:"message"`, which is what `lib/org/messaging.mjs` stamps) is "dm" —
|
|
939
|
+
// correct as a route, a lie as a label, and it printed
|
|
940
|
+
// `Election on dm in channel/roadmap` for an ambient PUBLIC-channel message
|
|
941
|
+
// in this very lane's test output. `cohortSurfaceLabel` never guesses.
|
|
942
|
+
const surfaceLabel = service === "cohort" ? cohortSurfaceLabel(item) : "";
|
|
943
|
+
const surfaceDeclared = service === "cohort" ? cohortSurfaceIsDeclared(item) : false;
|
|
944
|
+
// THE ADDRESS THE SERVER PROVED OUTRANKS THE LOCAL PROSE HEURISTIC. The
|
|
945
|
+
// comment above says an @mention or a named address "skips the election
|
|
946
|
+
// entirely (`isDirectedAtAgent` already decided)" — but `isDirectedAtAgent`
|
|
947
|
+
// is a SLACK-shaped rule: it matches the seat's configured first name and
|
|
948
|
+
// Slack member id against the message prose, and knows nothing about a
|
|
949
|
+
// Cohort mention row. A Cohort @mention whose prose spells the seat
|
|
950
|
+
// differently (a display name, a surname, a slug) therefore read as
|
|
951
|
+
// UNDIRECTED and went to an election — one surface's gate judging another
|
|
952
|
+
// surface's item, which is the same shape as the doc-comment bug above.
|
|
953
|
+
// `priority_signals.mentions_agent` is the ingest layer's real verdict,
|
|
954
|
+
// stamped by `lib/org/inbound/project.mjs` only for a genuine dm / mention /
|
|
955
|
+
// named / direct address, so it is the one to trust here.
|
|
956
|
+
const provenAddress = service === "cohort" && item.priority_signals && item.priority_signals.mentions_agent === true;
|
|
957
|
+
const undirectedCohort =
|
|
958
|
+
service === "cohort" && !isDm && !rollCall && !provenAddress && !isDirectedAtAgent(item);
|
|
959
|
+
// AMBIENT vs PERSONAL, and whether an election can be ASKED at all. Three
|
|
960
|
+
// facts, kept separate because they answer different questions:
|
|
961
|
+
//
|
|
962
|
+
// `nonRoom` — the surface's reply is not a message posted into a room.
|
|
963
|
+
// `ambient` — the address was proved by MEMBERSHIP or VISIBILITY
|
|
964
|
+
// (`channel`, `participant`, `shared`), so every seat that
|
|
965
|
+
// shares the room or the share holds an equal claim. That is
|
|
966
|
+
// what an election is for.
|
|
967
|
+
// `roomToArbitrate` — the item names a real Cohort channel, so
|
|
968
|
+
// `messaging.electResponder` has a roster to elect from.
|
|
969
|
+
//
|
|
970
|
+
// ONLY THE COMBINATION `nonRoom && ambient && !roomToArbitrate` IS A DEAD
|
|
971
|
+
// END. An ambient non-room item that DOES name a room still goes to the
|
|
972
|
+
// election, exactly as it did before this change — a comment on a
|
|
973
|
+
// chat-attached file (`resolveChatFile` → `yes("file_comment","channel")`)
|
|
974
|
+
// carries its channel id, so hq can arbitrate the room's roster and one
|
|
975
|
+
// seat can answer. Excluding it unconditionally would have made that class
|
|
976
|
+
// permanently unanswerable by anybody, which is a behaviour change this
|
|
977
|
+
// lane never claimed and does not want: if hq cannot resolve the comment id
|
|
978
|
+
// as a Message the election errors, the inverted fail-safe fires, and the
|
|
979
|
+
// silence is the same one — so asking costs nothing and can only turn a
|
|
980
|
+
// never into an answer.
|
|
981
|
+
const nonRoom = undirectedCohort && !isRoomSurface(surface);
|
|
982
|
+
const ambient = undirectedCohort && isMembershipReason(item.direct_reason);
|
|
983
|
+
const roomToArbitrate = Boolean(cohortChannelId(item));
|
|
984
|
+
if (nonRoom && ambient && !roomToArbitrate) {
|
|
985
|
+
// AMBIENT, AND NO MECHANISM CAN REACH IT. A doc comment is addressed to
|
|
986
|
+
// this seat only because the file is SHARED with it — a visibility fact a
|
|
987
|
+
// whole channel can hold at once — but the reply is an RPC against a file
|
|
988
|
+
// id, there is no Message row and no channel id, and
|
|
989
|
+
// `messaging.electResponder` has nothing to be asked about. So the seat
|
|
990
|
+
// stays silent, and the log says THAT: a missing mechanism, not a
|
|
991
|
+
// decision. Arbitration for ambient non-room surfaces is a real gap; it
|
|
992
|
+
// needs a server-side election keyed on the entity, not a local guess.
|
|
993
|
+
console.log(`[daemon] No arbitration exists for an ambient ${surfaceLabel} item (addressed by ${item.direct_reason || "membership"}, it names no room, and the responder election is messaging-only) — staying silent (gap, not a decision; from ${item.sender})`);
|
|
994
|
+
logEvent("classifications", {
|
|
995
|
+
item_id: itemId,
|
|
996
|
+
sender: item.sender,
|
|
997
|
+
service,
|
|
998
|
+
surface,
|
|
999
|
+
surface_declared: surfaceDeclared,
|
|
1000
|
+
skipped: true,
|
|
1001
|
+
reason: `no_arbitration_for_ambient_surface: ${surface}`,
|
|
1002
|
+
direct_reason: item.direct_reason || null,
|
|
1003
|
+
summary: classResult.summary,
|
|
1004
|
+
});
|
|
1005
|
+
markProcessed(item, service);
|
|
1006
|
+
return { ok: true, path: "filtered", reason: "no_arbitration_for_ambient_surface" };
|
|
1007
|
+
} else if (nonRoom && !ambient) {
|
|
1008
|
+
console.log(`[daemon] No election on a ${surfaceLabel} item — it is not room traffic and the inbound join already proved it is addressed here (${item.direct_reason || "addressed"}); handling it on its own surface (from ${item.sender})`);
|
|
1009
|
+
} else if (undirectedCohort) {
|
|
807
1010
|
const verdict = await _electResponderVerdict(item);
|
|
808
1011
|
if (!verdict.electedMe) {
|
|
809
|
-
|
|
1012
|
+
// TWO DIFFERENT EVENTS, TWO DIFFERENT SENTENCES. `decided` is true only
|
|
1013
|
+
// when hq ran the election and named responders; everything else is the
|
|
1014
|
+
// fail-safe firing on an outage or a malformed item, and calling that a
|
|
1015
|
+
// decision is how an outage reads as normal operation in the log.
|
|
1016
|
+
console.log(
|
|
1017
|
+
verdict.decided
|
|
1018
|
+
? `[daemon] Election on ${surfaceLabel} in ${item.channel}: ${verdict.reason} — this seat stays silent (from ${item.sender})`
|
|
1019
|
+
: `[daemon] Election on ${surfaceLabel} in ${item.channel} could not be decided: ${verdict.reason} — staying silent (fail-safe, not a decision; from ${item.sender})`,
|
|
1020
|
+
);
|
|
810
1021
|
logEvent("classifications", {
|
|
811
1022
|
item_id: itemId,
|
|
812
1023
|
sender: item.sender,
|
|
813
1024
|
service,
|
|
1025
|
+
surface,
|
|
1026
|
+
surface_declared: surfaceDeclared,
|
|
814
1027
|
skipped: true,
|
|
815
|
-
reason:
|
|
1028
|
+
reason: verdict.decided
|
|
1029
|
+
? `election_elected_another: ${verdict.reason}`
|
|
1030
|
+
: `election_undecided: ${verdict.reason}`,
|
|
1031
|
+
election_decided: verdict.decided === true,
|
|
816
1032
|
election: verdict.election || null,
|
|
817
1033
|
mode: verdict.mode || null,
|
|
818
1034
|
summary: classResult.summary,
|
|
819
1035
|
});
|
|
820
1036
|
markProcessed(item, service);
|
|
821
|
-
return {
|
|
1037
|
+
return {
|
|
1038
|
+
ok: true,
|
|
1039
|
+
path: "filtered",
|
|
1040
|
+
reason: verdict.decided ? "election_elected_another" : "election_undecided",
|
|
1041
|
+
};
|
|
822
1042
|
}
|
|
823
|
-
console.log(`[daemon] Election
|
|
1043
|
+
console.log(`[daemon] Election on ${surfaceLabel} in ${item.channel}: ${verdict.reason} (${verdict.election || "elected"}, mode=${verdict.mode || "?"}) — answering ${item.sender}`);
|
|
824
1044
|
}
|
|
825
1045
|
|
|
826
1046
|
// DIRECTED-MESSAGE GATE: In channels and group chats, only respond to
|
|
@@ -1046,6 +1266,32 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
|
|
|
1046
1266
|
// the forbidden one); the escalation is the trace that a human must act.
|
|
1047
1267
|
if (result.permanent) {
|
|
1048
1268
|
const cause = result.code || result.error || "permanent send failure";
|
|
1269
|
+
// A SCOPE REFUSAL IS A CONFIGURATION FAULT AND SOMEBODY ELSE'S TO FIX.
|
|
1270
|
+
// Everything below this line is correct and stays — the ask is not
|
|
1271
|
+
// lost, the obligation is durable, an operator can sweep
|
|
1272
|
+
// state/obligations/needs-attention. But all of it is on THIS DISK. The
|
|
1273
|
+
// measured case (2026-09-22, doc comment on a file this seat may not
|
|
1274
|
+
// comment on) produced three log lines and one JSON file, and nothing
|
|
1275
|
+
// off the box ever said the seat was missing a scope. A person would
|
|
1276
|
+
// have to already suspect this seat to go and look, which is the same
|
|
1277
|
+
// silence in a nicer folder.
|
|
1278
|
+
//
|
|
1279
|
+
// So a scope fault is also COUNTED, and the count rides the presence
|
|
1280
|
+
// beat (lib/telemetry/collect → machine.replyDebt → the
|
|
1281
|
+
// `seat_missing_scope` alert). Counting rather than sending: the only
|
|
1282
|
+
// channel we had was the forbidden one, and the seat cannot know which
|
|
1283
|
+
// other room would reach the right human. What it CAN do is stop being
|
|
1284
|
+
// the only thing that knows.
|
|
1285
|
+
//
|
|
1286
|
+
// Falling through to a session is still refused, for the reason below:
|
|
1287
|
+
// a denied session runs autonomously and improvises unrelated work into
|
|
1288
|
+
// a channel this seat was just told it may not write to.
|
|
1289
|
+
if (isScopeFault(result.code)) {
|
|
1290
|
+
counters.bump(REPLY_DEBT_COUNTERS.scopeRefused, {
|
|
1291
|
+
service, sender: item.sender || "unknown",
|
|
1292
|
+
channel: item.channel_id || item.channel || "none",
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1049
1295
|
console.error(`[daemon] Quick reply PERMANENTLY failed for ${item.sender} (${cause}) — NOT spawning a session; opening obligation + escalating`);
|
|
1050
1296
|
let obligationKey = itemId;
|
|
1051
1297
|
try {
|
|
@@ -1061,6 +1307,7 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
|
|
|
1061
1307
|
channel: item.channel_id || item.channel || null,
|
|
1062
1308
|
summary: classResult && classResult.summary, openedAt: Date.now(),
|
|
1063
1309
|
attempts: 1, sessionId: null, traceId: trace_id, lastError: cause,
|
|
1310
|
+
configFault: isScopeFault(result.code) ? "missing-scope" : null,
|
|
1064
1311
|
item: { content: item.content } },
|
|
1065
1312
|
{ failure: { label: cause }, told: false },
|
|
1066
1313
|
);
|
|
@@ -1556,6 +1556,11 @@ export function escalate(rec, o = {}) {
|
|
|
1556
1556
|
failedAt: new Date(Number.isFinite(o.now) ? o.now : Date.now()).toISOString(),
|
|
1557
1557
|
cause: o.failure ? o.failure.label : (rec.lastError || "unknown"),
|
|
1558
1558
|
requesterWasTold: !!o.told,
|
|
1559
|
+
// WHO has to act. A transport failure is this seat's own problem and the
|
|
1560
|
+
// sweep retries it; a scope fault is a configuration the seat cannot
|
|
1561
|
+
// grant itself, so the row says so in a word an operator can grep rather
|
|
1562
|
+
// than leaving them to infer it from `cause`.
|
|
1563
|
+
configFault: rec.configFault || null,
|
|
1559
1564
|
attempts: rec.attempts || 0,
|
|
1560
1565
|
sessionId: rec.sessionId || null,
|
|
1561
1566
|
traceId: rec.traceId || null,
|
|
@@ -34,7 +34,7 @@ import { recordOutbound } from "../../lib/comms/receipts.mjs";
|
|
|
34
34
|
// Pure data (a frozen table, no imports of its own) — the surface vocabulary the
|
|
35
35
|
// inbound projection stamps into `raw_ref`. Imported rather than re-listed so a
|
|
36
36
|
// new surface cannot be added upstream without this file's switch noticing.
|
|
37
|
-
import { SURFACE_NAMES } from "../../lib/org/inbound/surfaces.mjs";
|
|
37
|
+
import { ROOM_SURFACES, SURFACE_NAMES } from "../../lib/org/inbound/surfaces.mjs";
|
|
38
38
|
|
|
39
39
|
const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
|
|
40
40
|
|
|
@@ -117,11 +117,17 @@ export function resolveSlackChannel(item) {
|
|
|
117
117
|
* correct case for it.
|
|
118
118
|
*/
|
|
119
119
|
|
|
120
|
-
/**
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Surfaces that genuinely live in a Cohort room — these reply with a message.
|
|
122
|
+
*
|
|
123
|
+
* DERIVED, not re-typed. This was a hand-kept copy of five names, and a
|
|
124
|
+
* hand-kept copy is a list that can be forgotten: `broadcast` had to be added
|
|
125
|
+
* here by hand when the roll-call surface landed, and until it was, the reply
|
|
126
|
+
* had no transport, `canDeliverTo` said no, and the surface was dark at the last
|
|
127
|
+
* step. `surfaces.mjs` now carries `room` as a per-surface fact and this reads
|
|
128
|
+
* it, so a new room surface is routable the moment it is declared.
|
|
129
|
+
*/
|
|
130
|
+
const CHANNEL_SURFACES = ROOM_SURFACES;
|
|
125
131
|
|
|
126
132
|
/**
|
|
127
133
|
* Error frame codes a retry cannot change.
|
|
@@ -174,17 +180,77 @@ function entityFromRawRef(item) {
|
|
|
174
180
|
*/
|
|
175
181
|
export function cohortSurfaceOf(item) {
|
|
176
182
|
if (!item) return "";
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
// otherwise parse as a surface name and route a working DM to nowhere.
|
|
180
|
-
const m = /^cohort:([a-z_]+):/.exec(str(item.raw_ref));
|
|
181
|
-
if (m && SURFACE_NAMES.includes(m[1])) return m[1];
|
|
183
|
+
const declared = declaredSurface(item);
|
|
184
|
+
if (declared) return declared;
|
|
182
185
|
const kind = str(item.kind);
|
|
183
186
|
if (kind === "file_comment") return str(item.channel_id) ? "file_comment" : "doc_comment";
|
|
184
187
|
if (!kind || kind === "message") return "dm";
|
|
185
188
|
return kind;
|
|
186
189
|
}
|
|
187
190
|
|
|
191
|
+
/**
|
|
192
|
+
* The surface name `project.mjs` DECLARED in `raw_ref`, or "" when it declared
|
|
193
|
+
* none.
|
|
194
|
+
*
|
|
195
|
+
* Guarded against the LEGACY raw_ref shape, which is `cohort:<channelId>:
|
|
196
|
+
* <messageId>` — still on disk today, and still what `lib/org/messaging.mjs`
|
|
197
|
+
* stamps. An all-lowercase channel id would otherwise parse as a surface name
|
|
198
|
+
* and route a working DM to nowhere.
|
|
199
|
+
*/
|
|
200
|
+
function declaredSurface(item) {
|
|
201
|
+
const m = /^cohort:([a-z_]+):/.exec(str(item && item.raw_ref));
|
|
202
|
+
return m && SURFACE_NAMES.includes(m[1]) ? m[1] : "";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Did `raw_ref` actually DECLARE this item's surface, or was it inferred?
|
|
207
|
+
*
|
|
208
|
+
* Worth recording next to a logged surface: an inferred one is a guess made
|
|
209
|
+
* from `kind` / `channel_kind`, and an operator reading the row later should be
|
|
210
|
+
* able to tell the two apart without re-deriving it.
|
|
211
|
+
*
|
|
212
|
+
* @param {object} item
|
|
213
|
+
* @returns {boolean}
|
|
214
|
+
*/
|
|
215
|
+
export function cohortSurfaceIsDeclared(item) {
|
|
216
|
+
return declaredSurface(item) !== "";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* How to NAME this item's surface in a line a person reads.
|
|
221
|
+
*
|
|
222
|
+
* ── WHY THIS IS NOT `cohortSurfaceOf` ──
|
|
223
|
+
* Because that function's job is ROUTING, and for routing its fallbacks are
|
|
224
|
+
* right: an item with the legacy `cohort:<channelId>:<messageId>` raw_ref, or
|
|
225
|
+
* bare `kind:"message"`, replies with a message posted into `channel_id`, which
|
|
226
|
+
* is precisely what the `dm` branch does. But "dm" is then a ROUTE, not a
|
|
227
|
+
* claim about where the message was written — and a log line that prints it
|
|
228
|
+
* tells an operator "dm" about a message in a PUBLIC channel. The lane that
|
|
229
|
+
* added these lines to make the log truthful hit exactly that in its own test
|
|
230
|
+
* output: `Election on dm in channel/roadmap`.
|
|
231
|
+
*
|
|
232
|
+
* So the label is derived separately and never guesses: a declared surface is
|
|
233
|
+
* used verbatim; otherwise `channel_kind` (which hq stamps on the narrow reader)
|
|
234
|
+
* names the room; otherwise the item's own `is_dm`; otherwise it says plainly
|
|
235
|
+
* that the surface was never declared, rather than inventing one.
|
|
236
|
+
*
|
|
237
|
+
* @param {object} item
|
|
238
|
+
* @returns {string}
|
|
239
|
+
*/
|
|
240
|
+
export function cohortSurfaceLabel(item) {
|
|
241
|
+
if (!item) return "unknown surface";
|
|
242
|
+
const declared = declaredSurface(item);
|
|
243
|
+
if (declared) return declared;
|
|
244
|
+
const kind = str(item.kind);
|
|
245
|
+
if (kind === "file_comment") return str(item.channel_id) ? "file_comment" : "doc_comment";
|
|
246
|
+
if (kind && kind !== "message") return kind;
|
|
247
|
+
const channelKind = str(item.channel_kind).toUpperCase();
|
|
248
|
+
if (channelKind === "DM") return "dm";
|
|
249
|
+
if (channelKind) return `${channelKind.toLowerCase()} channel message`;
|
|
250
|
+
if (item.is_dm === true) return "dm";
|
|
251
|
+
return "message (surface not declared in raw_ref)";
|
|
252
|
+
}
|
|
253
|
+
|
|
188
254
|
/**
|
|
189
255
|
* Where a reply to this Cohort item goes — a pure function of the item, so it
|
|
190
256
|
* can be asked BEFORE anything is promised (see `canDeliverTo`).
|
|
@@ -766,5 +832,7 @@ export default {
|
|
|
766
832
|
canDeliverTo,
|
|
767
833
|
cohortReplyRoute,
|
|
768
834
|
cohortSurfaceOf,
|
|
835
|
+
cohortSurfaceLabel,
|
|
836
|
+
cohortSurfaceIsDeclared,
|
|
769
837
|
DELIVERABLE_SERVICES,
|
|
770
838
|
};
|