@the-open-engine/zeroshot 6.10.0 → 6.10.1
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/cli/index.js +16 -0
- package/cluster-hooks/block-ask-user-question.py +2 -3
- package/cluster-hooks/block-dangerous-git.py +2 -3
- package/lib/agent-cli-provider/adapters/claude.d.ts.map +1 -1
- package/lib/agent-cli-provider/adapters/claude.js +42 -1
- package/lib/agent-cli-provider/adapters/claude.js.map +1 -1
- package/lib/agent-cli-provider/contract-options.d.ts.map +1 -1
- package/lib/agent-cli-provider/contract-options.js +2 -0
- package/lib/agent-cli-provider/contract-options.js.map +1 -1
- package/lib/agent-cli-provider/types.d.ts +6 -2
- package/lib/agent-cli-provider/types.d.ts.map +1 -1
- package/lib/agent-cli-provider/types.js.map +1 -1
- package/package.json +1 -1
- package/src/agent/agent-lifecycle.js +13 -6
- package/src/agent/agent-task-executor.js +526 -313
- package/src/agent-cli-provider/adapters/claude.ts +46 -1
- package/src/agent-cli-provider/contract-options.ts +6 -0
- package/src/agent-cli-provider/types.ts +9 -6
- package/src/claude-task-runner.js +153 -44
- package/src/task-spawn-cleanup-ownership.js +82 -0
- package/src/worktree-claude-config.js +193 -85
- package/task-lib/attachable-watcher.js +93 -267
- package/task-lib/command-spec-cleanup.js +219 -0
- package/task-lib/commands/clean.js +35 -5
- package/task-lib/commands/get-task-id-by-spawn-token.js +10 -0
- package/task-lib/commands/kill.js +117 -4
- package/task-lib/commands/status.js +1 -0
- package/task-lib/runner.js +61 -4
- package/task-lib/store.js +68 -6
- package/task-lib/watcher-output-runtime.js +284 -0
- package/task-lib/watcher.js +124 -257
|
@@ -5,16 +5,11 @@
|
|
|
5
5
|
* Runs detached from parent, provides Unix socket for attach clients.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { appendFileSync
|
|
9
|
-
import { unlink } from 'fs/promises';
|
|
8
|
+
import { appendFileSync } from 'fs';
|
|
10
9
|
import { getTask, updateTask } from './store.js';
|
|
11
|
-
import {
|
|
12
|
-
detectProviderFatalError,
|
|
13
|
-
detectProviderStreamingModeError,
|
|
14
|
-
recoverProviderStructuredOutput,
|
|
15
|
-
supportsProviderStructuredOutputRecovery,
|
|
16
|
-
} from './provider-helper-runtime.js';
|
|
10
|
+
import { createCommandSpecCleanup } from './command-spec-cleanup.js';
|
|
17
11
|
import { createProviderSessionCapture } from './provider-session-capture.js';
|
|
12
|
+
import * as watcherOutputRuntime from './watcher-output-runtime.js';
|
|
18
13
|
import { createRequire } from 'module';
|
|
19
14
|
|
|
20
15
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -23,8 +18,9 @@ import { createRequire } from 'module';
|
|
|
23
18
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
24
19
|
|
|
25
20
|
const [, , taskIdArg, cwdArg, logFileArg, argsJsonArg, configJsonArg] = process.argv;
|
|
26
|
-
let
|
|
27
|
-
let
|
|
21
|
+
let commandCleanup = watcherOutputRuntime.COMMAND_CLEANUP_UNINITIALIZED;
|
|
22
|
+
let crashStarted = false;
|
|
23
|
+
let server = null;
|
|
28
24
|
|
|
29
25
|
function emergencyLog(msg) {
|
|
30
26
|
if (logFileArg) {
|
|
@@ -38,37 +34,47 @@ function emergencyLog(msg) {
|
|
|
38
34
|
}
|
|
39
35
|
}
|
|
40
36
|
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
emergencyLog(`\n[${timestamp}][CRASH] ${source}: ${errorMsg}\n`);
|
|
46
|
-
emergencyLog(`[${timestamp}][CRASH] Process terminating due to unhandled error\n`);
|
|
47
|
-
cleanupCommandSpecSync();
|
|
37
|
+
function stopAttachableProvider(exitObserved = false) {
|
|
38
|
+
return watcherOutputRuntime.terminateWatcherProvider(server, { exitObserved });
|
|
39
|
+
}
|
|
48
40
|
|
|
41
|
+
async function failWatcher(error, source) {
|
|
42
|
+
if (crashStarted) return;
|
|
43
|
+
crashStarted = true;
|
|
49
44
|
if (taskIdArg) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
emergencyLog
|
|
58
|
-
|
|
45
|
+
await watcherOutputRuntime.completeWatcherFailure({
|
|
46
|
+
taskId: taskIdArg,
|
|
47
|
+
error,
|
|
48
|
+
source,
|
|
49
|
+
commandCleanup,
|
|
50
|
+
terminateProvider: stopAttachableProvider,
|
|
51
|
+
updateTask,
|
|
52
|
+
emergencyLog,
|
|
53
|
+
terminalUpdates: { socketPath: null },
|
|
54
|
+
});
|
|
59
55
|
}
|
|
60
56
|
|
|
61
57
|
process.exit(1);
|
|
62
58
|
}
|
|
63
59
|
|
|
64
60
|
process.on('uncaughtException', (error) => {
|
|
65
|
-
|
|
61
|
+
void failWatcher(error, 'uncaughtException');
|
|
66
62
|
});
|
|
67
63
|
|
|
68
64
|
process.on('unhandledRejection', (reason) => {
|
|
69
|
-
|
|
65
|
+
void failWatcher(reason, 'unhandledRejection');
|
|
70
66
|
});
|
|
71
67
|
|
|
68
|
+
const persistedTask = taskIdArg ? getTask(taskIdArg) : null;
|
|
69
|
+
if (persistedTask) {
|
|
70
|
+
commandCleanup = createCommandSpecCleanup(
|
|
71
|
+
persistedTask.commandCleanup || { cleanup: [], cleanupMetadata: [] },
|
|
72
|
+
(cleanupPath, error) => {
|
|
73
|
+
emergencyLog(`[${Date.now()}][CLEANUP] Failed to delete ${cleanupPath}: ${error.message}\n`);
|
|
74
|
+
}
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
72
78
|
const require = createRequire(import.meta.url);
|
|
73
79
|
const { AttachServer } = require('../src/attach');
|
|
74
80
|
const { getTaskSocketPath } = require('../src/attach/socket-paths');
|
|
@@ -85,202 +91,57 @@ const commandSpec = config.commandSpec || {
|
|
|
85
91
|
env: config.env || {},
|
|
86
92
|
cleanup: [],
|
|
87
93
|
};
|
|
88
|
-
commandSpecCleanup = commandSpec.cleanup || [];
|
|
89
|
-
let server = null;
|
|
90
|
-
|
|
91
94
|
const socketPath = getTaskSocketPath(taskId);
|
|
92
95
|
|
|
93
96
|
function log(msg) {
|
|
94
97
|
appendFileSync(logFile, msg);
|
|
95
98
|
}
|
|
96
99
|
|
|
97
|
-
const providerName =
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
const { providerName, env, command, finalArgs } = watcherOutputRuntime.resolveWatcherCommand(
|
|
101
|
+
config,
|
|
102
|
+
commandSpec,
|
|
103
|
+
args,
|
|
104
|
+
normalizeProviderName
|
|
105
|
+
);
|
|
100
106
|
const providerSessionCapture = createProviderSessionCapture({
|
|
101
107
|
providerName,
|
|
102
108
|
taskId,
|
|
103
109
|
updateTask,
|
|
104
110
|
log,
|
|
105
|
-
requestedSessionId:
|
|
106
|
-
initialSessionId:
|
|
107
|
-
initialSessionIdConflict:
|
|
111
|
+
requestedSessionId: persistedTask?.requestedResumeSessionId || null,
|
|
112
|
+
initialSessionId: persistedTask?.sessionId || null,
|
|
113
|
+
initialSessionIdConflict: persistedTask?.sessionIdConflict === true,
|
|
108
114
|
});
|
|
109
|
-
const maybeCaptureProviderSession = providerSessionCapture.captureLine;
|
|
110
|
-
|
|
111
|
-
const env = { ...process.env, ...(commandSpec.env || {}) };
|
|
112
|
-
const command = commandSpec.binary;
|
|
113
|
-
const finalArgs = [...(commandSpec.args || args)];
|
|
114
|
-
|
|
115
|
-
const silentJsonMode =
|
|
116
|
-
config.outputFormat === 'json' &&
|
|
117
|
-
config.jsonSchema &&
|
|
118
|
-
config.silentJsonOutput &&
|
|
119
|
-
supportsProviderStructuredOutputRecovery(providerName);
|
|
120
|
-
|
|
121
|
-
let finalResultJson = null;
|
|
122
115
|
let outputBuffer = '';
|
|
123
|
-
let streamingModeError = null;
|
|
124
|
-
let fatalError = null;
|
|
125
|
-
|
|
126
|
-
function splitBufferLines(buffer, chunk) {
|
|
127
|
-
const nextBuffer = buffer + chunk;
|
|
128
|
-
const lines = nextBuffer.split('\n');
|
|
129
|
-
const remaining = lines.pop() || '';
|
|
130
|
-
return { lines, remaining };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function maybeHandleFatalError(line, timestamp) {
|
|
134
|
-
if (fatalError) {
|
|
135
|
-
return false;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const detected = detectProviderFatalError(providerName, line);
|
|
139
|
-
if (!detected) {
|
|
140
|
-
return false;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
fatalError = detected;
|
|
144
|
-
|
|
145
|
-
if (silentJsonMode) {
|
|
146
|
-
log(`[${timestamp}]${line}\n`);
|
|
147
|
-
}
|
|
148
|
-
log(`[${timestamp}][FATAL] ${detected}\n`);
|
|
149
116
|
|
|
117
|
+
function stopProviderAfterFatalOutput(timestamp) {
|
|
150
118
|
if (server) {
|
|
151
119
|
server.stop('SIGTERM').catch((error) => {
|
|
152
120
|
log(`[${timestamp}][FATAL] Attach server stop failed: ${error.message}\n`);
|
|
153
121
|
});
|
|
154
122
|
}
|
|
155
|
-
return true;
|
|
156
123
|
}
|
|
157
124
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
return true;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function maybeCaptureStructuredOutput(line) {
|
|
169
|
-
try {
|
|
170
|
-
const json = JSON.parse(line);
|
|
171
|
-
if (json.structured_output) {
|
|
172
|
-
finalResultJson = line;
|
|
173
|
-
}
|
|
174
|
-
} catch {
|
|
175
|
-
// Not JSON, skip
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function handleSilentJsonLines(lines, timestamp) {
|
|
180
|
-
for (const line of lines) {
|
|
181
|
-
if (!line.trim()) continue;
|
|
182
|
-
maybeCaptureProviderSession(line);
|
|
183
|
-
maybeHandleFatalError(line, timestamp);
|
|
184
|
-
if (captureStreamingError(line, timestamp)) {
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
|
-
maybeCaptureStructuredOutput(line);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function handleStreamingLines(lines, timestamp) {
|
|
192
|
-
for (const line of lines) {
|
|
193
|
-
maybeCaptureProviderSession(line);
|
|
194
|
-
maybeHandleFatalError(line, timestamp);
|
|
195
|
-
if (captureStreamingError(line, timestamp)) {
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
198
|
-
log(`[${timestamp}]${line}\n`);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function flushOutputBuffer(timestamp) {
|
|
203
|
-
if (!outputBuffer.trim()) {
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
maybeCaptureProviderSession(outputBuffer);
|
|
208
|
-
if (!enableRecovery) {
|
|
209
|
-
if (!silentJsonMode) {
|
|
210
|
-
log(`[${timestamp}]${outputBuffer}\n`);
|
|
211
|
-
}
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
maybeHandleFatalError(outputBuffer, timestamp);
|
|
216
|
-
if (captureStreamingError(outputBuffer, timestamp)) {
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
if (silentJsonMode) {
|
|
221
|
-
maybeCaptureStructuredOutput(outputBuffer);
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
log(`[${timestamp}]${outputBuffer}\n`);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function attemptRecovery(code, timestamp) {
|
|
229
|
-
if (!(code !== 0 && streamingModeError?.sessionId)) {
|
|
230
|
-
return null;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const recovered = recoverProviderStructuredOutput(providerName, streamingModeError.sessionId);
|
|
234
|
-
if (recovered?.payload) {
|
|
235
|
-
const recoveredLine = JSON.stringify(recovered.payload);
|
|
236
|
-
if (silentJsonMode) {
|
|
237
|
-
finalResultJson = recoveredLine;
|
|
238
|
-
} else {
|
|
239
|
-
log(`[${timestamp}]${recoveredLine}\n`);
|
|
240
|
-
}
|
|
241
|
-
} else if (streamingModeError.line) {
|
|
242
|
-
if (silentJsonMode) {
|
|
243
|
-
log(streamingModeError.line + '\n');
|
|
244
|
-
} else {
|
|
245
|
-
log(`[${streamingModeError.timestamp}]${streamingModeError.line}\n`);
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
return recovered;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
async function cleanupCommandSpec() {
|
|
253
|
-
if (cleanupStarted) return;
|
|
254
|
-
cleanupStarted = true;
|
|
255
|
-
for (const file of commandSpecCleanup) {
|
|
256
|
-
try {
|
|
257
|
-
await unlink(file);
|
|
258
|
-
} catch (error) {
|
|
259
|
-
log(`[${Date.now()}][CLEANUP] Failed to delete ${file}: ${error.message}\n`);
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function cleanupCommandSpecSync() {
|
|
265
|
-
if (cleanupStarted) return;
|
|
266
|
-
cleanupStarted = true;
|
|
267
|
-
for (const file of commandSpecCleanup) {
|
|
268
|
-
try {
|
|
269
|
-
unlinkSync(file);
|
|
270
|
-
} catch (error) {
|
|
271
|
-
emergencyLog(`[${Date.now()}][CLEANUP] Failed to delete ${file}: ${error.message}\n`);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function writeCompletionFooter(code, signal) {
|
|
277
|
-
if (config.outputFormat === 'json') {
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
125
|
+
const outputRuntime = watcherOutputRuntime.createWatcherOutputRuntime({
|
|
126
|
+
config,
|
|
127
|
+
providerName,
|
|
128
|
+
log,
|
|
129
|
+
stopProvider: stopProviderAfterFatalOutput,
|
|
130
|
+
providerSessionCapture,
|
|
131
|
+
});
|
|
280
132
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
133
|
+
if (
|
|
134
|
+
await watcherOutputRuntime.completePendingWatcherCancellation({
|
|
135
|
+
taskId,
|
|
136
|
+
getTask,
|
|
137
|
+
commandCleanup,
|
|
138
|
+
terminateProvider: () => true,
|
|
139
|
+
updateTask,
|
|
140
|
+
emergencyLog,
|
|
141
|
+
terminalUpdates: { socketPath: null },
|
|
142
|
+
})
|
|
143
|
+
) {
|
|
144
|
+
process.exit(0);
|
|
284
145
|
}
|
|
285
146
|
|
|
286
147
|
server = new AttachServer({
|
|
@@ -295,55 +156,21 @@ server = new AttachServer({
|
|
|
295
156
|
});
|
|
296
157
|
|
|
297
158
|
server.on('output', (data) => {
|
|
298
|
-
|
|
299
|
-
const timestamp = Date.now();
|
|
300
|
-
|
|
301
|
-
const { lines, remaining } = splitBufferLines(outputBuffer, chunk);
|
|
302
|
-
outputBuffer = remaining;
|
|
303
|
-
|
|
304
|
-
if (silentJsonMode) {
|
|
305
|
-
handleSilentJsonLines(lines, timestamp);
|
|
306
|
-
} else {
|
|
307
|
-
handleStreamingLines(lines, timestamp);
|
|
308
|
-
}
|
|
159
|
+
outputBuffer = outputRuntime.consumeOutput(outputBuffer, data);
|
|
309
160
|
});
|
|
310
161
|
|
|
311
162
|
server.on('exit', async ({ exitCode, signal }) => {
|
|
312
|
-
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const sessionIdentityError = providerSessionCapture.getCompletionError();
|
|
324
|
-
const resolvedCode =
|
|
325
|
-
fatalError || sessionIdentityError ? 1 : recovered?.payload ? 0 : code;
|
|
326
|
-
const status = resolvedCode === 0 ? 'completed' : 'failed';
|
|
327
|
-
|
|
328
|
-
writeCompletionFooter(resolvedCode, signal);
|
|
329
|
-
await cleanupCommandSpec();
|
|
330
|
-
|
|
331
|
-
try {
|
|
332
|
-
await updateTask(taskId, {
|
|
333
|
-
status,
|
|
334
|
-
pid: null,
|
|
335
|
-
processGroupId: null,
|
|
336
|
-
exitCode: resolvedCode,
|
|
337
|
-
...providerSessionCapture.getCompletionUpdate(resolvedCode),
|
|
338
|
-
error:
|
|
339
|
-
fatalError ||
|
|
340
|
-
sessionIdentityError ||
|
|
341
|
-
(resolvedCode !== 0 && signal ? `Killed by ${signal}` : null),
|
|
342
|
-
socketPath: null,
|
|
343
|
-
});
|
|
344
|
-
} catch (updateError) {
|
|
345
|
-
log(`[${Date.now()}][ERROR] Failed to update task status: ${updateError.message}\n`);
|
|
346
|
-
}
|
|
163
|
+
if (crashStarted) return;
|
|
164
|
+
const completion = outputRuntime.complete({ code: exitCode, signal, outputBuffer });
|
|
165
|
+
await watcherOutputRuntime.completeWatcherTask({
|
|
166
|
+
taskId,
|
|
167
|
+
completion,
|
|
168
|
+
commandCleanup,
|
|
169
|
+
terminateProvider: () => stopAttachableProvider(true),
|
|
170
|
+
updateTask,
|
|
171
|
+
emergencyLog,
|
|
172
|
+
terminalUpdates: { socketPath: null },
|
|
173
|
+
});
|
|
347
174
|
|
|
348
175
|
setTimeout(() => {
|
|
349
176
|
process.exit(0);
|
|
@@ -351,19 +178,7 @@ server.on('exit', async ({ exitCode, signal }) => {
|
|
|
351
178
|
});
|
|
352
179
|
|
|
353
180
|
server.on('error', async (err) => {
|
|
354
|
-
|
|
355
|
-
await cleanupCommandSpec();
|
|
356
|
-
try {
|
|
357
|
-
await updateTask(taskId, {
|
|
358
|
-
status: 'failed',
|
|
359
|
-
pid: null,
|
|
360
|
-
processGroupId: null,
|
|
361
|
-
error: err.message,
|
|
362
|
-
});
|
|
363
|
-
} catch (updateError) {
|
|
364
|
-
log(`[${Date.now()}][ERROR] Failed to update task status: ${updateError.message}\n`);
|
|
365
|
-
}
|
|
366
|
-
process.exit(1);
|
|
181
|
+
await failWatcher(err, 'attach server error');
|
|
367
182
|
});
|
|
368
183
|
|
|
369
184
|
server.on('clientAttach', ({ clientId }) => {
|
|
@@ -385,22 +200,33 @@ try {
|
|
|
385
200
|
terminationStrategy: process.platform === 'win32' ? 'process-tree' : 'process-group',
|
|
386
201
|
});
|
|
387
202
|
|
|
203
|
+
if (getTask(taskId)?.cancelRequested) {
|
|
204
|
+
crashStarted = true;
|
|
205
|
+
await watcherOutputRuntime.completePendingWatcherCancellation({
|
|
206
|
+
taskId,
|
|
207
|
+
getTask,
|
|
208
|
+
commandCleanup,
|
|
209
|
+
terminateProvider: stopAttachableProvider,
|
|
210
|
+
updateTask,
|
|
211
|
+
emergencyLog,
|
|
212
|
+
terminalUpdates: { socketPath: null },
|
|
213
|
+
});
|
|
214
|
+
process.exit(0);
|
|
215
|
+
}
|
|
216
|
+
|
|
388
217
|
log(`[${Date.now()}][SYSTEM] Started with PTY (attachable)\n`);
|
|
389
218
|
log(`[${Date.now()}][SYSTEM] Socket: ${socketPath}\n`);
|
|
390
219
|
log(`[${Date.now()}][SYSTEM] PID: ${server.pid}\n`);
|
|
391
220
|
} catch (err) {
|
|
392
|
-
|
|
393
|
-
await cleanupCommandSpec();
|
|
394
|
-
updateTask(taskId, { status: 'failed', error: err.message });
|
|
395
|
-
process.exit(1);
|
|
221
|
+
await failWatcher(err, 'attach server start');
|
|
396
222
|
}
|
|
397
223
|
|
|
398
224
|
process.on('SIGTERM', async () => {
|
|
399
225
|
log(`[${Date.now()}][SYSTEM] Received SIGTERM, stopping...\n`);
|
|
400
|
-
await
|
|
226
|
+
await stopAttachableProvider();
|
|
401
227
|
});
|
|
402
228
|
|
|
403
229
|
process.on('SIGINT', async () => {
|
|
404
230
|
log(`[${Date.now()}][SYSTEM] Received SIGINT, stopping...\n`);
|
|
405
|
-
await
|
|
231
|
+
await stopAttachableProvider();
|
|
406
232
|
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { lstat, realpath, rm, unlink } from 'fs/promises';
|
|
2
|
+
import { lstatSync, realpathSync, rmSync, unlinkSync } from 'fs';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { basename, dirname, isAbsolute, resolve } from 'path';
|
|
5
|
+
import { createRequire } from 'module';
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const {
|
|
9
|
+
isCanonicalClaudeSettingsOverlayDirectory,
|
|
10
|
+
isClaudeSettingsOverlayDirectory,
|
|
11
|
+
} = require('../src/worktree-claude-config');
|
|
12
|
+
|
|
13
|
+
const CLEANUP_METADATA_KEYS = ['kind', 'path', 'provider', 'reason'];
|
|
14
|
+
const SCHEMA_DIRECTORY_PATTERN = /^zeroshot-schema-[A-Za-z0-9_-]+$/u;
|
|
15
|
+
const SCHEMA_FILE_PATTERN =
|
|
16
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.json$/u;
|
|
17
|
+
|
|
18
|
+
function assertClosedCleanupMetadata(cleanupPath, metadata) {
|
|
19
|
+
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
|
|
20
|
+
throw new Error(`Refusing cleanup without closed ownership metadata: ${cleanupPath}`);
|
|
21
|
+
}
|
|
22
|
+
const keys = Object.keys(metadata).sort();
|
|
23
|
+
if (
|
|
24
|
+
keys.length !== CLEANUP_METADATA_KEYS.length ||
|
|
25
|
+
keys.some((key, index) => key !== CLEANUP_METADATA_KEYS[index])
|
|
26
|
+
) {
|
|
27
|
+
throw new Error(`Refusing cleanup with open ownership metadata: ${cleanupPath}`);
|
|
28
|
+
}
|
|
29
|
+
if (metadata.path !== cleanupPath) {
|
|
30
|
+
throw new Error(`Refusing cleanup with mismatched ownership path: ${cleanupPath}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createCleanupPlan(commandSpec) {
|
|
35
|
+
if (!Array.isArray(commandSpec?.cleanup) || !Array.isArray(commandSpec?.cleanupMetadata)) {
|
|
36
|
+
throw new Error('Refusing cleanup with malformed cleanup collections');
|
|
37
|
+
}
|
|
38
|
+
if (commandSpec.cleanup.length !== commandSpec.cleanupMetadata.length) {
|
|
39
|
+
throw new Error('Refusing cleanup without one closed metadata receipt per path');
|
|
40
|
+
}
|
|
41
|
+
const uniquePaths = new Set(commandSpec.cleanup);
|
|
42
|
+
if (uniquePaths.size !== commandSpec.cleanup.length) {
|
|
43
|
+
throw new Error('Refusing cleanup with duplicate cleanup paths');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return commandSpec.cleanup.map((cleanupPath) => {
|
|
47
|
+
if (typeof cleanupPath !== 'string' || cleanupPath.length === 0) {
|
|
48
|
+
throw new Error('Refusing cleanup with a non-string or empty path');
|
|
49
|
+
}
|
|
50
|
+
const matches = commandSpec.cleanupMetadata.filter(
|
|
51
|
+
(metadata) => metadata?.path === cleanupPath
|
|
52
|
+
);
|
|
53
|
+
if (matches.length !== 1) {
|
|
54
|
+
throw new Error(`Refusing cleanup without exact ownership metadata: ${cleanupPath}`);
|
|
55
|
+
}
|
|
56
|
+
assertClosedCleanupMetadata(cleanupPath, matches[0]);
|
|
57
|
+
return { cleanupPath, metadata: matches[0] };
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function assertOwnedTempDirectory(cleanupPath, metadata) {
|
|
62
|
+
if (
|
|
63
|
+
metadata.provider !== 'claude' ||
|
|
64
|
+
metadata.reason !== 'settings-overlay' ||
|
|
65
|
+
!isCanonicalClaudeSettingsOverlayDirectory(cleanupPath)
|
|
66
|
+
) {
|
|
67
|
+
throw new Error(`Refusing unowned temporary directory cleanup: ${cleanupPath}`);
|
|
68
|
+
}
|
|
69
|
+
if (isClaudeSettingsOverlayDirectory(cleanupPath)) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
lstatSync(cleanupPath);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error?.code === 'ENOENT') return true;
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
throw new Error(`Refusing unowned temporary directory cleanup: ${cleanupPath}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function assertCanonicalSchemaPath(cleanupPath, metadata) {
|
|
82
|
+
if (
|
|
83
|
+
metadata.kind !== 'temp-file' ||
|
|
84
|
+
metadata.provider !== 'codex' ||
|
|
85
|
+
metadata.reason !== 'output-schema' ||
|
|
86
|
+
!isAbsolute(cleanupPath) ||
|
|
87
|
+
resolve(cleanupPath) !== cleanupPath
|
|
88
|
+
) {
|
|
89
|
+
throw new Error(`Refusing unowned output-schema cleanup: ${cleanupPath}`);
|
|
90
|
+
}
|
|
91
|
+
const schemaDirectory = dirname(cleanupPath);
|
|
92
|
+
if (
|
|
93
|
+
dirname(schemaDirectory) !== resolve(tmpdir()) ||
|
|
94
|
+
!SCHEMA_DIRECTORY_PATTERN.test(basename(schemaDirectory)) ||
|
|
95
|
+
!SCHEMA_FILE_PATTERN.test(basename(cleanupPath))
|
|
96
|
+
) {
|
|
97
|
+
throw new Error(`Refusing non-canonical output-schema cleanup: ${cleanupPath}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function assertOwnedSchemaFile(cleanupPath, metadata) {
|
|
102
|
+
assertCanonicalSchemaPath(cleanupPath, metadata);
|
|
103
|
+
const schemaDirectory = dirname(cleanupPath);
|
|
104
|
+
const [tempRoot, realSchemaDirectory, directoryStat, schemaStat] = await Promise.all([
|
|
105
|
+
realpath(tmpdir()),
|
|
106
|
+
realpath(schemaDirectory),
|
|
107
|
+
lstat(schemaDirectory),
|
|
108
|
+
lstat(cleanupPath),
|
|
109
|
+
]);
|
|
110
|
+
if (
|
|
111
|
+
directoryStat.isSymbolicLink() ||
|
|
112
|
+
!directoryStat.isDirectory() ||
|
|
113
|
+
dirname(realSchemaDirectory) !== tempRoot ||
|
|
114
|
+
basename(realSchemaDirectory) !== basename(schemaDirectory) ||
|
|
115
|
+
schemaStat.isSymbolicLink() ||
|
|
116
|
+
!schemaStat.isFile()
|
|
117
|
+
) {
|
|
118
|
+
throw new Error(`Refusing non-regular or escaped output-schema cleanup: ${cleanupPath}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function assertOwnedSchemaFileSync(cleanupPath, metadata) {
|
|
123
|
+
assertCanonicalSchemaPath(cleanupPath, metadata);
|
|
124
|
+
const schemaDirectory = dirname(cleanupPath);
|
|
125
|
+
const tempRoot = realpathSync(tmpdir());
|
|
126
|
+
const realSchemaDirectory = realpathSync(schemaDirectory);
|
|
127
|
+
const directoryStat = lstatSync(schemaDirectory);
|
|
128
|
+
const schemaStat = lstatSync(cleanupPath);
|
|
129
|
+
if (
|
|
130
|
+
directoryStat.isSymbolicLink() ||
|
|
131
|
+
!directoryStat.isDirectory() ||
|
|
132
|
+
dirname(realSchemaDirectory) !== tempRoot ||
|
|
133
|
+
basename(realSchemaDirectory) !== basename(schemaDirectory) ||
|
|
134
|
+
schemaStat.isSymbolicLink() ||
|
|
135
|
+
!schemaStat.isFile()
|
|
136
|
+
) {
|
|
137
|
+
throw new Error(`Refusing non-regular or escaped output-schema cleanup: ${cleanupPath}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function removeCleanupPath(cleanupPath, metadata) {
|
|
142
|
+
if (metadata.kind === 'temp-directory') {
|
|
143
|
+
if (assertOwnedTempDirectory(cleanupPath, metadata)) return;
|
|
144
|
+
await rm(cleanupPath, { recursive: true, force: true });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
await assertOwnedSchemaFile(cleanupPath, metadata);
|
|
148
|
+
await unlink(cleanupPath);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function removeCleanupPathSync(cleanupPath, metadata) {
|
|
152
|
+
if (metadata.kind === 'temp-directory') {
|
|
153
|
+
if (assertOwnedTempDirectory(cleanupPath, metadata)) return;
|
|
154
|
+
rmSync(cleanupPath, { recursive: true, force: true });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
assertOwnedSchemaFileSync(cleanupPath, metadata);
|
|
158
|
+
unlinkSync(cleanupPath);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Build one idempotent cleanup owner for a provider command. Callers may run it
|
|
163
|
+
* only after the persisted provider termination boundary is terminal.
|
|
164
|
+
*/
|
|
165
|
+
export function createCommandSpecCleanup(commandSpec, logFailure) {
|
|
166
|
+
let started = false;
|
|
167
|
+
let result = true;
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
async run() {
|
|
171
|
+
if (started) return result;
|
|
172
|
+
started = true;
|
|
173
|
+
let cleanupPlan;
|
|
174
|
+
try {
|
|
175
|
+
cleanupPlan = createCleanupPlan(commandSpec);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
logFailure('<command-cleanup>', error);
|
|
178
|
+
result = false;
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
let succeeded = true;
|
|
182
|
+
for (const { cleanupPath, metadata } of cleanupPlan) {
|
|
183
|
+
try {
|
|
184
|
+
await removeCleanupPath(cleanupPath, metadata);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error?.code === 'ENOENT') continue;
|
|
187
|
+
succeeded = false;
|
|
188
|
+
logFailure(cleanupPath, error);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
result = succeeded;
|
|
192
|
+
return result;
|
|
193
|
+
},
|
|
194
|
+
runSync() {
|
|
195
|
+
if (started) return result;
|
|
196
|
+
started = true;
|
|
197
|
+
let cleanupPlan;
|
|
198
|
+
try {
|
|
199
|
+
cleanupPlan = createCleanupPlan(commandSpec);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
logFailure('<command-cleanup>', error);
|
|
202
|
+
result = false;
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
let succeeded = true;
|
|
206
|
+
for (const { cleanupPath, metadata } of cleanupPlan) {
|
|
207
|
+
try {
|
|
208
|
+
removeCleanupPathSync(cleanupPath, metadata);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (error?.code === 'ENOENT') continue;
|
|
211
|
+
succeeded = false;
|
|
212
|
+
logFailure(cleanupPath, error);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
result = succeeded;
|
|
216
|
+
return result;
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|