parallel-codex-tui 0.1.0 → 0.1.4
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/.parallel-codex/config.example.toml +90 -3
- package/README.md +269 -12
- package/dist/bootstrap.js +50 -18
- package/dist/cli-args.js +96 -14
- package/dist/cli-help.js +20 -0
- package/dist/cli-startup-recovery.js +70 -0
- package/dist/cli-workspace-picker.js +330 -0
- package/dist/cli-workspace-transition.js +33 -0
- package/dist/cli-workspace.js +40 -0
- package/dist/cli.js +291 -35
- package/dist/core/app-root.js +8 -0
- package/dist/core/collaboration-timeline.js +261 -0
- package/dist/core/config-errors.js +14 -0
- package/dist/core/config.js +191 -23
- package/dist/core/file-store.js +130 -6
- package/dist/core/lease-finalization.js +22 -0
- package/dist/core/paths.js +10 -0
- package/dist/core/process-identity.js +48 -0
- package/dist/core/process-mutation-turn.js +128 -0
- package/dist/core/process-ownership.js +276 -0
- package/dist/core/process-tree.js +90 -0
- package/dist/core/router-audit.js +155 -0
- package/dist/core/router-redaction.js +31 -0
- package/dist/core/router.js +473 -42
- package/dist/core/session-index.js +225 -30
- package/dist/core/session-manager.js +1182 -44
- package/dist/core/task-state-machine.js +17 -0
- package/dist/core/workspace-commit-recovery.js +118 -0
- package/dist/core/workspace.js +126 -0
- package/dist/doctor.js +384 -30
- package/dist/domain/schemas.js +127 -6
- package/dist/orchestrator/collaboration-channel.js +255 -4
- package/dist/orchestrator/feature-plan.js +70 -0
- package/dist/orchestrator/judge-artifacts.js +236 -0
- package/dist/orchestrator/orchestrator.js +1777 -212
- package/dist/orchestrator/prompts.js +126 -2
- package/dist/orchestrator/supervisor-summary.js +56 -2
- package/dist/orchestrator/workspace-sandbox.js +911 -0
- package/dist/tui/App.js +2838 -159
- package/dist/tui/AppShell.js +188 -23
- package/dist/tui/CollaborationTimelineView.js +327 -0
- package/dist/tui/FeatureBoardView.js +227 -0
- package/dist/tui/InputBar.js +514 -57
- package/dist/tui/RouterDiagnosticsView.js +469 -0
- package/dist/tui/StatusBar.js +610 -57
- package/dist/tui/TaskSessionsView.js +207 -0
- package/dist/tui/TerminalOutput.js +53 -9
- package/dist/tui/WorkerOutputView.js +1403 -161
- package/dist/tui/WorkerOverviewView.js +250 -0
- package/dist/tui/chat-history.js +25 -0
- package/dist/tui/chat-input.js +67 -19
- package/dist/tui/chat-paste.js +76 -0
- package/dist/tui/display-width.js +41 -3
- package/dist/tui/incremental-text-file.js +101 -0
- package/dist/tui/keyboard.js +46 -0
- package/dist/tui/markdown-text.js +14 -0
- package/dist/tui/raw-input-decoder.js +3 -0
- package/dist/tui/scrolling.js +2 -1
- package/dist/tui/status-line.js +318 -11
- package/dist/tui/task-memory.js +15 -0
- package/dist/tui/task-result.js +105 -0
- package/dist/tui/terminal-screen.js +13 -1
- package/dist/tui/theme-contrast.js +144 -0
- package/dist/tui/theme-preview.js +109 -0
- package/dist/tui/theme.js +158 -0
- package/dist/version.js +1 -1
- package/dist/workers/capabilities.js +212 -0
- package/dist/workers/live-probe.js +176 -0
- package/dist/workers/mock-adapter.js +39 -6
- package/dist/workers/native-attach.js +147 -8
- package/dist/workers/native-session-detection.js +17 -0
- package/dist/workers/process-adapter.js +580 -81
- package/dist/workers/registry.js +4 -2
- package/package.json +17 -2
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { StringDecoder } from "node:string_decoder";
|
|
2
4
|
import { appendText, writeJson } from "../core/file-store.js";
|
|
5
|
+
import { clearWorkerProcessRecord, writeWorkerProcessRecord } from "../core/process-ownership.js";
|
|
6
|
+
import { terminateProcessTree } from "../core/process-tree.js";
|
|
7
|
+
import { detectNativeSessionId } from "./native-session-detection.js";
|
|
8
|
+
const WORKER_DIAGNOSTIC_TAIL_CHARS = 64 * 1024;
|
|
9
|
+
const RESUME_DETECTION_OVERLAP_CHARS = 512;
|
|
10
|
+
const RESUME_REASON_CHARS = 2048;
|
|
3
11
|
export class ProcessWorkerAdapter {
|
|
4
12
|
name;
|
|
5
13
|
command;
|
|
@@ -13,7 +21,8 @@ export class ProcessWorkerAdapter {
|
|
|
13
21
|
}
|
|
14
22
|
async run(spec) {
|
|
15
23
|
const model = spec.modelConfig ?? this.defaults.model;
|
|
16
|
-
const
|
|
24
|
+
const capabilities = resolveWorkerCapabilities(this.name, this.defaults.capabilities);
|
|
25
|
+
const launch = buildLaunch(this.args, spec.nativeSession, spec.nativeSessionConfig, model, capabilities, spec.writableDirs, spec.enforceWorkspaceIsolation);
|
|
17
26
|
const runSpec = {
|
|
18
27
|
...spec,
|
|
19
28
|
timeoutMs: spec.timeoutMs ?? this.defaults.timeoutMs,
|
|
@@ -22,7 +31,9 @@ export class ProcessWorkerAdapter {
|
|
|
22
31
|
nativeSession: launch.nativeSession,
|
|
23
32
|
modelConfig: model
|
|
24
33
|
};
|
|
25
|
-
const first = await this.runAttempt(runSpec, launch
|
|
34
|
+
const first = await this.runAttempt(runSpec, launch, {
|
|
35
|
+
initialNativeSessionId: launch.initialNativeSessionId
|
|
36
|
+
});
|
|
26
37
|
if (!shouldFallbackToNewNativeSession(first, runSpec.nativeSessionConfig)) {
|
|
27
38
|
return first.result;
|
|
28
39
|
}
|
|
@@ -31,20 +42,32 @@ export class ProcessWorkerAdapter {
|
|
|
31
42
|
await runSpec.onNativeSessionRetired?.(retiredSessionId, first.output);
|
|
32
43
|
}
|
|
33
44
|
await appendText(runSpec.outputLogPath, `\nNative resume for ${retiredSessionId ?? "unknown session"} is unrecoverable; starting a fresh native session.\n`);
|
|
34
|
-
const freshLaunch = buildFreshLaunch(this.args, model);
|
|
45
|
+
const freshLaunch = buildFreshLaunch(this.args, model, capabilities, runSpec.writableDirs, runSpec.enforceWorkspaceIsolation, runSpec.nativeSessionConfig);
|
|
35
46
|
return (await this.runAttempt({
|
|
36
47
|
...runSpec,
|
|
37
48
|
nativeSession: null
|
|
38
49
|
}, freshLaunch, {
|
|
39
|
-
initialNativeSessionId:
|
|
50
|
+
initialNativeSessionId: freshLaunch.initialNativeSessionId,
|
|
40
51
|
startPhase: "native-resume-fallback",
|
|
41
52
|
startSummary: `${this.command} starting fresh session after unrecoverable native resume`
|
|
42
53
|
})).result;
|
|
43
54
|
}
|
|
44
55
|
async runAttempt(runSpec, launch, options = {}) {
|
|
56
|
+
if (runSpec.signal?.aborted) {
|
|
57
|
+
const result = {
|
|
58
|
+
workerId: runSpec.workerId,
|
|
59
|
+
exitCode: 130,
|
|
60
|
+
signal: "SIGTERM",
|
|
61
|
+
cancelled: true
|
|
62
|
+
};
|
|
63
|
+
await setStatus(runSpec, "cancelled", "process-cancelled", `${this.command} cancelled before start`);
|
|
64
|
+
await appendText(runSpec.outputLogPath, "Process cancelled by user before start\n");
|
|
65
|
+
return { result, output: "", launch };
|
|
66
|
+
}
|
|
45
67
|
await setStatus(runSpec, "starting", options.startPhase ?? "process-starting", options.startSummary ?? `Starting ${this.command}`);
|
|
46
|
-
await appendText(runSpec.outputLogPath, `$ ${this.command
|
|
68
|
+
await appendText(runSpec.outputLogPath, `$ ${formatShellCommand(this.command, launch.args)}\n`);
|
|
47
69
|
return new Promise((resolve, reject) => {
|
|
70
|
+
const detached = process.platform !== "win32";
|
|
48
71
|
const child = spawn(this.command, launch.args, {
|
|
49
72
|
cwd: runSpec.cwd,
|
|
50
73
|
env: {
|
|
@@ -54,102 +77,354 @@ export class ProcessWorkerAdapter {
|
|
|
54
77
|
PARALLEL_CODEX_ROLE: runSpec.role,
|
|
55
78
|
PARALLEL_CODEX_FILES_DIR: runSpec.filesDir
|
|
56
79
|
},
|
|
57
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
80
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
81
|
+
detached
|
|
58
82
|
});
|
|
83
|
+
let processRecordError;
|
|
84
|
+
const processRecordReady = typeof child.pid === "number"
|
|
85
|
+
? writeWorkerProcessRecord(runSpec.filesDir, {
|
|
86
|
+
workerId: runSpec.workerId,
|
|
87
|
+
pid: child.pid,
|
|
88
|
+
command: this.command,
|
|
89
|
+
...(detached ? { processGroupId: child.pid } : {})
|
|
90
|
+
}).then(() => undefined, (error) => {
|
|
91
|
+
processRecordError = error;
|
|
92
|
+
})
|
|
93
|
+
: Promise.resolve();
|
|
59
94
|
let settled = false;
|
|
95
|
+
let finishing = false;
|
|
60
96
|
let timeout;
|
|
61
97
|
let idleTimeout;
|
|
62
98
|
let firstOutputTimeout;
|
|
99
|
+
let processTreeCleanup;
|
|
100
|
+
let abortListener;
|
|
63
101
|
let terminalPhase;
|
|
64
102
|
let terminalSummary;
|
|
103
|
+
let terminalState;
|
|
65
104
|
let outputWrites = Promise.resolve();
|
|
105
|
+
let persistenceError;
|
|
106
|
+
let hasPersistenceError = false;
|
|
66
107
|
let detectedNativeSessionId = options.initialNativeSessionId ?? runSpec.nativeSession?.session_id;
|
|
108
|
+
let initialNativeSessionPersisted = !options.initialNativeSessionId;
|
|
109
|
+
const persistedNativeSessionId = () => (initialNativeSessionPersisted ? detectedNativeSessionId : undefined);
|
|
110
|
+
let stdoutSessionDetectionTail = "";
|
|
111
|
+
let stderrSessionDetectionTail = "";
|
|
67
112
|
let sawOutput = false;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
113
|
+
let outputTail = "";
|
|
114
|
+
let stdoutResumeDetectionTail = "";
|
|
115
|
+
let stderrResumeDetectionTail = "";
|
|
116
|
+
let unrecoverableResumeReason;
|
|
117
|
+
const stdoutDecoder = new StringDecoder("utf8");
|
|
118
|
+
const stderrDecoder = new StringDecoder("utf8");
|
|
119
|
+
let outputDecodersEnded = false;
|
|
120
|
+
const clearRunTimers = () => {
|
|
74
121
|
if (timeout) {
|
|
75
122
|
clearTimeout(timeout);
|
|
123
|
+
timeout = undefined;
|
|
76
124
|
}
|
|
77
125
|
if (idleTimeout) {
|
|
78
126
|
clearTimeout(idleTimeout);
|
|
127
|
+
idleTimeout = undefined;
|
|
79
128
|
}
|
|
80
129
|
if (firstOutputTimeout) {
|
|
81
130
|
clearTimeout(firstOutputTimeout);
|
|
131
|
+
firstOutputTimeout = undefined;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
const ensureProcessTreeStopped = () => {
|
|
135
|
+
processTreeCleanup ??= terminateProcessTree(child, {
|
|
136
|
+
processGroup: detached,
|
|
137
|
+
label: `${this.command} worker process`,
|
|
138
|
+
termGraceMs: 1500,
|
|
139
|
+
killWaitMs: 500,
|
|
140
|
+
pollMs: 20
|
|
141
|
+
});
|
|
142
|
+
return processTreeCleanup;
|
|
143
|
+
};
|
|
144
|
+
const terminalFallbackResult = () => ({
|
|
145
|
+
workerId: runSpec.workerId,
|
|
146
|
+
exitCode: terminalState === "cancelled" ? 130 : 1,
|
|
147
|
+
signal: null,
|
|
148
|
+
...(terminalState === "cancelled" ? { cancelled: true } : {})
|
|
149
|
+
});
|
|
150
|
+
const finishAfterCleanupFailure = (error) => {
|
|
151
|
+
void finish(terminalFallbackResult(), error);
|
|
152
|
+
};
|
|
153
|
+
const failForPersistence = (error) => {
|
|
154
|
+
if (!hasPersistenceError) {
|
|
155
|
+
hasPersistenceError = true;
|
|
156
|
+
persistenceError = error;
|
|
157
|
+
}
|
|
158
|
+
if (settled || terminalState) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
terminalState = "failed";
|
|
162
|
+
terminalPhase = "process-finalization-error";
|
|
163
|
+
terminalSummary = `${this.command} persistence failed: ${errorMessage(error)}`;
|
|
164
|
+
clearRunTimers();
|
|
165
|
+
void ensureProcessTreeStopped().catch(finishAfterCleanupFailure);
|
|
166
|
+
};
|
|
167
|
+
const queuePersistence = (operation) => {
|
|
168
|
+
outputWrites = outputWrites.then(async () => {
|
|
169
|
+
if (hasPersistenceError) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
await operation();
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
failForPersistence(error);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
const recordDecodedOutput = (text, stream) => {
|
|
181
|
+
if (!text || settled || finishing) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
outputTail = appendBoundedTextTail(outputTail, text, WORKER_DIAGNOSTIC_TAIL_CHARS);
|
|
185
|
+
if (launch.isResume && !unrecoverableResumeReason) {
|
|
186
|
+
const detectionTail = stream === "stdout" ? stdoutResumeDetectionTail : stderrResumeDetectionTail;
|
|
187
|
+
const detectionText = `${detectionTail}${text}`;
|
|
188
|
+
unrecoverableResumeReason = findUnrecoverableNativeResumeReason(detectionText);
|
|
189
|
+
if (stream === "stdout") {
|
|
190
|
+
stdoutResumeDetectionTail = detectionText.slice(-RESUME_DETECTION_OVERLAP_CHARS);
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
stderrResumeDetectionTail = detectionText.slice(-RESUME_DETECTION_OVERLAP_CHARS);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
queuePersistence(async () => {
|
|
197
|
+
await appendText(runSpec.outputLogPath, text);
|
|
198
|
+
if (!initialNativeSessionPersisted && options.initialNativeSessionId) {
|
|
199
|
+
await runSpec.onNativeSession?.(options.initialNativeSessionId);
|
|
200
|
+
initialNativeSessionPersisted = true;
|
|
201
|
+
}
|
|
202
|
+
if (!detectedNativeSessionId && runSpec.nativeSessionConfig?.detectSessionId !== false) {
|
|
203
|
+
const detectionTail = stream === "stdout" ? stdoutSessionDetectionTail : stderrSessionDetectionTail;
|
|
204
|
+
const detectionText = `${detectionTail}${text}`;
|
|
205
|
+
const sessionId = detectNativeSessionId(detectionText);
|
|
206
|
+
if (sessionId) {
|
|
207
|
+
detectedNativeSessionId = sessionId;
|
|
208
|
+
stdoutSessionDetectionTail = "";
|
|
209
|
+
stderrSessionDetectionTail = "";
|
|
210
|
+
await runSpec.onNativeSession?.(sessionId);
|
|
211
|
+
}
|
|
212
|
+
else if (stream === "stdout") {
|
|
213
|
+
stdoutSessionDetectionTail = detectionText.slice(-512);
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
stderrSessionDetectionTail = detectionText.slice(-512);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (!settled && !terminalState) {
|
|
220
|
+
await setStatus(runSpec, "running", "process-output", summarizeOutput(text), persistedNativeSessionId());
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
};
|
|
224
|
+
const endOutputDecoders = () => {
|
|
225
|
+
if (outputDecodersEnded) {
|
|
226
|
+
return;
|
|
82
227
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
228
|
+
outputDecodersEnded = true;
|
|
229
|
+
recordDecodedOutput(stdoutDecoder.end(), "stdout");
|
|
230
|
+
recordDecodedOutput(stderrDecoder.end(), "stderr");
|
|
231
|
+
};
|
|
232
|
+
const failAndTerminate = (phase, summary, logLine) => {
|
|
233
|
+
if (settled || terminalState) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
terminalState = "failed";
|
|
237
|
+
terminalPhase = phase;
|
|
238
|
+
terminalSummary = summary;
|
|
239
|
+
clearRunTimers();
|
|
240
|
+
queuePersistence(async () => {
|
|
241
|
+
await appendText(runSpec.outputLogPath, logLine);
|
|
242
|
+
await setStatus(runSpec, "running", "process-stopping", `${summary}; stopping process tree`, persistedNativeSessionId());
|
|
94
243
|
});
|
|
244
|
+
void ensureProcessTreeStopped().catch(finishAfterCleanupFailure);
|
|
245
|
+
};
|
|
246
|
+
const failForProcessOwnership = () => {
|
|
247
|
+
if (!processRecordError || settled || terminalState) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const detail = processRecordError instanceof Error
|
|
251
|
+
? processRecordError.message
|
|
252
|
+
: String(processRecordError);
|
|
253
|
+
failAndTerminate("process-ownership-error", `${this.command} process ownership could not be recorded: ${detail}`, `\nProcess ownership record failed: ${detail}\n`);
|
|
254
|
+
};
|
|
255
|
+
const rejectForFinalization = async (error) => {
|
|
256
|
+
settled = true;
|
|
257
|
+
const detail = errorMessage(error);
|
|
258
|
+
const phase = "process-finalization-error";
|
|
259
|
+
const summary = `${this.command} worker finalization failed: ${detail}`;
|
|
260
|
+
try {
|
|
261
|
+
await appendText(runSpec.outputLogPath, `\nWorker finalization failed: ${detail}\n`);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// Preserve the original finalization error.
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
await setStatus(runSpec, "failed", phase, summary, persistedNativeSessionId());
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// The ownership record remains the recovery authority when status cannot be written.
|
|
271
|
+
}
|
|
272
|
+
reject(new Error(summary, { cause: error }));
|
|
273
|
+
};
|
|
274
|
+
const finish = async (result, knownCleanupError) => {
|
|
275
|
+
if (settled || finishing) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
endOutputDecoders();
|
|
279
|
+
finishing = true;
|
|
280
|
+
try {
|
|
281
|
+
clearRunTimers();
|
|
282
|
+
if (abortListener) {
|
|
283
|
+
runSpec.signal?.removeEventListener("abort", abortListener);
|
|
284
|
+
abortListener = undefined;
|
|
285
|
+
}
|
|
286
|
+
await processRecordReady;
|
|
287
|
+
failForProcessOwnership();
|
|
288
|
+
let cleanupError = knownCleanupError;
|
|
289
|
+
if (!cleanupError) {
|
|
290
|
+
try {
|
|
291
|
+
await ensureProcessTreeStopped();
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
cleanupError = error;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
settled = true;
|
|
298
|
+
await outputWrites;
|
|
299
|
+
if (cleanupError) {
|
|
300
|
+
const detail = errorMessage(cleanupError);
|
|
301
|
+
const phase = "process-cleanup-error";
|
|
302
|
+
const summary = `${this.command} process tree cleanup failed: ${detail}`;
|
|
303
|
+
await appendText(runSpec.outputLogPath, `\nProcess tree cleanup failed: ${detail}\n`);
|
|
304
|
+
await setStatus(runSpec, "failed", phase, summary, persistedNativeSessionId());
|
|
305
|
+
resolve({
|
|
306
|
+
result: {
|
|
307
|
+
workerId: runSpec.workerId,
|
|
308
|
+
exitCode: result.exitCode || 1,
|
|
309
|
+
signal: result.signal,
|
|
310
|
+
failure: { phase, summary }
|
|
311
|
+
},
|
|
312
|
+
output: unrecoverableResumeReason ?? outputTail,
|
|
313
|
+
launch
|
|
314
|
+
});
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (hasPersistenceError) {
|
|
318
|
+
throw persistenceError;
|
|
319
|
+
}
|
|
320
|
+
const phase = terminalPhase ?? (launch.isResume && result.exitCode !== 0 ? "native-resume-failed" : "process-exited");
|
|
321
|
+
const summary = terminalSummary ??
|
|
322
|
+
(launch.isResume && result.exitCode !== 0
|
|
323
|
+
? `${this.command} native resume exited with code ${result.exitCode}`
|
|
324
|
+
: `${this.command} exited with code ${result.exitCode}`);
|
|
325
|
+
await setStatus(runSpec, terminalState ?? (result.exitCode === 0 ? "done" : "failed"), phase, summary, persistedNativeSessionId());
|
|
326
|
+
if (!processRecordError) {
|
|
327
|
+
await clearWorkerProcessRecord(runSpec.filesDir);
|
|
328
|
+
}
|
|
329
|
+
const finalResult = {
|
|
330
|
+
...result,
|
|
331
|
+
...(terminalState === "cancelled" ? { cancelled: true } : {}),
|
|
332
|
+
...(terminalState === "failed"
|
|
333
|
+
? { failure: { phase, summary } }
|
|
334
|
+
: {})
|
|
335
|
+
};
|
|
336
|
+
resolve({
|
|
337
|
+
result: finalResult,
|
|
338
|
+
output: unrecoverableResumeReason ?? outputTail,
|
|
339
|
+
launch
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
await rejectForFinalization(error);
|
|
344
|
+
}
|
|
95
345
|
};
|
|
96
346
|
const resetIdleTimeout = () => {
|
|
97
|
-
if (!runSpec.idleTimeoutMs || runSpec.idleTimeoutMs <= 0 || settled) {
|
|
347
|
+
if (!runSpec.idleTimeoutMs || runSpec.idleTimeoutMs <= 0 || settled || terminalState) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (!sawOutput) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (runSpec.timeoutMs
|
|
354
|
+
&& runSpec.timeoutMs > 0
|
|
355
|
+
&& runSpec.idleTimeoutMs >= runSpec.timeoutMs) {
|
|
98
356
|
return;
|
|
99
357
|
}
|
|
100
358
|
if (idleTimeout) {
|
|
101
359
|
clearTimeout(idleTimeout);
|
|
102
360
|
}
|
|
103
361
|
idleTimeout = setTimeout(() => {
|
|
104
|
-
|
|
105
|
-
terminalSummary = `${this.command} produced no output for ${runSpec.idleTimeoutMs}ms`;
|
|
106
|
-
void appendText(runSpec.outputLogPath, `\nProcess idle timed out after ${runSpec.idleTimeoutMs}ms\n`);
|
|
107
|
-
void setStatus(runSpec, "failed", terminalPhase, terminalSummary);
|
|
108
|
-
child.kill("SIGTERM");
|
|
362
|
+
failAndTerminate("process-idle-timeout", `${this.command} produced no output for ${runSpec.idleTimeoutMs}ms`, `\nProcess idle timed out after ${runSpec.idleTimeoutMs}ms\n`);
|
|
109
363
|
}, runSpec.idleTimeoutMs);
|
|
110
364
|
};
|
|
111
|
-
const recordOutput = (chunk) => {
|
|
365
|
+
const recordOutput = (chunk, decoder, stream) => {
|
|
366
|
+
if (settled || finishing) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
112
369
|
sawOutput = true;
|
|
113
370
|
if (firstOutputTimeout) {
|
|
114
371
|
clearTimeout(firstOutputTimeout);
|
|
115
372
|
firstOutputTimeout = undefined;
|
|
116
373
|
}
|
|
117
|
-
|
|
118
|
-
outputChunks.push(text);
|
|
119
|
-
outputWrites = outputWrites.then(async () => {
|
|
120
|
-
await appendText(runSpec.outputLogPath, text);
|
|
121
|
-
const sessionId = detectNativeSessionId(text);
|
|
122
|
-
if (sessionId && sessionId !== detectedNativeSessionId && runSpec.nativeSessionConfig?.detectSessionId !== false) {
|
|
123
|
-
detectedNativeSessionId = sessionId;
|
|
124
|
-
await runSpec.onNativeSession?.(sessionId);
|
|
125
|
-
}
|
|
126
|
-
if (!settled) {
|
|
127
|
-
await setStatus(runSpec, "running", "process-output", summarizeOutput(text), detectedNativeSessionId);
|
|
128
|
-
}
|
|
129
|
-
});
|
|
374
|
+
recordDecodedOutput(decoder.write(chunk), stream);
|
|
130
375
|
resetIdleTimeout();
|
|
131
376
|
};
|
|
132
377
|
child.stdout.on("data", (chunk) => {
|
|
133
|
-
recordOutput(chunk);
|
|
378
|
+
recordOutput(chunk, stdoutDecoder, "stdout");
|
|
134
379
|
});
|
|
135
380
|
child.stderr.on("data", (chunk) => {
|
|
136
|
-
recordOutput(chunk);
|
|
381
|
+
recordOutput(chunk, stderrDecoder, "stderr");
|
|
137
382
|
});
|
|
138
383
|
child.on("error", (error) => {
|
|
139
384
|
if (settled) {
|
|
140
385
|
return;
|
|
141
386
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
387
|
+
if (terminalState) {
|
|
388
|
+
void finish({
|
|
389
|
+
workerId: runSpec.workerId,
|
|
390
|
+
exitCode: terminalState === "cancelled" ? 130 : 1,
|
|
391
|
+
signal: null,
|
|
392
|
+
...(terminalState === "cancelled" ? { cancelled: true } : {})
|
|
393
|
+
});
|
|
394
|
+
return;
|
|
145
395
|
}
|
|
146
|
-
if (
|
|
147
|
-
|
|
396
|
+
if (runSpec.signal?.aborted) {
|
|
397
|
+
terminalState = "cancelled";
|
|
398
|
+
terminalPhase = "process-cancelled";
|
|
399
|
+
terminalSummary = `${this.command} cancelled by user`;
|
|
400
|
+
void finish({
|
|
401
|
+
workerId: runSpec.workerId,
|
|
402
|
+
exitCode: 130,
|
|
403
|
+
signal: "SIGTERM",
|
|
404
|
+
cancelled: true
|
|
405
|
+
});
|
|
406
|
+
return;
|
|
148
407
|
}
|
|
149
|
-
|
|
150
|
-
|
|
408
|
+
endOutputDecoders();
|
|
409
|
+
settled = true;
|
|
410
|
+
clearRunTimers();
|
|
411
|
+
if (abortListener) {
|
|
412
|
+
runSpec.signal?.removeEventListener("abort", abortListener);
|
|
151
413
|
}
|
|
152
|
-
void
|
|
414
|
+
void (async () => {
|
|
415
|
+
try {
|
|
416
|
+
await processRecordReady;
|
|
417
|
+
await ensureProcessTreeStopped();
|
|
418
|
+
await setStatus(runSpec, "failed", "process-error", error.message, persistedNativeSessionId());
|
|
419
|
+
if (!processRecordError) {
|
|
420
|
+
await clearWorkerProcessRecord(runSpec.filesDir);
|
|
421
|
+
}
|
|
422
|
+
reject(error);
|
|
423
|
+
}
|
|
424
|
+
catch (finalizationError) {
|
|
425
|
+
await rejectForFinalization(finalizationError);
|
|
426
|
+
}
|
|
427
|
+
})();
|
|
153
428
|
});
|
|
154
429
|
child.on("close", (code, signal) => {
|
|
155
430
|
void finish({
|
|
@@ -158,53 +433,225 @@ export class ProcessWorkerAdapter {
|
|
|
158
433
|
signal
|
|
159
434
|
});
|
|
160
435
|
});
|
|
436
|
+
child.stdin.once("error", (error) => {
|
|
437
|
+
failAndTerminate("process-input-error", `${this.command} input failed: ${error.message}`, `\nProcess input failed: ${error.message}\n`);
|
|
438
|
+
});
|
|
439
|
+
abortListener = () => {
|
|
440
|
+
if (settled || terminalState) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
terminalState = "cancelled";
|
|
444
|
+
terminalPhase = "process-cancelled";
|
|
445
|
+
terminalSummary = `${this.command} cancelled by user`;
|
|
446
|
+
clearRunTimers();
|
|
447
|
+
queuePersistence(async () => {
|
|
448
|
+
await appendText(runSpec.outputLogPath, "\nProcess cancelled by user\n");
|
|
449
|
+
await setStatus(runSpec, "running", "process-stopping", `${terminalSummary}; stopping process tree`, persistedNativeSessionId());
|
|
450
|
+
});
|
|
451
|
+
void ensureProcessTreeStopped().catch(finishAfterCleanupFailure);
|
|
452
|
+
};
|
|
453
|
+
runSpec.signal?.addEventListener("abort", abortListener, { once: true });
|
|
161
454
|
if (runSpec.timeoutMs && runSpec.timeoutMs > 0) {
|
|
162
455
|
timeout = setTimeout(() => {
|
|
163
|
-
|
|
164
|
-
terminalSummary = `${this.command} exceeded ${runSpec.timeoutMs}ms`;
|
|
165
|
-
void setStatus(runSpec, "failed", terminalPhase, terminalSummary, detectedNativeSessionId);
|
|
166
|
-
child.kill("SIGTERM");
|
|
167
|
-
void appendText(runSpec.outputLogPath, `\nProcess timed out after ${runSpec.timeoutMs}ms\n`);
|
|
456
|
+
failAndTerminate("process-timeout", `${this.command} exceeded ${runSpec.timeoutMs}ms`, `\nProcess timed out after ${runSpec.timeoutMs}ms\n`);
|
|
168
457
|
}, runSpec.timeoutMs);
|
|
169
458
|
}
|
|
170
|
-
if (runSpec.firstOutputTimeoutMs
|
|
459
|
+
if (runSpec.firstOutputTimeoutMs
|
|
460
|
+
&& runSpec.firstOutputTimeoutMs > 0
|
|
461
|
+
&& (!runSpec.timeoutMs || runSpec.timeoutMs <= 0 || runSpec.firstOutputTimeoutMs < runSpec.timeoutMs)) {
|
|
171
462
|
firstOutputTimeout = setTimeout(() => {
|
|
172
463
|
if (sawOutput || settled) {
|
|
173
464
|
return;
|
|
174
465
|
}
|
|
175
|
-
|
|
176
|
-
terminalSummary = `${this.command} produced no first output for ${runSpec.firstOutputTimeoutMs}ms`;
|
|
177
|
-
void setStatus(runSpec, "failed", terminalPhase, terminalSummary, detectedNativeSessionId);
|
|
178
|
-
child.kill("SIGTERM");
|
|
179
|
-
void appendText(runSpec.outputLogPath, `\nProcess produced no first output after ${runSpec.firstOutputTimeoutMs}ms\n`);
|
|
466
|
+
failAndTerminate("process-first-output-timeout", `${this.command} produced no first output for ${runSpec.firstOutputTimeoutMs}ms`, `\nProcess produced no first output after ${runSpec.firstOutputTimeoutMs}ms\n`);
|
|
180
467
|
}, runSpec.firstOutputTimeoutMs);
|
|
181
468
|
}
|
|
182
|
-
void
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
469
|
+
void processRecordReady.then(() => {
|
|
470
|
+
failForProcessOwnership();
|
|
471
|
+
if (processRecordError || settled || finishing || terminalState) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (runSpec.signal?.aborted) {
|
|
475
|
+
abortListener?.();
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
child.stdin.end(runSpec.prompt);
|
|
479
|
+
}
|
|
480
|
+
});
|
|
186
481
|
});
|
|
187
482
|
}
|
|
188
483
|
}
|
|
189
|
-
function buildLaunch(defaultArgs, nativeSession, nativeSessionConfig, modelConfig) {
|
|
484
|
+
function buildLaunch(defaultArgs, nativeSession, nativeSessionConfig, modelConfig, capabilities, writableDirs, enforceWorkspaceIsolation = false) {
|
|
190
485
|
const modelArgs = buildModelArgs(modelConfig);
|
|
191
486
|
if (!nativeSession || !nativeSessionConfig?.enabled || nativeSessionConfig.resumeArgs.length === 0) {
|
|
192
|
-
return buildFreshLaunch(defaultArgs, modelConfig);
|
|
487
|
+
return buildFreshLaunch(defaultArgs, modelConfig, capabilities, writableDirs, enforceWorkspaceIsolation, nativeSessionConfig);
|
|
193
488
|
}
|
|
194
489
|
return {
|
|
195
|
-
args: [
|
|
490
|
+
args: withWritableDirectoryArgs(enforceWorkerIsolationArgs([
|
|
196
491
|
...nativeSessionConfig.resumeArgs.map((arg) => renderTemplate(arg, nativeSession.session_id, modelConfig)),
|
|
197
492
|
...modelArgs
|
|
198
|
-
],
|
|
493
|
+
], capabilities.profile, enforceWorkspaceIsolation), capabilities, true, writableDirs),
|
|
199
494
|
isResume: true,
|
|
200
495
|
nativeSession
|
|
201
496
|
};
|
|
202
497
|
}
|
|
203
|
-
function buildFreshLaunch(defaultArgs, modelConfig) {
|
|
498
|
+
function buildFreshLaunch(defaultArgs, modelConfig, capabilities, writableDirs, enforceWorkspaceIsolation = false, nativeSessionConfig) {
|
|
499
|
+
const isolatedArgs = enforceWorkerIsolationArgs([...defaultArgs, ...buildModelArgs(modelConfig)], capabilities.profile, enforceWorkspaceIsolation);
|
|
500
|
+
const freshSession = withFreshSessionId(isolatedArgs, capabilities, nativeSessionConfig, modelConfig);
|
|
204
501
|
return {
|
|
205
|
-
args:
|
|
502
|
+
args: withWritableDirectoryArgs(freshSession.args, capabilities, false, writableDirs),
|
|
206
503
|
isResume: false,
|
|
207
|
-
nativeSession: null
|
|
504
|
+
nativeSession: null,
|
|
505
|
+
...(freshSession.sessionId ? { initialNativeSessionId: freshSession.sessionId } : {})
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
function withFreshSessionId(args, capabilities, nativeSessionConfig, modelConfig) {
|
|
509
|
+
if (!nativeSessionConfig?.enabled
|
|
510
|
+
|| nativeSessionConfig.detectSessionId === false
|
|
511
|
+
|| nativeSessionConfig.resumeArgs.length === 0
|
|
512
|
+
|| capabilities.freshSessionArgs.length === 0) {
|
|
513
|
+
return { args };
|
|
514
|
+
}
|
|
515
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
516
|
+
const arg = args[index] ?? "";
|
|
517
|
+
if (arg === "--session-id") {
|
|
518
|
+
const sessionId = args[index + 1]?.trim();
|
|
519
|
+
return sessionId ? { args, sessionId } : { args };
|
|
520
|
+
}
|
|
521
|
+
const match = arg.match(/^--session-id=(.+)$/);
|
|
522
|
+
if (match?.[1]?.trim()) {
|
|
523
|
+
return { args, sessionId: match[1].trim() };
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const sessionId = randomUUID();
|
|
527
|
+
return {
|
|
528
|
+
args: [
|
|
529
|
+
...args,
|
|
530
|
+
...capabilities.freshSessionArgs.map((arg) => renderTemplate(arg, sessionId, modelConfig))
|
|
531
|
+
],
|
|
532
|
+
sessionId
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
function enforceWorkerIsolationArgs(args, profile, enforce) {
|
|
536
|
+
if (!enforce) {
|
|
537
|
+
return args;
|
|
538
|
+
}
|
|
539
|
+
if (profile === "codex") {
|
|
540
|
+
return enforceCodexWorkspaceSandbox(args);
|
|
541
|
+
}
|
|
542
|
+
if (profile === "claude") {
|
|
543
|
+
return enforceClaudeEditPermissions(args);
|
|
544
|
+
}
|
|
545
|
+
return args;
|
|
546
|
+
}
|
|
547
|
+
function enforceCodexWorkspaceSandbox(args) {
|
|
548
|
+
const result = [];
|
|
549
|
+
let hasSandbox = false;
|
|
550
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
551
|
+
const arg = args[index] ?? "";
|
|
552
|
+
if (arg === "--dangerously-bypass-approvals-and-sandbox") {
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (arg === "--sandbox" || arg === "-s") {
|
|
556
|
+
result.push(arg, "workspace-write");
|
|
557
|
+
hasSandbox = true;
|
|
558
|
+
index += 1;
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (arg.startsWith("--sandbox=") || arg.startsWith("-s=")) {
|
|
562
|
+
result.push(arg.startsWith("-s=") ? "-s=workspace-write" : "--sandbox=workspace-write");
|
|
563
|
+
hasSandbox = true;
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
result.push(arg);
|
|
567
|
+
}
|
|
568
|
+
if (!hasSandbox) {
|
|
569
|
+
const execIndex = result.indexOf("exec");
|
|
570
|
+
const promptIndex = result.lastIndexOf("-");
|
|
571
|
+
const insertAt = execIndex >= 0 ? execIndex + 1 : promptIndex >= 0 ? promptIndex : result.length;
|
|
572
|
+
result.splice(insertAt, 0, "--sandbox", "workspace-write");
|
|
573
|
+
}
|
|
574
|
+
return result;
|
|
575
|
+
}
|
|
576
|
+
function enforceClaudeEditPermissions(args) {
|
|
577
|
+
const result = [];
|
|
578
|
+
let hasPermissionMode = false;
|
|
579
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
580
|
+
const arg = args[index] ?? "";
|
|
581
|
+
if (arg === "--dangerously-skip-permissions") {
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (arg === "--permission-mode") {
|
|
585
|
+
result.push(arg, "acceptEdits");
|
|
586
|
+
hasPermissionMode = true;
|
|
587
|
+
index += 1;
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
if (arg.startsWith("--permission-mode=")) {
|
|
591
|
+
result.push("--permission-mode=acceptEdits");
|
|
592
|
+
hasPermissionMode = true;
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
result.push(arg);
|
|
596
|
+
}
|
|
597
|
+
if (!hasPermissionMode) {
|
|
598
|
+
result.push("--permission-mode", "acceptEdits");
|
|
599
|
+
}
|
|
600
|
+
return result;
|
|
601
|
+
}
|
|
602
|
+
function withWritableDirectoryArgs(args, capabilities, isResume, writableDirs) {
|
|
603
|
+
const directories = [...new Set((writableDirs ?? []).filter(Boolean))];
|
|
604
|
+
if (directories.length === 0 || capabilities.writableDirArgs.length === 0) {
|
|
605
|
+
return args;
|
|
606
|
+
}
|
|
607
|
+
const directoryArgs = directories.flatMap((directory) => (capabilities.writableDirArgs.map((arg) => arg.replaceAll("{dir}", directory))));
|
|
608
|
+
if (capabilities.profile !== "codex") {
|
|
609
|
+
return [...args, ...directoryArgs];
|
|
610
|
+
}
|
|
611
|
+
if (isResume) {
|
|
612
|
+
const resumeIndex = args.indexOf("resume");
|
|
613
|
+
const execIndex = args.lastIndexOf("exec", resumeIndex);
|
|
614
|
+
if (resumeIndex < 0 || execIndex < 0) {
|
|
615
|
+
return args;
|
|
616
|
+
}
|
|
617
|
+
return [
|
|
618
|
+
...args.slice(0, resumeIndex),
|
|
619
|
+
...directoryArgs,
|
|
620
|
+
...args.slice(resumeIndex)
|
|
621
|
+
];
|
|
622
|
+
}
|
|
623
|
+
const promptIndex = args.lastIndexOf("-");
|
|
624
|
+
if (promptIndex < 0) {
|
|
625
|
+
return [...args, ...directoryArgs];
|
|
626
|
+
}
|
|
627
|
+
return [
|
|
628
|
+
...args.slice(0, promptIndex),
|
|
629
|
+
...directoryArgs,
|
|
630
|
+
...args.slice(promptIndex)
|
|
631
|
+
];
|
|
632
|
+
}
|
|
633
|
+
function resolveWorkerCapabilities(engine, configured) {
|
|
634
|
+
if (configured) {
|
|
635
|
+
return configured;
|
|
636
|
+
}
|
|
637
|
+
if (engine === "codex") {
|
|
638
|
+
return {
|
|
639
|
+
profile: "codex",
|
|
640
|
+
writableDirArgs: ["--add-dir", "{dir}"],
|
|
641
|
+
freshSessionArgs: []
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
if (engine === "claude") {
|
|
645
|
+
return {
|
|
646
|
+
profile: "claude",
|
|
647
|
+
writableDirArgs: ["--add-dir", "{dir}"],
|
|
648
|
+
freshSessionArgs: ["--session-id", "{sessionId}"]
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
return {
|
|
652
|
+
profile: "generic",
|
|
653
|
+
writableDirArgs: [],
|
|
654
|
+
freshSessionArgs: []
|
|
208
655
|
};
|
|
209
656
|
}
|
|
210
657
|
function buildModelArgs(modelConfig) {
|
|
@@ -220,8 +667,13 @@ function buildModelEnv(modelConfig) {
|
|
|
220
667
|
return Object.fromEntries(Object.entries(modelConfig.env).map(([key, value]) => [key, renderTemplate(value, undefined, modelConfig)]));
|
|
221
668
|
}
|
|
222
669
|
function shouldFallbackToNewNativeSession(attempt, nativeSessionConfig) {
|
|
670
|
+
if (attempt.result.failure?.phase === "process-cleanup-error"
|
|
671
|
+
|| attempt.result.failure?.phase === "process-ownership-error") {
|
|
672
|
+
return false;
|
|
673
|
+
}
|
|
223
674
|
return (attempt.launch.isResume &&
|
|
224
|
-
attempt.result.
|
|
675
|
+
!attempt.result.cancelled &&
|
|
676
|
+
(Boolean(attempt.result.failure) || attempt.result.exitCode !== 0) &&
|
|
225
677
|
nativeSessionConfig?.fallback === "new" &&
|
|
226
678
|
isUnrecoverableNativeResumeOutput(attempt.output));
|
|
227
679
|
}
|
|
@@ -232,6 +684,33 @@ function isUnrecoverableNativeResumeOutput(output) {
|
|
|
232
684
|
normalized.includes("clear earlier history") ||
|
|
233
685
|
normalized.includes("start a new thread"));
|
|
234
686
|
}
|
|
687
|
+
function findUnrecoverableNativeResumeReason(output) {
|
|
688
|
+
const normalized = output.toLowerCase();
|
|
689
|
+
const matchIndex = [
|
|
690
|
+
"context window",
|
|
691
|
+
"ran out of room",
|
|
692
|
+
"clear earlier history",
|
|
693
|
+
"start a new thread"
|
|
694
|
+
].reduce((earliest, phrase) => {
|
|
695
|
+
const index = normalized.indexOf(phrase);
|
|
696
|
+
return index < 0 || (earliest >= 0 && earliest <= index) ? earliest : index;
|
|
697
|
+
}, -1);
|
|
698
|
+
if (matchIndex < 0) {
|
|
699
|
+
return undefined;
|
|
700
|
+
}
|
|
701
|
+
const lineStart = output.lastIndexOf("\n", matchIndex - 1) + 1;
|
|
702
|
+
const nextLine = output.indexOf("\n", matchIndex);
|
|
703
|
+
const lineEnd = nextLine < 0 ? output.length : nextLine;
|
|
704
|
+
const contextStart = Math.max(lineStart, matchIndex - 256);
|
|
705
|
+
const contextEnd = Math.min(lineEnd, contextStart + RESUME_REASON_CHARS);
|
|
706
|
+
return output.slice(contextStart, contextEnd).trim() || "Native session context is exhausted";
|
|
707
|
+
}
|
|
708
|
+
function appendBoundedTextTail(current, chunk, limit) {
|
|
709
|
+
if (chunk.length >= limit) {
|
|
710
|
+
return chunk.slice(-limit);
|
|
711
|
+
}
|
|
712
|
+
return `${current.slice(-(limit - chunk.length))}${chunk}`;
|
|
713
|
+
}
|
|
235
714
|
function renderTemplate(value, sessionId, modelConfig) {
|
|
236
715
|
return value
|
|
237
716
|
.replaceAll("{sessionId}", sessionId ?? "")
|
|
@@ -239,6 +718,15 @@ function renderTemplate(value, sessionId, modelConfig) {
|
|
|
239
718
|
.replaceAll("{provider}", modelConfig?.provider ?? "")
|
|
240
719
|
.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => process.env[name] ?? "");
|
|
241
720
|
}
|
|
721
|
+
function formatShellCommand(command, args) {
|
|
722
|
+
return [command, ...args].map(shellQuote).join(" ");
|
|
723
|
+
}
|
|
724
|
+
function shellQuote(value) {
|
|
725
|
+
if (value.length > 0 && /^[A-Za-z0-9_./:=@%+,-]+$/.test(value)) {
|
|
726
|
+
return value;
|
|
727
|
+
}
|
|
728
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
729
|
+
}
|
|
242
730
|
function summarizeOutput(text) {
|
|
243
731
|
const lines = text
|
|
244
732
|
.split(/\r?\n/)
|
|
@@ -247,9 +735,14 @@ function summarizeOutput(text) {
|
|
|
247
735
|
const summary = lines.at(-1) ?? "Worker produced output";
|
|
248
736
|
return summary.length > 160 ? `${summary.slice(0, 157)}...` : summary;
|
|
249
737
|
}
|
|
738
|
+
function errorMessage(error) {
|
|
739
|
+
return error instanceof Error ? error.message : String(error);
|
|
740
|
+
}
|
|
250
741
|
async function setStatus(spec, state, phase, summary, nativeSessionId) {
|
|
251
|
-
|
|
742
|
+
const status = {
|
|
252
743
|
worker_id: spec.workerId,
|
|
744
|
+
...(spec.featureId ? { feature_id: spec.featureId } : {}),
|
|
745
|
+
...(spec.featureTitle ? { feature_title: spec.featureTitle } : {}),
|
|
253
746
|
role: spec.role,
|
|
254
747
|
engine: spec.engine,
|
|
255
748
|
state,
|
|
@@ -257,9 +750,15 @@ async function setStatus(spec, state, phase, summary, nativeSessionId) {
|
|
|
257
750
|
last_event_at: new Date().toISOString(),
|
|
258
751
|
summary,
|
|
259
752
|
...(nativeSessionId ? { native_session_id: nativeSessionId } : {})
|
|
260
|
-
}
|
|
753
|
+
};
|
|
754
|
+
await writeJson(spec.statusPath, status);
|
|
755
|
+
notifyStatus(spec, status);
|
|
261
756
|
}
|
|
262
|
-
function
|
|
263
|
-
|
|
264
|
-
|
|
757
|
+
function notifyStatus(spec, status) {
|
|
758
|
+
try {
|
|
759
|
+
void Promise.resolve(spec.onStatus?.(status)).catch(() => { });
|
|
760
|
+
}
|
|
761
|
+
catch {
|
|
762
|
+
// Status observers cannot change the worker outcome.
|
|
763
|
+
}
|
|
265
764
|
}
|