claude-tempo 0.26.0-beta.2 → 0.26.0-beta.3

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/CLAUDE.md CHANGED
@@ -20,12 +20,15 @@ src/
20
20
  ├── cli.ts # CLI entry point (claude-tempo command)
21
21
  ├── daemon.ts # Daemon entry point — runs Temporal workers as a detached background process
22
22
  ├── cli/
23
- │ ├── commands.ts # CLI command implementations (up, start, conduct, status, stop, upgrade, …)
24
- │ ├── config-command.ts # config subcommand (interactive + set/show)
25
- │ ├── daemon.ts # Daemon management utilities (start, stop, status, logs, isDaemonRunning)
23
+ │ ├── commands.ts # CLI command implementations (up, start, conduct, status, stop, …)
24
+ │ ├── config-command.ts # config subcommand (interactive + set/show) — crash-proof for show/set
25
+ │ ├── daemon.ts # Daemon management utilities (start, stop, status, heartbeat, isDaemonRunning)
26
+ │ ├── daemon-command.ts # daemon subcommand handler — crash-proof, no Temporal deps
27
+ │ ├── help-text.ts # help output — crash-proof, no Temporal deps
26
28
  │ ├── mcp.ts # MCP server registration helpers (init, global vs project)
27
29
  │ ├── output.ts # Shared CLI output formatting helpers
28
- └── preflight.ts # Environment preflight checks
30
+ ├── preflight.ts # Environment preflight checks
31
+ │ └── upgrade-command.ts # upgrade subcommand — crash-proof; dynamic-imports Temporal only for active-session warning
29
32
  ├── adapters/
30
33
  │ ├── README.md # Adapter contract documentation
31
34
  │ ├── index.ts # Adapter registry bootstrap + barrel exports
@@ -137,13 +137,49 @@ export declare abstract class BaseAttachment {
137
137
  private schedulePhaseWatcher;
138
138
  private tickPhaseWatcher;
139
139
  /**
140
- * Classify an error as terminal (WorkflowNotFound / ExecutionAlreadyCompleted / phase gone).
140
+ * Classify a Temporal error as terminal-class vs transient.
141
141
  *
142
- * Uses name-sniffing rather than `instanceof` to avoid tight coupling to
143
- * `@temporalio/client` internals errors surface through both the Client SDK
144
- * and the server's gRPC layer with slightly different shapes.
142
+ * **Gotcha the caller must know:** the Temporal Node SDK conflates the two
143
+ * terminal sub-kinds on pinned-runId signal/query failures. Both
144
+ * (a) the closed run's specific runId no longer accepting traffic (CAN or
145
+ * true COMPLETE / TERMINATED), and
146
+ * (b) the workflow id having been fully GC'd
147
+ * surface as `WorkflowNotFoundError` with message "workflow execution already
148
+ * completed". We therefore can't tell them apart by error shape alone — the
149
+ * authoritative differentiator is `fetchHistory` ({@link findCanSuccessorRunId}).
150
+ * This method only says "yes, this is a terminal-class error; caller must
151
+ * decide sub-kind" vs "transient, keep retrying."
152
+ *
153
+ * Uses name/message-sniffing rather than `instanceof` to tolerate the slightly
154
+ * different shapes that errors take between `@temporalio/client` and the raw
155
+ * gRPC layer.
156
+ */
157
+ private isTerminalErr;
158
+ /**
159
+ * Shared error-classification path for the heartbeat + phase-watcher ticks (#226).
160
+ *
161
+ * Returns `true` if the error was a terminal-class (handled inline: CAN rebind
162
+ * kicked off, or destroy fired). Returns `false` when the caller should treat
163
+ * the error as transient and continue its backoff.
164
+ *
165
+ * Always consults `fetchHistory` on any terminal-class error, because the
166
+ * Temporal SDK can't distinguish CAN-close from true-complete at the error
167
+ * level — see {@link isTerminalErr}. The history lookup is cheap (only runs
168
+ * on terminal, so at most once per adapter lifetime per terminal) and safer
169
+ * than re-querying by workflow id (which could race a fresh session reusing
170
+ * the id).
145
171
  */
146
- private isWorkflowGone;
172
+ private handleRunEndError;
173
+ /**
174
+ * Fetch the closed pinned run's history and return the runId of a CAN successor
175
+ * if present, else `null`. Scoped to the pinned (old) run via `this.pinnedHandle`,
176
+ * so it can't be fooled by a fresh session that happens to reuse the workflow id.
177
+ *
178
+ * Called only on the terminal path from {@link handleRunEndError}, so the cost
179
+ * of `fetchHistory` (a full event stream for the closed run) is paid at most
180
+ * once per terminal — not on every tick.
181
+ */
182
+ private findCanSuccessorRunId;
147
183
  private fireTerminal;
148
184
  /**
149
185
  * Opt-in reconnect policy. Default: return `false` — the base class behaves
@@ -194,6 +230,24 @@ export declare abstract class BaseAttachment {
194
230
  * when the reason is potentially recoverable.
195
231
  */
196
232
  private fireTerminalOrReconnect;
233
+ /**
234
+ * #226 CAN rebind. Transparently repoints `pinnedHandle` at the successor run,
235
+ * keeps the existing `attachmentId` / `leaseMs` (the workflow extended the lease
236
+ * by one heartbeat interval during the CAN transition per §2.3, so the lease is
237
+ * still live on the new run), notifies the subclass to restart its delivery
238
+ * loop, and resumes heartbeat + phase-watcher.
239
+ *
240
+ * Why this is safe without re-claiming:
241
+ * - The new run carries forward `currentAttachment` verbatim from the old run.
242
+ * - The adapter's `attachmentId` still matches, so the next `heartbeat` /
243
+ * `markDelivered` / `adapterExited` signal on the new pinned handle will be
244
+ * accepted unchanged by the workflow's handlers.
245
+ * - If the lease actually did expire before we got here (e.g. adapter was
246
+ * offline through multiple CAN cycles), the next phase-watcher tick on the
247
+ * new pinned handle will see `phase=detached` + no current attachment and
248
+ * fall through to the existing #201 reclaim path — belt-and-suspenders.
249
+ */
250
+ private runCanRebind;
197
251
  /**
198
252
  * Budget-bounded reconnect loop.
199
253
  *
@@ -214,14 +214,8 @@ class BaseAttachment {
214
214
  this.heartbeatBackoff = 0;
215
215
  }
216
216
  catch (err) {
217
- if (this.isWorkflowGone(err)) {
218
- // C1 (PR-C dual-QA follow-up): WorkflowNotFound means the session workflow
219
- // has COMPLETEd — that's the `destroy` terminal, not `agent-exited` (which
220
- // means our local process died). Matches the phase-watcher `phase === 'gone'
221
- // → fireTerminal('destroy')` branch below.
222
- this.fireTerminal('destroy');
217
+ if (await this.handleRunEndError(err))
223
218
  return;
224
- }
225
219
  this.heartbeatBackoff = Math.min(this.heartbeatBackoff ? this.heartbeatBackoff * LOOP_BACKOFF_FACTOR : this.descriptor.heartbeatMs, LOOP_BACKOFF_MAX_MS);
226
220
  log(`heartbeat transient error (retry in ${Math.round(this.heartbeatBackoff)}ms):`, err?.message ?? err);
227
221
  }
@@ -292,13 +286,8 @@ class BaseAttachment {
292
286
  }
293
287
  }
294
288
  catch (err) {
295
- if (this.isWorkflowGone(err)) {
296
- // C1 (PR-C dual-QA follow-up): WorkflowNotFound on the phase-watcher query
297
- // has the same meaning as on the heartbeat signal — the workflow is gone,
298
- // so the terminal reason is `destroy`.
299
- this.fireTerminal('destroy');
289
+ if (await this.handleRunEndError(err))
300
290
  return;
301
- }
302
291
  this.phaseBackoff = Math.min(this.phaseBackoff ? this.phaseBackoff * LOOP_BACKOFF_FACTOR : this.descriptor.heartbeatMs, LOOP_BACKOFF_MAX_MS);
303
292
  log(`phase watcher transient error (retry in ${Math.round(this.phaseBackoff)}ms):`, err?.message ?? err);
304
293
  }
@@ -306,20 +295,90 @@ class BaseAttachment {
306
295
  this.schedulePhaseWatcher();
307
296
  }
308
297
  /**
309
- * Classify an error as terminal (WorkflowNotFound / ExecutionAlreadyCompleted / phase gone).
298
+ * Classify a Temporal error as terminal-class vs transient.
310
299
  *
311
- * Uses name-sniffing rather than `instanceof` to avoid tight coupling to
312
- * `@temporalio/client` internals errors surface through both the Client SDK
313
- * and the server's gRPC layer with slightly different shapes.
300
+ * **Gotcha the caller must know:** the Temporal Node SDK conflates the two
301
+ * terminal sub-kinds on pinned-runId signal/query failures. Both
302
+ * (a) the closed run's specific runId no longer accepting traffic (CAN or
303
+ * true COMPLETE / TERMINATED), and
304
+ * (b) the workflow id having been fully GC'd
305
+ * surface as `WorkflowNotFoundError` with message "workflow execution already
306
+ * completed". We therefore can't tell them apart by error shape alone — the
307
+ * authoritative differentiator is `fetchHistory` ({@link findCanSuccessorRunId}).
308
+ * This method only says "yes, this is a terminal-class error; caller must
309
+ * decide sub-kind" vs "transient, keep retrying."
310
+ *
311
+ * Uses name/message-sniffing rather than `instanceof` to tolerate the slightly
312
+ * different shapes that errors take between `@temporalio/client` and the raw
313
+ * gRPC layer.
314
314
  */
315
- isWorkflowGone(err) {
315
+ isTerminalErr(err) {
316
316
  const e = err;
317
317
  const name = e?.name ?? '';
318
318
  const msg = e?.message ?? '';
319
319
  return (name.includes('WorkflowNotFound') ||
320
320
  name.includes('WorkflowExecutionAlreadyCompleted') ||
321
321
  msg.includes('WorkflowGone') ||
322
- msg.includes('NOT_FOUND'));
322
+ msg.includes('NOT_FOUND') ||
323
+ msg.includes('workflow execution already completed'));
324
+ }
325
+ /**
326
+ * Shared error-classification path for the heartbeat + phase-watcher ticks (#226).
327
+ *
328
+ * Returns `true` if the error was a terminal-class (handled inline: CAN rebind
329
+ * kicked off, or destroy fired). Returns `false` when the caller should treat
330
+ * the error as transient and continue its backoff.
331
+ *
332
+ * Always consults `fetchHistory` on any terminal-class error, because the
333
+ * Temporal SDK can't distinguish CAN-close from true-complete at the error
334
+ * level — see {@link isTerminalErr}. The history lookup is cheap (only runs
335
+ * on terminal, so at most once per adapter lifetime per terminal) and safer
336
+ * than re-querying by workflow id (which could race a fresh session reusing
337
+ * the id).
338
+ */
339
+ async handleRunEndError(err) {
340
+ if (!this.isTerminalErr(err))
341
+ return false;
342
+ // Always try to find a CAN successor — the Temporal SDK's error shape is
343
+ // ambiguous between CAN and true-destroy, so history is the only reliable
344
+ // disambiguator (option 1 from the #226 design brief).
345
+ const successorRunId = await this.findCanSuccessorRunId();
346
+ if (successorRunId) {
347
+ this.fireTerminalOrReconnect('continued-as-new', successorRunId);
348
+ return true;
349
+ }
350
+ // No CAN event in the closed run's history → truly terminal (COMPLETED /
351
+ // TERMINATED / FAILED / workflow-id GC'd).
352
+ this.fireTerminal('destroy');
353
+ return true;
354
+ }
355
+ /**
356
+ * Fetch the closed pinned run's history and return the runId of a CAN successor
357
+ * if present, else `null`. Scoped to the pinned (old) run via `this.pinnedHandle`,
358
+ * so it can't be fooled by a fresh session that happens to reuse the workflow id.
359
+ *
360
+ * Called only on the terminal path from {@link handleRunEndError}, so the cost
361
+ * of `fetchHistory` (a full event stream for the closed run) is paid at most
362
+ * once per terminal — not on every tick.
363
+ */
364
+ async findCanSuccessorRunId() {
365
+ if (!this.pinnedHandle)
366
+ return null;
367
+ try {
368
+ const history = await this.pinnedHandle.fetchHistory();
369
+ const events = history?.events ?? [];
370
+ for (const ev of events) {
371
+ const attrs = ev.workflowExecutionContinuedAsNewEventAttributes;
372
+ const newRunId = attrs?.newExecutionRunId;
373
+ if (newRunId)
374
+ return newRunId;
375
+ }
376
+ return null;
377
+ }
378
+ catch (err) {
379
+ log('findCanSuccessorRunId: fetchHistory failed:', err?.message ?? err);
380
+ return null;
381
+ }
323
382
  }
324
383
  fireTerminal(reason) {
325
384
  if (this.terminalFired)
@@ -431,7 +490,7 @@ class BaseAttachment {
431
490
  * Called by the heartbeat / phase-watcher ticks instead of `fireTerminal`
432
491
  * when the reason is potentially recoverable.
433
492
  */
434
- fireTerminalOrReconnect(reason) {
493
+ fireTerminalOrReconnect(reason, canSuccessorRunId) {
435
494
  if (this.stopped || this.terminalFired || this.reconnecting)
436
495
  return;
437
496
  if (!this.shouldReconnect(reason)) {
@@ -449,12 +508,87 @@ class BaseAttachment {
449
508
  this.phaseWatcherTimer = null;
450
509
  }
451
510
  log(`reconnect requested (reason=${reason})`);
511
+ // #226: CAN takes the short-circuit rebind path (no backoff, no re-claim —
512
+ // the workflow's §2.3 CAN-boundary lease extension keeps the lease alive
513
+ // across the transition). Every other recoverable reason goes through the
514
+ // full #201 budget-bounded re-claim loop.
515
+ if (reason === 'continued-as-new' && canSuccessorRunId) {
516
+ void this.runCanRebind(canSuccessorRunId).catch((err) => {
517
+ log(`CAN rebind crashed:`, err?.message ?? err);
518
+ this.reconnecting = false;
519
+ this.fireTerminal('reconnect-exhausted');
520
+ });
521
+ return;
522
+ }
452
523
  void this.runReconnectLoop(reason).catch((err) => {
453
524
  log(`reconnect loop crashed:`, err?.message ?? err);
454
525
  this.reconnecting = false;
455
526
  this.fireTerminal('reconnect-exhausted');
456
527
  });
457
528
  }
529
+ /**
530
+ * #226 CAN rebind. Transparently repoints `pinnedHandle` at the successor run,
531
+ * keeps the existing `attachmentId` / `leaseMs` (the workflow extended the lease
532
+ * by one heartbeat interval during the CAN transition per §2.3, so the lease is
533
+ * still live on the new run), notifies the subclass to restart its delivery
534
+ * loop, and resumes heartbeat + phase-watcher.
535
+ *
536
+ * Why this is safe without re-claiming:
537
+ * - The new run carries forward `currentAttachment` verbatim from the old run.
538
+ * - The adapter's `attachmentId` still matches, so the next `heartbeat` /
539
+ * `markDelivered` / `adapterExited` signal on the new pinned handle will be
540
+ * accepted unchanged by the workflow's handlers.
541
+ * - If the lease actually did expire before we got here (e.g. adapter was
542
+ * offline through multiple CAN cycles), the next phase-watcher tick on the
543
+ * new pinned handle will see `phase=detached` + no current attachment and
544
+ * fall through to the existing #201 reclaim path — belt-and-suspenders.
545
+ */
546
+ async runCanRebind(newRunId) {
547
+ try {
548
+ if (!this.client || !this.pinnedHandle || !this.token) {
549
+ log('runCanRebind: missing client/handle/token — firing terminal');
550
+ this.fireTerminal('reconnect-exhausted');
551
+ return;
552
+ }
553
+ const workflowId = this.pinnedHandle.workflowId;
554
+ const oldRunId = this.token.runId;
555
+ try {
556
+ // Tear down any subclass-owned stream against the stale pinned handle
557
+ // before repointing, so the subclass doesn't race itself on the rebuild.
558
+ await this.onReconnectStart('continued-as-new');
559
+ }
560
+ catch (err) {
561
+ log('onReconnectStart threw:', err?.message ?? err);
562
+ }
563
+ const newHandle = this.client.workflow.getHandle(workflowId, newRunId);
564
+ this.pinnedHandle = newHandle;
565
+ // Keep attachmentId + leaseMs (lease carried across CAN); refresh runId so
566
+ // diagnostic logging and any token-based debug output reflect the live run.
567
+ this.token = { ...this.token, runId: newRunId };
568
+ this.knownPhase = null; // force next phase-watcher tick to re-emit phaseChange
569
+ this.heartbeatBackoff = 0;
570
+ this.phaseBackoff = 0;
571
+ log(`rebound ${workflowId} to CAN successor ` +
572
+ `(attachmentId=${this.token.attachmentId}, oldRunId=${oldRunId}, newRunId=${newRunId})`);
573
+ try {
574
+ await this.onReconnected(newHandle);
575
+ }
576
+ catch (err) {
577
+ log('onReconnected threw:', err?.message ?? err);
578
+ }
579
+ // Clear reconnecting BEFORE rescheduling so the first tick after rebind
580
+ // doesn't short-circuit on its own reconnecting-guard. Mirrors the pattern
581
+ // in `runReconnectLoop`'s success path (#206).
582
+ this.reconnecting = false;
583
+ if (!this.stopped) {
584
+ this.scheduleHeartbeat();
585
+ this.schedulePhaseWatcher();
586
+ }
587
+ }
588
+ finally {
589
+ this.reconnecting = false;
590
+ }
591
+ }
458
592
  /**
459
593
  * Budget-bounded reconnect loop.
460
594
  *
@@ -476,119 +610,141 @@ class BaseAttachment {
476
610
  * firing terminal) on abort — `stopV2Lifecycle` owns teardown messaging.
477
611
  */
478
612
  async runReconnectLoop(initialReason) {
479
- if (!this.client || !this.host || !this.token || !this.pinnedHandle) {
480
- log('runReconnectLoop: missing client/host/token/handle aborting');
481
- this.reconnecting = false;
482
- this.fireTerminal('reconnect-exhausted');
483
- return;
484
- }
485
- const workflowId = this.pinnedHandle.workflowId;
486
- const oldAttachmentId = this.token.attachmentId;
613
+ // Single try/finally so `reconnecting` always resets no matter how we exit
614
+ // success path, any fireTerminal, abort-during-sleep, or an unexpected
615
+ // throw. #206 fixed the prior abort-catch path that leaked `reconnecting=true`
616
+ // if `stopV2Lifecycle` aborted the backoff sleep.
487
617
  try {
488
- await this.onReconnectStart(initialReason);
489
- }
490
- catch (err) {
491
- log('onReconnectStart threw:', err?.message ?? err);
492
- }
493
- const deadline = Date.now() + this.reconnectBudgetMs;
494
- let backoff = this.reconnectBaseMs;
495
- let attempt = 0;
496
- while (!this.stopped && Date.now() < deadline) {
497
- attempt++;
498
- log(`reconnect attempt ${attempt} (sleep ${Math.round(backoff)}ms)`);
499
- try {
500
- await this.abortableSleep(backoff);
501
- }
502
- catch {
503
- // User-initiated stop during sleep — teardown already owns the rest.
504
- log('reconnect aborted by stop during backoff');
618
+ if (!this.client || !this.host || !this.token || !this.pinnedHandle) {
619
+ log('runReconnectLoop: missing client/host/token/handle — aborting');
620
+ this.fireTerminal('reconnect-exhausted');
505
621
  return;
506
622
  }
507
- if (this.stopped)
508
- return;
509
- // §Pre-check (architect §1): query attachmentInfo via a fresh unpinned handle.
510
- // The old pinned handle's runId may be stale after a continueAsNew.
511
- const unpinned = this.client.workflow.getHandle(workflowId);
512
- let info;
623
+ const workflowId = this.pinnedHandle.workflowId;
624
+ const oldAttachmentId = this.token.attachmentId;
513
625
  try {
514
- info = await unpinned.query(signals_1.attachmentInfoQuery);
626
+ await this.onReconnectStart(initialReason);
515
627
  }
516
628
  catch (err) {
517
- if (this.isWorkflowGone(err)) {
518
- log('reconnect: workflow gone during pre-check');
519
- this.reconnecting = false;
520
- this.fireTerminal('destroy');
629
+ log('onReconnectStart threw:', err?.message ?? err);
630
+ }
631
+ const deadline = Date.now() + this.reconnectBudgetMs;
632
+ let backoff = this.reconnectBaseMs;
633
+ let attempt = 0;
634
+ while (!this.stopped && Date.now() < deadline) {
635
+ attempt++;
636
+ log(`reconnect attempt ${attempt} (sleep ${Math.round(backoff)}ms)`);
637
+ try {
638
+ await this.abortableSleep(backoff);
639
+ }
640
+ catch {
641
+ // User-initiated stop during sleep — teardown already owns the rest.
642
+ // The finally block still resets `reconnecting` so a subsequent
643
+ // reclaim attempt (hypothetical — stop normally ends the adapter) would
644
+ // find clean state. #206.
645
+ log('reconnect aborted by stop during backoff');
521
646
  return;
522
647
  }
523
- backoff = Math.min(backoff * this.reconnectBackoffFactor, this.reconnectMaxMs);
524
- log(`reconnect pre-check transient error (next backoff ${Math.round(backoff)}ms):`, err?.message ?? err);
525
- continue;
526
- }
527
- if (info.phase === 'gone') {
528
- log('reconnect: phase=gone — giving up');
529
- this.reconnecting = false;
530
- this.fireTerminal('destroy');
531
- return;
532
- }
533
- if (info.currentAttachment && info.currentAttachment.attachmentId !== oldAttachmentId) {
534
- log(`reconnect: another adapter holds the lease (${info.currentAttachment.attachmentId}) — bailing`);
535
- this.reconnecting = false;
536
- this.fireTerminal('superseded');
537
- return;
538
- }
539
- if (info.phase === 'draining') {
540
- // About to reap — give the workflow one more tick to finish collapsing.
541
- backoff = Math.min(backoff * this.reconnectBackoffFactor, this.reconnectMaxMs);
542
- log(`reconnect: phase=draining, waiting (next backoff ${Math.round(backoff)}ms)`);
543
- continue;
544
- }
545
- // §Claim: attempt a fresh `claimAttachment` (no expectedAttachmentId — our
546
- // previous lease is revoked, this is a fresh claim from the workflow's POV).
547
- try {
548
- const newToken = await unpinned.executeUpdate(signals_1.claimAttachmentUpdate, {
549
- args: [{
550
- host: this.host,
551
- adapterId: this.descriptor.adapterId,
552
- adapterClass: this.descriptor.adapterClass,
553
- leaseMs: 3 * this.descriptor.heartbeatMs,
554
- }],
555
- });
556
- // Success — rebuild pinned handle from the NEW runId and hand it to the subclass.
557
- this.token = newToken;
558
- this.pinnedHandle = this.client.workflow.getHandle(workflowId, newToken.runId);
559
- this.knownPhase = null; // force the next phase-watcher tick to re-emit phaseChange
560
- this.heartbeatBackoff = 0;
561
- this.phaseBackoff = 0;
562
- log(`reconnected to ${workflowId} after ${attempt} attempt(s) ` +
563
- `(new attachmentId=${newToken.attachmentId}, runId=${newToken.runId})`);
648
+ if (this.stopped)
649
+ return;
650
+ // §Pre-check (architect §1): query attachmentInfo via a fresh unpinned handle.
651
+ // The old pinned handle's runId may be stale after a continueAsNew.
652
+ const unpinned = this.client.workflow.getHandle(workflowId);
653
+ let info;
564
654
  try {
565
- await this.onReconnected(this.pinnedHandle);
655
+ info = await unpinned.query(signals_1.attachmentInfoQuery);
566
656
  }
567
657
  catch (err) {
568
- log('onReconnected threw:', err?.message ?? err);
658
+ if (this.isTerminalErr(err)) {
659
+ // #226: either terminal kind inside the reconnect loop's pre-check
660
+ // ends the loop. We don't recurse into another CAN rebind here —
661
+ // that path is only for the pinned-handle tick ticks where we can
662
+ // read the closed run's history. Inside the loop the unpinned
663
+ // query has already followed any CAN chain, so a gone error means
664
+ // the workflow id is truly absent.
665
+ log('reconnect: workflow gone during pre-check');
666
+ this.fireTerminal('destroy');
667
+ return;
668
+ }
669
+ backoff = Math.min(backoff * this.reconnectBackoffFactor, this.reconnectMaxMs);
670
+ log(`reconnect pre-check transient error (next backoff ${Math.round(backoff)}ms):`, err?.message ?? err);
671
+ continue;
569
672
  }
570
- this.reconnecting = false;
571
- if (!this.stopped) {
572
- this.scheduleHeartbeat();
573
- this.schedulePhaseWatcher();
673
+ if (info.phase === 'gone') {
674
+ log('reconnect: phase=gone — giving up');
675
+ this.fireTerminal('destroy');
676
+ return;
574
677
  }
575
- return;
576
- }
577
- catch (err) {
578
- if (this.isWorkflowGone(err)) {
579
- log('reconnect: workflow gone during claim');
678
+ if (info.currentAttachment && info.currentAttachment.attachmentId !== oldAttachmentId) {
679
+ log(`reconnect: another adapter holds the lease (${info.currentAttachment.attachmentId}) — bailing`);
680
+ this.fireTerminal('superseded');
681
+ return;
682
+ }
683
+ if (info.phase === 'draining') {
684
+ // About to reap — give the workflow one more tick to finish collapsing.
685
+ backoff = Math.min(backoff * this.reconnectBackoffFactor, this.reconnectMaxMs);
686
+ log(`reconnect: phase=draining, waiting (next backoff ${Math.round(backoff)}ms)`);
687
+ continue;
688
+ }
689
+ // §Claim: attempt a fresh `claimAttachment` (no expectedAttachmentId — our
690
+ // previous lease is revoked, this is a fresh claim from the workflow's POV).
691
+ try {
692
+ const newToken = await unpinned.executeUpdate(signals_1.claimAttachmentUpdate, {
693
+ args: [{
694
+ host: this.host,
695
+ adapterId: this.descriptor.adapterId,
696
+ adapterClass: this.descriptor.adapterClass,
697
+ leaseMs: 3 * this.descriptor.heartbeatMs,
698
+ }],
699
+ });
700
+ // Success — rebuild pinned handle from the NEW runId and hand it to the subclass.
701
+ this.token = newToken;
702
+ this.pinnedHandle = this.client.workflow.getHandle(workflowId, newToken.runId);
703
+ this.knownPhase = null; // force the next phase-watcher tick to re-emit phaseChange
704
+ this.heartbeatBackoff = 0;
705
+ this.phaseBackoff = 0;
706
+ log(`reconnected to ${workflowId} after ${attempt} attempt(s) ` +
707
+ `(new attachmentId=${newToken.attachmentId}, runId=${newToken.runId})`);
708
+ try {
709
+ await this.onReconnected(this.pinnedHandle);
710
+ }
711
+ catch (err) {
712
+ log('onReconnected threw:', err?.message ?? err);
713
+ }
714
+ // Clear the reconnecting flag BEFORE rescheduling so the first
715
+ // heartbeat/watcher tick after reconnect doesn't short-circuit on
716
+ // its own `this.reconnecting` guard. The finally block reasserts
717
+ // `reconnecting=false` after return; this early assignment is the
718
+ // one that matters for loop wiring.
580
719
  this.reconnecting = false;
581
- this.fireTerminal('destroy');
720
+ if (!this.stopped) {
721
+ this.scheduleHeartbeat();
722
+ this.schedulePhaseWatcher();
723
+ }
582
724
  return;
583
725
  }
584
- backoff = Math.min(backoff * this.reconnectBackoffFactor, this.reconnectMaxMs);
585
- log(`reconnect claim failed (next backoff ${Math.round(backoff)}ms):`, err?.message ?? err);
726
+ catch (err) {
727
+ if (this.isTerminalErr(err)) {
728
+ log('reconnect: workflow gone during claim');
729
+ this.fireTerminal('destroy');
730
+ return;
731
+ }
732
+ backoff = Math.min(backoff * this.reconnectBackoffFactor, this.reconnectMaxMs);
733
+ log(`reconnect claim failed (next backoff ${Math.round(backoff)}ms):`, err?.message ?? err);
734
+ }
586
735
  }
736
+ // Budget exhausted — give up cleanly.
737
+ log(`reconnect budget exhausted after ${attempt} attempt(s)`);
738
+ this.fireTerminal('reconnect-exhausted');
739
+ }
740
+ finally {
741
+ // Guarantee state reset regardless of which path we exited on. Safe to
742
+ // assign unconditionally — a successful reconnect also ends up here after
743
+ // the early assignment inside the success path (the early one is needed
744
+ // so tick reschedulers see `reconnecting=false`; this one is belt-and-
745
+ // suspenders for the abort/throw/terminal paths).
746
+ this.reconnecting = false;
587
747
  }
588
- // Budget exhausted — give up cleanly.
589
- log(`reconnect budget exhausted after ${attempt} attempt(s)`);
590
- this.reconnecting = false;
591
- this.fireTerminal('reconnect-exhausted');
592
748
  }
593
749
  }
594
750
  exports.BaseAttachment = BaseAttachment;
@@ -50,6 +50,14 @@ export declare const claudeCodeDescriptor: AdapterDescriptor;
50
50
  * can resume. Truly terminal events (`destroy`, `reconnect-exhausted`)
51
51
  * still tear the adapter down permanently.
52
52
  *
53
+ * CAN rebind (#226): when the session workflow continues-as-new, the pinned
54
+ * runId starts returning `WorkflowExecutionAlreadyCompleted`. The base class
55
+ * reads the closed run's history, extracts the successor runId from the
56
+ * `WorkflowExecutionContinuedAsNewEvent`, rebinds `pinnedHandle` in place (no
57
+ * re-claim — the workflow's §2.3 CAN-boundary lease extension keeps the lease
58
+ * alive across the transition), and calls {@link onReconnected} so we restart
59
+ * the poller on the live run. Transparent to upstream.
60
+ *
53
61
  * PR-H (#132): the legacy unpinned-poll fallback (gated on
54
62
  * `CLAUDE_TEMPO_LIFECYCLE_V2=0`) has been removed. V2 is the only path.
55
63
  */
@@ -89,15 +97,21 @@ export declare class InteractiveAttachment extends BaseAttachment {
89
97
  */
90
98
  private startV2;
91
99
  /**
92
- * #201: reconnect opt-in. The interactive adapter is stateless wrt in-flight
93
- * messages (no processing-signal pairing; `markDelivered` is idempotent), so
94
- * both recoverable reasons are safe to replay on a fresh lease:
100
+ * #201 / #226: reconnect opt-in. The interactive adapter is stateless wrt
101
+ * in-flight messages (no processing-signal pairing; `markDelivered` is
102
+ * idempotent), so every recoverable reason is safe to replay on a fresh or
103
+ * re-bound lease:
95
104
  *
96
- * - `heartbeat-timeout`: the workflow reaped our lease (e.g. laptop slept).
97
- * Re-claim and resume — this is the #201 happy path.
98
- * - `superseded`: another adapter currently holds our slot. The reconnect
99
- * loop's pre-check will re-query and bail cleanly if that's still true;
100
- * we enter the loop in case the competitor releases during our backoff.
105
+ * - `heartbeat-timeout` (#201): the workflow reaped our lease (e.g. laptop
106
+ * slept). Re-claim and resume — full budget-bounded reconnect loop.
107
+ * - `superseded` (#201): another adapter currently holds our slot. The
108
+ * reconnect loop's pre-check will re-query and bail cleanly if that's
109
+ * still true; we enter the loop in case the competitor releases during
110
+ * our backoff.
111
+ * - `continued-as-new` (#226): the session workflow's CAN transition closed
112
+ * our pinned runId while the workflow id kept running on a successor. The
113
+ * base class transparently rebinds to the successor runId (no re-claim —
114
+ * the lease is carried across CAN per §2.3) and our poller resumes.
101
115
  *
102
116
  * Unrecoverable reasons (`destroy`, `gone`, anything else) fall through to
103
117
  * the default `false`, firing terminal directly.