@tyhld/conductor 0.12.55 → 0.12.57

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/cli.js CHANGED
@@ -165,6 +165,8 @@ function loadConfig(args) {
165
165
  runnerMode,
166
166
  };
167
167
  }
168
+ /** 報告ファイルの拒否回数を追跡する(常駐の生存期間にわたって保持する)。 */
169
+ const reportRejectCounts = new Map();
168
170
  /** 1回分の報告を送る。送信結果(HTTPステータス)をログ表示する。 */
169
171
  async function sendReport(cfg) {
170
172
  const body = {
@@ -223,7 +225,7 @@ async function sendReport(cfg) {
223
225
  // ★配達役がいま追っている便の報告は送らない(完了の道が完了と一緒に渡すため)。
224
226
  if (pending.ids.length > 0) {
225
227
  const tracking = relayStateForReport?.pending?.id;
226
- await sendPendingReportFiles(cfg, { exclude: tracking ? [tracking] : [] });
228
+ await sendPendingReportFiles(cfg, { exclude: tracking ? [tracking] : [], rejectCounts: reportRejectCounts });
227
229
  }
228
230
  }
229
231
  /** `conductor start`: 起動直後に1回 + 以後 intervalSec ごとに報告し続ける。 */
package/dist/headless.js CHANGED
@@ -16,6 +16,9 @@
16
16
  */
17
17
  import { spawn } from 'node:child_process';
18
18
  import { createInterface } from 'node:readline';
19
+ import { existsSync } from 'node:fs';
20
+ import path from 'node:path';
21
+ import os from 'node:os';
19
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';
20
23
  const log = (msg) => {
21
24
  console.log(`[headless] ${msg}`);
@@ -36,6 +39,67 @@ export const EVENT_TEXT_MAX = 500;
36
39
  /** resume 時に職人へ渡す続行指示。 */
37
40
  const RESUME_PROMPT = '前回の作業の続きを行ってください。中断された箇所から再開してください。';
38
41
  // ─────────────────────────────────────────────────────────────────────────────
42
+ // claude の実行ファイルの探索
43
+ //
44
+ // systemd のユーザー常駐はシェルの PATH を継承しない。nvm の下に入った claude は
45
+ // PATH に居ないため spawn('claude') が ENOENT で失敗する(実測 2026-10-01)。
46
+ // conductor-run が node を探すのと同じ考え方で、既知の場所を順に探す。
47
+ // ─────────────────────────────────────────────────────────────────────────────
48
+ /** claude の実行ファイルの既知の候補を返す(新しい node の版から順)。 */
49
+ function claudeBinCandidates() {
50
+ const h = os.homedir();
51
+ const candidates = [];
52
+ // nvm の各 node 版の bin(新しい版から)
53
+ const nvmDir = path.join(h, '.nvm', 'versions', 'node');
54
+ try {
55
+ const { readdirSync } = require('node:fs');
56
+ const versions = readdirSync(nvmDir)
57
+ .filter((v) => v.startsWith('v'))
58
+ .sort((a, b) => {
59
+ // v24.21.0 → [24, 21, 0] で降順
60
+ const pa = a.slice(1).split('.').map(Number);
61
+ const pb = b.slice(1).split('.').map(Number);
62
+ for (let i = 0; i < 3; i++) {
63
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0))
64
+ return (pb[i] ?? 0) - (pa[i] ?? 0);
65
+ }
66
+ return 0;
67
+ });
68
+ for (const v of versions) {
69
+ candidates.push(path.join(nvmDir, v, 'bin', 'claude'));
70
+ }
71
+ }
72
+ catch {
73
+ // nvm が無い場合
74
+ }
75
+ // その他の既知の場所
76
+ candidates.push(path.join(h, '.local', 'bin', 'claude'), path.join(h, '.claude', 'local', 'claude'), '/usr/local/bin/claude', '/usr/bin/claude');
77
+ return candidates;
78
+ }
79
+ /**
80
+ * claude の実行ファイルのフルパスを返す。見つからなければ null。
81
+ *
82
+ * 探す順:
83
+ * 1. 環境変数 CONDUCTOR_CLAUDE_BIN(テスト用の差し替え口)
84
+ * 2. nvm の各 node 版の bin/claude(新しい版から)
85
+ * 3. ~/.local/bin/claude
86
+ * 4. ~/.claude/local/claude
87
+ * 5. /usr/local/bin/claude, /usr/bin/claude
88
+ */
89
+ export function resolveClaudeBin() {
90
+ const override = process.env.CONDUCTOR_CLAUDE_BIN;
91
+ if (override) {
92
+ if (existsSync(override))
93
+ return override;
94
+ return null;
95
+ }
96
+ for (const candidate of claudeBinCandidates()) {
97
+ if (existsSync(candidate))
98
+ return candidate;
99
+ }
100
+ return null;
101
+ }
102
+ // ─────────────────────────────────────────────────────────────────────────────
39
103
  // 秘密のマスク
40
104
  // ─────────────────────────────────────────────────────────────────────────────
41
105
  /** 秘密の値を検出するパターン。マッチした部分を *** に置換してからイベントを送る。 */
@@ -66,9 +130,11 @@ export function maskSecrets(text) {
66
130
  // ─────────────────────────────────────────────────────────────────────────────
67
131
  // ヘルパー
68
132
  // ─────────────────────────────────────────────────────────────────────────────
69
- /** commandId からセッション ID を作る(resume で再利用する決定的な値)。 */
133
+ /** commandId からセッション ID を作る(resume で再利用する決定的な値)。
134
+ * ★commandId はそれ自体が UUID なので、そのまま使う(プレフィックスを付けない)。
135
+ */
70
136
  export function sessionIdForCommand(commandId) {
71
- return `conductor-${commandId}`;
137
+ return commandId;
72
138
  }
73
139
  /** stream-json の1行を CommandEvent に変換する。無関係な行は null。 */
74
140
  export function streamEventToCommandEvent(parsed, attempt) {
@@ -121,8 +187,16 @@ export function isAuthError(job) {
121
187
  *
122
188
  * 【stdout】stream-json 形式。行ごとに JSON を出す。readline で1行ずつ読む。
123
189
  * 【stderr】蓄積して認証エラーの検出に使う。
190
+ *
191
+ * 【起動失敗】claude が見つからない(ENOENT)・実行権限がない(EACCES)などで
192
+ * spawn が失敗した場合、null を返す。呼び出し側はエラーイベントを管制へ送る。
124
193
  */
125
194
  export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
195
+ const claudeBin = resolveClaudeBin();
196
+ if (claudeBin === null) {
197
+ log(`ERROR claude が見つかりません。探した場所: nvm の各版の bin, ~/.local/bin, ~/.claude/local, /usr/local/bin, /usr/bin`);
198
+ return null;
199
+ }
126
200
  const env = { ...process.env };
127
201
  delete env.CLAUDECODE;
128
202
  delete env.ANTHROPIC_API_KEY;
@@ -131,16 +205,24 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
131
205
  const args = [
132
206
  '-p',
133
207
  '--output-format', 'stream-json',
208
+ '--verbose',
134
209
  '--permission-mode', cfg.permissionMode ?? 'acceptEdits',
135
210
  '--session-id', sessionId,
136
211
  ];
137
212
  if (isResume)
138
213
  args.push('--resume');
139
- const proc = spawn('claude', args, {
140
- cwd: cfg.workDir,
141
- env,
142
- stdio: ['pipe', 'pipe', 'pipe'],
143
- });
214
+ let proc;
215
+ try {
216
+ proc = spawn(claudeBin, args, {
217
+ cwd: cfg.workDir,
218
+ env,
219
+ stdio: ['pipe', 'pipe', 'pipe'],
220
+ });
221
+ }
222
+ catch (err) {
223
+ log(`ERROR claude の起動に失敗しました (${claudeBin}): ${err instanceof Error ? err.message : String(err)}`);
224
+ return null;
225
+ }
144
226
  const job = {
145
227
  commandId: cmd.id,
146
228
  process: proc,
@@ -156,8 +238,19 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
156
238
  lastStopCheckAt: Date.now(),
157
239
  stderrBuf: '',
158
240
  };
241
+ // spawn の 'error' イベント(ENOENT 等は非同期で届く。同期例外と両方捕まえる)
242
+ proc.on('error', (err) => {
243
+ if (!job.completed) {
244
+ job.completed = true;
245
+ job.exitCode = -1;
246
+ job.exitSignal = null;
247
+ job.stderrBuf += `\nspawn error: ${err.message}`;
248
+ }
249
+ });
159
250
  // stdin: 指示本文を書いて閉じる
160
251
  if (proc.stdin) {
252
+ // stdin の write エラーは spawn 失敗時に起きうる(握り潰す)
253
+ proc.stdin.on('error', () => { });
161
254
  if (isResume) {
162
255
  proc.stdin.write(RESUME_PROMPT);
163
256
  }
@@ -411,6 +504,17 @@ export async function restoreHeadlessOrphans(cfg, state) {
411
504
  // ★孤児は resume で再開する(元の指示本文ではなく続行プロンプトを渡す)。
412
505
  const cmd = { id: pick.id, body: pick.body };
413
506
  const job = spawnClaudeProcess(cfg, cmd, sessionId, attempt, true);
507
+ if (job === null) {
508
+ await sendCommandEvents(cfg, pick.id, [{
509
+ type: 'error',
510
+ text: 'claude が見つかりません(孤児復元を中止)',
511
+ at: new Date().toISOString(),
512
+ attempt,
513
+ }]);
514
+ state.relay.abandoned.add(pick.id);
515
+ log(`ERROR site=${cfg.site} id=${pick.id} 孤児復元: claude が見つからない → 放棄`);
516
+ return;
517
+ }
414
518
  state.job = job;
415
519
  markDelivered(state.relay, pick.id);
416
520
  state.relay.lastHeartbeatAt = null;
@@ -507,6 +611,18 @@ export async function headlessTick(cfg, state, hooks) {
507
611
  const sessionId = sessionIdForCommand(cmd.id);
508
612
  const isResume = attempt > 1;
509
613
  const job = spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume);
614
+ if (job === null) {
615
+ // ★起動失敗を管制へ知らせる(「起動しました」とは言わない)
616
+ await sendCommandEvents(cfg, cmd.id, [{
617
+ type: 'error',
618
+ text: 'claude が見つかりません(起動できませんでした)',
619
+ at: new Date().toISOString(),
620
+ attempt,
621
+ }]);
622
+ rs.abandoned.add(cmd.id);
623
+ log(`ERROR site=${cfg.site} id=${cmd.id} claude が見つからない → 放棄`);
624
+ return;
625
+ }
510
626
  state.job = job;
511
627
  markDelivered(rs, cmd.id);
512
628
  rs.lastHeartbeatAt = null;
package/dist/relay.js CHANGED
@@ -1225,6 +1225,10 @@ export const RELAY_DELIVERY_NOTE = '(配達役より)上の貼り付けは
1225
1225
  // 実体はこのファイルの下(★relay.ts は試験から直に読まれるので、部品を別ファイルに分けない)。
1226
1226
  /** 処理済みの控えを置く下位フォルダの名前。 */
1227
1227
  export const PROCESSED_DIR = 'processed';
1228
+ /** 中央が恒久的に拒否した報告を退ける下位フォルダの名前。 */
1229
+ export const REJECTED_DIR = 'rejected';
1230
+ /** 同じ報告を拒否されたまま送り直す上限回数。これを超えたら rejected/ へ移す。 */
1231
+ export const REPORT_REJECT_MAX = 3;
1228
1232
  /** 処理済みの控えを残す期間(既定30日)。★未処理はこの期間では消さない。 */
1229
1233
  export const REPORT_KEEP_PROCESSED_MS = 30 * 24 * 60 * 60 * 1000;
1230
1234
  /**
@@ -1348,6 +1352,27 @@ export function markReportProcessed(commandId, env, now = Date.now()) {
1348
1352
  }
1349
1353
  return moved;
1350
1354
  }
1355
+ /**
1356
+ * 中央が恒久的に拒否した報告を【rejected/】へ退ける(★消さない・毎周の送り直しを止めるだけ)。
1357
+ * 返り: 退けられたら true。
1358
+ */
1359
+ export function markReportRejected(commandId, env) {
1360
+ let moved = false;
1361
+ for (const file of pendingCandidates(commandId, env)) {
1362
+ if (!existsSync(file))
1363
+ continue;
1364
+ const dest = path.join(path.dirname(file), REJECTED_DIR, path.basename(file));
1365
+ try {
1366
+ mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
1367
+ renameSync(file, dest);
1368
+ moved = true;
1369
+ }
1370
+ catch {
1371
+ // 移せなかったら触らない
1372
+ }
1373
+ }
1374
+ return moved;
1375
+ }
1351
1376
  /**
1352
1377
  * まだ管制へ渡していない報告の指示ID(古い順)。★生存報告に載せて管制へ知らせるための一覧。
1353
1378
  * - `processed/` の中は数えない(もう渡した物)。
@@ -1428,13 +1453,14 @@ export function isInstructionMissingReport(raw) {
1428
1453
  }
1429
1454
  /**
1430
1455
  * 未処理の報告ファイルの全文を中央へ送り、受け取られた物だけ処理済みにする。
1431
- * 返り: 送った件数・処理済みにした件数・未処理のまま残した件数。
1456
+ * 返り: 送った件数・処理済みにした件数・未処理のまま残した件数・拒否で退けた件数。
1432
1457
  */
1433
1458
  export async function sendPendingReportFiles(cfg, opts = {}) {
1434
1459
  const env = opts.env;
1435
1460
  const doFetch = opts.fetchImpl ?? fetch;
1436
1461
  const logLine = opts.logLine ?? log;
1437
1462
  const exclude = new Set(opts.exclude ?? []);
1463
+ const rejectCounts = opts.rejectCounts ?? new Map();
1438
1464
  const items = [];
1439
1465
  for (const id of pendingReportIds(env, REPORT_FILES_MAX_PER_ROUND).ids) {
1440
1466
  for (const file of pendingCandidates(id, env)) {
@@ -1452,6 +1478,7 @@ export async function sendPendingReportFiles(cfg, opts = {}) {
1452
1478
  let sent = 0;
1453
1479
  let processed = 0;
1454
1480
  let kept = 0;
1481
+ let rejected = 0;
1455
1482
  for (let i = 0; i < items.length; i += REPORT_FILES_BATCH) {
1456
1483
  const chunk = items.slice(i, i + REPORT_FILES_BATCH);
1457
1484
  let results = null;
@@ -1484,20 +1511,40 @@ export async function sendPendingReportFiles(cfg, opts = {}) {
1484
1511
  const r = results?.[k];
1485
1512
  if (r && r.ok === true && markReportProcessed(chunk[k].id, env)) {
1486
1513
  processed++;
1514
+ rejectCounts.delete(chunk[k].id);
1487
1515
  }
1488
1516
  else {
1489
- kept++;
1490
- if (r) {
1517
+ if (r && r.ok === false) {
1518
+ // ★中央が拒否した(不完全な便ID 等)。連続拒否回数を数え、上限を超えたら rejected/ へ退ける。
1519
+ const count = (rejectCounts.get(chunk[k].id) ?? 0) + 1;
1520
+ rejectCounts.set(chunk[k].id, count);
1491
1521
  const why = String(r.reason ?? r.error ?? '理由なし').slice(0, 200);
1492
- logLine(`site=${cfg.site} id=${chunk[k].id} の報告ファイルは受け取られませんでした(未処理のまま残す): ${why}`);
1522
+ if (count >= REPORT_REJECT_MAX) {
1523
+ if (markReportRejected(chunk[k].id, env)) {
1524
+ rejected++;
1525
+ rejectCounts.delete(chunk[k].id);
1526
+ logLine(`site=${cfg.site} id=${chunk[k].id} の報告ファイルは ${count} 回拒否されたため退けました: ${why}`);
1527
+ }
1528
+ else {
1529
+ kept++;
1530
+ logLine(`site=${cfg.site} id=${chunk[k].id} の報告ファイルは受け取られませんでした(未処理のまま残す): ${why}`);
1531
+ }
1532
+ }
1533
+ else {
1534
+ kept++;
1535
+ logLine(`site=${cfg.site} id=${chunk[k].id} の報告ファイルは受け取られませんでした(${count}/${REPORT_REJECT_MAX}回目・未処理のまま残す): ${why}`);
1536
+ }
1537
+ }
1538
+ else {
1539
+ kept++;
1493
1540
  }
1494
1541
  }
1495
1542
  }
1496
1543
  }
1497
1544
  if (items.length > 0) {
1498
- logLine(`site=${cfg.site} 報告ファイル: 送った ${sent} 件・処理済み ${processed} 件・未処理のまま ${kept} 件`);
1545
+ logLine(`site=${cfg.site} 報告ファイル: 送った ${sent} 件・処理済み ${processed} 件・未処理のまま ${kept} 件${rejected > 0 ? `・退けた ${rejected} 件` : ''}`);
1499
1546
  }
1500
- return { sent, processed, kept };
1547
+ return { sent, processed, kept, rejected };
1501
1548
  }
1502
1549
  /**
1503
1550
  * 古い控えを片付ける。★消すのは `processed/` の中だけ(未処理は何日たっても消さない)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.55",
3
+ "version": "0.12.57",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {