@ours.network/fleet 0.17.0 → 0.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -2
- package/dist/application/role-removal-service.js +1 -1
- package/dist/application/session-control.d.ts +14 -10
- package/dist/application/session-control.js +14 -3
- package/dist/atomic-file.d.ts +7 -1
- package/dist/atomic-file.js +33 -5
- package/dist/build-info.json +10 -0
- package/dist/capabilities.d.ts +20 -0
- package/dist/capabilities.js +21 -0
- package/dist/cli.js +98 -10
- package/dist/config.d.ts +9 -2
- package/dist/config.js +16 -2
- package/dist/creation.d.ts +16 -0
- package/dist/creation.js +28 -0
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +70 -4
- package/dist/doctor.d.ts +5 -0
- package/dist/doctor.js +87 -2
- package/dist/fleet-proxy.js +2 -2
- package/dist/harness/acp-agent.d.ts +3 -0
- package/dist/harness/acp-agent.js +4 -1
- package/dist/harness/codex-app-server-proxy.d.ts +4 -0
- package/dist/harness/codex-app-server-proxy.js +133 -0
- package/dist/harness/codex.js +116 -11
- package/dist/harness/types.d.ts +2 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/loops/manager.d.ts +42 -1
- package/dist/loops/manager.js +115 -16
- package/dist/loops/state.d.ts +46 -2
- package/dist/loops/state.js +81 -3
- package/dist/monitor.d.ts +21 -0
- package/dist/monitor.js +42 -0
- package/dist/ops.d.ts +6 -0
- package/dist/ops.js +46 -1
- package/dist/owner-channel/channel.d.ts +18 -2
- package/dist/owner-channel/channel.js +146 -2
- package/dist/owner-channel/commands.d.ts +2 -2
- package/dist/owner-channel/commands.js +7 -2
- package/dist/owner-channel/notices.d.ts +2 -0
- package/dist/owner-channel/notices.js +3 -0
- package/dist/permissions.d.ts +2 -0
- package/dist/permissions.js +5 -0
- package/dist/provenance.d.ts +77 -0
- package/dist/provenance.js +283 -0
- package/dist/runner.d.ts +7 -1
- package/dist/runner.js +100 -14
- package/dist/session/acp.d.ts +40 -4
- package/dist/session/acp.js +272 -37
- package/dist/session/arbiter.d.ts +28 -2
- package/dist/session/arbiter.js +75 -4
- package/dist/session/control.js +12 -6
- package/dist/session/event-log.d.ts +109 -0
- package/dist/session/event-log.js +247 -0
- package/dist/session/events.d.ts +21 -0
- package/dist/session/events.js +105 -26
- package/dist/session/tmux.d.ts +3 -2
- package/dist/session/tmux.js +2 -0
- package/dist/session/types.d.ts +39 -2
- package/dist/session/types.js +11 -1
- package/dist/spawn.d.ts +3 -3
- package/dist/spawn.js +40 -14
- package/dist/temp-lifecycle.d.ts +62 -0
- package/dist/temp-lifecycle.js +437 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -302,9 +302,27 @@ ours-fleet loops reload <Role>
|
|
|
302
302
|
ours-fleet loops run-now|disable|enable <Role> <Loop>
|
|
303
303
|
ours-fleet rm <Name>
|
|
304
304
|
ours-fleet doctor [--harness H]
|
|
305
|
+
ours-fleet version [--json] build identity, capabilities, installs on PATH
|
|
305
306
|
ours-fleet init
|
|
306
307
|
```
|
|
307
308
|
|
|
309
|
+
### Which build am I running?
|
|
310
|
+
|
|
311
|
+
`--version` prints a semver, and a semver does not identify an artifact: version
|
|
312
|
+
bumps land in their own release commit, so a build cut between two releases
|
|
313
|
+
carries the previous version while already containing new behaviour. Two installs
|
|
314
|
+
on one host once both reported `0.16.0` while disagreeing about whether
|
|
315
|
+
`monitor.interrupt: after_tool` was valid.
|
|
316
|
+
|
|
317
|
+
Every build therefore stamps `dist/build-info.json` with a content-derived build
|
|
318
|
+
id, its commit, and the capability tokens the shipped code declares.
|
|
319
|
+
`ours-fleet version` prints that identity plus every `ours-fleet` it can see on
|
|
320
|
+
`PATH`; `ours-fleet doctor` fails when two of them share a semver but are
|
|
321
|
+
different builds, or when the executable on `PATH` is a different artifact from
|
|
322
|
+
the one running. Two prefixes holding identical content are not a conflict.
|
|
323
|
+
Installs predating the stamp are compared by hashing their `dist/`, so two of
|
|
324
|
+
those are still told apart.
|
|
325
|
+
|
|
308
326
|
A permanent role brought up via `-c custom.yaml` remembers that file (`.config-path`
|
|
309
327
|
in its state dir) across supervisor-triggered restarts — systemd/launchd re-invoke the
|
|
310
328
|
agent process with no arguments, so without this the role would silently fall back to
|
|
@@ -437,6 +455,20 @@ schedule or selector changes reset that loop to its configured initial delay.
|
|
|
437
455
|
`run-now` still obeys idle-only admission and returns exit 3 when busy; an
|
|
438
456
|
unavailable or uncertain control plane returns exit 2 and is never retried.
|
|
439
457
|
|
|
458
|
+
A failed state write — a full disk is the case seen in practice — never stops the
|
|
459
|
+
manager. The failure is held in memory as `health: failed`, `anomaly:
|
|
460
|
+
persist_failed`; the occurrence that could not be checkpointed is recorded as
|
|
461
|
+
`skipped_unpersisted` rather than started, since a run that is not on disk is one
|
|
462
|
+
a restart would submit twice; and the manager keeps a bounded retry armed (1s
|
|
463
|
+
doubling to 60s) so it resumes on its own once writes land. Because that health
|
|
464
|
+
flip is itself unwritable, readers do not trust a stored file that has stopped
|
|
465
|
+
advancing: `status`, `loops status`, and `doctor` report `stale` — never the
|
|
466
|
+
recorded health — once the checkpoint of a role that still has a loop to run is
|
|
467
|
+
more than 5 minutes old. A role whose loops are all disabled stops checkpointing
|
|
468
|
+
by design and is never called stale. `status` asks the live control socket
|
|
469
|
+
whenever it is there, including when the first checkpoint never got written, and
|
|
470
|
+
`doctor` fails a running role that has no stored loop state at all.
|
|
471
|
+
|
|
440
472
|
A scheduled turn has typed internal provenance and no owner authority. It cannot
|
|
441
473
|
cancel owner work and its ordinary completion is local only. Material proactive
|
|
442
474
|
owner reporting remains possible solely through an already-open authenticated
|
|
@@ -501,8 +533,12 @@ mode can cross.
|
|
|
501
533
|
|
|
502
534
|
ACP exposes agent-specific session mode IDs and `session/set_mode`, but no
|
|
503
535
|
portable permission-policy capability. Fleet uses that primitive where an
|
|
504
|
-
adapter has a corresponding mode and otherwise translates at the adapter
|
|
505
|
-
|
|
536
|
+
adapter has a corresponding mode and otherwise translates at the adapter. The
|
|
537
|
+
bundled Codex ACP adapter couples approval and sandboxing in its advertised
|
|
538
|
+
mode IDs, so fleet preserves the selected sandbox preset and enforces approval
|
|
539
|
+
independently on the app-server turn request. Thus `allow` + `workspace` is
|
|
540
|
+
actually `never` + `workspace-write`, never `danger-full-access`. Live session
|
|
541
|
+
metadata reports the normalized policy and the ACP sandbox-preset ID.
|
|
506
542
|
|
|
507
543
|
### Isolation at creation time
|
|
508
544
|
|
|
@@ -74,7 +74,7 @@ export class RoleRemovalService {
|
|
|
74
74
|
cpSync(sourceState, join(recoveryPath, 'state'), { recursive: true });
|
|
75
75
|
if (role?.sourceFile.includes('/fleet.d/') && existsSync(role.sourceFile))
|
|
76
76
|
cpSync(role.sourceFile, join(recoveryPath, basename(role.sourceFile)));
|
|
77
|
-
if (role)
|
|
77
|
+
if (role || preview.lifetime === 'temporary')
|
|
78
78
|
await rmRole(cfg, preview.role, this.options.ops);
|
|
79
79
|
else {
|
|
80
80
|
await this.options.ops.backend.uninstall(preview.role);
|
|
@@ -13,6 +13,16 @@ export interface ConversationPageView {
|
|
|
13
13
|
export interface ConversationFollowHandle {
|
|
14
14
|
close(): void;
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* An interrupt that reached the session. `state` says whether the turn stopped
|
|
18
|
+
* cooperatively or through bounded forced recovery — both are accepted, so no
|
|
19
|
+
* consumer may render `forced` as a failed interrupt.
|
|
20
|
+
*/
|
|
21
|
+
export interface InterruptReceipt {
|
|
22
|
+
accepted: true;
|
|
23
|
+
state: 'settled' | 'forced';
|
|
24
|
+
reasonCode?: string;
|
|
25
|
+
}
|
|
16
26
|
export interface RoleSessionControl {
|
|
17
27
|
describe(): Promise<SessionDescriptor>;
|
|
18
28
|
snapshot(): Promise<SessionSnapshot>;
|
|
@@ -21,9 +31,7 @@ export interface RoleSessionControl {
|
|
|
21
31
|
limit?: number;
|
|
22
32
|
}): Promise<OutputPage>;
|
|
23
33
|
sendText(text: string): Promise<SendReceipt>;
|
|
24
|
-
interrupt?(): Promise<
|
|
25
|
-
accepted: true;
|
|
26
|
-
}>;
|
|
34
|
+
interrupt?(): Promise<InterruptReceipt>;
|
|
27
35
|
respondPermission?(request: {
|
|
28
36
|
permissionId: string;
|
|
29
37
|
optionId: string;
|
|
@@ -40,8 +48,7 @@ export interface RoleSessionControl {
|
|
|
40
48
|
actorBrowserSession: string;
|
|
41
49
|
source: 'owner_admin_console';
|
|
42
50
|
}): Promise<PromptReceipt>;
|
|
43
|
-
interruptV2?(commandId: string): Promise<{
|
|
44
|
-
accepted: true;
|
|
51
|
+
interruptV2?(commandId: string): Promise<InterruptReceipt & {
|
|
45
52
|
commandId: string;
|
|
46
53
|
}>;
|
|
47
54
|
respondPermissionV2?(request: {
|
|
@@ -76,9 +83,7 @@ export declare class AcpRoleSessionAdapter implements RoleSessionControl {
|
|
|
76
83
|
limit?: number;
|
|
77
84
|
}): Promise<OutputPage>;
|
|
78
85
|
sendText(text: string): Promise<SendReceipt>;
|
|
79
|
-
interrupt(): Promise<
|
|
80
|
-
accepted: true;
|
|
81
|
-
}>;
|
|
86
|
+
interrupt(): Promise<InterruptReceipt>;
|
|
82
87
|
conversationPage(request?: {
|
|
83
88
|
after?: string;
|
|
84
89
|
limit?: number;
|
|
@@ -89,8 +94,7 @@ export declare class AcpRoleSessionAdapter implements RoleSessionControl {
|
|
|
89
94
|
actorBrowserSession: string;
|
|
90
95
|
source: 'owner_admin_console';
|
|
91
96
|
}): Promise<PromptReceipt>;
|
|
92
|
-
interruptV2(commandId: string): Promise<{
|
|
93
|
-
accepted: true;
|
|
97
|
+
interruptV2(commandId: string): Promise<InterruptReceipt & {
|
|
94
98
|
commandId: string;
|
|
95
99
|
}>;
|
|
96
100
|
respondPermissionV2(request: {
|
|
@@ -51,8 +51,13 @@ export class AcpRoleSessionAdapter {
|
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
53
|
async interrupt() {
|
|
54
|
-
await this.call('interrupt');
|
|
55
|
-
|
|
54
|
+
const result = await this.call('interrupt');
|
|
55
|
+
// A pre-0.17.1 role control server answers `interrupt` with no result body.
|
|
56
|
+
return {
|
|
57
|
+
accepted: true,
|
|
58
|
+
state: result?.state === 'forced' ? 'forced' : 'settled',
|
|
59
|
+
...(typeof result?.reasonCode === 'string' ? { reasonCode: result.reasonCode } : {}),
|
|
60
|
+
};
|
|
56
61
|
}
|
|
57
62
|
async conversationPage(request = {}) {
|
|
58
63
|
return await this.call('conversation_page', {
|
|
@@ -78,7 +83,13 @@ export class AcpRoleSessionAdapter {
|
|
|
78
83
|
}
|
|
79
84
|
}
|
|
80
85
|
async interruptV2(commandId) {
|
|
81
|
-
|
|
86
|
+
const receipt = await this.call('interrupt_v2', { commandId });
|
|
87
|
+
return {
|
|
88
|
+
...receipt,
|
|
89
|
+
accepted: true,
|
|
90
|
+
state: receipt.state === 'forced' ? 'forced' : 'settled',
|
|
91
|
+
...(typeof receipt.reasonCode === 'string' ? { reasonCode: receipt.reasonCode } : {}),
|
|
92
|
+
};
|
|
82
93
|
}
|
|
83
94
|
async respondPermissionV2(request) {
|
|
84
95
|
try {
|
package/dist/atomic-file.d.ts
CHANGED
|
@@ -11,6 +11,12 @@ export interface LockDeps {
|
|
|
11
11
|
now?(): number;
|
|
12
12
|
sleep?(ms: number): Promise<void>;
|
|
13
13
|
}
|
|
14
|
+
/** The syscalls a replace depends on, injectable so faults can be forced deterministically. */
|
|
15
|
+
export interface WriteDeps {
|
|
16
|
+
writeSync?(fd: number, buffer: Buffer, offset: number, length: number): number;
|
|
17
|
+
fsyncSync?(fd: number): void;
|
|
18
|
+
closeSync?(fd: number): void;
|
|
19
|
+
}
|
|
14
20
|
/**
|
|
15
21
|
* Run `fn` holding a cross-process lock. `mkdir` is atomic on every platform we
|
|
16
22
|
* support, so the directory's existence IS the lock; the timestamp inside lets a
|
|
@@ -27,4 +33,4 @@ export declare function withFileLock<T>(lockPath: string, fn: () => T | Promise<
|
|
|
27
33
|
* half-written one — and an interrupted write leaves the previous contents
|
|
28
34
|
* intact.
|
|
29
35
|
*/
|
|
30
|
-
export declare function replaceFileAtomically(path: string, contents: string, mode?: number): void;
|
|
36
|
+
export declare function replaceFileAtomically(path: string, contents: string, mode?: number, deps?: WriteDeps): void;
|
package/dist/atomic-file.js
CHANGED
|
@@ -53,17 +53,45 @@ export async function withFileLock(lockPath, fn, deps = {}, staleMs = DEFAULT_ST
|
|
|
53
53
|
* half-written one — and an interrupted write leaves the previous contents
|
|
54
54
|
* intact.
|
|
55
55
|
*/
|
|
56
|
-
export function replaceFileAtomically(path, contents, mode = 0o600) {
|
|
56
|
+
export function replaceFileAtomically(path, contents, mode = 0o600, deps = {}) {
|
|
57
|
+
const write = deps.writeSync ?? writeSync;
|
|
58
|
+
const fsync = deps.fsyncSync ?? fsyncSync;
|
|
59
|
+
const close = deps.closeSync ?? closeSync;
|
|
57
60
|
const dir = dirname(path);
|
|
58
61
|
mkdirSync(dir, { recursive: true });
|
|
59
62
|
const tmp = join(dir, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
60
63
|
const fd = openSync(tmp, 'w', mode);
|
|
64
|
+
let closed = false;
|
|
61
65
|
try {
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
const buffer = Buffer.from(contents, 'utf8');
|
|
67
|
+
// A single writeSync is not a guarantee: the kernel may stop short, and a
|
|
68
|
+
// truncated temp renamed over the target is silent data loss.
|
|
69
|
+
for (let written = 0; written < buffer.length;) {
|
|
70
|
+
const advanced = write(fd, buffer, written, buffer.length - written);
|
|
71
|
+
// A write that accepts nothing would spin forever; fail instead.
|
|
72
|
+
if (advanced <= 0)
|
|
73
|
+
throw new Error(`write made no progress at byte ${written} of ${buffer.length}`);
|
|
74
|
+
written += advanced;
|
|
75
|
+
}
|
|
76
|
+
fsync(fd); // the bytes are on disk before anything points at them
|
|
77
|
+
close(fd); // a temp we could not close cleanly is not publishable
|
|
78
|
+
closed = true;
|
|
64
79
|
}
|
|
65
|
-
|
|
66
|
-
|
|
80
|
+
catch (e) {
|
|
81
|
+
// Cleanup must not mask why we are here: the original failure is what the
|
|
82
|
+
// caller needs, and a temp we could not finish must never be renamed over
|
|
83
|
+
// the last good file nor left occupying space on a disk that may be full.
|
|
84
|
+
if (!closed) {
|
|
85
|
+
try {
|
|
86
|
+
close(fd);
|
|
87
|
+
}
|
|
88
|
+
catch { /* already failing */ }
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
rmSync(tmp, { force: true });
|
|
92
|
+
}
|
|
93
|
+
catch { /* best effort */ }
|
|
94
|
+
throw e;
|
|
67
95
|
}
|
|
68
96
|
try {
|
|
69
97
|
renameSync(tmp, path);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability tokens declared by THIS source tree.
|
|
3
|
+
*
|
|
4
|
+
* Semver cannot answer "does this artifact support X": version bumps land in a
|
|
5
|
+
* separate release commit, so every build cut between two releases carries the
|
|
6
|
+
* previous version while already containing new behaviour. A token is added in
|
|
7
|
+
* the same commit as the feature it names, travels inside the built artifact
|
|
8
|
+
* (dist/build-info.json), and is therefore the only honest answer to that
|
|
9
|
+
* question — for this process and for any other install on the host.
|
|
10
|
+
*
|
|
11
|
+
* Add a token when a change makes previously-invalid configuration valid, or
|
|
12
|
+
* otherwise changes what an operator's fleet.yaml is allowed to say. Never
|
|
13
|
+
* remove one without also rejecting the configuration it admitted.
|
|
14
|
+
*/
|
|
15
|
+
/** `monitor.interrupt: after_tool` — cancel at the next tool boundary (#67). */
|
|
16
|
+
export declare const CAP_MONITOR_INTERRUPT_AFTER_TOOL = "monitor.interrupt.after_tool";
|
|
17
|
+
export declare const CAPABILITIES: readonly ["monitor.interrupt.after_tool"];
|
|
18
|
+
export type Capability = (typeof CAPABILITIES)[number];
|
|
19
|
+
/** Does this build declare `token`? */
|
|
20
|
+
export declare const hasCapability: (token: string) => boolean;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability tokens declared by THIS source tree.
|
|
3
|
+
*
|
|
4
|
+
* Semver cannot answer "does this artifact support X": version bumps land in a
|
|
5
|
+
* separate release commit, so every build cut between two releases carries the
|
|
6
|
+
* previous version while already containing new behaviour. A token is added in
|
|
7
|
+
* the same commit as the feature it names, travels inside the built artifact
|
|
8
|
+
* (dist/build-info.json), and is therefore the only honest answer to that
|
|
9
|
+
* question — for this process and for any other install on the host.
|
|
10
|
+
*
|
|
11
|
+
* Add a token when a change makes previously-invalid configuration valid, or
|
|
12
|
+
* otherwise changes what an operator's fleet.yaml is allowed to say. Never
|
|
13
|
+
* remove one without also rejecting the configuration it admitted.
|
|
14
|
+
*/
|
|
15
|
+
/** `monitor.interrupt: after_tool` — cancel at the next tool boundary (#67). */
|
|
16
|
+
export const CAP_MONITOR_INTERRUPT_AFTER_TOOL = 'monitor.interrupt.after_tool';
|
|
17
|
+
export const CAPABILITIES = [
|
|
18
|
+
CAP_MONITOR_INTERRUPT_AFTER_TOOL,
|
|
19
|
+
];
|
|
20
|
+
/** Does this build declare `token`? */
|
|
21
|
+
export const hasCapability = (token) => CAPABILITIES.includes(token);
|
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import { join as joinPath, resolve as resolvePath } from 'node:path';
|
|
|
6
6
|
import { createInterface } from 'node:readline';
|
|
7
7
|
import { Command } from 'commander';
|
|
8
8
|
import { VERSION } from './version.js';
|
|
9
|
+
import { analyzeInstalls, buildInfo, buildLabel, discoverInstalls, runningLabel, } from './provenance.js';
|
|
9
10
|
import { agentDir, agentsRoot, tmpRoot, logsRoot, deriveXdgRuntimeDir, watchdogsRoot } from './paths.js';
|
|
10
11
|
import { findRole, loadConfig, ROLE_NAME_RE } from './config.js';
|
|
11
12
|
import { formatDuration } from './duration.js';
|
|
@@ -22,13 +23,13 @@ import { acquireRunLock, latestReport, listRuns, readReport, releaseRunLock, rep
|
|
|
22
23
|
import { lastProvenance, spawnDryRun, spawnPermanent, spawnTemp, } from './spawn.js';
|
|
23
24
|
import { stringify } from 'yaml';
|
|
24
25
|
import { resolvedRolePlan } from './resolved-plan.js';
|
|
25
|
-
import { formatProvenance } from './creation.js';
|
|
26
|
+
import { creationBuildNote, formatProvenance, readProvenance } from './creation.js';
|
|
26
27
|
import { doctor } from './doctor.js';
|
|
27
28
|
import { allWarnings, analyzeFleetPermissions, formatNative } from './permissions.js';
|
|
28
29
|
import { AI_DOCS } from './docs.js';
|
|
29
30
|
import { controlRequest, controlSocketPath, followControl, livenessNote, } from './session/control.js';
|
|
30
31
|
import { SessionControlError } from './session/types.js';
|
|
31
|
-
import { readScheduledLoops } from './loops/state.js';
|
|
32
|
+
import { readScheduledLoops, storedLoopHealth } from './loops/state.js';
|
|
32
33
|
import { startWebConsole } from './web/runtime.js';
|
|
33
34
|
import { requestWebControl } from './web/control.js';
|
|
34
35
|
import { WebServiceManager } from './web/service.js';
|
|
@@ -68,6 +69,52 @@ program.command('docs')
|
|
|
68
69
|
.alias('man')
|
|
69
70
|
.description('print the complete AI-friendly command and configuration reference')
|
|
70
71
|
.action(() => { process.stdout.write(AI_DOCS); });
|
|
72
|
+
// `--version` prints the semver alone, because scripts parse it. Semver cannot
|
|
73
|
+
// identify an artifact — two installs on one host once reported 0.16.0 and
|
|
74
|
+
// disagreed about `monitor.interrupt: after_tool` — so the full identity of the
|
|
75
|
+
// executable answering, and of every other install it can see, lives here.
|
|
76
|
+
program.command('version')
|
|
77
|
+
.description('build identity, capabilities, and every ours-fleet install on this host')
|
|
78
|
+
.option('--json', 'emit the machine-readable build report')
|
|
79
|
+
.action(opts => {
|
|
80
|
+
const info = buildInfo();
|
|
81
|
+
const installs = discoverInstalls();
|
|
82
|
+
const running = installs.find(i => i.running);
|
|
83
|
+
const skew = analyzeInstalls(installs);
|
|
84
|
+
if (opts.json) {
|
|
85
|
+
process.stdout.write(`${JSON.stringify({
|
|
86
|
+
...info,
|
|
87
|
+
packageRoot: running?.packageRoot,
|
|
88
|
+
executable: binPath,
|
|
89
|
+
node: process.versions.node,
|
|
90
|
+
platform: process.platform,
|
|
91
|
+
installs: installs.map(i => ({
|
|
92
|
+
packageRoot: i.packageRoot,
|
|
93
|
+
version: i.version,
|
|
94
|
+
buildId: i.build?.buildId ?? null,
|
|
95
|
+
capabilities: i.build?.capabilities ?? null,
|
|
96
|
+
bin: i.bin ?? null,
|
|
97
|
+
running: i.running,
|
|
98
|
+
})),
|
|
99
|
+
skew,
|
|
100
|
+
}, null, 2)}\n`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
console.log(runningLabel());
|
|
104
|
+
if (info.commit)
|
|
105
|
+
console.log(` commit: ${info.commit.slice(0, 12)}${info.dirty ? ' (dirty tree)' : ''}`);
|
|
106
|
+
if (info.builtAt)
|
|
107
|
+
console.log(` built: ${info.builtAt}`);
|
|
108
|
+
console.log(` executable: ${binPath}`);
|
|
109
|
+
if (running)
|
|
110
|
+
console.log(` package root: ${running.packageRoot}`);
|
|
111
|
+
console.log(` node: v${process.versions.node} on ${process.platform}`);
|
|
112
|
+
console.log(` capabilities: ${info.capabilities.join(', ') || '(none declared)'}`);
|
|
113
|
+
for (const i of installs.filter(i => i.pathIndex !== undefined))
|
|
114
|
+
console.log(` on PATH: ${i.bin} -> ${buildLabel(i)}`);
|
|
115
|
+
for (const s of skew)
|
|
116
|
+
console.error(`${s.severity}: ${s.message}`);
|
|
117
|
+
});
|
|
71
118
|
function acpStateDir(name) {
|
|
72
119
|
const permanent = agentDir(name);
|
|
73
120
|
if (existsSync(controlSocketPath(permanent)))
|
|
@@ -171,6 +218,9 @@ cOpt(program.command('config').description('validate + print the merged plan (no
|
|
|
171
218
|
process.stdout.write(`${JSON.stringify(resolvedPlan(cfg), null, 2)}\n`);
|
|
172
219
|
return;
|
|
173
220
|
}
|
|
221
|
+
// Which artifact resolved this plan. Two installs can share a semver and
|
|
222
|
+
// disagree about what fleet.yaml means; the plan below is only true of this one.
|
|
223
|
+
console.log(`build: ${runningLabel()}`);
|
|
174
224
|
console.log(`config: ${cfg.files.join(' + ') || '(none)'}`);
|
|
175
225
|
for (const diagnostic of cfg.diagnostics)
|
|
176
226
|
console.log(`warning: ${diagnostic.message}`);
|
|
@@ -256,7 +306,7 @@ cOpt(program.command('up [names...]').description('create/start every role (or j
|
|
|
256
306
|
die(e);
|
|
257
307
|
}
|
|
258
308
|
});
|
|
259
|
-
cOpt(program.command('down [names...]').description('stop roles'))
|
|
309
|
+
cOpt(program.command('down [names...]').description('stop configured roles or exact named temporary roles'))
|
|
260
310
|
.action(async (names, opts) => {
|
|
261
311
|
try {
|
|
262
312
|
await down(loadConfig(opts.configuration), names, deps());
|
|
@@ -439,6 +489,12 @@ program.command('logs <name>').description('show the role log').option('-f, --fo
|
|
|
439
489
|
program.command('status <name>').description('unit/agent state')
|
|
440
490
|
.action(async (name) => {
|
|
441
491
|
console.log(await pickBackend().status(name));
|
|
492
|
+
// A role outlives the artifact that created it. If a different build is
|
|
493
|
+
// reporting now, its settings may resolve differently than at creation.
|
|
494
|
+
const created = readProvenance(agentDir(name));
|
|
495
|
+
const buildNote = created && creationBuildNote(created);
|
|
496
|
+
if (buildNote)
|
|
497
|
+
console.log(`build: ${buildNote}`);
|
|
442
498
|
// A held-down role looks like a healthy running unit from the outside — the
|
|
443
499
|
// runner is alive on purpose. Say so, with the reason and when (3.2).
|
|
444
500
|
const ledger = readRestartLedger(agentDir(name));
|
|
@@ -474,13 +530,37 @@ program.command('status <name>').description('unit/agent state')
|
|
|
474
530
|
console.log('session: acp control unavailable');
|
|
475
531
|
}
|
|
476
532
|
}
|
|
477
|
-
|
|
533
|
+
// Loop health is asked of the live manager first: when its state writes are
|
|
534
|
+
// failing it is the ONLY source that can say so — the stored file is frozen
|
|
535
|
+
// at whatever it last managed to record, which is why a role with every loop
|
|
536
|
+
// dead still reported `healthy` for 2h51m. The probe cannot be conditional on
|
|
537
|
+
// there being a stored file either: a manager whose very first checkpoint hit
|
|
538
|
+
// ENOSPC never wrote one, and it is precisely the one worth asking.
|
|
539
|
+
let loopState = readScheduledLoops(agentDir(name));
|
|
540
|
+
let loopEvidence = 'stored';
|
|
541
|
+
const loopControl = permanentLoopControlDir(name);
|
|
542
|
+
if (loopControl) {
|
|
543
|
+
try {
|
|
544
|
+
const response = await controlRequest(loopControl, { command: 'loop_status' }, 2_000);
|
|
545
|
+
if (response.ok) {
|
|
546
|
+
loopState = response.result;
|
|
547
|
+
loopEvidence = 'live';
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
catch { /* stored evidence stays honest */ }
|
|
551
|
+
}
|
|
478
552
|
if (loopState) {
|
|
479
553
|
const values = Object.values(loopState.loops);
|
|
480
554
|
const enabled = values.filter(loop => loop.enabled && !loop.operatorDisabled).length;
|
|
481
555
|
const next = values.filter(loop => loop.enabled && !loop.operatorDisabled)
|
|
482
556
|
.map(loop => Date.parse(loop.nextDueAt)).filter(Number.isFinite).sort((a, b) => a - b)[0];
|
|
483
|
-
|
|
557
|
+
const verdict = storedLoopHealth(loopState, Date.now());
|
|
558
|
+
const health = loopEvidence === 'live' ? loopState.health : verdict.health;
|
|
559
|
+
console.log(`loops: ${enabled} enabled${next ? `, next ${formatDuration(Math.max(0, next - Date.now()))}` : ''}, ${health}`
|
|
560
|
+
+ (loopEvidence === 'stored' && verdict.stale
|
|
561
|
+
? ` (no state update for ${formatDuration(verdict.ageMs)}; last recorded ${verdict.recorded})` : '')
|
|
562
|
+
+ (loopState.anomaly ? ` anomaly=${loopState.anomaly}` : '')
|
|
563
|
+
+ ` evidence=${loopEvidence}`);
|
|
484
564
|
}
|
|
485
565
|
});
|
|
486
566
|
const loopsCommand = program.command('loops')
|
|
@@ -579,13 +659,20 @@ cOpt(loopsCommand.command('status [role] [loop]').description('show live or stor
|
|
|
579
659
|
}
|
|
580
660
|
}
|
|
581
661
|
catch { /* stored evidence remains honest; timeout is not offline */ }
|
|
582
|
-
|
|
662
|
+
// Stored evidence is only as current as the last write that landed.
|
|
663
|
+
const verdict = state && evidence === 'stored' ? storedLoopHealth(state, Date.now()) : undefined;
|
|
664
|
+
results.push({
|
|
665
|
+
role: role.name, evidence, state,
|
|
666
|
+
health: verdict ? verdict.health : state?.health ?? null,
|
|
667
|
+
staleMs: verdict?.stale ? verdict.ageMs : null,
|
|
668
|
+
});
|
|
583
669
|
}
|
|
584
670
|
if (opts.json)
|
|
585
671
|
console.log(JSON.stringify({ schemaVersion: 1, roles: results }));
|
|
586
672
|
else {
|
|
587
673
|
const rows = results.flatMap(result => renderLoopRows(result.role, result.state, loopName)
|
|
588
|
-
.map(row => `${row} evidence=${result.evidence}`
|
|
674
|
+
.map(row => `${row} evidence=${result.evidence} health=${result.health}`
|
|
675
|
+
+ (result.staleMs === null ? '' : ` stale=${formatDuration(result.staleMs)}`)));
|
|
589
676
|
console.log(rows.join('\n') || '(no scheduled loop state)');
|
|
590
677
|
}
|
|
591
678
|
}
|
|
@@ -981,7 +1068,7 @@ cOpt(program.command('watchdog-report <name> [runId]')
|
|
|
981
1068
|
die(e);
|
|
982
1069
|
}
|
|
983
1070
|
});
|
|
984
|
-
cOpt(program.command('rm <name>').description('stop +
|
|
1071
|
+
cOpt(program.command('rm <name>').description('stop + remove a role (temporary evidence is archived)'))
|
|
985
1072
|
.action(async (name, opts) => {
|
|
986
1073
|
try {
|
|
987
1074
|
await rmRole(loadConfig(opts.configuration), name, deps());
|
|
@@ -992,7 +1079,7 @@ cOpt(program.command('rm <name>').description('stop + delete state dir (+ its fl
|
|
|
992
1079
|
});
|
|
993
1080
|
cOpt(program.command('spawn [name]').description('spawn a new agent (permanent by default)'))
|
|
994
1081
|
.option('--role <name>', 'role name (alternative to the positional name)')
|
|
995
|
-
.option('--temp', 'temporary:
|
|
1082
|
+
.option('--temp', 'temporary: independent transient supervisor, archived on retirement, gone on reboot')
|
|
996
1083
|
.option('--harness <id>', 'harness adapter (default: defaults.harness)')
|
|
997
1084
|
.option('--session <backend>', 'session backend: tmux|acp (default: defaults.session or tmux)')
|
|
998
1085
|
.option('--mission <text>', 'one-line mission')
|
|
@@ -1092,7 +1179,8 @@ cOpt(program.command('spawn [name]').description('spawn a new agent (permanent b
|
|
|
1092
1179
|
// The same provenance that was persisted, so what the operator reads now
|
|
1093
1180
|
// and what a reviewer reads later cannot disagree (6.6).
|
|
1094
1181
|
if (lastProvenance) {
|
|
1095
|
-
console.log(` created by ${lastProvenance.command}
|
|
1182
|
+
console.log(` created by ${lastProvenance.command} `
|
|
1183
|
+
+ `v${lastProvenance.fleetVersion}+${lastProvenance.fleetBuild} `
|
|
1096
1184
|
+ `at ${lastProvenance.createdAt} (${lastProvenance.lifetime})`);
|
|
1097
1185
|
for (const line of formatProvenance(lastProvenance))
|
|
1098
1186
|
console.log(line);
|
package/dist/config.d.ts
CHANGED
|
@@ -98,8 +98,15 @@ export type OwnerChannelConfigInput = Omit<Partial<OwnerChannelConfig>, 'attachm
|
|
|
98
98
|
export declare const DEFAULT_OWNER_ATTACHMENT_MIME: readonly ["application/pdf", "application/json", "text/plain", "image/png", "image/jpeg", "image/gif", "image/webp", "audio/ogg", "audio/mpeg", "audio/wav", "audio/x-wav", "audio/mp4", "audio/webm", "application/msword", "application/vnd.ms-excel", "application/vnd.ms-powerpoint", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.presentationml.presentation"];
|
|
99
99
|
/** Default wake sources when a role does not list its own (design §2). */
|
|
100
100
|
export declare const DEFAULT_WAKE_SOURCES: NotifyEventType[];
|
|
101
|
-
/**
|
|
102
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Validate a raw (role-level or merged) `monitor:` block; returns human-readable problems.
|
|
103
|
+
*
|
|
104
|
+
* `capabilities` defaults to what this build declares. A value is rejected in two
|
|
105
|
+
* distinct ways: unknown to every build (a typo), or known but absent from the
|
|
106
|
+
* artifact doing the validating — the second names the capability and the build,
|
|
107
|
+
* because the same fleet.yaml may well be accepted by another install on the host.
|
|
108
|
+
*/
|
|
109
|
+
export declare function validateMonitorConfig(raw: unknown, capabilities?: readonly string[]): string[];
|
|
103
110
|
export interface RoleConfig {
|
|
104
111
|
harness?: string;
|
|
105
112
|
session?: SessionBackendId;
|
package/dist/config.js
CHANGED
|
@@ -6,6 +6,8 @@ import { harnessRuntimeDir, resolveIsolation, validateIsolationConfig, } from '.
|
|
|
6
6
|
import { getAdapter } from './harness/registry.js';
|
|
7
7
|
import { resolveWatchdogs } from './watchdog/config.js';
|
|
8
8
|
import { resolveLoops } from './loops/config.js';
|
|
9
|
+
import { CAPABILITIES, CAP_MONITOR_INTERRUPT_AFTER_TOOL } from './capabilities.js';
|
|
10
|
+
import { runningLabel } from './provenance.js';
|
|
9
11
|
/** The 8 content-free event types the ours daemon appends to notifications.log. */
|
|
10
12
|
export const NOTIFY_EVENT_TYPES = [
|
|
11
13
|
'message_received', 'file_received', 'sibling_contact_added', 'local_contact_request',
|
|
@@ -30,8 +32,15 @@ const MONITOR_MODES = ['fleet', 'native'];
|
|
|
30
32
|
const MONITOR_DEFAULT_BATCH_MS = 2000;
|
|
31
33
|
const MONITOR_DEFAULT_TURN_FAIL_THRESHOLD = 3;
|
|
32
34
|
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Validate a raw (role-level or merged) `monitor:` block; returns human-readable problems.
|
|
37
|
+
*
|
|
38
|
+
* `capabilities` defaults to what this build declares. A value is rejected in two
|
|
39
|
+
* distinct ways: unknown to every build (a typo), or known but absent from the
|
|
40
|
+
* artifact doing the validating — the second names the capability and the build,
|
|
41
|
+
* because the same fleet.yaml may well be accepted by another install on the host.
|
|
42
|
+
*/
|
|
43
|
+
export function validateMonitorConfig(raw, capabilities = CAPABILITIES) {
|
|
35
44
|
const problems = [];
|
|
36
45
|
if (!isPlainObject(raw))
|
|
37
46
|
return ['monitor: must be a mapping'];
|
|
@@ -54,6 +63,11 @@ export function validateMonitorConfig(raw) {
|
|
|
54
63
|
if (m.interrupt !== undefined
|
|
55
64
|
&& typeof m.interrupt !== 'boolean' && m.interrupt !== 'after_tool')
|
|
56
65
|
problems.push("monitor.interrupt: must be true, false, or 'after_tool'");
|
|
66
|
+
else if (m.interrupt === 'after_tool' && !capabilities.includes(CAP_MONITOR_INTERRUPT_AFTER_TOOL))
|
|
67
|
+
problems.push(`monitor.interrupt: 'after_tool' needs capability ${CAP_MONITOR_INTERRUPT_AFTER_TOOL}, `
|
|
68
|
+
+ `which the build validating this config (${runningLabel()}) does not declare. `
|
|
69
|
+
+ 'Another install on this host may accept the same file — run `ours-fleet doctor` '
|
|
70
|
+
+ 'to see which artifact serves which path.');
|
|
57
71
|
if (m.turn_fail_threshold !== undefined
|
|
58
72
|
&& (typeof m.turn_fail_threshold !== 'number' || !Number.isInteger(m.turn_fail_threshold)
|
|
59
73
|
|| m.turn_fail_threshold < 1))
|
package/dist/creation.d.ts
CHANGED
|
@@ -151,6 +151,12 @@ export interface CreationProvenance {
|
|
|
151
151
|
/** The command that created the role, without its arguments. */
|
|
152
152
|
command: string;
|
|
153
153
|
fleetVersion: string;
|
|
154
|
+
/**
|
|
155
|
+
* Build id of the artifact that created the role. Two installs can report the
|
|
156
|
+
* same fleetVersion and resolve the same fleet.yaml differently, so the semver
|
|
157
|
+
* alone does not identify what actually ran. `unknown` for a pre-provenance build.
|
|
158
|
+
*/
|
|
159
|
+
fleetBuild: string;
|
|
154
160
|
createdAt: string;
|
|
155
161
|
lifetime: 'permanent' | 'temporary';
|
|
156
162
|
role: string;
|
|
@@ -187,6 +193,16 @@ export declare function buildProvenance(o: {
|
|
|
187
193
|
}): CreationProvenance;
|
|
188
194
|
/** Write the provenance record atomically, before the role is started. */
|
|
189
195
|
export declare function writeProvenance(stateDir: string, p: CreationProvenance): void;
|
|
196
|
+
/** Read the provenance a role was created with, if it has one. */
|
|
197
|
+
export declare function readProvenance(stateDir: string): CreationProvenance | undefined;
|
|
198
|
+
/**
|
|
199
|
+
* One line when the artifact reporting on a role is not the one that created it.
|
|
200
|
+
*
|
|
201
|
+
* A role carries its creating build in creation.json. If a different build now
|
|
202
|
+
* manages it, the role's fleet.yaml may mean something different than it did at
|
|
203
|
+
* creation — and because both can report the same semver, nothing else says so.
|
|
204
|
+
*/
|
|
205
|
+
export declare function creationBuildNote(p: CreationProvenance): string | undefined;
|
|
190
206
|
/** One concise line per non-built-in setting, for the post-creation summary. */
|
|
191
207
|
export declare function formatProvenance(p: CreationProvenance): string[];
|
|
192
208
|
/** Classify one setting: an explicit CLI value, a fleet default, or built-in. */
|
package/dist/creation.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { replaceFileAtomically, withFileLock } from './atomic-file.js';
|
|
4
4
|
import { stateRoot } from './paths.js';
|
|
5
5
|
import { resolveEndpoint } from './monitor.js';
|
|
6
|
+
import { buildInfo, UNKNOWN_BUILD } from './provenance.js';
|
|
6
7
|
/**
|
|
7
8
|
* One creation transaction: role name and ours identity reserved together,
|
|
8
9
|
* every artifact journalled, and everything undone in reverse on failure.
|
|
@@ -242,6 +243,7 @@ export function buildProvenance(o) {
|
|
|
242
243
|
version: 1,
|
|
243
244
|
command: 'ours-fleet spawn',
|
|
244
245
|
fleetVersion: o.fleetVersion,
|
|
246
|
+
fleetBuild: buildInfo().buildId,
|
|
245
247
|
createdAt: (o.now ?? new Date()).toISOString(),
|
|
246
248
|
lifetime: o.lifetime,
|
|
247
249
|
role: o.role,
|
|
@@ -255,6 +257,32 @@ export function buildProvenance(o) {
|
|
|
255
257
|
export function writeProvenance(stateDir, p) {
|
|
256
258
|
replaceFileAtomically(join(stateDir, CREATION_PROVENANCE_FILE), JSON.stringify(p, null, 2) + '\n', 0o600);
|
|
257
259
|
}
|
|
260
|
+
/** Read the provenance a role was created with, if it has one. */
|
|
261
|
+
export function readProvenance(stateDir) {
|
|
262
|
+
try {
|
|
263
|
+
return JSON.parse(readFileSync(join(stateDir, CREATION_PROVENANCE_FILE), 'utf8'));
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* One line when the artifact reporting on a role is not the one that created it.
|
|
271
|
+
*
|
|
272
|
+
* A role carries its creating build in creation.json. If a different build now
|
|
273
|
+
* manages it, the role's fleet.yaml may mean something different than it did at
|
|
274
|
+
* creation — and because both can report the same semver, nothing else says so.
|
|
275
|
+
*/
|
|
276
|
+
export function creationBuildNote(p) {
|
|
277
|
+
const now = buildInfo();
|
|
278
|
+
const created = p.fleetBuild ?? UNKNOWN_BUILD;
|
|
279
|
+
if (created === now.buildId && p.fleetVersion === now.version)
|
|
280
|
+
return undefined;
|
|
281
|
+
return `created by ours-fleet ${p.fleetVersion}+${created}, reported by ${now.version}+${now.buildId}`
|
|
282
|
+
+ (created === UNKNOWN_BUILD
|
|
283
|
+
? ' — the creating build predates build provenance and cannot be identified'
|
|
284
|
+
: ' — different artifact, so its resolved settings may differ');
|
|
285
|
+
}
|
|
258
286
|
/** One concise line per non-built-in setting, for the post-creation summary. */
|
|
259
287
|
export function formatProvenance(p) {
|
|
260
288
|
const mark = {
|