@ours.network/fleet 0.17.0 → 0.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -2
- package/dist/application/role-removal-service.js +1 -1
- package/dist/application/session-control.d.ts +14 -10
- package/dist/application/session-control.js +14 -3
- package/dist/atomic-file.d.ts +7 -1
- package/dist/atomic-file.js +33 -5
- package/dist/build-info.json +10 -0
- package/dist/capabilities.d.ts +20 -0
- package/dist/capabilities.js +21 -0
- package/dist/cli.js +98 -10
- package/dist/config.d.ts +9 -2
- package/dist/config.js +16 -2
- package/dist/creation.d.ts +16 -0
- package/dist/creation.js +28 -0
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +70 -4
- package/dist/doctor.d.ts +5 -0
- package/dist/doctor.js +87 -2
- package/dist/fleet-proxy.js +2 -2
- package/dist/harness/acp-agent.d.ts +3 -0
- package/dist/harness/acp-agent.js +4 -1
- package/dist/harness/codex-app-server-proxy.d.ts +4 -0
- package/dist/harness/codex-app-server-proxy.js +133 -0
- package/dist/harness/codex.js +116 -11
- package/dist/harness/types.d.ts +2 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/loops/manager.d.ts +42 -1
- package/dist/loops/manager.js +115 -16
- package/dist/loops/state.d.ts +46 -2
- package/dist/loops/state.js +81 -3
- package/dist/monitor.d.ts +21 -0
- package/dist/monitor.js +42 -0
- package/dist/ops.d.ts +6 -0
- package/dist/ops.js +46 -1
- package/dist/owner-channel/channel.d.ts +18 -2
- package/dist/owner-channel/channel.js +146 -2
- package/dist/owner-channel/commands.d.ts +2 -2
- package/dist/owner-channel/commands.js +7 -2
- package/dist/owner-channel/notices.d.ts +2 -0
- package/dist/owner-channel/notices.js +3 -0
- package/dist/permissions.d.ts +2 -0
- package/dist/permissions.js +5 -0
- package/dist/provenance.d.ts +77 -0
- package/dist/provenance.js +283 -0
- package/dist/runner.d.ts +7 -1
- package/dist/runner.js +100 -14
- package/dist/session/acp.d.ts +40 -4
- package/dist/session/acp.js +272 -37
- package/dist/session/arbiter.d.ts +28 -2
- package/dist/session/arbiter.js +75 -4
- package/dist/session/control.js +12 -6
- package/dist/session/event-log.d.ts +109 -0
- package/dist/session/event-log.js +247 -0
- package/dist/session/events.d.ts +21 -0
- package/dist/session/events.js +105 -26
- package/dist/session/tmux.d.ts +3 -2
- package/dist/session/tmux.js +2 -0
- package/dist/session/types.d.ts +39 -2
- package/dist/session/types.js +11 -1
- package/dist/spawn.d.ts +3 -3
- package/dist/spawn.js +40 -14
- package/dist/temp-lifecycle.d.ts +62 -0
- package/dist/temp-lifecycle.js +437 -0
- package/package.json +5 -3
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { replaceFileAtomically, withFileLock } from './atomic-file.js';
|
|
5
|
+
import { realExec } from './exec.js';
|
|
6
|
+
import { stateRoot, tmpRoot } from './paths.js';
|
|
7
|
+
export const TEMP_SUPERVISOR_FILE = '.temp-supervisor.json';
|
|
8
|
+
export const TEMP_TERMINATION_FILE = 'termination.jsonl';
|
|
9
|
+
export const TEMP_STOP_REQUEST_FILE = '.temp-stop-request.json';
|
|
10
|
+
const TEMP_GLOBAL_TERMINATION_MARKER = '.termination-globally-recorded';
|
|
11
|
+
export const TEMP_RECLAIM_BATCH = 32;
|
|
12
|
+
export const TEMP_LAUNCH_GRACE_MS = 60_000;
|
|
13
|
+
const metadataPath = (dir) => join(dir, TEMP_SUPERVISOR_FILE);
|
|
14
|
+
export const tempSystemdUnit = (name) => `ours-fleet-temp-${name}.service`;
|
|
15
|
+
export const tempLaunchdLabel = (name) => `network.ours.fleet.temp.${name}`;
|
|
16
|
+
export function prepareTempSupervisor(dir, role) {
|
|
17
|
+
const record = {
|
|
18
|
+
version: 1, role, launchId: randomUUID(), createdAt: new Date().toISOString(), phase: 'launching',
|
|
19
|
+
};
|
|
20
|
+
replaceFileAtomically(metadataPath(dir), JSON.stringify(record, null, 2) + '\n');
|
|
21
|
+
return record;
|
|
22
|
+
}
|
|
23
|
+
export function readTempSupervisor(dir) {
|
|
24
|
+
try {
|
|
25
|
+
const value = JSON.parse(readFileSync(metadataPath(dir), 'utf8'));
|
|
26
|
+
if (value.version !== 1 || typeof value.role !== 'string' || typeof value.launchId !== 'string')
|
|
27
|
+
return undefined;
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const metadataLockPath = (dir) => join(stateRoot(), 'locks', 'temp-supervisors', encodeURIComponent(basename(dir)));
|
|
35
|
+
async function updateTempSupervisor(dir, update) {
|
|
36
|
+
// The launcher and the just-started supervisor are separate processes. Lock
|
|
37
|
+
// their read/merge/write updates so neither can discard the other's kind,
|
|
38
|
+
// target, pid or phase. The lock lives outside the role directory so an
|
|
39
|
+
// already-archived role is never accidentally recreated by a late writer.
|
|
40
|
+
return withFileLock(metadataLockPath(dir), () => {
|
|
41
|
+
if (!existsSync(dir))
|
|
42
|
+
return undefined;
|
|
43
|
+
const current = readTempSupervisor(dir);
|
|
44
|
+
if (!current)
|
|
45
|
+
throw new Error(`temporary supervisor metadata is missing from ${dir}`);
|
|
46
|
+
const next = { ...current, ...update };
|
|
47
|
+
replaceFileAtomically(metadataPath(dir), JSON.stringify(next, null, 2) + '\n');
|
|
48
|
+
return next;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Launch a temp supervisor outside the caller's service-manager ownership
|
|
53
|
+
* boundary. `detached: true` creates a new process group but does not escape a
|
|
54
|
+
* systemd cgroup; a transient unit does, and is not enabled across reboot.
|
|
55
|
+
*/
|
|
56
|
+
export function makeTempSupervisorLauncher(options = {}) {
|
|
57
|
+
const exec = options.exec ?? realExec;
|
|
58
|
+
const platform = options.platform ?? process.platform;
|
|
59
|
+
const supervisor = options.supervisor ?? process.env.OURS_FLEET_SUPERVISOR;
|
|
60
|
+
return async (binPath, args, dir) => {
|
|
61
|
+
const inherited = [
|
|
62
|
+
'HOME', 'PATH', 'XDG_RUNTIME_DIR', 'OURS_FLEET_HOME', 'CODEX_HOME',
|
|
63
|
+
// The child supervisor performs daemon identity and wake probes itself;
|
|
64
|
+
// it must resolve the same ours profile as the spawning supervisor.
|
|
65
|
+
'OURS_PORT', 'OURS_STATE_DIR', 'OURS_API_TOKEN', 'OURS_CONFIG',
|
|
66
|
+
]
|
|
67
|
+
.flatMap(key => process.env[key] !== undefined ? [`${key}=${process.env[key]}`] : []);
|
|
68
|
+
const role = args.at(-1);
|
|
69
|
+
if (!role)
|
|
70
|
+
throw new Error('temporary supervisor launch requires a role name');
|
|
71
|
+
const log = join(dir, 'supervisor.log');
|
|
72
|
+
if (supervisor !== 'none' && platform === 'linux') {
|
|
73
|
+
const target = tempSystemdUnit(role);
|
|
74
|
+
await updateTempSupervisor(dir, { phase: 'launching', kind: 'systemd-transient', target, binPath });
|
|
75
|
+
const result = await exec('systemd-run', [
|
|
76
|
+
'--user', '--quiet', '--collect', `--unit=${target}`,
|
|
77
|
+
'--property=Type=exec', '--property=KillMode=control-group', '--property=TimeoutStopSec=15s',
|
|
78
|
+
`--property=StandardOutput=append:${log}`, `--property=StandardError=append:${log}`,
|
|
79
|
+
...inherited.map(value => `--setenv=${value}`),
|
|
80
|
+
process.execPath, binPath, ...args,
|
|
81
|
+
]);
|
|
82
|
+
if (result.code !== 0)
|
|
83
|
+
throw new Error(`systemd-run ${target} failed: ${result.stderr.trim() || `exit ${result.code}`}`);
|
|
84
|
+
await updateTempSupervisor(dir, { phase: 'active' });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (supervisor !== 'none' && platform === 'darwin') {
|
|
88
|
+
const target = tempLaunchdLabel(role);
|
|
89
|
+
await updateTempSupervisor(dir, { phase: 'launching', kind: 'launchd-transient', target, binPath });
|
|
90
|
+
const result = await exec('launchctl', [
|
|
91
|
+
'submit', '-l', target, '-o', log, '-e', log, '--',
|
|
92
|
+
'/usr/bin/env', ...inherited, process.execPath, binPath, ...args,
|
|
93
|
+
]);
|
|
94
|
+
if (result.code !== 0)
|
|
95
|
+
throw new Error(`launchctl submit ${target} failed: ${result.stderr.trim() || `exit ${result.code}`}`);
|
|
96
|
+
await updateTempSupervisor(dir, { phase: 'active' });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!options.spawnDetached)
|
|
100
|
+
throw new Error('detached temp launch requires a spawnDetached implementation');
|
|
101
|
+
const pid = options.spawnDetached(binPath, args, dir);
|
|
102
|
+
await updateTempSupervisor(dir, { phase: 'active', kind: 'detached', pid, binPath });
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
export async function markTempSupervisorActive(dir, pid = process.pid) {
|
|
106
|
+
const current = readTempSupervisor(dir);
|
|
107
|
+
if (!current)
|
|
108
|
+
return;
|
|
109
|
+
await updateTempSupervisor(dir, { phase: 'active', pid });
|
|
110
|
+
}
|
|
111
|
+
export function requestedTempStopReason(dir) {
|
|
112
|
+
try {
|
|
113
|
+
const value = JSON.parse(readFileSync(join(dir, TEMP_STOP_REQUEST_FILE), 'utf8'));
|
|
114
|
+
return value.reason === 'operator-stop' ? value.reason : undefined;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function archiveRoot() {
|
|
121
|
+
return join(stateRoot(), 'recovery', 'temporary');
|
|
122
|
+
}
|
|
123
|
+
function appendTermination(dir, record) {
|
|
124
|
+
const line = JSON.stringify(record) + '\n';
|
|
125
|
+
appendFileSync(join(dir, TEMP_TERMINATION_FILE), line, { mode: 0o600 });
|
|
126
|
+
appendGlobalTermination(line);
|
|
127
|
+
// Normal retirement no longer rereads the entire global journal. This
|
|
128
|
+
// durable per-archive marker tells crash recovery the append completed; only
|
|
129
|
+
// the narrow crash seam before this marker needs the legacy dedupe scan.
|
|
130
|
+
replaceFileAtomically(join(dir, TEMP_GLOBAL_TERMINATION_MARKER), line);
|
|
131
|
+
}
|
|
132
|
+
function appendGlobalTermination(line, checkExisting = false) {
|
|
133
|
+
mkdirSync(archiveRoot(), { recursive: true, mode: 0o700 });
|
|
134
|
+
const path = join(archiveRoot(), 'terminations.jsonl');
|
|
135
|
+
// Recovery may revisit a .retiring directory after a crash between the
|
|
136
|
+
// global append and final rename. Avoid duplicating that exact event.
|
|
137
|
+
if (checkExisting) {
|
|
138
|
+
try {
|
|
139
|
+
if (readFileSync(path, 'utf8').split('\n').includes(line.trimEnd()))
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
catch { /* the journal does not exist yet */ }
|
|
143
|
+
}
|
|
144
|
+
appendFileSync(path, line, { mode: 0o600 });
|
|
145
|
+
}
|
|
146
|
+
/** Pick a sibling path without overwriting evidence from an earlier attempt. */
|
|
147
|
+
function collisionSafeArchivePaths(targetBase, retiringBase) {
|
|
148
|
+
for (let attempt = 0;; attempt++) {
|
|
149
|
+
const discriminator = attempt === 0 ? '' : `-${attempt + 1}`;
|
|
150
|
+
const target = `${targetBase}${discriminator}`;
|
|
151
|
+
const retiring = `${retiringBase}${discriminator}`;
|
|
152
|
+
if (!existsSync(target) && !existsSync(retiring))
|
|
153
|
+
return { target, retiring };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function roleFromRetiringName(name) {
|
|
157
|
+
const stem = name.slice(1, -'.retiring'.length);
|
|
158
|
+
// Archive names end in the eight-hex launch discriminator, optionally plus
|
|
159
|
+
// a numeric collision discriminator. Strip from the RIGHT so role names such
|
|
160
|
+
// as Developer-3 and Tester-2 remain intact.
|
|
161
|
+
return /^(.*)-[0-9a-f]{8}(?:-\d+)?$/i.exec(stem)?.[1] ?? stem;
|
|
162
|
+
}
|
|
163
|
+
/** Move retired state out of the live roster without deleting any evidence. */
|
|
164
|
+
export function archiveTempState(role, reason, outcome, detail, now = new Date()) {
|
|
165
|
+
const dir = join(tmpRoot(), role);
|
|
166
|
+
if (!existsSync(dir))
|
|
167
|
+
return undefined;
|
|
168
|
+
const supervisor = readTempSupervisor(dir);
|
|
169
|
+
const record = {
|
|
170
|
+
version: 1, role, launchId: supervisor?.launchId, at: now.toISOString(), reason, outcome, detail,
|
|
171
|
+
};
|
|
172
|
+
mkdirSync(archiveRoot(), { recursive: true, mode: 0o700 });
|
|
173
|
+
const stamp = now.toISOString().replaceAll(/[:.]/g, '-');
|
|
174
|
+
const suffix = supervisor?.launchId.slice(0, 8) ?? randomUUID().slice(0, 8);
|
|
175
|
+
const paths = collisionSafeArchivePaths(join(archiveRoot(), `${stamp}-${role}-${suffix}`), join(archiveRoot(), `.${role}-${suffix}.retiring`));
|
|
176
|
+
// The rename is the idempotency boundary: only one concurrent retirement
|
|
177
|
+
// owns the live directory. Everyone else sees it absent and does nothing.
|
|
178
|
+
try {
|
|
179
|
+
renameSync(dir, paths.retiring);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
if (error.code === 'ENOENT' && !existsSync(dir))
|
|
183
|
+
return undefined;
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
appendTermination(paths.retiring, record);
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
renameSync(paths.retiring, paths.target);
|
|
191
|
+
}
|
|
192
|
+
return paths.target;
|
|
193
|
+
}
|
|
194
|
+
/** Finish bounded archive renames interrupted by process or host termination. */
|
|
195
|
+
function recoverInterruptedArchives(now) {
|
|
196
|
+
let names;
|
|
197
|
+
try {
|
|
198
|
+
names = readdirSync(archiveRoot(), { withFileTypes: true })
|
|
199
|
+
.filter(entry => entry.isDirectory() && !entry.isSymbolicLink()
|
|
200
|
+
&& entry.name.startsWith('.') && entry.name.endsWith('.retiring'))
|
|
201
|
+
.map(entry => entry.name)
|
|
202
|
+
.sort()
|
|
203
|
+
.slice(0, TEMP_RECLAIM_BATCH);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
const recovered = [];
|
|
209
|
+
for (const name of names) {
|
|
210
|
+
const source = join(archiveRoot(), name);
|
|
211
|
+
const supervisor = readTempSupervisor(source);
|
|
212
|
+
let line;
|
|
213
|
+
try {
|
|
214
|
+
line = readFileSync(join(source, TEMP_TERMINATION_FILE), 'utf8')
|
|
215
|
+
.split('\n').filter(Boolean).at(-1);
|
|
216
|
+
}
|
|
217
|
+
catch { /* synthesize the audit event below */ }
|
|
218
|
+
if (!line) {
|
|
219
|
+
const record = {
|
|
220
|
+
version: 1,
|
|
221
|
+
role: supervisor?.role ?? roleFromRetiringName(name),
|
|
222
|
+
launchId: supervisor?.launchId,
|
|
223
|
+
at: now.toISOString(),
|
|
224
|
+
reason: 'stale-supervisor',
|
|
225
|
+
outcome: 'reclaimed',
|
|
226
|
+
detail: 'completed an evidence archive interrupted before its termination journal was durable',
|
|
227
|
+
};
|
|
228
|
+
line = JSON.stringify(record);
|
|
229
|
+
appendFileSync(join(source, TEMP_TERMINATION_FILE), line + '\n', { mode: 0o600 });
|
|
230
|
+
}
|
|
231
|
+
if (!existsSync(join(source, TEMP_GLOBAL_TERMINATION_MARKER))) {
|
|
232
|
+
appendGlobalTermination(line + '\n', true);
|
|
233
|
+
replaceFileAtomically(join(source, TEMP_GLOBAL_TERMINATION_MARKER), line + '\n');
|
|
234
|
+
}
|
|
235
|
+
const suffix = name.slice(1, -'.retiring'.length);
|
|
236
|
+
const targetBase = join(archiveRoot(), `${now.toISOString().replaceAll(/[:.]/g, '-')}-recovered-${suffix}`);
|
|
237
|
+
let target = targetBase;
|
|
238
|
+
for (let attempt = 2; existsSync(target); attempt++)
|
|
239
|
+
target = `${targetBase}-${attempt}`;
|
|
240
|
+
renameSync(source, target);
|
|
241
|
+
recovered.push(target);
|
|
242
|
+
}
|
|
243
|
+
return recovered;
|
|
244
|
+
}
|
|
245
|
+
async function detachedProcessLiveness(record, deps) {
|
|
246
|
+
if (!record.pid || !Number.isSafeInteger(record.pid) || record.pid < 2)
|
|
247
|
+
return 'unknown';
|
|
248
|
+
try {
|
|
249
|
+
(deps.kill ?? process.kill)(record.pid, 0);
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
return error.code === 'ESRCH' ? 'stopped' : 'unknown';
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
const argv = readFileSync(`/proc/${record.pid}/cmdline`, 'utf8').split('\0').filter(Boolean);
|
|
256
|
+
const i = argv.indexOf('_run-temp');
|
|
257
|
+
return i >= 1 && argv[i + 1] === record.role ? 'running' : 'stopped';
|
|
258
|
+
}
|
|
259
|
+
catch { /* macOS and other non-/proc hosts use ps below */ }
|
|
260
|
+
const result = await (deps.exec ?? realExec)('ps', ['-p', String(record.pid), '-o', 'command=']);
|
|
261
|
+
if (result.code !== 0)
|
|
262
|
+
return result.code === 1 ? 'stopped' : 'unknown';
|
|
263
|
+
const role = record.role.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
264
|
+
return new RegExp(`(?:^|\\s)_run-temp\\s+${role}(?:\\s|$)`).test(result.stdout.trim())
|
|
265
|
+
? 'running' : 'stopped';
|
|
266
|
+
}
|
|
267
|
+
async function exactTempSupervisorPids(role, exec) {
|
|
268
|
+
const processes = await exec('ps', ['-ax', '-o', 'pid=', '-o', 'command=']);
|
|
269
|
+
if (processes.code !== 0)
|
|
270
|
+
return undefined;
|
|
271
|
+
const escaped = role.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
272
|
+
const pattern = new RegExp(`(?:^|\\s)_run-temp\\s+${escaped}(?:\\s|$)`);
|
|
273
|
+
return processes.stdout.split('\n').flatMap(line => {
|
|
274
|
+
const match = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
275
|
+
return match && pattern.test(match[2]) ? [Number(match[1])] : [];
|
|
276
|
+
}).filter(pid => Number.isSafeInteger(pid) && pid >= 2);
|
|
277
|
+
}
|
|
278
|
+
export async function tempSupervisorLiveness(dir, deps = {}) {
|
|
279
|
+
const record = readTempSupervisor(dir);
|
|
280
|
+
if (!record)
|
|
281
|
+
return 'unknown';
|
|
282
|
+
const exec = deps.exec ?? realExec;
|
|
283
|
+
const age = (deps.now ?? Date.now)() - Date.parse(record.createdAt);
|
|
284
|
+
// A concurrent spawn writes the target before systemd-run/launchctl has
|
|
285
|
+
// registered it. A not-yet-created unit looks exactly like an inactive one,
|
|
286
|
+
// so launch phase plus its bounded grace is not cleanup authority.
|
|
287
|
+
if (record.phase === 'launching'
|
|
288
|
+
&& (!Number.isFinite(age) || age < TEMP_LAUNCH_GRACE_MS))
|
|
289
|
+
return 'unknown';
|
|
290
|
+
// Older writers could lose `kind` in an unlocked metadata RMW while retaining
|
|
291
|
+
// the supervisor pid. Exact argv ownership is stronger than the missing tag.
|
|
292
|
+
if (record.pid && (!record.kind || record.kind === 'detached'))
|
|
293
|
+
return detachedProcessLiveness(record, deps);
|
|
294
|
+
if (record.kind === 'systemd-transient' && record.target) {
|
|
295
|
+
const result = await exec('systemctl', [
|
|
296
|
+
'--user', 'show', '-p', 'ActiveState', '--value', record.target,
|
|
297
|
+
]);
|
|
298
|
+
const state = result.stdout.trim();
|
|
299
|
+
if (['active', 'activating', 'reloading', 'deactivating'].includes(state))
|
|
300
|
+
return 'running';
|
|
301
|
+
if (['inactive', 'failed'].includes(state)
|
|
302
|
+
|| /not (?:be )?(?:found|loaded)|could not be found/i.test(result.stderr))
|
|
303
|
+
return 'stopped';
|
|
304
|
+
return 'unknown';
|
|
305
|
+
}
|
|
306
|
+
if (record.kind === 'launchd-transient' && record.target) {
|
|
307
|
+
const result = await exec('launchctl', ['print', `gui/${process.getuid?.() ?? 501}/${record.target}`]);
|
|
308
|
+
if (result.code === 0)
|
|
309
|
+
return 'running';
|
|
310
|
+
return /could not find service|no such process/i.test(`${result.stdout}\n${result.stderr}`)
|
|
311
|
+
? 'stopped' : 'unknown';
|
|
312
|
+
}
|
|
313
|
+
if (record.kind === 'detached')
|
|
314
|
+
return detachedProcessLiveness(record, deps);
|
|
315
|
+
if (!Number.isFinite(age) || age < TEMP_LAUNCH_GRACE_MS)
|
|
316
|
+
return 'unknown';
|
|
317
|
+
// Incomplete records are settled only from an exact process-table match.
|
|
318
|
+
// Zero matches proves the old supervisor is gone; ambiguity remains unknown.
|
|
319
|
+
const matches = await exactTempSupervisorPids(record.role, exec);
|
|
320
|
+
if (!matches || matches.length > 1)
|
|
321
|
+
return 'unknown';
|
|
322
|
+
return matches.length === 1 ? 'running' : 'stopped';
|
|
323
|
+
}
|
|
324
|
+
export async function stopTempSupervisor(role, deps = {}) {
|
|
325
|
+
const dir = join(tmpRoot(), role);
|
|
326
|
+
if (!existsSync(dir))
|
|
327
|
+
return 'already-stopped';
|
|
328
|
+
const exec = deps.exec ?? realExec;
|
|
329
|
+
let record = readTempSupervisor(dir);
|
|
330
|
+
if (!record) {
|
|
331
|
+
// Upgrade seam for pre-metadata temp roles: derive an exact supervisor only
|
|
332
|
+
// from the OS process table, adopt that one pid into the new fenced record,
|
|
333
|
+
// and refuse ambiguity. Never infer ownership from a directory alone.
|
|
334
|
+
const matches = await exactTempSupervisorPids(role, exec);
|
|
335
|
+
if (!matches)
|
|
336
|
+
throw new Error(`temporary role '${role}' has no supervisor metadata and the process table `
|
|
337
|
+
+ 'could not be read; refusing an unverified process kill');
|
|
338
|
+
if (matches.length > 1)
|
|
339
|
+
throw new Error(`temporary role '${role}' has ${matches.length} matching legacy supervisors; `
|
|
340
|
+
+ 'refusing an ambiguous process kill');
|
|
341
|
+
if (matches.length === 0)
|
|
342
|
+
return 'already-stopped';
|
|
343
|
+
prepareTempSupervisor(dir, role);
|
|
344
|
+
record = await updateTempSupervisor(dir, {
|
|
345
|
+
phase: 'active', kind: 'detached', pid: matches[0], binPath: '(legacy adopted)',
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
if (!record)
|
|
349
|
+
return 'already-stopped';
|
|
350
|
+
if (record.role !== role)
|
|
351
|
+
throw new Error(`temporary role '${role}' metadata names '${record.role}'; refusing mismatched supervisor control`);
|
|
352
|
+
replaceFileAtomically(join(dir, TEMP_STOP_REQUEST_FILE), JSON.stringify({
|
|
353
|
+
version: 1, role, reason: 'operator-stop', requestedAt: new Date().toISOString(),
|
|
354
|
+
}) + '\n');
|
|
355
|
+
if (record.kind === 'systemd-transient' && record.target) {
|
|
356
|
+
const result = await exec('systemctl', ['--user', 'stop', record.target]);
|
|
357
|
+
if (result.code !== 0
|
|
358
|
+
&& !/not (?:be )?(?:found|loaded)|could not be found/i.test(result.stderr))
|
|
359
|
+
throw new Error(`systemctl stop ${record.target} failed: ${result.stderr.trim()}`);
|
|
360
|
+
return result.code === 0 ? 'stopped' : 'already-stopped';
|
|
361
|
+
}
|
|
362
|
+
if (record.kind === 'launchd-transient' && record.target) {
|
|
363
|
+
const result = await exec('launchctl', ['remove', record.target]);
|
|
364
|
+
if (result.code !== 0 && !/could not find|no such process/i.test(`${result.stdout}\n${result.stderr}`))
|
|
365
|
+
throw new Error(`launchctl remove ${record.target} failed: ${result.stderr.trim()}`);
|
|
366
|
+
return result.code === 0 ? 'stopped' : 'already-stopped';
|
|
367
|
+
}
|
|
368
|
+
if (record.pid && (!record.kind || record.kind === 'detached')) {
|
|
369
|
+
const live = await detachedProcessLiveness(record, deps);
|
|
370
|
+
if (live === 'stopped')
|
|
371
|
+
return 'already-stopped';
|
|
372
|
+
if (live === 'unknown')
|
|
373
|
+
throw new Error(`temporary role '${role}' process ownership could not be verified; refusing to signal pid ${record.pid}`);
|
|
374
|
+
(deps.kill ?? process.kill)(record.pid, 'SIGTERM');
|
|
375
|
+
return 'stopped';
|
|
376
|
+
}
|
|
377
|
+
// A valid-but-incomplete record from an interrupted/unlocked older launch is
|
|
378
|
+
// not permanently unremovable. Adopt exactly one matching legacy process, or
|
|
379
|
+
// prove there is none; never guess when the process table is unreadable or
|
|
380
|
+
// more than one candidate exists.
|
|
381
|
+
const matches = await exactTempSupervisorPids(role, exec);
|
|
382
|
+
if (!matches)
|
|
383
|
+
throw new Error(`temporary role '${role}' has incomplete supervisor metadata and the process table `
|
|
384
|
+
+ 'could not be read; refusing an unverified process kill');
|
|
385
|
+
if (matches.length > 1)
|
|
386
|
+
throw new Error(`temporary role '${role}' has incomplete supervisor metadata and ${matches.length} `
|
|
387
|
+
+ 'matching supervisors; refusing an ambiguous process kill');
|
|
388
|
+
if (matches.length === 0)
|
|
389
|
+
return 'already-stopped';
|
|
390
|
+
record = await updateTempSupervisor(dir, {
|
|
391
|
+
phase: 'active', kind: 'detached', pid: matches[0], binPath: '(incomplete metadata adopted)',
|
|
392
|
+
});
|
|
393
|
+
if (!record)
|
|
394
|
+
return 'already-stopped';
|
|
395
|
+
const live = await detachedProcessLiveness(record, deps);
|
|
396
|
+
if (live !== 'running') {
|
|
397
|
+
if (live === 'stopped')
|
|
398
|
+
return 'already-stopped';
|
|
399
|
+
throw new Error(`temporary role '${role}' adopted process ownership could not be verified; `
|
|
400
|
+
+ `refusing to signal pid ${record.pid}`);
|
|
401
|
+
}
|
|
402
|
+
(deps.kill ?? process.kill)(record.pid, 'SIGTERM');
|
|
403
|
+
return 'stopped';
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Move a bounded batch of definitely-dead temp state into the evidence archive.
|
|
407
|
+
* Unknown/legacy/live entries are preserved; absence of proof is never cleanup authority.
|
|
408
|
+
*/
|
|
409
|
+
export async function reclaimStaleTempState(deps = {}) {
|
|
410
|
+
const now = new Date((deps.now ?? Date.now)());
|
|
411
|
+
const recovered = recoverInterruptedArchives(now);
|
|
412
|
+
let entries = [];
|
|
413
|
+
try {
|
|
414
|
+
entries = readdirSync(tmpRoot(), { withFileTypes: true })
|
|
415
|
+
.filter(entry => entry.isDirectory() && !entry.isSymbolicLink())
|
|
416
|
+
.map(entry => ({ name: entry.name, mtimeMs: statSync(join(tmpRoot(), entry.name)).mtimeMs }))
|
|
417
|
+
.sort((a, b) => a.mtimeMs - b.mtimeMs)
|
|
418
|
+
.slice(0, TEMP_RECLAIM_BATCH);
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
return recovered;
|
|
422
|
+
}
|
|
423
|
+
const archived = [...recovered];
|
|
424
|
+
for (const entry of entries) {
|
|
425
|
+
const dir = join(tmpRoot(), entry.name);
|
|
426
|
+
if (!readTempSupervisor(dir))
|
|
427
|
+
continue; // legacy evidence has no safe ownership proof
|
|
428
|
+
if (await tempSupervisorLiveness(dir, deps) !== 'stopped')
|
|
429
|
+
continue;
|
|
430
|
+
const target = archiveTempState(entry.name, 'stale-supervisor', 'reclaimed', 'supervisor is definitively stopped; state moved from the live roster without deletion', now);
|
|
431
|
+
if (target) {
|
|
432
|
+
archived.push(target);
|
|
433
|
+
deps.log?.(`reclaimed stale temporary role '${entry.name}' to ${target}`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return archived;
|
|
437
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.2",
|
|
4
4
|
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|
|
@@ -23,13 +23,15 @@
|
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
25
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
26
|
-
"build": "npm run clean && tsc -p tsconfig.json && vite build",
|
|
26
|
+
"build": "npm run clean && tsc -p tsconfig.json && vite build && node scripts/build-info.mjs",
|
|
27
27
|
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p web/tsconfig.json --noEmit",
|
|
28
28
|
"lint": "npm run typecheck",
|
|
29
29
|
"test": "vitest run",
|
|
30
30
|
"test:web": "vitest run test/web",
|
|
31
|
+
"test:pack": "vitest run --config vitest.integration.config.ts",
|
|
31
32
|
"test:e2e": "npm run build && playwright test",
|
|
32
33
|
"dev:web": "vite",
|
|
34
|
+
"prepack": "npm run build",
|
|
33
35
|
"prepublishOnly": "npm run build && npm test"
|
|
34
36
|
},
|
|
35
37
|
"dependencies": {
|
|
@@ -50,7 +52,7 @@
|
|
|
50
52
|
},
|
|
51
53
|
"optionalDependencies": {
|
|
52
54
|
"@agentclientprotocol/claude-agent-acp": "^0.63.0",
|
|
53
|
-
"@agentclientprotocol/codex-acp": "
|
|
55
|
+
"@agentclientprotocol/codex-acp": "1.1.7",
|
|
54
56
|
"node-pty": "^1.1.0"
|
|
55
57
|
},
|
|
56
58
|
"devDependencies": {
|