claude-comms 1.0.46 → 2.0.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/CHANGELOG.md +20 -1
- package/README.md +12 -13
- package/package.json +6 -10
- package/.agents/AGENTS.md +0 -61
- package/.agents/repo.md +0 -18
- package/.agents/tools/README.md +0 -45
- package/.agents/tools/agent-job/README.md +0 -208
- package/.agents/tools/agent-job/__pycache__/agent_monitor.cpython-312.pyc +0 -0
- package/.agents/tools/agent-job/agent_job.py +0 -936
- package/.agents/tools/agent-job/agent_monitor.py +0 -575
- package/.agents/tools/agent-job/consultant_template.txt +0 -73
- package/.agents/tools/chrome-devtools/README.md +0 -101
- package/.agents/tools/chrome-devtools/browsertools.py +0 -839
- package/.agents/tools/chrome-devtools/config.json +0 -24
- package/.agents/tools/workflow/CONSUMER_SETUP.md +0 -114
- package/.agents/tools/workflow/ONBOARD.md +0 -167
- package/.agents/tools/workflow/README.md +0 -169
- package/.agents/tools/workflow/cli.ts +0 -726
- package/.agents/tools/workflow/config.example.json +0 -16
- package/.agents/tools/workflow/init.ts +0 -76
- package/.agents/tools/workflow/lib/file-tracker.test.ts +0 -81
- package/.agents/tools/workflow/lib/file-tracker.ts +0 -387
- package/.agents/tools/workflow/lib/harness-executor.ts +0 -649
- package/.agents/tools/workflow/lib/prompts.scenario.test.ts +0 -262
- package/.agents/tools/workflow/lib/prompts.ts +0 -548
- package/.agents/tools/workflow/lib/streams.test.ts +0 -140
- package/.agents/tools/workflow/lib/streams.ts +0 -254
- package/.agents/tools/workflow/runner.ts +0 -994
- package/.agents/tools/workflow/templates/document.md +0 -42
- package/.agents/tools/workflow/templates/implement.md +0 -117
- package/.agents/tools/workflow/templates/plan.md +0 -54
- package/.agents/tools/workflow/templates/pm.md +0 -84
- package/.agents/tools/workflow/templates/product-owner.md +0 -211
- package/.agents/tools/workflow/templates/review.md +0 -56
- package/.agents/tools/workflow/templates/uat.md +0 -71
- package/.claude/agents/core/engineer.md +0 -146
- package/.claude/commands/action-feedback-item.md +0 -27
- package/.claude/commands/install-feedback-widget.md +0 -599
- package/.claude/commands/learn.md +0 -32
- package/.claude/hooks/comms/__pycache__/register_subagent.cpython-312.pyc +0 -0
- package/.claude/hooks/comms/__pycache__/update_subagent_completion.cpython-312.pyc +0 -0
- package/.claude/hooks/comms/get_unread_messages.py +0 -71
- package/.claude/hooks/comms/register_subagent.py +0 -74
- package/.claude/hooks/comms/send_message.py +0 -87
- package/.claude/hooks/comms/update_subagent_completion.py +0 -126
- package/.claude/hooks/context/session_id.py +0 -35
- package/.claude/hooks/observability/send_event.py +0 -110
- package/.claude/hooks/session-data/get_session_data.py +0 -261
- package/.claude/hooks/session-data/intercept_session_id.py +0 -69
- package/.claude/hooks/utils/__pycache__/server_config.cpython-312.pyc +0 -0
- package/.claude/hooks/utils/server_config.py +0 -17
- package/.claude/settings.json +0 -108
- package/.claude/settings.local.json +0 -46
- package/CLAUDE.md +0 -240
|
@@ -1,649 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Harness Executor
|
|
3
|
-
*
|
|
4
|
-
* Encapsulates the execution of AI harness processes (Claude, Codex, Gemini)
|
|
5
|
-
* with file-based event streaming for crash resilience.
|
|
6
|
-
*
|
|
7
|
-
* Key features:
|
|
8
|
-
* - Process stdout writes directly to log file (independent of runner)
|
|
9
|
-
* - LogTailer watches file for real-time event processing
|
|
10
|
-
* - Orphan detection and reconciliation on startup
|
|
11
|
-
* - Clean separation from runner orchestration logic
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { spawn, ChildProcess } from "child_process";
|
|
15
|
-
import {
|
|
16
|
-
watch,
|
|
17
|
-
FSWatcher,
|
|
18
|
-
openSync,
|
|
19
|
-
closeSync,
|
|
20
|
-
createReadStream,
|
|
21
|
-
statSync,
|
|
22
|
-
existsSync,
|
|
23
|
-
writeFileSync,
|
|
24
|
-
} from "fs";
|
|
25
|
-
import { createInterface } from "readline";
|
|
26
|
-
import { EventEmitter } from "events";
|
|
27
|
-
|
|
28
|
-
import {
|
|
29
|
-
JobTracker,
|
|
30
|
-
FileJobStatus,
|
|
31
|
-
JobPaths,
|
|
32
|
-
ensureJobDir,
|
|
33
|
-
findOrphanedJobs,
|
|
34
|
-
isPidAlive,
|
|
35
|
-
utcNowIso,
|
|
36
|
-
writeJobStatus,
|
|
37
|
-
} from "./file-tracker.js";
|
|
38
|
-
|
|
39
|
-
import {
|
|
40
|
-
StreamHandler,
|
|
41
|
-
createStreamHandler,
|
|
42
|
-
buildCommand,
|
|
43
|
-
CommandOptions,
|
|
44
|
-
} from "./streams.js";
|
|
45
|
-
|
|
46
|
-
// ============================================================================
|
|
47
|
-
// Types
|
|
48
|
-
// ============================================================================
|
|
49
|
-
|
|
50
|
-
export type Harness = "claude" | "codex" | "gemini";
|
|
51
|
-
|
|
52
|
-
export interface ExecutionCallbacks {
|
|
53
|
-
/** Called when job completes successfully */
|
|
54
|
-
onComplete: (result: string, sessionId?: string) => void;
|
|
55
|
-
/** Called when job fails */
|
|
56
|
-
onFail: (reason: string, partialResult?: string) => void;
|
|
57
|
-
/** Called when job times out */
|
|
58
|
-
onTimeout: (partialResult: string) => void;
|
|
59
|
-
/** Optional: called for each event (for custom handling) */
|
|
60
|
-
onEvent?: (event: Record<string, unknown>) => void;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export interface ExecutionHandle {
|
|
64
|
-
jobId: string;
|
|
65
|
-
pid: number;
|
|
66
|
-
/** Kill the process */
|
|
67
|
-
kill: () => void;
|
|
68
|
-
/** Get current tracker for status inspection */
|
|
69
|
-
getTracker: () => JobTracker;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export interface ExecutorConfig {
|
|
73
|
-
/** Timeout in milliseconds before killing the process */
|
|
74
|
-
timeoutMs: number;
|
|
75
|
-
/** Working directory for spawned processes (defaults to process.cwd()) */
|
|
76
|
-
cwd?: string;
|
|
77
|
-
/** Polling interval for file watcher fallback (ms) */
|
|
78
|
-
pollIntervalMs?: number;
|
|
79
|
-
/** Debounce time for file change events (ms) */
|
|
80
|
-
debounceMs?: number;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export interface ExecuteOptions {
|
|
84
|
-
jobId: string;
|
|
85
|
-
harness: Harness;
|
|
86
|
-
prompt: string;
|
|
87
|
-
/** Session ID for Claude resume */
|
|
88
|
-
sessionId?: string;
|
|
89
|
-
/** Additional environment variables */
|
|
90
|
-
env?: Record<string, string>;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export interface ReconciliationResult {
|
|
94
|
-
jobId: string;
|
|
95
|
-
finalStatus: "complete" | "error" | "timeout";
|
|
96
|
-
result?: string;
|
|
97
|
-
sessionId?: string;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// ============================================================================
|
|
101
|
-
// LogTailer - Watches log file and processes new lines
|
|
102
|
-
// ============================================================================
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Tails a log file and emits events for each new JSON line.
|
|
106
|
-
* Handles file watching with fallback polling for reliability.
|
|
107
|
-
*/
|
|
108
|
-
export class LogTailer extends EventEmitter {
|
|
109
|
-
private logPath: string;
|
|
110
|
-
private position: number;
|
|
111
|
-
private watcher: FSWatcher | null = null;
|
|
112
|
-
private pollTimer: NodeJS.Timeout | null = null;
|
|
113
|
-
private debounceTimer: NodeJS.Timeout | null = null;
|
|
114
|
-
private processing = false;
|
|
115
|
-
private stopped = false;
|
|
116
|
-
private lineBuffer = "";
|
|
117
|
-
|
|
118
|
-
private pollIntervalMs: number;
|
|
119
|
-
private debounceMs: number;
|
|
120
|
-
|
|
121
|
-
constructor(
|
|
122
|
-
logPath: string,
|
|
123
|
-
startPosition: number = 0,
|
|
124
|
-
options: { pollIntervalMs?: number; debounceMs?: number } = {}
|
|
125
|
-
) {
|
|
126
|
-
super();
|
|
127
|
-
this.logPath = logPath;
|
|
128
|
-
this.position = startPosition;
|
|
129
|
-
this.pollIntervalMs = options.pollIntervalMs ?? 500;
|
|
130
|
-
this.debounceMs = options.debounceMs ?? 50;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Start watching the log file
|
|
135
|
-
*/
|
|
136
|
-
start(): void {
|
|
137
|
-
if (this.stopped) return;
|
|
138
|
-
|
|
139
|
-
// Process any existing content first
|
|
140
|
-
this.processNewContent();
|
|
141
|
-
|
|
142
|
-
// Set up file watcher
|
|
143
|
-
try {
|
|
144
|
-
this.watcher = watch(this.logPath, (eventType) => {
|
|
145
|
-
if (eventType === "change") {
|
|
146
|
-
this.scheduleProcess();
|
|
147
|
-
}
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
this.watcher.on("error", (err) => {
|
|
151
|
-
console.error(`[LogTailer] Watch error for ${this.logPath}:`, err);
|
|
152
|
-
// Fall back to polling
|
|
153
|
-
this.startPolling();
|
|
154
|
-
});
|
|
155
|
-
} catch (err) {
|
|
156
|
-
console.error(`[LogTailer] Failed to watch ${this.logPath}:`, err);
|
|
157
|
-
this.startPolling();
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Also poll as a fallback (some systems have flaky fs.watch)
|
|
161
|
-
this.startPolling();
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Stop watching
|
|
166
|
-
*/
|
|
167
|
-
stop(): void {
|
|
168
|
-
this.stopped = true;
|
|
169
|
-
|
|
170
|
-
if (this.watcher) {
|
|
171
|
-
this.watcher.close();
|
|
172
|
-
this.watcher = null;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
if (this.pollTimer) {
|
|
176
|
-
clearInterval(this.pollTimer);
|
|
177
|
-
this.pollTimer = null;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
if (this.debounceTimer) {
|
|
181
|
-
clearTimeout(this.debounceTimer);
|
|
182
|
-
this.debounceTimer = null;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Get current read position
|
|
188
|
-
*/
|
|
189
|
-
getPosition(): number {
|
|
190
|
-
return this.position;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Process all remaining content (call before stopping for final read)
|
|
195
|
-
*/
|
|
196
|
-
async flush(): Promise<void> {
|
|
197
|
-
await this.processNewContent();
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
private startPolling(): void {
|
|
201
|
-
if (this.pollTimer || this.stopped) return;
|
|
202
|
-
|
|
203
|
-
this.pollTimer = setInterval(() => {
|
|
204
|
-
this.scheduleProcess();
|
|
205
|
-
}, this.pollIntervalMs);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
private scheduleProcess(): void {
|
|
209
|
-
if (this.stopped) return;
|
|
210
|
-
|
|
211
|
-
// Debounce rapid changes
|
|
212
|
-
if (this.debounceTimer) {
|
|
213
|
-
clearTimeout(this.debounceTimer);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
this.debounceTimer = setTimeout(() => {
|
|
217
|
-
this.processNewContent();
|
|
218
|
-
}, this.debounceMs);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
private async processNewContent(): Promise<void> {
|
|
222
|
-
if (this.processing || this.stopped) return;
|
|
223
|
-
this.processing = true;
|
|
224
|
-
|
|
225
|
-
try {
|
|
226
|
-
if (!existsSync(this.logPath)) {
|
|
227
|
-
this.processing = false;
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const stats = statSync(this.logPath);
|
|
232
|
-
const fileSize = stats.size;
|
|
233
|
-
|
|
234
|
-
// Handle file truncation (e.g., log rotation)
|
|
235
|
-
if (fileSize < this.position) {
|
|
236
|
-
console.log(`[LogTailer] File truncated, resetting position from ${this.position} to 0`);
|
|
237
|
-
this.position = 0;
|
|
238
|
-
this.lineBuffer = "";
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
if (fileSize <= this.position) {
|
|
242
|
-
this.processing = false;
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// Read new content from current position
|
|
247
|
-
const stream = createReadStream(this.logPath, {
|
|
248
|
-
start: this.position,
|
|
249
|
-
end: fileSize - 1, // Read up to current size (exclusive end)
|
|
250
|
-
encoding: "utf-8",
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
// Collect all new content
|
|
254
|
-
let newContent = this.lineBuffer;
|
|
255
|
-
for await (const chunk of stream) {
|
|
256
|
-
newContent += chunk;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Split into lines, keeping incomplete last line in buffer
|
|
260
|
-
const lines = newContent.split("\n");
|
|
261
|
-
|
|
262
|
-
// If content doesn't end with newline, last element is incomplete
|
|
263
|
-
if (!newContent.endsWith("\n")) {
|
|
264
|
-
this.lineBuffer = lines.pop() || "";
|
|
265
|
-
} else {
|
|
266
|
-
this.lineBuffer = "";
|
|
267
|
-
// Remove empty string from trailing newline
|
|
268
|
-
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
269
|
-
lines.pop();
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// Process complete lines
|
|
274
|
-
for (const line of lines) {
|
|
275
|
-
if (!line.trim()) continue;
|
|
276
|
-
|
|
277
|
-
try {
|
|
278
|
-
const event = JSON.parse(line);
|
|
279
|
-
this.emit("event", event, line);
|
|
280
|
-
} catch {
|
|
281
|
-
// Not valid JSON, emit as raw line
|
|
282
|
-
this.emit("line", line);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// Update position to file size (we've read everything up to this point)
|
|
287
|
-
// Subtract buffered incomplete line bytes
|
|
288
|
-
const bufferedBytes = Buffer.byteLength(this.lineBuffer, "utf-8");
|
|
289
|
-
this.position = fileSize - bufferedBytes;
|
|
290
|
-
this.emit("position", this.position);
|
|
291
|
-
} catch (err) {
|
|
292
|
-
console.error(`[LogTailer] Error processing ${this.logPath}:`, err);
|
|
293
|
-
} finally {
|
|
294
|
-
this.processing = false;
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// ============================================================================
|
|
300
|
-
// HarnessExecutor - Main executor class
|
|
301
|
-
// ============================================================================
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* Executes harness processes with file-based event streaming.
|
|
305
|
-
*/
|
|
306
|
-
export class HarnessExecutor {
|
|
307
|
-
private config: ExecutorConfig;
|
|
308
|
-
private activeJobs = new Map<
|
|
309
|
-
string,
|
|
310
|
-
{
|
|
311
|
-
child: ChildProcess;
|
|
312
|
-
tailer: LogTailer;
|
|
313
|
-
tracker: JobTracker;
|
|
314
|
-
handler: StreamHandler;
|
|
315
|
-
timeout: NodeJS.Timeout;
|
|
316
|
-
}
|
|
317
|
-
>();
|
|
318
|
-
|
|
319
|
-
constructor(config: ExecutorConfig) {
|
|
320
|
-
this.config = {
|
|
321
|
-
pollIntervalMs: 500,
|
|
322
|
-
debounceMs: 50,
|
|
323
|
-
...config,
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* Execute a harness job with file-based event streaming
|
|
329
|
-
*/
|
|
330
|
-
execute(options: ExecuteOptions, callbacks: ExecutionCallbacks): ExecutionHandle {
|
|
331
|
-
const { jobId, harness, prompt, sessionId, env } = options;
|
|
332
|
-
|
|
333
|
-
// 1. Create job directory and ensure log file exists
|
|
334
|
-
const paths = ensureJobDir(jobId);
|
|
335
|
-
writeFileSync(paths.logPath, ""); // Ensure empty file exists
|
|
336
|
-
|
|
337
|
-
// 2. Open file descriptor for child stdout
|
|
338
|
-
const logFd = openSync(paths.logPath, "a");
|
|
339
|
-
|
|
340
|
-
// 3. Build command
|
|
341
|
-
const commandOptions: CommandOptions = {};
|
|
342
|
-
if (sessionId && harness === "claude") {
|
|
343
|
-
commandOptions.sessionId = sessionId;
|
|
344
|
-
}
|
|
345
|
-
const { cmd, args } = buildCommand(harness, prompt, commandOptions);
|
|
346
|
-
|
|
347
|
-
// 4. Spawn child with stdout going directly to file
|
|
348
|
-
const child = spawn(cmd, args, {
|
|
349
|
-
stdio: ["ignore", logFd, "pipe"], // stdout to file, stderr to pipe
|
|
350
|
-
env: { ...process.env, ...env },
|
|
351
|
-
cwd: this.config.cwd,
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
// Close our copy of the fd (child inherited it)
|
|
355
|
-
closeSync(logFd);
|
|
356
|
-
|
|
357
|
-
const pid = child.pid || 0;
|
|
358
|
-
|
|
359
|
-
// 5. Create tracker (this also writes initial status.json)
|
|
360
|
-
const tracker = new JobTracker(jobId, harness, pid);
|
|
361
|
-
|
|
362
|
-
// 6. Create stream handler
|
|
363
|
-
const handler = createStreamHandler(harness);
|
|
364
|
-
|
|
365
|
-
// 7. Create and start log tailer
|
|
366
|
-
const tailer = new LogTailer(paths.logPath, 0, {
|
|
367
|
-
pollIntervalMs: this.config.pollIntervalMs,
|
|
368
|
-
debounceMs: this.config.debounceMs,
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
// Wire up event handling
|
|
372
|
-
tailer.on("event", (event: Record<string, unknown>, _rawLine: string) => {
|
|
373
|
-
handler.onEvent(event);
|
|
374
|
-
const eventType = (event.type as string) || "event";
|
|
375
|
-
tracker.recordEvent(eventType);
|
|
376
|
-
|
|
377
|
-
// Call optional user callback
|
|
378
|
-
callbacks.onEvent?.(event);
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
tailer.on("position", (pos: number) => {
|
|
382
|
-
tracker.setReadPosition(pos);
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
tailer.start();
|
|
386
|
-
|
|
387
|
-
// 8. Set up timeout
|
|
388
|
-
let timedOut = false;
|
|
389
|
-
let spawnFailed = false;
|
|
390
|
-
const timeout = setTimeout(() => {
|
|
391
|
-
timedOut = true;
|
|
392
|
-
console.log(`[${jobId}] Timeout after ${this.config.timeoutMs}ms`);
|
|
393
|
-
tracker.timeout();
|
|
394
|
-
child.kill("SIGTERM");
|
|
395
|
-
setTimeout(() => child.kill("SIGKILL"), 5000);
|
|
396
|
-
}, this.config.timeoutMs);
|
|
397
|
-
|
|
398
|
-
// 9. Handle spawn errors (command not found, permission denied, etc.)
|
|
399
|
-
child.on("error", (err) => {
|
|
400
|
-
spawnFailed = true;
|
|
401
|
-
clearTimeout(timeout);
|
|
402
|
-
tailer.stop();
|
|
403
|
-
this.activeJobs.delete(jobId);
|
|
404
|
-
|
|
405
|
-
const reason = `spawn_error: ${err.message}`;
|
|
406
|
-
console.error(`[${jobId}] Spawn failed: ${err.message}`);
|
|
407
|
-
tracker.fail(reason);
|
|
408
|
-
callbacks.onFail(reason, undefined);
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
// 10. Handle stderr
|
|
412
|
-
child.stderr?.on("data", (data: Buffer) => {
|
|
413
|
-
console.error(`[${jobId}] stderr: ${data.toString()}`);
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
// 12. Handle process exit
|
|
417
|
-
child.on("close", async (code) => {
|
|
418
|
-
// If spawn failed, error handler already cleaned up
|
|
419
|
-
if (spawnFailed) return;
|
|
420
|
-
|
|
421
|
-
clearTimeout(timeout);
|
|
422
|
-
|
|
423
|
-
// Final flush to capture any remaining events
|
|
424
|
-
await tailer.flush();
|
|
425
|
-
tailer.stop();
|
|
426
|
-
|
|
427
|
-
this.activeJobs.delete(jobId);
|
|
428
|
-
|
|
429
|
-
const result = handler.getResult();
|
|
430
|
-
console.log(`[${jobId}] Exited with code ${code}`);
|
|
431
|
-
|
|
432
|
-
if (timedOut) {
|
|
433
|
-
callbacks.onTimeout(result || "(no output)");
|
|
434
|
-
} else if (code === 0 && handler.isComplete()) {
|
|
435
|
-
tracker.complete(result);
|
|
436
|
-
callbacks.onComplete(result, handler.getSessionId() || undefined);
|
|
437
|
-
} else {
|
|
438
|
-
const failureReason = handler.getFailureReason();
|
|
439
|
-
const reason = failureReason
|
|
440
|
-
? `process_exit_${code} (${failureReason})`
|
|
441
|
-
: `process_exit_${code}`;
|
|
442
|
-
tracker.fail(reason);
|
|
443
|
-
callbacks.onFail(reason, result);
|
|
444
|
-
}
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
// Track active job
|
|
448
|
-
this.activeJobs.set(jobId, { child, tailer, tracker, handler, timeout });
|
|
449
|
-
|
|
450
|
-
return {
|
|
451
|
-
jobId,
|
|
452
|
-
pid,
|
|
453
|
-
kill: () => {
|
|
454
|
-
child.kill("SIGTERM");
|
|
455
|
-
setTimeout(() => {
|
|
456
|
-
if (!child.killed) child.kill("SIGKILL");
|
|
457
|
-
}, 5000);
|
|
458
|
-
},
|
|
459
|
-
getTracker: () => tracker,
|
|
460
|
-
};
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
/**
|
|
464
|
-
* Reconcile orphaned jobs on startup.
|
|
465
|
-
* Reads unprocessed events from log files and determines final status.
|
|
466
|
-
*/
|
|
467
|
-
async reconcileOrphans(
|
|
468
|
-
onReconciled: (result: ReconciliationResult) => Promise<void>
|
|
469
|
-
): Promise<void> {
|
|
470
|
-
const orphans = findOrphanedJobs();
|
|
471
|
-
|
|
472
|
-
if (orphans.length === 0) {
|
|
473
|
-
console.log("[Reconcile] No orphaned jobs found");
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
console.log(`[Reconcile] Found ${orphans.length} orphaned jobs`);
|
|
478
|
-
|
|
479
|
-
for (const { jobId, status, paths } of orphans) {
|
|
480
|
-
console.log(`[Reconcile] Processing orphan: ${jobId}`);
|
|
481
|
-
|
|
482
|
-
try {
|
|
483
|
-
const result = await this.reconcileOrphan(jobId, status, paths);
|
|
484
|
-
await onReconciled(result);
|
|
485
|
-
} catch (err) {
|
|
486
|
-
console.error(`[Reconcile] Error processing ${jobId}:`, err);
|
|
487
|
-
|
|
488
|
-
// Mark as error
|
|
489
|
-
status.status = "error";
|
|
490
|
-
status.status_reason = `reconciliation_error: ${err}`;
|
|
491
|
-
status.end_time = utcNowIso();
|
|
492
|
-
writeJobStatus(paths.statusPath, status);
|
|
493
|
-
|
|
494
|
-
await onReconciled({
|
|
495
|
-
jobId,
|
|
496
|
-
finalStatus: "error",
|
|
497
|
-
result: `Reconciliation failed: ${err}`,
|
|
498
|
-
});
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
/**
|
|
504
|
-
* Process a single orphaned job
|
|
505
|
-
*/
|
|
506
|
-
private async reconcileOrphan(
|
|
507
|
-
jobId: string,
|
|
508
|
-
status: FileJobStatus,
|
|
509
|
-
paths: JobPaths
|
|
510
|
-
): Promise<ReconciliationResult> {
|
|
511
|
-
// Check if process is somehow still alive (shouldn't be, but check anyway)
|
|
512
|
-
if (isPidAlive(status.pid)) {
|
|
513
|
-
console.log(`[Reconcile] ${jobId} still alive (PID ${status.pid}), killing`);
|
|
514
|
-
try {
|
|
515
|
-
process.kill(status.pid!, "SIGTERM");
|
|
516
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
517
|
-
if (isPidAlive(status.pid)) {
|
|
518
|
-
process.kill(status.pid!, "SIGKILL");
|
|
519
|
-
}
|
|
520
|
-
} catch {
|
|
521
|
-
// Ignore kill errors
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
// Create handler to process events
|
|
526
|
-
const handler = createStreamHandler(status.harness);
|
|
527
|
-
|
|
528
|
-
// Read log from last known position (or 0 if not set)
|
|
529
|
-
const startPosition = status.read_position ?? 0;
|
|
530
|
-
|
|
531
|
-
if (!existsSync(paths.logPath)) {
|
|
532
|
-
// No log file - mark as error
|
|
533
|
-
status.status = "error";
|
|
534
|
-
status.status_reason = "orphaned_no_log";
|
|
535
|
-
status.end_time = utcNowIso();
|
|
536
|
-
writeJobStatus(paths.statusPath, status);
|
|
537
|
-
|
|
538
|
-
return {
|
|
539
|
-
jobId,
|
|
540
|
-
finalStatus: "error",
|
|
541
|
-
result: "Job orphaned with no log file",
|
|
542
|
-
};
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
// Process remaining events in log
|
|
546
|
-
const stream = createReadStream(paths.logPath, {
|
|
547
|
-
start: startPosition,
|
|
548
|
-
encoding: "utf-8",
|
|
549
|
-
});
|
|
550
|
-
|
|
551
|
-
const rl = createInterface({
|
|
552
|
-
input: stream,
|
|
553
|
-
crlfDelay: Infinity,
|
|
554
|
-
});
|
|
555
|
-
|
|
556
|
-
let eventsProcessed = 0;
|
|
557
|
-
|
|
558
|
-
for await (const line of rl) {
|
|
559
|
-
if (!line.trim()) continue;
|
|
560
|
-
|
|
561
|
-
try {
|
|
562
|
-
const event = JSON.parse(line);
|
|
563
|
-
handler.onEvent(event);
|
|
564
|
-
eventsProcessed++;
|
|
565
|
-
} catch {
|
|
566
|
-
// Not JSON, skip
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
console.log(
|
|
571
|
-
`[Reconcile] ${jobId}: processed ${eventsProcessed} events from position ${startPosition}`
|
|
572
|
-
);
|
|
573
|
-
|
|
574
|
-
// Determine final status
|
|
575
|
-
const result = handler.getResult();
|
|
576
|
-
const sessionId = handler.getSessionId();
|
|
577
|
-
|
|
578
|
-
if (handler.isComplete()) {
|
|
579
|
-
// Job actually completed successfully
|
|
580
|
-
status.status = "complete";
|
|
581
|
-
status.status_reason = "reconciled_complete";
|
|
582
|
-
status.end_time = utcNowIso();
|
|
583
|
-
status.completion.final_message = result;
|
|
584
|
-
writeJobStatus(paths.statusPath, status);
|
|
585
|
-
|
|
586
|
-
return {
|
|
587
|
-
jobId,
|
|
588
|
-
finalStatus: "complete",
|
|
589
|
-
result,
|
|
590
|
-
sessionId: sessionId || undefined,
|
|
591
|
-
};
|
|
592
|
-
} else {
|
|
593
|
-
// Job was interrupted
|
|
594
|
-
status.status = "error";
|
|
595
|
-
status.status_reason = "orphaned_interrupted";
|
|
596
|
-
status.end_time = utcNowIso();
|
|
597
|
-
if (result) {
|
|
598
|
-
status.completion.final_message = result;
|
|
599
|
-
}
|
|
600
|
-
writeJobStatus(paths.statusPath, status);
|
|
601
|
-
|
|
602
|
-
return {
|
|
603
|
-
jobId,
|
|
604
|
-
finalStatus: "error",
|
|
605
|
-
result: result || "Job orphaned without completion",
|
|
606
|
-
};
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
/**
|
|
611
|
-
* Check if a job is currently being tracked
|
|
612
|
-
*/
|
|
613
|
-
isTracking(jobId: string): boolean {
|
|
614
|
-
return this.activeJobs.has(jobId);
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
/**
|
|
618
|
-
* Get handle for an active job
|
|
619
|
-
*/
|
|
620
|
-
getHandle(jobId: string): ExecutionHandle | null {
|
|
621
|
-
const job = this.activeJobs.get(jobId);
|
|
622
|
-
if (!job) return null;
|
|
623
|
-
|
|
624
|
-
return {
|
|
625
|
-
jobId,
|
|
626
|
-
pid: job.child.pid || 0,
|
|
627
|
-
kill: () => {
|
|
628
|
-
job.child.kill("SIGTERM");
|
|
629
|
-
setTimeout(() => {
|
|
630
|
-
if (!job.child.killed) job.child.kill("SIGKILL");
|
|
631
|
-
}, 5000);
|
|
632
|
-
},
|
|
633
|
-
getTracker: () => job.tracker,
|
|
634
|
-
};
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
/**
|
|
638
|
-
* Kill all active jobs (for shutdown)
|
|
639
|
-
*/
|
|
640
|
-
killAll(): void {
|
|
641
|
-
for (const [jobId, job] of this.activeJobs) {
|
|
642
|
-
console.log(`[Executor] Killing job ${jobId}`);
|
|
643
|
-
clearTimeout(job.timeout);
|
|
644
|
-
job.tailer.stop();
|
|
645
|
-
job.child.kill("SIGTERM");
|
|
646
|
-
}
|
|
647
|
-
this.activeJobs.clear();
|
|
648
|
-
}
|
|
649
|
-
}
|