parallel-codex-tui 0.1.3 → 0.1.5
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 +136 -3
- package/README.md +299 -21
- package/dist/bootstrap.js +37 -17
- package/dist/cli-args.js +34 -3
- package/dist/cli-help.js +20 -0
- package/dist/cli-startup-preflight.js +18 -0
- package/dist/cli-startup-recovery.js +82 -0
- package/dist/cli-workspace-picker.js +330 -0
- package/dist/cli-workspace-transition.js +33 -0
- package/dist/cli-workspace.js +7 -71
- package/dist/cli.js +234 -24
- package/dist/core/collaboration-timeline.js +268 -0
- package/dist/core/config-errors.js +14 -0
- package/dist/core/config.js +297 -109
- package/dist/core/file-store.js +119 -6
- package/dist/core/lease-finalization.js +22 -0
- package/dist/core/paths.js +7 -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 +462 -35
- package/dist/core/session-index.js +412 -88
- package/dist/core/session-manager.js +1110 -40
- package/dist/core/task-session-details.js +175 -0
- package/dist/core/task-state-machine.js +18 -0
- package/dist/core/workspace-commit-recovery.js +118 -0
- package/dist/core/workspace.js +19 -11
- package/dist/doctor.js +373 -34
- package/dist/domain/schemas.js +142 -7
- package/dist/orchestrator/collaboration-channel.js +289 -6
- package/dist/orchestrator/feature-plan.js +70 -0
- package/dist/orchestrator/final-acceptance.js +86 -0
- package/dist/orchestrator/judge-artifacts.js +236 -0
- package/dist/orchestrator/orchestrator.js +2086 -203
- package/dist/orchestrator/prompts.js +168 -5
- package/dist/orchestrator/supervisor-summary.js +56 -2
- package/dist/orchestrator/workspace-sandbox.js +927 -0
- package/dist/tui/App.js +3187 -161
- package/dist/tui/AppShell.js +196 -25
- package/dist/tui/CollaborationTimelineView.js +327 -0
- package/dist/tui/FeatureBoardView.js +232 -0
- package/dist/tui/InputBar.js +581 -57
- package/dist/tui/RouterDiagnosticsView.js +469 -0
- package/dist/tui/StatusBar.js +610 -57
- package/dist/tui/StatusDetailView.js +164 -0
- package/dist/tui/TaskSessionDetailView.js +222 -0
- package/dist/tui/TaskSessionsView.js +210 -0
- package/dist/tui/TerminalOutput.js +53 -9
- package/dist/tui/WorkerOutputView.js +1404 -161
- package/dist/tui/WorkerOverviewView.js +269 -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 +49 -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 +360 -11
- package/dist/tui/task-memory.js +13 -1
- 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 +213 -0
- package/dist/workers/live-probe.js +177 -0
- package/dist/workers/mock-adapter.js +73 -8
- package/dist/workers/native-attach.js +106 -16
- package/dist/workers/process-adapter.js +572 -77
- package/dist/workers/provider.js +26 -0
- package/dist/workers/registry.js +12 -20
- package/package.json +11 -2
|
@@ -1,6 +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";
|
|
3
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;
|
|
4
11
|
export class ProcessWorkerAdapter {
|
|
5
12
|
name;
|
|
6
13
|
command;
|
|
@@ -14,7 +21,8 @@ export class ProcessWorkerAdapter {
|
|
|
14
21
|
}
|
|
15
22
|
async run(spec) {
|
|
16
23
|
const model = spec.modelConfig ?? this.defaults.model;
|
|
17
|
-
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);
|
|
18
26
|
const runSpec = {
|
|
19
27
|
...spec,
|
|
20
28
|
timeoutMs: spec.timeoutMs ?? this.defaults.timeoutMs,
|
|
@@ -23,7 +31,9 @@ export class ProcessWorkerAdapter {
|
|
|
23
31
|
nativeSession: launch.nativeSession,
|
|
24
32
|
modelConfig: model
|
|
25
33
|
};
|
|
26
|
-
const first = await this.runAttempt(runSpec, launch
|
|
34
|
+
const first = await this.runAttempt(runSpec, launch, {
|
|
35
|
+
initialNativeSessionId: launch.initialNativeSessionId
|
|
36
|
+
});
|
|
27
37
|
if (!shouldFallbackToNewNativeSession(first, runSpec.nativeSessionConfig)) {
|
|
28
38
|
return first.result;
|
|
29
39
|
}
|
|
@@ -32,20 +42,32 @@ export class ProcessWorkerAdapter {
|
|
|
32
42
|
await runSpec.onNativeSessionRetired?.(retiredSessionId, first.output);
|
|
33
43
|
}
|
|
34
44
|
await appendText(runSpec.outputLogPath, `\nNative resume for ${retiredSessionId ?? "unknown session"} is unrecoverable; starting a fresh native session.\n`);
|
|
35
|
-
const freshLaunch = buildFreshLaunch(this.args, model);
|
|
45
|
+
const freshLaunch = buildFreshLaunch(this.args, model, capabilities, runSpec.writableDirs, runSpec.enforceWorkspaceIsolation, runSpec.nativeSessionConfig);
|
|
36
46
|
return (await this.runAttempt({
|
|
37
47
|
...runSpec,
|
|
38
48
|
nativeSession: null
|
|
39
49
|
}, freshLaunch, {
|
|
40
|
-
initialNativeSessionId:
|
|
50
|
+
initialNativeSessionId: freshLaunch.initialNativeSessionId,
|
|
41
51
|
startPhase: "native-resume-fallback",
|
|
42
52
|
startSummary: `${this.command} starting fresh session after unrecoverable native resume`
|
|
43
53
|
})).result;
|
|
44
54
|
}
|
|
45
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
|
+
}
|
|
46
67
|
await setStatus(runSpec, "starting", options.startPhase ?? "process-starting", options.startSummary ?? `Starting ${this.command}`);
|
|
47
68
|
await appendText(runSpec.outputLogPath, `$ ${formatShellCommand(this.command, launch.args)}\n`);
|
|
48
69
|
return new Promise((resolve, reject) => {
|
|
70
|
+
const detached = process.platform !== "win32";
|
|
49
71
|
const child = spawn(this.command, launch.args, {
|
|
50
72
|
cwd: runSpec.cwd,
|
|
51
73
|
env: {
|
|
@@ -55,102 +77,354 @@ export class ProcessWorkerAdapter {
|
|
|
55
77
|
PARALLEL_CODEX_ROLE: runSpec.role,
|
|
56
78
|
PARALLEL_CODEX_FILES_DIR: runSpec.filesDir
|
|
57
79
|
},
|
|
58
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
80
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
81
|
+
detached
|
|
59
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();
|
|
60
94
|
let settled = false;
|
|
95
|
+
let finishing = false;
|
|
61
96
|
let timeout;
|
|
62
97
|
let idleTimeout;
|
|
63
98
|
let firstOutputTimeout;
|
|
99
|
+
let processTreeCleanup;
|
|
100
|
+
let abortListener;
|
|
64
101
|
let terminalPhase;
|
|
65
102
|
let terminalSummary;
|
|
103
|
+
let terminalState;
|
|
66
104
|
let outputWrites = Promise.resolve();
|
|
105
|
+
let persistenceError;
|
|
106
|
+
let hasPersistenceError = false;
|
|
67
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 = "";
|
|
68
112
|
let sawOutput = false;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 = () => {
|
|
75
121
|
if (timeout) {
|
|
76
122
|
clearTimeout(timeout);
|
|
123
|
+
timeout = undefined;
|
|
77
124
|
}
|
|
78
125
|
if (idleTimeout) {
|
|
79
126
|
clearTimeout(idleTimeout);
|
|
127
|
+
idleTimeout = undefined;
|
|
80
128
|
}
|
|
81
129
|
if (firstOutputTimeout) {
|
|
82
130
|
clearTimeout(firstOutputTimeout);
|
|
131
|
+
firstOutputTimeout = undefined;
|
|
83
132
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
result,
|
|
93
|
-
output: outputChunks.join(""),
|
|
94
|
-
launch
|
|
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
|
|
95
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;
|
|
227
|
+
}
|
|
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());
|
|
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
|
+
}
|
|
96
345
|
};
|
|
97
346
|
const resetIdleTimeout = () => {
|
|
98
|
-
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) {
|
|
99
356
|
return;
|
|
100
357
|
}
|
|
101
358
|
if (idleTimeout) {
|
|
102
359
|
clearTimeout(idleTimeout);
|
|
103
360
|
}
|
|
104
361
|
idleTimeout = setTimeout(() => {
|
|
105
|
-
|
|
106
|
-
terminalSummary = `${this.command} produced no output for ${runSpec.idleTimeoutMs}ms`;
|
|
107
|
-
void appendText(runSpec.outputLogPath, `\nProcess idle timed out after ${runSpec.idleTimeoutMs}ms\n`);
|
|
108
|
-
void setStatus(runSpec, "failed", terminalPhase, terminalSummary);
|
|
109
|
-
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`);
|
|
110
363
|
}, runSpec.idleTimeoutMs);
|
|
111
364
|
};
|
|
112
|
-
const recordOutput = (chunk) => {
|
|
365
|
+
const recordOutput = (chunk, decoder, stream) => {
|
|
366
|
+
if (settled || finishing) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
113
369
|
sawOutput = true;
|
|
114
370
|
if (firstOutputTimeout) {
|
|
115
371
|
clearTimeout(firstOutputTimeout);
|
|
116
372
|
firstOutputTimeout = undefined;
|
|
117
373
|
}
|
|
118
|
-
|
|
119
|
-
outputChunks.push(text);
|
|
120
|
-
outputWrites = outputWrites.then(async () => {
|
|
121
|
-
await appendText(runSpec.outputLogPath, text);
|
|
122
|
-
const sessionId = detectNativeSessionId(text);
|
|
123
|
-
if (sessionId && sessionId !== detectedNativeSessionId && runSpec.nativeSessionConfig?.detectSessionId !== false) {
|
|
124
|
-
detectedNativeSessionId = sessionId;
|
|
125
|
-
await runSpec.onNativeSession?.(sessionId);
|
|
126
|
-
}
|
|
127
|
-
if (!settled) {
|
|
128
|
-
await setStatus(runSpec, "running", "process-output", summarizeOutput(text), detectedNativeSessionId);
|
|
129
|
-
}
|
|
130
|
-
});
|
|
374
|
+
recordDecodedOutput(decoder.write(chunk), stream);
|
|
131
375
|
resetIdleTimeout();
|
|
132
376
|
};
|
|
133
377
|
child.stdout.on("data", (chunk) => {
|
|
134
|
-
recordOutput(chunk);
|
|
378
|
+
recordOutput(chunk, stdoutDecoder, "stdout");
|
|
135
379
|
});
|
|
136
380
|
child.stderr.on("data", (chunk) => {
|
|
137
|
-
recordOutput(chunk);
|
|
381
|
+
recordOutput(chunk, stderrDecoder, "stderr");
|
|
138
382
|
});
|
|
139
383
|
child.on("error", (error) => {
|
|
140
384
|
if (settled) {
|
|
141
385
|
return;
|
|
142
386
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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;
|
|
146
395
|
}
|
|
147
|
-
if (
|
|
148
|
-
|
|
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;
|
|
149
407
|
}
|
|
150
|
-
|
|
151
|
-
|
|
408
|
+
endOutputDecoders();
|
|
409
|
+
settled = true;
|
|
410
|
+
clearRunTimers();
|
|
411
|
+
if (abortListener) {
|
|
412
|
+
runSpec.signal?.removeEventListener("abort", abortListener);
|
|
152
413
|
}
|
|
153
|
-
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
|
+
})();
|
|
154
428
|
});
|
|
155
429
|
child.on("close", (code, signal) => {
|
|
156
430
|
void finish({
|
|
@@ -159,53 +433,225 @@ export class ProcessWorkerAdapter {
|
|
|
159
433
|
signal
|
|
160
434
|
});
|
|
161
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 });
|
|
162
454
|
if (runSpec.timeoutMs && runSpec.timeoutMs > 0) {
|
|
163
455
|
timeout = setTimeout(() => {
|
|
164
|
-
|
|
165
|
-
terminalSummary = `${this.command} exceeded ${runSpec.timeoutMs}ms`;
|
|
166
|
-
void setStatus(runSpec, "failed", terminalPhase, terminalSummary, detectedNativeSessionId);
|
|
167
|
-
child.kill("SIGTERM");
|
|
168
|
-
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`);
|
|
169
457
|
}, runSpec.timeoutMs);
|
|
170
458
|
}
|
|
171
|
-
if (runSpec.firstOutputTimeoutMs
|
|
459
|
+
if (runSpec.firstOutputTimeoutMs
|
|
460
|
+
&& runSpec.firstOutputTimeoutMs > 0
|
|
461
|
+
&& (!runSpec.timeoutMs || runSpec.timeoutMs <= 0 || runSpec.firstOutputTimeoutMs < runSpec.timeoutMs)) {
|
|
172
462
|
firstOutputTimeout = setTimeout(() => {
|
|
173
463
|
if (sawOutput || settled) {
|
|
174
464
|
return;
|
|
175
465
|
}
|
|
176
|
-
|
|
177
|
-
terminalSummary = `${this.command} produced no first output for ${runSpec.firstOutputTimeoutMs}ms`;
|
|
178
|
-
void setStatus(runSpec, "failed", terminalPhase, terminalSummary, detectedNativeSessionId);
|
|
179
|
-
child.kill("SIGTERM");
|
|
180
|
-
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`);
|
|
181
467
|
}, runSpec.firstOutputTimeoutMs);
|
|
182
468
|
}
|
|
183
|
-
void
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
+
});
|
|
187
481
|
});
|
|
188
482
|
}
|
|
189
483
|
}
|
|
190
|
-
function buildLaunch(defaultArgs, nativeSession, nativeSessionConfig, modelConfig) {
|
|
484
|
+
function buildLaunch(defaultArgs, nativeSession, nativeSessionConfig, modelConfig, capabilities, writableDirs, enforceWorkspaceIsolation = false) {
|
|
191
485
|
const modelArgs = buildModelArgs(modelConfig);
|
|
192
486
|
if (!nativeSession || !nativeSessionConfig?.enabled || nativeSessionConfig.resumeArgs.length === 0) {
|
|
193
|
-
return buildFreshLaunch(defaultArgs, modelConfig);
|
|
487
|
+
return buildFreshLaunch(defaultArgs, modelConfig, capabilities, writableDirs, enforceWorkspaceIsolation, nativeSessionConfig);
|
|
194
488
|
}
|
|
195
489
|
return {
|
|
196
|
-
args: [
|
|
490
|
+
args: withWritableDirectoryArgs(enforceWorkerIsolationArgs([
|
|
197
491
|
...nativeSessionConfig.resumeArgs.map((arg) => renderTemplate(arg, nativeSession.session_id, modelConfig)),
|
|
198
492
|
...modelArgs
|
|
199
|
-
],
|
|
493
|
+
], capabilities.profile, enforceWorkspaceIsolation), capabilities, true, writableDirs),
|
|
200
494
|
isResume: true,
|
|
201
495
|
nativeSession
|
|
202
496
|
};
|
|
203
497
|
}
|
|
204
|
-
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);
|
|
205
501
|
return {
|
|
206
|
-
args:
|
|
502
|
+
args: withWritableDirectoryArgs(freshSession.args, capabilities, false, writableDirs),
|
|
207
503
|
isResume: false,
|
|
208
|
-
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: []
|
|
209
655
|
};
|
|
210
656
|
}
|
|
211
657
|
function buildModelArgs(modelConfig) {
|
|
@@ -221,8 +667,13 @@ function buildModelEnv(modelConfig) {
|
|
|
221
667
|
return Object.fromEntries(Object.entries(modelConfig.env).map(([key, value]) => [key, renderTemplate(value, undefined, modelConfig)]));
|
|
222
668
|
}
|
|
223
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
|
+
}
|
|
224
674
|
return (attempt.launch.isResume &&
|
|
225
|
-
attempt.result.
|
|
675
|
+
!attempt.result.cancelled &&
|
|
676
|
+
(Boolean(attempt.result.failure) || attempt.result.exitCode !== 0) &&
|
|
226
677
|
nativeSessionConfig?.fallback === "new" &&
|
|
227
678
|
isUnrecoverableNativeResumeOutput(attempt.output));
|
|
228
679
|
}
|
|
@@ -233,6 +684,33 @@ function isUnrecoverableNativeResumeOutput(output) {
|
|
|
233
684
|
normalized.includes("clear earlier history") ||
|
|
234
685
|
normalized.includes("start a new thread"));
|
|
235
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
|
+
}
|
|
236
714
|
function renderTemplate(value, sessionId, modelConfig) {
|
|
237
715
|
return value
|
|
238
716
|
.replaceAll("{sessionId}", sessionId ?? "")
|
|
@@ -257,15 +735,32 @@ function summarizeOutput(text) {
|
|
|
257
735
|
const summary = lines.at(-1) ?? "Worker produced output";
|
|
258
736
|
return summary.length > 160 ? `${summary.slice(0, 157)}...` : summary;
|
|
259
737
|
}
|
|
738
|
+
function errorMessage(error) {
|
|
739
|
+
return error instanceof Error ? error.message : String(error);
|
|
740
|
+
}
|
|
260
741
|
async function setStatus(spec, state, phase, summary, nativeSessionId) {
|
|
261
|
-
|
|
742
|
+
const status = {
|
|
262
743
|
worker_id: spec.workerId,
|
|
744
|
+
...(spec.featureId ? { feature_id: spec.featureId } : {}),
|
|
745
|
+
...(spec.featureTitle ? { feature_title: spec.featureTitle } : {}),
|
|
263
746
|
role: spec.role,
|
|
264
747
|
engine: spec.engine,
|
|
748
|
+
...(spec.modelConfig?.name.trim() ? { model_name: spec.modelConfig.name.trim() } : {}),
|
|
749
|
+
...(spec.modelConfig?.provider.trim() ? { model_provider: spec.modelConfig.provider.trim() } : {}),
|
|
265
750
|
state,
|
|
266
751
|
phase,
|
|
267
752
|
last_event_at: new Date().toISOString(),
|
|
268
753
|
summary,
|
|
269
754
|
...(nativeSessionId ? { native_session_id: nativeSessionId } : {})
|
|
270
|
-
}
|
|
755
|
+
};
|
|
756
|
+
await writeJson(spec.statusPath, status);
|
|
757
|
+
notifyStatus(spec, status);
|
|
758
|
+
}
|
|
759
|
+
function notifyStatus(spec, status) {
|
|
760
|
+
try {
|
|
761
|
+
void Promise.resolve(spec.onStatus?.(status)).catch(() => { });
|
|
762
|
+
}
|
|
763
|
+
catch {
|
|
764
|
+
// Status observers cannot change the worker outcome.
|
|
765
|
+
}
|
|
271
766
|
}
|