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