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
@@ -1,10 +1,19 @@
1
- import type { TodoItem, TodoContext } from "./todo-provider.js";
1
+ import type { TodoItem, TodoContext, TodoProvider } from "./todo-provider.js";
2
2
  import { createTodoProvider } from "./todo-provider.js";
3
3
 
4
4
  /**
5
5
  * Simple debug logger that writes to file, not stdout
6
6
  */
7
7
  const DEBUG_LOG_FILE = "/tmp/pi-harness-debug.log";
8
+
9
+ /**
10
+ * Watchdog for a dispatch that never settles.
11
+ *
12
+ * A hanging dispatch (a dead transport, a promise that is never resolved) must
13
+ * not block every later continuation: after this timeout the in-flight guard
14
+ * is released so the next decision can try again.
15
+ */
16
+ const INFLIGHT_TIMEOUT_MS = 60_000;
8
17
  function debugLog(component: string, ...args: unknown[]): void {
9
18
  try {
10
19
  const timestamp = new Date().toISOString();
@@ -20,6 +29,14 @@ function debugLog(component: string, ...args: unknown[]): void {
20
29
  /**
21
30
  * Continuation configuration
22
31
  */
32
+ /**
33
+ * When a guard release arrives while a dispatch is on the wire, the release is
34
+ * dropped (it belongs to the turn that was already running). The guard then
35
+ * stays armed until the safety-net timeout - which would stall the
36
+ * auto-continue for minutes. It is released shortly after instead.
37
+ */
38
+ const GUARD_RELEASE_AFTER_DROP_MS = 1000;
39
+
23
40
  export interface ContinuationConfig {
24
41
  /** Dispatch function to send messages to the agent (required) */
25
42
  dispatch: (content: string) => Promise<void>;
@@ -31,6 +48,46 @@ export interface ContinuationConfig {
31
48
  maxSteerAttempts?: number;
32
49
  /** Enable Jev for ambiguous cases */
33
50
  enableJev?: boolean;
51
+ /** Injectable todo provider (defaults to the pi-backed provider) */
52
+ todoProvider?: TodoProvider;
53
+ /**
54
+ * How long a dispatched continuation may stay "queued" before the guard is
55
+ * released automatically. Guards are normally released when the agent turn
56
+ * settles; this is the safety net for a lost settle event.
57
+ */
58
+ guardReleaseTimeoutMs?: number;
59
+ /**
60
+ * How long the guard stays armed after a guard release was DROPPED
61
+ * (it arrived while a dispatch was on the wire). Without this the guard
62
+ * would stay armed for the full `guardReleaseTimeoutMs` safety net and
63
+ * the auto-continue would stall for minutes.
64
+ */
65
+ guardReleaseAfterDropMs?: number;
66
+ /**
67
+ * Max successful dispatches per session across all items.
68
+ *
69
+ * The per-item budget (`maxSteerAttempts`) is reset when the work item
70
+ * changes, so alternating between two items would never exhaust it. This
71
+ * session-wide cap bounds such a run.
72
+ */
73
+ maxTotalSteerAttempts?: number;
74
+ /**
75
+ * How long the circuit breaker stays open after repeated send failures.
76
+ *
77
+ * After the cooldown a single trial dispatch is allowed again.
78
+ */
79
+ circuitBreakerCooldownMs?: number;
80
+ /**
81
+ * How many consecutive send failures open the circuit breaker.
82
+ */
83
+ maxConsecutiveFailures?: number;
84
+ /**
85
+ * How long a dispatch may stay on the wire before the in-flight guard is
86
+ * force-released.
87
+ *
88
+ * A dispatch that never settles must not block every later continuation.
89
+ */
90
+ inFlightTimeoutMs?: number;
34
91
  }
35
92
 
36
93
  /**
@@ -52,6 +109,37 @@ export interface ContinuationState {
52
109
  lastEvaluationResult: string | null;
53
110
  }
54
111
 
112
+ /**
113
+ * Outcome of a steer attempt
114
+ */
115
+ export interface SteerResult {
116
+ /** True when the continuation message was actually handed to the agent */
117
+ dispatched: boolean;
118
+ /** Why the dispatch was skipped (only set when dispatched is false) */
119
+ reason?: string;
120
+ /** Attempt counter for the item (successful dispatches only) */
121
+ attempts: number;
122
+ /** Number of dispatches that were blocked by a guard */
123
+ blockedDispatches: number;
124
+ }
125
+
126
+ /**
127
+ * Result of a guarded dispatch.
128
+ *
129
+ * `blocked` distinguishes the two reasons a dispatch does not happen:
130
+ * a guard skipped it (session busy, paused, duplicate, item completed) or
131
+ * the send itself failed. A blocked dispatch is counted separately and never
132
+ * consumes the attempt budget.
133
+ */
134
+ export interface GuardedDispatchResult {
135
+ /** True when the continuation message was actually handed to the agent */
136
+ dispatched: boolean;
137
+ /** Why the dispatch did not happen (only set when dispatched is false) */
138
+ reason?: string;
139
+ /** True when a guard skipped the dispatch, false when the send itself failed */
140
+ blocked: boolean;
141
+ }
142
+
55
143
  /**
56
144
  * Continuation result
57
145
  */
@@ -70,7 +158,15 @@ export interface ContinuationResult {
70
158
  export class TodoContinuationController {
71
159
  private ctx: TodoContext;
72
160
  private config: Required<ContinuationConfig>;
73
- private todoProvider: ReturnType<typeof createTodoProvider>;
161
+ private todoProvider: TodoProvider;
162
+
163
+ /**
164
+ * How long a dispatched continuation may stay queued before the guard is
165
+ * released automatically (safety net for a lost agent_settled event).
166
+ */
167
+ private guardReleaseTimeoutMs: number;
168
+ /** Delay before the guard is released after a dropped mid-dispatch release */
169
+ private guardReleaseAfterDropMs: number;
74
170
 
75
171
  private state: ContinuationState = {
76
172
  sessionId: "",
@@ -88,11 +184,37 @@ export class TodoContinuationController {
88
184
  lastEvaluationResult: null,
89
185
  };
90
186
 
91
- private _inFlight = false; // Guard against concurrent dispatches
187
+ private _inFlightSeq: number | null = null; // Sequence number of the active dispatch (null = idle)
188
+ private _dispatchSeq = 0; // Monotonic counter for every dispatch attempt
189
+ private _guardTimer: ReturnType<typeof setTimeout> | null = null;
190
+ private _blockedDispatches = 0; // Dispatches skipped by a guard (do NOT consume the attempt budget)
191
+ private _pendingGuardRelease: string | null = null; // Guard release requested while a dispatch was in flight
192
+ private _cancelRequested = false; // clearQueue()/pause() called while a dispatch was in flight
193
+ private _lastDroppedGuardRelease: string | null = null; // Reason of the last dropped mid-dispatch release
194
+ private _stalledItemId: number | null = null; // Item of a dispatch that was force-released by the watchdog
195
+ private _consecutiveFailures = 0; // Failed sends in a row (circuit breaker)
196
+ private _maxConsecutiveFailures = 3; // Failed sends tolerated before the breaker opens
197
+ private _lastFailureAt = 0; // Timestamp of the last failed send (circuit breaker cooldown)
198
+ private _circuitBreakerCooldownMs = 5 * 60 * 1000; // How long the breaker stays open
199
+ private _inFlightTimeoutMs = INFLIGHT_TIMEOUT_MS; // Watchdog for a hanging dispatch
200
+ private _attemptsByItem = new Map<number, number>(); // Successful dispatches per item
201
+ private _totalAttempts = 0; // Successful dispatches in this session (runaway guard)
202
+ private _maxTotalSteerAttempts = 9; // Session-wide cap on successful dispatches
203
+ private _disposed = false; // Set on shutdown: no dispatch and no timer may act anymore
204
+
205
+ /** True while a dispatch is on the wire */
206
+ private get inFlight(): boolean {
207
+ return this._inFlightSeq !== null;
208
+ }
92
209
 
93
210
  constructor(ctx: TodoContext, config?: ContinuationConfig) {
94
211
  this.ctx = ctx;
95
- this.todoProvider = createTodoProvider(ctx);
212
+ this.todoProvider = config?.todoProvider ?? createTodoProvider(ctx);
213
+ this.guardReleaseTimeoutMs = config?.guardReleaseTimeoutMs ?? 5 * 60 * 1000;
214
+ this.guardReleaseAfterDropMs = config?.guardReleaseAfterDropMs ?? GUARD_RELEASE_AFTER_DROP_MS;
215
+ this._circuitBreakerCooldownMs = config?.circuitBreakerCooldownMs ?? 5 * 60 * 1000;
216
+ this._maxConsecutiveFailures = config?.maxConsecutiveFailures ?? 3;
217
+ this._inFlightTimeoutMs = config?.inFlightTimeoutMs ?? INFLIGHT_TIMEOUT_MS;
96
218
  this.config = {
97
219
  jevFallbackMinWaitMs: 5 * 60 * 1000, // 5 minutes
98
220
  routineThreshold: 0.7,
@@ -100,17 +222,50 @@ export class TodoContinuationController {
100
222
  enableJev: true,
101
223
  ...config,
102
224
  } as Required<ContinuationConfig>;
225
+ this._maxTotalSteerAttempts =
226
+ config?.maxTotalSteerAttempts ?? this.config.maxSteerAttempts * 3;
227
+ }
228
+
229
+ /**
230
+ * Successful dispatches recorded for an item.
231
+ *
232
+ * The budget is per item: switching to a different item starts a fresh
233
+ * budget for that item, but the session-wide cap (`maxTotalSteerAttempts`)
234
+ * still bounds an alternating run.
235
+ */
236
+ private attemptsFor(itemId: number): number {
237
+ return this._attemptsByItem.get(itemId) ?? 0;
238
+ }
239
+
240
+ /**
241
+ * True while the circuit breaker forbids a send.
242
+ *
243
+ * After the cooldown a single trial dispatch is allowed again, so a
244
+ * transient transport outage does not stop auto-continue forever.
245
+ */
246
+ private breakerOpen(): boolean {
247
+ if (this._consecutiveFailures < this._maxConsecutiveFailures) return false;
248
+ return Date.now() - this._lastFailureAt < this._circuitBreakerCooldownMs;
103
249
  }
104
250
 
105
251
  /**
106
252
  * Check if continuation is allowed (not paused, session idle)
107
253
  */
108
254
  private canContinue(): boolean {
109
- // Only blocked by explicit pause, not by temporary user input pause
255
+ // Only an explicit pause blocks the continuation. A temporary pause
256
+ // (user input) is honoured as well - it is cleared by the next
257
+ // decision that is not racing with a running dispatch.
110
258
  if (this.state.explicitPause) {
111
259
  debugLog("TodoContinuation", "Explicitly paused, not continuing");
112
260
  return false;
113
261
  }
262
+ if (this.state.userPaused) {
263
+ // NOTE: the temporary pause is auto-cleared by the next decision
264
+ // (see decide()), so this branch only triggers while a dispatch is
265
+ // in flight and the pause was raised after that decision.
266
+ debugLog("TodoContinuation", "User input pending, not continuing");
267
+ return false;
268
+ }
114
269
  if (this.ctx.isIdle?.() === false) {
115
270
  debugLog("TodoContinuation", "Session not idle, not continuing");
116
271
  return false;
@@ -129,6 +284,12 @@ export class TodoContinuationController {
129
284
  debugLog("TodoContinuation", "Explicit pause requested");
130
285
  this.state.explicitPause = true;
131
286
  this.state.userPaused = true;
287
+ // Disarm the guard completely: keeping continuationQueued set while the
288
+ // release timer is cancelled would block every later dispatch forever.
289
+ this.state.continuationQueued = false;
290
+ this._pendingGuardRelease = null;
291
+ if (this.inFlight) this._cancelRequested = true;
292
+ this.cancelGuardTimer();
132
293
  }
133
294
 
134
295
  /**
@@ -141,6 +302,10 @@ export class TodoContinuationController {
141
302
  this.state.userPaused = false;
142
303
  this.clearQueue();
143
304
  }
305
+ // A resumed session gets a fresh transport budget: an open circuit
306
+ // breaker must not stay open forever.
307
+ this._consecutiveFailures = 0;
308
+ this._lastFailureAt = 0;
144
309
  }
145
310
 
146
311
  /**
@@ -157,6 +322,12 @@ export class TodoContinuationController {
157
322
  tempPause(): void {
158
323
  debugLog("TodoContinuation", "Temporary pause for user input");
159
324
  this.state.userPaused = true;
325
+ // Same as explicitPause(): the guard must be fully disarmed, otherwise
326
+ // the cancelled release timer leaves the queue flag stuck at true.
327
+ this.state.continuationQueued = false;
328
+ this._pendingGuardRelease = null;
329
+ if (this.inFlight) this._cancelRequested = true;
330
+ this.cancelGuardTimer();
160
331
  }
161
332
 
162
333
  /**
@@ -164,16 +335,85 @@ export class TodoContinuationController {
164
335
  */
165
336
  clearQueue(): void {
166
337
  this.state.continuationQueued = false;
167
- this._inFlight = false;
338
+ // NOTE: _inFlight is NOT cleared here. It belongs to the dispatch
339
+ // lifecycle and is released in the dispatch's finally block. Clearing it
340
+ // here would re-open the concurrent-dispatch window while a dispatch
341
+ // send is still on the wire.
342
+ // A dispatch that is still on the wire must not re-arm the guard when
343
+ // it completes. The cancel intent is only meaningful for a dispatch
344
+ // that is actually running - otherwise it would disarm the guard of a
345
+ // LATER dispatch.
346
+ if (this.inFlight) this._cancelRequested = true;
347
+ this.cancelGuardTimer();
168
348
  debugLog("TodoContinuation", "Continuation queue cleared");
169
349
  }
170
350
 
351
+ /**
352
+ * Release the dispatch guard after a verified lifecycle transition.
353
+ *
354
+ * The guard is set when a continuation is handed to the agent and must be
355
+ * released once that turn has settled - otherwise every later dispatch is
356
+ * blocked while the attempt budget keeps being consumed.
357
+ */
358
+ releaseContinuationGuard(reason = "agent settled"): void {
359
+ if (this.inFlight) {
360
+ // A dispatch is still on the wire. Releasing the guard now would
361
+ // disarm the queue flag the dispatch just set and allow a duplicate
362
+ // continuation. Defer the release until the dispatch settles.
363
+ this._pendingGuardRelease = reason;
364
+ debugLog("TodoContinuation", "Continuation guard release deferred:", reason);
365
+ return;
366
+ }
367
+ const wasQueued = this.state.continuationQueued;
368
+ this.state.continuationQueued = false;
369
+ this.cancelGuardTimer();
370
+ // NOTE: the in-flight flag belongs to the dispatch lifecycle. Clearing
371
+ // it here would allow a duplicate continuation while a dispatch is
372
+ // still on the wire.
373
+ if (wasQueued) {
374
+ debugLog("TodoContinuation", "Continuation guard released:", reason);
375
+ }
376
+ }
377
+
378
+ /** Cancel the automatic guard-release timer */
379
+ private cancelGuardTimer(): void {
380
+ if (this._guardTimer) {
381
+ clearTimeout(this._guardTimer);
382
+ this._guardTimer = null;
383
+ }
384
+ }
385
+
386
+ /** Schedule an automatic guard release (safety net for a lost settle event) */
387
+ private scheduleGuardRelease(delayMs: number = this.guardReleaseTimeoutMs): void {
388
+ this.cancelGuardTimer();
389
+ this._guardTimer = setTimeout(() => {
390
+ this._guardTimer = null;
391
+ debugLog("TodoContinuation", "Guard auto-released after timeout");
392
+ this.state.continuationQueued = false;
393
+ }, delayMs);
394
+ // Do not keep the process alive just for the guard timer
395
+ this._guardTimer.unref?.();
396
+ }
397
+
171
398
  /**
172
399
  * Public method to evaluate and execute continuation
173
400
  * Called by external handlers (e.g., agent_settled)
174
401
  * Checks blockers and pause BEFORE selecting a task
175
402
  */
176
403
  async decide(): Promise<ContinuationResult> {
404
+ // A temporary pause (user input) is auto-cleared by the next decision:
405
+ // the decision itself is the "next decision" the comment refers to.
406
+ // A decision that runs WHILE a dispatch is in flight must not clear
407
+ // the pause: the pending dispatch would then send a message the user
408
+ // asked to hold back.
409
+ if (!this.inFlight) this.state.userPaused = false;
410
+
411
+ // The ids describe the CURRENT decision: they are set on the continue
412
+ // paths and cleared here so a stop/blocked decision does not keep
413
+ // reporting the last continued item.
414
+ this.state.currentItemId = null;
415
+ this.state.nextItemId = null;
416
+
177
417
  // 1. Check if continuation is allowed
178
418
  if (!this.canContinue()) {
179
419
  return { action: "stop", reason: "paused or session not idle" };
@@ -216,17 +456,22 @@ export class TodoContinuationController {
216
456
  if (unblockedInProgress.length > 0) {
217
457
  const item = unblockedInProgress[0];
218
458
 
219
- // Reset counter if switching to a different task
220
- if (this.state.lastItemContinued !== item.id) {
221
- this.state.steerAttemptsForCurrentItem = 0;
222
- this.state.lastItemContinued = item.id;
223
- }
459
+ // The attempt budget is per item; switching items does not reset it
460
+ this.state.lastItemContinued = item.id;
461
+ this.state.steerAttemptsForCurrentItem = this.attemptsFor(item.id);
224
462
 
225
463
  // Check per-task attempt limit
226
464
  if (this.state.steerAttemptsForCurrentItem >= this.config.maxSteerAttempts) {
227
465
  return { action: "stop", reason: `max attempts (${this.config.maxSteerAttempts}) reached for task ${item.id}` };
228
466
  }
467
+ // Session-wide cap: alternating between two items would never
468
+ // exhaust the per-item budget.
469
+ if (this._totalAttempts >= this._maxTotalSteerAttempts) {
470
+ return { action: "stop", reason: `max total attempts (${this._maxTotalSteerAttempts}) reached` };
471
+ }
229
472
 
473
+ this.state.currentItemId = item.id;
474
+ this.state.nextItemId = item.id;
230
475
  return { action: "continue", nextItemId: item.id };
231
476
  }
232
477
 
@@ -234,17 +479,22 @@ export class TodoContinuationController {
234
479
  if (ready.length > 0) {
235
480
  const item = ready[0];
236
481
 
237
- // Reset counter if switching to a different task
238
- if (this.state.lastItemContinued !== item.id) {
239
- this.state.steerAttemptsForCurrentItem = 0;
240
- this.state.lastItemContinued = item.id;
241
- }
482
+ // The attempt budget is per item; switching items does not reset it
483
+ this.state.lastItemContinued = item.id;
484
+ this.state.steerAttemptsForCurrentItem = this.attemptsFor(item.id);
242
485
 
243
486
  // Check per-task attempt limit
244
487
  if (this.state.steerAttemptsForCurrentItem >= this.config.maxSteerAttempts) {
245
488
  return { action: "stop", reason: `max attempts (${this.config.maxSteerAttempts}) reached for task ${item.id}` };
246
489
  }
490
+ // Session-wide cap: alternating between two items would never
491
+ // exhaust the per-item budget.
492
+ if (this._totalAttempts >= this._maxTotalSteerAttempts) {
493
+ return { action: "stop", reason: `max total attempts (${this._maxTotalSteerAttempts}) reached` };
494
+ }
247
495
 
496
+ this.state.currentItemId = item.id;
497
+ this.state.nextItemId = item.id;
248
498
  return { action: "continue", nextItemId: item.id };
249
499
  }
250
500
 
@@ -260,126 +510,346 @@ export class TodoContinuationController {
260
510
 
261
511
  /**
262
512
  * Continue a specific todo item
513
+ *
514
+ * Routed through the guarded dispatch so the in-flight guard, the
515
+ * duplicate-queue guard and the guard-release timer behave exactly like
516
+ * they do for an automatic continuation. A blocked or failed dispatch
517
+ * never consumes the attempt budget.
263
518
  */
264
519
  async continueItem(item: TodoItem): Promise<void> {
265
- // Increment attempts for this specific item
266
- if (this.state.lastItemContinued !== item.id) {
267
- this.state.steerAttemptsForCurrentItem = 0;
268
- this.state.lastItemContinued = item.id;
269
- }
520
+ // The attempt budget is per item; switching items does not reset it
521
+ this.state.lastItemContinued = item.id;
522
+ this.state.steerAttemptsForCurrentItem = this.attemptsFor(item.id);
270
523
 
271
524
  if (this.state.steerAttemptsForCurrentItem >= this.config.maxSteerAttempts) {
272
525
  debugLog("TodoContinuation", "Max steer attempts reached for item", item.id);
273
526
  return;
274
527
  }
275
-
276
- this.state.steerAttemptsForCurrentItem++;
277
- this.state.continuationQueued = true;
528
+ if (this._totalAttempts >= this._maxTotalSteerAttempts) {
529
+ debugLog("TodoContinuation", "Max total steer attempts reached");
530
+ return;
531
+ }
278
532
 
279
533
  // Build continuation message
280
534
  const message = item.description
281
535
  ? `Continue task: ${item.subject}\n\n${item.description}`
282
536
  : `Continue task: ${item.subject}`;
537
+ // A retry after a stalled dispatch gets the same duplicate warning as
538
+ // the automatic continuation path.
539
+ const finalMessage =
540
+ this._stalledItemId === item.id
541
+ ? `${message} (retry after a stalled dispatch - a duplicate continuation for this item may still arrive)`
542
+ : message;
283
543
 
284
- this.state.lastSteerMessage = message;
285
544
  debugLog("TodoContinuation", "Sending continuation:", item.subject);
286
545
 
287
- // Send continuation message via dispatch callback
288
- try {
289
- await this.config.dispatch(message);
290
- } catch (err) {
291
- debugLog("TodoContinuation", "Failed to send continuation:", String(err));
292
- this.state.continuationQueued = false;
546
+ const result = await this.guardedDispatch(item.id, finalMessage);
547
+ if (result.dispatched) {
548
+ // Only a dispatch that actually reached the agent consumes an attempt
549
+ this.recordAttempt(item.id);
550
+ if (this._stalledItemId === item.id) this._stalledItemId = null;
551
+ } else {
552
+ debugLog("TodoContinuation", "Continuation not dispatched:", result.reason);
293
553
  }
294
554
  }
295
555
 
556
+ /**
557
+ * Record a successful dispatch against the per-item and session budgets.
558
+ */
559
+ private recordAttempt(itemId: number): void {
560
+ const attempts = this.attemptsFor(itemId) + 1;
561
+ this._attemptsByItem.set(itemId, attempts);
562
+ this._totalAttempts++;
563
+ this.state.steerAttemptsForCurrentItem = attempts;
564
+ }
565
+
296
566
  /**
297
567
  * Guarded dispatch - checks all conditions before sending
298
568
  * Uses in-flight guard to prevent race conditions
299
569
  */
300
- private async guardedDispatch(itemId: number, message: string): Promise<boolean> {
301
- // 0. Acquire in-flight guard to prevent concurrent dispatches
302
- if (this._inFlight) {
570
+ private async guardedDispatch(itemId: number, message: string): Promise<GuardedDispatchResult> {
571
+ // 0. A disposed controller never dispatches.
572
+ if (this._disposed) {
573
+ debugLog("TodoContinuation", "Guarded dispatch: controller disposed, skipping");
574
+ this._blockedDispatches++;
575
+ return { dispatched: false, reason: "controller disposed", blocked: true };
576
+ }
577
+
578
+ // 1. Acquire in-flight guard to prevent concurrent dispatches
579
+ if (this.inFlight) {
303
580
  debugLog("TodoContinuation", "Guarded dispatch: already in-flight, skipping");
304
- return false;
581
+ this._blockedDispatches++;
582
+ return { dispatched: false, reason: "dispatch already in flight", blocked: true };
305
583
  }
306
584
 
307
- // 1. Check if paused by explicit user pause
585
+ // 2. Check if paused by explicit user pause
308
586
  if (this.state.explicitPause) {
309
587
  debugLog("TodoContinuation", "Guarded dispatch: explicitly paused, skipping");
310
- return false;
588
+ this._blockedDispatches++;
589
+ return { dispatched: false, reason: "explicitly paused", blocked: true };
590
+ }
591
+
592
+ // 3. A user message that is still being processed pauses the
593
+ // continuation. tempPause() is auto-cleared on the next decision.
594
+ if (this.state.userPaused) {
595
+ debugLog("TodoContinuation", "Guarded dispatch: user input pending, skipping");
596
+ this._blockedDispatches++;
597
+ return { dispatched: false, reason: "user input pending", blocked: true };
311
598
  }
312
599
 
313
- // 2. Check if session is still idle
600
+ // 4. The circuit breaker: a permanently broken transport must not be
601
+ // retried on every settle. After the cooldown a trial is allowed.
602
+ if (this.breakerOpen()) {
603
+ debugLog("TodoContinuation", "Guarded dispatch: circuit breaker open, skipping");
604
+ this._blockedDispatches++;
605
+ return {
606
+ dispatched: false,
607
+ reason: `circuit breaker open after ${this._consecutiveFailures} consecutive failures`,
608
+ blocked: true,
609
+ };
610
+ }
611
+
612
+ // 5. Check if session is still idle
314
613
  if (this.ctx.isIdle?.() === false) {
315
614
  debugLog("TodoContinuation", "Guarded dispatch: session not idle, skipping");
316
- return false;
615
+ this._blockedDispatches++;
616
+ return { dispatched: false, reason: "session not idle", blocked: true };
317
617
  }
318
618
 
319
- // 3. Check if continuation already queued (prevent duplicates)
619
+ // 6. Check if continuation already queued (prevent duplicates)
320
620
  if (this.state.continuationQueued) {
321
621
  debugLog("TodoContinuation", "Guarded dispatch: already queued, skipping");
322
- return false;
622
+ this._blockedDispatches++;
623
+ return { dispatched: false, reason: "continuation already queued", blocked: true };
323
624
  }
324
625
 
325
- // 4. Verify item still needs work (not completed) - acquire guard BEFORE await
326
- this._inFlight = true;
626
+ // The in-flight guard is held for the WHOLE dispatch (including the
627
+ // send): it must only be released once the dispatch has settled.
628
+ // The sequence number identifies THIS dispatch: a stale dispatch (one
629
+ // whose watchdog already fired) must never clear the flag of a newer
630
+ // dispatch and must never send after it was superseded.
631
+ const seq = ++this._dispatchSeq;
632
+ this._inFlightSeq = seq;
633
+ let watchdog: ReturnType<typeof setTimeout> | null = null;
634
+
635
+ // The watchdog is armed IMMEDIATELY: a hanging todo read must not keep
636
+ // the guard forever. The timer belongs to THIS dispatch only.
637
+ watchdog = setTimeout(() => {
638
+ if (this._inFlightSeq !== seq) return; // A newer dispatch owns the guard
639
+ debugLog(
640
+ "TodoContinuation",
641
+ `In-flight dispatch still pending after ${this._inFlightTimeoutMs}ms - forcing release`,
642
+ );
643
+ this._inFlightSeq = null;
644
+ // A hung dispatch is treated like a failed one: the guard is
645
+ // released so the next decision can try again.
646
+ // NOTE: the flag is read BEFORE it is cleared: it tells us whether
647
+ // the send had already started (a message may still land).
648
+ const hadStartedSending = this.state.continuationQueued;
649
+ this.state.continuationQueued = false;
650
+ this.cancelGuardTimer();
651
+ // A cancel intent that was raised while this dispatch hung belongs to
652
+ // THIS dispatch. It must not survive: otherwise it would cancel the
653
+ // next legitimate dispatch and silently lose a continuation cycle.
654
+ this._cancelRequested = false;
655
+ // Remember the item: a later dispatch for the same item is a RETRY
656
+ // of a message that may still land. The message says so.
657
+ // Only when the send had already started - a hang in the todo read
658
+ // never put a message on the wire, so there is no duplicate to warn
659
+ // about.
660
+ if (hadStartedSending) this._stalledItemId = itemId;
661
+ }, this._inFlightTimeoutMs);
662
+ if (typeof watchdog.unref === "function") watchdog.unref();
327
663
  try {
328
- const snapshot = await this.todoProvider.getSnapshot();
329
- const item = snapshot.items.find((i) => i.id === itemId);
664
+ // 7. Verify item still needs work (not completed)
665
+ let item: TodoItem | undefined;
666
+ try {
667
+ const snapshot = await this.todoProvider.getSnapshot();
668
+ item = snapshot.items.find((i) => i.id === itemId);
669
+ } catch {
670
+ // If we can't read todos, skip dispatch
671
+ debugLog("TodoContinuation", "Guarded dispatch: failed to read todos, skipping");
672
+ this._blockedDispatches++;
673
+ return { dispatched: false, reason: "todo read failed", blocked: true };
674
+ }
330
675
  if (item?.status === "completed") {
331
676
  debugLog("TodoContinuation", "Guarded dispatch: item completed, skipping");
332
- this._inFlight = false;
333
- return false;
677
+ this._blockedDispatches++;
678
+ return { dispatched: false, reason: "item already completed", blocked: true };
334
679
  }
335
- } catch {
336
- // If we can't read todos, skip dispatch
337
- debugLog("TodoContinuation", "Guarded dispatch: failed to read todos, skipping");
338
- this._inFlight = false;
339
- return false;
340
- }
341
680
 
342
- // All checks passed - dispatch
343
- this.state.continuationQueued = true;
344
- this.state.lastSteerMessage = message;
681
+ // 8. The snapshot read above is asynchronous: the queue may have
682
+ // been cleared, a pause requested or the dispatch superseded in
683
+ // the meantime. In that case the message must NOT be sent.
684
+ //
685
+ // The superseded check comes FIRST: a stale dispatch must not
686
+ // consume the cancel intent of the dispatch that replaced it.
687
+ if (this._inFlightSeq !== seq) {
688
+ // The watchdog already released this dispatch and a newer one
689
+ // took over. Sending now would put two messages on the wire.
690
+ debugLog("TodoContinuation", "Guarded dispatch: superseded, skipping");
691
+ this._blockedDispatches++;
692
+ return { dispatched: false, reason: "dispatch superseded", blocked: true };
693
+ }
694
+ if (this.state.explicitPause || this.state.userPaused) {
695
+ // The pause is more specific than the generic cancel flag: a
696
+ // pause that arrived during the snapshot read is reported as a
697
+ // pause. The cancel intent is consumed here as well so a later
698
+ // dispatch is not cancelled by a stale flag.
699
+ this._cancelRequested = false;
700
+ debugLog("TodoContinuation", "Guarded dispatch: paused before send, skipping");
701
+ this._blockedDispatches++;
702
+ return { dispatched: false, reason: "paused before send", blocked: true };
703
+ }
704
+ if (this._cancelRequested) {
705
+ this._cancelRequested = false;
706
+ debugLog("TodoContinuation", "Guarded dispatch: cancelled before send, skipping");
707
+ this._blockedDispatches++;
708
+ return { dispatched: false, reason: "cancelled before send", blocked: true };
709
+ }
345
710
 
346
- try {
347
- await this.config.dispatch(message);
348
- // Queue stays set while agent processes - cleared on agent_settled
349
- return true;
350
- } catch (err) {
351
- debugLog("TodoContinuation", "Guarded dispatch failed:", String(err));
352
- this.state.continuationQueued = false;
353
- this._inFlight = false;
354
- return false;
711
+ // All checks passed - dispatch
712
+ this.state.continuationQueued = true;
713
+
714
+ // Arm the guard-release timer BEFORE the send: a dispatch that hangs
715
+ // must not leave the guard armed forever.
716
+ this.scheduleGuardRelease();
717
+
718
+ try {
719
+ await this.config.dispatch(message);
720
+ this.state.lastSteerMessage = message;
721
+ // Only the dispatch that owns the guard may reset the
722
+ // breaker: a stale success must not close a breaker that is
723
+ // legitimately open for the current dispatch.
724
+ if (this._inFlightSeq === seq) this._consecutiveFailures = 0;
725
+ } catch (err) {
726
+ const msg = err instanceof Error ? err.message : String(err);
727
+ debugLog("TodoContinuation", "Guarded dispatch failed:", msg);
728
+ // Only the dispatch that still owns the guard may disarm it: a
729
+ // stale failure must not clear the guard of a newer dispatch.
730
+ if (this._inFlightSeq === seq) {
731
+ this.state.continuationQueued = false;
732
+ this.cancelGuardTimer();
733
+ // The breaker counters belong to the dispatch that owns the
734
+ // guard: a stale failure must not trip the breaker for the
735
+ // current dispatch.
736
+ this._consecutiveFailures++;
737
+ this._lastFailureAt = Date.now();
738
+ }
739
+ // A failed send is NOT a guard block: report the real reason so
740
+ // the caller can see what actually went wrong.
741
+ const reason = `dispatch failed: ${msg}`;
742
+ if (this._consecutiveFailures >= this._maxConsecutiveFailures) {
743
+ // Circuit breaker: a permanently broken transport must not be
744
+ // retried on every settle with no budget at all.
745
+ return {
746
+ dispatched: false,
747
+ reason: `${reason} (circuit breaker open after ${this._consecutiveFailures} consecutive failures)`,
748
+ blocked: false,
749
+ };
750
+ }
751
+ return { dispatched: false, reason, blocked: false };
752
+ }
753
+
754
+ // Queue stays set while the agent processes the continuation.
755
+ // It is released by releaseContinuationGuard() when the turn settles.
756
+ // (The release timer was already armed before the send.)
757
+ return { dispatched: true, blocked: false };
758
+ } finally {
759
+ // Is this dispatch still the one that owns the guard?
760
+ const isCurrent = this._inFlightSeq === seq;
761
+ if (watchdog) {
762
+ clearTimeout(watchdog);
763
+ watchdog = null;
764
+ }
765
+ // Only THIS dispatch may release the in-flight guard.
766
+ if (isCurrent) {
767
+ this._inFlightSeq = null;
768
+ // A clearQueue() during the dispatch cancels it: the guard must not
769
+ // be re-armed for a continuation nobody is waiting for.
770
+ if (this._cancelRequested) {
771
+ this._cancelRequested = false;
772
+ this.state.continuationQueued = false;
773
+ this.cancelGuardTimer();
774
+ }
775
+ // A guard release that arrived while the dispatch was on the wire is
776
+ // DROPPED: such a release belongs to the turn that was already
777
+ // running, not to the dispatch that is settling now. Applying it
778
+ // would disarm the guard of the dispatch that just completed.
779
+ // The next settle releases the guard instead.
780
+ if (this._pendingGuardRelease !== null) {
781
+ debugLog(
782
+ "TodoContinuation",
783
+ "Continuation guard release dropped (arrived mid-dispatch):",
784
+ this._pendingGuardRelease,
785
+ );
786
+ this._lastDroppedGuardRelease = this._pendingGuardRelease;
787
+ this._pendingGuardRelease = null;
788
+ // The guard stays armed (the dispatch DID send), so it must not
789
+ // block the next continuation until the full safety-net
790
+ // timeout. Release it shortly after the dispatch settled.
791
+ this.scheduleGuardRelease(this.guardReleaseAfterDropMs);
792
+ }
793
+ }
355
794
  }
356
795
  }
357
796
 
358
797
  /**
359
798
  * Steer to continue a todo item
799
+ *
800
+ * The attempt budget is only consumed by dispatches that actually reached
801
+ * the agent. Dispatches blocked by a guard are counted separately so a
802
+ * blocked attempt can never exhaust the retry budget.
360
803
  */
361
- async steerToContinue(itemId: number): Promise<void> {
362
- // Increment attempts for this specific item
363
- if (this.state.lastItemContinued !== itemId) {
364
- this.state.steerAttemptsForCurrentItem = 0;
365
- this.state.lastItemContinued = itemId;
366
- }
804
+ async steerToContinue(itemId: number): Promise<SteerResult> {
805
+ // The attempt budget is per item; switching items does not reset it
806
+ this.state.lastItemContinued = itemId;
807
+ this.state.steerAttemptsForCurrentItem = this.attemptsFor(itemId);
367
808
 
368
809
  if (this.state.steerAttemptsForCurrentItem >= this.config.maxSteerAttempts) {
369
810
  debugLog("TodoContinuation", "Max steer attempts reached for item", itemId);
370
- return;
811
+ return {
812
+ dispatched: false,
813
+ reason: `max attempts (${this.config.maxSteerAttempts}) reached for task ${itemId}`,
814
+ attempts: this.state.steerAttemptsForCurrentItem,
815
+ blockedDispatches: this._blockedDispatches,
816
+ };
817
+ }
818
+ if (this._totalAttempts >= this._maxTotalSteerAttempts) {
819
+ return {
820
+ dispatched: false,
821
+ reason: `max total attempts (${this._maxTotalSteerAttempts}) reached`,
822
+ attempts: this.state.steerAttemptsForCurrentItem,
823
+ blockedDispatches: this._blockedDispatches,
824
+ };
371
825
  }
372
826
 
373
- this.state.steerAttemptsForCurrentItem++;
374
-
375
- const message = `Continue todo item ID ${itemId}.`;
827
+ const message =
828
+ this._stalledItemId === itemId
829
+ ? `Continue todo item ID ${itemId}. (retry after a stalled dispatch - a duplicate continuation for this item may still arrive)`
830
+ : `Continue todo item ID ${itemId}.`;
376
831
  debugLog("TodoContinuation", "Attempting steer to continue item", itemId);
377
832
 
378
833
  // Use guarded dispatch to verify all conditions
379
- const sent = await this.guardedDispatch(itemId, message);
380
- if (!sent) {
381
- debugLog("TodoContinuation", "Steer blocked by guard conditions");
834
+ const result = await this.guardedDispatch(itemId, message);
835
+ if (result.dispatched) {
836
+ // Only a dispatch that actually reached the agent consumes an attempt
837
+ this.recordAttempt(itemId);
838
+ if (this._stalledItemId === itemId) this._stalledItemId = null;
839
+ return {
840
+ dispatched: true,
841
+ attempts: this.state.steerAttemptsForCurrentItem,
842
+ blockedDispatches: this._blockedDispatches,
843
+ };
382
844
  }
845
+
846
+ debugLog("TodoContinuation", "Steer not dispatched:", result.reason);
847
+ return {
848
+ dispatched: false,
849
+ reason: result.reason,
850
+ attempts: this.state.steerAttemptsForCurrentItem,
851
+ blockedDispatches: this._blockedDispatches,
852
+ };
383
853
  }
384
854
 
385
855
  /**
@@ -397,13 +867,28 @@ export class TodoContinuationController {
397
867
  pause(): void {
398
868
  debugLog("TodoContinuation", "User paused");
399
869
  this.state.userPaused = true;
870
+ // A user pause stops automatic continuations until resume() is called.
871
+ this.state.explicitPause = true;
400
872
  this.state.continuationQueued = false;
873
+ this._pendingGuardRelease = null;
874
+ if (this.inFlight) this._cancelRequested = true;
875
+ this.cancelGuardTimer();
401
876
  }
402
877
 
403
878
  /**
404
879
  * Reset state for new session
405
880
  */
406
881
  _reset(): void {
882
+ this.cancelGuardTimer();
883
+ this._blockedDispatches = 0;
884
+ this._consecutiveFailures = 0;
885
+ this._lastFailureAt = 0;
886
+ this._pendingGuardRelease = null;
887
+ this._lastDroppedGuardRelease = null;
888
+ this._stalledItemId = null;
889
+ this._cancelRequested = false;
890
+ this._attemptsByItem.clear();
891
+ this._totalAttempts = 0;
407
892
  this.state = {
408
893
  sessionId: `session-${Date.now()}`,
409
894
  generation: 0,
@@ -419,7 +904,11 @@ export class TodoContinuationController {
419
904
  lastItemContinued: null,
420
905
  lastEvaluationResult: null,
421
906
  };
422
- this._inFlight = false;
907
+ // A fresh session starts with a clean in-flight guard. A dispatch that
908
+ // hung in the previous session is released by its own watchdog, and
909
+ // that watchdog can no longer touch the new session's guard because
910
+ // the sequence numbers differ.
911
+ this._inFlightSeq = null;
423
912
  }
424
913
 
425
914
  /**
@@ -427,18 +916,82 @@ export class TodoContinuationController {
427
916
  */
428
917
  _cleanup(): void {
429
918
  debugLog("TodoContinuation", "Cleaning up");
919
+ this.cancelGuardTimer();
430
920
  this.state.continuationQueued = false;
921
+ this._pendingGuardRelease = null;
431
922
  this.state.generation++;
432
923
  }
433
924
 
925
+ /**
926
+ * Public shutdown hook (used by the host on session shutdown).
927
+ *
928
+ * Releases the guard timer and the queue so a stopped session never keeps
929
+ * a timer alive or blocks a later continuation.
930
+ */
931
+ dispose(): void {
932
+ this._disposed = true;
933
+ this._cleanup();
934
+ // The in-flight guard is released as well: a dispatch that is still on
935
+ // the wire must not keep the session's guard busy. The sequence check
936
+ // in the dispatch's finally block keeps it from clearing the flag of a
937
+ // later dispatch.
938
+ this._inFlightSeq = null;
939
+ // A cancel intent that is left over from a cancelled dispatch must not
940
+ // disarm the guard of a later one.
941
+ this._cancelRequested = false;
942
+ this._stalledItemId = null;
943
+ }
944
+
434
945
  /**
435
946
  * Get current state summary
947
+ *
948
+ * The item ids are reported as ids - the full TodoItem is only available
949
+ * from the (asynchronous) provider snapshot, so no partial object is
950
+ * presented as a TodoItem.
951
+ */
952
+ getState(): { current: { id: number } | null; next: { id: number } | null } {
953
+ return {
954
+ current: this.state.currentItemId != null ? { id: this.state.currentItemId } : null,
955
+ next: this.state.nextItemId != null ? { id: this.state.nextItemId } : null,
956
+ };
957
+ }
958
+
959
+ /**
960
+ * Full controller state (for diagnostics and tests)
436
961
  */
437
- getState(): { current: TodoItem | null; next: TodoItem | null; ready: number } {
962
+ getDiagnostics(): {
963
+ continuationQueued: boolean;
964
+ inFlight: boolean;
965
+ steerAttemptsForCurrentItem: number;
966
+ blockedDispatches: number;
967
+ consecutiveFailures: number;
968
+ circuitBreakerOpen: boolean;
969
+ totalAttempts: number;
970
+ lastItemContinued: number | null;
971
+ lastSteerMessage: string | null;
972
+ explicitPause: boolean;
973
+ userPaused: boolean;
974
+ pendingGuardRelease: string | null;
975
+ lastDroppedGuardRelease: string | null;
976
+ cancelRequested: boolean;
977
+ disposed: boolean;
978
+ } {
438
979
  return {
439
- current: null,
440
- next: null,
441
- ready: 0,
980
+ continuationQueued: this.state.continuationQueued,
981
+ inFlight: this.inFlight,
982
+ steerAttemptsForCurrentItem: this.state.steerAttemptsForCurrentItem,
983
+ blockedDispatches: this._blockedDispatches,
984
+ consecutiveFailures: this._consecutiveFailures,
985
+ circuitBreakerOpen: this.breakerOpen(),
986
+ totalAttempts: this._totalAttempts,
987
+ lastItemContinued: this.state.lastItemContinued,
988
+ lastSteerMessage: this.state.lastSteerMessage,
989
+ explicitPause: this.state.explicitPause,
990
+ userPaused: this.state.userPaused,
991
+ pendingGuardRelease: this._pendingGuardRelease,
992
+ lastDroppedGuardRelease: this._lastDroppedGuardRelease,
993
+ cancelRequested: this._cancelRequested,
994
+ disposed: this._disposed,
442
995
  };
443
996
  }
444
997
  }