@phyzess/pappra 0.0.0-stage → 0.1.0-beta.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/LICENSE +21 -0
- package/README.md +80 -2
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/integrations/skills/pappra/SKILL.md +68 -0
- package/assets/integrations/skills/pappra/references/hosts.md +30 -0
- package/assets/integrations/skills/pappra/references/lifecycle.md +37 -0
- package/assets/integrations/skills/pappra/references/operations.md +14 -0
- package/assets/manifest.json +4 -0
- package/dist/app/collaboration.d.ts +170 -0
- package/dist/app/collaboration.js +1650 -0
- package/dist/app/doctor.d.ts +9 -0
- package/dist/app/doctor.js +1030 -0
- package/dist/app/init.d.ts +143 -0
- package/dist/app/init.js +1111 -0
- package/dist/cli/commands/code.d.ts +16 -0
- package/dist/cli/commands/code.js +127 -0
- package/dist/cli/commands/collaboration.d.ts +13 -0
- package/dist/cli/commands/collaboration.js +186 -0
- package/dist/cli/commands/continuity.d.ts +29 -0
- package/dist/cli/commands/continuity.js +133 -0
- package/dist/cli/commands/docs.d.ts +11 -0
- package/dist/cli/commands/docs.js +133 -0
- package/dist/cli/commands/doctor.d.ts +14 -0
- package/dist/cli/commands/doctor.js +57 -0
- package/dist/cli/commands/host.d.ts +13 -0
- package/dist/cli/commands/host.js +125 -0
- package/dist/cli/commands/init.d.ts +51 -0
- package/dist/cli/commands/init.js +668 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +10 -0
- package/dist/cli/output.d.ts +4 -0
- package/dist/cli/output.js +1 -0
- package/dist/cli/run.d.ts +2 -0
- package/dist/cli/run.js +597 -0
- package/dist/code/budget.d.ts +14 -0
- package/dist/code/budget.js +39 -0
- package/dist/code/exec.d.ts +23 -0
- package/dist/code/exec.js +133 -0
- package/dist/code/identity.d.ts +13 -0
- package/dist/code/identity.js +214 -0
- package/dist/code/index.d.ts +6 -0
- package/dist/code/index.js +3 -0
- package/dist/code/providers/codegraph.d.ts +59 -0
- package/dist/code/providers/codegraph.js +567 -0
- package/dist/code/registration.d.ts +9 -0
- package/dist/code/registration.js +293 -0
- package/dist/code/service.d.ts +53 -0
- package/dist/code/service.js +633 -0
- package/dist/code/text.d.ts +3 -0
- package/dist/code/text.js +202 -0
- package/dist/collab/admission.d.ts +450 -0
- package/dist/collab/admission.js +797 -0
- package/dist/collab/canonical.d.ts +26 -0
- package/dist/collab/canonical.js +136 -0
- package/dist/collab/contracts.d.ts +73 -0
- package/dist/collab/contracts.js +76 -0
- package/dist/collab/envelope.d.ts +52 -0
- package/dist/collab/envelope.js +241 -0
- package/dist/collab/fingerprint.d.ts +7 -0
- package/dist/collab/fingerprint.js +58 -0
- package/dist/collab/handoff.d.ts +350 -0
- package/dist/collab/handoff.js +513 -0
- package/dist/collab/index.d.ts +9 -0
- package/dist/collab/index.js +8 -0
- package/dist/collab/lifecycle.d.ts +449 -0
- package/dist/collab/lifecycle.js +534 -0
- package/dist/collab/messages.d.ts +148 -0
- package/dist/collab/messages.js +583 -0
- package/dist/collab/notify.d.ts +47 -0
- package/dist/collab/notify.js +140 -0
- package/dist/collab/preflight.d.ts +31 -0
- package/dist/collab/preflight.js +271 -0
- package/dist/collab/resources.d.ts +119 -0
- package/dist/collab/resources.js +451 -0
- package/dist/collab/store.d.ts +143 -0
- package/dist/collab/store.js +606 -0
- package/dist/collab/types.d.ts +354 -0
- package/dist/collab/types.js +1 -0
- package/dist/collab/validation.d.ts +10 -0
- package/dist/collab/validation.js +303 -0
- package/dist/continuity/context.d.ts +24 -0
- package/dist/continuity/context.js +179 -0
- package/dist/continuity/index.d.ts +3 -0
- package/dist/continuity/index.js +1 -0
- package/dist/continuity/paths.d.ts +37 -0
- package/dist/continuity/paths.js +258 -0
- package/dist/continuity/service.d.ts +59 -0
- package/dist/continuity/service.js +1258 -0
- package/dist/hosts/claude/native.d.ts +79 -0
- package/dist/hosts/claude/native.js +853 -0
- package/dist/hosts/codex/native.d.ts +345 -0
- package/dist/hosts/codex/native.js +1528 -0
- package/dist/hosts/codex/protocol.d.ts +51 -0
- package/dist/hosts/codex/protocol.js +42 -0
- package/dist/hosts/detect.d.ts +28 -0
- package/dist/hosts/detect.js +73 -0
- package/dist/hosts/index.d.ts +10 -0
- package/dist/hosts/index.js +10 -0
- package/dist/hosts/integration/bundle.d.ts +16 -0
- package/dist/hosts/integration/bundle.js +81 -0
- package/dist/hosts/integration/hook-entry.d.ts +1 -0
- package/dist/hosts/integration/hook-entry.js +68 -0
- package/dist/hosts/integration/hook.d.ts +32 -0
- package/dist/hosts/integration/hook.js +781 -0
- package/dist/hosts/integration/pi-extension-runtime.d.ts +18 -0
- package/dist/hosts/integration/pi-extension-runtime.js +89 -0
- package/dist/hosts/integration/pi-template.d.ts +3 -0
- package/dist/hosts/integration/pi-template.js +5 -0
- package/dist/hosts/integration/service.d.ts +41 -0
- package/dist/hosts/integration/service.js +1381 -0
- package/dist/hosts/integration/support.d.ts +36 -0
- package/dist/hosts/integration/support.js +41 -0
- package/dist/hosts/integration/templates.d.ts +22 -0
- package/dist/hosts/integration/templates.js +70 -0
- package/dist/hosts/pi/protocol.d.ts +22 -0
- package/dist/hosts/pi/protocol.js +28 -0
- package/dist/hosts/pi/query-result.d.ts +22 -0
- package/dist/hosts/pi/query-result.js +710 -0
- package/dist/hosts/pi/rpc.d.ts +937 -0
- package/dist/hosts/pi/rpc.js +1621 -0
- package/dist/hosts/pilot.d.ts +485 -0
- package/dist/hosts/pilot.js +593 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/infra/fs.d.ts +27 -0
- package/dist/infra/fs.js +130 -0
- package/dist/infra/git.d.ts +60 -0
- package/dist/infra/git.js +358 -0
- package/dist/infra/owned-process-group.d.ts +173 -0
- package/dist/infra/owned-process-group.js +571 -0
- package/dist/install/apply.d.ts +65 -0
- package/dist/install/apply.js +663 -0
- package/dist/install/index.d.ts +5 -0
- package/dist/install/index.js +5 -0
- package/dist/install/ownership.d.ts +85 -0
- package/dist/install/ownership.js +430 -0
- package/dist/install/plan.d.ts +39 -0
- package/dist/install/plan.js +97 -0
- package/dist/install/rollback.d.ts +18 -0
- package/dist/install/rollback.js +544 -0
- package/dist/install/runtime.d.ts +76 -0
- package/dist/install/runtime.js +1145 -0
- package/dist/knowledge/catalog.d.ts +47 -0
- package/dist/knowledge/catalog.js +308 -0
- package/dist/knowledge/context.d.ts +28 -0
- package/dist/knowledge/context.js +61 -0
- package/dist/knowledge/index.d.ts +3 -0
- package/dist/knowledge/index.js +1 -0
- package/dist/knowledge/journal.d.ts +16 -0
- package/dist/knowledge/journal.js +109 -0
- package/dist/knowledge/lifecycle.d.ts +8 -0
- package/dist/knowledge/lifecycle.js +1001 -0
- package/dist/knowledge/paths.d.ts +37 -0
- package/dist/knowledge/paths.js +236 -0
- package/dist/knowledge/refs.d.ts +36 -0
- package/dist/knowledge/refs.js +267 -0
- package/dist/knowledge/service.d.ts +21 -0
- package/dist/knowledge/service.js +393 -0
- package/dist/knowledge/types.d.ts +171 -0
- package/dist/knowledge/types.js +1 -0
- package/dist/knowledge/write.d.ts +8 -0
- package/dist/knowledge/write.js +1223 -0
- package/dist/package-info.d.ts +6 -0
- package/dist/package-info.js +19 -0
- package/dist/schemas/code.d.ts +377 -0
- package/dist/schemas/code.js +145 -0
- package/dist/schemas/collaboration.d.ts +115 -0
- package/dist/schemas/collaboration.js +106 -0
- package/dist/schemas/continuity.d.ts +315 -0
- package/dist/schemas/continuity.js +199 -0
- package/dist/schemas/diagnostics.d.ts +28 -0
- package/dist/schemas/diagnostics.js +27 -0
- package/dist/schemas/doctor.d.ts +157 -0
- package/dist/schemas/doctor.js +75 -0
- package/dist/schemas/document.d.ts +644 -0
- package/dist/schemas/document.js +296 -0
- package/dist/schemas/errors.d.ts +177 -0
- package/dist/schemas/errors.js +176 -0
- package/dist/schemas/evidence.d.ts +24 -0
- package/dist/schemas/evidence.js +16 -0
- package/dist/schemas/host.d.ts +436 -0
- package/dist/schemas/host.js +201 -0
- package/dist/schemas/index.d.ts +15 -0
- package/dist/schemas/index.js +15 -0
- package/dist/schemas/init.d.ts +121 -0
- package/dist/schemas/init.js +94 -0
- package/dist/schemas/install.d.ts +213 -0
- package/dist/schemas/install.js +139 -0
- package/dist/schemas/machine.d.ts +43 -0
- package/dist/schemas/machine.js +96 -0
- package/dist/schemas/outcome.d.ts +23 -0
- package/dist/schemas/outcome.js +17 -0
- package/dist/schemas/paths.d.ts +19 -0
- package/dist/schemas/paths.js +63 -0
- package/dist/schemas/workspace.d.ts +120 -0
- package/dist/schemas/workspace.js +201 -0
- package/dist/workspace/config-file.d.ts +36 -0
- package/dist/workspace/config-file.js +237 -0
- package/dist/workspace/config.d.ts +28 -0
- package/dist/workspace/config.js +79 -0
- package/dist/workspace/discover.d.ts +34 -0
- package/dist/workspace/discover.js +195 -0
- package/dist/workspace/documents.d.ts +21 -0
- package/dist/workspace/documents.js +395 -0
- package/dist/workspace/ignore.d.ts +10 -0
- package/dist/workspace/ignore.js +48 -0
- package/dist/workspace/index.d.ts +7 -0
- package/dist/workspace/index.js +3 -0
- package/dist/workspace/repositories.d.ts +24 -0
- package/dist/workspace/repositories.js +411 -0
- package/dist/workspace/resolve.d.ts +24 -0
- package/dist/workspace/resolve.js +268 -0
- package/dist/workspace/types.d.ts +71 -0
- package/dist/workspace/types.js +1 -0
- package/dist/workspace/validation.d.ts +18 -0
- package/dist/workspace/validation.js +197 -0
- package/package.json +105 -4
- package/skills/pappra/SKILL.md +68 -0
- package/skills/pappra/references/hosts.md +30 -0
- package/skills/pappra/references/lifecycle.md +37 -0
- package/skills/pappra/references/operations.md +14 -0
|
@@ -0,0 +1,1528 @@
|
|
|
1
|
+
import { object, parseJson, errorCode } from '../../collab/validation.js';
|
|
2
|
+
import { isRecord, } from './protocol.js';
|
|
3
|
+
function errorDetail(error) {
|
|
4
|
+
if (object(error) && typeof error['detail'] === 'string')
|
|
5
|
+
return error['detail'];
|
|
6
|
+
return error instanceof Error ? error.message : null;
|
|
7
|
+
}
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { groupState, ownedChild, snapshotOwnedTree, spawnOwnedGroup, stopOwnedGroup, stopSnapshottedTree, } from '../../infra/owned-process-group.js';
|
|
10
|
+
import { randomBytes } from 'node:crypto';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { buildMessage } from '../../collab/envelope.js';
|
|
14
|
+
import { submitMessage } from '../../collab/messages.js';
|
|
15
|
+
import { readRecord } from '../../collab/store.js';
|
|
16
|
+
// Owned Codex app-server stdio transport and task-scoped query/cancel.
|
|
17
|
+
//
|
|
18
|
+
// Protocol facts come from the installed Codex CLI 0.162.1 v2 schemas and the official
|
|
19
|
+
// app-server documentation (https://learn.chatgpt.com/docs/app-server):
|
|
20
|
+
// * `thread/read` does not resume or load the thread; a fresh server reports
|
|
21
|
+
// `status.type: notLoaded`, which says nothing about the original execution server.
|
|
22
|
+
// * `turn/interrupt` only requests cancellation; terminal `turn/completed` with
|
|
23
|
+
// `turn.status: interrupted` is the lifecycle evidence, and it may arrive before the
|
|
24
|
+
// interrupt response.
|
|
25
|
+
// * `thread/shellCommand` creates a standalone native turn when the thread is idle and
|
|
26
|
+
// emits `turn/started` then `turn/completed`; its acknowledgement is an empty object.
|
|
27
|
+
// * Server-to-client requests (approvals, tool calls, token refresh) are refused by default.
|
|
28
|
+
//
|
|
29
|
+
// Boundaries stated rather than hidden:
|
|
30
|
+
// * Opening a transport is a local process start with NO model inference; the arguments are
|
|
31
|
+
// always exactly `app-server --stdio` (tests inject a fake executable, never arguments).
|
|
32
|
+
// * `startThread`/`startTurn`/`startShellTurn` are explicit low-level APIs for a coordinator
|
|
33
|
+
// to create work AFTER existing admission. Query/cancel/recover never call them.
|
|
34
|
+
// * Ownership lives in private closure state and the transport object is frozen, so caller
|
|
35
|
+
// mutation cannot forge original ownership. A thread/turn is owned only when its id came
|
|
36
|
+
// back from a create response or a correlated native notification on THIS transport.
|
|
37
|
+
// * Model/effort/reasoning overrides are refused at the raw request boundary as well as in
|
|
38
|
+
// the convenience wrappers.
|
|
39
|
+
// * Claims are retained; no CANCELLED is ever sent; every cancel returns
|
|
40
|
+
// `reconciliation_required` once an intent has been recorded.
|
|
41
|
+
const SCHEMA = 'harness-collab/codex-native/1';
|
|
42
|
+
const CLIENT_INFO = Object.freeze({
|
|
43
|
+
name: 'harness-collab-codex-native',
|
|
44
|
+
version: '1',
|
|
45
|
+
});
|
|
46
|
+
const SERVER_ARGV = Object.freeze(['app-server', '--stdio']);
|
|
47
|
+
const CONTROLLED_SCOPE = 'controlled-owned-group';
|
|
48
|
+
const DEFAULT_SCOPE = 'default-process';
|
|
49
|
+
const MAX_BYTES = 4 * 1024 * 1024;
|
|
50
|
+
const MAX_TIMEOUT_MS = 30000;
|
|
51
|
+
const DEFAULT_TIMEOUT_MS = 10000;
|
|
52
|
+
const DEFAULT_SHUTDOWN_MS = 3000;
|
|
53
|
+
const DEFAULT_SIGNAL_GRACE_MS = 1000;
|
|
54
|
+
const DEFAULT_STREAM_CLOSE_GRACE_MS = 500;
|
|
55
|
+
const OVERRIDE_KEYS = [
|
|
56
|
+
'model',
|
|
57
|
+
'effort',
|
|
58
|
+
'reasoningEffort',
|
|
59
|
+
'summary',
|
|
60
|
+
'serviceTier',
|
|
61
|
+
'personality',
|
|
62
|
+
];
|
|
63
|
+
const ACCEPTED_HARNESSES = new Set(['codex']);
|
|
64
|
+
const EXPECTED_RUNNER = 'codex-app-server';
|
|
65
|
+
export class CodexNativeRefusal extends Error {
|
|
66
|
+
status;
|
|
67
|
+
code;
|
|
68
|
+
detail;
|
|
69
|
+
constructor(status, code, detail = null) {
|
|
70
|
+
super(code);
|
|
71
|
+
this.status = status;
|
|
72
|
+
this.code = code;
|
|
73
|
+
this.detail = detail;
|
|
74
|
+
this.status = status;
|
|
75
|
+
this.code = code;
|
|
76
|
+
this.detail = detail;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function refuse(code, status = 'invalid', detail = null) {
|
|
80
|
+
throw new CodexNativeRefusal(status, code, detail);
|
|
81
|
+
}
|
|
82
|
+
const nowIso = () => new Date().toISOString();
|
|
83
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
84
|
+
const realpath = (file) => {
|
|
85
|
+
try {
|
|
86
|
+
return fs.realpathSync(file);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const resolvePath = (file) => realpath(file) ?? path.resolve(file);
|
|
93
|
+
const isObject = object;
|
|
94
|
+
function bounded(value, fallback, code, cap = MAX_TIMEOUT_MS) {
|
|
95
|
+
const chosen = value ?? fallback;
|
|
96
|
+
if (!Number.isSafeInteger(chosen) || chosen < 1 || chosen > cap)
|
|
97
|
+
refuse(code);
|
|
98
|
+
return chosen;
|
|
99
|
+
}
|
|
100
|
+
function absolute(value, code) {
|
|
101
|
+
if (typeof value !== 'string' ||
|
|
102
|
+
!path.isAbsolute(value) ||
|
|
103
|
+
value.includes('\0'))
|
|
104
|
+
refuse(code);
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
function rejectOverrides(params = {}) {
|
|
108
|
+
if (!isObject(params))
|
|
109
|
+
refuse('params_invalid');
|
|
110
|
+
for (const key of OVERRIDE_KEYS)
|
|
111
|
+
if (params[key] !== undefined && params[key] !== null)
|
|
112
|
+
refuse('override_not_supported', 'invalid', key);
|
|
113
|
+
return params;
|
|
114
|
+
}
|
|
115
|
+
async function raceDeadline(promise, timeoutMs, code) {
|
|
116
|
+
let timer;
|
|
117
|
+
try {
|
|
118
|
+
return await Promise.race([
|
|
119
|
+
promise,
|
|
120
|
+
new Promise((_, reject) => {
|
|
121
|
+
timer = setTimeout(() => reject(new CodexNativeRefusal('needs_input', code)), timeoutMs);
|
|
122
|
+
}),
|
|
123
|
+
]);
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function tryWritePrivate(file, bytes) {
|
|
130
|
+
try {
|
|
131
|
+
fs.writeFileSync(file, bytes, { flag: 'wx', mode: 0o600 });
|
|
132
|
+
return { ref: file, bytes: bytes.length };
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
return {
|
|
136
|
+
error: errorCode(error) === 'EEXIST'
|
|
137
|
+
? 'evidence_exists'
|
|
138
|
+
: 'evidence_write_failed',
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Own one `codex app-server --stdio` child and speak bounded JSONL with it. Opening is a local
|
|
144
|
+
* no-inference process start; `initialize` performs no model work.
|
|
145
|
+
*/
|
|
146
|
+
export async function openCodexTransport(options) {
|
|
147
|
+
const codexBin = typeof options.codexBin === 'string' && options.codexBin
|
|
148
|
+
? options.codexBin
|
|
149
|
+
: 'codex';
|
|
150
|
+
if (options.args !== undefined)
|
|
151
|
+
refuse('arguments_not_supported', 'invalid', 'the app-server stdio surface is fixed');
|
|
152
|
+
const cwd = absolute(options.cwd, 'cwd_must_be_absolute');
|
|
153
|
+
const deadlineMs = bounded(options.deadlineMs, DEFAULT_TIMEOUT_MS, 'deadline_out_of_range');
|
|
154
|
+
const maxBytes = bounded(options.maxBytes, MAX_BYTES, 'max_bytes_out_of_range', MAX_BYTES);
|
|
155
|
+
const nonce = randomBytes(6).toString('hex');
|
|
156
|
+
const controlled = options.controlled === true;
|
|
157
|
+
let groupHandle = null;
|
|
158
|
+
let child;
|
|
159
|
+
try {
|
|
160
|
+
if (controlled) {
|
|
161
|
+
// Opt-in controlled mode: the app-server is the leader of a genuine owned process group.
|
|
162
|
+
groupHandle = spawnOwnedGroup(codexBin, [...SERVER_ARGV], {
|
|
163
|
+
cwd,
|
|
164
|
+
env: options.env,
|
|
165
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
166
|
+
});
|
|
167
|
+
child = ownedChild(groupHandle);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
child = spawn(codexBin, [...SERVER_ARGV], {
|
|
171
|
+
cwd,
|
|
172
|
+
env: options.env,
|
|
173
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
refuse('spawn_failed', 'needs_input', errorDetail(error));
|
|
179
|
+
}
|
|
180
|
+
const state = {
|
|
181
|
+
partial: Buffer.alloc(0),
|
|
182
|
+
bytes: 0,
|
|
183
|
+
events: [],
|
|
184
|
+
serverRequests: [],
|
|
185
|
+
pending: new Map(),
|
|
186
|
+
fatal: null,
|
|
187
|
+
exit: null,
|
|
188
|
+
spawnFailed: false,
|
|
189
|
+
requestLog: [],
|
|
190
|
+
streamClosed: false,
|
|
191
|
+
};
|
|
192
|
+
const createdThreads = new Map();
|
|
193
|
+
const createdTurns = new Map();
|
|
194
|
+
const openTurns = new Set();
|
|
195
|
+
const shellReservations = new Set();
|
|
196
|
+
const waiters = new Set();
|
|
197
|
+
const identity = Object.freeze({
|
|
198
|
+
kind: 'codex-app-server-stdio',
|
|
199
|
+
argv: [codexBin, ...SERVER_ARGV],
|
|
200
|
+
cwd,
|
|
201
|
+
pid: child.pid ?? null,
|
|
202
|
+
client_info: CLIENT_INFO,
|
|
203
|
+
opened_at: nowIso(),
|
|
204
|
+
model_inference: 'none',
|
|
205
|
+
admitted_launcher: false,
|
|
206
|
+
scope: controlled ? CONTROLLED_SCOPE : DEFAULT_SCOPE,
|
|
207
|
+
});
|
|
208
|
+
let sequence = 0;
|
|
209
|
+
let controlledThreadReserved = false;
|
|
210
|
+
let streamsDetached = false;
|
|
211
|
+
let killedAfterEof = false;
|
|
212
|
+
let signaledKill = false;
|
|
213
|
+
const wake = () => {
|
|
214
|
+
for (const waiter of [...waiters])
|
|
215
|
+
waiter();
|
|
216
|
+
};
|
|
217
|
+
const fail = (code, detail = null) => {
|
|
218
|
+
if (!state.fatal) {
|
|
219
|
+
state.fatal = new CodexNativeRefusal('needs_input', code, detail);
|
|
220
|
+
for (const pending of state.pending.values())
|
|
221
|
+
pending.reject(state.fatal);
|
|
222
|
+
state.pending.clear();
|
|
223
|
+
}
|
|
224
|
+
wake();
|
|
225
|
+
};
|
|
226
|
+
const closed = new Promise((resolve) => {
|
|
227
|
+
child.once('close', (code, signal) => {
|
|
228
|
+
state.exit = { code, signal };
|
|
229
|
+
state.streamClosed = true;
|
|
230
|
+
resolve();
|
|
231
|
+
wake();
|
|
232
|
+
});
|
|
233
|
+
child.once('error', (error) => {
|
|
234
|
+
state.spawnFailed = true;
|
|
235
|
+
fail('spawn_failed', errorDetail(error));
|
|
236
|
+
state.streamClosed = true;
|
|
237
|
+
resolve();
|
|
238
|
+
wake();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
const exitRefusal = closed.then(() => {
|
|
242
|
+
throw new CodexNativeRefusal('needs_input', 'native_transport_exited');
|
|
243
|
+
});
|
|
244
|
+
exitRefusal.catch(() => {
|
|
245
|
+
/* handled by each racer below */
|
|
246
|
+
});
|
|
247
|
+
for (const stream of [child.stdin, child.stdout, child.stderr])
|
|
248
|
+
stream?.on('error', () => {
|
|
249
|
+
/* drained */
|
|
250
|
+
});
|
|
251
|
+
child.stderr.on('data', () => {
|
|
252
|
+
/* stderr is drained and discarded; never logged or returned */
|
|
253
|
+
});
|
|
254
|
+
const handleRecord = (record) => {
|
|
255
|
+
if (record.id !== undefined &&
|
|
256
|
+
(Object.hasOwn(record, 'result') || Object.hasOwn(record, 'error'))) {
|
|
257
|
+
const pending = state.pending.get(record.id);
|
|
258
|
+
if (!pending) {
|
|
259
|
+
fail('unexpected_response_id', String(record.id));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
state.pending.delete(record.id);
|
|
263
|
+
if (Object.hasOwn(record, 'error'))
|
|
264
|
+
pending.reject(new CodexNativeRefusal('needs_input', 'native_error', `${record.error?.message ?? 'error'}`));
|
|
265
|
+
else if (record.result)
|
|
266
|
+
pending.resolve(record.result);
|
|
267
|
+
else
|
|
268
|
+
pending.reject(new CodexNativeRefusal('needs_input', 'native_result_malformed'));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (record.method !== undefined && record.id !== undefined) {
|
|
272
|
+
// A server-to-client request. Refuse it explicitly; a caller may opt into a handler.
|
|
273
|
+
state.serverRequests.push({
|
|
274
|
+
id: record.id,
|
|
275
|
+
method: record.method,
|
|
276
|
+
at: nowIso(),
|
|
277
|
+
});
|
|
278
|
+
const answer = options.onServerRequest
|
|
279
|
+
? options.onServerRequest({
|
|
280
|
+
id: record.id,
|
|
281
|
+
method: record.method,
|
|
282
|
+
params: record.params,
|
|
283
|
+
})
|
|
284
|
+
: {
|
|
285
|
+
error: {
|
|
286
|
+
code: -32000,
|
|
287
|
+
message: 'harness_collab_refuses_server_request',
|
|
288
|
+
data: { method: record.method },
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
try {
|
|
292
|
+
child.stdin.write(`${JSON.stringify({ id: record.id, ...answer })}\n`);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
/* child gone */
|
|
296
|
+
}
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (record.method !== undefined) {
|
|
300
|
+
if (record.method === 'turn/completed' &&
|
|
301
|
+
typeof record.params?.turn?.id === 'string')
|
|
302
|
+
openTurns.delete(record.params.turn.id);
|
|
303
|
+
state.events.push({
|
|
304
|
+
seq: state.events.length + 1,
|
|
305
|
+
method: record.method,
|
|
306
|
+
params: record.params ?? null,
|
|
307
|
+
at: nowIso(),
|
|
308
|
+
});
|
|
309
|
+
wake();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
fail('malformed_record');
|
|
313
|
+
};
|
|
314
|
+
child.stdout.on('data', (chunk) => {
|
|
315
|
+
state.bytes += chunk.length;
|
|
316
|
+
if (state.bytes > maxBytes) {
|
|
317
|
+
fail('response_too_large');
|
|
318
|
+
try {
|
|
319
|
+
child.kill('SIGKILL');
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
/* gone */
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
state.partial = Buffer.concat([state.partial, chunk]);
|
|
327
|
+
let index = state.partial.indexOf(10);
|
|
328
|
+
while (index !== -1) {
|
|
329
|
+
const line = state.partial.subarray(0, index);
|
|
330
|
+
state.partial = state.partial.subarray(index + 1);
|
|
331
|
+
if (line.length > maxBytes) {
|
|
332
|
+
fail('record_too_large');
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
if (line.length) {
|
|
336
|
+
let record = null;
|
|
337
|
+
try {
|
|
338
|
+
record = parseJson(line.toString('utf8'));
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
fail('malformed_record');
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
if (!isRecord(record)) {
|
|
345
|
+
fail('malformed_record');
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
handleRecord(record);
|
|
349
|
+
}
|
|
350
|
+
index = state.partial.indexOf(10);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
async function request(method, params, opts = {}) {
|
|
354
|
+
if (typeof method !== 'string' || !method)
|
|
355
|
+
refuse('method_required');
|
|
356
|
+
rejectOverrides(params ?? {});
|
|
357
|
+
if (controlled &&
|
|
358
|
+
(method === 'thread/start' ||
|
|
359
|
+
method === 'thread/resume' ||
|
|
360
|
+
method === 'thread/fork')) {
|
|
361
|
+
// Controlled mode is single-thread: no sibling thread can be created, resumed or forked,
|
|
362
|
+
// so a runtime-wide group stop can never stop work this transport does not own. The
|
|
363
|
+
// reservation is taken synchronously before any await, so concurrent starts cannot both pass.
|
|
364
|
+
if (method !== 'thread/start')
|
|
365
|
+
refuse('controlled_single_thread_required', 'invalid', `${method} is not allowed in controlled mode`);
|
|
366
|
+
if (controlledThreadReserved)
|
|
367
|
+
refuse('controlled_single_thread_required', 'invalid', 'controlled mode admits one thread start');
|
|
368
|
+
controlledThreadReserved = true;
|
|
369
|
+
}
|
|
370
|
+
if (state.fatal)
|
|
371
|
+
throw state.fatal;
|
|
372
|
+
if (state.exit !== null || state.spawnFailed)
|
|
373
|
+
refuse('native_transport_exited', 'needs_input');
|
|
374
|
+
const timeoutMs = bounded(opts.timeoutMs, deadlineMs, 'request_timeout_out_of_range');
|
|
375
|
+
const id = `hcx-${nonce}-${sequence++}`;
|
|
376
|
+
const promise = new Promise((resolve, reject) => state.pending.set(id, { resolve, reject }));
|
|
377
|
+
try {
|
|
378
|
+
child.stdin.write(`${JSON.stringify({ id, method, params: params ?? {} })}\n`);
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
state.pending.delete(id);
|
|
382
|
+
refuse('native_write_failed', 'needs_input', errorDetail(error));
|
|
383
|
+
}
|
|
384
|
+
try {
|
|
385
|
+
const result = await raceDeadline(Promise.race([promise, exitRefusal]), timeoutMs, 'native_request_deadline');
|
|
386
|
+
state.requestLog.push({ id, method, at: nowIso(), ok: true });
|
|
387
|
+
return result;
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
state.pending.delete(id);
|
|
391
|
+
state.requestLog.push({
|
|
392
|
+
id,
|
|
393
|
+
method,
|
|
394
|
+
at: nowIso(),
|
|
395
|
+
ok: false,
|
|
396
|
+
code: errorCode(error) ?? null,
|
|
397
|
+
});
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
function waitForNotification(predicate, opts = {}) {
|
|
402
|
+
const timeoutMs = bounded(opts.timeoutMs, deadlineMs, 'event_timeout_out_of_range');
|
|
403
|
+
const fromIndex = opts.fromIndex ?? 0;
|
|
404
|
+
const found = state.events.slice(fromIndex).find(predicate);
|
|
405
|
+
if (found)
|
|
406
|
+
return Promise.resolve(found);
|
|
407
|
+
return raceDeadline(new Promise((resolve, reject) => {
|
|
408
|
+
const waiter = () => {
|
|
409
|
+
if (state.fatal) {
|
|
410
|
+
waiters.delete(waiter);
|
|
411
|
+
reject(state.fatal);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const event = state.events.slice(fromIndex).find(predicate);
|
|
415
|
+
if (event) {
|
|
416
|
+
waiters.delete(waiter);
|
|
417
|
+
resolve(event);
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
waiters.add(waiter);
|
|
421
|
+
waiter();
|
|
422
|
+
}), timeoutMs, 'native_event_deadline');
|
|
423
|
+
}
|
|
424
|
+
const transport = Object.freeze({
|
|
425
|
+
schema: SCHEMA,
|
|
426
|
+
identity,
|
|
427
|
+
pid: child.pid ?? null,
|
|
428
|
+
request,
|
|
429
|
+
notify: (method, params) => {
|
|
430
|
+
try {
|
|
431
|
+
child.stdin.write(`${JSON.stringify({ method, params: params ?? {} })}\n`);
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
/* gone */
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
events: () => state.events.slice(),
|
|
438
|
+
requestLog: () => state.requestLog.slice(),
|
|
439
|
+
serverRequestsRefused: () => state.serverRequests.slice(),
|
|
440
|
+
fatal: () => state.fatal,
|
|
441
|
+
requestIds: () => state.requestLog.map((entry) => entry.id),
|
|
442
|
+
ownedThread: (threadId) => createdThreads.has(threadId),
|
|
443
|
+
ownedTurn: (threadId, turnId) => createdTurns.get(turnId) === threadId,
|
|
444
|
+
owned: (threadId, turnId) => createdThreads.has(threadId) && createdTurns.get(turnId) === threadId,
|
|
445
|
+
createdThreads: () => [...createdThreads.keys()],
|
|
446
|
+
createdTurns: () => [...createdTurns.entries()].map(([turnId, threadId]) => ({
|
|
447
|
+
threadId,
|
|
448
|
+
turnId,
|
|
449
|
+
})),
|
|
450
|
+
waitForNotification,
|
|
451
|
+
initialize: async () => {
|
|
452
|
+
const result = await request('initialize', {
|
|
453
|
+
clientInfo: CLIENT_INFO,
|
|
454
|
+
capabilities: { experimentalApi: true },
|
|
455
|
+
});
|
|
456
|
+
try {
|
|
457
|
+
child.stdin.write(`${JSON.stringify({ method: 'initialized', params: {} })}\n`);
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
/* gone */
|
|
461
|
+
}
|
|
462
|
+
return result;
|
|
463
|
+
},
|
|
464
|
+
/** Explicit low-level fixture creation. Never called by query/cancel/recover. */
|
|
465
|
+
startThread: async (params) => {
|
|
466
|
+
rejectOverrides(params);
|
|
467
|
+
const cwd = absolute(params.cwd, 'thread_cwd_must_be_absolute');
|
|
468
|
+
const result = await request('thread/start', { ...params, cwd });
|
|
469
|
+
const thread = result?.thread;
|
|
470
|
+
if (!isObject(thread) ||
|
|
471
|
+
typeof thread.id !== 'string' ||
|
|
472
|
+
typeof thread.cwd !== 'string')
|
|
473
|
+
refuse('thread_response_malformed', 'needs_input');
|
|
474
|
+
createdThreads.set(thread.id, { cwd: thread.cwd, created_at: nowIso() });
|
|
475
|
+
return { threadId: thread.id, thread };
|
|
476
|
+
},
|
|
477
|
+
/** Explicit low-level fixture creation. Never called by query/cancel/recover. */
|
|
478
|
+
startTurn: async (params) => {
|
|
479
|
+
rejectOverrides(params);
|
|
480
|
+
const threadId = params.threadId;
|
|
481
|
+
if (!createdThreads.has(threadId))
|
|
482
|
+
refuse('unowned_thread', 'needs_input');
|
|
483
|
+
const result = await request('turn/start', { ...params, threadId });
|
|
484
|
+
const turn = result?.turn;
|
|
485
|
+
if (!isObject(turn) || typeof turn.id !== 'string')
|
|
486
|
+
refuse('turn_response_malformed', 'needs_input');
|
|
487
|
+
createdTurns.set(turn.id, threadId);
|
|
488
|
+
openTurns.add(turn.id);
|
|
489
|
+
return { threadId, turnId: turn.id, turn };
|
|
490
|
+
},
|
|
491
|
+
/**
|
|
492
|
+
* Explicit native shell turn on an owned, idle thread. This performs no model request: it
|
|
493
|
+
* runs a local shell command through the app-server's own user-shell surface. The event
|
|
494
|
+
* wait is installed before the request, so a `turn/started` that precedes the empty
|
|
495
|
+
* acknowledgement is still correlated by thread and turn id.
|
|
496
|
+
*/
|
|
497
|
+
startShellTurn: async (params) => {
|
|
498
|
+
rejectOverrides(params);
|
|
499
|
+
const threadId = params.threadId;
|
|
500
|
+
if (!createdThreads.has(threadId))
|
|
501
|
+
refuse('unowned_thread', 'needs_input');
|
|
502
|
+
if (typeof params.command !== 'string' || !params.command.trim())
|
|
503
|
+
refuse('command_required');
|
|
504
|
+
const waitMs = bounded(params.waitMs, deadlineMs, 'shell_timeout_out_of_range');
|
|
505
|
+
let nativeTimeout;
|
|
506
|
+
if (params.timeoutMs !== undefined && params.timeoutMs !== null) {
|
|
507
|
+
// Installed ThreadShellCommandParams exposes `timeoutMs` (non-negative int64) as the
|
|
508
|
+
// native shell execution bound; forward it, never as the client wait bound.
|
|
509
|
+
if (!Number.isSafeInteger(params.timeoutMs) || params.timeoutMs < 0)
|
|
510
|
+
refuse('shell_timeout_out_of_range');
|
|
511
|
+
nativeTimeout = params.timeoutMs;
|
|
512
|
+
}
|
|
513
|
+
// Synchronous per-thread reservation before the first await: two concurrent callers must
|
|
514
|
+
// not both observe idle and each send a shell command.
|
|
515
|
+
if (shellReservations.has(threadId))
|
|
516
|
+
refuse('thread_busy', 'needs_input', 'shell turn already starting');
|
|
517
|
+
shellReservations.add(threadId);
|
|
518
|
+
try {
|
|
519
|
+
const read = await request('thread/read', { threadId, includeTurns: false }, { timeoutMs: waitMs });
|
|
520
|
+
if (read?.thread?.status?.type !== 'idle')
|
|
521
|
+
refuse('thread_busy', 'needs_input', String(read?.thread?.status?.type ?? 'unknown'));
|
|
522
|
+
for (const turnId of openTurns)
|
|
523
|
+
if (createdTurns.get(turnId) === threadId)
|
|
524
|
+
refuse('thread_busy', 'needs_input', `active turn ${turnId}`);
|
|
525
|
+
const fromIndex = state.events.length;
|
|
526
|
+
const firstEvent = waitForNotification((event) => (event.method === 'turn/started' ||
|
|
527
|
+
event.method === 'turn/completed') &&
|
|
528
|
+
event.params?.threadId === threadId, { timeoutMs: waitMs, fromIndex });
|
|
529
|
+
// Consume the pending wait outcome immediately so a later event deadline, transport
|
|
530
|
+
// close or request rejection can never surface as an unhandled rejection.
|
|
531
|
+
firstEvent.catch(() => {
|
|
532
|
+
/* consumed below */
|
|
533
|
+
});
|
|
534
|
+
const shellParams = { threadId, command: params.command };
|
|
535
|
+
if (nativeTimeout !== undefined)
|
|
536
|
+
shellParams.timeoutMs = nativeTimeout;
|
|
537
|
+
let ack;
|
|
538
|
+
try {
|
|
539
|
+
ack = await request('thread/shellCommand', shellParams, {
|
|
540
|
+
timeoutMs: waitMs,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
catch (error) {
|
|
544
|
+
await firstEvent.catch(() => {
|
|
545
|
+
/* drain the pending wait */
|
|
546
|
+
});
|
|
547
|
+
throw error;
|
|
548
|
+
}
|
|
549
|
+
const eventsSeenAtAck = state.events.length;
|
|
550
|
+
const event = await firstEvent;
|
|
551
|
+
const turnId = event.params?.turn?.id;
|
|
552
|
+
if (typeof turnId !== 'string')
|
|
553
|
+
refuse('turn_response_malformed', 'needs_input');
|
|
554
|
+
createdTurns.set(turnId, threadId);
|
|
555
|
+
const completedAlready = state.events.some((entry) => entry.method === 'turn/completed' &&
|
|
556
|
+
entry.params?.turn?.id === turnId);
|
|
557
|
+
if (event.method === 'turn/started' && !completedAlready)
|
|
558
|
+
openTurns.add(turnId);
|
|
559
|
+
else
|
|
560
|
+
openTurns.delete(turnId);
|
|
561
|
+
return {
|
|
562
|
+
threadId,
|
|
563
|
+
turnId,
|
|
564
|
+
ack,
|
|
565
|
+
event: { method: event.method, seq: event.seq, at: event.at },
|
|
566
|
+
event_preceded_ack: event.seq <= eventsSeenAtAck,
|
|
567
|
+
native_timeout_ms: nativeTimeout ?? null,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
finally {
|
|
571
|
+
shellReservations.delete(threadId);
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
threadRead: (threadId, params = {}) => request('thread/read', {
|
|
575
|
+
threadId,
|
|
576
|
+
includeTurns: params.includeTurns === true,
|
|
577
|
+
}),
|
|
578
|
+
listTurns: (threadId, params = {}) => request('thread/turns/list', {
|
|
579
|
+
threadId,
|
|
580
|
+
itemsView: params.itemsView ?? 'full',
|
|
581
|
+
sortDirection: params.sortDirection ?? 'asc',
|
|
582
|
+
limit: params.limit ?? 50,
|
|
583
|
+
}),
|
|
584
|
+
listItems: (threadId, params = {}) => request('thread/items/list', {
|
|
585
|
+
threadId,
|
|
586
|
+
turnId: params.turnId ?? null,
|
|
587
|
+
sortDirection: params.sortDirection ?? 'asc',
|
|
588
|
+
limit: params.limit ?? 200,
|
|
589
|
+
}),
|
|
590
|
+
/**
|
|
591
|
+
* Frozen runtime capability. `stop` terminates ONLY the owned process group created for a
|
|
592
|
+
* controlled transport (bounded TERM then KILL) and positively re-observes the group. In
|
|
593
|
+
* default mode it refuses; it never releases resources and never signals a caller-supplied
|
|
594
|
+
* pid/pgid. Descendants that escaped the group via setsid are outside this guarantee.
|
|
595
|
+
*/
|
|
596
|
+
runtime: Object.freeze({
|
|
597
|
+
scope: controlled ? CONTROLLED_SCOPE : DEFAULT_SCOPE,
|
|
598
|
+
controlled,
|
|
599
|
+
stop: async (stopOptions = {}) => {
|
|
600
|
+
if (!controlled || groupHandle === null)
|
|
601
|
+
refuse('runtime_not_controlled', 'invalid', 'this transport has no owned process group');
|
|
602
|
+
const proof = await stopOwnedGroup(groupHandle, {
|
|
603
|
+
termMs: stopOptions.termMs ?? 2000,
|
|
604
|
+
killMs: stopOptions.killMs ?? 2000,
|
|
605
|
+
});
|
|
606
|
+
const observed = groupState(groupHandle);
|
|
607
|
+
return {
|
|
608
|
+
scope: CONTROLLED_SCOPE,
|
|
609
|
+
child_pid: child.pid ?? null,
|
|
610
|
+
group_observed: observed.observable,
|
|
611
|
+
group_live: observed.group_live,
|
|
612
|
+
live_members: observed.live_members,
|
|
613
|
+
parent_exited: observed.parent_exited,
|
|
614
|
+
stopped: proof.stopped && observed.group_live === false,
|
|
615
|
+
term_sent: proof.term_sent,
|
|
616
|
+
kill_sent: proof.kill_sent,
|
|
617
|
+
remaining: proof.remaining,
|
|
618
|
+
note: 'original owned group only; use snapshotTree/stopTree to include captured descendant groups',
|
|
619
|
+
};
|
|
620
|
+
},
|
|
621
|
+
/**
|
|
622
|
+
* Read-only snapshot of the owned root and its actual descendant groups (they may live in
|
|
623
|
+
* their own process groups, as a real native user-shell does). Frozen opaque token backed
|
|
624
|
+
* by private state; only `stopTree` accepts it, and the snapshot is privately bound to the
|
|
625
|
+
* exact owned group handle that captured it.
|
|
626
|
+
*/
|
|
627
|
+
snapshotTree: () => {
|
|
628
|
+
if (!controlled || groupHandle === null)
|
|
629
|
+
refuse('runtime_not_controlled', 'invalid', 'this transport has no owned process group');
|
|
630
|
+
return snapshotOwnedTree(groupHandle);
|
|
631
|
+
},
|
|
632
|
+
/**
|
|
633
|
+
* Stops only the groups captured in this exact snapshot and only when this transport's own
|
|
634
|
+
* owned group captured it. Members are re-verified against their captured PID + start
|
|
635
|
+
* identity and captured PGID before TERM, again before any KILL escalation, and once more
|
|
636
|
+
* before a positive `stopped: true` proof.
|
|
637
|
+
*/
|
|
638
|
+
stopTree: async (snapshot, stopOptions = {}) => {
|
|
639
|
+
if (!controlled || groupHandle === null)
|
|
640
|
+
refuse('runtime_not_controlled', 'invalid', 'this transport has no owned process group');
|
|
641
|
+
return stopSnapshottedTree(snapshot, {
|
|
642
|
+
termMs: stopOptions.termMs ?? 2000,
|
|
643
|
+
killMs: stopOptions.killMs ?? 2000,
|
|
644
|
+
handle: groupHandle,
|
|
645
|
+
});
|
|
646
|
+
},
|
|
647
|
+
}),
|
|
648
|
+
close: async () => {
|
|
649
|
+
if (state.exit === null && !state.spawnFailed) {
|
|
650
|
+
try {
|
|
651
|
+
child.stdin.end();
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
/* already gone */
|
|
655
|
+
}
|
|
656
|
+
let exited = await raceDeadline(closed.then(() => true), DEFAULT_SHUTDOWN_MS, 'shutdown_deadline').catch(() => false);
|
|
657
|
+
if (!exited) {
|
|
658
|
+
killedAfterEof = true;
|
|
659
|
+
try {
|
|
660
|
+
child.kill('SIGTERM');
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
/* gone */
|
|
664
|
+
}
|
|
665
|
+
exited = await raceDeadline(closed.then(() => true), DEFAULT_SIGNAL_GRACE_MS, 'signal_grace_deadline').catch(() => false);
|
|
666
|
+
if (!exited) {
|
|
667
|
+
signaledKill = true;
|
|
668
|
+
try {
|
|
669
|
+
child.kill('SIGKILL');
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
/* gone */
|
|
673
|
+
}
|
|
674
|
+
await raceDeadline(closed.then(() => true), DEFAULT_SIGNAL_GRACE_MS, 'kill_grace_deadline').catch(() => false);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (!state.streamClosed) {
|
|
679
|
+
await delay(Math.min(DEFAULT_STREAM_CLOSE_GRACE_MS, 250));
|
|
680
|
+
if (!state.streamClosed) {
|
|
681
|
+
streamsDetached = true;
|
|
682
|
+
for (const stream of [child.stdin, child.stdout, child.stderr]) {
|
|
683
|
+
try {
|
|
684
|
+
stream?.destroy();
|
|
685
|
+
}
|
|
686
|
+
catch {
|
|
687
|
+
/* gone */
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
await delay(50);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return {
|
|
694
|
+
exit_code: state.exit?.code ?? null,
|
|
695
|
+
signal: state.exit?.signal ?? null,
|
|
696
|
+
parent_exited: state.exit !== null || state.spawnFailed,
|
|
697
|
+
stream_closed: state.streamClosed,
|
|
698
|
+
streams_detached: streamsDetached,
|
|
699
|
+
killed_after_eof_timeout: killedAfterEof,
|
|
700
|
+
signaled_kill: signaledKill,
|
|
701
|
+
request_ids: state.requestLog.map((entry) => entry.id),
|
|
702
|
+
server_requests_refused: state.serverRequests.length,
|
|
703
|
+
};
|
|
704
|
+
},
|
|
705
|
+
});
|
|
706
|
+
if (options.initialize !== false) {
|
|
707
|
+
try {
|
|
708
|
+
await transport.initialize();
|
|
709
|
+
}
|
|
710
|
+
catch (error) {
|
|
711
|
+
await transport.close();
|
|
712
|
+
throw error;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return transport;
|
|
716
|
+
}
|
|
717
|
+
// ---------------------------------------------------------------------------
|
|
718
|
+
// Task-scoped query / cancel / recovery on an owned original transport
|
|
719
|
+
// ---------------------------------------------------------------------------
|
|
720
|
+
function normalizeAttemptOptions(options) {
|
|
721
|
+
if (!Number.isSafeInteger(options.attempt) || options.attempt < 1)
|
|
722
|
+
refuse('invalid_attempt');
|
|
723
|
+
if (!Number.isSafeInteger(options.ownerEpoch) || options.ownerEpoch < 1)
|
|
724
|
+
refuse('invalid_owner_epoch');
|
|
725
|
+
for (const key of [
|
|
726
|
+
'taskRoot',
|
|
727
|
+
'agentId',
|
|
728
|
+
'sessionId',
|
|
729
|
+
'threadId',
|
|
730
|
+
'turnId',
|
|
731
|
+
]) {
|
|
732
|
+
if (typeof options[key] !== 'string' || !options[key].trim())
|
|
733
|
+
refuse(`${key}_required`);
|
|
734
|
+
}
|
|
735
|
+
if (!options.transport ||
|
|
736
|
+
typeof options.transport.request !== 'function' ||
|
|
737
|
+
!options.transport.identity)
|
|
738
|
+
refuse('owned_transport_required', 'needs_input');
|
|
739
|
+
return {
|
|
740
|
+
taskRoot: absolute(options.taskRoot, 'task_root_must_be_absolute'),
|
|
741
|
+
subtaskId: typeof options.subtaskId === 'string' && options.subtaskId.trim()
|
|
742
|
+
? options.subtaskId
|
|
743
|
+
: refuse('subtask_required'),
|
|
744
|
+
attempt: options.attempt,
|
|
745
|
+
ownerEpoch: options.ownerEpoch,
|
|
746
|
+
agentId: options.agentId,
|
|
747
|
+
sessionId: options.sessionId,
|
|
748
|
+
threadId: options.threadId,
|
|
749
|
+
turnId: options.turnId,
|
|
750
|
+
cwd: absolute(options.cwd, 'cwd_must_be_absolute'),
|
|
751
|
+
transport: options.transport,
|
|
752
|
+
timeoutMs: bounded(options.timeoutMs, DEFAULT_TIMEOUT_MS, 'timeout_out_of_range'),
|
|
753
|
+
reason: options.reason ?? null,
|
|
754
|
+
evidenceDir: options.evidenceDir === undefined || options.evidenceDir === null
|
|
755
|
+
? null
|
|
756
|
+
: absolute(options.evidenceDir, 'evidence_dir_must_be_absolute'),
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
function requireAttemptRecord(opts) {
|
|
760
|
+
const record = readRecord(opts.taskRoot);
|
|
761
|
+
if (!record)
|
|
762
|
+
refuse('task_record_unavailable', 'needs_input');
|
|
763
|
+
if (record.coordinator?.owner_epoch !== opts.ownerEpoch)
|
|
764
|
+
refuse('stale_epoch', 'needs_input');
|
|
765
|
+
const target = (record.attempts ?? []).find((item) => item.subtask_id === opts.subtaskId && item.attempt === opts.attempt);
|
|
766
|
+
if (!target)
|
|
767
|
+
refuse('unknown_attempt', 'needs_input');
|
|
768
|
+
const assignee = target.assignee ?? {};
|
|
769
|
+
if (assignee.agent_id !== opts.agentId ||
|
|
770
|
+
assignee.session_id !== opts.sessionId)
|
|
771
|
+
refuse('wrong_assignee', 'needs_input');
|
|
772
|
+
if (!ACCEPTED_HARNESSES.has(assignee.harness ?? ''))
|
|
773
|
+
refuse('native_harness_mismatch', 'needs_input', `assignee harness ${assignee.harness ?? 'unknown'}`);
|
|
774
|
+
const native = target.native ?? {};
|
|
775
|
+
if (native.runner !== EXPECTED_RUNNER)
|
|
776
|
+
refuse('native_runner_mismatch', 'needs_input', `native runner ${native.runner ?? 'unknown'}`);
|
|
777
|
+
const recordedThread = native.session_id ?? null;
|
|
778
|
+
const recordedTurn = native.run_id ?? null;
|
|
779
|
+
if (recordedThread === null || recordedTurn === null)
|
|
780
|
+
refuse('native_refs_unrecorded', 'needs_input');
|
|
781
|
+
if (recordedThread !== opts.threadId)
|
|
782
|
+
refuse('native_thread_ref_mismatch', 'needs_input');
|
|
783
|
+
if (recordedTurn !== opts.turnId)
|
|
784
|
+
refuse('native_turn_ref_mismatch', 'needs_input');
|
|
785
|
+
const recordedCwd = target.cwd ?? assignee.cwd ?? null;
|
|
786
|
+
if (recordedCwd !== null &&
|
|
787
|
+
resolvePath(recordedCwd) !== resolvePath(opts.cwd))
|
|
788
|
+
refuse('native_cwd_mismatch', 'needs_input');
|
|
789
|
+
opts.record = record;
|
|
790
|
+
opts.assigneeAgent = assignee.agent_id;
|
|
791
|
+
opts.assigneeSession = assignee.session_id;
|
|
792
|
+
opts.assigneeHarness = assignee.harness;
|
|
793
|
+
return target;
|
|
794
|
+
}
|
|
795
|
+
function ids(opts) {
|
|
796
|
+
if (!opts.record?.coordinator || !opts.assigneeAgent || !opts.assigneeSession)
|
|
797
|
+
refuse('protocol_identity_unavailable');
|
|
798
|
+
const coordinator = opts.record.coordinator;
|
|
799
|
+
return {
|
|
800
|
+
taskId: opts.record.task_id,
|
|
801
|
+
from: {
|
|
802
|
+
agent_id: coordinator.agent_id,
|
|
803
|
+
session_id: coordinator.session_id,
|
|
804
|
+
harness: coordinator.harness ?? 'pi',
|
|
805
|
+
},
|
|
806
|
+
to: {
|
|
807
|
+
agent_id: opts.assigneeAgent,
|
|
808
|
+
session_id: opts.assigneeSession,
|
|
809
|
+
harness: opts.assigneeHarness ?? 'codex',
|
|
810
|
+
},
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
/** Registers the query intent before any native read; returns the message id. */
|
|
814
|
+
function registerStatusQuery(opts) {
|
|
815
|
+
const { taskId, from, to } = ids(opts);
|
|
816
|
+
const query = submitMessage(opts.taskRoot, buildMessage({
|
|
817
|
+
type: 'STATUS_QUERY',
|
|
818
|
+
task_id: taskId,
|
|
819
|
+
from,
|
|
820
|
+
to,
|
|
821
|
+
owner_epoch: opts.ownerEpoch,
|
|
822
|
+
body: { reason: 'native app-server observation' },
|
|
823
|
+
}));
|
|
824
|
+
if (query.ok !== true)
|
|
825
|
+
refuse('status_query_not_recorded', 'invalid', query.reason ?? null);
|
|
826
|
+
return query.registration.message_id;
|
|
827
|
+
}
|
|
828
|
+
/** Reports the factual native observation; terminal never becomes task completion here. */
|
|
829
|
+
function reportStatus(opts, queryId, observation, nativeStatus, nativeFacts) {
|
|
830
|
+
const { taskId, from, to } = ids(opts);
|
|
831
|
+
const bodyStatus = observation === 'active'
|
|
832
|
+
? 'running'
|
|
833
|
+
: observation === 'interrupted'
|
|
834
|
+
? 'cancelled'
|
|
835
|
+
: observation === 'failed'
|
|
836
|
+
? 'failed'
|
|
837
|
+
: 'unknown';
|
|
838
|
+
const status = submitMessage(opts.taskRoot, buildMessage({
|
|
839
|
+
type: 'STATUS',
|
|
840
|
+
task_id: taskId,
|
|
841
|
+
from: to,
|
|
842
|
+
to: from,
|
|
843
|
+
owner_epoch: opts.ownerEpoch,
|
|
844
|
+
reply_to: queryId,
|
|
845
|
+
subtask_id: opts.subtaskId,
|
|
846
|
+
attempt: opts.attempt,
|
|
847
|
+
body: {
|
|
848
|
+
status: bodyStatus,
|
|
849
|
+
native_status: nativeStatus,
|
|
850
|
+
...nativeFacts,
|
|
851
|
+
observation,
|
|
852
|
+
},
|
|
853
|
+
}));
|
|
854
|
+
if (status.ok !== true)
|
|
855
|
+
refuse('status_not_recorded', 'invalid', status.reason ?? null);
|
|
856
|
+
return status.registration.message_id;
|
|
857
|
+
}
|
|
858
|
+
/** Native read of the exact thread/turn, with no thread/resume, fork or prompt. */
|
|
859
|
+
async function readTurn(transport, threadId, turnId, timeoutMs) {
|
|
860
|
+
const read = await transport.request('thread/read', { threadId, includeTurns: false }, { timeoutMs });
|
|
861
|
+
const thread = read?.thread;
|
|
862
|
+
if (!isObject(thread) || thread.id !== threadId)
|
|
863
|
+
refuse('native_thread_identity_mismatch', 'needs_input');
|
|
864
|
+
if (typeof thread.cwd !== 'string')
|
|
865
|
+
refuse('native_thread_cwd_unsupported', 'needs_input');
|
|
866
|
+
const listed = await transport.request('thread/turns/list', { threadId, itemsView: 'full', sortDirection: 'asc', limit: 50 }, { timeoutMs });
|
|
867
|
+
const turns = Array.isArray(listed?.data)
|
|
868
|
+
? listed.data
|
|
869
|
+
: refuse('native_turns_malformed', 'needs_input');
|
|
870
|
+
const turn = turns.find((item) => item?.id === turnId) ?? null;
|
|
871
|
+
if (!turn)
|
|
872
|
+
refuse('native_turn_not_found', 'needs_input');
|
|
873
|
+
if (typeof turn.status !== 'string')
|
|
874
|
+
refuse('native_turn_status_unsupported', 'needs_input');
|
|
875
|
+
return {
|
|
876
|
+
thread: { ...thread, cwd: thread.cwd },
|
|
877
|
+
turn: { ...turn, status: turn.status },
|
|
878
|
+
queried_at: nowIso(),
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Classifies persisted turn items into a usable original native result. A model message and a
|
|
883
|
+
* successful native shell command are kept distinct; nothing is synthesized.
|
|
884
|
+
*/
|
|
885
|
+
function classifyItems(entries) {
|
|
886
|
+
const items = entries.map((entry) => entry?.item);
|
|
887
|
+
const messages = items.filter((item) => item?.type === 'agentMessage' &&
|
|
888
|
+
typeof item.text === 'string' &&
|
|
889
|
+
item.text.trim());
|
|
890
|
+
if (messages.length) {
|
|
891
|
+
return {
|
|
892
|
+
result_kind: 'agent_message',
|
|
893
|
+
agent_message: messages.at(-1).text,
|
|
894
|
+
native_command_output: null,
|
|
895
|
+
native_command_exit_code: null,
|
|
896
|
+
item_count: entries.length,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
const commands = items.filter((item) => item?.type === 'commandExecution' &&
|
|
900
|
+
item.status === 'completed' &&
|
|
901
|
+
item.exitCode === 0 &&
|
|
902
|
+
typeof item.aggregatedOutput === 'string');
|
|
903
|
+
if (commands.length) {
|
|
904
|
+
const last = commands.at(-1);
|
|
905
|
+
return {
|
|
906
|
+
result_kind: 'native_command_output',
|
|
907
|
+
agent_message: null,
|
|
908
|
+
native_command_output: last.aggregatedOutput,
|
|
909
|
+
native_command_exit_code: last.exitCode,
|
|
910
|
+
item_count: entries.length,
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
return {
|
|
914
|
+
result_kind: null,
|
|
915
|
+
agent_message: null,
|
|
916
|
+
native_command_output: null,
|
|
917
|
+
native_command_exit_code: null,
|
|
918
|
+
item_count: entries.length,
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
async function readItems(transport, threadId, turnId, timeoutMs) {
|
|
922
|
+
const listed = await transport.request('thread/items/list', { threadId, turnId, sortDirection: 'asc', limit: 200 }, { timeoutMs });
|
|
923
|
+
const entries = Array.isArray(listed?.data)
|
|
924
|
+
? listed.data
|
|
925
|
+
: refuse('native_items_malformed', 'needs_input');
|
|
926
|
+
return classifyItems(entries);
|
|
927
|
+
}
|
|
928
|
+
function observationOf(status) {
|
|
929
|
+
if (status === 'completed')
|
|
930
|
+
return 'completed';
|
|
931
|
+
if (status === 'interrupted')
|
|
932
|
+
return 'interrupted';
|
|
933
|
+
if (status === 'failed')
|
|
934
|
+
return 'failed';
|
|
935
|
+
if (status === 'inProgress')
|
|
936
|
+
return 'active';
|
|
937
|
+
refuse('native_turn_status_unsupported', 'needs_input');
|
|
938
|
+
}
|
|
939
|
+
function refusalResult(error, extra = {}) {
|
|
940
|
+
const known = error instanceof CodexNativeRefusal;
|
|
941
|
+
return {
|
|
942
|
+
schema: SCHEMA,
|
|
943
|
+
status: known ? error.status : 'invalid',
|
|
944
|
+
blockers: [known ? error.code : 'internal_error'],
|
|
945
|
+
detail: known
|
|
946
|
+
? error.detail
|
|
947
|
+
: String(error instanceof Error ? errorDetail(error) : error),
|
|
948
|
+
...extra,
|
|
949
|
+
reconciliation_required: false,
|
|
950
|
+
claims_retained: true,
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
/** In-flight status of one exact recorded attempt on its owned original transport and turn. */
|
|
954
|
+
export async function queryCodexAttempt(options) {
|
|
955
|
+
let statusQueryId = null;
|
|
956
|
+
try {
|
|
957
|
+
const opts = normalizeAttemptOptions(options);
|
|
958
|
+
requireAttemptRecord(opts);
|
|
959
|
+
if (!opts.transport.owned(opts.threadId, opts.turnId))
|
|
960
|
+
refuse('unowned_native_turn', 'needs_input');
|
|
961
|
+
statusQueryId = registerStatusQuery(opts);
|
|
962
|
+
const native = await readTurn(opts.transport, opts.threadId, opts.turnId, opts.timeoutMs);
|
|
963
|
+
if (resolvePath(native.thread.cwd) !== resolvePath(opts.cwd))
|
|
964
|
+
refuse('native_cwd_mismatch', 'needs_input');
|
|
965
|
+
const observation = observationOf(native.turn.status);
|
|
966
|
+
const result = {
|
|
967
|
+
schema: SCHEMA,
|
|
968
|
+
status: observation,
|
|
969
|
+
blockers: [],
|
|
970
|
+
observation,
|
|
971
|
+
native: {
|
|
972
|
+
thread_id: opts.threadId,
|
|
973
|
+
turn_id: opts.turnId,
|
|
974
|
+
thread_status: native.thread.status ?? null,
|
|
975
|
+
thread_cwd: native.thread.cwd,
|
|
976
|
+
turn_status: native.turn.status,
|
|
977
|
+
},
|
|
978
|
+
transport: {
|
|
979
|
+
identity: opts.transport.identity,
|
|
980
|
+
request_ids: opts.transport.requestIds(),
|
|
981
|
+
queried_at: native.queried_at,
|
|
982
|
+
},
|
|
983
|
+
reconciliation_required: false,
|
|
984
|
+
claims_retained: true,
|
|
985
|
+
evidence: {
|
|
986
|
+
observed_ids: { thread_id: opts.threadId, turn_id: opts.turnId },
|
|
987
|
+
queried_at: native.queried_at,
|
|
988
|
+
},
|
|
989
|
+
};
|
|
990
|
+
try {
|
|
991
|
+
const statusId = reportStatus(opts, statusQueryId, observation, native.turn.status, { native_thread_id: opts.threadId, native_turn_id: opts.turnId });
|
|
992
|
+
result.protocol = { status_query: statusQueryId, status: statusId };
|
|
993
|
+
}
|
|
994
|
+
catch (error) {
|
|
995
|
+
result.record_error =
|
|
996
|
+
error instanceof CodexNativeRefusal ? error.code : 'internal_error';
|
|
997
|
+
result.protocol = { status_query: statusQueryId, status: null };
|
|
998
|
+
}
|
|
999
|
+
return result;
|
|
1000
|
+
}
|
|
1001
|
+
catch (error) {
|
|
1002
|
+
return refusalResult(error, { status_query: statusQueryId });
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Requests cancellation of one exact recorded attempt. Requires a fresh active observation on
|
|
1007
|
+
* the owned original transport, records the CANCEL intent, invokes `turn/interrupt` with the
|
|
1008
|
+
* exact ids, and accepts only terminal `turn/completed` with `interrupted` as evidence - even
|
|
1009
|
+
* if it precedes the interrupt response. Claims stay held and no CANCELLED is sent; any failure
|
|
1010
|
+
* after the recorded intent still returns `reconciliation_required: true`.
|
|
1011
|
+
*/
|
|
1012
|
+
export async function cancelCodexAttempt(options) {
|
|
1013
|
+
const ctx = {
|
|
1014
|
+
cancel_message_id: null,
|
|
1015
|
+
intent_recorded: false,
|
|
1016
|
+
intent_error: null,
|
|
1017
|
+
interrupt_request_ids: [],
|
|
1018
|
+
interrupt_response: null,
|
|
1019
|
+
interrupt_error: null,
|
|
1020
|
+
terminal_event: null,
|
|
1021
|
+
evidence_error: null,
|
|
1022
|
+
};
|
|
1023
|
+
try {
|
|
1024
|
+
const opts = normalizeAttemptOptions(options);
|
|
1025
|
+
requireAttemptRecord(opts);
|
|
1026
|
+
if (!opts.transport.owned(opts.threadId, opts.turnId))
|
|
1027
|
+
refuse('unowned_native_turn', 'needs_input');
|
|
1028
|
+
const before = await readTurn(opts.transport, opts.threadId, opts.turnId, opts.timeoutMs);
|
|
1029
|
+
const beforeObservation = observationOf(before.turn.status);
|
|
1030
|
+
if (beforeObservation !== 'active') {
|
|
1031
|
+
return {
|
|
1032
|
+
schema: SCHEMA,
|
|
1033
|
+
status: 'not_cancellable',
|
|
1034
|
+
blockers: ['native_not_active'],
|
|
1035
|
+
observation: beforeObservation,
|
|
1036
|
+
native: {
|
|
1037
|
+
thread_id: opts.threadId,
|
|
1038
|
+
turn_id: opts.turnId,
|
|
1039
|
+
turn_status: before.turn.status,
|
|
1040
|
+
},
|
|
1041
|
+
reconciliation_required: false,
|
|
1042
|
+
claims_retained: true,
|
|
1043
|
+
cancelled_message_sent: false,
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
const { taskId, from, to } = ids(opts);
|
|
1047
|
+
const intent = submitMessage(opts.taskRoot, buildMessage({
|
|
1048
|
+
type: 'CANCEL',
|
|
1049
|
+
task_id: taskId,
|
|
1050
|
+
from,
|
|
1051
|
+
to,
|
|
1052
|
+
subtask_id: opts.subtaskId,
|
|
1053
|
+
attempt: opts.attempt,
|
|
1054
|
+
owner_epoch: opts.ownerEpoch,
|
|
1055
|
+
body: {
|
|
1056
|
+
reason: opts.reason ?? 'native app-server cancellation',
|
|
1057
|
+
native_thread_id: opts.threadId,
|
|
1058
|
+
native_turn_id: opts.turnId,
|
|
1059
|
+
},
|
|
1060
|
+
}));
|
|
1061
|
+
if (intent.ok !== true) {
|
|
1062
|
+
ctx.intent_error = intent.reason ?? 'cancel_intent_not_recorded';
|
|
1063
|
+
return {
|
|
1064
|
+
schema: SCHEMA,
|
|
1065
|
+
status: 'invalid',
|
|
1066
|
+
blockers: ['cancel_intent_not_recorded'],
|
|
1067
|
+
intent_error: ctx.intent_error,
|
|
1068
|
+
native: {
|
|
1069
|
+
thread_id: opts.threadId,
|
|
1070
|
+
turn_id: opts.turnId,
|
|
1071
|
+
turn_status: before.turn.status,
|
|
1072
|
+
},
|
|
1073
|
+
reconciliation_required: false,
|
|
1074
|
+
claims_retained: true,
|
|
1075
|
+
cancelled_message_sent: false,
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
ctx.cancel_message_id = intent.registration.message_id;
|
|
1079
|
+
ctx.intent_recorded = true;
|
|
1080
|
+
const fromIndex = opts.transport.events().length;
|
|
1081
|
+
const terminalPromise = opts.transport.waitForNotification((event) => event.method === 'turn/completed' &&
|
|
1082
|
+
event.params?.threadId === opts.threadId &&
|
|
1083
|
+
event.params?.turn?.id === opts.turnId, { timeoutMs: opts.timeoutMs, fromIndex });
|
|
1084
|
+
const interruptPromise = opts.transport
|
|
1085
|
+
.request('turn/interrupt', { threadId: opts.threadId, turnId: opts.turnId }, { timeoutMs: opts.timeoutMs })
|
|
1086
|
+
.then((result) => {
|
|
1087
|
+
ctx.interrupt_response = result;
|
|
1088
|
+
}, (error) => {
|
|
1089
|
+
ctx.interrupt_error = error;
|
|
1090
|
+
});
|
|
1091
|
+
const terminal = await terminalPromise.catch((error) => ({
|
|
1092
|
+
error: error instanceof CodexNativeRefusal
|
|
1093
|
+
? error
|
|
1094
|
+
: new CodexNativeRefusal('needs_input', 'native_event_deadline', errorDetail(error)),
|
|
1095
|
+
}));
|
|
1096
|
+
await interruptPromise;
|
|
1097
|
+
ctx.interrupt_request_ids = opts.transport
|
|
1098
|
+
.requestLog()
|
|
1099
|
+
.filter((entry) => entry.method === 'turn/interrupt')
|
|
1100
|
+
.map((entry) => entry.id);
|
|
1101
|
+
ctx.terminal_event = terminal.error
|
|
1102
|
+
? null
|
|
1103
|
+
: {
|
|
1104
|
+
method: terminal.method,
|
|
1105
|
+
at: terminal.at,
|
|
1106
|
+
turn_status: terminal.params?.turn?.status ?? null,
|
|
1107
|
+
};
|
|
1108
|
+
const evidence = {
|
|
1109
|
+
observed_ids: { thread_id: opts.threadId, turn_id: opts.turnId },
|
|
1110
|
+
cancel_message_id: ctx.cancel_message_id,
|
|
1111
|
+
interrupt_request_ids: ctx.interrupt_request_ids,
|
|
1112
|
+
interrupt_response: ctx.interrupt_response,
|
|
1113
|
+
interrupt_error: ctx.interrupt_error
|
|
1114
|
+
? {
|
|
1115
|
+
code: errorCode(ctx.interrupt_error) ?? null,
|
|
1116
|
+
detail: errorDetail(ctx.interrupt_error),
|
|
1117
|
+
}
|
|
1118
|
+
: null,
|
|
1119
|
+
terminal_event: ctx.terminal_event,
|
|
1120
|
+
before: {
|
|
1121
|
+
turn_status: before.turn.status,
|
|
1122
|
+
queried_at: before.queried_at,
|
|
1123
|
+
},
|
|
1124
|
+
queried_at: nowIso(),
|
|
1125
|
+
};
|
|
1126
|
+
if (opts.evidenceDir) {
|
|
1127
|
+
try {
|
|
1128
|
+
fs.mkdirSync(opts.evidenceDir, { recursive: true, mode: 0o700 });
|
|
1129
|
+
}
|
|
1130
|
+
catch {
|
|
1131
|
+
/* reported below */
|
|
1132
|
+
}
|
|
1133
|
+
const written = tryWritePrivate(path.join(opts.evidenceDir, 'codex-native-cancel.json'), Buffer.from(`${JSON.stringify(evidence, null, 2)}\n`));
|
|
1134
|
+
if (written.error)
|
|
1135
|
+
ctx.evidence_error = written.error;
|
|
1136
|
+
else
|
|
1137
|
+
evidence.evidence_ref = written;
|
|
1138
|
+
}
|
|
1139
|
+
const shared = {
|
|
1140
|
+
evidence,
|
|
1141
|
+
evidence_error: ctx.evidence_error,
|
|
1142
|
+
cancel_message_id: ctx.cancel_message_id,
|
|
1143
|
+
intent_recorded: ctx.intent_recorded,
|
|
1144
|
+
reconciliation_required: true,
|
|
1145
|
+
claims_retained: true,
|
|
1146
|
+
cancelled_message_sent: false,
|
|
1147
|
+
};
|
|
1148
|
+
if (terminal.error) {
|
|
1149
|
+
return {
|
|
1150
|
+
schema: SCHEMA,
|
|
1151
|
+
status: 'needs_input',
|
|
1152
|
+
blockers: [terminal.error.code ?? 'native_event_deadline'],
|
|
1153
|
+
native: {
|
|
1154
|
+
thread_id: opts.threadId,
|
|
1155
|
+
turn_id: opts.turnId,
|
|
1156
|
+
turn_status: before.turn.status,
|
|
1157
|
+
},
|
|
1158
|
+
interrupt_accepted: ctx.interrupt_response !== null,
|
|
1159
|
+
...shared,
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
const terminalStatus = terminal.params?.turn?.status ?? null;
|
|
1163
|
+
if (terminalStatus !== 'interrupted') {
|
|
1164
|
+
return {
|
|
1165
|
+
schema: SCHEMA,
|
|
1166
|
+
status: 'needs_input',
|
|
1167
|
+
blockers: ['terminal_state_unexpected'],
|
|
1168
|
+
native: {
|
|
1169
|
+
thread_id: opts.threadId,
|
|
1170
|
+
turn_id: opts.turnId,
|
|
1171
|
+
turn_status: terminalStatus,
|
|
1172
|
+
},
|
|
1173
|
+
...shared,
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
return {
|
|
1177
|
+
schema: SCHEMA,
|
|
1178
|
+
status: 'terminal',
|
|
1179
|
+
blockers: [],
|
|
1180
|
+
native: {
|
|
1181
|
+
thread_id: opts.threadId,
|
|
1182
|
+
turn_id: opts.turnId,
|
|
1183
|
+
turn_status: terminalStatus,
|
|
1184
|
+
},
|
|
1185
|
+
native_terminal: {
|
|
1186
|
+
turn_status: terminalStatus,
|
|
1187
|
+
observed_at: terminal.at,
|
|
1188
|
+
},
|
|
1189
|
+
...shared,
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
catch (error) {
|
|
1193
|
+
return refusalResult(error, {
|
|
1194
|
+
cancel_message_id: ctx.cancel_message_id,
|
|
1195
|
+
intent_recorded: ctx.intent_recorded,
|
|
1196
|
+
intent_error: ctx.intent_error,
|
|
1197
|
+
interrupt_request_ids: ctx.interrupt_request_ids,
|
|
1198
|
+
interrupt_response: ctx.interrupt_response,
|
|
1199
|
+
terminal_event: ctx.terminal_event,
|
|
1200
|
+
evidence_error: ctx.evidence_error,
|
|
1201
|
+
reconciliation_required: ctx.intent_recorded,
|
|
1202
|
+
cancelled_message_sent: false,
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Recovery of a completed turn whose notification was lost. Only the exact requested terminal
|
|
1208
|
+
* turn is accepted, and it must carry a usable original native result: a model agent message or
|
|
1209
|
+
* an explicit successful native shell command output. Nothing is synthesized.
|
|
1210
|
+
*/
|
|
1211
|
+
export async function recoverCodexCompleted(options) {
|
|
1212
|
+
try {
|
|
1213
|
+
const opts = normalizeAttemptOptions(options);
|
|
1214
|
+
requireAttemptRecord(opts);
|
|
1215
|
+
if (!opts.transport.owned(opts.threadId, opts.turnId))
|
|
1216
|
+
refuse('unowned_native_turn', 'needs_input');
|
|
1217
|
+
const native = await readTurn(opts.transport, opts.threadId, opts.turnId, opts.timeoutMs);
|
|
1218
|
+
if (resolvePath(native.thread.cwd) !== resolvePath(opts.cwd))
|
|
1219
|
+
refuse('native_cwd_mismatch', 'needs_input');
|
|
1220
|
+
if (native.turn.status !== 'completed') {
|
|
1221
|
+
return {
|
|
1222
|
+
schema: SCHEMA,
|
|
1223
|
+
status: 'needs_input',
|
|
1224
|
+
blockers: ['terminal_turn_not_completed'],
|
|
1225
|
+
native: {
|
|
1226
|
+
thread_id: opts.threadId,
|
|
1227
|
+
turn_id: opts.turnId,
|
|
1228
|
+
turn_status: native.turn.status,
|
|
1229
|
+
},
|
|
1230
|
+
claims_retained: true,
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
const classified = await readItems(opts.transport, opts.threadId, opts.turnId, opts.timeoutMs);
|
|
1234
|
+
if (classified.result_kind === null) {
|
|
1235
|
+
return {
|
|
1236
|
+
schema: SCHEMA,
|
|
1237
|
+
status: 'needs_input',
|
|
1238
|
+
blockers: ['native_result_unavailable'],
|
|
1239
|
+
native: {
|
|
1240
|
+
thread_id: opts.threadId,
|
|
1241
|
+
turn_id: opts.turnId,
|
|
1242
|
+
turn_status: native.turn.status,
|
|
1243
|
+
},
|
|
1244
|
+
item_count: classified.item_count,
|
|
1245
|
+
claims_retained: true,
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
return {
|
|
1249
|
+
schema: SCHEMA,
|
|
1250
|
+
status: 'recovered',
|
|
1251
|
+
blockers: [],
|
|
1252
|
+
requested: {
|
|
1253
|
+
thread_id: opts.threadId,
|
|
1254
|
+
turn_id: opts.turnId,
|
|
1255
|
+
turn_type: 'recorded',
|
|
1256
|
+
},
|
|
1257
|
+
native: {
|
|
1258
|
+
thread_id: opts.threadId,
|
|
1259
|
+
turn_id: opts.turnId,
|
|
1260
|
+
turn_status: native.turn.status,
|
|
1261
|
+
thread_cwd: native.thread.cwd,
|
|
1262
|
+
},
|
|
1263
|
+
result_kind: classified.result_kind,
|
|
1264
|
+
agent_message: classified.agent_message,
|
|
1265
|
+
native_command_output: classified.native_command_output,
|
|
1266
|
+
native_command_exit_code: classified.native_command_exit_code,
|
|
1267
|
+
item_count: classified.item_count,
|
|
1268
|
+
transport: {
|
|
1269
|
+
identity: opts.transport.identity,
|
|
1270
|
+
request_ids: opts.transport.requestIds(),
|
|
1271
|
+
queried_at: native.queried_at,
|
|
1272
|
+
},
|
|
1273
|
+
receipt: null,
|
|
1274
|
+
report_requires_validation: true,
|
|
1275
|
+
claims_retained: true,
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
catch (error) {
|
|
1279
|
+
return refusalResult(error);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Persisted-history read on a FRESH server. `notLoaded` describes only this server's load state,
|
|
1284
|
+
* never the original execution. The canonical thread cwd is always validated against the
|
|
1285
|
+
* requested cwd, and with `requireCompleted` only an exact completed turn with a usable native
|
|
1286
|
+
* result is accepted.
|
|
1287
|
+
*/
|
|
1288
|
+
export async function queryPersistedHistory(options) {
|
|
1289
|
+
const threadId = typeof options.threadId === 'string' && options.threadId.trim()
|
|
1290
|
+
? options.threadId
|
|
1291
|
+
: refuse('thread_id_required');
|
|
1292
|
+
const turnId = options.turnId ?? null;
|
|
1293
|
+
const timeoutMs = bounded(options.timeoutMs, DEFAULT_TIMEOUT_MS, 'timeout_out_of_range');
|
|
1294
|
+
const cwd = absolute(options.cwd, 'cwd_must_be_absolute');
|
|
1295
|
+
let transport = null;
|
|
1296
|
+
try {
|
|
1297
|
+
transport = await openCodexTransport({
|
|
1298
|
+
codexBin: options.codexBin,
|
|
1299
|
+
cwd,
|
|
1300
|
+
env: options.env,
|
|
1301
|
+
deadlineMs: timeoutMs,
|
|
1302
|
+
maxBytes: options.maxBytes,
|
|
1303
|
+
});
|
|
1304
|
+
const read = await transport.request('thread/read', { threadId, includeTurns: false }, { timeoutMs });
|
|
1305
|
+
const thread = read?.thread;
|
|
1306
|
+
if (!isObject(thread) || thread.id !== threadId)
|
|
1307
|
+
refuse('native_thread_identity_mismatch', 'needs_input');
|
|
1308
|
+
if (typeof thread.cwd !== 'string')
|
|
1309
|
+
refuse('native_thread_cwd_unsupported', 'needs_input');
|
|
1310
|
+
if (resolvePath(thread.cwd) !== resolvePath(cwd))
|
|
1311
|
+
refuse('native_cwd_mismatch', 'needs_input');
|
|
1312
|
+
const listed = await transport.request('thread/turns/list', { threadId, itemsView: 'full', sortDirection: 'asc', limit: 50 }, { timeoutMs });
|
|
1313
|
+
const turns = Array.isArray(listed?.data)
|
|
1314
|
+
? listed.data
|
|
1315
|
+
: refuse('native_turns_malformed', 'needs_input');
|
|
1316
|
+
const turn = turnId === null
|
|
1317
|
+
? null
|
|
1318
|
+
: (turns.find((item) => item?.id === turnId) ?? null);
|
|
1319
|
+
if (turnId !== null && !turn)
|
|
1320
|
+
refuse('native_turn_not_found', 'needs_input');
|
|
1321
|
+
const base = {
|
|
1322
|
+
schema: SCHEMA,
|
|
1323
|
+
status: 'persisted_history_only',
|
|
1324
|
+
blockers: [],
|
|
1325
|
+
note: 'thread status from a fresh server describes this server load state only; it is not a statement about the original server or process',
|
|
1326
|
+
requested: { thread_id: threadId, turn_id: turnId, cwd },
|
|
1327
|
+
native: {
|
|
1328
|
+
thread_id: threadId,
|
|
1329
|
+
turn_id: turnId,
|
|
1330
|
+
thread_status: thread.status ?? null,
|
|
1331
|
+
thread_cwd: thread.cwd,
|
|
1332
|
+
turn_status: turn?.status ?? null,
|
|
1333
|
+
},
|
|
1334
|
+
transport: {
|
|
1335
|
+
identity: transport.identity,
|
|
1336
|
+
request_ids: transport.requestIds(),
|
|
1337
|
+
queried_at: nowIso(),
|
|
1338
|
+
},
|
|
1339
|
+
in_flight_status: null,
|
|
1340
|
+
claims_retained: true,
|
|
1341
|
+
};
|
|
1342
|
+
if (options.requireCompleted === true) {
|
|
1343
|
+
if (!turn)
|
|
1344
|
+
refuse('turn_id_required', 'invalid');
|
|
1345
|
+
if (turn.status !== 'completed')
|
|
1346
|
+
return {
|
|
1347
|
+
...base,
|
|
1348
|
+
status: 'needs_input',
|
|
1349
|
+
blockers: ['terminal_turn_not_completed'],
|
|
1350
|
+
};
|
|
1351
|
+
const classified = await readItems(transport, threadId, turnId, timeoutMs);
|
|
1352
|
+
if (classified.result_kind === null)
|
|
1353
|
+
return {
|
|
1354
|
+
...base,
|
|
1355
|
+
status: 'needs_input',
|
|
1356
|
+
blockers: ['native_result_unavailable'],
|
|
1357
|
+
item_count: classified.item_count,
|
|
1358
|
+
};
|
|
1359
|
+
return {
|
|
1360
|
+
...base,
|
|
1361
|
+
status: 'recovered',
|
|
1362
|
+
result_kind: classified.result_kind,
|
|
1363
|
+
agent_message: classified.agent_message,
|
|
1364
|
+
native_command_output: classified.native_command_output,
|
|
1365
|
+
native_command_exit_code: classified.native_command_exit_code,
|
|
1366
|
+
item_count: classified.item_count,
|
|
1367
|
+
receipt: null,
|
|
1368
|
+
report_requires_validation: true,
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
return base;
|
|
1372
|
+
}
|
|
1373
|
+
catch (error) {
|
|
1374
|
+
return refusalResult(error);
|
|
1375
|
+
}
|
|
1376
|
+
finally {
|
|
1377
|
+
if (transport)
|
|
1378
|
+
await transport.close();
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Cancellation plus controlled-runtime stop for one exact recorded attempt.
|
|
1383
|
+
*
|
|
1384
|
+
* The native handshake (CANCEL intent + `turn/interrupt` + observed terminal event) is reported
|
|
1385
|
+
* separately from the group/process stop proof. The owned process group is stopped ONLY when the
|
|
1386
|
+
* cancellation intent was actually recorded, and only for a controlled transport that created
|
|
1387
|
+
* the original runtime. Claims are retained, no CANCELLED is sent, and the result always carries
|
|
1388
|
+
* `reconciliation_required: true` once the intent exists. A non-terminal native handshake may
|
|
1389
|
+
* still require the controlled shutdown; it is never reported as a native interruption.
|
|
1390
|
+
* Task-specific evidence (known child pids/heartbeats) remains the caller's responsibility:
|
|
1391
|
+
* an empty owned group is not proof that every OS side effect stopped.
|
|
1392
|
+
*/
|
|
1393
|
+
export async function cancelAndStopCodexAttempt(options) {
|
|
1394
|
+
// Exact target validation FIRST and read-only: no native request, no mutation on an invalid
|
|
1395
|
+
// or unowned target, so a snapshot is never taken for work this adapter does not own.
|
|
1396
|
+
let prepared;
|
|
1397
|
+
try {
|
|
1398
|
+
prepared = normalizeAttemptOptions(options);
|
|
1399
|
+
requireAttemptRecord(prepared);
|
|
1400
|
+
if (!prepared.transport.owned(prepared.threadId, prepared.turnId))
|
|
1401
|
+
refuse('unowned_native_turn', 'needs_input');
|
|
1402
|
+
}
|
|
1403
|
+
catch (error) {
|
|
1404
|
+
const known = error instanceof CodexNativeRefusal;
|
|
1405
|
+
return {
|
|
1406
|
+
schema: SCHEMA,
|
|
1407
|
+
status: known ? error.status : 'invalid',
|
|
1408
|
+
blockers: [known ? error.code : 'internal_error'],
|
|
1409
|
+
detail: known
|
|
1410
|
+
? error.detail
|
|
1411
|
+
: String(error instanceof Error ? errorDetail(error) : error),
|
|
1412
|
+
native_handshake: null,
|
|
1413
|
+
runtime_stop: {
|
|
1414
|
+
attempted: false,
|
|
1415
|
+
reason: known ? error.code : 'internal_error',
|
|
1416
|
+
},
|
|
1417
|
+
claims_retained: true,
|
|
1418
|
+
reconciliation_required: false,
|
|
1419
|
+
cancelled_message_sent: false,
|
|
1420
|
+
task_specific_verification_required: true,
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
const controlled = prepared.transport.runtime?.controlled === true;
|
|
1424
|
+
let snapshot = null;
|
|
1425
|
+
if (controlled) {
|
|
1426
|
+
// Read-only snapshot of the genuine owned root and its actual descendant groups, taken
|
|
1427
|
+
// before the interrupt so orphaned groups stay targetable. A failed capture refuses instead
|
|
1428
|
+
// of creating a runtime that could not be stopped.
|
|
1429
|
+
try {
|
|
1430
|
+
snapshot = prepared.transport.runtime.snapshotTree();
|
|
1431
|
+
}
|
|
1432
|
+
catch (error) {
|
|
1433
|
+
return {
|
|
1434
|
+
schema: SCHEMA,
|
|
1435
|
+
status: 'needs_input',
|
|
1436
|
+
blockers: [errorCode(error) ?? 'snapshot_failed'],
|
|
1437
|
+
detail: errorDetail(error) ?? '',
|
|
1438
|
+
native_handshake: null,
|
|
1439
|
+
runtime_stop: {
|
|
1440
|
+
attempted: false,
|
|
1441
|
+
refused: 'snapshot_unavailable',
|
|
1442
|
+
reason: errorCode(error) ?? 'snapshot_failed',
|
|
1443
|
+
},
|
|
1444
|
+
claims_retained: true,
|
|
1445
|
+
reconciliation_required: false,
|
|
1446
|
+
cancelled_message_sent: false,
|
|
1447
|
+
task_specific_verification_required: true,
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
const handshake = await cancelCodexAttempt(options);
|
|
1452
|
+
const native_handshake = {
|
|
1453
|
+
status: handshake.status,
|
|
1454
|
+
blockers: handshake.blockers ?? [],
|
|
1455
|
+
detail: handshake.detail ?? null,
|
|
1456
|
+
cancel_message_id: handshake.cancel_message_id ?? null,
|
|
1457
|
+
intent_recorded: handshake.intent_recorded === true,
|
|
1458
|
+
interrupts: handshake.evidence?.interrupt_request_ids ?? [],
|
|
1459
|
+
interrupt_response: handshake.evidence?.interrupt_response ??
|
|
1460
|
+
handshake.interrupt_response ??
|
|
1461
|
+
null,
|
|
1462
|
+
interrupt_error: handshake.evidence?.interrupt_error ?? handshake.interrupt_error ?? null,
|
|
1463
|
+
terminal_event: handshake.evidence?.terminal_event ?? handshake.terminal_event ?? null,
|
|
1464
|
+
native: handshake.native ?? null,
|
|
1465
|
+
native_terminal: handshake.native_terminal ?? null,
|
|
1466
|
+
native_interrupted_confirmed: handshake.status === 'terminal' &&
|
|
1467
|
+
handshake.native?.turn_status === 'interrupted',
|
|
1468
|
+
evidence_error: handshake.evidence_error ?? null,
|
|
1469
|
+
};
|
|
1470
|
+
const base = {
|
|
1471
|
+
schema: SCHEMA,
|
|
1472
|
+
status: handshake.status,
|
|
1473
|
+
native_handshake,
|
|
1474
|
+
snapshot: snapshot === null
|
|
1475
|
+
? null
|
|
1476
|
+
: {
|
|
1477
|
+
scope: snapshot.scope,
|
|
1478
|
+
original_pgid: snapshot.original_pgid,
|
|
1479
|
+
groups: snapshot.groups,
|
|
1480
|
+
descendants: snapshot.descendants.length,
|
|
1481
|
+
captured_at: snapshot.captured_at,
|
|
1482
|
+
},
|
|
1483
|
+
claims_retained: true,
|
|
1484
|
+
reconciliation_required: true,
|
|
1485
|
+
cancelled_message_sent: false,
|
|
1486
|
+
task_specific_verification_required: true,
|
|
1487
|
+
};
|
|
1488
|
+
if (native_handshake.intent_recorded !== true) {
|
|
1489
|
+
return {
|
|
1490
|
+
...base,
|
|
1491
|
+
runtime_stop: {
|
|
1492
|
+
attempted: false,
|
|
1493
|
+
reason: handshake.blockers?.[0] ?? 'cancel_intent_not_recorded',
|
|
1494
|
+
},
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
if (!controlled) {
|
|
1498
|
+
return {
|
|
1499
|
+
...base,
|
|
1500
|
+
runtime_stop: { attempted: false, refused: 'runtime_not_controlled' },
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1503
|
+
try {
|
|
1504
|
+
const proof = await prepared.transport.runtime.stopTree(snapshot, {
|
|
1505
|
+
termMs: options.stopTermMs,
|
|
1506
|
+
killMs: options.stopKillMs,
|
|
1507
|
+
});
|
|
1508
|
+
return {
|
|
1509
|
+
...base,
|
|
1510
|
+
runtime_stop: {
|
|
1511
|
+
attempted: true,
|
|
1512
|
+
source: 'owned-descendant-tree-snapshot',
|
|
1513
|
+
...proof,
|
|
1514
|
+
},
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
catch (error) {
|
|
1518
|
+
return {
|
|
1519
|
+
...base,
|
|
1520
|
+
runtime_stop: {
|
|
1521
|
+
attempted: true,
|
|
1522
|
+
stopped: false,
|
|
1523
|
+
error: errorCode(error) ?? 'internal_error',
|
|
1524
|
+
detail: errorDetail(error),
|
|
1525
|
+
},
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
}
|