@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.
Files changed (2) hide show
  1. package/dist/headless.js +82 -5
  2. 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
- job.stderrBuf += chunk.toString('utf8');
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}): ${job.stderrBuf.slice(0, 300)}`).slice(0, EVENT_TEXT_MAX),
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
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.57",
3
+ "version": "0.12.58",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {