@ours.network/fleet 0.17.1 → 0.17.3
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/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 +79 -11
- 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/provenance.d.ts +77 -0
- package/dist/provenance.js +283 -0
- package/dist/runner.d.ts +7 -1
- package/dist/runner.js +109 -16
- package/dist/session/acp.d.ts +40 -4
- package/dist/session/acp.js +157 -30
- 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/dist/web-app/assets/{TerminalView-B3rnVWbo.js → TerminalView-BAVk1Bot.js} +1 -1
- package/dist/web-app/assets/{index-CliHATFt.js → index-C3S-xFRU.js} +5 -5
- package/dist/web-app/index.html +1 -1
- 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
|
|
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';
|
|
@@ -15,7 +15,7 @@ import { resolveLaunchRuntime } from './isolation/runtime.js';
|
|
|
15
15
|
import { AcpSession } from './session/acp.js';
|
|
16
16
|
import { controlRequest, RoleControlServer } from './session/control.js';
|
|
17
17
|
import { TmuxSession } from './session/tmux.js';
|
|
18
|
-
import { classifyShellStatus } from './session/types.js';
|
|
18
|
+
import { ACP_CANCEL_DEADLINE_EXCEEDED, classifyShellStatus } from './session/types.js';
|
|
19
19
|
import { effectiveModelForRole, modelRecoveryHeld, reconcileModelRecovery, recordModelFailure, classifyFailureText, } from './model-recovery.js';
|
|
20
20
|
import { rotateWorklog } from './worklog.js';
|
|
21
21
|
import { OwnerChannel } from './owner-channel/channel.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
|
|
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;
|
|
606
|
-
: boundary.state
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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:
|
|
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
|
|
@@ -814,7 +863,14 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
814
863
|
rotated = true;
|
|
815
864
|
deps.log(`[${name}] ${why} -> rotated session-id; next start is FRESH`);
|
|
816
865
|
};
|
|
817
|
-
if (exitRecord.
|
|
866
|
+
if (exitRecord.detail.includes(ACP_CANCEL_DEADLINE_EXCEEDED))
|
|
867
|
+
// This is a deliberate adapter reclamation, not evidence that resume state
|
|
868
|
+
// is poisoned. Preserve the context even when the resumed generation hits
|
|
869
|
+
// the same bound immediately; runSupervised still counts the fast exit and
|
|
870
|
+
// opens its circuit after the configured number of consecutive failures.
|
|
871
|
+
deps.log(`[${name}] forced cancellation recovery (${elapsed.toFixed(0)}s, ${exitRecord.detail}) ` +
|
|
872
|
+
`-> next start RESUMES context`);
|
|
873
|
+
else if (exitRecord.class === 'clean' && adapter.exitPolicy.cleanExitIsFresh)
|
|
818
874
|
rotate(`clean exit (code 0)`);
|
|
819
875
|
else if (exitRecord.class === 'session-destroyed')
|
|
820
876
|
// Someone tore the console down; the agent did not fail. Rotating here
|
|
@@ -832,7 +888,10 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
832
888
|
}
|
|
833
889
|
else
|
|
834
890
|
deps.log(`[${name}] ${exitRecord.detail} (${elapsed.toFixed(0)}s) -> next start RESUMES context`);
|
|
835
|
-
return {
|
|
891
|
+
return {
|
|
892
|
+
elapsedSecs: elapsed, exit: exitRecord, rotated, mode, modelRecovery,
|
|
893
|
+
...(retirementReason ? { retirementReason } : {}),
|
|
894
|
+
};
|
|
836
895
|
}
|
|
837
896
|
/**
|
|
838
897
|
* The persistent supervisor for one permanent role: run child sessions in a
|
|
@@ -950,12 +1009,46 @@ function fastFailSecsFor(name, configPath) {
|
|
|
950
1009
|
return 20;
|
|
951
1010
|
}
|
|
952
1011
|
}
|
|
953
|
-
/** Temp-agent entrypoint: run
|
|
1012
|
+
/** Temp-agent entrypoint: run once, journal why it ended, then archive its evidence. */
|
|
954
1013
|
export async function runTemp(name, deps = {}) {
|
|
1014
|
+
const dir = agentDir(name, true);
|
|
1015
|
+
await markTempSupervisorActive(dir);
|
|
1016
|
+
let signal;
|
|
1017
|
+
const onTerm = () => { signal = 'SIGTERM'; };
|
|
1018
|
+
const onInt = () => { signal = 'SIGINT'; };
|
|
1019
|
+
process.on('SIGTERM', onTerm);
|
|
1020
|
+
process.on('SIGINT', onInt);
|
|
1021
|
+
let result;
|
|
1022
|
+
let failure;
|
|
955
1023
|
try {
|
|
956
|
-
await runOnce(name, { temp: true },
|
|
1024
|
+
result = await runOnce(name, { temp: true }, {
|
|
1025
|
+
...deps,
|
|
1026
|
+
shouldStop: () => Boolean(signal) || (deps.shouldStop?.() ?? false),
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
catch (error) {
|
|
1030
|
+
failure = error;
|
|
957
1031
|
}
|
|
958
1032
|
finally {
|
|
959
|
-
|
|
1033
|
+
process.off('SIGTERM', onTerm);
|
|
1034
|
+
process.off('SIGINT', onInt);
|
|
1035
|
+
const requested = requestedTempStopReason(dir);
|
|
1036
|
+
const reason = requested
|
|
1037
|
+
?? result?.retirementReason
|
|
1038
|
+
?? (signal ? 'supervisor-signal' : failure ? 'startup-failure' : 'session-ended');
|
|
1039
|
+
// A service-manager stop can make the child connection close before the
|
|
1040
|
+
// runner reaches its normal loop. That is still a successful requested
|
|
1041
|
+
// retirement, not a startup failure.
|
|
1042
|
+
const outcome = failure && !requested && !signal ? 'failed' : 'retired';
|
|
1043
|
+
const detail = failure
|
|
1044
|
+
? (failure instanceof Error ? failure.message : String(failure))
|
|
1045
|
+
: result
|
|
1046
|
+
? `${result.exit.detail}; elapsed=${result.elapsedSecs.toFixed(1)}s`
|
|
1047
|
+
: 'temporary supervisor ended without an attempt result';
|
|
1048
|
+
const archived = archiveTempState(name, reason, outcome, detail);
|
|
1049
|
+
deps.log?.(`[${name}] temporary lifecycle ${outcome}: ${reason}`
|
|
1050
|
+
+ `${archived ? `; evidence archived at ${archived}` : '; state already archived'}`);
|
|
960
1051
|
}
|
|
1052
|
+
if (failure)
|
|
1053
|
+
throw failure;
|
|
961
1054
|
}
|
package/dist/session/acp.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
99
|
-
*
|
|
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
|
-
|
|
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
|