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
|
@@ -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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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<
|
|
301
|
-
// 0.
|
|
302
|
-
if (this.
|
|
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
|
-
|
|
581
|
+
this._blockedDispatches++;
|
|
582
|
+
return { dispatched: false, reason: "dispatch already in flight", blocked: true };
|
|
305
583
|
}
|
|
306
584
|
|
|
307
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
615
|
+
this._blockedDispatches++;
|
|
616
|
+
return { dispatched: false, reason: "session not idle", blocked: true };
|
|
317
617
|
}
|
|
318
618
|
|
|
319
|
-
//
|
|
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
|
-
|
|
622
|
+
this._blockedDispatches++;
|
|
623
|
+
return { dispatched: false, reason: "continuation already queued", blocked: true };
|
|
323
624
|
}
|
|
324
625
|
|
|
325
|
-
//
|
|
326
|
-
|
|
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
|
-
|
|
329
|
-
|
|
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.
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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<
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
|
380
|
-
if (
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
}
|