agent-tempo 2.0.0-beta.1 → 2.0.0-beta.2

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 (40) hide show
  1. package/dashboard/package.json +1 -1
  2. package/dist/activities/maestro.js +3 -0
  3. package/dist/activities/outbox.js +12 -0
  4. package/dist/activities/resolve.d.ts +18 -7
  5. package/dist/activities/resolve.js +58 -46
  6. package/dist/adapters/claude-code/adapter.js +7 -0
  7. package/dist/cli/command-center-command.js +15 -1
  8. package/dist/cli/help-text.js +2 -0
  9. package/dist/config.d.ts +14 -0
  10. package/dist/config.js +14 -0
  11. package/dist/constants.d.ts +28 -0
  12. package/dist/constants.js +38 -1
  13. package/dist/daemon.js +29 -0
  14. package/dist/http/event-types.d.ts +33 -0
  15. package/dist/http/server.js +10 -0
  16. package/dist/http/snapshot.js +3 -0
  17. package/dist/observability/nondeterminism-alarm.d.ts +113 -0
  18. package/dist/observability/nondeterminism-alarm.js +162 -0
  19. package/dist/server-tools.js +30 -29
  20. package/dist/server.js +42 -0
  21. package/dist/tools/action-guard.d.ts +23 -0
  22. package/dist/tools/action-guard.js +33 -0
  23. package/dist/tools/coat-check.d.ts +20 -0
  24. package/dist/tools/coat-check.js +129 -0
  25. package/dist/tools/gate.d.ts +13 -0
  26. package/dist/tools/gate.js +88 -0
  27. package/dist/tools/schedule.d.ts +18 -0
  28. package/dist/tools/schedule.js +93 -1
  29. package/dist/tools/stage.d.ts +17 -0
  30. package/dist/tools/stage.js +85 -1
  31. package/dist/tools/state.d.ts +14 -0
  32. package/dist/tools/state.js +95 -0
  33. package/dist/types.d.ts +39 -1
  34. package/dist/utils/orphan-guard.d.ts +37 -0
  35. package/dist/utils/orphan-guard.js +26 -0
  36. package/dist/utils/search-attributes.d.ts +1 -0
  37. package/dist/utils/search-attributes.js +9 -0
  38. package/dist/workflows/session.js +193 -43
  39. package/package.json +1 -1
  40. package/workflow-bundle.js +241 -45
@@ -70,6 +70,31 @@ async function agentSessionWorkflow(input) {
70
70
  const DEFAULT_DRAINING_DEADLINE_MS = 5_000;
71
71
  /** Max duration a messageId can stay in-flight before the safety timer ejects it. */
72
72
  const PROCESSING_DEADLINE_MS = 15 * 60 * 1000;
73
+ /**
74
+ * #704 — max duration a session may sit in `booting` (no attachment claimed)
75
+ * before the watchdog fails the recruit. Default 180s (architect OQ-3: 120s
76
+ * floor; 180s clears a cold launch + cross-host recruit handshake). Workflows
77
+ * can't read process.env, so an operator/test override rides durable metadata
78
+ * (`bootingDeadlineMs`, sourced from `AGENT_TEMPO_BOOTING_DEADLINE_MS` at
79
+ * spawn). Only ARMED for headless adapters on a fresh (non-handoff) boot —
80
+ * see `armBootingWatchdog` below.
81
+ */
82
+ const BOOTING_DEADLINE_MS = typeof input.metadata.bootingDeadlineMs === 'number' && input.metadata.bootingDeadlineMs > 0
83
+ ? input.metadata.bootingDeadlineMs
84
+ : 180_000;
85
+ /**
86
+ * #704 Item 2 — generous main-loop wake backstop (architect-ruled FINAL).
87
+ * Every deadline-mutating handler now bumps `wakeEpoch`, so in steady state
88
+ * the loop wakes on exactly `nextDeadlineMs()`. This backstop is defense-in-
89
+ * depth for a FUTURE handler that mutates wake-relevant state but forgets the
90
+ * bump: the loop still re-evaluates at least every `BACKSTOP_MS` instead of
91
+ * sleeping forever (we deliberately do NOT add an `Infinity → no-timer` branch
92
+ * — a silent indefinite sleep is the exact failure class #704 exists to kill).
93
+ * A fallback-cap wake that finds actionable state emits a loud WARN (below) so
94
+ * a missed bump is detectable, not masked. 30min is large enough that it never
95
+ * fires in correct steady state yet bounds any regression.
96
+ */
97
+ const BACKSTOP_MS = 30 * 60 * 1000;
73
98
  // ── 2.0 clean-slate (#787) ──
74
99
  // The replay-only `patched()` markers that protected in-flight 1.x sessions
75
100
  // across rolling deploys are gone: 2.0 is a hard cutover (the #786 boot guard
@@ -188,6 +213,44 @@ async function agentSessionWorkflow(input) {
188
213
  : undefined;
189
214
  /** ISO timestamp of when the workflow most recently entered `detached`. */
190
215
  let detachedSince = null;
216
+ // ── #704 — Booting attach-timeout watchdog ──
217
+ // A session that starts FRESH in `booting` (no attachment handoff) and never
218
+ // reaches `claimAttachment` within `BOOTING_DEADLINE_MS` is a failed recruit:
219
+ // the adapter never launched, wedged on a launch dialog, or crashed pre-attach.
220
+ // We ARM a deadline only when ALL THREE hold:
221
+ // 1. `startedFresh` — no handoff. A restart/migrate carries `currentAttachment`
222
+ // (and a non-`booting` phase), so its successor must NEVER arm — it already
223
+ // has an adapter contract. Detached/other CAN successors are likewise skipped.
224
+ // 2. `recruitedBy` present — this is an actual RECRUIT (a spawn is coming and is
225
+ // expected to attach). The watchdog is a failed-recruit detector — it even
226
+ // notifies `recruitedBy` ("recruit of <name> never attached"). Skeletons created
227
+ // WITHOUT a recruiter intentionally sit in `booting` awaiting a manual attach and
228
+ // must NOT be swept: from-upgrade re-attach skeletons (#786 — the designed
229
+ // await-per-player-restart behavior), conductor/`up`, manual self-bootstrap. A
230
+ // positive allowlist ("arm only real recruits") rather than a per-skeleton-path
231
+ // blocklist, so future adapterless-skeleton paths inherit the safe default.
232
+ // 3. `canBlockOnDialog !== true` — the adapter can't park on a blocking
233
+ // launch-time dialog. Interactive `claude-code` (dev-channels dialog) sets
234
+ // this true and is DISARMED until #890 dissolves the dialog: an operator-away
235
+ // false-kill of a legitimately-waiting recruit is worse than the hang.
236
+ // The structural `canBlockOnDialog` (resolved from the adapter descriptor at
237
+ // spawn, threaded via metadata) is the contract — NOT an `agentType` hardcode.
238
+ const startedFresh = !input.currentAttachment && (input.phase === undefined || input.phase === 'booting');
239
+ const armBootingWatchdog = startedFresh && !!input.metadata.recruitedBy && input.metadata.canBlockOnDialog !== true;
240
+ /**
241
+ * ISO timestamp of when this run entered `booting`, or `null` when the watchdog
242
+ * is disarmed (handoff / non-recruit skeleton / interactive / already attached).
243
+ * Cleared on the first successful fresh claim. Only non-null ⟹ armed, so
244
+ * `nextDeadlineMs()` and the main-loop reap can gate on `bootingSince !== null` alone.
245
+ */
246
+ let bootingSince = armBootingWatchdog ? workflowNow().toISOString() : null;
247
+ if (startedFresh && input.metadata.recruitedBy && input.metadata.canBlockOnDialog === true) {
248
+ // Visibility for the known, bounded gap (companion brief §1): interactive
249
+ // claude-code RECRUITS keep today's indefinite-`booting` behavior until #890.
250
+ // (Non-recruit skeletons are disarmed for a different reason — no recruiter —
251
+ // and don't log this #890 notice.)
252
+ workflow_1.log.info('booting watchdog DISARMED for interactive claude-code recruit (canBlockOnDialog) — pending #890');
253
+ }
191
254
  // ── Processing Lifecycle State (fixes #99) ──
192
255
  // Tracks messages currently being processed by a blocking adapter. While non-empty,
193
256
  // stale detection is suppressed AND the phase refines to `processing`.
@@ -270,6 +333,11 @@ async function agentSessionWorkflow(input) {
270
333
  function nextDeadlineMs() {
271
334
  const nowMs = workflowNow().getTime();
272
335
  const candidates = [];
336
+ // #704 — booting attach-timeout. `bootingSince` is non-null only while the
337
+ // watchdog is armed AND the session is still booting (cleared on fresh claim).
338
+ if (phase === 'booting' && bootingSince) {
339
+ candidates.push(new Date(bootingSince).getTime() + BOOTING_DEADLINE_MS);
340
+ }
273
341
  if (currentAttachment) {
274
342
  candidates.push(new Date(currentAttachment.expiresAt).getTime());
275
343
  }
@@ -534,6 +602,10 @@ async function agentSessionWorkflow(input) {
534
602
  // Phase refinement: if we're attached (or awaiting), move to `processing`.
535
603
  if (phase === 'attached' || phase === 'awaiting')
536
604
  setPhase('processing');
605
+ // #704 Item 2 — setting `processingSince` adds a new `PROCESSING_DEADLINE_MS`
606
+ // deadline; bump `wakeEpoch` so the main loop arms it immediately rather than
607
+ // relying on the backstop cap (the historical reason for the 5-min cap).
608
+ wakeEpoch++;
537
609
  }
538
610
  lastActivityTime = workflowNow().getTime();
539
611
  activityCount++;
@@ -564,6 +636,10 @@ async function agentSessionWorkflow(input) {
564
636
  const outboxIdle = !outbox.some((e) => e.status === 'pending' || e.status === 'processing');
565
637
  setPhase(outboxIdle ? 'awaiting' : 'attached');
566
638
  }
639
+ // #704 Item 2 — clearing `processingSince` removes the processing deadline;
640
+ // bump `wakeEpoch` so the main loop re-evaluates (and dispatches any pending
641
+ // outbox) immediately rather than waiting on the backstop cap.
642
+ wakeEpoch++;
567
643
  }
568
644
  lastActivityTime = workflowNow().getTime();
569
645
  activityCount++;
@@ -658,6 +734,11 @@ async function agentSessionWorkflow(input) {
658
734
  (0, workflow_1.upsertSearchAttributes)({
659
735
  AgentTempoAttachedHost: [''],
660
736
  });
737
+ // #704 Item 1b — stamp the typed close-reason MEMO on this terminal
738
+ // completion. The bootstrap orphan-guard (`server.ts`) reads it via
739
+ // `describe().memo` to self-tombstone a late-launching orphan process whose
740
+ // run was destroyed and never recreated (no running run + this reason).
741
+ (0, workflow_1.upsertMemo)({ [search_attributes_1.MEMO_KEYS.closeReason]: 'destroyed' });
661
742
  setPhase('gone');
662
743
  // Inject a final audit message so the old adapter-completion path has something to show.
663
744
  messages.push({
@@ -669,6 +750,10 @@ async function agentSessionWorkflow(input) {
669
750
  });
670
751
  lastActivityTime = workflowNow().getTime();
671
752
  activityCount++;
753
+ // #704 Item 2 — `destroyRequested` (set above) is in the main-loop predicate,
754
+ // but bump `wakeEpoch` too so a loop asleep on a far deadline wakes promptly
755
+ // to run the terminal exit path.
756
+ wakeEpoch++;
672
757
  });
673
758
  (0, workflow_1.setHandler)(signals_1.isDestroyedQuery, () => destroyed || destroyRequested);
674
759
  // ── Test-only CAN trigger (#226) ──
@@ -712,6 +797,10 @@ async function agentSessionWorkflow(input) {
712
797
  currentAttachment.leaseMs = leaseMs;
713
798
  lastActivityTime = nowMs;
714
799
  activityCount++;
800
+ // #704 Item 2 — this mutates `expiresAt` (a `nextDeadlineMs()` input); bump
801
+ // `wakeEpoch` so the main loop re-evaluates the new lease deadline instead
802
+ // of relying on the backstop cap.
803
+ wakeEpoch++;
715
804
  return attachmentTokenFrom(currentAttachment, leaseMs);
716
805
  }
717
806
  // Conflict: active lease held by someone else.
@@ -741,12 +830,19 @@ async function agentSessionWorkflow(input) {
741
830
  drainingSince = null;
742
831
  drainingDeadlineMs = null;
743
832
  detachedSince = null;
833
+ // #704 Item 1a — first successful claim: disarm the booting watchdog so
834
+ // `nextDeadlineMs()` drops the booting deadline (the lease deadline takes over).
835
+ bootingSince = null;
744
836
  setPhase('attached');
745
837
  (0, workflow_1.upsertSearchAttributes)({
746
838
  AgentTempoAttachedHost: [host],
747
839
  });
748
840
  lastActivityTime = nowMs;
749
841
  activityCount++;
842
+ // #704 Item 2 — a fresh claim moves a `booting`/`detached` session to a
843
+ // finite lease deadline (often from `+Infinity`); bump `wakeEpoch` so the
844
+ // main loop picks up the new deadline immediately.
845
+ wakeEpoch++;
750
846
  return attachmentTokenFrom(newAttachment, leaseMs);
751
847
  }, {
752
848
  validator: ({ host, leaseMs, protocolVersion }) => {
@@ -1432,52 +1528,36 @@ async function agentSessionWorkflow(input) {
1432
1528
  // timer would leave the workflow in `draining` until that old timer fired.
1433
1529
  const epochAtWait = wakeEpoch;
1434
1530
  const deadlineMs = nextDeadlineMs();
1435
- // NOTE: This 5-min fallback wake is LOAD-BEARING despite an old "PR-C shim"
1436
- // framing that surfaced in researcher's tier-2 cleanup audit (2026-04-26).
1437
- // While #175 removed the legacy stale/blocked detection block this originally
1438
- // fed (see ~§1527 below), the wake itself remains essential as the loop's
1439
- // periodic re-evaluation tick for state changes from handlers that mutate
1440
- // `nextDeadlineMs()` inputs WITHOUT bumping `wakeEpoch`:
1441
- //
1442
- // - `claimAttachmentUpdate` (renewal + fresh paths) sets
1443
- // `currentAttachment.expiresAt`new lease-expiry deadline
1444
- // - `processingStartUpdate` sets `processingSince` new
1445
- // `PROCESSING_DEADLINE_MS` deadline
1446
- // - `processingEndUpdate` clears `processingSince` cancels processing
1447
- // deadline
1448
- // - `destroyUpdate` (async hard-terminate-then-flip path)
1449
- //
1450
- // Without the fallback wake, a workflow waiting in `condition(predicate)` on
1451
- // an `Infinity` deadline (booting / detached, no draining, no processing)
1452
- // never re-evaluates `nextDeadlineMs()` after one of these handlers fires —
1453
- // the freshly-set lease-expiry timer is never picked up, lease expiry is
1454
- // never reaped, and the workflow stalls until external state forces the
1455
- // predicate true. Smoking-gun test that fails without the fallback:
1456
- // `test/session-phase-processing.test.ts:54` "attached -> processing ->
1457
- // awaiting via processingStart/End (#117 fix)" — times out at 10s because
1458
- // the loop never makes progress after `processingStart` lands on a fresh
1459
- // claim.
1460
- //
1461
- // Removing this fallback safely is a "main-loop wake-discipline cleanup"
1462
- // separate from the audit's framing — adds `wakeEpoch++` to each affected
1463
- // handler, gates with `patched()` markers for replay-determinism (live
1464
- // workflow histories already recorded the existing `Timer 5min` events),
1465
- // and adds a regression test covering the handler-induced-deadline pickup.
1466
- // Estimated 4–6 handler edits + tests, separate dedicated PR. See the
1467
- // 2026-04-26 forensics for the full mechanism walkthrough — link from this
1468
- // file's PR history.
1469
- //
1470
- // Until that cleanup happens, DO NOT remove this fallback. The
1471
- // `Math.min(deadlineMs, 5 * 60 * 1000)` cap is part of the same mechanism:
1472
- // it ensures every deadline (even hour-long lease-expiry timers) is
1473
- // re-evaluated at least every 5 min so handler-induced deadline shortenings
1474
- // can't be missed.
1475
- const conditionPromise = (0, workflow_1.condition)(() => destroyRequested ||
1531
+ // #704 Item 2 wake discipline. Every handler that mutates a `nextDeadlineMs()`
1532
+ // input now bumps `wakeEpoch` (claim renew+fresh, processingStart/End, the
1533
+ // draining/detach paths, destroy), so in steady state the loop wakes on exactly
1534
+ // `nextDeadlineMs()`. `BACKSTOP_MS` is defense-in-depth for a FUTURE handler that
1535
+ // mutates wake-relevant state but forgets the bump: the loop still re-evaluates
1536
+ // at least every 30 min instead of sleeping forever. We deliberately keep the
1537
+ // backstop even when `deadlineMs === Infinity` (idle booting-disarmed / detached)
1538
+ // a silent indefinite sleep is the exact failure class #704 exists to kill, so
1539
+ // there is NO `Infinityno-timer` branch. A backstop wake that finds actionable
1540
+ // state emits a loud WARN below so a missed bump is detectable, not masked.
1541
+ // Canary: `test/session-phase-processing.test.ts` ("attached → processing →
1542
+ // awaiting via processingStart/End") fails if a deadline-mutating handler drops
1543
+ // its bump.
1544
+ const wokeByPredicate = await (0, workflow_1.condition)(() => destroyRequested ||
1476
1545
  canDispatch() ||
1477
1546
  hasPendingStop() ||
1478
1547
  phase === 'gone' ||
1479
- wakeEpoch !== epochAtWait, deadlineMs === Number.POSITIVE_INFINITY ? '5 minutes' : Math.min(deadlineMs, 5 * 60 * 1000));
1480
- await conditionPromise;
1548
+ wakeEpoch !== epochAtWait, Math.min(deadlineMs, BACKSTOP_MS));
1549
+ // #704 Item 2 — missed-bump breadcrumb. If we woke on the backstop cap (NOT the
1550
+ // predicate) yet a deadline is already overdue or the predicate is now actionable,
1551
+ // a handler likely mutated wake-relevant state without bumping `wakeEpoch`.
1552
+ // Surface it loudly instead of letting the backstop silently mask the regression.
1553
+ if (!wokeByPredicate) {
1554
+ const overdue = nextDeadlineMs() <= 0;
1555
+ const actionable = destroyRequested || canDispatch() || hasPendingStop() || phase === 'gone';
1556
+ if (overdue || actionable) {
1557
+ workflow_1.log.warn(`main-loop woke via fallback backstop with actionable state — possible missed ` +
1558
+ `wakeEpoch bump (phase=${phase}, overdue=${overdue}, actionable=${actionable})`);
1559
+ }
1560
+ }
1481
1561
  if (destroyRequested)
1482
1562
  break;
1483
1563
  // ── §9.5.a: Lease expiry — reap attachment and transition to `detached`. ──
@@ -1497,6 +1577,70 @@ async function agentSessionWorkflow(input) {
1497
1577
  });
1498
1578
  workflow_1.log.warn(`lease expired for attachment ${reaped.attachmentId} (host=${reaped.hostname})`);
1499
1579
  }
1580
+ // ── §9.5.a2: booting attach-timeout (#704). ──
1581
+ // A fresh, armed session that never reached `claimAttachment` within
1582
+ // `BOOTING_DEADLINE_MS` is a failed recruit (adapter never launched / wedged /
1583
+ // crashed pre-attach). Fail it LOUDLY: sweep any orphan process, notify the
1584
+ // recruiter, stamp the close-reason tombstone MEMO, and COMPLETE terminal
1585
+ // `gone`. `bootingSince !== null` ⟹ the watchdog is armed (headless adapter on
1586
+ // a fresh, non-handoff boot). The bootstrap orphan-guard backstops the case
1587
+ // where the swept process (or a never-swept one) re-launches later.
1588
+ if (phase === 'booting' &&
1589
+ bootingSince !== null &&
1590
+ workflowNow().getTime() - new Date(bootingSince).getTime() >= BOOTING_DEADLINE_MS) {
1591
+ lastDetachReason = 'boot-timeout';
1592
+ workflow_1.log.warn(`boot-timeout: session never attached within ${Math.round(BOOTING_DEADLINE_MS / 1000)}s — failing recruit`);
1593
+ // Best-effort orphan sweep. At the deadline the spawned process usually
1594
+ // EXISTS (still booting), so this command-line kill is MORE likely to land
1595
+ // than the destroy-time sweep. No-op if nothing launched.
1596
+ const killHost = preferredHost ?? input.metadata.hostname;
1597
+ if (killHost) {
1598
+ try {
1599
+ const killResult = await getHardTerminateProxyForDestroy(killHost)({
1600
+ ensemble: input.metadata.ensemble,
1601
+ playerName: input.metadata.playerId,
1602
+ agent: (input.metadata.agentType ?? 'claude'),
1603
+ workDir: input.metadata.workDir,
1604
+ });
1605
+ workflow_1.log.info(`boot-timeout hard-terminate on ${killHost}: strategy=${killResult.strategy}, ` +
1606
+ `killedPids=[${killResult.killedPids.join(',')}]`);
1607
+ }
1608
+ catch (err) {
1609
+ workflow_1.log.warn(`boot-timeout hard-terminate failed on ${killHost} (best-effort): ` +
1610
+ `${err instanceof Error ? err.message : String(err)}`);
1611
+ }
1612
+ }
1613
+ // Notify the recruiter (if any) — reuses the outbox `deliverCue` activity.
1614
+ const recruiter = input.metadata.recruitedBy;
1615
+ if (recruiter) {
1616
+ try {
1617
+ await deliverCue({
1618
+ ensemble: input.metadata.ensemble,
1619
+ fromPlayerId: input.metadata.playerId,
1620
+ targetPlayerId: recruiter,
1621
+ message: `Recruit of "${input.metadata.playerId}" never attached within ` +
1622
+ `${Math.round(BOOTING_DEADLINE_MS / 1000)}s — failed; the spawned process (if any) was swept.`,
1623
+ });
1624
+ }
1625
+ catch (err) {
1626
+ workflow_1.log.warn(`boot-timeout recruiter-notify failed (best-effort): ` +
1627
+ `${err instanceof Error ? err.message : String(err)}`);
1628
+ }
1629
+ }
1630
+ // Tombstone MEMO (shared with the orphan-guard) + terminal `gone`.
1631
+ (0, workflow_1.upsertMemo)({ [search_attributes_1.MEMO_KEYS.closeReason]: 'boot-timeout' });
1632
+ lastAdapterMeta = lastAdapterMeta ?? {
1633
+ hostname: killHost ?? input.metadata.hostname,
1634
+ adapterId: '',
1635
+ };
1636
+ bootingSince = null;
1637
+ setPhase('gone');
1638
+ (0, workflow_1.upsertSearchAttributes)({ AgentTempoAttachedHost: [''] });
1639
+ // Route through the terminal exit path (mirrors `destroy`): isDestroyed
1640
+ // queries read true, then the loop breaks to COMPLETE.
1641
+ destroyRequested = true;
1642
+ break;
1643
+ }
1500
1644
  // ── §9.5.b: processingDeadline — force exit from `processing` if a messageId is wedged. ──
1501
1645
  if (processingSince !== null &&
1502
1646
  workflowNow().getTime() - new Date(processingSince).getTime() > PROCESSING_DEADLINE_MS) {
@@ -1770,6 +1914,12 @@ async function agentSessionWorkflow(input) {
1770
1914
  adapterId: currentAttachment.adapterId,
1771
1915
  };
1772
1916
  lastDetachReason = 'spawn-failed';
1917
+ // #704 Item 2 — no `wakeEpoch++` needed here despite clearing
1918
+ // `nextDeadlineMs()` inputs (currentAttachment/processingSince/draining):
1919
+ // this rollback runs INLINE in the main-loop body (outbox dispatch), not
1920
+ // in a signal/update handler, so the very next loop iteration recomputes
1921
+ // `nextDeadlineMs()` before the next `condition()` wait. (The bump
1922
+ // discipline is for HANDLERS that mutate these while the loop is parked.)
1773
1923
  currentAttachment = null;
1774
1924
  inFlightMessages.clear();
1775
1925
  processingSince = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-tempo",
3
- "version": "2.0.0-beta.1",
3
+ "version": "2.0.0-beta.2",
4
4
  "description": "Many agents, one tempo. Durable coordination for multi-agent work via Temporal.",
5
5
  "keywords": [
6
6
  "mcp",