@ours.network/fleet 1.1.0-nightly.20 → 1.1.0-nightly.22

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.
@@ -8,14 +8,17 @@ import { acquireLaunchSnapshotLock, releaseLaunchSnapshot } from './launch-snaps
8
8
  import { parseGroupedMemberArgs, readMembersFile } from './member-overrides.js';
9
9
  const MEMBER_ARG_FLAGS = new Set([
10
10
  '--member', '--agent-template', '--brain', '--role', '--approval', '--filesystem', '--unattended',
11
- '--cwd', '--model', '--effort',
11
+ '--cwd', '--model', '--effort', '--loops-file', '--no-loops',
12
12
  ]);
13
13
  export function cliMemberOverrides(membersFile, argv = process.argv.slice(2)) {
14
14
  const grouped = [];
15
15
  for (let i = 0; i < argv.length; i++)
16
16
  if (MEMBER_ARG_FLAGS.has(argv[i])) {
17
- grouped.push(argv[i], argv[i + 1]);
18
- i += 1;
17
+ grouped.push(argv[i]);
18
+ if (argv[i] !== '--no-loops') {
19
+ grouped.push(argv[i + 1]);
20
+ i += 1;
21
+ }
19
22
  }
20
23
  if (membersFile && grouped.length)
21
24
  throw new Error('--members-file cannot be combined with grouped --member options');
@@ -712,6 +715,8 @@ export function registerTaskCommands(parent, cOpt) {
712
715
  .option('--cwd <path>', 'working directory for the current member block')
713
716
  .option('--model <id>', 'model for the current member block')
714
717
  .option('--effort <level>', 'reasoning effort for the current member block')
718
+ .option('--loops-file <path>', 'owner-only loops: YAML for the current temporary member')
719
+ .option('--no-loops', 'disable loops for the current temporary member')
715
720
  .option('--json', 'JSON output')
716
721
  .action(async (opts, command) => {
717
722
  try {
@@ -926,6 +931,8 @@ export function registerTaskCommands(parent, cOpt) {
926
931
  .option('--cwd <path>', 'working directory for current member')
927
932
  .option('--model <id>', 'model for current member')
928
933
  .option('--effort <level>', 'reasoning effort for current member')
934
+ .option('--loops-file <path>', 'owner-only loops: YAML for current member')
935
+ .option('--no-loops', 'disable loops for current member')
929
936
  .option('--json', 'JSON output')
930
937
  .action(async (id, opts, command) => {
931
938
  try {
@@ -1336,6 +1343,8 @@ export function registerTaskCommands(parent, cOpt) {
1336
1343
  .option('--cwd <path>', 'working directory for current member')
1337
1344
  .option('--model <id>', 'model for current member')
1338
1345
  .option('--effort <level>', 'reasoning effort for current member')
1346
+ .option('--loops-file <path>', 'owner-only loops: YAML for current member')
1347
+ .option('--no-loops', 'disable loops for current member')
1339
1348
  .option('--json', 'JSON output')
1340
1349
  .action(async (id, opts, command) => {
1341
1350
  try {
@@ -1446,6 +1455,8 @@ export function registerRoomCommands(parent, cOpt) {
1446
1455
  .option('--cwd <path>', 'working directory for current member')
1447
1456
  .option('--model <id>', 'model for current member')
1448
1457
  .option('--effort <level>', 'reasoning effort for current member')
1458
+ .option('--loops-file <path>', 'owner-only loops: YAML for current member')
1459
+ .option('--no-loops', 'disable loops for current member')
1449
1460
  .option('--json', 'JSON output')
1450
1461
  .action(async (opts, command) => {
1451
1462
  try {
@@ -99,6 +99,11 @@ function sweepUnreferencedSnapshotsLocked() {
99
99
  }
100
100
  }
101
101
  export function redactLaunchDefinition(value, key = '') {
102
+ if (key === 'prompt' && typeof value === 'string')
103
+ return {
104
+ bytes: Buffer.byteLength(value, 'utf8'),
105
+ sha256: createHash('sha256').update(value).digest('hex'),
106
+ };
102
107
  if (['env', 'harness_options', 'session_options', 'owner_channel', 'auth_proxy'].includes(key))
103
108
  return '<redacted>';
104
109
  if (Array.isArray(value))
@@ -1,5 +1,6 @@
1
1
  import { type AgentDefinition, type AgentTemplateDefinition, type FleetConfig } from '../config.js';
2
2
  import type { TemplateDefinition, TemplateSnapshot } from './types.js';
3
+ import type { AgentLoopsConfig } from '../loops/config.js';
3
4
  export interface MemberOverride {
4
5
  agent_template?: string;
5
6
  brain?: string;
@@ -10,6 +11,8 @@ export interface MemberOverride {
10
11
  cwd?: string;
11
12
  model?: string;
12
13
  effort?: string;
14
+ /** Whole-block override; false is the explicit no-loops policy. */
15
+ loops?: AgentLoopsConfig | false;
13
16
  overrides?: Partial<AgentDefinition>;
14
17
  }
15
18
  export type MemberOverrides = Record<string, MemberOverride>;
@@ -6,6 +6,8 @@ import { analyzeRolePermissions } from '../permissions.js';
6
6
  import { canonicalJson } from '../canonical-json.js';
7
7
  import { redactLaunchDefinition } from './launch-snapshot.js';
8
8
  import { snapshotTemplate } from './templates.js';
9
+ import { readLoopsFile } from '../spawn.js';
10
+ import { canonicalAgentLoops, resolveAgentLoops } from '../loops/config.js';
9
11
  export function hashMemberOverrides(overrides) {
10
12
  return createHash('sha256').update(canonicalJson(overrides)).digest('hex');
11
13
  }
@@ -16,20 +18,37 @@ const OPTION_FIELDS = {
16
18
  };
17
19
  /** Parse only the ordered member-option subsequence supplied by the CLI action. */
18
20
  export function parseGroupedMemberArgs(argv) {
19
- if (argv.length % 2)
20
- throw new Error(`${argv.at(-1)}: value required`);
21
21
  const result = {};
22
22
  let current;
23
- for (let index = 0; index < argv.length; index += 2) {
23
+ for (let index = 0; index < argv.length;) {
24
24
  const flag = argv[index];
25
+ if (flag === '--no-loops') {
26
+ if (!current)
27
+ throw new Error(`${flag} must follow --member <slot>`);
28
+ if (result[current].loops !== undefined)
29
+ throw new Error(`duplicate loop override for member '${current}'`);
30
+ result[current].loops = false;
31
+ index += 1;
32
+ continue;
33
+ }
25
34
  const value = argv[index + 1];
26
- if (!value)
35
+ if (!value || value.startsWith('--'))
27
36
  throw new Error(`${flag}: value required`);
28
37
  if (flag === '--member') {
29
38
  if (result[value])
30
39
  throw new Error(`duplicate member slot '${value}'`);
31
40
  current = value;
32
41
  result[current] = {};
42
+ index += 2;
43
+ continue;
44
+ }
45
+ if (flag === '--loops-file') {
46
+ if (!current)
47
+ throw new Error(`${flag} must follow --member <slot>`);
48
+ if (result[current].loops !== undefined)
49
+ throw new Error(`duplicate loop override for member '${current}'`);
50
+ result[current].loops = readLoopsFile(value);
51
+ index += 2;
33
52
  continue;
34
53
  }
35
54
  const field = OPTION_FIELDS[flag];
@@ -40,6 +59,7 @@ export function parseGroupedMemberArgs(argv) {
40
59
  if (result[current][field] !== undefined)
41
60
  throw new Error(`duplicate ${flag} for member '${current}'`);
42
61
  result[current][field] = value;
62
+ index += 2;
43
63
  }
44
64
  for (const [slot, value] of Object.entries(result))
45
65
  validateMemberOverride(value, `member '${slot}'`);
@@ -60,7 +80,7 @@ export function readMembersFile(path) {
60
80
  validateMemberOverride(value, `${path}: member '${slot}'`);
61
81
  return members;
62
82
  }
63
- const MEMBER_KEYS = new Set(['agent_template', 'brain', 'role', 'approval', 'filesystem', 'unattended', 'cwd', 'model', 'effort', 'overrides']);
83
+ const MEMBER_KEYS = new Set(['agent_template', 'brain', 'role', 'approval', 'filesystem', 'unattended', 'cwd', 'model', 'effort', 'overrides', 'loops']);
64
84
  function validateMemberOverride(value, where) {
65
85
  if (!value || typeof value !== 'object' || Array.isArray(value))
66
86
  throw new Error(`${where}: must be a mapping`);
@@ -81,6 +101,11 @@ function validateMemberOverride(value, where) {
81
101
  throw new Error(`${where}: ${key} must be a non-blank string`);
82
102
  if (item.overrides !== undefined && (!item.overrides || typeof item.overrides !== 'object' || Array.isArray(item.overrides)))
83
103
  throw new Error(`${where}: overrides must be a mapping`);
104
+ if (item.overrides && Object.hasOwn(item.overrides, 'loops'))
105
+ throw new Error(`${where}: overrides.loops is unsupported; use the top-level loops member override`);
106
+ if (item.loops !== undefined && item.loops !== false
107
+ && (!item.loops || typeof item.loops !== 'object' || Array.isArray(item.loops)))
108
+ throw new Error(`${where}: loops must be a mapping or false`);
84
109
  }
85
110
  const MAPS = new Set(['permissions', 'env', 'isolation', 'monitor', 'owner_channel', 'worklog', 'auth_proxy']);
86
111
  function merge(base, overlay) {
@@ -95,6 +120,8 @@ function merge(base, overlay) {
95
120
  return out;
96
121
  }
97
122
  export function prepareExecutionPlan(template, cfg, overrides = {}) {
123
+ for (const [slot, value] of Object.entries(overrides))
124
+ validateMemberOverride(value, `member '${slot}'`);
98
125
  const slots = new Set(template.members.map(member => member.slot));
99
126
  for (const slot of Object.keys(overrides))
100
127
  if (!slots.has(slot))
@@ -108,6 +135,10 @@ export function prepareExecutionPlan(template, cfg, overrides = {}) {
108
135
  if (!base)
109
136
  throw new Error(`Agent Template '${source}' not found`);
110
137
  let definition = merge(base, (input.overrides ?? {}));
138
+ if (input.loops === false)
139
+ delete definition.loops;
140
+ else if (input.loops !== undefined)
141
+ definition.loops = structuredClone(input.loops);
111
142
  const rolePreset = input.role ? cfg.rolePresets?.[input.role] : undefined;
112
143
  if (input.role && !rolePreset)
113
144
  throw new Error(`Role '${input.role}' not found`);
@@ -144,6 +175,8 @@ export function prepareExecutionPlan(template, cfg, overrides = {}) {
144
175
  const role = cfg.resolveAgentDefinition(`RoomMember_${member.slot}`, {
145
176
  ...definition, identity: `RoomMember_${member.slot}`,
146
177
  });
178
+ if (definition.loops !== undefined)
179
+ definition.loops = canonicalAgentLoops(resolveAgentLoops(definition.loops, role, `(member '${member.slot}' resolved loops)`));
147
180
  const analysis = analyzeRolePermissions(role);
148
181
  if (analysis.conflicts?.length)
149
182
  throw new Error(`member '${member.slot}' has native permission conflicts: ${analysis.conflicts.map(item => item.warning).join('; ')}`);
@@ -154,6 +187,7 @@ export function prepareExecutionPlan(template, cfg, overrides = {}) {
154
187
  launchDefinitions[id] = definition;
155
188
  const hash = createHash('sha256').update(canonicalJson(definition)).digest('hex');
156
189
  return { ...member, agent_template: source, launch_definition_id: id,
190
+ loop_source: input.loops !== undefined ? 'cli' : base.loops !== undefined ? 'agent-template' : 'omitted',
157
191
  agent_template_hash: hash,
158
192
  ...(input.role ? { role_preset: { id: input.role, hash: createHash('sha256').update(canonicalJson(rolePreset)).digest('hex') } } : {}),
159
193
  ...(input.brain ? { brain_preset: { id: input.brain, hash: createHash('sha256').update(canonicalJson(brainPreset)).digest('hex') } } : {}),
@@ -100,6 +100,7 @@ function expandMembers(template, prefix) {
100
100
  agentTemplate: slot.agent_template,
101
101
  launchDefinitionId: slot.launch_definition_id ?? slot.agent_template,
102
102
  agentTemplateHash: slot.agent_template_hash,
103
+ loopSource: slot.loop_source ?? 'omitted',
103
104
  });
104
105
  }
105
106
  }
@@ -121,6 +122,7 @@ function settingsFor(member, cfg, sealed) {
121
122
  template: member.agentTemplate,
122
123
  templateHash: member.agentTemplateHash
123
124
  ?? createHash('sha256').update(canonicalJson(definition)).digest('hex'),
125
+ loopSource: member.loopSource,
124
126
  };
125
127
  }
126
128
  function roomTask(input, member, settings, members, roomIdentityCid, ownerSeatCid) {
@@ -286,6 +288,9 @@ async function launchMemberUnlocked(input) {
286
288
  temp: true,
287
289
  identity: member.name,
288
290
  agentDefinition: settings.definition,
291
+ ...(settings.loopSource === 'cli' && settings.definition.loops === undefined
292
+ ? { noLoops: true } : {}),
293
+ loopSource: settings.loopSource,
289
294
  surface: 'agent',
290
295
  creationActionId: actionId,
291
296
  roomMemberStartup: startup,
@@ -276,6 +276,8 @@ export interface TemplateDefinition {
276
276
  members: TemplateMemberSlot[];
277
277
  }
278
278
  export interface TemplateSnapshotMember extends TemplateMemberSlot {
279
+ /** Source of the effective temporary-loop policy, independent of its sealed value. */
280
+ loop_source?: 'agent-template' | 'cli' | 'omitted';
279
281
  /** Secret-safe public projection; launch material lives in the sealed snapshot. */
280
282
  agent_projection?: Record<string, unknown>;
281
283
  agent_template_hash?: string;
package/dist/runner.d.ts CHANGED
@@ -7,6 +7,7 @@ import type { AgentSession, ExitRecord, TurnResult } from './session/types.js';
7
7
  import type { AgentSessionAdapter, AgentSessionStartOptions } from './harness/agent-session.js';
8
8
  import { type OwnerChannelHandle, type OwnerChannelOptions } from './owner-channel/channel.js';
9
9
  import { type OwnerBinderLease } from './owner-channel/binder.js';
10
+ import { ScheduledLoopManager, type ScheduledLoopManagerHandle } from './loops/manager.js';
10
11
  export interface RunnerDeps {
11
12
  exec: Exec;
12
13
  cpuDelegated(): boolean;
@@ -26,6 +27,8 @@ export interface RunnerDeps {
26
27
  startAgentSession(adapter: AgentSessionAdapter, options: AgentSessionStartOptions): Promise<AgentSession>;
27
28
  /** Construct the authenticated role control route (injectable where sockets are unavailable). */
28
29
  createControlServer(stateDir: string, session: AgentSession, log: (line: string) => void): Pick<RoleControlServer, 'start' | 'close' | 'setFleetSpawner' | 'setFleetAuditor' | 'setOwnerChannel' | 'setConfigReloader' | 'setLoopManager'>;
30
+ /** Construct scheduled-loop execution (injectable for fail-closed startup tests). */
31
+ createLoopManager(...args: ConstructorParameters<typeof ScheduledLoopManager>): ScheduledLoopManagerHandle;
29
32
  /** Acquire the cross-process owner-channel binder lease before replacing the control socket. */
30
33
  acquireOwnerBinder(stateDir: string, role: string, identity: string): Promise<OwnerBinderLease>;
31
34
  /** Ask the still-authenticated predecessor to emit the fixed recovery notice. */
package/dist/runner.js CHANGED
@@ -53,6 +53,7 @@ const defaultDeps = () => ({
53
53
  createOwnerChannel: opts => new OwnerChannel(opts),
54
54
  startAgentSession: (adapter, options) => adapter.start(options),
55
55
  createControlServer: (stateDir, session, log) => new RoleControlServer(stateDir, session, log),
56
+ createLoopManager: (...args) => new ScheduledLoopManager(...args),
56
57
  acquireOwnerBinder: (stateDir, role, identity) => acquireOwnerBinderLease(stateDir, role, identity),
57
58
  reportOwnerStartupFailure: async (stateDir) => {
58
59
  const response = await controlRequest(stateDir, {
@@ -774,7 +775,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
774
775
  if (generation === loopGeneration && (loopManager || !definitions.length))
775
776
  return { changed: false, loops: definitions.length };
776
777
  if (!loopManager && definitions.length) {
777
- loopManager = new ScheduledLoopManager(name, definitions, dir, arbiter, {
778
+ loopManager = deps.createLoopManager(name, definitions, dir, arbiter, {
778
779
  now: deps.now,
779
780
  setTimer: (callback, ms) => setTimeout(callback, ms),
780
781
  clearTimer: timer => clearTimeout(timer),
@@ -790,10 +791,12 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
790
791
  deps.log(`[${name}] scheduled loops reloaded (${definitions.length} definitions)`);
791
792
  return { changed: true, loops: definitions.length };
792
793
  };
793
- control.setConfigReloader(reloadLoopConfig);
794
+ // Temporary agents are immutable launch snapshots: never re-resolve mutable Fleet YAML.
795
+ if (!temp)
796
+ control.setConfigReloader(reloadLoopConfig);
794
797
  if (role.loops?.length) {
795
798
  try {
796
- loopManager = new ScheduledLoopManager(name, role.loops, dir, arbiter, {
799
+ loopManager = deps.createLoopManager(name, role.loops, dir, arbiter, {
797
800
  now: deps.now,
798
801
  setTimer: (callback, ms) => setTimeout(callback, ms),
799
802
  clearTimer: timer => clearTimeout(timer),
@@ -803,6 +806,17 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
803
806
  loopManager.start();
804
807
  }
805
808
  catch (error) {
809
+ if (temp) {
810
+ monitor?.stop();
811
+ if (monitorLoop)
812
+ await monitorLoop;
813
+ await control.close();
814
+ ownerBinder?.release();
815
+ await agentSession.close();
816
+ unsubscribeRecovery?.();
817
+ throw new Error(`[${name}] configured temporary loop manager failed to start: `
818
+ + `${error?.message ?? String(error)}`);
819
+ }
806
820
  deps.log(`[${name}] scheduled loop manager unavailable: ${error?.name ?? 'Error'}`);
807
821
  }
808
822
  }
package/dist/spawn.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { IsolationConfig } from './isolation/types.js';
2
+ import type { AgentLoopsConfig } from './loops/config.js';
2
3
  import { type ApprovalMode, type FilesystemMode, type ResolvedRole, type MonitorConfig, type UnattendedMode, type RoomMemberStartup, type AgentDefinition, type AgentSelection } from './config.js';
3
4
  import { type OpsDeps } from './ops.js';
4
5
  import { type CreationDeps, type CreationProvenance } from './creation.js';
@@ -40,6 +41,12 @@ export interface SpawnOpts {
40
41
  * input in this release.
41
42
  */
42
43
  isolationFile?: string;
44
+ /** Trusted YAML containing exactly a top-level `loops:` map. Temporary launches only. */
45
+ loopsFile?: string;
46
+ /** Explicitly override any selected Agent Template loops with none. */
47
+ noLoops?: boolean;
48
+ /** Internal source label retained after room-member plan resolution. */
49
+ loopSource?: 'agent-template' | 'cli' | 'omitted';
43
50
  overseeInterval?: string;
44
51
  configPath?: string;
45
52
  dryRun?: boolean;
@@ -56,6 +63,8 @@ export declare function agentDefinitionFromSpawn(o: SpawnOpts): AgentDefinition;
56
63
  * must fail before any artifact exists.
57
64
  */
58
65
  export declare function readIsolationFile(path: string): IsolationConfig;
66
+ /** Read a private canonical temporary-loop override before any creation side effect. */
67
+ export declare function readLoopsFile(path: string): AgentLoopsConfig;
59
68
  export declare function validateSpawnOpts(o: SpawnOpts): void;
60
69
  /** The ours identity a spawn will bind: explicit, else the role name. */
61
70
  export declare const effectiveIdentity: (o: SpawnOpts) => string;
package/dist/spawn.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn as spawnChild } from 'node:child_process';
2
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { parse, stringify } from 'yaml';
5
5
  import { agentDir, defaultConfigPath } from './paths.js';
@@ -22,9 +22,12 @@ export function agentDefinitionFromSpawn(o) {
22
22
  if (o.agentDefinition) {
23
23
  if (o.brain !== undefined || o.role !== undefined || o.cwd !== undefined
24
24
  || o.coordinator !== undefined || o.approval !== undefined || o.filesystem !== undefined
25
- || o.unattended !== undefined || o.isolationFile !== undefined || o.monitorConfig !== undefined)
25
+ || o.unattended !== undefined || o.isolationFile !== undefined || o.monitorConfig !== undefined
26
+ || o.loopsFile !== undefined)
26
27
  throw new Error('canonical agentDefinition conflicts with separate Agent fields');
27
28
  const definition = structuredClone(o.agentDefinition);
29
+ if (o.noLoops === true && definition.loops !== undefined)
30
+ throw new Error('canonical agentDefinition loops conflict with explicit no-loops policy');
28
31
  if (o.identity)
29
32
  definition.identity = o.identity;
30
33
  return definition;
@@ -46,12 +49,21 @@ export function agentDefinitionFromSpawn(o) {
46
49
  ...(permissions ? { permissions } : {}),
47
50
  ...(o.isolationFile ? { isolation: readIsolationFile(o.isolationFile) } : {}),
48
51
  ...(o.monitorConfig ? { monitor: structuredClone(o.monitorConfig) } : {}),
52
+ ...(o.loopsFile ? { loops: readLoopsFile(o.loopsFile) } : {}),
49
53
  };
50
54
  }
51
55
  function resolvedSpawn(o) {
52
56
  const definition = agentDefinitionFromSpawn(o);
53
- const cfg = loadConfig(o.configPath, { additionalAgent: { id: o.name, definition } });
54
- return { definition, role: findRole(cfg, o.name) };
57
+ const cfg = loadConfig(o.configPath, {
58
+ additionalAgent: { id: o.name, definition, temporary: o.temp === true },
59
+ });
60
+ const role = findRole(cfg, o.name);
61
+ if (o.temp)
62
+ role.temporaryLoopSource = o.loopSource
63
+ ?? (role.temporaryLoops?.length ? 'agent-template' : 'omitted');
64
+ if (o.noLoops === true)
65
+ role.temporaryLoops = [];
66
+ return { definition, role };
55
67
  }
56
68
  /**
57
69
  * Read and validate an `--isolation-file`. The file is the existing
@@ -78,7 +90,37 @@ export function readIsolationFile(path) {
78
90
  throw new Error(`--isolation-file ${path}: ${problems.join('; ')}`);
79
91
  return cfg;
80
92
  }
93
+ /** Read a private canonical temporary-loop override before any creation side effect. */
94
+ export function readLoopsFile(path) {
95
+ const stat = lstatSync(path);
96
+ const uid = process.getuid?.();
97
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1_000_000
98
+ || (uid !== undefined && stat.uid !== uid) || (stat.mode & 0o777) !== 0o600)
99
+ throw new Error(`${path}: loops file must be an owner-only regular file no larger than 1 MB`);
100
+ let raw;
101
+ try {
102
+ raw = parse(readFileSync(path, 'utf8'));
103
+ }
104
+ catch (error) {
105
+ throw new Error(`--loops-file ${path}: ${error.message}`);
106
+ }
107
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)
108
+ || !Object.hasOwn(raw, 'loops')
109
+ || Object.keys(raw).some(key => key !== 'loops'))
110
+ throw new Error(`${path}: expected exactly a top-level loops: mapping`);
111
+ const loops = raw.loops;
112
+ if (!loops || typeof loops !== 'object' || Array.isArray(loops)
113
+ || !Object.keys(loops).length)
114
+ throw new Error(`${path}: loops must be a non-empty mapping; use --no-loops to disable loops`);
115
+ return structuredClone(loops);
116
+ }
81
117
  export function validateSpawnOpts(o) {
118
+ if (o.loopsFile && o.noLoops === true)
119
+ throw new Error('--loops-file and --no-loops are mutually exclusive');
120
+ if ((o.loopsFile || o.noLoops === true || o.agentDefinition?.loops !== undefined) && !o.temp)
121
+ throw new Error('temporary Agent loops require --temp');
122
+ if (o.loopsFile)
123
+ readLoopsFile(o.loopsFile);
82
124
  if (o.approval && !['ask', 'auto', 'allow', 'deny'].includes(o.approval))
83
125
  throw new Error(`invalid --approval '${o.approval}'; allowed: ask, auto, allow (deprecated alias: deny)`);
84
126
  if (o.filesystem && !['read-only', 'workspace', 'unrestricted'].includes(o.filesystem))
@@ -149,7 +191,7 @@ export function spawnDryRun(o) {
149
191
  * `env`, `bio`, `persona` and `harness_options` are deliberately absent: the
150
192
  * record exists to be read, and must not become a place credentials collect.
151
193
  */
152
- function provenanceSettings(o, defaults) {
194
+ function provenanceSettings(o, defaults, resolvedLoops = []) {
153
195
  const perms = (defaults.permissions ?? {});
154
196
  const callerDefaults = new Set(o.inheritedFromCaller ?? []);
155
197
  const tagged = (key, entry) => callerDefaults.has(key) ? { ...entry, source: 'caller-role' } : entry;
@@ -169,6 +211,16 @@ function provenanceSettings(o, defaults) {
169
211
  ? { value: 'declared via --isolation-file', source: 'cli' }
170
212
  : { value: defaults.isolation ? 'from fleet defaults' : undefined, source: defaults.isolation ? 'fleet-default' : 'built-in' },
171
213
  monitor: tagged('monitorConfig', provenanceOf(o.monitorConfig, defaults.monitor, { mode: 'fleet' })),
214
+ loops: {
215
+ value: o.noLoops ? { enabled: false, loops: [] }
216
+ : resolvedLoops.length ? resolvedLoops.map(loop => ({
217
+ name: loop.name, enabled: loop.enabled, intervalMs: loop.intervalMs,
218
+ initialDelayMs: loop.initialDelayMs, jitterMs: loop.jitterMs,
219
+ prompt: { bytes: loop.promptBytes, sha256: loop.promptHash },
220
+ })) : 'omitted (legacy no temporary loops)',
221
+ source: o.loopSource === 'agent-template' ? 'agent-template'
222
+ : o.loopsFile || o.noLoops || o.loopSource === 'cli' ? 'cli' : 'built-in',
223
+ },
172
224
  };
173
225
  }
174
226
  /** Permanent spawn: persist one bare Agent document under the selected v2 root. */
@@ -232,7 +284,7 @@ export async function spawnPermanent(o, deps, creation = {}) {
232
284
  // launch still records how it was asked for.
233
285
  const provenance = buildProvenance({
234
286
  role: o.name, lifetime: 'permanent', fleetVersion: VERSION,
235
- settings: provenanceSettings(o, cfg.defaults),
287
+ settings: provenanceSettings(o, cfg.defaults, prepared.role.temporaryLoops),
236
288
  surface: o.surface, creationActionId: o.creationActionId, callerRole: o.callerRole,
237
289
  });
238
290
  mkdirSync(agentDir(o.name), { recursive: true });
@@ -293,14 +345,19 @@ export async function spawnTemp(o, binPath, launch = independentSupervisor, crea
293
345
  }
294
346
  async function spawnTempInner(o, preparedRole, binPath, launch, tx, onStage) {
295
347
  const cfg = loadConfig(o.configPath);
348
+ const { temporaryLoops, ...launchRole } = preparedRole;
296
349
  const role = {
297
- ...preparedRole, sourceFile: '(temp)', loops: undefined, roomMemberStartup: o.roomMemberStartup,
350
+ ...launchRole, sourceFile: '(temp)',
351
+ ...(o.noLoops === true ? { loops: [] }
352
+ : temporaryLoops?.length ? { loops: temporaryLoops } : { loops: undefined }),
353
+ temporaryLoopSource: o.loopSource ?? (temporaryLoops?.length ? 'agent-template' : 'omitted'),
354
+ roomMemberStartup: o.roomMemberStartup,
298
355
  };
299
356
  onStage?.('writing_role');
300
357
  const dir = applyRole(role, { temp: true, identityGuarantee: 'unverified' });
301
358
  const provenance = buildProvenance({
302
359
  role: o.name, lifetime: 'temporary', fleetVersion: VERSION,
303
- settings: provenanceSettings(o, cfg.defaults),
360
+ settings: provenanceSettings(o, cfg.defaults, preparedRole.temporaryLoops),
304
361
  surface: o.surface, creationActionId: o.creationActionId, callerRole: o.callerRole,
305
362
  });
306
363
  writeProvenance(dir, provenance);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.1.0-nightly.20",
3
+ "version": "1.1.0-nightly.22",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",
@@ -2,3 +2,48 @@ role: { ref: LocalCoordinator }
2
2
  brain: { ref: claude-default }
3
3
  coordinator: FleetCoordinator
4
4
  permissions: { approval: ask, filesystem: workspace, unattended: deny }
5
+ loops:
6
+ continuity:
7
+ interval: 15m
8
+ prompt: |
9
+ Perform one bounded, observation-only continuity pass for the assigned task room. Do not modify
10
+ task or project state; the only permitted writes are the room action and secret-free WORKLOG
11
+ checkpoint defined below. Anchor the preceding interval to scheduled_at minus 15 minutes
12
+ through scheduled_at. A late start or missed-loop metadata describes this loop's outage, not
13
+ evidence that development froze.
14
+
15
+ Re-read ROUTINES.md when present and the continuity checkpoint in WORKLOG.md. Use bounded
16
+ `list_history` reads filtered to the assigned room CID and direction as needed. Do not drain
17
+ unread mail or mark unrelated mail read. Ignore unrelated contacts and private messages.
18
+ Use only attributable room/task evidence. Exclude loop-authored `CONTINUITY *` messages from
19
+ development evidence and fingerprint inputs; retain their request/report IDs separately for
20
+ dedupe. Lifecycle state `active`, elapsed time, silence, task difficulty, and filesystem
21
+ dirtiness alone do not prove development is active, meaningful progress, or a stall. Meaningful
22
+ progress is a concrete reported change, test result, decision, handoff, blocker delta, active
23
+ step, or unexpired ETA.
24
+
25
+ After a mandatory final history recheck, choose exactly one mutually exclusive branch:
26
+ - Terminal: if attributable evidence says the task is terminal, do nothing.
27
+ - Recent update: if the room already has a meaningful update in the interval, do nothing.
28
+ - Ongoing: if attributable development evidence is ongoing but the room has no meaningful
29
+ update in the interval, send at most one concise
30
+ `CONTINUITY ACTIVE [start..end]: <observed facts>; next=<known step or ETA>` update.
31
+ - Unobservable/no evidence: never invent activity. If no qualifying outstanding continuity
32
+ request exists, send one direct `CONTINUITY CHECK` request for status, next step, and ETA.
33
+ If the prior request is less than 10 minutes old or its ETA is unexpired, do nothing. If it
34
+ is at least 10 minutes old, no later meaningful evidence exists, and the ETA is absent or
35
+ elapsed, send at most one clear `CONTINUITY STALLED` report.
36
+ - Explicit stop: if attributable evidence says work stopped or is blocked with no active next
37
+ step, send at most one clear `CONTINUITY STALLED` report.
38
+
39
+ Before any send, suppress a semantic duplicate found by the final history recheck or checkpoint;
40
+ room history wins if they disagree. Build the stable fingerprint from classification and
41
+ non-loop evidence IDs/timestamps plus requested/reported state, excluding loop-authored
42
+ continuity messages, prose, and time-of-check so unchanged classification and evidence state
43
+ stay silent. Store continuity request/report IDs separately. Later meaningful progress resets
44
+ a prior request or stall. Append a secret-free WORKLOG checkpoint with interval, classification,
45
+ evidence IDs/times, fingerprint, and any separate request/report ID.
46
+
47
+ Do not edit project files, run implementation or tests, interrupt peers, spawn or manage agents,
48
+ invoke task/room lifecycle actions, infer from unrelated messages, or exceed LocalCoordinator
49
+ authority. Complete silently when no room action is required.
@@ -3,6 +3,7 @@ description: "Locally coordinated team: LocalCoordinator sequences, Developer ex
3
3
  room: { quiet_membership: false, anonymous: false }
4
4
  contract: |
5
5
  LocalCoordinator coordinates sequencing, handoffs, context, and blockers among existing members.
6
+ LocalCoordinator performs a bounded 15-minute continuity check without taking over implementation.
6
7
  Developer owns implementation and verification evidence.
7
8
  Critic independently reviews and withholds sign-off on material failures.
8
9
  LocalCoordinator has no Fleet lifecycle or agent-management authority.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "preset_revision": 4,
3
+ "preset_revision": 5,
4
4
  "brain_catalog_revision": 1,
5
5
  "description": "ours-fleet standard configuration presets"
6
6
  }