@ours.network/fleet 1.1.0 → 1.1.2
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/README.md +100 -0
- package/dist/build-info.json +4 -4
- package/dist/config.d.ts +4 -0
- package/dist/config.js +9 -1
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +39 -0
- package/dist/doctor.js +32 -0
- package/dist/exec.d.ts +1 -0
- package/dist/exec.js +1 -1
- package/dist/harness/claude-code-session.js +3 -0
- package/dist/harness/codex-runtime.d.ts +14 -0
- package/dist/harness/codex-runtime.js +70 -0
- package/dist/harness/codex-session.js +3 -0
- package/dist/harness/codex.js +21 -5
- package/dist/runner.js +13 -3
- package/dist/session/acp.d.ts +23 -0
- package/dist/session/acp.js +278 -4
- package/dist/session/stall-watchdog.d.ts +60 -0
- package/dist/session/stall-watchdog.js +210 -0
- package/dist/session/types.d.ts +5 -2
- package/dist/temp-lifecycle.js +3 -0
- package/package.json +2 -2
package/dist/session/acp.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import { existsSync, lstatSync, readFileSync, readlinkSync, realpathSync, writeFileSync, } from 'node:fs';
|
|
4
4
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
5
|
import { Readable, Writable } from 'node:stream';
|
|
@@ -7,6 +7,7 @@ import * as acp from '@agentclientprotocol/sdk';
|
|
|
7
7
|
import { normalizeSessionUpdate } from './conversation-normalizer.js';
|
|
8
8
|
import { ConversationEventStore } from './conversation-store.js';
|
|
9
9
|
import { SessionEvents } from './events.js';
|
|
10
|
+
import { DEFAULT_STALL_TIMEOUT_MS, STALL_RECOVERY_PROMPT, StallWatchdog, StallToolHistory, hasStallRecoveryClaim } from './stall-watchdog.js';
|
|
10
11
|
import { ACP_CANCEL_DEADLINE_EXCEEDED, SessionControlError, classifyChildExit, sessionBackendCapabilities, turnResult, } from './types.js';
|
|
11
12
|
const CANCEL_SETTLE_GRACE_MS = 15_000;
|
|
12
13
|
const CANCEL_TERMINATE_GRACE_MS = 5_000;
|
|
@@ -158,6 +159,7 @@ function canonicallyWithin(root, candidates) {
|
|
|
158
159
|
function conversationSource(origin) {
|
|
159
160
|
switch (origin?.kind) {
|
|
160
161
|
case 'owner-admin-console': return { source: 'owner_admin_console', persistBody: true };
|
|
162
|
+
case 'stall-watchdog': return { source: 'fleet_monitor', persistBody: false };
|
|
161
163
|
case 'startup': return { source: 'startup', persistBody: true };
|
|
162
164
|
case 'owner': return { source: 'owner_channel', persistBody: false };
|
|
163
165
|
case 'fleet-monitor': return { source: 'fleet_monitor', persistBody: false };
|
|
@@ -265,6 +267,15 @@ export class AcpSession {
|
|
|
265
267
|
terminate;
|
|
266
268
|
/** ACP-authenticated in-flight calls, including independently reserved permissions. */
|
|
267
269
|
activeToolCalls = new Map();
|
|
270
|
+
stallWatchdog;
|
|
271
|
+
stallToolHistory;
|
|
272
|
+
stallRecoveryClaimed = false;
|
|
273
|
+
managedTurnCount = 0;
|
|
274
|
+
steeringWasUsed = false;
|
|
275
|
+
steeringRequests = 0;
|
|
276
|
+
retryNativeTurnId;
|
|
277
|
+
stallTimer;
|
|
278
|
+
stallAttempt;
|
|
268
279
|
toolBoundaryWaiters = new Set();
|
|
269
280
|
activeTurn;
|
|
270
281
|
constructor(options, child, connection) {
|
|
@@ -285,6 +296,9 @@ export class AcpSession {
|
|
|
285
296
|
this.terminated.catch(() => undefined);
|
|
286
297
|
child.stderr.on('data', chunk => options.log(`[${options.name}] acp: ${String(chunk).trimEnd()}`));
|
|
287
298
|
child.once('exit', (code, signal) => {
|
|
299
|
+
if (this.stallTimer)
|
|
300
|
+
clearInterval(this.stallTimer);
|
|
301
|
+
this.stallTimer = undefined;
|
|
288
302
|
if (this.cancelForceKill)
|
|
289
303
|
clearTimeout(this.cancelForceKill);
|
|
290
304
|
this.cancelForceKill = undefined;
|
|
@@ -341,7 +355,8 @@ export class AcpSession {
|
|
|
341
355
|
let instance;
|
|
342
356
|
const app = acp.client({ name: 'ours-fleet' })
|
|
343
357
|
.onNotification(acp.methods.client.session.update, ({ params }) => {
|
|
344
|
-
instance
|
|
358
|
+
if (instance && (!instance.sessionId || params.sessionId === instance.sessionId))
|
|
359
|
+
instance.recordUpdate(params.update);
|
|
345
360
|
})
|
|
346
361
|
.onRequest(acp.methods.client.session.requestPermission, ({ params }) => {
|
|
347
362
|
if (!instance)
|
|
@@ -353,6 +368,7 @@ export class AcpSession {
|
|
|
353
368
|
instance = new AcpSession(options, child, connection);
|
|
354
369
|
try {
|
|
355
370
|
await instance.initialize();
|
|
371
|
+
instance.startStallWatchdog();
|
|
356
372
|
instance.recoverOpenPrompts();
|
|
357
373
|
return instance;
|
|
358
374
|
}
|
|
@@ -471,10 +487,14 @@ export class AcpSession {
|
|
|
471
487
|
reserveTool(toolCallId) {
|
|
472
488
|
if (toolCallId)
|
|
473
489
|
this.toolCall(toolCallId).lifecycle = true;
|
|
490
|
+
else if (this.activeTurn)
|
|
491
|
+
this.activeTurn.boundaryUnknown = true;
|
|
474
492
|
}
|
|
475
493
|
reservePermission(toolCallId, permissionId) {
|
|
476
494
|
if (toolCallId)
|
|
477
495
|
this.toolCall(toolCallId).permissions.set(permissionId, 'pending');
|
|
496
|
+
else if (this.activeTurn)
|
|
497
|
+
this.activeTurn.boundaryUnknown = true;
|
|
478
498
|
}
|
|
479
499
|
allowPermission(toolCallId, permissionId) {
|
|
480
500
|
if (!toolCallId)
|
|
@@ -559,6 +579,8 @@ export class AcpSession {
|
|
|
559
579
|
* steering response from becoming a tight replay loop.
|
|
560
580
|
*/
|
|
561
581
|
async steerOrQueueWake(text, options) {
|
|
582
|
+
if (this.stallRecoveryClaimed)
|
|
583
|
+
return this.submitPrompt(text, { ...options, interrupt: false, steer: false });
|
|
562
584
|
const steered = await this.steerPrompt(text);
|
|
563
585
|
if (steered.accepted || steered.detail !== 'ACP steering failed'
|
|
564
586
|
|| this.closing || !this.isAlive())
|
|
@@ -622,6 +644,8 @@ export class AcpSession {
|
|
|
622
644
|
* for it is what turned a busy agent into a timeout and then into "dead".
|
|
623
645
|
*/
|
|
624
646
|
async queuePrompt(text, options = {}) {
|
|
647
|
+
if (this.stallRecoveryClaimed && options.origin?.kind === 'fleet-monitor')
|
|
648
|
+
options = { ...options, interrupt: false, steer: false };
|
|
625
649
|
if (this.cancelRecoveryReason)
|
|
626
650
|
throw new SessionControlError('control-unavailable', 'ACP adapter restart is in progress after the cancellation deadline', ACP_CANCEL_DEADLINE_EXCEEDED);
|
|
627
651
|
if (this.closing || !this.sessionId || !this.isAlive())
|
|
@@ -781,6 +805,8 @@ export class AcpSession {
|
|
|
781
805
|
}
|
|
782
806
|
}
|
|
783
807
|
async cancelActive(source) {
|
|
808
|
+
if (this.stallAttempt && source !== 'stall-watchdog' && source !== 'fleet-monitor')
|
|
809
|
+
this.stallAttempt.superseded = true;
|
|
784
810
|
if (!this.sessionId)
|
|
785
811
|
return;
|
|
786
812
|
const active = this.activeTurn;
|
|
@@ -989,6 +1015,11 @@ export class AcpSession {
|
|
|
989
1015
|
}
|
|
990
1016
|
async close() {
|
|
991
1017
|
this.closing = true;
|
|
1018
|
+
if (this.stallTimer)
|
|
1019
|
+
clearInterval(this.stallTimer);
|
|
1020
|
+
this.stallTimer = undefined;
|
|
1021
|
+
if (this.stallAttempt)
|
|
1022
|
+
this.stallAttempt.superseded = true;
|
|
992
1023
|
if (this.cancelEscalation)
|
|
993
1024
|
clearTimeout(this.cancelEscalation);
|
|
994
1025
|
this.cancelEscalation = undefined;
|
|
@@ -1130,13 +1161,158 @@ export class AcpSession {
|
|
|
1130
1161
|
this.runtimeModel = runtimeSelector(options, 'model');
|
|
1131
1162
|
this.reasoningEffort = runtimeSelector(options, 'thought_level') ?? reasoningFromModelId(modelId);
|
|
1132
1163
|
}
|
|
1164
|
+
startStallWatchdog() {
|
|
1165
|
+
if (!this.options.stallRecovery)
|
|
1166
|
+
return;
|
|
1167
|
+
const timeoutMs = this.options.stallRecovery.timeoutMs ?? DEFAULT_STALL_TIMEOUT_MS;
|
|
1168
|
+
this.stallRecoveryClaimed = hasStallRecoveryClaim(this.options.stateDir, this.sessionId);
|
|
1169
|
+
this.stallToolHistory = new StallToolHistory(this.options.stateDir, this.sessionId, this.options.mode === 'resume');
|
|
1170
|
+
this.stallWatchdog = new StallWatchdog({
|
|
1171
|
+
stateDir: this.options.stateDir, timeoutMs, previouslyClaimed: this.stallRecoveryClaimed, now: () => Date.now(),
|
|
1172
|
+
observe: () => this.stallObservation(),
|
|
1173
|
+
recover: (observed, report) => this.recoverStall(observed, report),
|
|
1174
|
+
diagnostic: diagnostic => {
|
|
1175
|
+
if (['interrupt_requested', 'blocked_previous_attempt', 'blocked_persistence'].includes(diagnostic.status))
|
|
1176
|
+
this.stallRecoveryClaimed = true;
|
|
1177
|
+
this.events.emit('stall_recovery', { status: diagnostic.status, stallDiagnostic: diagnostic });
|
|
1178
|
+
this.options.log(`[${this.options.name}] ACP stall recovery: ${diagnostic.status}; `
|
|
1179
|
+
+ 'inspect structured session events before continuing; never replay uncertain side effects');
|
|
1180
|
+
},
|
|
1181
|
+
});
|
|
1182
|
+
this.stallTimer = setInterval(() => this.checkStallWatchdog(), this.options.stallRecovery.tickMs ?? Math.min(10_000, timeoutMs));
|
|
1183
|
+
this.stallTimer.unref?.();
|
|
1184
|
+
}
|
|
1185
|
+
checkStallWatchdog() {
|
|
1186
|
+
void this.stallWatchdog?.tick();
|
|
1187
|
+
const attempt = this.stallAttempt;
|
|
1188
|
+
if (attempt?.recoveryStartedAt === undefined || attempt.blocked || attempt.superseded)
|
|
1189
|
+
return;
|
|
1190
|
+
const observed = this.stallObservation();
|
|
1191
|
+
if (!observed?.safe || observed.turnId !== attempt.recoveryId)
|
|
1192
|
+
return;
|
|
1193
|
+
const last = observed.lastProgressAt || attempt.recoveryStartedAt;
|
|
1194
|
+
const timeoutMs = this.options.stallRecovery?.timeoutMs ?? DEFAULT_STALL_TIMEOUT_MS;
|
|
1195
|
+
if (Date.now() - last >= timeoutMs * 2) {
|
|
1196
|
+
attempt.blocked = true;
|
|
1197
|
+
try {
|
|
1198
|
+
attempt.report('blocked_restall');
|
|
1199
|
+
}
|
|
1200
|
+
catch { /* durable claim already prevents retry */ }
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
stallObservation() {
|
|
1204
|
+
const active = this.activeTurn;
|
|
1205
|
+
if (!active || !this.sessionId)
|
|
1206
|
+
return undefined;
|
|
1207
|
+
return {
|
|
1208
|
+
sessionId: this.sessionId, generation: this.sessionGeneration, turnId: active.id,
|
|
1209
|
+
startedAt: active.startedAt, lastProgressAt: active.lastProgressAt, progressCount: active.progressCount,
|
|
1210
|
+
transportFailures: active.transportFailures,
|
|
1211
|
+
boundaryEvidenceAvailable: this.stallToolHistory?.available() !== false,
|
|
1212
|
+
safe: this.isAlive() && !this.closing && this.readiness === 'running'
|
|
1213
|
+
&& !active.cancellationSource && !active.boundaryUnknown && !this.steeringOccupied
|
|
1214
|
+
&& this.steeringRequests === 0 && this.stallToolHistory?.available() !== false
|
|
1215
|
+
&& this.activeToolCalls.size === 0 && this.pendingPermissions.size === 0,
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
async recoverStall(observed, report) {
|
|
1219
|
+
const current = this.stallObservation();
|
|
1220
|
+
if (!current?.safe || current.turnId !== observed.turnId
|
|
1221
|
+
|| current.lastProgressAt !== observed.lastProgressAt || current.progressCount !== observed.progressCount) {
|
|
1222
|
+
report('superseded');
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
const active = this.activeTurn;
|
|
1226
|
+
let resolveReady;
|
|
1227
|
+
const ready = new Promise(resolve => { resolveReady = resolve; });
|
|
1228
|
+
const attempt = this.stallAttempt = {
|
|
1229
|
+
turnId: active.id, recoveryId: randomUUID(), ready, superseded: false,
|
|
1230
|
+
report, resumed: false, blocked: false,
|
|
1231
|
+
};
|
|
1232
|
+
active.cancellationSource = 'stall-watchdog';
|
|
1233
|
+
// This intentionally does not use cancelActive: automatic recovery may
|
|
1234
|
+
// never enter its SIGTERM/SIGKILL escalation or settle a permission.
|
|
1235
|
+
let timer;
|
|
1236
|
+
try {
|
|
1237
|
+
const completed = await Promise.race([
|
|
1238
|
+
(async () => {
|
|
1239
|
+
await this.connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: this.sessionId });
|
|
1240
|
+
await active.settled;
|
|
1241
|
+
return true;
|
|
1242
|
+
})(),
|
|
1243
|
+
new Promise(resolve => {
|
|
1244
|
+
timer = setTimeout(() => resolve(false), this.options.stallRecovery?.cancelWaitMs ?? CANCEL_SETTLE_GRACE_MS);
|
|
1245
|
+
timer.unref?.();
|
|
1246
|
+
}),
|
|
1247
|
+
]);
|
|
1248
|
+
if (!completed || attempt.superseded || this.closing || !this.isAlive()) {
|
|
1249
|
+
attempt.blocked = true;
|
|
1250
|
+
report(attempt.superseded || this.closing ? 'superseded' : 'blocked_cancel');
|
|
1251
|
+
resolveReady(false);
|
|
1252
|
+
}
|
|
1253
|
+
else
|
|
1254
|
+
resolveReady(true);
|
|
1255
|
+
}
|
|
1256
|
+
catch {
|
|
1257
|
+
attempt.blocked = true;
|
|
1258
|
+
try {
|
|
1259
|
+
report('blocked_cancel');
|
|
1260
|
+
}
|
|
1261
|
+
finally {
|
|
1262
|
+
resolveReady(false);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
finally {
|
|
1266
|
+
if (timer)
|
|
1267
|
+
clearTimeout(timer);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
/** Keep the original queue slot (including startup) until recovery finishes. */
|
|
1133
1271
|
async runPrompt(text, turnId = randomUUID(), origin) {
|
|
1272
|
+
const result = await this.runSinglePrompt(text, turnId, origin);
|
|
1273
|
+
const attempt = this.stallAttempt;
|
|
1274
|
+
if (!attempt || attempt.turnId !== turnId)
|
|
1275
|
+
return result;
|
|
1276
|
+
const ready = await attempt.ready;
|
|
1277
|
+
if (attempt.superseded || this.closing)
|
|
1278
|
+
return result;
|
|
1279
|
+
if (!ready || result.outcome !== 'cancelled' || result.cancellationSource !== 'stall-watchdog') {
|
|
1280
|
+
// An RPC error/refusal/ambiguous terminal answer to cancellation is not
|
|
1281
|
+
// permission to replay work or to fail startup and restart the process.
|
|
1282
|
+
if (!attempt.blocked) {
|
|
1283
|
+
attempt.blocked = true;
|
|
1284
|
+
try {
|
|
1285
|
+
attempt.report('blocked_cancel');
|
|
1286
|
+
}
|
|
1287
|
+
catch { /* claim remains durable */ }
|
|
1288
|
+
}
|
|
1289
|
+
return turnResult(true, 'cancelled', 'diagnostic cancellation requires operator attention', undefined, 'stall-watchdog');
|
|
1290
|
+
}
|
|
1291
|
+
try {
|
|
1292
|
+
attempt.report('recovery_started');
|
|
1293
|
+
attempt.recoveryStartedAt = Date.now();
|
|
1294
|
+
const recoveryOrigin = origin?.kind === 'scheduled-loop'
|
|
1295
|
+
? origin : { kind: 'stall-watchdog' };
|
|
1296
|
+
this.admitToLedger(attempt.recoveryId, STALL_RECOVERY_PROMPT, 0, { origin: recoveryOrigin });
|
|
1297
|
+
const recovered = await this.runSinglePrompt(STALL_RECOVERY_PROMPT, attempt.recoveryId, recoveryOrigin);
|
|
1298
|
+
attempt.report(attempt.superseded ? 'superseded'
|
|
1299
|
+
: recovered.succeeded && attempt.resumed ? 'recovery_completed' : 'blocked_recovery');
|
|
1300
|
+
// A failed diagnostic turn must not make startup tear down the session.
|
|
1301
|
+
return recovered.succeeded && attempt.resumed ? recovered : turnResult(true, 'cancelled', 'diagnostic recovery requires operator attention', undefined, 'stall-watchdog');
|
|
1302
|
+
}
|
|
1303
|
+
catch {
|
|
1304
|
+
return turnResult(true, 'cancelled', 'diagnostic recovery requires operator attention', undefined, 'stall-watchdog');
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
async runSinglePrompt(text, turnId = randomUUID(), origin) {
|
|
1134
1308
|
if (!this.sessionId || !this.isAlive())
|
|
1135
1309
|
return turnResult(false, 'failed', this.lastError ?? 'ACP session is offline');
|
|
1136
1310
|
this.readiness = 'running';
|
|
1311
|
+
this.managedTurnCount++;
|
|
1312
|
+
this.retryNativeTurnId = undefined;
|
|
1137
1313
|
let settle;
|
|
1138
1314
|
const settled = new Promise(resolve => { settle = resolve; });
|
|
1139
|
-
this.activeTurn = { id: turnId, output: '', origin, settled, settle };
|
|
1315
|
+
this.activeTurn = { toolEvidence: new Map(), startedAt: Date.now(), toolIds: new Set(), lastProgressAt: 0, progressCount: 0, transportFailures: 0, boundaryUnknown: false, id: turnId, output: '', origin, settled, settle };
|
|
1140
1316
|
this.events.emit('state', { turnId, status: 'running', origin });
|
|
1141
1317
|
this.conversation.appendSafe({
|
|
1142
1318
|
kind: 'prompt.started', sessionGeneration: this.sessionGeneration,
|
|
@@ -1180,7 +1356,8 @@ export class AcpSession {
|
|
|
1180
1356
|
this.lastError = origin?.kind === 'scheduled-loop' ? 'scheduled-loop turn failed' : detail;
|
|
1181
1357
|
this.readiness = this.isAlive() ? 'idle' : 'failed';
|
|
1182
1358
|
this.events.emit('error', {
|
|
1183
|
-
turnId, origin
|
|
1359
|
+
turnId, origin: this.activeTurn?.cancellationSource === 'stall-watchdog'
|
|
1360
|
+
? { kind: 'stall-watchdog' } : origin,
|
|
1184
1361
|
text: origin?.kind === 'scheduled-loop' ? 'scheduled-loop turn failed' : this.lastError,
|
|
1185
1362
|
});
|
|
1186
1363
|
if (this.isAlive())
|
|
@@ -1208,8 +1385,10 @@ export class AcpSession {
|
|
|
1208
1385
|
}
|
|
1209
1386
|
}
|
|
1210
1387
|
async steerPrompt(text) {
|
|
1388
|
+
this.steeringWasUsed = true;
|
|
1211
1389
|
if (!this.sessionId || !this.isAlive())
|
|
1212
1390
|
return turnResult(false, 'failed', this.lastError ?? 'ACP session is offline');
|
|
1391
|
+
this.steeringRequests++;
|
|
1213
1392
|
try {
|
|
1214
1393
|
const response = await Promise.race([
|
|
1215
1394
|
this.connection.agent.request('_session/steering', {
|
|
@@ -1234,6 +1413,9 @@ export class AcpSession {
|
|
|
1234
1413
|
this.events.emit('error', { text: detail });
|
|
1235
1414
|
return turnResult(false, 'failed', detail);
|
|
1236
1415
|
}
|
|
1416
|
+
finally {
|
|
1417
|
+
this.steeringRequests--;
|
|
1418
|
+
}
|
|
1237
1419
|
}
|
|
1238
1420
|
requestPermission(params) {
|
|
1239
1421
|
// `kinds` is a PRIORITY order. Scanning the agent's option array instead
|
|
@@ -1409,6 +1591,48 @@ export class AcpSession {
|
|
|
1409
1591
|
&& params.options.some(option => option.optionId === 'allow_once' && option.kind === 'allow_once')
|
|
1410
1592
|
&& params.options.some(option => option.optionId === 'decline' && option.kind === 'reject_once');
|
|
1411
1593
|
}
|
|
1594
|
+
/** Pinned codex-acp 1.1.7 structured metadata, never stderr or assistant text. */
|
|
1595
|
+
recordStallMetadata(update) {
|
|
1596
|
+
if (this.options.permissionMetadataSource !== 'codex-acp'
|
|
1597
|
+
|| update.sessionUpdate !== 'session_info_update' || !this.activeTurn)
|
|
1598
|
+
return;
|
|
1599
|
+
const meta = update._meta?.codex;
|
|
1600
|
+
if (!meta || typeof meta !== 'object' || Array.isArray(meta))
|
|
1601
|
+
return;
|
|
1602
|
+
const codex = meta;
|
|
1603
|
+
const status = codex.threadStatus;
|
|
1604
|
+
if (Object.prototype.hasOwnProperty.call(codex, 'threadStatus')) {
|
|
1605
|
+
const value = status && typeof status === 'object' && !Array.isArray(status)
|
|
1606
|
+
? status : {};
|
|
1607
|
+
// Unknown or modal thread status is a permanent conservative fence for
|
|
1608
|
+
// this turn. A later delayed idle/active status must not clear it.
|
|
1609
|
+
if (value.type !== 'active' || !Array.isArray(value.activeFlags)
|
|
1610
|
+
|| value.activeFlags.length > 0)
|
|
1611
|
+
this.activeTurn.boundaryUnknown = true;
|
|
1612
|
+
}
|
|
1613
|
+
const error = codex.error;
|
|
1614
|
+
if (!error || typeof error !== 'object' || Array.isArray(error))
|
|
1615
|
+
return;
|
|
1616
|
+
const value = error;
|
|
1617
|
+
const info = value.codexErrorInfo;
|
|
1618
|
+
if (value.willRetry !== true || typeof value.turnId !== 'string' || !value.turnId
|
|
1619
|
+
|| !info || typeof info !== 'object' || Array.isArray(info))
|
|
1620
|
+
return;
|
|
1621
|
+
if (!['responseStreamConnectionFailed', 'responseStreamDisconnected']
|
|
1622
|
+
.some(key => Object.prototype.hasOwnProperty.call(info, key)))
|
|
1623
|
+
return;
|
|
1624
|
+
// ACP 1.1.7 does not expose a native-turn-to-prompt mapping. Only the first
|
|
1625
|
+
// fresh managed turn with no steering can be correlated without guessing;
|
|
1626
|
+
// later/resumed turns retain the conservative generic no-progress path.
|
|
1627
|
+
if (this.options.mode !== 'fresh' || this.managedTurnCount !== 1 || this.steeringWasUsed)
|
|
1628
|
+
return;
|
|
1629
|
+
if (this.retryNativeTurnId && this.retryNativeTurnId !== value.turnId) {
|
|
1630
|
+
this.activeTurn.boundaryUnknown = true;
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
this.retryNativeTurnId = value.turnId;
|
|
1634
|
+
this.activeTurn.transportFailures++;
|
|
1635
|
+
}
|
|
1412
1636
|
recordUpdate(update) {
|
|
1413
1637
|
// Replayed history is not current activity: `session/load` would otherwise
|
|
1414
1638
|
// make a cold session look like it had just been working. The same reason
|
|
@@ -1418,6 +1642,53 @@ export class AcpSession {
|
|
|
1418
1642
|
this.lastUpdateAt = new Date().toISOString();
|
|
1419
1643
|
this.refreshSteeringOccupancy();
|
|
1420
1644
|
}
|
|
1645
|
+
if (!this.replaying && this.activeTurn) {
|
|
1646
|
+
this.recordStallMetadata(update);
|
|
1647
|
+
const active = this.activeTurn;
|
|
1648
|
+
const kind = update.sessionUpdate;
|
|
1649
|
+
if (this.options.stallRecovery && (kind === 'tool_call' || kind === 'tool_call_update')) {
|
|
1650
|
+
if (!update.toolCallId || active.toolIds.size >= 4096)
|
|
1651
|
+
active.boundaryUnknown = true;
|
|
1652
|
+
else
|
|
1653
|
+
active.toolIds.add(update.toolCallId);
|
|
1654
|
+
if (this.stallToolHistory?.observe(update.toolCallId, active.id) === false)
|
|
1655
|
+
active.boundaryUnknown = true;
|
|
1656
|
+
}
|
|
1657
|
+
let meaningful = ((kind === 'agent_message_chunk' || kind === 'agent_thought_chunk')
|
|
1658
|
+
&& (update.content.type !== 'text' || update.content.text.length > 0))
|
|
1659
|
+
|| kind === 'tool_call' || kind === 'tool_call_update' || kind === 'plan';
|
|
1660
|
+
if (this.options.stallRecovery && (kind === 'tool_call' || kind === 'tool_call_update' || kind === 'plan')) {
|
|
1661
|
+
const fingerprint = createHash('sha256').update(JSON.stringify(update)).digest('hex');
|
|
1662
|
+
if (kind === 'plan') {
|
|
1663
|
+
meaningful = fingerprint !== active.planEvidence;
|
|
1664
|
+
active.planEvidence = fingerprint;
|
|
1665
|
+
}
|
|
1666
|
+
else if (active.toolEvidence.size < 4096 || active.toolEvidence.has(update.toolCallId)) {
|
|
1667
|
+
meaningful = fingerprint !== active.toolEvidence.get(update.toolCallId);
|
|
1668
|
+
active.toolEvidence.set(update.toolCallId, fingerprint);
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
if (this.options.stallRecovery && ![
|
|
1672
|
+
'agent_message_chunk', 'agent_thought_chunk', 'tool_call', 'tool_call_update', 'plan',
|
|
1673
|
+
'available_commands_update', 'current_mode_update', 'config_option_update', 'session_info_update', 'usage_update',
|
|
1674
|
+
].includes(kind))
|
|
1675
|
+
active.boundaryUnknown = true;
|
|
1676
|
+
if (meaningful) {
|
|
1677
|
+
active.lastProgressAt = Date.now();
|
|
1678
|
+
active.progressCount++;
|
|
1679
|
+
active.transportFailures = 0;
|
|
1680
|
+
const attempt = this.stallAttempt;
|
|
1681
|
+
if (attempt?.recoveryId === active.id && !attempt.resumed) {
|
|
1682
|
+
attempt.resumed = true;
|
|
1683
|
+
try {
|
|
1684
|
+
attempt.report('progress_resumed');
|
|
1685
|
+
}
|
|
1686
|
+
catch {
|
|
1687
|
+
attempt.blocked = true;
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1421
1692
|
const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
|
|
1422
1693
|
const messagePhase = update.sessionUpdate === 'agent_message_chunk'
|
|
1423
1694
|
? this.codexMessagePhase(update) : undefined;
|
|
@@ -1475,6 +1746,9 @@ export class AcpSession {
|
|
|
1475
1746
|
this.releaseTool(update.toolCallId);
|
|
1476
1747
|
else if (update.status !== undefined)
|
|
1477
1748
|
this.reserveTool(update.toolCallId);
|
|
1749
|
+
else if (this.options.stallRecovery && this.activeTurn
|
|
1750
|
+
&& !this.activeToolCalls.has(update.toolCallId))
|
|
1751
|
+
this.activeTurn.boundaryUnknown = true;
|
|
1478
1752
|
break;
|
|
1479
1753
|
default:
|
|
1480
1754
|
break;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export declare const DEFAULT_STALL_TIMEOUT_MS: number;
|
|
2
|
+
export declare const STALL_RECOVERY_PROMPT: string;
|
|
3
|
+
export type StallStatus = 'interrupt_requested' | 'recovery_started' | 'progress_resumed' | 'recovery_completed' | 'blocked_cancel' | 'blocked_recovery' | 'blocked_restall' | 'blocked_persistence' | 'blocked_previous_attempt' | 'blocked_evidence' | 'superseded';
|
|
4
|
+
export interface StallDiagnostic {
|
|
5
|
+
version: 1;
|
|
6
|
+
kind: 'stall_recovery';
|
|
7
|
+
eventId: string;
|
|
8
|
+
session: string;
|
|
9
|
+
turn: string;
|
|
10
|
+
status: StallStatus;
|
|
11
|
+
evidence: 'adapter_transport' | 'no_progress';
|
|
12
|
+
idleMs: number;
|
|
13
|
+
}
|
|
14
|
+
export interface StallObservation {
|
|
15
|
+
sessionId: string;
|
|
16
|
+
generation: string;
|
|
17
|
+
turnId: string;
|
|
18
|
+
startedAt: number;
|
|
19
|
+
lastProgressAt: number;
|
|
20
|
+
progressCount: number;
|
|
21
|
+
transportFailures: number;
|
|
22
|
+
safe: boolean;
|
|
23
|
+
boundaryEvidenceAvailable?: boolean;
|
|
24
|
+
}
|
|
25
|
+
/** Presence, including an incomplete claim, restores conservative mail policy. */
|
|
26
|
+
export declare function hasStallRecoveryClaim(stateDir: string, sessionId: string): boolean;
|
|
27
|
+
/** ACP has no turn IDs on tool updates. Reuse across turns is ambiguous. */
|
|
28
|
+
export declare class StallToolHistory {
|
|
29
|
+
private readonly turns;
|
|
30
|
+
private healthy;
|
|
31
|
+
private readonly directory;
|
|
32
|
+
private readonly path;
|
|
33
|
+
private readonly session;
|
|
34
|
+
constructor(stateDir: string, sessionId: string, resume: boolean);
|
|
35
|
+
available(): boolean;
|
|
36
|
+
/** Record before relying on a tool event. False means cancellation is unsafe. */
|
|
37
|
+
observe(toolId: string, turnId: string): boolean;
|
|
38
|
+
private persist;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* One durable attempt per ACP session, deliberately stricter than one per turn.
|
|
42
|
+
* A restarted supervisor never guesses whether cancellation or recovery ran.
|
|
43
|
+
* Claim files are never reclaimed automatically, including malformed/empty ones.
|
|
44
|
+
*/
|
|
45
|
+
export declare class StallWatchdog {
|
|
46
|
+
private readonly options;
|
|
47
|
+
private checking;
|
|
48
|
+
private disabled;
|
|
49
|
+
private unavailableTurn?;
|
|
50
|
+
constructor(options: {
|
|
51
|
+
stateDir: string;
|
|
52
|
+
timeoutMs: number;
|
|
53
|
+
previouslyClaimed?: boolean;
|
|
54
|
+
now(): number;
|
|
55
|
+
observe(): StallObservation | undefined;
|
|
56
|
+
recover(observed: StallObservation, report: (status: StallStatus) => void): Promise<void>;
|
|
57
|
+
diagnostic(event: StallDiagnostic): void;
|
|
58
|
+
});
|
|
59
|
+
tick(): Promise<void>;
|
|
60
|
+
}
|