@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.
@@ -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
- const monitor = role.monitor?.mode === 'fleet' ? deps.createMonitor({
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
- const result = await acpSession.submitPrompt(text, { ...options, steer: true });
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
- // Start monitoring as soon as the initial prompt has been submitted. ACP
394
- // steering can deliver a wake into that turn without a boot-time deaf gap.
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.mission)
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;
@@ -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
+ }