@ours.network/fleet 1.1.0 → 1.1.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 +100 -0
- package/dist/build-info.json +4 -4
- package/dist/config.d.ts +4 -0
- package/dist/config.js +9 -1
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +39 -0
- package/dist/doctor.js +32 -0
- package/dist/exec.d.ts +1 -0
- package/dist/exec.js +1 -1
- package/dist/harness/claude-code-session.js +3 -0
- package/dist/harness/codex-runtime.d.ts +14 -0
- package/dist/harness/codex-runtime.js +70 -0
- package/dist/harness/codex-session.js +3 -0
- package/dist/harness/codex.js +21 -5
- package/dist/runner.js +13 -3
- package/dist/session/acp.d.ts +23 -0
- package/dist/session/acp.js +278 -4
- package/dist/session/stall-watchdog.d.ts +60 -0
- package/dist/session/stall-watchdog.js +210 -0
- package/dist/session/types.d.ts +5 -2
- package/dist/temp-lifecycle.js +3 -0
- package/package.json +2 -2
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { closeSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { replaceFileAtomically } from '../atomic-file.js';
|
|
5
|
+
export const DEFAULT_STALL_TIMEOUT_MS = 15 * 60_000;
|
|
6
|
+
export const STALL_RECOVERY_PROMPT = 'This is a diagnostic interruption. The previous turn showed no progress. '
|
|
7
|
+
+ 'Inspect recorded terminal events and re-check completed actions before continuing. '
|
|
8
|
+
+ 'Never assume an issued side effect failed: do not replay ambiguous or already-completed mutations. '
|
|
9
|
+
+ 'Continue the previous task if safe; otherwise report an actionable blocker.';
|
|
10
|
+
const digest = (text) => createHash('sha256').update(text).digest('hex');
|
|
11
|
+
/** Presence, including an incomplete claim, restores conservative mail policy. */
|
|
12
|
+
export function hasStallRecoveryClaim(stateDir, sessionId) {
|
|
13
|
+
try {
|
|
14
|
+
lstatSync(join(stateDir, '.stall-recovery', `${digest(sessionId)}.claim`));
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
return error.code !== 'ENOENT';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** ACP has no turn IDs on tool updates. Reuse across turns is ambiguous. */
|
|
22
|
+
export class StallToolHistory {
|
|
23
|
+
turns = new Map();
|
|
24
|
+
healthy = true;
|
|
25
|
+
directory;
|
|
26
|
+
path;
|
|
27
|
+
session;
|
|
28
|
+
constructor(stateDir, sessionId, resume) {
|
|
29
|
+
this.session = digest(sessionId);
|
|
30
|
+
this.directory = join(stateDir, '.stall-recovery');
|
|
31
|
+
this.path = join(this.directory, `${this.session}.tools.json`);
|
|
32
|
+
try {
|
|
33
|
+
const stored = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
34
|
+
if (stored.version !== 1 || stored.session !== this.session || !Array.isArray(stored.tools)
|
|
35
|
+
|| stored.tools.length > 4096 || !stored.tools.every((id) => typeof id === 'string' && /^[a-f0-9]{64}$/.test(id)))
|
|
36
|
+
throw new Error('invalid history');
|
|
37
|
+
for (const id of stored.tools)
|
|
38
|
+
this.turns.set(id, 'previous-generation');
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code !== 'ENOENT' || resume)
|
|
42
|
+
this.healthy = false;
|
|
43
|
+
else {
|
|
44
|
+
try {
|
|
45
|
+
mkdirSync(this.directory, { recursive: true, mode: 0o700 });
|
|
46
|
+
this.persist();
|
|
47
|
+
const fd = openSync(stateDir, 'r');
|
|
48
|
+
try {
|
|
49
|
+
fsyncSync(fd);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
closeSync(fd);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
this.healthy = false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
available() { return this.healthy; }
|
|
62
|
+
/** Record before relying on a tool event. False means cancellation is unsafe. */
|
|
63
|
+
observe(toolId, turnId) {
|
|
64
|
+
if (!this.healthy || !toolId)
|
|
65
|
+
return false;
|
|
66
|
+
const id = digest(toolId);
|
|
67
|
+
const previous = this.turns.get(id);
|
|
68
|
+
if (previous !== undefined)
|
|
69
|
+
return previous === turnId;
|
|
70
|
+
if (this.turns.size >= 4096) {
|
|
71
|
+
this.healthy = false;
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
this.turns.set(id, turnId);
|
|
75
|
+
try {
|
|
76
|
+
this.persist();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
this.healthy = false;
|
|
80
|
+
}
|
|
81
|
+
return this.healthy;
|
|
82
|
+
}
|
|
83
|
+
persist() {
|
|
84
|
+
replaceFileAtomically(this.path, JSON.stringify({ version: 1, session: this.session,
|
|
85
|
+
tools: [...this.turns.keys()] }) + '\n');
|
|
86
|
+
const fd = openSync(this.directory, 'r');
|
|
87
|
+
try {
|
|
88
|
+
fsyncSync(fd);
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
closeSync(fd);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* One durable attempt per ACP session, deliberately stricter than one per turn.
|
|
97
|
+
* A restarted supervisor never guesses whether cancellation or recovery ran.
|
|
98
|
+
* Claim files are never reclaimed automatically, including malformed/empty ones.
|
|
99
|
+
*/
|
|
100
|
+
export class StallWatchdog {
|
|
101
|
+
options;
|
|
102
|
+
checking = false;
|
|
103
|
+
disabled = false;
|
|
104
|
+
unavailableTurn;
|
|
105
|
+
constructor(options) {
|
|
106
|
+
this.options = options;
|
|
107
|
+
}
|
|
108
|
+
async tick() {
|
|
109
|
+
if (this.checking || this.disabled)
|
|
110
|
+
return;
|
|
111
|
+
const observed = this.options.observe();
|
|
112
|
+
if (!observed)
|
|
113
|
+
return;
|
|
114
|
+
const missingBoundary = observed.boundaryEvidenceAvailable === false;
|
|
115
|
+
if (!observed.safe && !missingBoundary && !this.options.previouslyClaimed)
|
|
116
|
+
return;
|
|
117
|
+
const missingEvidence = observed.progressCount === 0 || missingBoundary;
|
|
118
|
+
// Turn age can only produce an informational blocker, never cancellation.
|
|
119
|
+
const idleMs = this.options.now() - (observed.progressCount === 0 ? observed.startedAt : observed.lastProgressAt);
|
|
120
|
+
// Generic silence needs two full windows. Authenticated repeated transport
|
|
121
|
+
// failures strengthen evidence, but never bypass protected-operation checks.
|
|
122
|
+
const threshold = this.options.timeoutMs * (!missingEvidence && observed.transportFailures >= 2 ? 1 : 2);
|
|
123
|
+
if (!Number.isFinite(idleMs) || (idleMs < threshold && !this.options.previouslyClaimed))
|
|
124
|
+
return;
|
|
125
|
+
this.checking = true;
|
|
126
|
+
const session = digest(observed.sessionId);
|
|
127
|
+
const turn = digest(observed.generation + '\0' + observed.turnId);
|
|
128
|
+
const directory = join(this.options.stateDir, '.stall-recovery');
|
|
129
|
+
const report = (status) => {
|
|
130
|
+
const event = {
|
|
131
|
+
version: 1, kind: 'stall_recovery', eventId: `${turn}:${status}`,
|
|
132
|
+
session, turn, status, idleMs: Math.floor(idleMs),
|
|
133
|
+
evidence: observed.transportFailures >= 2 ? 'adapter_transport' : 'no_progress',
|
|
134
|
+
};
|
|
135
|
+
const fd = openSync(join(directory, 'audit.jsonl'), 'a', 0o600);
|
|
136
|
+
try {
|
|
137
|
+
writeFileSync(fd, JSON.stringify(event) + '\n');
|
|
138
|
+
fsyncSync(fd);
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
closeSync(fd);
|
|
142
|
+
}
|
|
143
|
+
this.options.diagnostic(event);
|
|
144
|
+
};
|
|
145
|
+
try {
|
|
146
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
147
|
+
const parentFd = openSync(this.options.stateDir, 'r');
|
|
148
|
+
try {
|
|
149
|
+
fsyncSync(parentFd);
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
closeSync(parentFd);
|
|
153
|
+
}
|
|
154
|
+
if (this.options.previouslyClaimed) {
|
|
155
|
+
this.disabled = true;
|
|
156
|
+
report('blocked_previous_attempt');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (missingEvidence) {
|
|
160
|
+
if (this.unavailableTurn !== turn) {
|
|
161
|
+
this.unavailableTurn = turn;
|
|
162
|
+
report('blocked_evidence');
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
let fd;
|
|
167
|
+
try {
|
|
168
|
+
fd = openSync(join(directory, `${session}.claim`), 'wx', 0o600);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
this.disabled = true;
|
|
172
|
+
if (error.code === 'EEXIST') {
|
|
173
|
+
report('blocked_previous_attempt');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
// Even a crash before this write leaves a permanent conservative fence.
|
|
179
|
+
try {
|
|
180
|
+
writeFileSync(fd, JSON.stringify({ version: 1, session, turn }) + '\n');
|
|
181
|
+
fsyncSync(fd);
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
closeSync(fd);
|
|
185
|
+
}
|
|
186
|
+
const dirFd = openSync(directory, 'r');
|
|
187
|
+
try {
|
|
188
|
+
fsyncSync(dirFd);
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
closeSync(dirFd);
|
|
192
|
+
}
|
|
193
|
+
this.disabled = true;
|
|
194
|
+
report('interrupt_requested');
|
|
195
|
+
// No await between the durable claim and the adapter's final atomic
|
|
196
|
+
// observation check. Recovery itself must re-check before session/cancel.
|
|
197
|
+
await this.options.recover(observed, report);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
this.disabled = true;
|
|
201
|
+
// Never include an exception string: it may contain paths or wire data.
|
|
202
|
+
this.options.diagnostic({ version: 1, kind: 'stall_recovery',
|
|
203
|
+
eventId: `${turn}:blocked_persistence`, session, turn,
|
|
204
|
+
status: 'blocked_persistence', evidence: 'no_progress', idleMs: Math.floor(idleMs) });
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
this.checking = false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
package/dist/session/types.d.ts
CHANGED
|
@@ -13,9 +13,11 @@ import type { ConversationEventV1, ConversationSnapshot, PromptReceipt, SubmitPr
|
|
|
13
13
|
*/
|
|
14
14
|
export type SessionReadiness = 'starting' | 'idle' | 'running' | 'awaiting_permission' | 'failed';
|
|
15
15
|
export type TurnOutcome = 'completed' | 'refused' | 'cancelled' | 'failed' | 'inconclusive';
|
|
16
|
-
export type TurnCancellationSource = 'owner' | 'local-console' | 'fleet-monitor' | 'scheduled-loop' | 'shutdown';
|
|
16
|
+
export type TurnCancellationSource = 'owner' | 'local-console' | 'fleet-monitor' | 'scheduled-loop' | 'shutdown' | 'stall-watchdog';
|
|
17
17
|
export type PromptOrigin = {
|
|
18
18
|
kind: 'startup';
|
|
19
|
+
} | {
|
|
20
|
+
kind: 'stall-watchdog';
|
|
19
21
|
} | {
|
|
20
22
|
kind: 'local-console';
|
|
21
23
|
} | {
|
|
@@ -221,7 +223,7 @@ export interface AgentSessionCapabilities {
|
|
|
221
223
|
}
|
|
222
224
|
/** Conservative static capabilities used before a live session is reachable. */
|
|
223
225
|
export declare function sessionBackendCapabilities(backend: SessionBackendId, harness?: string): AgentSessionCapabilities;
|
|
224
|
-
export type SessionEventKind = 'state' | 'agent_text' | 'thought' | 'tool_call' | 'tool_update' | 'permission' | 'monitor_delivery' | 'turn_stop' | 'error';
|
|
226
|
+
export type SessionEventKind = 'state' | 'agent_text' | 'thought' | 'tool_call' | 'tool_update' | 'permission' | 'stall_recovery' | 'monitor_delivery' | 'turn_stop' | 'error';
|
|
225
227
|
/** What a settled permission request resolved to. */
|
|
226
228
|
export type PermissionDecision = 'allowed' | 'denied' | 'cancelled';
|
|
227
229
|
export interface SessionEvent {
|
|
@@ -263,6 +265,7 @@ export interface SessionEvent {
|
|
|
263
265
|
/** The option actually selected, when one was. */
|
|
264
266
|
optionId?: string;
|
|
265
267
|
/** Body-free evidence for monitor safe-boundary delivery. */
|
|
268
|
+
stallDiagnostic?: import('./stall-watchdog.js').StallDiagnostic;
|
|
266
269
|
monitorPolicy?: 'after_tool';
|
|
267
270
|
activeToolCount?: number;
|
|
268
271
|
waitedMs?: number;
|
package/dist/temp-lifecycle.js
CHANGED
|
@@ -60,6 +60,9 @@ export function makeTempSupervisorLauncher(options = {}) {
|
|
|
60
60
|
return async (binPath, args, dir) => {
|
|
61
61
|
const inherited = [
|
|
62
62
|
'HOME', 'PATH', 'XDG_RUNTIME_DIR', 'OURS_FLEET_HOME', 'CODEX_HOME',
|
|
63
|
+
// Preserve runtime selection across the service-manager boundary, including
|
|
64
|
+
// an explicit empty value which selects the bundle over manager defaults.
|
|
65
|
+
'CODEX_PATH',
|
|
63
66
|
// The child supervisor performs daemon identity and wake probes itself;
|
|
64
67
|
// it must resolve the same ours profile as the spawning supervisor.
|
|
65
68
|
'OURS_PORT', 'OURS_STATE_DIR', 'OURS_API_TOKEN', 'OURS_CONFIG',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
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",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"optionalDependencies": {
|
|
54
54
|
"@agentclientprotocol/claude-agent-acp": "^0.63.0",
|
|
55
|
-
"@agentclientprotocol/codex-acp": "1.
|
|
55
|
+
"@agentclientprotocol/codex-acp": "1.10.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@playwright/test": "^1.54.1",
|