@tyhld/conductor 0.12.57 → 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 +82 -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
|
/**
|
|
@@ -223,6 +253,7 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
223
253
|
log(`ERROR claude の起動に失敗しました (${claudeBin}): ${err instanceof Error ? err.message : String(err)}`);
|
|
224
254
|
return null;
|
|
225
255
|
}
|
|
256
|
+
const logs = openRunLogs(cmd.id, attempt);
|
|
226
257
|
const job = {
|
|
227
258
|
commandId: cmd.id,
|
|
228
259
|
process: proc,
|
|
@@ -237,6 +268,9 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
237
268
|
lastFlushAt: Date.now(),
|
|
238
269
|
lastStopCheckAt: Date.now(),
|
|
239
270
|
stderrBuf: '',
|
|
271
|
+
stdoutLog: logs?.stdoutLog ?? null,
|
|
272
|
+
stderrLog: logs?.stderrLog ?? null,
|
|
273
|
+
durationMs: 0,
|
|
240
274
|
};
|
|
241
275
|
// spawn の 'error' イベント(ENOENT 等は非同期で届く。同期例外と両方捕まえる)
|
|
242
276
|
proc.on('error', (err) => {
|
|
@@ -261,10 +295,11 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
261
295
|
}
|
|
262
296
|
proc.stdin.end();
|
|
263
297
|
}
|
|
264
|
-
// stdout: stream-json を行ごとに読み、CommandEvent
|
|
298
|
+
// stdout: stream-json を行ごとに読み、CommandEvent に変換してバッファへ。ログファイルにも書く。
|
|
265
299
|
if (proc.stdout) {
|
|
266
300
|
const rl = createInterface({ input: proc.stdout });
|
|
267
301
|
rl.on('line', (line) => {
|
|
302
|
+
job.stdoutLog?.write(line + '\n');
|
|
268
303
|
try {
|
|
269
304
|
const parsed = JSON.parse(line);
|
|
270
305
|
const event = streamEventToCommandEvent(parsed, attempt);
|
|
@@ -276,10 +311,12 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
276
311
|
}
|
|
277
312
|
});
|
|
278
313
|
}
|
|
279
|
-
// stderr: 蓄積(認証エラー検出用。上限5000
|
|
314
|
+
// stderr: 蓄積(認証エラー検出用。上限5000字で先頭を切り落とす)。ログファイルにも書く。
|
|
280
315
|
if (proc.stderr) {
|
|
281
316
|
proc.stderr.on('data', (chunk) => {
|
|
282
|
-
|
|
317
|
+
const text = chunk.toString('utf8');
|
|
318
|
+
job.stderrLog?.write(text);
|
|
319
|
+
job.stderrBuf += text;
|
|
283
320
|
if (job.stderrBuf.length > 5000) {
|
|
284
321
|
job.stderrBuf = job.stderrBuf.slice(-3000);
|
|
285
322
|
}
|
|
@@ -290,6 +327,9 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
290
327
|
job.completed = true;
|
|
291
328
|
job.exitCode = code;
|
|
292
329
|
job.exitSignal = signal?.toString() ?? null;
|
|
330
|
+
job.durationMs = Date.now() - job.startedAt;
|
|
331
|
+
closeLog(job.stdoutLog);
|
|
332
|
+
closeLog(job.stderrLog);
|
|
293
333
|
});
|
|
294
334
|
return job;
|
|
295
335
|
}
|
|
@@ -417,9 +457,10 @@ async function checkRunningJob(cfg, state) {
|
|
|
417
457
|
return;
|
|
418
458
|
}
|
|
419
459
|
// 異常終了 → 次の tick で自動再開を試みる
|
|
460
|
+
const stderrTail = job.stderrBuf.slice(-300);
|
|
420
461
|
await sendCommandEvents(cfg, job.commandId, [{
|
|
421
462
|
type: 'error',
|
|
422
|
-
text: maskSecrets(`異常終了 (exit=${job.exitCode}, signal=${job.exitSignal}): ${
|
|
463
|
+
text: maskSecrets(`異常終了 (exit=${job.exitCode}, signal=${job.exitSignal}): ${stderrTail}`).slice(0, EVENT_TEXT_MAX),
|
|
423
464
|
at: new Date().toISOString(),
|
|
424
465
|
attempt: job.attempt,
|
|
425
466
|
}]);
|
|
@@ -429,6 +470,8 @@ async function checkRunningJob(cfg, state) {
|
|
|
429
470
|
rs.abandoned.add(job.commandId);
|
|
430
471
|
}
|
|
431
472
|
else {
|
|
473
|
+
// ★ pendingRetry に詰めて、次の tick で実際に spawn する(fetchCommand は sent を返さない)
|
|
474
|
+
state.pendingRetry = { cmd: { id: job.commandId, body: '' }, attempt: nextAttempt, durationMs: job.durationMs };
|
|
432
475
|
log(`WARN site=${cfg.site} id=${job.commandId} 異常終了(exit=${job.exitCode}) → ` +
|
|
433
476
|
`次の tick で再開を試みます (attempt ${nextAttempt}/${HEADLESS_MAX_ATTEMPTS})`);
|
|
434
477
|
}
|
|
@@ -584,6 +627,39 @@ export async function headlessTick(cfg, state, hooks) {
|
|
|
584
627
|
log('停止が解除されました。relayを再開します');
|
|
585
628
|
}
|
|
586
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
|
+
}
|
|
587
663
|
// ── 新規指示の取得 ──
|
|
588
664
|
const cmd = await fetchCommand(cfg);
|
|
589
665
|
if (!cmd) {
|
|
@@ -654,6 +730,7 @@ export function createHeadlessState() {
|
|
|
654
730
|
job: null,
|
|
655
731
|
attempts: new Map(),
|
|
656
732
|
restored: false,
|
|
733
|
+
pendingRetry: null,
|
|
657
734
|
};
|
|
658
735
|
}
|
|
659
736
|
/**
|