@tyhld/conductor 0.12.66 → 0.12.68

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 +80 -24
  2. package/package.json +1 -1
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, unlinkSync, writeFileSync } from 'node:fs';
19
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, 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';
@@ -129,35 +129,51 @@ export function loadAndClearLegacyActiveJob() {
129
129
  }
130
130
  }
131
131
  /**
132
- * 手元の記録が自分(この中継)のものか照合する。
133
- * site, machine, workDir, gitOrigin を比べる。
134
- * 1点でも不一致なら理由を返す。すべて一致なら null。
132
+ * 手元の記録が自分(この中継)のものか照合する(Fail Closed)。
133
+ * site, machine, workDir, gitOrigin の4項目を比べる。
134
+ *
135
+ * ★Fail Closed: 確認できない項目(記録に無い・今の値が取れない)は
136
+ * 不一致と同じ扱いにする。確認できた項目がすべて一致した場合だけ null を返す。
135
137
  *
136
138
  * ★commandId は「この便が中央でまだ sent か」の確認(ネットワーク照合)で別に行う。
137
139
  * ここではローカルの所有者照合のみ。
138
140
  */
139
141
  export function validateActiveJobOwnership(rec, cfg) {
140
142
  const mismatches = [];
141
- if (rec.site !== undefined && rec.site !== cfg.site) {
143
+ // site — 記録に無ければ確認不能
144
+ if (rec.site === undefined) {
145
+ mismatches.push('site: 記録に無い(確認不能)');
146
+ }
147
+ else if (rec.site !== cfg.site) {
142
148
  mismatches.push(`site: 記録=${rec.site} 自分=${cfg.site}`);
143
149
  }
144
- if (rec.machine !== undefined && rec.machine !== cfg.machine) {
150
+ // machine — 記録に無ければ確認不能
151
+ if (rec.machine === undefined) {
152
+ mismatches.push('machine: 記録に無い(確認不能)');
153
+ }
154
+ else if (rec.machine !== cfg.machine) {
145
155
  mismatches.push(`machine: 記録=${rec.machine} 自分=${cfg.machine}`);
146
156
  }
147
- if (rec.workDir !== undefined && rec.workDir !== cfg.workDir) {
157
+ // workDir — 記録に無ければ確認不能
158
+ if (rec.workDir === undefined) {
159
+ mismatches.push('workDir: 記録に無い(確認不能)');
160
+ }
161
+ else if (rec.workDir !== cfg.workDir) {
148
162
  mismatches.push(`workDir: 記録=${rec.workDir} 自分=${cfg.workDir}`);
149
163
  }
150
- // ★git リポの同一性: パスが同じでも別のリポなら止める
151
- if (rec.gitOrigin !== undefined) {
164
+ // gitOrigin — 記録に無い or 今の値が取れない → 確認不能
165
+ if (rec.gitOrigin === undefined) {
166
+ mismatches.push('gitOrigin: 記録に無い(確認不能)');
167
+ }
168
+ else {
152
169
  const currentOrigin = resolveGitOrigin(cfg.workDir);
153
- if (currentOrigin !== null && rec.gitOrigin !== currentOrigin) {
170
+ if (currentOrigin === null) {
171
+ mismatches.push('gitOrigin: 今のリポの origin を取得できない(確認不能)');
172
+ }
173
+ else if (rec.gitOrigin !== currentOrigin) {
154
174
  mismatches.push(`gitOrigin: 記録=${rec.gitOrigin} 自分=${currentOrigin}`);
155
175
  }
156
176
  }
157
- // 照合項目がそろわない古い記録は安全に拾えない。
158
- if (rec.site === undefined || rec.machine === undefined || rec.workDir === undefined) {
159
- mismatches.push('照合項目が不足(古い記録)');
160
- }
161
177
  return mismatches.length > 0 ? mismatches.join(', ') : null;
162
178
  }
163
179
  // ─────────────────────────────────────────────────────────────────────────────
@@ -174,7 +190,6 @@ function claudeBinCandidates() {
174
190
  // nvm の各 node 版の bin(新しい版から)
175
191
  const nvmDir = path.join(h, '.nvm', 'versions', 'node');
176
192
  try {
177
- const { readdirSync } = require('node:fs');
178
193
  const versions = readdirSync(nvmDir)
179
194
  .filter((v) => v.startsWith('v'))
180
195
  .sort((a, b) => {
@@ -191,8 +206,11 @@ function claudeBinCandidates() {
191
206
  candidates.push(path.join(nvmDir, v, 'bin', 'claude'));
192
207
  }
193
208
  }
194
- catch {
195
- // nvm が無い場合
209
+ catch (e) {
210
+ // nvm が無い(ENOENT)は正常。それ以外は探索の失敗として記録する。
211
+ if (e?.code !== 'ENOENT') {
212
+ console.error('[conductor] nvm claude exploration failed:', e);
213
+ }
196
214
  }
197
215
  // その他の既知の場所
198
216
  candidates.push(path.join(h, '.local', 'bin', 'claude'), path.join(h, '.claude', 'local', 'claude'), '/usr/local/bin/claude', '/usr/bin/claude');
@@ -755,25 +773,38 @@ export async function restoreHeadlessOrphans(cfg, state) {
755
773
  log(`site=${cfg.site} 古い共有の active-job.json を消しました(現場ごとの記録を優先)`);
756
774
  }
757
775
  if (saved !== null) {
758
- // ★4点照合: machine, site, commandId, workDir がすべて一致しなければ再開しない
776
+ // ★所有者照合(Fail Closed): site, machine, workDir, gitOrigin がすべて確認でき一致しなければ再開しない
759
777
  const mismatch = validateActiveJobOwnership(saved, cfg);
760
778
  if (mismatch !== null) {
761
- log(`WARN site=${cfg.site} id=${saved.commandId} 手元の記録は別の中継のもの → 再開しない(不一致: ${mismatch})`);
779
+ log(`WARN site=${cfg.site} id=${saved.commandId} 手元の記録は自分のものと確認できない → 再開しない(${mismatch})`);
762
780
  await sendCommandEvents(cfg, saved.commandId, [{
763
781
  type: 'error',
764
- text: `手元の記録の4点照合で不一致 → 再開しない(${mismatch})`.slice(0, EVENT_TEXT_MAX),
782
+ text: `所有者照合で再開しない(${mismatch})`.slice(0, EVENT_TEXT_MAX),
765
783
  at: new Date().toISOString(),
766
784
  attempt: saved.attempt,
767
785
  }]);
768
786
  return;
769
787
  }
770
- // ★便IDの照合: 中央でまだ sent のまま残っている便か確かめる
788
+ // ★便IDの照合(Fail Closed): 中央でまだ sent のまま残っている便か確かめる。
789
+ // 確かめられなかった(null)ときも再開しない。ただし記録を戻して次の tick で再試行する。
771
790
  const centralStatus = await fetchCommandStatus(cfg, saved.commandId);
772
- if (centralStatus !== null && centralStatus !== 'sent') {
773
- log(`WARN site=${cfg.site} id=${saved.commandId} 手元の記録の便は中央で ${centralStatus} → 再開しない`);
791
+ if (centralStatus === null) {
792
+ log(`WARN site=${cfg.site} id=${saved.commandId} 中央の状態を確かめられない → 再開しない(次の tick で再試行)`);
793
+ // ★記録を戻す: 次の tick でもう一度確かめられるようにする(便を黙って捨てない)
794
+ saveActiveJob(cfg.site, saved);
795
+ await sendCommandEvents(cfg, saved.commandId, [{
796
+ type: 'error',
797
+ text: '中央の便の状態を確かめられない(ネットワーク障害等)→ 再開しない(次の tick で再試行)'.slice(0, EVENT_TEXT_MAX),
798
+ at: new Date().toISOString(),
799
+ attempt: saved.attempt,
800
+ }]);
801
+ return;
802
+ }
803
+ if (centralStatus !== 'sent') {
804
+ log(`WARN site=${cfg.site} id=${saved.commandId} 中央で ${centralStatus} → 再開しない`);
774
805
  await sendCommandEvents(cfg, saved.commandId, [{
775
806
  type: 'error',
776
- text: `便IDの照合: 中央の状態が sent ではない(${centralStatus})→ 再開しない`.slice(0, EVENT_TEXT_MAX),
807
+ text: `中央の便の状態が sent ではない(${centralStatus})→ 再開しない`.slice(0, EVENT_TEXT_MAX),
777
808
  at: new Date().toISOString(),
778
809
  attempt: saved.attempt,
779
810
  }]);
@@ -794,6 +825,7 @@ export async function restoreHeadlessOrphans(cfg, state) {
794
825
  const cmd = { id: pick.id, body: pick.body };
795
826
  const job = spawnClaudeProcess(cfg, cmd, sessionId, attempt, true);
796
827
  if (job === null) {
828
+ await patchStatus(cfg, pick.id, 'failed', 'claude が見つかりません(手元の孤児復元を中止)');
797
829
  state.relay.abandoned.add(pick.id);
798
830
  log(`ERROR site=${cfg.site} id=${pick.id} 手元の孤児復元: claude が見つからない → 放棄`);
799
831
  return;
@@ -813,10 +845,31 @@ export async function restoreHeadlessOrphans(cfg, state) {
813
845
  return;
814
846
  }
815
847
  // ── 経路2: 中央から孤児を拾う(PC 再起動後) ──
848
+ // ★Fail Closed: 中央の孤児には machine/workDir/gitOrigin が無い。
849
+ // 手元の記録(active-job ファイル)が無い場合、所有者照合で確認不能 → 再開しない。
850
+ // 手元の記録があれば経路1で拾われているので、経路2に来る時点で手元の記録は無い。
816
851
  const orphans = await fetchOrphans(cfg);
817
852
  const pick = selectOrphanToRestore(orphans, null);
818
853
  if (pick === null)
819
854
  return;
855
+ // ★所有者照合(Fail Closed): 中央の孤児は所有者情報を持たないため確認不能。
856
+ // 手元の記録があれば経路1で処理済み。ここに来る=手元の記録が無い=確認不能。
857
+ const orphanAsRecord = {
858
+ commandId: pick.id, body: pick.body, sessionId: '', attempt: 0,
859
+ startedAt: 0, site: pick.site,
860
+ // machine, workDir, gitOrigin は中央に無い → undefined → 確認不能
861
+ };
862
+ const orphanMismatch = validateActiveJobOwnership(orphanAsRecord, cfg);
863
+ if (orphanMismatch !== null) {
864
+ log(`WARN site=${cfg.site} id=${pick.id} 中央の孤児は所有者を確認できない → 再開しない(${orphanMismatch})`);
865
+ await sendCommandEvents(cfg, pick.id, [{
866
+ type: 'error',
867
+ text: `中央の孤児の所有者照合で再開しない(${orphanMismatch})`.slice(0, EVENT_TEXT_MAX),
868
+ at: new Date().toISOString(),
869
+ attempt: 0,
870
+ }]);
871
+ return;
872
+ }
820
873
  const attempt = (state.attempts.get(pick.id) ?? 0) + 1;
821
874
  state.attempts.set(pick.id, attempt);
822
875
  if (attempt > HEADLESS_MAX_ATTEMPTS) {
@@ -830,6 +883,7 @@ export async function restoreHeadlessOrphans(cfg, state) {
830
883
  const cmd = { id: pick.id, body: pick.body };
831
884
  const job = spawnClaudeProcess(cfg, cmd, sessionId, attempt, true);
832
885
  if (job === null) {
886
+ await patchStatus(cfg, pick.id, 'failed', 'claude が見つかりません(孤児復元を中止)');
833
887
  await sendCommandEvents(cfg, pick.id, [{
834
888
  type: 'error',
835
889
  text: 'claude が見つかりません(孤児復元を中止)',
@@ -923,6 +977,7 @@ export async function headlessTick(cfg, state, hooks) {
923
977
  : sessionIdForCommand(retry.cmd.id, retry.attempt);
924
978
  const job = spawnClaudeProcess(cfg, retry.cmd, sessionId, retry.attempt, isResume);
925
979
  if (job === null) {
980
+ await patchStatus(cfg, retry.cmd.id, 'failed', 'claude が見つかりません(自動再開を中止)');
926
981
  await sendCommandEvents(cfg, retry.cmd.id, [{
927
982
  type: 'error',
928
983
  text: 'claude が見つかりません(自動再開を中止)',
@@ -979,6 +1034,7 @@ export async function headlessTick(cfg, state, hooks) {
979
1034
  const job = spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume);
980
1035
  if (job === null) {
981
1036
  // ★起動失敗を管制へ知らせる(「起動しました」とは言わない)
1037
+ await patchStatus(cfg, cmd.id, 'failed', 'claude が見つかりません(起動できませんでした)');
982
1038
  await sendCommandEvents(cfg, cmd.id, [{
983
1039
  type: 'error',
984
1040
  text: 'claude が見つかりません(起動できませんでした)',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.66",
3
+ "version": "0.12.68",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {