@ours.network/fleet 0.17.7 → 0.18.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 +8 -20
- package/dist/application/fleet-query-service.js +0 -12
- package/dist/application/role-creation-service.js +1 -3
- package/dist/application/types.d.ts +0 -11
- package/dist/briefing.js +0 -5
- package/dist/build-info.json +5 -5
- package/dist/cli.js +7 -29
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +10 -22
- package/dist/fleet-proxy.d.ts +0 -5
- package/dist/harness/acp-agent.js +6 -11
- package/dist/harness/codex.d.ts +1 -4
- package/dist/harness/codex.js +11 -59
- package/dist/harness/types.d.ts +3 -3
- package/dist/loops/manager.d.ts +1 -30
- package/dist/loops/manager.js +6 -65
- package/dist/loops/state.d.ts +0 -18
- package/dist/loops/state.js +0 -4
- package/dist/owner-channel/attachments.d.ts +25 -2
- package/dist/owner-channel/attachments.js +61 -5
- package/dist/owner-channel/channel.d.ts +18 -2
- package/dist/owner-channel/channel.js +78 -74
- package/dist/owner-channel/ours-client.d.ts +126 -0
- package/dist/owner-channel/ours-client.js +148 -0
- package/dist/runner.js +3 -27
- package/dist/session/acp.d.ts +0 -55
- package/dist/session/acp.js +1 -122
- package/dist/session/types.d.ts +0 -24
- package/dist/supervisor/systemd.js +28 -1
- package/dist/watchdog/briefing.js +0 -7
- package/package.json +2 -1
- 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/runner.js
CHANGED
|
@@ -59,15 +59,10 @@ const defaultDeps = () => ({
|
|
|
59
59
|
},
|
|
60
60
|
});
|
|
61
61
|
const MONITOR_OWNER_FILE = '.monitor-owner';
|
|
62
|
-
/** Fleet roles consume the operator-owned daemon; a role session never starts it. */
|
|
63
|
-
const FLEET_OURS_AUTOSTART = '0';
|
|
64
62
|
/** Environment injected only into the managed harness process. */
|
|
65
63
|
export function managedFleetProxyEnv(role, stateDir) {
|
|
66
64
|
return {
|
|
67
65
|
...(role.env ?? {}),
|
|
68
|
-
// This must win over both inherited/configured auto-start. ACP agents run
|
|
69
|
-
// directly rather than through ours-codex, so the runner owns this fence.
|
|
70
|
-
OURS_AUTOSTART: FLEET_OURS_AUTOSTART,
|
|
71
66
|
[FLEET_PROXY_STATE_DIR_ENV]: stateDir,
|
|
72
67
|
[FLEET_PROXY_CALLER_ENV]: role.name,
|
|
73
68
|
};
|
|
@@ -111,14 +106,11 @@ async function executeManagedSpawn(caller, configPath, requested, log) {
|
|
|
111
106
|
session: preview.session,
|
|
112
107
|
...(preview.model ? { model: preview.model } : {}),
|
|
113
108
|
monitor: { mode: preview.monitor.mode, interrupt: preview.monitor.interrupt },
|
|
114
|
-
permissionMode: effectivePermissionMode(preview),
|
|
115
109
|
inherited,
|
|
116
110
|
creationActionId,
|
|
117
111
|
};
|
|
118
112
|
log(`[${caller.name}] managed fleet proxy spawned ${result.lifetime} role ${result.role} `
|
|
119
|
-
+ `harness=${result.harness} session=${result.session}
|
|
120
|
-
+ `permission=${result.permissionMode.fleetMode} `
|
|
121
|
-
+ `native=${result.permissionMode.nativeMode}`);
|
|
113
|
+
+ `harness=${result.harness} session=${result.session}`);
|
|
122
114
|
return result;
|
|
123
115
|
}
|
|
124
116
|
/**
|
|
@@ -148,10 +140,6 @@ export function recordMonitorOwner(dir, owner) {
|
|
|
148
140
|
export function buildPaneCommand(launch, roleEnv, exitStatusPath, paneArgv = launch.argv) {
|
|
149
141
|
const env = {
|
|
150
142
|
PATH: process.env.PATH ?? '', COLORTERM: 'truecolor', ...launch.env, ...(roleEnv ?? {}),
|
|
151
|
-
// Tmux roles have the same daemon-client boundary as ACP roles. Keep this
|
|
152
|
-
// last so neither harness preparation nor a role env block can take over
|
|
153
|
-
// the shared daemon lifecycle.
|
|
154
|
-
OURS_AUTOSTART: FLEET_OURS_AUTOSTART,
|
|
155
143
|
};
|
|
156
144
|
// Interactive panes should advertise colour even when the supervisor itself
|
|
157
145
|
// was launched with NO_COLOR. A role may still deliberately opt back in to
|
|
@@ -542,10 +530,6 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
542
530
|
permissions: perms,
|
|
543
531
|
modeId: adapter.acpPermissionModeId?.(role),
|
|
544
532
|
permissionMode: effectivePermissionMode(role),
|
|
545
|
-
// Provenance travels with the exact ACP launch. Keeping it out of a
|
|
546
|
-
// role-only adapter hook prevents a PATH fallback or resolver skew from
|
|
547
|
-
// claiming metadata trust for an argv it did not authenticate.
|
|
548
|
-
permissionMetadataSource: launch.permissionMetadataSource,
|
|
549
533
|
log: deps.log,
|
|
550
534
|
});
|
|
551
535
|
pid = acpSession.pid;
|
|
@@ -993,16 +977,8 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
|
|
|
993
977
|
continue;
|
|
994
978
|
}
|
|
995
979
|
const fastFailSecs = fastFailSecsFor(name, opts.configPath);
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
// 19s and 20s deaths erase one another forever. Require the configured
|
|
999
|
-
// number of fast-fail windows to survive before closing an active streak.
|
|
1000
|
-
// This hysteresis stays adapter-relative (100s for the current 20s/5-attempt
|
|
1001
|
-
// policy) and still lets a genuinely sustained session reset the breaker.
|
|
1002
|
-
const stableRecoverySecs = fastFailSecs * RESTART_FAIL_THRESHOLD;
|
|
1003
|
-
const recoveryFailed = result.elapsedSecs < fastFailSecs
|
|
1004
|
-
|| (ledger.consecutiveImmediateFailures > 0 && result.elapsedSecs < stableRecoverySecs);
|
|
1005
|
-
if (!recoveryFailed) {
|
|
980
|
+
const immediate = result.elapsedSecs < fastFailSecs;
|
|
981
|
+
if (!immediate) {
|
|
1006
982
|
// A session that ran for a while is not a restart loop, whatever ended it.
|
|
1007
983
|
writeRestartLedger(dir, {
|
|
1008
984
|
...emptyLedger(),
|
package/dist/session/acp.d.ts
CHANGED
|
@@ -5,21 +5,6 @@ import type { ConversationSnapshot, PromptOrigin, PromptReceipt, SubmitPromptCom
|
|
|
5
5
|
import type { ConversationHandlePage, ExitRecord, InterruptOutcome, QueuedPrompt, SessionEvent, RuntimeSelectorMetadata, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnOutcome, TurnResult } from './types.js';
|
|
6
6
|
/** Bound safe-boundary waiting without turning a hung tool into cancellation. */
|
|
7
7
|
export declare const AFTER_TOOL_BOUNDARY_TIMEOUT_MS = 120000;
|
|
8
|
-
/**
|
|
9
|
-
* How long a steering-started turn is presumed to still own the adapter after
|
|
10
|
-
* its last update. Such a turn has no prompt id, so it never reports a
|
|
11
|
-
* stopReason and there is no exact end to observe — silence is the only signal
|
|
12
|
-
* available, and this is the bound that turns it into a decision.
|
|
13
|
-
*
|
|
14
|
-
* Sized from the fleet's own scheduled-run history: across 1513 completed
|
|
15
|
-
* scheduled runs the longest silence WITHIN a working turn was 120.2 s (p99
|
|
16
|
-
* 41.0 s; 5 runs above 60 s). A shorter grace would release the lease while the
|
|
17
|
-
* adapter is still working and re-admit a prompt into a busy turn, which is the
|
|
18
|
-
* FLEET-003 failure itself. The costs are deliberately asymmetric: holding too
|
|
19
|
-
* long skips one best-effort maintenance tick, releasing too early SIGTERMs a
|
|
20
|
-
* live role.
|
|
21
|
-
*/
|
|
22
|
-
export declare const STEERING_OCCUPANCY_IDLE_MS = 150000;
|
|
23
8
|
/** Server-generated typed provenance followed by the exact human-authored body. */
|
|
24
9
|
export declare function promptContentBlocks(text: string, origin?: PromptOrigin): acp.ContentBlock[];
|
|
25
10
|
export declare function runtimeSelector(options: acp.SessionConfigOption[] | null | undefined, category: string): RuntimeSelectorMetadata | undefined;
|
|
@@ -35,8 +20,6 @@ export interface AcpSessionOptions {
|
|
|
35
20
|
modeId?: string;
|
|
36
21
|
/** Adapter-resolved live permission policy; separate from ACP agent-specific session modes. */
|
|
37
22
|
permissionMode?: NonNullable<SessionSnapshot['permissionMode']>;
|
|
38
|
-
/** Adapter-authenticated request-metadata vocabulary; never inferred from ACP `_meta`. */
|
|
39
|
-
permissionMetadataSource?: 'codex-acp';
|
|
40
23
|
log(line: string): void;
|
|
41
24
|
/** Test seam for the cancel-escalation grace period; production uses the default. */
|
|
42
25
|
cancelGraceMs?: number;
|
|
@@ -48,8 +31,6 @@ export interface AcpSessionOptions {
|
|
|
48
31
|
controllerGraceMs?: number;
|
|
49
32
|
/** Test seam; production uses AFTER_TOOL_BOUNDARY_TIMEOUT_MS. */
|
|
50
33
|
afterToolBoundaryTimeoutMs?: number;
|
|
51
|
-
/** Test seam; production uses STEERING_OCCUPANCY_IDLE_MS. */
|
|
52
|
-
steeringOccupancyIdleMs?: number;
|
|
53
34
|
}
|
|
54
35
|
/**
|
|
55
36
|
* Classify an ACP `stopReason` into a terminal outcome. A refusal and a
|
|
@@ -77,12 +58,6 @@ export declare class AcpSession implements SessionHandle {
|
|
|
77
58
|
private connection;
|
|
78
59
|
private sessionId?;
|
|
79
60
|
private readiness;
|
|
80
|
-
/**
|
|
81
|
-
* Last non-replayed session update from the agent. `readiness` cannot answer
|
|
82
|
-
* "is this agent working" for a steered turn (FLEET-002), and this is the
|
|
83
|
-
* evidence that can.
|
|
84
|
-
*/
|
|
85
|
-
private lastUpdateAt?;
|
|
86
61
|
private lastError?;
|
|
87
62
|
private promptTail;
|
|
88
63
|
private queueDepth;
|
|
@@ -98,13 +73,6 @@ export declare class AcpSession implements SessionHandle {
|
|
|
98
73
|
private cancelEscalation?;
|
|
99
74
|
private cancelForceKill?;
|
|
100
75
|
private cancelRecoveryReason?;
|
|
101
|
-
/**
|
|
102
|
-
* Held while a steering-started turn is believed to own the adapter. It is a
|
|
103
|
-
* lease, not a latch: `steeringRelease` always fires, so the role can never be
|
|
104
|
-
* stranded busy by a wake whose turn ended without telling anyone.
|
|
105
|
-
*/
|
|
106
|
-
private steeringOccupied;
|
|
107
|
-
private steeringRelease?;
|
|
108
76
|
/**
|
|
109
77
|
* Rejects the moment the adapter process is gone. Every in-flight ACP request
|
|
110
78
|
* races it, so a dead adapter can never leave a turn — and therefore a
|
|
@@ -126,20 +94,6 @@ export declare class AcpSession implements SessionHandle {
|
|
|
126
94
|
*/
|
|
127
95
|
private recoverOpenPrompts;
|
|
128
96
|
isAlive(): boolean;
|
|
129
|
-
/**
|
|
130
|
-
* Take the occupancy lease for a turn the adapter started on its own behalf.
|
|
131
|
-
* Refreshed by every adapter update, so it tracks work actually happening
|
|
132
|
-
* rather than a fixed guess at how long a wake takes.
|
|
133
|
-
*/
|
|
134
|
-
private holdSteeringOccupancy;
|
|
135
|
-
private refreshSteeringOccupancy;
|
|
136
|
-
/**
|
|
137
|
-
* Every exit from occupancy comes through here, including the ones that are
|
|
138
|
-
* not the timer: a real turn boundary, close, and adapter exit. A lease that
|
|
139
|
-
* can leak is worse than the bug it fixes — it would leave the role reporting
|
|
140
|
-
* `running` forever and starve scheduled admission permanently.
|
|
141
|
-
*/
|
|
142
|
-
private releaseSteeringOccupancy;
|
|
143
97
|
snapshot(): SessionSnapshot;
|
|
144
98
|
private toolCall;
|
|
145
99
|
private reserveTool;
|
|
@@ -231,15 +185,6 @@ export declare class AcpSession implements SessionHandle {
|
|
|
231
185
|
*/
|
|
232
186
|
private settleAutomatically;
|
|
233
187
|
private withinAutomaticBoundary;
|
|
234
|
-
/**
|
|
235
|
-
* Codex ACP 1.1.7 marks its protected MCP elicitation bridge on a locationless
|
|
236
|
-
* execute request. The marker is meaningful only together with the runner's
|
|
237
|
-
* independently supplied, adapter-authenticated metadata vocabulary and effective
|
|
238
|
-
* mode: an arbitrary ACP process cannot gain this path by copying `_meta` alone.
|
|
239
|
-
* Exact option ids/kinds bind recognition to the protected-MCP shape and keep
|
|
240
|
-
* malformed requests on the ordinary fail-closed path.
|
|
241
|
-
*/
|
|
242
|
-
private isEffectiveCodexProtectedMcpApproval;
|
|
243
188
|
private recordUpdate;
|
|
244
189
|
/**
|
|
245
190
|
* Codex ACP's phase extension is the only currently supported visibility
|
package/dist/session/acp.js
CHANGED
|
@@ -16,21 +16,6 @@ const PERMISSION_TIMEOUT_MS = 10 * 60_000;
|
|
|
16
16
|
const CONTROLLER_GRACE_MS = 12_000;
|
|
17
17
|
/** Bound safe-boundary waiting without turning a hung tool into cancellation. */
|
|
18
18
|
export const AFTER_TOOL_BOUNDARY_TIMEOUT_MS = 120_000;
|
|
19
|
-
/**
|
|
20
|
-
* How long a steering-started turn is presumed to still own the adapter after
|
|
21
|
-
* its last update. Such a turn has no prompt id, so it never reports a
|
|
22
|
-
* stopReason and there is no exact end to observe — silence is the only signal
|
|
23
|
-
* available, and this is the bound that turns it into a decision.
|
|
24
|
-
*
|
|
25
|
-
* Sized from the fleet's own scheduled-run history: across 1513 completed
|
|
26
|
-
* scheduled runs the longest silence WITHIN a working turn was 120.2 s (p99
|
|
27
|
-
* 41.0 s; 5 runs above 60 s). A shorter grace would release the lease while the
|
|
28
|
-
* adapter is still working and re-admit a prompt into a busy turn, which is the
|
|
29
|
-
* FLEET-003 failure itself. The costs are deliberately asymmetric: holding too
|
|
30
|
-
* long skips one best-effort maintenance tick, releasing too early SIGTERMs a
|
|
31
|
-
* live role.
|
|
32
|
-
*/
|
|
33
|
-
export const STEERING_OCCUPANCY_IDLE_MS = 150_000;
|
|
34
19
|
const TERMINAL_TOOL_STATUSES = new Set(['completed', 'failed']);
|
|
35
20
|
const SCHEDULED_LOOP_REDACTION = '[scheduled-loop content redacted]';
|
|
36
21
|
const OWNER_COMMENTARY_REDACTION = '[assistant commentary redacted]';
|
|
@@ -217,12 +202,6 @@ export class AcpSession {
|
|
|
217
202
|
connection;
|
|
218
203
|
sessionId;
|
|
219
204
|
readiness = 'starting';
|
|
220
|
-
/**
|
|
221
|
-
* Last non-replayed session update from the agent. `readiness` cannot answer
|
|
222
|
-
* "is this agent working" for a steered turn (FLEET-002), and this is the
|
|
223
|
-
* evidence that can.
|
|
224
|
-
*/
|
|
225
|
-
lastUpdateAt;
|
|
226
205
|
lastError;
|
|
227
206
|
promptTail = Promise.resolve();
|
|
228
207
|
queueDepth = 0;
|
|
@@ -238,13 +217,6 @@ export class AcpSession {
|
|
|
238
217
|
cancelEscalation;
|
|
239
218
|
cancelForceKill;
|
|
240
219
|
cancelRecoveryReason;
|
|
241
|
-
/**
|
|
242
|
-
* Held while a steering-started turn is believed to own the adapter. It is a
|
|
243
|
-
* lease, not a latch: `steeringRelease` always fires, so the role can never be
|
|
244
|
-
* stranded busy by a wake whose turn ended without telling anyone.
|
|
245
|
-
*/
|
|
246
|
-
steeringOccupied = false;
|
|
247
|
-
steeringRelease;
|
|
248
220
|
/**
|
|
249
221
|
* Rejects the moment the adapter process is gone. Every in-flight ACP request
|
|
250
222
|
* races it, so a dead adapter can never leave a turn — and therefore a
|
|
@@ -275,7 +247,6 @@ export class AcpSession {
|
|
|
275
247
|
if (this.cancelForceKill)
|
|
276
248
|
clearTimeout(this.cancelForceKill);
|
|
277
249
|
this.cancelForceKill = undefined;
|
|
278
|
-
this.releaseSteeringOccupancy('adapter exited');
|
|
279
250
|
// Record the child's real exit code/signal. The tmux path can only see a
|
|
280
251
|
// shell's `$?`; here the truth is available, so keep it.
|
|
281
252
|
const classified = classifyChildExit(code, signal);
|
|
@@ -376,60 +347,17 @@ export class AcpSession {
|
|
|
376
347
|
// terminal fact (signal exits deliberately leave exitCode null).
|
|
377
348
|
return this.child.exitCode === null && (this.child.signalCode ?? null) === null;
|
|
378
349
|
}
|
|
379
|
-
/**
|
|
380
|
-
* Take the occupancy lease for a turn the adapter started on its own behalf.
|
|
381
|
-
* Refreshed by every adapter update, so it tracks work actually happening
|
|
382
|
-
* rather than a fixed guess at how long a wake takes.
|
|
383
|
-
*/
|
|
384
|
-
holdSteeringOccupancy() {
|
|
385
|
-
if (this.closing || !this.isAlive())
|
|
386
|
-
return;
|
|
387
|
-
this.steeringOccupied = true;
|
|
388
|
-
this.refreshSteeringOccupancy();
|
|
389
|
-
}
|
|
390
|
-
refreshSteeringOccupancy() {
|
|
391
|
-
if (!this.steeringOccupied)
|
|
392
|
-
return;
|
|
393
|
-
if (this.steeringRelease)
|
|
394
|
-
clearTimeout(this.steeringRelease);
|
|
395
|
-
this.steeringRelease = setTimeout(() => this.releaseSteeringOccupancy('adapter silent'), this.options.steeringOccupancyIdleMs ?? STEERING_OCCUPANCY_IDLE_MS);
|
|
396
|
-
this.steeringRelease.unref?.();
|
|
397
|
-
}
|
|
398
|
-
/**
|
|
399
|
-
* Every exit from occupancy comes through here, including the ones that are
|
|
400
|
-
* not the timer: a real turn boundary, close, and adapter exit. A lease that
|
|
401
|
-
* can leak is worse than the bug it fixes — it would leave the role reporting
|
|
402
|
-
* `running` forever and starve scheduled admission permanently.
|
|
403
|
-
*/
|
|
404
|
-
releaseSteeringOccupancy(reason) {
|
|
405
|
-
if (this.steeringRelease)
|
|
406
|
-
clearTimeout(this.steeringRelease);
|
|
407
|
-
this.steeringRelease = undefined;
|
|
408
|
-
if (!this.steeringOccupied)
|
|
409
|
-
return;
|
|
410
|
-
this.steeringOccupied = false;
|
|
411
|
-
this.options.log(`[${this.options.name}] steering-started turn no longer holds the adapter (${reason})`);
|
|
412
|
-
}
|
|
413
350
|
snapshot() {
|
|
414
351
|
return {
|
|
415
352
|
backend: 'acp',
|
|
416
353
|
alive: this.isAlive(),
|
|
417
|
-
|
|
418
|
-
// session idle while it runs is what let the arbiter admit a scheduled
|
|
419
|
-
// prompt into a busy adapter, whose `session/prompt` then never returned
|
|
420
|
-
// a stopReason and ended in a cancellation deadline and a SIGTERM.
|
|
421
|
-
readiness: this.readiness === 'idle' && this.steeringOccupied
|
|
422
|
-
? 'running' : this.readiness,
|
|
354
|
+
readiness: this.readiness,
|
|
423
355
|
sessionId: this.sessionId,
|
|
424
356
|
lastError: this.lastError,
|
|
425
357
|
pendingPermissionId: this.pendingPermissions.keys().next().value,
|
|
426
358
|
runtimeModel: this.runtimeModel,
|
|
427
359
|
reasoningEffort: this.reasoningEffort,
|
|
428
360
|
permissionMode: this.options.permissionMode,
|
|
429
|
-
activity: {
|
|
430
|
-
activeToolCalls: this.activeToolCalls.size,
|
|
431
|
-
...(this.lastUpdateAt ? { lastUpdateAt: this.lastUpdateAt } : {}),
|
|
432
|
-
},
|
|
433
361
|
};
|
|
434
362
|
}
|
|
435
363
|
toolCall(toolCallId) {
|
|
@@ -927,7 +855,6 @@ export class AcpSession {
|
|
|
927
855
|
if (this.controllerGrace)
|
|
928
856
|
clearTimeout(this.controllerGrace);
|
|
929
857
|
this.controllerGrace = undefined;
|
|
930
|
-
this.releaseSteeringOccupancy('session closed');
|
|
931
858
|
for (const [permissionId, pending] of [...this.pendingPermissions])
|
|
932
859
|
this.settlePendingAutomatically(permissionId, pending, 'cancelled', undefined, 'the session closed while this request was pending');
|
|
933
860
|
this.releaseAllTools();
|
|
@@ -1086,10 +1013,6 @@ export class AcpSession {
|
|
|
1086
1013
|
}
|
|
1087
1014
|
finally {
|
|
1088
1015
|
this.releaseAllTools();
|
|
1089
|
-
// A turn this client owned has ended, so the adapter has reported a
|
|
1090
|
-
// boundary: whatever a steering call started before it is over too. This
|
|
1091
|
-
// is the release path that does not depend on the silence timer.
|
|
1092
|
-
this.releaseSteeringOccupancy('turn boundary');
|
|
1093
1016
|
if (this.activeTurn?.id === turnId) {
|
|
1094
1017
|
this.activeTurn.settle();
|
|
1095
1018
|
if (this.cancelEscalation)
|
|
@@ -1112,12 +1035,6 @@ export class AcpSession {
|
|
|
1112
1035
|
]);
|
|
1113
1036
|
if (response.outcome === 'failed')
|
|
1114
1037
|
return turnResult(false, 'failed', 'ACP steering failed');
|
|
1115
|
-
// `injected` joined a turn this client already owns and will settle.
|
|
1116
|
-
// `startedNewTurn` created one nobody owns: the adapter is working and
|
|
1117
|
-
// will never answer for it, so admission has to learn about it here or
|
|
1118
|
-
// not at all.
|
|
1119
|
-
if (response.outcome === 'startedNewTurn')
|
|
1120
|
-
this.holdSteeringOccupancy();
|
|
1121
1038
|
return turnResult(true, 'inconclusive', response.outcome);
|
|
1122
1039
|
}
|
|
1123
1040
|
catch (error) {
|
|
@@ -1145,17 +1062,6 @@ export class AcpSession {
|
|
|
1145
1062
|
// Permission is part of the tool lifecycle. Reserve before any policy or
|
|
1146
1063
|
// human decision so a monitor wake cannot slip between request and answer.
|
|
1147
1064
|
this.reservePermission(toolCallId, permissionId);
|
|
1148
|
-
if (this.isEffectiveCodexProtectedMcpApproval(params)) {
|
|
1149
|
-
// Protected MCP approval is already the tool's narrow gate. Never turn
|
|
1150
|
-
// this one decision into an adapter-wide standing grant.
|
|
1151
|
-
const option = choose(['allow_once']);
|
|
1152
|
-
const response = this.settleAutomatically(params, option, 'allowed', 'permissionMode.fleetMode=allow', 'the trusted Codex adapter authenticated a protected MCP approval request');
|
|
1153
|
-
if (option)
|
|
1154
|
-
this.allowPermission(toolCallId, permissionId);
|
|
1155
|
-
else
|
|
1156
|
-
this.releasePermission(toolCallId, permissionId);
|
|
1157
|
-
return Promise.resolve(response);
|
|
1158
|
-
}
|
|
1159
1065
|
if (this.options.permissions.approval === 'allow' && this.withinAutomaticBoundary(params)) {
|
|
1160
1066
|
const option = choose(['allow_always', 'allow_once']);
|
|
1161
1067
|
const response = this.settleAutomatically(params, option, 'allowed', 'permissions.approval=allow', `the request is inside the ${this.options.permissions.filesystem} boundary`);
|
|
@@ -1282,34 +1188,7 @@ export class AcpSession {
|
|
|
1282
1188
|
const cwd = resolve(this.options.cwd);
|
|
1283
1189
|
return canonicallyWithin(cwd, locations.map(location => resolve(location.path)));
|
|
1284
1190
|
}
|
|
1285
|
-
/**
|
|
1286
|
-
* Codex ACP 1.1.7 marks its protected MCP elicitation bridge on a locationless
|
|
1287
|
-
* execute request. The marker is meaningful only together with the runner's
|
|
1288
|
-
* independently supplied, adapter-authenticated metadata vocabulary and effective
|
|
1289
|
-
* mode: an arbitrary ACP process cannot gain this path by copying `_meta` alone.
|
|
1290
|
-
* Exact option ids/kinds bind recognition to the protected-MCP shape and keep
|
|
1291
|
-
* malformed requests on the ordinary fail-closed path.
|
|
1292
|
-
*/
|
|
1293
|
-
isEffectiveCodexProtectedMcpApproval(params) {
|
|
1294
|
-
const locations = params.toolCall.locations ?? [];
|
|
1295
|
-
return this.options.permissionMetadataSource === 'codex-acp'
|
|
1296
|
-
&& this.options.permissionMode?.fleetMode === 'allow'
|
|
1297
|
-
&& params.toolCall.kind === 'execute'
|
|
1298
|
-
&& params.toolCall.status === 'pending'
|
|
1299
|
-
&& locations.length === 0
|
|
1300
|
-
&& params._meta?.is_mcp_tool_approval === true
|
|
1301
|
-
&& params.options.some(option => option.optionId === 'allow_once' && option.kind === 'allow_once')
|
|
1302
|
-
&& params.options.some(option => option.optionId === 'decline' && option.kind === 'reject_once');
|
|
1303
|
-
}
|
|
1304
1191
|
recordUpdate(update) {
|
|
1305
|
-
// Replayed history is not current activity: `session/load` would otherwise
|
|
1306
|
-
// make a cold session look like it had just been working. The same reason
|
|
1307
|
-
// keeps it from extending the steering lease, which is evidence the adapter
|
|
1308
|
-
// is working right now — for a steering-started turn, the only evidence.
|
|
1309
|
-
if (!this.replaying) {
|
|
1310
|
-
this.lastUpdateAt = new Date().toISOString();
|
|
1311
|
-
this.refreshSteeringOccupancy();
|
|
1312
|
-
}
|
|
1313
1192
|
const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
|
|
1314
1193
|
const messagePhase = update.sessionUpdate === 'agent_message_chunk'
|
|
1315
1194
|
? this.codexMessagePhase(update) : undefined;
|
package/dist/session/types.d.ts
CHANGED
|
@@ -1,16 +1,5 @@
|
|
|
1
1
|
import type { SessionBackendId } from '../config.js';
|
|
2
2
|
import type { ConversationEventV1, ConversationSnapshot, PromptReceipt, SubmitPromptCommand } from './conversation-types.js';
|
|
3
|
-
/**
|
|
4
|
-
* TURN OCCUPANCY, and nothing else: `idle` means no fleet-tracked turn is in
|
|
5
|
-
* flight, which is exactly the question `arbiter.tryScheduled` asks before it
|
|
6
|
-
* admits a prompt. It is NOT a claim that the agent is doing nothing — a wake
|
|
7
|
-
* delivered through the `_session/steering` extension answers `startedNewTurn`
|
|
8
|
-
* and runs a whole turn that fleet never gets a `session/prompt` response for
|
|
9
|
-
* (ACP has no turn-end session update), so `readiness` stays `idle` for its
|
|
10
|
-
* entire duration. Anything reporting activity or liveness to a human must
|
|
11
|
-
* corroborate with `SessionSnapshot.activity` instead of reading `idle` here as
|
|
12
|
-
* "not working".
|
|
13
|
-
*/
|
|
14
3
|
export type SessionReadiness = 'starting' | 'idle' | 'running' | 'awaiting_permission' | 'failed';
|
|
15
4
|
export type TurnOutcome = 'completed' | 'refused' | 'cancelled' | 'failed' | 'inconclusive';
|
|
16
5
|
export type TurnCancellationSource = 'owner' | 'local-console' | 'fleet-monitor' | 'scheduled-loop' | 'shutdown';
|
|
@@ -181,19 +170,6 @@ export interface SessionSnapshot {
|
|
|
181
170
|
/** Exact harness-native approval/permission mode used by this runner. */
|
|
182
171
|
nativeMode: string;
|
|
183
172
|
};
|
|
184
|
-
/**
|
|
185
|
-
* Observed agent activity, independent of turn occupancy: the evidence a
|
|
186
|
-
* human-facing surface needs before calling a role idle. Absent on backends
|
|
187
|
-
* that cannot observe the agent at all (tmux), which is itself honest — no
|
|
188
|
-
* evidence is not evidence of inactivity.
|
|
189
|
-
*/
|
|
190
|
-
activity?: SessionActivity;
|
|
191
|
-
}
|
|
192
|
-
export interface SessionActivity {
|
|
193
|
-
/** ACP tool calls currently reserved (lifecycle open or permission pending). */
|
|
194
|
-
activeToolCalls: number;
|
|
195
|
-
/** When the agent last sent ANY session update, replay excluded. */
|
|
196
|
-
lastUpdateAt?: string;
|
|
197
173
|
}
|
|
198
174
|
export type SessionEventKind = 'state' | 'agent_text' | 'thought' | 'tool_call' | 'tool_update' | 'permission' | 'monitor_delivery' | 'turn_stop' | 'error';
|
|
199
175
|
/** What a settled permission request resolved to. */
|
|
@@ -69,6 +69,33 @@ export function makeSystemdBackend(exec = realExec) {
|
|
|
69
69
|
dirname(process.execPath),
|
|
70
70
|
...(process.env.PATH ?? '').split(delimiter),
|
|
71
71
|
].filter(Boolean))].join(delimiter);
|
|
72
|
+
// Same reason as PATH, for the other thing a lingering unit cannot inherit:
|
|
73
|
+
// WHICH ours daemon this fleet was set up against.
|
|
74
|
+
//
|
|
75
|
+
// ours-fleet resolves its daemon from OURS_CONFIG / OURS_PORT / OURS_STATE_DIR
|
|
76
|
+
// and otherwise falls back to ~/.ours and port 3050 (src/monitor.ts). A host
|
|
77
|
+
// set up against a non-default daemon — the installer's multi-daemon profiles
|
|
78
|
+
// do exactly this — passes that selection to `ours-fleet init` in the
|
|
79
|
+
// environment, and it died there: nothing persisted it, so every runner
|
|
80
|
+
// systemd started at boot resolved the default daemon again, and on a host
|
|
81
|
+
// where only the non-default daemon exists that is a daemon that is not there.
|
|
82
|
+
//
|
|
83
|
+
// ONLY OURS_CONFIG is baked, deliberately. It names which daemon, and leaves
|
|
84
|
+
// that daemon's own config file authoritative for port and state directory, so
|
|
85
|
+
// a later edit to it still wins. Baking OURS_PORT/OURS_STATE_DIR would freeze
|
|
86
|
+
// those into a unit file that outranks the config for ever after — the failure
|
|
87
|
+
// mode @ours.network/cli avoids for the same reason (packages/cli/src/
|
|
88
|
+
// service.ts: "The port is deliberately NOT baked").
|
|
89
|
+
//
|
|
90
|
+
// Absent from init's environment ⇒ no line, and the unit is byte-for-byte what
|
|
91
|
+
// it has always been. This teaches fleet nothing about installer profiles; it
|
|
92
|
+
// persists the selection fleet was initialised with.
|
|
93
|
+
const unitEnv = ['PATH=' + servicePath];
|
|
94
|
+
if (process.env.OURS_CONFIG)
|
|
95
|
+
unitEnv.push('OURS_CONFIG=' + process.env.OURS_CONFIG);
|
|
96
|
+
const environmentLines = unitEnv
|
|
97
|
+
.map(value => `Environment="${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%')}"`)
|
|
98
|
+
.join('\n');
|
|
72
99
|
mkdirSync(unitDir, { recursive: true });
|
|
73
100
|
writeFileSync(join(unitDir, UNIT_TEMPLATE), `[Unit]
|
|
74
101
|
Description=ours-fleet agent %i
|
|
@@ -76,7 +103,7 @@ After=default.target
|
|
|
76
103
|
|
|
77
104
|
[Service]
|
|
78
105
|
Type=simple
|
|
79
|
-
|
|
106
|
+
${environmentLines}
|
|
80
107
|
ExecStart=${unitArg(process.execPath)} ${unitArg(binPath)} _run %i
|
|
81
108
|
# The RUNNER owns the child-session restart loop, with a counted, backed-off
|
|
82
109
|
# circuit breaker (3.2). systemd must only recover the runner PROCESS crashing —
|
|
@@ -80,13 +80,6 @@ export function generateWatchdogBriefing(opts) {
|
|
|
80
80
|
L.push('- `healthy` — alive, on-briefing, recent progress.');
|
|
81
81
|
L.push('- `idle` — alive, nothing assigned or nothing to do. Not an anomaly.');
|
|
82
82
|
L.push('- `stale` = no worklog append and no console progress for ≥ 3 intervals.');
|
|
83
|
-
L.push('');
|
|
84
|
-
L.push('`session.readiness` from `ours-fleet status` is TURN OCCUPANCY, not activity: a mail');
|
|
85
|
-
L.push('wake delivered by ACP steering runs an entire turn while readiness stays `idle`. Never');
|
|
86
|
-
L.push('report `idle` or `stale` from `readiness=idle` alone — corroborate with the');
|
|
87
|
-
L.push('`activity:` line of the same `status` output (`active` means the agent is working),');
|
|
88
|
-
L.push('the worklog, or `ours-fleet peek`. `activity: unobservable` is missing evidence, not');
|
|
89
|
-
L.push('an idle agent.');
|
|
90
83
|
L.push('- `blocked` = waiting on a permission/prompt/modal longer than one interval.');
|
|
91
84
|
L.push('- `off_briefing` — activity contradicts the briefing (wrong repo, out-of-scope work,');
|
|
92
85
|
L.push(' ignored routine).');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0-nightly.2",
|
|
4
4
|
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"@agentclientprotocol/sdk": "^1.3.0",
|
|
39
39
|
"@fastify/static": "^10.1.2",
|
|
40
40
|
"@fastify/websocket": "^11.2.0",
|
|
41
|
+
"@ours.network/sdk": "1.3.1",
|
|
41
42
|
"@xterm/addon-fit": "0.10.0",
|
|
42
43
|
"@xterm/addon-serialize": "0.13.0",
|
|
43
44
|
"@xterm/headless": "5.5.0",
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
export declare class OursMcpError extends Error {
|
|
2
|
-
}
|
|
3
|
-
export interface OursToolClient {
|
|
4
|
-
start(): Promise<void>;
|
|
5
|
-
callTool(name: string, args?: Record<string, unknown>): Promise<unknown>;
|
|
6
|
-
close(): Promise<void>;
|
|
7
|
-
}
|
|
8
|
-
/** Minimal MCP stdio client. One instance owns exactly one ours identity binding. */
|
|
9
|
-
export declare class OursMcpClient implements OursToolClient {
|
|
10
|
-
private readonly command;
|
|
11
|
-
private readonly env;
|
|
12
|
-
private readonly log;
|
|
13
|
-
private child?;
|
|
14
|
-
private nextId;
|
|
15
|
-
private tail;
|
|
16
|
-
constructor(command?: string, env?: Record<string, string>, log?: (line: string) => void);
|
|
17
|
-
start(): Promise<void>;
|
|
18
|
-
callTool(name: string, args?: Record<string, unknown>): Promise<unknown>;
|
|
19
|
-
close(): Promise<void>;
|
|
20
|
-
private request;
|
|
21
|
-
private requestNow;
|
|
22
|
-
private notify;
|
|
23
|
-
private write;
|
|
24
|
-
}
|
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { createInterface } from 'node:readline';
|
|
4
|
-
export class OursMcpError extends Error {
|
|
5
|
-
}
|
|
6
|
-
/** Minimal MCP stdio client. One instance owns exactly one ours identity binding. */
|
|
7
|
-
export class OursMcpClient {
|
|
8
|
-
command;
|
|
9
|
-
env;
|
|
10
|
-
log;
|
|
11
|
-
child;
|
|
12
|
-
nextId = 0;
|
|
13
|
-
tail = Promise.resolve();
|
|
14
|
-
constructor(command = 'ours-mcp', env = {}, log = () => undefined) {
|
|
15
|
-
this.command = command;
|
|
16
|
-
this.env = env;
|
|
17
|
-
this.log = log;
|
|
18
|
-
}
|
|
19
|
-
async start() {
|
|
20
|
-
if (this.child && this.child.exitCode === null)
|
|
21
|
-
return;
|
|
22
|
-
// ours-mcp normally records the long-lived client PID so an identity lease
|
|
23
|
-
// survives connector churn. An owner-channel connector has the opposite
|
|
24
|
-
// lifecycle: each supervised attempt owns a fresh connector and must make
|
|
25
|
-
// its lease reclaimable when that connector exits, even though the fleet
|
|
26
|
-
// supervisor itself remains alive. POSIX exec preserves the shell PID as
|
|
27
|
-
// the proxy PID, giving the daemon an exact process-lifetime fence without
|
|
28
|
-
// interpolating the command path into shell text.
|
|
29
|
-
const child = spawn('/bin/sh', [
|
|
30
|
-
'-c', 'OURS_CLIENT_PID=$$; export OURS_CLIENT_PID; exec "$1" "$2"',
|
|
31
|
-
'ours-fleet-owner-proxy', this.command, 'proxy',
|
|
32
|
-
], {
|
|
33
|
-
env: {
|
|
34
|
-
...process.env,
|
|
35
|
-
...this.env,
|
|
36
|
-
// Bindings are keyed by this value. Sharing it would silently rebind a
|
|
37
|
-
// role's normal mailbox or another owner channel.
|
|
38
|
-
CLAUDE_CODE_SESSION_ID: `ours-fleet-owner-${process.pid}-${randomUUID()}`,
|
|
39
|
-
},
|
|
40
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
41
|
-
});
|
|
42
|
-
await new Promise((resolve, reject) => {
|
|
43
|
-
child.once('spawn', resolve);
|
|
44
|
-
child.once('error', reject);
|
|
45
|
-
});
|
|
46
|
-
this.child = child;
|
|
47
|
-
child.once('exit', (code, signal) => {
|
|
48
|
-
this.log(`ours-mcp proxy launcher exited (${code ?? signal ?? 'unknown'})`);
|
|
49
|
-
});
|
|
50
|
-
child.stdin.on('error', error => this.log(`ours-mcp stdin: ${error.message}`));
|
|
51
|
-
createInterface({ input: child.stderr }).on('line', line => this.log(`ours-mcp: ${line}`));
|
|
52
|
-
try {
|
|
53
|
-
await this.request('initialize', {
|
|
54
|
-
protocolVersion: '2025-03-26', capabilities: {},
|
|
55
|
-
clientInfo: { name: 'ours-fleet-owner-channel', version: '1' },
|
|
56
|
-
});
|
|
57
|
-
await this.notify('notifications/initialized', {});
|
|
58
|
-
}
|
|
59
|
-
catch (error) {
|
|
60
|
-
await this.close();
|
|
61
|
-
throw error;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
async callTool(name, args = {}) {
|
|
65
|
-
const result = await this.request('tools/call', { name, arguments: args });
|
|
66
|
-
const text = (result.content ?? [])
|
|
67
|
-
.filter(item => item.type === 'text').map(item => item.text ?? '').join('\n').trim();
|
|
68
|
-
if (result.isError)
|
|
69
|
-
throw new OursMcpError(text || `ours tool ${name} failed`);
|
|
70
|
-
if (result.structuredContent !== undefined)
|
|
71
|
-
return result.structuredContent;
|
|
72
|
-
if (!text)
|
|
73
|
-
return {};
|
|
74
|
-
try {
|
|
75
|
-
return JSON.parse(text);
|
|
76
|
-
}
|
|
77
|
-
catch {
|
|
78
|
-
return text;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
async close() {
|
|
82
|
-
const child = this.child;
|
|
83
|
-
this.child = undefined;
|
|
84
|
-
if (!child || child.exitCode !== null)
|
|
85
|
-
return;
|
|
86
|
-
// EOF asks the proxy to close normally. Once this exact process exits, the
|
|
87
|
-
// daemon can reclaim its lease even while the supervisor stays alive.
|
|
88
|
-
child.stdin.end();
|
|
89
|
-
const exited = await new Promise(resolve => {
|
|
90
|
-
const timer = setTimeout(() => resolve(false), 1_000);
|
|
91
|
-
child.once('exit', () => { clearTimeout(timer); resolve(true); });
|
|
92
|
-
});
|
|
93
|
-
if (exited || child.exitCode !== null)
|
|
94
|
-
return;
|
|
95
|
-
child.kill('SIGTERM');
|
|
96
|
-
await new Promise(resolve => {
|
|
97
|
-
const timer = setTimeout(() => { child.kill('SIGKILL'); resolve(); }, 5_000);
|
|
98
|
-
child.once('exit', () => { clearTimeout(timer); resolve(); });
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
request(method, params) {
|
|
102
|
-
const run = this.tail.then(() => this.requestNow(method, params));
|
|
103
|
-
this.tail = run.then(() => undefined, () => undefined);
|
|
104
|
-
return run;
|
|
105
|
-
}
|
|
106
|
-
async requestNow(method, params) {
|
|
107
|
-
const child = this.child;
|
|
108
|
-
if (!child || child.exitCode !== null)
|
|
109
|
-
throw new OursMcpError('ours-mcp proxy is not running');
|
|
110
|
-
const id = ++this.nextId;
|
|
111
|
-
await this.write(child, { jsonrpc: '2.0', id, method, params });
|
|
112
|
-
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
113
|
-
try {
|
|
114
|
-
for await (const line of lines) {
|
|
115
|
-
let response;
|
|
116
|
-
try {
|
|
117
|
-
response = JSON.parse(line);
|
|
118
|
-
}
|
|
119
|
-
catch {
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
if (response.id !== id)
|
|
123
|
-
continue;
|
|
124
|
-
if (response.error !== undefined)
|
|
125
|
-
throw new OursMcpError(JSON.stringify(response.error));
|
|
126
|
-
return response.result ?? {};
|
|
127
|
-
}
|
|
128
|
-
throw new OursMcpError('ours-mcp proxy closed its output');
|
|
129
|
-
}
|
|
130
|
-
finally {
|
|
131
|
-
lines.close();
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
async notify(method, params) {
|
|
135
|
-
const child = this.child;
|
|
136
|
-
if (!child || child.exitCode !== null)
|
|
137
|
-
throw new OursMcpError('ours-mcp proxy is not running');
|
|
138
|
-
await this.write(child, { jsonrpc: '2.0', method, params });
|
|
139
|
-
}
|
|
140
|
-
write(child, value) {
|
|
141
|
-
return new Promise((resolve, reject) => {
|
|
142
|
-
child.stdin.write(JSON.stringify(value) + '\n', error => error ? reject(error) : resolve());
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
}
|