@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.
@@ -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?.recordUpdate(params.update);
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
+ }