@tyhld/conductor 0.12.73 → 0.12.75

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 CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { execFileSync, spawn } from 'node:child_process';
18
18
  import { createInterface } from 'node:readline';
19
- import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
19
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
20
20
  import path from 'node:path';
21
21
  import os from 'node:os';
22
22
  import { CENTRAL_BUSY_HOLD_MAX_MS, RELAY_DELIVER_MAX, RELAY_FETCH_TIMEOUT_MS, RELAY_HEARTBEAT_INTERVAL_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';
@@ -546,6 +546,107 @@ export function isAuthError(job) {
546
546
  export function eventsLogPath(commandId, attempt) {
547
547
  return path.join(RUNS_DIR, commandId, `attempt-${attempt}.events.jsonl`);
548
548
  }
549
+ // ─────────────────────────────────────────────────────────────────────────────
550
+ // 実行ログの後片付け(便ごとに増え続けるので、古い便の分を消す)
551
+ //
552
+ // 【なぜ要るか】1便ごとに runs/<便ID>/ が1つ増え、中に生の記録(attempt-N.jsonl・
553
+ // 実物は1行が数千文字)と進捗(attempt-N.events.jsonl)と stderr が入る。
554
+ // 消す仕組みが無いと、動いているPCで際限なく増える(前便 fbaba4b1 の残件)。
555
+ // ─────────────────────────────────────────────────────────────────────────────
556
+ /**
557
+ * 実行ログを残す期間(ms)。これより古い便のフォルダは消す。
558
+ * ★線引きはこの2つの定数だけ(1か所)。
559
+ *
560
+ * 【なぜ14日か】この記録を読むのは「あの便は何をしたのか」を後から確かめるとき。
561
+ * 受入判定(CC1)は当日〜数日、えふさんの確認と作り直しを挟んでも2週あれば足りる。
562
+ * それを過ぎた便は報告(reports/<指示ID>.md)と devlog に残っているので、
563
+ * 生の記録そのものは要らなくなる。
564
+ */
565
+ export const RUNS_RETENTION_MS = 14 * 24 * 60 * 60_000;
566
+ /**
567
+ * 残す便のフォルダの最大数(新しい順)。期間内でも、これを超えた分は古い方から消す。
568
+ *
569
+ * 【なぜ50件か】1便の生の記録は実測で数MB〜十数MB。50件なら最悪でも数百MBに収まり、
570
+ * かつ「直近の数日ぶんの便」は必ず残る(1日に50便を超える現場は今のところ無い)。
571
+ * ★期間だけだと短期間に大量の便が来たPCで膨らみ、上限だけだと静かなPCで古い物が
572
+ * いつまでも残る。両方を掛けて初めて上限が決まる。
573
+ */
574
+ export const RUNS_RETENTION_MAX_DIRS = 50;
575
+ /** 便IDらしい名前だけを消す対象にする(★runs/ の中の見知らぬ物を消さない)。 */
576
+ const RUN_DIR_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
577
+ /** そのフォルダの中で、いちばん新しい更新時刻(ms)を返す。読めなければ 0。 */
578
+ function newestMtimeMs(dir) {
579
+ let newest = 0;
580
+ try {
581
+ newest = statSync(dir).mtimeMs;
582
+ for (const name of readdirSync(dir)) {
583
+ try {
584
+ newest = Math.max(newest, statSync(path.join(dir, name)).mtimeMs);
585
+ }
586
+ catch { /* 読めない物は飛ばす */ }
587
+ }
588
+ }
589
+ catch { /* フォルダが読めなければ 0 のまま */ }
590
+ return newest;
591
+ }
592
+ /**
593
+ * 古い便の実行ログを消す。消したフォルダ名を返す。
594
+ *
595
+ * ★消さない物(Fail Safe):
596
+ * - keep に挙げた便(いま作業中の便・直前に終わった便・これから書く便)
597
+ * - 期間内(RUNS_RETENTION_MS)の便
598
+ * - 新しい順に RUNS_RETENTION_MAX_DIRS 件まで
599
+ * - 便IDらしくない名前・フォルダでない物
600
+ * ★失敗しても作業は止めない(後片付けは本業ではない)。
601
+ */
602
+ export function cleanupRunLogs(opts = {}) {
603
+ const root = opts.root ?? RUNS_DIR;
604
+ const nowMs = opts.nowMs ?? Date.now();
605
+ const keep = new Set(opts.keep ?? []);
606
+ const removed = [];
607
+ let names;
608
+ try {
609
+ names = readdirSync(root);
610
+ }
611
+ catch {
612
+ return removed; // まだ1便も走っていない
613
+ }
614
+ const dirs = [];
615
+ for (const name of names) {
616
+ if (!RUN_DIR_NAME_RE.test(name))
617
+ continue;
618
+ const p = path.join(root, name);
619
+ try {
620
+ if (!statSync(p).isDirectory())
621
+ continue;
622
+ }
623
+ catch {
624
+ continue;
625
+ }
626
+ dirs.push({ name, at: newestMtimeMs(p) });
627
+ }
628
+ dirs.sort((a, b) => b.at - a.at); // 新しい順
629
+ for (let i = 0; i < dirs.length; i++) {
630
+ const d = dirs[i];
631
+ if (keep.has(d.name))
632
+ continue;
633
+ const tooOld = nowMs - d.at > RUNS_RETENTION_MS;
634
+ const overflow = i >= RUNS_RETENTION_MAX_DIRS;
635
+ if (!tooOld && !overflow)
636
+ continue;
637
+ try {
638
+ rmSync(path.join(root, d.name), { recursive: true, force: true });
639
+ removed.push(d.name);
640
+ }
641
+ catch (err) {
642
+ log(`WARN 古い実行ログを消せません (${d.name}): ${err instanceof Error ? err.message : String(err)}`);
643
+ }
644
+ }
645
+ if (removed.length > 0) {
646
+ log(`古い実行ログを ${removed.length} 件片付けました(${RUNS_RETENTION_MS / 86_400_000}日・最大${RUNS_RETENTION_MAX_DIRS}件を残す)`);
647
+ }
648
+ return removed;
649
+ }
549
650
  /** 実行ログのディレクトリを作り、stdout / stderr / 進捗 の WriteStream を返す。 */
550
651
  function openRunLogs(commandId, attempt) {
551
652
  try {
@@ -606,6 +707,15 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
606
707
  env.CONDUCTOR_SITE = cfg.site;
607
708
  env.CONDUCTOR_MACHINE = cfg.machine;
608
709
  env.CONDUCTOR_WORK_DIR = cfg.workDir;
710
+ // ★claude が見つかった bin ディレクトリを PATH の先頭に足す(便 24c9b6b6)。
711
+ // systemd 経由の起動では nvm の bin が PATH に無いため、子プロセスの bash から
712
+ // claude・node・npm・pnpm が command not found になる。resolveClaudeBin() が
713
+ // 信頼したディレクトリだけを足す。重複させない・PATH が空でも動く。
714
+ const claudeBinDir = path.dirname(claudeBin);
715
+ const currentPath = env.PATH ?? '';
716
+ if (!currentPath.split(':').includes(claudeBinDir)) {
717
+ env.PATH = currentPath ? `${claudeBinDir}:${currentPath}` : claudeBinDir;
718
+ }
609
719
  // ★テスト用: CONDUCTOR_CLAUDE_CONFIG_DIR を設定すると、claude の設定フォルダを差し替える。
610
720
  // 本物のログインを壊さずに認証エラーを再現できる(空のフォルダを指す → not logged in)。
611
721
  // 本番では設定しない(claude は既定の ~/.claude を使う)。
@@ -640,6 +750,12 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
640
750
  return null;
641
751
  }
642
752
  const logs = openRunLogs(cmd.id, attempt);
753
+ // ★後片付けはフォルダが増える瞬間にだけ行う(便が1つ増えたら古い便を1つ片付ける)。
754
+ // これから書く便・いま手元に記録がある便・直前に終わった便は、名指しで残す。
755
+ cleanupRunLogs({
756
+ keep: [cmd.id, loadActiveJob(cfg.site)?.commandId, loadLastJob(cfg.site)?.commandId]
757
+ .filter((id) => typeof id === 'string' && id.length > 0),
758
+ });
643
759
  const job = {
644
760
  commandId: cmd.id,
645
761
  process: proc,
package/dist/view.js CHANGED
@@ -26,11 +26,25 @@ import { RUNS_DIR, eventsLogPath, isProcessAlive, loadActiveJob, loadLastJob, ma
26
26
  // 定数
27
27
  // ─────────────────────────────────────────────────────────────────────────────
28
28
  /**
29
- * 「最近の作業記録がある」と見なす上限(ms)。これを超えて記録が出ていなければ
30
- * 実プロセスが動いていても「作業中」とは出さない(えふさん確定②)。
31
- * ★長い1操作(試験の実行など)で記録が止まることがあるので、短くし過ぎない。
29
+ * 「最近」の線引き(ms)。★線引きはこの1か所だけ。
30
+ *
31
+ * 【使い所は2つだけ(便 062431f2 の直し)】
32
+ * ① 見守り役の生死が確かめられないときに限り、「作業記録が最近あるか」の判定に使う。
33
+ * ② 🟠 のまま「記録が N分 出ていません」と1行添えるかの線引き。
34
+ *
35
+ * 【★これを「止まった(🟡)」の判定には使わない(えふさん確定 2026-10-02 の直し)】
36
+ * 長い試験(建造くんは4,000件超・TYHLD-platform は8分級)や長い考えごとの間は、
37
+ * 実プロセスが動いていても記録は何分も出ない。その間に「職人は止まった/終了しました」と
38
+ * 出すのは、動いている物を「止まった」と言う嘘になる。
39
+ * 確定の文言は「最近のheartbeat/作業記録」=**どちらかが最近あればよい**。
40
+ *
41
+ * 【なぜ5分か】①は見守り役の生死が分からない機体(systemd も launchd も無い)でだけ働く
42
+ * 予備の判定で、そこでは「記録が出ている」しか手がかりが無い。②の注記は、読む人が
43
+ * 「止まったのでは」と不安になる前に理由を添えるのが目的なので、ふつうの1操作
44
+ * (ファイルを読む・編集する=数十秒)より十分長く、長い試験(8分級)より短い所に置く。
45
+ * ★この値は表示の言葉を変えるだけで、状態(🟠/🟡)を変えない。
32
46
  */
33
- export const STALE_ACTIVITY_MS = 5 * 60_000;
47
+ export const QUIET_ACTIVITY_MS = 5 * 60_000;
34
48
  /** 通常表示に出す「流れ」の行数の上限。 */
35
49
  export const FLOW_LINES_MAX = 20;
36
50
  /** 詳細表示に出す生ログの行数の上限。 */
@@ -58,11 +72,20 @@ export const STATUS_NOTE = {
58
72
  /**
59
73
  * 6つの状態のどれかを決める純粋関数(★「作業中」の条件を厳密にする所)。
60
74
  *
75
+ * 【えふさん確定(2026-10-02・1文字も変えない)】
76
+ * 🟠 作業中 と表示してよいのは「Claude Codeの実プロセスが動いている +
77
+ * 最近のheartbeat/作業記録がある」場合だけ。
78
+ * =実プロセスは必須。もう一方は **heartbeat か 作業記録 のどちらかが最近あればよい**。
79
+ *
61
80
  * ★順番に意味がある。
62
81
  * 1. 見守り役が止まっていれば、何も管制へ届かない=💤。
63
82
  * 2. えふさんの手番は、動いているかどうかより先に出す(人を待たせないため)。
64
- * 3. 実行中の仕事があるときは、実プロセスと最近の記録の【両方】がそろって初めて 🟠。
65
- * そろわなければ 🟡(勝手に「作業中」と出さない)。
83
+ * 3. 実行中の仕事があるとき:
84
+ * a. 実プロセスが消えた・終了した・生死を確かめられない → 🟡 未確認。
85
+ * b. 実プロセスが生きていて、見守り役も生きている → 🟠(★記録が止まっていても 🟠)。
86
+ * 長い試験や長い考えごとの最中に「止まった」と言わないため(便 062431f2 の直し)。
87
+ * c. 実プロセスが生きていて、見守り役の生死が確かめられない(null)→
88
+ * 手がかりは記録だけ。最近の記録があれば 🟠、無ければ 🟡。
66
89
  * 4. 実行中の仕事が無ければ、直前の結果が在れば ✅、無ければ ⏳。
67
90
  */
68
91
  export function decideSiteStatus(input) {
@@ -73,16 +96,26 @@ export function decideSiteStatus(input) {
73
96
  if (input.hasActiveJob) {
74
97
  if (!input.processAlive)
75
98
  return 'unconfirmed';
76
- if (input.sinceActivityMs === null)
77
- return 'unconfirmed';
78
- if (input.sinceActivityMs > STALE_ACTIVITY_MS)
79
- return 'unconfirmed';
80
- return 'working';
99
+ if (input.relayRunning === true)
100
+ return 'working';
101
+ const activityFresh = input.sinceActivityMs !== null && input.sinceActivityMs <= QUIET_ACTIVITY_MS;
102
+ return activityFresh ? 'working' : 'unconfirmed';
81
103
  }
82
104
  if (input.hasLastJob)
83
105
  return 'done';
84
106
  return 'queued';
85
107
  }
108
+ /**
109
+ * 🟠 のまま添える注記を返す(記録が長く止まっているときだけ1行)。
110
+ * ★状態は変えない。「止まったのでは」と読まれる前に、理由の候補を添えるだけ。
111
+ */
112
+ export function quietActivityNote(sinceActivityMs) {
113
+ if (sinceActivityMs === null)
114
+ return null;
115
+ if (sinceActivityMs <= QUIET_ACTIVITY_MS)
116
+ return null;
117
+ return `記録が ${friendlyDuration(sinceActivityMs)} 出ていません(長い試験の最中かもしれません)`;
118
+ }
86
119
  // ─────────────────────────────────────────────────────────────────────────────
87
120
  // 書式
88
121
  // ─────────────────────────────────────────────────────────────────────────────
@@ -347,9 +380,19 @@ export function renderNormalView(v) {
347
380
  else {
348
381
  out.push('実作業:—');
349
382
  }
350
- out.push(`最後に動いた:${v.lastActivityAt !== null ? friendlyTime(v.lastActivityAt, v.nowMs) : unknown}`);
383
+ // ★「いつ」だけでなく「どれだけ前か」も出す(止まっている時間が一目で分かる)。
384
+ const sinceActivityMs = v.lastActivityAt === null ? null : v.nowMs - v.lastActivityAt;
385
+ out.push(v.lastActivityAt !== null
386
+ ? `最後に動いた:${friendlyTime(v.lastActivityAt, v.nowMs)}(${friendlyDuration(sinceActivityMs ?? 0)}前)`
387
+ : `最後に動いた:${unknown}`);
351
388
  const current = v.flow.length > 0 ? v.flow[v.flow.length - 1].text : '';
352
389
  out.push(`現在:${current.length > 0 ? current : unknown}`);
390
+ // ★作業中のまま記録が長く止まっているときは、理由の候補を1行添える(状態は変えない)。
391
+ if (v.status === 'working') {
392
+ const note = quietActivityNote(sinceActivityMs);
393
+ if (note !== null)
394
+ out.push(`※${note}`);
395
+ }
353
396
  out.push(RULE);
354
397
  if (v.flow.length > 0) {
355
398
  for (const l of v.flow)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.73",
3
+ "version": "0.12.75",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {