@ours.network/fleet 0.17.8 → 0.17.10
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/dist/build-info.json +4 -4
- package/dist/cli.js +8 -0
- package/dist/config.js +15 -8
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +27 -2
- package/dist/harness/claude-code.js +198 -7
- package/dist/harness/codex.js +10 -1
- package/dist/harness/types.d.ts +50 -1
- package/dist/model-env.d.ts +71 -0
- package/dist/model-env.js +106 -0
- package/dist/ops.js +1 -1
- package/dist/owner-channel/channel.d.ts +10 -0
- package/dist/owner-channel/channel.js +28 -13
- package/dist/owner-channel/notices.d.ts +7 -0
- package/dist/owner-channel/notices.js +9 -0
- package/dist/runner.d.ts +48 -0
- package/dist/runner.js +220 -92
- package/dist/session/acp.d.ts +46 -0
- package/dist/session/acp.js +62 -7
- package/dist/session/types.d.ts +11 -0
- package/dist/spawn.js +29 -17
- package/package.json +1 -1
package/dist/runner.js
CHANGED
|
@@ -24,6 +24,7 @@ import { RoleTurnArbiter } from './session/arbiter.js';
|
|
|
24
24
|
import { ScheduledLoopManager, } from './loops/manager.js';
|
|
25
25
|
import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, inheritCallerSpawnDefaults, } from './fleet-proxy.js';
|
|
26
26
|
import { effectivePermissionMode } from './permissions.js';
|
|
27
|
+
import { assertModelPinReachesChild, effectiveRoleModel, repinModelEnv } from './model-env.js';
|
|
27
28
|
import { archiveTempState, markTempSupervisorActive, requestedTempStopReason, } from './temp-lifecycle.js';
|
|
28
29
|
const defaultDeps = () => ({
|
|
29
30
|
tmux: new Tmux(),
|
|
@@ -72,6 +73,19 @@ export function managedFleetProxyEnv(role, stateDir) {
|
|
|
72
73
|
[FLEET_PROXY_CALLER_ENV]: role.name,
|
|
73
74
|
};
|
|
74
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* The environment a managed harness child actually receives, checked at the one
|
|
78
|
+
* point where it is composed. `role.env` deliberately wins over harness prep,
|
|
79
|
+
* which is exactly how a stale fleet-wide model pin used to outrank the model
|
|
80
|
+
* the role was spawned with — so the model pin is verified here rather than
|
|
81
|
+
* trusted, and a disagreement stops the launch instead of being reported as a
|
|
82
|
+
* success (see src/model-env.ts).
|
|
83
|
+
*/
|
|
84
|
+
export function harnessChildEnv(role, launchEnv, stateDir) {
|
|
85
|
+
const env = { ...(launchEnv ?? {}), ...managedFleetProxyEnv(role, stateDir) };
|
|
86
|
+
assertModelPinReachesChild(role, env);
|
|
87
|
+
return env;
|
|
88
|
+
}
|
|
75
89
|
/**
|
|
76
90
|
* Execute a typed proxy request in the caller's supervisor. Dynamic imports
|
|
77
91
|
* avoid a runner↔spawn initialization cycle (spawn imports runner constants).
|
|
@@ -109,7 +123,9 @@ async function executeManagedSpawn(caller, configPath, requested, log) {
|
|
|
109
123
|
statePath,
|
|
110
124
|
harness: preview.harness,
|
|
111
125
|
session: preview.session,
|
|
112
|
-
|
|
126
|
+
// Read back from the resolved environment, not from the request: the banner
|
|
127
|
+
// must name the model the child will run, not the one that was asked for.
|
|
128
|
+
...(effectiveRoleModel(preview) ? { model: effectiveRoleModel(preview) } : {}),
|
|
113
129
|
monitor: { mode: preview.monitor.mode, interrupt: preview.monitor.interrupt },
|
|
114
130
|
permissionMode: effectivePermissionMode(preview),
|
|
115
131
|
inherited,
|
|
@@ -117,6 +133,7 @@ async function executeManagedSpawn(caller, configPath, requested, log) {
|
|
|
117
133
|
};
|
|
118
134
|
log(`[${caller.name}] managed fleet proxy spawned ${result.lifetime} role ${result.role} `
|
|
119
135
|
+ `harness=${result.harness} session=${result.session} `
|
|
136
|
+
+ `model=${result.model ?? '(harness default)'} `
|
|
120
137
|
+ `permission=${result.permissionMode.fleetMode} `
|
|
121
138
|
+ `native=${result.permissionMode.nativeMode}`);
|
|
122
139
|
return result;
|
|
@@ -230,6 +247,66 @@ const emptyLedger = () => ({
|
|
|
230
247
|
circuit: 'closed',
|
|
231
248
|
updatedAt: new Date(0).toISOString(),
|
|
232
249
|
});
|
|
250
|
+
/**
|
|
251
|
+
* Carried across a supervisor process's life so its successor can tell an
|
|
252
|
+
* orderly exit from a kill. Present on disk == "a supervisor believed it was
|
|
253
|
+
* running"; the next start finding one that is not its own is proof the
|
|
254
|
+
* previous process died without getting to write anything.
|
|
255
|
+
*/
|
|
256
|
+
export const RUN_MARKER_FILE = '.supervisor-run.json';
|
|
257
|
+
function readRunMarker(dir) {
|
|
258
|
+
try {
|
|
259
|
+
const raw = JSON.parse(readFileSync(join(dir, RUN_MARKER_FILE), 'utf8'));
|
|
260
|
+
return raw.version === 1 && typeof raw.pid === 'number' && typeof raw.startedAt === 'string'
|
|
261
|
+
? raw : undefined;
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Claim this state directory for the current supervisor process and report how
|
|
269
|
+
* the previous one ended. Runs BEFORE the first attempt, which is the whole
|
|
270
|
+
* point: after an abrupt kill nothing else writes until an attempt finishes,
|
|
271
|
+
* and an attempt can take minutes.
|
|
272
|
+
*/
|
|
273
|
+
export function claimSupervisorRun(dir, startedAt, pid = process.pid) {
|
|
274
|
+
const previous = readRunMarker(dir);
|
|
275
|
+
const termination = previous && previous.pid !== pid
|
|
276
|
+
? {
|
|
277
|
+
class: 'abrupt',
|
|
278
|
+
detail: `supervisor pid ${previous.pid} left an open run marker; `
|
|
279
|
+
+ 'it was terminated without an orderly exit (signal, OOM-kill, or host reset)',
|
|
280
|
+
observedAt: startedAt,
|
|
281
|
+
runStartedAt: previous.startedAt,
|
|
282
|
+
}
|
|
283
|
+
: previous
|
|
284
|
+
? { class: 'unknown', detail: 'run marker belongs to this process', observedAt: startedAt }
|
|
285
|
+
: { class: 'clean', detail: 'no previous run marker', observedAt: startedAt };
|
|
286
|
+
try {
|
|
287
|
+
mkdirSync(dir, { recursive: true });
|
|
288
|
+
writeFileSync(join(dir, RUN_MARKER_FILE), JSON.stringify({ version: 1, pid, startedAt }, null, 2) + '\n');
|
|
289
|
+
}
|
|
290
|
+
catch { /* diagnostics must never take the role down */ }
|
|
291
|
+
return termination;
|
|
292
|
+
}
|
|
293
|
+
/** Orderly exit: the successor must not read this run as a kill. */
|
|
294
|
+
export function releaseSupervisorRun(dir) {
|
|
295
|
+
try {
|
|
296
|
+
rmSync(join(dir, RUN_MARKER_FILE), { force: true });
|
|
297
|
+
}
|
|
298
|
+
catch { /* best effort */ }
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Fields that describe THIS process's history rather than the current failure
|
|
302
|
+
* streak. Clearing the streak (recovery, an operator `up`, an approved model
|
|
303
|
+
* transition) must not erase the record that the role died and came back.
|
|
304
|
+
*/
|
|
305
|
+
const carriedForward = (previous) => ({
|
|
306
|
+
...(previous.lastTermination ? { lastTermination: previous.lastTermination } : {}),
|
|
307
|
+
...(previous.abruptTerminations ? { abruptTerminations: previous.abruptTerminations } : {}),
|
|
308
|
+
...(previous.supervisorStartedAt ? { supervisorStartedAt: previous.supervisorStartedAt } : {}),
|
|
309
|
+
});
|
|
233
310
|
/** Bounded exponential backoff for the nth consecutive immediate failure. */
|
|
234
311
|
export function backoffFor(consecutiveFailures) {
|
|
235
312
|
if (consecutiveFailures <= 0)
|
|
@@ -264,7 +341,10 @@ export function writeRestartLedger(dir, ledger) {
|
|
|
264
341
|
export function resetRestartLedger(dir) {
|
|
265
342
|
if (!existsSync(dir))
|
|
266
343
|
return;
|
|
267
|
-
|
|
344
|
+
const previous = readRestartLedger(dir);
|
|
345
|
+
writeRestartLedger(dir, {
|
|
346
|
+
...emptyLedger(), ...carriedForward(previous), updatedAt: new Date().toISOString(),
|
|
347
|
+
});
|
|
268
348
|
}
|
|
269
349
|
/** Filename spawnTemp writes into a temp agent dir to carry the fleet start-stagger. */
|
|
270
350
|
export const START_STAGGER_FILE = '.start-stagger-ms';
|
|
@@ -395,11 +475,18 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
395
475
|
const effectiveModel = effectiveModelForRole(dir, role);
|
|
396
476
|
if (effectiveModel !== role.model) {
|
|
397
477
|
deps.log(`[${name}] model recovery drift: declared=${role.model ?? '(none)'} effective=${effectiveModel}`);
|
|
398
|
-
|
|
478
|
+
// The env pin has to move with it. A down-shift that changed only
|
|
479
|
+
// `role.model` was reported as a model change while the child kept running
|
|
480
|
+
// the model that had just failed, because the pin is what the harness reads.
|
|
481
|
+
role = { ...role, model: effectiveModel, env: repinModelEnv(role, effectiveModel) };
|
|
399
482
|
}
|
|
400
483
|
if (modelRecoveryHeld(dir))
|
|
401
484
|
throw new Error(`[${name}] model chain exhausted — held down until config changes or recovery reset`);
|
|
402
485
|
const adapter = getAdapter(role.harness);
|
|
486
|
+
// Say the running model out loud, once, from the resolved environment. The
|
|
487
|
+
// spawn banner is a claim made before the process exists; this is the log line
|
|
488
|
+
// that can be checked against the session afterwards.
|
|
489
|
+
deps.log(`[${name}] model: ${effectiveRoleModel(role) ?? '(harness default)'}`);
|
|
403
490
|
mkdirSync(dir, { recursive: true });
|
|
404
491
|
const rotation = rotateWorklog(join(dir, 'WORKLOG.md'), role.worklog);
|
|
405
492
|
if (rotation.deferred)
|
|
@@ -414,8 +501,12 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
414
501
|
const exitFile = join(dir, '.exit-status');
|
|
415
502
|
const booted = existsSync(bootedFile);
|
|
416
503
|
const mode = booted && adapter.supportsResume ? 'resume' : 'fresh';
|
|
417
|
-
|
|
418
|
-
|
|
504
|
+
// Stamp EVERY attempt, not just the first. `.booted` used to be written only
|
|
505
|
+
// on the fresh path, so after a restart — including one the supervisor never
|
|
506
|
+
// saw, like an OOM-kill — its mtime still read the original boot and any
|
|
507
|
+
// health check reading it reported "no restarts". The existence test above
|
|
508
|
+
// already ran, so rewriting cannot change the fresh/resume decision.
|
|
509
|
+
writeFileSync(bootedFile, `${new Date(deps.now()).toISOString()} ${mode}\n`);
|
|
419
510
|
const runCwd = role.cwd && existsSync(role.cwd) ? role.cwd : dir;
|
|
420
511
|
const prep = await adapter.prepareSession(role, { stateDir: dir, runCwd });
|
|
421
512
|
const sessionBackend = role.session ?? 'tmux';
|
|
@@ -536,11 +627,18 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
536
627
|
name,
|
|
537
628
|
argv: wrappedArgv,
|
|
538
629
|
cwd: runCwd,
|
|
539
|
-
env:
|
|
630
|
+
env: harnessChildEnv(role, launch.env, dir),
|
|
540
631
|
stateDir: dir,
|
|
541
632
|
mode,
|
|
542
633
|
permissions: perms,
|
|
543
634
|
modeId: adapter.acpPermissionModeId?.(role),
|
|
635
|
+
// The role's declared MCP servers, and the bundled agent's `_meta`
|
|
636
|
+
// vocabulary for the options it takes no flag for. Both come from the
|
|
637
|
+
// ADAPTER and from `prep`: the ACP launch cannot carry `prep.argv`, so this
|
|
638
|
+
// is the route by which harness_options that used to be silently dropped
|
|
639
|
+
// for an ACP role actually reach the session.
|
|
640
|
+
mcpServers: adapter.acpMcpServers?.(role),
|
|
641
|
+
sessionMeta: adapter.acpSessionMeta?.(role, prep),
|
|
544
642
|
permissionMode: effectivePermissionMode(role),
|
|
545
643
|
// Provenance travels with the exact ACP launch. Keeping it out of a
|
|
546
644
|
// role-only adapter hook prevents a PATH fallback or resolver skew from
|
|
@@ -943,99 +1041,129 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
|
|
|
943
1041
|
mkdirSync(dir, { recursive: true });
|
|
944
1042
|
const shouldStop = deps.shouldStop ?? (() => false);
|
|
945
1043
|
const stamp = () => new Date(deps.now()).toISOString();
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
1044
|
+
// Record how the PREVIOUS supervisor process ended before doing anything
|
|
1045
|
+
// else. An external kill writes nothing itself, and the next ledger write is
|
|
1046
|
+
// an attempt away — which is why an OOM-kill used to leave every durable
|
|
1047
|
+
// indicator describing the run that died.
|
|
1048
|
+
const startedAt = stamp();
|
|
1049
|
+
const termination = claimSupervisorRun(dir, startedAt);
|
|
1050
|
+
{
|
|
1051
|
+
const previous = readRestartLedger(dir);
|
|
1052
|
+
const abrupt = (previous.abruptTerminations ?? 0) + (termination.class === 'abrupt' ? 1 : 0);
|
|
1053
|
+
writeRestartLedger(dir, {
|
|
1054
|
+
...previous,
|
|
1055
|
+
lastTermination: termination,
|
|
1056
|
+
abruptTerminations: abrupt,
|
|
1057
|
+
supervisorStartedAt: startedAt,
|
|
1058
|
+
updatedAt: startedAt,
|
|
1059
|
+
});
|
|
1060
|
+
if (termination.class === 'abrupt')
|
|
1061
|
+
deps.log(`[${name}] previous supervisor run (started ${termination.runStartedAt}) `
|
|
1062
|
+
+ `ended abruptly: ${termination.detail}; abrupt terminations recorded: ${abrupt}`);
|
|
1063
|
+
}
|
|
1064
|
+
try {
|
|
1065
|
+
while (!shouldStop()) {
|
|
1066
|
+
let ledger = readRestartLedger(dir);
|
|
1067
|
+
try {
|
|
1068
|
+
const configPath = resolveConfigPath(dir, opts.configPath);
|
|
1069
|
+
const role = findRole(loadConfig(configPath), name);
|
|
1070
|
+
reconcileModelRecovery(dir, role, stamp());
|
|
1071
|
+
if (modelRecoveryHeld(dir)) {
|
|
1072
|
+
await deps.sleep(HELD_DOWN_POLL_MS);
|
|
1073
|
+
continue;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
catch {
|
|
1077
|
+
// Normal attempt path reports config errors through the restart circuit.
|
|
1078
|
+
}
|
|
1079
|
+
if (ledger.circuit === 'open') {
|
|
1080
|
+
// Held down. Stay alive — exiting would hand the role straight back to
|
|
1081
|
+
// the service manager — and watch for an operator reset.
|
|
953
1082
|
await deps.sleep(HELD_DOWN_POLL_MS);
|
|
954
1083
|
continue;
|
|
955
1084
|
}
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
//
|
|
972
|
-
|
|
973
|
-
result
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1085
|
+
let result;
|
|
1086
|
+
try {
|
|
1087
|
+
result = await attempt(name, { configPath: opts.configPath, allowResumeRotation: !ledger.resumeDiscarded }, deps);
|
|
1088
|
+
}
|
|
1089
|
+
catch (e) {
|
|
1090
|
+
// A session that could not even start is an immediate failure like any
|
|
1091
|
+
// other; it must count, or an unstartable role loops forever.
|
|
1092
|
+
result = {
|
|
1093
|
+
elapsedSecs: 0,
|
|
1094
|
+
exit: { version: 1, class: 'unknown', detail: e instanceof Error ? e.message : String(e) },
|
|
1095
|
+
rotated: false,
|
|
1096
|
+
mode: 'fresh',
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
// Re-read: the attempt itself may have taken minutes, and an operator may
|
|
1100
|
+
// have reset the ledger meanwhile.
|
|
1101
|
+
ledger = readRestartLedger(dir);
|
|
1102
|
+
if (result.modelRecovery === 'advance') {
|
|
1103
|
+
writeRestartLedger(dir, {
|
|
1104
|
+
...emptyLedger(),
|
|
1105
|
+
...carriedForward(ledger),
|
|
1106
|
+
lastReason: 'approved model-chain transition',
|
|
1107
|
+
updatedAt: stamp(),
|
|
1108
|
+
});
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
if (result.modelRecovery === 'hold') {
|
|
1112
|
+
await deps.sleep(HELD_DOWN_POLL_MS);
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
const fastFailSecs = fastFailSecsFor(name, opts.configPath);
|
|
1116
|
+
// The fast-fail boundary starts a recovery episode; it must not also be
|
|
1117
|
+
// the boundary that declares recovery successful. Otherwise alternating
|
|
1118
|
+
// 19s and 20s deaths erase one another forever. Require the configured
|
|
1119
|
+
// number of fast-fail windows to survive before closing an active streak.
|
|
1120
|
+
// This hysteresis stays adapter-relative (100s for the current 20s/5-attempt
|
|
1121
|
+
// policy) and still lets a genuinely sustained session reset the breaker.
|
|
1122
|
+
const stableRecoverySecs = fastFailSecs * RESTART_FAIL_THRESHOLD;
|
|
1123
|
+
const recoveryFailed = result.elapsedSecs < fastFailSecs
|
|
1124
|
+
|| (ledger.consecutiveImmediateFailures > 0 && result.elapsedSecs < stableRecoverySecs);
|
|
1125
|
+
if (!recoveryFailed) {
|
|
1126
|
+
// A session that ran for a while is not a restart loop, whatever ended it.
|
|
1127
|
+
writeRestartLedger(dir, {
|
|
1128
|
+
...emptyLedger(),
|
|
1129
|
+
...carriedForward(ledger),
|
|
1130
|
+
lastReason: result.exit.detail,
|
|
1131
|
+
updatedAt: stamp(),
|
|
1132
|
+
});
|
|
1133
|
+
continue;
|
|
1134
|
+
}
|
|
1135
|
+
const failures = ledger.consecutiveImmediateFailures + 1;
|
|
1136
|
+
const reason = `${result.exit.detail} after ${result.elapsedSecs.toFixed(1)}s`;
|
|
1137
|
+
const next = {
|
|
1138
|
+
version: 1,
|
|
1139
|
+
...carriedForward(ledger),
|
|
1140
|
+
consecutiveImmediateFailures: failures,
|
|
1141
|
+
lastReason: reason,
|
|
1142
|
+
nextDelayMs: backoffFor(failures),
|
|
1143
|
+
resumeDiscarded: ledger.resumeDiscarded || result.rotated,
|
|
1144
|
+
circuit: failures >= RESTART_FAIL_THRESHOLD ? 'open' : 'closed',
|
|
1010
1145
|
updatedAt: stamp(),
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
nextDelayMs: backoffFor(failures),
|
|
1021
|
-
resumeDiscarded: ledger.resumeDiscarded || result.rotated,
|
|
1022
|
-
circuit: failures >= RESTART_FAIL_THRESHOLD ? 'open' : 'closed',
|
|
1023
|
-
updatedAt: stamp(),
|
|
1024
|
-
};
|
|
1025
|
-
if (next.circuit === 'open') {
|
|
1026
|
-
next.openedAt = stamp();
|
|
1027
|
-
next.nextDelayMs = 0;
|
|
1146
|
+
};
|
|
1147
|
+
if (next.circuit === 'open') {
|
|
1148
|
+
next.openedAt = stamp();
|
|
1149
|
+
next.nextDelayMs = 0;
|
|
1150
|
+
writeRestartLedger(dir, next);
|
|
1151
|
+
deps.log(`[${name}] HELD DOWN after ${failures} immediate failures at ${next.openedAt} — ` +
|
|
1152
|
+
`${reason}; the agent will not be restarted until: ours-fleet restart ${name}`);
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
1028
1155
|
writeRestartLedger(dir, next);
|
|
1029
|
-
deps.log(`[${name}]
|
|
1030
|
-
|
|
1031
|
-
|
|
1156
|
+
deps.log(`[${name}] immediate failure ${failures}/${RESTART_FAIL_THRESHOLD} (${reason}) ` +
|
|
1157
|
+
`-> backing off ${next.nextDelayMs}ms`);
|
|
1158
|
+
await deps.sleep(next.nextDelayMs);
|
|
1032
1159
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1160
|
+
return readRestartLedger(dir);
|
|
1161
|
+
}
|
|
1162
|
+
finally {
|
|
1163
|
+
// Only an orderly return through this loop clears the marker; a signal or
|
|
1164
|
+
// an OOM-kill leaves it, which is exactly how the successor detects them.
|
|
1165
|
+
releaseSupervisorRun(dir);
|
|
1037
1166
|
}
|
|
1038
|
-
return readRestartLedger(dir);
|
|
1039
1167
|
}
|
|
1040
1168
|
/**
|
|
1041
1169
|
* How short an attempt has to be to count as immediate. The role's harness
|
package/dist/session/acp.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as acp from '@agentclientprotocol/sdk';
|
|
2
2
|
import type { CommonPermissions } from '../config.js';
|
|
3
|
+
import type { AcpMcpServer } from '../harness/types.js';
|
|
3
4
|
import { ConversationEventStore } from './conversation-store.js';
|
|
4
5
|
import type { ConversationSnapshot, PromptOrigin, PromptReceipt, SubmitPromptCommand } from './conversation-types.js';
|
|
5
6
|
import type { ConversationHandlePage, ExitRecord, InterruptOutcome, QueuedPrompt, SessionEvent, RuntimeSelectorMetadata, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnOutcome, TurnResult } from './types.js';
|
|
@@ -37,6 +38,20 @@ export interface AcpSessionOptions {
|
|
|
37
38
|
permissionMode?: NonNullable<SessionSnapshot['permissionMode']>;
|
|
38
39
|
/** Adapter-authenticated request-metadata vocabulary; never inferred from ACP `_meta`. */
|
|
39
40
|
permissionMetadataSource?: 'codex-acp';
|
|
41
|
+
/**
|
|
42
|
+
* MCP servers the ROLE declares, for every session/new, resume and load. Empty
|
|
43
|
+
* or omitted sends `[]`, which is what fleet has always sent and leaves the
|
|
44
|
+
* agent's own configuration untouched.
|
|
45
|
+
*/
|
|
46
|
+
mcpServers?: AcpMcpServer[];
|
|
47
|
+
/**
|
|
48
|
+
* Adapter-supplied `_meta` for session/new — the only route by which a
|
|
49
|
+
* capability the CLI takes as a flag reaches an agent that accepts none.
|
|
50
|
+
* Per-agent vocabulary, so the ADAPTER decides whether there is anything to
|
|
51
|
+
* send; this layer only forwards it. Never sent on resume or load: it carries
|
|
52
|
+
* session-creation options the agent has already applied.
|
|
53
|
+
*/
|
|
54
|
+
sessionMeta?: Record<string, unknown>;
|
|
40
55
|
log(line: string): void;
|
|
41
56
|
/** Test seam for the cancel-escalation grace period; production uses the default. */
|
|
42
57
|
cancelGraceMs?: number;
|
|
@@ -172,6 +187,28 @@ export declare class AcpSession implements SessionHandle {
|
|
|
172
187
|
* for it is what turned a busy agent into a timeout and then into "dead".
|
|
173
188
|
*/
|
|
174
189
|
queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
|
|
190
|
+
/**
|
|
191
|
+
* Prepare the session for a prompt that asked to pre-empt current work.
|
|
192
|
+
*
|
|
193
|
+
* The old behaviour was one unconditional `session/cancel` notification
|
|
194
|
+
* followed immediately by `session/prompt`. That is what produced the owner's
|
|
195
|
+
* "request failed before completion":
|
|
196
|
+
*
|
|
197
|
+
* - `cancelActive` only awaits settlement when `this.activeTurn` is set, and
|
|
198
|
+
* a turn the ADAPTER started (steering's `startedNewTurn`) is never tracked
|
|
199
|
+
* here. So the cancel raced the adapter's own transcript repair and the new
|
|
200
|
+
* prompt landed while the last assistant message still held an unresolved
|
|
201
|
+
* `tool_use` — rejected with `stop_reason=tool_use`.
|
|
202
|
+
* - With nothing running at all, it still sent the cancel, and the prompt
|
|
203
|
+
* landed on a bare interrupted user message — rejected with
|
|
204
|
+
* `stop_reason=null`.
|
|
205
|
+
*
|
|
206
|
+
* So: never cancel across a tool boundary, and never cancel something whose
|
|
207
|
+
* settlement cannot be awaited. Everything else is queued, which the ACP queue
|
|
208
|
+
* already does correctly. The returned state is what the caller may claim to a
|
|
209
|
+
* human — `interrupted` only when a turn really was cancelled.
|
|
210
|
+
*/
|
|
211
|
+
private prepareInterruptingDelivery;
|
|
175
212
|
/**
|
|
176
213
|
* Durably record a prompt admission BEFORE acceptance is returned. Browser
|
|
177
214
|
* admissions are transactional — a prompt the ledger cannot hold is refused,
|
|
@@ -219,6 +256,15 @@ export declare class AcpSession implements SessionHandle {
|
|
|
219
256
|
private settlePendingAutomatically;
|
|
220
257
|
exitResult(): ExitRecord | null;
|
|
221
258
|
close(): Promise<void>;
|
|
259
|
+
/**
|
|
260
|
+
* The role's declared MCP servers, or `[]`.
|
|
261
|
+
*
|
|
262
|
+
* Sent on resume and load as well as on new: the agent builds its server set
|
|
263
|
+
* once per session, so a resumed session that omitted them would come back
|
|
264
|
+
* without the tools the role's config declares — which is exactly the shape of
|
|
265
|
+
* silent drop this plumbing exists to end.
|
|
266
|
+
*/
|
|
267
|
+
private declaredMcpServers;
|
|
222
268
|
private initialize;
|
|
223
269
|
private captureRuntimeMetadata;
|
|
224
270
|
private runPrompt;
|
package/dist/session/acp.js
CHANGED
|
@@ -598,15 +598,19 @@ export class AcpSession {
|
|
|
598
598
|
throw new SessionControlError('control-unavailable', 'ACP adapter restart is in progress after the cancellation deadline', ACP_CANCEL_DEADLINE_EXCEEDED);
|
|
599
599
|
if (this.closing || !this.sessionId || !this.isAlive())
|
|
600
600
|
throw new SessionControlError('offline', this.lastError ?? 'ACP session is offline');
|
|
601
|
-
|
|
602
|
-
await this.
|
|
601
|
+
const delivery = options.interrupt
|
|
602
|
+
? await this.prepareInterruptingDelivery(options.interruptSource ?? 'local-console')
|
|
603
|
+
: undefined;
|
|
603
604
|
// Interrupting delivery must still use steering when supported. With no
|
|
604
605
|
// live turn, the extension starts one and acknowledges `startedNewTurn`
|
|
605
606
|
// immediately; a normal session/prompt would keep the monitor blocked until
|
|
606
607
|
// the entire wake-triggered turn terminated.
|
|
607
608
|
if (options.steer && this.steeringSupported) {
|
|
608
609
|
const promptId = randomUUID();
|
|
609
|
-
return {
|
|
610
|
+
return {
|
|
611
|
+
promptId, queuedBehind: 0, completion: this.steerPrompt(text), origin: options.origin,
|
|
612
|
+
...(delivery ? { delivery } : {}),
|
|
613
|
+
};
|
|
610
614
|
}
|
|
611
615
|
const promptId = randomUUID();
|
|
612
616
|
const queuedBehind = this.queueDepth;
|
|
@@ -618,7 +622,46 @@ export class AcpSession {
|
|
|
618
622
|
this.queueDepth = Math.max(0, this.queueDepth - 1);
|
|
619
623
|
return turnResult(false, 'failed', error?.message ?? String(error));
|
|
620
624
|
});
|
|
621
|
-
return {
|
|
625
|
+
return {
|
|
626
|
+
promptId, queuedBehind, completion, origin: options.origin,
|
|
627
|
+
delivery: delivery ?? (queuedBehind > 0 ? 'queued' : 'started'),
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Prepare the session for a prompt that asked to pre-empt current work.
|
|
632
|
+
*
|
|
633
|
+
* The old behaviour was one unconditional `session/cancel` notification
|
|
634
|
+
* followed immediately by `session/prompt`. That is what produced the owner's
|
|
635
|
+
* "request failed before completion":
|
|
636
|
+
*
|
|
637
|
+
* - `cancelActive` only awaits settlement when `this.activeTurn` is set, and
|
|
638
|
+
* a turn the ADAPTER started (steering's `startedNewTurn`) is never tracked
|
|
639
|
+
* here. So the cancel raced the adapter's own transcript repair and the new
|
|
640
|
+
* prompt landed while the last assistant message still held an unresolved
|
|
641
|
+
* `tool_use` — rejected with `stop_reason=tool_use`.
|
|
642
|
+
* - With nothing running at all, it still sent the cancel, and the prompt
|
|
643
|
+
* landed on a bare interrupted user message — rejected with
|
|
644
|
+
* `stop_reason=null`.
|
|
645
|
+
*
|
|
646
|
+
* So: never cancel across a tool boundary, and never cancel something whose
|
|
647
|
+
* settlement cannot be awaited. Everything else is queued, which the ACP queue
|
|
648
|
+
* already does correctly. The returned state is what the caller may claim to a
|
|
649
|
+
* human — `interrupted` only when a turn really was cancelled.
|
|
650
|
+
*/
|
|
651
|
+
async prepareInterruptingDelivery(source) {
|
|
652
|
+
if (!this.sessionId)
|
|
653
|
+
return 'started';
|
|
654
|
+
// No fleet-tracked turn to await. Either the session is idle — cancelling it
|
|
655
|
+
// corrupts the transcript for no gain — or the adapter is running a turn
|
|
656
|
+
// fleet never started, whose settlement nothing here can wait for. Queue in
|
|
657
|
+
// both cases: the ACP queue already orders this correctly.
|
|
658
|
+
if (!this.activeTurn)
|
|
659
|
+
return this.activeToolCalls.size > 0 ? 'deferred' : 'started';
|
|
660
|
+
// A tracked turn IS safe to cancel: cancelActive settles pending permissions
|
|
661
|
+
// and awaits the turn's own settlement before this returns, so the prompt
|
|
662
|
+
// below cannot race the adapter's transcript repair.
|
|
663
|
+
await this.cancelActive(source);
|
|
664
|
+
return 'interrupted';
|
|
622
665
|
}
|
|
623
666
|
/**
|
|
624
667
|
* Durably record a prompt admission BEFORE acceptance is returned. Browser
|
|
@@ -945,6 +988,17 @@ export class AcpSession {
|
|
|
945
988
|
});
|
|
946
989
|
this.conversation.close();
|
|
947
990
|
}
|
|
991
|
+
/**
|
|
992
|
+
* The role's declared MCP servers, or `[]`.
|
|
993
|
+
*
|
|
994
|
+
* Sent on resume and load as well as on new: the agent builds its server set
|
|
995
|
+
* once per session, so a resumed session that omitted them would come back
|
|
996
|
+
* without the tools the role's config declares — which is exactly the shape of
|
|
997
|
+
* silent drop this plumbing exists to end.
|
|
998
|
+
*/
|
|
999
|
+
declaredMcpServers() {
|
|
1000
|
+
return this.options.mcpServers ?? [];
|
|
1001
|
+
}
|
|
948
1002
|
async initialize() {
|
|
949
1003
|
const initialized = await this.connection.agent.request(acp.methods.agent.initialize, {
|
|
950
1004
|
protocolVersion: acp.PROTOCOL_VERSION,
|
|
@@ -964,7 +1018,7 @@ export class AcpSession {
|
|
|
964
1018
|
const resumed = await this.connection.agent.request(acp.methods.agent.session.resume, {
|
|
965
1019
|
sessionId: persisted,
|
|
966
1020
|
cwd: this.options.cwd,
|
|
967
|
-
mcpServers:
|
|
1021
|
+
mcpServers: this.declaredMcpServers(),
|
|
968
1022
|
});
|
|
969
1023
|
this.captureRuntimeMetadata(resumed.configOptions);
|
|
970
1024
|
this.sessionId = persisted;
|
|
@@ -977,7 +1031,7 @@ export class AcpSession {
|
|
|
977
1031
|
const loaded = await this.connection.agent.request(acp.methods.agent.session.load, {
|
|
978
1032
|
sessionId: persisted,
|
|
979
1033
|
cwd: this.options.cwd,
|
|
980
|
-
mcpServers:
|
|
1034
|
+
mcpServers: this.declaredMcpServers(),
|
|
981
1035
|
});
|
|
982
1036
|
this.captureRuntimeMetadata(loaded.configOptions);
|
|
983
1037
|
}
|
|
@@ -989,7 +1043,8 @@ export class AcpSession {
|
|
|
989
1043
|
else {
|
|
990
1044
|
const created = await this.connection.agent.request(acp.methods.agent.session.new, {
|
|
991
1045
|
cwd: this.options.cwd,
|
|
992
|
-
mcpServers:
|
|
1046
|
+
mcpServers: this.declaredMcpServers(),
|
|
1047
|
+
...(this.options.sessionMeta ? { _meta: this.options.sessionMeta } : {}),
|
|
993
1048
|
});
|
|
994
1049
|
this.sessionId = created.sessionId;
|
|
995
1050
|
this.captureRuntimeMetadata(created.configOptions);
|
package/dist/session/types.d.ts
CHANGED
|
@@ -110,6 +110,15 @@ export declare function interruptOutcome(result: InterruptResult): InterruptOutc
|
|
|
110
110
|
* stop here: the session has the prompt, and waiting for the turn to finish is
|
|
111
111
|
* a different question with a different, much longer, timescale.
|
|
112
112
|
*/
|
|
113
|
+
/**
|
|
114
|
+
* What actually happened to an admitted prompt, so a caller reporting to a
|
|
115
|
+
* human can be accurate instead of repeating what it asked for.
|
|
116
|
+
*
|
|
117
|
+
* `interrupted` is only ever returned when a turn was really cancelled for this
|
|
118
|
+
* prompt. `deferred` says the session is busy with work this prompt could not
|
|
119
|
+
* safely pre-empt — the prompt is admitted and will run, just not yet.
|
|
120
|
+
*/
|
|
121
|
+
export type PromptDelivery = 'started' | 'queued' | 'interrupted' | 'deferred';
|
|
113
122
|
export interface QueuedPrompt {
|
|
114
123
|
promptId: string;
|
|
115
124
|
/** Turns already queued ahead of this one. 0 means it starts immediately. */
|
|
@@ -117,6 +126,8 @@ export interface QueuedPrompt {
|
|
|
117
126
|
origin?: PromptOrigin;
|
|
118
127
|
/** The turn's terminal result. Never rejects. */
|
|
119
128
|
completion: Promise<TurnResult>;
|
|
129
|
+
/** Observed admission outcome. Absent on backends that do not report it. */
|
|
130
|
+
delivery?: PromptDelivery;
|
|
120
131
|
}
|
|
121
132
|
/**
|
|
122
133
|
* How a session's process ended.
|