agent-tempo 1.7.0 → 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 (171) hide show
  1. package/CLAUDE.md +10 -13
  2. package/README.md +21 -15
  3. package/dashboard/dist/assets/index-BbR7zIdK.js.map +1 -1
  4. package/dashboard/package.json +1 -1
  5. package/dist/activities/hard-terminate.d.ts +2 -2
  6. package/dist/activities/hard-terminate.js +3 -9
  7. package/dist/activities/maestro.d.ts +8 -13
  8. package/dist/activities/maestro.js +3 -10
  9. package/dist/activities/outbox.js +16 -2
  10. package/dist/activities/resolve.d.ts +18 -7
  11. package/dist/activities/resolve.js +58 -46
  12. package/dist/adapters/base.js +3 -0
  13. package/dist/adapters/claude-code/adapter.js +7 -0
  14. package/dist/cli/command-center-command.js +15 -1
  15. package/dist/cli/commands.d.ts +8 -0
  16. package/dist/cli/commands.js +79 -11
  17. package/dist/cli/daemon-command.js +19 -33
  18. package/dist/cli/help-text.js +9 -7
  19. package/dist/cli/home-command.d.ts +26 -0
  20. package/dist/cli/home-command.js +156 -0
  21. package/dist/cli/removed-verbs.d.ts +0 -4
  22. package/dist/cli/removed-verbs.js +23 -12
  23. package/dist/cli/sa-preflight.d.ts +0 -14
  24. package/dist/cli/sa-preflight.js +1 -17
  25. package/dist/cli/startup.d.ts +1 -10
  26. package/dist/cli/startup.js +0 -40
  27. package/dist/cli.js +34 -80
  28. package/dist/client/core.js +13 -15
  29. package/dist/client/index.d.ts +2 -1
  30. package/dist/client/interface.d.ts +5 -14
  31. package/dist/client/with-spawn.d.ts +9 -4
  32. package/dist/client/with-spawn.js +7 -10
  33. package/dist/config.d.ts +14 -13
  34. package/dist/config.js +14 -0
  35. package/dist/constants.d.ts +50 -0
  36. package/dist/constants.js +60 -1
  37. package/dist/daemon.js +81 -0
  38. package/dist/http/auth.d.ts +3 -21
  39. package/dist/http/auth.js +10 -31
  40. package/dist/http/event-types.d.ts +38 -4
  41. package/dist/http/orphans.d.ts +3 -3
  42. package/dist/http/orphans.js +3 -3
  43. package/dist/http/server.d.ts +0 -5
  44. package/dist/http/server.js +79 -29
  45. package/dist/http/snapshot.js +3 -0
  46. package/dist/http/writes.d.ts +1 -1
  47. package/dist/http/writes.js +25 -2
  48. package/dist/observability/nondeterminism-alarm.d.ts +113 -0
  49. package/dist/observability/nondeterminism-alarm.js +162 -0
  50. package/dist/palette/index.d.ts +6 -7
  51. package/dist/palette/index.js +6 -7
  52. package/dist/pi/mission-control/actions.d.ts +67 -1
  53. package/dist/pi/mission-control/actions.js +61 -0
  54. package/dist/pi/mission-control/extension.d.ts +77 -0
  55. package/dist/pi/mission-control/extension.js +171 -0
  56. package/dist/pi/workflow-client.d.ts +14 -16
  57. package/dist/pi/workflow-client.js +18 -44
  58. package/dist/server-tools.js +30 -29
  59. package/dist/server.js +42 -0
  60. package/dist/tools/action-guard.d.ts +23 -0
  61. package/dist/tools/action-guard.js +33 -0
  62. package/dist/tools/coat-check.d.ts +20 -0
  63. package/dist/tools/coat-check.js +129 -0
  64. package/dist/tools/gate.d.ts +13 -0
  65. package/dist/tools/gate.js +88 -0
  66. package/dist/tools/schedule.d.ts +18 -0
  67. package/dist/tools/schedule.js +93 -1
  68. package/dist/tools/stage.d.ts +17 -0
  69. package/dist/tools/stage.js +85 -1
  70. package/dist/tools/state.d.ts +14 -0
  71. package/dist/tools/state.js +95 -0
  72. package/dist/types.d.ts +54 -7
  73. package/dist/upgrade/boot-guard.d.ts +95 -0
  74. package/dist/upgrade/boot-guard.js +88 -0
  75. package/dist/upgrade/from-upgrade.d.ts +95 -0
  76. package/dist/upgrade/from-upgrade.js +330 -0
  77. package/dist/upgrade/phase-engine.js +4 -1
  78. package/dist/utils/duration.d.ts +3 -6
  79. package/dist/utils/duration.js +3 -6
  80. package/dist/utils/format-hosts.d.ts +1 -1
  81. package/dist/utils/format-hosts.js +0 -2
  82. package/dist/utils/grpc-shutdown-guard.js +0 -4
  83. package/dist/utils/hosts.d.ts +4 -10
  84. package/dist/utils/hosts.js +12 -28
  85. package/dist/utils/orphan-guard.d.ts +37 -0
  86. package/dist/utils/orphan-guard.js +26 -0
  87. package/dist/utils/search-attributes.d.ts +28 -18
  88. package/dist/utils/search-attributes.js +48 -18
  89. package/dist/utils/visibility-deadline.d.ts +16 -0
  90. package/dist/utils/visibility-deadline.js +16 -0
  91. package/dist/workflows/maestro-signals.d.ts +0 -13
  92. package/dist/workflows/maestro-signals.js +6 -14
  93. package/dist/workflows/maestro.js +27 -32
  94. package/dist/workflows/scheduler.js +7 -1
  95. package/dist/workflows/session.js +237 -153
  96. package/dist/workflows/signals.d.ts +9 -1
  97. package/dist/workflows/signals.js +8 -6
  98. package/package.json +1 -6
  99. package/workflow-bundle.js +478 -225
  100. package/dist/cli/legacy-migration.d.ts +0 -35
  101. package/dist/cli/legacy-migration.js +0 -341
  102. package/dist/client/ensure-conductor-spawned.d.ts +0 -35
  103. package/dist/client/ensure-conductor-spawned.js +0 -48
  104. package/dist/tui/App.d.ts +0 -85
  105. package/dist/tui/App.js +0 -1791
  106. package/dist/tui/bootstrap-types.d.ts +0 -46
  107. package/dist/tui/bootstrap-types.js +0 -7
  108. package/dist/tui/commands.d.ts +0 -71
  109. package/dist/tui/commands.js +0 -1375
  110. package/dist/tui/components/ChatView.d.ts +0 -35
  111. package/dist/tui/components/ChatView.js +0 -54
  112. package/dist/tui/components/CommandPalette.d.ts +0 -21
  113. package/dist/tui/components/CommandPalette.js +0 -67
  114. package/dist/tui/components/ConversationStream.d.ts +0 -114
  115. package/dist/tui/components/ConversationStream.js +0 -307
  116. package/dist/tui/components/CreateEnsembleWizard.d.ts +0 -19
  117. package/dist/tui/components/CreateEnsembleWizard.js +0 -223
  118. package/dist/tui/components/DestroyConfirmModal.d.ts +0 -17
  119. package/dist/tui/components/DestroyConfirmModal.js +0 -62
  120. package/dist/tui/components/ErrorView.d.ts +0 -31
  121. package/dist/tui/components/ErrorView.js +0 -129
  122. package/dist/tui/components/HomeView.d.ts +0 -54
  123. package/dist/tui/components/HomeView.js +0 -306
  124. package/dist/tui/components/LoadLineupModal.d.ts +0 -18
  125. package/dist/tui/components/LoadLineupModal.js +0 -79
  126. package/dist/tui/components/MainView.d.ts +0 -21
  127. package/dist/tui/components/MainView.js +0 -107
  128. package/dist/tui/components/NewEnsembleModal.d.ts +0 -9
  129. package/dist/tui/components/NewEnsembleModal.js +0 -73
  130. package/dist/tui/components/Picker.d.ts +0 -23
  131. package/dist/tui/components/Picker.js +0 -70
  132. package/dist/tui/components/PlayerDetailView.d.ts +0 -26
  133. package/dist/tui/components/PlayerDetailView.js +0 -118
  134. package/dist/tui/components/PromptArea.d.ts +0 -50
  135. package/dist/tui/components/PromptArea.js +0 -303
  136. package/dist/tui/components/RecruitWizard.d.ts +0 -17
  137. package/dist/tui/components/RecruitWizard.js +0 -221
  138. package/dist/tui/components/RestoreConfirmModal.d.ts +0 -18
  139. package/dist/tui/components/RestoreConfirmModal.js +0 -71
  140. package/dist/tui/components/ScheduleWizard.d.ts +0 -19
  141. package/dist/tui/components/ScheduleWizard.js +0 -259
  142. package/dist/tui/components/Splash.d.ts +0 -23
  143. package/dist/tui/components/Splash.js +0 -221
  144. package/dist/tui/components/StatusBar.d.ts +0 -48
  145. package/dist/tui/components/StatusBar.js +0 -128
  146. package/dist/tui/components/StatusOverlay.d.ts +0 -15
  147. package/dist/tui/components/StatusOverlay.js +0 -76
  148. package/dist/tui/components/TitleBar.d.ts +0 -10
  149. package/dist/tui/components/TitleBar.js +0 -21
  150. package/dist/tui/index.d.ts +0 -15
  151. package/dist/tui/index.js +0 -162
  152. package/dist/tui/ink-context.d.ts +0 -18
  153. package/dist/tui/ink-context.js +0 -59
  154. package/dist/tui/ink-loader.d.ts +0 -26
  155. package/dist/tui/ink-loader.js +0 -42
  156. package/dist/tui/removed-commands.d.ts +0 -9
  157. package/dist/tui/removed-commands.js +0 -22
  158. package/dist/tui/sse-handler.d.ts +0 -52
  159. package/dist/tui/sse-handler.js +0 -157
  160. package/dist/tui/store.d.ts +0 -598
  161. package/dist/tui/store.js +0 -753
  162. package/dist/tui/utils/format.d.ts +0 -56
  163. package/dist/tui/utils/format.js +0 -155
  164. package/dist/tui/utils/fullscreen.d.ts +0 -23
  165. package/dist/tui/utils/fullscreen.js +0 -71
  166. package/dist/tui/utils/history.d.ts +0 -10
  167. package/dist/tui/utils/history.js +0 -85
  168. package/dist/tui/utils/platform.d.ts +0 -45
  169. package/dist/tui/utils/platform.js +0 -258
  170. package/dist/tui/utils/theme.d.ts +0 -21
  171. package/dist/tui/utils/theme.js +0 -24
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.agentSessionWorkflow = agentSessionWorkflow;
4
4
  const workflow_1 = require("@temporalio/workflow");
5
5
  const search_attributes_1 = require("../utils/search-attributes");
6
+ const constants_1 = require("../constants");
6
7
  const common_1 = require("@temporalio/common");
7
8
  /**
8
9
  * Workflow-deterministic clock. The Temporal TS SDK intercepts `new Date()` at the
@@ -61,15 +62,6 @@ async function agentSessionWorkflow(input) {
61
62
  // PR-C commit 6 (#119a): each attachment carries its own `leaseMs` (negotiated at
62
63
  // claim time). No workflow-side default constant — heartbeats extend `expiresAt`
63
64
  // by `currentAttachment.leaseMs`.
64
- /**
65
- * Legacy CAN-extension constant. Retained solely so sessions that ran on a
66
- * pre-#249 workflow bundle replay deterministically: the `patched()` branch at
67
- * the CAN-boundary extension site selects this constant on the non-patched
68
- * side, matching the exact arg sequence those histories recorded. New runs
69
- * (and all post-#249 CAN transitions) use `currentAttachment.leaseMs` instead
70
- * — see the call site below.
71
- */
72
- const HEARTBEAT_INTERVAL_MS = 30_000;
73
65
  /**
74
66
  * Default grace period for `draining → detached` transition after requestDetach. Used when a
75
67
  * `requestDetach` signal omits `deadlineMs`. Per-signal overrides are honored via the
@@ -78,65 +70,51 @@ async function agentSessionWorkflow(input) {
78
70
  const DEFAULT_DRAINING_DEADLINE_MS = 5_000;
79
71
  /** Max duration a messageId can stay in-flight before the safety timer ejects it. */
80
72
  const PROCESSING_DEADLINE_MS = 15 * 60 * 1000;
81
- // Version marker for v0.10 — records a patch marker in workflow history.
82
- // Future workflow changes that alter the command sequence should use
83
- // patched('v0.10-<change-name>') to protect in-flight sessions from
84
- // non-determinism errors during rolling deploys.
85
- (0, workflow_1.patched)('v0.10-initial');
86
- (0, workflow_1.patched)('v0.11-check-and-set-status');
87
- (0, workflow_1.patched)('v0.13-quality-gates');
88
- (0, workflow_1.patched)('v0.14-worktrees');
89
- (0, workflow_1.patched)('v0.15-blocked-detection');
90
- (0, workflow_1.patched)('v0.18-stages');
91
- (0, workflow_1.patched)('v0.23-hold-release');
92
- (0, workflow_1.patched)('v0.25-attachment-lifecycle');
93
- // Issue #172: kept as a replay marker for workflows that predate the
94
- // simpler hold-on-startup design (v0.26). The state field + interceptor
95
- // were removed in favor of baking the banner/directive into
96
- // `SessionInput.messages` at workflow creation, but leaving the patched
97
- // marker ensures existing replay histories that recorded this command
98
- // still deserialize cleanly. Safe no-op today.
99
- (0, workflow_1.patched)('v0.26-pending-startup-context');
100
- // ── T0.5 SA diet (#747) ONE marker gates the whole diet ──
101
- //
102
- // New runs (incl. post-deploy CAN successors fresh executions re-record
103
- // the marker) stop writing the read-only/write-only search attributes:
104
- // - AgentTempoGitRoot / AgentTempoPlayerType / AgentTempoIsConductor
105
- // migrate to the workflow MEMO (low-churn fields only — NEVER memo
106
- // counters like activityCount/lastActivityAt: memo upserts are
107
- // billable actions, that would trade query actions for upsert actions).
108
- // - AgentTempoAttachmentId is dropped outright (zero readers anywhere;
109
- // adapters correlate via the claimAttachment token).
110
- // Old histories replay the recorded SA-write branch unchanged safe,
111
- // because any namespace with such histories has the legacy SAs registered.
112
- // Readers go through the dual-read helpers in utils/search-attributes.ts
113
- // (memo preferred, SA fallback). Filter SAs (Ensemble, PlayerId, Hostname,
114
- // AttachedHost, AttachmentState) are untouched — they appear in visibility
115
- // query expressions and MUST stay search attributes.
116
- const saDiet = (0, workflow_1.patched)('v1.8-sa-diet');
117
- // T0.1 (#748) — the observation-path memo extension: workDir / agentType /
118
- // gitBranch join the memo so the cloud-profile maestro scan can read the
119
- // full player row from visibility list results (zero per-player queries).
120
- // Payload-only upsertMemo change, but gated behind its own marker per the
121
- // repo's conservative determinism policy. All three pass the low-churn bar
122
- // (workDir/agentType set-at-start; gitBranch via rare updateMetadata).
123
- const memoObservationFields = (0, workflow_1.patched)('v1.8-memo-observation-fields');
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;
98
+ // ── 2.0 clean-slate (#787) ──
99
+ // The replay-only `patched()` markers that protected in-flight 1.x sessions
100
+ // across rolling deploys are gone: 2.0 is a hard cutover (the #786 boot guard
101
+ // refuses to replay any 1.x history), so there are no pre-patch histories left
102
+ // to protect. The SA-diet (#747) and observation-field memo (#748) behavior is
103
+ // now unconditional the new path IS the only path.
124
104
  /**
125
105
  * T0.5 — the memo mirror of the migrated read-only metadata fields.
126
106
  * Shared by the run-start upsert and the updateMetadata handler so the
127
107
  * two write sites can't drift. Key names come from the shared
128
108
  * {@link MEMO_KEYS} registry (also used by the client-side
129
- * `workflow.start({ memo })` seeds and the dual-read helpers).
109
+ * `workflow.start({ memo })` seeds and the memo readers).
130
110
  */
131
111
  const metaMemo = () => ({
132
112
  ...(input.metadata.gitRoot ? { [search_attributes_1.MEMO_KEYS.gitRoot]: input.metadata.gitRoot } : {}),
133
113
  ...(input.metadata.playerType ? { [search_attributes_1.MEMO_KEYS.playerType]: input.metadata.playerType } : {}),
134
114
  [search_attributes_1.MEMO_KEYS.isConductor]: input.metadata.isConductor === true,
135
- ...(memoObservationFields ? {
136
- [search_attributes_1.MEMO_KEYS.workDir]: input.metadata.workDir,
137
- [search_attributes_1.MEMO_KEYS.agentType]: input.metadata.agentType || 'claude',
138
- ...(input.metadata.gitBranch ? { [search_attributes_1.MEMO_KEYS.gitBranch]: input.metadata.gitBranch } : {}),
139
- } : {}),
115
+ [search_attributes_1.MEMO_KEYS.workDir]: input.metadata.workDir,
116
+ [search_attributes_1.MEMO_KEYS.agentType]: input.metadata.agentType || 'claude',
117
+ ...(input.metadata.gitBranch ? { [search_attributes_1.MEMO_KEYS.gitBranch]: input.metadata.gitBranch } : {}),
140
118
  });
141
119
  // Ensure search attributes are always current — critical when reconnecting
142
120
  // via WorkflowIdConflictPolicy.USE_EXISTING, which skips the attributes
@@ -145,17 +123,20 @@ async function agentSessionWorkflow(input) {
145
123
  AgentTempoEnsemble: [input.metadata.ensemble],
146
124
  AgentTempoPlayerId: [input.metadata.playerId],
147
125
  AgentTempoHostname: [input.metadata.hostname],
148
- ...(saDiet ? {} : {
149
- ...(input.metadata.gitRoot ? { AgentTempoGitRoot: [input.metadata.gitRoot] } : {}),
150
- ...(input.metadata.playerType ? { AgentTempoPlayerType: [input.metadata.playerType] } : {}),
151
- AgentTempoIsConductor: [input.metadata.isConductor === true],
152
- }),
153
126
  // v0.25 attachment search attributes — initial values for a fresh/restored workflow.
154
127
  // Updated on every phase transition below.
155
128
  AgentTempoAttachedHost: [input.currentAttachment?.hostname ?? ''],
156
129
  AgentTempoAttachmentState: [input.phase ?? 'booting'],
157
- ...(saDiet ? {} : { AgentTempoAttachmentId: [input.currentAttachment?.attachmentId ?? ''] }),
158
130
  });
131
+ // #786 — 2.0 cutover protocol STAMP. Authoritative from the constant (this IS
132
+ // the 2.0 bundle → always PROTOCOL_VERSION, regardless of `input.protocol`).
133
+ // Re-upserted on EVERY run, incl. continueAsNew successors, so visibility
134
+ // always shows the stamp for the daemon boot guard — no need to thread a memo
135
+ // arg through the CAN payload. A 1.x run never executes this, so the guard
136
+ // sees its memo as undefined → un-stamped → refuse. Unconditional (no
137
+ // `patched()` marker): #786 is the first change on the 2.0 line, so there are
138
+ // no pre-stamp v2 histories to protect from this new command.
139
+ (0, workflow_1.upsertMemo)({ [search_attributes_1.MEMO_KEYS.protocol]: constants_1.PROTOCOL_VERSION });
159
140
  // ── State (carried across continue-as-new) ──
160
141
  let part = input.part ?? input.autoSummary ?? 'No description set';
161
142
  // T0.5 (#747) — memo carrier for the migrated read-only fields + part.
@@ -164,9 +145,7 @@ async function agentSessionWorkflow(input) {
164
145
  // `memo` option so the fields are visible before the first workflow task
165
146
  // (and on standard-visibility servers whose list results may lag memo
166
147
  // upserts — see PR #747 T0.5 notes). Low-churn fields ONLY.
167
- if (saDiet) {
168
- (0, workflow_1.upsertMemo)({ ...metaMemo(), [search_attributes_1.MEMO_KEYS.part]: part });
169
- }
148
+ (0, workflow_1.upsertMemo)({ ...metaMemo(), [search_attributes_1.MEMO_KEYS.part]: part });
170
149
  const messages = input.messages ?? [];
171
150
  const sentMessages = input.sentMessages ?? [];
172
151
  const outbox = input.outbox ?? [];
@@ -234,6 +213,44 @@ async function agentSessionWorkflow(input) {
234
213
  : undefined;
235
214
  /** ISO timestamp of when the workflow most recently entered `detached`. */
236
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
+ }
237
254
  // ── Processing Lifecycle State (fixes #99) ──
238
255
  // Tracks messages currently being processed by a blocking adapter. While non-empty,
239
256
  // stale detection is suppressed AND the phase refines to `processing`.
@@ -316,6 +333,11 @@ async function agentSessionWorkflow(input) {
316
333
  function nextDeadlineMs() {
317
334
  const nowMs = workflowNow().getTime();
318
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
+ }
319
341
  if (currentAttachment) {
320
342
  candidates.push(new Date(currentAttachment.expiresAt).getTime());
321
343
  }
@@ -408,7 +430,7 @@ async function agentSessionWorkflow(input) {
408
430
  // #399 W2 — every inbound cue counts as received traffic.
409
431
  receivedCount++;
410
432
  // Track inbound messages that expect a response (default: true for backward compat)
411
- if ((0, workflow_1.patched)('v0.20-response-requested-blocked') && msg.responseRequested !== false) {
433
+ if (msg.responseRequested !== false) {
412
434
  lastInboundRRTime = workflowNow().getTime();
413
435
  }
414
436
  });
@@ -418,8 +440,7 @@ async function agentSessionWorkflow(input) {
418
440
  // daemon aggregate — T0.1) can read it from visibility list results
419
441
  // instead of a per-player getPart query. Low-churn by nature: part
420
442
  // changes when a player re-describes its work, not per message.
421
- if (saDiet)
422
- (0, workflow_1.upsertMemo)({ [search_attributes_1.MEMO_KEYS.part]: newPart });
443
+ (0, workflow_1.upsertMemo)({ [search_attributes_1.MEMO_KEYS.part]: newPart });
423
444
  lastActivityTime = workflowNow().getTime();
424
445
  activityCount++;
425
446
  lastOutboundTime = workflowNow().getTime();
@@ -461,7 +482,6 @@ async function agentSessionWorkflow(input) {
461
482
  lastActivityTime = workflowNow().getTime();
462
483
  activityCount++;
463
484
  });
464
- (0, workflow_1.setHandler)(signals_1.pendingResetQuery, () => pendingReset);
465
485
  (0, workflow_1.setHandler)(signals_1.ackResetSignal, (resetId) => {
466
486
  // Race-safe: only clear if the ack matches the current pending reset, so a
467
487
  // newer reset landing during the extension's wipe isn't silently dropped.
@@ -494,16 +514,10 @@ async function agentSessionWorkflow(input) {
494
514
  AgentTempoEnsemble: [input.metadata.ensemble],
495
515
  AgentTempoPlayerId: [input.metadata.playerId],
496
516
  AgentTempoHostname: [input.metadata.hostname],
497
- ...(saDiet ? {} : {
498
- ...(input.metadata.gitRoot ? { AgentTempoGitRoot: [input.metadata.gitRoot] } : {}),
499
- ...(input.metadata.playerType ? { AgentTempoPlayerType: [input.metadata.playerType] } : {}),
500
- AgentTempoIsConductor: [input.metadata.isConductor === true],
501
- }),
502
517
  });
503
518
  // T0.5 — keep the memo mirror current (low-churn: metadata updates are
504
519
  // rare lifecycle events, not per-message traffic).
505
- if (saDiet)
506
- (0, workflow_1.upsertMemo)(metaMemo());
520
+ (0, workflow_1.upsertMemo)(metaMemo());
507
521
  lastActivityTime = workflowNow().getTime();
508
522
  activityCount++;
509
523
  });
@@ -588,6 +602,10 @@ async function agentSessionWorkflow(input) {
588
602
  // Phase refinement: if we're attached (or awaiting), move to `processing`.
589
603
  if (phase === 'attached' || phase === 'awaiting')
590
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++;
591
609
  }
592
610
  lastActivityTime = workflowNow().getTime();
593
611
  activityCount++;
@@ -618,6 +636,10 @@ async function agentSessionWorkflow(input) {
618
636
  const outboxIdle = !outbox.some((e) => e.status === 'pending' || e.status === 'processing');
619
637
  setPhase(outboxIdle ? 'awaiting' : 'attached');
620
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++;
621
643
  }
622
644
  lastActivityTime = workflowNow().getTime();
623
645
  activityCount++;
@@ -711,8 +733,12 @@ async function agentSessionWorkflow(input) {
711
733
  }
712
734
  (0, workflow_1.upsertSearchAttributes)({
713
735
  AgentTempoAttachedHost: [''],
714
- ...(saDiet ? {} : { AgentTempoAttachmentId: [''] }),
715
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' });
716
742
  setPhase('gone');
717
743
  // Inject a final audit message so the old adapter-completion path has something to show.
718
744
  messages.push({
@@ -724,6 +750,10 @@ async function agentSessionWorkflow(input) {
724
750
  });
725
751
  lastActivityTime = workflowNow().getTime();
726
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++;
727
757
  });
728
758
  (0, workflow_1.setHandler)(signals_1.isDestroyedQuery, () => destroyed || destroyRequested);
729
759
  // ── Test-only CAN trigger (#226) ──
@@ -767,6 +797,10 @@ async function agentSessionWorkflow(input) {
767
797
  currentAttachment.leaseMs = leaseMs;
768
798
  lastActivityTime = nowMs;
769
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++;
770
804
  return attachmentTokenFrom(currentAttachment, leaseMs);
771
805
  }
772
806
  // Conflict: active lease held by someone else.
@@ -796,16 +830,33 @@ async function agentSessionWorkflow(input) {
796
830
  drainingSince = null;
797
831
  drainingDeadlineMs = null;
798
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;
799
836
  setPhase('attached');
800
837
  (0, workflow_1.upsertSearchAttributes)({
801
838
  AgentTempoAttachedHost: [host],
802
- ...(saDiet ? {} : { AgentTempoAttachmentId: [newAttachment.attachmentId] }),
803
839
  });
804
840
  lastActivityTime = nowMs;
805
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++;
806
846
  return attachmentTokenFrom(newAttachment, leaseMs);
807
847
  }, {
808
- validator: ({ leaseMs }) => {
848
+ validator: ({ host, leaseMs, protocolVersion }) => {
849
+ // #786 — cross-host cutover safety, checked FIRST (pure, synchronous, no
850
+ // IO, no history event — rejects pre-admission). A v1 adapter omits
851
+ // `protocolVersion` (→ undefined); any value other than PROTOCOL_VERSION is
852
+ // a stale/foreign adapter that must not claim this 2.0 workflow. Actionable
853
+ // error names the host + the fix.
854
+ if (protocolVersion !== constants_1.PROTOCOL_VERSION) {
855
+ throw common_1.ApplicationFailure.nonRetryable(`claimAttachment rejected on ${(0, workflow_1.workflowInfo)().workflowId}: adapter on host ` +
856
+ `'${host}' speaks protocol ${protocolVersion ?? '(v1/unset)'}, but this is a ` +
857
+ `protocol-${constants_1.PROTOCOL_VERSION} (2.0) workflow. Upgrade the agent-tempo install on ` +
858
+ `that host to 2.0 (it cannot drive a 2.0 session).`, 'ProtocolMismatch');
859
+ }
809
860
  if (!Number.isInteger(leaseMs) || leaseMs < 1_000 || leaseMs > 600_000) {
810
861
  throw common_1.ApplicationFailure.nonRetryable(`leaseMs must be between 1000 and 600000, got ${leaseMs}`, 'InvalidLease');
811
862
  }
@@ -881,7 +932,7 @@ async function agentSessionWorkflow(input) {
881
932
  // mismatch any history that recorded the race — conservative tiebreak.
882
933
  // Evaluated lazily (the v0.26-can-lease-from-attachment single-use
883
934
  // precedent): only histories that hit forceDetach record the marker.
884
- if ((0, workflow_1.patched)('v0.27-force-detach-recheck') && currentAttachment?.attachmentId !== reaped.attachmentId) {
935
+ if (currentAttachment?.attachmentId !== reaped.attachmentId) {
885
936
  workflow_1.log.info(`forceDetach: attachment changed during hard-terminate await ` +
886
937
  `(expected ${reaped.attachmentId}, now ${currentAttachment?.attachmentId ?? 'none'}) — ` +
887
938
  `not clobbering the fresh claim`);
@@ -898,7 +949,6 @@ async function agentSessionWorkflow(input) {
898
949
  setPhase('detached');
899
950
  (0, workflow_1.upsertSearchAttributes)({
900
951
  AgentTempoAttachedHost: [''],
901
- ...(saDiet ? {} : { AgentTempoAttachmentId: [''] }),
902
952
  });
903
953
  // #159 Gap 1b: wake the main loop — `phase === 'detached'` isn't in the predicate
904
954
  // and the condition would otherwise sleep on the now-stale lease-expiry deadline.
@@ -1025,7 +1075,6 @@ async function agentSessionWorkflow(input) {
1025
1075
  setPhase('detached');
1026
1076
  (0, workflow_1.upsertSearchAttributes)({
1027
1077
  AgentTempoAttachedHost: [''],
1028
- ...(saDiet ? {} : { AgentTempoAttachmentId: [''] }),
1029
1078
  });
1030
1079
  lastActivityTime = workflowNow().getTime();
1031
1080
  activityCount++;
@@ -1356,12 +1405,10 @@ async function agentSessionWorkflow(input) {
1356
1405
  // live handler. Reconciled entries keep the ORIGINAL receipt time as
1357
1406
  // `reportedAt` and carry `reconciled: true`.
1358
1407
  //
1359
- // patched() gate: stage state itself drives no commands, but the
1360
- // completion/failure messages pushed here join `messages`, which
1361
- // participates in main-loop condition predicates whose resolution
1362
- // interleaves with recorded timer commands — a command-sequence
1363
- // dependency on replay. Conservative tiebreak per the ruling: marker.
1364
- if ((0, workflow_1.patched)('v0.27-stage-reconcile-reports')) {
1408
+ // Stage reconciliation fold each player's latest result/blocker report
1409
+ // into stage status, then apply the stage's completion/failure transitions.
1410
+ // (2.0: was patched()-gated for 1.x replay determinism; now unconditional — #787.)
1411
+ {
1365
1412
  const nowMs = workflowNow().getTime();
1366
1413
  for (const playerEntry of entry.players) {
1367
1414
  // Most recent stage-relevant (result|blocker) report from this
@@ -1481,52 +1528,36 @@ async function agentSessionWorkflow(input) {
1481
1528
  // timer would leave the workflow in `draining` until that old timer fired.
1482
1529
  const epochAtWait = wakeEpoch;
1483
1530
  const deadlineMs = nextDeadlineMs();
1484
- // NOTE: This 5-min fallback wake is LOAD-BEARING despite an old "PR-C shim"
1485
- // framing that surfaced in researcher's tier-2 cleanup audit (2026-04-26).
1486
- // While #175 removed the legacy stale/blocked detection block this originally
1487
- // fed (see ~§1527 below), the wake itself remains essential as the loop's
1488
- // periodic re-evaluation tick for state changes from handlers that mutate
1489
- // `nextDeadlineMs()` inputs WITHOUT bumping `wakeEpoch`:
1490
- //
1491
- // - `claimAttachmentUpdate` (renewal + fresh paths) sets
1492
- // `currentAttachment.expiresAt`new lease-expiry deadline
1493
- // - `processingStartUpdate` sets `processingSince` new
1494
- // `PROCESSING_DEADLINE_MS` deadline
1495
- // - `processingEndUpdate` clears `processingSince` cancels processing
1496
- // deadline
1497
- // - `destroyUpdate` (async hard-terminate-then-flip path)
1498
- //
1499
- // Without the fallback wake, a workflow waiting in `condition(predicate)` on
1500
- // an `Infinity` deadline (booting / detached, no draining, no processing)
1501
- // never re-evaluates `nextDeadlineMs()` after one of these handlers fires —
1502
- // the freshly-set lease-expiry timer is never picked up, lease expiry is
1503
- // never reaped, and the workflow stalls until external state forces the
1504
- // predicate true. Smoking-gun test that fails without the fallback:
1505
- // `test/session-phase-processing.test.ts:54` "attached -> processing ->
1506
- // awaiting via processingStart/End (#117 fix)" — times out at 10s because
1507
- // the loop never makes progress after `processingStart` lands on a fresh
1508
- // claim.
1509
- //
1510
- // Removing this fallback safely is a "main-loop wake-discipline cleanup"
1511
- // separate from the audit's framing — adds `wakeEpoch++` to each affected
1512
- // handler, gates with `patched()` markers for replay-determinism (live
1513
- // workflow histories already recorded the existing `Timer 5min` events),
1514
- // and adds a regression test covering the handler-induced-deadline pickup.
1515
- // Estimated 4–6 handler edits + tests, separate dedicated PR. See the
1516
- // 2026-04-26 forensics for the full mechanism walkthrough — link from this
1517
- // file's PR history.
1518
- //
1519
- // Until that cleanup happens, DO NOT remove this fallback. The
1520
- // `Math.min(deadlineMs, 5 * 60 * 1000)` cap is part of the same mechanism:
1521
- // it ensures every deadline (even hour-long lease-expiry timers) is
1522
- // re-evaluated at least every 5 min so handler-induced deadline shortenings
1523
- // can't be missed.
1524
- 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 ||
1525
1545
  canDispatch() ||
1526
1546
  hasPendingStop() ||
1527
1547
  phase === 'gone' ||
1528
- wakeEpoch !== epochAtWait, deadlineMs === Number.POSITIVE_INFINITY ? '5 minutes' : Math.min(deadlineMs, 5 * 60 * 1000));
1529
- 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
+ }
1530
1561
  if (destroyRequested)
1531
1562
  break;
1532
1563
  // ── §9.5.a: Lease expiry — reap attachment and transition to `detached`. ──
@@ -1543,10 +1574,73 @@ async function agentSessionWorkflow(input) {
1543
1574
  setPhase('detached');
1544
1575
  (0, workflow_1.upsertSearchAttributes)({
1545
1576
  AgentTempoAttachedHost: [''],
1546
- ...(saDiet ? {} : { AgentTempoAttachmentId: [''] }),
1547
1577
  });
1548
1578
  workflow_1.log.warn(`lease expired for attachment ${reaped.attachmentId} (host=${reaped.hostname})`);
1549
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
+ }
1550
1644
  // ── §9.5.b: processingDeadline — force exit from `processing` if a messageId is wedged. ──
1551
1645
  if (processingSince !== null &&
1552
1646
  workflowNow().getTime() - new Date(processingSince).getTime() > PROCESSING_DEADLINE_MS) {
@@ -1607,7 +1701,6 @@ async function agentSessionWorkflow(input) {
1607
1701
  setPhase('detached');
1608
1702
  (0, workflow_1.upsertSearchAttributes)({
1609
1703
  AgentTempoAttachedHost: [''],
1610
- ...(saDiet ? {} : { AgentTempoAttachmentId: [''] }),
1611
1704
  });
1612
1705
  if (reaped) {
1613
1706
  workflow_1.log.info(`drainingDeadline exceeded (${Math.round(window / 1000)}s); ` +
@@ -1821,6 +1914,12 @@ async function agentSessionWorkflow(input) {
1821
1914
  adapterId: currentAttachment.adapterId,
1822
1915
  };
1823
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.)
1824
1923
  currentAttachment = null;
1825
1924
  inFlightMessages.clear();
1826
1925
  processingSince = null;
@@ -1830,7 +1929,6 @@ async function agentSessionWorkflow(input) {
1830
1929
  setPhase('detached');
1831
1930
  (0, workflow_1.upsertSearchAttributes)({
1832
1931
  AgentTempoAttachedHost: [''],
1833
- ...(saDiet ? {} : { AgentTempoAttachmentId: [''] }),
1834
1932
  });
1835
1933
  workflow_1.log.warn(`spawn failed for "${entry.targetName}"; rolled back attachment ${entry.attachmentId} → detached`);
1836
1934
  }
@@ -1873,25 +1971,11 @@ async function agentSessionWorkflow(input) {
1873
1971
  // matches what the adapter signed up for and covers at least one full heartbeat
1874
1972
  // interval for every adapter class.
1875
1973
  //
1876
- // The `patched()` gate keeps replay of pre-#249 workflow runs deterministic:
1877
- // histories that CAN'd on the old bundle recorded `extendAttachmentForCAN(…, 30_000, …)`,
1878
- // so replaying those runs must pick the legacy constant. New runs (and in-flight
1879
- // runs that CAN *after* the deploy) take the patched branch.
1880
- //
1881
1974
  // Math lives in `./attachment-math.ts` for direct unit testability (#127).
1882
- //
1883
- // #255 cleanup: the `patched()` call stays at the eager/unconditional
1884
- // position it was introduced in — relocating it inside the
1885
- // `currentAttachment ?` branch would skip marker recording on histories
1886
- // that hit the CAN site with a null attachment, risking replay
1887
- // non-determinism against those recordings. The dead-code cleanup is
1888
- // strictly the removal of the `?? HEARTBEAT_INTERVAL_MS` fallback that
1889
- // used to sit inside the ternary: on the patched branch it never fires
1890
- // (Attachment.leaseMs is required), and on the pre-patched branch the
1891
- // fallback is replaced by the bare constant — same value either way.
1892
- const usePatchedLease = (0, workflow_1.patched)('v0.26-can-lease-from-attachment');
1975
+ // (2.0: the v0.26-can-lease-from-attachment patched() gate is gone — #787 —
1976
+ // so CAN always extends by `currentAttachment.leaseMs` = 3 × heartbeatMs.)
1893
1977
  const extendedAttachment = currentAttachment
1894
- ? (0, attachment_math_1.extendAttachmentForCAN)(currentAttachment, usePatchedLease ? currentAttachment.leaseMs : HEARTBEAT_INTERVAL_MS, workflowNow().getTime())
1978
+ ? (0, attachment_math_1.extendAttachmentForCAN)(currentAttachment, currentAttachment.leaseMs, workflowNow().getTime())
1895
1979
  : undefined;
1896
1980
  await (0, workflow_1.continueAsNew)({
1897
1981
  ...input,