@ours.network/fleet 0.17.1 → 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 (61) 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/harness/acp-agent.d.ts +3 -0
  20. package/dist/harness/acp-agent.js +4 -1
  21. package/dist/harness/codex-app-server-proxy.d.ts +4 -0
  22. package/dist/harness/codex-app-server-proxy.js +133 -0
  23. package/dist/harness/codex.js +79 -11
  24. package/dist/index.d.ts +2 -1
  25. package/dist/index.js +1 -0
  26. package/dist/loops/manager.d.ts +42 -1
  27. package/dist/loops/manager.js +115 -16
  28. package/dist/loops/state.d.ts +46 -2
  29. package/dist/loops/state.js +81 -3
  30. package/dist/monitor.d.ts +21 -0
  31. package/dist/monitor.js +42 -0
  32. package/dist/ops.d.ts +6 -0
  33. package/dist/ops.js +46 -1
  34. package/dist/owner-channel/channel.d.ts +18 -2
  35. package/dist/owner-channel/channel.js +146 -2
  36. package/dist/owner-channel/commands.d.ts +2 -2
  37. package/dist/owner-channel/commands.js +7 -2
  38. package/dist/owner-channel/notices.d.ts +2 -0
  39. package/dist/owner-channel/notices.js +3 -0
  40. package/dist/provenance.d.ts +77 -0
  41. package/dist/provenance.js +283 -0
  42. package/dist/runner.d.ts +7 -1
  43. package/dist/runner.js +100 -14
  44. package/dist/session/acp.d.ts +40 -4
  45. package/dist/session/acp.js +157 -30
  46. package/dist/session/arbiter.d.ts +28 -2
  47. package/dist/session/arbiter.js +75 -4
  48. package/dist/session/control.js +12 -6
  49. package/dist/session/event-log.d.ts +109 -0
  50. package/dist/session/event-log.js +247 -0
  51. package/dist/session/events.d.ts +21 -0
  52. package/dist/session/events.js +105 -26
  53. package/dist/session/tmux.d.ts +3 -2
  54. package/dist/session/tmux.js +2 -0
  55. package/dist/session/types.d.ts +39 -2
  56. package/dist/session/types.js +11 -1
  57. package/dist/spawn.d.ts +3 -3
  58. package/dist/spawn.js +40 -14
  59. package/dist/temp-lifecycle.d.ts +62 -0
  60. package/dist/temp-lifecycle.js +437 -0
  61. package/package.json +5 -3
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Build and install provenance.
3
+ *
4
+ * Two installs of this package once served the same host with the same semver
5
+ * (0.16.0) and different behaviour: one accepted `monitor.interrupt: after_tool`,
6
+ * the other rejected it. Their `dist/cli.js` were byte-identical, so `--version`
7
+ * could not tell them apart — the divergence lived in other modules, built from a
8
+ * commit after the feature landed but before the release commit bumped the
9
+ * version. Nothing in either artifact recorded which source tree it came from.
10
+ *
11
+ * This module gives every build a content-derived identity, exposes the
12
+ * capabilities it declares, and can enumerate the other installs reachable on
13
+ * this host so the mismatch is reported instead of guessed at.
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+ import { accessSync, constants, existsSync, readFileSync, readdirSync, realpathSync, statSync, } from 'node:fs';
17
+ import { delimiter, dirname, join, parse, relative, sep } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { CAPABILITIES } from './capabilities.js';
20
+ import { VERSION } from './version.js';
21
+ export const PACKAGE_NAME = '@ours.network/fleet';
22
+ export const BIN_NAME = 'ours-fleet';
23
+ /** Stand-in build id for artifacts built before this module existed. */
24
+ export const UNKNOWN_BUILD = 'unknown';
25
+ function readJson(file) {
26
+ try {
27
+ return JSON.parse(readFileSync(file, 'utf8'));
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
33
+ function parseBuildInfo(value) {
34
+ if (typeof value?.version !== 'string' || typeof value.buildId !== 'string')
35
+ return undefined;
36
+ return {
37
+ version: value.version,
38
+ buildId: value.buildId,
39
+ commit: typeof value.commit === 'string' ? value.commit : undefined,
40
+ dirty: typeof value.dirty === 'boolean' ? value.dirty : undefined,
41
+ builtAt: typeof value.builtAt === 'string' ? value.builtAt : undefined,
42
+ capabilities: Array.isArray(value.capabilities)
43
+ ? value.capabilities.filter((c) => typeof c === 'string')
44
+ : [],
45
+ };
46
+ }
47
+ /** Read the install rooted at `packageRoot`, or undefined if that is not one. */
48
+ export function readInstall(packageRoot) {
49
+ const pkg = readJson(join(packageRoot, 'package.json'));
50
+ if (pkg?.name !== PACKAGE_NAME || typeof pkg.version !== 'string')
51
+ return undefined;
52
+ return {
53
+ packageRoot,
54
+ version: pkg.version,
55
+ build: parseBuildInfo(readJson(join(packageRoot, 'dist', 'build-info.json'))),
56
+ };
57
+ }
58
+ /** Walk up from `from` to the @ours.network/fleet package directory containing it. */
59
+ export function findPackageRoot(from) {
60
+ let dir = from;
61
+ const { root } = parse(dir);
62
+ for (;;) {
63
+ if (readInstall(dir))
64
+ return dir;
65
+ if (dir === root)
66
+ return undefined;
67
+ const up = dirname(dir);
68
+ if (up === dir)
69
+ return undefined;
70
+ dir = up;
71
+ }
72
+ }
73
+ const realPathOr = (p) => { try {
74
+ return realpathSync(p);
75
+ }
76
+ catch {
77
+ return p;
78
+ } };
79
+ /**
80
+ * Would a shell run this PATH candidate? A directory that happens to carry the
81
+ * command's name is not the command, and neither is a regular file without its
82
+ * execute bit — a half-finished install. Counting either lets something the
83
+ * operator can never actually invoke shadow the real one in every verdict.
84
+ *
85
+ * Windows has no execute bit (PATHEXT decides), but this CLI supervises through
86
+ * systemd/launchd and does not run there, so the mode check is POSIX-only.
87
+ */
88
+ function isExecutableFile(path, platform = process.platform) {
89
+ try {
90
+ if (!statSync(path).isFile())
91
+ return false;
92
+ if (platform === 'win32')
93
+ return true;
94
+ accessSync(path, constants.X_OK);
95
+ return true;
96
+ }
97
+ catch {
98
+ return false;
99
+ }
100
+ }
101
+ /**
102
+ * Every install reachable from PATH, plus the one executing right now.
103
+ * PATH order is preserved; an install reached by several PATH entries is listed
104
+ * once, at its earliest position.
105
+ */
106
+ export function discoverInstalls(opts = {}) {
107
+ const binName = opts.binName ?? BIN_NAME;
108
+ const platform = opts.platform ?? process.platform;
109
+ const entries = (opts.path ?? process.env.PATH ?? '').split(delimiter).filter(Boolean);
110
+ const records = [];
111
+ const byRoot = new Map();
112
+ entries.forEach((entry, pathIndex) => {
113
+ const bin = join(entry, binName);
114
+ const realBin = realPathOr(bin);
115
+ if (!isExecutableFile(realBin, platform))
116
+ return;
117
+ const packageRoot = findPackageRoot(dirname(realBin));
118
+ if (!packageRoot || byRoot.has(packageRoot))
119
+ return;
120
+ const install = readInstall(packageRoot);
121
+ if (!install)
122
+ return;
123
+ const record = { ...install, bin, realBin, pathIndex, running: false };
124
+ byRoot.set(packageRoot, record);
125
+ records.push(record);
126
+ });
127
+ const argv1 = 'argv1' in opts ? opts.argv1 : process.argv[1];
128
+ const runningRoot = argv1 ? findPackageRoot(dirname(realPathOr(argv1))) : undefined;
129
+ if (runningRoot) {
130
+ const known = byRoot.get(runningRoot);
131
+ if (known)
132
+ known.running = true;
133
+ else {
134
+ const install = readInstall(runningRoot);
135
+ if (install)
136
+ records.push({ ...install, realBin: realPathOr(argv1), running: true });
137
+ }
138
+ }
139
+ return records;
140
+ }
141
+ /**
142
+ * sha256 over an install's dist/ tree, first 12 hex — the same bytes and order
143
+ * `scripts/build-info.mjs` hashes, so a stamped install's digest equals its
144
+ * buildId. This is what tells two PRE-provenance installs apart: they both
145
+ * report `unknown`, but they are not the same artifact, and the host that
146
+ * motivated this module had exactly that pair.
147
+ */
148
+ export function contentDigest(packageRoot) {
149
+ const dist = join(packageRoot, 'dist');
150
+ if (!existsSync(dist))
151
+ return undefined;
152
+ const stamp = join(dist, 'build-info.json');
153
+ const files = [];
154
+ const walk = (dir) => {
155
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
156
+ const p = join(dir, entry.name);
157
+ if (entry.isDirectory())
158
+ walk(p);
159
+ else if (p !== stamp)
160
+ files.push(relative(dist, p).split(sep).join('/'));
161
+ }
162
+ };
163
+ try {
164
+ walk(dist);
165
+ }
166
+ catch {
167
+ return undefined;
168
+ }
169
+ const hash = createHash('sha256');
170
+ for (const rel of files.sort()) {
171
+ const bytes = readFileSync(join(dist, rel));
172
+ hash.update(rel);
173
+ hash.update('\0');
174
+ hash.update(String(bytes.length));
175
+ hash.update('\0');
176
+ hash.update(bytes);
177
+ }
178
+ return hash.digest('hex').slice(0, 12);
179
+ }
180
+ /** `0.17.0+9f1c2a3b4d5e`, or `…+unknown` for a pre-provenance build. */
181
+ export const buildLabel = (install) => `${install.version}+${install.build?.buildId ?? UNKNOWN_BUILD}`;
182
+ /** What an install says it can do — never guessed from its version. */
183
+ export const capabilitySummary = (install) => !install.build ? 'capabilities unknown'
184
+ : install.build.capabilities.length
185
+ ? `capabilities: ${install.build.capabilities.join(', ')}`
186
+ : 'no declared capabilities';
187
+ const describe = (r, identity) => `${r.packageRoot} (${buildLabel(r)}`
188
+ + (identity && identity !== r.build?.buildId ? `, content ${identity}` : '')
189
+ + `${r.running ? ', running' : ''}; ${capabilitySummary(r)})`;
190
+ /**
191
+ * What an install effectively IS: its stamped build id, or — for an artifact
192
+ * built before stamps existed — a hash of what it is made of. `unknown` only
193
+ * when neither can be established.
194
+ */
195
+ const identityOf = (install, digest) => install.build?.buildId ?? digest(install.packageRoot) ?? UNKNOWN_BUILD;
196
+ /**
197
+ * Conflicts an operator must know about. `version-build-conflict` is the one
198
+ * that bit this host: same semver, different artifact, silently different rules.
199
+ *
200
+ * `digest` is only consulted inside a version group of two or more, so the
201
+ * common single-install case never hashes anything.
202
+ */
203
+ export function analyzeInstalls(records, digest = contentDigest) {
204
+ const skews = [];
205
+ const conflicted = new Set();
206
+ const byVersion = new Map();
207
+ for (const r of records)
208
+ byVersion.set(r.version, [...(byVersion.get(r.version) ?? []), r]);
209
+ for (const [version, group] of byVersion) {
210
+ if (group.length < 2)
211
+ continue;
212
+ // A pre-provenance build has no id to compare, so fall back to what it is
213
+ // made of. Two installs are the same artifact or they are not.
214
+ const identity = new Map(group.map(r => [r.packageRoot, identityOf(r, digest)]));
215
+ if (new Set(identity.values()).size < 2)
216
+ continue;
217
+ for (const r of group)
218
+ conflicted.add(r.packageRoot);
219
+ skews.push({
220
+ kind: 'version-build-conflict',
221
+ severity: 'error',
222
+ message: `${group.length} installs report ours-fleet ${version} but are different builds: `
223
+ + `${group.map(r => describe(r, identity.get(r.packageRoot))).join(' vs ')}`
224
+ + '. They accept different fleet.yaml — compare `ours-fleet version --json` on each '
225
+ + 'and remove or update the stale one.',
226
+ });
227
+ }
228
+ const running = records.find(r => r.running);
229
+ const first = records.filter(r => r.pathIndex !== undefined)
230
+ .sort((a, b) => a.pathIndex - b.pathIndex)[0];
231
+ // Two prefixes holding the same artifact are not a skew — whichever answers,
232
+ // the operator gets the same behaviour. Only compare content when the roots
233
+ // differ, so the ordinary single-install case never hashes anything.
234
+ const sameArtifact = running && first && first.packageRoot !== running.packageRoot
235
+ && identityOf(first, digest) === identityOf(running, digest)
236
+ && first.version === running.version;
237
+ if (running && first && first.packageRoot !== running.packageRoot && !sameArtifact)
238
+ skews.push({
239
+ kind: 'shadowed-runtime',
240
+ // Same semver on both sides is the trap: nothing an operator can see
241
+ // distinguishes them. Different semver is merely worth knowing (running a
242
+ // checkout while a global install exists is an ordinary way to work).
243
+ severity: first.version === running.version ? 'error' : 'warn',
244
+ message: `this process runs ${describe(running)} but \`${BIN_NAME}\` on PATH resolves to `
245
+ + `${first.bin} -> ${first.packageRoot} (${buildLabel(first)}). A command you type and this `
246
+ + 'runtime are not the same artifact.',
247
+ });
248
+ for (const r of records) {
249
+ if (r.build || conflicted.has(r.packageRoot))
250
+ continue;
251
+ skews.push({
252
+ kind: 'unknown-build-identity',
253
+ severity: 'warn',
254
+ message: `${r.packageRoot} (${r.version}) predates build provenance — it ships no `
255
+ + 'dist/build-info.json, so its source tree and capabilities cannot be verified.',
256
+ });
257
+ }
258
+ return skews;
259
+ }
260
+ let cached;
261
+ /** Identity of the build executing right now. */
262
+ export function buildInfo() {
263
+ if (cached)
264
+ return cached;
265
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
266
+ const root = findPackageRoot(moduleDir);
267
+ const candidates = [
268
+ join(moduleDir, 'build-info.json'),
269
+ ...(root ? [join(root, 'dist', 'build-info.json')] : []),
270
+ ];
271
+ for (const file of candidates) {
272
+ const info = parseBuildInfo(readJson(file));
273
+ if (info)
274
+ return (cached = info);
275
+ }
276
+ // Running from a tree that was never built (or a build that predates this).
277
+ return (cached = { version: VERSION, buildId: UNKNOWN_BUILD, capabilities: [...CAPABILITIES] });
278
+ }
279
+ /** `ours-fleet 0.17.0+9f1c2a3b4d5e` — one line, safe for any operator output. */
280
+ export const runningLabel = () => {
281
+ const info = buildInfo();
282
+ return `${BIN_NAME} ${buildLabel({ version: info.version, build: info })}`;
283
+ };
package/dist/runner.d.ts CHANGED
@@ -100,7 +100,13 @@ export interface AttemptResult {
100
100
  rotated: boolean;
101
101
  mode: 'fresh' | 'resume';
102
102
  modelRecovery?: 'advance' | 'hold';
103
+ /** Present only when a temporary-role lifecycle signal ended the session. */
104
+ retirementReason?: 'identity-closed' | 'operator-stop' | 'supervisor-signal';
103
105
  }
106
+ /** Continuous authoritative absence required after an identity was observed. */
107
+ export declare const TEMP_IDENTITY_CLOSE_DEBOUNCE_MS = 5000;
108
+ /** Lifecycle polling is deliberately slower than the 500ms stop-signal loop. */
109
+ export declare const TEMP_IDENTITY_POLL_MS = 2000;
104
110
  /** One session lifecycle. `runSupervised` (or a one-shot caller) drives it. */
105
111
  export declare function runOnce(name: string, opts?: {
106
112
  temp?: boolean;
@@ -123,5 +129,5 @@ export declare function runSupervised(name: string, opts?: {
123
129
  configPath?: string;
124
130
  allowResumeRotation?: boolean;
125
131
  }, d: Partial<RunnerDeps>) => Promise<AttemptResult>): Promise<RestartLedger>;
126
- /** Temp-agent entrypoint: run one session, then remove the temp dir. */
132
+ /** Temp-agent entrypoint: run once, journal why it ended, then archive its evidence. */
127
133
  export declare function runTemp(name: string, deps?: Partial<RunnerDeps>): Promise<void>;
package/dist/runner.js CHANGED
@@ -6,7 +6,7 @@ import { agentDir, stateRoot } from './paths.js';
6
6
  import { loadConfig, findRole, isolationContextFor, resolveMonitorConfig, resolvePermissions, } from './config.js';
7
7
  import { getAdapter } from './harness/registry.js';
8
8
  import { Tmux } from './tmux.js';
9
- import { createMonitor } from './monitor.js';
9
+ import { createMonitor, probeIdentityPresence, } from './monitor.js';
10
10
  import { realExec, shq } from './exec.js';
11
11
  import { resolveIsolation } from './isolation/policy.js';
12
12
  import { selectIsolationBackend } from './isolation/registry.js';
@@ -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 { archiveTempState, markTempSupervisorActive, requestedTempStopReason, } from './temp-lifecycle.js';
27
28
  const defaultDeps = () => ({
28
29
  tmux: new Tmux(),
29
30
  exec: realExec,
@@ -347,6 +348,10 @@ function resolveConfigPath(dir, explicit) {
347
348
  return undefined;
348
349
  return readFileSync(marker, 'utf8').trim() || undefined;
349
350
  }
351
+ /** Continuous authoritative absence required after an identity was observed. */
352
+ export const TEMP_IDENTITY_CLOSE_DEBOUNCE_MS = 5_000;
353
+ /** Lifecycle polling is deliberately slower than the 500ms stop-signal loop. */
354
+ export const TEMP_IDENTITY_POLL_MS = 2_000;
350
355
  /** One session lifecycle. `runSupervised` (or a one-shot caller) drives it. */
351
356
  export async function runOnce(name, opts = {}, partialDeps = {}) {
352
357
  const deps = { ...defaultDeps(), ...partialDeps };
@@ -489,6 +494,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
489
494
  let unsubscribeRecovery;
490
495
  let monitorLoop;
491
496
  let acpStartupComplete = false;
497
+ let sessionClosed = false;
492
498
  let ownerChannel;
493
499
  let ownerBinder;
494
500
  const pendingFleetSpawnNotices = [];
@@ -600,17 +606,24 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
600
606
  const steered = result.accepted
601
607
  && (result.detail === 'injected' || result.detail === 'startedNewTurn');
602
608
  const boundary = result.safeBoundary;
603
- const boundaryDetail = boundary
609
+ const queuedAfterSteeringFailure = result.detail?.startsWith('steering rejected;') === true;
610
+ const boundaryDetail = boundary && queuedAfterSteeringFailure
604
611
  ? boundary.state === 'timeout'
605
- ? `after_tool timed out after ${boundary.waitedMs}ms; steered without cancellation`
606
- : boundary.state === 'unsupported'
607
- ? 'after_tool unsupported; used non-cancelling queued delivery'
608
- : `after_tool ${boundary.state} delivery after ${boundary.waitedMs}ms`
609
- : result.detail;
612
+ ? `after_tool timed out after ${boundary.waitedMs}ms; steering rejected, queued without cancellation`
613
+ : `after_tool ${boundary.state} boundary after ${boundary.waitedMs}ms; steering rejected, queued without cancellation`
614
+ : boundary
615
+ ? boundary.state === 'timeout'
616
+ ? `after_tool timed out after ${boundary.waitedMs}ms; steered without cancellation`
617
+ : boundary.state === 'unsupported'
618
+ ? 'after_tool unsupported; used non-cancelling queued delivery'
619
+ : `after_tool ${boundary.state} delivery after ${boundary.waitedMs}ms`
620
+ : result.detail;
610
621
  return {
611
622
  succeeded: result.succeeded || steered,
612
623
  outcome: steered ? result.detail : result.outcome,
613
- detail: boundaryDetail,
624
+ detail: result.succeeded || steered || !boundary
625
+ ? boundaryDetail
626
+ : [result.detail, boundaryDetail].filter(Boolean).join('; '),
614
627
  ...(boundary ? { safeBoundary: boundary.state } : {}),
615
628
  };
616
629
  },
@@ -753,10 +766,46 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
753
766
  monitorLoop ??= monitor?.run(pid);
754
767
  const start = deps.now();
755
768
  let nextLoopReloadAt = deps.now() + 30_000;
769
+ let nextIdentityPollAt = deps.now();
756
770
  let lastReloadError = '';
771
+ let identityObserved = false;
772
+ let identityAbsentSince;
773
+ let retirementReason;
757
774
  while (sessionHandle.isAlive()) {
758
- await deps.sleep(2000);
775
+ await deps.sleep(temp ? 500 : 2000);
759
776
  const now = deps.now();
777
+ if (temp && deps.shouldStop?.()) {
778
+ retirementReason = requestedTempStopReason(dir) ?? 'supervisor-signal';
779
+ deps.log(`[${name}] temporary supervisor retirement requested (${retirementReason})`);
780
+ await sessionHandle.close();
781
+ sessionClosed = true;
782
+ break;
783
+ }
784
+ if (temp && now >= nextIdentityPollAt) {
785
+ nextIdentityPollAt = now + TEMP_IDENTITY_POLL_MS;
786
+ const presence = await probeIdentityPresence(role.identity, deps.fetch, resolvedMonitorDeps.env);
787
+ if (presence.state === 'present') {
788
+ identityObserved = true;
789
+ identityAbsentSince = undefined;
790
+ }
791
+ else if (presence.state === 'absent' && identityObserved) {
792
+ identityAbsentSince ??= now;
793
+ // Require a continuous, time-bounded run of authoritative absence.
794
+ // The first positive observation is the readiness gate: cold tmux
795
+ // starts may spend minutes loading the harness and briefing before the
796
+ // agent creates/binds its identity, and absence before then is not a
797
+ // close event. After readiness, debounce a real disappearance.
798
+ if (now - identityAbsentSince >= TEMP_IDENTITY_CLOSE_DEBOUNCE_MS) {
799
+ retirementReason = 'identity-closed';
800
+ deps.log(`[${name}] temporary identity '${role.identity}' closed; retiring session and supervisor`);
801
+ await sessionHandle.close();
802
+ sessionClosed = true;
803
+ break;
804
+ }
805
+ }
806
+ else if (presence.state === 'unknown')
807
+ identityAbsentSince = undefined;
808
+ }
760
809
  if (reloadLoopConfig && now >= nextLoopReloadAt) {
761
810
  nextLoopReloadAt = now + 30_000;
762
811
  try {
@@ -791,7 +840,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
791
840
  await monitorLoop;
792
841
  }
793
842
  unsubscribeRecovery?.();
794
- if (acpSession)
843
+ if (acpSession && !sessionClosed)
795
844
  await acpSession.close();
796
845
  const elapsed = (deps.now() - start) / 1000;
797
846
  // Establish what actually happened before deciding anything. Absence of a
@@ -832,7 +881,10 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
832
881
  }
833
882
  else
834
883
  deps.log(`[${name}] ${exitRecord.detail} (${elapsed.toFixed(0)}s) -> next start RESUMES context`);
835
- return { elapsedSecs: elapsed, exit: exitRecord, rotated, mode, modelRecovery };
884
+ return {
885
+ elapsedSecs: elapsed, exit: exitRecord, rotated, mode, modelRecovery,
886
+ ...(retirementReason ? { retirementReason } : {}),
887
+ };
836
888
  }
837
889
  /**
838
890
  * The persistent supervisor for one permanent role: run child sessions in a
@@ -950,12 +1002,46 @@ function fastFailSecsFor(name, configPath) {
950
1002
  return 20;
951
1003
  }
952
1004
  }
953
- /** Temp-agent entrypoint: run one session, then remove the temp dir. */
1005
+ /** Temp-agent entrypoint: run once, journal why it ended, then archive its evidence. */
954
1006
  export async function runTemp(name, deps = {}) {
1007
+ const dir = agentDir(name, true);
1008
+ await markTempSupervisorActive(dir);
1009
+ let signal;
1010
+ const onTerm = () => { signal = 'SIGTERM'; };
1011
+ const onInt = () => { signal = 'SIGINT'; };
1012
+ process.on('SIGTERM', onTerm);
1013
+ process.on('SIGINT', onInt);
1014
+ let result;
1015
+ let failure;
955
1016
  try {
956
- await runOnce(name, { temp: true }, deps);
1017
+ result = await runOnce(name, { temp: true }, {
1018
+ ...deps,
1019
+ shouldStop: () => Boolean(signal) || (deps.shouldStop?.() ?? false),
1020
+ });
1021
+ }
1022
+ catch (error) {
1023
+ failure = error;
957
1024
  }
958
1025
  finally {
959
- rmSync(agentDir(name, true), { recursive: true, force: true });
1026
+ process.off('SIGTERM', onTerm);
1027
+ process.off('SIGINT', onInt);
1028
+ const requested = requestedTempStopReason(dir);
1029
+ const reason = requested
1030
+ ?? result?.retirementReason
1031
+ ?? (signal ? 'supervisor-signal' : failure ? 'startup-failure' : 'session-ended');
1032
+ // A service-manager stop can make the child connection close before the
1033
+ // runner reaches its normal loop. That is still a successful requested
1034
+ // retirement, not a startup failure.
1035
+ const outcome = failure && !requested && !signal ? 'failed' : 'retired';
1036
+ const detail = failure
1037
+ ? (failure instanceof Error ? failure.message : String(failure))
1038
+ : result
1039
+ ? `${result.exit.detail}; elapsed=${result.elapsedSecs.toFixed(1)}s`
1040
+ : 'temporary supervisor ended without an attempt result';
1041
+ const archived = archiveTempState(name, reason, outcome, detail);
1042
+ deps.log?.(`[${name}] temporary lifecycle ${outcome}: ${reason}`
1043
+ + `${archived ? `; evidence archived at ${archived}` : '; state already archived'}`);
960
1044
  }
1045
+ if (failure)
1046
+ throw failure;
961
1047
  }
@@ -2,7 +2,7 @@ import * as acp from '@agentclientprotocol/sdk';
2
2
  import type { CommonPermissions } from '../config.js';
3
3
  import { ConversationEventStore } from './conversation-store.js';
4
4
  import type { ConversationSnapshot, PromptOrigin, PromptReceipt, SubmitPromptCommand } from './conversation-types.js';
5
- import type { ConversationHandlePage, ExitRecord, QueuedPrompt, SessionEvent, RuntimeSelectorMetadata, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnOutcome, TurnResult } from './types.js';
5
+ import type { ConversationHandlePage, ExitRecord, InterruptOutcome, QueuedPrompt, SessionEvent, RuntimeSelectorMetadata, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnOutcome, TurnResult } from './types.js';
6
6
  /** Bound safe-boundary waiting without turning a hung tool into cancellation. */
7
7
  export declare const AFTER_TOOL_BOUNDARY_TIMEOUT_MS = 120000;
8
8
  /** Server-generated typed provenance followed by the exact human-authored body. */
@@ -23,6 +23,8 @@ export interface AcpSessionOptions {
23
23
  log(line: string): void;
24
24
  /** Test seam for the cancel-escalation grace period; production uses the default. */
25
25
  cancelGraceMs?: number;
26
+ /** Test seam for SIGTERM -> SIGKILL escalation after an ignored cancellation. */
27
+ cancelTerminateGraceMs?: number;
26
28
  /** How long a pending permission may wait for a human before it expires. */
27
29
  permissionTimeoutMs?: number;
28
30
  /** Grace after the last controller detaches before the unattended policy applies. */
@@ -69,6 +71,15 @@ export declare class AcpSession implements SessionHandle {
69
71
  /** Armed when the last controller detaches; unattended policy applies on fire. */
70
72
  private controllerGrace?;
71
73
  private cancelEscalation?;
74
+ private cancelForceKill?;
75
+ private cancelRecoveryReason?;
76
+ /**
77
+ * Rejects the moment the adapter process is gone. Every in-flight ACP request
78
+ * races it, so a dead adapter can never leave a turn — and therefore a
79
+ * scheduled run's `activeRunId` or an admission claim — unsettled forever.
80
+ */
81
+ private readonly terminated;
82
+ private terminate;
72
83
  /** ACP-authenticated in-flight calls, including independently reserved permissions. */
73
84
  private readonly activeToolCalls;
74
85
  private readonly toolBoundaryWaiters;
@@ -95,8 +106,18 @@ export declare class AcpSession implements SessionHandle {
95
106
  private waitForToolBoundary;
96
107
  private recordAfterToolDelivery;
97
108
  /**
98
- * Monitor-only safe-boundary delivery. Steering is the interruption: this
99
- * path never calls session/cancel and never resolves a pending permission.
109
+ * Steering is an optional admission fast path, not the only safe way to
110
+ * deliver a wake. Codex can reject `_session/steering` while a long-running
111
+ * turn is between tools. Queue one ordinary, non-cancelling prompt in that
112
+ * case and wait for its terminal result. This keeps the monitor's cursor
113
+ * uncommitted until the wake really runs and, critically, keeps one rejected
114
+ * steering response from becoming a tight replay loop.
115
+ */
116
+ private steerOrQueueWake;
117
+ /**
118
+ * Monitor-only safe-boundary delivery. Steering is the preferred live
119
+ * insertion and rejected steering is queued: this path never calls
120
+ * session/cancel and never resolves a pending permission.
100
121
  */
101
122
  submitPromptAfterTool(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
102
123
  /**
@@ -115,8 +136,23 @@ export declare class AcpSession implements SessionHandle {
115
136
  /** Idempotent browser prompt admission (control v3 `submit_prompt_v2`). */
116
137
  submitPromptBrowser(command: SubmitPromptCommand): Promise<PromptReceipt>;
117
138
  submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
118
- interrupt(source?: TurnCancellationSource): Promise<void>;
139
+ /**
140
+ * Explicit cancellation on behalf of a human or an operator. Forced recovery
141
+ * is reported as an outcome, never as a thrown failure: by the time this
142
+ * resolves the turn is over either way, and only the durable-ingress path
143
+ * (`queuePrompt({ interrupt: true })`) needs the typed error, because only it
144
+ * still owes an undelivered message a replay.
145
+ */
146
+ interrupt(source?: TurnCancellationSource): Promise<InterruptOutcome>;
119
147
  private cancelActive;
148
+ /**
149
+ * Do not admit work behind a turn whose adapter may already require restart.
150
+ * A cooperative adapter settles this promise immediately through runPrompt's
151
+ * finally block. A stubborn adapter receives SIGTERM at the deadline and
152
+ * SIGKILL after one more bounded grace; callers get a typed recovery error so
153
+ * durable ingress can leave the next request replayable for the resumed run.
154
+ */
155
+ private awaitCancellationSettlement;
120
156
  respondPermission(permissionId: string, optionId: string): boolean;
121
157
  /**
122
158
  * A v2 decision binds to the session generation it was shown under. A stale