@ours.network/fleet 1.2.0-nightly.53 → 1.2.0-nightly.55
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 +22 -12
- package/dist/agent-ours/bridge.js +10 -3
- package/dist/agent-ours/file-rpc.d.ts +8 -0
- package/dist/agent-ours/file-rpc.js +11 -1
- package/dist/agent-ours/harness.js +47 -3
- package/dist/agent-ours/mcp-endpoint.d.ts +5 -0
- package/dist/agent-ours/mcp-endpoint.js +4 -0
- package/dist/agent-ours/service.d.ts +6 -1
- package/dist/agent-ours/service.js +73 -8
- package/dist/application/conversation-history.d.ts +2 -0
- package/dist/application/conversation-history.js +25 -2
- package/dist/application/log-service.js +8 -8
- package/dist/application/role-creation-service.js +2 -1
- package/dist/application/role-removal-service.js +7 -1
- package/dist/application/role-repository.js +17 -0
- package/dist/application/supervisor-ours-tools.js +3 -1
- package/dist/application/task-layouts.js +12 -2
- package/dist/application/task-room-service.js +19 -17
- package/dist/application/types.d.ts +2 -0
- package/dist/build-info.json +4 -4
- package/dist/cli.js +31 -3
- package/dist/config.d.ts +11 -0
- package/dist/config.js +13 -2
- package/dist/file-delivery/reader.d.ts +17 -0
- package/dist/file-delivery/reader.js +94 -0
- package/dist/file-delivery/store.d.ts +15 -0
- package/dist/file-delivery/store.js +124 -0
- package/dist/file-delivery/types.d.ts +19 -0
- package/dist/file-delivery/types.js +1 -0
- package/dist/harness/agent-session.d.ts +2 -0
- package/dist/harness/claude-code-session.js +1 -1
- package/dist/harness/codex-app-server-proxy.js +6 -2
- package/dist/harness/codex-file-inventory.d.ts +15 -0
- package/dist/harness/codex-file-inventory.js +113 -0
- package/dist/harness/codex-session.js +2 -2
- package/dist/harness/codex.js +2 -1
- package/dist/isolation/policy.js +4 -1
- package/dist/managed-recovery.d.ts +13 -0
- package/dist/managed-recovery.js +31 -0
- package/dist/ops.d.ts +2 -0
- package/dist/ops.js +15 -2
- package/dist/rooms-tasks/cli.js +21 -0
- package/dist/rooms-tasks/close.d.ts +2 -0
- package/dist/rooms-tasks/close.js +66 -1
- package/dist/rooms-tasks/deletion.js +10 -0
- package/dist/rooms-tasks/erasure.js +4 -2
- package/dist/rooms-tasks/layout-member-retirement.d.ts +10 -0
- package/dist/rooms-tasks/layout-member-retirement.js +90 -0
- package/dist/rooms-tasks/layout-service.d.ts +1 -0
- package/dist/rooms-tasks/layout-service.js +31 -13
- package/dist/rooms-tasks/layout.d.ts +7 -0
- package/dist/rooms-tasks/layout.js +35 -0
- package/dist/rooms-tasks/legacy-task-member.d.ts +19 -0
- package/dist/rooms-tasks/legacy-task-member.js +116 -0
- package/dist/rooms-tasks/member-ownership.d.ts +5 -0
- package/dist/rooms-tasks/member-ownership.js +40 -0
- package/dist/rooms-tasks/provision.js +26 -1
- package/dist/rooms-tasks/recovery.d.ts +19 -0
- package/dist/rooms-tasks/recovery.js +110 -0
- package/dist/rooms-tasks/task-service-retirement.d.ts +11 -0
- package/dist/rooms-tasks/task-service-retirement.js +21 -0
- package/dist/rooms-tasks/task-state.d.ts +1 -0
- package/dist/rooms-tasks/task-state.js +16 -1
- package/dist/rooms-tasks/types.d.ts +3 -0
- package/dist/rooms-tasks/workspace.d.ts +2 -0
- package/dist/rooms-tasks/workspace.js +5 -0
- package/dist/runner.d.ts +6 -0
- package/dist/runner.js +161 -22
- package/dist/session/acp.d.ts +5 -0
- package/dist/session/acp.js +68 -8
- package/dist/session/arbiter.d.ts +5 -0
- package/dist/session/arbiter.js +16 -2
- package/dist/session/codex-app-server.d.ts +1 -0
- package/dist/session/codex-app-server.js +5 -0
- package/dist/session/conversation-types.d.ts +4 -2
- package/dist/session/types.d.ts +3 -0
- package/dist/spawn.d.ts +5 -1
- package/dist/spawn.js +32 -3
- package/dist/startup-readiness.d.ts +1 -1
- package/dist/startup-readiness.js +3 -3
- package/dist/supervisor/adoption.d.ts +8 -0
- package/dist/supervisor/adoption.js +78 -0
- package/dist/supervisor/catalog.d.ts +62 -0
- package/dist/supervisor/catalog.js +254 -0
- package/dist/supervisor/fleet.d.ts +8 -0
- package/dist/supervisor/fleet.js +89 -0
- package/dist/supervisor/index.js +3 -6
- package/dist/supervisor/launchd.d.ts +1 -1
- package/dist/supervisor/launchd.js +24 -12
- package/dist/supervisor/legacy.d.ts +35 -0
- package/dist/supervisor/legacy.js +524 -0
- package/dist/supervisor/manager.d.ts +15 -0
- package/dist/supervisor/manager.js +258 -0
- package/dist/supervisor/scope.d.ts +4 -0
- package/dist/supervisor/scope.js +10 -0
- package/dist/supervisor/systemd.d.ts +2 -2
- package/dist/supervisor/systemd.js +14 -14
- package/dist/task-supervision.d.ts +9 -0
- package/dist/task-supervision.js +145 -0
- package/dist/temp-lifecycle.d.ts +17 -7
- package/dist/temp-lifecycle.js +85 -53
- package/dist/temp-supervisor-recovery.d.ts +1 -1
- package/dist/temp-supervisor-recovery.js +2 -2
- package/dist/web/server.js +24 -0
- package/docs/acp-files.md +102 -0
- package/docs/fleet-migration.md +54 -0
- package/docs/task-workspaces.md +26 -0
- package/package.json +7 -3
package/dist/temp-lifecycle.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, } from 'node:fs';
|
|
2
|
+
import { parse } from 'yaml';
|
|
2
3
|
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { basename, join } from 'node:path';
|
|
4
5
|
import { replaceFileAtomically, withFileLock, withSynchronousFileLock } from './atomic-file.js';
|
|
5
6
|
import { realExec } from './exec.js';
|
|
7
|
+
import { captureMemberEnvironment, catalogLiveness, memberKey, readMember, registerMember, unregisterMember, unregisterTaskMember } from './supervisor/catalog.js';
|
|
8
|
+
import { ensureFleetParent } from './supervisor/fleet.js';
|
|
6
9
|
import { stateRoot, tmpRoot } from './paths.js';
|
|
7
10
|
export const TEMP_SUPERVISOR_FILE = '.temp-supervisor.json';
|
|
8
11
|
export const TEMP_TERMINATION_FILE = 'termination.jsonl';
|
|
@@ -13,26 +16,40 @@ export const TEMP_LAUNCH_GRACE_MS = 60_000;
|
|
|
13
16
|
const metadataPath = (dir) => join(dir, TEMP_SUPERVISOR_FILE);
|
|
14
17
|
export const tempSystemdUnit = (name) => `ours-fleet-temp-${name}.service`;
|
|
15
18
|
export const tempLaunchdLabel = (name) => `network.ours.fleet.temp.${name}`;
|
|
16
|
-
export function prepareTempSupervisor(dir, role) {
|
|
19
|
+
export function prepareTempSupervisor(dir, role, taskOwner) {
|
|
17
20
|
const record = {
|
|
18
|
-
version: 1, role, launchId: randomUUID(), createdAt: new Date().toISOString(), phase: 'launching',
|
|
21
|
+
version: 1, role, launchId: randomUUID(), createdAt: new Date().toISOString(), phase: 'launching', ...(taskOwner ? { taskOwner } : {}),
|
|
19
22
|
};
|
|
20
23
|
replaceFileAtomically(metadataPath(dir), JSON.stringify(record, null, 2) + '\n');
|
|
21
24
|
return record;
|
|
22
25
|
}
|
|
23
26
|
export function readTempSupervisor(dir) {
|
|
27
|
+
let value;
|
|
24
28
|
try {
|
|
25
|
-
|
|
26
|
-
if (value.version !== 1 || typeof value.role !== 'string' || typeof value.launchId !== 'string')
|
|
27
|
-
return undefined;
|
|
28
|
-
return value;
|
|
29
|
+
value = JSON.parse(readFileSync(metadataPath(dir), 'utf8'));
|
|
29
30
|
}
|
|
30
31
|
catch {
|
|
31
32
|
return undefined;
|
|
32
33
|
}
|
|
34
|
+
if (!value || typeof value !== 'object' || value.version !== 1 || typeof value.role !== 'string' || typeof value.launchId !== 'string')
|
|
35
|
+
return undefined;
|
|
36
|
+
if (value.kind !== undefined && !['fleet-managed', 'systemd-transient', 'launchd-transient', 'detached'].includes(value.kind))
|
|
37
|
+
throw Error('TEMP_SUPERVISOR_KIND_UNSUPPORTED');
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
/** Broad scans retain unsupported entries instead of blocking other members. */
|
|
41
|
+
export function readTempSupervisorForScan(dir) {
|
|
42
|
+
try {
|
|
43
|
+
return readTempSupervisor(dir);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error instanceof Error && error.message === 'TEMP_SUPERVISOR_KIND_UNSUPPORTED')
|
|
47
|
+
return undefined;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
33
50
|
}
|
|
34
51
|
const metadataLockPath = (dir) => join(stateRoot(), 'locks', 'temp-supervisors', encodeURIComponent(basename(dir)));
|
|
35
|
-
async function updateTempSupervisor(dir, update) {
|
|
52
|
+
export async function updateTempSupervisor(dir, update) {
|
|
36
53
|
// The launcher and the just-started supervisor are separate processes. Lock
|
|
37
54
|
// their read/merge/write updates so neither can discard the other's kind,
|
|
38
55
|
// target, pid or phase. The lock lives outside the role directory so an
|
|
@@ -48,54 +65,22 @@ async function updateTempSupervisor(dir, update) {
|
|
|
48
65
|
return next;
|
|
49
66
|
});
|
|
50
67
|
}
|
|
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
|
-
*/
|
|
68
|
+
/** Register managed members under Fleet; explicit manual mode uses a detached child. */
|
|
56
69
|
export function makeTempSupervisorLauncher(options = {}) {
|
|
57
70
|
const exec = options.exec ?? realExec;
|
|
58
71
|
const platform = options.platform ?? process.platform;
|
|
59
72
|
const supervisor = options.supervisor ?? process.env.OURS_FLEET_SUPERVISOR;
|
|
60
73
|
return async (binPath, args, dir) => {
|
|
61
|
-
const inherited = [
|
|
62
|
-
'HOME', 'PATH', 'XDG_RUNTIME_DIR', 'OURS_FLEET_HOME', 'OURS_FLEET_SOCKET_ROOT', 'CODEX_HOME',
|
|
63
|
-
// Preserve runtime selection across the service-manager boundary, including
|
|
64
|
-
// an explicit empty value which selects the bundle over manager defaults.
|
|
65
|
-
'CODEX_PATH',
|
|
66
|
-
// The child supervisor performs daemon identity and wake probes itself;
|
|
67
|
-
// it must resolve the same ours profile as the spawning supervisor.
|
|
68
|
-
'OURS_PORT', 'OURS_STATE_DIR', 'OURS_API_TOKEN', 'OURS_CONFIG',
|
|
69
|
-
]
|
|
70
|
-
.flatMap(key => process.env[key] !== undefined ? [`${key}=${process.env[key]}`] : []);
|
|
71
74
|
const role = args.at(-1);
|
|
72
75
|
if (!role)
|
|
73
76
|
throw new Error('temporary supervisor launch requires a role name');
|
|
74
|
-
const
|
|
75
|
-
if (supervisor !== 'none'
|
|
76
|
-
const
|
|
77
|
-
await updateTempSupervisor(dir, { phase: 'launching', kind: '
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
`--property=StandardOutput=append:${log}`, `--property=StandardError=append:${log}`,
|
|
82
|
-
...inherited.map(value => `--setenv=${value}`),
|
|
83
|
-
process.execPath, binPath, ...args,
|
|
84
|
-
]);
|
|
85
|
-
if (result.code !== 0)
|
|
86
|
-
throw new Error(`systemd-run ${target} failed: ${result.stderr.trim() || `exit ${result.code}`}`);
|
|
87
|
-
await updateTempSupervisor(dir, { phase: 'active' });
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
if (supervisor !== 'none' && platform === 'darwin') {
|
|
91
|
-
const target = tempLaunchdLabel(role);
|
|
92
|
-
await updateTempSupervisor(dir, { phase: 'launching', kind: 'launchd-transient', target, binPath });
|
|
93
|
-
const result = await exec('launchctl', [
|
|
94
|
-
'submit', '-l', target, '-o', log, '-e', log, '--',
|
|
95
|
-
'/usr/bin/env', ...inherited, process.execPath, binPath, ...args,
|
|
96
|
-
]);
|
|
97
|
-
if (result.code !== 0)
|
|
98
|
-
throw new Error(`launchctl submit ${target} failed: ${result.stderr.trim() || `exit ${result.code}`}`);
|
|
77
|
+
const metadata = readTempSupervisor(dir);
|
|
78
|
+
if (supervisor !== 'none') {
|
|
79
|
+
const kind = metadata?.taskOwner ? 'task' : 'temporary';
|
|
80
|
+
await updateTempSupervisor(dir, { phase: 'launching', kind: 'fleet-managed', target: memberKey(role, kind), binPath });
|
|
81
|
+
await ensureFleetParent(exec, platform);
|
|
82
|
+
await registerMember({ name: role, kind, dir, environment: await captureMemberEnvironment(),
|
|
83
|
+
...(metadata?.taskOwner ? { taskOwner: metadata.taskOwner, launchId: metadata.launchId } : {}) });
|
|
99
84
|
await updateTempSupervisor(dir, { phase: 'active' });
|
|
100
85
|
return;
|
|
101
86
|
}
|
|
@@ -211,7 +196,7 @@ export function archiveTempState(role, reason, outcome, detail, now = new Date()
|
|
|
211
196
|
return paths.target;
|
|
212
197
|
}
|
|
213
198
|
function isArchiveForLaunch(path, role, launchId) {
|
|
214
|
-
const supervisor =
|
|
199
|
+
const supervisor = readTempSupervisorForScan(path);
|
|
215
200
|
if (supervisor?.role !== role || supervisor.launchId !== launchId)
|
|
216
201
|
return false;
|
|
217
202
|
try {
|
|
@@ -256,7 +241,7 @@ export function tempArchiveForCreationAction(role, creationActionId) {
|
|
|
256
241
|
return undefined;
|
|
257
242
|
}
|
|
258
243
|
const matches = entries.flatMap(path => {
|
|
259
|
-
const supervisor =
|
|
244
|
+
const supervisor = readTempSupervisorForScan(path);
|
|
260
245
|
if (!supervisor || supervisor.role !== role || !isArchiveForLaunch(path, role, supervisor.launchId))
|
|
261
246
|
return [];
|
|
262
247
|
try {
|
|
@@ -317,7 +302,15 @@ function recoverInterruptedArchives(now) {
|
|
|
317
302
|
const recovered = [];
|
|
318
303
|
for (const name of names) {
|
|
319
304
|
const source = join(archiveRoot(), name);
|
|
320
|
-
|
|
305
|
+
let supervisor;
|
|
306
|
+
try {
|
|
307
|
+
supervisor = readTempSupervisor(source);
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
if (error instanceof Error && error.message === 'TEMP_SUPERVISOR_KIND_UNSUPPORTED')
|
|
311
|
+
continue;
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
321
314
|
let line;
|
|
322
315
|
try {
|
|
323
316
|
line = readFileSync(join(source, TEMP_TERMINATION_FILE), 'utf8')
|
|
@@ -393,11 +386,13 @@ export async function tempSupervisorLiveness(dir, deps = {}) {
|
|
|
393
386
|
// A concurrent spawn writes the target before systemd-run/launchctl has
|
|
394
387
|
// registered it. A not-yet-created unit looks exactly like an inactive one,
|
|
395
388
|
// so launch phase plus its bounded grace is not cleanup authority.
|
|
396
|
-
if (record.phase === 'launching'
|
|
389
|
+
if (record.phase === 'launching' && !requestedTempStopReason(dir)
|
|
397
390
|
&& (!Number.isFinite(age) || age < TEMP_LAUNCH_GRACE_MS))
|
|
398
391
|
return 'unknown';
|
|
399
392
|
// Older writers could lose `kind` in an unlocked metadata RMW while retaining
|
|
400
393
|
// the supervisor pid. Exact argv ownership is stronger than the missing tag.
|
|
394
|
+
if (record.kind === 'fleet-managed')
|
|
395
|
+
return (await catalogLiveness(record.target ?? memberKey(record.role, record.taskOwner ? 'task' : 'temporary'), exec)).state;
|
|
401
396
|
if (record.pid && (!record.kind || record.kind === 'detached'))
|
|
402
397
|
return detachedProcessLiveness(record, deps);
|
|
403
398
|
if (record.kind === 'systemd-transient' && record.target) {
|
|
@@ -461,6 +456,13 @@ export async function stopTempSupervisor(role, deps = {}) {
|
|
|
461
456
|
replaceFileAtomically(join(dir, TEMP_STOP_REQUEST_FILE), JSON.stringify({
|
|
462
457
|
version: 1, role, reason: 'operator-stop', requestedAt: new Date().toISOString(),
|
|
463
458
|
}) + '\n');
|
|
459
|
+
if (record.kind === 'fleet-managed') {
|
|
460
|
+
if (record.taskOwner)
|
|
461
|
+
await unregisterTaskMember(role, { ...record.taskOwner, launchId: record.launchId }, { exec, sleep: deps.sleep });
|
|
462
|
+
else
|
|
463
|
+
await unregisterMember(record.target ?? memberKey(role, 'temporary'), { exec, sleep: deps.sleep });
|
|
464
|
+
return 'stopped';
|
|
465
|
+
}
|
|
464
466
|
if (record.kind === 'systemd-transient' && record.target) {
|
|
465
467
|
const result = await exec('systemctl', ['--user', 'stop', record.target]);
|
|
466
468
|
if (result.code !== 0
|
|
@@ -511,6 +513,26 @@ export async function stopTempSupervisor(role, deps = {}) {
|
|
|
511
513
|
(deps.kill ?? process.kill)(record.pid, 'SIGTERM');
|
|
512
514
|
return 'stopped';
|
|
513
515
|
}
|
|
516
|
+
/** Legacy task snapshots remain recovery evidence even before explicit migration. */
|
|
517
|
+
function hasRetainedTaskReference(dir) {
|
|
518
|
+
try {
|
|
519
|
+
if (basename(dir).startsWith('layout-')) {
|
|
520
|
+
const provenance = JSON.parse(readFileSync(join(dir, 'creation.json'), 'utf8'));
|
|
521
|
+
if (typeof provenance.creationActionId === 'string' && provenance.creationActionId.startsWith('task-'))
|
|
522
|
+
return true;
|
|
523
|
+
}
|
|
524
|
+
const startup = parse(readFileSync(join(dir, 'role.yaml'), 'utf8'))?.roomMemberStartup;
|
|
525
|
+
if (!startup?.room_id || !/^[A-Za-z0-9_-]{1,120}$/.test(startup.room_id))
|
|
526
|
+
return false;
|
|
527
|
+
const room = JSON.parse(readFileSync(join(stateRoot(), 'rooms', `${startup.room_id}.json`), 'utf8'));
|
|
528
|
+
return typeof room.task_id === 'string'
|
|
529
|
+
&& room.member_seats.some((seat) => seat.role_name === basename(dir));
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
// An unreadable role/room is uncertain ownership, never permission to reclaim.
|
|
533
|
+
return error.code !== 'ENOENT';
|
|
534
|
+
}
|
|
535
|
+
}
|
|
514
536
|
/**
|
|
515
537
|
* Move a bounded batch of definitely-dead temp state into the evidence archive.
|
|
516
538
|
* Unknown/legacy/live entries are preserved; absence of proof is never cleanup authority.
|
|
@@ -532,8 +554,18 @@ export async function reclaimStaleTempState(deps = {}) {
|
|
|
532
554
|
const archived = [...recovered];
|
|
533
555
|
for (const entry of entries) {
|
|
534
556
|
const dir = join(tmpRoot(), entry.name);
|
|
535
|
-
|
|
536
|
-
|
|
557
|
+
const supervisor = readTempSupervisorForScan(dir);
|
|
558
|
+
if (!supervisor || supervisor.taskOwner || hasRetainedTaskReference(dir))
|
|
559
|
+
continue;
|
|
560
|
+
if (supervisor?.kind === 'fleet-managed' && supervisor.target) {
|
|
561
|
+
try {
|
|
562
|
+
if (readMember(supervisor.target)?.desired === 'running')
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
continue;
|
|
567
|
+
} // uncertain catalog intent is never reclamation authority
|
|
568
|
+
}
|
|
537
569
|
if (await tempSupervisorLiveness(dir, deps) !== 'stopped')
|
|
538
570
|
continue;
|
|
539
571
|
const target = archiveTempState(entry.name, 'stale-supervisor', 'reclaimed', 'supervisor is definitively stopped; state moved from the live roster without deletion', now);
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export declare const TEMP_RECYCLE_EXIT = 75;
|
|
2
2
|
/** Keep the service's main process alive while replacing a PID-scoped worker. */
|
|
3
|
-
export declare function runTempSupervisor(name: string, entrypoint: string, initial?: () => Promise<void
|
|
3
|
+
export declare function runTempSupervisor(name: string, entrypoint: string, initial?: () => Promise<void>, suspendOnStop?: boolean): Promise<void>;
|
|
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { runTemp, SupervisorRecycleRequiredError } from './runner.js';
|
|
3
3
|
export const TEMP_RECYCLE_EXIT = 75;
|
|
4
4
|
/** Keep the service's main process alive while replacing a PID-scoped worker. */
|
|
5
|
-
export async function runTempSupervisor(name, entrypoint, initial = () => runTemp(name)) {
|
|
5
|
+
export async function runTempSupervisor(name, entrypoint, initial = () => runTemp(name), suspendOnStop = false) {
|
|
6
6
|
try {
|
|
7
7
|
await initial();
|
|
8
8
|
return;
|
|
@@ -12,7 +12,7 @@ export async function runTempSupervisor(name, entrypoint, initial = () => runTem
|
|
|
12
12
|
throw error;
|
|
13
13
|
}
|
|
14
14
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
15
|
-
const child = spawn(process.execPath, [entrypoint, '_run-temp-worker', name], {
|
|
15
|
+
const child = spawn(process.execPath, [entrypoint, '_run-temp-worker', name, ...(suspendOnStop ? ['--suspend-on-stop'] : [])], {
|
|
16
16
|
stdio: 'inherit',
|
|
17
17
|
env: process.env,
|
|
18
18
|
});
|
package/dist/web/server.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { conversationHasAttachment } from '../application/conversation-history.js';
|
|
2
|
+
import { readDeliveredFile } from '../file-delivery/store.js';
|
|
1
3
|
import { WorkspaceDeviceAuthError } from './workspace-devices.js';
|
|
2
4
|
import { readFileSync } from 'node:fs';
|
|
3
5
|
import { packagedPresetRoot } from '../preset-bootstrap.js';
|
|
@@ -756,6 +758,28 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
756
758
|
const page = await control.conversationPage({ after: request.query.after, limit });
|
|
757
759
|
return { ...page, events: page.events.map(present) };
|
|
758
760
|
});
|
|
761
|
+
app.get('/api/v1/roles/:id/artifacts/:artifactId', async (request, reply) => {
|
|
762
|
+
auth.authenticate(request);
|
|
763
|
+
const role = await services.repository.get(request.params.id);
|
|
764
|
+
if (!role)
|
|
765
|
+
throw new FleetError('role_not_found', 'Agent not found');
|
|
766
|
+
const stateDir = services.repository.stateDir(role);
|
|
767
|
+
if (!stateDir)
|
|
768
|
+
throw new FleetError('resource_not_found', 'Agent file not found');
|
|
769
|
+
try {
|
|
770
|
+
const { record, bytes } = await readDeliveredFile(stateDir, request.params.artifactId);
|
|
771
|
+
if (!await conversationHasAttachment(stateDir, record))
|
|
772
|
+
throw Error('ARTIFACT_NOT_PUBLISHED');
|
|
773
|
+
reply.header('Cache-Control', 'private, no-store');
|
|
774
|
+
reply.header('X-Content-Type-Options', 'nosniff');
|
|
775
|
+
reply.header('Content-Security-Policy', "default-src 'none'; sandbox");
|
|
776
|
+
reply.header('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(record.name).replace(/'/g, '%27')}`);
|
|
777
|
+
return reply.type('application/octet-stream').send(bytes);
|
|
778
|
+
}
|
|
779
|
+
catch {
|
|
780
|
+
throw new FleetError('resource_not_found', 'Agent file unavailable');
|
|
781
|
+
}
|
|
782
|
+
});
|
|
759
783
|
app.get('/api/v1/roles/:id/attachments/:attachmentId', async (request, reply) => {
|
|
760
784
|
auth.authenticate(request);
|
|
761
785
|
const role = await services.repository.get(request.params.id);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Sending files in an ACP chat
|
|
2
|
+
|
|
3
|
+
An opted-in managed Agent uses the existing Ours `send_file` tool:
|
|
4
|
+
|
|
5
|
+
```json
|
|
6
|
+
{"path":"deliverables/report.pdf"}
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Omitting `contact` sends to the current ACP chat. Supplying a non-empty contact
|
|
10
|
+
keeps ordinary Ours delivery:
|
|
11
|
+
|
|
12
|
+
```json
|
|
13
|
+
{"contact":"Alice","path":"deliverables/report.pdf"}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Only an absent contact chooses the chat. Null, empty or whitespace-only contacts,
|
|
17
|
+
unknown fields and unknown recipients fail without falling back to chat. Outside
|
|
18
|
+
opt-in, contact remains required. Current-chat sending accepts `path`, optional
|
|
19
|
+
`filename` and `mime`; inline base64 and Ours reply fields are unsupported.
|
|
20
|
+
|
|
21
|
+
Each call makes one attempt. Failures are returned to the agent. There is no
|
|
22
|
+
request ID, deduplication, automatic retry or recovery of failed sends. If a user
|
|
23
|
+
asks to resend, another tool call makes a new attempt and can produce another
|
|
24
|
+
attachment. UUIDs identify stored attachments; the stored SHA-256 checks integrity.
|
|
25
|
+
Neither is a retry key.
|
|
26
|
+
|
|
27
|
+
Set this on the Agent definition, not manifest defaults or a Brain:
|
|
28
|
+
|
|
29
|
+
```yaml
|
|
30
|
+
file_delivery:
|
|
31
|
+
enabled: true
|
|
32
|
+
directory: deliverables
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The tool description advertises this directory and the 20 MiB limit to the Agent.
|
|
36
|
+
The directory is a non-hidden subtree of the Agent's working directory. Both
|
|
37
|
+
routes interpret relative paths from that working directory; absolute paths keep
|
|
38
|
+
their usual meaning. Chat sending additionally enforces the export directory.
|
|
39
|
+
The Agent should finish writing the file before calling `send_file`. Symlinks,
|
|
40
|
+
hardlinks, hidden/credential paths, changed files and files over 20 MiB fail.
|
|
41
|
+
|
|
42
|
+
Initial support requires Linux, the managed Ours connector, a stock Codex or
|
|
43
|
+
Claude ACP adapter, and a tracked active turn. Idle, scheduled, steered, cancelled,
|
|
44
|
+
replaced and unsupported sessions cannot export. Existing native tool approval
|
|
45
|
+
and the neutral role permission policy both apply before bytes are read. An
|
|
46
|
+
`ask` decision needs an attached controller; `deny` refuses. A native policy that
|
|
47
|
+
refuses MCP execution (including Codex `never` without a native tool allowance)
|
|
48
|
+
can still refuse before Fleet receives the call.
|
|
49
|
+
|
|
50
|
+
Codex requires native runtime 0.159.0 or newer and Fleet's managed App Server
|
|
51
|
+
proxy. Before each turn, its public exact-thread, paginated MCP inventory must
|
|
52
|
+
show a connected managed `ours` with the expected optional-contact schema.
|
|
53
|
+
Ours aliases, filtered aliases, ambiguous identity, failed discovery and an
|
|
54
|
+
incompatible schema make the turn unavailable. Unrelated identified tools are
|
|
55
|
+
preserved. Explicitly disabled Ours is rejected before replacement. Inherited
|
|
56
|
+
TOML MCP settings, including project/profile sources, are conservatively
|
|
57
|
+
unsupported until their disabling semantics can be qualified before replacement.
|
|
58
|
+
Use a clean Codex configuration home for this initial opt-in.
|
|
59
|
+
|
|
60
|
+
Claude ACP 0.63.0's installed wrapper has no public same-session inventory RPC. Initial
|
|
61
|
+
support therefore requires an operator's explicit restricted MCP configuration:
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
harness_options:
|
|
65
|
+
mcp_servers_only: true
|
|
66
|
+
mcp_servers:
|
|
67
|
+
ours: { command: ours-mcp, args: [proxy] }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Fleet replaces that connector with its bound bridge. Additional explicit MCP
|
|
71
|
+
servers and custom ACP commands are unsupported for this feature. Default-off
|
|
72
|
+
Agents retain their existing configuration.
|
|
73
|
+
|
|
74
|
+
Successful sends copy complete bytes into private immutable storage and append a
|
|
75
|
+
server-origin `file.attached` event containing the actual session generation,
|
|
76
|
+
ACP session and managed turn. One export can run at a time. Cancellation or
|
|
77
|
+
steering before publication revokes it; cancellation after publication does not
|
|
78
|
+
undo it. Unpublished stored bytes are never replayed or republished. Storage is
|
|
79
|
+
bounded to 100 files and 200 MiB per role incarnation, including incomplete and
|
|
80
|
+
orphaned copies. Exhausted storage fails; no automatic recovery is performed.
|
|
81
|
+
|
|
82
|
+
The App renders persisted file cards from these typed events. HTML and images
|
|
83
|
+
also download as files. Downloads require authentication, the matching role
|
|
84
|
+
incarnation, a published conversation event and intact bytes. Deleted, missing,
|
|
85
|
+
changed or orphaned artifacts return unavailable. Remote workspaces fetch bytes
|
|
86
|
+
using their existing device credential, limit the read to 20 MiB and prevent
|
|
87
|
+
saving after a workspace change.
|
|
88
|
+
|
|
89
|
+
## Release prerequisite and qualification
|
|
90
|
+
|
|
91
|
+
This source requires the companion MCP implementation's
|
|
92
|
+
`MANAGED_FILE_DELIVERY_VERSION = 1`. Fleet pins published
|
|
93
|
+
`@ours.network/mcp@1.2.0-nightly.13`, which includes it. Older MCP packages without
|
|
94
|
+
this capability fail closed when file delivery is enabled. Changes to the MCP
|
|
95
|
+
pin require clean installed-package qualification without a built MCP overlay.
|
|
96
|
+
|
|
97
|
+
The deterministic native fixture uses real ACP adapters, the real managed
|
|
98
|
+
connector and an isolated daemon. A local scripted provider supplies only tool
|
|
99
|
+
intents; approval, read, copy, publication and bytes come from the implementation.
|
|
100
|
+
It does not qualify external-model inference, deployment or arbitrary native
|
|
101
|
+
MCP request-to-ACP tool-call ID mapping. Correlation is the authenticated bridge
|
|
102
|
+
and the actual active Fleet turn, which is revoked by cancellation/steering.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Upgrade to one Fleet service
|
|
2
|
+
|
|
3
|
+
Permanent and task agents share one systemd user service on Linux or one
|
|
4
|
+
LaunchAgent on macOS. Install the package and explicitly run `ours-fleet init`;
|
|
5
|
+
package installation alone does not migrate services. New task/temporary launches
|
|
6
|
+
refuse `FLEET_SERVICE_NOT_INSTALLED` until init succeeds. Native supervision uses
|
|
7
|
+
the OS user's home; a custom `OURS_FLEET_HOME` is refused. Manual/isolated fleets
|
|
8
|
+
can use `OURS_FLEET_SUPERVISOR=none` without boot recovery.
|
|
9
|
+
|
|
10
|
+
Before init, back up Fleet configuration, private member/runtime/session state,
|
|
11
|
+
task/room records and native units/plists. Preserve operator readiness commands,
|
|
12
|
+
timeout/restart settings and environment in the common parent's explicit
|
|
13
|
+
systemd drop-ins (`~/.config/systemd/user/ours-fleet.service.d/`). Conflicting
|
|
14
|
+
per-agent settings need operator resolution. Migration does not copy scripts or
|
|
15
|
+
silently discard hooks. Resolve `OPERATOR_DROPIN_REQUIRED` or `UNSUPPORTED_DROPIN`,
|
|
16
|
+
reload the user manager, verify its effective settings, then retry.
|
|
17
|
+
|
|
18
|
+
Init preflights configured permanent agents, retires only their proven old native
|
|
19
|
+
services and registers them after exact old-process absence. `up NAME` migrates
|
|
20
|
+
only NAME. Existing identity and conversation files remain in place. On Linux,
|
|
21
|
+
enabled legacy roles transfer running boot intent, including enabled inactive
|
|
22
|
+
roles; disabled inactive roles remain untouched. macOS stopped roles stay stopped.
|
|
23
|
+
Interrupted transfers retain receipts; retry with the original state instead of
|
|
24
|
+
deleting proof or starting old and new supervisors together.
|
|
25
|
+
|
|
26
|
+
The parent starts existing agent runners. Adding a member does not restart it.
|
|
27
|
+
Restarting the parent affects every managed agent. Permanent agents resume their
|
|
28
|
+
conversation with their existing readiness/continuity prompts and may receive a
|
|
29
|
+
new private runtime instance after orderly release. Task agents retain their
|
|
30
|
+
original identity, runtime owner and conversation. Managed stop persists across
|
|
31
|
+
parent restart/reboot. Non-resumable temporary agents retire on parent shutdown.
|
|
32
|
+
|
|
33
|
+
## Existing tasks
|
|
34
|
+
|
|
35
|
+
Running pre-upgrade transient task members remain untouched and report
|
|
36
|
+
`migration_pending`; init does not make them durable. Clean legacy shutdown or
|
|
37
|
+
explicit temporary stop releases/archives them. After abrupt process loss,
|
|
38
|
+
`ours-fleet task recover-members TASK_ID` can adopt a stopped single-room member
|
|
39
|
+
only with its original owner, identity, launch, workspace, conversation and room
|
|
40
|
+
admission intact and exact process absence proven. Missing/mismatched evidence
|
|
41
|
+
and unmarked legacy layouts require reconciliation. There is no automatic live
|
|
42
|
+
handover: let old work retire normally, or preserve and inspect its original
|
|
43
|
+
state before a controlled transition. Do not create replacement identities,
|
|
44
|
+
redeem invitations again or fabricate ownership records to force recovery.
|
|
45
|
+
|
|
46
|
+
## Rollback
|
|
47
|
+
|
|
48
|
+
Stop the common parent and prove all managed process groups absent. Preserve an
|
|
49
|
+
offline backup of catalog, receipts, private state and operator drop-ins. Restore
|
|
50
|
+
the prior Fleet release and reviewed legacy units/plists, enabling only intended
|
|
51
|
+
running agents. Keep the common parent disabled while legacy supervision runs.
|
|
52
|
+
Re-entering central mode requires retained-proof reconciliation and old-process
|
|
53
|
+
absence. Physical reboot/boot ordering, real macOS launchd and live host migration
|
|
54
|
+
have not been qualified by the isolated process and injected native tests.
|
package/docs/task-workspaces.md
CHANGED
|
@@ -159,3 +159,29 @@ claiming complete erasure. An identity with no recoverable authenticated binding
|
|
|
159
159
|
requires restoring its actual ownership evidence; a name-only force removal is
|
|
160
160
|
not supported. Room deletion removes the linked Task's dead room/member links and preserves its workspace; use explicit
|
|
161
161
|
Task deletion when the request includes the associated Task and its artifacts.
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
## Retained task agents
|
|
165
|
+
|
|
166
|
+
Task agents use the common Fleet service and existing runners while remaining
|
|
167
|
+
outside the standalone agent roster. Recovery retains their original identity,
|
|
168
|
+
private runtime owner, launch, working directory, admitted room and supported
|
|
169
|
+
backend conversation. `ours-fleet task recover-members TASK_ID` resumes proven
|
|
170
|
+
stopped members or resets their held restart circuit; it never creates identities
|
|
171
|
+
or invitations. See [upgrade and rollback](fleet-migration.md) for legacy tasks.
|
|
172
|
+
|
|
173
|
+
Recovery delivers an undispatched assignment once, continues interrupted work,
|
|
174
|
+
and sends no readiness/work prompt to a completed idle conversation. An uncertain
|
|
175
|
+
dispatch gets a continuation instead of replaying the assignment. First-session
|
|
176
|
+
retry requires proof that no readiness/work was submitted; established session
|
|
177
|
+
loss requires restoration of the original context. Task agents require a resumable
|
|
178
|
+
harness. Missing ownership, workspace or room evidence keeps recovery held.
|
|
179
|
+
|
|
180
|
+
Parent shutdown suspends task agents. Explicit member stop terminally releases
|
|
181
|
+
the exact saved owner and archives its launch; it does not complete the task.
|
|
182
|
+
Finish/cancel/review retain their existing semantics. Task deletion stops and
|
|
183
|
+
unregisters only owned members, verifies identity absence and retries cleanup
|
|
184
|
+
before erasing task/room/workspace resources. Permanent siblings remain intact.
|
|
185
|
+
Standalone CLI/web removal refuses `TASK_MEMBER_IS_TASK_OWNED`; use the owning
|
|
186
|
+
task lifecycle. Lost/corrupt task or launch records require restoring original
|
|
187
|
+
backup evidence or explicit ownership reconciliation before deletion can finish.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "1.2.0-nightly.
|
|
3
|
+
"version": "1.2.0-nightly.55",
|
|
4
4
|
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
"docs/live-voice.md",
|
|
23
23
|
"docs/room-layouts.md",
|
|
24
24
|
"docs/validation/managed-cli-permissions.md",
|
|
25
|
-
"
|
|
25
|
+
"docs/fleet-migration.md",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"docs/acp-files.md"
|
|
26
28
|
],
|
|
27
29
|
"engines": {
|
|
28
30
|
"node": ">=22.13.0"
|
|
@@ -37,13 +39,15 @@
|
|
|
37
39
|
"test:pack": "vitest run --config vitest.integration.config.ts",
|
|
38
40
|
"prepack": "npm run build",
|
|
39
41
|
"prepublishOnly": "npm run build && npm test",
|
|
42
|
+
"test:task-recovery": "node test/task-runtime-recovery.integration.mjs",
|
|
43
|
+
"test:fleet-recovery": "node test/fleet-manager-recovery.integration.mjs",
|
|
40
44
|
"test:v1-recovery": "node test/fleet-v1-recovery.integration.mjs retry && node test/fleet-v1-recovery.integration.mjs terminal-after-failure && node test/fleet-v1-recovery.integration.mjs terminal-release-failure"
|
|
41
45
|
},
|
|
42
46
|
"dependencies": {
|
|
43
47
|
"@agentclientprotocol/sdk": "^1.3.0",
|
|
44
48
|
"@fastify/websocket": "^11.2.0",
|
|
45
49
|
"@ours.network/cli": "2.8.1-nightly.15",
|
|
46
|
-
"@ours.network/mcp": "1.2.0-nightly.
|
|
50
|
+
"@ours.network/mcp": "1.2.0-nightly.13",
|
|
47
51
|
"@ours.network/sdk": "3.8.1-nightly.17",
|
|
48
52
|
"commander": "^12.1.0",
|
|
49
53
|
"fastify": "^5.4.0",
|