mtech-ai-local-runner 0.1.31 → 0.1.32
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 +64 -1
- package/dist/src/adapters/claude-code/adapter.js +76 -17
- package/dist/src/adapters/codex/adapter.js +131 -30
- package/dist/src/adapters/codex/exec-launch-host.js +28 -11
- package/dist/src/adapters/codex/stream-parser.js +8 -17
- package/dist/src/adapters/completion-report.js +8 -5
- package/dist/src/daemon/daemon.js +62 -4
- package/dist/src/daemon/execution-coordinator.js +325 -111
- package/dist/src/daemon/job-poller.js +4 -1
- package/dist/src/daemon/manual-codex.js +10 -3
- package/dist/src/daemon/preparation-lease.js +142 -0
- package/dist/src/daemon/runtime.js +51 -25
- package/dist/src/daemon/terminal-recovery.js +29 -5
- package/dist/src/gateway-client.js +53 -22
- package/dist/src/process/node-process-group-host.js +72 -52
- package/dist/src/process/owned-process-guardian.js +140 -0
- package/dist/src/publication/installed-host.js +31 -1
- package/dist/src/publication/production-host.js +7 -3
- package/dist/src/publication/server-ci-error.js +15 -0
- package/dist/src/publication/server-ci-host.js +160 -0
- package/dist/src/runner.js +33 -4
- package/dist/src/safety/codex-daemon-host.js +2 -2
- package/dist/src/safety/execution-failure.js +4 -0
- package/dist/src/safety/terminal-evidence.js +67 -16
- package/dist/src/safety/unsupported-supervisor-command.js +12 -0
- package/dist/src/state-store.js +50 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,3 +1,65 @@
|
|
|
1
|
+
## Unreleased: P0 terminal safety baseline (local verification only)
|
|
2
|
+
|
|
3
|
+
The upstream source package is `0.1.31`; P0 does not bump or publish a version.
|
|
4
|
+
Deploy a compatible Gateway receiver before upgrading any runner. Publication,
|
|
5
|
+
installation, restart, flag activation and live acceptance require their own
|
|
6
|
+
delivery gates; this local change authorizes none of them. See the repository
|
|
7
|
+
runbook at `docs/ai-ops-gateway/runbooks/local-agent-daemon-rollout.md`.
|
|
8
|
+
|
|
9
|
+
Pause/replan now report Runtime `cancelled` and Job `cancelled`. Blocked reports,
|
|
10
|
+
required questions and scope conflicts report Runtime `failed` and Job `blocked`,
|
|
11
|
+
with bounded useful diagnostics. Gateway retains compatibility with historical
|
|
12
|
+
blocked pause/replan receipts. Claude revision messages preserve actual
|
|
13
|
+
`acceptance_criteria` and `required_changes`. Unsupported active Codex controls
|
|
14
|
+
stop the owned launch and acknowledge transport; that ACK does not mean the
|
|
15
|
+
requested revision was performed. No automatic relaunch or grant reuse occurs.
|
|
16
|
+
|
|
17
|
+
Terminal receipts are retained and replayed exactly. On restart, claimed/shadow
|
|
18
|
+
attempts report failed `runner_restarted`; binding/binding_uncertain attempts
|
|
19
|
+
report blocked `binding_outcome_unknown`. Running/reconciling attempts stay
|
|
20
|
+
manually suspended because previous OS cleanup is unknown. Server expiry closure
|
|
21
|
+
records failed provenance; it does not kill a process, complete a Loop or attest
|
|
22
|
+
client evidence. Minimal fsynced recovery markers and invalid state (including
|
|
23
|
+
JSON null) are preserved. Do not delete dirty workspaces, recovery or audit state
|
|
24
|
+
to resume claims. State-store mutations are serialized and save/suspend/clear
|
|
25
|
+
snapshot their input before queuing; a hung filesystem can leave durable recovery
|
|
26
|
+
pending, without a false acknowledgement or new claims.
|
|
27
|
+
|
|
28
|
+
A model-free guardian owns each newly created executor group and observes a
|
|
29
|
+
private parent-liveness pipe. Parent EOF invokes TERM, a 2-second grace, then
|
|
30
|
+
KILL for surviving owned processes. The 6-second cleanup-confirmation bound is
|
|
31
|
+
not a model-runtime budget. Guardian death or descendants escaping the group
|
|
32
|
+
remain an unconfirmed-cleanup/manual-reconciliation gap; local tests do not
|
|
33
|
+
establish Task 7/P0 operational acceptance.
|
|
34
|
+
|
|
35
|
+
Tracked diff hashing streams UTF-8-compatible input and retains only hashes and
|
|
36
|
+
manifest metadata; collection is abort-fenced. Preparation immediately renews the
|
|
37
|
+
Job lease with a single-flight heartbeat, checking exact job ID, generation,
|
|
38
|
+
status and expiry. The existing 900000 ms deadline bounds preparation only.
|
|
39
|
+
Transient retries use 1/2/4-second backoff, at most three, only within the last
|
|
40
|
+
confirmed lease and deadline. Authentication (401/403), fingerprint and fence
|
|
41
|
+
failures are fatal. Git workspace preparation has no abort API: late audit
|
|
42
|
+
workspaces are retained, but late completion cannot launch a downstream model.
|
|
43
|
+
OAuth policy, admission and manual pause behavior remain unchanged.
|
|
44
|
+
|
|
45
|
+
Running execution has an independent watchdog for the last server-confirmed Job
|
|
46
|
+
lease. Hung startup, control, heartbeat, stream, evidence or event-response waits
|
|
47
|
+
cannot defer cancellation past that lease. Late responses cannot renew host
|
|
48
|
+
authority, acknowledge a command or advance reporting/state after fence loss;
|
|
49
|
+
an unknown remote append remains uncertain for reconciliation. Healthy validated
|
|
50
|
+
renewals extend execution without a fixed model-runtime limit.
|
|
51
|
+
|
|
52
|
+
Local-fix preparation, owned freeze, publication finalization and server cursor
|
|
53
|
+
synchronization share that fence. A stopped or expired attempt cannot regain
|
|
54
|
+
publication authority through a late response. Once publication may have taken
|
|
55
|
+
effect, retain `publication_pending` recovery instead of falsely completing,
|
|
56
|
+
failing or restarting the attempt. Canonical publication approval remains required.
|
|
57
|
+
|
|
58
|
+
Component, shadow admission, installed-package and live-provider evidence are
|
|
59
|
+
separate. Historical 0.1.10 hook behavior below is not current Claude admission;
|
|
60
|
+
Claude parity requires the actual current isolation/admission gate to pass.
|
|
61
|
+
No live push/hook probe or real CLI/model run is part of this verification.
|
|
62
|
+
|
|
1
63
|
## 0.1.30: governed local alert investigation evidence
|
|
2
64
|
|
|
3
65
|
The Gateway candidate offers explicit Local Codex Runner or Local Claude Runner
|
|
@@ -337,7 +399,8 @@ npx mtech-ai-local-runner doctor
|
|
|
337
399
|
Cancelling a Work Loop also cancels its non-terminal local jobs. Repeating the
|
|
338
400
|
cancellation repairs jobs left by an earlier Gateway version; terminal results
|
|
339
401
|
are preserved. Claim/reclaim and lease-gated launch checks reject cancelled
|
|
340
|
-
Loops. An active adapter stops
|
|
402
|
+
Loops. An active adapter stops on a failed heartbeat or expiry of its last
|
|
403
|
+
confirmed Job lease, even when the current request never responds. Cancellation
|
|
341
404
|
is not an immediate process-kill acknowledgement.
|
|
342
405
|
|
|
343
406
|
Before binding, the Gateway permits at most three queue deliveries and a
|
|
@@ -44,6 +44,9 @@ export class ClaudeCodeAdapter {
|
|
|
44
44
|
tools;
|
|
45
45
|
cancelled = false;
|
|
46
46
|
started = false;
|
|
47
|
+
startupAcquisition;
|
|
48
|
+
revokedTools;
|
|
49
|
+
revokeFailed = false;
|
|
47
50
|
receipt;
|
|
48
51
|
toolEndpoint;
|
|
49
52
|
readSnapshot;
|
|
@@ -59,6 +62,8 @@ export class ClaudeCodeAdapter {
|
|
|
59
62
|
this.options = options;
|
|
60
63
|
}
|
|
61
64
|
async start(ctx) {
|
|
65
|
+
if (this.cancelled)
|
|
66
|
+
throw new Error('adapter startup cancelled');
|
|
62
67
|
const resolvedModelId = ctx.resolvedModelId;
|
|
63
68
|
if (!resolvedModelId) {
|
|
64
69
|
throw new Error("refusing to start: no Gateway-resolved model ID was supplied");
|
|
@@ -100,6 +105,10 @@ export class ClaudeCodeAdapter {
|
|
|
100
105
|
}
|
|
101
106
|
const hookCommand = this.requireTrustedHookCommand();
|
|
102
107
|
const prompt = compilePrompt(ctx.taskPackage, { workItemLabel: this.options.workItemLabel });
|
|
108
|
+
if (this.cancelled)
|
|
109
|
+
throw new Error('adapter startup cancelled');
|
|
110
|
+
let acquired;
|
|
111
|
+
this.startupAcquisition = new Promise(resolve => { acquired = resolve; });
|
|
103
112
|
try {
|
|
104
113
|
if (!this.options.createTools || !this.options.writeLocalMcpConfig)
|
|
105
114
|
throw new Error("job-local tool setup is required");
|
|
@@ -165,9 +174,15 @@ export class ClaudeCodeAdapter {
|
|
|
165
174
|
handle.stdin.write(`${JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: prompt }] } })}\n`);
|
|
166
175
|
}
|
|
167
176
|
catch (error) {
|
|
177
|
+
// Settle only the ownership-acquisition fence before cleanup. Waiting for
|
|
178
|
+
// all of start() here would deadlock cancellation against this catch.
|
|
179
|
+
acquired();
|
|
168
180
|
await this.cleanup();
|
|
169
181
|
throw error;
|
|
170
182
|
}
|
|
183
|
+
finally {
|
|
184
|
+
acquired();
|
|
185
|
+
}
|
|
171
186
|
}
|
|
172
187
|
async *events() {
|
|
173
188
|
const handle = this.requireHandle();
|
|
@@ -223,38 +238,82 @@ export class ClaudeCodeAdapter {
|
|
|
223
238
|
if (command.command === "answer_question" && typeof command.arguments?.answer === "string") {
|
|
224
239
|
payload.answer = command.arguments.answer;
|
|
225
240
|
}
|
|
226
|
-
if (command.command === "request_revision"
|
|
227
|
-
|
|
241
|
+
if (command.command === "request_revision") {
|
|
242
|
+
// The Gateway validates these exact string lists against the approved brief.
|
|
243
|
+
for (const key of ["acceptance_criteria", "required_changes"]) {
|
|
244
|
+
if (Array.isArray(command.arguments?.[key]))
|
|
245
|
+
payload[key] = command.arguments[key];
|
|
246
|
+
}
|
|
228
247
|
}
|
|
229
248
|
handle.stdin.write(`${JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: JSON.stringify(payload) }] } })}\n`);
|
|
230
249
|
}
|
|
231
|
-
|
|
250
|
+
cancelPromise;
|
|
251
|
+
cancel() {
|
|
232
252
|
this.cancelled = true;
|
|
233
|
-
|
|
253
|
+
if (this.cancelPromise)
|
|
254
|
+
return this.cancelPromise;
|
|
255
|
+
this.localReadObservations();
|
|
256
|
+
this.revokeTools(this.tools);
|
|
257
|
+
const cleanup = (async () => { await this.startupAcquisition; await this.cleanup(); })();
|
|
258
|
+
// This bounds cleanup confirmation, never admitted model runtime. Unknown
|
|
259
|
+
// acquisition stays unconfirmed; any late owner is still disposed by start.
|
|
260
|
+
this.cancelPromise = new Promise((resolve, reject) => {
|
|
261
|
+
const timer = setTimeout(() => reject(new Error('Provider process group cleanup could not be confirmed')), 6000);
|
|
262
|
+
void cleanup.then(() => { clearTimeout(timer); resolve(); }, () => { clearTimeout(timer); reject(new Error('Provider process group cleanup could not be confirmed')); });
|
|
263
|
+
});
|
|
264
|
+
return this.cancelPromise;
|
|
265
|
+
}
|
|
266
|
+
revokeTools(tools) {
|
|
267
|
+
if (!tools || this.revokedTools === tools)
|
|
268
|
+
return;
|
|
269
|
+
this.revokedTools = tools;
|
|
270
|
+
try {
|
|
271
|
+
tools.revoke();
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
this.revokeFailed = true;
|
|
275
|
+
}
|
|
234
276
|
}
|
|
235
|
-
|
|
277
|
+
cleanupPromise;
|
|
278
|
+
cleanup() {
|
|
279
|
+
if (this.cleanupPromise)
|
|
280
|
+
return this.cleanupPromise;
|
|
236
281
|
this.localReadObservations();
|
|
237
282
|
this.toolEndpoint = undefined;
|
|
238
283
|
const tools = this.tools;
|
|
239
284
|
const handle = this.handle;
|
|
240
285
|
this.tools = undefined;
|
|
241
286
|
this.handle = null;
|
|
242
|
-
|
|
243
|
-
tools
|
|
244
|
-
|
|
245
|
-
finally {
|
|
287
|
+
const operation = Promise.resolve().then(async () => {
|
|
288
|
+
this.revokeTools(tools);
|
|
289
|
+
let failed = this.revokeFailed;
|
|
246
290
|
try {
|
|
247
291
|
handle?.stdin.end();
|
|
248
292
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
handle?.kill("SIGKILL");
|
|
252
|
-
}
|
|
253
|
-
finally {
|
|
254
|
-
await tools?.close();
|
|
255
|
-
}
|
|
293
|
+
catch {
|
|
294
|
+
failed = true;
|
|
256
295
|
}
|
|
257
|
-
|
|
296
|
+
try {
|
|
297
|
+
if (handle?.terminateOwned)
|
|
298
|
+
await handle.terminateOwned();
|
|
299
|
+
else
|
|
300
|
+
handle?.kill('SIGKILL');
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
failed = true;
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
await tools?.close();
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
failed = true;
|
|
310
|
+
}
|
|
311
|
+
if (failed)
|
|
312
|
+
throw new Error('Provider process group cleanup could not be confirmed');
|
|
313
|
+
});
|
|
314
|
+
if (tools || handle)
|
|
315
|
+
this.cleanupPromise = operation;
|
|
316
|
+
return operation;
|
|
258
317
|
}
|
|
259
318
|
/** Claude Code treats a hook it cannot run as no objection, so the hook runs through Node. */
|
|
260
319
|
requireTrustedHookCommand() {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { executionError } from '../../safety/execution-failure.js';
|
|
2
|
+
import { UnsupportedSupervisorCommandError } from '../../safety/unsupported-supervisor-command.js';
|
|
2
3
|
import { CodexExecLaunchHost } from './exec-launch-host.js';
|
|
3
4
|
import { requireSecurityRequirements } from '../../safety/security-requirements.js';
|
|
4
5
|
import { createJobSecurityReceipt } from '../../safety/job-security-receipt.js';
|
|
@@ -27,6 +28,8 @@ export class CodexAdapter {
|
|
|
27
28
|
parser = new CodexStreamParser();
|
|
28
29
|
startupAbort = new AbortController();
|
|
29
30
|
securityPolicy;
|
|
31
|
+
/** Only the retained transport's pending owned-handle acquisition. */
|
|
32
|
+
pendingHandle;
|
|
30
33
|
constructor(options) {
|
|
31
34
|
this.options = options;
|
|
32
35
|
}
|
|
@@ -117,6 +120,29 @@ export class CodexAdapter {
|
|
|
117
120
|
await this.cancel();
|
|
118
121
|
throw error;
|
|
119
122
|
}
|
|
123
|
+
let lateCleanupFailed = false;
|
|
124
|
+
const stopReturnedHandle = async (handle) => {
|
|
125
|
+
let failed = false;
|
|
126
|
+
try {
|
|
127
|
+
handle.stdin.end();
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
failed = true;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
if (handle.terminateOwned)
|
|
134
|
+
await handle.terminateOwned();
|
|
135
|
+
else
|
|
136
|
+
handle.kill('SIGKILL');
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
failed = true;
|
|
140
|
+
}
|
|
141
|
+
if (failed) {
|
|
142
|
+
lateCleanupFailed = true;
|
|
143
|
+
throw new Error('Provider process group cleanup could not be confirmed');
|
|
144
|
+
}
|
|
145
|
+
};
|
|
120
146
|
try {
|
|
121
147
|
const prompt = compileCodexPrompt(pkg, this.options.jobId);
|
|
122
148
|
this.tools = this.options.createTools(pkg, approved);
|
|
@@ -128,27 +154,46 @@ export class CodexAdapter {
|
|
|
128
154
|
worktreeRoot: this.options.cwd ?? this.options.worktreeRoot, sourceRoot: this.options.worktreeRoot, schemaPath, endpoint, env: this.options.env, scratch: this.options.scratch });
|
|
129
155
|
if (!deferred)
|
|
130
156
|
assertAdmission();
|
|
131
|
-
|
|
157
|
+
this.assertNotCancelled();
|
|
158
|
+
let settleHandle;
|
|
159
|
+
this.pendingHandle = new Promise((resolve, reject) => { settleHandle = error => { if (error)
|
|
160
|
+
reject(error);
|
|
161
|
+
else
|
|
162
|
+
resolve(); }; });
|
|
163
|
+
void this.pendingHandle.catch(() => { });
|
|
132
164
|
try {
|
|
133
|
-
assertAdmission
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
165
|
+
const handle = await this.options.startVerifiedSession(invocation, prompt, this.startupAbort.signal, assertAdmission);
|
|
166
|
+
try {
|
|
167
|
+
assertAdmission();
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
try {
|
|
171
|
+
this.tools.revoke();
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
await stopReturnedHandle(handle);
|
|
175
|
+
}
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
// Publish either the adopted handle or its exact disposal outcome before
|
|
179
|
+
// releasing cancellation. The outer catch may then await shared cancel.
|
|
180
|
+
if (this.cancelled) {
|
|
181
|
+
await stopReturnedHandle(handle);
|
|
182
|
+
this.assertNotCancelled();
|
|
183
|
+
}
|
|
184
|
+
this.handle = handle;
|
|
139
185
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
handle.kill("SIGKILL");
|
|
143
|
-
this.assertNotCancelled();
|
|
186
|
+
finally {
|
|
187
|
+
settleHandle(lateCleanupFailed ? new Error('Provider process group cleanup could not be confirmed') : undefined);
|
|
144
188
|
}
|
|
145
|
-
this.handle = handle;
|
|
146
189
|
if (deferred)
|
|
147
190
|
this.receipt = createJobSecurityReceipt(this.securityPolicy, { jobId: this.options.jobId, generation: ctx.generation, modelId: ctx.resolvedModelId, sourceSha: String(source.base_commit_sha), ...ctx.securityBinding, requirements: ctx.approvedTaskPackage?.security_requirements });
|
|
148
191
|
}
|
|
149
192
|
catch {
|
|
150
193
|
const wasCancelled = this.cancelled;
|
|
151
194
|
await this.cancel();
|
|
195
|
+
if (lateCleanupFailed)
|
|
196
|
+
throw new Error('Provider process group cleanup could not be confirmed');
|
|
152
197
|
throw new Error(wasCancelled ? "Codex launch cancelled during startup" : "Codex job setup failed before execution could be confirmed");
|
|
153
198
|
}
|
|
154
199
|
}
|
|
@@ -200,33 +245,89 @@ export class CodexAdapter {
|
|
|
200
245
|
clearTimeout(timer);
|
|
201
246
|
}
|
|
202
247
|
}
|
|
203
|
-
|
|
248
|
+
cancelPromise;
|
|
249
|
+
cancel() {
|
|
250
|
+
if (this.cancelPromise)
|
|
251
|
+
return this.cancelPromise;
|
|
204
252
|
this.cancelled = true;
|
|
253
|
+
let finish, reject, failed = false;
|
|
254
|
+
this.cancelPromise = new Promise((resolve, rejectPromise) => { finish = resolve; reject = rejectPromise; });
|
|
255
|
+
const timer = setTimeout(() => reject(new Error('Provider process group cleanup could not be confirmed')), 6000);
|
|
256
|
+
// Let the official owner freeze its observations before external revocation.
|
|
257
|
+
// Known owner cleanup and endpoint closure start without waiting for a late
|
|
258
|
+
// retained handle; only confirmation waits for its acquisition/disposal.
|
|
259
|
+
let officialStop;
|
|
260
|
+
if (this.usingOfficialExec) {
|
|
261
|
+
try {
|
|
262
|
+
officialStop = this.ownedExec?.cancel();
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
failed = true;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
205
268
|
try {
|
|
206
|
-
if (this.usingOfficialExec)
|
|
207
|
-
await this.ownedExec?.cancel();
|
|
208
269
|
this.options.revokeSecurity?.();
|
|
209
270
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
271
|
+
catch {
|
|
272
|
+
failed = true;
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
this.tools?.revoke();
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
failed = true;
|
|
279
|
+
}
|
|
280
|
+
this.startupAbort.abort();
|
|
281
|
+
const ownerStop = (async () => {
|
|
282
|
+
if (this.usingOfficialExec) {
|
|
283
|
+
try {
|
|
284
|
+
await officialStop;
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
failed = true;
|
|
288
|
+
}
|
|
213
289
|
}
|
|
214
|
-
|
|
215
|
-
this.startupAbort.abort();
|
|
290
|
+
else {
|
|
216
291
|
try {
|
|
217
|
-
|
|
218
|
-
this.handle?.kill("SIGKILL");
|
|
292
|
+
this.handle?.stdin.end();
|
|
219
293
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
await this.
|
|
226
|
-
|
|
294
|
+
catch {
|
|
295
|
+
failed = true;
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
if (this.handle?.terminateOwned)
|
|
299
|
+
await this.handle.terminateOwned();
|
|
300
|
+
else
|
|
301
|
+
this.handle?.kill('SIGKILL');
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
failed = true;
|
|
227
305
|
}
|
|
228
306
|
}
|
|
307
|
+
})();
|
|
308
|
+
const toolsClose = (async () => { try {
|
|
309
|
+
await this.tools?.close();
|
|
229
310
|
}
|
|
311
|
+
catch {
|
|
312
|
+
failed = true;
|
|
313
|
+
} })();
|
|
314
|
+
void (async () => {
|
|
315
|
+
const outcomes = await Promise.allSettled([ownerStop, toolsClose, this.pendingHandle]);
|
|
316
|
+
if (outcomes.some(outcome => outcome.status === 'rejected'))
|
|
317
|
+
failed = true;
|
|
318
|
+
try {
|
|
319
|
+
await this.options.closeOfficialExec?.();
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
failed = true;
|
|
323
|
+
}
|
|
324
|
+
clearTimeout(timer);
|
|
325
|
+
if (failed)
|
|
326
|
+
reject(new Error('Provider process group cleanup could not be confirmed'));
|
|
327
|
+
else
|
|
328
|
+
finish();
|
|
329
|
+
})();
|
|
330
|
+
return this.cancelPromise;
|
|
230
331
|
}
|
|
231
332
|
assertNotCancelled() {
|
|
232
333
|
if (this.cancelled)
|
|
@@ -237,6 +338,6 @@ export class CodexAdapter {
|
|
|
237
338
|
return this.cancel();
|
|
238
339
|
if (command.command === "request_checkpoint")
|
|
239
340
|
return;
|
|
240
|
-
throw new
|
|
341
|
+
throw new UnsupportedSupervisorCommandError();
|
|
241
342
|
}
|
|
242
343
|
}
|
|
@@ -120,7 +120,7 @@ export class CodexExecLaunchHost {
|
|
|
120
120
|
this.handle = h;
|
|
121
121
|
timing.mark('spawn_returned');
|
|
122
122
|
if (this.stopped) {
|
|
123
|
-
|
|
123
|
+
await this.cancel();
|
|
124
124
|
throw fail();
|
|
125
125
|
}
|
|
126
126
|
h.once('exit', (code, signal) => {
|
|
@@ -295,16 +295,33 @@ export class CodexExecLaunchHost {
|
|
|
295
295
|
cleanupError = true;
|
|
296
296
|
}
|
|
297
297
|
this.abort.abort();
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
298
|
+
Promise.resolve().then(async () => {
|
|
299
|
+
try {
|
|
300
|
+
this.handle?.stdin.end();
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
cleanupError = true;
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
if (this.handle?.terminateOwned)
|
|
307
|
+
await this.handle.terminateOwned();
|
|
308
|
+
else
|
|
309
|
+
this.handle?.kill('SIGKILL');
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
cleanupError = true;
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
await this.deps.closeTools();
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
cleanupError = true;
|
|
319
|
+
}
|
|
320
|
+
if (cleanupError)
|
|
321
|
+
reject(new Error('CODEX_EXEC_CLEANUP_UNCONFIRMED'));
|
|
322
|
+
else
|
|
323
|
+
finish();
|
|
324
|
+
});
|
|
308
325
|
return this.closePromise;
|
|
309
326
|
}
|
|
310
327
|
}
|
|
@@ -1,21 +1,12 @@
|
|
|
1
1
|
import { createHmac, randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { completionEvent } from "../completion-report.js";
|
|
3
3
|
function record(value) {
|
|
4
4
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
5
5
|
? value : undefined;
|
|
6
6
|
}
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
return complete;
|
|
11
|
-
const value = completionEnvelope(text);
|
|
12
|
-
const bounded = (v, size) => typeof v === "string" && v.trim().length > 0 && v.length <= size;
|
|
13
|
-
const entries = (v, min) => Array.isArray(v) && v.length >= min && v.length <= 20 && v.every(item => bounded(item, 500));
|
|
14
|
-
if (!value || Object.keys(value).some(key => !["status", "report", "evidence", "pending_work"].includes(key))
|
|
15
|
-
|| value.status !== "blocked" || !bounded(value.report, 2000)
|
|
16
|
-
|| !entries(value.evidence, 0) || !entries(value.pending_work, 1))
|
|
17
|
-
return complete;
|
|
18
|
-
return { eventType: "blocked", summary: value.report.trim(), payload: { pending_work: value.pending_work, completion_evidence: value.evidence } };
|
|
7
|
+
/** Valid blocked reports are terminal failures, but still count for uniqueness. */
|
|
8
|
+
function acceptedReport(event) {
|
|
9
|
+
return event.eventType === "completed" || event.eventType === "failed" && event.payload?.reason === "executor_blocked";
|
|
19
10
|
}
|
|
20
11
|
/** Codex wire parsing is independent of Claude. Never forward reasoning or raw errors. */
|
|
21
12
|
export class CodexStreamParser {
|
|
@@ -52,8 +43,8 @@ export class CodexStreamParser {
|
|
|
52
43
|
}
|
|
53
44
|
if (event.type === "item.completed" && item.type === "agent_message") {
|
|
54
45
|
this.messages += 1;
|
|
55
|
-
this.report =
|
|
56
|
-
if (
|
|
46
|
+
this.report = completionEvent(item.text);
|
|
47
|
+
if (acceptedReport(this.report)) {
|
|
57
48
|
this.candidates += 1;
|
|
58
49
|
// Bounded diagnostic sample only. Neither raw IDs nor text digests
|
|
59
50
|
// leave this parser, and sampling never changes report acceptance.
|
|
@@ -73,9 +64,9 @@ export class CodexStreamParser {
|
|
|
73
64
|
const terminal = this.candidates === 0
|
|
74
65
|
? this.reportFailure(this.messages === 0 ? "CODEX_COMPLETION_REPORT_MISSING" : "CODEX_COMPLETION_REPORT_INVALID")
|
|
75
66
|
: this.candidates > 1 ? this.reportFailure("CODEX_COMPLETION_REPORT_MULTIPLE")
|
|
76
|
-
: this.report &&
|
|
67
|
+
: this.report && acceptedReport(this.report) ? this.report
|
|
77
68
|
: this.reportFailure("CODEX_COMPLETION_REPORT_NOT_FINAL");
|
|
78
|
-
if (terminal.eventType === "
|
|
69
|
+
if (terminal.eventType === "failed" && terminal.payload?.reason === "executor_blocked")
|
|
79
70
|
terminal.payload = { ...terminal.payload, completion_report: {
|
|
80
71
|
version: "codex-completion-report-v1", code: "CODEX_COMPLETION_REPORT_BLOCKED", agent_message_count: this.messages,
|
|
81
72
|
valid_report_count: this.candidates, invalid_message_count: this.messages - this.candidates, identity: this.reportIdentity(),
|
|
@@ -93,15 +93,18 @@ export function completionEvent(result, costUsd) {
|
|
|
93
93
|
if (!value)
|
|
94
94
|
return failed;
|
|
95
95
|
if (Object.keys(value).some((key) => !["status", "report", "evidence", "pending_work"].includes(key))
|
|
96
|
-
|| value.status !== "completed"
|
|
96
|
+
|| value.status !== "completed" && value.status !== "blocked"
|
|
97
97
|
|| !boundedText(value.report, MAX_EVENT_SUMMARY_CHARS)
|
|
98
|
-
|| !Array.isArray(value.pending_work) || value.pending_work.length
|
|
99
|
-
||
|
|
98
|
+
|| !Array.isArray(value.pending_work) || value.pending_work.length > MAX_EVIDENCE_ITEMS
|
|
99
|
+
|| (value.status === "completed" ? value.pending_work.length !== 0 : value.pending_work.length < 1)
|
|
100
|
+
|| !value.pending_work.every((item) => boundedText(item, MAX_EVIDENCE_CHARS))
|
|
101
|
+
|| !Array.isArray(value.evidence) || value.evidence.length < (value.status === "completed" ? 1 : 0) || value.evidence.length > MAX_EVIDENCE_ITEMS
|
|
100
102
|
|| !value.evidence.every((item) => boundedText(item, MAX_EVIDENCE_CHARS)))
|
|
101
103
|
return failed;
|
|
102
104
|
return {
|
|
103
|
-
eventType: "completed",
|
|
105
|
+
eventType: value.status === "completed" ? "completed" : "failed",
|
|
104
106
|
summary: value.report.trim(),
|
|
105
|
-
payload: { ...usage, investigation_summary: value.report.trim(), completion_evidence: value.evidence
|
|
107
|
+
payload: { ...usage, investigation_summary: value.report.trim(), completion_evidence: value.evidence,
|
|
108
|
+
...(value.status === "blocked" ? { reason: "executor_blocked", pending_work: value.pending_work } : {}) },
|
|
106
109
|
};
|
|
107
110
|
}
|
|
@@ -1,15 +1,73 @@
|
|
|
1
1
|
export class Daemon {
|
|
2
2
|
deps;
|
|
3
|
+
stopping = new AbortController();
|
|
3
4
|
constructor(deps) {
|
|
4
5
|
this.deps = deps;
|
|
5
6
|
}
|
|
7
|
+
stop() { this.deps.poller.stop?.(); this.stopping.abort(); }
|
|
8
|
+
async suspend(state, duringExecution = false) {
|
|
9
|
+
const phase = state && typeof state === 'object' && 'phase' in state ? state.phase : undefined;
|
|
10
|
+
const reason = duringExecution ? 'execution_outcome_unknown' : phase === 'binding' || phase === 'binding_uncertain' ? 'binding_outcome_unknown' : 'runner_restarted';
|
|
11
|
+
try {
|
|
12
|
+
await this.deps.state.suspend?.(reason);
|
|
13
|
+
}
|
|
14
|
+
catch { /* Preserve original state and remain alive even if the marker cannot be written. */ }
|
|
15
|
+
try {
|
|
16
|
+
this.deps.reportRecoveryRequired?.(reason);
|
|
17
|
+
}
|
|
18
|
+
catch { /* Logging cannot reopen claims or exit suspended recovery. */ }
|
|
19
|
+
if (this.stopping.signal.aborted)
|
|
20
|
+
return;
|
|
21
|
+
if (this.deps.wait) {
|
|
22
|
+
await this.deps.wait(this.stopping.signal);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
// A pending promise does not keep Node alive. This timer deliberately stays
|
|
26
|
+
// referenced, holds the process lock and wakes only for explicit close.
|
|
27
|
+
await new Promise(resolve => {
|
|
28
|
+
const timer = setInterval(() => { }, 60_000);
|
|
29
|
+
this.stopping.signal.addEventListener('abort', () => { clearInterval(timer); resolve(); }, { once: true });
|
|
30
|
+
});
|
|
31
|
+
}
|
|
6
32
|
async run() {
|
|
7
33
|
await this.deps.lock.acquire();
|
|
8
34
|
try {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
await this.deps.
|
|
12
|
-
|
|
35
|
+
let state;
|
|
36
|
+
try {
|
|
37
|
+
state = await this.deps.state.load();
|
|
38
|
+
if (state !== null && state !== undefined) {
|
|
39
|
+
await this.deps.reconcile(state);
|
|
40
|
+
state = await this.deps.state.load();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
await this.suspend(state);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (state !== null && state !== undefined) {
|
|
48
|
+
await this.suspend(state);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (this.stopping.signal.aborted)
|
|
52
|
+
return;
|
|
53
|
+
try {
|
|
54
|
+
await this.deps.poller.run();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
try {
|
|
58
|
+
this.deps.poller.stop?.();
|
|
59
|
+
}
|
|
60
|
+
catch { /* Still inspect retained state and suspend below. */ }
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
state = await this.deps.state.load();
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
await this.suspend(undefined, true);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (state !== null && state !== undefined)
|
|
70
|
+
await this.suspend(state, true);
|
|
13
71
|
}
|
|
14
72
|
finally {
|
|
15
73
|
await this.deps.lock.release();
|