@tyhld/conductor 0.12.56 → 0.12.58
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/dist/headless.js +83 -5
- package/package.json +1 -1
package/dist/headless.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { spawn } from 'node:child_process';
|
|
18
18
|
import { createInterface } from 'node:readline';
|
|
19
|
-
import { existsSync } from 'node:fs';
|
|
19
|
+
import { createWriteStream, existsSync, mkdirSync } from 'node:fs';
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
import os from 'node:os';
|
|
22
22
|
import { RELAY_DELIVER_MAX, RELAY_FETCH_TIMEOUT_MS, RELAY_HEARTBEAT_INTERVAL_MS, RELAY_PENDING_TIMEOUT_MS, RELAY_RESTORE_ORPHANS_ENABLED, buildAnchor, buildSendText, canDeliver, completeWithReport, createRelayState, deliveryReport, fetchCommand, fetchCommandStatus, fetchControl, fetchOrphans, isTerminalCommandStatus, kindOf, markDelivered, noteCentralDown, noteCentralUp, noteReadiness, patchStatus, reconnectDelayMs, selectOrphanToRestore, sendHeartbeat, } from './relay.js';
|
|
@@ -38,6 +38,10 @@ export const HEADLESS_STOP_CHECK_MS = 15_000;
|
|
|
38
38
|
export const EVENT_TEXT_MAX = 500;
|
|
39
39
|
/** resume 時に職人へ渡す続行指示。 */
|
|
40
40
|
const RESUME_PROMPT = '前回の作業の続きを行ってください。中断された箇所から再開してください。';
|
|
41
|
+
/** 実行ログの保存先ルート。 */
|
|
42
|
+
export const RUNS_DIR = path.join(os.homedir(), '.local', 'state', 'conductor', 'runs');
|
|
43
|
+
/** セッションありと見なす最低実行時間(ms)。これより短いとセッションファイルが無い可能性が高い。 */
|
|
44
|
+
export const SESSION_MIN_DURATION_MS = 30_000;
|
|
41
45
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
42
46
|
// claude の実行ファイルの探索
|
|
43
47
|
//
|
|
@@ -171,6 +175,32 @@ export function isAuthError(job) {
|
|
|
171
175
|
s.includes('invalid_api_key'));
|
|
172
176
|
}
|
|
173
177
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
178
|
+
// 実行ログ
|
|
179
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
180
|
+
/** 実行ログのディレクトリを作り、stdout / stderr の WriteStream を返す。 */
|
|
181
|
+
function openRunLogs(commandId, attempt) {
|
|
182
|
+
try {
|
|
183
|
+
const dir = path.join(RUNS_DIR, commandId);
|
|
184
|
+
mkdirSync(dir, { recursive: true });
|
|
185
|
+
const stdoutLog = createWriteStream(path.join(dir, `attempt-${attempt}.jsonl`), { flags: 'a' });
|
|
186
|
+
const stderrLog = createWriteStream(path.join(dir, `attempt-${attempt}.stderr.log`), { flags: 'a' });
|
|
187
|
+
return { stdoutLog, stderrLog };
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
log(`WARN 実行ログのディレクトリを作れません: ${err instanceof Error ? err.message : String(err)}`);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** WriteStream を安全に閉じる。 */
|
|
195
|
+
function closeLog(ws) {
|
|
196
|
+
if (ws) {
|
|
197
|
+
try {
|
|
198
|
+
ws.end();
|
|
199
|
+
}
|
|
200
|
+
catch { /* ignore */ }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
174
204
|
// 子プロセスの起動と停止
|
|
175
205
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
176
206
|
/**
|
|
@@ -205,6 +235,7 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
205
235
|
const args = [
|
|
206
236
|
'-p',
|
|
207
237
|
'--output-format', 'stream-json',
|
|
238
|
+
'--verbose',
|
|
208
239
|
'--permission-mode', cfg.permissionMode ?? 'acceptEdits',
|
|
209
240
|
'--session-id', sessionId,
|
|
210
241
|
];
|
|
@@ -222,6 +253,7 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
222
253
|
log(`ERROR claude の起動に失敗しました (${claudeBin}): ${err instanceof Error ? err.message : String(err)}`);
|
|
223
254
|
return null;
|
|
224
255
|
}
|
|
256
|
+
const logs = openRunLogs(cmd.id, attempt);
|
|
225
257
|
const job = {
|
|
226
258
|
commandId: cmd.id,
|
|
227
259
|
process: proc,
|
|
@@ -236,6 +268,9 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
236
268
|
lastFlushAt: Date.now(),
|
|
237
269
|
lastStopCheckAt: Date.now(),
|
|
238
270
|
stderrBuf: '',
|
|
271
|
+
stdoutLog: logs?.stdoutLog ?? null,
|
|
272
|
+
stderrLog: logs?.stderrLog ?? null,
|
|
273
|
+
durationMs: 0,
|
|
239
274
|
};
|
|
240
275
|
// spawn の 'error' イベント(ENOENT 等は非同期で届く。同期例外と両方捕まえる)
|
|
241
276
|
proc.on('error', (err) => {
|
|
@@ -260,10 +295,11 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
260
295
|
}
|
|
261
296
|
proc.stdin.end();
|
|
262
297
|
}
|
|
263
|
-
// stdout: stream-json を行ごとに読み、CommandEvent
|
|
298
|
+
// stdout: stream-json を行ごとに読み、CommandEvent に変換してバッファへ。ログファイルにも書く。
|
|
264
299
|
if (proc.stdout) {
|
|
265
300
|
const rl = createInterface({ input: proc.stdout });
|
|
266
301
|
rl.on('line', (line) => {
|
|
302
|
+
job.stdoutLog?.write(line + '\n');
|
|
267
303
|
try {
|
|
268
304
|
const parsed = JSON.parse(line);
|
|
269
305
|
const event = streamEventToCommandEvent(parsed, attempt);
|
|
@@ -275,10 +311,12 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
275
311
|
}
|
|
276
312
|
});
|
|
277
313
|
}
|
|
278
|
-
// stderr: 蓄積(認証エラー検出用。上限5000
|
|
314
|
+
// stderr: 蓄積(認証エラー検出用。上限5000字で先頭を切り落とす)。ログファイルにも書く。
|
|
279
315
|
if (proc.stderr) {
|
|
280
316
|
proc.stderr.on('data', (chunk) => {
|
|
281
|
-
|
|
317
|
+
const text = chunk.toString('utf8');
|
|
318
|
+
job.stderrLog?.write(text);
|
|
319
|
+
job.stderrBuf += text;
|
|
282
320
|
if (job.stderrBuf.length > 5000) {
|
|
283
321
|
job.stderrBuf = job.stderrBuf.slice(-3000);
|
|
284
322
|
}
|
|
@@ -289,6 +327,9 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
289
327
|
job.completed = true;
|
|
290
328
|
job.exitCode = code;
|
|
291
329
|
job.exitSignal = signal?.toString() ?? null;
|
|
330
|
+
job.durationMs = Date.now() - job.startedAt;
|
|
331
|
+
closeLog(job.stdoutLog);
|
|
332
|
+
closeLog(job.stderrLog);
|
|
292
333
|
});
|
|
293
334
|
return job;
|
|
294
335
|
}
|
|
@@ -416,9 +457,10 @@ async function checkRunningJob(cfg, state) {
|
|
|
416
457
|
return;
|
|
417
458
|
}
|
|
418
459
|
// 異常終了 → 次の tick で自動再開を試みる
|
|
460
|
+
const stderrTail = job.stderrBuf.slice(-300);
|
|
419
461
|
await sendCommandEvents(cfg, job.commandId, [{
|
|
420
462
|
type: 'error',
|
|
421
|
-
text: maskSecrets(`異常終了 (exit=${job.exitCode}, signal=${job.exitSignal}): ${
|
|
463
|
+
text: maskSecrets(`異常終了 (exit=${job.exitCode}, signal=${job.exitSignal}): ${stderrTail}`).slice(0, EVENT_TEXT_MAX),
|
|
422
464
|
at: new Date().toISOString(),
|
|
423
465
|
attempt: job.attempt,
|
|
424
466
|
}]);
|
|
@@ -428,6 +470,8 @@ async function checkRunningJob(cfg, state) {
|
|
|
428
470
|
rs.abandoned.add(job.commandId);
|
|
429
471
|
}
|
|
430
472
|
else {
|
|
473
|
+
// ★ pendingRetry に詰めて、次の tick で実際に spawn する(fetchCommand は sent を返さない)
|
|
474
|
+
state.pendingRetry = { cmd: { id: job.commandId, body: '' }, attempt: nextAttempt, durationMs: job.durationMs };
|
|
431
475
|
log(`WARN site=${cfg.site} id=${job.commandId} 異常終了(exit=${job.exitCode}) → ` +
|
|
432
476
|
`次の tick で再開を試みます (attempt ${nextAttempt}/${HEADLESS_MAX_ATTEMPTS})`);
|
|
433
477
|
}
|
|
@@ -583,6 +627,39 @@ export async function headlessTick(cfg, state, hooks) {
|
|
|
583
627
|
log('停止が解除されました。relayを再開します');
|
|
584
628
|
}
|
|
585
629
|
rs.lastHalted = false;
|
|
630
|
+
// ── 異常終了後の自動再開(pendingRetry)──
|
|
631
|
+
if (state.pendingRetry !== null) {
|
|
632
|
+
const retry = state.pendingRetry;
|
|
633
|
+
state.pendingRetry = null;
|
|
634
|
+
state.attempts.set(retry.cmd.id, retry.attempt);
|
|
635
|
+
const sessionId = sessionIdForCommand(retry.cmd.id);
|
|
636
|
+
// 前回がセッションを作れないほど短命(< 30秒)なら fresh start、そうでなければ resume
|
|
637
|
+
const isResume = retry.attempt > 1 && retry.durationMs >= SESSION_MIN_DURATION_MS;
|
|
638
|
+
const job = spawnClaudeProcess(cfg, retry.cmd, sessionId, retry.attempt, isResume);
|
|
639
|
+
if (job === null) {
|
|
640
|
+
await sendCommandEvents(cfg, retry.cmd.id, [{
|
|
641
|
+
type: 'error',
|
|
642
|
+
text: 'claude が見つかりません(自動再開を中止)',
|
|
643
|
+
at: new Date().toISOString(),
|
|
644
|
+
attempt: retry.attempt,
|
|
645
|
+
}]);
|
|
646
|
+
rs.abandoned.add(retry.cmd.id);
|
|
647
|
+
log(`ERROR site=${cfg.site} id=${retry.cmd.id} 自動再開: claude が見つからない → 放棄`);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
state.job = job;
|
|
651
|
+
rs.lastHeartbeatAt = null;
|
|
652
|
+
noteReadiness(rs, 'delivered', retry.cmd.id);
|
|
653
|
+
await sendCommandEvents(cfg, retry.cmd.id, [{
|
|
654
|
+
type: 'resumed',
|
|
655
|
+
text: `attempt ${retry.attempt}/${HEADLESS_MAX_ATTEMPTS} (自動再開)`,
|
|
656
|
+
at: new Date().toISOString(),
|
|
657
|
+
attempt: retry.attempt,
|
|
658
|
+
}]);
|
|
659
|
+
log(`site=${cfg.site} id=${retry.cmd.id} ` +
|
|
660
|
+
`自動再開しました (session=${sessionId}, attempt ${retry.attempt}/${HEADLESS_MAX_ATTEMPTS})`);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
586
663
|
// ── 新規指示の取得 ──
|
|
587
664
|
const cmd = await fetchCommand(cfg);
|
|
588
665
|
if (!cmd) {
|
|
@@ -653,6 +730,7 @@ export function createHeadlessState() {
|
|
|
653
730
|
job: null,
|
|
654
731
|
attempts: new Map(),
|
|
655
732
|
restored: false,
|
|
733
|
+
pendingRetry: null,
|
|
656
734
|
};
|
|
657
735
|
}
|
|
658
736
|
/**
|