@cohortapp/agent-sdk 2.11.0 → 2.11.1
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/lib/channels/inbox-item.mjs +12 -0
- package/lib/channels/inbox-item.test.mjs +33 -0
- package/lib/execution/disposition.mjs +13 -2
- package/lib/execution/disposition.test.mjs +19 -2
- package/lib/execution/pipeline.test.mjs +4 -1
- package/lib/org/messaging.mjs +5 -0
- package/lib/org/messaging.test.mjs +7 -0
- package/package.json +1 -1
- package/scripts/daemon/agent-daemon.mjs +62 -2
- package/scripts/daemon/agent-daemon.test.mjs +117 -0
- package/scripts/daemon/cadence-handlers.mjs +48 -5
- package/scripts/daemon/cadence-handlers.test.mjs +57 -2
- package/scripts/daemon/inbox-deferral.mjs +49 -24
- package/scripts/daemon/inbox-deferral.test.mjs +39 -1
- package/scripts/daemon/responder.mjs +9 -0
- package/scripts/poller/inbox-scan-poller.mjs +7 -0
- package/scripts/poller/utils.mjs +11 -0
|
@@ -182,6 +182,18 @@ export function eventToInboxItem(ev) {
|
|
|
182
182
|
if (typeof ev.is_private === "boolean") item.is_private = ev.is_private;
|
|
183
183
|
if (typeof ev.is_dm === "boolean") item.is_dm = ev.is_dm;
|
|
184
184
|
|
|
185
|
+
// THE CHANNEL KIND (DM / GROUP_DM / PUBLIC / PRIVATE / …). Cohort carries this
|
|
186
|
+
// on its org event feed (lib/org/messaging.toMessageEvent), and it is the ONLY
|
|
187
|
+
// reliable is_dm signal for Cohort, whose channel ids are cuids with no
|
|
188
|
+
// Slack-style "D…"/"dm/" prefix for the daemon's `enrichItem` to key off. The
|
|
189
|
+
// signal was being DROPPED here (and again by the YAML writer/reader), so a
|
|
190
|
+
// Cohort 1:1 DM arrived at the election gate looking like an ambient group
|
|
191
|
+
// message and was suppressed. Serialised only when present, so every non-Cohort
|
|
192
|
+
// producer keeps a byte-identical inbox item (see the byte-compat fixture).
|
|
193
|
+
if (typeof ev.channel_kind === "string" && ev.channel_kind) {
|
|
194
|
+
item.channel_kind = sanitizeYamlField(ev.channel_kind);
|
|
195
|
+
}
|
|
196
|
+
|
|
185
197
|
// Only stamp `kind` when it's not the default. Existing message items must
|
|
186
198
|
// remain byte-identical to what the Slack socket-mode writer produced before
|
|
187
199
|
// this seam existed (it never wrote a `kind` field).
|
|
@@ -59,6 +59,39 @@ test("eventToInboxItem: channel_cc kind is stamped", () => {
|
|
|
59
59
|
assert.equal(item.kind, "channel_cc");
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
+
test("eventToInboxItem: channel_kind is carried through when present, omitted when absent", () => {
|
|
63
|
+
// Present → serialised (Cohort's only reliable is_dm signal), and the poll-lane
|
|
64
|
+
// parser reads it back, so enrichItem actually receives it.
|
|
65
|
+
const withKind = eventToInboxItem({
|
|
66
|
+
channel: "cohort",
|
|
67
|
+
message_id: "m1",
|
|
68
|
+
channel_kind: "DM",
|
|
69
|
+
from: { id: "human-1", name: "Human" },
|
|
70
|
+
text: "hi",
|
|
71
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
72
|
+
});
|
|
73
|
+
assert.equal(withKind.channel_kind, "DM");
|
|
74
|
+
|
|
75
|
+
// Absent → omitted entirely, so a non-Cohort item stays byte-identical.
|
|
76
|
+
const withoutKind = eventToInboxItem({
|
|
77
|
+
channel: "slack",
|
|
78
|
+
message_id: "m2",
|
|
79
|
+
from: { id: "U1", name: "casey" },
|
|
80
|
+
text: "hi",
|
|
81
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
82
|
+
});
|
|
83
|
+
assert.equal("channel_kind" in withoutKind, false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("parseInboxItemYaml: channel_kind survives the YAML round-trip (present/absent)", () => {
|
|
87
|
+
const withKind = parseInboxItemYaml(
|
|
88
|
+
'id: "m1"\nservice: "cohort"\nchannel_id: "cuid_abc"\nchannel_kind: "GROUP_DM"\ncontent: |\n hi\n',
|
|
89
|
+
);
|
|
90
|
+
assert.equal(withKind.channel_kind, "GROUP_DM");
|
|
91
|
+
const withoutKind = parseInboxItemYaml('id: "m2"\nservice: "slack"\ncontent: |\n hi\n');
|
|
92
|
+
assert.equal("channel_kind" in withoutKind, false, "absent line → no channel_kind key");
|
|
93
|
+
});
|
|
94
|
+
|
|
62
95
|
// ---------------------------------------------------------------------------
|
|
63
96
|
// round-trip event ↔ item
|
|
64
97
|
// ---------------------------------------------------------------------------
|
|
@@ -392,8 +392,19 @@ export function decide(o = {}) {
|
|
|
392
392
|
if (o.history && base.actor) {
|
|
393
393
|
const hits = o.history.actorActivity(base.actor);
|
|
394
394
|
if (hits >= floodLimit) {
|
|
395
|
-
|
|
396
|
-
|
|
395
|
+
// A DIRECTED 1:1 DM is EXEMPT. The flood counter tallies ALL of an actor's
|
|
396
|
+
// activity in the window — including their own doc/board/decision events,
|
|
397
|
+
// which fire far more often than they message — so a person's genuine 1:1
|
|
398
|
+
// message would be downgraded to "batch the reply" merely because their
|
|
399
|
+
// unrelated document edits tripped the limit. A 1:1 DM is the one surface
|
|
400
|
+
// where the human is unambiguously talking TO the seat and watching for a
|
|
401
|
+
// reply, so it stays react-now; every other surface still batches.
|
|
402
|
+
if (base.surface === "dm") {
|
|
403
|
+
why.push(`${base.actor} is over the flood limit (${hits}/${floodLimit}), but this is a directed 1:1 DM → not batched`);
|
|
404
|
+
} else {
|
|
405
|
+
why.push(`${base.actor} has woken me ${hits} time(s) in the flood window (limit ${floodLimit}) → batch the reply`);
|
|
406
|
+
return finish(base, "schedule", "actor_flood", why, o, { observeOnly, batched: true });
|
|
407
|
+
}
|
|
397
408
|
}
|
|
398
409
|
}
|
|
399
410
|
|
|
@@ -256,15 +256,32 @@ test("IGNORE: a malformed event never crashes the ladder", () => {
|
|
|
256
256
|
// ───────────────────────────────────────────────────────────────────────────
|
|
257
257
|
|
|
258
258
|
test("DOWNGRADE not silence: a flooding actor gets batched, not muted", () => {
|
|
259
|
-
|
|
259
|
+
// Assert on a NON-DM surface (an @mention): a directed 1:1 DM is now EXEMPT
|
|
260
|
+
// from actor-flood (see the next test), so it can no longer stand in for the
|
|
261
|
+
// general batching behaviour.
|
|
262
|
+
const floodMention = (over) => run({
|
|
263
|
+
verdict: yes("mention", "mention"),
|
|
264
|
+
candidate: { family: "messaging", kind: "send", surfaces: ["mention"] },
|
|
265
|
+
...over,
|
|
266
|
+
});
|
|
267
|
+
const d = floodMention({ history: freshHistory({ actorActivity: () => DEFAULT_ACTOR_FLOOD_LIMIT }) });
|
|
260
268
|
assert.equal(d.disposition, "schedule");
|
|
261
269
|
assert.equal(d.reason, "actor_flood");
|
|
262
270
|
assert.equal(d.batched, true);
|
|
263
271
|
// one under the limit still answers turn-by-turn
|
|
264
|
-
const under =
|
|
272
|
+
const under = floodMention({ history: freshHistory({ actorActivity: () => DEFAULT_ACTOR_FLOOD_LIMIT - 1 }) });
|
|
265
273
|
assert.equal(under.disposition, "react_now");
|
|
266
274
|
});
|
|
267
275
|
|
|
276
|
+
test("a directed 1:1 DM is EXEMPT from actor-flood batching", () => {
|
|
277
|
+
// The flood counter tallies ALL of an actor's activity (their doc/board edits
|
|
278
|
+
// included), so a genuine 1:1 message must not be batched just because the same
|
|
279
|
+
// person tripped the limit elsewhere. Even far over the limit, a DM reacts now.
|
|
280
|
+
const d = run({ history: freshHistory({ actorActivity: () => DEFAULT_ACTOR_FLOOD_LIMIT * 5 }) });
|
|
281
|
+
assert.equal(d.disposition, "react_now");
|
|
282
|
+
assert.equal(d.reason, "directed_now");
|
|
283
|
+
});
|
|
284
|
+
|
|
268
285
|
test("DOWNGRADE not silence: an exhausted budget queues the reply", () => {
|
|
269
286
|
for (const runtime of [{ dailyCapExhausted: true }, { remainingCents: 0 }]) {
|
|
270
287
|
const d = run({ runtime });
|
|
@@ -157,7 +157,10 @@ test("a burst from one actor is batched rather than answered turn-by-turn", asyn
|
|
|
157
157
|
const fx = fakeEffects();
|
|
158
158
|
const items = [];
|
|
159
159
|
for (let i = 0; i < 6; i += 1) {
|
|
160
|
-
|
|
160
|
+
// A burst of @mentions (NOT DMs): a directed 1:1 DM is now exempt from
|
|
161
|
+
// actor-flood, so flood batching is exercised on a channel surface where
|
|
162
|
+
// it still applies. `is_dm:false` + mentions_agent → surface "mention".
|
|
163
|
+
items.push(dm({ id: `cohort-msg_${i}`, raw_ref: `cohort:mention:msg_${i}:${100 + i}`, thread_id: `t_${i}`, channel_id: `c_${i}`, is_dm: false }));
|
|
161
164
|
}
|
|
162
165
|
const { results } = await run(items, { agentRoot: root, effects: fx, actorFloodLimit: 3 });
|
|
163
166
|
const dispositions = results.map((r) => r.decision.disposition);
|
package/lib/org/messaging.mjs
CHANGED
|
@@ -859,6 +859,11 @@ export function toMessageEvent(ev, me) {
|
|
|
859
859
|
kind: isCall ? "call" : "message",
|
|
860
860
|
subject: isCall ? "Cohort call invite" : "Cohort message",
|
|
861
861
|
channel_id: channelId,
|
|
862
|
+
// The org event feed carries channelKind (DM/GROUP_DM/PUBLIC/…); surface it
|
|
863
|
+
// so the daemon's is_dm detection works for Cohort (whose channel ids are
|
|
864
|
+
// cuids, not Slack-style "D…"/"dm/…" prefixes). Without this a Cohort DM was
|
|
865
|
+
// misread as a group message and wrongly routed through the election gate.
|
|
866
|
+
channel_kind: String(p.channelKind || p.channel_kind || "").toUpperCase(),
|
|
862
867
|
channel_label: String(p.channelName || channelId || "cohort"),
|
|
863
868
|
sender: String(p.fromName || fromId),
|
|
864
869
|
priority_signals: {
|
|
@@ -248,6 +248,13 @@ test("toMessageEvent: projects a messaging event onto the MessageEvent contract"
|
|
|
248
248
|
assert.equal(ev.ingest_source, "cohort");
|
|
249
249
|
});
|
|
250
250
|
|
|
251
|
+
test("toMessageEvent: projects channelKind so the daemon can tell a DM from a GROUP_DM", () => {
|
|
252
|
+
const dm = toMessageEvent({ family: "messaging", seq: 8, payload: { id: "m1", channel: "dm-x", channelKind: "DM", from: "human-1", body: "hi" } }, "me");
|
|
253
|
+
assert.equal(dm.channel_kind, "DM", "a 1:1 DM carries channel_kind DM (→ is_dm true downstream)");
|
|
254
|
+
const group = toMessageEvent({ family: "messaging", seq: 9, payload: { id: "m2", channel: "gdm-x", channelKind: "group_dm", from: "human-1", body: "hi all" } }, "me");
|
|
255
|
+
assert.equal(group.channel_kind, "GROUP_DM", "a GROUP_DM is upper-cased and NOT a 1:1 DM downstream");
|
|
256
|
+
});
|
|
257
|
+
|
|
251
258
|
test("pullInbound: filters to directed events and advances the cursor to max seq", async () => {
|
|
252
259
|
const f = fakeFetch(() => ({
|
|
253
260
|
body: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cohortapp/agent-sdk",
|
|
3
|
-
"version": "2.11.
|
|
3
|
+
"version": "2.11.1",
|
|
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": {
|
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
sweepObligations,
|
|
70
70
|
openObligations,
|
|
71
71
|
escalate,
|
|
72
|
+
closeObligation,
|
|
72
73
|
} from "./assurance.mjs";
|
|
73
74
|
import { recordPoll, recordClassification, recordSession, writeHealthDashboard } from "./health.mjs";
|
|
74
75
|
import { acquireLock, releaseLock, updateLock, scanStaleLocks, acquireThreadLock, claimRequest, hasActiveClaim, sweepStaleItemClaims, sanitiseItemId } from "./session-lock.mjs";
|
|
@@ -326,7 +327,29 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
|
|
|
326
327
|
export function enrichItem(item, service) {
|
|
327
328
|
const channelStr = (item.channel || "").toLowerCase();
|
|
328
329
|
const channelId = item.channel_id || "";
|
|
329
|
-
|
|
330
|
+
// Cohort carries the channel kind explicitly (DM/GROUP_DM/…); its channel ids
|
|
331
|
+
// are cuids, so the Slack-style "dm/"/"D…" prefix heuristics never fire. When
|
|
332
|
+
// the kind is known it is AUTHORITATIVE:
|
|
333
|
+
// - DM → a 1:1 DM: is_dm true, so it skips the group election gate.
|
|
334
|
+
// - GROUP_DM → a multi-participant channel: is_dm FALSE, so an UNDIRECTED
|
|
335
|
+
// message goes through the election exactly like any other
|
|
336
|
+
// channel (only ONE seat should answer an ambient group DM).
|
|
337
|
+
// - any other explicit kind (PUBLIC/PRIVATE/…) → not a 1:1 DM.
|
|
338
|
+
// When the kind is ABSENT we do NOT immediately reach for the prefix
|
|
339
|
+
// heuristics: the DEFAULT (wide) inbound reader — `lib/org/inbound/project.mjs`
|
|
340
|
+
// toMessageEvent — projects a Cohort 1:1 DM with `is_dm:true`/`channel_type:'dm'`
|
|
341
|
+
// but does NOT emit `channel_kind`. That boolean survives the whole
|
|
342
|
+
// eventToInboxItem → writeInboxItem → parseInboxItemYaml chain, so HONOR it
|
|
343
|
+
// before falling back. Overwriting it with the cuid-blind prefix heuristics
|
|
344
|
+
// (which never match a Cohort id) is exactly what sent a genuine 1:1 DM through
|
|
345
|
+
// the group election gate and suppressed it. Precedence: explicit kind →
|
|
346
|
+
// pre-existing boolean is_dm → Slack-style id/label prefix heuristics.
|
|
347
|
+
const channelKind = String(item.channel_kind || "").toUpperCase();
|
|
348
|
+
const isDm = channelKind
|
|
349
|
+
? channelKind === "DM"
|
|
350
|
+
: typeof item.is_dm === "boolean"
|
|
351
|
+
? item.is_dm
|
|
352
|
+
: channelStr.startsWith("dm/") || channelId.startsWith("D");
|
|
330
353
|
const myFirstName = loadAgent().firstName || "Agent";
|
|
331
354
|
const agentThreadRegex = new RegExp(`^${myFirstName}:`, "m");
|
|
332
355
|
const agentInThread = !!(item.thread_context && agentThreadRegex.test(item.thread_context));
|
|
@@ -697,7 +720,44 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
|
|
|
697
720
|
});
|
|
698
721
|
return { ok: true, path: "quick_reply", reason: classResult.model };
|
|
699
722
|
}
|
|
700
|
-
//
|
|
723
|
+
// PERMANENT SEND FAILURE — NEVER FALL THROUGH. A permanent failure
|
|
724
|
+
// (FORBIDDEN_SCOPE from the send-gate, NOT_FOUND/BAD_REQUEST from hq) means
|
|
725
|
+
// retrying changes nothing AND that a spawned session would fail to post the
|
|
726
|
+
// very same way. Worse, that session runs AUTONOMOUSLY: denied its intended
|
|
727
|
+
// reply, it improvises and posts UNRELATED work into a channel this seat was
|
|
728
|
+
// just told it may not write to. So we stop here — open the durable debt so
|
|
729
|
+
// the ask is not lost, write a needs-attention escalation an operator can
|
|
730
|
+
// sweep, close the obligation as failed so the assurance sweep does not loop
|
|
731
|
+
// on an impossible send, and retire the item so the poll lane stops
|
|
732
|
+
// re-delivering it. The requester is not told (the only channel we had was
|
|
733
|
+
// the forbidden one); the escalation is the trace that a human must act.
|
|
734
|
+
if (result.permanent) {
|
|
735
|
+
const cause = result.code || result.error || "permanent send failure";
|
|
736
|
+
console.error(`[daemon] Quick reply PERMANENTLY failed for ${item.sender} (${cause}) — NOT spawning a session; opening obligation + escalating`);
|
|
737
|
+
let obligationKey = itemId;
|
|
738
|
+
try {
|
|
739
|
+
const opened = await openAndAcknowledge({
|
|
740
|
+
item, classResult, service, traceId: trace_id, ack: false,
|
|
741
|
+
});
|
|
742
|
+
if (opened && opened.key) obligationKey = opened.key;
|
|
743
|
+
} catch (err) {
|
|
744
|
+
console.error(`[daemon] openAndAcknowledge threw for ${itemId} on permanent send failure: ${err.message}`);
|
|
745
|
+
}
|
|
746
|
+
escalate(
|
|
747
|
+
{ key: obligationKey, sender: item.sender, service,
|
|
748
|
+
channel: item.channel_id || item.channel || null,
|
|
749
|
+
summary: classResult && classResult.summary, openedAt: Date.now(),
|
|
750
|
+
attempts: 1, sessionId: null, traceId: trace_id, lastError: cause,
|
|
751
|
+
item: { content: item.content } },
|
|
752
|
+
{ failure: { label: cause }, told: false },
|
|
753
|
+
);
|
|
754
|
+
try { closeObligation(obligationKey, { outcome: "failed", note: cause }); } catch { /* best-effort */ }
|
|
755
|
+
counters.bump("send.permanent_failure", { service, code: result.code || "unknown" });
|
|
756
|
+
emitEvent({ type: EVENT_TYPES.ERROR, trace_id, attrs: { item_id: itemId, service, stage: "quick_reply_permanent", error: cause } });
|
|
757
|
+
markProcessed(item, service);
|
|
758
|
+
return { ok: true, path: "quick_reply_permanent", reason: cause };
|
|
759
|
+
}
|
|
760
|
+
// If quick reply failed transiently or was blocked by validation, fall through to dispatch a full session
|
|
701
761
|
const reason = result.blocked ? `validation blocked: ${result.issues?.map(i => i.rule).join(", ")}` : "send failed";
|
|
702
762
|
console.warn(`[daemon] Quick reply not sent (${reason}), falling through to session dispatch`);
|
|
703
763
|
}
|
|
@@ -713,3 +713,120 @@ test("FAIL-SAFE: no holding ack is posted when the reply session cannot run (cla
|
|
|
713
713
|
assert.equal(down.dispatched, true, "the session is still dispatched");
|
|
714
714
|
assert.equal(res.path, "session", "answerItem took the session path");
|
|
715
715
|
});
|
|
716
|
+
|
|
717
|
+
// ===========================================================================
|
|
718
|
+
// is_dm END-TO-END — the Cohort channel_kind is the authoritative signal.
|
|
719
|
+
// ===========================================================================
|
|
720
|
+
|
|
721
|
+
test("enrichItem: Cohort channel_kind decides is_dm (DM yes, GROUP_DM no)", () => {
|
|
722
|
+
// A 1:1 DM skips the election gate; a GROUP_DM is multi-participant and must
|
|
723
|
+
// go through it. Cohort ids are cuids, so the kind is the only reliable signal.
|
|
724
|
+
const dm = daemon.enrichItem({ channel: "channel/x", channel_id: "cuid_abc", channel_kind: "DM" }, "cohort");
|
|
725
|
+
assert.equal(dm.is_dm, true, "channel_kind DM → is_dm true (skips election)");
|
|
726
|
+
const group = daemon.enrichItem({ channel: "channel/x", channel_id: "cuid_abc", channel_kind: "GROUP_DM" }, "cohort");
|
|
727
|
+
assert.equal(group.is_dm, false, "channel_kind GROUP_DM → is_dm false (goes through election)");
|
|
728
|
+
const pub = daemon.enrichItem({ channel: "channel/x", channel_id: "cuid_abc", channel_kind: "public" }, "cohort");
|
|
729
|
+
assert.equal(pub.is_dm, false, "any other explicit kind → not a 1:1 DM");
|
|
730
|
+
// No kind → fall back to the Slack-style id/label prefix heuristics.
|
|
731
|
+
assert.equal(daemon.enrichItem({ channel: "dm/ceo", channel_id: "D123" }, "slack").is_dm, true);
|
|
732
|
+
assert.equal(daemon.enrichItem({ channel: "channel/roadmap", channel_id: "C123" }, "slack").is_dm, false);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test("enrichItem: a WIDE-path Cohort 1:1 DM (is_dm:true, NO channel_kind, cuid id) stays is_dm through the whole chain", async () => {
|
|
736
|
+
// REGRESSION. The DEFAULT inbound reader is lib/org/inbound.pullWideInbound,
|
|
737
|
+
// whose projection (lib/org/inbound/project.mjs toMessageEvent) marks a 1:1 DM
|
|
738
|
+
// with is_dm:true + channel_type:"dm" but does NOT emit channel_kind (only the
|
|
739
|
+
// non-default MESSAGING_INBOUND_WIDE=0 narrow reader sets channel_kind). Cohort
|
|
740
|
+
// channel ids are cuids, so the Slack-style "dm/"/"D…" prefix heuristics never
|
|
741
|
+
// fire on them. Before the fix, enrichItem derived is_dm ONLY from an (absent)
|
|
742
|
+
// channel_kind or those (never-matching) heuristics and CLOBBERED the true
|
|
743
|
+
// is_dm to false — routing a genuine 1:1 DM through the group election gate,
|
|
744
|
+
// which then suppressed it. This drives the real chain end to end:
|
|
745
|
+
// toMessageEvent → eventToInboxItem → writeInboxItem → parseInboxItemYaml → enrichItem
|
|
746
|
+
// and asserts is_dm survives as true.
|
|
747
|
+
const { toMessageEvent } = await import("../../lib/org/inbound/project.mjs");
|
|
748
|
+
const { classifyEvent } = await import("../../lib/org/inbound/directedness.mjs");
|
|
749
|
+
const { eventToInboxItem } = await import("../../lib/channels/inbox-item.mjs");
|
|
750
|
+
const { writeInboxItem } = await import("../poller/utils.mjs");
|
|
751
|
+
const { parseInboxItemYaml } = await import("../poller/inbox-scan-poller.mjs");
|
|
752
|
+
|
|
753
|
+
const CUID = "clw9x7k4a0000abcd1234efgh"; // a real cuid: no "D…"/"dm/" prefix to key off
|
|
754
|
+
const cand = classifyEvent({
|
|
755
|
+
seq: 501, at: "2026-08-25T09:00:00.000Z", actor: "M-them",
|
|
756
|
+
family: "messaging", kind: "send", entity_id: "m501",
|
|
757
|
+
payload: { actor: "M-them", channelId: CUID, channelKind: "DM" },
|
|
758
|
+
});
|
|
759
|
+
const facts = { channels: new Map([[CUID, { id: CUID, kind: "DM", name: "them" }]]) };
|
|
760
|
+
const mev = toMessageEvent({
|
|
761
|
+
candidate: cand,
|
|
762
|
+
verdict: { surface: "dm", reason: "dm", directed: true },
|
|
763
|
+
hydrated: { ok: true, text: "ping", from: { id: "M-them", name: "Casey" }, channelId: CUID, channelLabel: "them" },
|
|
764
|
+
me: "M-me",
|
|
765
|
+
facts,
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
// The premise the regression rests on: the wide projection marks the DM but
|
|
769
|
+
// does NOT emit channel_kind.
|
|
770
|
+
assert.equal(mev.is_dm, true, "wide-path DM projects is_dm:true");
|
|
771
|
+
assert.equal(mev.channel_type, "dm");
|
|
772
|
+
assert.equal("channel_kind" in mev, false, "the DEFAULT wide reader emits NO channel_kind");
|
|
773
|
+
|
|
774
|
+
// eventToInboxItem carries is_dm through but (correctly) attaches no channel_kind.
|
|
775
|
+
const item = eventToInboxItem(mev);
|
|
776
|
+
assert.equal(item.is_dm, true);
|
|
777
|
+
assert.equal("channel_kind" in item, false);
|
|
778
|
+
|
|
779
|
+
// writeInboxItem → parseInboxItemYaml: the on-disk round-trip the poller does.
|
|
780
|
+
writeInboxItem("cohort", item, AGENT_DIR);
|
|
781
|
+
const dir = join(AGENT_DIR, "state", "inbox", "cohort");
|
|
782
|
+
const file = readdirSync(dir).find((f) => f.includes(item.id) && f.endsWith(".yaml"));
|
|
783
|
+
assert.ok(file, "the inbox item was written to disk");
|
|
784
|
+
const parsed = parseInboxItemYaml(readFileSync(join(dir, file), "utf8"));
|
|
785
|
+
assert.equal(parsed.is_dm, true, "is_dm survives the YAML round-trip");
|
|
786
|
+
assert.equal("channel_kind" in parsed, false, "and still no channel_kind on the wide path");
|
|
787
|
+
|
|
788
|
+
// enrichItem is the last hop before the election gate. It MUST honor the
|
|
789
|
+
// pre-existing boolean is_dm when channel_kind is absent, not clobber it.
|
|
790
|
+
const enriched = daemon.enrichItem(parsed, "cohort");
|
|
791
|
+
assert.equal(enriched.is_dm, true, "the wide-path 1:1 DM stays is_dm — it must SKIP the group election gate");
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
// ===========================================================================
|
|
795
|
+
// NO FALLTHROUGH ON PERMANENT FAILURE — a permanent quick-reply send failure
|
|
796
|
+
// (FORBIDDEN_SCOPE) must escalate, not spawn an autonomous session that would
|
|
797
|
+
// post UNRELATED work into a channel the seat may not write to.
|
|
798
|
+
// ===========================================================================
|
|
799
|
+
|
|
800
|
+
test("NO FALLTHROUGH: a PERMANENT quick-reply failure escalates instead of spawning a session", async () => {
|
|
801
|
+
resetState();
|
|
802
|
+
const item = { id: "MSG-PERM", raw_ref: "slack:DPERM:1", service: "slack", channel: "dm/ceo-perm", channel_id: "DPERM0001", is_dm: true, sender: "ceo", subject: "forbidden send", content: "please post this" };
|
|
803
|
+
let dispatched = false;
|
|
804
|
+
const res = await daemon.answerItem(item, "slack", "MSG-PERM", "trace-perm", {
|
|
805
|
+
classify: async () => ({ priority: "high", action: "respond", model: "haiku", summary: "quick answer", category: "action_required", directed_at_agent: true }),
|
|
806
|
+
isQuickReply: () => true,
|
|
807
|
+
sendQuickResponse: async () => ({ sent: false, permanent: true, code: "FORBIDDEN_SCOPE", error: "FORBIDDEN_SCOPE: blocked by send-gate" }),
|
|
808
|
+
dispatch: () => { dispatched = true; },
|
|
809
|
+
claudeAvailable: () => true,
|
|
810
|
+
});
|
|
811
|
+
assert.equal(dispatched, false, "a permanent send failure must NOT spawn an autonomous session");
|
|
812
|
+
assert.equal(res.path, "quick_reply_permanent");
|
|
813
|
+
assert.match(res.reason, /FORBIDDEN_SCOPE/);
|
|
814
|
+
const naDir = join(AGENT_DIR, "state", "obligations", "needs-attention");
|
|
815
|
+
assert.ok(existsSync(naDir) && readdirSync(naDir).length > 0, "a durable needs-attention escalation was written for an operator");
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
test("NO FALLTHROUGH: a TRANSIENT quick-reply failure still falls through to a full session", async () => {
|
|
819
|
+
resetState();
|
|
820
|
+
const item = { id: "MSG-TRAN", raw_ref: "slack:DTRAN:1", service: "slack", channel: "dm/ceo-tran", channel_id: "DTRAN0001", is_dm: true, sender: "ceo", subject: "transient send", content: "please post this" };
|
|
821
|
+
let dispatched = false;
|
|
822
|
+
const res = await daemon.answerItem(item, "slack", "MSG-TRAN", "trace-tran", {
|
|
823
|
+
classify: async () => ({ priority: "high", action: "respond", model: "haiku", summary: "quick answer", category: "action_required", directed_at_agent: true }),
|
|
824
|
+
isQuickReply: () => true,
|
|
825
|
+
sendQuickResponse: async () => ({ sent: false, error: "hq 503 blip" }), // transient: NO permanent flag
|
|
826
|
+
sendHoldingMessage: async () => ({ sent: true, holdingText: "on it" }),
|
|
827
|
+
dispatch: (_p, _it, _cr, _s, opts) => { dispatched = true; opts.onClose({ ok: true, code: 0 }); },
|
|
828
|
+
claudeAvailable: () => true,
|
|
829
|
+
});
|
|
830
|
+
assert.equal(dispatched, true, "a transient failure falls through to the full session, as before");
|
|
831
|
+
assert.equal(res.path, "session");
|
|
832
|
+
});
|
|
@@ -667,16 +667,32 @@ async function guardDynamicJobs({ event, agentRoot }, opts = {}) {
|
|
|
667
667
|
|
|
668
668
|
const MESSAGING_CURSOR_REL = join("state", "messaging", "inbound-cursor.json");
|
|
669
669
|
|
|
670
|
-
/**
|
|
670
|
+
/**
|
|
671
|
+
* Read the org-messaging inbound cursor (seq).
|
|
672
|
+
*
|
|
673
|
+
* Returns `null` when there is NO cursor on disk (or it is corrupt/unreadable) —
|
|
674
|
+
* NOT 0. The distinction is load-bearing, exactly as it is for the push channel
|
|
675
|
+
* (lib/org/push.readCursor): `cursor=0` on the events read means "replay the
|
|
676
|
+
* ENTIRE org ledger from genesis", so a fresh install that started at 0 would
|
|
677
|
+
* wake on every historical message in the workspace. `null` is the caller's cue
|
|
678
|
+
* to SEED from the push channel head instead (see guardMessagingInbound), so a
|
|
679
|
+
* (re)install starts at "now" and replays nothing. A real, persisted cursor
|
|
680
|
+
* (always > 0 in practice — see writeMessagingCursor) is returned as-is.
|
|
681
|
+
*/
|
|
671
682
|
function readMessagingCursor(agentRoot) {
|
|
672
683
|
try {
|
|
673
684
|
const p = join(agentRoot, MESSAGING_CURSOR_REL);
|
|
674
|
-
if (!existsSync(p)) return
|
|
685
|
+
if (!existsSync(p)) return null;
|
|
675
686
|
const obj = JSON.parse(readFileSync(p, "utf8"));
|
|
676
687
|
const c = Number(obj && obj.cursor);
|
|
677
|
-
|
|
688
|
+
// A non-finite / non-positive cursor is corrupt, not "from genesis" — treat it
|
|
689
|
+
// as absent (seed from head) rather than replaying all history. Aligned with
|
|
690
|
+
// lib/org/push.readCursor (`n > 0`): a persisted cursor is always > 0 (seeded
|
|
691
|
+
// from a head that is null-or->0, then only advanced forward), so 0 is never a
|
|
692
|
+
// real watermark — accepting it would replay the entire org ledger.
|
|
693
|
+
return Number.isFinite(c) && c > 0 ? c : null;
|
|
678
694
|
} catch {
|
|
679
|
-
return
|
|
695
|
+
return null;
|
|
680
696
|
}
|
|
681
697
|
}
|
|
682
698
|
|
|
@@ -764,7 +780,34 @@ async function guardMessagingInbound({ event, agentRoot, log }, opts = {}) {
|
|
|
764
780
|
opts.toInboxImpl || (await import("../../lib/channels/inbox-item.mjs")).eventToInboxItem;
|
|
765
781
|
const writeInboxItem = opts.writeImpl || (await import("../poller/utils.mjs")).writeInboxItem;
|
|
766
782
|
|
|
767
|
-
|
|
783
|
+
let cursor = readMessagingCursor(agentRoot);
|
|
784
|
+
if (cursor == null) {
|
|
785
|
+
// FIRST RUN (or a corrupt cursor): no usable inbound cursor on disk. Do NOT
|
|
786
|
+
// start the poll lane at genesis — `events?cursor=0` replays the ENTIRE org
|
|
787
|
+
// ledger and wakes the fresh agent on every historical message. Seed from
|
|
788
|
+
// the PUSH CHANNEL HEAD instead: the SSE lane bootstraps at head and
|
|
789
|
+
// persists that seq to state/org/push-cursor.json, so it is the local
|
|
790
|
+
// "everything before this is history" watermark. Persist it as our starting
|
|
791
|
+
// cursor so a crash before the first advance cannot fall back to genesis.
|
|
792
|
+
let head = null;
|
|
793
|
+
try {
|
|
794
|
+
head = typeof opts.pushHeadImpl === "function"
|
|
795
|
+
? opts.pushHeadImpl(agentRoot)
|
|
796
|
+
: (await import("../../lib/org/push.mjs")).readCursor(agentRoot);
|
|
797
|
+
} catch { head = null; }
|
|
798
|
+
if (head == null) {
|
|
799
|
+
// Neither lane has established a head yet (truly fresh install, push not
|
|
800
|
+
// yet connected). Replaying the backlog is the ONE thing we must not do,
|
|
801
|
+
// so hold this tick — the push channel writes a head shortly and the next
|
|
802
|
+
// tick seeds from it. Never silent: the reason says exactly why 0 routed.
|
|
803
|
+
if (typeof log === "function") {
|
|
804
|
+
log("info", "[messaging-inbound] fresh install: no inbound cursor and no push-channel head yet — holding this tick to avoid replaying the org backlog");
|
|
805
|
+
}
|
|
806
|
+
return { ok: true, decision: "inline", cadence: event.cadence, reason: "awaiting push-channel head to seed inbound cursor (fresh install)", routed: 0 };
|
|
807
|
+
}
|
|
808
|
+
cursor = Number(head);
|
|
809
|
+
writeMessagingCursor(agentRoot, cursor);
|
|
810
|
+
}
|
|
768
811
|
// Pass the agent id explicitly. It is what suppresses the agent's OWN
|
|
769
812
|
// outbound echoes — without it `me` is null, every echo-filter branch is
|
|
770
813
|
// skipped, and the agent re-ingests its own replies as fresh inbound (a
|
|
@@ -445,6 +445,10 @@ test("messaging-inbound: pulls new directed items and routes them into the inbox
|
|
|
445
445
|
{ event: { cadence: "messaging-inbound" }, agentRoot: root, log: () => {} },
|
|
446
446
|
{
|
|
447
447
|
cfg: { org: { cohort: { enabled: true, base: "https://x", token: "t", agentId: "me" } } },
|
|
448
|
+
// Fresh root → no inbound cursor. Seed from the push-channel head so the
|
|
449
|
+
// pull starts at "now" (not genesis). The stub stands in for
|
|
450
|
+
// lib/org/push.readCursor.
|
|
451
|
+
pushHeadImpl: () => 5,
|
|
448
452
|
pullImpl: async () => ({ events: [ev1, ev2], nextCursor: 12 }),
|
|
449
453
|
toInboxImpl: (e) => ({ id: e.message_id, service: "cohort", content: e.text }),
|
|
450
454
|
writeImpl: (service, item) => written.push({ service, item }),
|
|
@@ -461,20 +465,67 @@ test("messaging-inbound: pulls new directed items and routes them into the inbox
|
|
|
461
465
|
assert.equal(JSON.parse(readFileSync(cursorFile, "utf8")).cursor, 12);
|
|
462
466
|
});
|
|
463
467
|
|
|
468
|
+
test("messaging-inbound: FIRST RUN seeds the inbound cursor from the push-channel head (no backlog replay)", async () => {
|
|
469
|
+
const root = tmpRoot();
|
|
470
|
+
let pulledCursor = null;
|
|
471
|
+
const res = await guardMessagingInbound(
|
|
472
|
+
{ event: { cadence: "messaging-inbound" }, agentRoot: root, log: () => {} },
|
|
473
|
+
{
|
|
474
|
+
cfg: { org: { cohort: { enabled: true, base: "https://x", token: "t" } } },
|
|
475
|
+
// The push channel has advanced to seq 4200. A fresh install must start
|
|
476
|
+
// there, NOT at 0 (which would replay the whole org ledger).
|
|
477
|
+
pushHeadImpl: () => 4200,
|
|
478
|
+
pullImpl: async ({ cursor }) => { pulledCursor = cursor; return { events: [], nextCursor: cursor }; },
|
|
479
|
+
toInboxImpl: () => ({}),
|
|
480
|
+
writeImpl: () => { throw new Error("should not write an item"); },
|
|
481
|
+
},
|
|
482
|
+
);
|
|
483
|
+
assert.equal(res.ok, true);
|
|
484
|
+
assert.equal(res.decision, "inline");
|
|
485
|
+
assert.equal(pulledCursor, 4200, "the pull started at the push head, not at genesis");
|
|
486
|
+
const cursorFile = join(root, "state", "messaging", "inbound-cursor.json");
|
|
487
|
+
assert.ok(existsSync(cursorFile), "the seeded head is persisted so a crash cannot fall back to genesis");
|
|
488
|
+
assert.equal(JSON.parse(readFileSync(cursorFile, "utf8")).cursor, 4200);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("messaging-inbound: FIRST RUN with no push head yet HOLDS the tick (never replays from genesis)", async () => {
|
|
492
|
+
const root = tmpRoot();
|
|
493
|
+
const res = await guardMessagingInbound(
|
|
494
|
+
{ event: { cadence: "messaging-inbound" }, agentRoot: root, log: () => {} },
|
|
495
|
+
{
|
|
496
|
+
cfg: { org: { cohort: { enabled: true, base: "https://x", token: "t" } } },
|
|
497
|
+
pushHeadImpl: () => null, // push channel has not established a head yet
|
|
498
|
+
pullImpl: async () => { throw new Error("must not pull before a head exists"); },
|
|
499
|
+
toInboxImpl: () => ({}),
|
|
500
|
+
writeImpl: () => { throw new Error("must not write"); },
|
|
501
|
+
},
|
|
502
|
+
);
|
|
503
|
+
assert.equal(res.ok, true);
|
|
504
|
+
assert.equal(res.decision, "inline");
|
|
505
|
+
assert.equal(res.routed, 0);
|
|
506
|
+
assert.match(res.reason, /awaiting push-channel head/);
|
|
507
|
+
assert.ok(!existsSync(join(root, "state", "messaging", "inbound-cursor.json")), "no cursor seeded while we wait for a head");
|
|
508
|
+
});
|
|
509
|
+
|
|
464
510
|
test("messaging-inbound: no new directed items → inline, routed 0, cursor unchanged", async () => {
|
|
465
511
|
const root = tmpRoot();
|
|
512
|
+
// A cursor already exists (not a first run), so seeding is skipped and the pull
|
|
513
|
+
// starts from the persisted seq. Nothing new → the cursor must not move.
|
|
514
|
+
const cursorFile = join(root, "state", "messaging", "inbound-cursor.json");
|
|
515
|
+
mkdirSync(join(root, "state", "messaging"), { recursive: true });
|
|
516
|
+
writeFileSync(cursorFile, JSON.stringify({ cursor: 9, updatedAt: "2026-01-01T00:00:00Z" }));
|
|
466
517
|
const res = await guardMessagingInbound(
|
|
467
518
|
{ event: { cadence: "messaging-inbound" }, agentRoot: root, log: () => {} },
|
|
468
519
|
{
|
|
469
520
|
cfg: { org: { cohort: { enabled: true, base: "https://x", token: "t" } } },
|
|
470
|
-
pullImpl: async ({ cursor }) => ({ events: [], nextCursor: cursor }
|
|
521
|
+
pullImpl: async ({ cursor }) => { assert.equal(cursor, 9, "pull starts from the persisted cursor"); return { events: [], nextCursor: cursor }; },
|
|
471
522
|
toInboxImpl: () => ({}),
|
|
472
523
|
writeImpl: () => { throw new Error("should not write"); },
|
|
473
524
|
},
|
|
474
525
|
);
|
|
475
526
|
assert.equal(res.decision, "inline");
|
|
476
527
|
assert.equal(res.routed, 0);
|
|
477
|
-
assert.
|
|
528
|
+
assert.equal(JSON.parse(readFileSync(cursorFile, "utf8")).cursor, 9, "cursor unchanged when nothing advanced");
|
|
478
529
|
});
|
|
479
530
|
|
|
480
531
|
test("messaging-inbound: org not configured → inline no-op (fail-open)", async () => {
|
|
@@ -496,6 +547,7 @@ test("messaging-inbound: MESSAGING_INBOUND_ESCALATE=1 escalates when items arriv
|
|
|
496
547
|
{ event: { cadence: "messaging-inbound" }, agentRoot: tmpRoot(), log: () => {} },
|
|
497
548
|
{
|
|
498
549
|
cfg: { org: { cohort: { enabled: true, base: "https://x", token: "t" } } },
|
|
550
|
+
pushHeadImpl: () => 1,
|
|
499
551
|
pullImpl: async () => ({ events: [{ message_id: "m1", text: "@me" }], nextCursor: 1 }),
|
|
500
552
|
toInboxImpl: (e) => ({ id: e.message_id }),
|
|
501
553
|
writeImpl: () => {},
|
|
@@ -514,6 +566,7 @@ test("messaging-inbound: fully fail-open (pull throws → inline, never throws)"
|
|
|
514
566
|
{ event: { cadence: "messaging-inbound" }, agentRoot: tmpRoot(), log: () => {} },
|
|
515
567
|
{
|
|
516
568
|
cfg: { org: { cohort: { enabled: true, base: "https://x", token: "t" } } },
|
|
569
|
+
pushHeadImpl: () => 3,
|
|
517
570
|
pullImpl: async () => { throw new Error("network down"); },
|
|
518
571
|
},
|
|
519
572
|
);
|
|
@@ -737,6 +790,7 @@ test("messaging-inbound (JOINT 3): the DEFAULT reader is wide — no family filt
|
|
|
737
790
|
{
|
|
738
791
|
cfg: { org: { cohort: { enabled: true, base: "https://x.test", token: "t" } } },
|
|
739
792
|
agentId: ME,
|
|
793
|
+
pushHeadImpl: () => 1, // fresh root: seed past genesis so the ledger read runs
|
|
740
794
|
fetchImpl,
|
|
741
795
|
toInboxImpl: (e) => ({ id: e.id, service: "cohort", kind: e.kind, content: e.text }),
|
|
742
796
|
writeImpl: (service, item) => written.push({ service, item }),
|
|
@@ -765,6 +819,7 @@ test("messaging-inbound (JOINT 3): MESSAGING_INBOUND_WIDE=0 falls back to the na
|
|
|
765
819
|
{
|
|
766
820
|
cfg: { org: { cohort: { enabled: true, base: "https://x.test", token: "t" } } },
|
|
767
821
|
agentId: "member-me",
|
|
822
|
+
pushHeadImpl: () => 1, // fresh root: seed past genesis so the read runs
|
|
768
823
|
// The narrow reader pins family=messaging,calling on its URL; assert the
|
|
769
824
|
// degradation is announced rather than inferred from an empty inbox.
|
|
770
825
|
fetchImpl: async () => ({ ok: true, status: 200, headers: { get: () => "application/json" }, text: async () => JSON.stringify({ events: [], nextCursor: 0 }), json: async () => ({ events: [], nextCursor: 0 }) }),
|
|
@@ -199,38 +199,63 @@ export function promoteDeferred(channel, agentRoot) {
|
|
|
199
199
|
matches.push({
|
|
200
200
|
file,
|
|
201
201
|
timestamp: readScalar(body, "timestamp") || "",
|
|
202
|
+
// THE SURFACE within the channel. `channel_id` names the ROOM; a single
|
|
203
|
+
// room carries several independent conversations at once — a directed 1:1
|
|
204
|
+
// DM thread AND the actor's own doc/board/decision events that resolve to
|
|
205
|
+
// the same channel_id. Bundling "latest-wins across the whole channel"
|
|
206
|
+
// folded those unrelated surfaces into one and silently dropped
|
|
207
|
+
// (`.processed-bundled`) a directed DM because a newer doc-comment event
|
|
208
|
+
// arrived in the same room. thread_context only ever carries the SAME
|
|
209
|
+
// thread's history, so bundling is sound ONLY within one surface. Key on
|
|
210
|
+
// thread_id, else the surface's own scope_id, else "" (a bare-channel
|
|
211
|
+
// burst — the original same-thread case, preserved).
|
|
212
|
+
surface:
|
|
213
|
+
readScalar(body, "thread_id") ||
|
|
214
|
+
readScalar(body, "scope_id") ||
|
|
215
|
+
"",
|
|
202
216
|
});
|
|
203
217
|
}
|
|
204
218
|
if (matches.length === 0) continue;
|
|
205
219
|
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const live = latest.file.replace(/\.deferred$/, "");
|
|
214
|
-
renameSync(join(inboxDir, latest.file), join(inboxDir, live));
|
|
215
|
-
result.promoted++;
|
|
216
|
-
result.service = service;
|
|
217
|
-
} catch {
|
|
218
|
-
// If the rename failed (race with another process), leave it
|
|
219
|
-
// as .deferred — the next session-close will retry.
|
|
220
|
+
// Group by surface within the channel, then bundle latest-wins WITHIN each
|
|
221
|
+
// group. Distinct surfaces each promote their own latest; none is dropped
|
|
222
|
+
// just because a newer message landed on a different surface in the room.
|
|
223
|
+
const groups = new Map();
|
|
224
|
+
for (const m of matches) {
|
|
225
|
+
if (!groups.has(m.surface)) groups.set(m.surface, []);
|
|
226
|
+
groups.get(m.surface).push(m);
|
|
220
227
|
}
|
|
221
228
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
229
|
+
for (const group of groups.values()) {
|
|
230
|
+
// Latest-wins: lex-sort ISO timestamps, take the most recent.
|
|
231
|
+
group.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
232
|
+
const latest = group[group.length - 1];
|
|
233
|
+
const older = group.slice(0, -1);
|
|
234
|
+
|
|
235
|
+
// Promote the latest back to live inbox.
|
|
226
236
|
try {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
result.bundled++;
|
|
237
|
+
const live = latest.file.replace(/\.deferred$/, "");
|
|
238
|
+
renameSync(join(inboxDir, latest.file), join(inboxDir, live));
|
|
239
|
+
result.promoted++;
|
|
240
|
+
result.service = service;
|
|
232
241
|
} catch {
|
|
233
|
-
//
|
|
242
|
+
// If the rename failed (race with another process), leave it
|
|
243
|
+
// as .deferred — the next session-close will retry.
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Mark older bursts as bundled — their content is already part of
|
|
247
|
+
// the latest item's thread_context, so they don't need their own
|
|
248
|
+
// session, but we preserve the file for audit.
|
|
249
|
+
for (const m of older) {
|
|
250
|
+
try {
|
|
251
|
+
renameSync(
|
|
252
|
+
join(inboxDir, m.file),
|
|
253
|
+
join(inboxDir, m.file.replace(/\.deferred$/, ".processed-bundled"))
|
|
254
|
+
);
|
|
255
|
+
result.bundled++;
|
|
256
|
+
} catch {
|
|
257
|
+
// Best-effort; missing file is fine.
|
|
258
|
+
}
|
|
234
259
|
}
|
|
235
260
|
}
|
|
236
261
|
}
|
|
@@ -12,11 +12,13 @@ function makeAgentRoot() {
|
|
|
12
12
|
return root;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
function writeInboxItem(root, name, { channel_id, timestamp, id = "test-id" }) {
|
|
15
|
+
function writeInboxItem(root, name, { channel_id, timestamp, id = "test-id", thread_id, scope_id }) {
|
|
16
16
|
const body = [
|
|
17
17
|
`id: "${id}"`,
|
|
18
18
|
`service: "slack"`,
|
|
19
19
|
`channel_id: "${channel_id}"`,
|
|
20
|
+
...(thread_id ? [`thread_id: "${thread_id}"`] : []),
|
|
21
|
+
...(scope_id ? [`scope_id: "${scope_id}"`] : []),
|
|
20
22
|
`timestamp: "${timestamp}"`,
|
|
21
23
|
`content: |`,
|
|
22
24
|
` body`,
|
|
@@ -118,6 +120,42 @@ test("promoteDeferred with multiple items: keeps latest, bundles rest", () => {
|
|
|
118
120
|
}
|
|
119
121
|
});
|
|
120
122
|
|
|
123
|
+
test("promoteDeferred bundles WITHIN a surface, not across the whole channel", () => {
|
|
124
|
+
// A directed 1:1 DM thread AND the actor's doc-comment events resolve to the
|
|
125
|
+
// same channel_id. Bundling latest-wins across the channel would drop the DM
|
|
126
|
+
// just because a newer doc event landed. Each surface must promote its own
|
|
127
|
+
// latest; nothing is bundled across surfaces.
|
|
128
|
+
const root = makeAgentRoot();
|
|
129
|
+
try {
|
|
130
|
+
// The directed DM (one message, thread T-DM).
|
|
131
|
+
writeInboxItem(root, "dm-1.yaml.deferred", {
|
|
132
|
+
channel_id: "room-1", thread_id: "T-DM", id: "dm-1",
|
|
133
|
+
timestamp: "2026-05-13T00:00:30Z",
|
|
134
|
+
});
|
|
135
|
+
// Two doc-comment events on the same room but a different surface (scope S-DOC),
|
|
136
|
+
// the later of which would have "won" under the old channel-wide bundling.
|
|
137
|
+
writeInboxItem(root, "doc-1.yaml.deferred", {
|
|
138
|
+
channel_id: "room-1", scope_id: "S-DOC", id: "doc-1",
|
|
139
|
+
timestamp: "2026-05-13T00:01:00Z",
|
|
140
|
+
});
|
|
141
|
+
writeInboxItem(root, "doc-2.yaml.deferred", {
|
|
142
|
+
channel_id: "room-1", scope_id: "S-DOC", id: "doc-2",
|
|
143
|
+
timestamp: "2026-05-13T00:02:00Z",
|
|
144
|
+
});
|
|
145
|
+
const r = promoteDeferred("room-1", root);
|
|
146
|
+
assert.equal(r.promoted, 2, "the DM and the latest doc event are BOTH promoted");
|
|
147
|
+
assert.equal(r.bundled, 1, "only the older doc event (same surface) is bundled");
|
|
148
|
+
const files = readdirSync(join(root, "state", "inbox", "slack")).sort();
|
|
149
|
+
assert.deepEqual(files, [
|
|
150
|
+
"dm-1.yaml", // the directed DM survives — NOT dropped
|
|
151
|
+
"doc-1.yaml.processed-bundled", // older doc event folded into doc-2
|
|
152
|
+
"doc-2.yaml", // latest doc event promoted
|
|
153
|
+
]);
|
|
154
|
+
} finally {
|
|
155
|
+
rmSync(root, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
121
159
|
test("promoteDeferred ignores items in other channels", () => {
|
|
122
160
|
const root = makeAgentRoot();
|
|
123
161
|
try {
|
|
@@ -901,6 +901,15 @@ export async function sendQuickResponse(item, classResult, routed = null) {
|
|
|
901
901
|
via: delivered.via,
|
|
902
902
|
...(delivered.channel ? { channel: delivered.channel } : {}),
|
|
903
903
|
...(delivered.draft_path ? { draft_path: delivered.draft_path } : {}),
|
|
904
|
+
// PROPAGATE THE PERMANENCE. `deliver` marks a failure the send-gate refused
|
|
905
|
+
// (FORBIDDEN_SCOPE) or hq declared unroutable (NOT_FOUND/BAD_REQUEST) as
|
|
906
|
+
// `permanent` with the RPC `code`. Without carrying these up, the caller
|
|
907
|
+
// (agent-daemon.answerItem) could not tell a transient blip from an
|
|
908
|
+
// impossible send, so it fell through to a full autonomous session — which
|
|
909
|
+
// then posted UNRELATED work into a channel this seat was just told it may
|
|
910
|
+
// not write to. The daemon uses `permanent` to escalate instead of spawn.
|
|
911
|
+
...(delivered.permanent ? { permanent: true } : {}),
|
|
912
|
+
...(delivered.code ? { code: delivered.code } : {}),
|
|
904
913
|
...(delivered.error ? { error: delivered.error } : {}),
|
|
905
914
|
};
|
|
906
915
|
|
|
@@ -111,6 +111,13 @@ export function parseInboxItemYaml(body) {
|
|
|
111
111
|
if (channelType) item.channel_type = channelType;
|
|
112
112
|
if (isPrivate !== undefined) item.is_private = isPrivate;
|
|
113
113
|
if (isDm !== undefined) item.is_dm = isDm;
|
|
114
|
+
// THE CHANNEL KIND. Cohort's only reliable DM signal, written by
|
|
115
|
+
// scripts/poller/utils.inboxItemToYaml when present. Attached only when the
|
|
116
|
+
// writer emitted it so a non-Cohort item keeps its old shape; the daemon's
|
|
117
|
+
// enrichItem reads it to tell a 1:1 DM (skips the election) from a GROUP_DM
|
|
118
|
+
// (goes through the election as a multi-participant channel).
|
|
119
|
+
const channelKind = scalar("channel_kind");
|
|
120
|
+
if (channelKind) item.channel_kind = channelKind;
|
|
114
121
|
// The hq chain kind, when the org projection carried one through the writer.
|
|
115
122
|
// Attached only when present so a non-org item keeps exactly its old shape;
|
|
116
123
|
// `lib/execution/intake.fromInboxItem` prefers it over the surface so an
|
package/scripts/poller/utils.mjs
CHANGED
|
@@ -239,6 +239,17 @@ raw_ref: "${item.raw_ref || ""}"
|
|
|
239
239
|
yaml += `channel_id: "${item.channel_id}"\n`;
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
// THE CHANNEL KIND (DM / GROUP_DM / PUBLIC / …). Cohort's only reliable DM
|
|
243
|
+
// signal (its channel ids are cuids, so the "D…"/"dm/" prefix heuristics never
|
|
244
|
+
// fire). Written ONLY when present so a non-Cohort item is byte-identical to
|
|
245
|
+
// before; the daemon's `enrichItem` reads it to route a 1:1 DM past the group
|
|
246
|
+
// election gate while a GROUP_DM still goes through it. Enumerated writers like
|
|
247
|
+
// this one drop anything not named here, which is exactly how the signal was
|
|
248
|
+
// lost between eventToInboxItem and parseInboxItemYaml.
|
|
249
|
+
if (item.channel_kind) {
|
|
250
|
+
yaml += `channel_kind: "${item.channel_kind}"\n`;
|
|
251
|
+
}
|
|
252
|
+
|
|
242
253
|
// CONFIDENTIALITY SIGNALS. Slack's conversation-ID prefix is not a reliable
|
|
243
254
|
// type indicator (a private channel can carry a `C…` id), so downstream
|
|
244
255
|
// consumers — notably the daemon's fail-closed outcome gate,
|