@ours.network/fleet 0.10.0 → 0.10.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 +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 +14 -3
- package/dist/monitor.js +20 -10
- 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 +8 -0
- package/dist/runner.js +117 -7
- 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,5 @@
|
|
|
1
|
+
import type { FleetConfig, ResolvedRole } from './config.js';
|
|
2
|
+
export declare const RESOLVED_PLAN_SCHEMA_VERSION = 1;
|
|
3
|
+
/** Stable, secret-safe machine contract consumed by config JSON and spawn dry-run. */
|
|
4
|
+
export declare function resolvedPlan(cfg: FleetConfig): Record<string, unknown>;
|
|
5
|
+
export declare function resolvedRolePlan(role: ResolvedRole): Record<string, unknown>;
|
|
@@ -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
|
@@ -19,6 +19,13 @@ export interface RunnerDeps {
|
|
|
19
19
|
/** Lets a test (or a shutdown path) end the supervised restart loop. */
|
|
20
20
|
shouldStop?(): boolean;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Record who owns wake delivery for this run. Returning true means a fleet
|
|
24
|
+
* monitor is taking ownership back from a native harness and must start at the
|
|
25
|
+
* current stream tip rather than replay notifications the native owner was
|
|
26
|
+
* responsible for.
|
|
27
|
+
*/
|
|
28
|
+
export declare function recordMonitorOwner(dir: string, owner: 'fleet' | 'native'): boolean;
|
|
22
29
|
/**
|
|
23
30
|
* Compose the tmux pane shell command: env prefix + argv + exit-status capture.
|
|
24
31
|
* `paneArgv` defaults to `launch.argv`; when isolation is active the caller passes
|
|
@@ -82,6 +89,7 @@ export interface AttemptResult {
|
|
|
82
89
|
/** Whether this attempt threw away resume state to start fresh. */
|
|
83
90
|
rotated: boolean;
|
|
84
91
|
mode: 'fresh' | 'resume';
|
|
92
|
+
modelRecovery?: 'advance' | 'hold';
|
|
85
93
|
}
|
|
86
94
|
/** One session lifecycle. `runSupervised` (or a one-shot caller) drives it. */
|
|
87
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,
|
|
@@ -32,6 +34,24 @@ const defaultDeps = () => ({
|
|
|
32
34
|
fetch: (url, init) => globalThis.fetch(url, init),
|
|
33
35
|
createMonitor: opts => createMonitor(opts),
|
|
34
36
|
});
|
|
37
|
+
const MONITOR_OWNER_FILE = '.monitor-owner';
|
|
38
|
+
/**
|
|
39
|
+
* Record who owns wake delivery for this run. Returning true means a fleet
|
|
40
|
+
* monitor is taking ownership back from a native harness and must start at the
|
|
41
|
+
* current stream tip rather than replay notifications the native owner was
|
|
42
|
+
* responsible for.
|
|
43
|
+
*/
|
|
44
|
+
export function recordMonitorOwner(dir, owner) {
|
|
45
|
+
let previous = null;
|
|
46
|
+
try {
|
|
47
|
+
const path = join(dir, MONITOR_OWNER_FILE);
|
|
48
|
+
if (existsSync(path))
|
|
49
|
+
previous = readFileSync(path, 'utf8').trim();
|
|
50
|
+
writeFileSync(path, `${owner}\n`);
|
|
51
|
+
}
|
|
52
|
+
catch { /* ownership diagnostics must never take the role down */ }
|
|
53
|
+
return owner === 'fleet' && previous === 'native';
|
|
54
|
+
}
|
|
35
55
|
/**
|
|
36
56
|
* Compose the tmux pane shell command: env prefix + argv + exit-status capture.
|
|
37
57
|
* `paneArgv` defaults to `launch.argv`; when isolation is active the caller passes
|
|
@@ -264,8 +284,20 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
264
284
|
role = findRole(cfg, name);
|
|
265
285
|
staggerMs = cfg.startStaggerMs;
|
|
266
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`);
|
|
267
294
|
const adapter = getAdapter(role.harness);
|
|
268
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`);
|
|
269
301
|
const sidFile = join(dir, '.session-id');
|
|
270
302
|
if (!existsSync(sidFile))
|
|
271
303
|
writeFileSync(sidFile, randomUUID() + '\n');
|
|
@@ -333,19 +365,42 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
333
365
|
// (backlog before the tip is the SessionStart hook's job). Native-mode roles
|
|
334
366
|
// leave wake ownership to the harness. Temp snapshots predating `monitor:` are
|
|
335
367
|
// treated as native (monitor may be undefined on an old role.yaml).
|
|
368
|
+
let sessionHandle;
|
|
369
|
+
let modelRecovery;
|
|
336
370
|
const resolvedMonitorDeps = monitorDeps(deps, role.env);
|
|
337
|
-
|
|
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
|
+
};
|
|
389
|
+
const monitorOwner = role.monitor?.mode === 'fleet' ? 'fleet' : 'native';
|
|
390
|
+
const resetMonitorCursor = recordMonitorOwner(dir, monitorOwner);
|
|
391
|
+
const monitor = monitorOwner === 'fleet' ? deps.createMonitor({
|
|
338
392
|
name, identity: role.identity, agentDir: dir, cfg: role.monitor,
|
|
339
393
|
deps: resolvedMonitorDeps,
|
|
340
394
|
}) : null;
|
|
341
395
|
if (monitor)
|
|
342
|
-
await monitor.prime();
|
|
396
|
+
await monitor.prime({ resetCursor: resetMonitorCursor });
|
|
343
397
|
rmSync(exitFile, { force: true });
|
|
344
398
|
let pid;
|
|
345
|
-
let sessionHandle;
|
|
346
399
|
let acpSession;
|
|
347
400
|
let control;
|
|
401
|
+
let unsubscribeRecovery;
|
|
348
402
|
let monitorLoop;
|
|
403
|
+
let acpStartupComplete = false;
|
|
349
404
|
if (sessionBackend === 'acp') {
|
|
350
405
|
const perms = role.permissions ?? resolvePermissions(undefined, undefined);
|
|
351
406
|
// Say once, at startup, that this role will decide permission requests by
|
|
@@ -366,6 +421,13 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
366
421
|
});
|
|
367
422
|
pid = acpSession.pid;
|
|
368
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
|
+
});
|
|
369
431
|
control = new RoleControlServer(dir, acpSession, deps.log);
|
|
370
432
|
await control.start();
|
|
371
433
|
resolvedMonitorDeps.delivery = {
|
|
@@ -373,7 +435,12 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
373
435
|
// refusal or a cancellation reached the agent and was not acted on, so
|
|
374
436
|
// the monitor must keep its cursor and try again.
|
|
375
437
|
submit: async (text, options) => {
|
|
376
|
-
|
|
438
|
+
// Cancelling the runner-owned startup prompt makes startup look failed
|
|
439
|
+
// and closes the session before the wake turn can run. During startup,
|
|
440
|
+
// steer into the live turn instead; after it completes, honor the
|
|
441
|
+
// configured interrupt policy normally.
|
|
442
|
+
const interrupt = options?.interrupt === true && acpStartupComplete;
|
|
443
|
+
const result = await acpSession.submitPrompt(text, { ...options, interrupt, steer: true });
|
|
377
444
|
const steered = result.accepted
|
|
378
445
|
&& (result.detail === 'injected' || result.detail === 'startedNewTurn');
|
|
379
446
|
return {
|
|
@@ -390,17 +457,35 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
390
457
|
// startup prompt and then refuses it has not started; logging the role as
|
|
391
458
|
// up would hide a role that never read its briefing.
|
|
392
459
|
const starting = acpSession.submitPrompt(firstPrompt);
|
|
393
|
-
//
|
|
394
|
-
//
|
|
460
|
+
// Monitoring starts immediately. The delivery adapter above downgrades
|
|
461
|
+
// interruption to steering until this startup turn reaches a terminal
|
|
462
|
+
// success, so there is neither a deaf gap nor a boot-cancellation loop.
|
|
395
463
|
monitorLoop = monitor?.run(pid);
|
|
396
464
|
const started = await starting;
|
|
397
465
|
if (!started.succeeded) {
|
|
398
466
|
monitor?.stop();
|
|
399
467
|
await control.close();
|
|
400
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
|
+
}
|
|
401
485
|
throw new Error(`[${name}] ACP startup prompt ${started.outcome}` +
|
|
402
486
|
`${started.detail ? `: ${started.detail}` : ''}`);
|
|
403
487
|
}
|
|
488
|
+
acpStartupComplete = true;
|
|
404
489
|
}
|
|
405
490
|
else {
|
|
406
491
|
await deps.tmux.kill(name);
|
|
@@ -427,6 +512,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
427
512
|
monitor.stop();
|
|
428
513
|
await monitorLoop;
|
|
429
514
|
}
|
|
515
|
+
unsubscribeRecovery?.();
|
|
430
516
|
if (control)
|
|
431
517
|
await control.close();
|
|
432
518
|
if (acpSession)
|
|
@@ -470,7 +556,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
470
556
|
}
|
|
471
557
|
else
|
|
472
558
|
deps.log(`[${name}] ${exitRecord.detail} (${elapsed.toFixed(0)}s) -> next start RESUMES context`);
|
|
473
|
-
return { elapsedSecs: elapsed, exit: exitRecord, rotated, mode };
|
|
559
|
+
return { elapsedSecs: elapsed, exit: exitRecord, rotated, mode, modelRecovery };
|
|
474
560
|
}
|
|
475
561
|
/**
|
|
476
562
|
* The persistent supervisor for one permanent role: run child sessions in a
|
|
@@ -490,6 +576,18 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
|
|
|
490
576
|
const stamp = () => new Date(deps.now()).toISOString();
|
|
491
577
|
while (!shouldStop()) {
|
|
492
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
|
+
}
|
|
493
591
|
if (ledger.circuit === 'open') {
|
|
494
592
|
// Held down. Stay alive — exiting would hand the role straight back to
|
|
495
593
|
// the service manager — and watch for an operator reset.
|
|
@@ -513,6 +611,18 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
|
|
|
513
611
|
// Re-read: the attempt itself may have taken minutes, and an operator may
|
|
514
612
|
// have reset the ledger meanwhile.
|
|
515
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
|
+
}
|
|
516
626
|
const fastFailSecs = fastFailSecsFor(name, opts.configPath);
|
|
517
627
|
const immediate = result.elapsedSecs < fastFailSecs;
|
|
518
628
|
if (!immediate) {
|
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
|
+
}
|