@ours.network/fleet 0.8.0 → 0.9.0

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 CHANGED
@@ -180,6 +180,7 @@ the default `~/fleet.yaml` on its very first crash-restart and fail to resolve.
180
180
 
181
181
  ```yaml
182
182
  vars: { work_root: /home/me/work } # ${var} substitution anywhere below
183
+ start_stagger_ms: 0 # delay between agent LAUNCHES (host-wide, ms); 0 = no stagger
183
184
  defaults:
184
185
  harness: claude-code # for roles that don't set one
185
186
  model: claude-fable-5 # default model for roles that don't set one (per-role model / --model wins)
@@ -240,6 +241,24 @@ role's `harness_options`, so a fleet can set common Codex permission/profile def
240
241
  and override individual keys per role. `monitor` merges the same way — a role block
241
242
  overrides `defaults.monitor` key-by-key.
242
243
 
244
+ ### Start staggering
245
+
246
+ `start_stagger_ms` (top-level, host-wide, default `0`) spaces out agent **launches**
247
+ so a burst of boots doesn't hit the harness/API rate limit (429) all at once. When
248
+ set, each launch is held until at least `start_stagger_ms` after the previous one
249
+ across the whole host. It is enforced at the harness-launch point inside the runner,
250
+ so — unlike a delay in the `up`/`restart` command loop — it also covers **systemd
251
+ host boot**, where every agent's unit starts concurrently. The gate is time-based:
252
+ a lone start or a solo crash-restart waits **zero**; only genuinely concurrent
253
+ launches are spread out. Example: `start_stagger_ms: 4000` on a 7-agent fleet spaces
254
+ their boots ~4 s apart instead of firing all seven at once.
255
+
256
+ > **Migration (v0.9+):** this replaces the old `FLEET_START_STAGGER` environment
257
+ > variable, which only staggered the `ours-fleet up`/`restart` command loop (not
258
+ > host boot) and defaulted to 5 s. `FLEET_START_STAGGER` is **retired** — set
259
+ > `start_stagger_ms` in `fleet.yaml` instead (note: milliseconds, and the default
260
+ > is now `0`, so add it explicitly if you relied on the old implicit 5 s spacing).
261
+
243
262
  ### Mail monitor
244
263
 
245
264
  With `monitor.enabled` (the default), the **supervisor** delivers a role's mail
package/dist/cli.js CHANGED
@@ -26,7 +26,6 @@ catch {
26
26
  const deps = () => ({
27
27
  backend: pickBackend(),
28
28
  binPath,
29
- sleep: ms => new Promise(r => setTimeout(r, ms)),
30
29
  log: l => console.log(l),
31
30
  });
32
31
  const die = (e) => { console.error(String(e instanceof Error ? e.message : e)); process.exit(1); };
package/dist/config.d.ts CHANGED
@@ -48,6 +48,8 @@ export interface FleetConfig {
48
48
  vars: Record<string, string>;
49
49
  defaults: Record<string, unknown>;
50
50
  files: string[];
51
+ /** Fleet-wide delay (ms) enforced between agent launches to avoid boot bursts (0 = none). */
52
+ startStaggerMs: number;
51
53
  }
52
54
  export declare class ConfigError extends Error {
53
55
  }
package/dist/config.js CHANGED
@@ -86,6 +86,7 @@ export function loadConfig(configPath) {
86
86
  const baseDoc = docs.length && docs[0].file === base ? docs[0].doc : {};
87
87
  const vars = (baseDoc.vars ?? {});
88
88
  const defaults = (baseDoc.defaults ?? {});
89
+ const startStaggerMs = resolveStartStaggerMs(baseDoc.start_stagger_ms, base);
89
90
  const seen = new Map();
90
91
  const roles = [];
91
92
  for (const { file, doc } of docs) {
@@ -132,7 +133,15 @@ export function loadConfig(configPath) {
132
133
  });
133
134
  }
134
135
  }
135
- return { roles, vars, defaults, files };
136
+ return { roles, vars, defaults, files, startStaggerMs };
137
+ }
138
+ /** Validate the top-level `start_stagger_ms` (supervisor launch spacing); default 0. */
139
+ function resolveStartStaggerMs(raw, base) {
140
+ if (raw === undefined || raw === null)
141
+ return 0;
142
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0)
143
+ throw new ConfigError(`${base}: start_stagger_ms must be a non-negative number of milliseconds (got ${JSON.stringify(raw)})`);
144
+ return raw;
136
145
  }
137
146
  /**
138
147
  * Merge `defaults.monitor` under the role's own `monitor:` key-by-key, validate the
package/dist/ops.d.ts CHANGED
@@ -3,7 +3,6 @@ import type { SupervisorBackend } from './supervisor/types.js';
3
3
  export interface OpsDeps {
4
4
  backend: SupervisorBackend;
5
5
  binPath: string;
6
- sleep(ms: number): Promise<void>;
7
6
  log(line: string): void;
8
7
  }
9
8
  /** Materialize a role's state dir from config: briefing + markers. Returns the dir. */
package/dist/ops.js CHANGED
@@ -5,7 +5,11 @@ import { agentDir, fleetDDir } from './paths.js';
5
5
  import { findRole } from './config.js';
6
6
  import { getAdapter } from './harness/registry.js';
7
7
  import { generateBriefing } from './briefing.js';
8
- const STAGGER_MS = () => 1000 * Number(process.env.FLEET_START_STAGGER ?? 5);
8
+ // Launch staggering now lives at the harness-launch point (the runner's start
9
+ // gate, driven by `start_stagger_ms`), so it covers systemd host-boot too — not
10
+ // just the `up`/`restart` command loop below. The old in-loop FLEET_START_STAGGER
11
+ // sleep is retired; `up`/`restart` fire installs promptly and the gate spaces the
12
+ // resulting launches.
9
13
  /** Materialize a role's state dir from config: briefing + markers. Returns the dir. */
10
14
  export function applyRole(role, opts = {}) {
11
15
  const adapter = getAdapter(role.harness);
@@ -43,11 +47,7 @@ function selectRoles(cfg, names) {
43
47
  }
44
48
  /** Create/start roles declaratively. Idempotent; active roles keep their context. */
45
49
  export async function up(cfg, names, deps, configPath) {
46
- let first = true;
47
50
  for (const role of selectRoles(cfg, names)) {
48
- if (!first)
49
- await deps.sleep(STAGGER_MS());
50
- first = false;
51
51
  const dir = applyRole(role, { configPath });
52
52
  // If the role isn't running, boot fresh so it reads the briefing we just wrote.
53
53
  const status = await deps.backend.status(role.name).catch(() => '');
@@ -70,11 +70,7 @@ export async function down(cfg, names, deps) {
70
70
  }
71
71
  /** Re-sync from config + bounce. mode 'keep' resumes context; 'fresh' wipes it. */
72
72
  export async function restartRoles(cfg, names, deps, mode, configPath) {
73
- let first = true;
74
73
  for (const role of selectRoles(cfg, names)) {
75
- if (!first)
76
- await deps.sleep(STAGGER_MS());
77
- first = false;
78
74
  applyRole(role, { fresh: mode === 'fresh', configPath });
79
75
  await deps.backend.restart(role.name);
80
76
  deps.log(mode === 'fresh'
package/dist/runner.d.ts CHANGED
@@ -24,6 +24,17 @@ export interface RunnerDeps {
24
24
  * still sees the real exit code.
25
25
  */
26
26
  export declare function buildPaneCommand(launch: Launch, roleEnv: Record<string, string> | undefined, exitStatusPath: string, paneArgv?: string[]): string;
27
+ /** Filename spawnTemp writes into a temp agent dir to carry the fleet start-stagger. */
28
+ export declare const START_STAGGER_FILE = ".start-stagger-ms";
29
+ /**
30
+ * Reserve this process's launch slot on the host-wide start gate and return the
31
+ * wall-clock time it may launch at. A tiny atomic mutex (mkdir is atomic across
32
+ * processes) guards a single `.last-launch` timestamp: each launcher takes the
33
+ * next slot = max(now, last + staggerMs), so concurrent boots serialize and spread
34
+ * out by staggerMs while a lone/idle start returns `now` (zero wait). A crashed
35
+ * launcher's stale lock is broken so the gate can never deadlock the fleet.
36
+ */
37
+ export declare function reserveLaunchSlot(root: string, staggerMs: number, deps: Pick<RunnerDeps, 'now' | 'sleep' | 'log'>): Promise<number>;
27
38
  /** Read a temp role's config snapshot written by spawnTemp. */
28
39
  export declare function loadTempRole(name: string): ResolvedRole;
29
40
  /** One supervised session lifecycle. The supervisor re-invokes us after we return. */
package/dist/runner.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync } from 'node
2
2
  import { join } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { parse } from 'yaml';
5
- import { agentDir, home } from './paths.js';
5
+ import { agentDir, home, stateRoot } from './paths.js';
6
6
  import { loadConfig, findRole } from './config.js';
7
7
  import { getAdapter } from './harness/registry.js';
8
8
  import { Tmux } from './tmux.js';
@@ -54,6 +54,72 @@ function monitorDeps(deps) {
54
54
  timers: { set: (fn, ms) => setTimeout(fn, ms), clear: t => clearTimeout(t) },
55
55
  };
56
56
  }
57
+ /** Filename spawnTemp writes into a temp agent dir to carry the fleet start-stagger. */
58
+ export const START_STAGGER_FILE = '.start-stagger-ms';
59
+ /** Read the start-stagger a temp agent was spawned with (0 if none / unreadable). */
60
+ function readStartStagger(dir) {
61
+ try {
62
+ const n = parseInt(readFileSync(join(dir, START_STAGGER_FILE), 'utf8').trim(), 10);
63
+ return Number.isFinite(n) && n > 0 ? n : 0;
64
+ }
65
+ catch {
66
+ return 0;
67
+ }
68
+ }
69
+ /**
70
+ * Reserve this process's launch slot on the host-wide start gate and return the
71
+ * wall-clock time it may launch at. A tiny atomic mutex (mkdir is atomic across
72
+ * processes) guards a single `.last-launch` timestamp: each launcher takes the
73
+ * next slot = max(now, last + staggerMs), so concurrent boots serialize and spread
74
+ * out by staggerMs while a lone/idle start returns `now` (zero wait). A crashed
75
+ * launcher's stale lock is broken so the gate can never deadlock the fleet.
76
+ */
77
+ export async function reserveLaunchSlot(root, staggerMs, deps) {
78
+ mkdirSync(root, { recursive: true });
79
+ const lockDir = join(root, '.launch-gate.lock');
80
+ const lockTsFile = join(lockDir, 'ts');
81
+ const tsFile = join(root, '.last-launch');
82
+ const staleMs = Math.max(staggerMs * 4, 10_000);
83
+ const POLL_MS = 50;
84
+ const readTs = (p) => {
85
+ try {
86
+ const n = parseInt(readFileSync(p, 'utf8').trim(), 10);
87
+ return Number.isFinite(n) ? n : null;
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ };
93
+ for (let waited = 0;;) {
94
+ try {
95
+ mkdirSync(lockDir);
96
+ writeFileSync(lockTsFile, String(deps.now()));
97
+ break;
98
+ }
99
+ catch (e) {
100
+ if (e.code !== 'EEXIST')
101
+ throw e;
102
+ const lockTs = readTs(lockTsFile);
103
+ const stale = lockTs !== null && deps.now() - lockTs > staleMs;
104
+ if (stale || waited > staleMs * 2) { // break a crashed launcher's lock; never deadlock
105
+ rmSync(lockDir, { recursive: true, force: true });
106
+ continue;
107
+ }
108
+ await deps.sleep(POLL_MS);
109
+ waited += POLL_MS;
110
+ }
111
+ }
112
+ try {
113
+ const now = deps.now();
114
+ const last = readTs(tsFile);
115
+ const target = last === null ? now : Math.max(now, last + staggerMs);
116
+ writeFileSync(tsFile, String(target));
117
+ return target;
118
+ }
119
+ finally {
120
+ rmSync(lockDir, { recursive: true, force: true });
121
+ }
122
+ }
57
123
  /** Read a temp role's config snapshot written by spawnTemp. */
58
124
  export function loadTempRole(name) {
59
125
  const p = join(agentDir(name, true), 'role.yaml');
@@ -86,7 +152,20 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
86
152
  const temp = opts.temp === true;
87
153
  const dir = agentDir(name, temp);
88
154
  const configPath = temp ? opts.configPath : resolveConfigPath(dir, opts.configPath);
89
- const role = temp ? loadTempRole(name) : findRole(loadConfig(configPath), name);
155
+ // Resolve the role and the fleet-wide start-stagger. Permanent roles read the
156
+ // live config; temp/detached agents read the value spawnTemp snapshotted into
157
+ // their dir (they have no config path threaded through the detached supervisor).
158
+ let role;
159
+ let staggerMs;
160
+ if (temp) {
161
+ role = loadTempRole(name);
162
+ staggerMs = readStartStagger(dir);
163
+ }
164
+ else {
165
+ const cfg = loadConfig(configPath);
166
+ role = findRole(cfg, name);
167
+ staggerMs = cfg.startStaggerMs;
168
+ }
90
169
  const adapter = getAdapter(role.harness);
91
170
  mkdirSync(dir, { recursive: true });
92
171
  const sidFile = join(dir, '.session-id');
@@ -132,6 +211,20 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
132
211
  if (rprefix.length)
133
212
  paneArgv = [...rprefix, ...paneArgv];
134
213
  }
214
+ // Start-stagger: space this launch at least start_stagger_ms after the previous
215
+ // agent launch across the whole host, so a burst of boots (systemd starts every
216
+ // user unit concurrently on boot; `ours-fleet up`/restart-all bulk-start) does not
217
+ // hit the harness/API rate limit at once. Time-based via a shared launch gate, so
218
+ // a lone start or a solo crash-restart waits zero. Applied right before the harness
219
+ // launch (tmux.newSession); the cheap monitor prime still runs immediately after.
220
+ if (staggerMs > 0) {
221
+ const slot = await reserveLaunchSlot(stateRoot(), staggerMs, deps);
222
+ const wait = slot - deps.now();
223
+ if (wait > 0) {
224
+ deps.log(`[${name}] start-stagger: holding ${wait}ms before launch`);
225
+ await deps.sleep(wait);
226
+ }
227
+ }
135
228
  // Supervisor mail monitor (design §1): prime the notification cursor at the
136
229
  // stream tip BEFORE the session launches so no arrival is missed during boot
137
230
  // (backlog before the tip is the SessionStart hook's job). Disabled roles keep
package/dist/spawn.js CHANGED
@@ -5,6 +5,7 @@ import { stringify } from 'yaml';
5
5
  import { agentDir, fleetDDir } from './paths.js';
6
6
  import { loadConfig, resolveMonitorConfig } from './config.js';
7
7
  import { applyRole, up } from './ops.js';
8
+ import { START_STAGGER_FILE } from './runner.js';
8
9
  function roleFromOpts(o, defaultHarness) {
9
10
  const r = {};
10
11
  if (o.harness)
@@ -96,6 +97,11 @@ export async function spawnTemp(o, binPath, launch = detachedSupervisor) {
96
97
  };
97
98
  const dir = applyRole(role, { temp: true });
98
99
  writeFileSync(join(dir, 'role.yaml'), stringify(role));
100
+ // Snapshot the fleet start-stagger so the detached temp supervisor (no config path
101
+ // threaded through it) honors the same launch gate — a burst of temp spawns spaces
102
+ // out; a lone temp spawn still waits zero (time-based gate).
103
+ if (cfg.startStaggerMs > 0)
104
+ writeFileSync(join(dir, START_STAGGER_FILE), String(cfg.startStaggerMs));
99
105
  // Run the supervisor DETACHED — NOT inside a tmux session named <name>.
100
106
  // `_run-temp` -> runOnce() creates AND kills the tmux session <name> for the
101
107
  // agent itself; a supervisor sharing that session name would SIGHUP its own
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux consoles, systemd/launchd supervision, ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",