@ours.network/fleet 0.17.0 → 0.17.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.
Files changed (65) hide show
  1. package/README.md +38 -2
  2. package/dist/application/role-removal-service.js +1 -1
  3. package/dist/application/session-control.d.ts +14 -10
  4. package/dist/application/session-control.js +14 -3
  5. package/dist/atomic-file.d.ts +7 -1
  6. package/dist/atomic-file.js +33 -5
  7. package/dist/build-info.json +10 -0
  8. package/dist/capabilities.d.ts +20 -0
  9. package/dist/capabilities.js +21 -0
  10. package/dist/cli.js +98 -10
  11. package/dist/config.d.ts +9 -2
  12. package/dist/config.js +16 -2
  13. package/dist/creation.d.ts +16 -0
  14. package/dist/creation.js +28 -0
  15. package/dist/docs.d.ts +1 -1
  16. package/dist/docs.js +70 -4
  17. package/dist/doctor.d.ts +5 -0
  18. package/dist/doctor.js +87 -2
  19. package/dist/fleet-proxy.js +2 -2
  20. package/dist/harness/acp-agent.d.ts +3 -0
  21. package/dist/harness/acp-agent.js +4 -1
  22. package/dist/harness/codex-app-server-proxy.d.ts +4 -0
  23. package/dist/harness/codex-app-server-proxy.js +133 -0
  24. package/dist/harness/codex.js +116 -11
  25. package/dist/harness/types.d.ts +2 -0
  26. package/dist/index.d.ts +2 -1
  27. package/dist/index.js +1 -0
  28. package/dist/loops/manager.d.ts +42 -1
  29. package/dist/loops/manager.js +115 -16
  30. package/dist/loops/state.d.ts +46 -2
  31. package/dist/loops/state.js +81 -3
  32. package/dist/monitor.d.ts +21 -0
  33. package/dist/monitor.js +42 -0
  34. package/dist/ops.d.ts +6 -0
  35. package/dist/ops.js +46 -1
  36. package/dist/owner-channel/channel.d.ts +18 -2
  37. package/dist/owner-channel/channel.js +146 -2
  38. package/dist/owner-channel/commands.d.ts +2 -2
  39. package/dist/owner-channel/commands.js +7 -2
  40. package/dist/owner-channel/notices.d.ts +2 -0
  41. package/dist/owner-channel/notices.js +3 -0
  42. package/dist/permissions.d.ts +2 -0
  43. package/dist/permissions.js +5 -0
  44. package/dist/provenance.d.ts +77 -0
  45. package/dist/provenance.js +283 -0
  46. package/dist/runner.d.ts +7 -1
  47. package/dist/runner.js +100 -14
  48. package/dist/session/acp.d.ts +40 -4
  49. package/dist/session/acp.js +272 -37
  50. package/dist/session/arbiter.d.ts +28 -2
  51. package/dist/session/arbiter.js +75 -4
  52. package/dist/session/control.js +12 -6
  53. package/dist/session/event-log.d.ts +109 -0
  54. package/dist/session/event-log.js +247 -0
  55. package/dist/session/events.d.ts +21 -0
  56. package/dist/session/events.js +105 -26
  57. package/dist/session/tmux.d.ts +3 -2
  58. package/dist/session/tmux.js +2 -0
  59. package/dist/session/types.d.ts +39 -2
  60. package/dist/session/types.js +11 -1
  61. package/dist/spawn.d.ts +3 -3
  62. package/dist/spawn.js +40 -14
  63. package/dist/temp-lifecycle.d.ts +62 -0
  64. package/dist/temp-lifecycle.js +437 -0
  65. package/package.json +5 -3
@@ -1,14 +1,15 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
- import { isAbsolute, join, relative, resolve } from 'node:path';
3
+ import { existsSync, lstatSync, readFileSync, readlinkSync, realpathSync, writeFileSync, } from 'node:fs';
4
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
5
5
  import { Readable, Writable } from 'node:stream';
6
6
  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 { SessionControlError, classifyChildExit, turnResult } from './types.js';
10
+ import { ACP_CANCEL_DEADLINE_EXCEEDED, SessionControlError, classifyChildExit, turnResult, } from './types.js';
11
11
  const CANCEL_SETTLE_GRACE_MS = 15_000;
12
+ const CANCEL_TERMINATE_GRACE_MS = 5_000;
12
13
  /** A permission no human answered is eventually a decision nobody made. */
13
14
  const PERMISSION_TIMEOUT_MS = 10 * 60_000;
14
15
  /** Spec §4.3: 10-15 s before a vanished controller triggers the unattended policy. */
@@ -18,7 +19,119 @@ export const AFTER_TOOL_BOUNDARY_TIMEOUT_MS = 120_000;
18
19
  const TERMINAL_TOOL_STATUSES = new Set(['completed', 'failed']);
19
20
  const SCHEDULED_LOOP_REDACTION = '[scheduled-loop content redacted]';
20
21
  const OWNER_COMMENTARY_REDACTION = '[assistant commentary redacted]';
22
+ const MAX_CANONICAL_SYMLINK_DEPTH = 40;
21
23
  const scheduledTurn = (turn) => turn?.origin?.kind === 'scheduled-loop';
24
+ /**
25
+ * Two matching realpath observations narrow the opportunity for a concurrent
26
+ * retarget, but are only an advisory consistency check: they do not lock the
27
+ * path. A mutation after the completed check remains an unavoidable TOCTOU
28
+ * window until ACP offers handle-based access.
29
+ */
30
+ function stableRealpath(path) {
31
+ const first = realpathSync.native(path);
32
+ const second = realpathSync.native(path);
33
+ return first === second ? first : undefined;
34
+ }
35
+ /** Distinguish an absent component from a dangling symlink at that component. */
36
+ function inspectMissingPath(path) {
37
+ try {
38
+ const stat = lstatSync(path);
39
+ // realpath said ENOENT but lstat found a non-link: the path changed while
40
+ // inspected, so there is no coherent canonical answer to trust.
41
+ if (!stat.isSymbolicLink())
42
+ return { kind: 'unsafe' };
43
+ try {
44
+ return { kind: 'symlink', target: resolve(dirname(path), readlinkSync(path)) };
45
+ }
46
+ catch {
47
+ return { kind: 'unsafe' };
48
+ }
49
+ }
50
+ catch (error) {
51
+ return error.code === 'ENOENT'
52
+ ? { kind: 'absent' }
53
+ : { kind: 'unsafe' };
54
+ }
55
+ }
56
+ /**
57
+ * Canonicalize an existing path, or a not-yet-created target through its
58
+ * nearest existing ancestor. Dangling links are followed explicitly because
59
+ * realpath reports their absent target as ENOENT. Only genuine absence is
60
+ * recoverable; loops, permissions, races, and every other error fail closed.
61
+ */
62
+ function canonicalTarget(path, symlinkDepth = 0) {
63
+ let probe = resolve(path);
64
+ const missing = [];
65
+ while (true) {
66
+ try {
67
+ let canonical = stableRealpath(probe);
68
+ if (!canonical)
69
+ return undefined;
70
+ // A component may have appeared while the ancestor search was in
71
+ // progress. Re-walk the suffix so a newly-created symlink is resolved,
72
+ // not treated as a lexical child of the old ancestor.
73
+ let logical = probe;
74
+ for (let i = 0; i < missing.length; i++) {
75
+ logical = resolve(logical, missing[i]);
76
+ try {
77
+ const appeared = stableRealpath(logical);
78
+ if (!appeared)
79
+ return undefined;
80
+ canonical = appeared;
81
+ }
82
+ catch (error) {
83
+ if (error.code !== 'ENOENT')
84
+ return undefined;
85
+ const inspected = inspectMissingPath(logical);
86
+ if (inspected.kind === 'unsafe')
87
+ return undefined;
88
+ if (inspected.kind === 'symlink') {
89
+ if (symlinkDepth >= MAX_CANONICAL_SYMLINK_DEPTH)
90
+ return undefined;
91
+ return canonicalTarget(resolve(inspected.target, ...missing.slice(i + 1)), symlinkDepth + 1);
92
+ }
93
+ return resolve(canonical, ...missing.slice(i));
94
+ }
95
+ }
96
+ return canonical;
97
+ }
98
+ catch (error) {
99
+ if (error.code !== 'ENOENT')
100
+ return undefined;
101
+ const inspected = inspectMissingPath(probe);
102
+ if (inspected.kind === 'unsafe')
103
+ return undefined;
104
+ if (inspected.kind === 'symlink') {
105
+ if (symlinkDepth >= MAX_CANONICAL_SYMLINK_DEPTH)
106
+ return undefined;
107
+ return canonicalTarget(resolve(inspected.target, ...missing), symlinkDepth + 1);
108
+ }
109
+ const parent = dirname(probe);
110
+ if (parent === probe)
111
+ return undefined;
112
+ missing.unshift(basename(probe));
113
+ probe = parent;
114
+ }
115
+ }
116
+ }
117
+ function canonicallyWithin(root, candidates) {
118
+ if (candidates.length === 0)
119
+ return false;
120
+ try {
121
+ const firstRoot = realpathSync.native(root);
122
+ const paths = candidates.map(canonicalTarget);
123
+ const secondRoot = realpathSync.native(root);
124
+ if (firstRoot !== secondRoot || paths.some(path => path === undefined))
125
+ return false;
126
+ return paths.every(candidate => {
127
+ const rel = relative(firstRoot, candidate);
128
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
129
+ });
130
+ }
131
+ catch {
132
+ return false;
133
+ }
134
+ }
22
135
  /**
23
136
  * Map typed prompt provenance to the conversation ledger's source vocabulary.
24
137
  * Only operator-authored local sources may persist prompt bodies; external
@@ -102,6 +215,15 @@ export class AcpSession {
102
215
  /** Armed when the last controller detaches; unattended policy applies on fire. */
103
216
  controllerGrace;
104
217
  cancelEscalation;
218
+ cancelForceKill;
219
+ cancelRecoveryReason;
220
+ /**
221
+ * Rejects the moment the adapter process is gone. Every in-flight ACP request
222
+ * races it, so a dead adapter can never leave a turn — and therefore a
223
+ * scheduled run's `activeRunId` or an admission claim — unsettled forever.
224
+ */
225
+ terminated;
226
+ terminate;
105
227
  /** ACP-authenticated in-flight calls, including independently reserved permissions. */
106
228
  activeToolCalls = new Map();
107
229
  toolBoundaryWaiters = new Set();
@@ -116,16 +238,29 @@ export class AcpSession {
116
238
  roleId: options.name, log: line => options.log(`[${options.name}] ${line}`),
117
239
  });
118
240
  this.sessionFile = join(options.stateDir, '.acp-session-id');
241
+ this.terminated = new Promise((_resolve, reject) => { this.terminate = reject; });
242
+ // Nothing awaits this promise until a request races it; an unobserved
243
+ // rejection here must never take the whole runner down.
244
+ this.terminated.catch(() => undefined);
119
245
  child.stderr.on('data', chunk => options.log(`[${options.name}] acp: ${String(chunk).trimEnd()}`));
120
246
  child.once('exit', (code, signal) => {
247
+ if (this.cancelForceKill)
248
+ clearTimeout(this.cancelForceKill);
249
+ this.cancelForceKill = undefined;
121
250
  // Record the child's real exit code/signal. The tmux path can only see a
122
251
  // shell's `$?`; here the truth is available, so keep it.
123
- this.exit = classifyChildExit(code, signal);
252
+ const classified = classifyChildExit(code, signal);
253
+ this.exit = this.cancelRecoveryReason
254
+ ? { ...classified, detail: `${this.cancelRecoveryReason}; ${classified.detail}` }
255
+ : classified;
124
256
  options.log(`[${options.name}] acp: agent exited (${code ?? signal ?? 'unknown'})`);
125
257
  if (this.readiness !== 'failed') {
126
258
  this.readiness = 'failed';
127
259
  this.lastError = `ACP agent ${this.exit.detail}`;
128
260
  }
261
+ // A request whose peer no longer exists will never answer. Fail it here
262
+ // rather than trusting the transport to notice the closed stream.
263
+ this.terminate(new SessionControlError('offline', `ACP agent ${this.exit.detail}`));
129
264
  this.events.emit('state', { status: 'failed', text: this.lastError });
130
265
  this.conversation.appendSafe({
131
266
  kind: 'session.state', sessionGeneration: this.sessionGeneration,
@@ -207,7 +342,10 @@ export class AcpSession {
207
342
  }
208
343
  }
209
344
  isAlive() {
210
- return this.child.exitCode === null && !this.child.killed;
345
+ // child.killed only means kill() successfully SENT a signal. A process may
346
+ // ignore SIGTERM and remain alive. exitCode or signalCode is the actual
347
+ // terminal fact (signal exits deliberately leave exitCode null).
348
+ return this.child.exitCode === null && (this.child.signalCode ?? null) === null;
211
349
  }
212
350
  snapshot() {
213
351
  return {
@@ -313,8 +451,28 @@ export class AcpSession {
313
451
  });
314
452
  }
315
453
  /**
316
- * Monitor-only safe-boundary delivery. Steering is the interruption: this
317
- * path never calls session/cancel and never resolves a pending permission.
454
+ * Steering is an optional admission fast path, not the only safe way to
455
+ * deliver a wake. Codex can reject `_session/steering` while a long-running
456
+ * turn is between tools. Queue one ordinary, non-cancelling prompt in that
457
+ * case and wait for its terminal result. This keeps the monitor's cursor
458
+ * uncommitted until the wake really runs and, critically, keeps one rejected
459
+ * steering response from becoming a tight replay loop.
460
+ */
461
+ async steerOrQueueWake(text, options) {
462
+ const steered = await this.steerPrompt(text);
463
+ if (steered.accepted || steered.detail !== 'ACP steering failed'
464
+ || this.closing || !this.isAlive())
465
+ return steered;
466
+ const queued = await this.submitPrompt(text, { ...options, interrupt: false, steer: false });
467
+ return {
468
+ ...queued,
469
+ detail: `steering rejected; queued delivery ${queued.detail ?? queued.outcome}`,
470
+ };
471
+ }
472
+ /**
473
+ * Monitor-only safe-boundary delivery. Steering is the preferred live
474
+ * insertion and rejected steering is queued: this path never calls
475
+ * session/cancel and never resolves a pending permission.
318
476
  */
319
477
  async submitPromptAfterTool(text, options = {}) {
320
478
  if (this.closing || !this.isAlive())
@@ -331,7 +489,7 @@ export class AcpSession {
331
489
  }
332
490
  if (initialToolCount === 0) {
333
491
  this.recordAfterToolDelivery('direct', 0, 0);
334
- const result = await this.steerPrompt(text);
492
+ const result = await this.steerOrQueueWake(text, options);
335
493
  return { ...result, safeBoundary: { state: 'direct', waitedMs: 0, activeToolCount: 0 } };
336
494
  }
337
495
  this.recordAfterToolDelivery('deferred', initialToolCount, 0);
@@ -352,7 +510,7 @@ export class AcpSession {
352
510
  const state = atBoundary ? 'after_tool' : 'timeout';
353
511
  const remainingToolCount = this.activeToolCalls.size;
354
512
  this.recordAfterToolDelivery(state, remainingToolCount, waitedMs);
355
- const result = await this.steerPrompt(text);
513
+ const result = await this.steerOrQueueWake(text, options);
356
514
  return {
357
515
  ...result,
358
516
  safeBoundary: { state, waitedMs, activeToolCount: remainingToolCount },
@@ -364,7 +522,9 @@ export class AcpSession {
364
522
  * for it is what turned a busy agent into a timeout and then into "dead".
365
523
  */
366
524
  async queuePrompt(text, options = {}) {
367
- if (!this.sessionId || !this.isAlive())
525
+ if (this.cancelRecoveryReason)
526
+ throw new SessionControlError('control-unavailable', 'ACP adapter restart is in progress after the cancellation deadline', ACP_CANCEL_DEADLINE_EXCEEDED);
527
+ if (this.closing || !this.sessionId || !this.isAlive())
368
528
  throw new SessionControlError('offline', this.lastError ?? 'ACP session is offline');
369
529
  if (options.interrupt)
370
530
  await this.cancelActive(options.interruptSource ?? 'local-console');
@@ -458,8 +618,24 @@ export class AcpSession {
458
618
  throw error;
459
619
  }
460
620
  }
621
+ /**
622
+ * Explicit cancellation on behalf of a human or an operator. Forced recovery
623
+ * is reported as an outcome, never as a thrown failure: by the time this
624
+ * resolves the turn is over either way, and only the durable-ingress path
625
+ * (`queuePrompt({ interrupt: true })`) needs the typed error, because only it
626
+ * still owes an undelivered message a replay.
627
+ */
461
628
  async interrupt(source = 'local-console') {
462
- await this.cancelActive(source);
629
+ try {
630
+ await this.cancelActive(source);
631
+ return { state: 'settled' };
632
+ }
633
+ catch (error) {
634
+ if (error instanceof SessionControlError
635
+ && error.reasonCode === ACP_CANCEL_DEADLINE_EXCEEDED)
636
+ return { state: 'forced', reasonCode: ACP_CANCEL_DEADLINE_EXCEEDED };
637
+ throw error;
638
+ }
463
639
  }
464
640
  async cancelActive(source) {
465
641
  if (!this.sessionId)
@@ -482,21 +658,66 @@ export class AcpSession {
482
658
  acpSessionId: this.sessionId, promptId: active.id, turnId: active.id,
483
659
  payload: { cancellationSource: source },
484
660
  });
485
- if (this.cancelEscalation)
486
- clearTimeout(this.cancelEscalation);
487
- const turnId = active.id;
661
+ }
662
+ for (const [permissionId, pending] of [...this.pendingPermissions])
663
+ this.settlePendingAutomatically(permissionId, pending, 'cancelled', undefined, 'the turn was cancelled while this request was pending');
664
+ if (active && this.activeTurn === active) {
665
+ active.cancellationWait ??= this.awaitCancellationSettlement(active);
666
+ await active.cancellationWait;
667
+ }
668
+ }
669
+ /**
670
+ * Do not admit work behind a turn whose adapter may already require restart.
671
+ * A cooperative adapter settles this promise immediately through runPrompt's
672
+ * finally block. A stubborn adapter receives SIGTERM at the deadline and
673
+ * SIGKILL after one more bounded grace; callers get a typed recovery error so
674
+ * durable ingress can leave the next request replayable for the resumed run.
675
+ */
676
+ awaitCancellationSettlement(active) {
677
+ const settleMs = this.options.cancelGraceMs ?? CANCEL_SETTLE_GRACE_MS;
678
+ const deadline = new Promise((resolve, reject) => {
488
679
  this.cancelEscalation = setTimeout(() => {
489
- if (this.activeTurn?.id !== turnId || !this.isAlive())
680
+ // Both bail-outs must SETTLE the race. Returning silently once left the
681
+ // caller — and the arbiter's exclusive tail behind it — awaiting a
682
+ // promise nothing would ever resolve.
683
+ if (this.activeTurn !== active) {
684
+ resolve();
685
+ return;
686
+ }
687
+ if (!this.isAlive()) {
688
+ resolve();
490
689
  return;
491
- this.lastError = 'ACP turn ignored cancellation; restarting adapter';
492
- this.options.log(`[${this.options.name}] ${this.lastError}`);
493
- this.events.emit('error', { turnId, origin: active.origin, text: this.lastError });
690
+ }
691
+ active.cancellationDeadlineExceeded = true;
692
+ this.cancelRecoveryReason = ACP_CANCEL_DEADLINE_EXCEEDED;
693
+ this.readiness = 'failed';
694
+ this.lastError = `${ACP_CANCEL_DEADLINE_EXCEEDED}: ACP turn did not settle within ${settleMs}ms`;
695
+ this.options.log(`[${this.options.name}] ${this.lastError}; restarting adapter with resume`);
696
+ this.events.emit('error', {
697
+ turnId: active.id, origin: active.origin, status: ACP_CANCEL_DEADLINE_EXCEEDED,
698
+ text: this.lastError,
699
+ });
700
+ this.conversation.appendSafe({
701
+ kind: 'session.state', sessionGeneration: this.sessionGeneration,
702
+ acpSessionId: this.sessionId,
703
+ promptId: active.id, turnId: active.id,
704
+ payload: { status: 'failed', detail: this.lastError },
705
+ });
494
706
  this.child.kill('SIGTERM');
495
- }, this.options.cancelGraceMs ?? CANCEL_SETTLE_GRACE_MS);
707
+ const terminateMs = this.options.cancelTerminateGraceMs ?? CANCEL_TERMINATE_GRACE_MS;
708
+ this.cancelForceKill = setTimeout(() => {
709
+ if (this.child.exitCode === null) {
710
+ this.options.log(`[${this.options.name}] ${ACP_CANCEL_DEADLINE_EXCEEDED}: `
711
+ + `adapter ignored SIGTERM for ${terminateMs}ms; sending SIGKILL`);
712
+ this.child.kill('SIGKILL');
713
+ }
714
+ }, terminateMs);
715
+ this.cancelForceKill.unref?.();
716
+ reject(new SessionControlError('control-unavailable', 'ACP adapter restart is in progress after the cancellation deadline', ACP_CANCEL_DEADLINE_EXCEEDED));
717
+ }, settleMs);
496
718
  this.cancelEscalation.unref?.();
497
- }
498
- for (const [permissionId, pending] of [...this.pendingPermissions])
499
- this.settlePendingAutomatically(permissionId, pending, 'cancelled', undefined, 'the turn was cancelled while this request was pending');
719
+ });
720
+ return Promise.race([active.settled, deadline]);
500
721
  }
501
722
  respondPermission(permissionId, optionId) {
502
723
  const pending = this.pendingPermissions.get(permissionId);
@@ -628,6 +849,9 @@ export class AcpSession {
628
849
  if (this.cancelEscalation)
629
850
  clearTimeout(this.cancelEscalation);
630
851
  this.cancelEscalation = undefined;
852
+ if (this.cancelForceKill)
853
+ clearTimeout(this.cancelForceKill);
854
+ this.cancelForceKill = undefined;
631
855
  if (this.controllerGrace)
632
856
  clearTimeout(this.controllerGrace);
633
857
  this.controllerGrace = undefined;
@@ -640,6 +864,8 @@ export class AcpSession {
640
864
  this.connection.close();
641
865
  if (this.isAlive())
642
866
  this.child.kill('SIGTERM');
867
+ // The transport is gone: nothing still awaiting an ACP answer can get one.
868
+ this.terminate(new SessionControlError('offline', 'the ACP session was closed'));
643
869
  this.conversation.appendSafe({
644
870
  kind: 'session.state', sessionGeneration: this.sessionGeneration,
645
871
  acpSessionId: this.sessionId, payload: { status: 'offline' },
@@ -727,7 +953,9 @@ export class AcpSession {
727
953
  if (!this.sessionId || !this.isAlive())
728
954
  return turnResult(false, 'failed', this.lastError ?? 'ACP session is offline');
729
955
  this.readiness = 'running';
730
- this.activeTurn = { id: turnId, output: '', origin };
956
+ let settle;
957
+ const settled = new Promise(resolve => { settle = resolve; });
958
+ this.activeTurn = { id: turnId, output: '', origin, settled, settle };
731
959
  this.events.emit('state', { turnId, status: 'running', origin });
732
960
  this.conversation.appendSafe({
733
961
  kind: 'prompt.started', sessionGeneration: this.sessionGeneration,
@@ -735,10 +963,13 @@ export class AcpSession {
735
963
  source: conversationSource(origin).source, payload: {},
736
964
  });
737
965
  try {
738
- const response = await this.connection.agent.request(acp.methods.agent.session.prompt, {
739
- sessionId: this.sessionId,
740
- prompt: promptContentBlocks(text, origin),
741
- });
966
+ const response = await Promise.race([
967
+ this.connection.agent.request(acp.methods.agent.session.prompt, {
968
+ sessionId: this.sessionId,
969
+ prompt: promptContentBlocks(text, origin),
970
+ }),
971
+ this.terminated,
972
+ ]);
742
973
  this.readiness = 'idle';
743
974
  const cancellationSource = this.activeTurn?.id === turnId
744
975
  ? this.activeTurn.cancellationSource : undefined;
@@ -760,7 +991,11 @@ export class AcpSession {
760
991
  return turnResult(true, classifyStopReason(response.stopReason), response.stopReason, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined, this.activeTurn?.id === turnId ? this.activeTurn.cancellationSource : undefined);
761
992
  }
762
993
  catch (error) {
763
- const detail = error?.message ?? String(error);
994
+ const escalated = this.activeTurn?.id === turnId
995
+ && this.activeTurn.cancellationDeadlineExceeded;
996
+ const detail = escalated
997
+ ? this.lastError ?? ACP_CANCEL_DEADLINE_EXCEEDED
998
+ : error?.message ?? String(error);
764
999
  this.lastError = origin?.kind === 'scheduled-loop' ? 'scheduled-loop turn failed' : detail;
765
1000
  this.readiness = this.isAlive() ? 'idle' : 'failed';
766
1001
  this.events.emit('error', {
@@ -779,6 +1014,7 @@ export class AcpSession {
779
1014
  finally {
780
1015
  this.releaseAllTools();
781
1016
  if (this.activeTurn?.id === turnId) {
1017
+ this.activeTurn.settle();
782
1018
  if (this.cancelEscalation)
783
1019
  clearTimeout(this.cancelEscalation);
784
1020
  this.cancelEscalation = undefined;
@@ -790,10 +1026,13 @@ export class AcpSession {
790
1026
  if (!this.sessionId || !this.isAlive())
791
1027
  return turnResult(false, 'failed', this.lastError ?? 'ACP session is offline');
792
1028
  try {
793
- const response = await this.connection.agent.request('_session/steering', {
794
- sessionId: this.sessionId,
795
- prompt: [{ type: 'text', text }],
796
- });
1029
+ const response = await Promise.race([
1030
+ this.connection.agent.request('_session/steering', {
1031
+ sessionId: this.sessionId,
1032
+ prompt: [{ type: 'text', text }],
1033
+ }),
1034
+ this.terminated,
1035
+ ]);
797
1036
  if (response.outcome === 'failed')
798
1037
  return turnResult(false, 'failed', 'ACP steering failed');
799
1038
  return turnResult(true, 'inconclusive', response.outcome);
@@ -947,11 +1186,7 @@ export class AcpSession {
947
1186
  if (locations.length === 0)
948
1187
  return false;
949
1188
  const cwd = resolve(this.options.cwd);
950
- return locations.every(location => {
951
- const path = resolve(location.path);
952
- const rel = relative(cwd, path);
953
- return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
954
- });
1189
+ return canonicallyWithin(cwd, locations.map(location => resolve(location.path)));
955
1190
  }
956
1191
  recordUpdate(update) {
957
1192
  const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
@@ -1,4 +1,4 @@
1
- import type { ConversationHandlePage, ExitRecord, PromptOrigin, QueuedPrompt, SessionEvent, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnResult } from './types.js';
1
+ import type { ConversationHandlePage, ExitRecord, InterruptOutcome, PromptOrigin, QueuedPrompt, SessionEvent, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnResult } from './types.js';
2
2
  import type { ConversationEventV1, ConversationSnapshot, PromptReceipt, SubmitPromptCommand } from './conversation-types.js';
3
3
  export type ScheduledAttempt = {
4
4
  state: 'started';
@@ -22,9 +22,35 @@ export declare class RoleTurnArbiter implements SessionHandle {
22
22
  private tail;
23
23
  private unsettled;
24
24
  private stopping;
25
+ /** Bumped by `retireStalledAdmission`; every claim carries the one it was made under. */
26
+ private generation;
27
+ /** Resolves the instant the CURRENT generation is retired. */
28
+ private retirement;
25
29
  constructor(session: SessionHandle);
30
+ /**
31
+ * Waiters race the tail against their own generation's retirement, so a
32
+ * single operation that never settles cannot own the boundary forever. A
33
+ * woken waiter from a retired generation never runs its operation: the
34
+ * ordering it was promised no longer exists, and admitting it silently would
35
+ * be the one thing worse than telling the caller it was not admitted.
36
+ */
26
37
  private exclusive;
27
38
  private track;
39
+ /**
40
+ * Release the admission boundary after a cancellation that never settled.
41
+ * Nothing can make a hung ACP call return, so the stuck operation keeps its
42
+ * own promise — it just stops owning `tail` and stops holding a claim.
43
+ *
44
+ * Without this, one unsettled `interrupt` left every later producer queued
45
+ * behind it forever: a scheduled poll never returned, so the loop was never
46
+ * rescheduled and reported neither `started` nor `skipped_busy`, and an owner
47
+ * prompt stayed pending with nothing to resolve it.
48
+ *
49
+ * One-active-run safety is unchanged. The arbiter only orders admission;
50
+ * `tryScheduled` still asks the session itself whether it is idle, and the
51
+ * session still refuses to run two turns at once.
52
+ */
53
+ retireStalledAdmission(): void;
28
54
  queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
29
55
  submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
30
56
  /**
@@ -45,7 +71,7 @@ export declare class RoleTurnArbiter implements SessionHandle {
45
71
  conversationSnapshot(): ConversationSnapshot;
46
72
  subscribeConversation(listener: (event: ConversationEventV1) => void): () => void;
47
73
  submitPromptBrowser(command: SubmitPromptCommand): Promise<PromptReceipt>;
48
- interrupt(source?: TurnCancellationSource): Promise<void>;
74
+ interrupt(source?: TurnCancellationSource): Promise<InterruptOutcome>;
49
75
  respondPermission(permissionId: string, optionId: string): boolean;
50
76
  respondPermissionV2(permissionId: string, optionId: string, sessionGeneration: string): 'accepted' | 'stale';
51
77
  eventsSince(seq: number): SessionEvent[];
@@ -1,3 +1,20 @@
1
+ import { ACP_CANCEL_DEADLINE_EXCEEDED, SessionControlError, interruptOutcome, } from './types.js';
2
+ /**
3
+ * Raised for admissions that were still waiting on a generation which had to be
4
+ * retired. It carries `ACP_CANCEL_DEADLINE_EXCEEDED` because that is the only
5
+ * thing that ever retires a generation, and because durable owner ingress
6
+ * already treats that reason as "never delivered — replay it".
7
+ */
8
+ class AdmissionRetiredError extends SessionControlError {
9
+ constructor() {
10
+ super('control-unavailable', 'admission was retired after a cancellation that never settled', ACP_CANCEL_DEADLINE_EXCEEDED);
11
+ }
12
+ }
13
+ function retirementSignal() {
14
+ let retire;
15
+ const promise = new Promise(resolve => { retire = resolve; });
16
+ return { promise, retire };
17
+ }
1
18
  /**
2
19
  * One in-process admission boundary for every producer targeting a role.
3
20
  * Scheduled callers get an atomic idle recheck plus submission; ordinary
@@ -11,21 +28,64 @@ export class RoleTurnArbiter {
11
28
  tail = Promise.resolve();
12
29
  unsettled = 0;
13
30
  stopping = false;
31
+ /** Bumped by `retireStalledAdmission`; every claim carries the one it was made under. */
32
+ generation = 0;
33
+ /** Resolves the instant the CURRENT generation is retired. */
34
+ retirement = retirementSignal();
14
35
  constructor(session) {
15
36
  this.session = session;
16
37
  this.backend = session.backend;
17
38
  this.pid = session.pid;
18
39
  }
40
+ /**
41
+ * Waiters race the tail against their own generation's retirement, so a
42
+ * single operation that never settles cannot own the boundary forever. A
43
+ * woken waiter from a retired generation never runs its operation: the
44
+ * ordering it was promised no longer exists, and admitting it silently would
45
+ * be the one thing worse than telling the caller it was not admitted.
46
+ */
19
47
  exclusive(operation) {
20
- const run = this.tail.then(operation);
48
+ const generation = this.generation;
49
+ const run = Promise.race([this.tail, this.retirement.promise]).then(() => {
50
+ if (generation !== this.generation)
51
+ throw new AdmissionRetiredError();
52
+ return operation();
53
+ });
21
54
  this.tail = run.then(() => undefined, () => undefined);
22
55
  return run;
23
56
  }
24
57
  track(queued) {
58
+ const generation = this.generation;
25
59
  this.unsettled++;
26
- const completion = queued.completion.finally(() => { this.unsettled = Math.max(0, this.unsettled - 1); });
60
+ const completion = queued.completion.finally(() => {
61
+ // A retired generation's claims were released in one step already.
62
+ if (generation === this.generation)
63
+ this.unsettled = Math.max(0, this.unsettled - 1);
64
+ });
27
65
  return { ...queued, completion };
28
66
  }
67
+ /**
68
+ * Release the admission boundary after a cancellation that never settled.
69
+ * Nothing can make a hung ACP call return, so the stuck operation keeps its
70
+ * own promise — it just stops owning `tail` and stops holding a claim.
71
+ *
72
+ * Without this, one unsettled `interrupt` left every later producer queued
73
+ * behind it forever: a scheduled poll never returned, so the loop was never
74
+ * rescheduled and reported neither `started` nor `skipped_busy`, and an owner
75
+ * prompt stayed pending with nothing to resolve it.
76
+ *
77
+ * One-active-run safety is unchanged. The arbiter only orders admission;
78
+ * `tryScheduled` still asks the session itself whether it is idle, and the
79
+ * session still refuses to run two turns at once.
80
+ */
81
+ retireStalledAdmission() {
82
+ const retired = this.retirement;
83
+ this.generation++;
84
+ this.retirement = retirementSignal();
85
+ this.tail = Promise.resolve();
86
+ this.unsettled = 0;
87
+ retired.retire();
88
+ }
29
89
  queuePrompt(text, options = {}) {
30
90
  return this.exclusive(async () => this.track(await this.session.queuePrompt(text, options)));
31
91
  }
@@ -54,13 +114,24 @@ export class RoleTurnArbiter {
54
114
  try {
55
115
  await beforeQueue?.();
56
116
  const queued = await this.session.queuePrompt(text, { interrupt: false, origin });
57
- if (queued.queuedBehind > 0)
117
+ // The race is lost, but the prompt was admitted and WILL run. Keep the
118
+ // claim so idle accounting matches reality instead of under-counting.
119
+ if (queued.queuedBehind > 0) {
120
+ this.track(queued);
58
121
  return { state: 'unavailable', error: 'scheduled admission race' };
122
+ }
59
123
  return { state: 'started', queued: this.track(queued) };
60
124
  }
61
125
  catch (error) {
62
126
  return { state: 'unavailable', error: error?.message ?? String(error) };
63
127
  }
128
+ }).catch(error => {
129
+ // A retired attempt was never submitted. Reporting that as an outcome
130
+ // instead of a rejection is what keeps the poll — and with it the loop's
131
+ // own rescheduling — from being abandoned mid-tick.
132
+ if (error instanceof AdmissionRetiredError)
133
+ return { state: 'unavailable', error: error.message };
134
+ throw error;
64
135
  });
65
136
  }
66
137
  stopScheduledAdmission() { this.stopping = true; }
@@ -87,7 +158,7 @@ export class RoleTurnArbiter {
87
158
  return this.exclusive(() => this.session.submitPromptBrowser(command));
88
159
  }
89
160
  interrupt(source = 'local-console') {
90
- return this.exclusive(() => this.session.interrupt(source));
161
+ return this.exclusive(async () => interruptOutcome(await this.session.interrupt(source)));
91
162
  }
92
163
  respondPermission(permissionId, optionId) {
93
164
  return this.session.respondPermission(permissionId, optionId);