agent-tempo 1.7.0-beta.8 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CLAUDE.md +29 -6
  2. package/README.md +2 -1
  3. package/dashboard/package.json +1 -1
  4. package/dist/activities/maestro.d.ts +46 -1
  5. package/dist/activities/maestro.js +69 -20
  6. package/dist/activities/outbox.d.ts +14 -1
  7. package/dist/activities/outbox.js +71 -10
  8. package/dist/activities/resolve.d.ts +118 -17
  9. package/dist/activities/resolve.js +256 -28
  10. package/dist/activities/schedule-fire.js +5 -2
  11. package/dist/adapters/base.d.ts +2 -0
  12. package/dist/adapters/base.js +9 -0
  13. package/dist/adapters/claude-api/adapter.js +26 -4
  14. package/dist/adapters/claude-code/adapter.js +15 -9
  15. package/dist/adapters/claude-code-headless/adapter.js +22 -4
  16. package/dist/adapters/copilot/adapter.js +58 -8
  17. package/dist/adapters/opencode/adapter.js +26 -6
  18. package/dist/adapters/sdk/base.d.ts +60 -0
  19. package/dist/adapters/sdk/base.js +83 -0
  20. package/dist/adapters/sdk/doorbell-client.d.ts +106 -0
  21. package/dist/adapters/sdk/doorbell-client.js +261 -0
  22. package/dist/adapters/sdk/idle-backoff.d.ts +71 -0
  23. package/dist/adapters/sdk/idle-backoff.js +136 -0
  24. package/dist/cli/command-center-command.js +25 -2
  25. package/dist/cli/commands.d.ts +28 -0
  26. package/dist/cli/commands.js +111 -20
  27. package/dist/cli/config-command.js +14 -0
  28. package/dist/cli/daemon-command.js +41 -5
  29. package/dist/cli/daemon.d.ts +245 -5
  30. package/dist/cli/daemon.js +505 -44
  31. package/dist/cli/help-text.js +3 -1
  32. package/dist/cli/mcp.d.ts +26 -2
  33. package/dist/cli/mcp.js +33 -4
  34. package/dist/cli/sa-preflight.d.ts +27 -1
  35. package/dist/cli/sa-preflight.js +29 -5
  36. package/dist/cli/startup.js +8 -2
  37. package/dist/cli/upgrade-to-2-command.d.ts +10 -0
  38. package/dist/cli/upgrade-to-2-command.js +159 -0
  39. package/dist/cli.js +37 -5
  40. package/dist/client/core.d.ts +6 -0
  41. package/dist/client/core.js +81 -34
  42. package/dist/client/subscribe.d.ts +10 -0
  43. package/dist/client/subscribe.js +2 -0
  44. package/dist/config.d.ts +53 -0
  45. package/dist/config.js +70 -1
  46. package/dist/daemon.d.ts +78 -7
  47. package/dist/daemon.js +313 -29
  48. package/dist/ensemble/saver.d.ts +10 -0
  49. package/dist/ensemble/saver.js +18 -6
  50. package/dist/http/aggregate.d.ts +88 -1
  51. package/dist/http/aggregate.js +157 -13
  52. package/dist/http/deliverability.d.ts +68 -0
  53. package/dist/http/deliverability.js +78 -0
  54. package/dist/http/doorbell-routes.d.ts +32 -0
  55. package/dist/http/doorbell-routes.js +72 -0
  56. package/dist/http/doorbell.d.ts +45 -0
  57. package/dist/http/doorbell.js +162 -0
  58. package/dist/http/inner-loop-routes.d.ts +9 -0
  59. package/dist/http/inner-loop-routes.js +4 -0
  60. package/dist/http/qa.js +8 -1
  61. package/dist/http/server.d.ts +9 -0
  62. package/dist/http/server.js +45 -1
  63. package/dist/http/snapshot.d.ts +28 -5
  64. package/dist/http/snapshot.js +16 -6
  65. package/dist/http/writes.js +24 -2
  66. package/dist/pi/cue-pump.d.ts +107 -7
  67. package/dist/pi/cue-pump.js +131 -24
  68. package/dist/pi/extension.js +11 -0
  69. package/dist/pi/headless.js +49 -13
  70. package/dist/pi/install.d.ts +15 -0
  71. package/dist/pi/install.js +35 -0
  72. package/dist/pi/mission-control/actions.d.ts +52 -3
  73. package/dist/pi/mission-control/actions.js +112 -3
  74. package/dist/pi/mission-control/board.d.ts +104 -0
  75. package/dist/pi/mission-control/board.js +103 -2
  76. package/dist/pi/mission-control/extension.d.ts +166 -1
  77. package/dist/pi/mission-control/extension.js +477 -15
  78. package/dist/pi/mission-control/render.d.ts +25 -0
  79. package/dist/pi/mission-control/render.js +142 -8
  80. package/dist/pi/workflow-client.d.ts +29 -0
  81. package/dist/pi/workflow-client.js +87 -3
  82. package/dist/reconcile/orphans.js +8 -9
  83. package/dist/server-tools.js +1 -1
  84. package/dist/server.js +11 -1
  85. package/dist/spawn.d.ts +78 -26
  86. package/dist/spawn.js +69 -37
  87. package/dist/tools/broadcast.js +15 -1
  88. package/dist/tools/cue.js +52 -11
  89. package/dist/tools/ensemble.js +43 -3
  90. package/dist/tools/recruit.js +11 -29
  91. package/dist/tools/who-am-i.d.ts +3 -2
  92. package/dist/tools/who-am-i.js +9 -2
  93. package/dist/types.d.ts +42 -0
  94. package/dist/upgrade/phase-engine.d.ts +160 -0
  95. package/dist/upgrade/phase-engine.js +555 -0
  96. package/dist/upgrade/snapshot-v1.d.ts +214 -0
  97. package/dist/upgrade/snapshot-v1.js +165 -0
  98. package/dist/utils/action-counters.d.ts +75 -0
  99. package/dist/utils/action-counters.js +279 -0
  100. package/dist/utils/format-hosts.js +7 -0
  101. package/dist/utils/hosts.js +5 -0
  102. package/dist/utils/sdk-probe.d.ts +12 -0
  103. package/dist/utils/sdk-probe.js +28 -0
  104. package/dist/utils/search-attributes.d.ts +76 -4
  105. package/dist/utils/search-attributes.js +89 -4
  106. package/dist/utils/suspension.d.ts +99 -0
  107. package/dist/utils/suspension.js +128 -0
  108. package/dist/utils/visibility-deadline.js +5 -0
  109. package/dist/worker.d.ts +16 -1
  110. package/dist/worker.js +25 -4
  111. package/dist/workflows/maestro.d.ts +10 -0
  112. package/dist/workflows/maestro.js +126 -21
  113. package/dist/workflows/scheduler.js +17 -2
  114. package/dist/workflows/session.js +251 -14
  115. package/dist/workflows/signals.d.ts +8 -0
  116. package/dist/workflows/signals.js +3 -2
  117. package/package.json +8 -4
  118. package/workflow-bundle.js +580 -40
@@ -2,7 +2,9 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.InteractiveAttachment = exports.claudeCodeDescriptor = void 0;
4
4
  const base_1 = require("../base");
5
+ const idle_backoff_1 = require("../sdk/idle-backoff");
5
6
  const terminal_error_1 = require("../terminal-error");
7
+ const action_counters_1 = require("../../utils/action-counters");
6
8
  const config_1 = require("../../config");
7
9
  const log = (...args) => console.error('[agent-tempo:poller]', ...args);
8
10
  /**
@@ -21,9 +23,11 @@ exports.claudeCodeDescriptor = {
21
23
  // heartbeat loop at this interval when the V2 lifecycle path is active.
22
24
  heartbeatMs: 60_000,
23
25
  };
24
- const POLL_BASE_MS = 2000;
25
- const POLL_BACKOFF_FACTOR = 1.5;
26
- const POLL_MAX_MS = 30000;
26
+ // #749: one family of backoff curves across the codebase — this poller's
27
+ // error backoff shares the SDK idle-backoff constants (values unchanged:
28
+ // 2s base, 1.5× growth, 30s cap; imported at the top of the file). Static
29
+ // defaults only; the AGENT_TEMPO_SDK_POLL_* env overrides apply to SDK
30
+ // adapters, not here.
27
31
  /**
28
32
  * Poll a session workflow for pending messages and deliver them via `onMessages`.
29
33
  *
@@ -48,7 +52,7 @@ const POLL_MAX_MS = 30000;
48
52
  function startMessagePoller(handle, onMessages) {
49
53
  let stopped = false;
50
54
  let timeout = null;
51
- let currentInterval = POLL_BASE_MS;
55
+ let currentInterval = idle_backoff_1.SDK_POLL_BASE_MS;
52
56
  let consecutiveErrors = 0;
53
57
  const cleanupTimer = () => {
54
58
  if (timeout) {
@@ -56,7 +60,9 @@ function startMessagePoller(handle, onMessages) {
56
60
  timeout = null;
57
61
  }
58
62
  };
59
- const poll = async () => {
63
+ // #753 — meter each poll tick's Temporal calls (pendingMessages query +
64
+ // markDelivered signal) under 'sdk-poller'.
65
+ const poll = () => (0, action_counters_1.withActionSource)('sdk-poller', async () => {
60
66
  if (stopped)
61
67
  return;
62
68
  try {
@@ -69,7 +75,7 @@ function startMessagePoller(handle, onMessages) {
69
75
  await handle.signal('markDelivered', ids);
70
76
  }
71
77
  // Reset backoff on successful poll
72
- currentInterval = POLL_BASE_MS;
78
+ currentInterval = idle_backoff_1.SDK_POLL_BASE_MS;
73
79
  consecutiveErrors = 0;
74
80
  }
75
81
  catch (err) {
@@ -88,15 +94,15 @@ function startMessagePoller(handle, onMessages) {
88
94
  }
89
95
  consecutiveErrors++;
90
96
  // Apply exponential backoff on errors
91
- currentInterval = Math.min(currentInterval * POLL_BACKOFF_FACTOR, POLL_MAX_MS);
97
+ currentInterval = Math.min(currentInterval * idle_backoff_1.SDK_POLL_BACKOFF_FACTOR, idle_backoff_1.SDK_POLL_MAX_MS);
92
98
  log(`Poll error (attempt ${consecutiveErrors}, next in ${Math.round(currentInterval)}ms):`, err);
93
99
  }
94
100
  if (!stopped) {
95
101
  timeout = setTimeout(poll, currentInterval);
96
102
  }
97
- };
103
+ });
98
104
  // Start the first poll
99
- timeout = setTimeout(poll, POLL_BASE_MS);
105
+ timeout = setTimeout(poll, idle_backoff_1.SDK_POLL_BASE_MS);
100
106
  return () => {
101
107
  stopped = true;
102
108
  cleanupTimer();
@@ -80,6 +80,7 @@ const client_1 = require("@temporalio/client");
80
80
  const base_1 = require("../sdk/base");
81
81
  const config_1 = require("../../config");
82
82
  const connection_1 = require("../../connection");
83
+ const action_counters_1 = require("../../utils/action-counters");
83
84
  const signals_1 = require("../../workflows/signals");
84
85
  const stream_json_1 = require("./stream-json");
85
86
  const error_mapper_1 = require("./error-mapper");
@@ -130,7 +131,8 @@ const log = (...args) => {
130
131
  }).join(' ')}\n`;
131
132
  fs.writeSync(2, msg);
132
133
  };
133
- /** Idle poll cadence short enough for snappy cue delivery, loose on Temporal. */
134
+ /** Busy-wait cadence (ms) while a turn is in flight. The IDLE cadence is owned
135
+ * by the inherited `pollBackoff` (#749): 2s base → 30s cap, reset on delivery. */
134
136
  const POLL_INTERVAL_MS = 2000;
135
137
  /** Workflow-register poll bounds — same as claude-api/opencode. */
136
138
  const WORKFLOW_REGISTER_ATTEMPTS = 30;
@@ -266,6 +268,7 @@ class ClaudeCodeHeadlessAttachment extends base_1.SdkAttachment {
266
268
  const client = new client_1.Client({
267
269
  connection,
268
270
  namespace: config.temporalNamespace,
271
+ interceptors: (0, action_counters_1.actionCountingInterceptors)(),
269
272
  });
270
273
  this.configureV2(client, os.hostname());
271
274
  // Wait for the session workflow to register in Temporal (the spawn
@@ -352,6 +355,10 @@ class ClaudeCodeHeadlessAttachment extends base_1.SdkAttachment {
352
355
  await cleanup();
353
356
  process.exit(1);
354
357
  }
358
+ // T1.1 PR-2 — cue doorbell: ding ⇒ immediate poll + backoff reset;
359
+ // connected ⇒ 60s idle ceiling. No token / daemon down ⇒ silent no-op
360
+ // (pure #761 polling).
361
+ this.startDoorbell(config.ensemble, playerIdForWorkflow);
355
362
  // PID file so callers can find / kill orphaned adapter processes.
356
363
  // #690 — write/unlink the EXACT path the spawner computed (ENV.PID_FILE) so the
357
364
  // adapter pid can't diverge from the spawner's; helper fallback for a manual launch.
@@ -377,7 +384,9 @@ class ClaudeCodeHeadlessAttachment extends base_1.SdkAttachment {
377
384
  process.on('SIGINT', shutdown);
378
385
  process.on('SIGTERM', shutdown);
379
386
  // Drive the poll loop until cleanup is requested.
380
- await this.pollLoop(handle);
387
+ // #753 — meter its Temporal calls under 'sdk-poller'.
388
+ await (0, action_counters_1.withActionSource)('sdk-poller', () => this.pollLoop(handle));
389
+ this.stopDoorbell();
381
390
  try {
382
391
  fs.unlinkSync(pidFile);
383
392
  }
@@ -397,6 +406,9 @@ class ClaudeCodeHeadlessAttachment extends base_1.SdkAttachment {
397
406
  let processing = false;
398
407
  let pollCount = 0;
399
408
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
409
+ // #749: fresh poll loop starts at the fast cadence — never inherit a
410
+ // stale 30s interval across a restart.
411
+ this.pollBackoff.reset();
400
412
  while (polling && !this.shouldStop()) {
401
413
  pollCount++;
402
414
  // Periodic workflow-status check — detect external destroy / completion.
@@ -434,13 +446,19 @@ class ClaudeCodeHeadlessAttachment extends base_1.SdkAttachment {
434
446
  }
435
447
  catch (err) {
436
448
  log(`pendingMessages query failed: ${err?.message ?? err}`);
437
- await sleep(POLL_INTERVAL_MS);
449
+ // #749: grow on errors too — don't hammer a wedged worker at 2s.
450
+ // T1.1: pollSleep is doorbell-interruptible — a ding ends it early.
451
+ await this.pollSleep(this.pollBackoff.next(false));
438
452
  continue;
439
453
  }
440
454
  if (messages.length === 0) {
441
- await sleep(POLL_INTERVAL_MS);
455
+ // #749: idle backoff — 2s → 30s while nothing is pending (60s
456
+ // ceiling while the T1.1 doorbell is connected; a ding wakes it).
457
+ await this.pollSleep(this.pollBackoff.next(false));
442
458
  continue;
443
459
  }
460
+ // #749: live conversation — snap back to the fast cadence.
461
+ this.pollBackoff.reset();
444
462
  processing = true;
445
463
  try {
446
464
  const ackIds = messages.map((m) => m.id);
@@ -74,6 +74,7 @@ const path = __importStar(require("path"));
74
74
  const client_1 = require("@temporalio/client");
75
75
  const config_1 = require("../../config");
76
76
  const connection_1 = require("../../connection");
77
+ const action_counters_1 = require("../../utils/action-counters");
77
78
  const base_1 = require("../sdk/base");
78
79
  // #536 — shared SDK-class system-prompt + MAESTRO_ACK (was inline
79
80
  // here pre-#536; moved to the shared module so the post-#536
@@ -128,11 +129,17 @@ const log = (...args) => {
128
129
  };
129
130
  /** Filter process.env to exclude undefined values (safe to spread as Record<string, string>). */
130
131
  const cleanEnv = () => Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined));
132
+ /**
133
+ * Busy-wait cadence (ms) while a turn is in flight. The IDLE cadence is owned
134
+ * by the inherited `pollBackoff` (#749, T0.2): 2s base stretching to a 30s
135
+ * cap on empty polls, snapping back to base on any delivered message.
136
+ */
131
137
  const POLL_INTERVAL_MS = 2000;
132
138
  const CREATE_SESSION_TIMEOUT_MS = 45_000;
133
139
  const MAX_CONSECUTIVE_FAILURES = 3;
134
140
  const MAX_SESSION_RECREATIONS = 2;
135
- /** Check workflow status every N polls (~30s at 2s interval). */
141
+ /** Check workflow status every N polls (30s–7.5min depending on the #749
142
+ * idle-backoff cadence; the busier the player, the fresher the check). */
136
143
  const WORKFLOW_STATUS_CHECK_INTERVAL = 15;
137
144
  /** Proactively recreate the Copilot session after this idle period (ms). Default 60 min. */
138
145
  const SESSION_MAX_IDLE_MS = 60 * 60 * 1000;
@@ -233,6 +240,7 @@ class CopilotSdkAttachment extends base_1.SdkAttachment {
233
240
  const client = new client_1.Client({
234
241
  connection,
235
242
  namespace: config.temporalNamespace,
243
+ interceptors: (0, action_counters_1.actionCountingInterceptors)(),
236
244
  });
237
245
  // Hand the client + host to BaseAttachment so startV2Lifecycle (below) can
238
246
  // issue claimAttachment + heartbeat against it. No-op on legacy path.
@@ -457,6 +465,10 @@ class CopilotSdkAttachment extends base_1.SdkAttachment {
457
465
  const expectedAttachmentId = process.env[config_1.ENV.ATTACHMENT_ID] || undefined;
458
466
  handle = await this.startV2Lifecycle(expectedWorkflowId, expectedAttachmentId);
459
467
  log(`V2 attachment claimed (attachmentId=${this.token?.attachmentId}${expectedAttachmentId ? ', renewed' : ''})`);
468
+ // T1.1 PR-2 — cue doorbell: ding ⇒ immediate poll tick + backoff
469
+ // reset; connected ⇒ 60s idle ceiling. No token / daemon down ⇒
470
+ // silent no-op (pure #761 polling).
471
+ this.startDoorbell(config.ensemble, playerIdForWorkflow);
460
472
  }
461
473
  catch (err) {
462
474
  log(`ERROR: V2 claimAttachment failed: ${err?.message ?? err}`);
@@ -506,8 +518,6 @@ class CopilotSdkAttachment extends base_1.SdkAttachment {
506
518
  let sessionRecreations = 0;
507
519
  let proactiveRecreations = 0;
508
520
  let lastActivityTime = Date.now();
509
- // interval declared here, assigned after poll is defined
510
- let interval;
511
521
  // Shared cleanup — disconnects session, removes PID file, stops client.
512
522
  let shuttingDown = false;
513
523
  const cleanup = async () => {
@@ -515,7 +525,10 @@ class CopilotSdkAttachment extends base_1.SdkAttachment {
515
525
  return;
516
526
  shuttingDown = true;
517
527
  polling = false;
518
- clearInterval(interval);
528
+ // T1.1 — end the doorbell stream and pop any parked inter-tick sleep
529
+ // so the scheduler chain observes `polling = false` promptly.
530
+ this.stopDoorbell();
531
+ this.wakePollSleep();
519
532
  // V2 graceful detach — fires `adapterExited` so the workflow collapses
520
533
  // draining → detached immediately per §11.1. No-op if V2 was off or if
521
534
  // startV2Lifecycle never ran successfully.
@@ -597,12 +610,19 @@ class CopilotSdkAttachment extends base_1.SdkAttachment {
597
610
  return false;
598
611
  }
599
612
  };
613
+ /**
614
+ * One poll tick. Returns the delay (ms) before the next tick; the
615
+ * scheduler (`runPollTick`) re-checks `polling` before arming the timer,
616
+ * so shutdown needs no sentinel return (exit paths call `process.exit`).
617
+ * #749: idle ticks grow the inherited `pollBackoff` toward 30s; delivered
618
+ * messages snap back to the 2s base.
619
+ */
600
620
  const poll = async () => {
601
621
  if (!polling || processing)
602
- return;
622
+ return POLL_INTERVAL_MS; // stopped or busy — scheduler gates on `polling`
603
623
  pollCount++;
604
624
  // Periodic health check
605
- if (pollCount % 30 === 0) { // every ~60 seconds
625
+ if (pollCount % 30 === 0) { // every 30 polls (~60s busy, up to ~15min idle)
606
626
  const silenceSec = ((Date.now() - lastEventTime) / 1000).toFixed(0);
607
627
  log(`[health] poll #${pollCount}, sessionAlive=${sessionAlive}, lastEvent=${lastEventType} ${silenceSec}s ago`);
608
628
  }
@@ -663,8 +683,9 @@ class CopilotSdkAttachment extends base_1.SdkAttachment {
663
683
  }
664
684
  try {
665
685
  const messages = await handle.query('pendingMessages');
686
+ // #749: idle backoff — nothing pending, stretch the next poll.
666
687
  if (messages.length === 0)
667
- return;
688
+ return this.pollBackoff.next(false);
668
689
  processing = true;
669
690
  const ids = messages.map((m) => m.id);
670
691
  // Format messages into a single prompt, appending ack instruction for Maestro messages
@@ -704,6 +725,9 @@ class CopilotSdkAttachment extends base_1.SdkAttachment {
704
725
  lastActivityTime = Date.now();
705
726
  sessionAlive = true;
706
727
  processing = false;
728
+ // #749: live conversation — snap back to the fast cadence so
729
+ // follow-up cues land at 2s responsiveness.
730
+ return this.pollBackoff.next(true);
707
731
  }
708
732
  catch (err) {
709
733
  processing = false;
@@ -718,10 +742,36 @@ class CopilotSdkAttachment extends base_1.SdkAttachment {
718
742
  await cleanup();
719
743
  process.exit(2);
720
744
  }
745
+ // Recovered — retry promptly so pending messages drain.
746
+ return this.pollBackoff.next(true);
721
747
  }
748
+ // #749: grow on errors too — don't hammer a failing session/worker.
749
+ return this.pollBackoff.next(false);
722
750
  }
723
751
  };
724
- interval = setInterval(poll, POLL_INTERVAL_MS);
752
+ // #749: self-scheduling poll chain (replaces the fixed-2s setInterval —
753
+ // a tick now schedules its successor AFTER it completes, so long ticks
754
+ // can no longer stack skipped-overlap invocations).
755
+ // #753 — meter each poll tick's Temporal calls under 'sdk-poller'.
756
+ const runPollTick = async () => {
757
+ const delay = await (0, action_counters_1.withActionSource)('sdk-poller', poll).catch((err) => {
758
+ // poll() has its own catch — this guards the scheduling chain itself.
759
+ log(`poll tick threw outside its handler: ${err?.message ?? err}`);
760
+ return POLL_INTERVAL_MS;
761
+ });
762
+ if (polling) {
763
+ // T1.1 — the inter-tick wait is doorbell-interruptible: a ding pops
764
+ // it (and reset the backoff) so the next tick runs immediately.
765
+ await this.pollSleep(delay);
766
+ if (polling)
767
+ void runPollTick();
768
+ }
769
+ };
770
+ this.pollBackoff.reset();
771
+ void this.pollSleep(POLL_INTERVAL_MS).then(() => {
772
+ if (polling)
773
+ void runPollTick();
774
+ });
725
775
  log('Message poller started. Bridge is running.');
726
776
  // Graceful shutdown on SIGINT/SIGTERM — signal the workflow before exiting
727
777
  const shutdown = async () => {
@@ -71,6 +71,7 @@ const client_1 = require("@temporalio/client");
71
71
  const base_1 = require("../sdk/base");
72
72
  const config_1 = require("../../config");
73
73
  const connection_1 = require("../../connection");
74
+ const action_counters_1 = require("../../utils/action-counters");
74
75
  const signals_1 = require("../../workflows/signals");
75
76
  const server_tools_1 = require("../../server-tools");
76
77
  const config_2 = require("./config");
@@ -130,7 +131,8 @@ const log = (...args) => {
130
131
  const DEFAULT_MODEL = 'anthropic/claude-opus-4-7';
131
132
  /** Tested-pinned OpenCode SDK version — drift triggers a stderr WARNING. */
132
133
  const TESTED_OPENCODE_VERSION = '~1.14.29';
133
- /** Idle poll cadence short enough for snappy cue delivery, loose on Temporal. */
134
+ /** Busy-wait cadence (ms) while a turn is in flight. The IDLE cadence is owned
135
+ * by the inherited `pollBackoff` (#749): 2s base → 30s cap, reset on delivery. */
134
136
  const POLL_INTERVAL_MS = 2000;
135
137
  /** Workflow-register poll bounds. */
136
138
  const WORKFLOW_REGISTER_ATTEMPTS = 30;
@@ -305,7 +307,11 @@ class OpenCodeAttachment extends base_1.SdkAttachment {
305
307
  }
306
308
  // (5) Connect Temporal, wait for workflow, hand client to BaseAttachment.
307
309
  const connection = await (0, connection_1.createTemporalConnection)(config);
308
- const client = new client_1.Client({ connection, namespace: config.temporalNamespace });
310
+ const client = new client_1.Client({
311
+ connection,
312
+ namespace: config.temporalNamespace,
313
+ interceptors: (0, action_counters_1.actionCountingInterceptors)(),
314
+ });
309
315
  this.configureV2(client, os.hostname());
310
316
  log(`Waiting for workflow ${expectedWorkflowId} to register...`);
311
317
  let handle = client.workflow.getHandle(expectedWorkflowId);
@@ -379,6 +385,10 @@ class OpenCodeAttachment extends base_1.SdkAttachment {
379
385
  await cleanup();
380
386
  process.exit(1);
381
387
  }
388
+ // T1.1 PR-2 — cue doorbell: ding ⇒ immediate poll + backoff reset;
389
+ // connected ⇒ 60s idle ceiling. No token / daemon down ⇒ silent no-op
390
+ // (pure #761 polling).
391
+ this.startDoorbell(config.ensemble, playerIdForWorkflow);
382
392
  // (9) PID file — two lines (adapter PID + opencode-serve PID) per
383
393
  // design §6.5. Operators can grep / kill either.
384
394
  const pidFile = path.join(logDir, `${playerIdForWorkflow}.pid`);
@@ -401,8 +411,9 @@ class OpenCodeAttachment extends base_1.SdkAttachment {
401
411
  };
402
412
  process.on('SIGINT', shutdown);
403
413
  process.on('SIGTERM', shutdown);
404
- // (11) Drive the poll loop.
405
- await this.pollLoop(handle);
414
+ // (11) Drive the poll loop. #753 — meter its Temporal calls under 'sdk-poller'.
415
+ await (0, action_counters_1.withActionSource)('sdk-poller', () => this.pollLoop(handle));
416
+ this.stopDoorbell();
406
417
  try {
407
418
  fs.unlinkSync(pidFile);
408
419
  }
@@ -416,6 +427,9 @@ class OpenCodeAttachment extends base_1.SdkAttachment {
416
427
  let polling = true;
417
428
  let processing = false;
418
429
  let pollCount = 0;
430
+ // #749: fresh poll loop starts at the fast cadence — never inherit a
431
+ // stale 30s interval across a restart.
432
+ this.pollBackoff.reset();
419
433
  while (polling && !this.shouldStop()) {
420
434
  pollCount++;
421
435
  // Periodic workflow-status check — detect external destroy / completion.
@@ -453,13 +467,19 @@ class OpenCodeAttachment extends base_1.SdkAttachment {
453
467
  }
454
468
  catch (err) {
455
469
  log(`pendingMessages query failed: ${err?.message ?? err}`);
456
- await sleep(POLL_INTERVAL_MS);
470
+ // #749: grow on errors too — don't hammer a wedged worker at 2s.
471
+ // T1.1: pollSleep is doorbell-interruptible — a ding ends it early.
472
+ await this.pollSleep(this.pollBackoff.next(false));
457
473
  continue;
458
474
  }
459
475
  if (messages.length === 0) {
460
- await sleep(POLL_INTERVAL_MS);
476
+ // #749: idle backoff — 2s → 30s while nothing is pending (60s
477
+ // ceiling while the T1.1 doorbell is connected; a ding wakes it).
478
+ await this.pollSleep(this.pollBackoff.next(false));
461
479
  continue;
462
480
  }
481
+ // #749: live conversation — snap back to the fast cadence.
482
+ this.pollBackoff.reset();
463
483
  processing = true;
464
484
  try {
465
485
  const ackIds = messages.map((m) => m.id);
@@ -26,6 +26,7 @@
26
26
  */
27
27
  import type { WorkflowHandle } from '@temporalio/client';
28
28
  import { BaseAttachment, type BaseAttachmentOptions } from '../base';
29
+ import { IdleBackoff } from './idle-backoff';
29
30
  import type { Message, DetachReason } from '../../types';
30
31
  /** Per-message result from `SdkAttachment.deliver()`. */
31
32
  export interface SdkDeliverResult {
@@ -57,6 +58,31 @@ export declare abstract class SdkAttachment extends BaseAttachment {
57
58
  * implementations to decide whether cancellation actually has an effect.
58
59
  */
59
60
  protected sdkInFlight: boolean;
61
+ /**
62
+ * #749 (T0.2) — shared idle-backoff delay computer for the subclass message
63
+ * poll loops. Contract: `next(messages.length > 0)` per tick — active
64
+ * conversations stay at the 2s base; idle stretches to the 30s cap (15×
65
+ * fewer billable `pendingMessages` queries on an idle player); any
66
+ * delivered message snaps back to base. Subclasses should `reset()` at
67
+ * poll-loop (re)start so a reconnect doesn't inherit a stale slow cadence.
68
+ * Heartbeat/phase-watcher cadences (BaseAttachment) are deliberately NOT
69
+ * driven by this — lease math depends on them (#249).
70
+ */
71
+ protected readonly pollBackoff: IdleBackoff;
72
+ /**
73
+ * T1.1 PR-2 — cue doorbell (docs/design/t11-cue-doorbell.md §2.4). Started
74
+ * by the concrete adapter via {@link startDoorbell} once it knows its
75
+ * `{ensemble, playerId}`; null until then and after {@link stopDoorbell}.
76
+ * A ding is a HINT: it wakes the poll sleep + resets the backoff — the
77
+ * poll itself (and its acks) remain the sole delivery path.
78
+ */
79
+ private doorbell;
80
+ /**
81
+ * The ding→immediate-poll primitive (level-triggered, identity-guarded —
82
+ * see {@link WakeableSleep}). Shared shape with the PR-3 Pi pump, which
83
+ * composes the same class without being an SdkAttachment.
84
+ */
85
+ private readonly pollSleeper;
60
86
  constructor(options?: BaseAttachmentOptions);
61
87
  /**
62
88
  * Deliver one message through the SDK turn. Wire-protocol surface:
@@ -86,6 +112,40 @@ export declare abstract class SdkAttachment extends BaseAttachment {
86
112
  * with one `markDelivered` signal — pass the full batch.
87
113
  */
88
114
  protected deliver(pinned: WorkflowHandle, msg: Message, prompt: string, timeoutMs: number, invokeSdk: (prompt: string, timeoutMs: number) => Promise<unknown>, ackIds?: string[]): Promise<SdkDeliverResult>;
115
+ /**
116
+ * T1.1 PR-2 — start the doorbell once the concrete adapter knows its
117
+ * identity (typically right after `startV2Lifecycle`). Wires:
118
+ *
119
+ * - `ding` → `pollBackoff.reset()` + wake the parked {@link pollSleep}
120
+ * (or set the pending bit if the loop is mid-tick) ⇒ the very next
121
+ * poll runs now, at the fast cadence.
122
+ * - connection state → idle ceiling: connected ⇒ 60s
123
+ * (`SDK_POLL_DOORBELL_MAX_MS`), disconnected ⇒ the T0.2 30s floor.
124
+ * Degradation is automatic and silent — daemon down means the player
125
+ * behaves exactly like post-#761 (§5 of the design doc).
126
+ *
127
+ * No token / no daemon ⇒ the client never subscribes and this is a no-op
128
+ * beyond one breadcrumb. Idempotent per attachment (restart calls stop
129
+ * first).
130
+ */
131
+ protected startDoorbell(ensemble: string, playerId: string): void;
132
+ /** Tear down the doorbell stream (graceful detach, adapter exit). Idempotent. */
133
+ protected stopDoorbell(): void;
134
+ /**
135
+ * T1.1 PR-2 — doorbell-interruptible sleep for the subclass poll loops.
136
+ * Drop-in for the previous `await sleep(pollBackoff.next(...))` sites: a
137
+ * `ding` (or {@link wakePollSleep}) resolves it early so the next poll
138
+ * runs immediately; otherwise it behaves exactly like a plain sleep.
139
+ * A ding that landed while the loop was mid-tick resolves the NEXT call
140
+ * instantly (level-triggered pending bit — hints are consumed, not lost).
141
+ */
142
+ protected pollSleep(ms: number): Promise<void>;
143
+ /**
144
+ * Wake a parked {@link pollSleep} now (ding, or a subclass's own stop
145
+ * path wanting a prompt loop exit). No parked sleep ⇒ sets the pending
146
+ * bit so the next `pollSleep` returns immediately.
147
+ */
148
+ protected wakePollSleep(): void;
89
149
  /**
90
150
  * Subclass-facing convenience: call this from `startV2Lifecycle` path to
91
151
  * fire `adapterExited` on clean shutdown and tear down the V2 machinery.
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SdkAttachment = void 0;
4
4
  const base_1 = require("../base");
5
5
  const signals_1 = require("../../workflows/signals");
6
+ const idle_backoff_1 = require("./idle-backoff");
7
+ const doorbell_client_1 = require("./doorbell-client");
6
8
  const log = (...args) => console.error('[agent-tempo:sdk-adapter]', ...args);
7
9
  /**
8
10
  * Abstract base for SDK-class adapters.
@@ -25,6 +27,31 @@ class SdkAttachment extends base_1.BaseAttachment {
25
27
  * implementations to decide whether cancellation actually has an effect.
26
28
  */
27
29
  sdkInFlight = false;
30
+ /**
31
+ * #749 (T0.2) — shared idle-backoff delay computer for the subclass message
32
+ * poll loops. Contract: `next(messages.length > 0)` per tick — active
33
+ * conversations stay at the 2s base; idle stretches to the 30s cap (15×
34
+ * fewer billable `pendingMessages` queries on an idle player); any
35
+ * delivered message snaps back to base. Subclasses should `reset()` at
36
+ * poll-loop (re)start so a reconnect doesn't inherit a stale slow cadence.
37
+ * Heartbeat/phase-watcher cadences (BaseAttachment) are deliberately NOT
38
+ * driven by this — lease math depends on them (#249).
39
+ */
40
+ pollBackoff = new idle_backoff_1.IdleBackoff();
41
+ /**
42
+ * T1.1 PR-2 — cue doorbell (docs/design/t11-cue-doorbell.md §2.4). Started
43
+ * by the concrete adapter via {@link startDoorbell} once it knows its
44
+ * `{ensemble, playerId}`; null until then and after {@link stopDoorbell}.
45
+ * A ding is a HINT: it wakes the poll sleep + resets the backoff — the
46
+ * poll itself (and its acks) remain the sole delivery path.
47
+ */
48
+ doorbell = null;
49
+ /**
50
+ * The ding→immediate-poll primitive (level-triggered, identity-guarded —
51
+ * see {@link WakeableSleep}). Shared shape with the PR-3 Pi pump, which
52
+ * composes the same class without being an SdkAttachment.
53
+ */
54
+ pollSleeper = new doorbell_client_1.WakeableSleep();
28
55
  constructor(options = {}) {
29
56
  super(options);
30
57
  // Wire the V2 lease-revoked signal (fired by BaseAttachment's phase watcher
@@ -122,12 +149,68 @@ class SdkAttachment extends base_1.BaseAttachment {
122
149
  }
123
150
  return { sdkResult, elapsedMs };
124
151
  }
152
+ /**
153
+ * T1.1 PR-2 — start the doorbell once the concrete adapter knows its
154
+ * identity (typically right after `startV2Lifecycle`). Wires:
155
+ *
156
+ * - `ding` → `pollBackoff.reset()` + wake the parked {@link pollSleep}
157
+ * (or set the pending bit if the loop is mid-tick) ⇒ the very next
158
+ * poll runs now, at the fast cadence.
159
+ * - connection state → idle ceiling: connected ⇒ 60s
160
+ * (`SDK_POLL_DOORBELL_MAX_MS`), disconnected ⇒ the T0.2 30s floor.
161
+ * Degradation is automatic and silent — daemon down means the player
162
+ * behaves exactly like post-#761 (§5 of the design doc).
163
+ *
164
+ * No token / no daemon ⇒ the client never subscribes and this is a no-op
165
+ * beyond one breadcrumb. Idempotent per attachment (restart calls stop
166
+ * first).
167
+ */
168
+ startDoorbell(ensemble, playerId) {
169
+ this.stopDoorbell();
170
+ this.doorbell = new doorbell_client_1.DoorbellClient({
171
+ ensemble,
172
+ playerId,
173
+ onDing: () => {
174
+ this.pollBackoff.reset();
175
+ this.wakePollSleep();
176
+ },
177
+ onConnectionChange: (connected) => {
178
+ this.pollBackoff.setCeiling(connected ? (0, idle_backoff_1.resolveDoorbellCeilingMs)() : (0, idle_backoff_1.resolveIdleBackoffConfig)().maxMs);
179
+ },
180
+ });
181
+ this.doorbell.start();
182
+ }
183
+ /** Tear down the doorbell stream (graceful detach, adapter exit). Idempotent. */
184
+ stopDoorbell() {
185
+ this.doorbell?.stop();
186
+ this.doorbell = null;
187
+ }
188
+ /**
189
+ * T1.1 PR-2 — doorbell-interruptible sleep for the subclass poll loops.
190
+ * Drop-in for the previous `await sleep(pollBackoff.next(...))` sites: a
191
+ * `ding` (or {@link wakePollSleep}) resolves it early so the next poll
192
+ * runs immediately; otherwise it behaves exactly like a plain sleep.
193
+ * A ding that landed while the loop was mid-tick resolves the NEXT call
194
+ * instantly (level-triggered pending bit — hints are consumed, not lost).
195
+ */
196
+ pollSleep(ms) {
197
+ return this.pollSleeper.sleep(ms);
198
+ }
199
+ /**
200
+ * Wake a parked {@link pollSleep} now (ding, or a subclass's own stop
201
+ * path wanting a prompt loop exit). No parked sleep ⇒ sets the pending
202
+ * bit so the next `pollSleep` returns immediately.
203
+ */
204
+ wakePollSleep() {
205
+ this.pollSleeper.wake();
206
+ }
125
207
  /**
126
208
  * Subclass-facing convenience: call this from `startV2Lifecycle` path to
127
209
  * fire `adapterExited` on clean shutdown and tear down the V2 machinery.
128
210
  * Forwards to `BaseAttachment.stopV2Lifecycle` with graceful=true.
129
211
  */
130
212
  async detachGracefully(reason = 'user-stop') {
213
+ this.stopDoorbell();
131
214
  await this.stopV2Lifecycle(reason, /* graceful */ true);
132
215
  }
133
216
  }