@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.
- package/README.md +38 -2
- package/dist/application/role-removal-service.js +1 -1
- package/dist/application/session-control.d.ts +14 -10
- package/dist/application/session-control.js +14 -3
- package/dist/atomic-file.d.ts +7 -1
- package/dist/atomic-file.js +33 -5
- package/dist/build-info.json +10 -0
- package/dist/capabilities.d.ts +20 -0
- package/dist/capabilities.js +21 -0
- package/dist/cli.js +98 -10
- package/dist/config.d.ts +9 -2
- package/dist/config.js +16 -2
- package/dist/creation.d.ts +16 -0
- package/dist/creation.js +28 -0
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +70 -4
- package/dist/doctor.d.ts +5 -0
- package/dist/doctor.js +87 -2
- package/dist/fleet-proxy.js +2 -2
- package/dist/harness/acp-agent.d.ts +3 -0
- package/dist/harness/acp-agent.js +4 -1
- package/dist/harness/codex-app-server-proxy.d.ts +4 -0
- package/dist/harness/codex-app-server-proxy.js +133 -0
- package/dist/harness/codex.js +116 -11
- package/dist/harness/types.d.ts +2 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/loops/manager.d.ts +42 -1
- package/dist/loops/manager.js +115 -16
- package/dist/loops/state.d.ts +46 -2
- package/dist/loops/state.js +81 -3
- package/dist/monitor.d.ts +21 -0
- package/dist/monitor.js +42 -0
- package/dist/ops.d.ts +6 -0
- package/dist/ops.js +46 -1
- package/dist/owner-channel/channel.d.ts +18 -2
- package/dist/owner-channel/channel.js +146 -2
- package/dist/owner-channel/commands.d.ts +2 -2
- package/dist/owner-channel/commands.js +7 -2
- package/dist/owner-channel/notices.d.ts +2 -0
- package/dist/owner-channel/notices.js +3 -0
- package/dist/permissions.d.ts +2 -0
- package/dist/permissions.js +5 -0
- package/dist/provenance.d.ts +77 -0
- package/dist/provenance.js +283 -0
- package/dist/runner.d.ts +7 -1
- package/dist/runner.js +100 -14
- package/dist/session/acp.d.ts +40 -4
- package/dist/session/acp.js +272 -37
- package/dist/session/arbiter.d.ts +28 -2
- package/dist/session/arbiter.js +75 -4
- package/dist/session/control.js +12 -6
- package/dist/session/event-log.d.ts +109 -0
- package/dist/session/event-log.js +247 -0
- package/dist/session/events.d.ts +21 -0
- package/dist/session/events.js +105 -26
- package/dist/session/tmux.d.ts +3 -2
- package/dist/session/tmux.js +2 -0
- package/dist/session/types.d.ts +39 -2
- package/dist/session/types.js +11 -1
- package/dist/spawn.d.ts +3 -3
- package/dist/spawn.js +40 -14
- package/dist/temp-lifecycle.d.ts +62 -0
- package/dist/temp-lifecycle.js +437 -0
- package/package.json +5 -3
package/dist/session/acp.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
317
|
-
*
|
|
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.
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
739
|
-
|
|
740
|
-
|
|
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
|
|
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
|
|
794
|
-
|
|
795
|
-
|
|
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.
|
|
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<
|
|
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[];
|
package/dist/session/arbiter.js
CHANGED
|
@@ -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
|
|
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(() => {
|
|
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
|
-
|
|
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);
|