@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.
@@ -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) {
@@ -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
- else if (options.steer && this.steeringSupported) {
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.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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.10.1",
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
  ],