@ours.network/fleet 0.18.1 → 0.19.0-nightly.2
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 +57 -91
- package/dist/application/fleet-query-service.js +0 -12
- package/dist/application/role-creation-service.js +10 -7
- package/dist/application/types.d.ts +0 -11
- package/dist/briefing.js +6 -15
- package/dist/build-info.json +5 -5
- package/dist/cli.js +12 -37
- package/dist/config.d.ts +8 -6
- package/dist/config.js +46 -30
- package/dist/creation.d.ts +38 -22
- package/dist/creation.js +111 -24
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +44 -95
- package/dist/doctor.d.ts +5 -1
- package/dist/doctor.js +18 -11
- package/dist/fleet-proxy.d.ts +0 -5
- package/dist/harness/acp-agent.js +6 -11
- package/dist/harness/claude-code.js +11 -200
- package/dist/harness/codex.d.ts +1 -4
- package/dist/harness/codex.js +12 -70
- package/dist/harness/types.d.ts +4 -54
- package/dist/loops/manager.d.ts +1 -30
- package/dist/loops/manager.js +6 -69
- package/dist/loops/state.d.ts +0 -18
- package/dist/loops/state.js +0 -4
- package/dist/monitor.js +1 -1
- package/dist/ops.d.ts +3 -0
- package/dist/ops.js +8 -3
- package/dist/owner-channel/attachments.d.ts +25 -2
- package/dist/owner-channel/attachments.js +61 -5
- package/dist/owner-channel/channel.d.ts +29 -30
- package/dist/owner-channel/channel.js +291 -291
- package/dist/owner-channel/commands.js +89 -0
- package/dist/owner-channel/message-recovery.d.ts +25 -0
- package/dist/owner-channel/message-recovery.js +114 -0
- package/dist/owner-channel/notices.d.ts +0 -7
- package/dist/owner-channel/notices.js +0 -9
- package/dist/owner-channel/ours-client.d.ts +148 -0
- package/dist/owner-channel/ours-client.js +231 -0
- package/dist/rooms-tasks/cli.d.ts +4 -0
- package/dist/rooms-tasks/cli.js +565 -0
- package/dist/rooms-tasks/config.d.ts +15 -0
- package/dist/rooms-tasks/config.js +171 -0
- package/dist/rooms-tasks/cowork-adapter.d.ts +80 -0
- package/dist/rooms-tasks/cowork-adapter.js +48 -0
- package/dist/rooms-tasks/index.d.ts +7 -0
- package/dist/rooms-tasks/index.js +7 -0
- package/dist/rooms-tasks/room-state.d.ts +22 -0
- package/dist/rooms-tasks/room-state.js +117 -0
- package/dist/rooms-tasks/task-state.d.ts +31 -0
- package/dist/rooms-tasks/task-state.js +173 -0
- package/dist/rooms-tasks/templates.d.ts +6 -0
- package/dist/rooms-tasks/templates.js +80 -0
- package/dist/rooms-tasks/types.d.ts +153 -0
- package/dist/rooms-tasks/types.js +20 -0
- package/dist/runner.d.ts +0 -48
- package/dist/runner.js +94 -236
- package/dist/session/acp.d.ts +0 -104
- package/dist/session/acp.js +10 -213
- package/dist/session/conversation-normalizer.d.ts +0 -6
- package/dist/session/conversation-normalizer.js +10 -153
- package/dist/session/conversation-types.d.ts +4 -23
- package/dist/session/types.d.ts +0 -35
- package/dist/spawn.js +26 -33
- package/dist/supervisor/systemd.js +29 -2
- package/dist/watchdog/briefing.js +0 -7
- package/dist/watchdog/run.js +3 -3
- package/dist/web-app/assets/{TerminalView-C_G1ID2P.js → TerminalView-BAVk1Bot.js} +1 -1
- package/dist/web-app/assets/{index-BCBK78hw.js → index-C3S-xFRU.js} +5 -5
- package/dist/web-app/index.html +1 -1
- package/dist/worklog.d.ts +1 -7
- package/dist/worklog.js +39 -191
- package/package.json +3 -1
- package/dist/model-env.d.ts +0 -71
- package/dist/model-env.js +0 -106
- package/dist/owner-channel/mcp.d.ts +0 -24
- package/dist/owner-channel/mcp.js +0 -145
- package/dist/session/activity.d.ts +0 -31
- package/dist/session/activity.js +0 -48
package/dist/harness/codex.js
CHANGED
|
@@ -5,7 +5,7 @@ import { agentDir, home } from '../paths.js';
|
|
|
5
5
|
import { realExec } from '../exec.js';
|
|
6
6
|
import { registerAdapter } from './registry.js';
|
|
7
7
|
import { harnessRuntimeDir } from '../isolation/policy.js';
|
|
8
|
-
import {
|
|
8
|
+
import { bundledAcpAgent, resolveBundledAcpAgent } from './acp-agent.js';
|
|
9
9
|
const OPTION_KEYS = [
|
|
10
10
|
'launcher', 'sandbox', 'approval', 'permission_mode', 'search', 'profile', 'config', 'add_dirs',
|
|
11
11
|
'monitor',
|
|
@@ -51,7 +51,9 @@ function sandboxMode(role) {
|
|
|
51
51
|
throw new Error(`invalid harness_options.sandbox "${s}"; allowed: ${SANDBOX_MODES.join(', ')}`);
|
|
52
52
|
return s;
|
|
53
53
|
}
|
|
54
|
-
|
|
54
|
+
/** codex-acp exposes the same sandbox postures as named ACP agent modes. */
|
|
55
|
+
function acpAgentMode(role) {
|
|
56
|
+
const sandbox = sandboxMode(role);
|
|
55
57
|
if (sandbox === 'read-only')
|
|
56
58
|
return 'read-only';
|
|
57
59
|
if (sandbox === 'workspace-write')
|
|
@@ -60,26 +62,6 @@ function modeForSandbox(sandbox) {
|
|
|
60
62
|
return 'agent-full-access';
|
|
61
63
|
return undefined;
|
|
62
64
|
}
|
|
63
|
-
/**
|
|
64
|
-
* Resolve the coupled Codex ACP mode.
|
|
65
|
-
*
|
|
66
|
-
* The portable approval contract owns the default mode selection: `allow`
|
|
67
|
-
* means the adapter's fully non-interactive yolo preset and `auto` means its
|
|
68
|
-
* ordinary agent preset. This intentionally means that Codex ACP cannot retain
|
|
69
|
-
* an independent neutral filesystem posture for those two modes. An explicit
|
|
70
|
-
* native sandbox remains authoritative and selects its corresponding preset.
|
|
71
|
-
*/
|
|
72
|
-
function acpAgentMode(role) {
|
|
73
|
-
const explicitSandbox = role.harness_options?.sandbox;
|
|
74
|
-
if (explicitSandbox != null)
|
|
75
|
-
return modeForSandbox(sandboxMode(role));
|
|
76
|
-
if (role.permissions?.approval === 'allow')
|
|
77
|
-
return 'agent-full-access';
|
|
78
|
-
if (role.permissions?.approval === 'auto')
|
|
79
|
-
return 'agent';
|
|
80
|
-
const sandbox = sandboxMode(role);
|
|
81
|
-
return modeForSandbox(sandbox);
|
|
82
|
-
}
|
|
83
65
|
function acpModePermissions(mode) {
|
|
84
66
|
if (mode === 'read-only')
|
|
85
67
|
return { approval: 'on-request', sandbox: 'read-only' };
|
|
@@ -87,10 +69,6 @@ function acpModePermissions(mode) {
|
|
|
87
69
|
return { approval: 'never', sandbox: 'danger-full-access' };
|
|
88
70
|
return { approval: 'on-request', sandbox: 'workspace-write' };
|
|
89
71
|
}
|
|
90
|
-
/** The sandbox Codex will actually receive from the selected coupled ACP mode. */
|
|
91
|
-
function acpRuntimeSandbox(role) {
|
|
92
|
-
return acpModePermissions(acpAgentMode(role)).sandbox;
|
|
93
|
-
}
|
|
94
72
|
function fleetModeForApproval(nativeMode) {
|
|
95
73
|
if (nativeMode === 'never')
|
|
96
74
|
return 'allow';
|
|
@@ -124,18 +102,6 @@ function launcherMode(role) {
|
|
|
124
102
|
function bundledCodexAcp() {
|
|
125
103
|
return resolveBundledAcpAgent(CODEX_ACP_PACKAGE, 'codex-acp', 'codex-acp');
|
|
126
104
|
}
|
|
127
|
-
/** Bind launch argv and metadata provenance to one already-completed resolution. */
|
|
128
|
-
export function codexAcpLaunchForResolution(resolution) {
|
|
129
|
-
const permissionMetadataSource = resolution.bundled
|
|
130
|
-
&& resolution.version === BUNDLED_CODEX_ACP_VERSION
|
|
131
|
-
&& resolution.manifestPath !== undefined
|
|
132
|
-
? 'codex-acp'
|
|
133
|
-
: undefined;
|
|
134
|
-
return {
|
|
135
|
-
argv: [...resolution.argv],
|
|
136
|
-
...(permissionMetadataSource ? { permissionMetadataSource } : {}),
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
105
|
function canOverrideBundledAcpApproval() {
|
|
140
106
|
const resolution = bundledCodexAcp();
|
|
141
107
|
return resolution.bundled && resolution.version === BUNDLED_CODEX_ACP_VERSION
|
|
@@ -181,7 +147,7 @@ function codexAcpEnvironment(role, dirs) {
|
|
|
181
147
|
return {
|
|
182
148
|
CODEX_PATH: command,
|
|
183
149
|
[CODEX_PROXY_APPROVAL_ENV]: approvalPolicy(role) ?? 'on-request',
|
|
184
|
-
[CODEX_PROXY_SANDBOX_ENV]:
|
|
150
|
+
[CODEX_PROXY_SANDBOX_ENV]: sandboxMode(role) ?? 'workspace-write',
|
|
185
151
|
[CODEX_PROXY_MANIFEST_ENV]: resolution.manifestPath,
|
|
186
152
|
...(process.env.CODEX_PATH ? { [CODEX_PROXY_REAL_PATH_ENV]: process.env.CODEX_PATH } : {}),
|
|
187
153
|
};
|
|
@@ -319,16 +285,7 @@ export function makeCodexAdapter(exec = realExec) {
|
|
|
319
285
|
if (requested === 'ours-codex' && !hasOursCodex)
|
|
320
286
|
throw new Error('harness_options.launcher is ours-codex, but ours-codex is not on PATH; install @ours.network/codex or use launcher: auto');
|
|
321
287
|
const command = requested === 'codex' ? 'codex' : hasOursCodex ? 'ours-codex' : 'codex';
|
|
322
|
-
return {
|
|
323
|
-
argv: [],
|
|
324
|
-
// OURS_BIND_IDENTITY is the connector's startup bind seed — see the note in
|
|
325
|
-
// claude-code.ts's prepareSession. It belongs on EVERY harness that runs a
|
|
326
|
-
// role with an ours identity, not just claude-code: a seed that works on one
|
|
327
|
-
// harness and silently does nothing on the other is the same class of defect
|
|
328
|
-
// as a config key that only works on one session type.
|
|
329
|
-
env: { OURS_BIND_IDENTITY: role.identity, ...codexAcpEnvironment(role, dirs) },
|
|
330
|
-
command,
|
|
331
|
-
};
|
|
288
|
+
return { argv: [], env: codexAcpEnvironment(role, dirs), command };
|
|
332
289
|
},
|
|
333
290
|
buildLaunch(role, mode, _s, prep) {
|
|
334
291
|
const stateDir = roleStateDir(role);
|
|
@@ -342,30 +299,17 @@ export function makeCodexAdapter(exec = realExec) {
|
|
|
342
299
|
},
|
|
343
300
|
buildAcpLaunch(role, prep) {
|
|
344
301
|
const configured = role.session_options?.acp?.command;
|
|
345
|
-
// Resolve once: both argv and permission-metadata provenance must describe
|
|
346
|
-
// the same artifact. A bare PATH fallback is launchable for compatibility,
|
|
347
|
-
// but is never authenticated for protected-MCP auto-approval.
|
|
348
|
-
const resolved = configured == null
|
|
349
|
-
? codexAcpLaunchForResolution(bundledCodexAcp())
|
|
350
|
-
: undefined;
|
|
351
302
|
const argv = Array.isArray(configured)
|
|
352
303
|
? [...configured]
|
|
353
304
|
: typeof configured === 'string'
|
|
354
305
|
? ['sh', '-c', configured]
|
|
355
|
-
:
|
|
306
|
+
: bundledAcpAgent(CODEX_ACP_PACKAGE, 'codex-acp', 'codex-acp');
|
|
356
307
|
const initialMode = acpAgentMode(role);
|
|
357
308
|
return {
|
|
358
309
|
argv,
|
|
359
310
|
env: initialMode ? { ...prep.env, INITIAL_AGENT_MODE: initialMode } : prep.env,
|
|
360
|
-
...(resolved?.permissionMetadataSource
|
|
361
|
-
? { permissionMetadataSource: resolved.permissionMetadataSource } : {}),
|
|
362
311
|
};
|
|
363
312
|
},
|
|
364
|
-
// INITIAL_AGENT_MODE covers session/new in codex-acp; session/set_mode
|
|
365
|
-
// keeps resumed/loaded sessions and live status on the identical mode.
|
|
366
|
-
acpPermissionModeId(role) {
|
|
367
|
-
return acpAgentMode(role);
|
|
368
|
-
},
|
|
369
313
|
isolationPaths(role, _dirs) {
|
|
370
314
|
const codexHome = join(home(), '.codex');
|
|
371
315
|
const profile = role.harness_options?.profile;
|
|
@@ -418,9 +362,7 @@ export function makeCodexAdapter(exec = realExec) {
|
|
|
418
362
|
const mode = acpAgentMode(role) ?? 'agent';
|
|
419
363
|
const configured = role.session_options?.acp?.command;
|
|
420
364
|
const overrideAvailable = configured == null && canOverrideBundledAcpApproval();
|
|
421
|
-
const actual = overrideAvailable
|
|
422
|
-
? { approval, sandbox: acpRuntimeSandbox(role) }
|
|
423
|
-
: acpModePermissions(mode);
|
|
365
|
+
const actual = overrideAvailable ? { approval, sandbox } : acpModePermissions(mode);
|
|
424
366
|
const exact = actual.approval === approval && actual.sandbox === sandbox;
|
|
425
367
|
return {
|
|
426
368
|
...translated,
|
|
@@ -430,9 +372,10 @@ export function makeCodexAdapter(exec = realExec) {
|
|
|
430
372
|
? `custom ACP command cannot be verified against approval=${approval} sandbox=${sandbox}; `
|
|
431
373
|
+ `its '${mode}' mode is conservatively treated as approval=${actual.approval} `
|
|
432
374
|
+ `sandbox=${actual.sandbox}`
|
|
433
|
-
: `
|
|
434
|
-
+ `
|
|
435
|
-
+ `represent
|
|
375
|
+
: `codex-acp mode '${mode}' actually uses approval=${actual.approval} `
|
|
376
|
+
+ `sandbox=${actual.sandbox}, and the bundled ${BUNDLED_CODEX_ACP_VERSION} `
|
|
377
|
+
+ `app-server override is unavailable; this does not exactly represent `
|
|
378
|
+
+ `approval=${approval} sandbox=${sandbox}`],
|
|
436
379
|
capabilities: codexCapabilities(actual.approval, actual.sandbox),
|
|
437
380
|
};
|
|
438
381
|
}
|
|
@@ -468,7 +411,6 @@ export function makeCodexAdapter(exec = realExec) {
|
|
|
468
411
|
currentIdentityTool: 'current_identity',
|
|
469
412
|
sendTool: 'send_message',
|
|
470
413
|
getMessagesTool: 'get_messages',
|
|
471
|
-
watchCommand: id => `ours-mcp watch "${id}"`,
|
|
472
414
|
monitorInstruction: (id, configuredRole) => {
|
|
473
415
|
const consented = configuredRole?.harness_options?.monitor === true;
|
|
474
416
|
const consent = consented
|
package/dist/harness/types.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { McpServer } from '@agentclientprotocol/sdk';
|
|
2
1
|
import type { CommonPermissions, FleetPermissionMode, ResolvedRole } from '../config.js';
|
|
3
2
|
export interface PrereqCheck {
|
|
4
3
|
name: string;
|
|
@@ -22,39 +21,14 @@ export interface SessionPrep {
|
|
|
22
21
|
env: Record<string, string>;
|
|
23
22
|
/** Optional launcher selected after runtime prerequisite probing. */
|
|
24
23
|
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;
|
|
39
24
|
}
|
|
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;
|
|
51
25
|
export interface Launch {
|
|
52
26
|
argv: string[];
|
|
53
27
|
env: Record<string, string>;
|
|
54
28
|
}
|
|
55
|
-
export interface AcpLaunch
|
|
56
|
-
|
|
57
|
-
|
|
29
|
+
export interface AcpLaunch {
|
|
30
|
+
argv: string[];
|
|
31
|
+
env: Record<string, string>;
|
|
58
32
|
}
|
|
59
33
|
/**
|
|
60
34
|
* The result of expressing neutral `permissions:` in a harness's own terms.
|
|
@@ -93,7 +67,6 @@ export interface BriefingVocab {
|
|
|
93
67
|
currentIdentityTool: string;
|
|
94
68
|
sendTool: string;
|
|
95
69
|
getMessagesTool: string;
|
|
96
|
-
watchCommand(identity: string): string;
|
|
97
70
|
monitorInstruction(identity: string, role?: ResolvedRole): string;
|
|
98
71
|
/** Wake-source wording for a role whose monitor is supervisor-owned (monitor.mode=fleet). */
|
|
99
72
|
supervisedWakeNote(identity: string, role?: ResolvedRole): string;
|
|
@@ -122,13 +95,7 @@ export interface HarnessAdapter {
|
|
|
122
95
|
id: string;
|
|
123
96
|
supportsResume: boolean;
|
|
124
97
|
checkPrereqs(): Promise<PrereqReport>;
|
|
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[];
|
|
98
|
+
validateOptions(opts: unknown): ValidationError[];
|
|
132
99
|
prepareSession(role: ResolvedRole, dirs: RoleDirs): Promise<SessionPrep>;
|
|
133
100
|
buildLaunch(role: ResolvedRole, mode: 'fresh' | 'resume', s: SessionState, prep: SessionPrep): Launch;
|
|
134
101
|
buildAcpLaunch?(role: ResolvedRole, prep: SessionPrep): AcpLaunch;
|
|
@@ -138,23 +105,6 @@ export interface HarnessAdapter {
|
|
|
138
105
|
* agent's default. Omit for a harness whose ACP agent has no modes.
|
|
139
106
|
*/
|
|
140
107
|
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;
|
|
158
108
|
/** Effective portable policy and harness-native approval mode after native overrides win. */
|
|
159
109
|
effectivePermissionMode?(role: ResolvedRole): {
|
|
160
110
|
fleetMode: FleetPermissionMode;
|
package/dist/loops/manager.d.ts
CHANGED
|
@@ -68,32 +68,8 @@ 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
|
-
*/
|
|
78
71
|
private skipMissed;
|
|
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;
|
|
72
|
+
private skipRestartMisses;
|
|
97
73
|
/**
|
|
98
74
|
* A run the store could not record is dropped, not retried: the cursor has
|
|
99
75
|
* already moved, so this can never become a busy loop, and the outage is
|
|
@@ -110,10 +86,5 @@ export declare class ScheduledLoopManager implements ScheduledLoopManagerHandle
|
|
|
110
86
|
* until the process was restarted.
|
|
111
87
|
*/
|
|
112
88
|
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
|
-
*/
|
|
118
89
|
private envelope;
|
|
119
90
|
}
|
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.skipRestartMisses();
|
|
43
43
|
this.schedule();
|
|
44
44
|
}
|
|
45
45
|
async stop() {
|
|
@@ -143,15 +143,10 @@ 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
|
-
|
|
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);
|
|
146
|
+
const prompt = this.envelope(definition, runId, scheduledAt);
|
|
151
147
|
let claimed = false;
|
|
152
148
|
const result = await this.arbiter.tryScheduled(prompt, origin, () => {
|
|
153
149
|
claimed = true;
|
|
154
|
-
state.missedGap = null;
|
|
155
150
|
state.activeRunId = runId;
|
|
156
151
|
state.lastRunId = runId;
|
|
157
152
|
state.lastStartedAt = new Date(this.deps.now()).toISOString();
|
|
@@ -270,15 +265,7 @@ export class ScheduledLoopManager {
|
|
|
270
265
|
state.nextScheduledAt = new Date(next).toISOString();
|
|
271
266
|
state.nextDueAt = new Date(next + deterministicJitter(this.role, definition.name, next, definition.jitterMs)).toISOString();
|
|
272
267
|
}
|
|
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
|
-
*/
|
|
280
268
|
skipMissed(definition, state, now) {
|
|
281
|
-
const from = state.nextScheduledAt;
|
|
282
269
|
let missed = 0;
|
|
283
270
|
while (Date.parse(state.nextDueAt) <= now) {
|
|
284
271
|
this.advance(definition, state);
|
|
@@ -288,43 +275,14 @@ export class ScheduledLoopManager {
|
|
|
288
275
|
state.counts.skippedMissed = increment(state.counts.skippedMissed, missed);
|
|
289
276
|
state.lastOutcome = 'skipped_missed';
|
|
290
277
|
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
|
-
};
|
|
300
278
|
this.store.persist();
|
|
301
|
-
this.deps.log(`[${this.role}] loop ${definition.name} skipped_missed count=${missed}
|
|
302
|
-
+ `gap=${from}..${state.missedGap.throughAt} `
|
|
303
|
-
+ `unreported=${state.missedGap.count}`);
|
|
279
|
+
this.deps.log(`[${this.role}] loop ${definition.name} skipped_missed count=${missed}`);
|
|
304
280
|
}
|
|
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() {
|
|
281
|
+
skipRestartMisses() {
|
|
323
282
|
const now = this.deps.now();
|
|
324
283
|
for (const definition of this.definitions.values()) {
|
|
325
284
|
const state = this.store.state.loops[definition.name];
|
|
326
|
-
if (definition.enabled && !state.operatorDisabled
|
|
327
|
-
&& now >= Date.parse(state.nextDueAt) + definition.intervalMs)
|
|
285
|
+
if (definition.enabled && !state.operatorDisabled && Date.parse(state.nextDueAt) <= now)
|
|
328
286
|
this.skipMissed(definition, state, now);
|
|
329
287
|
}
|
|
330
288
|
}
|
|
@@ -382,38 +340,17 @@ export class ScheduledLoopManager {
|
|
|
382
340
|
this.deps.clearTimer(this.timer);
|
|
383
341
|
this.arm(backoffMs(this.pollFailures));
|
|
384
342
|
}
|
|
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);
|
|
343
|
+
envelope(definition, runId, scheduledAt) {
|
|
392
344
|
return [
|
|
393
345
|
'[fleet-loop]',
|
|
394
346
|
`loop: ${definition.name}`,
|
|
395
347
|
`run: ${runId}`,
|
|
396
348
|
`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
|
-
] : []),
|
|
403
349
|
'origin: local-trusted-config',
|
|
404
350
|
'',
|
|
405
351
|
'This is a scheduled internal maintenance turn, not an owner message and not ordinary ours mail.',
|
|
406
352
|
'Perform one bounded pass. Do not wait for the next tick. Do not report to an owner unless your',
|
|
407
353
|
'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
|
-
] : []),
|
|
417
354
|
'',
|
|
418
355
|
definition.prompt,
|
|
419
356
|
].join('\n');
|
package/dist/loops/state.d.ts
CHANGED
|
@@ -8,29 +8,11 @@ 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
|
-
}
|
|
27
11
|
export interface LoopRuntimeState {
|
|
28
12
|
definitionHash: string;
|
|
29
13
|
promptHash: string;
|
|
30
14
|
enabled: boolean;
|
|
31
15
|
operatorDisabled: boolean;
|
|
32
|
-
/** Unreported gap, cleared by the first run that carries it. */
|
|
33
|
-
missedGap: LoopMissedGap | null;
|
|
34
16
|
nextScheduledAt: string;
|
|
35
17
|
nextDueAt: string;
|
|
36
18
|
lastScheduledAt: string | null;
|
package/dist/loops/state.js
CHANGED
|
@@ -100,9 +100,6 @@ 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,
|
|
106
103
|
};
|
|
107
104
|
}
|
|
108
105
|
else {
|
|
@@ -122,7 +119,6 @@ export class ScheduledLoopStateStore {
|
|
|
122
119
|
activeRunId: old?.activeRunId ?? null,
|
|
123
120
|
counts: old?.counts ?? zeroCounts(), lastError: old?.lastError ?? null,
|
|
124
121
|
operatorDisabled: old?.operatorDisabled ?? false,
|
|
125
|
-
missedGap: old?.missedGap ?? null,
|
|
126
122
|
};
|
|
127
123
|
}
|
|
128
124
|
if (recoverActive && next[definition.name].activeRunId) {
|
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
|
+
/** Preserve the daemon's legacy 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.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { FleetConfig, ResolvedRole } from './config.js';
|
|
2
2
|
import type { InstallOutcome as BackendInstallOutcome, SupervisorBackend } from './supervisor/types.js';
|
|
3
3
|
import { type Exec } from './exec.js';
|
|
4
|
+
import { type IdentityProvisioner } from './creation.js';
|
|
4
5
|
/** An install outcome tagged with the role it belongs to. */
|
|
5
6
|
export interface InstallOutcome extends BackendInstallOutcome {
|
|
6
7
|
role: string;
|
|
@@ -14,6 +15,8 @@ export interface OpsDeps {
|
|
|
14
15
|
/** Test seam for exact detached-supervisor signaling/liveness. */
|
|
15
16
|
kill?(pid: number, signal: NodeJS.Signals | 0): void;
|
|
16
17
|
sleep?(ms: number): Promise<void>;
|
|
18
|
+
/** Permanent identity reconciliation seam; temporary roles never use this lifecycle. */
|
|
19
|
+
identityProvisioner?: IdentityProvisioner;
|
|
17
20
|
/**
|
|
18
21
|
* Called the INSTANT a registration is created, before anything else can
|
|
19
22
|
* fail. A creation transaction that learns about registrations only from
|
package/dist/ops.js
CHANGED
|
@@ -8,6 +8,7 @@ import { generateBriefing } from './briefing.js';
|
|
|
8
8
|
import { resetRestartLedger } from './runner.js';
|
|
9
9
|
import { archiveTempState, stopTempSupervisor, tempSupervisorLiveness, } from './temp-lifecycle.js';
|
|
10
10
|
import { realExec } from './exec.js';
|
|
11
|
+
import { daemonIdentityProvisioner, reconcilePermanentRoleIdentities, } from './creation.js';
|
|
11
12
|
// Launch staggering now lives at the harness-launch point (the runner's start
|
|
12
13
|
// gate, driven by `start_stagger_ms`), so it covers systemd host-boot too — not
|
|
13
14
|
// just the `up`/`restart` command loop below. The old in-loop FLEET_START_STAGGER
|
|
@@ -16,7 +17,7 @@ import { realExec } from './exec.js';
|
|
|
16
17
|
/** Materialize a role's state dir from config: briefing + markers. Returns the dir. */
|
|
17
18
|
export function applyRole(role, opts = {}) {
|
|
18
19
|
const adapter = getAdapter(role.harness);
|
|
19
|
-
const errs = adapter.validateOptions(role.harness_options
|
|
20
|
+
const errs = adapter.validateOptions(role.harness_options);
|
|
20
21
|
if (errs.length)
|
|
21
22
|
throw new Error(`role '${role.name}': ` + errs.map(e => `${e.path}: ${e.message}`).join('; '));
|
|
22
23
|
const dir = agentDir(role.name, opts.temp === true);
|
|
@@ -54,7 +55,8 @@ function selectRoles(cfg, names) {
|
|
|
54
55
|
export async function up(cfg, names, deps, configPath, identityGuarantee) {
|
|
55
56
|
const outcomes = [];
|
|
56
57
|
for (const role of selectRoles(cfg, names)) {
|
|
57
|
-
const
|
|
58
|
+
const guarantee = await reconcilePermanentRoleIdentities(role, deps.identityProvisioner ?? daemonIdentityProvisioner(), deps.log, identityGuarantee);
|
|
59
|
+
const dir = applyRole(role, { configPath, identityGuarantee: guarantee });
|
|
58
60
|
// Only a *definite* stop boots fresh so the role reads the briefing we just
|
|
59
61
|
// wrote. A running, restarting, or unprobeable role keeps its context —
|
|
60
62
|
// guessing "stopped" from an unanswered probe silently discards a live
|
|
@@ -221,7 +223,10 @@ export async function down(cfg, names, deps) {
|
|
|
221
223
|
/** Re-sync from config + bounce. mode 'keep' resumes context; 'fresh' wipes it. */
|
|
222
224
|
export async function restartRoles(cfg, names, deps, mode, configPath) {
|
|
223
225
|
for (const role of selectRoles(cfg, names)) {
|
|
224
|
-
const
|
|
226
|
+
const guarantee = await reconcilePermanentRoleIdentities(role, deps.identityProvisioner ?? daemonIdentityProvisioner(), deps.log);
|
|
227
|
+
const dir = applyRole(role, {
|
|
228
|
+
fresh: mode === 'fresh', configPath, identityGuarantee: guarantee,
|
|
229
|
+
});
|
|
225
230
|
resetRestartLedger(dir); // explicit restart closes the circuit
|
|
226
231
|
await deps.backend.restart(role.name);
|
|
227
232
|
deps.log(mode === 'fresh'
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { type FileHandle } from 'node:fs/promises';
|
|
1
2
|
import type { OwnerAttachmentConfig } from '../config.js';
|
|
3
|
+
import type { OursIncomingFile, OursRetrievedFiles } from './ours-client.js';
|
|
2
4
|
export interface AttachmentReplyRef {
|
|
3
5
|
wire_id: string;
|
|
4
6
|
sentence?: number;
|
|
@@ -42,8 +44,14 @@ export interface AdmittedAttachment {
|
|
|
42
44
|
kind: 'file' | 'voice_message';
|
|
43
45
|
transcription?: Omit<VoiceTranscription, 'audioPath'>;
|
|
44
46
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Admit the daemon's file listing. The rows are typed now, but every field is
|
|
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[];
|
|
47
55
|
export declare function validateAttachmentSelection(files: IncomingAttachment[], config: OwnerAttachmentConfig): string | undefined;
|
|
48
56
|
/**
|
|
49
57
|
* Managed-agent -> owner egress limits. This intentionally does not consult
|
|
@@ -54,6 +62,21 @@ export declare function prepareAttachmentDirectory(root: string, requestId: stri
|
|
|
54
62
|
export declare function admitAttachments(files: RetrievedAttachment[], dir: string, config: OwnerAttachmentConfig, options?: {
|
|
55
63
|
mimePolicy?: 'strict' | 'report-only';
|
|
56
64
|
}): 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>;
|
|
57
80
|
export declare function recoveredAttachment(file: IncomingAttachment, path: string): Promise<RetrievedAttachment>;
|
|
58
81
|
export declare function removeRequestDirectory(path: string): Promise<void>;
|
|
59
82
|
export declare function cleanupAttachmentRoot(root: string, now: number, retentionMs: number, limit?: number): Promise<number>;
|