@cohortapp/agent-sdk 2.18.15 → 2.18.17
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/org/inbound/hydrate.mjs +35 -1
- package/lib/org/inbound/project.mjs +3 -0
- package/lib/session/frontdoor.mjs +105 -8
- package/lib/session/handoffs.mjs +57 -0
- package/lib/session/inbox-claims.mjs +106 -2
- package/lib/session/revive.mjs +302 -5
- package/lib/telemetry/collect.mjs +224 -0
- package/package.json +1 -1
- package/scripts/daemon/agent-daemon.mjs +262 -10
- package/scripts/daemon/assurance.mjs +56 -1
- package/scripts/daemon/deliver.mjs +80 -0
- package/scripts/fleet/rollout.mjs +48 -4
- package/scripts/hooks/pre-write-yaml-validate.mjs +63 -2
- package/scripts/local-triggers/autoupdate.sh +287 -22
|
@@ -432,7 +432,29 @@ function hydrateMessage(c, facts) {
|
|
|
432
432
|
const page = facts.channelPages instanceof Map ? facts.channelPages.get(s(c.ids.channelId)) : null;
|
|
433
433
|
const msg = page && page.byId ? page.byId.get(s(c.ids.messageId)) : null;
|
|
434
434
|
if (!msg) return { ok: false, reason: "message_not_in_page", text: "" };
|
|
435
|
-
|
|
435
|
+
let body = s(msg.body ?? msg.text).trim();
|
|
436
|
+
|
|
437
|
+
// VOICE_NOTE_OVERLAY (2026-09-26; pb-2026-09-26-inbound-voice-notes-reach-agents-as-fileid-only).
|
|
438
|
+
// A voice note / attachment-only message arrives with an EMPTY body + attachments[]. Treating that
|
|
439
|
+
// as empty_body dropped the whole event. Server carries no transcript + no byte route, so the honest
|
|
440
|
+
// text is what the attachment IS and that it couldn't be heard; metadata rides on the event.
|
|
441
|
+
const atts = Array.isArray(msg.attachments) ? msg.attachments.filter((a) => a && typeof a === "object") : [];
|
|
442
|
+
if (!body && atts.length) {
|
|
443
|
+
body = atts
|
|
444
|
+
.map((a) => {
|
|
445
|
+
const name = s(a.name || a.filename || a.fileId || "attachment");
|
|
446
|
+
const mime = s(a.contentType || a.mime || a.mimetype || "");
|
|
447
|
+
const bytes = Number(a.sizeBytes ?? a.size_bytes ?? a.size ?? 0);
|
|
448
|
+
const secs = Number(a.durationSec ?? a.duration_sec ?? a.duration ?? 0);
|
|
449
|
+
const isAudio = /^audio\//.test(mime) || secs > 0;
|
|
450
|
+
const tx = s(a.transcript);
|
|
451
|
+
return tx
|
|
452
|
+
? `[${isAudio ? "voice note" : "attachment"} ${name}] ${tx}`
|
|
453
|
+
: `[${isAudio ? "voice note" : "attachment"}: ${name}${mime ? ", " + mime : ""}${bytes ? ", " + bytes + " bytes" : ""}${secs ? ", " + secs + " s" : ""}; no transcript available on this seat]`;
|
|
454
|
+
})
|
|
455
|
+
.join("\n");
|
|
456
|
+
}
|
|
457
|
+
|
|
436
458
|
if (!body) return { ok: false, reason: "empty_body", text: "" };
|
|
437
459
|
|
|
438
460
|
const chan = facts.channels instanceof Map ? facts.channels.get(s(c.ids.channelId)) : null;
|
|
@@ -465,6 +487,18 @@ function hydrateMessage(c, facts) {
|
|
|
465
487
|
from: { id: s(msg.authorId || c.actor), name: s(msg.authorName || msg.authorId || c.actor) },
|
|
466
488
|
channelId: s(c.ids.channelId),
|
|
467
489
|
channelLabel: s((chan && (chan.name || chan.slug)) || c.ids.channelId),
|
|
490
|
+
// VOICE_NOTE_OVERLAY: attachment metadata rides on the event so
|
|
491
|
+
// lib/channels/inbox-item.mjs writes attachments[] (and transcript when present).
|
|
492
|
+
attachments: atts.length
|
|
493
|
+
? atts.map((a) => ({
|
|
494
|
+
id: s(a.fileId || a.id),
|
|
495
|
+
name: s(a.name || a.filename || "attachment"),
|
|
496
|
+
mimetype: s(a.contentType || a.mime || a.mimetype || "application/octet-stream"),
|
|
497
|
+
size: Number(a.sizeBytes ?? a.size_bytes ?? a.size ?? 0),
|
|
498
|
+
duration_sec: Number(a.durationSec ?? a.duration_sec ?? 0) || undefined,
|
|
499
|
+
...(s(a.transcript) ? { transcript: s(a.transcript) } : {}),
|
|
500
|
+
}))
|
|
501
|
+
: undefined,
|
|
468
502
|
};
|
|
469
503
|
}
|
|
470
504
|
|
|
@@ -116,6 +116,9 @@ export function toMessageEvent(o = {}) {
|
|
|
116
116
|
sender: s(from.name || from.id),
|
|
117
117
|
is_reply: Boolean(hydrated.isReply),
|
|
118
118
|
thread_context: hydrated.threadContext != null ? hydrated.threadContext : null,
|
|
119
|
+
// VOICE_NOTE_OVERLAY (2026-09-26): attachment metadata from the hydrator rides on the event so
|
|
120
|
+
// lib/channels/inbox-item.mjs writes attachments[] (and a transcript when one exists).
|
|
121
|
+
...(Array.isArray(hydrated.attachments) && hydrated.attachments.length ? { attachments: hydrated.attachments } : {}),
|
|
119
122
|
priority_signals: {
|
|
120
123
|
from_ceo: false,
|
|
121
124
|
tagged_urgent: URGENT.test(text) || c.kind === "board.blocked" || c.family === "escalation",
|
|
@@ -3,14 +3,29 @@
|
|
|
3
3
|
* or the daemon's legacy `claude --print` lane (design §3.1, §3.3, §3.4).
|
|
4
4
|
*
|
|
5
5
|
* THE ONE RULE. When the seat's main session is the configured front door AND
|
|
6
|
-
* it is provably live (a fresh `state/session/heartbeat.json`)
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* it is provably live (a fresh `state/session/heartbeat.json`) AND it has
|
|
7
|
+
* actually CONSUMED inbound within the assurance window, the daemon stops
|
|
8
|
+
* spawning for the surfaces the session owns: Cohort inbox items are left in
|
|
9
|
+
* place for the session to claim, and escalate/guarded cadence ticks are
|
|
10
|
+
* written as handoffs for the session to ack. When the session is NOT live —
|
|
11
|
+
* no heartbeat, a stale one, or the front door is `daemon` — the daemon
|
|
11
12
|
* dispatches exactly as it always has. Liveness is MEASURED, never assumed,
|
|
12
13
|
* and the fallback is the legacy lane, so nothing is ever dropped.
|
|
13
14
|
*
|
|
15
|
+
* A WRITTEN HEARTBEAT IS NOT A CONSUMED INBOX (DM-LATENCY). A session answers
|
|
16
|
+
* inbound only between its own tool calls; it can sit in a long catch-up turn,
|
|
17
|
+
* beating the whole time, without ever draining the inbox. "Live" and "not
|
|
18
|
+
* draining" are then both true and nobody answers a slow DM. So liveness alone
|
|
19
|
+
* no longer holds the item: the session must ALSO have proved a consume — an
|
|
20
|
+
* inbox claim/reply/done or a handoff ack, both of which stamp the ONE
|
|
21
|
+
* `state/session/last-consumed.json` file (see lib/session/handoffs.mjs). A
|
|
22
|
+
* fresh beat with a stale last-consume is a beating-but-wedged front door, and
|
|
23
|
+
* the daemon takes the item rather than let it go unanswered. The window is
|
|
24
|
+
* the 20-min assurance sweep interval, NOT the 90-s heartbeat: a session mid
|
|
25
|
+
* long turn is normal and must not lose the item on a heartbeat-length silence.
|
|
26
|
+
* The consume signal only ever *loses* the session the item — a missing or
|
|
27
|
+
* unreadable stamp fails open to the daemon taking it, never to dropping it.
|
|
28
|
+
*
|
|
14
29
|
* PURE CORE. `sessionLiveFromHeartbeat`, `resolveFrontDoor`,
|
|
15
30
|
* `frontDoorServices`, `shouldDaemonDispatch` and `shouldHandOffTick` take
|
|
16
31
|
* every input as a parameter — the parsed heartbeat object, the clock, the
|
|
@@ -28,6 +43,7 @@
|
|
|
28
43
|
import { join } from "node:path";
|
|
29
44
|
import * as nodeFs from "node:fs";
|
|
30
45
|
import { createRequire } from "node:module";
|
|
46
|
+
import { readLastConsumed } from "./handoffs.mjs";
|
|
31
47
|
|
|
32
48
|
// js-yaml is a declared dependency; loaded synchronously so the reader stays
|
|
33
49
|
// sync. Fail-open: without it the flat line parser below still reads the
|
|
@@ -40,6 +56,16 @@ try {
|
|
|
40
56
|
|
|
41
57
|
/** A heartbeat older than this is stale — the session is treated as not live. */
|
|
42
58
|
export const DEFAULT_STALE_MS = 90_000;
|
|
59
|
+
/**
|
|
60
|
+
* A last-consume older than this is stale — the session is beating but has not
|
|
61
|
+
* proved it drained inbound, so the daemon takes the item. This is the 20-min
|
|
62
|
+
* assurance-sweep interval, deliberately much longer than DEFAULT_STALE_MS: a
|
|
63
|
+
* session mid a long catch-up turn is normal and must not lose the item on a
|
|
64
|
+
* heartbeat-length silence.
|
|
65
|
+
*/
|
|
66
|
+
export const DEFAULT_CONSUMED_STALE_MS = 20 * 60 * 1000;
|
|
67
|
+
/** Where the session runtime stamps its last inbound consume (relative to the agent root). */
|
|
68
|
+
export const LAST_CONSUMED_RELATIVE = "state/session/last-consumed.json";
|
|
43
69
|
/** Inbox services the session fronts by default. */
|
|
44
70
|
export const DEFAULT_FRONT_DOOR_SERVICES = Object.freeze(["cohort"]);
|
|
45
71
|
/** Cadence modes whose tick spawns a sub-session — the ones a live session takes over. */
|
|
@@ -75,6 +101,30 @@ export function sessionLiveFromHeartbeat(heartbeat, o = {}) {
|
|
|
75
101
|
return { live: true, reason: "fresh", ageMs };
|
|
76
102
|
}
|
|
77
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Has the front door CONSUMED inbound recently, given its last-consume epoch
|
|
106
|
+
* ms (from `state/session/last-consumed.json`, stamped by an inbox
|
|
107
|
+
* claim/reply/done or a handoff ack)? Pure. `null`/`undefined`/non-finite
|
|
108
|
+
* means "never proved a consume" — NOT "just now" — so it reads as not
|
|
109
|
+
* consumed and the daemon takes the item (fail-open in the safe direction:
|
|
110
|
+
* the item is answered, never dropped). A stamp a moment in the future is
|
|
111
|
+
* writer/reader clock skew, not a broken read, and counts as consumed.
|
|
112
|
+
*
|
|
113
|
+
* @param {number|null|undefined} lastConsumedAt epoch ms, or nullish = never
|
|
114
|
+
* @param {object} [o]
|
|
115
|
+
* @param {number} [o.now] epoch ms
|
|
116
|
+
* @param {number} [o.staleMs] window (default DEFAULT_CONSUMED_STALE_MS)
|
|
117
|
+
* @returns {{consumed:boolean, reason:string, ageMs:number|null}}
|
|
118
|
+
*/
|
|
119
|
+
export function sessionConsumedRecently(lastConsumedAt, o = {}) {
|
|
120
|
+
const now = typeof o.now === "number" ? o.now : Date.now();
|
|
121
|
+
const staleMs = Number.isFinite(o.staleMs) && o.staleMs > 0 ? o.staleMs : DEFAULT_CONSUMED_STALE_MS;
|
|
122
|
+
if (!Number.isFinite(lastConsumedAt)) return { consumed: false, reason: "never-consumed", ageMs: null };
|
|
123
|
+
const ageMs = now - lastConsumedAt;
|
|
124
|
+
if (ageMs > staleMs) return { consumed: false, reason: "consumed-stale", ageMs };
|
|
125
|
+
return { consumed: true, reason: "consumed", ageMs };
|
|
126
|
+
}
|
|
127
|
+
|
|
78
128
|
/**
|
|
79
129
|
* Which process is the front door. `MAESTRO_FRONT_DOOR` in the env wins, then
|
|
80
130
|
* `frontDoor` / `front_door` in config/session.yaml (a string, or an object
|
|
@@ -116,16 +166,46 @@ export function frontDoorServices(config) {
|
|
|
116
166
|
* `dispatch:false` means "leave the item in place — the live session owns it".
|
|
117
167
|
* Any malformed input resolves to dispatch (fail-open: never drop an item).
|
|
118
168
|
*
|
|
119
|
-
*
|
|
169
|
+
* The gate is two clauses now: the session must be the live front door for the
|
|
170
|
+
* service AND it must have consumed inbound within the window. A live session
|
|
171
|
+
* that has stopped draining the inbox (`sessionConsumed:false`) loses the item
|
|
172
|
+
* to the daemon — a beating heartbeat is not a consumed inbox (DM-LATENCY).
|
|
173
|
+
* The consumed clause is opt-in on an explicit `false`: an absent
|
|
174
|
+
* `sessionConsumed` (an older reader, a direct caller) never steals the item,
|
|
175
|
+
* so behaviour without the field is exactly the pre-DM-latency gate.
|
|
176
|
+
*
|
|
177
|
+
* PER-ITEM OVERRIDE — `itemHandled` (DM-LATENCY DOUBLE-ANSWER, 2026-09-27). The
|
|
178
|
+
* `sessionConsumed` clause is a SEAT-WIDE fact: a live door that has not stamped
|
|
179
|
+
* `last-consumed.json` looks not-consuming for EVERY item, so the daemon takes
|
|
180
|
+
* over the whole service. But a door that has already answered a SPECIFIC item
|
|
181
|
+
* left a marker on that item on disk — a terminal `.yaml.processed`, or a live
|
|
182
|
+
* `claimed_by: session` `.yaml.dispatched` — through `maestro inbox
|
|
183
|
+
* claim/reply/done`, which is OLD code every door already runs REGARDLESS of
|
|
184
|
+
* whether it restarted onto the stamp path. So when the caller has read that
|
|
185
|
+
* marker and passes `itemHandled:true`, the daemon must NOT re-take that item,
|
|
186
|
+
* even mid-takeover — this is what closes the double-answer without requiring a
|
|
187
|
+
* fleet restart. It short-circuits FIRST and unconditionally: an already-handled
|
|
188
|
+
* item is the door's whether the door is live, wedged or dead. Opt-in on an
|
|
189
|
+
* explicit `true`: an absent `itemHandled` is exactly the pre-fix gate.
|
|
190
|
+
*
|
|
191
|
+
* @param {{frontDoor?:string, sessionLive?:boolean, sessionConsumed?:boolean, itemHandled?:boolean, service?:string, services?:string[]}} a
|
|
120
192
|
* @returns {{dispatch:boolean, reason:string}}
|
|
121
193
|
*/
|
|
122
194
|
export function shouldDaemonDispatch(a) {
|
|
123
195
|
const x = a && typeof a === "object" ? a : {};
|
|
196
|
+
// A specific item the door already holds on disk is never re-taken — not even
|
|
197
|
+
// when the seat-wide gate would open because a live door has not stamped
|
|
198
|
+
// last-consumed. See PER-ITEM OVERRIDE above.
|
|
199
|
+
if (x.itemHandled === true) return { dispatch: false, reason: "item-already-handled" };
|
|
124
200
|
if (x.frontDoor !== "session") return { dispatch: true, reason: "front-door-daemon" };
|
|
125
201
|
if (x.sessionLive !== true) return { dispatch: true, reason: "session-not-live" };
|
|
126
202
|
const services = Array.isArray(x.services) && x.services.length ? x.services : DEFAULT_FRONT_DOOR_SERVICES;
|
|
127
203
|
const svc = String(x.service || "").trim().toLowerCase();
|
|
128
204
|
if (!svc || !services.includes(svc)) return { dispatch: true, reason: "service-not-front-door" };
|
|
205
|
+
// Live and owns the service, but has it actually drained inbound? A live
|
|
206
|
+
// front door that has not consumed within the window is wedged mid-turn —
|
|
207
|
+
// the daemon takes the item so a slow DM is not left unanswered.
|
|
208
|
+
if (x.sessionConsumed === false) return { dispatch: true, reason: "session-not-consuming" };
|
|
129
209
|
return { dispatch: false, reason: "session-live" };
|
|
130
210
|
}
|
|
131
211
|
|
|
@@ -187,9 +267,10 @@ function parseSessionYaml(text, yamlParse) {
|
|
|
187
267
|
* @param {object} [deps.fs] `{existsSync, readFileSync}` (default node:fs)
|
|
188
268
|
* @param {object} [deps.env] (default process.env)
|
|
189
269
|
* @param {number} [deps.now] epoch ms
|
|
190
|
-
* @param {number} [deps.staleMs]
|
|
270
|
+
* @param {number} [deps.staleMs] heartbeat freshness window
|
|
271
|
+
* @param {number} [deps.consumedStaleMs] last-consume freshness window (default DEFAULT_CONSUMED_STALE_MS)
|
|
191
272
|
* @param {Function} [deps.yamlParse] optional YAML parser (js-yaml `load`)
|
|
192
|
-
* @returns {{frontDoor:"session"|"daemon", sessionLive:boolean, services:string[], liveness:object, heartbeat:object|null}}
|
|
273
|
+
* @returns {{frontDoor:"session"|"daemon", sessionLive:boolean, sessionConsumed:boolean, lastConsumedAt:number|null, services:string[], liveness:object, consumed:object, heartbeat:object|null}}
|
|
193
274
|
*/
|
|
194
275
|
export function readFrontDoorState(agentRoot, deps = {}) {
|
|
195
276
|
const fs = deps.fs || nodeFs;
|
|
@@ -209,11 +290,24 @@ export function readFrontDoorState(agentRoot, deps = {}) {
|
|
|
209
290
|
}
|
|
210
291
|
} catch { heartbeat = null; /* corrupt heartbeat → not live → legacy lane (fail-open) */ }
|
|
211
292
|
const liveness = sessionLiveFromHeartbeat(heartbeat, { now, staleMs: deps.staleMs });
|
|
293
|
+
// The other half of "provably answering": has the front door drained inbound
|
|
294
|
+
// within the window? `readLastConsumed` (lib/session/handoffs.mjs) reads the
|
|
295
|
+
// ONE last-consumed.json both consume paths stamp; a missing/unreadable
|
|
296
|
+
// stamp is `null` and reads as not-consumed (fail-open to the daemon taking
|
|
297
|
+
// the item). Kept SEPARATE from `sessionLive`: an alive-but-unconsumed
|
|
298
|
+
// session is still alive, so the revive path (which reads `sessionLive`)
|
|
299
|
+
// must not fire on it — only the dispatch gate reacts to `sessionConsumed`.
|
|
300
|
+
let lastConsumedAt = null;
|
|
301
|
+
try { lastConsumedAt = readLastConsumed(agentRoot, { fs }); } catch { lastConsumedAt = null; }
|
|
302
|
+
const consumed = sessionConsumedRecently(lastConsumedAt, { now, staleMs: deps.consumedStaleMs });
|
|
212
303
|
return {
|
|
213
304
|
frontDoor: resolveFrontDoor({ env, config }),
|
|
214
305
|
sessionLive: liveness.live,
|
|
306
|
+
sessionConsumed: consumed.consumed,
|
|
307
|
+
lastConsumedAt,
|
|
215
308
|
services: frontDoorServices(config),
|
|
216
309
|
liveness,
|
|
310
|
+
consumed,
|
|
217
311
|
heartbeat,
|
|
218
312
|
};
|
|
219
313
|
}
|
|
@@ -254,9 +348,12 @@ export function makeFrontDoorGate(o = {}) {
|
|
|
254
348
|
|
|
255
349
|
export default {
|
|
256
350
|
DEFAULT_STALE_MS,
|
|
351
|
+
DEFAULT_CONSUMED_STALE_MS,
|
|
352
|
+
LAST_CONSUMED_RELATIVE,
|
|
257
353
|
DEFAULT_FRONT_DOOR_SERVICES,
|
|
258
354
|
HANDOFF_MODES,
|
|
259
355
|
sessionLiveFromHeartbeat,
|
|
356
|
+
sessionConsumedRecently,
|
|
260
357
|
resolveFrontDoor,
|
|
261
358
|
frontDoorServices,
|
|
262
359
|
shouldDaemonDispatch,
|
package/lib/session/handoffs.mjs
CHANGED
|
@@ -28,6 +28,14 @@ export const DEFAULT_HANDOFF_DEADLINE_MS = 30 * 60_000;
|
|
|
28
28
|
export const DEFAULT_HANDOFF_RETENTION_MS = 7 * 24 * 60 * 60_000;
|
|
29
29
|
/** Handoff directory (relative to the agent root). */
|
|
30
30
|
export const HANDOFFS_RELATIVE = "state/session/handoffs";
|
|
31
|
+
/**
|
|
32
|
+
* WHEN the front door last consumed inbound (DARK-SEAT wedged-verdict).
|
|
33
|
+
* There are two consume paths — acking a handoff (this module, below) and an
|
|
34
|
+
* inbound claim/reply/done (session-runtime) — and both stamp this ONE file,
|
|
35
|
+
* so `collect.mjs#sessionWedge` has a single fact to read regardless of which
|
|
36
|
+
* path fired. Relative to the agent root, like every other state/ path here.
|
|
37
|
+
*/
|
|
38
|
+
export const LAST_CONSUMED_RELATIVE = "state/session/last-consumed.json";
|
|
31
39
|
|
|
32
40
|
// A tick id is a bus event id (`evt-<iso>-<hex>`) or a test literal. It is a
|
|
33
41
|
// path segment, so anything that could climb out of the directory is refused.
|
|
@@ -139,6 +147,47 @@ export function readHandoff(agentRoot, tickId, deps = {}) {
|
|
|
139
147
|
return rec ? { ...rec, path } : null;
|
|
140
148
|
}
|
|
141
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Stamp `LAST_CONSUMED_RELATIVE` with the current instant — the front door
|
|
152
|
+
* just consumed inbound. Best-effort by design: every caller of this (an ack,
|
|
153
|
+
* an inbound claim) has its own success/failure to report, and a stamp that
|
|
154
|
+
* cannot be written must never turn an otherwise-successful consume into a
|
|
155
|
+
* failure. Callers that need to know still get `{ok:false, error}` back; they
|
|
156
|
+
* are just not obliged to act on it.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} agentRoot
|
|
159
|
+
* @param {{now?:number, fs?:object, writeJson?:Function}} [o]
|
|
160
|
+
* @returns {{ok:true, path:string}|{ok:false, error:string}}
|
|
161
|
+
*/
|
|
162
|
+
export function stampConsumed(agentRoot, o = {}) {
|
|
163
|
+
const fs = o.fs || nodeFs;
|
|
164
|
+
const writeJson = o.writeJson || writeJsonAtomicDefault;
|
|
165
|
+
const path = join(agentRoot, LAST_CONSUMED_RELATIVE);
|
|
166
|
+
try {
|
|
167
|
+
fs.mkdirSync(join(agentRoot, "state", "session"), { recursive: true });
|
|
168
|
+
writeJson(path, { ts: iso(nowOf(o)) });
|
|
169
|
+
return { ok: true, path };
|
|
170
|
+
} catch (err) {
|
|
171
|
+
return { ok: false, error: err && err.message ? err.message : String(err) };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Read `LAST_CONSUMED_RELATIVE` back as epoch ms, or `null` when absent or
|
|
177
|
+
* unreadable — fail-open, like every read in this ledger. `sessionWedge`
|
|
178
|
+
* treats `null` the same as "never consumed", not as "just now".
|
|
179
|
+
*
|
|
180
|
+
* @param {string} agentRoot
|
|
181
|
+
* @param {{fs?:object}} [deps]
|
|
182
|
+
* @returns {number|null}
|
|
183
|
+
*/
|
|
184
|
+
export function readLastConsumed(agentRoot, deps = {}) {
|
|
185
|
+
const fs = deps.fs || nodeFs;
|
|
186
|
+
const rec = readJson(fs, join(agentRoot, LAST_CONSUMED_RELATIVE));
|
|
187
|
+
const ms = rec ? Date.parse(rec.ts || "") : NaN;
|
|
188
|
+
return Number.isFinite(ms) ? ms : null;
|
|
189
|
+
}
|
|
190
|
+
|
|
142
191
|
/** Move an open handoff into done/ with a terminal status. */
|
|
143
192
|
function retire(agentRoot, rec, patch, deps) {
|
|
144
193
|
const fs = deps.fs || nodeFs;
|
|
@@ -181,6 +230,11 @@ export function ackHandoff(agentRoot, tickId, o = {}) {
|
|
|
181
230
|
resultPath: o.resultPath || null,
|
|
182
231
|
...(o.note ? { note: String(o.note) } : {}),
|
|
183
232
|
}, o);
|
|
233
|
+
// Consume path #1 (DARK-SEAT wedged-verdict): acking a handoff is proof
|
|
234
|
+
// the front door read and answered inbound. Best-effort — a stamp that
|
|
235
|
+
// fails to write must never turn a completed ack into a reported failure;
|
|
236
|
+
// the wedge verdict simply falls back to whatever it saw before.
|
|
237
|
+
try { stampConsumed(agentRoot, o); } catch { /* the ack already succeeded */ }
|
|
184
238
|
return { ok: true, path };
|
|
185
239
|
} catch (err) {
|
|
186
240
|
return { ok: false, error: err && err.message ? err.message : String(err) };
|
|
@@ -285,6 +339,7 @@ export default {
|
|
|
285
339
|
DEFAULT_HANDOFF_DEADLINE_MS,
|
|
286
340
|
DEFAULT_HANDOFF_RETENTION_MS,
|
|
287
341
|
HANDOFFS_RELATIVE,
|
|
342
|
+
LAST_CONSUMED_RELATIVE,
|
|
288
343
|
handoffPaths,
|
|
289
344
|
writeHandoff,
|
|
290
345
|
listHandoffs,
|
|
@@ -292,4 +347,6 @@ export default {
|
|
|
292
347
|
ackHandoff,
|
|
293
348
|
expireHandoffs,
|
|
294
349
|
pruneHandoffs,
|
|
350
|
+
stampConsumed,
|
|
351
|
+
readLastConsumed,
|
|
295
352
|
};
|
|
@@ -43,6 +43,7 @@ import { join } from "node:path";
|
|
|
43
43
|
import { readdirSync, readFileSync, existsSync, renameSync, statSync, openSync, ftruncateSync, writeSync, closeSync } from "node:fs";
|
|
44
44
|
import { writeFileAtomic } from "../fs-atomic.mjs";
|
|
45
45
|
import { readFrontDoorState } from "./frontdoor.mjs";
|
|
46
|
+
import { stampConsumed } from "./handoffs.mjs";
|
|
46
47
|
|
|
47
48
|
/** A session claim neither replied nor done within this window is reopened. */
|
|
48
49
|
export const SESSION_CLAIM_STALE_MS = 20 * 60_000;
|
|
@@ -68,6 +69,21 @@ function iso(ms) { return new Date(ms).toISOString(); }
|
|
|
68
69
|
function nowOf(o) { return typeof o.now === "number" ? o.now : Date.now(); }
|
|
69
70
|
function q(v) { return `"${String(v).replace(/[\r\n]+/g, " ").replace(/"/g, "'")}"`; }
|
|
70
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Stamp the ONE `state/session/last-consumed.json` the front door reads to
|
|
74
|
+
* decide whether a live session is actually draining inbound (DM-LATENCY).
|
|
75
|
+
* This is the inbound consume path — a claim/reply/done — the mirror of the
|
|
76
|
+
* handoff-ack path in lib/session/handoffs.mjs; both stamp the same file, so
|
|
77
|
+
* `frontdoor.readFrontDoorState` has a single fact to read. Best-effort by
|
|
78
|
+
* design: a stamp that cannot be written must never turn an otherwise
|
|
79
|
+
* successful claim/reply/done into a failure. The injected `writeConsumed`
|
|
80
|
+
* seam (default `stampConsumed`) lets a test drive the failure path.
|
|
81
|
+
*/
|
|
82
|
+
function noteConsumed(agentRoot, o = {}) {
|
|
83
|
+
const stamp = typeof o.writeConsumed === "function" ? o.writeConsumed : stampConsumed;
|
|
84
|
+
try { stamp(agentRoot, { now: nowOf(o) }); } catch { /* the caller's own transition already succeeded */ }
|
|
85
|
+
}
|
|
86
|
+
|
|
71
87
|
/**
|
|
72
88
|
* The marker lines to append below an item.
|
|
73
89
|
* @param {{claimedBy?:string, claimedAt?:string, now?:number, deferredUntil?:number|string, deferredReason?:string, repliedAt?:number|string}} o
|
|
@@ -194,6 +210,64 @@ export function findInboxFile(agentRoot, id, o = {}) {
|
|
|
194
210
|
return looseIds.size === 1 ? loose : null;
|
|
195
211
|
}
|
|
196
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Does the front door ALREADY hold this specific inbound item on disk? A
|
|
215
|
+
* terminal `.yaml.processed` / `.yaml.processed-bundled` marker (the door read
|
|
216
|
+
* it and ran `inbox done`), or a live `claimed_by: session` `.yaml.dispatched`
|
|
217
|
+
* claim, both mean the door has this item — so a daemon takeover must NOT answer
|
|
218
|
+
* it a second time in the seat's name (DM-LATENCY DOUBLE-ANSWER, 2026-09-27),
|
|
219
|
+
* even when `last-consumed.json` was never stamped because the door has not
|
|
220
|
+
* restarted onto the stamp path.
|
|
221
|
+
*
|
|
222
|
+
* Keyed on the inbound id / raw_ref across the WHOLE service dir, NOT on an
|
|
223
|
+
* exact filename — and that is the crux of the fix. `writeInboxItem`
|
|
224
|
+
* (scripts/poller/utils.mjs) dedups on the exact `<ts>-<id>` base, so a re-fetch
|
|
225
|
+
* that lands the SAME message under a different timestamp base slips its dedup
|
|
226
|
+
* and writes a fresh live `.yaml` beside the door's terminal file. The seat then
|
|
227
|
+
* carries two files for one message — one answered, one live — and the live one
|
|
228
|
+
* is what the daemon scans. An id-keyed sweep sees the answered sibling; the
|
|
229
|
+
* exact-path dedup structurally cannot.
|
|
230
|
+
*
|
|
231
|
+
* A daemon-owned `.dispatched` (no session marker) is NOT the door holding it —
|
|
232
|
+
* it is the daemon's own in-flight admission, and must never block the daemon's
|
|
233
|
+
* own reclaim — so only a `claimed_by` claim counts. Fail-open in the safe
|
|
234
|
+
* direction: an unreadable dir or claim body reads as NOT handled (the item is
|
|
235
|
+
* answered, never dropped), the same bias as the rest of the DM-latency
|
|
236
|
+
* contract; the assurance sweep releases a genuinely stale claim (>20 min) back
|
|
237
|
+
* to `.yaml`, so a dead door can never permanently hold an item this way.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} agentRoot
|
|
240
|
+
* @param {string} service
|
|
241
|
+
* @param {{id?:string, raw_ref?:string}} item
|
|
242
|
+
* @returns {{handled:boolean, reason:string}}
|
|
243
|
+
*/
|
|
244
|
+
export function itemHandledByDoor(agentRoot, service, item) {
|
|
245
|
+
const needles = [item && item.id, item && item.raw_ref]
|
|
246
|
+
.filter(Boolean)
|
|
247
|
+
.map((n) => String(n).trim())
|
|
248
|
+
.filter((n) => n.length > 0);
|
|
249
|
+
if (!needles.length) return { handled: false, reason: "no-id" };
|
|
250
|
+
const dir = join(agentRoot, "state", "inbox", service);
|
|
251
|
+
let names;
|
|
252
|
+
try { names = readdirSync(dir); } catch { return { handled: false, reason: "no-dir" }; }
|
|
253
|
+
for (const file of names) {
|
|
254
|
+
if (!needles.some((n) => file.includes(n))) continue;
|
|
255
|
+
if (file.endsWith(".yaml.processed") || file.endsWith(".yaml.processed-bundled")) {
|
|
256
|
+
return { handled: true, reason: "processed" };
|
|
257
|
+
}
|
|
258
|
+
if (file.endsWith(".yaml.dispatched")) {
|
|
259
|
+
// Only a SESSION claim (`claimed_by`) is the door holding it; a
|
|
260
|
+
// daemon-owned `.dispatched` carries no marker and is the daemon's own.
|
|
261
|
+
try {
|
|
262
|
+
if (parseSessionMarkers(readFileSync(join(dir, file), "utf-8")).claimedBy) {
|
|
263
|
+
return { handled: true, reason: "claimed-by-session" };
|
|
264
|
+
}
|
|
265
|
+
} catch { /* unreadable claim body → not proven handled (fail-open to answering) */ }
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return { handled: false, reason: "not-handled" };
|
|
269
|
+
}
|
|
270
|
+
|
|
197
271
|
/**
|
|
198
272
|
* Claim an item for the session: `.yaml` → `.yaml.dispatched` (the shared
|
|
199
273
|
* durable-admission marker) + `claimed_by: "session"`. Only a NEW item is
|
|
@@ -216,6 +290,7 @@ export function markSessionClaim(agentRoot, service, item, o = {}) {
|
|
|
216
290
|
const body = stripSessionMarkers(readFileSync(dst, "utf-8"));
|
|
217
291
|
const sep = body.endsWith("\n") || body === "" ? "" : "\n";
|
|
218
292
|
writeFileAtomic(dst, body + sep + sessionMarkerLines({ claimedBy: o.by || "session", now: nowOf(o) }));
|
|
293
|
+
noteConsumed(agentRoot, o); // claiming an item is the front door draining inbound (DM-LATENCY)
|
|
219
294
|
return { ok: true, path: dst };
|
|
220
295
|
} catch (err) {
|
|
221
296
|
return { ok: false, error: err && err.message ? err.message : String(err) };
|
|
@@ -248,6 +323,7 @@ export function markSessionReplied(agentRoot, service, item, o = {}) {
|
|
|
248
323
|
repliedAt: nowOf(o),
|
|
249
324
|
});
|
|
250
325
|
writeInPlace(found.path, body + sep + markers);
|
|
326
|
+
noteConsumed(agentRoot, o); // replying is the front door draining inbound (DM-LATENCY)
|
|
251
327
|
return { ok: true, path: found.path };
|
|
252
328
|
} catch (err) {
|
|
253
329
|
return { ok: false, error: err && err.message ? err.message : String(err) };
|
|
@@ -318,19 +394,46 @@ function retire(dir, file) {
|
|
|
318
394
|
|
|
319
395
|
/**
|
|
320
396
|
* Strip the session markers off a file in place (same inode). Exported for
|
|
321
|
-
* the CLI's terminal transitions. Never throws; returns whether
|
|
397
|
+
* the CLI's terminal transitions (`inbox done`). Never throws; returns whether
|
|
398
|
+
* it wrote.
|
|
399
|
+
*
|
|
400
|
+
* Finishing an item is also the front door draining inbound, so it advances
|
|
401
|
+
* the last-consumed stamp (DM-LATENCY) — the same consume signal a claim and a
|
|
402
|
+
* reply write. The item path is `<agentRoot>/state/inbox/<service>/<file>`, so
|
|
403
|
+
* the agent root is the path with those four segments removed; when it can be
|
|
404
|
+
* recovered, the consume is stamped best-effort. An explicit `o.agentRoot`
|
|
405
|
+
* wins, so a caller that already holds the root need not rely on the derivation.
|
|
406
|
+
*
|
|
322
407
|
* @param {string} path
|
|
408
|
+
* @param {{now?:number, agentRoot?:string, writeConsumed?:Function}} [o]
|
|
323
409
|
* @returns {boolean}
|
|
324
410
|
*/
|
|
325
|
-
export function stripMarkersInPlace(path) {
|
|
411
|
+
export function stripMarkersInPlace(path, o = {}) {
|
|
326
412
|
try {
|
|
327
413
|
const raw = readFileSync(path, "utf-8");
|
|
328
414
|
const clean = stripSessionMarkers(raw);
|
|
329
415
|
if (clean !== raw) writeInPlace(path, clean);
|
|
416
|
+
const agentRoot = o.agentRoot || agentRootFromInboxPath(path);
|
|
417
|
+
if (agentRoot) noteConsumed(agentRoot, o);
|
|
330
418
|
return true;
|
|
331
419
|
} catch { return false; }
|
|
332
420
|
}
|
|
333
421
|
|
|
422
|
+
/**
|
|
423
|
+
* Recover the agent root from an inbox item path
|
|
424
|
+
* (`<agentRoot>/state/inbox/<service>/<file>`) by dropping the last four
|
|
425
|
+
* segments — but only when the tail actually IS `state/inbox/<service>/<file>`,
|
|
426
|
+
* so a path of an unexpected shape yields null rather than a wrong root.
|
|
427
|
+
*/
|
|
428
|
+
function agentRootFromInboxPath(path) {
|
|
429
|
+
const parts = String(path || "").split(/[\\/]/);
|
|
430
|
+
if (parts.length < 5) return null;
|
|
431
|
+
const [file, service, inbox, state] = [parts[parts.length - 1], parts[parts.length - 2], parts[parts.length - 3], parts[parts.length - 4]];
|
|
432
|
+
void file; void service;
|
|
433
|
+
if (inbox !== "inbox" || state !== "state") return null;
|
|
434
|
+
return parts.slice(0, parts.length - 4).join("/") || "/";
|
|
435
|
+
}
|
|
436
|
+
|
|
334
437
|
/**
|
|
335
438
|
* THE ASSURANCE SWEEP. Reopens (a) a session claim neither replied nor done
|
|
336
439
|
* within `staleMs` (default 20 min), (b) a session deferral whose
|
|
@@ -425,6 +528,7 @@ export default {
|
|
|
425
528
|
parseSessionMarkers,
|
|
426
529
|
stripSessionMarkers,
|
|
427
530
|
stripMarkersInPlace,
|
|
531
|
+
itemHandledByDoor,
|
|
428
532
|
findInboxFile,
|
|
429
533
|
markSessionClaim,
|
|
430
534
|
markSessionDeferral,
|