@ours.network/fleet 0.18.0-nightly.6 → 0.18.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 +111 -43
- package/dist/application/fleet-query-service.js +12 -0
- package/dist/application/role-creation-service.js +3 -1
- package/dist/application/types.d.ts +11 -0
- package/dist/briefing.js +9 -2
- package/dist/build-info.json +7 -6
- package/dist/capabilities.d.ts +3 -1
- package/dist/capabilities.js +3 -0
- package/dist/cli.js +83 -7
- package/dist/config.d.ts +11 -3
- package/dist/config.js +40 -15
- package/dist/creation.d.ts +14 -15
- package/dist/creation.js +19 -13
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +117 -35
- package/dist/doctor.d.ts +1 -5
- package/dist/doctor.js +11 -18
- package/dist/fleet-proxy.d.ts +5 -0
- package/dist/harness/acp-agent.js +11 -6
- package/dist/harness/claude-code.js +204 -11
- package/dist/harness/codex.d.ts +4 -1
- package/dist/harness/codex.js +74 -12
- package/dist/harness/types.d.ts +54 -4
- package/dist/harness-plugins.d.ts +48 -0
- package/dist/harness-plugins.js +309 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/loops/manager.d.ts +30 -1
- package/dist/loops/manager.js +69 -6
- package/dist/loops/state.d.ts +18 -0
- package/dist/loops/state.js +4 -0
- package/dist/model-env.d.ts +71 -0
- package/dist/model-env.js +106 -0
- package/dist/monitor.js +1 -1
- package/dist/ops.js +1 -1
- package/dist/owner-channel/attachments.d.ts +2 -25
- package/dist/owner-channel/attachments.js +5 -61
- package/dist/owner-channel/channel.d.ts +30 -29
- package/dist/owner-channel/channel.js +291 -291
- package/dist/owner-channel/mcp.d.ts +24 -0
- package/dist/owner-channel/mcp.js +145 -0
- package/dist/owner-channel/notices.d.ts +7 -0
- package/dist/owner-channel/notices.js +9 -0
- package/dist/resolved-plan.js +1 -0
- package/dist/runner.d.ts +48 -0
- package/dist/runner.js +237 -85
- package/dist/session/acp.d.ts +104 -0
- package/dist/session/acp.js +213 -10
- package/dist/session/activity.d.ts +31 -0
- package/dist/session/activity.js +48 -0
- package/dist/session/conversation-normalizer.d.ts +6 -0
- package/dist/session/conversation-normalizer.js +153 -10
- package/dist/session/conversation-types.d.ts +23 -4
- package/dist/session/types.d.ts +35 -0
- package/dist/spawn.js +29 -17
- package/dist/supervisor/systemd.js +2 -29
- package/dist/watchdog/briefing.js +7 -0
- package/dist/web-app/assets/{TerminalView-BAVk1Bot.js → TerminalView-C_G1ID2P.js} +1 -1
- package/dist/web-app/assets/{index-C3S-xFRU.js → index-BCBK78hw.js} +5 -5
- package/dist/web-app/index.html +1 -1
- package/dist/worklog.d.ts +7 -1
- package/dist/worklog.js +191 -39
- package/package.json +1 -3
- package/dist/owner-channel/message-recovery.d.ts +0 -25
- package/dist/owner-channel/message-recovery.js +0 -114
- package/dist/owner-channel/ours-client.d.ts +0 -148
- package/dist/owner-channel/ours-client.js +0 -231
package/dist/loops/manager.d.ts
CHANGED
|
@@ -68,8 +68,32 @@ export declare class ScheduledLoopManager implements ScheduledLoopManagerHandle
|
|
|
68
68
|
private armAbandon;
|
|
69
69
|
private finish;
|
|
70
70
|
private advance;
|
|
71
|
+
/**
|
|
72
|
+
* Coalesce a backlog into one skip. The counters alone say how many
|
|
73
|
+
* occurrences were lost but never when or for how long, so the window is
|
|
74
|
+
* recorded too and carried on the state until a run is actually told about it
|
|
75
|
+
* — a dropped pass has to stay visible to the next one, not just to whoever
|
|
76
|
+
* was reading the log at the time.
|
|
77
|
+
*/
|
|
71
78
|
private skipMissed;
|
|
72
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Restart is not, by itself, a reason to lose an occurrence a running manager
|
|
81
|
+
* would still have run. `poll` tolerates lateness up to one full interval and
|
|
82
|
+
* runs the tick late; this path used to drop anything already due however
|
|
83
|
+
* recently, so a role restarted seconds after its own tick came due lost it
|
|
84
|
+
* outright. For an oversight role that is precisely the pass which would have
|
|
85
|
+
* recorded why it restarted, so the failure erased its own witness.
|
|
86
|
+
*
|
|
87
|
+
* The tolerance is the only thing shared with `poll`. A backlog at least one
|
|
88
|
+
* interval deep is still coalesced into a single skip and never replayed —
|
|
89
|
+
* after a long outage exactly one occurrence survives, and `schedule` then
|
|
90
|
+
* arms it through the ordinary path rather than firing a burst here.
|
|
91
|
+
*
|
|
92
|
+
* Running the survivor late cannot outpace the configured cadence: `advance`
|
|
93
|
+
* moves the cursor by exactly one `intervalMs` per occurrence from the nominal
|
|
94
|
+
* time, so a loop that keeps restarting still runs at most once per interval.
|
|
95
|
+
*/
|
|
96
|
+
private skipRestartBacklog;
|
|
73
97
|
/**
|
|
74
98
|
* A run the store could not record is dropped, not retried: the cursor has
|
|
75
99
|
* already moved, so this can never become a busy loop, and the outage is
|
|
@@ -86,5 +110,10 @@ export declare class ScheduledLoopManager implements ScheduledLoopManagerHandle
|
|
|
86
110
|
* until the process was restarted.
|
|
87
111
|
*/
|
|
88
112
|
private recover;
|
|
113
|
+
/**
|
|
114
|
+
* The envelope is the only channel a scheduled pass has for learning about
|
|
115
|
+
* the passes that did not happen. A gap stated here is what lets an oversight
|
|
116
|
+
* role report its own outage instead of resuming as if nothing was missed.
|
|
117
|
+
*/
|
|
89
118
|
private envelope;
|
|
90
119
|
}
|
package/dist/loops/manager.js
CHANGED
|
@@ -39,7 +39,7 @@ export class ScheduledLoopManager {
|
|
|
39
39
|
}
|
|
40
40
|
start() {
|
|
41
41
|
if (!this.store.fresh)
|
|
42
|
-
this.
|
|
42
|
+
this.skipRestartBacklog();
|
|
43
43
|
this.schedule();
|
|
44
44
|
}
|
|
45
45
|
async stop() {
|
|
@@ -143,10 +143,15 @@ export class ScheduledLoopManager {
|
|
|
143
143
|
async attempt(definition, state, scheduledAt) {
|
|
144
144
|
const runId = `sl_${randomUUID()}`;
|
|
145
145
|
const origin = { kind: 'scheduled-loop', loop: definition.name, runId };
|
|
146
|
-
|
|
146
|
+
// The gap is read here and cleared only if the turn is actually admitted:
|
|
147
|
+
// an attempt that ends `skipped_busy` or `unavailable` reported it to
|
|
148
|
+
// nobody, so it has to still be there for the attempt that succeeds.
|
|
149
|
+
const gap = state.missedGap;
|
|
150
|
+
const prompt = this.envelope(definition, runId, scheduledAt, gap);
|
|
147
151
|
let claimed = false;
|
|
148
152
|
const result = await this.arbiter.tryScheduled(prompt, origin, () => {
|
|
149
153
|
claimed = true;
|
|
154
|
+
state.missedGap = null;
|
|
150
155
|
state.activeRunId = runId;
|
|
151
156
|
state.lastRunId = runId;
|
|
152
157
|
state.lastStartedAt = new Date(this.deps.now()).toISOString();
|
|
@@ -265,7 +270,15 @@ export class ScheduledLoopManager {
|
|
|
265
270
|
state.nextScheduledAt = new Date(next).toISOString();
|
|
266
271
|
state.nextDueAt = new Date(next + deterministicJitter(this.role, definition.name, next, definition.jitterMs)).toISOString();
|
|
267
272
|
}
|
|
273
|
+
/**
|
|
274
|
+
* Coalesce a backlog into one skip. The counters alone say how many
|
|
275
|
+
* occurrences were lost but never when or for how long, so the window is
|
|
276
|
+
* recorded too and carried on the state until a run is actually told about it
|
|
277
|
+
* — a dropped pass has to stay visible to the next one, not just to whoever
|
|
278
|
+
* was reading the log at the time.
|
|
279
|
+
*/
|
|
268
280
|
skipMissed(definition, state, now) {
|
|
281
|
+
const from = state.nextScheduledAt;
|
|
269
282
|
let missed = 0;
|
|
270
283
|
while (Date.parse(state.nextDueAt) <= now) {
|
|
271
284
|
this.advance(definition, state);
|
|
@@ -275,14 +288,43 @@ export class ScheduledLoopManager {
|
|
|
275
288
|
state.counts.skippedMissed = increment(state.counts.skippedMissed, missed);
|
|
276
289
|
state.lastOutcome = 'skipped_missed';
|
|
277
290
|
state.lastFinishedAt = new Date(now).toISOString();
|
|
291
|
+
// Successive outages before any run lands merge into one gap: the earliest
|
|
292
|
+
// start wins, so the window always spans the whole silence.
|
|
293
|
+
const previous = state.missedGap;
|
|
294
|
+
state.missedGap = {
|
|
295
|
+
count: increment(previous?.count ?? 0, missed),
|
|
296
|
+
fromAt: previous?.fromAt ?? from,
|
|
297
|
+
throughAt: state.lastScheduledAt ?? from,
|
|
298
|
+
detectedAt: new Date(now).toISOString(),
|
|
299
|
+
};
|
|
278
300
|
this.store.persist();
|
|
279
|
-
this.deps.log(`[${this.role}] loop ${definition.name} skipped_missed count=${missed}`
|
|
301
|
+
this.deps.log(`[${this.role}] loop ${definition.name} skipped_missed count=${missed} `
|
|
302
|
+
+ `gap=${from}..${state.missedGap.throughAt} `
|
|
303
|
+
+ `unreported=${state.missedGap.count}`);
|
|
280
304
|
}
|
|
281
|
-
|
|
305
|
+
/**
|
|
306
|
+
* Restart is not, by itself, a reason to lose an occurrence a running manager
|
|
307
|
+
* would still have run. `poll` tolerates lateness up to one full interval and
|
|
308
|
+
* runs the tick late; this path used to drop anything already due however
|
|
309
|
+
* recently, so a role restarted seconds after its own tick came due lost it
|
|
310
|
+
* outright. For an oversight role that is precisely the pass which would have
|
|
311
|
+
* recorded why it restarted, so the failure erased its own witness.
|
|
312
|
+
*
|
|
313
|
+
* The tolerance is the only thing shared with `poll`. A backlog at least one
|
|
314
|
+
* interval deep is still coalesced into a single skip and never replayed —
|
|
315
|
+
* after a long outage exactly one occurrence survives, and `schedule` then
|
|
316
|
+
* arms it through the ordinary path rather than firing a burst here.
|
|
317
|
+
*
|
|
318
|
+
* Running the survivor late cannot outpace the configured cadence: `advance`
|
|
319
|
+
* moves the cursor by exactly one `intervalMs` per occurrence from the nominal
|
|
320
|
+
* time, so a loop that keeps restarting still runs at most once per interval.
|
|
321
|
+
*/
|
|
322
|
+
skipRestartBacklog() {
|
|
282
323
|
const now = this.deps.now();
|
|
283
324
|
for (const definition of this.definitions.values()) {
|
|
284
325
|
const state = this.store.state.loops[definition.name];
|
|
285
|
-
if (definition.enabled && !state.operatorDisabled
|
|
326
|
+
if (definition.enabled && !state.operatorDisabled
|
|
327
|
+
&& now >= Date.parse(state.nextDueAt) + definition.intervalMs)
|
|
286
328
|
this.skipMissed(definition, state, now);
|
|
287
329
|
}
|
|
288
330
|
}
|
|
@@ -340,17 +382,38 @@ export class ScheduledLoopManager {
|
|
|
340
382
|
this.deps.clearTimer(this.timer);
|
|
341
383
|
this.arm(backoffMs(this.pollFailures));
|
|
342
384
|
}
|
|
343
|
-
|
|
385
|
+
/**
|
|
386
|
+
* The envelope is the only channel a scheduled pass has for learning about
|
|
387
|
+
* the passes that did not happen. A gap stated here is what lets an oversight
|
|
388
|
+
* role report its own outage instead of resuming as if nothing was missed.
|
|
389
|
+
*/
|
|
390
|
+
envelope(definition, runId, scheduledAt, gap) {
|
|
391
|
+
const lateBy = Math.max(0, this.deps.now() - scheduledAt);
|
|
344
392
|
return [
|
|
345
393
|
'[fleet-loop]',
|
|
346
394
|
`loop: ${definition.name}`,
|
|
347
395
|
`run: ${runId}`,
|
|
348
396
|
`scheduled_at: ${new Date(scheduledAt).toISOString()}`,
|
|
397
|
+
...(lateBy > 0 ? [`started_late_by_ms: ${lateBy}`] : []),
|
|
398
|
+
...(gap ? [
|
|
399
|
+
`missed_occurrences: ${gap.count}`,
|
|
400
|
+
`missed_window: ${gap.fromAt}..${gap.throughAt}`,
|
|
401
|
+
`missed_gap_ms: ${Math.max(0, Date.parse(gap.detectedAt) - Date.parse(gap.fromAt))}`,
|
|
402
|
+
] : []),
|
|
349
403
|
'origin: local-trusted-config',
|
|
350
404
|
'',
|
|
351
405
|
'This is a scheduled internal maintenance turn, not an owner message and not ordinary ours mail.',
|
|
352
406
|
'Perform one bounded pass. Do not wait for the next tick. Do not report to an owner unless your',
|
|
353
407
|
'configured policy and an existing authenticated proactive-report route authorize a material report.',
|
|
408
|
+
// Same single route as the owner-request prompt, and for the same reason.
|
|
409
|
+
'To send a file, call ours `send_file` with the recipient and the path — to your owner-channel',
|
|
410
|
+
'identity if this role has one, otherwise directly to the contact who should receive it.',
|
|
411
|
+
'A file written anywhere else is not delivered and nothing will report that it was not.',
|
|
412
|
+
...(gap ? ['',
|
|
413
|
+
'This loop did not run for the window above: those occurrences were coalesced away while the role',
|
|
414
|
+
'was unavailable, and this pass is the first since. Treat the gap as part of what you are reporting',
|
|
415
|
+
'on — it is the record of your own outage, and no later pass will be told about it.',
|
|
416
|
+
] : []),
|
|
354
417
|
'',
|
|
355
418
|
definition.prompt,
|
|
356
419
|
].join('\n');
|
package/dist/loops/state.d.ts
CHANGED
|
@@ -8,11 +8,29 @@ export interface LoopCounts {
|
|
|
8
8
|
skippedBusy: number;
|
|
9
9
|
skippedMissed: number;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* A coalesced run of occurrences that were never submitted, held until a run
|
|
13
|
+
* actually starts and can be told about it. Without it a dropped occurrence
|
|
14
|
+
* survives only as a counter, which says how many were lost but never when or
|
|
15
|
+
* for how long — and an oversight role cannot report an outage it cannot date.
|
|
16
|
+
*/
|
|
17
|
+
export interface LoopMissedGap {
|
|
18
|
+
/** Occurrences coalesced away, summed across every skip since the last run. */
|
|
19
|
+
count: number;
|
|
20
|
+
/** Nominal time of the earliest occurrence in the gap. */
|
|
21
|
+
fromAt: string;
|
|
22
|
+
/** Nominal time of the latest occurrence in the gap. */
|
|
23
|
+
throughAt: string;
|
|
24
|
+
/** When the manager noticed — the end of the outage, not of the last skip. */
|
|
25
|
+
detectedAt: string;
|
|
26
|
+
}
|
|
11
27
|
export interface LoopRuntimeState {
|
|
12
28
|
definitionHash: string;
|
|
13
29
|
promptHash: string;
|
|
14
30
|
enabled: boolean;
|
|
15
31
|
operatorDisabled: boolean;
|
|
32
|
+
/** Unreported gap, cleared by the first run that carries it. */
|
|
33
|
+
missedGap: LoopMissedGap | null;
|
|
16
34
|
nextScheduledAt: string;
|
|
17
35
|
nextDueAt: string;
|
|
18
36
|
lastScheduledAt: string | null;
|
package/dist/loops/state.js
CHANGED
|
@@ -100,6 +100,9 @@ export class ScheduledLoopStateStore {
|
|
|
100
100
|
if (old?.definitionHash === definition.definitionHash) {
|
|
101
101
|
next[definition.name] = {
|
|
102
102
|
...old, promptHash: definition.promptHash, enabled: definition.enabled,
|
|
103
|
+
// A file written before this field existed restores as undefined; an
|
|
104
|
+
// unreported gap is absent, not lost, so normalize rather than trust.
|
|
105
|
+
missedGap: old.missedGap ?? null,
|
|
103
106
|
};
|
|
104
107
|
}
|
|
105
108
|
else {
|
|
@@ -119,6 +122,7 @@ export class ScheduledLoopStateStore {
|
|
|
119
122
|
activeRunId: old?.activeRunId ?? null,
|
|
120
123
|
counts: old?.counts ?? zeroCounts(), lastError: old?.lastError ?? null,
|
|
121
124
|
operatorDisabled: old?.operatorDisabled ?? false,
|
|
125
|
+
missedGap: old?.missedGap ?? null,
|
|
122
126
|
};
|
|
123
127
|
}
|
|
124
128
|
if (recoverActive && next[definition.name].activeRunId) {
|
|
@@ -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/monitor.js
CHANGED
|
@@ -42,7 +42,7 @@ class AuthError extends Error {
|
|
|
42
42
|
}
|
|
43
43
|
/** Path to the daemon config the MCP client uses: OURS_CONFIG ?? real ~/.ours/config.json. */
|
|
44
44
|
const daemonConfigPath = (env) => env.OURS_CONFIG ?? join(homedir(), '.ours', 'config.json');
|
|
45
|
-
/**
|
|
45
|
+
/** Match ours-mcp's env integer semantics: parseInt, invalid → absent. */
|
|
46
46
|
function envInt(env, name) {
|
|
47
47
|
const raw = env[name];
|
|
48
48
|
if (raw === undefined)
|
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);
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import { type FileHandle } from 'node:fs/promises';
|
|
2
1
|
import type { OwnerAttachmentConfig } from '../config.js';
|
|
3
|
-
import type { OursIncomingFile, OursRetrievedFiles } from './ours-client.js';
|
|
4
2
|
export interface AttachmentReplyRef {
|
|
5
3
|
wire_id: string;
|
|
6
4
|
sentence?: number;
|
|
@@ -44,14 +42,8 @@ export interface AdmittedAttachment {
|
|
|
44
42
|
kind: 'file' | 'voice_message';
|
|
45
43
|
transcription?: Omit<VoiceTranscription, 'audioPath'>;
|
|
46
44
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
* still re-validated here: sender CID, wire id, sizes and ids all cross the
|
|
50
|
-
* trust boundary and decide routing, so a daemon-side shape change must drop a
|
|
51
|
-
* row rather than produce a half-built attachment.
|
|
52
|
-
*/
|
|
53
|
-
export declare function parseIncomingAttachments(raw: OursIncomingFile[] | undefined): IncomingAttachment[];
|
|
54
|
-
export declare function parseRetrievedAttachments(raw: OursRetrievedFiles | undefined, expected: IncomingAttachment[], recovered?: boolean): RetrievedAttachment[];
|
|
45
|
+
export declare function parseIncomingAttachments(raw: unknown): IncomingAttachment[];
|
|
46
|
+
export declare function parseRetrievedAttachments(raw: unknown, expected: IncomingAttachment[], recovered?: boolean): RetrievedAttachment[];
|
|
55
47
|
export declare function validateAttachmentSelection(files: IncomingAttachment[], config: OwnerAttachmentConfig): string | undefined;
|
|
56
48
|
/**
|
|
57
49
|
* Managed-agent -> owner egress limits. This intentionally does not consult
|
|
@@ -62,21 +54,6 @@ export declare function prepareAttachmentDirectory(root: string, requestId: stri
|
|
|
62
54
|
export declare function admitAttachments(files: RetrievedAttachment[], dir: string, config: OwnerAttachmentConfig, options?: {
|
|
63
55
|
mimePolicy?: 'strict' | 'report-only';
|
|
64
56
|
}): Promise<AdmittedAttachment[]>;
|
|
65
|
-
/** Injectable short-write seam, so partial writes are provably handled. */
|
|
66
|
-
export interface AttachmentWriteDeps {
|
|
67
|
-
write?(handle: FileHandle, bytes: Uint8Array, offset: number): Promise<number>;
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* Land crash-recovered file bytes inside an already-prepared request directory.
|
|
71
|
-
*
|
|
72
|
-
* The MCP path handed the daemon a `dest_path` and let its connector write the
|
|
73
|
-
* file. Nothing writes on our behalf any more, so this owns both halves of that
|
|
74
|
-
* contract: the destination is DERIVED from a validated wire id inside `dir`
|
|
75
|
-
* rather than accepted from a caller, and the file is published by link-after-
|
|
76
|
-
* fsync, so a crash or a short write can never leave a partial file where the
|
|
77
|
-
* admission step would read it as complete.
|
|
78
|
-
*/
|
|
79
|
-
export declare function writeRecoveredAttachment(dir: string, wireId: string, bytes: Uint8Array, deps?: AttachmentWriteDeps): Promise<string>;
|
|
80
57
|
export declare function recoveredAttachment(file: IncomingAttachment, path: string): Promise<RetrievedAttachment>;
|
|
81
58
|
export declare function removeRequestDirectory(path: string): Promise<void>;
|
|
82
59
|
export declare function cleanupAttachmentRoot(root: string, now: number, retentionMs: number, limit?: number): Promise<number>;
|
|
@@ -6,14 +6,8 @@ import { replaceFileAtomically } from '../atomic-file.js';
|
|
|
6
6
|
const WIRE = /^[A-Fa-f0-9]{64}$/;
|
|
7
7
|
const CID = /^[A-Fa-f0-9]{64}$/;
|
|
8
8
|
const MAX_PENDING_REQUESTS = 32;
|
|
9
|
-
/**
|
|
10
|
-
* Admit the daemon's file listing. The rows are typed now, but every field is
|
|
11
|
-
* still re-validated here: sender CID, wire id, sizes and ids all cross the
|
|
12
|
-
* trust boundary and decide routing, so a daemon-side shape change must drop a
|
|
13
|
-
* row rather than produce a half-built attachment.
|
|
14
|
-
*/
|
|
15
9
|
export function parseIncomingAttachments(raw) {
|
|
16
|
-
const values = raw;
|
|
10
|
+
const values = raw?.files;
|
|
17
11
|
if (!Array.isArray(values))
|
|
18
12
|
return [];
|
|
19
13
|
const out = [];
|
|
@@ -46,12 +40,12 @@ export function parseIncomingAttachments(raw) {
|
|
|
46
40
|
export function parseRetrievedAttachments(raw, expected, recovered = false) {
|
|
47
41
|
const values = raw?.files;
|
|
48
42
|
if (!Array.isArray(values) || values.length !== expected.length)
|
|
49
|
-
throw new Error('
|
|
43
|
+
throw new Error('ours-mcp returned an incomplete selected attachment set');
|
|
50
44
|
const byWire = new Map(expected.map(file => [file.wireId, file]));
|
|
51
45
|
const out = [];
|
|
52
46
|
for (const value of values) {
|
|
53
47
|
if (!value || typeof value !== 'object')
|
|
54
|
-
throw new Error('
|
|
48
|
+
throw new Error('ours-mcp returned invalid attachment metadata');
|
|
55
49
|
const file = value;
|
|
56
50
|
const wireId = String(file.wire_id ?? '');
|
|
57
51
|
const listed = byWire.get(wireId);
|
|
@@ -64,7 +58,7 @@ export function parseRetrievedAttachments(raw, expected, recovered = false) {
|
|
|
64
58
|
|| !Number.isSafeInteger(size) || size !== listed.size || mime !== listed.mime
|
|
65
59
|
|| kind !== listed.kind || !/^[a-f0-9]{64}$/.test(sha256)
|
|
66
60
|
|| typeof file.path !== 'string' || !file.path)
|
|
67
|
-
throw new Error('selected attachment provenance or integrity metadata mismatched');
|
|
61
|
+
throw new Error('ours-mcp selected attachment provenance or integrity metadata mismatched');
|
|
68
62
|
out.push({
|
|
69
63
|
...listed, filename: safeField(file.filename, 255), mime, size, path: file.path, sha256, kind,
|
|
70
64
|
...(recovered ? {} : parseTranscription(file.transcription, wireId)),
|
|
@@ -72,7 +66,7 @@ export function parseRetrievedAttachments(raw, expected, recovered = false) {
|
|
|
72
66
|
byWire.delete(wireId);
|
|
73
67
|
}
|
|
74
68
|
if (byWire.size)
|
|
75
|
-
throw new Error('
|
|
69
|
+
throw new Error('ours-mcp omitted a selected attachment');
|
|
76
70
|
return out;
|
|
77
71
|
}
|
|
78
72
|
function parseTranscription(value, wireId) {
|
|
@@ -225,56 +219,6 @@ export async function admitAttachments(files, dir, config, options = {}) {
|
|
|
225
219
|
}
|
|
226
220
|
return admitted;
|
|
227
221
|
}
|
|
228
|
-
/**
|
|
229
|
-
* Land crash-recovered file bytes inside an already-prepared request directory.
|
|
230
|
-
*
|
|
231
|
-
* The MCP path handed the daemon a `dest_path` and let its connector write the
|
|
232
|
-
* file. Nothing writes on our behalf any more, so this owns both halves of that
|
|
233
|
-
* contract: the destination is DERIVED from a validated wire id inside `dir`
|
|
234
|
-
* rather than accepted from a caller, and the file is published by link-after-
|
|
235
|
-
* fsync, so a crash or a short write can never leave a partial file where the
|
|
236
|
-
* admission step would read it as complete.
|
|
237
|
-
*/
|
|
238
|
-
export async function writeRecoveredAttachment(dir, wireId, bytes, deps = {}) {
|
|
239
|
-
if (!WIRE.test(wireId))
|
|
240
|
-
throw new Error('recovered attachment wire id is not a 64-hex value');
|
|
241
|
-
const dirStat = await lstat(dir);
|
|
242
|
-
if (!dirStat.isDirectory() || dirStat.isSymbolicLink())
|
|
243
|
-
throw new Error('recovered attachment directory is not a safe directory');
|
|
244
|
-
const write = deps.write
|
|
245
|
-
?? ((handle, buffer, offset) => handle.write(buffer, offset, buffer.length - offset)
|
|
246
|
-
.then(result => result.bytesWritten));
|
|
247
|
-
const finalPath = join(dir, `.recovered-${wireId}-${randomUUID()}`);
|
|
248
|
-
const tmp = join(dir, `.${basename(finalPath)}.${randomUUID()}.tmp`);
|
|
249
|
-
const handle = await open(tmp, 'wx', 0o600);
|
|
250
|
-
try {
|
|
251
|
-
for (let written = 0; written < bytes.length;) {
|
|
252
|
-
const advanced = await write(handle, bytes, written);
|
|
253
|
-
if (advanced <= 0)
|
|
254
|
-
throw new Error(`recovered attachment write made no progress at byte ${written}`);
|
|
255
|
-
written += advanced;
|
|
256
|
-
}
|
|
257
|
-
await handle.sync();
|
|
258
|
-
}
|
|
259
|
-
catch (error) {
|
|
260
|
-
await handle.close().catch(() => undefined);
|
|
261
|
-
await rm(tmp, { force: true });
|
|
262
|
-
throw error;
|
|
263
|
-
}
|
|
264
|
-
await handle.close();
|
|
265
|
-
// link publishes the finished bytes under a name that never existed in a
|
|
266
|
-
// partial state; the temp is only ever removed after it succeeded.
|
|
267
|
-
try {
|
|
268
|
-
await link(tmp, finalPath);
|
|
269
|
-
}
|
|
270
|
-
catch (error) {
|
|
271
|
-
await rm(tmp, { force: true });
|
|
272
|
-
throw error;
|
|
273
|
-
}
|
|
274
|
-
await rm(tmp, { force: true });
|
|
275
|
-
await chmod(finalPath, 0o600);
|
|
276
|
-
return finalPath;
|
|
277
|
-
}
|
|
278
222
|
export async function recoveredAttachment(file, path) {
|
|
279
223
|
const stat = await lstat(path);
|
|
280
224
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== file.size)
|