@ours.network/fleet 1.1.5 → 1.2.0-nightly.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +163 -21
- package/dist/agent-ours/bridge.d.ts +3 -0
- package/dist/agent-ours/bridge.js +178 -0
- package/dist/agent-ours/controller.d.ts +14 -0
- package/dist/agent-ours/controller.js +60 -0
- package/dist/agent-ours/file-rpc.d.ts +23 -0
- package/dist/agent-ours/file-rpc.js +123 -0
- package/dist/agent-ours/harness.d.ts +14 -0
- package/dist/agent-ours/harness.js +64 -0
- package/dist/agent-ours/mcp-endpoint.d.ts +15 -0
- package/dist/agent-ours/mcp-endpoint.js +111 -0
- package/dist/agent-ours/runtime.d.ts +53 -0
- package/dist/agent-ours/runtime.js +247 -0
- package/dist/agent-ours/service.d.ts +23 -0
- package/dist/agent-ours/service.js +364 -0
- package/dist/agent-ours/state.d.ts +45 -0
- package/dist/agent-ours/state.js +95 -0
- package/dist/agent-ours/wire.d.ts +14 -0
- package/dist/agent-ours/wire.js +55 -0
- package/dist/agent-recovery-gate.js +1 -1
- package/dist/application/legacy-supervisor-identity.d.ts +7 -0
- package/dist/application/legacy-supervisor-identity.js +43 -0
- package/dist/application/supervisor-ours-tools.d.ts +159 -0
- package/dist/application/supervisor-ours-tools.js +121 -0
- package/dist/application/task-room-service.js +55 -19
- package/dist/briefing.js +10 -59
- package/dist/build-info.json +6 -5
- package/dist/capabilities.d.ts +2 -1
- package/dist/capabilities.js +2 -0
- package/dist/cli.js +75 -8
- package/dist/client-profile.d.ts +18 -0
- package/dist/client-profile.js +130 -0
- package/dist/config.d.ts +1 -0
- package/dist/creation.d.ts +1 -1
- package/dist/creation.js +13 -21
- package/dist/daemon-recovery.d.ts +2 -0
- package/dist/daemon-recovery.js +53 -2
- package/dist/docs.d.ts +3 -3
- package/dist/docs.js +32 -23
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +70 -14
- package/dist/fleet-command-audit.js +3 -2
- package/dist/harness/agent-session.d.ts +2 -0
- package/dist/harness/claude-code-session.js +4 -2
- package/dist/harness/codex-session.js +9 -2
- package/dist/harness/codex.d.ts +3 -2
- package/dist/harness/codex.js +8 -3
- package/dist/harness/hermes-session.js +7 -1
- package/dist/init-wizard.d.ts +11 -6
- package/dist/init-wizard.js +33 -0
- package/dist/monitor.d.ts +34 -3
- package/dist/monitor.js +174 -74
- package/dist/owner-channel/channel.d.ts +3 -0
- package/dist/owner-channel/channel.js +28 -6
- package/dist/owner-channel/ours-client.d.ts +12 -5
- package/dist/owner-channel/ours-client.js +48 -12
- package/dist/rooms-tasks/archived-absence.d.ts +8 -0
- package/dist/rooms-tasks/archived-absence.js +48 -0
- package/dist/rooms-tasks/cli.js +1 -1
- package/dist/rooms-tasks/close.d.ts +3 -0
- package/dist/rooms-tasks/close.js +95 -17
- package/dist/rooms-tasks/cowork-adapter.d.ts +4 -0
- package/dist/rooms-tasks/cowork-adapter.js +10 -2
- package/dist/rooms-tasks/cowork-http.d.ts +3 -0
- package/dist/rooms-tasks/cowork-http.js +70 -0
- package/dist/rooms-tasks/deletion.js +40 -3
- package/dist/rooms-tasks/member-startup.d.ts +1 -0
- package/dist/rooms-tasks/member-startup.js +2 -0
- package/dist/rooms-tasks/provision.d.ts +11 -0
- package/dist/rooms-tasks/provision.js +138 -26
- package/dist/rooms-tasks/room-state.js +13 -0
- package/dist/rooms-tasks/task-state.d.ts +13 -3
- package/dist/rooms-tasks/task-state.js +42 -6
- package/dist/rooms-tasks/types.d.ts +12 -0
- package/dist/rooms-tasks/workspace-artifacts.d.ts +6 -0
- package/dist/rooms-tasks/workspace-artifacts.js +93 -0
- package/dist/rooms-tasks/workspace.d.ts +26 -0
- package/dist/rooms-tasks/workspace.js +195 -0
- package/dist/runner.d.ts +14 -0
- package/dist/runner.js +540 -494
- package/dist/session/codex-app-server-transport.js +1 -1
- package/dist/session/codex-app-server.js +31 -5
- package/dist/spawn.js +19 -16
- package/dist/startup-readiness.d.ts +13 -0
- package/dist/startup-readiness.js +38 -0
- package/dist/supervisor/launchd.js +8 -1
- package/dist/supervisor/systemd.js +5 -0
- package/dist/temp-supervisor-recovery.d.ts +3 -0
- package/dist/temp-supervisor-recovery.js +46 -0
- package/dist/watchdog/briefing.js +3 -16
- package/dist/watchdog/run.js +19 -5
- package/dist/web/server.d.ts +2 -0
- package/dist/web/server.js +18 -1
- package/docs/task-workspaces.md +98 -0
- package/package.json +8 -4
- package/presets/fleet/roles/Coordinator.yaml +22 -0
|
@@ -55,7 +55,7 @@ export class CodexAppServerTransport {
|
|
|
55
55
|
throw new Error('Codex app-server command is empty');
|
|
56
56
|
const child = spawn(options.argv[0], options.argv.slice(1), {
|
|
57
57
|
cwd: options.cwd,
|
|
58
|
-
env: { ...process.env, ...options.env },
|
|
58
|
+
env: { ...(options.env?.FLEET_OURS_MANAGED === '1' ? {} : process.env), ...options.env },
|
|
59
59
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
60
60
|
});
|
|
61
61
|
// `pid` is assigned synchronously on a successful local spawn. Checking it
|
|
@@ -879,6 +879,21 @@ export class CodexAppServerSession {
|
|
|
879
879
|
this.transport.respond(id, { currentTimeAt: Math.floor(Date.now() / 1_000) });
|
|
880
880
|
return;
|
|
881
881
|
}
|
|
882
|
+
if (method === 'mcpServer/elicitation/request') {
|
|
883
|
+
const schema = params.requestedSchema;
|
|
884
|
+
// The permission UI can answer a confirmation, but cannot collect form
|
|
885
|
+
// fields or perform URL-based verification on the user's behalf.
|
|
886
|
+
if (params.mode !== 'form' || !isObject(schema) || schema.type !== 'object'
|
|
887
|
+
|| !isObject(schema.properties) || Object.keys(schema.properties).length
|
|
888
|
+
|| (schema.required !== undefined
|
|
889
|
+
&& (!Array.isArray(schema.required) || schema.required.length))) {
|
|
890
|
+
this.options.log(`[${this.options.name}] codex app-server: unsupported MCP elicitation form`);
|
|
891
|
+
this.transport.respond(id, { action: 'decline', content: null, _meta: null });
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
this.requestPermission(method, id, params);
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
882
897
|
if (method === 'item/tool/requestUserInput') {
|
|
883
898
|
this.options.log(`[${this.options.name}] codex app-server: requestUserInput has no interactive Fleet mapping; returning no answers`);
|
|
884
899
|
this.transport.respond(id, { answers: {} });
|
|
@@ -896,9 +911,11 @@ export class CodexAppServerSession {
|
|
|
896
911
|
{ optionId: 'allow_session', name: 'Allow for session', kind: 'allow_always', native: 'acceptForSession' },
|
|
897
912
|
{ optionId: 'deny_once', name: 'Deny', kind: 'reject_once', native: 'decline' },
|
|
898
913
|
{ optionId: 'cancel', name: 'Cancel', kind: 'reject_once', native: 'cancel' },
|
|
899
|
-
].filter(option => method === '
|
|
900
|
-
|
|
901
|
-
|
|
914
|
+
].filter(option => method === 'mcpServer/elicitation/request'
|
|
915
|
+
? option.optionId !== 'allow_session'
|
|
916
|
+
: method === 'item/fileChange/requestApproval'
|
|
917
|
+
|| method === 'item/permissions/requestApproval'
|
|
918
|
+
|| !available.length || available.includes(option.native))
|
|
902
919
|
.map(({ native: _native, ...option }) => option);
|
|
903
920
|
const pending = {
|
|
904
921
|
requestId, method, toolCallId, options,
|
|
@@ -909,7 +926,8 @@ export class CodexAppServerSession {
|
|
|
909
926
|
// `approval=allow` covers native operations inside the declared boundary;
|
|
910
927
|
// it must never silently widen filesystem or network authority.
|
|
911
928
|
if (this.options.permissions.approval === 'allow'
|
|
912
|
-
&& method !== 'item/permissions/requestApproval'
|
|
929
|
+
&& method !== 'item/permissions/requestApproval'
|
|
930
|
+
&& method !== 'mcpServer/elicitation/request') {
|
|
913
931
|
this.transport.respond(requestId, this.permissionResponse(pending, 'accept'));
|
|
914
932
|
this.resolvePermission(permissionId, pending, 'allowed', 'automatic', 'allow_once', 'permissions.approval=allow', 'the native request is inside the configured Codex boundary');
|
|
915
933
|
return;
|
|
@@ -925,7 +943,9 @@ export class CodexAppServerSession {
|
|
|
925
943
|
this.readiness = 'awaiting_permission';
|
|
926
944
|
const timeoutMs = this.options.permissionTimeoutMs ?? PERMISSION_TIMEOUT_MS;
|
|
927
945
|
const expiresAt = new Date(Date.now() + timeoutMs).toISOString();
|
|
928
|
-
const title =
|
|
946
|
+
const title = (method === 'mcpServer/elicitation/request'
|
|
947
|
+
? `${string(params.serverName) ?? 'MCP'}: ${string(params.message) ?? 'Confirmation requested'}`
|
|
948
|
+
: undefined) ?? string(params.command) ?? string(params.reason)
|
|
929
949
|
?? (method === 'item/fileChange/requestApproval' ? 'Apply file changes'
|
|
930
950
|
: method === 'item/permissions/requestApproval' ? 'Grant additional permissions'
|
|
931
951
|
: 'Run command');
|
|
@@ -993,6 +1013,12 @@ export class CodexAppServerSession {
|
|
|
993
1013
|
return !turnId || !this.activeTurn.nativeTurnId || turnId === this.activeTurn.nativeTurnId;
|
|
994
1014
|
}
|
|
995
1015
|
permissionResponse(pending, decision) {
|
|
1016
|
+
if (pending.method === 'mcpServer/elicitation/request')
|
|
1017
|
+
return {
|
|
1018
|
+
action: decision === 'acceptForSession' ? 'accept' : decision,
|
|
1019
|
+
content: decision.startsWith('accept') ? {} : null,
|
|
1020
|
+
_meta: null,
|
|
1021
|
+
};
|
|
996
1022
|
if (pending.method !== 'item/permissions/requestApproval')
|
|
997
1023
|
return { decision };
|
|
998
1024
|
const requested = pending.requestedPermissions ?? {};
|
package/dist/spawn.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { storeRoomSecret, storeTemporaryLaunch, preparePermanentAssignment } from './agent-ours/service.js';
|
|
3
|
+
import { ensureWorkspace } from './rooms-tasks/workspace.js';
|
|
1
4
|
import { spawn as spawnChild } from 'node:child_process';
|
|
2
5
|
import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
6
|
import { join } from 'node:path';
|
|
@@ -7,7 +10,7 @@ import { validateIsolationConfig } from './isolation/policy.js';
|
|
|
7
10
|
import { loadConfig, findRole, splitRootFor, validateMonitorConfig, } from './config.js';
|
|
8
11
|
import { applyRole, up } from './ops.js';
|
|
9
12
|
import { START_STAGGER_FILE } from './runner.js';
|
|
10
|
-
import { buildProvenance,
|
|
13
|
+
import { buildProvenance, provenanceOf, withCreationTransaction, writeProvenance, writeRoleFile, } from './creation.js';
|
|
11
14
|
import { VERSION } from './version.js';
|
|
12
15
|
import { recordGeneratedAgentSource } from './generated-agent-source.js';
|
|
13
16
|
import './harness/claude-code.js';
|
|
@@ -239,22 +242,12 @@ export async function spawnPermanent(o, deps, creation = {}) {
|
|
|
239
242
|
return withCreationTransaction({ role: o.name, identity: effectiveIdentity(o) }, async (tx) => {
|
|
240
243
|
assertNameFree(o);
|
|
241
244
|
const cfg = loadConfig(o.configPath);
|
|
242
|
-
//
|
|
243
|
-
//
|
|
245
|
+
// Pin an existing assignment without binding; the supervisor provisions
|
|
246
|
+
// a missing role identity before starting its harness.
|
|
244
247
|
creation.onStage?.('checking_identity');
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
});
|
|
249
|
-
if (guarantee.state === 'created')
|
|
250
|
-
// We minted it; a failed creation must not leave an orphan identity
|
|
251
|
-
// behind. Only ever removes an identity THIS transaction created.
|
|
252
|
-
tx.record({
|
|
253
|
-
stage: `ours identity ${effectiveIdentity(o)}`,
|
|
254
|
-
undo: async () => {
|
|
255
|
-
await creation.identityProvisioner?.remove?.(effectiveIdentity(o));
|
|
256
|
-
},
|
|
257
|
-
});
|
|
248
|
+
const assignment = await preparePermanentAssignment(prepared.role);
|
|
249
|
+
const guarantee = { state: assignment, evidence: assignment === 'verified' ? 'verified' : 'missing' };
|
|
250
|
+
creation.onStage?.('checking_identity', { result: guarantee.evidence, guarantee: guarantee.state });
|
|
258
251
|
const agentRoot = join(splitRootFor(o.configPath ?? defaultConfigPath()), 'agents');
|
|
259
252
|
mkdirSync(agentRoot, { recursive: true });
|
|
260
253
|
creation.onStage?.('writing_role');
|
|
@@ -329,6 +322,12 @@ export async function spawnTemp(o, binPath, launch = independentSupervisor, crea
|
|
|
329
322
|
if (o.isolationFile)
|
|
330
323
|
readIsolationFile(o.isolationFile); // fail before reserving
|
|
331
324
|
const prepared = resolvedSpawn(o); // canonical validation before mutation
|
|
325
|
+
if (o.roomMemberStartup?.workspace) {
|
|
326
|
+
const workspace = o.roomMemberStartup.workspace;
|
|
327
|
+
if (prepared.role.cwd !== workspace.path)
|
|
328
|
+
throw new Error('room member cwd must equal its recorded owned workspace');
|
|
329
|
+
ensureWorkspace(workspace);
|
|
330
|
+
}
|
|
332
331
|
// Retire only supervisors whose recorded owner is definitively stopped. This
|
|
333
332
|
// bounded pass keeps the active roster clean without deleting old evidence.
|
|
334
333
|
await reclaimStaleTempState();
|
|
@@ -354,6 +353,10 @@ async function spawnTempInner(o, preparedRole, binPath, launch, tx, onStage) {
|
|
|
354
353
|
temporaryLoopSource: o.loopSource ?? (temporaryLoops?.length ? 'agent-template' : 'omitted'),
|
|
355
354
|
roomMemberStartup: o.roomMemberStartup,
|
|
356
355
|
};
|
|
356
|
+
storeTemporaryLaunch(role, o.creationActionId ?? randomUUID());
|
|
357
|
+
storeRoomSecret(role);
|
|
358
|
+
if (role.roomMemberStartup)
|
|
359
|
+
role.roomMemberStartup = { ...role.roomMemberStartup, invite: '' };
|
|
357
360
|
onStage?.('writing_role');
|
|
358
361
|
const dir = applyRole(role, { temp: true, identityGuarantee: 'unverified' });
|
|
359
362
|
const provenance = buildProvenance({
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type DaemonGenerationProbe } from './daemon-recovery.js';
|
|
2
|
+
export declare const STARTUP_WAIT_MS = 240000;
|
|
3
|
+
export declare const STARTUP_RETRY_MS = 5000;
|
|
4
|
+
interface ReadinessDeps {
|
|
5
|
+
probe(env: NodeJS.ProcessEnv): Promise<DaemonGenerationProbe>;
|
|
6
|
+
now(): number;
|
|
7
|
+
sleep(ms: number): Promise<void>;
|
|
8
|
+
log(message: string): void;
|
|
9
|
+
env: NodeJS.ProcessEnv;
|
|
10
|
+
}
|
|
11
|
+
/** Read-only preflight: never binds identities, starts a harness or edits its ledger. */
|
|
12
|
+
export declare function waitForRoleDaemon(name: string, configPath?: string, partial?: Partial<ReadinessDeps>): Promise<void>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { agentDir } from './paths.js';
|
|
2
|
+
import { findRole, loadConfig } from './config.js';
|
|
3
|
+
import { resolveConfigPath } from './runner.js';
|
|
4
|
+
import { probeDaemonGeneration } from './daemon-recovery.js';
|
|
5
|
+
export const STARTUP_WAIT_MS = 240_000;
|
|
6
|
+
export const STARTUP_RETRY_MS = 5_000;
|
|
7
|
+
/** Read-only preflight: never binds identities, starts a harness or edits its ledger. */
|
|
8
|
+
export async function waitForRoleDaemon(name, configPath, partial = {}) {
|
|
9
|
+
const deps = {
|
|
10
|
+
probe: env => probeDaemonGeneration(((url, init) => globalThis.fetch(url, {
|
|
11
|
+
...init, signal: AbortSignal.timeout(10_000),
|
|
12
|
+
})), env),
|
|
13
|
+
now: Date.now,
|
|
14
|
+
sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
15
|
+
log: message => process.stderr.write(message + '\n'),
|
|
16
|
+
env: process.env,
|
|
17
|
+
...partial,
|
|
18
|
+
};
|
|
19
|
+
const path = resolveConfigPath(agentDir(name), configPath);
|
|
20
|
+
const role = findRole(loadConfig(path), name);
|
|
21
|
+
const env = { ...deps.env, ...role.env };
|
|
22
|
+
const deadline = deps.now() + STARTUP_WAIT_MS;
|
|
23
|
+
let lastReason = '';
|
|
24
|
+
while (deps.now() < deadline) {
|
|
25
|
+
const result = await deps.probe(env);
|
|
26
|
+
if (result.state === 'ready') {
|
|
27
|
+
deps.log(`[${name}] daemon ready; starting supervised agent`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
// Only structured diagnostic codes, never endpoint responses or credentials.
|
|
31
|
+
const reason = /^[A-Z][A-Z0-9_]*$/.test(result.reason) ? result.reason : 'DAEMON_UNAVAILABLE';
|
|
32
|
+
if (reason !== lastReason)
|
|
33
|
+
deps.log(`[${name}] waiting for configured daemon: ${reason}`);
|
|
34
|
+
lastReason = reason;
|
|
35
|
+
await deps.sleep(Math.min(STARTUP_RETRY_MS, Math.max(0, deadline - deps.now())));
|
|
36
|
+
}
|
|
37
|
+
throw new Error(`Daemon not ready (${lastReason}); service manager will retry startup`);
|
|
38
|
+
}
|
|
@@ -14,8 +14,15 @@ const LAST_EXIT_RE = /^\s*last exit (?:code|status|reason)\s*=\s*(.+?)\s*$/mi;
|
|
|
14
14
|
const STATE_RE = /^\s*state\s*=\s*(.+?)\s*$/m;
|
|
15
15
|
const agentsDir = () => join(home(), 'Library', 'LaunchAgents');
|
|
16
16
|
const plistPath = (name) => join(agentsDir(), `${labelFor(name)}.plist`);
|
|
17
|
+
const xml = (value) => value
|
|
18
|
+
.replaceAll('&', '&')
|
|
19
|
+
.replaceAll('<', '<')
|
|
20
|
+
.replaceAll('>', '>')
|
|
21
|
+
.replaceAll('"', '"')
|
|
22
|
+
.replaceAll("'", ''');
|
|
17
23
|
function plist(name, binPath) {
|
|
18
24
|
const log = join(logsRoot(), `${name}.log`);
|
|
25
|
+
const config = process.env.OURS_CONFIG;
|
|
19
26
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
20
27
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
21
28
|
<plist version="1.0">
|
|
@@ -23,7 +30,7 @@ function plist(name, binPath) {
|
|
|
23
30
|
<key>Label</key><string>${labelFor(name)}</string>
|
|
24
31
|
<key>ProgramArguments</key>
|
|
25
32
|
<array><string>${binPath}</string><string>_run</string><string>${name}</string></array>
|
|
26
|
-
<!-- The runner owns the child-session restart loop. launchd must only
|
|
33
|
+
${config ? ` <key>EnvironmentVariables</key><dict><key>OURS_CONFIG</key><string>${xml(config)}</string></dict>\n` : ''} <!-- The runner owns the child-session restart loop. launchd must only
|
|
27
34
|
recover the runner PROCESS crashing: a bare KeepAlive would resume the
|
|
28
35
|
uncounted relaunch loop and restart a deliberately held-down agent. -->
|
|
29
36
|
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
|
|
@@ -100,10 +100,15 @@ export function makeSystemdBackend(exec = realExec) {
|
|
|
100
100
|
writeFileSync(join(unitDir, UNIT_TEMPLATE), `[Unit]
|
|
101
101
|
Description=ours-fleet agent %i
|
|
102
102
|
After=default.target
|
|
103
|
+
StartLimitIntervalSec=0
|
|
103
104
|
|
|
104
105
|
[Service]
|
|
105
106
|
Type=simple
|
|
106
107
|
${environmentLines}
|
|
108
|
+
# Wait outside the runner so boot-time daemon unavailability cannot consume
|
|
109
|
+
# the agent crash budget. Selection comes from the same role and profile.
|
|
110
|
+
ExecStartPre=${unitArg(process.execPath)} ${unitArg(binPath)} _wait-daemon %i
|
|
111
|
+
TimeoutStartSec=270
|
|
107
112
|
ExecStart=${unitArg(process.execPath)} ${unitArg(binPath)} _run %i
|
|
108
113
|
# The RUNNER owns the child-session restart loop, with a counted, backed-off
|
|
109
114
|
# circuit breaker. systemd must only recover the runner PROCESS crashing —
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { runTemp, SupervisorRecycleRequiredError } from './runner.js';
|
|
3
|
+
export const TEMP_RECYCLE_EXIT = 75;
|
|
4
|
+
/** Keep the service's main process alive while replacing a PID-scoped worker. */
|
|
5
|
+
export async function runTempSupervisor(name, entrypoint, initial = () => runTemp(name)) {
|
|
6
|
+
try {
|
|
7
|
+
await initial();
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
if (!(error instanceof SupervisorRecycleRequiredError))
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
15
|
+
const child = spawn(process.execPath, [entrypoint, '_run-temp-worker', name], {
|
|
16
|
+
stdio: 'inherit',
|
|
17
|
+
env: process.env,
|
|
18
|
+
});
|
|
19
|
+
let stopping = false;
|
|
20
|
+
const term = () => {
|
|
21
|
+
stopping = true;
|
|
22
|
+
child.kill('SIGTERM');
|
|
23
|
+
}, interrupt = () => {
|
|
24
|
+
stopping = true;
|
|
25
|
+
child.kill('SIGINT');
|
|
26
|
+
};
|
|
27
|
+
process.on('SIGTERM', term);
|
|
28
|
+
process.on('SIGINT', interrupt);
|
|
29
|
+
let code;
|
|
30
|
+
try {
|
|
31
|
+
code = await new Promise((resolve, reject) => {
|
|
32
|
+
child.once('error', reject);
|
|
33
|
+
child.once('exit', resolve);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
process.off('SIGTERM', term);
|
|
38
|
+
process.off('SIGINT', interrupt);
|
|
39
|
+
}
|
|
40
|
+
if (code === 0 || stopping)
|
|
41
|
+
return;
|
|
42
|
+
if (code !== TEMP_RECYCLE_EXIT)
|
|
43
|
+
throw Error(`Temporary supervisor replacement exited with ${code}`);
|
|
44
|
+
}
|
|
45
|
+
throw Error('Temporary supervisor recycle limit reached; identity retained for explicit recovery');
|
|
46
|
+
}
|
|
@@ -5,22 +5,9 @@ import { formatDuration } from '../duration.js';
|
|
|
5
5
|
* wasn't verified when configured, fall back to createTool on first run.
|
|
6
6
|
*/
|
|
7
7
|
function bindSection(heading, wd, v, guarantee) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
if (guarantee === 'unverified') {
|
|
12
|
-
L.push(`- This identity was NOT verified when this watchdog was configured, so it may not`);
|
|
13
|
-
L.push(` exist yet. If binding reports no such identity, call **${v.createTool}** name`);
|
|
14
|
-
L.push(` "${wd.identity}" once to mint it on this first run, then you are bound. Re-binding`);
|
|
15
|
-
L.push(' your OWN identity is always allowed.');
|
|
16
|
-
}
|
|
17
|
-
else {
|
|
18
|
-
L.push(`- It was ${guarantee === 'created' ? 'created' : 'verified to exist'} when this`);
|
|
19
|
-
L.push(' watchdog was configured, so binding should succeed. If it unexpectedly reports no');
|
|
20
|
-
L.push(` such identity, call **${v.createTool}** name "${wd.identity}" once and report the`);
|
|
21
|
-
L.push(' discrepancy — something removed it since.');
|
|
22
|
-
}
|
|
23
|
-
return L;
|
|
8
|
+
return ['', '## Assigned identity',
|
|
9
|
+
`Your supervisor has bound your identity **${wd.identity}**. The ours tools are ready.`,
|
|
10
|
+
'No identity setup or skill reading is required.'];
|
|
24
11
|
}
|
|
25
12
|
/**
|
|
26
13
|
* Render a watchdog run's fixed contract (briefing.md-equivalent for a one-shot clean-context
|
package/dist/watchdog/run.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { preparePermanentAssignment, releaseManagedAgent } from '../agent-ours/service.js';
|
|
1
2
|
import { closeSync, existsSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
2
3
|
import { spawn as spawnChild } from 'node:child_process';
|
|
3
4
|
import { join } from 'node:path';
|
|
@@ -7,8 +8,7 @@ import { acquireRunLock, formatRunId, pruneReports, releaseRunLock, writeReport
|
|
|
7
8
|
import { generateNotifierBriefing, generateWatchdogBriefing } from './briefing.js';
|
|
8
9
|
import { computeDigest, reconcileLedger, readLedger, writeLedger } from './alerts.js';
|
|
9
10
|
import { applyRole } from '../ops.js';
|
|
10
|
-
import {
|
|
11
|
-
import { runOnce, START_STAGGER_FILE } from '../runner.js';
|
|
11
|
+
import { runOnce, loadTempRole, START_STAGGER_FILE } from '../runner.js';
|
|
12
12
|
import { agentDir, tmpRoot } from '../paths.js';
|
|
13
13
|
import { loadConfig, ROLE_NAME_RE, } from '../config.js';
|
|
14
14
|
import { getAdapter } from '../harness/registry.js';
|
|
@@ -53,6 +53,7 @@ async function killIfNeeded(reason, child, roleName, sleep) {
|
|
|
53
53
|
if (reason === 'stable')
|
|
54
54
|
await sleep(HARVEST_GRACE_MS);
|
|
55
55
|
child.kill();
|
|
56
|
+
await child.exited;
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
function defaultLaunchChild(binPath, roleName, runDir) {
|
|
@@ -139,7 +140,7 @@ export async function executeWatchdogRun(wd, deps) {
|
|
|
139
140
|
rmSync(runDir, { recursive: true, force: true });
|
|
140
141
|
let report;
|
|
141
142
|
try {
|
|
142
|
-
const guarantee =
|
|
143
|
+
const guarantee = { state: 'unverified' }; // The child supervisor provisions before its harness starts.
|
|
143
144
|
const cfg = deps.cfg ?? loadConfig();
|
|
144
145
|
const discovered = wd.watchExplicit
|
|
145
146
|
? []
|
|
@@ -272,7 +273,7 @@ export async function executeNotifierRun(wd, text, deps) {
|
|
|
272
273
|
// A crashed previous run can leave the temp dir behind; start clean.
|
|
273
274
|
if (existsSync(runDir))
|
|
274
275
|
rmSync(runDir, { recursive: true, force: true });
|
|
275
|
-
const guarantee =
|
|
276
|
+
const guarantee = { state: 'unverified' }; // The child supervisor provisions before its harness starts.
|
|
276
277
|
const cfg = deps.cfg ?? loadConfig();
|
|
277
278
|
const role = buildWatchdogRole(wd, cfg);
|
|
278
279
|
const dir = applyRole(role, { temp: true, identityGuarantee: guarantee.state });
|
|
@@ -317,5 +318,18 @@ export async function executeNotifierRun(wd, text, deps) {
|
|
|
317
318
|
}
|
|
318
319
|
/** What `_run-watchdog` calls: one supervised session, no cleanup — the parent harvests. */
|
|
319
320
|
export async function runWatchdogAgent(name) {
|
|
320
|
-
|
|
321
|
+
const role = loadTempRole(name);
|
|
322
|
+
await preparePermanentAssignment(role);
|
|
323
|
+
let stopping = false;
|
|
324
|
+
const stop = () => { stopping = true; };
|
|
325
|
+
process.on('SIGTERM', stop);
|
|
326
|
+
process.on('SIGINT', stop);
|
|
327
|
+
try {
|
|
328
|
+
await runOnce(name, { temp: true, identityLifetime: 'permanent' }, { shouldStop: () => stopping });
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
process.off('SIGTERM', stop);
|
|
332
|
+
process.off('SIGINT', stop);
|
|
333
|
+
await releaseManagedAgent(role);
|
|
334
|
+
}
|
|
321
335
|
}
|
package/dist/web/server.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SupervisorOursTools } from '../application/supervisor-ours-tools.js';
|
|
1
2
|
import { type FastifyInstance } from 'fastify';
|
|
2
3
|
import type { FleetQueryService } from '../application/fleet-query-service.js';
|
|
3
4
|
import type { RoleRepository } from '../application/role-repository.js';
|
|
@@ -31,6 +32,7 @@ export interface WebServices {
|
|
|
31
32
|
topologyPromote?: TopologyPromoteService;
|
|
32
33
|
removal?: RoleRemovalService;
|
|
33
34
|
taskRooms?: TaskRoomApplicationService;
|
|
35
|
+
oursTools?: Pick<SupervisorOursTools, 'list' | 'call'>;
|
|
34
36
|
}
|
|
35
37
|
export interface WebServer {
|
|
36
38
|
app: FastifyInstance;
|
package/dist/web/server.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SupervisorOursTools } from '../application/supervisor-ours-tools.js';
|
|
1
2
|
import { existsSync } from 'node:fs';
|
|
2
3
|
import { dirname, join } from 'node:path';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
@@ -51,7 +52,11 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
51
52
|
}
|
|
52
53
|
});
|
|
53
54
|
app.setErrorHandler(async (error, request, reply) => {
|
|
54
|
-
const
|
|
55
|
+
const privateToolParseError = request.routeOptions.url === '/api/v1/roles/:id/ours/call'
|
|
56
|
+
&& error instanceof Error && 'code' in error
|
|
57
|
+
&& typeof error.code === 'string' && error.code.startsWith('FST_ERR_CTP_');
|
|
58
|
+
const fleetError = normalizeError(privateToolParseError
|
|
59
|
+
? new FleetError('invalid_request', 'expected a valid JSON tool request') : error, request.id);
|
|
55
60
|
await audit.record({
|
|
56
61
|
requestId: request.id, action: `${request.method} ${request.routeOptions.url ?? request.url}`,
|
|
57
62
|
result: 'rejected', errorCode: fleetError.code,
|
|
@@ -311,6 +316,18 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
311
316
|
});
|
|
312
317
|
return result;
|
|
313
318
|
});
|
|
319
|
+
const oursTools = services.oursTools ?? new SupervisorOursTools();
|
|
320
|
+
app.get('/api/v1/roles/:id/ours/tools', async (request) => {
|
|
321
|
+
auth.authenticate(request);
|
|
322
|
+
return oursTools.list(request.params.id);
|
|
323
|
+
});
|
|
324
|
+
app.post('/api/v1/roles/:id/ours/call', async (request) => {
|
|
325
|
+
const session = auth.authenticate(request, true);
|
|
326
|
+
const result = await oursTools.call(request.params.id, request.body);
|
|
327
|
+
await audit.record({ requestId: request.id, browser: session.id,
|
|
328
|
+
action: 'ours.call', result: result.result.isError === true ? 'tool_error' : 'succeeded' });
|
|
329
|
+
return result;
|
|
330
|
+
});
|
|
314
331
|
app.get('/api/v1/roles/:id', async (request) => {
|
|
315
332
|
auth.authenticate(request);
|
|
316
333
|
return services.query.detail(request.params.id);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Task workspaces and artifact retention
|
|
2
|
+
|
|
3
|
+
New Tasks allocate a private workspace at
|
|
4
|
+
`$OURS_FLEET_HOME/.ours-fleet/workspaces/tasks/<task-id>` (the user's home is the
|
|
5
|
+
default for `OURS_FLEET_HOME`). New standalone Rooms use
|
|
6
|
+
`.ours-fleet/workspaces/rooms/<room-id>`. A linked Room shares its Task's workspace.
|
|
7
|
+
The absolute path, owner kind/ID and random ownership token are stored in the
|
|
8
|
+
record before directory allocation; `.fleet-workspace.json` proves ownership.
|
|
9
|
+
The path is visible in Task/Room JSON from both CLI and API. A folder of the same
|
|
10
|
+
name without the matching marker is never adopted.
|
|
11
|
+
|
|
12
|
+
Every newly managed member starts at that exact workspace, including retries.
|
|
13
|
+
An explicit template/member `cwd` remains in the sealed input execution plan as
|
|
14
|
+
source context, but the owned workspace overrides the effective launch cwd.
|
|
15
|
+
Copy or clone required source into the workspace; Fleet does not move or delete
|
|
16
|
+
the configured source directory. Briefings explain this rule. Use separate
|
|
17
|
+
per-agent branches/directories inside the workspace for concurrent edits.
|
|
18
|
+
|
|
19
|
+
All execution artifacts, dependency checkouts and worktrees belong inside the
|
|
20
|
+
workspace. Clone each repository there first, then use `git worktree add` with
|
|
21
|
+
destinations inside the same workspace. Git's native worktree registry is the
|
|
22
|
+
registry of record. Fleet audits `.git`, `commondir` and reverse `gitdir` links
|
|
23
|
+
before deletion; both the common repository and all registered worktrees must
|
|
24
|
+
be inside the workspace. An external repository's worktree inside the workspace,
|
|
25
|
+
or an internal repository's worktree outside it, blocks deletion. No broad
|
|
26
|
+
`git worktree prune`, branch deletion, reset or modification of unrelated Git
|
|
27
|
+
metadata is performed. Removing the owned repositories also removes their
|
|
28
|
+
complete internal worktree registries, including dirty and stale owned entries.
|
|
29
|
+
|
|
30
|
+
| Operation | Workspace and execution artifacts | Member runtime evidence |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| Complete, fail, cancel | Retained while Task is recorded | Retained in Fleet recovery archives |
|
|
33
|
+
| Linked Room retirement or deletion | Task workspace retained | Retained |
|
|
34
|
+
| Internal standalone Room retirement | Room workspace retained | Retained |
|
|
35
|
+
| Explicit Task deletion | Removed after member retirement | All proven owned retired launches removed |
|
|
36
|
+
| Explicit standalone Room deletion | Removed after member retirement | All proven owned retired launches removed |
|
|
37
|
+
|
|
38
|
+
Internal room close means retirement, not destructive Task deletion.
|
|
39
|
+
The existing CLI `room close ID ID` remains a deprecated alias for explicit
|
|
40
|
+
`room delete ID ID`: it deletes standalone room-owned artifacts, but always
|
|
41
|
+
retains a linked Task workspace. Its repeated-ID confirmation is unchanged. To remove a Task's
|
|
42
|
+
artifacts, explicitly delete the Task. Cancellation is not deletion. Automatic
|
|
43
|
+
compatibility cleanup of legacy closed Rooms skips records with owned workspaces.
|
|
44
|
+
These choices reconcile close/remove wording with the requirement that completed,
|
|
45
|
+
recorded Tasks retain all artifacts.
|
|
46
|
+
|
|
47
|
+
Fleet's active member state (briefings, logs, session state and WORKLOG) remains
|
|
48
|
+
under `.ours-fleet/tmp` while supervisors run, then in
|
|
49
|
+
`.ours-fleet/recovery/temporary`. The launch stores the workspace descriptor.
|
|
50
|
+
Explicit deletion first proves managed agents retired, then identifies all
|
|
51
|
+
matching archived launches, including failed and replaced attempts, using the
|
|
52
|
+
workspace descriptor plus role and termination/launch evidence. It preflights
|
|
53
|
+
these archives and atomically transfers them to `.fleet-retired-agents` in the
|
|
54
|
+
workspace before removal. Unrelated archives are not adopted by a name prefix.
|
|
55
|
+
Fleet audit receipts, creation/termination journals and shared infrastructure
|
|
56
|
+
logs are audit metadata and intentionally outlive Task artifact deletion.
|
|
57
|
+
|
|
58
|
+
Provisioning serializes Task operation then Room close locks, including bounded
|
|
59
|
+
seat waits, so concurrent retries cannot publish competing members and deletion
|
|
60
|
+
cannot race a member launch. Local published Rooms are reused if the Task link
|
|
61
|
+
write was interrupted. Workspace allocation publishes a marked staging directory;
|
|
62
|
+
restart reuses the durable token. Deletion renames the validated workspace to a
|
|
63
|
+
token-specific sibling tombstone before removal. A crash during removal resumes
|
|
64
|
+
that exact tombstone; the Task record is unlinked last. Before collecting archives,
|
|
65
|
+
Task deletion durably checkpoints verified retirement evidence. Retries verify
|
|
66
|
+
any remaining original archive and still refuse replacement live member state
|
|
67
|
+
or a recreated identity even after the archive has been consumed. Failed deletion
|
|
68
|
+
remains retryable with its recorded error.
|
|
69
|
+
|
|
70
|
+
Deletion rejects traversal, foreign recorded paths, changed markers and symlinked
|
|
71
|
+
workspace ancestors. Ordinary artifact symlinks are unlinked without following
|
|
72
|
+
their targets. Git control symlinks and foreign pointers are refused. As with
|
|
73
|
+
Fleet's other filesystem lifecycle operations, a hostile same-UID process that
|
|
74
|
+
mutates paths during validation requires OS isolation; application checks are
|
|
75
|
+
not a security boundary against that process. Fleet enforces launch cwd and
|
|
76
|
+
safe deletion; unrestricted agents can still write outside cwd, so the artifact
|
|
77
|
+
placement rule also requires agent compliance and/or filesystem isolation.
|
|
78
|
+
|
|
79
|
+
## Compatibility and limits
|
|
80
|
+
|
|
81
|
+
Records from before this feature without `workspace` remain unmanaged. Existing
|
|
82
|
+
agents retain their cwd and exact briefing/fingerprint so restart matching works.
|
|
83
|
+
Fleet never claims an old cwd, an outcome artifact path, or a legacy recovery
|
|
84
|
+
archive as owned merely because it was mentioned by a Task. Create a new managed
|
|
85
|
+
Task and explicitly copy desired artifacts to migrate; deleting a legacy Task
|
|
86
|
+
continues its previous lifecycle and does not delete arbitrary old files.
|
|
87
|
+
|
|
88
|
+
Changing `OURS_FLEET_HOME` does not silently rebase recorded absolute ownership
|
|
89
|
+
paths. Restore the original state root or explicitly migrate records and files
|
|
90
|
+
with the supervisors stopped; mismatched paths fail closed. Archive transfer
|
|
91
|
+
uses same-filesystem rename and reports a recoverable failure across mounts.
|
|
92
|
+
|
|
93
|
+
Cowork's remote create operation has no idempotency key in this interface. A crash
|
|
94
|
+
after remote creation but before local Room publication is an existing uncertain
|
|
95
|
+
remote result; this change deduplicates locally recorded rooms and does not claim
|
|
96
|
+
to solve that remote transaction window. Inspect remote room ownership before
|
|
97
|
+
retrying ambiguous remote creation. No services or installed Fleet code are
|
|
98
|
+
changed by building or testing this feature.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0-nightly.10",
|
|
4
4
|
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"examples",
|
|
19
19
|
"presets",
|
|
20
20
|
"README.md",
|
|
21
|
+
"docs/task-workspaces.md",
|
|
21
22
|
"LICENSE"
|
|
22
23
|
],
|
|
23
24
|
"engines": {
|
|
@@ -34,14 +35,16 @@
|
|
|
34
35
|
"test:e2e": "npm run build && playwright test",
|
|
35
36
|
"dev:web": "vite",
|
|
36
37
|
"prepack": "npm run build",
|
|
37
|
-
"prepublishOnly": "npm run build && npm test"
|
|
38
|
+
"prepublishOnly": "npm run build && npm test",
|
|
39
|
+
"test:v1-recovery": "node test/fleet-v1-recovery.integration.mjs retry && node test/fleet-v1-recovery.integration.mjs terminal-after-failure && node test/fleet-v1-recovery.integration.mjs terminal-release-failure"
|
|
38
40
|
},
|
|
39
41
|
"dependencies": {
|
|
40
42
|
"@agentclientprotocol/sdk": "^1.3.0",
|
|
41
43
|
"@fastify/static": "^10.1.2",
|
|
42
44
|
"@fastify/websocket": "^11.2.0",
|
|
43
|
-
"@ours.network/cli": "
|
|
44
|
-
"@ours.network/
|
|
45
|
+
"@ours.network/cli": "2.8.1-nightly.9",
|
|
46
|
+
"@ours.network/mcp": "1.2.0-nightly.7",
|
|
47
|
+
"@ours.network/sdk": "3.8.1-nightly.11",
|
|
45
48
|
"commander": "^12.1.0",
|
|
46
49
|
"fastify": "^5.4.0",
|
|
47
50
|
"react": "^19.1.1",
|
|
@@ -55,6 +58,7 @@
|
|
|
55
58
|
"@agentclientprotocol/codex-acp": "1.10.0"
|
|
56
59
|
},
|
|
57
60
|
"devDependencies": {
|
|
61
|
+
"@ours.network/daemon": "3.8.1-nightly.3",
|
|
58
62
|
"@playwright/test": "^1.54.1",
|
|
59
63
|
"@types/node": "^20.14.0",
|
|
60
64
|
"@types/react": "^19.1.9",
|
|
@@ -12,6 +12,28 @@ persona: |
|
|
|
12
12
|
Only execute task work yourself when the owner explicitly orders you to override this boundary
|
|
13
13
|
for that specific task.
|
|
14
14
|
|
|
15
|
+
Agent identity and contact management requested by the owner is Fleet management work.
|
|
16
|
+
For requests such as "generate an invite for this agent", "accept this invite for that agent",
|
|
17
|
+
"list its contacts", or "send this message as that agent", select the named agent and call the
|
|
18
|
+
corresponding deterministic supervisor operation through the Fleet CLI or REST API. The
|
|
19
|
+
supervisor owns and binds that agent's identity; verify that the selected agent and returned
|
|
20
|
+
identity match the owner's request. Use the shared supervisor tool interface for these
|
|
21
|
+
operations, including generate_invite, add_contact, list_contacts, and send_message.
|
|
22
|
+
Discover schemas with `ours-fleet ours tools <agent>`; invoke with
|
|
23
|
+
`ours-fleet ours call <agent> <tool> --args-file <private-json-file>` (omit the file for {}).
|
|
24
|
+
REST equivalents are GET `/api/v1/roles/<agent>/ours/tools` and POST
|
|
25
|
+
`/api/v1/roles/<agent>/ours/call` with body {"tool":"<tool>","arguments":{...}}.
|
|
26
|
+
Use the normal authenticated Fleet session and CSRF token for REST calls.
|
|
27
|
+
Do not ask the agent in its conversation to perform these operations, send the invite to its
|
|
28
|
+
LLM session, or use session steering as an identity-management API. Do not create a separate
|
|
29
|
+
identity, choose or rebind an identity, force a binding, or restart a supervisor to perform a
|
|
30
|
+
contact operation. If the supported supervisor operation is unavailable or identity matching
|
|
31
|
+
fails, report that exact blocker and retain the request without attempting a substitute path.
|
|
32
|
+
Keep invite inputs in the supported private-file or request-body channel, out of logs and
|
|
33
|
+
unrelated rooms. Report the operation's actual result: accepting an invite may leave contact
|
|
34
|
+
verification pending, so do not claim that contact establishment or message delivery is complete
|
|
35
|
+
until the corresponding result confirms it.
|
|
36
|
+
|
|
15
37
|
For every new task:
|
|
16
38
|
1. Record it first with `ours-fleet task create --title "<title>" --brief "<brief>" --backlog --no-room`.
|
|
17
39
|
2. Ask the owner which room template to use: `single`, `pair`, or `team`.
|