@cohortapp/agent-sdk 2.4.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/maestro.mjs +9 -0
- package/lib/backlog.mjs +35 -0
- package/lib/backlog.test.mjs +36 -0
- package/lib/channels/contract.mjs +1 -0
- package/lib/channels/contract.test.mjs +2 -1
- package/lib/channels/inbox-item.mjs +54 -0
- package/lib/comms/send-gate.mjs +56 -1
- package/lib/comms/send-gate.test.mjs +56 -0
- package/lib/execution/disposition.mjs +62 -2
- package/lib/execution/disposition.test.mjs +54 -0
- package/lib/execution/drive.mjs +1 -1
- package/lib/execution/effects.mjs +282 -24
- package/lib/execution/effects.test.mjs +112 -0
- package/lib/execution/index.mjs +1 -0
- package/lib/execution/intake.mjs +43 -9
- package/lib/execution/intake.test.mjs +46 -0
- package/lib/execution/pipeline.mjs +5 -0
- package/lib/execution/surface-policy.mjs +80 -30
- package/lib/goals/classify.mjs +49 -5
- package/lib/goals/classify.test.mjs +58 -0
- package/lib/goals/collaborate.mjs +131 -17
- package/lib/goals/collaborate.test.mjs +16 -4
- package/lib/goals/loop.mjs +160 -9
- package/lib/goals/loop.test.mjs +129 -3
- package/lib/kpi-sensors.mjs +666 -0
- package/lib/kpi-sensors.test.mjs +275 -0
- package/lib/kpi.mjs +23 -0
- package/lib/mandate/audit.mjs +3 -0
- package/lib/mandate/contract.mjs +277 -0
- package/lib/mandate/contract.test.mjs +185 -0
- package/lib/mandate/derive.mjs +49 -5
- package/lib/mandate/derive.test.mjs +7 -1
- package/lib/mandate/model.mjs +10 -1
- package/lib/mandate/model.test.mjs +22 -3
- package/lib/mandate/refresh.mjs +53 -5
- package/lib/mandate/refresh.test.mjs +83 -1
- package/lib/org/doctor.mjs +66 -0
- package/lib/org/doctor.test.mjs +73 -1
- package/lib/org/inbound/directedness.mjs +119 -1
- package/lib/org/inbound/directedness.test.mjs +67 -0
- package/lib/org/inbound/facts.mjs +132 -9
- package/lib/org/inbound/facts.test.mjs +96 -0
- package/lib/org/inbound/hydrate.mjs +40 -0
- package/lib/org/inbound/index.test.mjs +83 -0
- package/lib/org/inbound/project.mjs +8 -0
- package/lib/org/inbound/surfaces.mjs +20 -0
- package/lib/org/param-contract.mjs +16 -2
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +214 -2
- package/lib/org/protocol.test.mjs +11 -2
- package/lib/org/push.mjs +213 -49
- package/lib/org/push.test.mjs +112 -10
- package/lib/plan/compile.mjs +85 -8
- package/lib/plan/compile.test.mjs +82 -0
- package/lib/plan/emit.test.mjs +6 -1
- package/lib/setup/enroll-from-cohort.mjs +22 -2
- package/lib/setup/enroll-from-cohort.test.mjs +25 -0
- package/lib/setup/sections/mandate.mjs +43 -1
- package/lib/subagents/schema.mjs +14 -2
- package/lib/subagents/schema.test.mjs +22 -0
- package/package.json +1 -1
- package/scripts/ci/check-subagent-frontmatter.mjs +139 -0
- package/scripts/ci/check-subagent-frontmatter.test.mjs +124 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/ci/conformance-org-api.mjs +16 -0
- package/scripts/ci/journey-approval-escalation.mjs +341 -0
- package/scripts/daemon/agent-daemon.mjs +582 -28
- package/scripts/daemon/cadence-handlers.mjs +273 -17
- package/scripts/daemon/cadence-handlers.test.mjs +101 -0
- package/scripts/daemon/execution-ladder.test.mjs +430 -0
- package/scripts/daemon/goal-steward-cadence.test.mjs +69 -0
- package/scripts/daemon/maestro-daemon.mjs +53 -0
- package/scripts/daemon/prompt-builder.mjs +47 -0
- package/scripts/daemon/responder.mjs +70 -3
- package/scripts/poller/imap-client.mjs +20 -1
- package/scripts/poller/inbox-scan-poller.mjs +15 -0
- package/scripts/poller/utils.mjs +51 -0
- package/scripts/setup/generate-capability.mjs +120 -11
- package/scripts/setup/generate-capability.test.mjs +134 -0
- package/scripts/setup/generate-plan.mjs +6 -1
- package/scripts/setup/repair-subagent-frontmatter.mjs +231 -0
package/lib/org/push.mjs
CHANGED
|
@@ -64,23 +64,35 @@
|
|
|
64
64
|
* THE WIRE CONTRACT (what this expects of hq)
|
|
65
65
|
* ============================================================================
|
|
66
66
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* `{
|
|
67
|
+
* VERIFIED AGAINST THE DEPLOYED APP (os.cohortapp.com), not inferred.
|
|
68
|
+
*
|
|
69
|
+
* Rung 1 — GET `{base}/api/v1/stream?cursor=<seq>`
|
|
70
|
+
* `stream`, not `agent.stream`: it is a static Next route
|
|
71
|
+
* (`hq src/app/api/v1/stream/route.ts`) that wins over the `[...method]`
|
|
72
|
+
* catch-all. Accept: `text/event-stream`. Held open (15-min lifetime cap),
|
|
73
|
+
* ~2KB comment preamble, 20s heartbeat comments, `X-Accel-Buffering: no`.
|
|
74
|
+
* Both framings are accepted by the parser below (SSE `data:` lines and bare
|
|
75
|
+
* NDJSON), because a future hq lane may use either.
|
|
76
|
+
* Frame bodies, all UNTYPED (no `type` field) — recognised structurally:
|
|
77
|
+
* `{seq, topic, reason, family, kind, entityId, actor, at, ids}` — an item
|
|
78
|
+
* ALREADY FILTERED TO THIS SEAT by hq (relevance.ts#selectForMember)
|
|
79
|
+
* `{orgId, memberId, cursor, heartbeatMs, lifetimeMs}` — `event: ready`
|
|
80
|
+
* `{cursor}` — `event: cursor`,
|
|
81
|
+
* the watermark past events that were NOT this seat's
|
|
82
|
+
* `{reason, cursor, message}` — `event: bye`
|
|
83
|
+
* `: …` comment lines — heartbeats
|
|
78
84
|
* Anything unrecognised is ignored (forward-compatible).
|
|
79
85
|
*
|
|
80
|
-
* Rung 2 —
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
86
|
+
* Rung 2 — POST `{base}/api/v1/agent.wait` `{since?, timeoutMs, limit?}`
|
|
87
|
+
* A METHOD on the dispatcher, NOT a GET read (the read lane's HANDLED_READS
|
|
88
|
+
* list does not contain it — see waitOnce for the full autopsy). Returns the
|
|
89
|
+
* standard ok-frame wrapping:
|
|
90
|
+
* `{ cursor, items: [{seq, family, kind, entityId, actor, at, reason, ref}],
|
|
91
|
+
* timedOut, bootstrap, memberId, waitedMs }`
|
|
92
|
+
* `reason` is hq's RELATIONAL verdict (mention/channel/assignee/reviewer/
|
|
93
|
+
* call_participant/mailbox_owner/mailbox_member), resolved server-side against
|
|
94
|
+
* the projection tables — the chain payload is redacted and cannot name a
|
|
95
|
+
* recipient. Empty `items` after the timeout is a normal, successful response.
|
|
84
96
|
*
|
|
85
97
|
* Both lanes key off `CohortEvent.seq` — the per-org monotonic, DB-unique,
|
|
86
98
|
* gap-free ledger cursor hq's own poll route uses. Not the 30s ephemeral ring,
|
|
@@ -117,17 +129,32 @@ export const PUSH_RUNG = Object.freeze({
|
|
|
117
129
|
CADENCE: "cadence",
|
|
118
130
|
});
|
|
119
131
|
|
|
120
|
-
/**
|
|
121
|
-
|
|
122
|
-
|
|
132
|
+
/**
|
|
133
|
+
* Path for the held stream (rung 1).
|
|
134
|
+
*
|
|
135
|
+
* `stream`, NOT `agent.stream`. hq serves this as an ORDINARY Next route at
|
|
136
|
+
* `src/app/api/v1/stream/route.ts` — a static segment that wins over the
|
|
137
|
+
* `[...method]` catch-all. `agent.stream` was a guess made before the server
|
|
138
|
+
* existed, and it resolved to the catch-all, which answered `NOT_FOUND` for an
|
|
139
|
+
* unknown read: the ladder read that as "this hq has no stream endpoint",
|
|
140
|
+
* demoted instantly and never probed rung 1 again. Verified against the
|
|
141
|
+
* deployed app: `GET /api/v1/stream` → 200 `text/event-stream`.
|
|
142
|
+
*/
|
|
143
|
+
export const DEFAULT_STREAM_PATH = "stream";
|
|
144
|
+
/** Method name for the bounded long-poll (rung 2). A POST RPC — see waitOnce. */
|
|
123
145
|
export const DEFAULT_WAIT_PATH = "agent.wait";
|
|
124
146
|
|
|
125
147
|
/**
|
|
126
|
-
* Long-poll hold, ms.
|
|
127
|
-
*
|
|
128
|
-
*
|
|
148
|
+
* Long-poll hold, ms.
|
|
149
|
+
*
|
|
150
|
+
* 25s, not 55s. `approval.wait` holds 55s and proves Railway tolerates it, but
|
|
151
|
+
* `agent.wait` deliberately caps itself at 25s (hq `methods/agent/wait.ts`
|
|
152
|
+
* MAX_WAIT_MS) because 30s is the tightest idle-read timeout in the common proxy
|
|
153
|
+
* layer between a daemon and the app. Asking for 55s does not get 55s — hq
|
|
154
|
+
* clamps it — it only makes OUR abort budget wrong, so we would tear down a
|
|
155
|
+
* healthy hold that was about to answer. Ask for what the server actually gives.
|
|
129
156
|
*/
|
|
130
|
-
export const DEFAULT_WAIT_MS =
|
|
157
|
+
export const DEFAULT_WAIT_MS = 25_000;
|
|
131
158
|
|
|
132
159
|
/**
|
|
133
160
|
* Recycle a held stream after this long even when it looks healthy (ms). A
|
|
@@ -175,11 +202,47 @@ export const STATUS_RELATIVE = "state/org/push-status.json";
|
|
|
175
202
|
* replace, or disable any of them without touching this module.
|
|
176
203
|
*/
|
|
177
204
|
export const FAMILY_WAKE_TARGET = Object.freeze({
|
|
205
|
+
// ── The INBOUND families. Every one of these can carry something addressed to
|
|
206
|
+
// this seat, and every one is resolved authoritatively by the wide inbound
|
|
207
|
+
// reader behind the `messaging-inbound` cadence
|
|
208
|
+
// (lib/org/inbound.pullWideInbound). They therefore all wake the SAME
|
|
209
|
+
// target, which is the whole point: one nudge, one authoritative pull, one
|
|
210
|
+
// cursor, one dedup-guarded write.
|
|
211
|
+
//
|
|
212
|
+
// This map used to send `board`/`work` to a "board" target whose handler
|
|
213
|
+
// escalates (spawns a session), and to name no target at all for files,
|
|
214
|
+
// decision, escalation, approval, handoff or calendar — so a task assigned
|
|
215
|
+
// to this agent produced NO wake, and the only thing that would ever find
|
|
216
|
+
// it was a cadence tick against a reader that filtered the family out. That
|
|
217
|
+
// is the shape of "board, task, calendar, escalation, approval and mandate
|
|
218
|
+
// events never reach a daemon at all".
|
|
178
219
|
messaging: "org-messaging",
|
|
179
220
|
calling: "org-messaging",
|
|
221
|
+
board: "org-messaging",
|
|
222
|
+
work: "org-messaging",
|
|
223
|
+
file: "org-messaging",
|
|
224
|
+
files: "org-messaging",
|
|
225
|
+
decision: "org-messaging",
|
|
226
|
+
escalation: "org-messaging",
|
|
227
|
+
approval: "org-messaging",
|
|
228
|
+
handoff: "org-messaging",
|
|
229
|
+
calendar: "org-messaging",
|
|
230
|
+
meetings: "org-messaging",
|
|
231
|
+
// `mandate.*` is a governance lane: adopting/retiring a mandate changes what
|
|
232
|
+
// this seat is FOR. It gets its OWN target rather than being folded into
|
|
233
|
+
// `org-messaging`, because the wide inbound reader has no `mandate` branch —
|
|
234
|
+
// waking it would spend a pull that structurally cannot see the event. With no
|
|
235
|
+
// default handler this target logs once and degrades to the plan refresh's own
|
|
236
|
+
// cadence, which is honest; an agent repo that wants instant mandate reload
|
|
237
|
+
// passes `wake: { mandate: () => … }`.
|
|
238
|
+
mandate: "mandate",
|
|
239
|
+
|
|
240
|
+
// ── Lanes with their OWN fetcher. Email is polled by the orgmail channel
|
|
241
|
+
// adapter (which writes its own inbox items) — waking the wide reader too
|
|
242
|
+
// would double-deliver every mail into a thread-lease race, which is
|
|
243
|
+
// exactly why the `email` inbound surface defaults OFF. Branding has its
|
|
244
|
+
// own steward cadence.
|
|
180
245
|
email: "orgmail",
|
|
181
|
-
board: "board",
|
|
182
|
-
work: "board",
|
|
183
246
|
branding: "brand",
|
|
184
247
|
});
|
|
185
248
|
|
|
@@ -335,13 +398,32 @@ export function decodeFrameLine(line) {
|
|
|
335
398
|
if (type === "error") return { kind: "malformed" }; // a server-side error frame ends the read
|
|
336
399
|
|
|
337
400
|
// An event either says so, or is recognised structurally: hq's own fan-out
|
|
338
|
-
// frame is {family, kind, entityId, actor, at,
|
|
401
|
+
// frame is {seq, topic, reason, family, kind, entityId, actor, at, ids} with
|
|
402
|
+
// NO `type` (src/server/agent-stream/relevance.ts#selectForMember).
|
|
339
403
|
const seq = Number(obj.seq);
|
|
340
404
|
const hasShape = Number.isFinite(seq) && typeof obj.family === "string" && obj.family !== "";
|
|
341
405
|
if (type === "event" || hasShape) {
|
|
342
406
|
if (!hasShape) return { kind: "malformed" }; // claimed to be an event, isn't one
|
|
343
407
|
return { kind: "event", event: obj };
|
|
344
408
|
}
|
|
409
|
+
|
|
410
|
+
// ── WATERMARK FRAMES. hq emits three untyped frames that carry only a cursor:
|
|
411
|
+
// event: ready {orgId, memberId, cursor, heartbeatMs, lifetimeMs}
|
|
412
|
+
// event: cursor {cursor}
|
|
413
|
+
// event: bye {reason, cursor, message}
|
|
414
|
+
// The `cursor` one is NOT optional bookkeeping — it is how the server moves
|
|
415
|
+
// our resume watermark past events that were NOT for this seat. Verified on
|
|
416
|
+
// the deployed app: a 20s idle hold emitted `data: {"cursor":12766}` after a
|
|
417
|
+
// `ready` at 12600, i.e. 166 events of other members' business. Treating it
|
|
418
|
+
// as `ignore` (which this parser did) means every reconnect re-asks hq to
|
|
419
|
+
// re-scan that whole span, and it grows without bound in a busy org.
|
|
420
|
+
//
|
|
421
|
+
// We key off the SHAPE — a numeric `cursor` and nothing event-like — rather
|
|
422
|
+
// than the SSE `event:` field, because the field arrives on a different line
|
|
423
|
+
// and this decoder is deliberately line-at-a-time and stateless.
|
|
424
|
+
const c = Number(obj.cursor);
|
|
425
|
+
if (Number.isFinite(c) && c >= 0) return { kind: "cursor", cursor: c };
|
|
426
|
+
|
|
345
427
|
return { kind: "ignore" }; // forward-compatible: unknown frame types are fine
|
|
346
428
|
}
|
|
347
429
|
|
|
@@ -366,7 +448,23 @@ export function decodeFrameLine(line) {
|
|
|
366
448
|
function wakeTargetFor(ctx, ev) {
|
|
367
449
|
const family = typeof ev.family === "string" ? ev.family : "";
|
|
368
450
|
const target = FAMILY_WAKE_TARGET[family];
|
|
369
|
-
if (!target)
|
|
451
|
+
if (!target) {
|
|
452
|
+
// NEVER SILENT. hq's SSE lane classifies eleven families
|
|
453
|
+
// (`agent-stream/relevance.ts`) and every frame it sends has ALREADY been
|
|
454
|
+
// proven to concern this seat. A family with no entry here is therefore a
|
|
455
|
+
// frame hq went to the trouble of addressing to us, that we consume and
|
|
456
|
+
// throw away. That is a delivery gap, not a filter — say so, once per
|
|
457
|
+
// family, so it shows up as a line rather than as an agent that "seems
|
|
458
|
+
// slow on artifacts".
|
|
459
|
+
if (family && !ctx.warnedTargets.has(`family:${family}`)) {
|
|
460
|
+
ctx.warnedTargets.add(`family:${family}`);
|
|
461
|
+
logWarn(
|
|
462
|
+
`hq pushed a "${family}" frame it had already resolved as THIS SEAT'S, and nothing here consumes that family — ` +
|
|
463
|
+
`it is being dropped. Add it to FAMILY_WAKE_TARGET (and give the wide inbound reader a branch) to close the gap.`,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
370
468
|
|
|
371
469
|
// Our own committed action echoing back is not news. Only applies when the
|
|
372
470
|
// caller told us which ids are "us" — otherwise we cannot know, so we wake.
|
|
@@ -507,6 +605,31 @@ function dispatchEvent(ctx, ev) {
|
|
|
507
605
|
return outcome;
|
|
508
606
|
}
|
|
509
607
|
|
|
608
|
+
/**
|
|
609
|
+
* Move the persisted watermark to a server-reported head. FORWARD ONLY.
|
|
610
|
+
*
|
|
611
|
+
* Both lanes report a head that can be AHEAD of the last frame they sent us:
|
|
612
|
+
* the SSE `cursor` checkpoint and `agent.wait`'s `cursor` both mean "I have
|
|
613
|
+
* EVALUATED up to here and nothing else in that span was yours." Honouring it is
|
|
614
|
+
* what stops every reconnect re-scanning other members' business; refusing to
|
|
615
|
+
* rewind is what stops a stale or mis-ordered frame replaying work.
|
|
616
|
+
*
|
|
617
|
+
* @param {object} ctx
|
|
618
|
+
* @param {number|undefined} head
|
|
619
|
+
* @param {string} why frame kind, for the log
|
|
620
|
+
* @returns {boolean} whether the cursor moved
|
|
621
|
+
*/
|
|
622
|
+
function advanceCursor(ctx, head, why) {
|
|
623
|
+
const n = Number(head);
|
|
624
|
+
if (!Number.isFinite(n) || n < 0) return false;
|
|
625
|
+
if (ctx.cursor != null && n <= ctx.cursor) return false;
|
|
626
|
+
ctx.cursor = n;
|
|
627
|
+
writeCursor(ctx.agentRoot, n, ctx.now);
|
|
628
|
+
ctx.stats.checkpoints = (ctx.stats.checkpoints || 0) + 1;
|
|
629
|
+
if (why === "ready") logInfo(`resumed at ledger cursor ${n}`);
|
|
630
|
+
return true;
|
|
631
|
+
}
|
|
632
|
+
|
|
510
633
|
// ---------------------------------------------------------------------------
|
|
511
634
|
// URLs + headers
|
|
512
635
|
// ---------------------------------------------------------------------------
|
|
@@ -648,6 +771,11 @@ async function consumeStream(ctx) {
|
|
|
648
771
|
const frame = decodeFrameLine(line);
|
|
649
772
|
if (frame.kind === "event") {
|
|
650
773
|
if (dispatchEvent(ctx, frame.event) === "delivered") delivered += 1;
|
|
774
|
+
} else if (frame.kind === "cursor" || frame.kind === "ready") {
|
|
775
|
+
// A server watermark. Forward-only, always — a rewind would replay
|
|
776
|
+
// events we have already woken on, and the cursor is the ONE piece of
|
|
777
|
+
// durable state this module owns.
|
|
778
|
+
advanceCursor(ctx, frame.cursor, frame.kind);
|
|
651
779
|
} else if (frame.kind === "malformed") {
|
|
652
780
|
// ONE bad frame must not cost the connection: count it, keep reading.
|
|
653
781
|
ctx.stats.malformed += 1;
|
|
@@ -683,20 +811,49 @@ async function consumeStream(ctx) {
|
|
|
683
811
|
// ---------------------------------------------------------------------------
|
|
684
812
|
|
|
685
813
|
/**
|
|
686
|
-
* One `agent.wait` round-trip.
|
|
687
|
-
*
|
|
688
|
-
*
|
|
814
|
+
* One `agent.wait` round-trip.
|
|
815
|
+
*
|
|
816
|
+
* ── IT IS A POST RPC, NOT A GET READ ──
|
|
817
|
+
* This used to call `client.read("agent.wait?cursor=…&timeoutMs=…")`, i.e. a GET
|
|
818
|
+
* on the `/v1` read lane. That lane dispatches from a CLOSED list
|
|
819
|
+
* (`hq src/server/rpc/reads.ts#HANDLED_READS`: snapshot, directory, events,
|
|
820
|
+
* board.ready, hierarchy, board.context, ops, approval.wait, cost.rollup,
|
|
821
|
+
* decision.list, policy, contacts.list, meetings.list, subagent.roster) —
|
|
822
|
+
* `agent.wait` is not on it and never was. Every rung-2 attempt therefore came
|
|
823
|
+
* back `NOT_FOUND` with HTTP 404, which `isUnsupportedStatus` reads as "this hq
|
|
824
|
+
* doesn't have the endpoint": instant demotion to CADENCE, no retry, no probe.
|
|
825
|
+
* The long-poll rung could not have worked on any hq, ever.
|
|
826
|
+
*
|
|
827
|
+
* `agent.wait` is a `sideEffecting:false` METHOD — `POST /api/v1/agent.wait`
|
|
828
|
+
* through the dispatcher (`hq src/server/methods/agent/wait.ts`). Its params are
|
|
829
|
+
* `{since, timeoutMs, limit}` under a `.strict()` zod schema, so `cursor` would
|
|
830
|
+
* have been rejected as an unknown key even on the right verb.
|
|
831
|
+
*
|
|
832
|
+
* ── THE RETURN CONTRACT IS `items`, NOT `events` ──
|
|
833
|
+
* `{cursor, items, timedOut, bootstrap, memberId, waitedMs}`. Each item is
|
|
834
|
+
* `{seq, family, kind, entityId, actor, at, reason, ref}` — ledger coordinates
|
|
835
|
+
* plus a RELATIONAL `reason` ("mention" | "channel" | "assignee" | "reviewer" |
|
|
836
|
+
* "call_participant" | "mailbox_owner" | "mailbox_member") that hq derived by
|
|
837
|
+
* joining the projection tables that name a seat. No body, no subject, no
|
|
838
|
+
* payload. That is the ACL-safe way to learn "this one is for me": the server
|
|
839
|
+
* resolved it against Mention / ChannelMember / Task / CallParticipant / Mailbox
|
|
840
|
+
* under `ctx.scopeWhere`, and we never widened a chain payload to carry it.
|
|
841
|
+
*
|
|
842
|
+
* The abort budget outlasts the server's own bound so hq gets to answer rather
|
|
689
843
|
* than us aborting first. Never throws.
|
|
690
844
|
*
|
|
691
845
|
* @returns {Promise<{ok:boolean, unsupported?:boolean, reason:string, delivered:number}>}
|
|
692
846
|
*/
|
|
693
847
|
async function waitOnce(ctx) {
|
|
694
848
|
const startedAt = ctx.now();
|
|
695
|
-
const
|
|
696
|
-
|
|
849
|
+
const params = { timeoutMs: ctx.waitMs };
|
|
850
|
+
// `since` ABSENT means "bootstrap at head, replay nothing" — the same
|
|
851
|
+
// distinction readCursor preserves. Sending 0 would replay the org's history.
|
|
852
|
+
if (ctx.cursor != null) params.since = ctx.cursor;
|
|
853
|
+
|
|
697
854
|
let r;
|
|
698
855
|
try {
|
|
699
|
-
r = await ctx.client.
|
|
856
|
+
r = await ctx.client.call(ctx.waitPath, params, {
|
|
700
857
|
base: ctx.base,
|
|
701
858
|
token: ctx.token,
|
|
702
859
|
orgId: ctx.orgId,
|
|
@@ -704,36 +861,42 @@ async function waitOnce(ctx) {
|
|
|
704
861
|
timeoutMs: ctx.waitMs + 5_000,
|
|
705
862
|
});
|
|
706
863
|
} catch (err) {
|
|
707
|
-
// client.
|
|
864
|
+
// client.call is documented fail-open, but a caller must never assume.
|
|
708
865
|
return { ok: false, reason: `wait threw: ${err && err.message}`, delivered: 0, lived: ctx.now() - startedAt };
|
|
709
866
|
}
|
|
710
|
-
if (!r ||
|
|
867
|
+
if (!r || r.ok !== true) {
|
|
711
868
|
const status = Number(r && r.status) || 0;
|
|
712
|
-
if (isUnsupportedStatus(status)) return { ok: false, unsupported: true, reason: `http ${status} (endpoint absent)`, delivered: 0, lived: ctx.now() - startedAt };
|
|
713
869
|
const code = (r && r.error && r.error.code) || "UNKNOWN";
|
|
870
|
+
// A method this hq does not carry answers NOT_FOUND (the dispatcher's own
|
|
871
|
+
// frame) or 404/405/501 at the transport. Both mean "stop asking".
|
|
872
|
+
if (isUnsupportedStatus(status) || code === "NOT_FOUND") {
|
|
873
|
+
return { ok: false, unsupported: true, reason: `${code}${status ? ` (http ${status})` : ""} — endpoint absent`, delivered: 0, lived: ctx.now() - startedAt };
|
|
874
|
+
}
|
|
714
875
|
return { ok: false, reason: `${code}${status ? ` (http ${status})` : ""}`, delivered: 0, lived: ctx.now() - startedAt };
|
|
715
876
|
}
|
|
716
877
|
|
|
717
|
-
const
|
|
718
|
-
|
|
878
|
+
const result = (r.result && typeof r.result === "object" ? r.result : null) || {};
|
|
879
|
+
// `items` is the contract. `events` is tolerated so an older/newer hq, or the
|
|
880
|
+
// reference server, does not silently deliver nothing.
|
|
881
|
+
const items = Array.isArray(result.items)
|
|
882
|
+
? result.items
|
|
883
|
+
: Array.isArray(result.events)
|
|
884
|
+
? result.events
|
|
885
|
+
: [];
|
|
719
886
|
let delivered = 0;
|
|
720
|
-
for (const
|
|
887
|
+
for (const it of items) {
|
|
721
888
|
if (ctx.stopped) break;
|
|
722
|
-
|
|
723
|
-
if (outcome === "delivered") delivered += 1;
|
|
724
|
-
}
|
|
725
|
-
// hq may report a head beyond the last event it sent (e.g. everything in the
|
|
726
|
-
// slice was filtered server-side). Trust it forward-only — never rewind.
|
|
727
|
-
const head = Number(payload.cursor);
|
|
728
|
-
if (Number.isFinite(head) && head > 0 && (ctx.cursor == null || head > ctx.cursor)) {
|
|
729
|
-
ctx.cursor = head;
|
|
730
|
-
writeCursor(ctx.agentRoot, head, ctx.now);
|
|
889
|
+
if (dispatchEvent(ctx, it) === "delivered") delivered += 1;
|
|
731
890
|
}
|
|
891
|
+
// hq reports a head beyond the last item it sent whenever the span it
|
|
892
|
+
// evaluated held other members' business. Forward-only — never rewind.
|
|
893
|
+
advanceCursor(ctx, result.cursor, "wait");
|
|
894
|
+
|
|
732
895
|
return {
|
|
733
896
|
ok: true,
|
|
734
|
-
reason:
|
|
897
|
+
reason: items.length ? `${items.length} item(s) for this seat` : "timeout (nothing for this seat)",
|
|
735
898
|
delivered,
|
|
736
|
-
events:
|
|
899
|
+
events: items.length,
|
|
737
900
|
lived: ctx.now() - startedAt,
|
|
738
901
|
};
|
|
739
902
|
}
|
|
@@ -1013,6 +1176,7 @@ export const _internals = {
|
|
|
1013
1176
|
consumeStream,
|
|
1014
1177
|
waitOnce,
|
|
1015
1178
|
dispatchEvent,
|
|
1179
|
+
advanceCursor,
|
|
1016
1180
|
scheduleWake,
|
|
1017
1181
|
wakeTargetFor,
|
|
1018
1182
|
setRung,
|
package/lib/org/push.test.mjs
CHANGED
|
@@ -49,18 +49,33 @@ function cfgDisabled() {
|
|
|
49
49
|
return { org: { cohort: { enabled: false, base: BASE, token: "tok" } } };
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* A fake org client: only what push.mjs uses.
|
|
54
|
+
*
|
|
55
|
+
* `call` is the LONG-POLL seam. `agent.wait` is a POST METHOD on hq's
|
|
56
|
+
* dispatcher, not a GET read — it is absent from `reads.ts#HANDLED_READS`, so
|
|
57
|
+
* the GET this suite used to assert would have 404'd against every hq that has
|
|
58
|
+
* ever existed. The ok-frame shape below (`{ok:true, result:{cursor, items}}`)
|
|
59
|
+
* is hq `methods/agent/wait.ts`'s documented return contract.
|
|
60
|
+
*/
|
|
53
61
|
function fakeClient(opts = {}) {
|
|
54
62
|
const c = {
|
|
55
63
|
isEnabled: (cfg) => !!(cfg && cfg.org && cfg.org.cohort && cfg.org.cohort.enabled),
|
|
56
64
|
configFromAgent: () => ({ base: BASE, token: "tok", orgId: "acme", enabled: true }),
|
|
57
65
|
readCalls: [],
|
|
66
|
+
callCalls: [],
|
|
58
67
|
_reads: opts.reads ? opts.reads.slice() : [],
|
|
68
|
+
_calls: opts.calls ? opts.calls.slice() : [],
|
|
59
69
|
read(path, o) {
|
|
60
70
|
c.readCalls.push({ path, o });
|
|
61
71
|
const next = c._reads.length ? c._reads.shift() : { ok: true, payload: { events: [] }, status: 200 };
|
|
62
72
|
return Promise.resolve(typeof next === "function" ? next(path, o) : next);
|
|
63
73
|
},
|
|
74
|
+
call(method, params, o) {
|
|
75
|
+
c.callCalls.push({ method, params, o });
|
|
76
|
+
const next = c._calls.length ? c._calls.shift() : { ok: true, status: 200, result: { items: [], cursor: null, timedOut: true } };
|
|
77
|
+
return Promise.resolve(typeof next === "function" ? next(method, params, o) : next);
|
|
78
|
+
},
|
|
64
79
|
};
|
|
65
80
|
return c;
|
|
66
81
|
}
|
|
@@ -315,7 +330,7 @@ test("ladder: repeated stream failures demote to the long-poll, which then deliv
|
|
|
315
330
|
new Error("ECONNRESET"),
|
|
316
331
|
]);
|
|
317
332
|
const client = fakeClient({
|
|
318
|
-
|
|
333
|
+
calls: [{ ok: true, status: 200, result: { items: [JSON.parse(EV(100)), JSON.parse(EV(101))], cursor: 101, timedOut: false } }],
|
|
319
334
|
});
|
|
320
335
|
const { handle, ctx } = await mkHandle(root, {
|
|
321
336
|
fetchImpl, client, wakeCoalesceMs: 0, enqueueTick: (t) => woke.push(t),
|
|
@@ -333,9 +348,10 @@ test("ladder: repeated stream failures demote to the long-poll, which then deliv
|
|
|
333
348
|
|
|
334
349
|
await _internals.ladderStep(ctx);
|
|
335
350
|
await flush();
|
|
336
|
-
assert.equal(client.
|
|
337
|
-
assert.
|
|
338
|
-
assert.
|
|
351
|
+
assert.equal(client.callCalls.length, 1, "the long-poll lane took over");
|
|
352
|
+
assert.equal(client.readCalls.length, 0, "agent.wait is a POST method — the GET read lane has no handler for it and 404s");
|
|
353
|
+
assert.equal(client.callCalls[0].method, "agent.wait");
|
|
354
|
+
assert.equal(client.callCalls[0].params.timeoutMs, 25_000, "hq caps agent.wait at 25s (MAX_WAIT_MS); asking for 55s only makes our abort budget wrong");
|
|
339
355
|
assert.equal(ctx.cursor, 101);
|
|
340
356
|
assert.deepEqual(woke.map((w) => w.metadata.pushSeq), [100, 101]);
|
|
341
357
|
handle.stop();
|
|
@@ -344,7 +360,7 @@ test("ladder: repeated stream failures demote to the long-poll, which then deliv
|
|
|
344
360
|
test("ladder: repeated long-poll failures demote to CADENCE, which idles instead of hammering", async () => {
|
|
345
361
|
const root = tmpRepo();
|
|
346
362
|
const client = fakeClient({
|
|
347
|
-
|
|
363
|
+
calls: [
|
|
348
364
|
{ ok: false, status: 500, error: { code: "INTERNAL" } },
|
|
349
365
|
{ ok: false, status: 500, error: { code: "INTERNAL" } },
|
|
350
366
|
{ ok: false, status: 0, error: { code: "INTERNAL" } },
|
|
@@ -361,9 +377,9 @@ test("ladder: repeated long-poll failures demote to CADENCE, which idles instead
|
|
|
361
377
|
await _internals.ladderStep(ctx);
|
|
362
378
|
assert.equal(ctx.rung, PUSH_RUNG.CADENCE, "the floor of the ladder");
|
|
363
379
|
|
|
364
|
-
const before = client.
|
|
380
|
+
const before = client.callCalls.length;
|
|
365
381
|
const delay = await _internals.ladderStep(ctx);
|
|
366
|
-
assert.equal(client.
|
|
382
|
+
assert.equal(client.callCalls.length, before, "the cadence rung issues NO requests — it is the 45s poll doing the work");
|
|
367
383
|
assert.ok(delay >= 1_000, "it sleeps out the dwell rather than spinning");
|
|
368
384
|
|
|
369
385
|
// The status file must say, out loud, which lane the agent is on.
|
|
@@ -396,7 +412,7 @@ test("ladder: a lower rung is probed back up to the stream after the dwell perio
|
|
|
396
412
|
test("ladder: a 404 (hq has no such endpoint) demotes INSTANTLY, with no retries and no backoff", async () => {
|
|
397
413
|
const root = tmpRepo();
|
|
398
414
|
const fetchImpl = fakeFetch([{ status: 404, body: null }]);
|
|
399
|
-
const client = fakeClient({
|
|
415
|
+
const client = fakeClient({ calls: [{ ok: false, status: 404, error: { code: "NOT_FOUND" } }] });
|
|
400
416
|
const { handle, ctx } = await mkHandle(root, { fetchImpl, client, demoteAfter: 3 });
|
|
401
417
|
|
|
402
418
|
const d1 = await _internals.ladderStep(ctx);
|
|
@@ -514,7 +530,7 @@ test("a long-poll that answers INSTANTLY with nothing is floored, not spun", asy
|
|
|
514
530
|
// A half-implemented (or proxy-terminated) agent.wait that returns at once
|
|
515
531
|
// instead of holding would otherwise re-arm at wire speed against hq.
|
|
516
532
|
let clock = 7_000;
|
|
517
|
-
const client = fakeClient({
|
|
533
|
+
const client = fakeClient({ calls: [{ ok: true, status: 200, result: { items: [], cursor: null, timedOut: true } }] });
|
|
518
534
|
const { handle, ctx } = await mkHandle(root, {
|
|
519
535
|
client, now: () => clock, startRung: PUSH_RUNG.LONGPOLL, baseBackoffMs: 750, minHealthyMs: 2_000,
|
|
520
536
|
});
|
|
@@ -688,3 +704,89 @@ test("housekeeping: tmp roots are removable (no lingering handles)", () => {
|
|
|
688
704
|
rmSync(root, { recursive: true, force: true });
|
|
689
705
|
assert.equal(existsSync(root), false);
|
|
690
706
|
});
|
|
707
|
+
|
|
708
|
+
// ---------------------------------------------------------------------------
|
|
709
|
+
// JOINT 3 — the wire contract, corrected against the DEPLOYED hq
|
|
710
|
+
// ---------------------------------------------------------------------------
|
|
711
|
+
|
|
712
|
+
test("JOINT 3: the stream path is /api/v1/stream, not /api/v1/agent.stream", async () => {
|
|
713
|
+
const root = tmpRepo();
|
|
714
|
+
const fetchImpl = fakeFetch([streamResponse([EV(5)])]);
|
|
715
|
+
const { handle, ctx } = await mkHandle(root, { fetchImpl, wakeCoalesceMs: 0, minHealthyMs: 0, enqueueTick: () => {} });
|
|
716
|
+
await _internals.ladderStep(ctx);
|
|
717
|
+
// `agent.stream` resolves to hq's [...method] catch-all, which answers 404 for
|
|
718
|
+
// an unknown read — read by the ladder as "endpoint absent" and demoted
|
|
719
|
+
// instantly, forever. hq serves the stream as a STATIC route at /api/v1/stream.
|
|
720
|
+
assert.match(fetchImpl.calls[0].url, /\/api\/v1\/stream(\?|$)/);
|
|
721
|
+
assert.ok(!/agent\.stream/.test(fetchImpl.calls[0].url), "agent.stream is a path that has never existed on hq");
|
|
722
|
+
handle.stop();
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test("JOINT 3: hq's untyped `cursor` watermark frame advances the resume point", () => {
|
|
726
|
+
// These are hq's REAL frames (src/server/agent-stream/sse.ts): no `type`, no
|
|
727
|
+
// `seq`, no `family` — the old decoder returned "ignore" for all three, so a
|
|
728
|
+
// reconnect re-asked hq to rescan every event that was not this seat's.
|
|
729
|
+
assert.deepEqual(decodeFrameLine('data: {"cursor":12766}'), { kind: "cursor", cursor: 12766 });
|
|
730
|
+
assert.deepEqual(
|
|
731
|
+
decodeFrameLine('data: {"orgId":"o","memberId":"m","cursor":12600,"heartbeatMs":20000,"lifetimeMs":900000}'),
|
|
732
|
+
{ kind: "cursor", cursor: 12600 },
|
|
733
|
+
);
|
|
734
|
+
assert.deepEqual(decodeFrameLine('data: {"reason":"lifetime","cursor":13000,"message":"reconnect"}'), { kind: "cursor", cursor: 13000 });
|
|
735
|
+
// An event frame is still an event frame — the watermark branch must not eat it.
|
|
736
|
+
assert.equal(decodeFrameLine(`data: ${EV(9)}`).kind, "event");
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test("JOINT 3: a watermark frame moves the persisted cursor FORWARD ONLY", async () => {
|
|
740
|
+
const root = tmpRepo();
|
|
741
|
+
const fetchImpl = fakeFetch([
|
|
742
|
+
streamResponse([
|
|
743
|
+
'data: {"orgId":"o","memberId":"m","cursor":500}',
|
|
744
|
+
EV(501),
|
|
745
|
+
'data: {"cursor":900}', // 399 events that were NOT ours
|
|
746
|
+
'data: {"cursor":400}', // a stale/mis-ordered frame must not rewind us
|
|
747
|
+
]),
|
|
748
|
+
]);
|
|
749
|
+
const { handle, ctx } = await mkHandle(root, { fetchImpl, wakeCoalesceMs: 0, minHealthyMs: 0, enqueueTick: () => {} });
|
|
750
|
+
await _internals.ladderStep(ctx);
|
|
751
|
+
await flush();
|
|
752
|
+
assert.equal(ctx.cursor, 900, "the checkpoint past other members' business is honoured");
|
|
753
|
+
assert.equal(readCursor(root), 900, "and persisted, so a restart does not rescan it");
|
|
754
|
+
handle.stop();
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
test("JOINT 3: every inbound family wakes the authoritative pull — board included", async () => {
|
|
758
|
+
const root = tmpRepo();
|
|
759
|
+
const woke = [];
|
|
760
|
+
const families = ["messaging", "board", "calendar", "escalation", "approval", "decision", "files", "handoff", "calling"];
|
|
761
|
+
const lines = families.map((f, i) => EV(200 + i, f, "x"));
|
|
762
|
+
const fetchImpl = fakeFetch([streamResponse(lines)]);
|
|
763
|
+
const { handle, ctx } = await mkHandle(root, {
|
|
764
|
+
fetchImpl, wakeCoalesceMs: 0, minHealthyMs: 0, enqueueTick: (t) => woke.push(t),
|
|
765
|
+
});
|
|
766
|
+
await _internals.ladderStep(ctx);
|
|
767
|
+
await flush();
|
|
768
|
+
assert.equal(woke.length, families.length, `every inbound family must wake something; got ${woke.length}/${families.length}`);
|
|
769
|
+
assert.ok(woke.every((w) => w.cadence === "messaging-inbound"), "one nudge, one authoritative ACL'd pull, one cursor");
|
|
770
|
+
handle.stop();
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
test("JOINT 3: a family hq proved is ours but nothing consumes is dropped LOUDLY", async () => {
|
|
774
|
+
const root = tmpRepo();
|
|
775
|
+
const warns = [];
|
|
776
|
+
const realWarn = console.warn;
|
|
777
|
+
console.warn = (...a) => warns.push(a.join(" "));
|
|
778
|
+
try {
|
|
779
|
+
const fetchImpl = fakeFetch([streamResponse([EV(300, "artifact", "action")])]);
|
|
780
|
+
const { handle, ctx } = await mkHandle(root, { fetchImpl, wakeCoalesceMs: 0, minHealthyMs: 0, enqueueTick: () => {} });
|
|
781
|
+
await _internals.ladderStep(ctx);
|
|
782
|
+
await flush();
|
|
783
|
+
assert.equal(ctx.cursor, 300, "it is still consumed — not advancing would re-deliver it forever");
|
|
784
|
+
assert.ok(
|
|
785
|
+
warns.some((w) => /"artifact" frame it had already resolved as THIS SEAT'S/.test(w)),
|
|
786
|
+
`a dropped for-me frame must be visible; got ${JSON.stringify(warns)}`,
|
|
787
|
+
);
|
|
788
|
+
handle.stop();
|
|
789
|
+
} finally {
|
|
790
|
+
console.warn = realWarn;
|
|
791
|
+
}
|
|
792
|
+
});
|