@tyhld/conductor 0.12.100 → 0.12.102

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.
@@ -252,33 +252,82 @@ function installConductor(d, version, offline) {
252
252
  return { ok: false, detail: `npm install の終了 ${r.status}・入っている版 ${now ?? '不明'}(${(r.error ?? r.stderr).trim().slice(-200)})` };
253
253
  }
254
254
  /**
255
- * 更新係・自己診断・常駐の入口を、入れ替えた配布物から写し直す(★在る物だけ・原子的に置き換える)。
255
+ * 入れ替え後に揃える物(配布物の中の元 → 置き場 → 実行権を付けるか)。
256
+ * ★`dir` は ~/.tyhld の下の置き場の名前。番人が職人の書き込みを止める場所なので、
257
+ * ここを書けるのは更新係・設置台本(人が打つ物)だけ。
258
+ */
259
+ const ENTRY_SYNC = [
260
+ { from: 'scripts/tyhld-autoupdate.sh', dir: 'bin', to: 'tyhld-autoupdate', exe: true },
261
+ { from: 'scripts/tyhld-selfcheck.sh', dir: 'bin', to: 'tyhld-selfcheck', exe: true },
262
+ { from: 'scripts/dist-source.sh', dir: 'bin', to: 'dist-source.sh', exe: false },
263
+ { from: 'scripts/conductor-run.sh', dir: 'bin', to: 'conductor-run', exe: true },
264
+ { from: 'scripts/hooks/skill_sync_notify.py', dir: 'bin', to: 'skill_sync_notify.py', exe: false },
265
+ // ★Codex の完了の受け口(便 08af46e0)。
266
+ // 【なぜここで揃えるか】受け口の置き場(~/.tyhld/hooks/codex-notify.py)は
267
+ // ~/.codex/config.toml の notify が【絶対パス】で指す1か所。版が上がって中身が変わっても、
268
+ // 各PCで人が設置台本(install-codex-hooks.sh)を打つまで古い受け口が残り、
269
+ // 新しい配り役が読む受け皿に完了の知らせが入らない。
270
+ // 【広げないための決め】★既に受け口が置いてあるPCだけに写す(下の `not-installed`)。
271
+ // 未設置のPC(Codex を使っていないPC)には1バイトも置かない。
272
+ // ★~/.codex/hooks.json と ~/.codex/config.toml には触らない(設定を変える道は、
273
+ // 今までどおり人が設置台本を打つ)。ここで揃えるのは【受け口のファイルだけ】。
274
+ { from: 'scripts/codex/codex-notify.py', dir: 'hooks', to: 'codex-notify.py', exe: true },
275
+ ];
276
+ /** 中身が1バイトも違わないか(★同じなら書き換えない=毎回の入れ替えで日付だけ動かない)。 */
277
+ function sameContent(a, b) {
278
+ try {
279
+ return readFileSync(a).equals(readFileSync(b));
280
+ }
281
+ catch {
282
+ return false;
283
+ }
284
+ }
285
+ /**
286
+ * 更新係・自己診断・常駐の入口と Codex の完了の受け口を、入れ替えた配布物から写し直す
287
+ * (★在る物だけ・原子的に置き換える)。
256
288
  * 常駐の入口(conductor-run・dist-source.sh・skill_sync_notify.py)は毎時同期でも揃うが、
257
289
  * 起こし直す前に揃えておく(新しい配布物の探し方で起きる)。
258
290
  */
259
291
  export function refreshEntries(home, distRoot) {
260
- const bin = path.join(home, '.tyhld', 'bin');
261
- const pairs = [
262
- ['scripts/tyhld-autoupdate.sh', 'tyhld-autoupdate', true],
263
- ['scripts/tyhld-selfcheck.sh', 'tyhld-selfcheck', true],
264
- ['scripts/dist-source.sh', 'dist-source.sh', false],
265
- ['scripts/conductor-run.sh', 'conductor-run', true],
266
- ['scripts/hooks/skill_sync_notify.py', 'skill_sync_notify.py', false],
267
- ];
268
292
  const out = [];
269
- for (const [from, to, exe] of pairs) {
293
+ for (const { from, dir, to, exe } of ENTRY_SYNC) {
270
294
  const src = path.join(distRoot, from);
271
- const dst = path.join(bin, to);
272
- if (!existsSync(src) || !existsSync(dst))
295
+ const dst = path.join(home, '.tyhld', dir, to);
296
+ if (!existsSync(src)) {
297
+ out.push({ name: to, outcome: 'no-source' });
298
+ continue;
299
+ }
300
+ // ★既に置いてある物だけを揃える(未設置のPCには新しく置かない)。
301
+ if (!existsSync(dst)) {
302
+ out.push({ name: to, outcome: 'not-installed' });
273
303
  continue;
304
+ }
305
+ if (sameContent(src, dst)) {
306
+ out.push({ name: to, outcome: 'same' });
307
+ continue;
308
+ }
274
309
  copyFileSync(src, `${dst}.tmp`);
275
310
  if (exe)
276
311
  chmodSync(`${dst}.tmp`, 0o755);
277
312
  renameSync(`${dst}.tmp`, dst);
278
- out.push(to);
313
+ out.push({ name: to, outcome: 'synced' });
279
314
  }
280
315
  return out;
281
316
  }
317
+ /** 揃えた結果を1行の日本語にする(★記録に残して、あとから何をしたか読めるようにする)。 */
318
+ export function describeEntrySync(results) {
319
+ const label = {
320
+ synced: '揃えた',
321
+ same: '揃える必要が無かった',
322
+ 'not-installed': '置いていないので何もしなかった',
323
+ 'no-source': '配布物に元が無かった',
324
+ };
325
+ const parts = ['synced', 'same', 'not-installed', 'no-source']
326
+ .map((k) => ({ k, names: results.filter((r) => r.outcome === k).map((r) => r.name) }))
327
+ .filter((g) => g.names.length > 0)
328
+ .map((g) => `${label[g.k]}=${g.names.join(',')}`);
329
+ return `入口と受け口の同期: ${parts.length > 0 ? parts.join('/') : '対象なし'}`;
330
+ }
282
331
  /** 常駐(conductor@*・耳・司令塔)の一覧。 */
283
332
  function relayUnits(d) {
284
333
  const r = d.exec('systemctl', ['--user', 'list-units', 'conductor@*.service', 'conductor-ear.service', 'conductor-build-runner.service', '--all', '--no-legend', '--plain'], { timeoutMs: 15_000 });
@@ -410,7 +459,7 @@ async function rollBack(d, s, back) {
410
459
  if (comp === 'conductor') {
411
460
  const r = installConductor(d, back, true);
412
461
  d.log(r.detail);
413
- refreshEntries(d.home, d.distRoot);
462
+ d.log(describeEntrySync(refreshEntries(d.home, d.distRoot)));
414
463
  d.log(await restartRelays(d, back));
415
464
  }
416
465
  else {
@@ -471,6 +520,40 @@ function waitingForHuman(d, now) {
471
520
  detail: `自己診断を測り直しましたが、まだ通りません(${r.failedKeys.join(',') || '不明'}${r.humanOnly ? '・人の操作待ち' : ''})`,
472
521
  };
473
522
  }
523
+ /**
524
+ * 最後の自己診断が「仕組みの異常(🟡・fault)」で落ちたままなら、HELD_RECHECK_MS ごとに測り直す(便 cbc27db6)。
525
+ *
526
+ * 【なぜ要るか(実物 2026-10-05 00:19Z・メインPC)】
527
+ * スリープから起きた直後の毎日の自己診断で、「管制との通信」の1行だけが時間切れで落ちた
528
+ * (ほかの項目は全部通っていた)=回線が戻りきる前の、その場かぎりの失敗。
529
+ * ところがこの FAIL は【次の毎日の自己診断(翌日)まで残る】。そのあいだ管制は🟡のままで、
530
+ * この機は後続の順番から外れ、新しい版を受け取れない。人が手で流し直すまで戻らなかった。
531
+ * =人の手で直すのではなく、更新係が測り直して、通ったら自分で元に戻れるようにする(根治)。
532
+ *
533
+ * 【緩めていない所】
534
+ * ★合否の決め方は1つも変えない。通らなければ今までどおり FAIL のまま(管制が🟡にし、順番から外す)。
535
+ * ★入れ替えの直後(after-conductor・after-claude)の FAIL には当てない。
536
+ * あちらは phase が swapped / held で先に処理されるので、ここまで来ない(戻す → 自己診断のまま)。
537
+ * ★測り直すたびに小さな試験の費用がかかるので、間隔は held の測り直しと同じ(それより短くしない)。
538
+ * ★常駐がまだ起きていない起動時(boot)は測らない(常駐が動いていない=落ちて当たり前)。
539
+ * ★通ったことは自己診断自身が管制へ届ける(更新係は届け直さない)。
540
+ */
541
+ function recheckAfterFault(d, now) {
542
+ if (d.boot)
543
+ return '';
544
+ const last = readLastSelfcheck(d.home);
545
+ if (!last || last.pass || last.humanOnly)
546
+ return '';
547
+ const age = last.finishedAt === null ? Infinity : now.getTime() - last.finishedAt;
548
+ if (age < HELD_RECHECK_MS)
549
+ return '';
550
+ const r = d.runSelfcheck('daily');
551
+ const detail = r.pass
552
+ ? '仕組みの異常(🟡)で落ちていた自己診断を測り直したら通りました(管制へも届きました)'
553
+ : `仕組みの異常(🟡)の自己診断を測り直しましたが、まだ通りません(${r.failedKeys.join(',') || '不明'})`;
554
+ d.log(detail);
555
+ return detail;
556
+ }
474
557
  // ─────────────────────────────────────────────────────────────────────────────
475
558
  // 1回ぶん
476
559
  // ─────────────────────────────────────────────────────────────────────────────
@@ -530,6 +613,10 @@ export async function runUpdater(d) {
530
613
  // ③-2 人の操作待ち(🔵)で自己診断が落ちたままなら、測り直してから先へ進む(便 4cf71dde)。
531
614
  // ★落ちたまま入れ替えると、同じ理由で落ちて戻し、版に疑いが付く(実物 2026-10-03 02:43Z・ノートPC)。
532
615
  const human = waitingForHuman(d, now);
616
+ // ③-3 仕組みの異常(🟡)で自己診断が落ちたままなら、held と同じ間隔で測り直す(便 cbc27db6)。
617
+ // ★その場かぎりの時間切れで落ちた FAIL が翌日まで残り、この機が順番から外れたままになるのを直す。
618
+ // ★入れ替えるかどうかの決め方は1つも変えない(通らなければ今までどおり FAIL のまま)。
619
+ const refault = recheckAfterFault(d, now);
533
620
  // ④ どの部品を動かすか(★conductor の目標が今と違うあいだは Claude Code を動かさない)。
534
621
  const claudeNow = d.resolveClaude();
535
622
  let comp = null;
@@ -565,7 +652,8 @@ export async function runUpdater(d) {
565
652
  // ★後続の機は候補を探さない・候補の自己診断を流さない(役は管制が決める・欄が無ければ今までどおり)。
566
653
  if (target.role !== 'follower')
567
654
  await reportCandidates(d, s, installed, claudeNow?.version ?? null);
568
- return done(d, s, 'none', `目標の版と同じです(conductor ${installed ?? '不明'}・Claude Code ${claudeNow?.version ?? '不明'})`);
655
+ const same = `目標の版と同じです(conductor ${installed ?? '不明'}・Claude Code ${claudeNow?.version ?? '不明'})`;
656
+ return done(d, s, 'none', refault ? `${same}。${refault}` : same);
569
657
  }
570
658
  if (human.waiting) {
571
659
  removeDrain(d, s);
@@ -615,7 +703,7 @@ export async function runUpdater(d) {
615
703
  removeDrain(d, s);
616
704
  return done(d, s, 'install-failed', r.detail);
617
705
  }
618
- refreshEntries(d.home, d.distRoot);
706
+ d.log(describeEntrySync(refreshEntries(d.home, d.distRoot)));
619
707
  d.log(await restartRelays(d, want));
620
708
  }
621
709
  else {
package/dist/headless.js CHANGED
@@ -19,7 +19,7 @@ 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, ackResumeSignal, buildAnchor, buildSendText, canDeliver, completeWithReport, createRelayState, deliverJobEndFacts, deliveryReport, fetchCommand, fetchCommandStatus, fetchControl, fetchOrphans, flushPendingJobEndFacts, isTerminalCommandStatus, kindOf, markDelivered, noteCentralDown, noteCentralUp, noteDrain, noteReadiness, patchStatus, recoverStuckProcessedReports, reconnectDelayMs, WAIT_CLOSED_STATUS, FINISH_GATE_KINDS, reportDelivery, 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, ackResumeSignal, buildAnchor, buildSendText, canDeliver, completeWithReportOutcome, createRelayState, deliverJobEndFacts, deliveryReport, fetchCommand, fetchCommandStatus, fetchControl, fetchOrphans, flushPendingJobEndFacts, isTerminalCommandStatus, kindOf, markDelivered, noteCentralDown, noteCentralUp, noteDrain, noteReadiness, patchStatus, recoverStuckProcessedReports, reconnectDelayMs, WAIT_CLOSED_STATUS, FINISH_GATE_KINDS, reportDelivery, selectOrphanToRestore, sendHeartbeat, } from './relay.js';
23
23
  import { claudePinPath, readClaudePinLine, } from './autoupdate-flags.js';
24
24
  const log = (msg) => {
25
25
  console.log(`[headless] ${msg}`);
@@ -1471,10 +1471,16 @@ async function checkRunningJob(cfg, state) {
1471
1471
  // ★書けるまでやり直す(便 6ebba0d0)。以前は false のとき patchStatus を1回試し、
1472
1472
  // 成否を見ずに state.job を畳んでいた=誰も done を書いていないのに便を手放していた。
1473
1473
  // 畳まない間は次の便を配らない/管制には生存報告と理由が残る。
1474
- if (await completeWithReport(cfg, job.commandId, log)) {
1474
+ // ★「どう畳んだか」を見て、手番の記録にも実際を書く(便 f20dd761 の候補3)。
1475
+ // 以前は畳めたら必ず outcome=done・detail=「報告まで出しました」だった。
1476
+ // 本番操作の承認待ちで閉じた手番(完了は1バイトも書いていない)まで
1477
+ // 「報告まで出しました」になり、`kc 現場名` が「終わりました」と見せていた。
1478
+ // ★札(outcome)は増やさない。実際を言うのは detail の役目にする。
1479
+ const settle = await completeWithReportOutcome(cfg, job.commandId, log);
1480
+ if (settle.settled) {
1475
1481
  state.doneWriteTries?.delete(job.commandId);
1476
- log(`site=${cfg.site} id=${job.commandId} 完了(exit=0, attempt ${job.attempt})`);
1477
- noteLastJob('done', '報告まで出しました');
1482
+ log(`site=${cfg.site} id=${job.commandId} 畳みます(exit=0, attempt ${job.attempt}・${settle.detail})`);
1483
+ noteLastJob('done', settle.detail);
1478
1484
  state.job = null;
1479
1485
  rs.lastHeartbeatAt = null;
1480
1486
  return;
package/dist/relay.js CHANGED
@@ -3491,6 +3491,8 @@ export async function fetchControl(cfg) {
3491
3491
  return {
3492
3492
  relayHalted: true,
3493
3493
  ok: false,
3494
+ // ★例外で落ちた=時間切れ・回線の失敗。断られた答え(HTTP 非2xx)はここへ来ない(上で返している)。
3495
+ transient: true,
3494
3496
  detail: err instanceof Error ? err.message : String(err),
3495
3497
  };
3496
3498
  }
@@ -3679,8 +3681,16 @@ export async function patchStatus(cfg, id, status, report) {
3679
3681
  * 書けなければ未処理のまま残す=次tickでやり直せるし、管制も生存報告で気づける。
3680
3682
  *
3681
3683
  * 返り: 追跡を畳んでよいか(true = 便の終了状態が確定した/false = 書けていない=次の周でやり直す)。
3684
+ * ★★「どう畳んだか」も要るときは completeWithReportOutcome を呼ぶ(手番の記録に実際を書くため)。
3682
3685
  */
3683
3686
  export async function completeWithReport(cfg, id, logLine = log) {
3687
+ return (await completeWithReportOutcome(cfg, id, logLine)).settled;
3688
+ }
3689
+ /**
3690
+ * 完了を1回で送り、【どう畳んだか】も返す(★中身の説明は上の completeWithReport と同じ)。
3691
+ * 呼び手は settled だけ見てもよいが、手番の記録を書くときは kind・detail も使う。
3692
+ */
3693
+ export async function completeWithReportOutcome(cfg, id, logLine = log) {
3684
3694
  // ★①報告がどの段にあるか+形が整っているか(送る工程の話)。②③の実行可否には使わない。
3685
3695
  // ★形が整っていない報告は「届いた」と数えない(便 461be339)。ファイルは消さず・移さず残す。
3686
3696
  const delivery = reportDelivery(id);
@@ -3697,7 +3707,7 @@ export async function completeWithReport(cfg, id, logLine = log) {
3697
3707
  (phase === 'pending' && !delivery.delivered
3698
3708
  ? `→relayからは送りません(${delivery.note}=未処理のまま残します)`
3699
3709
  : '→relayからは送りません(報告は控えとして処理済みにしました)'));
3700
- return true;
3710
+ return { settled: true, kind: 'already-finished', detail: `管制で既に終わっていました(${already})` };
3701
3711
  }
3702
3712
  if (isWaitClosedCommandStatus(already)) {
3703
3713
  // ★待ちのまま閉じられた便(本番操作の承認待ち・管制 PR #425)。★完了を1バイトも書かない。
@@ -3709,30 +3719,43 @@ export async function completeWithReport(cfg, id, logLine = log) {
3709
3719
  logLine(`site=${cfg.site} id=${id} は待ちのまま閉じられています(status=${already})` +
3710
3720
  '→完了は書きません(本番操作が済めば管制の合図で同じ便の続きから起きます)' +
3711
3721
  (phase === 'none' ? '' : `/報告は手元の控えに残します(${phase})`));
3712
- return true;
3722
+ return {
3723
+ settled: true,
3724
+ kind: 'wait-closed',
3725
+ // ★ここが「報告まで出しました」になっていると、承認を待っている手番が
3726
+ // 「終わりました」と記録される(便 f20dd761 の候補3)。実際を書く。
3727
+ detail: '本番操作の承認待ちのまま閉じました(完了は書いていません。承認が済めば続きから起きます)',
3728
+ };
3713
3729
  }
3714
3730
  if (phase === 'processed') {
3715
3731
  // ★①は済み・②は未了。本文は渡してあるので、状態だけを書く(開発履歴を2件にしない)。
3716
3732
  // 書けなければ false=次の周でやり直す(完了を落とさない)。
3717
- if (!(await patchStatus(cfg, id, 'done')))
3718
- return false;
3733
+ if (!(await patchStatus(cfg, id, 'done'))) {
3734
+ return { settled: false, kind: 'not-written', detail: '便の終了状態を管制へ書けませんでした' };
3735
+ }
3719
3736
  logLine(`site=${cfg.site} id=${id} done(報告は回収の道で渡し済み=本文は添えない)`);
3720
- return true;
3737
+ return { settled: true, kind: 'done', detail: '報告まで出しました' };
3721
3738
  }
3722
3739
  // ★形が整っていない報告は添えない(書きかけを完了の材料にしない)。便の確定は今までどおり行う
3723
3740
  // =現場を占有したままにしない。「報告が届いていない」ことは終了の事実で管制へ届く。
3724
3741
  const report = delivery.delivered && delivery.raw !== null ? parseReport(delivery.raw).body : null;
3725
- if (!(await patchStatus(cfg, id, 'done', report)))
3726
- return false;
3742
+ if (!(await patchStatus(cfg, id, 'done', report))) {
3743
+ return { settled: false, kind: 'not-written', detail: '便の終了状態を管制へ書けませんでした' };
3744
+ }
3727
3745
  if (report !== null)
3728
3746
  markReportProcessed(id); // ★消さずに処理済みへ(同じ報告を二度送らない)
3729
- logLine(`site=${cfg.site} id=${id} done` +
3730
- (report !== null
3731
- ? `(報告添付・${report.length}文字)`
3732
- : phase === 'none'
3733
- ? '(報告ファイルなし=完了のみ)'
3734
- : `(${delivery.note}=添えずに未処理のまま残します)`));
3735
- return true;
3747
+ const how = report !== null
3748
+ ? `(報告添付・${report.length}文字)`
3749
+ : phase === 'none'
3750
+ ? '(報告ファイルなし=完了のみ)'
3751
+ : `(${delivery.note}=添えずに未処理のまま残します)`;
3752
+ logLine(`site=${cfg.site} id=${id} done${how}`);
3753
+ return {
3754
+ settled: true,
3755
+ kind: 'done',
3756
+ // ★報告が無い/形が整っていない回を「報告まで出しました」と言わない。
3757
+ detail: report !== null ? '報告まで出しました' : `完了は書きました${how}`,
3758
+ };
3736
3759
  }
3737
3760
  /**
3738
3761
  * 「実プロセスが無い・報告は渡し済み・管制ではまだ終わっていない」便に、便の終了状態だけを書く
package/dist/selfcheck.js CHANGED
@@ -235,6 +235,12 @@ export function aiKanaMcpState(mcp) {
235
235
  export const MCP_SETTLE_TRIES = 3;
236
236
  export const MCP_SETTLE_WAIT_MS = 5_000;
237
237
  export const MCP_LIST_TIMEOUT_MS = 45_000;
238
+ /**
239
+ * 「緊急停止の読み取り」が時間切れ・回線の失敗で通らなかったとき、読み直すまでの待ち(便 cbc27db6)。
240
+ * ★読み直しは1回だけ(回線が戻るのを待つだけなので、何度も読まない)。
241
+ * ★管制が断った答え(HTTP 401・403・500 など)は読み直さない=この待ちも入らない。
242
+ */
243
+ export const CONTROL_RETRY_WAIT_MS = 5_000;
238
244
  /**
239
245
  * `claude mcp list` の出力を読む(★実機 2026-10-03・Claude Code 2.1.287 の形)。
240
246
  * `claude.ai ai-kana: https://… - ✔ Connected` / `ai-kana: https://… (HTTP) - ! Needs authentication` /
@@ -459,11 +465,30 @@ async function checkCentral(d) {
459
465
  return item;
460
466
  }
461
467
  const site = d.env.CONDUCTOR_SITE || 'selfcheck';
462
- const ctl = await withFetch(d, () => fetchControl({ url, token, machine: d.machine, site }));
468
+ const read = () => withFetch(d, () => fetchControl({ url, token, machine: d.machine, site }));
469
+ let ctl = await read();
470
+ /** 1回目が「その場かぎりの失敗」で通らなかったときの、その理由(読み直していなければ null)。 */
471
+ let firstDetail = null;
472
+ if (!ctl.ok && ctl.transient) {
473
+ // ★時間切れ・回線の失敗のときだけ、少し待って1回だけ読み直す(便 cbc27db6)。
474
+ // 実物 2026-10-05 00:19Z メインPC:スリープから起きた直後の毎日の自己診断で、回線が戻りきる前に
475
+ // 読んで時間切れ(The operation was aborted due to timeout)。ほかは全部通っているのに FAIL になり、
476
+ // 🟡 が次の日の自己診断まで残って、この機が後続の順番から外れた。
477
+ // ★管制が断った答え(HTTP 401・403・500 など)と応答が不正は読み直さない(待っても変わらない)。
478
+ firstDetail = ctl.detail;
479
+ const sleep = d.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
480
+ await sleep(CONTROL_RETRY_WAIT_MS);
481
+ ctl = await read();
482
+ }
483
+ const again = firstDetail === null
484
+ ? ''
485
+ : ctl.ok
486
+ ? `・1回目は通りませんでした(${firstDetail})・2回目で通りました`
487
+ : `・1回目も通りませんでした(${firstDetail})`;
463
488
  if (ctl.ok)
464
- pass(item, `緊急停止の読み取りが通りました(停止中=${ctl.relayHalted ? 'はい' : 'いいえ'})`);
489
+ pass(item, `緊急停止の読み取りが通りました(停止中=${ctl.relayHalted ? 'はい' : 'いいえ'})${again}`);
465
490
  else
466
- fail(item, 'fault', `緊急停止の読み取りが通りません(${ctl.detail})`);
491
+ fail(item, 'fault', `緊急停止の読み取りが通りません(${ctl.detail})${again}`);
467
492
  return item;
468
493
  }
469
494
  /** relay.ts の fetch を試験で差し替えるための包み(globalThis.fetch を一時的に置き換える)。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.100",
3
+ "version": "0.12.102",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -110,22 +110,32 @@ REINSTALL = ('npm install -g @tyhld/conductor '
110
110
  RESYNC = 'APPLY=1 bash "$(npm root -g)/@tyhld/conductor/scripts/update-guard.sh"'
111
111
 
112
112
 
113
- def autoupdate_enabled():
114
- """自動更新が入っている PC か(自社PCの鍵 ~/.tyhld/bin/autoupdate/enabled・便 29babc5d)。"""
115
- return os.path.isfile(os.path.join(os.path.expanduser('~'), '.tyhld', 'bin', 'autoupdate', 'enabled'))
113
+ def autoupdate_enabled(home=None):
114
+ """自動更新が入っている PC か(自社PCの鍵 ~/.tyhld/bin/autoupdate/enabled・便 29babc5d)。
115
+
116
+ 【なぜ住まいを受け取れる形にしたか(便 f20dd761 の重大発見2・便 08af46e0 で直した)】
117
+ 住まいを中で決めていたので、試験が【実機の住まい】を覗いてしまっていた。
118
+ 自動更新が入っているPCでは案内が「次の自動更新でそろいます」に変わるため、
119
+ 同じ試験が機体によって通ったり落ちたりした。呼び手から渡せる形にして、
120
+ 試験は使い捨ての住まいで判定する=どのPCでも同じ結果になる。
121
+ """
122
+ base = os.path.expanduser('~') if home is None else home
123
+ return os.path.isfile(os.path.join(base, '.tyhld', 'bin', 'autoupdate', 'enabled'))
116
124
 
117
125
 
118
- def recovery_steps(reason):
126
+ def recovery_steps(reason, home=None):
119
127
  """うまくいかなかった理由に【合った】手順を返す(便 675e2d05)。
120
128
 
121
129
  【なぜ理由ごとに分けるか(実地 2026-09-20・便 8692c586 の重大発見1)】
122
130
  以前は理由によらず「update-guard を打ってください」の固定文だった。version-drift
123
131
  (入っている版と公開の版が違う)のときにこれを打っても、配り元の確認でまた弾かれる=
124
132
  同じ失敗を繰り返すだけで直らない。先に入れ直しが要る。
133
+
134
+ home … 自動更新の鍵を探す住まい(既定は実機の住まい。試験は使い捨てを渡す)。
125
135
  """
126
136
  if reason in ('flagged-bad', 'flagged-suspect'):
127
137
  # ★管制で不具合・疑いの印が付いた版(便 29babc5d)。入れ直しても同じ版なら直らない。
128
- if autoupdate_enabled():
138
+ if autoupdate_enabled(home):
129
139
  return ['再開のしかた: この PC は自動更新が入っています。管制が直した版を決めれば、'
130
140
  '次の自動更新で入れ替わります(人の操作は要りません)。']
131
141
  return ['再開のしかた: この版には管制で不具合の印が付いています。CC1 に知らせてください'
@@ -134,7 +144,7 @@ def recovery_steps(reason):
134
144
  # ★自動更新が入っている PC は、更新係が管制の決めた版へそろえる(便 29babc5d)。
135
145
  # 人が npm で入れると段階配布の順番が崩れるので、入れ直しを促さない。
136
146
  # ★not-installed・incomplete は配布物そのものが無い=更新係も動けないので、自動更新の有無によらず入れ直す。
137
- if autoupdate_enabled() and reason in ('unpublished', 'deprecated', 'version-drift'):
147
+ if autoupdate_enabled(home) and reason in ('unpublished', 'deprecated', 'version-drift'):
138
148
  return ['再開のしかた: この PC は自動更新が入っています。次の自動更新(管制が決めた版を、'
139
149
  '作業中の便が終わってから入れます)でそろいます。管制の「PCの状態」で確かめてください。']
140
150
  return [f'再開のしかた: この PC で次の順に打ってください。① {REINSTALL}',
@@ -153,7 +163,7 @@ def recovery_steps(reason):
153
163
  return [f'再開のしかた: この PC で次の1行を打ってください。 {RESYNC}']
154
164
 
155
165
 
156
- def build_payload(rec, env=None):
166
+ def build_payload(rec, env=None, home=None):
157
167
  """カードの中身。★何が起きていて、何をすれば直るかを日本語で書く。
158
168
 
159
169
  ★本文は【その回の控え(rec)の実際の値】だけから組み立てる。固定の版番号や、
@@ -165,7 +175,7 @@ def build_payload(rec, env=None):
165
175
  kind = notice_kind(rec)
166
176
  lines = [
167
177
  f'いま止まっているもの: この PC({machine})で、{kind["stopped"]}',
168
- *recovery_steps(reason),
178
+ *recovery_steps(reason, home),
169
179
  '★このカードは管制からは押せません(この PC での操作が要ります)。',
170
180
  f'PC: {machine}/うまくいかなかった理由: {reason}',
171
181
  f'最後に試した時刻: {rec.get("at", "不明")}',
@@ -231,13 +241,15 @@ def _close(cfg, datalink, cards, key, path=None):
231
241
  return True
232
242
 
233
243
 
234
- def run(last_path, cfg=None, datalink=hook_datalink, state_path=None):
244
+ def run(last_path, cfg=None, datalink=hook_datalink, state_path=None, home=None):
235
245
  """返り値: 出したカードのID / None(出さなかった・閉じただけ)。
236
246
 
237
247
  ★失敗の回 … 前に出した札を【閉じてから】出し直す。管制は同じ鍵の再登録を寿命の延長
238
248
  として扱い、本文は最初のままになる(実地 2026-09-20・札に 4日前の版が出ていた)。
239
249
  閉じてから出し直せば、本文は必ずその回の値になる。
240
250
  ★成功の回 … 出していた札を閉じる。直ったのに札が最大60分出たままにならない。
251
+
252
+ home … 自動更新の鍵を探す住まい(既定は実機の住まい。試験は使い捨てを渡す)。
241
253
  """
242
254
  rec = parse_last(last_path)
243
255
  status = rec.get('status')
@@ -256,7 +268,7 @@ def run(last_path, cfg=None, datalink=hook_datalink, state_path=None):
256
268
 
257
269
  # 失敗。★古い本文の札が残っていたら先に閉じる(上書きできない作りへの対処)。
258
270
  _close(cfg, datalink, cards, key, state_path)
259
- reg = datalink.register(cfg, build_payload(rec))
271
+ reg = datalink.register(cfg, build_payload(rec, home=home))
260
272
  if not reg or not reg.get('id'):
261
273
  return None
262
274
  cards[key] = reg['id']
@@ -23,6 +23,12 @@
23
23
  (i) 成功の回は【出していた札を閉じる】=直ったのに札が最大60分出たままにならない。
24
24
  (j) 再開の手順が【うまくいかなかった理由に合っている】=同じ失敗を繰り返させない。
25
25
  版のずれに「配り直してください」とだけ言っても、配り元の確認でまた弾かれる。
26
+
27
+ 【★便 08af46e0 で塞いだ穴(便 f20dd761 の重大発見2)】
28
+ (k) 自動更新が入っているかの判定は【呼び手が渡した住まい】だけを見る。
29
+ 以前は中で実機の住まい(~/.tyhld/bin/autoupdate/enabled)を覗いていたので、
30
+ 自動更新が入ったPCでは案内の文が変わり、(j) の判定が機体ごとに通ったり落ちたりした。
31
+ この試験は使い捨ての住まいを渡すので、どのPCでも同じ結果になる。
26
32
  """
27
33
  import json
28
34
  import os
@@ -279,12 +285,19 @@ with tempfile.TemporaryDirectory() as tmp:
279
285
  # ── (j) 再開の手順が理由に合っている ────────────────────────────────────────
280
286
  print('\n【(j) 再開の手順が、うまくいかなかった理由に合っている】')
281
287
  with tempfile.TemporaryDirectory() as tmp:
282
- def body_for(reason, what='dist-source'):
288
+ # ★自動更新の鍵を探す住まいは、必ず【この回だけの使い捨て】を渡す(便 08af46e0)。
289
+ # 既定は実機の住まい(~/.tyhld/bin/autoupdate/enabled)なので、渡さないと
290
+ # 自動更新が入っているPCでは案内が別の文に変わり、同じ試験が機体によって落ちる
291
+ # (便 f20dd761 の重大発見2)。下の PLAIN_HOME は鍵を置かない=自動更新なしのPC。
292
+ PLAIN_HOME = os.path.join(tmp, 'home-plain')
293
+ os.makedirs(PLAIN_HOME, exist_ok=True)
294
+
295
+ def body_for(reason, what='dist-source', home=PLAIN_HOME):
283
296
  line = (f'2026-09-20T09:00:00+0900 status=failed reason={reason} what={what} '
284
297
  'version=0.12.6 published=0.12.31 root=/n/@tyhld/conductor')
285
298
  link = FakeLink()
286
299
  sn.run(write(tmp, f'{reason}.last', line + '\n'), datalink=link,
287
- state_path=state(tmp, f'{reason}.json'))
300
+ state_path=state(tmp, f'{reason}.json'), home=home)
288
301
  return link.sent[0]['question']
289
302
 
290
303
  drift = body_for('version-drift')
@@ -332,21 +345,33 @@ with tempfile.TemporaryDirectory() as tmp:
332
345
 
333
346
  # ★自動更新が入っている PC(鍵 ~/.tyhld/bin/autoupdate/enabled)は、人に npm で入れさせない
334
347
  # (更新係が管制の決めた版へそろえる。人が入れると段階配布の順番が崩れる)。
335
- old_home = os.environ.get('HOME')
336
- os.environ['HOME'] = tmp
337
- try:
338
- os.makedirs(os.path.join(tmp, '.tyhld', 'bin', 'autoupdate'), exist_ok=True)
339
- write(os.path.join(tmp, '.tyhld', 'bin', 'autoupdate'), 'enabled', 'x\n')
340
- auto = body_for('unpublished')
341
- check_true('自動更新の PC: 次の自動更新でそろうと書く', '次の自動更新' in auto)
342
- check('自動更新の PC: npm で入れさせない', 'npm install -g' in auto, False)
343
- check_true('自動更新の PC・不具合の印: 人の操作は要らないと書く', '人の操作は要りません' in body_for('flagged-suspect'))
344
- check_true('自動更新の PC でも、配布物が無いときは入れ直す', 'npm install -g' in body_for('not-installed'))
345
- finally:
346
- if old_home is None:
347
- os.environ.pop('HOME', None)
348
- else:
349
- os.environ['HOME'] = old_home
348
+ # ★判定する住まいは使い捨てを渡す(実機の住まいを覗かない=どのPCでも同じ結果)。
349
+ AUTO_HOME = os.path.join(tmp, 'home-auto')
350
+ os.makedirs(os.path.join(AUTO_HOME, '.tyhld', 'bin', 'autoupdate'), exist_ok=True)
351
+ write(os.path.join(AUTO_HOME, '.tyhld', 'bin', 'autoupdate'), 'enabled', 'x\n')
352
+ auto = body_for('unpublished', home=AUTO_HOME)
353
+ check_true('自動更新の PC: 次の自動更新でそろうと書く', '次の自動更新' in auto)
354
+ check('自動更新の PC: npm で入れさせない', 'npm install -g' in auto, False)
355
+ check_true('自動更新の PC・不具合の印: 人の操作は要らないと書く',
356
+ '人の操作は要りません' in body_for('flagged-suspect', home=AUTO_HOME))
357
+ check_true('自動更新の PC でも、配布物が無いときは入れ直す',
358
+ 'npm install -g' in body_for('not-installed', home=AUTO_HOME))
359
+
360
+ # ★判定は【渡した住まい】だけを見る=実機の住まいに鍵が在っても無くても結果が変わらない
361
+ # (便 f20dd761 の重大発見2 の根治。ここが穴だと、この試験は機体ごとに答えが変わる)。
362
+ check('鍵の無い住まい: 自動更新なしと判定', sn.autoupdate_enabled(PLAIN_HOME), False)
363
+ check('鍵の在る住まい: 自動更新ありと判定', sn.autoupdate_enabled(AUTO_HOME), True)
364
+ for fake_home in (PLAIN_HOME, AUTO_HOME, tmp):
365
+ old_home = os.environ.get('HOME')
366
+ os.environ['HOME'] = fake_home
367
+ try:
368
+ check(f'実機の住まいを覗かない(HOME={os.path.basename(fake_home)})',
369
+ sn.autoupdate_enabled(PLAIN_HOME), False)
370
+ finally:
371
+ if old_home is None:
372
+ os.environ.pop('HOME', None)
373
+ else:
374
+ os.environ['HOME'] = old_home
350
375
 
351
376
  # ── ★札の覚え書き(壊れていても・書けなくても止まらない)──────────────────
352
377
  print('\n【★札の覚え書き】')