@cohortapp/agent-sdk 2.18.15 → 2.18.16
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 +87 -8
- package/lib/session/handoffs.mjs +57 -0
- package/lib/session/inbox-claims.mjs +47 -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 +242 -9
- package/scripts/daemon/assurance.mjs +56 -1
- package/scripts/fleet/rollout.mjs +48 -4
- package/scripts/hooks/pre-write-yaml-validate.mjs +63 -2
- package/scripts/local-triggers/autoupdate.sh +194 -9
|
@@ -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,7 +166,15 @@ 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
|
+
* @param {{frontDoor?:string, sessionLive?:boolean, sessionConsumed?:boolean, service?:string, services?:string[]}} a
|
|
120
178
|
* @returns {{dispatch:boolean, reason:string}}
|
|
121
179
|
*/
|
|
122
180
|
export function shouldDaemonDispatch(a) {
|
|
@@ -126,6 +184,10 @@ export function shouldDaemonDispatch(a) {
|
|
|
126
184
|
const services = Array.isArray(x.services) && x.services.length ? x.services : DEFAULT_FRONT_DOOR_SERVICES;
|
|
127
185
|
const svc = String(x.service || "").trim().toLowerCase();
|
|
128
186
|
if (!svc || !services.includes(svc)) return { dispatch: true, reason: "service-not-front-door" };
|
|
187
|
+
// Live and owns the service, but has it actually drained inbound? A live
|
|
188
|
+
// front door that has not consumed within the window is wedged mid-turn —
|
|
189
|
+
// the daemon takes the item so a slow DM is not left unanswered.
|
|
190
|
+
if (x.sessionConsumed === false) return { dispatch: true, reason: "session-not-consuming" };
|
|
129
191
|
return { dispatch: false, reason: "session-live" };
|
|
130
192
|
}
|
|
131
193
|
|
|
@@ -187,9 +249,10 @@ function parseSessionYaml(text, yamlParse) {
|
|
|
187
249
|
* @param {object} [deps.fs] `{existsSync, readFileSync}` (default node:fs)
|
|
188
250
|
* @param {object} [deps.env] (default process.env)
|
|
189
251
|
* @param {number} [deps.now] epoch ms
|
|
190
|
-
* @param {number} [deps.staleMs]
|
|
252
|
+
* @param {number} [deps.staleMs] heartbeat freshness window
|
|
253
|
+
* @param {number} [deps.consumedStaleMs] last-consume freshness window (default DEFAULT_CONSUMED_STALE_MS)
|
|
191
254
|
* @param {Function} [deps.yamlParse] optional YAML parser (js-yaml `load`)
|
|
192
|
-
* @returns {{frontDoor:"session"|"daemon", sessionLive:boolean, services:string[], liveness:object, heartbeat:object|null}}
|
|
255
|
+
* @returns {{frontDoor:"session"|"daemon", sessionLive:boolean, sessionConsumed:boolean, lastConsumedAt:number|null, services:string[], liveness:object, consumed:object, heartbeat:object|null}}
|
|
193
256
|
*/
|
|
194
257
|
export function readFrontDoorState(agentRoot, deps = {}) {
|
|
195
258
|
const fs = deps.fs || nodeFs;
|
|
@@ -209,11 +272,24 @@ export function readFrontDoorState(agentRoot, deps = {}) {
|
|
|
209
272
|
}
|
|
210
273
|
} catch { heartbeat = null; /* corrupt heartbeat → not live → legacy lane (fail-open) */ }
|
|
211
274
|
const liveness = sessionLiveFromHeartbeat(heartbeat, { now, staleMs: deps.staleMs });
|
|
275
|
+
// The other half of "provably answering": has the front door drained inbound
|
|
276
|
+
// within the window? `readLastConsumed` (lib/session/handoffs.mjs) reads the
|
|
277
|
+
// ONE last-consumed.json both consume paths stamp; a missing/unreadable
|
|
278
|
+
// stamp is `null` and reads as not-consumed (fail-open to the daemon taking
|
|
279
|
+
// the item). Kept SEPARATE from `sessionLive`: an alive-but-unconsumed
|
|
280
|
+
// session is still alive, so the revive path (which reads `sessionLive`)
|
|
281
|
+
// must not fire on it — only the dispatch gate reacts to `sessionConsumed`.
|
|
282
|
+
let lastConsumedAt = null;
|
|
283
|
+
try { lastConsumedAt = readLastConsumed(agentRoot, { fs }); } catch { lastConsumedAt = null; }
|
|
284
|
+
const consumed = sessionConsumedRecently(lastConsumedAt, { now, staleMs: deps.consumedStaleMs });
|
|
212
285
|
return {
|
|
213
286
|
frontDoor: resolveFrontDoor({ env, config }),
|
|
214
287
|
sessionLive: liveness.live,
|
|
288
|
+
sessionConsumed: consumed.consumed,
|
|
289
|
+
lastConsumedAt,
|
|
215
290
|
services: frontDoorServices(config),
|
|
216
291
|
liveness,
|
|
292
|
+
consumed,
|
|
217
293
|
heartbeat,
|
|
218
294
|
};
|
|
219
295
|
}
|
|
@@ -254,9 +330,12 @@ export function makeFrontDoorGate(o = {}) {
|
|
|
254
330
|
|
|
255
331
|
export default {
|
|
256
332
|
DEFAULT_STALE_MS,
|
|
333
|
+
DEFAULT_CONSUMED_STALE_MS,
|
|
334
|
+
LAST_CONSUMED_RELATIVE,
|
|
257
335
|
DEFAULT_FRONT_DOOR_SERVICES,
|
|
258
336
|
HANDOFF_MODES,
|
|
259
337
|
sessionLiveFromHeartbeat,
|
|
338
|
+
sessionConsumedRecently,
|
|
260
339
|
resolveFrontDoor,
|
|
261
340
|
frontDoorServices,
|
|
262
341
|
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
|
|
@@ -216,6 +232,7 @@ export function markSessionClaim(agentRoot, service, item, o = {}) {
|
|
|
216
232
|
const body = stripSessionMarkers(readFileSync(dst, "utf-8"));
|
|
217
233
|
const sep = body.endsWith("\n") || body === "" ? "" : "\n";
|
|
218
234
|
writeFileAtomic(dst, body + sep + sessionMarkerLines({ claimedBy: o.by || "session", now: nowOf(o) }));
|
|
235
|
+
noteConsumed(agentRoot, o); // claiming an item is the front door draining inbound (DM-LATENCY)
|
|
219
236
|
return { ok: true, path: dst };
|
|
220
237
|
} catch (err) {
|
|
221
238
|
return { ok: false, error: err && err.message ? err.message : String(err) };
|
|
@@ -248,6 +265,7 @@ export function markSessionReplied(agentRoot, service, item, o = {}) {
|
|
|
248
265
|
repliedAt: nowOf(o),
|
|
249
266
|
});
|
|
250
267
|
writeInPlace(found.path, body + sep + markers);
|
|
268
|
+
noteConsumed(agentRoot, o); // replying is the front door draining inbound (DM-LATENCY)
|
|
251
269
|
return { ok: true, path: found.path };
|
|
252
270
|
} catch (err) {
|
|
253
271
|
return { ok: false, error: err && err.message ? err.message : String(err) };
|
|
@@ -318,19 +336,46 @@ function retire(dir, file) {
|
|
|
318
336
|
|
|
319
337
|
/**
|
|
320
338
|
* Strip the session markers off a file in place (same inode). Exported for
|
|
321
|
-
* the CLI's terminal transitions. Never throws; returns whether
|
|
339
|
+
* the CLI's terminal transitions (`inbox done`). Never throws; returns whether
|
|
340
|
+
* it wrote.
|
|
341
|
+
*
|
|
342
|
+
* Finishing an item is also the front door draining inbound, so it advances
|
|
343
|
+
* the last-consumed stamp (DM-LATENCY) — the same consume signal a claim and a
|
|
344
|
+
* reply write. The item path is `<agentRoot>/state/inbox/<service>/<file>`, so
|
|
345
|
+
* the agent root is the path with those four segments removed; when it can be
|
|
346
|
+
* recovered, the consume is stamped best-effort. An explicit `o.agentRoot`
|
|
347
|
+
* wins, so a caller that already holds the root need not rely on the derivation.
|
|
348
|
+
*
|
|
322
349
|
* @param {string} path
|
|
350
|
+
* @param {{now?:number, agentRoot?:string, writeConsumed?:Function}} [o]
|
|
323
351
|
* @returns {boolean}
|
|
324
352
|
*/
|
|
325
|
-
export function stripMarkersInPlace(path) {
|
|
353
|
+
export function stripMarkersInPlace(path, o = {}) {
|
|
326
354
|
try {
|
|
327
355
|
const raw = readFileSync(path, "utf-8");
|
|
328
356
|
const clean = stripSessionMarkers(raw);
|
|
329
357
|
if (clean !== raw) writeInPlace(path, clean);
|
|
358
|
+
const agentRoot = o.agentRoot || agentRootFromInboxPath(path);
|
|
359
|
+
if (agentRoot) noteConsumed(agentRoot, o);
|
|
330
360
|
return true;
|
|
331
361
|
} catch { return false; }
|
|
332
362
|
}
|
|
333
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Recover the agent root from an inbox item path
|
|
366
|
+
* (`<agentRoot>/state/inbox/<service>/<file>`) by dropping the last four
|
|
367
|
+
* segments — but only when the tail actually IS `state/inbox/<service>/<file>`,
|
|
368
|
+
* so a path of an unexpected shape yields null rather than a wrong root.
|
|
369
|
+
*/
|
|
370
|
+
function agentRootFromInboxPath(path) {
|
|
371
|
+
const parts = String(path || "").split(/[\\/]/);
|
|
372
|
+
if (parts.length < 5) return null;
|
|
373
|
+
const [file, service, inbox, state] = [parts[parts.length - 1], parts[parts.length - 2], parts[parts.length - 3], parts[parts.length - 4]];
|
|
374
|
+
void file; void service;
|
|
375
|
+
if (inbox !== "inbox" || state !== "state") return null;
|
|
376
|
+
return parts.slice(0, parts.length - 4).join("/") || "/";
|
|
377
|
+
}
|
|
378
|
+
|
|
334
379
|
/**
|
|
335
380
|
* THE ASSURANCE SWEEP. Reopens (a) a session claim neither replied nor done
|
|
336
381
|
* within `staleMs` (default 20 min), (b) a session deferral whose
|