@tyhld/conductor 0.12.84 → 0.12.86

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
@@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url';
15
15
  import os from 'node:os';
16
16
  import path from 'node:path';
17
17
  import { RELAY_FETCH_TIMEOUT_MS, createRelayState, deliveryReport, parseAgentKind, reconnectDelayMs, relayTick, tmuxHasSession, pendingReportIds, sendPendingReportFiles, sweepProcessedReports, } from './relay.js';
18
- import { createHeadlessState, headlessNextDelayMs, headlessTick, headlessTrackedCommandIds, parseRunnerMode, } from './headless.js';
18
+ import { createHeadlessState, headlessNextDelayMs, headlessTick, headlessTrackedCommandIds, evaluateClaudePin, lastClaudePinStatus, parseRunnerMode, } from './headless.js';
19
19
  import { runViewCommand } from './view.js';
20
20
  import { createRelayRunner } from './relay-runner.js';
21
21
  import { installProcessGuards } from './process-guard.js';
@@ -24,6 +24,9 @@ import { startEar } from './ear.js';
24
24
  import { loadEarConfig } from './ear-routing.js';
25
25
  import { ensureEnvFilePermissions } from './env-file-perm.js';
26
26
  import { createCentralPorts } from './build-central.js';
27
+ import { drainReport, readDrain } from './autoupdate-flags.js';
28
+ import { runSelfcheckCommand } from './selfcheck.js';
29
+ import { runAutoupdateCommand } from './autoupdate.js';
27
30
  import { BUILD_NUDGE_NAME, REQUIRED_ENV, createBuildRunner, createMemoStore, loadBuildRunnerConfig, } from './build-runner.js';
28
31
  /**
29
32
  * relay の状態の参照(生存報告に「いま配れるか」を載せるため)。
@@ -199,6 +202,14 @@ async function sendReport(cfg) {
199
202
  if (d.waitingSec !== null)
200
203
  body.waitingSec = d.waitingSec;
201
204
  }
205
+ // ★入れ替え待ちの札と Claude Code の指名(便 b7bd5e43)。どちらも【在るときだけ】載せる
206
+ // =札も指名も無い機の本文は今までと1バイトも変わらない。
207
+ const drain = drainReport(readDrain());
208
+ if (drain)
209
+ Object.assign(body, drain);
210
+ const pin = process.env.CONDUCTOR_CLAUDE_BIN ? lastClaudePinStatus() : evaluateClaudePin().status;
211
+ if (pin.state !== 'none')
212
+ body.claudePin = pin;
202
213
  // ★未処理の報告(まだ管制へ渡せていない控え)の指示IDを載せる(便 57c04e6d)。
203
214
  // 1件も無ければ載せない=今までと同じ本文。多すぎるときは上限までにして総数を添える。
204
215
  const pending = pendingReportIds();
@@ -489,6 +500,9 @@ function printUsage() {
489
500
  ' conductor build-runner この機に1本の「司令塔」(試作の段取り役)',
490
501
  ' conductor setup <現場名> [フォルダ] [--profile <default|proto>] 采配くんをこのPCに設置する(初回の1回だけ)',
491
502
  ' conductor view <現場名> [--once] [--detail] その現場の今を見るだけの画面(kc の実体)',
503
+ ' conductor selfcheck [--trigger <daily|after-conductor|after-claude|after-rollback|manual|candidate>] [--json] [--no-send]',
504
+ ' この機の自己診断(副作用なし・結果は手元の控えと管制へ)',
505
+ ' conductor autoupdate [--boot] 自社PCの更新係(鍵が無い機では何もしない・systemd から呼ぶ)',
492
506
  '',
493
507
  '見るだけの画面(conductor view = kc 現場名):',
494
508
  ' いま職人が何をしているかを、やさしい日本語で見るだけの画面です。',
@@ -574,6 +588,14 @@ async function main() {
574
588
  // ★引数はそのまま setup.sh へ渡す(judgement は全部インストーラ側)。
575
589
  runSetupCommand(process.argv.slice(3));
576
590
  break;
591
+ case 'selfcheck':
592
+ // ★自己診断(便 b7bd5e43・A1)。副作用なし(commit・push・PR・合流・配備・公開をしない)。
593
+ process.exit(await runSelfcheckCommand(process.argv.slice(3)));
594
+ break;
595
+ case 'autoupdate':
596
+ // ★更新係(便 29babc5d・A3)。鍵(~/.tyhld/bin/autoupdate/enabled)が無い機では何もしない。
597
+ process.exit(await runAutoupdateCommand(process.argv.slice(3)));
598
+ break;
577
599
  case 'view':
578
600
  // ★見るだけの画面。職人も tmux も relay も起動せず、入力も受け付けない。
579
601
  // 既定は「開いたまま流れ続ける」ので、Ctrl+C まで戻らない(--once は1回で戻る)。
package/dist/headless.js CHANGED
@@ -19,7 +19,8 @@ import { createInterface } from 'node:readline';
19
19
  import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
20
20
  import path from 'node:path';
21
21
  import os from 'node:os';
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, recoverStuckProcessedReports, reconnectDelayMs, selectOrphanToRestore, sendHeartbeat, } from './relay.js';
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, noteDrain, noteReadiness, patchStatus, recoverStuckProcessedReports, reconnectDelayMs, selectOrphanToRestore, sendHeartbeat, } from './relay.js';
23
+ import { claudePinPath, readClaudePinLine, } from './autoupdate-flags.js';
23
24
  const log = (msg) => {
24
25
  console.log(`[headless] ${msg}`);
25
26
  };
@@ -352,6 +353,64 @@ export function claudeUpdateHint(bin) {
352
353
  }
353
354
  return `入れ方を見分けられませんでした。ネイティブ導入なら "${bin}" update / npm 導入なら npm install -g @anthropic-ai/claude-code@latest`;
354
355
  }
356
+ // ── Claude Code の指名(~/.tyhld/bin/autoupdate/claude-pin・便 b7bd5e43) ─────
357
+ //
358
+ // 【なぜ要るか(設計 3ed9e2b2 の発見4)】中継はいちばん新しい claude を選ぶ。2つ入っている機では、
359
+ // 更新係が片方を元の版へ戻しても、もう片方の新しい版が選ばれて戻らない。更新係が「この実体を
360
+ // 使う」と指名できるようにする。★置くのは更新係(~/.tyhld/bin の下=番人がどの書き方でも止める=職人は書けない)。
361
+ // 【決め】指名が無い機は今までと1つも変わらない。CONDUCTOR_CLAUDE_BIN の指定は今までどおり最優先。
362
+ // 指名の実体が無い・読めない・最低版より古いときは、今までの選び方に戻り、その理由を管制へ届ける。
363
+ const CLAUDE_PIN_NONE = { state: 'none', pinned: null, reason: null, version: null };
364
+ /** 直近に resolveClaudeBin が決めた指名の扱い(生存報告と自己診断が読む)。 */
365
+ let lastClaudePin = CLAUDE_PIN_NONE;
366
+ export function lastClaudePinStatus() {
367
+ return lastClaudePin;
368
+ }
369
+ /** 指名を確かめた結果の控え(★同じ指名・同じ実体なら --version を打ち直さない)。 */
370
+ let pinCache = null;
371
+ /**
372
+ * 指名を確かめる。info が在れば指名どおりの claude を使ってよい。
373
+ * ★指名が無ければ { info: null, status: none }=呼び出し側は今までの選び方をそのまま使う。
374
+ */
375
+ export function evaluateClaudePin(file = claudePinPath()) {
376
+ const line = readClaudePinLine(file);
377
+ if (line === undefined)
378
+ return { info: null, status: CLAUDE_PIN_NONE };
379
+ const fallback = (reason, pinned, version = null) => ({
380
+ info: null,
381
+ status: { state: 'fallback', pinned, reason: `${reason}。今までの選び方(いちばん新しい版)に戻りました`, version },
382
+ });
383
+ if (line === null)
384
+ return fallback('指名のファイルを読めません', null);
385
+ if (line === '')
386
+ return fallback('指名のファイルが空です', null);
387
+ if (!path.isAbsolute(line))
388
+ return fallback(`指名が絶対パスではありません(${line})`, line);
389
+ let key = '';
390
+ try {
391
+ const st = statSync(file);
392
+ const target = statSync(line);
393
+ key = `${file}:${st.mtimeMs}:${line}:${target.mtimeMs}:${target.size}`;
394
+ }
395
+ catch {
396
+ return fallback(`指名の claude がありません(${line})`, line);
397
+ }
398
+ if (pinCache && pinCache.key === key)
399
+ return pinCache.result;
400
+ const version = getClaudeVersion(line);
401
+ let result;
402
+ if (version === null) {
403
+ result = fallback(`指名の claude の版を読めません(${line})`, line);
404
+ }
405
+ else if (compareVersions(version, CLAUDE_MIN_VERSION) < 0) {
406
+ result = fallback(`指名の claude が最低版より古い(${line} = ${version}、必要な版 ${CLAUDE_MIN_VERSION} 以上)`, line, version);
407
+ }
408
+ else {
409
+ result = { info: { bin: line, version }, status: { state: 'used', pinned: line, reason: null, version } };
410
+ }
411
+ pinCache = { key, result };
412
+ return result;
413
+ }
355
414
  /** ホームの外に在る、claude の既知の置き場(既定の候補)。 */
356
415
  export const CLAUDE_SYSTEM_BIN_DIRS = ['/usr/local/bin', '/usr/bin'];
357
416
  /** claude の実行ファイルの既知の候補を返す(新しい node の版から順)。 */
@@ -392,6 +451,8 @@ function claudeBinCandidates(systemDirs) {
392
451
  *
393
452
  * 探す順:
394
453
  * 1. 環境変数 CONDUCTOR_CLAUDE_BIN(テスト用の差し替え口)→ 指定があればそれだけ
454
+ * 1b. 指名(~/.tyhld/bin/autoupdate/claude-pin)が在り、使える(在る・版が読める・最低版以上)ならそれ
455
+ * ★使えなければ黙って別の版を使わず、理由を lastClaudePinStatus() に残して 2 へ
395
456
  * 2. 候補のうち実在する物の版を確かめ、いちばん新しい版を使う
396
457
  * 3. 版を確かめられない物は後回し(版が分かる物が1つもないときだけ使う)
397
458
  *
@@ -401,10 +462,21 @@ function claudeBinCandidates(systemDirs) {
401
462
  export function resolveClaudeBin(systemDirs = CLAUDE_SYSTEM_BIN_DIRS) {
402
463
  const override = process.env.CONDUCTOR_CLAUDE_BIN;
403
464
  if (override) {
465
+ // ★今までどおり指定が最優先。指名が在っても見ない(在ることだけ管制へ届ける)。
466
+ lastClaudePin = readClaudePinLine() === undefined ? CLAUDE_PIN_NONE : {
467
+ state: 'override', pinned: null, version: null,
468
+ reason: 'CONDUCTOR_CLAUDE_BIN の指定があるので、指名は使っていません',
469
+ };
404
470
  if (!existsSync(override))
405
471
  return null;
406
472
  return { bin: override, version: getClaudeVersion(override) };
407
473
  }
474
+ // ★指名(~/.tyhld/bin/autoupdate/claude-pin)が在れば最初に選ぶ(便 b7bd5e43・A2)。
475
+ // 使えないときは黙って別の版を使わず、今までの選び方に戻ったことと理由を残す(管制へ届く)。
476
+ const pinned = evaluateClaudePin();
477
+ lastClaudePin = pinned.status;
478
+ if (pinned.info)
479
+ return pinned.info;
408
480
  const existing = claudeBinCandidates(systemDirs).filter(c => existsSync(c));
409
481
  if (existing.length === 0)
410
482
  return null;
@@ -838,6 +910,11 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
838
910
  return null;
839
911
  }
840
912
  const { bin: claudeBin, version: claudeVersion } = resolved;
913
+ const pin = lastClaudePinStatus();
914
+ if (pin.state === 'fallback')
915
+ log(`WARN Claude Code の指名を使えません: ${pin.reason}`);
916
+ else if (pin.state === 'used')
917
+ log(`Claude Code の指名どおりに使います: ${pin.pinned}`);
841
918
  // ── 最低版チェック ──
842
919
  if (claudeVersion && compareVersions(claudeVersion, CLAUDE_MIN_VERSION) < 0) {
843
920
  // ★候補のうちいちばん新しい物でも最低版より古い=全部が古いときだけここに来る
@@ -1569,6 +1646,12 @@ export async function headlessTick(cfg, state, hooks) {
1569
1646
  `自動再開しました (session=${sessionId}, attempt ${retry.attempt}/${HEADLESS_MAX_ATTEMPTS})`);
1570
1647
  return;
1571
1648
  }
1649
+ // ── 入れ替え待ちの札(便 b7bd5e43・A2)──
1650
+ // 札が在るあいだは新しい便を【取りに行かない】。管制に積まれた便はそのまま待つ=
1651
+ // 失われない・失敗にならない・試行回数も配達回数も使わない。札が外れたら次の周で今までどおり取る。
1652
+ // ★走っている便(state.job)と自動再開(pendingRetry)は上で済んでいる=止めない。
1653
+ if (!noteDrain(rs, cfg.site))
1654
+ return;
1572
1655
  // ── 新規指示の取得 ──
1573
1656
  const cmd = await fetchCommand(cfg);
1574
1657
  if (!cmd) {
package/dist/relay.js CHANGED
@@ -231,6 +231,7 @@ export function createRelayState() {
231
231
  running: false,
232
232
  restored: false,
233
233
  lastHalted: null,
234
+ lastDrain: null,
234
235
  lastSkippedId: null,
235
236
  skipCount: 0,
236
237
  skipWarned: false,
@@ -245,6 +246,53 @@ export function createRelayState() {
245
246
  readiness: createReadiness(),
246
247
  };
247
248
  }
249
+ // ── 入れ替え待ちの札(便 b7bd5e43・設計 3ed9e2b2 の A2)─────────────────────
250
+ // 置き場の決め方と理由は src/autoupdate-flags.ts の冒頭(★~/.tyhld/bin の下=職人は書けない)。
251
+ // ★本体をここに置くのは、relay.ts を試験が src から直接読み込むため(ほかの src を import しない)。
252
+ /** 札と指名の置き場(★番人がどの書き方でも書き込みを止める ~/.tyhld/bin の下)。 */
253
+ export function autoupdateDir() {
254
+ return path.join(os.homedir(), '.tyhld', 'bin', 'autoupdate');
255
+ }
256
+ /** 札の場所(★置くのは更新係。中身は読まない=在るか無いかだけで決める)。 */
257
+ export function drainFilePath() {
258
+ return process.env.CONDUCTOR_DRAIN_FILE || path.join(autoupdateDir(), 'drain');
259
+ }
260
+ /**
261
+ * 札を読む。★読めない(権限など)ときも「在る」と扱う=迷ったら新しい便を取らない側。
262
+ * 便は管制で待つだけで、失われない・失敗にならない・試行回数も使わない。
263
+ */
264
+ export function readDrain(file = drainFilePath()) {
265
+ try {
266
+ const st = statSync(file);
267
+ return { active: true, sinceMs: st.mtimeMs };
268
+ }
269
+ catch (e) {
270
+ if (e?.code === 'ENOENT')
271
+ return { active: false, sinceMs: null };
272
+ return { active: true, sinceMs: null };
273
+ }
274
+ }
275
+ /**
276
+ * 入れ替え待ちの札を見る(便 b7bd5e43・A2)。返り true=新しい便を取りに行ってよい。
277
+ * ★画面あり・画面なしの両方式が、新しい便を取る直前にこれを呼ぶ(走っている便には効かない)。
278
+ * ★置かれた/外れたの変わり目だけ1行出す。
279
+ */
280
+ export function noteDrain(state, site, now = Date.now()) {
281
+ const d = readDrain();
282
+ if (d.active) {
283
+ if (state.lastDrain !== true) {
284
+ log(`site=${site} 入れ替え待ちの札があるため、新しい便を取りません(走っている便は止めません・積まれた便は管制で待ちます)`);
285
+ }
286
+ state.lastDrain = true;
287
+ noteReadiness(state, 'draining', null, now);
288
+ return false;
289
+ }
290
+ if (state.lastDrain === true) {
291
+ log(`site=${site} 入れ替え待ちの札が外れました。便の受け取りを再開します`);
292
+ }
293
+ state.lastDrain = false;
294
+ return true;
295
+ }
248
296
  /** 「指示は在るのに配れていない」状態(この間が未配達の時間)。 */
249
297
  const BLOCKED_STATES = new Set([
250
298
  'busy',
@@ -3512,6 +3560,9 @@ export async function relayTick(cfg, state, hooks) {
3512
3560
  log('停止が解除されました。relayを再開します');
3513
3561
  }
3514
3562
  state.lastHalted = false;
3563
+ // ── 入れ替え待ちの札(便 b7bd5e43)──札が在るあいだは新しい便を取りに行かない。
3564
+ if (!noteDrain(state, cfg.site))
3565
+ return;
3515
3566
  // ── 新規指示の取得 ──
3516
3567
  const cmd = await fetchCommand(cfg);
3517
3568
  if (!cmd) {