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.
- package/harness/job-state-machine.ts +97 -10
- package/harness/langchain/surge.test.ts +57 -25
- package/index.ts +124 -4
- package/package.json +8 -4
- package/packages/a2a-adapter/package.json +1 -1
- package/packages/agent-policy/package.json +1 -1
- package/packages/architecture-generator/package.json +1 -1
- package/packages/auth/dist/minimax-browser-auth.d.ts +1 -0
- package/packages/auth/dist/minimax-browser-auth.d.ts.map +1 -1
- package/packages/auth/dist/minimax-browser-auth.js +2 -2
- package/packages/auth/dist/minimax-browser-auth.js.map +1 -1
- package/packages/auth/package.json +1 -1
- package/packages/auth/src/minimax-browser-auth.ts +15 -3
- package/packages/autonomous-refactor/package.json +1 -1
- package/packages/autonomous-runtime/package.json +1 -1
- package/packages/cache-strategy/package.json +1 -1
- package/packages/capability-registry/package.json +1 -1
- package/packages/checkpoint/dist/checkpoint-manager.d.ts.map +1 -1
- package/packages/checkpoint/dist/checkpoint-manager.js +3 -0
- package/packages/checkpoint/dist/checkpoint-manager.js.map +1 -1
- package/packages/checkpoint/package.json +1 -1
- package/packages/checkpoint/src/checkpoint-manager.ts +8 -1
- package/packages/cli-plugin-sdk/package.json +1 -1
- package/packages/clipboard/package.json +1 -1
- package/packages/clipboard-plugin/package.json +1 -1
- package/packages/code-generation/package.json +1 -1
- package/packages/code-review/package.json +1 -1
- package/packages/codex-adapter/package.json +1 -1
- package/packages/codex-watcher/package.json +1 -1
- package/packages/config-capture/package.json +1 -1
- package/packages/connection-runtime/package.json +1 -1
- package/packages/context-compiler/package.json +1 -1
- package/packages/context-discovery/package.json +1 -1
- package/packages/context-manager/package.json +1 -1
- package/packages/contracts/package.json +1 -1
- package/packages/cookie-sanitizer/package.json +1 -1
- package/packages/cost-optimizer/package.json +1 -1
- package/packages/dependency-analyzer/package.json +1 -1
- package/packages/django-plugin/package.json +1 -1
- package/packages/doc-generator/package.json +1 -1
- package/packages/durable-review/package.json +1 -1
- package/packages/e2e-tester/package.json +1 -1
- package/packages/evaluation-engine/package.json +1 -1
- package/packages/evaluation-runner/package.json +1 -1
- package/packages/event-bus/package.json +1 -1
- package/packages/event-sink/package.json +1 -1
- package/packages/event-store/package.json +1 -1
- package/packages/experience-replay/package.json +1 -1
- package/packages/feedback-collector/package.json +1 -1
- package/packages/file-copy-helper/package.json +1 -1
- package/packages/framework-detector/package.json +1 -1
- package/packages/framework-plugin-sdk/package.json +1 -1
- package/packages/frappe-plugin/package.json +1 -1
- package/packages/generic-web-plugin/package.json +1 -1
- package/packages/health-monitor/package.json +1 -1
- package/packages/intent-analyzer/package.json +1 -1
- package/packages/jev-judge/dist/continuation-controller.d.ts +174 -9
- package/packages/jev-judge/dist/continuation-controller.d.ts.map +1 -1
- package/packages/jev-judge/dist/continuation-controller.js +530 -77
- package/packages/jev-judge/dist/continuation-controller.js.map +1 -1
- package/packages/jev-judge/dist/typesafe-provider.d.ts.map +1 -1
- package/packages/jev-judge/dist/typesafe-provider.js +29 -2
- package/packages/jev-judge/dist/typesafe-provider.js.map +1 -1
- package/packages/jev-judge/package.json +1 -1
- package/packages/jev-judge/src/continuation-controller.ts +636 -83
- package/packages/jev-judge/src/typesafe-provider.ts +31 -2
- package/packages/jev-judge/tests/continuation-controller.test.ts +1229 -0
- package/packages/jev-judge/tests/typesafe-provider.test.ts +180 -0
- package/packages/knowledge-graph/package.json +1 -1
- package/packages/knowledge-retrieval/package.json +1 -1
- package/packages/laravel-plugin/package.json +1 -1
- package/packages/learning-engine/package.json +1 -1
- package/packages/mcp-adapter/package.json +1 -1
- package/packages/memory-engine/package.json +1 -1
- package/packages/milestone-manager/package.json +1 -1
- package/packages/model-registry/package.json +1 -1
- package/packages/nextjs-plugin/package.json +1 -1
- package/packages/notification/package.json +1 -1
- package/packages/observability/package.json +1 -1
- package/packages/okf-indexer/package.json +1 -1
- package/packages/paperclip-integration/package.json +1 -1
- package/packages/performance-optimizer/package.json +1 -1
- package/packages/permission-mode/package.json +1 -1
- package/packages/privilege-broker/package.json +1 -1
- package/packages/project-analyzer/package.json +1 -1
- package/packages/project-bootstrap/package.json +1 -1
- package/packages/projection-engine/package.json +1 -1
- package/packages/prompt-compiler/package.json +1 -1
- package/packages/prompt-versioning/package.json +1 -1
- package/packages/provider-adapter-sdk/package.json +1 -1
- package/packages/provider-router/package.json +1 -1
- package/packages/provider-selector/package.json +1 -1
- package/packages/providers/package.json +1 -1
- package/packages/qdrant-skills/package.json +1 -1
- package/packages/quota-manager/package.json +1 -1
- package/packages/rate-limiter/package.json +1 -1
- package/packages/react-vite-plugin/package.json +1 -1
- package/packages/relay/dist/client.d.ts +36 -1
- package/packages/relay/dist/client.js +195 -25
- package/packages/relay/dist/server.d.ts +5 -1
- package/packages/relay/dist/server.js +74 -7
- package/packages/relay/dist/types.d.ts +7 -0
- package/packages/relay/package.json +1 -1
- package/packages/relay/src/client.ts +206 -42
- package/packages/relay/src/server.ts +78 -10
- package/packages/relay/src/types.ts +7 -0
- package/packages/relay/test/client.test.ts +312 -0
- package/packages/relay/test/relay-e2e.test.ts +263 -0
- package/packages/release-manager/package.json +1 -1
- package/packages/requirement-compiler/package.json +1 -1
- package/packages/runtime/package.json +1 -1
- package/packages/scheduler/package.json +1 -1
- package/packages/scheduler-adapter/package.json +1 -1
- package/packages/session/package.json +1 -1
- package/packages/session-api/package.json +1 -1
- package/packages/session-export/package.json +1 -1
- package/packages/shared-context/package.json +1 -1
- package/packages/skill-mcp-client/package.json +1 -1
- package/packages/skill-registry/package.json +1 -1
- package/packages/skills/package.json +1 -1
- package/packages/sprint-planner/package.json +1 -1
- package/packages/subscription-engine/package.json +1 -1
- package/packages/task-compiler/package.json +1 -1
- package/packages/telegram/package.json +1 -1
- package/packages/tencentdb-memory/package.json +1 -1
- package/packages/tencentdb-sync/package.json +1 -1
- package/packages/test-data-generator/package.json +1 -1
- package/packages/test-generator/package.json +1 -1
- package/packages/todo-bd-sync/package.json +1 -1
- package/packages/token-estimation/package.json +1 -1
- package/packages/token-optimizer/package.json +1 -1
- package/packages/trajectory/package.json +1 -1
- package/packages/tui/dist/harness-footer.d.ts +131 -0
- package/packages/tui/dist/harness-footer.d.ts.map +1 -0
- package/packages/tui/dist/harness-footer.js +463 -0
- package/packages/tui/dist/harness-footer.js.map +1 -0
- package/packages/tui/dist/index.d.ts +1 -0
- package/packages/tui/dist/index.d.ts.map +1 -1
- package/packages/tui/dist/index.js +1 -0
- package/packages/tui/dist/index.js.map +1 -1
- package/packages/tui/package.json +1 -1
- package/packages/tui/src/harness-footer.ts +580 -0
- package/packages/tui/src/index.ts +17 -0
- package/packages/tui/test/harness-footer.test.ts +440 -0
- package/packages/types/package.json +1 -1
- package/packages/voice-api/package.json +1 -1
- package/packages/webmcp-tester/package.json +1 -1
- package/packages/wiki/package.json +1 -1
- package/packages/workflow-events/package.json +1 -1
- package/packages/workspace-scanner/package.json +1 -1
- package/packages/worktree/package.json +1 -1
- package/packages/write-review/package.json +1 -1
- package/scripts/run-e2e-scripts.mjs +74 -0
- package/scripts/run-langchain-tests.mjs +169 -0
- package/scripts/typecheck-packages.mjs +167 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
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.
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
216
|
-
debugLog("TodoContinuation", "
|
|
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.
|
|
226
|
-
if (this.
|
|
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
|
-
|
|
430
|
+
this._blockedDispatches++;
|
|
431
|
+
return { dispatched: false, reason: "dispatch already in flight", blocked: true };
|
|
229
432
|
}
|
|
230
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
460
|
+
this._blockedDispatches++;
|
|
461
|
+
return { dispatched: false, reason: "session not idle", blocked: true };
|
|
239
462
|
}
|
|
240
|
-
//
|
|
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
|
-
|
|
466
|
+
this._blockedDispatches++;
|
|
467
|
+
return { dispatched: false, reason: "continuation already queued", blocked: true };
|
|
244
468
|
}
|
|
245
|
-
//
|
|
246
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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.
|
|
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
|
-
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
//
|
|
282
|
-
|
|
283
|
-
|
|
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.
|
|
291
|
-
|
|
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
|
|
295
|
-
if (
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|