@cohortapp/agent-sdk 2.18.13 → 2.18.15

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.
Files changed (46) hide show
  1. package/bin/maestro.mjs +38 -1
  2. package/docs/runbooks/fleet-rollout.md +58 -7
  3. package/docs/runbooks/recovery-and-failover.md +18 -0
  4. package/lib/assurance/batch.mjs +353 -0
  5. package/lib/assurance/first-reply.mjs +423 -0
  6. package/lib/assurance/notice-voice.mjs +357 -0
  7. package/lib/assurance/plan-note.mjs +43 -0
  8. package/lib/assurance/room-budget.mjs +55 -6
  9. package/lib/cadence-failure-class.mjs +245 -0
  10. package/lib/claude-bin.mjs +26 -7
  11. package/lib/cli/doctor-checks.mjs +149 -1
  12. package/lib/comms/send-gate.mjs +59 -0
  13. package/lib/diagnostics/alerts.mjs +33 -0
  14. package/lib/engine/agents/usage.mjs +45 -0
  15. package/lib/engine/budget.mjs +293 -29
  16. package/lib/engine/cli.mjs +54 -5
  17. package/lib/engine/loop.mjs +30 -0
  18. package/lib/engine/output/json.mjs +26 -0
  19. package/lib/engine/wire/errors.mjs +179 -0
  20. package/lib/engine/wire/search.mjs +44 -8
  21. package/lib/identity/persona.mjs +31 -2
  22. package/lib/org/quota.mjs +27 -0
  23. package/lib/session/config.mjs +4 -0
  24. package/lib/session/identity.mjs +71 -7
  25. package/lib/session/launch-failure.mjs +251 -0
  26. package/lib/session/resume-target.mjs +86 -0
  27. package/lib/telemetry/alerts.mjs +94 -0
  28. package/lib/telemetry/collect.mjs +155 -2
  29. package/lib/upgrade/pinned-drift.mjs +467 -0
  30. package/package.json +1 -1
  31. package/scaffold/config/alerts.yaml +7 -0
  32. package/scripts/ci/check-cadence-prompts-exist.mjs +96 -0
  33. package/scripts/ci/check.mjs +3 -0
  34. package/scripts/daemon/agent-daemon.mjs +75 -5
  35. package/scripts/daemon/assurance.mjs +709 -44
  36. package/scripts/daemon/cadence-consumer.mjs +281 -34
  37. package/scripts/daemon/deliver.mjs +109 -0
  38. package/scripts/daemon/dispatcher.mjs +21 -3
  39. package/scripts/daemon/inbox-deferral.mjs +102 -9
  40. package/scripts/daemon/session-lock.mjs +41 -1
  41. package/scripts/emergency-stop.sh +114 -13
  42. package/scripts/fleet/rollout.mjs +256 -10
  43. package/scripts/healthcheck.sh +131 -33
  44. package/scripts/local-triggers/autoupdate.sh +144 -11
  45. package/scripts/resume-operations.sh +101 -6
  46. package/scripts/session/supervisor.mjs +198 -5
@@ -67,6 +67,7 @@ import { classifyItem, isDirectedAtAgent } from "./classifier.mjs";
67
67
  // gets a deterministic classification (no LLM triage), no holding ack, and a
68
68
  // per-seat jitter so thirteen answers do not land in the same second.
69
69
  import { isRollCallItem, rollCallJitterMs } from "../../lib/org/inbound/broadcast.mjs";
70
+ import { stampFirstReplyPlan } from "../../lib/assurance/first-reply.mjs";
70
71
  import { isRoomSurface } from "../../lib/org/inbound/surfaces.mjs";
71
72
  import { isMembershipReason } from "../../lib/org/inbound/directedness.mjs";
72
73
  import { cohortSurfaceOf, cohortSurfaceLabel, cohortSurfaceIsDeclared } from "./deliver.mjs";
@@ -96,7 +97,7 @@ import {
96
97
  isPidAlive,
97
98
  } from "./assurance.mjs";
98
99
  import { recordPoll, recordClassification, recordSession, writeHealthDashboard } from "./health.mjs";
99
- import { acquireLock, releaseLock, updateLock, scanStaleLocks, acquireThreadLock, claimRequest, hasActiveClaim, sweepStaleItemClaims, sanitiseItemId } from "./session-lock.mjs";
100
+ import { acquireLock, releaseLock, updateLock, scanStaleLocks, acquireThreadLock, threadLockKey, claimRequest, hasActiveClaim, sweepStaleItemClaims, sanitiseItemId } from "./session-lock.mjs";
100
101
  import { markDeferred } from "./inbox-deferral.mjs";
101
102
  import { parseQueueItems, rankBacklog, resolveBacklogWeights } from "../../lib/backlog.mjs";
102
103
  import { readLatestGaps } from "../../lib/goals/gaps.mjs";
@@ -322,10 +323,23 @@ async function reviveFrontDoorIfShut(state, deps = {}) {
322
323
  return verdict;
323
324
  }
324
325
 
325
- async function pollService(svc) {
326
+ /**
327
+ * Drain one service's inbox: poll, dedup, stamp the flurry plan, process.
328
+ *
329
+ * @param {{name:string, fn:Function}} svc
330
+ * @param {object} [deps] TEST-ONLY seams; PRODUCTION PASSES NOTHING, so the
331
+ * defaults are the real collaborators and behaviour is byte-identical. The
332
+ * same idiom as `processItem`/`sweepBacklog` above. Exported (and seamed) so
333
+ * the ONE-FIRST-REPLY-PER-FLURRY stamping has a test that fails when the call
334
+ * is removed — see `notice-emission.test.mjs`.
335
+ * @param {object} [deps.frontDoorGate] override the front-door gate
336
+ * @param {Function} [deps.processItem] override processItem
337
+ */
338
+ export async function pollService(svc, deps = {}) {
339
+ const processItemImpl = deps.processItem || processItem;
326
340
  try {
327
341
  // Front door: a live main session owns this service's inbox — leave it.
328
- const fd = _frontDoorGate.check(svc.name);
342
+ const fd = (deps.frontDoorGate || _frontDoorGate).check(svc.name);
329
343
  if (fd.changed) console.log(`[daemon] front door for ${svc.name}: ${fd.dispatch ? "daemon dispatches" : "main session owns intake"} (${fd.reason})`);
330
344
  if (!fd.dispatch) return 0;
331
345
  const result = await svc.fn();
@@ -358,8 +372,33 @@ async function pollService(svc) {
358
372
  return lock.acquired;
359
373
  });
360
374
 
375
+ // ── ONE FIRST REPLY PER FLURRY ───────────────────────────────────────
376
+ // "If there's a flurry of 4 messages in one go by others, instead of having
377
+ // a generic reply per message, it would be responding to the batch of
378
+ // messages." (owner, 2026-09-25.) The batch is computed HERE, over the whole
379
+ // poll, because this is the only place that sees more than one item at a
380
+ // time — `processItem` is called per item and structurally cannot know it is
381
+ // the third of four. Every non-leader carries `covered_by_batch`, which
382
+ // `assurance.shouldAcknowledge` turns into `ack:false` and the daemon makes
383
+ // durable as `interimForbidden`, so no later sweep in any process speaks for
384
+ // it either. The leader is told how many it stands for, so the one line it
385
+ // writes can answer all of them.
386
+ //
387
+ // WORK IS UNAFFECTED. Every item still gets its own `processItem`, its own
388
+ // obligation and its own answer; what is batched is only the FIRST thing
389
+ // said. Silence about timing is not silence about outcome.
390
+ //
391
+ // The stamping itself is `assurance/first-reply.stampFirstReplyPlan`, not
392
+ // four lines here, because four lines here had NO TEST THAT COULD SEE THEM:
393
+ // the only call site was inside this module-internal function, so deleting
394
+ // them left the whole suite green — including a test named "wired, not just
395
+ // available". `first-reply.test.mjs` now exercises the stamp and
396
+ // `notice-emission.test.mjs` drives THIS function through `pollService`'s
397
+ // injected seams, so removing the call is red in both.
398
+ stampFirstReplyPlan(newItems);
399
+
361
400
  for (const item of newItems) {
362
- await processItem(item, svc.name);
401
+ await processItemImpl(item, svc.name);
363
402
  }
364
403
 
365
404
  if (result.errors.length > 0) {
@@ -1107,7 +1146,38 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
1107
1146
  // This check runs BEFORE quick reply to prevent ALL duplicate responses.
1108
1147
  {
1109
1148
  const channel = item.channel_id || (item.raw_ref ? item.raw_ref.match(/slack:([^:]+):/)?.[1] : null) || item.channel;
1110
- const threadTs = item.thread_id || (isDm ? `dm-channel` : null);
1149
+ // THE UNTREADED CHANNEL POST USED TO TAKE NO LOCK AT ALL.
1150
+ //
1151
+ // `item.thread_id || (isDm ? "dm-channel" : null)` is null for exactly
1152
+ // one shape: a message posted to the main surface of a channel or group,
1153
+ // not in a thread, not a DM. That is the shape the owner screenshotted —
1154
+ // a flurry of messages into a room — and it was the one shape with no
1155
+ // dedup: each message cleared this block, spawned its own session, and
1156
+ // opened its own obligation, so the room got N sessions and N chances at
1157
+ // a holding line for one conversation.
1158
+ //
1159
+ // `channel-main` is the DM lock's counterpart for that surface: one
1160
+ // session at a time on a room's main feed, the rest deferred and
1161
+ // promoted at session close exactly like a DM burst.
1162
+ //
1163
+ // THIS IS ONLY SAFE BECAUSE OF THE BUNDLING FIX BESIDE IT. Deferral ends
1164
+ // in `promoteDeferred`, which collapses a group to its latest member and
1165
+ // marks the rest `.processed-bundled`. On a shared channel the deferred
1166
+ // set is several PEOPLE, not one person's flurry, so an unconditional
1167
+ // collapse here would silently bin a colleague's question — which is why
1168
+ // inbox-deferral.mjs now splits a group by sender unless the winner
1169
+ // actually carries the room's history. Widening this lock without that
1170
+ // change would trade a message storm for lost messages.
1171
+ //
1172
+ // The off-switch is real and deliberate: MAESTRO_CHANNEL_MAIN_LOCK=0
1173
+ // restores the previous behaviour on a seat without a code change, in
1174
+ // the same style as every other threshold in the assurance stack.
1175
+ const threadTs = threadLockKey({
1176
+ channel,
1177
+ threadId: item.thread_id,
1178
+ isDm,
1179
+ channelMainLock: String(process.env.MAESTRO_CHANNEL_MAIN_LOCK ?? "1") !== "0",
1180
+ });
1111
1181
  if (threadTs && channel) {
1112
1182
  const threadCheck = acquireThreadLock(channel, threadTs);
1113
1183
  if (!threadCheck.allowed) {