@ours.network/fleet 0.17.8 → 0.17.10

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.
@@ -1,3 +1,4 @@
1
+ import type { McpServer } from '@agentclientprotocol/sdk';
1
2
  import type { CommonPermissions, FleetPermissionMode, ResolvedRole } from '../config.js';
2
3
  export interface PrereqCheck {
3
4
  name: string;
@@ -21,7 +22,32 @@ export interface SessionPrep {
21
22
  env: Record<string, string>;
22
23
  /** Optional launcher selected after runtime prerequisite probing. */
23
24
  command?: string;
25
+ /**
26
+ * The settings overlay prepareSession wrote, if it wrote one.
27
+ *
28
+ * The tmux launch delivers this as `--settings <path>` in `argv`; an ACP agent
29
+ * takes no flags, so it needs the PATH rather than the flag. Recorded here so
30
+ * the two deliveries read one value instead of each re-deriving the filename.
31
+ */
32
+ settingsOverlay?: string;
33
+ /**
34
+ * The MCP config file prepareSession wrote for `harness_options.mcp_servers`,
35
+ * if the role declared any. Same reason as `settingsOverlay`: the tmux launch
36
+ * passes the file, the ACP launch has to send the servers themselves.
37
+ */
38
+ mcpConfigFile?: string;
24
39
  }
40
+ /**
41
+ * One MCP server as ACP's `session/new` declares it.
42
+ *
43
+ * ⚠ THE PROTOCOL'S OWN TYPE, DELIBERATELY NOT A LOCAL RESTATEMENT. `mcpServers`
44
+ * goes onto the wire unchanged, so a hand-written near-copy would compile while
45
+ * being subtly wrong — `env` and `headers` are REQUIRED arrays, and the stdio
46
+ * variant is the one with no `type` field at all. Aliasing it also keeps
47
+ * `session/new`'s response type inferable, which a structural stand-in silently
48
+ * broke (every field of the result degraded to `unknown`).
49
+ */
50
+ export type AcpMcpServer = McpServer;
25
51
  export interface Launch {
26
52
  argv: string[];
27
53
  env: Record<string, string>;
@@ -96,7 +122,13 @@ export interface HarnessAdapter {
96
122
  id: string;
97
123
  supportsResume: boolean;
98
124
  checkPrereqs(): Promise<PrereqReport>;
99
- validateOptions(opts: unknown): ValidationError[];
125
+ /**
126
+ * `role` is the SESSION-AWARE half: some harness options can only be honoured
127
+ * on some session types, and an option that is silently dropped is worse than
128
+ * one that is refused. Optional so an adapter that has nothing session-specific
129
+ * to say keeps its one-argument implementation.
130
+ */
131
+ validateOptions(opts: unknown, role?: ResolvedRole): ValidationError[];
100
132
  prepareSession(role: ResolvedRole, dirs: RoleDirs): Promise<SessionPrep>;
101
133
  buildLaunch(role: ResolvedRole, mode: 'fresh' | 'resume', s: SessionState, prep: SessionPrep): Launch;
102
134
  buildAcpLaunch?(role: ResolvedRole, prep: SessionPrep): AcpLaunch;
@@ -106,6 +138,23 @@ export interface HarnessAdapter {
106
138
  * agent's default. Omit for a harness whose ACP agent has no modes.
107
139
  */
108
140
  acpPermissionModeId?(role: ResolvedRole): string | undefined;
141
+ /**
142
+ * The MCP servers this role declares, for the `mcpServers` array of ACP's
143
+ * `session/new` / `resume` / `load`. Empty (or omitted) leaves the agent's own
144
+ * configuration alone, which is what fleet has always sent.
145
+ */
146
+ acpMcpServers?(role: ResolvedRole): AcpMcpServer[];
147
+ /**
148
+ * Agent-specific `_meta` for `session/new` — how a capability the CLI takes as
149
+ * a flag reaches an ACP agent that accepts no flags.
150
+ *
151
+ * ⚠ THIS IS A PER-AGENT VOCABULARY, NOT PROTOCOL. `_meta` is free-form in ACP,
152
+ * so what an adapter puts here is only honoured by the agent it was written
153
+ * for. An adapter must therefore return nothing for an ACP command it did not
154
+ * choose, and the options that depend on it must be refused at validation for
155
+ * such a role rather than sent and silently ignored.
156
+ */
157
+ acpSessionMeta?(role: ResolvedRole, prep: SessionPrep): Record<string, unknown> | undefined;
109
158
  /** Effective portable policy and harness-native approval mode after native overrides win. */
110
159
  effectivePermissionMode?(role: ResolvedRole): {
111
160
  fleetMode: FleetPermissionMode;
@@ -0,0 +1,71 @@
1
+ import type { ResolvedRole } from './config.js';
2
+ /**
3
+ * Which environment variable a harness reads to pin the model it RUNS.
4
+ *
5
+ * This is not a convenience: for `claude-code` it is the only channel that
6
+ * reaches the ACP backend at all. `buildLaunch` (tmux) passes `--model`, but
7
+ * `buildAcpLaunch` launches the ACP adapter with no model argument, and that
8
+ * adapter resolves its model in this order — ANTHROPIC_MODEL, then
9
+ * `settings.model`, then a resumed session's live model, then its first
10
+ * catalogue entry. A role's declared model was therefore invisible to every
11
+ * ACP role, and a fleet-wide `defaults.env.ANTHROPIC_MODEL` silently outranked
12
+ * an explicitly requested one.
13
+ */
14
+ export declare const MODEL_ENV_BY_HARNESS: Readonly<Record<string, string>>;
15
+ /** The model-pin variable for a harness, or undefined if it pins no model by env. */
16
+ export declare function modelEnvVar(harness: string | undefined): string | undefined;
17
+ export interface RoleModelEnvInput {
18
+ harness: string;
19
+ /** Already resolved by `resolveRoleModel` — may come from the fleet default. */
20
+ model: string | undefined;
21
+ /** True when the role (or `--model`) named a model, including `model: null`. */
22
+ modelWasExplicit: boolean;
23
+ defaultsEnv?: Record<string, string>;
24
+ roleEnv?: Record<string, string>;
25
+ authProxyBaseUrl?: string;
26
+ }
27
+ export interface RoleModelEnv {
28
+ env: Record<string, string>;
29
+ /**
30
+ * The model the harness will actually run. Equal to `env[pin]` for a harness
31
+ * that pins by env, so anything reporting this value reports the runtime.
32
+ */
33
+ model: string | undefined;
34
+ }
35
+ /**
36
+ * Resolve a role's environment and its runtime model TOGETHER, so the two can
37
+ * never disagree.
38
+ *
39
+ * Precedence, highest first:
40
+ * 1. an explicit `model:` / `--model` on the role
41
+ * 2. the role's own `env:` pin
42
+ * 3. the fleet `defaults.model`
43
+ * 4. the fleet `defaults.env` pin
44
+ *
45
+ * Inheriting the fleet default remains correct when the role names no model
46
+ * (2, 3, 4); an explicitly named one wins (1). Where both are explicit and they
47
+ * disagree, there is no defensible winner, so this refuses rather than picking
48
+ * one silently — the silence is what let a day of "Fable" work run on Opus.
49
+ *
50
+ * `model: null` explicitly asks for no fleet-chosen model, so it also clears an
51
+ * inherited pin instead of leaving one in place to act as a hidden default.
52
+ */
53
+ export declare function resolveRoleModelEnv(input: RoleModelEnvInput, describe?: (message: string) => Error): RoleModelEnv;
54
+ /**
55
+ * The model a role will actually run, read back from the environment it was
56
+ * resolved with. Use this wherever a model is reported to a human.
57
+ */
58
+ export declare function effectiveRoleModel(role: ResolvedRole): string | undefined;
59
+ /**
60
+ * Move a role's env pin onto a new model. Anything that changes the model a
61
+ * role runs after resolution — model-chain recovery is the live example — must
62
+ * go through this, or it changes only the label.
63
+ */
64
+ export declare function repinModelEnv(role: ResolvedRole, model: string | undefined): Record<string, string> | undefined;
65
+ /**
66
+ * Last line of defence, at the exact point a child's environment is composed:
67
+ * refuse to launch a role whose child would run a model other than the one the
68
+ * role declares and the banner reports. A spawn that cannot keep those two in
69
+ * agreement must fail loudly, not start and be believed.
70
+ */
71
+ export declare function assertModelPinReachesChild(role: ResolvedRole, childEnv: Record<string, string | undefined>): void;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Which environment variable a harness reads to pin the model it RUNS.
3
+ *
4
+ * This is not a convenience: for `claude-code` it is the only channel that
5
+ * reaches the ACP backend at all. `buildLaunch` (tmux) passes `--model`, but
6
+ * `buildAcpLaunch` launches the ACP adapter with no model argument, and that
7
+ * adapter resolves its model in this order — ANTHROPIC_MODEL, then
8
+ * `settings.model`, then a resumed session's live model, then its first
9
+ * catalogue entry. A role's declared model was therefore invisible to every
10
+ * ACP role, and a fleet-wide `defaults.env.ANTHROPIC_MODEL` silently outranked
11
+ * an explicitly requested one.
12
+ */
13
+ export const MODEL_ENV_BY_HARNESS = {
14
+ 'claude-code': 'ANTHROPIC_MODEL',
15
+ };
16
+ /** The model-pin variable for a harness, or undefined if it pins no model by env. */
17
+ export function modelEnvVar(harness) {
18
+ return harness === undefined ? undefined : MODEL_ENV_BY_HARNESS[harness];
19
+ }
20
+ /**
21
+ * Resolve a role's environment and its runtime model TOGETHER, so the two can
22
+ * never disagree.
23
+ *
24
+ * Precedence, highest first:
25
+ * 1. an explicit `model:` / `--model` on the role
26
+ * 2. the role's own `env:` pin
27
+ * 3. the fleet `defaults.model`
28
+ * 4. the fleet `defaults.env` pin
29
+ *
30
+ * Inheriting the fleet default remains correct when the role names no model
31
+ * (2, 3, 4); an explicitly named one wins (1). Where both are explicit and they
32
+ * disagree, there is no defensible winner, so this refuses rather than picking
33
+ * one silently — the silence is what let a day of "Fable" work run on Opus.
34
+ *
35
+ * `model: null` explicitly asks for no fleet-chosen model, so it also clears an
36
+ * inherited pin instead of leaving one in place to act as a hidden default.
37
+ */
38
+ export function resolveRoleModelEnv(input, describe = message => new Error(message)) {
39
+ const env = {
40
+ ...(input.defaultsEnv ?? {}),
41
+ ...(input.roleEnv ?? {}),
42
+ ...(input.authProxyBaseUrl ? { ANTHROPIC_BASE_URL: input.authProxyBaseUrl } : {}),
43
+ };
44
+ const pin = modelEnvVar(input.harness);
45
+ if (!pin)
46
+ return { env, model: input.model };
47
+ const rolePin = input.roleEnv?.[pin];
48
+ if (input.modelWasExplicit) {
49
+ if (rolePin !== undefined && rolePin !== input.model)
50
+ throw describe(`model '${input.model ?? '(none)'}' contradicts env.${pin} '${rolePin}'; `
51
+ + `remove one — ${pin} is what the harness actually runs`);
52
+ if (input.model === undefined)
53
+ delete env[pin];
54
+ else
55
+ env[pin] = input.model;
56
+ return { env, model: input.model };
57
+ }
58
+ // Not explicit: a role-level pin is the most specific thing said about this
59
+ // role, so it decides — and the reported model follows it.
60
+ if (rolePin !== undefined)
61
+ return { env, model: rolePin };
62
+ if (input.model !== undefined)
63
+ env[pin] = input.model;
64
+ return { env, model: input.model ?? env[pin] };
65
+ }
66
+ /**
67
+ * The model a role will actually run, read back from the environment it was
68
+ * resolved with. Use this wherever a model is reported to a human.
69
+ */
70
+ export function effectiveRoleModel(role) {
71
+ const pin = modelEnvVar(role.harness);
72
+ return (pin ? role.env?.[pin] : undefined) ?? role.model;
73
+ }
74
+ /**
75
+ * Move a role's env pin onto a new model. Anything that changes the model a
76
+ * role runs after resolution — model-chain recovery is the live example — must
77
+ * go through this, or it changes only the label.
78
+ */
79
+ export function repinModelEnv(role, model) {
80
+ const pin = modelEnvVar(role.harness);
81
+ if (!pin)
82
+ return role.env;
83
+ const env = { ...(role.env ?? {}) };
84
+ if (model === undefined)
85
+ delete env[pin];
86
+ else
87
+ env[pin] = model;
88
+ return Object.keys(env).length ? env : undefined;
89
+ }
90
+ /**
91
+ * Last line of defence, at the exact point a child's environment is composed:
92
+ * refuse to launch a role whose child would run a model other than the one the
93
+ * role declares and the banner reports. A spawn that cannot keep those two in
94
+ * agreement must fail loudly, not start and be believed.
95
+ */
96
+ export function assertModelPinReachesChild(role, childEnv) {
97
+ const pin = modelEnvVar(role.harness);
98
+ if (!pin || role.model === undefined)
99
+ return;
100
+ const actual = childEnv[pin];
101
+ if (actual === role.model)
102
+ return;
103
+ throw new Error(`[${role.name}] refusing to launch: role model is '${role.model}' but the child's `
104
+ + `${pin} is ${actual === undefined ? 'unset' : `'${actual}'`} — the session would run a `
105
+ + 'different model than the one reported');
106
+ }
package/dist/ops.js CHANGED
@@ -16,7 +16,7 @@ import { realExec } from './exec.js';
16
16
  /** Materialize a role's state dir from config: briefing + markers. Returns the dir. */
17
17
  export function applyRole(role, opts = {}) {
18
18
  const adapter = getAdapter(role.harness);
19
- const errs = adapter.validateOptions(role.harness_options);
19
+ const errs = adapter.validateOptions(role.harness_options, role);
20
20
  if (errs.length)
21
21
  throw new Error(`role '${role.name}': ` + errs.map(e => `${e.path}: ${e.message}`).join('; '));
22
22
  const dir = agentDir(role.name, opts.temp === true);
@@ -238,6 +238,16 @@ export declare class OwnerChannel implements OwnerChannelHandle {
238
238
  private warnOwnerOfUnauthorizedSender;
239
239
  private effectiveOwners;
240
240
  private authorizationIntegrity;
241
+ /**
242
+ * Report what the session actually did with the prompt, not what the config
243
+ * asked for. `interrupt: true` used to be reported as "your request
244
+ * interrupted the previous task" unconditionally; the session now answers
245
+ * whether anything was cancelled, whether the request is queued behind
246
+ * earlier prompts, or whether it is held until the current task reaches a
247
+ * safe stopping point. Backends that report no delivery state keep the old
248
+ * queuedBehind-based wording.
249
+ */
250
+ private acceptanceNotice;
241
251
  private complete;
242
252
  private commentsState;
243
253
  /** Model-authored commentary only; raw protocol/tool data never reaches here. */
@@ -715,11 +715,7 @@ export class OwnerChannel {
715
715
  origin: { kind: 'owner', requestId,
716
716
  ...(group.caption ? { displayText: String(group.caption.text ?? '') } : {}) },
717
717
  });
718
- const accepted = this.options.config.interrupt
719
- ? ownerNotices.receivedInterrupting()
720
- : queued.queuedBehind > 0
721
- ? ownerNotices.receivedQueued(queued.queuedBehind)
722
- : ownerNotices.receivedStarted();
718
+ const accepted = this.acceptanceNotice(queued);
723
719
  handledWireIds.forEach(wire => this.inFlight.add(wire));
724
720
  const receipt = this.send(sender.id, accepted, originWireId).then(() => undefined).catch(error => {
725
721
  this.logError(`attachment request ${requestId.slice(0, 12)} acceptance notice failed`, error);
@@ -860,14 +856,7 @@ export class OwnerChannel {
860
856
  this.state.remember(wireId);
861
857
  return true;
862
858
  }
863
- // Interrupting the live turn does not remove prompts which were already
864
- // accepted into the ACP queue. Never claim this request is running while
865
- // the session itself says earlier work remains ahead of it.
866
- const accepted = queued.queuedBehind > 0
867
- ? ownerNotices.receivedQueued(queued.queuedBehind)
868
- : this.options.config.interrupt
869
- ? ownerNotices.receivedInterrupting()
870
- : ownerNotices.receivedStarted();
859
+ const accepted = this.acceptanceNotice(queued);
871
860
  this.inFlight.add(wireId);
872
861
  const receipt = this.send(sender.id, accepted, wireId).then(() => undefined).catch(error => {
873
862
  this.logError(`request ${requestId.slice(0, 12)} acceptance notice failed`, error);
@@ -1253,6 +1242,32 @@ export class OwnerChannel {
1253
1242
  authorizationIntegrity() {
1254
1243
  return this.options.config.agent ? { ok: true } : this.authorizations.integrity();
1255
1244
  }
1245
+ /**
1246
+ * Report what the session actually did with the prompt, not what the config
1247
+ * asked for. `interrupt: true` used to be reported as "your request
1248
+ * interrupted the previous task" unconditionally; the session now answers
1249
+ * whether anything was cancelled, whether the request is queued behind
1250
+ * earlier prompts, or whether it is held until the current task reaches a
1251
+ * safe stopping point. Backends that report no delivery state keep the old
1252
+ * queuedBehind-based wording.
1253
+ */
1254
+ acceptanceNotice(queued) {
1255
+ switch (queued.delivery) {
1256
+ case 'interrupted': return ownerNotices.receivedInterrupting();
1257
+ case 'deferred': return ownerNotices.receivedDeferred();
1258
+ case 'queued': return ownerNotices.receivedQueued(Math.max(1, queued.queuedBehind));
1259
+ case 'started': return ownerNotices.receivedStarted();
1260
+ default:
1261
+ // Interrupting the live turn does not remove prompts which were already
1262
+ // accepted into the ACP queue. Never claim this request is running while
1263
+ // the session itself says earlier work remains ahead of it.
1264
+ return queued.queuedBehind > 0
1265
+ ? ownerNotices.receivedQueued(queued.queuedBehind)
1266
+ : this.options.config.interrupt
1267
+ ? ownerNotices.receivedInterrupting()
1268
+ : ownerNotices.receivedStarted();
1269
+ }
1270
+ }
1256
1271
  async complete(active, outbox, queued, activityCursor) {
1257
1272
  const progressMs = this.options.config.progress_interval_ms;
1258
1273
  let lastSeq = activityCursor;
@@ -22,6 +22,13 @@ export declare const ownerNotices: {
22
22
  receivedStarted: () => string;
23
23
  receivedQueued: (queuedBehind: number) => string;
24
24
  receivedInterrupting: () => string;
25
+ /**
26
+ * The honest answer when the agent is mid-task and pre-empting it would have
27
+ * corrupted the conversation. Says "not started yet" rather than borrowing
28
+ * `receivedInterrupting`'s claim that something was cancelled for this
29
+ * request.
30
+ */
31
+ receivedDeferred: () => string;
25
32
  status: (role: string, snapshot: SessionSnapshot) => string;
26
33
  interrupted: (role: string) => string;
27
34
  /** The turn IS cancelled — say how, without implying the owner must retry. */
@@ -26,6 +26,15 @@ export const ownerNotices = {
26
26
  receivedInterrupting: () => "ℹ️ Message received. The agent's previous task was interrupted to prioritize "
27
27
  + 'this request, and it is now working on a response. '
28
28
  + 'The response will arrive in this channel when ready.',
29
+ /**
30
+ * The honest answer when the agent is mid-task and pre-empting it would have
31
+ * corrupted the conversation. Says "not started yet" rather than borrowing
32
+ * `receivedInterrupting`'s claim that something was cancelled for this
33
+ * request.
34
+ */
35
+ receivedDeferred: () => 'ℹ️ Message received and held. The agent is in the middle of a task that '
36
+ + 'cannot be interrupted safely; this request starts as soon as that work '
37
+ + 'reaches a stopping point. The response will arrive in this channel when ready.',
29
38
  status: (role, snapshot) => `📊 ${role} status: ${snapshot.readiness}; session is ${snapshot.alive ? 'online' : 'offline'}.`,
30
39
  interrupted: (role) => `🛑 Interrupt sent to ${role}'s active turn.`,
31
40
  /** The turn IS cancelled — say how, without implying the owner must retry. */
package/dist/runner.d.ts CHANGED
@@ -35,6 +35,15 @@ export interface RunnerDeps {
35
35
  }
36
36
  /** Environment injected only into the managed harness process. */
37
37
  export declare function managedFleetProxyEnv(role: ResolvedRole, stateDir: string): Record<string, string>;
38
+ /**
39
+ * The environment a managed harness child actually receives, checked at the one
40
+ * point where it is composed. `role.env` deliberately wins over harness prep,
41
+ * which is exactly how a stale fleet-wide model pin used to outrank the model
42
+ * the role was spawned with — so the model pin is verified here rather than
43
+ * trusted, and a disagreement stops the launch instead of being reported as a
44
+ * success (see src/model-env.ts).
45
+ */
46
+ export declare function harnessChildEnv(role: ResolvedRole, launchEnv: Record<string, string> | undefined, stateDir: string): Record<string, string>;
38
47
  /**
39
48
  * Record who owns wake delivery for this run. Returning true means a fleet
40
49
  * monitor is taking ownership back from a native harness and must start at the
@@ -61,6 +70,23 @@ export declare function readExitRecord(path: string): ExitRecord | null;
61
70
  export declare const RESTART_LEDGER_FILE = ".restart-ledger.json";
62
71
  /** Consecutive immediate failures tolerated before the agent is held down. */
63
72
  export declare const RESTART_FAIL_THRESHOLD = 5;
73
+ /**
74
+ * How the previous supervisor process ended.
75
+ *
76
+ * `abrupt` is the case the ledger used to miss entirely: an OOM-kill or any
77
+ * other external signal takes the supervisor down before it can write anything,
78
+ * the service manager restarts the unit, and every durable indicator still
79
+ * describes the run that died. A health check reading them reported "no
80
+ * restarts" for a role that had died and come back.
81
+ */
82
+ export interface TerminationRecord {
83
+ class: 'clean' | 'abrupt' | 'unknown';
84
+ detail: string;
85
+ /** When the SURVIVING process observed it, not when it happened. */
86
+ observedAt: string;
87
+ /** Start time of the run that ended, when it was recorded. */
88
+ runStartedAt?: string;
89
+ }
64
90
  export interface RestartLedger {
65
91
  version: 1;
66
92
  consecutiveImmediateFailures: number;
@@ -72,7 +98,29 @@ export interface RestartLedger {
72
98
  updatedAt: string;
73
99
  /** When the circuit opened, for the held-down status line. */
74
100
  openedAt?: string;
101
+ /** How the previous supervisor process ended, including abnormal exits. */
102
+ lastTermination?: TerminationRecord;
103
+ /** Supervisor processes that died without closing their run marker. */
104
+ abruptTerminations?: number;
105
+ /** Start of the supervisor run that owns this state directory now. */
106
+ supervisorStartedAt?: string;
75
107
  }
108
+ /**
109
+ * Carried across a supervisor process's life so its successor can tell an
110
+ * orderly exit from a kill. Present on disk == "a supervisor believed it was
111
+ * running"; the next start finding one that is not its own is proof the
112
+ * previous process died without getting to write anything.
113
+ */
114
+ export declare const RUN_MARKER_FILE = ".supervisor-run.json";
115
+ /**
116
+ * Claim this state directory for the current supervisor process and report how
117
+ * the previous one ended. Runs BEFORE the first attempt, which is the whole
118
+ * point: after an abrupt kill nothing else writes until an attempt finishes,
119
+ * and an attempt can take minutes.
120
+ */
121
+ export declare function claimSupervisorRun(dir: string, startedAt: string, pid?: number): TerminationRecord;
122
+ /** Orderly exit: the successor must not read this run as a kill. */
123
+ export declare function releaseSupervisorRun(dir: string): void;
76
124
  /** Bounded exponential backoff for the nth consecutive immediate failure. */
77
125
  export declare function backoffFor(consecutiveFailures: number): number;
78
126
  /** Read a role's restart ledger; a missing or corrupt one starts clean. */