@ours.network/fleet 0.9.5 → 0.9.8
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 +101 -0
- package/dist/atomic-file.d.ts +30 -0
- package/dist/atomic-file.js +86 -0
- package/dist/briefing.d.ts +6 -0
- package/dist/briefing.js +41 -11
- package/dist/cli.js +95 -21
- package/dist/config.d.ts +15 -1
- package/dist/config.js +47 -2
- package/dist/creation.d.ts +179 -0
- package/dist/creation.js +254 -0
- package/dist/docs.d.ts +28 -1
- package/dist/docs.js +132 -0
- package/dist/doctor.js +74 -16
- package/dist/harness/claude-code.d.ts +39 -3
- package/dist/harness/claude-code.js +126 -24
- package/dist/harness/codex.d.ts +7 -1
- package/dist/harness/codex.js +57 -10
- package/dist/harness/registry.d.ts +2 -0
- package/dist/harness/registry.js +19 -0
- package/dist/harness/types.d.ts +50 -3
- package/dist/isolation/bubblewrap.js +7 -1
- package/dist/isolation/policy.d.ts +34 -5
- package/dist/isolation/policy.js +114 -7
- package/dist/isolation/resources.d.ts +6 -3
- package/dist/isolation/resources.js +6 -3
- package/dist/isolation/types.d.ts +19 -1
- package/dist/monitor.d.ts +30 -3
- package/dist/monitor.js +145 -30
- package/dist/ops.d.ts +15 -2
- package/dist/ops.js +32 -9
- package/dist/permissions.d.ts +70 -0
- package/dist/permissions.js +97 -0
- package/dist/runner.d.ts +65 -2
- package/dist/runner.js +239 -19
- package/dist/session/acp.d.ts +22 -1
- package/dist/session/acp.js +110 -26
- package/dist/session/control.d.ts +49 -1
- package/dist/session/control.js +116 -12
- package/dist/session/tmux.d.ts +8 -1
- package/dist/session/tmux.js +34 -4
- package/dist/session/types.d.ts +92 -1
- package/dist/session/types.js +42 -1
- package/dist/spawn.d.ts +27 -2
- package/dist/spawn.js +153 -15
- package/dist/supervisor/launchd.d.ts +50 -0
- package/dist/supervisor/launchd.js +121 -4
- package/dist/supervisor/none.js +22 -4
- package/dist/supervisor/systemd.d.ts +8 -1
- package/dist/supervisor/systemd.js +94 -4
- package/dist/supervisor/types.d.ts +36 -3
- package/dist/tmux.d.ts +34 -2
- package/dist/tmux.js +48 -11
- package/package.json +1 -1
package/dist/session/types.d.ts
CHANGED
|
@@ -1,10 +1,81 @@
|
|
|
1
1
|
import type { SessionBackendId } from '../config.js';
|
|
2
2
|
export type SessionReadiness = 'starting' | 'idle' | 'running' | 'awaiting_permission' | 'failed';
|
|
3
|
+
export type TurnOutcome = 'completed' | 'refused' | 'cancelled' | 'failed' | 'inconclusive';
|
|
4
|
+
/**
|
|
5
|
+
* Two independent facts about one turn, deliberately kept apart:
|
|
6
|
+
*
|
|
7
|
+
* - `accepted` — the live session took responsibility for the prompt. It says
|
|
8
|
+
* nothing about what the agent then did with it.
|
|
9
|
+
* - `outcome` / `succeeded` — how the turn TERMINATED. Only `completed` is a
|
|
10
|
+
* terminal success. A refusal or a cancellation is a prompt that was
|
|
11
|
+
* delivered and then not carried out; every caller that needs the work
|
|
12
|
+
* actually done (mail delivery, role startup) must treat it as a failure.
|
|
13
|
+
*
|
|
14
|
+
* Collapsing the two is what let a refused wake commit its notification cursor
|
|
15
|
+
* and a refused startup prompt log the role as up.
|
|
16
|
+
*/
|
|
3
17
|
export interface TurnResult {
|
|
4
18
|
accepted: boolean;
|
|
5
|
-
outcome:
|
|
19
|
+
outcome: TurnOutcome;
|
|
20
|
+
succeeded: boolean;
|
|
6
21
|
detail?: string;
|
|
7
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Why a control operation failed. The distinctions exist because collapsing
|
|
25
|
+
* them is what made a busy agent look dead: only `offline` is evidence that the
|
|
26
|
+
* session is gone, and `timeout` explicitly does NOT say the prompt was lost.
|
|
27
|
+
*/
|
|
28
|
+
export type ControlFailureKind = 'offline' | 'control-unavailable' | 'timeout' | 'rejected' | 'backend';
|
|
29
|
+
export declare class SessionControlError extends Error {
|
|
30
|
+
readonly kind: ControlFailureKind;
|
|
31
|
+
constructor(kind: ControlFailureKind, message: string);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A prompt the live session has taken responsibility for. Interactive callers
|
|
35
|
+
* stop here: the session has the prompt, and waiting for the turn to finish is
|
|
36
|
+
* a different question with a different, much longer, timescale.
|
|
37
|
+
*/
|
|
38
|
+
export interface QueuedPrompt {
|
|
39
|
+
promptId: string;
|
|
40
|
+
/** Turns already queued ahead of this one. 0 means it starts immediately. */
|
|
41
|
+
queuedBehind: number;
|
|
42
|
+
/** The turn's terminal result. Never rejects. */
|
|
43
|
+
completion: Promise<TurnResult>;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* How a session's process ended.
|
|
47
|
+
*
|
|
48
|
+
* `unknown` is the honest answer when no evidence was recorded — the previous
|
|
49
|
+
* code wrote the word `crash` there, asserting a failure it had not observed.
|
|
50
|
+
* `session-destroyed` (the console was torn down out from under a live process)
|
|
51
|
+
* and `program-exit` (the program decided to leave) are different events and
|
|
52
|
+
* must not collapse into one another, because they imply different next starts.
|
|
53
|
+
*/
|
|
54
|
+
export type ExitClass = 'clean' | 'program-exit' | 'signal' | 'session-destroyed' | 'unknown';
|
|
55
|
+
export interface ExitRecord {
|
|
56
|
+
version: 1;
|
|
57
|
+
class: ExitClass;
|
|
58
|
+
/** Exit code, when the program exited of its own accord. */
|
|
59
|
+
code?: number;
|
|
60
|
+
/** Signal that killed it, when one did. */
|
|
61
|
+
signal?: string;
|
|
62
|
+
/** Raw wait status as the pane shell saw it (tmux only). */
|
|
63
|
+
status?: number;
|
|
64
|
+
at?: string;
|
|
65
|
+
/** One line an operator can read. */
|
|
66
|
+
detail: string;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Classify a shell `$?`. Above 128 the shell is reporting 128+signal — the only
|
|
70
|
+
* signal evidence a pane wrapper can give us.
|
|
71
|
+
*/
|
|
72
|
+
export declare function classifyShellStatus(status: number): ExitRecord;
|
|
73
|
+
/** Classify a child process exit reported directly by node. */
|
|
74
|
+
export declare function classifyChildExit(code: number | null, signal: string | null): ExitRecord;
|
|
75
|
+
/** The single definition of terminal success. Nothing else may re-derive it. */
|
|
76
|
+
export declare const isTerminalSuccess: (outcome: TurnOutcome) => boolean;
|
|
77
|
+
/** Build a TurnResult with `succeeded` always consistent with `outcome`. */
|
|
78
|
+
export declare function turnResult(accepted: boolean, outcome: TurnOutcome, detail?: string): TurnResult;
|
|
8
79
|
export interface SessionSnapshot {
|
|
9
80
|
backend: SessionBackendId;
|
|
10
81
|
alive: boolean;
|
|
@@ -14,6 +85,8 @@ export interface SessionSnapshot {
|
|
|
14
85
|
pendingPermissionId?: string;
|
|
15
86
|
}
|
|
16
87
|
export type SessionEventKind = 'state' | 'agent_text' | 'thought' | 'tool_call' | 'tool_update' | 'permission' | 'turn_stop' | 'error';
|
|
88
|
+
/** What a settled permission request resolved to. */
|
|
89
|
+
export type PermissionDecision = 'allowed' | 'denied' | 'cancelled';
|
|
17
90
|
export interface SessionEvent {
|
|
18
91
|
version: 1;
|
|
19
92
|
seq: number;
|
|
@@ -31,17 +104,35 @@ export interface SessionEvent {
|
|
|
31
104
|
name: string;
|
|
32
105
|
kind: string;
|
|
33
106
|
}>;
|
|
107
|
+
/** What was decided. */
|
|
108
|
+
decision?: PermissionDecision;
|
|
109
|
+
/** Whether policy decided it, or a human answered the prompt. */
|
|
110
|
+
decisionSource?: 'automatic' | 'manual';
|
|
111
|
+
/** The configured policy that produced an automatic decision. */
|
|
112
|
+
policy?: string;
|
|
113
|
+
/** Why, in one human-readable line. */
|
|
114
|
+
reason?: string;
|
|
115
|
+
/** The option actually selected, when one was. */
|
|
116
|
+
optionId?: string;
|
|
34
117
|
}
|
|
35
118
|
export interface SessionHandle {
|
|
36
119
|
readonly backend: SessionBackendId;
|
|
37
120
|
readonly pid: number;
|
|
38
121
|
isAlive(): boolean;
|
|
39
122
|
snapshot(): SessionSnapshot;
|
|
123
|
+
/**
|
|
124
|
+
* Hand the session a prompt and return as soon as it has accepted
|
|
125
|
+
* responsibility for it. Throws `SessionControlError` if it cannot.
|
|
126
|
+
*/
|
|
127
|
+
queuePrompt(text: string): Promise<QueuedPrompt>;
|
|
128
|
+
/** Queue a prompt and wait for its terminal result. */
|
|
40
129
|
submitPrompt(text: string): Promise<TurnResult>;
|
|
41
130
|
interrupt(): Promise<void>;
|
|
42
131
|
respondPermission(permissionId: string, optionId: string): boolean;
|
|
43
132
|
eventsSince(seq: number): SessionEvent[];
|
|
44
133
|
subscribe(listener: (event: SessionEvent) => void): () => void;
|
|
45
134
|
setControllerAttached(attached: boolean): void;
|
|
135
|
+
/** How the backing process ended, or null while it is still running. */
|
|
136
|
+
exitResult(): ExitRecord | null;
|
|
46
137
|
close(): Promise<void>;
|
|
47
138
|
}
|
package/dist/session/types.js
CHANGED
|
@@ -1 +1,42 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export class SessionControlError extends Error {
|
|
2
|
+
kind;
|
|
3
|
+
constructor(kind, message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.kind = kind;
|
|
6
|
+
this.name = 'SessionControlError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Classify a shell `$?`. Above 128 the shell is reporting 128+signal — the only
|
|
11
|
+
* signal evidence a pane wrapper can give us.
|
|
12
|
+
*/
|
|
13
|
+
export function classifyShellStatus(status) {
|
|
14
|
+
if (!Number.isFinite(status))
|
|
15
|
+
return { version: 1, class: 'unknown', detail: 'pane wrote an unreadable exit status' };
|
|
16
|
+
if (status === 0)
|
|
17
|
+
return { version: 1, class: 'clean', code: 0, status, detail: 'exited cleanly (code 0)' };
|
|
18
|
+
if (status > 128) {
|
|
19
|
+
const signal = status - 128;
|
|
20
|
+
return {
|
|
21
|
+
version: 1, class: 'signal', signal: `SIG${signal}`, status,
|
|
22
|
+
detail: `killed by signal ${signal} (shell status ${status})`,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return { version: 1, class: 'program-exit', code: status, status, detail: `exited with code ${status}` };
|
|
26
|
+
}
|
|
27
|
+
/** Classify a child process exit reported directly by node. */
|
|
28
|
+
export function classifyChildExit(code, signal) {
|
|
29
|
+
if (signal)
|
|
30
|
+
return { version: 1, class: 'signal', signal, detail: `killed by ${signal}` };
|
|
31
|
+
if (code === 0)
|
|
32
|
+
return { version: 1, class: 'clean', code: 0, detail: 'exited cleanly (code 0)' };
|
|
33
|
+
if (code === null)
|
|
34
|
+
return { version: 1, class: 'unknown', detail: 'the process ended with neither a code nor a signal' };
|
|
35
|
+
return { version: 1, class: 'program-exit', code, detail: `exited with code ${code}` };
|
|
36
|
+
}
|
|
37
|
+
/** The single definition of terminal success. Nothing else may re-derive it. */
|
|
38
|
+
export const isTerminalSuccess = (outcome) => outcome === 'completed';
|
|
39
|
+
/** Build a TurnResult with `succeeded` always consistent with `outcome`. */
|
|
40
|
+
export function turnResult(accepted, outcome, detail) {
|
|
41
|
+
return { accepted, outcome, succeeded: isTerminalSuccess(outcome), detail };
|
|
42
|
+
}
|
package/dist/spawn.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
+
import type { IsolationConfig } from './isolation/types.js';
|
|
1
2
|
import { type ApprovalMode, type FilesystemMode, type SessionBackendId, type UnattendedMode } from './config.js';
|
|
2
3
|
import { type OpsDeps } from './ops.js';
|
|
4
|
+
import { type CreationDeps, type CreationProvenance } from './creation.js';
|
|
5
|
+
/**
|
|
6
|
+
* The provenance record written by the most recent spawn in this process, so
|
|
7
|
+
* the CLI can print the same summary it persisted rather than rebuilding it.
|
|
8
|
+
*/
|
|
9
|
+
export declare let lastProvenance: CreationProvenance | undefined;
|
|
3
10
|
export interface SpawnOpts {
|
|
4
11
|
name: string;
|
|
5
12
|
temp?: boolean;
|
|
@@ -23,12 +30,30 @@ export interface SpawnOpts {
|
|
|
23
30
|
monitor?: boolean;
|
|
24
31
|
bioFile?: string;
|
|
25
32
|
personaFile?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Path to a file holding exactly the existing `isolation:` mapping — the same
|
|
35
|
+
* schema fleet.yaml uses, not a second policy language. The ONE new operator
|
|
36
|
+
* input in this release (6.3).
|
|
37
|
+
*/
|
|
38
|
+
isolationFile?: string;
|
|
26
39
|
overseeInterval?: string;
|
|
27
40
|
configPath?: string;
|
|
28
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Read and validate an `--isolation-file`. The file is the existing
|
|
44
|
+
* `isolation:` mapping and nothing else — the same schema, the same validator
|
|
45
|
+
* (`validateIsolationConfig`), so a policy written here cannot mean something
|
|
46
|
+
* different from the identical block in fleet.yaml.
|
|
47
|
+
*
|
|
48
|
+
* Called BEFORE the creation transaction reserves anything: an invalid file
|
|
49
|
+
* must fail before any artifact exists.
|
|
50
|
+
*/
|
|
51
|
+
export declare function readIsolationFile(path: string): IsolationConfig;
|
|
52
|
+
/** The ours identity a spawn will bind: explicit, else the role name. */
|
|
53
|
+
export declare const effectiveIdentity: (o: SpawnOpts) => string;
|
|
29
54
|
/** Permanent spawn: persist to ~/fleet.d/<Name>.yaml, then bring it up. */
|
|
30
|
-
export declare function spawnPermanent(o: SpawnOpts, deps: OpsDeps): Promise<string>;
|
|
55
|
+
export declare function spawnPermanent(o: SpawnOpts, deps: OpsDeps, creation?: CreationDeps): Promise<string>;
|
|
31
56
|
/** Launches the detached temp supervisor (`_run-temp <name>`). Injectable for tests. */
|
|
32
57
|
export type SupervisorLauncher = (binPath: string, args: string[], dir: string) => void;
|
|
33
58
|
/** Temp spawn: state under ~/.ours-fleet/tmp, plain tmux, auto-clean on exit. */
|
|
34
|
-
export declare function spawnTemp(o: SpawnOpts, binPath: string, launch?: SupervisorLauncher): Promise<string>;
|
|
59
|
+
export declare function spawnTemp(o: SpawnOpts, binPath: string, launch?: SupervisorLauncher, creation?: CreationDeps): Promise<string>;
|
package/dist/spawn.js
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import { spawn as spawnChild } from 'node:child_process';
|
|
2
|
-
import { existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { stringify } from 'yaml';
|
|
4
|
+
import { parse, stringify } from 'yaml';
|
|
5
5
|
import { agentDir, fleetDDir } from './paths.js';
|
|
6
|
+
import { validateIsolationConfig } from './isolation/policy.js';
|
|
6
7
|
import { loadConfig, resolveMonitorConfig, resolvePermissions, } from './config.js';
|
|
7
8
|
import { applyRole, up } from './ops.js';
|
|
8
9
|
import { START_STAGGER_FILE } from './runner.js';
|
|
10
|
+
import { buildProvenance, daemonIdentityProvisioner, ensureIdentity, provenanceOf, withCreationTransaction, writeProvenance, writeRoleFile, } from './creation.js';
|
|
11
|
+
import { VERSION } from './version.js';
|
|
12
|
+
/**
|
|
13
|
+
* The provenance record written by the most recent spawn in this process, so
|
|
14
|
+
* the CLI can print the same summary it persisted rather than rebuilding it.
|
|
15
|
+
*/
|
|
16
|
+
export let lastProvenance;
|
|
9
17
|
function roleFromOpts(o, defaultHarness) {
|
|
10
18
|
const r = {};
|
|
11
19
|
if (o.harness)
|
|
@@ -53,8 +61,35 @@ function roleFromOpts(o, defaultHarness) {
|
|
|
53
61
|
r.bio = readFileSync(o.bioFile, 'utf8').trim();
|
|
54
62
|
if (o.personaFile)
|
|
55
63
|
r.persona = readFileSync(o.personaFile, 'utf8').trim();
|
|
64
|
+
if (o.isolationFile)
|
|
65
|
+
r.isolation = readIsolationFile(o.isolationFile);
|
|
56
66
|
return r;
|
|
57
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Read and validate an `--isolation-file`. The file is the existing
|
|
70
|
+
* `isolation:` mapping and nothing else — the same schema, the same validator
|
|
71
|
+
* (`validateIsolationConfig`), so a policy written here cannot mean something
|
|
72
|
+
* different from the identical block in fleet.yaml.
|
|
73
|
+
*
|
|
74
|
+
* Called BEFORE the creation transaction reserves anything: an invalid file
|
|
75
|
+
* must fail before any artifact exists.
|
|
76
|
+
*/
|
|
77
|
+
export function readIsolationFile(path) {
|
|
78
|
+
let raw;
|
|
79
|
+
try {
|
|
80
|
+
raw = parse(readFileSync(path, 'utf8'));
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
throw new Error(`--isolation-file ${path}: ${e.message}`);
|
|
84
|
+
}
|
|
85
|
+
// A file holding only comments parses to null; treat it as an empty policy,
|
|
86
|
+
// which is a meaningful request ("sandbox me with defaults").
|
|
87
|
+
const cfg = (raw ?? {});
|
|
88
|
+
const problems = validateIsolationConfig(cfg);
|
|
89
|
+
if (problems.length)
|
|
90
|
+
throw new Error(`--isolation-file ${path}: ${problems.join('; ')}`);
|
|
91
|
+
return cfg;
|
|
92
|
+
}
|
|
58
93
|
function validateSpawnOpts(o) {
|
|
59
94
|
if (o.session && !['tmux', 'acp'].includes(o.session))
|
|
60
95
|
throw new Error(`invalid --session '${o.session}'; allowed: tmux, acp`);
|
|
@@ -65,6 +100,12 @@ function validateSpawnOpts(o) {
|
|
|
65
100
|
if (o.unattended && !['deny', 'wait'].includes(o.unattended))
|
|
66
101
|
throw new Error(`invalid --unattended '${o.unattended}'; allowed: deny, wait`);
|
|
67
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Reject names that are already USED. This is a precondition, not a claim: it
|
|
105
|
+
* runs INSIDE the creation transaction, after both names are reserved, so the
|
|
106
|
+
* gap between checking and creating that let two spawns both succeed is closed
|
|
107
|
+
* by the reservation rather than by this function.
|
|
108
|
+
*/
|
|
68
109
|
function assertNameFree(o) {
|
|
69
110
|
const cfg = loadConfig(o.configPath);
|
|
70
111
|
if (cfg.roles.some(r => r.name === o.name))
|
|
@@ -72,18 +113,96 @@ function assertNameFree(o) {
|
|
|
72
113
|
if (existsSync(agentDir(o.name)) || existsSync(agentDir(o.name, true)))
|
|
73
114
|
throw new Error(`agent dir for '${o.name}' already exists — pick another name or 'ours-fleet rm ${o.name}'`);
|
|
74
115
|
}
|
|
116
|
+
/** The ours identity a spawn will bind: explicit, else the role name. */
|
|
117
|
+
export const effectiveIdentity = (o) => o.identity ?? o.name;
|
|
118
|
+
/**
|
|
119
|
+
* Which settings came from the operator, from fleet defaults, or from a
|
|
120
|
+
* built-in (6.6). Built while the options are still separable — once they are
|
|
121
|
+
* merged into a ResolvedRole the distinction is gone.
|
|
122
|
+
*
|
|
123
|
+
* `env`, `bio`, `persona` and `harness_options` are deliberately absent: the
|
|
124
|
+
* record exists to be read, and must not become a place credentials collect.
|
|
125
|
+
*/
|
|
126
|
+
function provenanceSettings(o, defaults) {
|
|
127
|
+
const perms = (defaults.permissions ?? {});
|
|
128
|
+
return {
|
|
129
|
+
harness: provenanceOf(o.harness, defaults.harness, 'claude-code'),
|
|
130
|
+
session: provenanceOf(o.session, defaults.session, 'tmux'),
|
|
131
|
+
identity: o.identity
|
|
132
|
+
? { value: o.identity, source: 'cli' }
|
|
133
|
+
: { value: o.name, source: 'built-in' }, // defaults to the role name
|
|
134
|
+
cwd: provenanceOf(o.cwd, undefined, undefined),
|
|
135
|
+
model: provenanceOf(o.model?.trim(), defaults.model, undefined),
|
|
136
|
+
coordinator: provenanceOf(o.coordinator, undefined, undefined),
|
|
137
|
+
approval: provenanceOf(o.approval, perms.approval, 'ask'),
|
|
138
|
+
filesystem: provenanceOf(o.filesystem, perms.filesystem, 'workspace'),
|
|
139
|
+
unattended: provenanceOf(o.unattended, perms.unattended, 'deny'),
|
|
140
|
+
isolation: o.isolationFile
|
|
141
|
+
? { value: 'declared via --isolation-file', source: 'cli' }
|
|
142
|
+
: { value: defaults.isolation ? 'from fleet defaults' : undefined, source: defaults.isolation ? 'fleet-default' : 'built-in' },
|
|
143
|
+
};
|
|
144
|
+
}
|
|
75
145
|
/** Permanent spawn: persist to ~/fleet.d/<Name>.yaml, then bring it up. */
|
|
76
|
-
export async function spawnPermanent(o, deps) {
|
|
146
|
+
export async function spawnPermanent(o, deps, creation = {}) {
|
|
77
147
|
validateSpawnOpts(o);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
148
|
+
if (o.isolationFile)
|
|
149
|
+
readIsolationFile(o.isolationFile); // fail before reserving
|
|
150
|
+
// Name AND identity reserved together, before anything is written or started
|
|
151
|
+
// (6.4). A loser of the race creates no config, no state, no service.
|
|
152
|
+
return withCreationTransaction({ role: o.name, identity: effectiveIdentity(o) }, async (tx) => {
|
|
153
|
+
assertNameFree(o);
|
|
154
|
+
const cfg = loadConfig(o.configPath);
|
|
155
|
+
// Establish the identity BEFORE the service is enabled (7.3), and record
|
|
156
|
+
// what was actually guaranteed so the briefing can say something true.
|
|
157
|
+
const guarantee = await ensureIdentity(effectiveIdentity(o), { bio: o.bioFile ? readFileSync(o.bioFile, 'utf8').trim() : undefined,
|
|
158
|
+
persona: o.personaFile ? readFileSync(o.personaFile, 'utf8').trim() : undefined }, creation.identityProvisioner ?? daemonIdentityProvisioner(), deps.log);
|
|
159
|
+
if (guarantee.state === 'created')
|
|
160
|
+
// We minted it; a failed creation must not leave an orphan identity
|
|
161
|
+
// behind. Only ever removes an identity THIS transaction created.
|
|
162
|
+
tx.record({
|
|
163
|
+
stage: `ours identity ${effectiveIdentity(o)}`,
|
|
164
|
+
undo: async () => {
|
|
165
|
+
await creation.identityProvisioner?.remove?.(effectiveIdentity(o));
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
mkdirSync(fleetDDir(), { recursive: true });
|
|
169
|
+
const file = join(fleetDDir(), `${o.name}.yaml`);
|
|
170
|
+
writeRoleFile(tx, file, stringify({
|
|
171
|
+
roles: { [o.name]: roleFromOpts(o, cfg.defaults.harness) },
|
|
172
|
+
}));
|
|
173
|
+
// `up` materialises the state dir and registers the service. Journal the
|
|
174
|
+
// dir before it exists so a failure leaves the name genuinely reusable
|
|
175
|
+
// rather than blocked by a half-built directory.
|
|
176
|
+
const stateDir = agentDir(o.name);
|
|
177
|
+
const stateExisted = existsSync(stateDir);
|
|
178
|
+
tx.record({
|
|
179
|
+
stage: `state dir ${stateDir}`,
|
|
180
|
+
undo: () => { if (!stateExisted)
|
|
181
|
+
rmSync(stateDir, { recursive: true, force: true }); },
|
|
182
|
+
});
|
|
183
|
+
// Journal the service registration BEFORE it happens, and undo only the
|
|
184
|
+
// registrations this transaction actually created (6.2). `registered` is
|
|
185
|
+
// filled by `up`'s onInstalled hook at the moment each registration is
|
|
186
|
+
// made — not from its return value, which never arrives when `up` throws
|
|
187
|
+
// after registering.
|
|
188
|
+
const registered = [];
|
|
189
|
+
tx.record({
|
|
190
|
+
stage: `service registration for ${o.name}`,
|
|
191
|
+
undo: async () => { for (const n of registered)
|
|
192
|
+
await deps.backend.uninstall(n); },
|
|
193
|
+
});
|
|
194
|
+
// Provenance is written BEFORE the role starts, so a role that fails to
|
|
195
|
+
// launch still records how it was asked for (6.6).
|
|
196
|
+
const provenance = buildProvenance({
|
|
197
|
+
role: o.name, lifetime: 'permanent', fleetVersion: VERSION,
|
|
198
|
+
settings: provenanceSettings(o, cfg.defaults),
|
|
199
|
+
});
|
|
200
|
+
mkdirSync(agentDir(o.name), { recursive: true });
|
|
201
|
+
writeProvenance(agentDir(o.name), provenance);
|
|
202
|
+
await up(loadConfig(o.configPath), [o.name], { ...deps, onInstalled: outcome => registered.push(outcome.role) }, o.configPath, guarantee.state);
|
|
203
|
+
lastProvenance = provenance;
|
|
204
|
+
return file;
|
|
205
|
+
}, creation);
|
|
87
206
|
}
|
|
88
207
|
const detachedSupervisor = (binPath, args, dir) => {
|
|
89
208
|
// Log to the temp dir; the fd stays valid even after runTemp removes the dir.
|
|
@@ -95,8 +214,19 @@ const detachedSupervisor = (binPath, args, dir) => {
|
|
|
95
214
|
child.unref();
|
|
96
215
|
};
|
|
97
216
|
/** Temp spawn: state under ~/.ours-fleet/tmp, plain tmux, auto-clean on exit. */
|
|
98
|
-
export async function spawnTemp(o, binPath, launch = detachedSupervisor) {
|
|
217
|
+
export async function spawnTemp(o, binPath, launch = detachedSupervisor, creation = {}) {
|
|
99
218
|
validateSpawnOpts(o);
|
|
219
|
+
if (o.isolationFile)
|
|
220
|
+
readIsolationFile(o.isolationFile); // fail before reserving
|
|
221
|
+
// Temporary roles go through the SAME reservation boundary as permanent ones
|
|
222
|
+
// (6.4): a temp agent competes for the same names.
|
|
223
|
+
return withCreationTransaction({ role: o.name, identity: effectiveIdentity(o) }, async (tx) => {
|
|
224
|
+
const guarantee = await ensureIdentity(effectiveIdentity(o), { bio: o.bioFile ? readFileSync(o.bioFile, 'utf8').trim() : undefined,
|
|
225
|
+
persona: o.personaFile ? readFileSync(o.personaFile, 'utf8').trim() : undefined }, creation.identityProvisioner ?? daemonIdentityProvisioner(), creation.log);
|
|
226
|
+
return spawnTempInner(o, binPath, launch, tx, guarantee);
|
|
227
|
+
}, creation);
|
|
228
|
+
}
|
|
229
|
+
async function spawnTempInner(o, binPath, launch, tx, guarantee) {
|
|
100
230
|
assertNameFree(o);
|
|
101
231
|
const cfg = loadConfig(o.configPath);
|
|
102
232
|
const defaultHarness = cfg.defaults.harness;
|
|
@@ -106,7 +236,7 @@ export async function spawnTemp(o, binPath, launch = detachedSupervisor) {
|
|
|
106
236
|
...(fromOpts.harness_options ?? {}),
|
|
107
237
|
};
|
|
108
238
|
const role = {
|
|
109
|
-
...fromOpts,
|
|
239
|
+
...fromOpts, // includes `isolation` when --isolation-file was given
|
|
110
240
|
name: o.name,
|
|
111
241
|
harness: o.harness ?? defaultHarness ?? 'claude-code',
|
|
112
242
|
session: o.session ?? cfg.defaults.session ?? 'tmux',
|
|
@@ -114,11 +244,19 @@ export async function spawnTemp(o, binPath, launch = detachedSupervisor) {
|
|
|
114
244
|
model: o.model?.trim() || cfg.defaults.model,
|
|
115
245
|
harness_options: Object.keys(mergedHarnessOptions).length ? mergedHarnessOptions : undefined,
|
|
116
246
|
permissions: resolvePermissions(cfg.defaults.permissions, fromOpts.permissions),
|
|
247
|
+
permissionsDeclared: fromOpts.permissions !== undefined || cfg.defaults.permissions !== undefined,
|
|
117
248
|
// Temp agents inherit the fleet-wide monitor defaults via the snapshot (design §2).
|
|
118
249
|
monitor: resolveMonitorConfig(cfg.defaults.monitor, fromOpts.monitor),
|
|
119
250
|
sourceFile: '(temp)',
|
|
120
251
|
};
|
|
121
|
-
const dir = applyRole(role, { temp: true });
|
|
252
|
+
const dir = applyRole(role, { temp: true, identityGuarantee: guarantee.state });
|
|
253
|
+
const provenance = buildProvenance({
|
|
254
|
+
role: o.name, lifetime: 'temporary', fleetVersion: VERSION,
|
|
255
|
+
settings: provenanceSettings(o, cfg.defaults),
|
|
256
|
+
});
|
|
257
|
+
writeProvenance(dir, provenance);
|
|
258
|
+
lastProvenance = provenance;
|
|
259
|
+
tx.record({ stage: `temp state dir ${dir}`, undo: () => rmSync(dir, { recursive: true, force: true }) });
|
|
122
260
|
writeFileSync(join(dir, 'role.yaml'), stringify(role));
|
|
123
261
|
// Snapshot the fleet start-stagger so the detached temp supervisor (no config path
|
|
124
262
|
// threaded through it) honors the same launch gate — a burst of temp spawns spaces
|
|
@@ -1,4 +1,54 @@
|
|
|
1
1
|
import { type Exec } from '../exec.js';
|
|
2
2
|
import type { SupervisorBackend } from './types.js';
|
|
3
3
|
export declare const labelFor: (name: string) => string;
|
|
4
|
+
/**
|
|
5
|
+
* What `launchctl print` said about a job, parsed ONCE so that the two questions
|
|
6
|
+
* asked of it cannot drift apart. They are not the same question:
|
|
7
|
+
*
|
|
8
|
+
* - `liveness` asks "does this role's context still exist" — a loaded job counts,
|
|
9
|
+
* including one waiting between KeepAlive restarts (1.1);
|
|
10
|
+
* - `install` asks "did the job I just bootstrapped actually START" — for which a
|
|
11
|
+
* job that is loaded, not running, and has already exited once is a failure.
|
|
12
|
+
*
|
|
13
|
+
* On systemd one `ActiveState` answers both. Here the answers differ, so what is
|
|
14
|
+
* shared is the READING of launchd's output, not its classification.
|
|
15
|
+
*/
|
|
16
|
+
export interface LaunchdJob {
|
|
17
|
+
/** `launchctl print` exited 0 — the job is loaded in the domain. */
|
|
18
|
+
loaded: boolean;
|
|
19
|
+
/** launchd's own `state = …`, e.g. `running`, `waiting`, `not running`. */
|
|
20
|
+
state?: string;
|
|
21
|
+
/**
|
|
22
|
+
* `last exit code|status|reason = …`. Present only once the program has RUN
|
|
23
|
+
* and exited — which is what separates "died" from "has not started yet".
|
|
24
|
+
*/
|
|
25
|
+
lastExit?: string;
|
|
26
|
+
/** The domain has no such service: a definite negative, not a failed probe. */
|
|
27
|
+
notFound: boolean;
|
|
28
|
+
/** Why the probe itself could not be read, when it could not. */
|
|
29
|
+
failure?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Did the job we just bootstrapped actually start?
|
|
33
|
+
*
|
|
34
|
+
* `launchctl bootstrap` exits 0 once the job is LOADED. With `RunAtLoad` the
|
|
35
|
+
* program then starts asynchronously, so a zero exit is a statement about the
|
|
36
|
+
* load, not about the program — the same shape of lie that `systemctl enable
|
|
37
|
+
* --now` tells on systemd 255, where the exit code is 0 and the unit is dead.
|
|
38
|
+
*
|
|
39
|
+
* Only a DEFINITE stop counts as a failed start, exactly as on systemd:
|
|
40
|
+
*
|
|
41
|
+
* - not loaded at all, though bootstrap said it worked → definite;
|
|
42
|
+
* - loaded, `state` is not running, AND launchd already has an exit status for
|
|
43
|
+
* it → it ran and died → definite;
|
|
44
|
+
* - loaded and running, or waiting for a KeepAlive restart, or not running with
|
|
45
|
+
* nothing exited yet (it simply has not been spawned yet — the asynchrony
|
|
46
|
+
* RunAtLoad introduces) → NOT a failure;
|
|
47
|
+
* - an unreadable probe → `unknown`, never a failure (1.1). launchd may be fine
|
|
48
|
+
* and the tool merely unable to answer.
|
|
49
|
+
*/
|
|
50
|
+
export declare function classifyStart(job: LaunchdJob): {
|
|
51
|
+
started: 'yes' | 'no' | 'unknown';
|
|
52
|
+
detail: string;
|
|
53
|
+
};
|
|
4
54
|
export declare function makeLaunchdBackend(exec?: Exec, uid?: number): SupervisorBackend;
|