@ours.network/fleet 0.19.0-nightly.9 → 1.0.1
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 +66 -11
- package/dist/application/fleet-query-service.d.ts +1 -1
- 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.d.ts +1 -1
- package/dist/briefing.js +103 -10
- package/dist/build-info.json +5 -5
- package/dist/cli.js +41 -11
- package/dist/config.d.ts +21 -7
- package/dist/config.js +32 -18
- package/dist/creation.d.ts +1 -1
- package/dist/docs.d.ts +2 -2
- package/dist/docs.js +117 -24
- package/dist/doctor.js +10 -10
- package/dist/duration.js +1 -1
- package/dist/fleet-proxy.d.ts +5 -0
- package/dist/harness/acp-agent.js +11 -6
- package/dist/harness/claude-code.js +201 -8
- package/dist/harness/codex.d.ts +4 -1
- package/dist/harness/codex.js +72 -13
- package/dist/harness/types.d.ts +57 -6
- package/dist/isolation/bubblewrap.d.ts +4 -4
- package/dist/isolation/bubblewrap.js +4 -4
- package/dist/isolation/policy.d.ts +1 -1
- package/dist/isolation/policy.js +2 -2
- package/dist/isolation/registry.d.ts +3 -3
- package/dist/isolation/registry.js +5 -5
- package/dist/isolation/resources.d.ts +1 -1
- package/dist/isolation/resources.js +1 -1
- package/dist/isolation/types.d.ts +3 -3
- 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.d.ts +2 -2
- package/dist/monitor.js +4 -4
- package/dist/ops.d.ts +2 -2
- package/dist/ops.js +9 -9
- package/dist/owner-channel/channel.d.ts +17 -0
- package/dist/owner-channel/channel.js +104 -23
- package/dist/owner-channel/commands.d.ts +9 -0
- package/dist/owner-channel/commands.js +226 -99
- package/dist/owner-channel/notices.d.ts +7 -0
- package/dist/owner-channel/notices.js +9 -0
- package/dist/rooms-tasks/cli.js +731 -219
- package/dist/rooms-tasks/close.d.ts +35 -0
- package/dist/rooms-tasks/close.js +190 -0
- package/dist/rooms-tasks/cowork-adapter.d.ts +24 -0
- package/dist/rooms-tasks/cowork-adapter.js +120 -0
- package/dist/rooms-tasks/external-worker.d.ts +2 -0
- package/dist/rooms-tasks/external-worker.js +40 -0
- package/dist/rooms-tasks/index.d.ts +2 -0
- package/dist/rooms-tasks/index.js +2 -0
- package/dist/rooms-tasks/markdown.d.ts +49 -0
- package/dist/rooms-tasks/markdown.js +206 -0
- package/dist/rooms-tasks/member-startup.d.ts +51 -0
- package/dist/rooms-tasks/member-startup.js +151 -0
- package/dist/rooms-tasks/provision.d.ts +8 -0
- package/dist/rooms-tasks/provision.js +418 -97
- package/dist/rooms-tasks/room-state.d.ts +13 -1
- package/dist/rooms-tasks/room-state.js +149 -1
- package/dist/rooms-tasks/task-state.d.ts +13 -2
- package/dist/rooms-tasks/task-state.js +131 -3
- package/dist/rooms-tasks/terminal.d.ts +23 -0
- package/dist/rooms-tasks/terminal.js +56 -0
- package/dist/rooms-tasks/types.d.ts +108 -2
- package/dist/runner.d.ts +48 -0
- package/dist/runner.js +252 -100
- package/dist/session/acp.d.ts +106 -2
- package/dist/session/acp.js +217 -14
- package/dist/session/activity.d.ts +31 -0
- package/dist/session/activity.js +48 -0
- package/dist/session/control.d.ts +1 -1
- package/dist/session/conversation-normalizer.d.ts +7 -1
- package/dist/session/conversation-normalizer.js +154 -11
- package/dist/session/conversation-store.js +1 -1
- package/dist/session/conversation-types.d.ts +25 -6
- package/dist/session/types.d.ts +35 -0
- package/dist/spawn.d.ts +4 -2
- package/dist/spawn.js +39 -26
- package/dist/supervisor/launchd.d.ts +2 -2
- package/dist/supervisor/launchd.js +4 -4
- package/dist/supervisor/systemd.js +4 -4
- package/dist/supervisor/types.d.ts +1 -1
- package/dist/temp-lifecycle.d.ts +14 -2
- package/dist/temp-lifecycle.js +90 -0
- package/dist/watchdog/alerts.d.ts +2 -2
- package/dist/watchdog/alerts.js +2 -2
- package/dist/watchdog/briefing.d.ts +5 -5
- package/dist/watchdog/briefing.js +13 -6
- package/dist/watchdog/config.js +1 -1
- package/dist/watchdog/query.d.ts +2 -2
- package/dist/watchdog/query.js +2 -2
- package/dist/watchdog/report.js +2 -2
- package/dist/watchdog/run.d.ts +2 -2
- package/dist/watchdog/run.js +6 -6
- package/dist/watchdog/scheduler.d.ts +7 -7
- package/dist/watchdog/scheduler.js +10 -10
- package/dist/watchdog/service.d.ts +4 -4
- package/dist/watchdog/service.js +5 -6
- package/dist/watchdog/store.d.ts +4 -4
- package/dist/watchdog/store.js +4 -4
- package/dist/web/runtime.js +1 -1
- package/dist/web/server.js +1 -1
- package/dist/web/topology-promote.js +1 -1
- 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 -1
|
@@ -23,7 +23,7 @@ export declare function mountConflict(mount: string, forbidden: string): 'exact'
|
|
|
23
23
|
*/
|
|
24
24
|
export declare function validateIsolationConfig(raw: unknown): string[];
|
|
25
25
|
/**
|
|
26
|
-
* Where a role's per-role harness runtime state lives
|
|
26
|
+
* Where a role's per-role harness runtime state lives. Under the agent's
|
|
27
27
|
* own state directory, so it is covered by the state dir's existing lifecycle
|
|
28
28
|
* and by the forbidden-path exception, and is never shared with a peer.
|
|
29
29
|
*/
|
package/dist/isolation/policy.js
CHANGED
|
@@ -105,7 +105,7 @@ export function validateIsolationConfig(raw) {
|
|
|
105
105
|
return problems;
|
|
106
106
|
}
|
|
107
107
|
/**
|
|
108
|
-
* Where a role's per-role harness runtime state lives
|
|
108
|
+
* Where a role's per-role harness runtime state lives. Under the agent's
|
|
109
109
|
* own state directory, so it is covered by the state dir's existing lifecycle
|
|
110
110
|
* and by the forbidden-path exception, and is never shared with a peer.
|
|
111
111
|
*/
|
|
@@ -147,7 +147,7 @@ export function resolveIsolation(cfg, ctx) {
|
|
|
147
147
|
addRw(stateDir);
|
|
148
148
|
addRw(runCwd);
|
|
149
149
|
if (ctx.harnessHome && ctx.harnessRuntimeDir) {
|
|
150
|
-
// The harness home is backed by a PER-ROLE directory
|
|
150
|
+
// The harness home is backed by a PER-ROLE directory: the agent gets a
|
|
151
151
|
// writable home for its sessions, caches and history, and anything a future
|
|
152
152
|
// CLI version writes lands there too. The shared credentials, global
|
|
153
153
|
// instructions and configuration are then layered back read-only, so they
|
|
@@ -11,12 +11,12 @@ export interface Selection {
|
|
|
11
11
|
detail: string;
|
|
12
12
|
}
|
|
13
13
|
/**
|
|
14
|
-
* Pick the isolation backend for a resolved policy, honouring
|
|
15
|
-
*
|
|
14
|
+
* Pick the isolation backend for a resolved policy, honouring rootless,
|
|
15
|
+
* bwrap-first `auto` and the `on_unavailable` degradation policy.
|
|
16
16
|
*
|
|
17
17
|
* - `none` → the identity backend.
|
|
18
18
|
* - `bubblewrap` → bwrap if available, else degrade/refuse per on_unavailable.
|
|
19
|
-
* - `podman` → not implemented
|
|
19
|
+
* - `podman` → not implemented ⇒ treated as unavailable.
|
|
20
20
|
* - `auto` → bwrap if available, else degrade/refuse.
|
|
21
21
|
*
|
|
22
22
|
* On `on_unavailable: strict` with nothing available, throws (fail closed).
|
|
@@ -4,12 +4,12 @@ import { makeNoneBackend } from './none.js';
|
|
|
4
4
|
export { makeBubblewrapBackend, unsharesNet } from './bubblewrap.js';
|
|
5
5
|
export { makeNoneBackend } from './none.js';
|
|
6
6
|
/**
|
|
7
|
-
* Pick the isolation backend for a resolved policy, honouring
|
|
8
|
-
*
|
|
7
|
+
* Pick the isolation backend for a resolved policy, honouring rootless,
|
|
8
|
+
* bwrap-first `auto` and the `on_unavailable` degradation policy.
|
|
9
9
|
*
|
|
10
10
|
* - `none` → the identity backend.
|
|
11
11
|
* - `bubblewrap` → bwrap if available, else degrade/refuse per on_unavailable.
|
|
12
|
-
* - `podman` → not implemented
|
|
12
|
+
* - `podman` → not implemented ⇒ treated as unavailable.
|
|
13
13
|
* - `auto` → bwrap if available, else degrade/refuse.
|
|
14
14
|
*
|
|
15
15
|
* On `on_unavailable: strict` with nothing available, throws (fail closed).
|
|
@@ -20,9 +20,9 @@ export async function selectIsolationBackend(policy, exec = realExec) {
|
|
|
20
20
|
const candidates = [];
|
|
21
21
|
if (policy.backend === 'auto' || policy.backend === 'bubblewrap')
|
|
22
22
|
candidates.push(makeBubblewrapBackend(exec));
|
|
23
|
-
// podman
|
|
23
|
+
// podman has no candidate yet, so it falls through to on_unavailable.
|
|
24
24
|
let lastDetail = policy.backend === 'podman'
|
|
25
|
-
? 'podman backend not implemented
|
|
25
|
+
? 'podman backend is not implemented'
|
|
26
26
|
: 'no isolation backend available';
|
|
27
27
|
for (const b of candidates) {
|
|
28
28
|
const a = await b.available();
|
|
@@ -5,7 +5,7 @@ export interface ResourceArgs {
|
|
|
5
5
|
}
|
|
6
6
|
/**
|
|
7
7
|
* Build the `systemd-run --user --scope -p … --` prefix that caps the pane's
|
|
8
|
-
* cgroup-v2 scope. Composed OUTSIDE the sandbox wrap
|
|
8
|
+
* cgroup-v2 scope. Composed OUTSIDE the sandbox wrap: a tmux pane is
|
|
9
9
|
* a child of a tmux SERVER rather than of the role's own runner process, so the
|
|
10
10
|
* only reliable per-agent limit is a transient scope at the pane itself. (Since
|
|
11
11
|
* #32 that server is per role rather than fleet-wide, which is what keeps one
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
/**
|
|
3
3
|
* Build the `systemd-run --user --scope -p … --` prefix that caps the pane's
|
|
4
|
-
* cgroup-v2 scope. Composed OUTSIDE the sandbox wrap
|
|
4
|
+
* cgroup-v2 scope. Composed OUTSIDE the sandbox wrap: a tmux pane is
|
|
5
5
|
* a child of a tmux SERVER rather than of the role's own runner process, so the
|
|
6
6
|
* only reliable per-agent limit is a transient scope at the pane itself. (Since
|
|
7
7
|
* #32 that server is per role rather than fleet-wide, which is what keeps one
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Isolation backend selector. `auto` probes bubblewrap
|
|
1
|
+
/** Isolation backend selector. `auto` probes bubblewrap; `none` disables wrapping. */
|
|
2
2
|
export type IsolationBackendId = 'auto' | 'bubblewrap' | 'podman' | 'none';
|
|
3
3
|
/** What to do when the requested backend is unavailable. */
|
|
4
4
|
export type OnUnavailable = 'warn' | 'strict';
|
|
@@ -38,7 +38,7 @@ export interface Mount {
|
|
|
38
38
|
mode: 'ro' | 'rw';
|
|
39
39
|
}
|
|
40
40
|
/**
|
|
41
|
-
* Runtime facts the pure resolver needs to compute the durable mount set
|
|
41
|
+
* Runtime facts the pure resolver needs to compute the durable mount set:
|
|
42
42
|
* the agent's state dir, its working dir, the fleet user's home, and (if the ours
|
|
43
43
|
* broker exposes one) a unix-socket endpoint to bind in.
|
|
44
44
|
*/
|
|
@@ -52,7 +52,7 @@ export interface WrapContext {
|
|
|
52
52
|
additionalWriteDirs?: string[];
|
|
53
53
|
/**
|
|
54
54
|
* The harness's home directory on the host (`~/.claude`, `~/.codex`). Mounted
|
|
55
|
-
* from `harnessRuntimeDir` so the agent's own runtime state is per-role
|
|
55
|
+
* from `harnessRuntimeDir` so the agent's own runtime state is per-role.
|
|
56
56
|
*/
|
|
57
57
|
harnessHome?: string;
|
|
58
58
|
/** Per-role writable directory backing `harnessHome` inside the sandbox. */
|
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.d.ts
CHANGED
|
@@ -122,7 +122,7 @@ export declare function filterEvents(events: NotifyEvent[], wakeSources: string[
|
|
|
122
122
|
/**
|
|
123
123
|
* Summarize a (coalesced) batch of events into one content-free console line —
|
|
124
124
|
* count + senders + ids, ending in the call to action. Falls back to compact
|
|
125
|
-
* counts when a burst would
|
|
125
|
+
* counts when a burst would exceed the length cap.
|
|
126
126
|
*/
|
|
127
127
|
export declare function formatNotificationLine(events: NotifyEvent[]): string;
|
|
128
128
|
/**
|
|
@@ -146,7 +146,7 @@ export declare function looksModal(pane: string): boolean;
|
|
|
146
146
|
* Heuristic: did the turn shown in this pane TERMINATE in an API-level error?
|
|
147
147
|
* Claude Code renders a failed turn's tail as an `API Error:` line (a Usage-Policy
|
|
148
148
|
* refusal, a 4xx, etc.). We scan a generous tail window so the marker survives a
|
|
149
|
-
* trailing idle composer redrawn beneath it
|
|
149
|
+
* trailing idle composer redrawn beneath it; refine empirically.
|
|
150
150
|
* The N-consecutive threshold in the Monitor debounces the odd false match.
|
|
151
151
|
*/
|
|
152
152
|
export declare function looksApiError(pane: string): boolean;
|
package/dist/monitor.js
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { classifyFailureText } from './model-recovery.js';
|
|
5
|
-
// Code constants
|
|
5
|
+
// Code constants rather than user configuration.
|
|
6
6
|
const DEFAULT_PORT = 3050;
|
|
7
7
|
// The daemon normally holds for 25s, but that value is operator-configurable
|
|
8
8
|
// and synchronous daemon work can delay the response. The former 35s timer
|
|
@@ -191,7 +191,7 @@ const plural = (n, one, many = one + 's') => (n === 1 ? one : many);
|
|
|
191
191
|
/**
|
|
192
192
|
* Summarize a (coalesced) batch of events into one content-free console line —
|
|
193
193
|
* count + senders + ids, ending in the call to action. Falls back to compact
|
|
194
|
-
* counts when a burst would
|
|
194
|
+
* counts when a burst would exceed the length cap.
|
|
195
195
|
*/
|
|
196
196
|
export function formatNotificationLine(events) {
|
|
197
197
|
const of = (t) => events.filter(e => e.event === t);
|
|
@@ -229,7 +229,7 @@ export function formatNotificationLine(events) {
|
|
|
229
229
|
].filter(Boolean).join(', ');
|
|
230
230
|
return `${PREFIX} ${compact} — run get_messages`;
|
|
231
231
|
}
|
|
232
|
-
// ─── Modal-dialog detection
|
|
232
|
+
// ─── Modal-dialog detection, refined empirically ─────────────────────────────
|
|
233
233
|
//
|
|
234
234
|
// `❯` is Claude Code's ordinary composer prompt, so it is on screen in nearly
|
|
235
235
|
// every capture. Testing for it *anywhere* in the pane therefore says nothing;
|
|
@@ -285,7 +285,7 @@ export function looksModal(pane) {
|
|
|
285
285
|
* Heuristic: did the turn shown in this pane TERMINATE in an API-level error?
|
|
286
286
|
* Claude Code renders a failed turn's tail as an `API Error:` line (a Usage-Policy
|
|
287
287
|
* refusal, a 4xx, etc.). We scan a generous tail window so the marker survives a
|
|
288
|
-
* trailing idle composer redrawn beneath it
|
|
288
|
+
* trailing idle composer redrawn beneath it; refine empirically.
|
|
289
289
|
* The N-consecutive threshold in the Monitor debounces the odd false match.
|
|
290
290
|
*/
|
|
291
291
|
export function looksApiError(pane) {
|
package/dist/ops.d.ts
CHANGED
|
@@ -21,12 +21,12 @@ export interface OpsDeps {
|
|
|
21
21
|
* Called the INSTANT a registration is created, before anything else can
|
|
22
22
|
* fail. A creation transaction that learns about registrations only from
|
|
23
23
|
* `up()`'s return value learns nothing when `up()` throws — and the service
|
|
24
|
-
* it just registered is then invisible to rollback
|
|
24
|
+
* it just registered is then invisible to rollback. Optional: plain
|
|
25
25
|
* `ours-fleet up` has no transaction to tell.
|
|
26
26
|
*/
|
|
27
27
|
onInstalled?(outcome: InstallOutcome): void;
|
|
28
28
|
/**
|
|
29
|
-
* Optional: supervises the single watchdog-scheduler process
|
|
29
|
+
* Optional: supervises the single watchdog-scheduler process.
|
|
30
30
|
* Absent for callers that predate watchdogs — `up`/`down` must never throw
|
|
31
31
|
* just because this hook is missing.
|
|
32
32
|
*/
|