pi-harness-runtime 1.1.382 → 1.1.384

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 (156) hide show
  1. package/harness/job-state-machine.ts +97 -10
  2. package/harness/langchain/surge.test.ts +57 -25
  3. package/index.ts +124 -4
  4. package/package.json +8 -4
  5. package/packages/a2a-adapter/package.json +1 -1
  6. package/packages/agent-policy/package.json +1 -1
  7. package/packages/architecture-generator/package.json +1 -1
  8. package/packages/auth/dist/minimax-browser-auth.d.ts +1 -0
  9. package/packages/auth/dist/minimax-browser-auth.d.ts.map +1 -1
  10. package/packages/auth/dist/minimax-browser-auth.js +2 -2
  11. package/packages/auth/dist/minimax-browser-auth.js.map +1 -1
  12. package/packages/auth/package.json +1 -1
  13. package/packages/auth/src/minimax-browser-auth.ts +15 -3
  14. package/packages/autonomous-refactor/package.json +1 -1
  15. package/packages/autonomous-runtime/package.json +1 -1
  16. package/packages/cache-strategy/package.json +1 -1
  17. package/packages/capability-registry/package.json +1 -1
  18. package/packages/checkpoint/dist/checkpoint-manager.d.ts.map +1 -1
  19. package/packages/checkpoint/dist/checkpoint-manager.js +3 -0
  20. package/packages/checkpoint/dist/checkpoint-manager.js.map +1 -1
  21. package/packages/checkpoint/package.json +1 -1
  22. package/packages/checkpoint/src/checkpoint-manager.ts +8 -1
  23. package/packages/cli-plugin-sdk/package.json +1 -1
  24. package/packages/clipboard/package.json +1 -1
  25. package/packages/clipboard-plugin/package.json +1 -1
  26. package/packages/code-generation/package.json +1 -1
  27. package/packages/code-review/package.json +1 -1
  28. package/packages/codex-adapter/package.json +1 -1
  29. package/packages/codex-watcher/package.json +1 -1
  30. package/packages/config-capture/package.json +1 -1
  31. package/packages/connection-runtime/package.json +1 -1
  32. package/packages/context-compiler/package.json +1 -1
  33. package/packages/context-discovery/package.json +1 -1
  34. package/packages/context-manager/package.json +1 -1
  35. package/packages/contracts/package.json +1 -1
  36. package/packages/cookie-sanitizer/package.json +1 -1
  37. package/packages/cost-optimizer/package.json +1 -1
  38. package/packages/dependency-analyzer/package.json +1 -1
  39. package/packages/django-plugin/package.json +1 -1
  40. package/packages/doc-generator/package.json +1 -1
  41. package/packages/durable-review/package.json +1 -1
  42. package/packages/e2e-tester/package.json +1 -1
  43. package/packages/evaluation-engine/package.json +1 -1
  44. package/packages/evaluation-runner/package.json +1 -1
  45. package/packages/event-bus/package.json +1 -1
  46. package/packages/event-sink/package.json +1 -1
  47. package/packages/event-store/package.json +1 -1
  48. package/packages/experience-replay/package.json +1 -1
  49. package/packages/feedback-collector/package.json +1 -1
  50. package/packages/file-copy-helper/package.json +1 -1
  51. package/packages/framework-detector/package.json +1 -1
  52. package/packages/framework-plugin-sdk/package.json +1 -1
  53. package/packages/frappe-plugin/package.json +1 -1
  54. package/packages/generic-web-plugin/package.json +1 -1
  55. package/packages/health-monitor/package.json +1 -1
  56. package/packages/intent-analyzer/package.json +1 -1
  57. package/packages/jev-judge/dist/continuation-controller.d.ts +174 -9
  58. package/packages/jev-judge/dist/continuation-controller.d.ts.map +1 -1
  59. package/packages/jev-judge/dist/continuation-controller.js +530 -77
  60. package/packages/jev-judge/dist/continuation-controller.js.map +1 -1
  61. package/packages/jev-judge/dist/typesafe-provider.d.ts.map +1 -1
  62. package/packages/jev-judge/dist/typesafe-provider.js +29 -2
  63. package/packages/jev-judge/dist/typesafe-provider.js.map +1 -1
  64. package/packages/jev-judge/package.json +1 -1
  65. package/packages/jev-judge/src/continuation-controller.ts +636 -83
  66. package/packages/jev-judge/src/typesafe-provider.ts +31 -2
  67. package/packages/jev-judge/tests/continuation-controller.test.ts +1229 -0
  68. package/packages/jev-judge/tests/typesafe-provider.test.ts +180 -0
  69. package/packages/knowledge-graph/package.json +1 -1
  70. package/packages/knowledge-retrieval/package.json +1 -1
  71. package/packages/laravel-plugin/package.json +1 -1
  72. package/packages/learning-engine/package.json +1 -1
  73. package/packages/mcp-adapter/package.json +1 -1
  74. package/packages/memory-engine/package.json +1 -1
  75. package/packages/milestone-manager/package.json +1 -1
  76. package/packages/model-registry/package.json +1 -1
  77. package/packages/nextjs-plugin/package.json +1 -1
  78. package/packages/notification/package.json +1 -1
  79. package/packages/observability/package.json +1 -1
  80. package/packages/okf-indexer/package.json +1 -1
  81. package/packages/paperclip-integration/package.json +1 -1
  82. package/packages/performance-optimizer/package.json +1 -1
  83. package/packages/permission-mode/package.json +1 -1
  84. package/packages/privilege-broker/package.json +1 -1
  85. package/packages/project-analyzer/package.json +1 -1
  86. package/packages/project-bootstrap/package.json +1 -1
  87. package/packages/projection-engine/package.json +1 -1
  88. package/packages/prompt-compiler/package.json +1 -1
  89. package/packages/prompt-versioning/package.json +1 -1
  90. package/packages/provider-adapter-sdk/package.json +1 -1
  91. package/packages/provider-router/package.json +1 -1
  92. package/packages/provider-selector/package.json +1 -1
  93. package/packages/providers/package.json +1 -1
  94. package/packages/qdrant-skills/package.json +1 -1
  95. package/packages/quota-manager/package.json +1 -1
  96. package/packages/rate-limiter/package.json +1 -1
  97. package/packages/react-vite-plugin/package.json +1 -1
  98. package/packages/relay/dist/client.d.ts +36 -1
  99. package/packages/relay/dist/client.js +195 -25
  100. package/packages/relay/dist/server.d.ts +5 -1
  101. package/packages/relay/dist/server.js +74 -7
  102. package/packages/relay/dist/types.d.ts +7 -0
  103. package/packages/relay/package.json +1 -1
  104. package/packages/relay/src/client.ts +206 -42
  105. package/packages/relay/src/server.ts +78 -10
  106. package/packages/relay/src/types.ts +7 -0
  107. package/packages/relay/test/client.test.ts +312 -0
  108. package/packages/relay/test/relay-e2e.test.ts +263 -0
  109. package/packages/release-manager/package.json +1 -1
  110. package/packages/requirement-compiler/package.json +1 -1
  111. package/packages/runtime/package.json +1 -1
  112. package/packages/scheduler/package.json +1 -1
  113. package/packages/scheduler-adapter/package.json +1 -1
  114. package/packages/session/package.json +1 -1
  115. package/packages/session-api/package.json +1 -1
  116. package/packages/session-export/package.json +1 -1
  117. package/packages/shared-context/package.json +1 -1
  118. package/packages/skill-mcp-client/package.json +1 -1
  119. package/packages/skill-registry/package.json +1 -1
  120. package/packages/skills/package.json +1 -1
  121. package/packages/sprint-planner/package.json +1 -1
  122. package/packages/subscription-engine/package.json +1 -1
  123. package/packages/task-compiler/package.json +1 -1
  124. package/packages/telegram/package.json +1 -1
  125. package/packages/tencentdb-memory/package.json +1 -1
  126. package/packages/tencentdb-sync/package.json +1 -1
  127. package/packages/test-data-generator/package.json +1 -1
  128. package/packages/test-generator/package.json +1 -1
  129. package/packages/todo-bd-sync/package.json +1 -1
  130. package/packages/token-estimation/package.json +1 -1
  131. package/packages/token-optimizer/package.json +1 -1
  132. package/packages/trajectory/package.json +1 -1
  133. package/packages/tui/dist/harness-footer.d.ts +131 -0
  134. package/packages/tui/dist/harness-footer.d.ts.map +1 -0
  135. package/packages/tui/dist/harness-footer.js +463 -0
  136. package/packages/tui/dist/harness-footer.js.map +1 -0
  137. package/packages/tui/dist/index.d.ts +1 -0
  138. package/packages/tui/dist/index.d.ts.map +1 -1
  139. package/packages/tui/dist/index.js +1 -0
  140. package/packages/tui/dist/index.js.map +1 -1
  141. package/packages/tui/package.json +1 -1
  142. package/packages/tui/src/harness-footer.ts +580 -0
  143. package/packages/tui/src/index.ts +17 -0
  144. package/packages/tui/test/harness-footer.test.ts +440 -0
  145. package/packages/types/package.json +1 -1
  146. package/packages/voice-api/package.json +1 -1
  147. package/packages/webmcp-tester/package.json +1 -1
  148. package/packages/wiki/package.json +1 -1
  149. package/packages/workflow-events/package.json +1 -1
  150. package/packages/workspace-scanner/package.json +1 -1
  151. package/packages/worktree/package.json +1 -1
  152. package/packages/write-review/package.json +1 -1
  153. package/scripts/run-e2e-scripts.mjs +74 -0
  154. package/scripts/run-langchain-tests.mjs +169 -0
  155. package/scripts/typecheck-packages.mjs +167 -0
  156. package/scripts/unified-webhook-server.ts +70 -13
@@ -3,6 +3,14 @@ import { createTodoProvider } from "./todo-provider.js";
3
3
  * Simple debug logger that writes to file, not stdout
4
4
  */
5
5
  const DEBUG_LOG_FILE = "/tmp/pi-harness-debug.log";
6
+ /**
7
+ * Watchdog for a dispatch that never settles.
8
+ *
9
+ * A hanging dispatch (a dead transport, a promise that is never resolved) must
10
+ * not block every later continuation: after this timeout the in-flight guard
11
+ * is released so the next decision can try again.
12
+ */
13
+ const INFLIGHT_TIMEOUT_MS = 60_000;
6
14
  function debugLog(component, ...args) {
7
15
  try {
8
16
  const timestamp = new Date().toISOString();
@@ -15,6 +23,16 @@ function debugLog(component, ...args) {
15
23
  // Silently fail
16
24
  }
17
25
  }
26
+ /**
27
+ * Continuation configuration
28
+ */
29
+ /**
30
+ * When a guard release arrives while a dispatch is on the wire, the release is
31
+ * dropped (it belongs to the turn that was already running). The guard then
32
+ * stays armed until the safety-net timeout - which would stall the
33
+ * auto-continue for minutes. It is released shortly after instead.
34
+ */
35
+ const GUARD_RELEASE_AFTER_DROP_MS = 1000;
18
36
  /**
19
37
  * Todo-Driven Continuation Controller
20
38
  *
@@ -24,6 +42,13 @@ export class TodoContinuationController {
24
42
  ctx;
25
43
  config;
26
44
  todoProvider;
45
+ /**
46
+ * How long a dispatched continuation may stay queued before the guard is
47
+ * released automatically (safety net for a lost agent_settled event).
48
+ */
49
+ guardReleaseTimeoutMs;
50
+ /** Delay before the guard is released after a dropped mid-dispatch release */
51
+ guardReleaseAfterDropMs;
27
52
  state = {
28
53
  sessionId: "",
29
54
  generation: 0,
@@ -39,10 +64,35 @@ export class TodoContinuationController {
39
64
  lastItemContinued: null,
40
65
  lastEvaluationResult: null,
41
66
  };
42
- _inFlight = false; // Guard against concurrent dispatches
67
+ _inFlightSeq = null; // Sequence number of the active dispatch (null = idle)
68
+ _dispatchSeq = 0; // Monotonic counter for every dispatch attempt
69
+ _guardTimer = null;
70
+ _blockedDispatches = 0; // Dispatches skipped by a guard (do NOT consume the attempt budget)
71
+ _pendingGuardRelease = null; // Guard release requested while a dispatch was in flight
72
+ _cancelRequested = false; // clearQueue()/pause() called while a dispatch was in flight
73
+ _lastDroppedGuardRelease = null; // Reason of the last dropped mid-dispatch release
74
+ _stalledItemId = null; // Item of a dispatch that was force-released by the watchdog
75
+ _consecutiveFailures = 0; // Failed sends in a row (circuit breaker)
76
+ _maxConsecutiveFailures = 3; // Failed sends tolerated before the breaker opens
77
+ _lastFailureAt = 0; // Timestamp of the last failed send (circuit breaker cooldown)
78
+ _circuitBreakerCooldownMs = 5 * 60 * 1000; // How long the breaker stays open
79
+ _inFlightTimeoutMs = INFLIGHT_TIMEOUT_MS; // Watchdog for a hanging dispatch
80
+ _attemptsByItem = new Map(); // Successful dispatches per item
81
+ _totalAttempts = 0; // Successful dispatches in this session (runaway guard)
82
+ _maxTotalSteerAttempts = 9; // Session-wide cap on successful dispatches
83
+ _disposed = false; // Set on shutdown: no dispatch and no timer may act anymore
84
+ /** True while a dispatch is on the wire */
85
+ get inFlight() {
86
+ return this._inFlightSeq !== null;
87
+ }
43
88
  constructor(ctx, config) {
44
89
  this.ctx = ctx;
45
- this.todoProvider = createTodoProvider(ctx);
90
+ this.todoProvider = config?.todoProvider ?? createTodoProvider(ctx);
91
+ this.guardReleaseTimeoutMs = config?.guardReleaseTimeoutMs ?? 5 * 60 * 1000;
92
+ this.guardReleaseAfterDropMs = config?.guardReleaseAfterDropMs ?? GUARD_RELEASE_AFTER_DROP_MS;
93
+ this._circuitBreakerCooldownMs = config?.circuitBreakerCooldownMs ?? 5 * 60 * 1000;
94
+ this._maxConsecutiveFailures = config?.maxConsecutiveFailures ?? 3;
95
+ this._inFlightTimeoutMs = config?.inFlightTimeoutMs ?? INFLIGHT_TIMEOUT_MS;
46
96
  this.config = {
47
97
  jevFallbackMinWaitMs: 5 * 60 * 1000, // 5 minutes
48
98
  routineThreshold: 0.7,
@@ -50,16 +100,48 @@ export class TodoContinuationController {
50
100
  enableJev: true,
51
101
  ...config,
52
102
  };
103
+ this._maxTotalSteerAttempts =
104
+ config?.maxTotalSteerAttempts ?? this.config.maxSteerAttempts * 3;
105
+ }
106
+ /**
107
+ * Successful dispatches recorded for an item.
108
+ *
109
+ * The budget is per item: switching to a different item starts a fresh
110
+ * budget for that item, but the session-wide cap (`maxTotalSteerAttempts`)
111
+ * still bounds an alternating run.
112
+ */
113
+ attemptsFor(itemId) {
114
+ return this._attemptsByItem.get(itemId) ?? 0;
115
+ }
116
+ /**
117
+ * True while the circuit breaker forbids a send.
118
+ *
119
+ * After the cooldown a single trial dispatch is allowed again, so a
120
+ * transient transport outage does not stop auto-continue forever.
121
+ */
122
+ breakerOpen() {
123
+ if (this._consecutiveFailures < this._maxConsecutiveFailures)
124
+ return false;
125
+ return Date.now() - this._lastFailureAt < this._circuitBreakerCooldownMs;
53
126
  }
54
127
  /**
55
128
  * Check if continuation is allowed (not paused, session idle)
56
129
  */
57
130
  canContinue() {
58
- // Only blocked by explicit pause, not by temporary user input pause
131
+ // Only an explicit pause blocks the continuation. A temporary pause
132
+ // (user input) is honoured as well - it is cleared by the next
133
+ // decision that is not racing with a running dispatch.
59
134
  if (this.state.explicitPause) {
60
135
  debugLog("TodoContinuation", "Explicitly paused, not continuing");
61
136
  return false;
62
137
  }
138
+ if (this.state.userPaused) {
139
+ // NOTE: the temporary pause is auto-cleared by the next decision
140
+ // (see decide()), so this branch only triggers while a dispatch is
141
+ // in flight and the pause was raised after that decision.
142
+ debugLog("TodoContinuation", "User input pending, not continuing");
143
+ return false;
144
+ }
63
145
  if (this.ctx.isIdle?.() === false) {
64
146
  debugLog("TodoContinuation", "Session not idle, not continuing");
65
147
  return false;
@@ -77,6 +159,13 @@ export class TodoContinuationController {
77
159
  debugLog("TodoContinuation", "Explicit pause requested");
78
160
  this.state.explicitPause = true;
79
161
  this.state.userPaused = true;
162
+ // Disarm the guard completely: keeping continuationQueued set while the
163
+ // release timer is cancelled would block every later dispatch forever.
164
+ this.state.continuationQueued = false;
165
+ this._pendingGuardRelease = null;
166
+ if (this.inFlight)
167
+ this._cancelRequested = true;
168
+ this.cancelGuardTimer();
80
169
  }
81
170
  /**
82
171
  * Resume from explicit pause
@@ -88,6 +177,10 @@ export class TodoContinuationController {
88
177
  this.state.userPaused = false;
89
178
  this.clearQueue();
90
179
  }
180
+ // A resumed session gets a fresh transport budget: an open circuit
181
+ // breaker must not stay open forever.
182
+ this._consecutiveFailures = 0;
183
+ this._lastFailureAt = 0;
91
184
  }
92
185
  /**
93
186
  * Get current todo snapshot (exposed for task intake)
@@ -102,21 +195,94 @@ export class TodoContinuationController {
102
195
  tempPause() {
103
196
  debugLog("TodoContinuation", "Temporary pause for user input");
104
197
  this.state.userPaused = true;
198
+ // Same as explicitPause(): the guard must be fully disarmed, otherwise
199
+ // the cancelled release timer leaves the queue flag stuck at true.
200
+ this.state.continuationQueued = false;
201
+ this._pendingGuardRelease = null;
202
+ if (this.inFlight)
203
+ this._cancelRequested = true;
204
+ this.cancelGuardTimer();
105
205
  }
106
206
  /**
107
207
  * Clear continuation queue (called when work completes or starts)
108
208
  */
109
209
  clearQueue() {
110
210
  this.state.continuationQueued = false;
111
- this._inFlight = false;
211
+ // NOTE: _inFlight is NOT cleared here. It belongs to the dispatch
212
+ // lifecycle and is released in the dispatch's finally block. Clearing it
213
+ // here would re-open the concurrent-dispatch window while a dispatch
214
+ // send is still on the wire.
215
+ // A dispatch that is still on the wire must not re-arm the guard when
216
+ // it completes. The cancel intent is only meaningful for a dispatch
217
+ // that is actually running - otherwise it would disarm the guard of a
218
+ // LATER dispatch.
219
+ if (this.inFlight)
220
+ this._cancelRequested = true;
221
+ this.cancelGuardTimer();
112
222
  debugLog("TodoContinuation", "Continuation queue cleared");
113
223
  }
224
+ /**
225
+ * Release the dispatch guard after a verified lifecycle transition.
226
+ *
227
+ * The guard is set when a continuation is handed to the agent and must be
228
+ * released once that turn has settled - otherwise every later dispatch is
229
+ * blocked while the attempt budget keeps being consumed.
230
+ */
231
+ releaseContinuationGuard(reason = "agent settled") {
232
+ if (this.inFlight) {
233
+ // A dispatch is still on the wire. Releasing the guard now would
234
+ // disarm the queue flag the dispatch just set and allow a duplicate
235
+ // continuation. Defer the release until the dispatch settles.
236
+ this._pendingGuardRelease = reason;
237
+ debugLog("TodoContinuation", "Continuation guard release deferred:", reason);
238
+ return;
239
+ }
240
+ const wasQueued = this.state.continuationQueued;
241
+ this.state.continuationQueued = false;
242
+ this.cancelGuardTimer();
243
+ // NOTE: the in-flight flag belongs to the dispatch lifecycle. Clearing
244
+ // it here would allow a duplicate continuation while a dispatch is
245
+ // still on the wire.
246
+ if (wasQueued) {
247
+ debugLog("TodoContinuation", "Continuation guard released:", reason);
248
+ }
249
+ }
250
+ /** Cancel the automatic guard-release timer */
251
+ cancelGuardTimer() {
252
+ if (this._guardTimer) {
253
+ clearTimeout(this._guardTimer);
254
+ this._guardTimer = null;
255
+ }
256
+ }
257
+ /** Schedule an automatic guard release (safety net for a lost settle event) */
258
+ scheduleGuardRelease(delayMs = this.guardReleaseTimeoutMs) {
259
+ this.cancelGuardTimer();
260
+ this._guardTimer = setTimeout(() => {
261
+ this._guardTimer = null;
262
+ debugLog("TodoContinuation", "Guard auto-released after timeout");
263
+ this.state.continuationQueued = false;
264
+ }, delayMs);
265
+ // Do not keep the process alive just for the guard timer
266
+ this._guardTimer.unref?.();
267
+ }
114
268
  /**
115
269
  * Public method to evaluate and execute continuation
116
270
  * Called by external handlers (e.g., agent_settled)
117
271
  * Checks blockers and pause BEFORE selecting a task
118
272
  */
119
273
  async decide() {
274
+ // A temporary pause (user input) is auto-cleared by the next decision:
275
+ // the decision itself is the "next decision" the comment refers to.
276
+ // A decision that runs WHILE a dispatch is in flight must not clear
277
+ // the pause: the pending dispatch would then send a message the user
278
+ // asked to hold back.
279
+ if (!this.inFlight)
280
+ this.state.userPaused = false;
281
+ // The ids describe the CURRENT decision: they are set on the continue
282
+ // paths and cleared here so a stop/blocked decision does not keep
283
+ // reporting the last continued item.
284
+ this.state.currentItemId = null;
285
+ this.state.nextItemId = null;
120
286
  // 1. Check if continuation is allowed
121
287
  if (!this.canContinue()) {
122
288
  return { action: "stop", reason: "paused or session not idle" };
@@ -154,29 +320,39 @@ export class TodoContinuationController {
154
320
  // 5. Continue unblocked in-progress work first
155
321
  if (unblockedInProgress.length > 0) {
156
322
  const item = unblockedInProgress[0];
157
- // Reset counter if switching to a different task
158
- if (this.state.lastItemContinued !== item.id) {
159
- this.state.steerAttemptsForCurrentItem = 0;
160
- this.state.lastItemContinued = item.id;
161
- }
323
+ // The attempt budget is per item; switching items does not reset it
324
+ this.state.lastItemContinued = item.id;
325
+ this.state.steerAttemptsForCurrentItem = this.attemptsFor(item.id);
162
326
  // Check per-task attempt limit
163
327
  if (this.state.steerAttemptsForCurrentItem >= this.config.maxSteerAttempts) {
164
328
  return { action: "stop", reason: `max attempts (${this.config.maxSteerAttempts}) reached for task ${item.id}` };
165
329
  }
330
+ // Session-wide cap: alternating between two items would never
331
+ // exhaust the per-item budget.
332
+ if (this._totalAttempts >= this._maxTotalSteerAttempts) {
333
+ return { action: "stop", reason: `max total attempts (${this._maxTotalSteerAttempts}) reached` };
334
+ }
335
+ this.state.currentItemId = item.id;
336
+ this.state.nextItemId = item.id;
166
337
  return { action: "continue", nextItemId: item.id };
167
338
  }
168
339
  // 5. Continue ready pending work (not blocked)
169
340
  if (ready.length > 0) {
170
341
  const item = ready[0];
171
- // Reset counter if switching to a different task
172
- if (this.state.lastItemContinued !== item.id) {
173
- this.state.steerAttemptsForCurrentItem = 0;
174
- this.state.lastItemContinued = item.id;
175
- }
342
+ // The attempt budget is per item; switching items does not reset it
343
+ this.state.lastItemContinued = item.id;
344
+ this.state.steerAttemptsForCurrentItem = this.attemptsFor(item.id);
176
345
  // Check per-task attempt limit
177
346
  if (this.state.steerAttemptsForCurrentItem >= this.config.maxSteerAttempts) {
178
347
  return { action: "stop", reason: `max attempts (${this.config.maxSteerAttempts}) reached for task ${item.id}` };
179
348
  }
349
+ // Session-wide cap: alternating between two items would never
350
+ // exhaust the per-item budget.
351
+ if (this._totalAttempts >= this._maxTotalSteerAttempts) {
352
+ return { action: "stop", reason: `max total attempts (${this._maxTotalSteerAttempts}) reached` };
353
+ }
354
+ this.state.currentItemId = item.id;
355
+ this.state.nextItemId = item.id;
180
356
  return { action: "continue", nextItemId: item.id };
181
357
  }
182
358
  // 6. Only report blocked when nothing can proceed
@@ -189,112 +365,323 @@ export class TodoContinuationController {
189
365
  }
190
366
  /**
191
367
  * Continue a specific todo item
368
+ *
369
+ * Routed through the guarded dispatch so the in-flight guard, the
370
+ * duplicate-queue guard and the guard-release timer behave exactly like
371
+ * they do for an automatic continuation. A blocked or failed dispatch
372
+ * never consumes the attempt budget.
192
373
  */
193
374
  async continueItem(item) {
194
- // Increment attempts for this specific item
195
- if (this.state.lastItemContinued !== item.id) {
196
- this.state.steerAttemptsForCurrentItem = 0;
197
- this.state.lastItemContinued = item.id;
198
- }
375
+ // The attempt budget is per item; switching items does not reset it
376
+ this.state.lastItemContinued = item.id;
377
+ this.state.steerAttemptsForCurrentItem = this.attemptsFor(item.id);
199
378
  if (this.state.steerAttemptsForCurrentItem >= this.config.maxSteerAttempts) {
200
379
  debugLog("TodoContinuation", "Max steer attempts reached for item", item.id);
201
380
  return;
202
381
  }
203
- this.state.steerAttemptsForCurrentItem++;
204
- this.state.continuationQueued = true;
382
+ if (this._totalAttempts >= this._maxTotalSteerAttempts) {
383
+ debugLog("TodoContinuation", "Max total steer attempts reached");
384
+ return;
385
+ }
205
386
  // Build continuation message
206
387
  const message = item.description
207
388
  ? `Continue task: ${item.subject}\n\n${item.description}`
208
389
  : `Continue task: ${item.subject}`;
209
- this.state.lastSteerMessage = message;
390
+ // A retry after a stalled dispatch gets the same duplicate warning as
391
+ // the automatic continuation path.
392
+ const finalMessage = this._stalledItemId === item.id
393
+ ? `${message} (retry after a stalled dispatch - a duplicate continuation for this item may still arrive)`
394
+ : message;
210
395
  debugLog("TodoContinuation", "Sending continuation:", item.subject);
211
- // Send continuation message via dispatch callback
212
- try {
213
- await this.config.dispatch(message);
396
+ const result = await this.guardedDispatch(item.id, finalMessage);
397
+ if (result.dispatched) {
398
+ // Only a dispatch that actually reached the agent consumes an attempt
399
+ this.recordAttempt(item.id);
400
+ if (this._stalledItemId === item.id)
401
+ this._stalledItemId = null;
214
402
  }
215
- catch (err) {
216
- debugLog("TodoContinuation", "Failed to send continuation:", String(err));
217
- this.state.continuationQueued = false;
403
+ else {
404
+ debugLog("TodoContinuation", "Continuation not dispatched:", result.reason);
218
405
  }
219
406
  }
407
+ /**
408
+ * Record a successful dispatch against the per-item and session budgets.
409
+ */
410
+ recordAttempt(itemId) {
411
+ const attempts = this.attemptsFor(itemId) + 1;
412
+ this._attemptsByItem.set(itemId, attempts);
413
+ this._totalAttempts++;
414
+ this.state.steerAttemptsForCurrentItem = attempts;
415
+ }
220
416
  /**
221
417
  * Guarded dispatch - checks all conditions before sending
222
418
  * Uses in-flight guard to prevent race conditions
223
419
  */
224
420
  async guardedDispatch(itemId, message) {
225
- // 0. Acquire in-flight guard to prevent concurrent dispatches
226
- if (this._inFlight) {
421
+ // 0. A disposed controller never dispatches.
422
+ if (this._disposed) {
423
+ debugLog("TodoContinuation", "Guarded dispatch: controller disposed, skipping");
424
+ this._blockedDispatches++;
425
+ return { dispatched: false, reason: "controller disposed", blocked: true };
426
+ }
427
+ // 1. Acquire in-flight guard to prevent concurrent dispatches
428
+ if (this.inFlight) {
227
429
  debugLog("TodoContinuation", "Guarded dispatch: already in-flight, skipping");
228
- return false;
430
+ this._blockedDispatches++;
431
+ return { dispatched: false, reason: "dispatch already in flight", blocked: true };
229
432
  }
230
- // 1. Check if paused by explicit user pause
433
+ // 2. Check if paused by explicit user pause
231
434
  if (this.state.explicitPause) {
232
435
  debugLog("TodoContinuation", "Guarded dispatch: explicitly paused, skipping");
233
- return false;
436
+ this._blockedDispatches++;
437
+ return { dispatched: false, reason: "explicitly paused", blocked: true };
438
+ }
439
+ // 3. A user message that is still being processed pauses the
440
+ // continuation. tempPause() is auto-cleared on the next decision.
441
+ if (this.state.userPaused) {
442
+ debugLog("TodoContinuation", "Guarded dispatch: user input pending, skipping");
443
+ this._blockedDispatches++;
444
+ return { dispatched: false, reason: "user input pending", blocked: true };
234
445
  }
235
- // 2. Check if session is still idle
446
+ // 4. The circuit breaker: a permanently broken transport must not be
447
+ // retried on every settle. After the cooldown a trial is allowed.
448
+ if (this.breakerOpen()) {
449
+ debugLog("TodoContinuation", "Guarded dispatch: circuit breaker open, skipping");
450
+ this._blockedDispatches++;
451
+ return {
452
+ dispatched: false,
453
+ reason: `circuit breaker open after ${this._consecutiveFailures} consecutive failures`,
454
+ blocked: true,
455
+ };
456
+ }
457
+ // 5. Check if session is still idle
236
458
  if (this.ctx.isIdle?.() === false) {
237
459
  debugLog("TodoContinuation", "Guarded dispatch: session not idle, skipping");
238
- return false;
460
+ this._blockedDispatches++;
461
+ return { dispatched: false, reason: "session not idle", blocked: true };
239
462
  }
240
- // 3. Check if continuation already queued (prevent duplicates)
463
+ // 6. Check if continuation already queued (prevent duplicates)
241
464
  if (this.state.continuationQueued) {
242
465
  debugLog("TodoContinuation", "Guarded dispatch: already queued, skipping");
243
- return false;
466
+ this._blockedDispatches++;
467
+ return { dispatched: false, reason: "continuation already queued", blocked: true };
244
468
  }
245
- // 4. Verify item still needs work (not completed) - acquire guard BEFORE await
246
- this._inFlight = true;
469
+ // The in-flight guard is held for the WHOLE dispatch (including the
470
+ // send): it must only be released once the dispatch has settled.
471
+ // The sequence number identifies THIS dispatch: a stale dispatch (one
472
+ // whose watchdog already fired) must never clear the flag of a newer
473
+ // dispatch and must never send after it was superseded.
474
+ const seq = ++this._dispatchSeq;
475
+ this._inFlightSeq = seq;
476
+ let watchdog = null;
477
+ // The watchdog is armed IMMEDIATELY: a hanging todo read must not keep
478
+ // the guard forever. The timer belongs to THIS dispatch only.
479
+ watchdog = setTimeout(() => {
480
+ if (this._inFlightSeq !== seq)
481
+ return; // A newer dispatch owns the guard
482
+ debugLog("TodoContinuation", `In-flight dispatch still pending after ${this._inFlightTimeoutMs}ms - forcing release`);
483
+ this._inFlightSeq = null;
484
+ // A hung dispatch is treated like a failed one: the guard is
485
+ // released so the next decision can try again.
486
+ // NOTE: the flag is read BEFORE it is cleared: it tells us whether
487
+ // the send had already started (a message may still land).
488
+ const hadStartedSending = this.state.continuationQueued;
489
+ this.state.continuationQueued = false;
490
+ this.cancelGuardTimer();
491
+ // A cancel intent that was raised while this dispatch hung belongs to
492
+ // THIS dispatch. It must not survive: otherwise it would cancel the
493
+ // next legitimate dispatch and silently lose a continuation cycle.
494
+ this._cancelRequested = false;
495
+ // Remember the item: a later dispatch for the same item is a RETRY
496
+ // of a message that may still land. The message says so.
497
+ // Only when the send had already started - a hang in the todo read
498
+ // never put a message on the wire, so there is no duplicate to warn
499
+ // about.
500
+ if (hadStartedSending)
501
+ this._stalledItemId = itemId;
502
+ }, this._inFlightTimeoutMs);
503
+ if (typeof watchdog.unref === "function")
504
+ watchdog.unref();
247
505
  try {
248
- const snapshot = await this.todoProvider.getSnapshot();
249
- const item = snapshot.items.find((i) => i.id === itemId);
506
+ // 7. Verify item still needs work (not completed)
507
+ let item;
508
+ try {
509
+ const snapshot = await this.todoProvider.getSnapshot();
510
+ item = snapshot.items.find((i) => i.id === itemId);
511
+ }
512
+ catch {
513
+ // If we can't read todos, skip dispatch
514
+ debugLog("TodoContinuation", "Guarded dispatch: failed to read todos, skipping");
515
+ this._blockedDispatches++;
516
+ return { dispatched: false, reason: "todo read failed", blocked: true };
517
+ }
250
518
  if (item?.status === "completed") {
251
519
  debugLog("TodoContinuation", "Guarded dispatch: item completed, skipping");
252
- this._inFlight = false;
253
- return false;
520
+ this._blockedDispatches++;
521
+ return { dispatched: false, reason: "item already completed", blocked: true };
254
522
  }
523
+ // 8. The snapshot read above is asynchronous: the queue may have
524
+ // been cleared, a pause requested or the dispatch superseded in
525
+ // the meantime. In that case the message must NOT be sent.
526
+ //
527
+ // The superseded check comes FIRST: a stale dispatch must not
528
+ // consume the cancel intent of the dispatch that replaced it.
529
+ if (this._inFlightSeq !== seq) {
530
+ // The watchdog already released this dispatch and a newer one
531
+ // took over. Sending now would put two messages on the wire.
532
+ debugLog("TodoContinuation", "Guarded dispatch: superseded, skipping");
533
+ this._blockedDispatches++;
534
+ return { dispatched: false, reason: "dispatch superseded", blocked: true };
535
+ }
536
+ if (this.state.explicitPause || this.state.userPaused) {
537
+ // The pause is more specific than the generic cancel flag: a
538
+ // pause that arrived during the snapshot read is reported as a
539
+ // pause. The cancel intent is consumed here as well so a later
540
+ // dispatch is not cancelled by a stale flag.
541
+ this._cancelRequested = false;
542
+ debugLog("TodoContinuation", "Guarded dispatch: paused before send, skipping");
543
+ this._blockedDispatches++;
544
+ return { dispatched: false, reason: "paused before send", blocked: true };
545
+ }
546
+ if (this._cancelRequested) {
547
+ this._cancelRequested = false;
548
+ debugLog("TodoContinuation", "Guarded dispatch: cancelled before send, skipping");
549
+ this._blockedDispatches++;
550
+ return { dispatched: false, reason: "cancelled before send", blocked: true };
551
+ }
552
+ // All checks passed - dispatch
553
+ this.state.continuationQueued = true;
554
+ // Arm the guard-release timer BEFORE the send: a dispatch that hangs
555
+ // must not leave the guard armed forever.
556
+ this.scheduleGuardRelease();
557
+ try {
558
+ await this.config.dispatch(message);
559
+ this.state.lastSteerMessage = message;
560
+ // Only the dispatch that owns the guard may reset the
561
+ // breaker: a stale success must not close a breaker that is
562
+ // legitimately open for the current dispatch.
563
+ if (this._inFlightSeq === seq)
564
+ this._consecutiveFailures = 0;
565
+ }
566
+ catch (err) {
567
+ const msg = err instanceof Error ? err.message : String(err);
568
+ debugLog("TodoContinuation", "Guarded dispatch failed:", msg);
569
+ // Only the dispatch that still owns the guard may disarm it: a
570
+ // stale failure must not clear the guard of a newer dispatch.
571
+ if (this._inFlightSeq === seq) {
572
+ this.state.continuationQueued = false;
573
+ this.cancelGuardTimer();
574
+ // The breaker counters belong to the dispatch that owns the
575
+ // guard: a stale failure must not trip the breaker for the
576
+ // current dispatch.
577
+ this._consecutiveFailures++;
578
+ this._lastFailureAt = Date.now();
579
+ }
580
+ // A failed send is NOT a guard block: report the real reason so
581
+ // the caller can see what actually went wrong.
582
+ const reason = `dispatch failed: ${msg}`;
583
+ if (this._consecutiveFailures >= this._maxConsecutiveFailures) {
584
+ // Circuit breaker: a permanently broken transport must not be
585
+ // retried on every settle with no budget at all.
586
+ return {
587
+ dispatched: false,
588
+ reason: `${reason} (circuit breaker open after ${this._consecutiveFailures} consecutive failures)`,
589
+ blocked: false,
590
+ };
591
+ }
592
+ return { dispatched: false, reason, blocked: false };
593
+ }
594
+ // Queue stays set while the agent processes the continuation.
595
+ // It is released by releaseContinuationGuard() when the turn settles.
596
+ // (The release timer was already armed before the send.)
597
+ return { dispatched: true, blocked: false };
255
598
  }
256
- catch {
257
- // If we can't read todos, skip dispatch
258
- debugLog("TodoContinuation", "Guarded dispatch: failed to read todos, skipping");
259
- this._inFlight = false;
260
- return false;
261
- }
262
- // All checks passed - dispatch
263
- this.state.continuationQueued = true;
264
- this.state.lastSteerMessage = message;
265
- try {
266
- await this.config.dispatch(message);
267
- // Queue stays set while agent processes - cleared on agent_settled
268
- return true;
269
- }
270
- catch (err) {
271
- debugLog("TodoContinuation", "Guarded dispatch failed:", String(err));
272
- this.state.continuationQueued = false;
273
- this._inFlight = false;
274
- return false;
599
+ finally {
600
+ // Is this dispatch still the one that owns the guard?
601
+ const isCurrent = this._inFlightSeq === seq;
602
+ if (watchdog) {
603
+ clearTimeout(watchdog);
604
+ watchdog = null;
605
+ }
606
+ // Only THIS dispatch may release the in-flight guard.
607
+ if (isCurrent) {
608
+ this._inFlightSeq = null;
609
+ // A clearQueue() during the dispatch cancels it: the guard must not
610
+ // be re-armed for a continuation nobody is waiting for.
611
+ if (this._cancelRequested) {
612
+ this._cancelRequested = false;
613
+ this.state.continuationQueued = false;
614
+ this.cancelGuardTimer();
615
+ }
616
+ // A guard release that arrived while the dispatch was on the wire is
617
+ // DROPPED: such a release belongs to the turn that was already
618
+ // running, not to the dispatch that is settling now. Applying it
619
+ // would disarm the guard of the dispatch that just completed.
620
+ // The next settle releases the guard instead.
621
+ if (this._pendingGuardRelease !== null) {
622
+ debugLog("TodoContinuation", "Continuation guard release dropped (arrived mid-dispatch):", this._pendingGuardRelease);
623
+ this._lastDroppedGuardRelease = this._pendingGuardRelease;
624
+ this._pendingGuardRelease = null;
625
+ // The guard stays armed (the dispatch DID send), so it must not
626
+ // block the next continuation until the full safety-net
627
+ // timeout. Release it shortly after the dispatch settled.
628
+ this.scheduleGuardRelease(this.guardReleaseAfterDropMs);
629
+ }
630
+ }
275
631
  }
276
632
  }
277
633
  /**
278
634
  * Steer to continue a todo item
635
+ *
636
+ * The attempt budget is only consumed by dispatches that actually reached
637
+ * the agent. Dispatches blocked by a guard are counted separately so a
638
+ * blocked attempt can never exhaust the retry budget.
279
639
  */
280
640
  async steerToContinue(itemId) {
281
- // Increment attempts for this specific item
282
- if (this.state.lastItemContinued !== itemId) {
283
- this.state.steerAttemptsForCurrentItem = 0;
284
- this.state.lastItemContinued = itemId;
285
- }
641
+ // The attempt budget is per item; switching items does not reset it
642
+ this.state.lastItemContinued = itemId;
643
+ this.state.steerAttemptsForCurrentItem = this.attemptsFor(itemId);
286
644
  if (this.state.steerAttemptsForCurrentItem >= this.config.maxSteerAttempts) {
287
645
  debugLog("TodoContinuation", "Max steer attempts reached for item", itemId);
288
- return;
646
+ return {
647
+ dispatched: false,
648
+ reason: `max attempts (${this.config.maxSteerAttempts}) reached for task ${itemId}`,
649
+ attempts: this.state.steerAttemptsForCurrentItem,
650
+ blockedDispatches: this._blockedDispatches,
651
+ };
652
+ }
653
+ if (this._totalAttempts >= this._maxTotalSteerAttempts) {
654
+ return {
655
+ dispatched: false,
656
+ reason: `max total attempts (${this._maxTotalSteerAttempts}) reached`,
657
+ attempts: this.state.steerAttemptsForCurrentItem,
658
+ blockedDispatches: this._blockedDispatches,
659
+ };
289
660
  }
290
- this.state.steerAttemptsForCurrentItem++;
291
- const message = `Continue todo item ID ${itemId}.`;
661
+ const message = this._stalledItemId === itemId
662
+ ? `Continue todo item ID ${itemId}. (retry after a stalled dispatch - a duplicate continuation for this item may still arrive)`
663
+ : `Continue todo item ID ${itemId}.`;
292
664
  debugLog("TodoContinuation", "Attempting steer to continue item", itemId);
293
665
  // Use guarded dispatch to verify all conditions
294
- const sent = await this.guardedDispatch(itemId, message);
295
- if (!sent) {
296
- debugLog("TodoContinuation", "Steer blocked by guard conditions");
666
+ const result = await this.guardedDispatch(itemId, message);
667
+ if (result.dispatched) {
668
+ // Only a dispatch that actually reached the agent consumes an attempt
669
+ this.recordAttempt(itemId);
670
+ if (this._stalledItemId === itemId)
671
+ this._stalledItemId = null;
672
+ return {
673
+ dispatched: true,
674
+ attempts: this.state.steerAttemptsForCurrentItem,
675
+ blockedDispatches: this._blockedDispatches,
676
+ };
297
677
  }
678
+ debugLog("TodoContinuation", "Steer not dispatched:", result.reason);
679
+ return {
680
+ dispatched: false,
681
+ reason: result.reason,
682
+ attempts: this.state.steerAttemptsForCurrentItem,
683
+ blockedDispatches: this._blockedDispatches,
684
+ };
298
685
  }
299
686
  /**
300
687
  * Mark a todo item as complete
@@ -310,12 +697,28 @@ export class TodoContinuationController {
310
697
  pause() {
311
698
  debugLog("TodoContinuation", "User paused");
312
699
  this.state.userPaused = true;
700
+ // A user pause stops automatic continuations until resume() is called.
701
+ this.state.explicitPause = true;
313
702
  this.state.continuationQueued = false;
703
+ this._pendingGuardRelease = null;
704
+ if (this.inFlight)
705
+ this._cancelRequested = true;
706
+ this.cancelGuardTimer();
314
707
  }
315
708
  /**
316
709
  * Reset state for new session
317
710
  */
318
711
  _reset() {
712
+ this.cancelGuardTimer();
713
+ this._blockedDispatches = 0;
714
+ this._consecutiveFailures = 0;
715
+ this._lastFailureAt = 0;
716
+ this._pendingGuardRelease = null;
717
+ this._lastDroppedGuardRelease = null;
718
+ this._stalledItemId = null;
719
+ this._cancelRequested = false;
720
+ this._attemptsByItem.clear();
721
+ this._totalAttempts = 0;
319
722
  this.state = {
320
723
  sessionId: `session-${Date.now()}`,
321
724
  generation: 0,
@@ -331,24 +734,74 @@ export class TodoContinuationController {
331
734
  lastItemContinued: null,
332
735
  lastEvaluationResult: null,
333
736
  };
334
- this._inFlight = false;
737
+ // A fresh session starts with a clean in-flight guard. A dispatch that
738
+ // hung in the previous session is released by its own watchdog, and
739
+ // that watchdog can no longer touch the new session's guard because
740
+ // the sequence numbers differ.
741
+ this._inFlightSeq = null;
335
742
  }
336
743
  /**
337
744
  * Cleanup on shutdown
338
745
  */
339
746
  _cleanup() {
340
747
  debugLog("TodoContinuation", "Cleaning up");
748
+ this.cancelGuardTimer();
341
749
  this.state.continuationQueued = false;
750
+ this._pendingGuardRelease = null;
342
751
  this.state.generation++;
343
752
  }
753
+ /**
754
+ * Public shutdown hook (used by the host on session shutdown).
755
+ *
756
+ * Releases the guard timer and the queue so a stopped session never keeps
757
+ * a timer alive or blocks a later continuation.
758
+ */
759
+ dispose() {
760
+ this._disposed = true;
761
+ this._cleanup();
762
+ // The in-flight guard is released as well: a dispatch that is still on
763
+ // the wire must not keep the session's guard busy. The sequence check
764
+ // in the dispatch's finally block keeps it from clearing the flag of a
765
+ // later dispatch.
766
+ this._inFlightSeq = null;
767
+ // A cancel intent that is left over from a cancelled dispatch must not
768
+ // disarm the guard of a later one.
769
+ this._cancelRequested = false;
770
+ this._stalledItemId = null;
771
+ }
344
772
  /**
345
773
  * Get current state summary
774
+ *
775
+ * The item ids are reported as ids - the full TodoItem is only available
776
+ * from the (asynchronous) provider snapshot, so no partial object is
777
+ * presented as a TodoItem.
346
778
  */
347
779
  getState() {
348
780
  return {
349
- current: null,
350
- next: null,
351
- ready: 0,
781
+ current: this.state.currentItemId != null ? { id: this.state.currentItemId } : null,
782
+ next: this.state.nextItemId != null ? { id: this.state.nextItemId } : null,
783
+ };
784
+ }
785
+ /**
786
+ * Full controller state (for diagnostics and tests)
787
+ */
788
+ getDiagnostics() {
789
+ return {
790
+ continuationQueued: this.state.continuationQueued,
791
+ inFlight: this.inFlight,
792
+ steerAttemptsForCurrentItem: this.state.steerAttemptsForCurrentItem,
793
+ blockedDispatches: this._blockedDispatches,
794
+ consecutiveFailures: this._consecutiveFailures,
795
+ circuitBreakerOpen: this.breakerOpen(),
796
+ totalAttempts: this._totalAttempts,
797
+ lastItemContinued: this.state.lastItemContinued,
798
+ lastSteerMessage: this.state.lastSteerMessage,
799
+ explicitPause: this.state.explicitPause,
800
+ userPaused: this.state.userPaused,
801
+ pendingGuardRelease: this._pendingGuardRelease,
802
+ lastDroppedGuardRelease: this._lastDroppedGuardRelease,
803
+ cancelRequested: this._cancelRequested,
804
+ disposed: this._disposed,
352
805
  };
353
806
  }
354
807
  }