@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
|
@@ -1,8 +1,17 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { home, logsRoot } from '../paths.js';
|
|
4
4
|
import { realExec } from '../exec.js';
|
|
5
5
|
export const labelFor = (name) => `network.ours.fleet.${name}`;
|
|
6
|
+
/**
|
|
7
|
+
* The spelling of the last-exit line is not stable across macOS releases
|
|
8
|
+
* (`last exit code`, `last exit status`, `last exit reason`), so match the
|
|
9
|
+
* family rather than one member. This is the load-bearing signal in
|
|
10
|
+
* `classifyStart`, so it errs towards NOT matching: an unrecognised spelling
|
|
11
|
+
* yields `unknown`, never a false failure.
|
|
12
|
+
*/
|
|
13
|
+
const LAST_EXIT_RE = /^\s*last exit (?:code|status|reason)\s*=\s*(.+?)\s*$/mi;
|
|
14
|
+
const STATE_RE = /^\s*state\s*=\s*(.+?)\s*$/m;
|
|
6
15
|
const agentsDir = () => join(home(), 'Library', 'LaunchAgents');
|
|
7
16
|
const plistPath = (name) => join(agentsDir(), `${labelFor(name)}.plist`);
|
|
8
17
|
function plist(name, binPath) {
|
|
@@ -14,7 +23,10 @@ function plist(name, binPath) {
|
|
|
14
23
|
<key>Label</key><string>${labelFor(name)}</string>
|
|
15
24
|
<key>ProgramArguments</key>
|
|
16
25
|
<array><string>${binPath}</string><string>_run</string><string>${name}</string></array>
|
|
17
|
-
|
|
26
|
+
<!-- The runner owns the child-session restart loop (3.2). launchd must only
|
|
27
|
+
recover the runner PROCESS crashing: a bare KeepAlive would resume the
|
|
28
|
+
uncounted relaunch loop and restart a deliberately held-down agent. -->
|
|
29
|
+
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
|
|
18
30
|
<key>RunAtLoad</key><true/>
|
|
19
31
|
<key>StandardOutPath</key><string>${log}</string>
|
|
20
32
|
<key>StandardErrorPath</key><string>${log}</string>
|
|
@@ -22,8 +34,59 @@ function plist(name, binPath) {
|
|
|
22
34
|
</plist>
|
|
23
35
|
`;
|
|
24
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Did the job we just bootstrapped actually start?
|
|
39
|
+
*
|
|
40
|
+
* `launchctl bootstrap` exits 0 once the job is LOADED. With `RunAtLoad` the
|
|
41
|
+
* program then starts asynchronously, so a zero exit is a statement about the
|
|
42
|
+
* load, not about the program — the same shape of lie that `systemctl enable
|
|
43
|
+
* --now` tells on systemd 255, where the exit code is 0 and the unit is dead.
|
|
44
|
+
*
|
|
45
|
+
* Only a DEFINITE stop counts as a failed start, exactly as on systemd:
|
|
46
|
+
*
|
|
47
|
+
* - not loaded at all, though bootstrap said it worked → definite;
|
|
48
|
+
* - loaded, `state` is not running, AND launchd already has an exit status for
|
|
49
|
+
* it → it ran and died → definite;
|
|
50
|
+
* - loaded and running, or waiting for a KeepAlive restart, or not running with
|
|
51
|
+
* nothing exited yet (it simply has not been spawned yet — the asynchrony
|
|
52
|
+
* RunAtLoad introduces) → NOT a failure;
|
|
53
|
+
* - an unreadable probe → `unknown`, never a failure (1.1). launchd may be fine
|
|
54
|
+
* and the tool merely unable to answer.
|
|
55
|
+
*/
|
|
56
|
+
export function classifyStart(job) {
|
|
57
|
+
if (job.notFound)
|
|
58
|
+
return { started: 'no', detail: 'the service is not loaded in the domain' };
|
|
59
|
+
if (!job.loaded)
|
|
60
|
+
return { started: 'unknown', detail: job.failure ?? 'launchctl print could not be read' };
|
|
61
|
+
// Deliberately narrow: only launchd's own `not running` is read as stopped.
|
|
62
|
+
// Any state this does not recognise falls through to "started", because a
|
|
63
|
+
// wrong guess here rolls back a role that is in fact fine.
|
|
64
|
+
const stopped = job.state !== undefined && /^not running$/i.test(job.state.trim());
|
|
65
|
+
if (stopped && job.lastExit !== undefined)
|
|
66
|
+
return { started: 'no', detail: `state = ${job.state}, last exit = ${job.lastExit}` };
|
|
67
|
+
if (stopped)
|
|
68
|
+
return { started: 'unknown', detail: `state = ${job.state}, but nothing has exited yet` };
|
|
69
|
+
return { started: 'yes', detail: job.state ? `state = ${job.state}` : 'loaded' };
|
|
70
|
+
}
|
|
25
71
|
export function makeLaunchdBackend(exec = realExec, uid = process.getuid?.() ?? 501) {
|
|
26
72
|
const domain = `gui/${uid}`;
|
|
73
|
+
/** Read `launchctl print` once; both callers classify it for their own question. */
|
|
74
|
+
const printJob = async (name) => {
|
|
75
|
+
const r = await exec('launchctl', ['print', `${domain}/${labelFor(name)}`]);
|
|
76
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
77
|
+
if (r.code !== 0)
|
|
78
|
+
return {
|
|
79
|
+
loaded: false,
|
|
80
|
+
notFound: /could not find service|no such process/i.test(out),
|
|
81
|
+
failure: `launchctl print ${labelFor(name)} failed: ${r.stderr.trim() || r.stdout.trim() || `exit ${r.code}`}`,
|
|
82
|
+
};
|
|
83
|
+
return {
|
|
84
|
+
loaded: true,
|
|
85
|
+
notFound: false,
|
|
86
|
+
state: STATE_RE.exec(r.stdout)?.[1],
|
|
87
|
+
lastExit: LAST_EXIT_RE.exec(r.stdout)?.[1],
|
|
88
|
+
};
|
|
89
|
+
};
|
|
27
90
|
return {
|
|
28
91
|
id: 'launchd',
|
|
29
92
|
async init() {
|
|
@@ -37,11 +100,50 @@ export function makeLaunchdBackend(exec = realExec, uid = process.getuid?.() ??
|
|
|
37
100
|
async install(name, binPath) {
|
|
38
101
|
mkdirSync(agentsDir(), { recursive: true });
|
|
39
102
|
mkdirSync(logsRoot(), { recursive: true });
|
|
103
|
+
// The plist's prior existence is the record of whether we created this.
|
|
104
|
+
const existed = existsSync(plistPath(name));
|
|
40
105
|
writeFileSync(plistPath(name), plist(name, binPath));
|
|
41
106
|
await exec('launchctl', ['bootout', `${domain}/${labelFor(name)}`]); // best-effort refresh
|
|
107
|
+
// Undo only what WE wrote. A plist that was already there belongs to
|
|
108
|
+
// whoever put it there, and rollback may never remove it (6.2).
|
|
109
|
+
const undo = async () => {
|
|
110
|
+
if (existed)
|
|
111
|
+
return;
|
|
112
|
+
await exec('launchctl', ['bootout', `${domain}/${labelFor(name)}`]);
|
|
113
|
+
rmSync(plistPath(name), { force: true });
|
|
114
|
+
};
|
|
42
115
|
const r = await exec('launchctl', ['bootstrap', domain, plistPath(name)]);
|
|
43
|
-
if (r.code !== 0)
|
|
116
|
+
if (r.code !== 0) {
|
|
117
|
+
// The plist is already on disk, carrying RunAtLoad. Throwing here means
|
|
118
|
+
// `install` never returns `{created: true}`, so the creation transaction
|
|
119
|
+
// records nothing and its rollback removes nothing — and a spawn that
|
|
120
|
+
// failed at registration leaves a launch artifact behind (6.2). Undo our
|
|
121
|
+
// own partial write before throwing, and only when WE wrote it.
|
|
122
|
+
await undo();
|
|
44
123
|
throw new Error(`launchctl bootstrap ${labelFor(name)} failed: ${r.stderr.trim()}`);
|
|
124
|
+
}
|
|
125
|
+
// THE EXIT CODE IS NOT THE SIGNAL — the launchd half of the same fix made
|
|
126
|
+
// for systemd in 4023e72.
|
|
127
|
+
//
|
|
128
|
+
// `bootstrap` exits 0 when the job is LOADED. `RunAtLoad` then starts the
|
|
129
|
+
// program asynchronously, so a zero exit says nothing about whether the
|
|
130
|
+
// program ran: "bootstrap exits 0, job immediately dead" is available on
|
|
131
|
+
// macOS for the same reason "enable --now exits 0, unit dead" is on
|
|
132
|
+
// systemd 255. Trusting it means `ours-fleet spawn` reports a created role
|
|
133
|
+
// whose job is loaded and not running.
|
|
134
|
+
//
|
|
135
|
+
// A failed start takes the SAME rollback path as a failed bootstrap, so
|
|
136
|
+
// nothing is left behind either way.
|
|
137
|
+
const start = classifyStart(await printJob(name));
|
|
138
|
+
if (start.started === 'no') {
|
|
139
|
+
await undo();
|
|
140
|
+
throw new Error(`launchctl bootstrap ${labelFor(name)} reported success but the job is not running `
|
|
141
|
+
+ `(${start.detail}); bootstrap exits 0 once the job is loaded, which does not report a `
|
|
142
|
+
+ `failed start. Check: launchctl print ${domain}/${labelFor(name)}`);
|
|
143
|
+
}
|
|
144
|
+
return existed
|
|
145
|
+
? { created: false, detail: `${labelFor(name)} was already installed (${start.detail})` }
|
|
146
|
+
: { created: true, detail: `installed ${labelFor(name)} (${start.detail})` };
|
|
45
147
|
},
|
|
46
148
|
async start(name) {
|
|
47
149
|
const r = await exec('launchctl', ['bootstrap', domain, plistPath(name)]);
|
|
@@ -64,9 +166,24 @@ export function makeLaunchdBackend(exec = realExec, uid = process.getuid?.() ??
|
|
|
64
166
|
return `not loaded (${labelFor(name)})`;
|
|
65
167
|
return r.stdout.split('\n').slice(0, 12).join('\n');
|
|
66
168
|
},
|
|
169
|
+
async liveness(name) {
|
|
170
|
+
const job = await printJob(name);
|
|
171
|
+
// Loaded. `state = waiting` is a KeepAlive service between restarts —
|
|
172
|
+
// still supervised, so its context stands. Unchanged by the install-time
|
|
173
|
+
// start check above, which asks a different question of the same output.
|
|
174
|
+
if (job.loaded)
|
|
175
|
+
return { state: 'running', detail: job.state ? `loaded (state = ${job.state})` : 'loaded' };
|
|
176
|
+
if (job.notFound)
|
|
177
|
+
return { state: 'stopped', detail: `not loaded (${labelFor(name)})` };
|
|
178
|
+
return { state: 'unknown', detail: job.failure ?? `launchctl print ${labelFor(name)} failed` };
|
|
179
|
+
},
|
|
67
180
|
async uninstall(name) {
|
|
68
|
-
|
|
181
|
+
const existed = existsSync(plistPath(name));
|
|
182
|
+
await exec('launchctl', ['bootout', `${domain}/${labelFor(name)}`]); // idempotent
|
|
69
183
|
rmSync(plistPath(name), { force: true });
|
|
184
|
+
return existed
|
|
185
|
+
? { removed: true, detail: `removed ${labelFor(name)}` }
|
|
186
|
+
: { removed: false, detail: `${labelFor(name)} was not installed` };
|
|
70
187
|
},
|
|
71
188
|
logsArgs(name, follow) {
|
|
72
189
|
const log = join(logsRoot(), `${name}.log`);
|
package/dist/supervisor/none.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Tmux } from '../tmux.js';
|
|
1
|
+
import { Tmux, tmuxArgs } from '../tmux.js';
|
|
2
2
|
import { realExec, shq } from '../exec.js';
|
|
3
3
|
/**
|
|
4
4
|
* No supervision: sessions are plain tmux, nothing survives a reboot and
|
|
@@ -11,14 +11,32 @@ export function makeNoneBackend(exec = realExec) {
|
|
|
11
11
|
id: 'none',
|
|
12
12
|
async init() { return ['no supervisor: sessions are plain tmux (no reboot survival)']; },
|
|
13
13
|
async install(name, binPath) {
|
|
14
|
-
await tmux.kill(name);
|
|
14
|
+
const existed = await tmux.kill(name); // true when a session was there
|
|
15
15
|
await tmux.newSession(name, process.cwd(), `${shq(binPath)} _run ${shq(name)}`);
|
|
16
|
+
return existed
|
|
17
|
+
? { created: false, detail: `replaced the existing tmux session '${name}'` }
|
|
18
|
+
: { created: true, detail: `created tmux session '${name}'` };
|
|
16
19
|
},
|
|
17
20
|
async start(name) { throw new Error(`'${name}' has no unit under the none backend — use install/spawn`); },
|
|
18
21
|
async stop(name) { await tmux.kill(name); },
|
|
19
22
|
async restart(name) { throw new Error(`restart unsupported under the none backend — stop + install '${name}'`); },
|
|
20
23
|
async status(name) { return (await tmux.has(name)) ? `tmux session '${name}' running` : `'${name}' not running`; },
|
|
21
|
-
async
|
|
22
|
-
|
|
24
|
+
async liveness(name) {
|
|
25
|
+
// Report the tmux probe directly: 0 = session exists, 1 = it does not.
|
|
26
|
+
// Any other code (127 = no tmux binary) is a failed probe, not a stop.
|
|
27
|
+
const r = await exec('tmux', tmuxArgs(name, ['has-session', '-t', name]));
|
|
28
|
+
if (r.code === 0)
|
|
29
|
+
return { state: 'running', detail: `tmux session '${name}' exists` };
|
|
30
|
+
if (r.code === 1)
|
|
31
|
+
return { state: 'stopped', detail: `no tmux session '${name}'` };
|
|
32
|
+
return { state: 'unknown', detail: `tmux has-session '${name}' failed (${r.code}): ${r.stderr.trim() || 'no output'}` };
|
|
33
|
+
},
|
|
34
|
+
async uninstall(name) {
|
|
35
|
+
const killed = await tmux.kill(name); // idempotent
|
|
36
|
+
return killed
|
|
37
|
+
? { removed: true, detail: `killed tmux session '${name}'` }
|
|
38
|
+
: { removed: false, detail: `no tmux session '${name}'` };
|
|
39
|
+
},
|
|
40
|
+
logsArgs(name) { return { cmd: 'tmux', args: tmuxArgs(name, ['capture-pane', '-t', name, '-p']) }; },
|
|
23
41
|
};
|
|
24
42
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Exec } from '../exec.js';
|
|
2
|
-
import type { SupervisorBackend } from './types.js';
|
|
2
|
+
import type { LivenessState, SupervisorBackend } from './types.js';
|
|
3
3
|
export declare const UNIT_TEMPLATE = "ours-fleet-agent@.service";
|
|
4
4
|
/**
|
|
5
5
|
* Actionable hint when systemctl cannot reach the user bus. After the cli.ts
|
|
@@ -9,4 +9,11 @@ export declare const UNIT_TEMPLATE = "ours-fleet-agent@.service";
|
|
|
9
9
|
*/
|
|
10
10
|
export declare const busHint: (stderr: string) => string;
|
|
11
11
|
export declare const unitFor: (name: string) => string;
|
|
12
|
+
/**
|
|
13
|
+
* systemd's own ActiveState vocabulary, classified. `activating` covers
|
|
14
|
+
* `auto-restart` — the unit is mid-restart, not stopped, so its context stands.
|
|
15
|
+
* `deactivating`/`reloading` still have a process. Only `inactive` and `failed`
|
|
16
|
+
* are definite stops. Anything systemd did not report is `unknown`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function classifyActiveState(activeState: string): LivenessState;
|
|
12
19
|
export declare function makeSystemdBackend(exec?: Exec): SupervisorBackend;
|
|
@@ -15,6 +15,44 @@ export const busHint = (stderr) => /user scope bus|XDG_RUNTIME_DIR/.test(stderr)
|
|
|
15
15
|
`\n (if linger is already on: export XDG_RUNTIME_DIR=/run/user/$(id -u))`
|
|
16
16
|
: '';
|
|
17
17
|
export const unitFor = (name) => `ours-fleet-agent@${name}.service`;
|
|
18
|
+
/**
|
|
19
|
+
* systemd's own ActiveState vocabulary, classified. `activating` covers
|
|
20
|
+
* `auto-restart` — the unit is mid-restart, not stopped, so its context stands.
|
|
21
|
+
* `deactivating`/`reloading` still have a process. Only `inactive` and `failed`
|
|
22
|
+
* are definite stops. Anything systemd did not report is `unknown`.
|
|
23
|
+
*/
|
|
24
|
+
export function classifyActiveState(activeState) {
|
|
25
|
+
switch (activeState) {
|
|
26
|
+
case 'active':
|
|
27
|
+
case 'activating':
|
|
28
|
+
case 'reloading':
|
|
29
|
+
case 'deactivating': return 'running';
|
|
30
|
+
case 'inactive':
|
|
31
|
+
case 'failed': return 'stopped';
|
|
32
|
+
default: return 'unknown';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Ask the unit what state it is actually in.
|
|
37
|
+
*
|
|
38
|
+
* `show --value` is machine-readable and stable across versions; `status`
|
|
39
|
+
* prose is not, and neither — as it turns out — is an exit code. Shared by
|
|
40
|
+
* `liveness` and by `install`'s start verification so the two cannot disagree
|
|
41
|
+
* about what "running" means.
|
|
42
|
+
*/
|
|
43
|
+
async function probeLiveness(ctl, name) {
|
|
44
|
+
const r = await ctl('show', '-p', 'ActiveState', '-p', 'SubState', '--value', unitFor(name));
|
|
45
|
+
const [activeState = '', subState = ''] = r.stdout.trim().split('\n').map(l => l.trim());
|
|
46
|
+
if (!activeState)
|
|
47
|
+
return {
|
|
48
|
+
state: 'unknown',
|
|
49
|
+
detail: `systemctl show ${unitFor(name)} failed: ${r.stderr.trim() || `exit ${r.code}`}${busHint(r.stderr)}`,
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
state: classifyActiveState(activeState),
|
|
53
|
+
detail: subState ? `${activeState} (${subState})` : activeState,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
18
56
|
export function makeSystemdBackend(exec = realExec) {
|
|
19
57
|
const ctl = (...args) => exec('systemctl', ['--user', ...args]);
|
|
20
58
|
return {
|
|
@@ -30,8 +68,12 @@ After=default.target
|
|
|
30
68
|
[Service]
|
|
31
69
|
Type=simple
|
|
32
70
|
ExecStart=${binPath} _run %i
|
|
33
|
-
|
|
34
|
-
|
|
71
|
+
# The RUNNER owns the child-session restart loop, with a counted, backed-off
|
|
72
|
+
# circuit breaker (3.2). systemd must only recover the runner PROCESS crashing —
|
|
73
|
+
# Restart=always here would resume the uncounted two-second relaunch loop, and
|
|
74
|
+
# would also restart a runner that is deliberately holding a failing agent down.
|
|
75
|
+
Restart=on-failure
|
|
76
|
+
RestartSec=5
|
|
35
77
|
TimeoutStopSec=15
|
|
36
78
|
|
|
37
79
|
[Install]
|
|
@@ -46,9 +88,49 @@ WantedBy=default.target
|
|
|
46
88
|
return msgs;
|
|
47
89
|
},
|
|
48
90
|
async install(name) {
|
|
91
|
+
// Ask FIRST whether this unit was already enabled, so a rollback can tell
|
|
92
|
+
// "we registered this" from "it was already here" (6.2).
|
|
93
|
+
const before = await ctl('is-enabled', unitFor(name));
|
|
94
|
+
const alreadyEnabled = before.stdout.trim() === 'enabled';
|
|
49
95
|
const r = await ctl('enable', '--now', unitFor(name));
|
|
50
|
-
|
|
96
|
+
// Undo only what WE enabled. A unit that was already enabled belongs to
|
|
97
|
+
// whoever enabled it, and rollback may never remove that (6.2).
|
|
98
|
+
const undo = async () => { if (!alreadyEnabled)
|
|
99
|
+
await ctl('disable', '--now', unitFor(name)); };
|
|
100
|
+
if (r.code !== 0) {
|
|
101
|
+
// `enable --now` is enable THEN start, so a non-zero result can arrive
|
|
102
|
+
// with the symlink already written. Throwing then means `install` never
|
|
103
|
+
// returns `{created: true}`, the creation transaction records nothing,
|
|
104
|
+
// and a spawn that failed at registration leaves an enabled unit behind.
|
|
105
|
+
await undo();
|
|
51
106
|
throw new Error(`systemctl enable --now ${unitFor(name)} failed: ${r.stderr.trim()}${busHint(r.stderr)}`);
|
|
107
|
+
}
|
|
108
|
+
// THE EXIT CODE IS NOT THE SIGNAL, so the start is verified rather than
|
|
109
|
+
// assumed.
|
|
110
|
+
//
|
|
111
|
+
// Measured on systemd 255: `systemctl --user enable --now` whose START
|
|
112
|
+
// half fails returns **0** and reports the failed job only as prose on
|
|
113
|
+
// stderr, while a bare `start` of the same unit returns 1. Trusting the
|
|
114
|
+
// code there means a spawn reports success while the role's unit sits
|
|
115
|
+
// enabled and dead — the exact failure this release exists to remove,
|
|
116
|
+
// inside the command that creates the role.
|
|
117
|
+
//
|
|
118
|
+
// Asking the unit its own ActiveState is version-independent: it does not
|
|
119
|
+
// care whether this systemd propagates a start failure into the exit
|
|
120
|
+
// code, so it is correct both on versions that do and versions that do
|
|
121
|
+
// not. Only a DEFINITE stop counts. An unanswerable probe is `unknown`
|
|
122
|
+
// and must never be read as a failed start (1.1) — the unit may be
|
|
123
|
+
// perfectly fine and the bus merely unreachable.
|
|
124
|
+
const live = await probeLiveness(ctl, name);
|
|
125
|
+
if (live.state === 'stopped') {
|
|
126
|
+
await undo();
|
|
127
|
+
throw new Error(`systemctl enable --now ${unitFor(name)} reported success but the unit is not running `
|
|
128
|
+
+ `(${live.detail}); systemctl exited 0, which on this version does not report a failed `
|
|
129
|
+
+ `start. Check: systemctl --user status ${unitFor(name)}`);
|
|
130
|
+
}
|
|
131
|
+
return alreadyEnabled
|
|
132
|
+
? { created: false, detail: `${unitFor(name)} was already enabled (${live.detail})` }
|
|
133
|
+
: { created: true, detail: `enabled ${unitFor(name)} (${live.detail})` };
|
|
52
134
|
},
|
|
53
135
|
async start(name) { await ctl('start', unitFor(name)); },
|
|
54
136
|
async stop(name) {
|
|
@@ -65,7 +147,15 @@ WantedBy=default.target
|
|
|
65
147
|
const r = await ctl('status', unitFor(name), '--no-pager');
|
|
66
148
|
return r.stdout || r.stderr;
|
|
67
149
|
},
|
|
68
|
-
|
|
150
|
+
liveness(name) { return probeLiveness(ctl, name); },
|
|
151
|
+
async uninstall(name) {
|
|
152
|
+
const before = await ctl('is-enabled', unitFor(name));
|
|
153
|
+
const wasEnabled = before.stdout.trim() === 'enabled';
|
|
154
|
+
await ctl('disable', '--now', unitFor(name)); // idempotent
|
|
155
|
+
return wasEnabled
|
|
156
|
+
? { removed: true, detail: `disabled ${unitFor(name)}` }
|
|
157
|
+
: { removed: false, detail: `${unitFor(name)} was not enabled` };
|
|
158
|
+
},
|
|
69
159
|
logsArgs(name, follow) {
|
|
70
160
|
return { cmd: 'journalctl', args: ['--user', '-u', unitFor(name), ...(follow ? ['-f'] : ['-n', '200'])] };
|
|
71
161
|
},
|
|
@@ -1,14 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed liveness verdict. `stopped` is a *definite* stop — the only state that
|
|
3
|
+
* lets a caller discard session context. `unknown` means the probe itself did
|
|
4
|
+
* not answer (bus unreachable, missing binary, unrecognised state) and must be
|
|
5
|
+
* treated as "may still be running".
|
|
6
|
+
*/
|
|
7
|
+
export type LivenessState = 'running' | 'stopped' | 'unknown';
|
|
8
|
+
export interface Liveness {
|
|
9
|
+
state: LivenessState;
|
|
10
|
+
/** The backend's own words: native state tokens, or the probe failure. */
|
|
11
|
+
detail: string;
|
|
12
|
+
}
|
|
13
|
+
/** Whether `install` had to create the registration, or found it already there. */
|
|
14
|
+
export interface InstallOutcome {
|
|
15
|
+
created: boolean;
|
|
16
|
+
detail: string;
|
|
17
|
+
}
|
|
18
|
+
export interface UninstallOutcome {
|
|
19
|
+
removed: boolean;
|
|
20
|
+
detail: string;
|
|
21
|
+
}
|
|
1
22
|
export interface SupervisorBackend {
|
|
2
23
|
id: 'systemd' | 'launchd' | 'none';
|
|
3
24
|
/** One-time host setup (unit template / dirs / linger). Returns human-readable messages. */
|
|
4
25
|
init(binPath: string): Promise<string[]>;
|
|
5
|
-
/**
|
|
6
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Ensure the role's unit exists and is enabled + started. Idempotent, and
|
|
28
|
+
* EXPLICIT about whether it created the registration: rollback may only
|
|
29
|
+
* remove what this transaction made, so "did I create this?" has to be
|
|
30
|
+
* answerable rather than assumed (6.2).
|
|
31
|
+
*/
|
|
32
|
+
install(name: string, binPath: string): Promise<InstallOutcome>;
|
|
7
33
|
start(name: string): Promise<void>;
|
|
8
34
|
stop(name: string): Promise<void>;
|
|
9
35
|
restart(name: string): Promise<void>;
|
|
10
36
|
status(name: string): Promise<string>;
|
|
11
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Classify the role's liveness from the backend's own native result — never
|
|
39
|
+
* by matching prose in `status()`. Implementations must not throw: a failed
|
|
40
|
+
* probe is `unknown` with the failure in `detail`.
|
|
41
|
+
*/
|
|
42
|
+
liveness(name: string): Promise<Liveness>;
|
|
43
|
+
/** Remove the registration. Idempotent; reports whether anything was there. */
|
|
44
|
+
uninstall(name: string): Promise<UninstallOutcome>;
|
|
12
45
|
/** Command the CLI execs (stdio inherited) to show logs. */
|
|
13
46
|
logsArgs(name: string, follow: boolean): {
|
|
14
47
|
cmd: string;
|
package/dist/tmux.d.ts
CHANGED
|
@@ -1,14 +1,46 @@
|
|
|
1
1
|
import { type Exec } from './exec.js';
|
|
2
|
+
/** Socket-name prefix for every tmux server this fleet starts. */
|
|
3
|
+
export declare const TMUX_SOCKET_PREFIX = "ours-fleet-";
|
|
4
|
+
/**
|
|
5
|
+
* The tmux socket a session lives on — ONE SERVER PER SESSION (#32).
|
|
6
|
+
*
|
|
7
|
+
* Without `-L`, every role's pane lands on the single default tmux server. That
|
|
8
|
+
* server is a child of whichever role happened to start it first, so it sits in
|
|
9
|
+
* that role's unit cgroup: stopping that one unit takes down every other role's
|
|
10
|
+
* pane with it. A per-session socket puts each role's server in its own unit,
|
|
11
|
+
* which is what makes `stop` local to the role being stopped.
|
|
12
|
+
*
|
|
13
|
+
* Role names are `[A-Za-z0-9_-]+` (config.ts), so this is always a usable
|
|
14
|
+
* socket filename.
|
|
15
|
+
*/
|
|
16
|
+
export declare const tmuxSocket: (session: string) => string;
|
|
17
|
+
/**
|
|
18
|
+
* Address a tmux command at that session's own server. EVERY tmux invocation in
|
|
19
|
+
* this repository must go through here — one that forgets `-L` silently talks to
|
|
20
|
+
* the shared default server and re-creates #32.
|
|
21
|
+
*/
|
|
22
|
+
export declare const tmuxArgs: (session: string, args: string[]) => string[];
|
|
2
23
|
/** Thin tmux wrapper; all session handling in the core goes through this. */
|
|
3
24
|
export declare class Tmux {
|
|
4
25
|
private exec;
|
|
5
26
|
constructor(exec?: Exec);
|
|
6
27
|
has(name: string): Promise<boolean>;
|
|
7
28
|
newSession(name: string, cwd: string, shellCommand: string): Promise<void>;
|
|
8
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Best-effort kill. Returns whether a session was actually there to destroy —
|
|
31
|
+
* the caller needs that to tell "we tore this session down" apart from "the
|
|
32
|
+
* program inside it exited on its own".
|
|
33
|
+
*/
|
|
34
|
+
kill(name: string): Promise<boolean>;
|
|
9
35
|
capture(name: string, lines?: number): Promise<string>;
|
|
10
36
|
panePid(name: string): Promise<number | null>;
|
|
11
|
-
|
|
37
|
+
/**
|
|
38
|
+
* List the live sessions among `names`, asking each server in turn.
|
|
39
|
+
* There is no fleet-wide `tmux ls` any more: a session per server means the
|
|
40
|
+
* caller says which sessions to ask about. A server that is not running
|
|
41
|
+
* answers non-zero and contributes nothing.
|
|
42
|
+
*/
|
|
43
|
+
list(names: readonly string[]): Promise<string>;
|
|
12
44
|
sendText(name: string, text: string): Promise<void>;
|
|
13
45
|
sendKey(name: string, key: string): Promise<void>;
|
|
14
46
|
}
|
package/dist/tmux.js
CHANGED
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
import { realExec } from './exec.js';
|
|
2
|
+
/** Socket-name prefix for every tmux server this fleet starts. */
|
|
3
|
+
export const TMUX_SOCKET_PREFIX = 'ours-fleet-';
|
|
4
|
+
/**
|
|
5
|
+
* The tmux socket a session lives on — ONE SERVER PER SESSION (#32).
|
|
6
|
+
*
|
|
7
|
+
* Without `-L`, every role's pane lands on the single default tmux server. That
|
|
8
|
+
* server is a child of whichever role happened to start it first, so it sits in
|
|
9
|
+
* that role's unit cgroup: stopping that one unit takes down every other role's
|
|
10
|
+
* pane with it. A per-session socket puts each role's server in its own unit,
|
|
11
|
+
* which is what makes `stop` local to the role being stopped.
|
|
12
|
+
*
|
|
13
|
+
* Role names are `[A-Za-z0-9_-]+` (config.ts), so this is always a usable
|
|
14
|
+
* socket filename.
|
|
15
|
+
*/
|
|
16
|
+
export const tmuxSocket = (session) => `${TMUX_SOCKET_PREFIX}${session}`;
|
|
17
|
+
/**
|
|
18
|
+
* Address a tmux command at that session's own server. EVERY tmux invocation in
|
|
19
|
+
* this repository must go through here — one that forgets `-L` silently talks to
|
|
20
|
+
* the shared default server and re-creates #32.
|
|
21
|
+
*/
|
|
22
|
+
export const tmuxArgs = (session, args) => ['-L', tmuxSocket(session), ...args];
|
|
2
23
|
/** Thin tmux wrapper; all session handling in the core goes through this. */
|
|
3
24
|
export class Tmux {
|
|
4
25
|
exec;
|
|
@@ -6,44 +27,60 @@ export class Tmux {
|
|
|
6
27
|
this.exec = exec;
|
|
7
28
|
}
|
|
8
29
|
async has(name) {
|
|
9
|
-
return (await this.exec('tmux', ['has-session', '-t', name])).code === 0;
|
|
30
|
+
return (await this.exec('tmux', tmuxArgs(name, ['has-session', '-t', name]))).code === 0;
|
|
10
31
|
}
|
|
11
32
|
async newSession(name, cwd, shellCommand) {
|
|
12
|
-
const r = await this.exec('tmux', ['new-session', '-d', '-s', name, '-c', cwd, shellCommand]);
|
|
33
|
+
const r = await this.exec('tmux', tmuxArgs(name, ['new-session', '-d', '-s', name, '-c', cwd, shellCommand]));
|
|
13
34
|
if (r.code !== 0)
|
|
14
35
|
throw new Error(`tmux new-session '${name}' failed (${r.code}): ${r.stderr.trim()}`);
|
|
15
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Best-effort kill. Returns whether a session was actually there to destroy —
|
|
39
|
+
* the caller needs that to tell "we tore this session down" apart from "the
|
|
40
|
+
* program inside it exited on its own".
|
|
41
|
+
*/
|
|
16
42
|
async kill(name) {
|
|
17
|
-
await this.exec('tmux', ['kill-session', '-t', name])
|
|
43
|
+
return (await this.exec('tmux', tmuxArgs(name, ['kill-session', '-t', name]))).code === 0;
|
|
18
44
|
}
|
|
19
45
|
async capture(name, lines = 40) {
|
|
20
|
-
const r = await this.exec('tmux', ['capture-pane', '-t', name, '-p']);
|
|
46
|
+
const r = await this.exec('tmux', tmuxArgs(name, ['capture-pane', '-t', name, '-p']));
|
|
21
47
|
if (r.code !== 0)
|
|
22
48
|
throw new Error(`tmux capture-pane '${name}' failed: ${r.stderr.trim()}`);
|
|
23
49
|
const all = r.stdout.replace(/\n+$/, '').split('\n');
|
|
24
50
|
return all.slice(-lines).join('\n');
|
|
25
51
|
}
|
|
26
52
|
async panePid(name) {
|
|
27
|
-
const r = await this.exec('tmux', ['list-panes', '-t', name, '-F', '#{pane_pid}']);
|
|
53
|
+
const r = await this.exec('tmux', tmuxArgs(name, ['list-panes', '-t', name, '-F', '#{pane_pid}']));
|
|
28
54
|
if (r.code !== 0)
|
|
29
55
|
return null;
|
|
30
56
|
const pid = parseInt(r.stdout.trim().split('\n')[0], 10);
|
|
31
57
|
return Number.isFinite(pid) ? pid : null;
|
|
32
58
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
59
|
+
/**
|
|
60
|
+
* List the live sessions among `names`, asking each server in turn.
|
|
61
|
+
* There is no fleet-wide `tmux ls` any more: a session per server means the
|
|
62
|
+
* caller says which sessions to ask about. A server that is not running
|
|
63
|
+
* answers non-zero and contributes nothing.
|
|
64
|
+
*/
|
|
65
|
+
async list(names) {
|
|
66
|
+
const lines = [];
|
|
67
|
+
for (const name of names) {
|
|
68
|
+
const r = await this.exec('tmux', tmuxArgs(name, ['ls']));
|
|
69
|
+
if (r.code === 0 && r.stdout.trim())
|
|
70
|
+
lines.push(r.stdout.trimEnd());
|
|
71
|
+
}
|
|
72
|
+
return lines.join('\n');
|
|
36
73
|
}
|
|
37
74
|
async sendText(name, text) {
|
|
38
|
-
let r = await this.exec('tmux', ['send-keys', '-t', name, '-l', text]);
|
|
75
|
+
let r = await this.exec('tmux', tmuxArgs(name, ['send-keys', '-t', name, '-l', text]));
|
|
39
76
|
if (r.code !== 0)
|
|
40
77
|
throw new Error(`tmux send-keys '${name}' failed: ${r.stderr.trim()}`);
|
|
41
|
-
r = await this.exec('tmux', ['send-keys', '-t', name, 'Enter']);
|
|
78
|
+
r = await this.exec('tmux', tmuxArgs(name, ['send-keys', '-t', name, 'Enter']));
|
|
42
79
|
if (r.code !== 0)
|
|
43
80
|
throw new Error(`tmux send-keys Enter '${name}' failed: ${r.stderr.trim()}`);
|
|
44
81
|
}
|
|
45
82
|
async sendKey(name, key) {
|
|
46
|
-
const r = await this.exec('tmux', ['send-keys', '-t', name, key]);
|
|
83
|
+
const r = await this.exec('tmux', tmuxArgs(name, ['send-keys', '-t', name, key]));
|
|
47
84
|
if (r.code !== 0)
|
|
48
85
|
throw new Error(`tmux send-keys '${name}' failed: ${r.stderr.trim()}`);
|
|
49
86
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.8",
|
|
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",
|