@ours.network/fleet 0.10.1 → 0.10.3
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 +49 -0
- package/contrib/anthropic-auth-proxy.mjs +85 -0
- package/dist/briefing.js +5 -0
- package/dist/cli.js +63 -4
- package/dist/config-yaml.d.ts +20 -0
- package/dist/config-yaml.js +76 -0
- package/dist/config.d.ts +25 -1
- package/dist/config.js +115 -9
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +34 -0
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +42 -4
- package/dist/harness/claude-code.js +11 -0
- package/dist/harness/codex.js +12 -0
- package/dist/harness/types.d.ts +2 -0
- package/dist/model-recovery.d.ts +42 -0
- package/dist/model-recovery.js +120 -0
- package/dist/monitor.d.ts +3 -0
- package/dist/monitor.js +13 -7
- package/dist/permissions.d.ts +3 -1
- package/dist/permissions.js +80 -6
- package/dist/resolved-plan.d.ts +5 -0
- package/dist/resolved-plan.js +66 -0
- package/dist/runner.d.ts +1 -0
- package/dist/runner.js +84 -2
- package/dist/session/acp.js +5 -1
- package/dist/spawn.d.ts +19 -1
- package/dist/spawn.js +91 -3
- package/dist/worklog.d.ts +25 -0
- package/dist/worklog.js +99 -0
- package/package.json +2 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { analyzeRolePermissions } from './permissions.js';
|
|
2
|
+
export const RESOLVED_PLAN_SCHEMA_VERSION = 1;
|
|
3
|
+
const sortedObject = (value) => Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
|
|
4
|
+
const redactSensitive = (value, key = '') => {
|
|
5
|
+
if (/(?:secret|token|password|authorization|api[_-]?key)/i.test(key))
|
|
6
|
+
return '<redacted>';
|
|
7
|
+
if (Array.isArray(value))
|
|
8
|
+
return value.map(item => redactSensitive(item));
|
|
9
|
+
if (value && typeof value === 'object')
|
|
10
|
+
return Object.fromEntries(Object.entries(value)
|
|
11
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
12
|
+
.map(([childKey, child]) => [childKey, redactSensitive(child, childKey)]));
|
|
13
|
+
return value;
|
|
14
|
+
};
|
|
15
|
+
/** Stable, secret-safe machine contract consumed by config JSON and spawn dry-run. */
|
|
16
|
+
export function resolvedPlan(cfg) {
|
|
17
|
+
return {
|
|
18
|
+
schemaVersion: RESOLVED_PLAN_SCHEMA_VERSION,
|
|
19
|
+
sourceFiles: [...cfg.files],
|
|
20
|
+
startStaggerMs: cfg.startStaggerMs,
|
|
21
|
+
diagnostics: cfg.diagnostics.map(diagnostic => ({ ...diagnostic })),
|
|
22
|
+
roles: cfg.roles.map(resolvedRolePlan),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function resolvedRolePlan(role) {
|
|
26
|
+
const analysis = analyzeRolePermissions(role);
|
|
27
|
+
return {
|
|
28
|
+
name: role.name,
|
|
29
|
+
sourceFile: role.sourceFile,
|
|
30
|
+
identity: role.identity,
|
|
31
|
+
harness: role.harness,
|
|
32
|
+
session: role.session,
|
|
33
|
+
sessionOptions: role.session_options ?? null,
|
|
34
|
+
model: role.model ?? null,
|
|
35
|
+
modelChain: role.model_chain ?? null,
|
|
36
|
+
cwd: role.cwd ?? null,
|
|
37
|
+
coordinator: role.coordinator ?? null,
|
|
38
|
+
maxTokens: role.max_tokens ?? null,
|
|
39
|
+
autocompactPct: role.autocompact_pct ?? null,
|
|
40
|
+
permissions: {
|
|
41
|
+
common: role.permissions,
|
|
42
|
+
effectiveNative: analysis.native ?? null,
|
|
43
|
+
supported: analysis.supported,
|
|
44
|
+
exact: analysis.exact ?? false,
|
|
45
|
+
capabilities: analysis.capabilities ?? [],
|
|
46
|
+
floor: analysis.floor ?? null,
|
|
47
|
+
severity: analysis.floorSeverity ?? null,
|
|
48
|
+
warnings: [
|
|
49
|
+
...analysis.warnings,
|
|
50
|
+
...(analysis.conflicts ?? []).map(conflict => conflict.warning),
|
|
51
|
+
...(analysis.floorWarning ? [analysis.floorWarning] : []),
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
monitor: role.monitor,
|
|
55
|
+
isolation: role.isolation ?? null,
|
|
56
|
+
worklog: role.worklog ?? null,
|
|
57
|
+
authProxy: role.auth_proxy ?? null,
|
|
58
|
+
oversee: role.oversee ?? [],
|
|
59
|
+
harnessOptions: redactSensitive(role.harness_options ?? null),
|
|
60
|
+
env: {
|
|
61
|
+
redacted: true,
|
|
62
|
+
keys: Object.keys(role.env ?? {}).sort(),
|
|
63
|
+
values: sortedObject(Object.fromEntries(Object.keys(role.env ?? {}).map(key => [key, '<redacted>']))),
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
package/dist/runner.d.ts
CHANGED
|
@@ -89,6 +89,7 @@ export interface AttemptResult {
|
|
|
89
89
|
/** Whether this attempt threw away resume state to start fresh. */
|
|
90
90
|
rotated: boolean;
|
|
91
91
|
mode: 'fresh' | 'resume';
|
|
92
|
+
modelRecovery?: 'advance' | 'hold';
|
|
92
93
|
}
|
|
93
94
|
/** One session lifecycle. `runSupervised` (or a one-shot caller) drives it. */
|
|
94
95
|
export declare function runOnce(name: string, opts?: {
|
package/dist/runner.js
CHANGED
|
@@ -15,6 +15,8 @@ import { AcpSession } from './session/acp.js';
|
|
|
15
15
|
import { RoleControlServer } from './session/control.js';
|
|
16
16
|
import { TmuxSession } from './session/tmux.js';
|
|
17
17
|
import { classifyShellStatus } from './session/types.js';
|
|
18
|
+
import { effectiveModelForRole, modelRecoveryHeld, reconcileModelRecovery, recordModelFailure, classifyFailureText, } from './model-recovery.js';
|
|
19
|
+
import { rotateWorklog } from './worklog.js';
|
|
18
20
|
const defaultDeps = () => ({
|
|
19
21
|
tmux: new Tmux(),
|
|
20
22
|
exec: realExec,
|
|
@@ -282,8 +284,20 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
282
284
|
role = findRole(cfg, name);
|
|
283
285
|
staggerMs = cfg.startStaggerMs;
|
|
284
286
|
}
|
|
287
|
+
const effectiveModel = effectiveModelForRole(dir, role);
|
|
288
|
+
if (effectiveModel !== role.model) {
|
|
289
|
+
deps.log(`[${name}] model recovery drift: declared=${role.model ?? '(none)'} effective=${effectiveModel}`);
|
|
290
|
+
role = { ...role, model: effectiveModel };
|
|
291
|
+
}
|
|
292
|
+
if (modelRecoveryHeld(dir))
|
|
293
|
+
throw new Error(`[${name}] model chain exhausted — held down until config changes or recovery reset`);
|
|
285
294
|
const adapter = getAdapter(role.harness);
|
|
286
295
|
mkdirSync(dir, { recursive: true });
|
|
296
|
+
const rotation = rotateWorklog(join(dir, 'WORKLOG.md'), role.worklog);
|
|
297
|
+
if (rotation.deferred)
|
|
298
|
+
deps.log(`[${name}] worklog rotation deferred: concurrent modification detected`);
|
|
299
|
+
else if (rotation.rotated)
|
|
300
|
+
deps.log(`[${name}] worklog rotated: ${rotation.beforeBytes} -> ${rotation.afterBytes} bytes`);
|
|
287
301
|
const sidFile = join(dir, '.session-id');
|
|
288
302
|
if (!existsSync(sidFile))
|
|
289
303
|
writeFileSync(sidFile, randomUUID() + '\n');
|
|
@@ -351,7 +365,27 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
351
365
|
// (backlog before the tip is the SessionStart hook's job). Native-mode roles
|
|
352
366
|
// leave wake ownership to the harness. Temp snapshots predating `monitor:` are
|
|
353
367
|
// treated as native (monitor may be undefined on an old role.yaml).
|
|
368
|
+
let sessionHandle;
|
|
369
|
+
let modelRecovery;
|
|
354
370
|
const resolvedMonitorDeps = monitorDeps(deps, role.env);
|
|
371
|
+
resolvedMonitorDeps.onFailureEvidence = evidence => {
|
|
372
|
+
if (!role.model_chain)
|
|
373
|
+
return false;
|
|
374
|
+
const action = recordModelFailure(dir, role, { ...evidence, model: evidence.model ?? role.model }, role.monitor.turn_fail_threshold ?? 3);
|
|
375
|
+
if (action.kind === 'advance') {
|
|
376
|
+
modelRecovery = 'advance';
|
|
377
|
+
deps.log(`[${name}] MODEL DOWN-SHIFT ${action.from} -> ${action.to}; restarting with resume`);
|
|
378
|
+
void sessionHandle?.close();
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
else if (action.kind === 'hold') {
|
|
382
|
+
modelRecovery = 'hold';
|
|
383
|
+
deps.log(`[${name}] MODEL CHAIN EXHAUSTED at ${action.model}; held down`);
|
|
384
|
+
void sessionHandle?.close();
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
return false;
|
|
388
|
+
};
|
|
355
389
|
const monitorOwner = role.monitor?.mode === 'fleet' ? 'fleet' : 'native';
|
|
356
390
|
const resetMonitorCursor = recordMonitorOwner(dir, monitorOwner);
|
|
357
391
|
const monitor = monitorOwner === 'fleet' ? deps.createMonitor({
|
|
@@ -362,9 +396,9 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
362
396
|
await monitor.prime({ resetCursor: resetMonitorCursor });
|
|
363
397
|
rmSync(exitFile, { force: true });
|
|
364
398
|
let pid;
|
|
365
|
-
let sessionHandle;
|
|
366
399
|
let acpSession;
|
|
367
400
|
let control;
|
|
401
|
+
let unsubscribeRecovery;
|
|
368
402
|
let monitorLoop;
|
|
369
403
|
let acpStartupComplete = false;
|
|
370
404
|
if (sessionBackend === 'acp') {
|
|
@@ -387,6 +421,13 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
387
421
|
});
|
|
388
422
|
pid = acpSession.pid;
|
|
389
423
|
sessionHandle = acpSession;
|
|
424
|
+
unsubscribeRecovery = acpSession.subscribe(event => {
|
|
425
|
+
if (event.kind !== 'error' || !event.text)
|
|
426
|
+
return;
|
|
427
|
+
const evidence = classifyFailureText(event.text, 'acp', new Date(deps.now()).toISOString());
|
|
428
|
+
if (evidence)
|
|
429
|
+
resolvedMonitorDeps.onFailureEvidence?.(evidence);
|
|
430
|
+
});
|
|
390
431
|
control = new RoleControlServer(dir, acpSession, deps.log);
|
|
391
432
|
await control.start();
|
|
392
433
|
resolvedMonitorDeps.delivery = {
|
|
@@ -425,6 +466,22 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
425
466
|
monitor?.stop();
|
|
426
467
|
await control.close();
|
|
427
468
|
await acpSession.close();
|
|
469
|
+
unsubscribeRecovery?.();
|
|
470
|
+
if (modelRecovery) {
|
|
471
|
+
if (monitorLoop)
|
|
472
|
+
await monitorLoop;
|
|
473
|
+
return {
|
|
474
|
+
elapsedSecs: 0,
|
|
475
|
+
exit: {
|
|
476
|
+
version: 1,
|
|
477
|
+
class: 'program-exit',
|
|
478
|
+
detail: `ACP startup triggered model recovery (${modelRecovery})`,
|
|
479
|
+
},
|
|
480
|
+
rotated: false,
|
|
481
|
+
mode,
|
|
482
|
+
modelRecovery,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
428
485
|
throw new Error(`[${name}] ACP startup prompt ${started.outcome}` +
|
|
429
486
|
`${started.detail ? `: ${started.detail}` : ''}`);
|
|
430
487
|
}
|
|
@@ -455,6 +512,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
455
512
|
monitor.stop();
|
|
456
513
|
await monitorLoop;
|
|
457
514
|
}
|
|
515
|
+
unsubscribeRecovery?.();
|
|
458
516
|
if (control)
|
|
459
517
|
await control.close();
|
|
460
518
|
if (acpSession)
|
|
@@ -498,7 +556,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
498
556
|
}
|
|
499
557
|
else
|
|
500
558
|
deps.log(`[${name}] ${exitRecord.detail} (${elapsed.toFixed(0)}s) -> next start RESUMES context`);
|
|
501
|
-
return { elapsedSecs: elapsed, exit: exitRecord, rotated, mode };
|
|
559
|
+
return { elapsedSecs: elapsed, exit: exitRecord, rotated, mode, modelRecovery };
|
|
502
560
|
}
|
|
503
561
|
/**
|
|
504
562
|
* The persistent supervisor for one permanent role: run child sessions in a
|
|
@@ -518,6 +576,18 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
|
|
|
518
576
|
const stamp = () => new Date(deps.now()).toISOString();
|
|
519
577
|
while (!shouldStop()) {
|
|
520
578
|
let ledger = readRestartLedger(dir);
|
|
579
|
+
try {
|
|
580
|
+
const configPath = resolveConfigPath(dir, opts.configPath);
|
|
581
|
+
const role = findRole(loadConfig(configPath), name);
|
|
582
|
+
reconcileModelRecovery(dir, role, stamp());
|
|
583
|
+
if (modelRecoveryHeld(dir)) {
|
|
584
|
+
await deps.sleep(HELD_DOWN_POLL_MS);
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
// Normal attempt path reports config errors through the restart circuit.
|
|
590
|
+
}
|
|
521
591
|
if (ledger.circuit === 'open') {
|
|
522
592
|
// Held down. Stay alive — exiting would hand the role straight back to
|
|
523
593
|
// the service manager — and watch for an operator reset.
|
|
@@ -541,6 +611,18 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
|
|
|
541
611
|
// Re-read: the attempt itself may have taken minutes, and an operator may
|
|
542
612
|
// have reset the ledger meanwhile.
|
|
543
613
|
ledger = readRestartLedger(dir);
|
|
614
|
+
if (result.modelRecovery === 'advance') {
|
|
615
|
+
writeRestartLedger(dir, {
|
|
616
|
+
...emptyLedger(),
|
|
617
|
+
lastReason: 'approved model-chain transition',
|
|
618
|
+
updatedAt: stamp(),
|
|
619
|
+
});
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
if (result.modelRecovery === 'hold') {
|
|
623
|
+
await deps.sleep(HELD_DOWN_POLL_MS);
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
544
626
|
const fastFailSecs = fastFailSecsFor(name, opts.configPath);
|
|
545
627
|
const immediate = result.elapsedSecs < fastFailSecs;
|
|
546
628
|
if (!immediate) {
|
package/dist/session/acp.js
CHANGED
|
@@ -118,7 +118,11 @@ export class AcpSession {
|
|
|
118
118
|
throw new SessionControlError('offline', this.lastError ?? 'ACP session is offline');
|
|
119
119
|
if (options.interrupt)
|
|
120
120
|
await this.interrupt();
|
|
121
|
-
|
|
121
|
+
// Interrupting delivery must still use steering when supported. With no
|
|
122
|
+
// live turn, the extension starts one and acknowledges `startedNewTurn`
|
|
123
|
+
// immediately; a normal session/prompt would keep the monitor blocked until
|
|
124
|
+
// the entire wake-triggered turn terminated.
|
|
125
|
+
if (options.steer && this.steeringSupported) {
|
|
122
126
|
const promptId = randomUUID();
|
|
123
127
|
return { promptId, queuedBehind: 0, completion: this.steerPrompt(text) };
|
|
124
128
|
}
|
package/dist/spawn.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { IsolationConfig } from './isolation/types.js';
|
|
2
|
-
import { type ApprovalMode, type FilesystemMode, type SessionBackendId, type UnattendedMode } from './config.js';
|
|
2
|
+
import { type ApprovalMode, type FilesystemMode, type ResolvedRole, type RoleConfig, type SessionBackendId, type UnattendedMode } from './config.js';
|
|
3
3
|
import { type OpsDeps } from './ops.js';
|
|
4
4
|
import { type CreationDeps, type CreationProvenance } from './creation.js';
|
|
5
5
|
/**
|
|
@@ -13,6 +13,7 @@ export interface SpawnOpts {
|
|
|
13
13
|
harness?: string;
|
|
14
14
|
session?: SessionBackendId;
|
|
15
15
|
mission?: string;
|
|
16
|
+
missionFile?: string;
|
|
16
17
|
identity?: string;
|
|
17
18
|
cwd?: string;
|
|
18
19
|
coordinator?: string;
|
|
@@ -38,6 +39,8 @@ export interface SpawnOpts {
|
|
|
38
39
|
isolationFile?: string;
|
|
39
40
|
overseeInterval?: string;
|
|
40
41
|
configPath?: string;
|
|
42
|
+
dryRun?: boolean;
|
|
43
|
+
json?: boolean;
|
|
41
44
|
}
|
|
42
45
|
/**
|
|
43
46
|
* Read and validate an `--isolation-file`. The file is the existing
|
|
@@ -49,8 +52,23 @@ export interface SpawnOpts {
|
|
|
49
52
|
* must fail before any artifact exists.
|
|
50
53
|
*/
|
|
51
54
|
export declare function readIsolationFile(path: string): IsolationConfig;
|
|
55
|
+
/** Read mission text without trimming or newline rewriting. */
|
|
56
|
+
export declare function readMissionFile(path: string): string;
|
|
52
57
|
/** The ours identity a spawn will bind: explicit, else the role name. */
|
|
53
58
|
export declare const effectiveIdentity: (o: SpawnOpts) => string;
|
|
59
|
+
export interface SpawnDryRun {
|
|
60
|
+
schemaVersion: 1;
|
|
61
|
+
warning: string;
|
|
62
|
+
roleDocument: {
|
|
63
|
+
roles: Record<string, RoleConfig>;
|
|
64
|
+
};
|
|
65
|
+
resolvedRole: ResolvedRole;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Validate and resolve a spawn without reserving names, contacting the daemon,
|
|
69
|
+
* or writing state. Collision checks are necessarily a point-in-time snapshot.
|
|
70
|
+
*/
|
|
71
|
+
export declare function spawnDryRun(o: SpawnOpts): SpawnDryRun;
|
|
54
72
|
/** Permanent spawn: persist to ~/fleet.d/<Name>.yaml, then bring it up. */
|
|
55
73
|
export declare function spawnPermanent(o: SpawnOpts, deps: OpsDeps, creation?: CreationDeps): Promise<string>;
|
|
56
74
|
/** Launches the detached temp supervisor (`_run-temp <name>`). Injectable for tests. */
|
package/dist/spawn.js
CHANGED
|
@@ -4,11 +4,12 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { parse, stringify } from 'yaml';
|
|
5
5
|
import { agentDir, fleetDDir } from './paths.js';
|
|
6
6
|
import { validateIsolationConfig } from './isolation/policy.js';
|
|
7
|
-
import { loadConfig, resolveMonitorConfig, resolvePermissions, } from './config.js';
|
|
7
|
+
import { loadConfig, resolveAuthProxy, resolveModelChain, resolveMonitorConfig, resolvePermissions, resolveWorklogPolicy, } from './config.js';
|
|
8
8
|
import { applyRole, up } from './ops.js';
|
|
9
9
|
import { START_STAGGER_FILE } from './runner.js';
|
|
10
10
|
import { buildProvenance, daemonIdentityProvisioner, ensureIdentity, provenanceOf, withCreationTransaction, writeProvenance, writeRoleFile, } from './creation.js';
|
|
11
11
|
import { VERSION } from './version.js';
|
|
12
|
+
import { getAdapter } from './harness/registry.js';
|
|
12
13
|
/**
|
|
13
14
|
* The provenance record written by the most recent spawn in this process, so
|
|
14
15
|
* the CLI can print the same summary it persisted rather than rebuilding it.
|
|
@@ -26,7 +27,9 @@ function roleFromOpts(o, defaultHarness) {
|
|
|
26
27
|
r.cwd = o.cwd;
|
|
27
28
|
if (o.coordinator)
|
|
28
29
|
r.coordinator = o.coordinator;
|
|
29
|
-
if (o.
|
|
30
|
+
if (o.missionFile)
|
|
31
|
+
r.mission = readMissionFile(o.missionFile);
|
|
32
|
+
else if (o.mission !== undefined)
|
|
30
33
|
r.mission = o.mission;
|
|
31
34
|
if (o.model?.trim())
|
|
32
35
|
r.model = o.model.trim();
|
|
@@ -91,6 +94,8 @@ export function readIsolationFile(path) {
|
|
|
91
94
|
return cfg;
|
|
92
95
|
}
|
|
93
96
|
function validateSpawnOpts(o) {
|
|
97
|
+
if (o.mission !== undefined && o.missionFile)
|
|
98
|
+
throw new Error('--mission and --mission-file are mutually exclusive');
|
|
94
99
|
if (o.session && !['tmux', 'acp'].includes(o.session))
|
|
95
100
|
throw new Error(`invalid --session '${o.session}'; allowed: tmux, acp`);
|
|
96
101
|
if (o.approval && !['ask', 'allow', 'deny'].includes(o.approval))
|
|
@@ -100,6 +105,15 @@ function validateSpawnOpts(o) {
|
|
|
100
105
|
if (o.unattended && !['deny', 'wait'].includes(o.unattended))
|
|
101
106
|
throw new Error(`invalid --unattended '${o.unattended}'; allowed: deny, wait`);
|
|
102
107
|
}
|
|
108
|
+
/** Read mission text without trimming or newline rewriting. */
|
|
109
|
+
export function readMissionFile(path) {
|
|
110
|
+
try {
|
|
111
|
+
return readFileSync(path, 'utf8');
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
throw new Error(`--mission-file ${path}: ${e.message}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
103
117
|
/**
|
|
104
118
|
* Reject names that are already USED. This is a precondition, not a claim: it
|
|
105
119
|
* runs INSIDE the creation transaction, after both names are reserved, so the
|
|
@@ -112,9 +126,69 @@ function assertNameFree(o) {
|
|
|
112
126
|
throw new Error(`role '${o.name}' already exists (${cfg.roles.find(r => r.name === o.name).sourceFile})`);
|
|
113
127
|
if (existsSync(agentDir(o.name)) || existsSync(agentDir(o.name, true)))
|
|
114
128
|
throw new Error(`agent dir for '${o.name}' already exists — pick another name or 'ours-fleet rm ${o.name}'`);
|
|
129
|
+
const identity = effectiveIdentity(o);
|
|
130
|
+
const owner = cfg.roles.find(role => role.identity === identity);
|
|
131
|
+
if (owner)
|
|
132
|
+
throw new Error(`identity '${identity}' is already used by role '${owner.name}' (${owner.sourceFile}); `
|
|
133
|
+
+ 'identity sharing is unsupported because binding is exclusive');
|
|
115
134
|
}
|
|
116
135
|
/** The ours identity a spawn will bind: explicit, else the role name. */
|
|
117
136
|
export const effectiveIdentity = (o) => o.identity ?? o.name;
|
|
137
|
+
/**
|
|
138
|
+
* Validate and resolve a spawn without reserving names, contacting the daemon,
|
|
139
|
+
* or writing state. Collision checks are necessarily a point-in-time snapshot.
|
|
140
|
+
*/
|
|
141
|
+
export function spawnDryRun(o) {
|
|
142
|
+
validateSpawnOpts(o);
|
|
143
|
+
if (o.isolationFile)
|
|
144
|
+
readIsolationFile(o.isolationFile);
|
|
145
|
+
if (o.missionFile)
|
|
146
|
+
readMissionFile(o.missionFile);
|
|
147
|
+
assertNameFree(o);
|
|
148
|
+
const cfg = loadConfig(o.configPath);
|
|
149
|
+
const raw = roleFromOpts(o, cfg.defaults.harness);
|
|
150
|
+
const harnessOptions = {
|
|
151
|
+
...(cfg.defaults.harness_options ?? {}),
|
|
152
|
+
...(raw.harness_options ?? {}),
|
|
153
|
+
};
|
|
154
|
+
const resolvedRole = {
|
|
155
|
+
...raw,
|
|
156
|
+
name: o.name,
|
|
157
|
+
sourceFile: o.temp ? '(temp dry-run)' : join(fleetDDir(), `${o.name}.yaml`),
|
|
158
|
+
harness: raw.harness ?? cfg.defaults.harness ?? 'claude-code',
|
|
159
|
+
session: raw.session ?? cfg.defaults.session ?? 'tmux',
|
|
160
|
+
session_options: raw.session_options,
|
|
161
|
+
permissions: resolvePermissions(cfg.defaults.permissions, raw.permissions),
|
|
162
|
+
permissionsDeclared: raw.permissions !== undefined || cfg.defaults.permissions !== undefined,
|
|
163
|
+
identity: effectiveIdentity(o),
|
|
164
|
+
model: raw.model ?? cfg.defaults.model,
|
|
165
|
+
model_chain: resolveModelChain(raw.model ?? cfg.defaults.model, raw.model_chain ?? cfg.defaults.model_chain),
|
|
166
|
+
harness_options: Object.keys(harnessOptions).length ? harnessOptions : undefined,
|
|
167
|
+
isolation: raw.isolation ?? cfg.defaults.isolation,
|
|
168
|
+
monitor: resolveMonitorConfig(cfg.defaults.monitor, raw.monitor),
|
|
169
|
+
worklog: resolveWorklogPolicy(cfg.defaults.worklog, raw.worklog),
|
|
170
|
+
auth_proxy: resolveAuthProxy(cfg.defaults.auth_proxy, raw.auth_proxy),
|
|
171
|
+
};
|
|
172
|
+
resolvedRole.env = {
|
|
173
|
+
...(cfg.defaults.env ?? {}),
|
|
174
|
+
...(raw.env ?? {}),
|
|
175
|
+
...(resolvedRole.auth_proxy
|
|
176
|
+
? { ANTHROPIC_BASE_URL: resolvedRole.auth_proxy.base_url }
|
|
177
|
+
: {}),
|
|
178
|
+
};
|
|
179
|
+
const adapter = getAdapter(resolvedRole.harness);
|
|
180
|
+
if (resolvedRole.auth_proxy && resolvedRole.harness !== 'claude-code')
|
|
181
|
+
throw new Error('auth_proxy is supported only by claude-code');
|
|
182
|
+
const optionProblems = adapter.validateOptions(resolvedRole.harness_options);
|
|
183
|
+
if (optionProblems.length)
|
|
184
|
+
throw new Error(optionProblems.map(problem => `${problem.path}: ${problem.message}`).join('; '));
|
|
185
|
+
return {
|
|
186
|
+
schemaVersion: 1,
|
|
187
|
+
warning: 'collision checks are a snapshot; a real spawn reserves names atomically',
|
|
188
|
+
roleDocument: { roles: { [o.name]: raw } },
|
|
189
|
+
resolvedRole,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
118
192
|
/**
|
|
119
193
|
* Which settings came from the operator, from fleet defaults, or from a
|
|
120
194
|
* built-in (6.6). Built while the options are still separable — once they are
|
|
@@ -147,6 +221,8 @@ export async function spawnPermanent(o, deps, creation = {}) {
|
|
|
147
221
|
validateSpawnOpts(o);
|
|
148
222
|
if (o.isolationFile)
|
|
149
223
|
readIsolationFile(o.isolationFile); // fail before reserving
|
|
224
|
+
if (o.missionFile)
|
|
225
|
+
readMissionFile(o.missionFile); // fail before reserving
|
|
150
226
|
// Name AND identity reserved together, before anything is written or started
|
|
151
227
|
// (6.4). A loser of the race creates no config, no state, no service.
|
|
152
228
|
return withCreationTransaction({ role: o.name, identity: effectiveIdentity(o) }, async (tx) => {
|
|
@@ -218,16 +294,18 @@ export async function spawnTemp(o, binPath, launch = detachedSupervisor, creatio
|
|
|
218
294
|
validateSpawnOpts(o);
|
|
219
295
|
if (o.isolationFile)
|
|
220
296
|
readIsolationFile(o.isolationFile); // fail before reserving
|
|
297
|
+
if (o.missionFile)
|
|
298
|
+
readMissionFile(o.missionFile); // fail before reserving
|
|
221
299
|
// Temporary roles go through the SAME reservation boundary as permanent ones
|
|
222
300
|
// (6.4): a temp agent competes for the same names.
|
|
223
301
|
return withCreationTransaction({ role: o.name, identity: effectiveIdentity(o) }, async (tx) => {
|
|
302
|
+
assertNameFree(o);
|
|
224
303
|
const guarantee = await ensureIdentity(effectiveIdentity(o), { bio: o.bioFile ? readFileSync(o.bioFile, 'utf8').trim() : undefined,
|
|
225
304
|
persona: o.personaFile ? readFileSync(o.personaFile, 'utf8').trim() : undefined }, creation.identityProvisioner ?? daemonIdentityProvisioner(), creation.log);
|
|
226
305
|
return spawnTempInner(o, binPath, launch, tx, guarantee);
|
|
227
306
|
}, creation);
|
|
228
307
|
}
|
|
229
308
|
async function spawnTempInner(o, binPath, launch, tx, guarantee) {
|
|
230
|
-
assertNameFree(o);
|
|
231
309
|
const cfg = loadConfig(o.configPath);
|
|
232
310
|
const defaultHarness = cfg.defaults.harness;
|
|
233
311
|
const fromOpts = roleFromOpts(o, defaultHarness);
|
|
@@ -242,13 +320,23 @@ async function spawnTempInner(o, binPath, launch, tx, guarantee) {
|
|
|
242
320
|
session: o.session ?? cfg.defaults.session ?? 'tmux',
|
|
243
321
|
identity: o.identity ?? o.name,
|
|
244
322
|
model: o.model?.trim() || cfg.defaults.model,
|
|
323
|
+
model_chain: resolveModelChain(o.model?.trim() || cfg.defaults.model, fromOpts.model_chain ?? cfg.defaults.model_chain),
|
|
245
324
|
harness_options: Object.keys(mergedHarnessOptions).length ? mergedHarnessOptions : undefined,
|
|
246
325
|
permissions: resolvePermissions(cfg.defaults.permissions, fromOpts.permissions),
|
|
247
326
|
permissionsDeclared: fromOpts.permissions !== undefined || cfg.defaults.permissions !== undefined,
|
|
248
327
|
// Temp agents inherit the fleet-wide monitor defaults via the snapshot (design §2).
|
|
249
328
|
monitor: resolveMonitorConfig(cfg.defaults.monitor, fromOpts.monitor),
|
|
329
|
+
worklog: resolveWorklogPolicy(cfg.defaults.worklog, fromOpts.worklog),
|
|
330
|
+
auth_proxy: resolveAuthProxy(cfg.defaults.auth_proxy, fromOpts.auth_proxy),
|
|
250
331
|
sourceFile: '(temp)',
|
|
251
332
|
};
|
|
333
|
+
role.env = {
|
|
334
|
+
...(cfg.defaults.env ?? {}),
|
|
335
|
+
...(fromOpts.env ?? {}),
|
|
336
|
+
...(role.auth_proxy ? { ANTHROPIC_BASE_URL: role.auth_proxy.base_url } : {}),
|
|
337
|
+
};
|
|
338
|
+
if (role.auth_proxy && role.harness !== 'claude-code')
|
|
339
|
+
throw new Error('auth_proxy is supported only by claude-code');
|
|
252
340
|
const dir = applyRole(role, { temp: true, identityGuarantee: guarantee.state });
|
|
253
341
|
const provenance = buildProvenance({
|
|
254
342
|
role: o.name, lifetime: 'temporary', fleetVersion: VERSION,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { WorklogPolicy } from './config.js';
|
|
2
|
+
export interface WorklogInspection {
|
|
3
|
+
enabled: boolean;
|
|
4
|
+
bytes: number;
|
|
5
|
+
overLimit: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface WorklogRotation {
|
|
8
|
+
rotated: boolean;
|
|
9
|
+
deferred?: boolean;
|
|
10
|
+
beforeBytes: number;
|
|
11
|
+
afterBytes: number;
|
|
12
|
+
archivePath?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function inspectWorklog(path: string, policy?: WorklogPolicy): WorklogInspection;
|
|
15
|
+
export declare function pruneWorklogArchives(path: string, maxArchives: number): void;
|
|
16
|
+
/**
|
|
17
|
+
* Conservatively rotate a stable snapshot. A changed size/mtime/inode aborts
|
|
18
|
+
* before replacement; callers retry at the next fleet-owned lifecycle point.
|
|
19
|
+
*/
|
|
20
|
+
export declare function rotateWorklog(path: string, policy?: WorklogPolicy, deps?: {
|
|
21
|
+
now?: () => Date;
|
|
22
|
+
beforeCommit?: () => void;
|
|
23
|
+
/** Deterministic test hook for the rename→link commit window. */
|
|
24
|
+
afterArchiveRename?: () => void;
|
|
25
|
+
}): WorklogRotation;
|
package/dist/worklog.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync, linkSync, readdirSync, readFileSync, renameSync, rmSync, statSync, } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { replaceFileAtomically } from './atomic-file.js';
|
|
5
|
+
const ARCHIVE_RE = /^WORKLOG\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z(?:\.\d+)?\.md$/;
|
|
6
|
+
export function inspectWorklog(path, policy) {
|
|
7
|
+
const bytes = existsSync(path) ? statSync(path).size : 0;
|
|
8
|
+
return {
|
|
9
|
+
enabled: policy !== undefined,
|
|
10
|
+
bytes,
|
|
11
|
+
overLimit: policy !== undefined && bytes > policy.max_kb * 1024,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
const safeTailStart = (buffer, wanted) => {
|
|
15
|
+
let start = Math.max(0, buffer.length - wanted);
|
|
16
|
+
while (start < buffer.length && (buffer[start] & 0xc0) === 0x80)
|
|
17
|
+
start++;
|
|
18
|
+
const newline = buffer.indexOf(0x0a, start);
|
|
19
|
+
return newline >= 0 && newline + 1 < buffer.length ? newline + 1 : start;
|
|
20
|
+
};
|
|
21
|
+
const archiveName = (path, now, collision) => {
|
|
22
|
+
const stamp = now.toISOString().replace(/:/g, '-');
|
|
23
|
+
return join(dirname(path), `WORKLOG.${stamp}${collision ? `.${collision}` : ''}.md`);
|
|
24
|
+
};
|
|
25
|
+
export function pruneWorklogArchives(path, maxArchives) {
|
|
26
|
+
const dir = dirname(path);
|
|
27
|
+
const archives = readdirSync(dir).filter(name => ARCHIVE_RE.test(name)).sort().reverse();
|
|
28
|
+
for (const old of archives.slice(maxArchives))
|
|
29
|
+
rmSync(join(dir, old), { force: true });
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Conservatively rotate a stable snapshot. A changed size/mtime/inode aborts
|
|
33
|
+
* before replacement; callers retry at the next fleet-owned lifecycle point.
|
|
34
|
+
*/
|
|
35
|
+
export function rotateWorklog(path, policy, deps = {}) {
|
|
36
|
+
const inspection = inspectWorklog(path, policy);
|
|
37
|
+
if (!policy || !inspection.overLimit)
|
|
38
|
+
return { rotated: false, beforeBytes: inspection.bytes, afterBytes: inspection.bytes };
|
|
39
|
+
const before = statSync(path);
|
|
40
|
+
const content = readFileSync(path);
|
|
41
|
+
const start = safeTailStart(content, policy.keep_tail_kb * 1024);
|
|
42
|
+
const tail = content.subarray(start);
|
|
43
|
+
deps.beforeCommit?.();
|
|
44
|
+
const current = statSync(path);
|
|
45
|
+
if (current.ino !== before.ino || current.size !== before.size
|
|
46
|
+
|| current.mtimeMs !== before.mtimeMs) {
|
|
47
|
+
return {
|
|
48
|
+
rotated: false, deferred: true,
|
|
49
|
+
beforeBytes: before.size, afterBytes: current.size,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
let collision = 0;
|
|
53
|
+
let archivePath = archiveName(path, deps.now?.() ?? new Date(), collision);
|
|
54
|
+
while (existsSync(archivePath))
|
|
55
|
+
archivePath = archiveName(path, deps.now?.() ?? new Date(), ++collision);
|
|
56
|
+
// Prepare the intended tail before moving the live inode. replaceFileAtomically
|
|
57
|
+
// gives us a fully written/fsynced inode; hard-linking it below publishes that
|
|
58
|
+
// inode without ever overwriting a path a concurrent appender may create.
|
|
59
|
+
const preparedTail = join(dirname(path), `.${basename(path)}.${randomUUID()}.rotate`);
|
|
60
|
+
replaceFileAtomically(preparedTail, tail.toString('utf8'), before.mode & 0o777);
|
|
61
|
+
try {
|
|
62
|
+
// Linearization point: the complete original inode becomes the archive.
|
|
63
|
+
// Writers that opened before this rename keep appending to that inode, so
|
|
64
|
+
// their acknowledged bytes remain in the archive even after the move.
|
|
65
|
+
renameSync(path, archivePath);
|
|
66
|
+
deps.afterArchiveRename?.();
|
|
67
|
+
try {
|
|
68
|
+
// Atomic create-without-overwrite. If a writer opened the missing path in
|
|
69
|
+
// the rename→link window, it created the new live file; EEXIST means leave
|
|
70
|
+
// that file untouched. The intended tail remains recoverable in the full
|
|
71
|
+
// archive, and every concurrent suffix remains in the writer-created live.
|
|
72
|
+
linkSync(preparedTail, path);
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
if (e.code !== 'EEXIST')
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
rmSync(preparedTail, { force: true });
|
|
81
|
+
}
|
|
82
|
+
const liveBytes = existsSync(path) ? statSync(path).size : 0;
|
|
83
|
+
const status = {
|
|
84
|
+
schemaVersion: 1,
|
|
85
|
+
rotatedAt: (deps.now?.() ?? new Date()).toISOString(),
|
|
86
|
+
beforeBytes: before.size,
|
|
87
|
+
afterBytes: liveBytes,
|
|
88
|
+
archive: basename(archivePath),
|
|
89
|
+
archiveContainsFullSnapshot: true,
|
|
90
|
+
};
|
|
91
|
+
replaceFileAtomically(join(dirname(path), '.worklog-rotation.json'), `${JSON.stringify(status, null, 2)}\n`);
|
|
92
|
+
pruneWorklogArchives(path, policy.max_archives);
|
|
93
|
+
return {
|
|
94
|
+
rotated: true,
|
|
95
|
+
beforeBytes: before.size,
|
|
96
|
+
afterBytes: liveBytes,
|
|
97
|
+
archivePath,
|
|
98
|
+
};
|
|
99
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.3",
|
|
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",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"main": "dist/index.js",
|
|
15
15
|
"files": [
|
|
16
16
|
"dist",
|
|
17
|
+
"contrib",
|
|
17
18
|
"README.md",
|
|
18
19
|
"LICENSE"
|
|
19
20
|
],
|