@tyhld/conductor 0.12.61 → 0.12.62

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 +146 -21
  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 { createWriteStream, existsSync, mkdirSync } from 'node:fs';
19
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } 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';
@@ -42,6 +42,44 @@ const RESUME_PROMPT = '前回の作業の続きを行ってください。中断
42
42
  export const RUNS_DIR = path.join(os.homedir(), '.local', 'state', 'conductor', 'runs');
43
43
  /** セッションありと見なす最低実行時間(ms)。これより短いとセッションファイルが無い可能性が高い。 */
44
44
  export const SESSION_MIN_DURATION_MS = 30_000;
45
+ /** 実行中のジョブを永続化するファイル。中継再起動後の拾い直しに使う。 */
46
+ export const ACTIVE_JOB_PATH = path.join(os.homedir(), '.local', 'state', 'conductor', 'active-job.json');
47
+ /** 実行中のジョブをファイルに書く(中継が死んでも拾い直せるように)。 */
48
+ export function saveActiveJob(record) {
49
+ try {
50
+ const dir = path.dirname(ACTIVE_JOB_PATH);
51
+ mkdirSync(dir, { recursive: true });
52
+ writeFileSync(ACTIVE_JOB_PATH, JSON.stringify(record) + '\n');
53
+ }
54
+ catch (err) {
55
+ log(`WARN active-job の保存に失敗: ${err instanceof Error ? err.message : String(err)}`);
56
+ }
57
+ }
58
+ /** 実行中のジョブ記録を消す(ジョブが完了・放棄したとき)。 */
59
+ export function clearActiveJob() {
60
+ try {
61
+ if (existsSync(ACTIVE_JOB_PATH))
62
+ unlinkSync(ACTIVE_JOB_PATH);
63
+ }
64
+ catch { /* ignore */ }
65
+ }
66
+ /** 起動時に前回の実行中ジョブを読む。無ければ null。 */
67
+ export function loadActiveJob() {
68
+ try {
69
+ if (!existsSync(ACTIVE_JOB_PATH))
70
+ return null;
71
+ const raw = readFileSync(ACTIVE_JOB_PATH, 'utf8').trim();
72
+ if (!raw)
73
+ return null;
74
+ const rec = JSON.parse(raw);
75
+ if (!rec.commandId)
76
+ return null;
77
+ return rec;
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
45
83
  // ─────────────────────────────────────────────────────────────────────────────
46
84
  // claude の実行ファイルの探索
47
85
  //
@@ -134,11 +172,23 @@ export function maskSecrets(text) {
134
172
  // ─────────────────────────────────────────────────────────────────────────────
135
173
  // ヘルパー
136
174
  // ─────────────────────────────────────────────────────────────────────────────
137
- /** commandId からセッション ID を作る(resume で再利用する決定的な値)。
138
- * ★commandId はそれ自体が UUID なので、そのまま使う(プレフィックスを付けない)。
175
+ /**
176
+ * commandId と attempt 番号からセッション ID を作る(決定的な値)。
177
+ *
178
+ * 【なぜ attempt ごとに変えるか(実測 2026-10-01・便 03d3649f)】
179
+ * attempt 1 で `--session-id UUID` → セッションが作られる。
180
+ * attempt 2 で同じ UUID を `--session-id UUID` で渡すと "already in use" で即死。
181
+ * attempt 2 で `--session-id UUID --resume` だと "requires --fork-session" で即死。
182
+ * ★resume のときは `--resume UUID` で前回のセッションIDを渡す(--session-id は使わない)。
183
+ * fresh start のときは新しいセッション ID を使う。
139
184
  */
140
- export function sessionIdForCommand(commandId) {
141
- return commandId;
185
+ export function sessionIdForCommand(commandId, attempt = 1) {
186
+ if (attempt <= 1)
187
+ return commandId;
188
+ // attempt 2+ は UUID v4 の範囲で決定的に作る(末尾4桁を attempt に置き換え)。
189
+ // UUID の形を保つ(claude が UUID を要求するため)。
190
+ const hex = attempt.toString(16).padStart(4, '0');
191
+ return commandId.slice(0, -4) + hex;
142
192
  }
143
193
  /** stream-json の1行を CommandEvent に変換する。無関係な行は null。 */
144
194
  export function streamEventToCommandEvent(parsed, attempt) {
@@ -172,7 +222,11 @@ export function isAuthError(job) {
172
222
  s.includes('unauthorized') ||
173
223
  s.includes('authentication failed') ||
174
224
  s.includes('invalid api key') ||
175
- s.includes('invalid_api_key'));
225
+ s.includes('invalid_api_key') ||
226
+ s.includes('not logged in') ||
227
+ s.includes('login required') ||
228
+ s.includes('session expired') ||
229
+ s.includes('token expired'));
176
230
  }
177
231
  // ─────────────────────────────────────────────────────────────────────────────
178
232
  // 実行ログ
@@ -232,15 +286,27 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
232
286
  delete env.ANTHROPIC_API_KEY;
233
287
  env.CONDUCTOR_SITE = cfg.site;
234
288
  env.CONDUCTOR_MACHINE = cfg.machine;
289
+ // ★テスト用: CONDUCTOR_CLAUDE_CONFIG_DIR を設定すると、claude の設定フォルダを差し替える。
290
+ // 本物のログインを壊さずに認証エラーを再現できる(空のフォルダを指す → not logged in)。
291
+ // 本番では設定しない(claude は既定の ~/.claude を使う)。
292
+ if (process.env.CONDUCTOR_CLAUDE_CONFIG_DIR) {
293
+ env.CLAUDE_CONFIG_DIR = process.env.CONDUCTOR_CLAUDE_CONFIG_DIR;
294
+ }
235
295
  const args = [
236
296
  '-p',
237
297
  '--output-format', 'stream-json',
238
298
  '--verbose',
239
299
  '--permission-mode', cfg.permissionMode ?? 'acceptEdits',
240
- '--session-id', sessionId,
241
300
  ];
242
- if (isResume)
243
- args.push('--resume');
301
+ if (isResume) {
302
+ // ★ --resume <前回のセッションID> で前回の続きから再開する。
303
+ // --session-id と --resume を同時に使うと --fork-session が必要になるため、
304
+ // --resume の引数としてセッション ID を渡す(実測 2026-10-01)。
305
+ args.push('--resume', sessionId);
306
+ }
307
+ else {
308
+ args.push('--session-id', sessionId);
309
+ }
244
310
  let proc;
245
311
  try {
246
312
  proc = spawn(claudeBin, args, {
@@ -413,6 +479,7 @@ async function checkRunningJob(cfg, state) {
413
479
  // ── 完了したプロセスの後始末 ──
414
480
  if (job.completed) {
415
481
  await flushEvents(cfg, job);
482
+ clearActiveJob();
416
483
  if (job.stopped) {
417
484
  // stop_requested で止めた
418
485
  await sendCommandEvents(cfg, job.commandId, [{
@@ -428,13 +495,15 @@ async function checkRunningJob(cfg, state) {
428
495
  }
429
496
  if (isAuthError(job)) {
430
497
  // 認証エラー → 再試行しない
498
+ const authMsg = maskSecrets(`認証エラー (exit=${job.exitCode}): ${job.stderrBuf.slice(0, 300)}`).slice(0, EVENT_TEXT_MAX);
431
499
  await sendCommandEvents(cfg, job.commandId, [{
432
500
  type: 'auth_error',
433
- text: maskSecrets(`認証エラー (exit=${job.exitCode}): ${job.stderrBuf.slice(0, 300)}`).slice(0, EVENT_TEXT_MAX),
501
+ text: authMsg,
434
502
  at: new Date().toISOString(),
435
503
  attempt: job.attempt,
436
504
  }]);
437
- // ★patchStatus は 'failed' を受け付けないので、放棄だけして中央の stalled/timeout に委ねる。
505
+ // ★中央へ failed を返す(次の便が止まらないように)
506
+ await patchStatus(cfg, job.commandId, 'failed', authMsg);
438
507
  rs.abandoned.add(job.commandId);
439
508
  state.job = null;
440
509
  rs.lastHeartbeatAt = null;
@@ -467,6 +536,15 @@ async function checkRunningJob(cfg, state) {
467
536
  const nextAttempt = (state.attempts.get(job.commandId) ?? job.attempt) + 1;
468
537
  if (nextAttempt > HEADLESS_MAX_ATTEMPTS) {
469
538
  log(`WARN site=${cfg.site} id=${job.commandId} 試行回数上限(${HEADLESS_MAX_ATTEMPTS}) → 放棄`);
539
+ // ★中央へ failed を返す(次の便が止まらないように)
540
+ const failReason = maskSecrets(`${HEADLESS_MAX_ATTEMPTS}回すべて異常終了 (最後: exit=${job.exitCode}, signal=${job.exitSignal}): ${stderrTail}`).slice(0, EVENT_TEXT_MAX);
541
+ await patchStatus(cfg, job.commandId, 'failed', failReason);
542
+ await sendCommandEvents(cfg, job.commandId, [{
543
+ type: 'error',
544
+ text: failReason,
545
+ at: new Date().toISOString(),
546
+ attempt: job.attempt,
547
+ }]);
470
548
  rs.abandoned.add(job.commandId);
471
549
  }
472
550
  else {
@@ -518,25 +596,61 @@ async function checkRunningJob(cfg, state) {
518
596
  /**
519
597
  * sent孤児を1件取得し、--resume で再開する(起動時1回)。
520
598
  *
521
- * PC 再起動後、「やっています(sent)」のまま残った便を中央から見つけ、
522
- * 決定的なセッション ID(conductor-{commandId})で resume する。
523
- * Claude Code はセッションを ~/.claude/ に保存するので、同じセッション ID で
524
- * 再開すれば前回の途中から続けられる。
599
+ * 【2つの経路で孤児を見つける】
600
+ * 1. 手元の記録(active-job.json):中継が起動して、まだ終わっていない便。
601
+ * 中継の再起動(子プロセスが一緒に死ぬ)後に、中央の生存報告に頼らず拾える。
602
+ * 2. 中央(fetchOrphans):生存報告が途切れた現場の sent 便。PC 再起動後の復元。
525
603
  *
526
- * ★tmux 方式の restoreOrphans と同じ呼び出し経路(fetchOrphans → selectOrphanToRestore)
527
- * を使う。headless では復元 = 即座に子プロセスを起動する(tmux のように pending に積んで待つ
528
- * のではなく、直接 resume する)。
604
+ * ★手元の記録を先に見る(中継再起動 = 生存報告が新しく、中央は孤児と見なさないため)。
605
+ * ★二重起動しない: state.job が null のときだけ動く(④を壊さない)。
529
606
  */
530
607
  export async function restoreHeadlessOrphans(cfg, state) {
531
608
  if (!RELAY_RESTORE_ORPHANS_ENABLED)
532
609
  return;
533
610
  if (state.job !== null)
534
611
  return; // 既にジョブ実行中
612
+ // ── 経路1: 手元の記録から拾い直す ──
613
+ const saved = loadActiveJob();
614
+ if (saved !== null) {
615
+ clearActiveJob(); // 拾ったら消す(二重起動防止)
616
+ log(`site=${cfg.site} id=${saved.commandId} 手元の記録から孤児を検出(中継再起動後の拾い直し)`);
617
+ const pick = { id: saved.commandId, body: saved.body, site: cfg.site, sentAt: saved.startedAt };
618
+ const attempt = saved.attempt + 1;
619
+ state.attempts.set(pick.id, attempt);
620
+ if (attempt > HEADLESS_MAX_ATTEMPTS) {
621
+ state.relay.abandoned.add(pick.id);
622
+ await patchStatus(cfg, pick.id, 'failed', `中継再起動後の拾い直し: 試行回数上限(${HEADLESS_MAX_ATTEMPTS})`);
623
+ log(`WARN site=${cfg.site} id=${pick.id} 手元の孤児復元: 試行回数上限 → 放棄`);
624
+ return;
625
+ }
626
+ // 前回のセッション ID で再開
627
+ const sessionId = sessionIdForCommand(pick.id, saved.attempt);
628
+ const cmd = { id: pick.id, body: pick.body };
629
+ const job = spawnClaudeProcess(cfg, cmd, sessionId, attempt, true);
630
+ if (job === null) {
631
+ state.relay.abandoned.add(pick.id);
632
+ log(`ERROR site=${cfg.site} id=${pick.id} 手元の孤児復元: claude が見つからない → 放棄`);
633
+ return;
634
+ }
635
+ state.job = job;
636
+ saveActiveJob({ commandId: pick.id, body: pick.body, sessionId, attempt, startedAt: Date.now() });
637
+ markDelivered(state.relay, pick.id);
638
+ state.relay.lastHeartbeatAt = null;
639
+ noteReadiness(state.relay, 'delivered', pick.id);
640
+ await sendCommandEvents(cfg, pick.id, [{
641
+ type: 'resumed',
642
+ text: `中継再起動後の拾い直し: attempt ${attempt}/${HEADLESS_MAX_ATTEMPTS}`,
643
+ at: new Date().toISOString(),
644
+ attempt,
645
+ }]);
646
+ log(`site=${cfg.site} id=${pick.id} 手元の記録から復元して再開しました (attempt ${attempt}/${HEADLESS_MAX_ATTEMPTS})`);
647
+ return;
648
+ }
649
+ // ── 経路2: 中央から孤児を拾う(PC 再起動後) ──
535
650
  const orphans = await fetchOrphans(cfg);
536
651
  const pick = selectOrphanToRestore(orphans, null);
537
652
  if (pick === null)
538
653
  return;
539
- const sessionId = sessionIdForCommand(pick.id);
540
654
  const attempt = (state.attempts.get(pick.id) ?? 0) + 1;
541
655
  state.attempts.set(pick.id, attempt);
542
656
  if (attempt > HEADLESS_MAX_ATTEMPTS) {
@@ -545,6 +659,8 @@ export async function restoreHeadlessOrphans(cfg, state) {
545
659
  return;
546
660
  }
547
661
  // ★孤児は resume で再開する(元の指示本文ではなく続行プロンプトを渡す)。
662
+ // 前回のセッション ID を渡す(attempt - 1 で作った ID で再開)。
663
+ const sessionId = sessionIdForCommand(pick.id, attempt > 1 ? attempt - 1 : 1);
548
664
  const cmd = { id: pick.id, body: pick.body };
549
665
  const job = spawnClaudeProcess(cfg, cmd, sessionId, attempt, true);
550
666
  if (job === null) {
@@ -559,6 +675,7 @@ export async function restoreHeadlessOrphans(cfg, state) {
559
675
  return;
560
676
  }
561
677
  state.job = job;
678
+ saveActiveJob({ commandId: pick.id, body: pick.body, sessionId, attempt, startedAt: Date.now() });
562
679
  markDelivered(state.relay, pick.id);
563
680
  state.relay.lastHeartbeatAt = null;
564
681
  noteReadiness(state.relay, 'delivered', pick.id);
@@ -632,9 +749,12 @@ export async function headlessTick(cfg, state, hooks) {
632
749
  const retry = state.pendingRetry;
633
750
  state.pendingRetry = null;
634
751
  state.attempts.set(retry.cmd.id, retry.attempt);
635
- const sessionId = sessionIdForCommand(retry.cmd.id);
636
752
  // 前回がセッションを作れないほど短命(< 30秒)なら fresh start、そうでなければ resume
637
753
  const isResume = retry.attempt > 1 && retry.durationMs >= SESSION_MIN_DURATION_MS;
754
+ // resume: 前回のセッション ID を渡す。fresh start: 新しいセッション ID を作る。
755
+ const sessionId = isResume
756
+ ? sessionIdForCommand(retry.cmd.id, retry.attempt - 1)
757
+ : sessionIdForCommand(retry.cmd.id, retry.attempt);
638
758
  const job = spawnClaudeProcess(cfg, retry.cmd, sessionId, retry.attempt, isResume);
639
759
  if (job === null) {
640
760
  await sendCommandEvents(cfg, retry.cmd.id, [{
@@ -648,6 +768,7 @@ export async function headlessTick(cfg, state, hooks) {
648
768
  return;
649
769
  }
650
770
  state.job = job;
771
+ saveActiveJob({ commandId: retry.cmd.id, body: retry.cmd.body, sessionId, attempt: retry.attempt, startedAt: Date.now() });
651
772
  rs.lastHeartbeatAt = null;
652
773
  noteReadiness(rs, 'delivered', retry.cmd.id);
653
774
  await sendCommandEvents(cfg, retry.cmd.id, [{
@@ -684,8 +805,11 @@ export async function headlessTick(cfg, state, hooks) {
684
805
  return;
685
806
  }
686
807
  // ── claude -p を起動 ──
687
- const sessionId = sessionIdForCommand(cmd.id);
688
808
  const isResume = attempt > 1;
809
+ // resume: 前回のセッション ID を渡す。fresh start: 新しいセッション ID を作る。
810
+ const sessionId = isResume
811
+ ? sessionIdForCommand(cmd.id, attempt - 1)
812
+ : sessionIdForCommand(cmd.id, attempt);
689
813
  const job = spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume);
690
814
  if (job === null) {
691
815
  // ★起動失敗を管制へ知らせる(「起動しました」とは言わない)
@@ -700,6 +824,7 @@ export async function headlessTick(cfg, state, hooks) {
700
824
  return;
701
825
  }
702
826
  state.job = job;
827
+ saveActiveJob({ commandId: cmd.id, body: cmd.body, sessionId, attempt, startedAt: Date.now() });
703
828
  markDelivered(rs, cmd.id);
704
829
  rs.lastHeartbeatAt = null;
705
830
  noteReadiness(rs, 'delivered', cmd.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.61",
3
+ "version": "0.12.62",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {