@tyhld/conductor 0.12.92 → 0.12.94

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
@@ -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, buildAnchor, buildSendText, canDeliver, completeWithReport, createRelayState, deliverJobEndFacts, deliveryReport, fetchCommand, fetchCommandStatus, fetchControl, fetchOrphans, flushPendingJobEndFacts, isTerminalCommandStatus, kindOf, markDelivered, noteCentralDown, noteCentralUp, noteDrain, noteReadiness, patchStatus, recoverStuckProcessedReports, reconnectDelayMs, 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, completeWithReport, createRelayState, deliverJobEndFacts, deliveryReport, fetchCommand, fetchCommandStatus, fetchControl, fetchOrphans, flushPendingJobEndFacts, isTerminalCommandStatus, kindOf, markDelivered, noteCentralDown, noteCentralUp, noteDrain, noteReadiness, patchStatus, recoverStuckProcessedReports, reconnectDelayMs, 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}`);
@@ -78,6 +78,36 @@ export function extractProgressLine(text) {
78
78
  }
79
79
  /** resume 時に職人へ渡す続行指示。 */
80
80
  const RESUME_PROMPT = '前回の作業の続きを行ってください。中断された箇所から再開してください。';
81
+ /**
82
+ * 続行の指示を組み立てる。添える物が無ければ、今までと1バイトも同じ文を返す。
83
+ *
84
+ * 【なぜ結果を載せるか】本番操作を待って閉じた便は、起きた瞬間に「何が終わったのか」を
85
+ * 知らないと、同じ待ちをもう一度始めてしまう。だから「前の値 → 後の値」をその場で渡す。
86
+ * ★秘密の値は載せない(載るのは管制が合図に入れた設定値だけ=鍵や合言葉は合図に入れない)。
87
+ */
88
+ export function buildResumePrompt(note) {
89
+ const n = (note ?? '').trim();
90
+ if (n.length === 0)
91
+ return RESUME_PROMPT;
92
+ return `${n}\n\n${RESUME_PROMPT}`;
93
+ }
94
+ /**
95
+ * 再開の合図から「続行の指示に載せる文」を作る。載せる物が無ければ null。
96
+ * 形は取り決め v1 のとおり(1行目=何が終わったか/以後=操作ごとに 前の値 → 後の値)。
97
+ */
98
+ export function resumeNoteFromSignal(signal) {
99
+ if (signal.outcome !== 'succeeded')
100
+ return null;
101
+ const lines = ['本番操作が終わりました。続きを行ってください。'];
102
+ for (const r of signal.results ?? []) {
103
+ const what = r.summary ?? r.kind ?? '本番操作';
104
+ const kindNote = r.summary !== undefined && r.kind !== undefined ? `(${r.kind})` : '';
105
+ const before = r.before ?? '不明';
106
+ const after = r.after ?? '不明';
107
+ lines.push(`・${what}${kindNote}: ${before} → ${after}`);
108
+ }
109
+ return lines.join('\n');
110
+ }
81
111
  /** 実行ログの保存先ルート。 */
82
112
  export const RUNS_DIR = path.join(os.homedir(), '.local', 'state', 'conductor', 'runs');
83
113
  /** セッションありと見なす最低実行時間(ms)。これより短いとセッションファイルが無い可能性が高い。 */
@@ -161,6 +191,9 @@ export function saveLastJob(site, record) {
161
191
  catch (err) {
162
192
  log(`WARN last-job の保存に失敗: ${err instanceof Error ? err.message : String(err)}`);
163
193
  }
194
+ // ★「この便を前回どの試行まで走らせたか」も残す(再開の合図で続きから起こすときに使う)。
195
+ // 直前の1件だけでは、間に別の便が走ると上書きされて照合できなくなる。
196
+ rememberResumable(site, record.commandId, record.attempt, record.body);
164
197
  }
165
198
  /** 直前に終わった仕事を読む。無ければ null。 */
166
199
  export function loadLastJob(site) {
@@ -996,7 +1029,47 @@ function closeLog(ws) {
996
1029
  * 失敗の理由は lastSpawnError に入る。
997
1030
  */
998
1031
  export let lastSpawnError = '';
999
- export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
1032
+ /** 職人の PATH で「古い node が先に来ない」ように見る道具の名前。 */
1033
+ const NODE_TOOLS = ['node', 'npm', 'npx'];
1034
+ /**
1035
+ * 職人(claude -p)の子プロセスへ渡す PATH を作る(便 24c9b6b6・e9276b86)。
1036
+ *
1037
+ * 【なぜ要るか(実物 2026-10-03・TYHLD-OTA 便 f770cd9d/ノートPC 便 4cf71dde)】
1038
+ * systemd の常駐はシェルの PATH を継承しないので、nvm の bin が職人の PATH に無い。
1039
+ * 以前は claude が nvm の bin に居たので、claude の bin を足すだけで npm・npx も一緒に見えていた。
1040
+ * Claude Code を公式の入れ方(~/.local/bin/claude)にそろえた後は、足されるのが ~/.local/bin だけになり、
1041
+ * 職人の bash で `npm: command not found`。node だけは /usr/local/bin/node(nvm への近道)で見えていた。
1042
+ * ★/usr/bin/node(v18)が別に在る機もあるので、PATH の順が崩れると古い node が動く。
1043
+ *
1044
+ * 【決め方】
1045
+ * - 中継を動かしている node(process.execPath=conductor-run が選んだ配布物の node)の bin を必ず足す。
1046
+ * =職人の node・npm・npx は、中継と同じ版になる(その bin に npm・npx が同居する入れ方=nvm 等)。
1047
+ * - claude の bin も足す(便 24c9b6b6)。ただし claude の bin に node・npm・npx のどれかが在るとき
1048
+ * (/usr/bin・nvm の別の版など)は、中継の node の bin をその前に置く=古い node が先に来ない。
1049
+ * claude の bin に node 類が無いとき(~/.local/bin などの公式の入れ方)は、claude の bin を先に置く
1050
+ * =職人の bash の `claude` は中継が選んだ claude のまま。
1051
+ * - 2つは既に PATH に在っても先頭へ寄せる(後ろに残っていると、先に在る古い node が勝つため)。重複させない。
1052
+ * - PATH が空でも動く。
1053
+ */
1054
+ export function buildWorkerPath(currentPath, claudeBin, nodeExecPath = process.execPath, hasTool = (dir, name) => existsSync(path.join(dir, name))) {
1055
+ const nodeDir = path.dirname(nodeExecPath);
1056
+ const claudeDir = path.dirname(claudeBin);
1057
+ let front;
1058
+ if (nodeDir === claudeDir)
1059
+ front = [nodeDir];
1060
+ else if (NODE_TOOLS.some((t) => hasTool(claudeDir, t)))
1061
+ front = [nodeDir, claudeDir];
1062
+ else
1063
+ front = [claudeDir, nodeDir];
1064
+ const rest = (currentPath ?? '').split(':').filter((d) => d !== '' && !front.includes(d));
1065
+ return [...front, ...rest].join(':');
1066
+ }
1067
+ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume,
1068
+ /**
1069
+ * resume のときに続行の指示へ足す文(例「本番操作が終わりました。前の値 → 後の値」)。
1070
+ * ★渡さなければ今までと1バイトも同じ続行の指示になる。
1071
+ */
1072
+ resumeNote) {
1000
1073
  // 前の失敗の理由を持ち越さない(理由の無い失敗に古い理由が付かないように)
1001
1074
  lastSpawnError = '';
1002
1075
  const resolved = resolveClaudeBin();
@@ -1025,15 +1098,9 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
1025
1098
  env.CONDUCTOR_SITE = cfg.site;
1026
1099
  env.CONDUCTOR_MACHINE = cfg.machine;
1027
1100
  env.CONDUCTOR_WORK_DIR = cfg.workDir;
1028
- // ★claude が見つかった bin ディレクトリを PATH の先頭に足す(便 24c9b6b6)。
1029
- // systemd 経由の起動では nvm の bin が PATH に無いため、子プロセスの bash から
1030
- // claude・node・npm・pnpm が command not found になる。resolveClaudeBin() が
1031
- // 信頼したディレクトリだけを足す。重複させない・PATH が空でも動く。
1032
- const claudeBinDir = path.dirname(claudeBin);
1033
- const currentPath = env.PATH ?? '';
1034
- if (!currentPath.split(':').includes(claudeBinDir)) {
1035
- env.PATH = currentPath ? `${claudeBinDir}:${currentPath}` : claudeBinDir;
1036
- }
1101
+ // ★職人の PATH の先頭に、中継を動かしている node の bin と claude の bin を足す
1102
+ // (便 24c9b6b6・e9276b86)。決め方は buildWorkerPath の説明を見ること。
1103
+ env.PATH = buildWorkerPath(env.PATH, claudeBin);
1037
1104
  // ★テスト用: CONDUCTOR_CLAUDE_CONFIG_DIR を設定すると、claude の設定フォルダを差し替える。
1038
1105
  // 本物のログインを壊さずに認証エラーを再現できる(空のフォルダを指す → not logged in)。
1039
1106
  // 本番では設定しない(claude は既定の ~/.claude を使う)。
@@ -1130,7 +1197,7 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
1130
1197
  noteHandoffFailed(`職人の処理へ本文を渡せませんでした(標準入力: ${err.message})`);
1131
1198
  });
1132
1199
  const text = isResume
1133
- ? RESUME_PROMPT
1200
+ ? buildResumePrompt(resumeNote)
1134
1201
  : buildSendText(cmd.body, cfg.site, buildAnchor(cmd.id), kindOf(cfg));
1135
1202
  try {
1136
1203
  // ★書き出しが本当に流れ切ってから 'done' にする(返事を待たずに「渡せた」と言わない)。
@@ -1711,6 +1778,306 @@ export async function restoreHeadlessOrphans(cfg, state) {
1711
1778
  `孤児を復元して再開しました (session=${sessionId}, attempt ${attempt}/${HEADLESS_MAX_ATTEMPTS})`);
1712
1779
  }
1713
1780
  // ─────────────────────────────────────────────────────────────────────────────
1781
+ // 再開の合図(本番操作窓口・再開の取り決め v1)
1782
+ //
1783
+ // 管制が「本番操作が終わった」と知らせてきたときに、待っている【同じ便】を続きから起こす。
1784
+ // ★起こすのは同じ便の続きだけ。新しい便を機械が選ぶ道はここに作らない
1785
+ // (「候補から自動投函はしない」の決まりは1行も変えない)。
1786
+ // ★合図が無いあいだは、ここは何もしない=今までと1バイトも動きが変わらない。
1787
+ // ─────────────────────────────────────────────────────────────────────────────
1788
+ /** 使い終わった合図を覚えておく件数の上限(古い物から落とす)。 */
1789
+ export const RESUME_MEMO_MAX = 200;
1790
+ /**
1791
+ * 中央の便の状態のうち、続きから起こしてよい物(★終端は含めない)。
1792
+ * perm_wait … 人の手番で止まっている(本番操作待ちの便はここで待つ)。この現場を占有し続ける。
1793
+ * sent … 配達済みのまま(管制が戻した・まだ閉じていない)。
1794
+ * ★done / failed / dismissed / queued では起こさない(終わった便・まだ配っていない便を起こさない)。
1795
+ */
1796
+ export const RESUMABLE_CENTRAL_STATUSES = new Set(['perm_wait', 'sent']);
1797
+ /** 「この便は続きから起こせる」の控えを覚えておく件数の上限。 */
1798
+ export const RESUMABLE_MEMO_MAX = 20;
1799
+ /** 現場ごとの「使い終わった合図」の記録ファイル。 */
1800
+ export function resumeMemoPath(site) {
1801
+ return path.join(ACTIVE_JOB_DIR, `resume-signals-${site}.json`);
1802
+ }
1803
+ /** 現場ごとの「続きから起こせる便」の控えファイル。 */
1804
+ export function resumableMemoPath(site) {
1805
+ return path.join(ACTIVE_JOB_DIR, `resumable-${site}.json`);
1806
+ }
1807
+ function readJsonArray(file) {
1808
+ try {
1809
+ if (!existsSync(file))
1810
+ return [];
1811
+ const raw = readFileSync(file, 'utf8').trim();
1812
+ if (!raw)
1813
+ return [];
1814
+ const parsed = JSON.parse(raw);
1815
+ return Array.isArray(parsed) ? parsed : [];
1816
+ }
1817
+ catch {
1818
+ return [];
1819
+ }
1820
+ }
1821
+ /** 使い終わった合図のIDを読む(★同じ合図で2回起こさないための記録)。 */
1822
+ export function loadResumeMemo(site) {
1823
+ return readJsonArray(resumeMemoPath(site))
1824
+ .filter((v) => typeof v === 'string' && v.length > 0);
1825
+ }
1826
+ /**
1827
+ * 合図を「使い終わった」として記録する。
1828
+ * ★起こす【前】に書く。書けなければ起こさない(二重に起こさないことを、失敗しても守る)。
1829
+ * 返り: 書けたか。
1830
+ */
1831
+ export function markResumeSignalUsed(site, signalId) {
1832
+ try {
1833
+ const kept = loadResumeMemo(site).filter((id) => id !== signalId);
1834
+ kept.push(signalId);
1835
+ mkdirSync(ACTIVE_JOB_DIR, { recursive: true });
1836
+ writeFileSync(resumeMemoPath(site), JSON.stringify(kept.slice(-RESUME_MEMO_MAX)) + '\n');
1837
+ return true;
1838
+ }
1839
+ catch (err) {
1840
+ log(`WARN 合図の記録に失敗: ${err instanceof Error ? err.message : String(err)}`);
1841
+ return false;
1842
+ }
1843
+ }
1844
+ /** 控えに残す便の本文の上限(記録ファイルを太らせない)。 */
1845
+ export const RESUMABLE_BODY_MAX = 4_000;
1846
+ /**
1847
+ * 便が終わるたびに「続きから起こせる」控えを残す。
1848
+ *
1849
+ * 【なぜ直前の1件では足りないか】`last-job-<現場>.json` は現場に1件しか無いので、
1850
+ * 本番操作を待つ便を閉じたあと同じ現場で別の便が走ると上書きされ、
1851
+ * 「前回どの試行まで走ったか」が分からなくなる=照合できず起こせない。
1852
+ * 数件ぶん覚えておけば、間に別の便が入っても同じ便を続きから起こせる。
1853
+ */
1854
+ export function rememberResumable(site, commandId, attempt, body = '') {
1855
+ if (!commandId)
1856
+ return;
1857
+ try {
1858
+ const kept = readJsonArray(resumableMemoPath(site))
1859
+ .filter((v) => v !== null && typeof v === 'object'
1860
+ && typeof v.commandId === 'string'
1861
+ && typeof v.attempt === 'number')
1862
+ .filter((r) => r.commandId !== commandId);
1863
+ kept.push({
1864
+ commandId,
1865
+ attempt,
1866
+ finishedAt: Date.now(),
1867
+ ...(body ? { body: body.slice(0, RESUMABLE_BODY_MAX) } : {}),
1868
+ });
1869
+ mkdirSync(ACTIVE_JOB_DIR, { recursive: true });
1870
+ writeFileSync(resumableMemoPath(site), JSON.stringify(kept.slice(-RESUMABLE_MEMO_MAX)) + '\n');
1871
+ }
1872
+ catch (err) {
1873
+ log(`WARN 続きから起こせる控えの保存に失敗: ${err instanceof Error ? err.message : String(err)}`);
1874
+ }
1875
+ }
1876
+ /** その便の「続きから起こせる」控えを返す。無ければ null(=照合できない)。 */
1877
+ export function resumableFor(site, commandId) {
1878
+ for (const r of readJsonArray(resumableMemoPath(site))) {
1879
+ if (r === null || typeof r !== 'object')
1880
+ continue;
1881
+ const rec = r;
1882
+ if (rec.commandId === commandId && typeof rec.attempt === 'number' && rec.attempt >= 1) {
1883
+ return rec;
1884
+ }
1885
+ }
1886
+ return null;
1887
+ }
1888
+ /**
1889
+ * 合図を起こしてよいか確かめる(★通信をしない部分だけ・Fail Closed)。
1890
+ *
1891
+ * 確かめる物は4つ: 現場・所有者(PC)・作業場所・結末。
1892
+ * 加えて「前回どの試行まで走ったか」が分からなければ起こさない(続きの場所が決まらない)。
1893
+ * ★中央の便の状態の確かめは、呼び出し側(通信あり)で行う。
1894
+ */
1895
+ export function checkResumeSignal(signal, cfg, deps) {
1896
+ if (signal.site !== cfg.site) {
1897
+ return { ok: false, reason: `現場が違う(合図=${signal.site} 自分=${cfg.site})`, final: false, ack: false };
1898
+ }
1899
+ if (signal.machine !== cfg.machine) {
1900
+ return { ok: false, reason: `所有者(PC)が違う(合図=${signal.machine} 自分=${cfg.machine})`, final: false, ack: false };
1901
+ }
1902
+ if (signal.workDir !== cfg.workDir) {
1903
+ return { ok: false, reason: `作業場所が違う(合図=${signal.workDir} 自分=${cfg.workDir})`, final: false, ack: true };
1904
+ }
1905
+ if (signal.gitOrigin !== undefined) {
1906
+ const current = deps.currentGitOrigin ?? null;
1907
+ if (current === null) {
1908
+ return { ok: false, reason: '今のリポの origin を取得できない(確認不能)', final: false, ack: true };
1909
+ }
1910
+ if (current !== signal.gitOrigin) {
1911
+ return { ok: false, reason: `リポが違う(合図=${signal.gitOrigin} 自分=${current})`, final: false, ack: true };
1912
+ }
1913
+ }
1914
+ if (signal.outcome !== 'succeeded') {
1915
+ const label = signal.outcome === 'rejected' ? '却下' : signal.outcome === 'expired' ? '時間切れ' : '失敗';
1916
+ const why = signal.reason ? `:${signal.reason}` : '';
1917
+ return {
1918
+ ok: false,
1919
+ reason: `本番操作は${label}でした${why}。便は起こしません(勝手に先へ進めません)。`,
1920
+ final: true,
1921
+ ack: true,
1922
+ };
1923
+ }
1924
+ if (deps.prevAttempt === null) {
1925
+ return {
1926
+ ok: false,
1927
+ reason: 'この便を前回どこまで走らせたかの控えがこのPCに無い(確認不能)',
1928
+ final: false,
1929
+ ack: true,
1930
+ };
1931
+ }
1932
+ return { ok: true, prevAttempt: deps.prevAttempt };
1933
+ }
1934
+ /**
1935
+ * まだ使っていない・自分(この現場とPC)宛の合図が指している便IDを返す。
1936
+ * ★取り残しの後始末が、これから起こす便に done を書いてしまわないように渡す。
1937
+ */
1938
+ export function resumeGuardCommandIds(signals, cfg, used) {
1939
+ const usedSet = new Set(used);
1940
+ const ids = new Set();
1941
+ for (const s of signals ?? []) {
1942
+ if (usedSet.has(s.id))
1943
+ continue;
1944
+ if (s.site !== cfg.site || s.machine !== cfg.machine)
1945
+ continue;
1946
+ ids.add(s.commandId);
1947
+ }
1948
+ return [...ids];
1949
+ }
1950
+ /**
1951
+ * 再開の合図を1件だけ処理する。
1952
+ * 返り: true = この周はここで終える(起こした/返事をした)/false = 合図は無かった。
1953
+ *
1954
+ * ★1周で扱うのは1件だけ(1現場同時1便を崩さない)。
1955
+ */
1956
+ export async function handleResumeSignals(cfg, state, signals) {
1957
+ if (signals === undefined || signals.length === 0)
1958
+ return false; // ★合図なし=今までと同じ
1959
+ if (state.job !== null)
1960
+ return false; // 走っている便がある=触らない
1961
+ const used = new Set(loadResumeMemo(cfg.site));
1962
+ for (const signal of signals) {
1963
+ if (used.has(signal.id))
1964
+ continue; // ★同じ合図は1回だけ
1965
+ const resumable = resumableFor(cfg.site, signal.commandId);
1966
+ const check = checkResumeSignal(signal, cfg, {
1967
+ prevAttempt: resumable?.attempt ?? null,
1968
+ currentGitOrigin: signal.gitOrigin !== undefined ? resolveGitOrigin(cfg.workDir) : null,
1969
+ });
1970
+ if (!check.ok) {
1971
+ if (!check.ack)
1972
+ continue; // 自分宛でない=黙って見送る(ほかのPCの合図)
1973
+ log(`site=${cfg.site} id=${signal.commandId} 再開の合図: 起こしません(${check.reason})`);
1974
+ if (check.final) {
1975
+ // ★却下・失敗・時間切れ: 理由を便へ返して畳む(再開しない・別の道を探さない)。
1976
+ markResumeSignalUsed(cfg.site, signal.id);
1977
+ await sendCommandEvents(cfg, signal.commandId, [{
1978
+ type: 'error',
1979
+ text: check.reason.slice(0, EVENT_TEXT_MAX),
1980
+ at: new Date().toISOString(),
1981
+ attempt: 0,
1982
+ }]);
1983
+ }
1984
+ await ackResumeSignal(cfg, signal, {
1985
+ accepted: false,
1986
+ final: check.final,
1987
+ reason: check.reason,
1988
+ });
1989
+ return true;
1990
+ }
1991
+ // ★中央の便の状態を確かめる(Fail Closed)。
1992
+ // 起こしてよいのは「まだ終わっていない・この現場を占有している」状態だけ
1993
+ // (perm_wait=人の手番で止まっている/sent=配達済みのまま)。
1994
+ // 終端(done・failed・dismissed)や確かめられないときは起こさない。
1995
+ const centralStatus = await fetchCommandStatus(cfg, signal.commandId);
1996
+ if (centralStatus === null || !RESUMABLE_CENTRAL_STATUSES.has(centralStatus)) {
1997
+ const reason = centralStatus === null
1998
+ ? '中央の便の状態を確かめられない(次の周でやり直します)'
1999
+ : `中央の便の状態が再開してよい状態ではない(${centralStatus})`;
2000
+ log(`site=${cfg.site} id=${signal.commandId} 再開の合図: 起こしません(${reason})`);
2001
+ await ackResumeSignal(cfg, signal, { accepted: false, final: false, reason });
2002
+ return true;
2003
+ }
2004
+ // ★配達の決まりはそのまま(放棄済み・配達回数上限の便は起こさない)。
2005
+ if (!canDeliver(state.relay, signal.commandId)) {
2006
+ const reason = `配達の上限(${RELAY_DELIVER_MAX})か放棄済みのため起こしません`;
2007
+ log(`site=${cfg.site} id=${signal.commandId} 再開の合図: ${reason}`);
2008
+ await ackResumeSignal(cfg, signal, { accepted: false, final: true, reason });
2009
+ markResumeSignalUsed(cfg.site, signal.id);
2010
+ return true;
2011
+ }
2012
+ // ★起こす前に「使った」を書く。書けないときは起こさない(二重に起こす方へ倒さない)。
2013
+ if (!markResumeSignalUsed(cfg.site, signal.id)) {
2014
+ const reason = '合図を使った記録を手元に書けないため起こしません(二重に起こさないため)';
2015
+ log(`site=${cfg.site} id=${signal.commandId} 再開の合図: ${reason}`);
2016
+ await ackResumeSignal(cfg, signal, { accepted: false, final: false, reason });
2017
+ return true;
2018
+ }
2019
+ const attempt = check.prevAttempt + 1;
2020
+ state.attempts.set(signal.commandId, attempt);
2021
+ const sessionId = sessionIdForCommand(signal.commandId, check.prevAttempt);
2022
+ const note = resumeNoteFromSignal(signal);
2023
+ const body = resumable?.body ?? '';
2024
+ const cmd = { id: signal.commandId, body };
2025
+ const job = spawnClaudeProcess(cfg, cmd, sessionId, attempt, true, note);
2026
+ if (job === null) {
2027
+ const why = `${lastSpawnError || 'claude が見つかりません'}(再開を中止)`;
2028
+ await patchStatus(cfg, signal.commandId, 'failed', why.slice(0, EVENT_TEXT_MAX));
2029
+ await sendCommandEvents(cfg, signal.commandId, [{
2030
+ type: 'error',
2031
+ text: why.slice(0, EVENT_TEXT_MAX),
2032
+ at: new Date().toISOString(),
2033
+ attempt,
2034
+ }]);
2035
+ state.relay.abandoned.add(signal.commandId);
2036
+ await ackResumeSignal(cfg, signal, { accepted: false, final: true, reason: why, attempt });
2037
+ log(`ERROR site=${cfg.site} id=${signal.commandId} 再開の合図: ${why}`);
2038
+ return true;
2039
+ }
2040
+ state.job = job;
2041
+ saveActiveJob(cfg.site, {
2042
+ commandId: signal.commandId,
2043
+ body,
2044
+ sessionId,
2045
+ attempt,
2046
+ startedAt: Date.now(),
2047
+ site: cfg.site,
2048
+ machine: cfg.machine,
2049
+ workDir: cfg.workDir,
2050
+ gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined,
2051
+ pid: job.process.pid,
2052
+ });
2053
+ markDelivered(state.relay, signal.commandId);
2054
+ state.relay.lastHeartbeatAt = null;
2055
+ noteReadiness(state.relay, 'delivered', signal.commandId);
2056
+ // ★人の手番で止まっていた便は、起こしたら配達済み(sent)へ戻す(管制が「作業中」と出せる形)。
2057
+ // 既に sent の便には書かない(同じ状態を書き直さない)。
2058
+ if (centralStatus !== 'sent') {
2059
+ await patchStatus(cfg, signal.commandId, 'sent');
2060
+ }
2061
+ await sendCommandEvents(cfg, signal.commandId, [{
2062
+ type: 'resumed',
2063
+ text: `本番操作の完了を受けて続きから起こしました(attempt ${attempt})`.slice(0, EVENT_TEXT_MAX),
2064
+ at: new Date().toISOString(),
2065
+ attempt,
2066
+ }]);
2067
+ // ★起こした事実と結果を管制へ返す(起こしたあとに返す=「起こした」とだけ言える形)。
2068
+ await ackResumeSignal(cfg, signal, {
2069
+ accepted: true,
2070
+ final: true,
2071
+ reason: note ?? '続きから起こしました',
2072
+ attempt,
2073
+ });
2074
+ log(`site=${cfg.site} id=${signal.commandId} ` +
2075
+ `再開の合図で続きから起こしました (session=${sessionId}, attempt ${attempt})`);
2076
+ return true;
2077
+ }
2078
+ return false;
2079
+ }
2080
+ // ─────────────────────────────────────────────────────────────────────────────
1714
2081
  // 取り残しの後始末(便 6ebba0d0 の項目4)
1715
2082
  // ─────────────────────────────────────────────────────────────────────────────
1716
2083
  /**
@@ -1805,7 +2172,16 @@ export async function headlessTick(cfg, state, hooks) {
1805
2172
  // 「実プロセスが無い・報告は渡し済み・管制ではまだ終わっていない」便に done を書く。
1806
2173
  // ★自分の PC・自分の現場の便だけ(判定は recoverStuckProcessedReports 側)。
1807
2174
  // ★停止トグルの確認より後に置く=止められている現場では1バイトも書かない。
1808
- await recoverStuckCommands(cfg, { trackedIds: headlessTrackedCommandIds(state), state });
2175
+ // ★これから続きから起こす便(再開の合図が来ている便)には書かない=起こす前に
2176
+ // done を書いてしまうと、照合(中央の状態が sent)で起こせなくなる。
2177
+ // ★合図が無いあいだは、手元の記録も読まない(今までと同じ動きのまま)。
2178
+ const resumeGuard = (ctl.resumeRequests?.length ?? 0) > 0
2179
+ ? resumeGuardCommandIds(ctl.resumeRequests, cfg, loadResumeMemo(cfg.site))
2180
+ : [];
2181
+ await recoverStuckCommands(cfg, {
2182
+ trackedIds: [...headlessTrackedCommandIds(state), ...resumeGuard],
2183
+ state,
2184
+ });
1809
2185
  // ── 異常終了後の自動再開(pendingRetry)──
1810
2186
  if (state.pendingRetry !== null) {
1811
2187
  const retry = state.pendingRetry;
@@ -1844,6 +2220,13 @@ export async function headlessTick(cfg, state, hooks) {
1844
2220
  `自動再開しました (session=${sessionId}, attempt ${retry.attempt}/${HEADLESS_MAX_ATTEMPTS})`);
1845
2221
  return;
1846
2222
  }
2223
+ // ── 再開の合図(本番操作窓口・取り決め v1)──
2224
+ // 管制が「本番操作が終わった」と知らせてきた便を、続きから起こす。
2225
+ // ★起こすのは同じ便の続きだけ(新しい便を機械が選ばない)。
2226
+ // ★合図が無いあいだは何もしない=ここより下は今までと同じ順で動く。
2227
+ // ★自動再開(pendingRetry)の後・新しい便を取る前に置く=走っている便を割り込まない。
2228
+ if (await handleResumeSignals(cfg, state, ctl.resumeRequests))
2229
+ return;
1847
2230
  // ── 入れ替え待ちの札(便 b7bd5e43・A2)──
1848
2231
  // 札が在るあいだは新しい便を【取りに行かない】。管制に積まれた便はそのまま待つ=
1849
2232
  // 失われない・失敗にならない・試行回数も配達回数も使わない。札が外れたら次の周で今までどおり取る。
@@ -22,6 +22,14 @@ export const EAR_REFRESH_FALLBACK_MS = 8 * 60 * 60 * 1000;
22
22
  /** 再接続の待ち(指数バックオフ)の初期値と上限。 */
23
23
  export const EAR_RETRY_BASE_MS = 5 * 1000;
24
24
  export const EAR_RETRY_MAX_MS = 5 * 60 * 1000;
25
+ /**
26
+ * 鍵が通らない(401・403)ときの聞き直しの待ち(指数バックオフ)の初期値と上限(便 e9276b86)。
27
+ * 【実物 2026-10-03 03:04〜03:24Z】鍵が通らない機から realtime-token が 2〜4秒おきに聞き直され、20分で 401 が189件。
28
+ * 鍵は待っても直らない(人が入れ直すまで同じ答え)ので、ふつうの切断(5秒から)より長く空ける。
29
+ * ★1分 → 2分 → 4分 …と広げ、30分で頭打ち。つながっているときの速さ(合図・取り直し)は変えない。
30
+ */
31
+ export const EAR_AUTH_RETRY_BASE_MS = 60 * 1000;
32
+ export const EAR_AUTH_RETRY_MAX_MS = 30 * 60 * 1000;
25
33
  /** 購読設定が無い(unsupported)ときに問い直す間隔(ms)。中央の設定投入に自然に追従するため。 */
26
34
  export const EAR_UNSUPPORTED_RETRY_MS = 30 * 60 * 1000;
27
35
  /**
@@ -108,6 +116,15 @@ export function computeRefreshDelayMs(expiresAt, nowMs) {
108
116
  return clamp(EAR_REFRESH_FALLBACK_MS);
109
117
  return clamp(at - nowMs - EAR_REFRESH_MARGIN_MS);
110
118
  }
119
+ /** 中央の答えが「鍵が通らない」か(401・403)。 */
120
+ export function isAuthRejected(r) {
121
+ return r.kind === 'error' && (r.status === 401 || r.status === 403);
122
+ }
123
+ /** 鍵が通らないときの待ち(ms)。続けて断られた回数に応じて 1分 → 2分 → 4分 …と広げ、30分で頭打ち。 */
124
+ export function computeAuthRetryDelayMs(failures) {
125
+ const n = failures < 0 ? 0 : failures;
126
+ return Math.min(EAR_AUTH_RETRY_MAX_MS, EAR_AUTH_RETRY_BASE_MS * 2 ** Math.min(n, 20));
127
+ }
111
128
  /** 再接続の待ち(ms)。試行回数に応じて 5s → 10s → 20s …と広げ、5分で頭打ちにする。 */
112
129
  export function computeRetryDelayMs(attempt) {
113
130
  const n = attempt < 0 ? 0 : attempt;
package/dist/realtime.js CHANGED
@@ -30,7 +30,7 @@
30
30
  * 読めなければ「耳なし=各relayのポーリングのみ」で静かに動き続ける。
31
31
  */
32
32
  import { describeTransport, resolveWebSocketTransport } from './websocket-transport.js';
33
- import { EAR_NO_SITE_RETRY_MS, EAR_NUDGE_MIN_GAP_MS, EAR_TOKEN_FETCH_TIMEOUT_MS, EAR_UNSUPPORTED_RETRY_MS, computeRefreshDelayMs, computeRetryDelayMs, parseRealtimeConnection, readSignalSite, } from './realtime-parse.js';
33
+ import { EAR_NO_SITE_RETRY_MS, EAR_NUDGE_MIN_GAP_MS, EAR_TOKEN_FETCH_TIMEOUT_MS, EAR_UNSUPPORTED_RETRY_MS, computeAuthRetryDelayMs, computeRefreshDelayMs, computeRetryDelayMs, isAuthRejected, parseRealtimeConnection, readSignalSite, } from './realtime-parse.js';
34
34
  // 読み取りと間合いの計算は src/realtime-parse.ts に分けてある(外部I/Oなし=単体テスト可能)。
35
35
  // 従来どおり `./realtime.js` からも使えるよう、ここで通しておく。
36
36
  export * from './realtime-parse.js';
@@ -60,7 +60,7 @@ export async function fetchRealtimeConnection(cfg, site) {
60
60
  signal: AbortSignal.timeout(EAR_TOKEN_FETCH_TIMEOUT_MS),
61
61
  });
62
62
  if (!res.ok)
63
- return { kind: 'error', detail: `HTTP ${res.status}` };
63
+ return { kind: 'error', detail: `HTTP ${res.status}`, status: res.status };
64
64
  const data = await res.json().catch(() => null);
65
65
  return parseRealtimeConnection(data);
66
66
  }
@@ -78,12 +78,17 @@ async function loadCreateClient() {
78
78
  return null;
79
79
  }
80
80
  }
81
+ const DEFAULT_IO = {
82
+ fetchConnection: fetchRealtimeConnection,
83
+ loadCreateClient,
84
+ resolveTransport: resolveWebSocketTransport,
85
+ };
81
86
  /**
82
87
  * 指示の着信合図を受け取る耳を1本張る(★このPCに1本だけ=耳プロセスからのみ呼ぶ)。
83
88
  *
84
89
  * 呼び出し側は各relayのポーリングを止めないこと——耳が死んでいる間はポーリングが肩代わりする。
85
90
  */
86
- export function startRealtimeEar(cfg, handlers) {
91
+ export function startRealtimeEar(cfg, handlers, io = DEFAULT_IO) {
87
92
  const { siteForToken, onSignal } = handlers;
88
93
  let stopped = false;
89
94
  let client = null;
@@ -96,6 +101,18 @@ export function startRealtimeEar(cfg, handlers) {
96
101
  let lastNudgeSite = null;
97
102
  /** 再接続の予約が二重に積まれないようにする(切断イベントは連続で飛んでくる)。 */
98
103
  let reconnectArmed = false;
104
+ /**
105
+ * 鍵が通らない(401・403)と続けて断られた回数(便 e9276b86)。★接続情報が取れたときだけ 0 に戻す
106
+ * (購読の SUBSCRIBED では戻さない=別の線の知らせで待ちが縮まない)。
107
+ */
108
+ let authFailures = 0;
109
+ /**
110
+ * いま張っている線の世代(便 e9276b86)。片付けるたびに1つ進める。
111
+ * ★片付けた古い線から遅れて届く CLOSED などの知らせは、世代が違うので無視する。
112
+ * (以前は、期限の手前の張り直しで古い線を片付けると、その CLOSED で2本目の張り直しが走り、
113
+ * 聞き直しが重なっていた。)
114
+ */
115
+ let generation = 0;
99
116
  const clearTimers = () => {
100
117
  if (retryTimer) {
101
118
  clearTimeout(retryTimer);
@@ -122,16 +139,28 @@ export function startRealtimeEar(cfg, handlers) {
122
139
  }
123
140
  channel = null;
124
141
  client = null;
142
+ generation += 1;
125
143
  };
126
- /** 一拍おいて張り直す。理由はログに残す(凝らない=最悪はポーリングが拾う)。 */
127
- const scheduleReconnect = (reason) => {
144
+ /**
145
+ * 一拍おいて張り直す。理由はログに残す(凝らない=最悪はポーリングが拾う)。
146
+ * authRejected=鍵が通らない(401・403)ときは、ふつうの切断より長く空ける(便 e9276b86)。
147
+ */
148
+ const scheduleReconnect = (reason, authRejected = false) => {
128
149
  if (stopped || reconnectArmed)
129
150
  return;
130
151
  reconnectArmed = true;
131
152
  teardown();
132
- const delay = computeRetryDelayMs(attempt);
133
- attempt += 1;
134
- log(`${reason} → ${Math.round(delay / 1000)}秒後に張り直します(この間は各relayの15秒ポーリングが肩代わり)`);
153
+ let delay;
154
+ if (authRejected) {
155
+ delay = computeAuthRetryDelayMs(authFailures);
156
+ authFailures += 1;
157
+ }
158
+ else {
159
+ delay = computeRetryDelayMs(attempt);
160
+ attempt += 1;
161
+ }
162
+ log(`${reason} → ${Math.round(delay / 1000)}秒後に張り直します(この間は各relayの15秒ポーリングが肩代わり)` +
163
+ (authRejected ? '。★鍵が通りません(~/.conductor.env の合言葉を確かめてください)' : ''));
135
164
  clearTimers();
136
165
  retryTimer = setTimeout(() => {
137
166
  reconnectArmed = false;
@@ -168,7 +197,7 @@ export function startRealtimeEar(cfg, handlers) {
168
197
  const connect = async () => {
169
198
  if (stopped)
170
199
  return;
171
- const createClient = await loadCreateClient();
200
+ const createClient = await io.loadCreateClient();
172
201
  if (createClient === null) {
173
202
  log('@supabase/supabase-js が読めないため耳は張りません(各relayの15秒ポーリングでこれまでどおり動きます)');
174
203
  return; // 依存は実行中に生えないので再試行しない。
@@ -176,7 +205,7 @@ export function startRealtimeEar(cfg, handlers) {
176
205
  // ★どの Node(18/20/22) でも耳が張れるようにする(実機 Node 20 の「native WebSocket not found」根治)。
177
206
  // 標準の WebSocket が無い機では同梱の ws を supabase-js の transport へ渡す。
178
207
  // どちらも用意できなければ耳は張らない——が、プロセスは落とさない(ポーリングが肩代わりする)。
179
- const transport = await resolveWebSocketTransport();
208
+ const transport = await io.resolveTransport();
180
209
  if (transport === null) {
181
210
  log('この機には標準のWebSocketが無く、同梱のWebSocket部品(ws)も読めないため耳は張りません' +
182
211
  '(npm install で解消します。それまでも各relayの15秒ポーリングでこれまでどおり動きます)');
@@ -189,7 +218,9 @@ export function startRealtimeEar(cfg, handlers) {
189
218
  retryTimer = setTimeout(() => void connect(), EAR_NO_SITE_RETRY_MS);
190
219
  return;
191
220
  }
192
- const result = await fetchRealtimeConnection(cfg, site);
221
+ const result = await io.fetchConnection(cfg, site);
222
+ if (stopped)
223
+ return;
193
224
  if (result.kind === 'unsupported') {
194
225
  log(`中央から購読設定が返らないため耳は張りません(${result.detail})。` +
195
226
  `各relayの15秒ポーリングで動き続け、${Math.round(EAR_UNSUPPORTED_RETRY_MS / 60000)}分後にもう一度問い合わせます`);
@@ -198,10 +229,12 @@ export function startRealtimeEar(cfg, handlers) {
198
229
  return;
199
230
  }
200
231
  if (result.kind === 'error') {
201
- scheduleReconnect(`接続情報を取得できません(${result.detail})`);
232
+ scheduleReconnect(`接続情報を取得できません(${result.detail})`, isAuthRejected(result));
202
233
  return;
203
234
  }
235
+ authFailures = 0;
204
236
  const conn = result.conn;
237
+ const myGeneration = generation;
205
238
  try {
206
239
  client = createClient(conn.supabaseUrl, conn.supabaseAnonKey, {
207
240
  auth: { persistSession: false, autoRefreshToken: false },
@@ -221,7 +254,8 @@ export function startRealtimeEar(cfg, handlers) {
221
254
  .channel(spec.channel)
222
255
  .on('postgres_changes', filterOpt, handleSignal)
223
256
  .subscribe((status, err) => {
224
- if (stopped)
257
+ // ★片付けた古い線の知らせは無視する(張り直しを重ねない・便 e9276b86)。
258
+ if (stopped || myGeneration !== generation)
225
259
  return;
226
260
  if (status === 'SUBSCRIBED') {
227
261
  attempt = 0;
package/dist/relay.js CHANGED
@@ -221,6 +221,93 @@ export function parseSentAt(raw) {
221
221
  */
222
222
  export const DISPATCH_DECISIONS_MAX = 10;
223
223
  export const DISPATCH_DECISION_CHARS = 300;
224
+ /** 本番操作の結末(★この4つだけ受け取る。succeeded 以外では便を起こさない)。 */
225
+ export const RESUME_SIGNAL_OUTCOMES = ['succeeded', 'rejected', 'failed', 'expired'];
226
+ /** 合図に載せられる結果の件数の上限(本文を太らせない)。 */
227
+ export const RESUME_SIGNAL_RESULTS_MAX = 10;
228
+ /** 合図の文字欄1つの上限(理由・値など)。 */
229
+ export const RESUME_SIGNAL_TEXT_MAX = 200;
230
+ /** 1回の control で受け取る合図の件数の上限。 */
231
+ export const RESUME_SIGNAL_MAX = 10;
232
+ /** 文字欄を1つ読む。空・文字でない物は undefined。長すぎる物は切り詰める。 */
233
+ function resumeText(raw) {
234
+ if (typeof raw !== 'string')
235
+ return undefined;
236
+ const t = raw.trim();
237
+ if (t.length === 0)
238
+ return undefined;
239
+ return [...t].length > RESUME_SIGNAL_TEXT_MAX
240
+ ? `${[...t].slice(0, RESUME_SIGNAL_TEXT_MAX - 1).join('')}…`
241
+ : t;
242
+ }
243
+ /**
244
+ * 管制の応答から合図の一覧を読む。★形が整っている物だけを受け取り、ほかは黙って捨てる。
245
+ *
246
+ * 【なぜ厳しく読むか】ここで受け取った合図は職人を起こす。形の怪しい物を「たぶん大丈夫」で
247
+ * 通すと、宛先の違う便を起こしうる。欠けている物は推測で埋めない(Fail Closed)。
248
+ */
249
+ export function parseResumeSignals(raw) {
250
+ if (!Array.isArray(raw))
251
+ return [];
252
+ const out = [];
253
+ for (const entry of raw) {
254
+ if (entry === null || typeof entry !== 'object')
255
+ continue;
256
+ const e = entry;
257
+ const id = resumeText(e.id);
258
+ const commandId = resumeText(e.commandId);
259
+ const site = resumeText(e.site);
260
+ const machine = resumeText(e.machine);
261
+ const workDir = resumeText(e.workDir);
262
+ const outcomeRaw = typeof e.outcome === 'string' ? e.outcome.trim() : '';
263
+ if (!id || !commandId || !site || !machine || !workDir)
264
+ continue;
265
+ if (!RESUME_SIGNAL_OUTCOMES.includes(outcomeRaw))
266
+ continue;
267
+ const signal = {
268
+ id, commandId, site, machine, workDir,
269
+ outcome: outcomeRaw,
270
+ };
271
+ const reason = resumeText(e.reason);
272
+ if (reason !== undefined)
273
+ signal.reason = reason;
274
+ const gitOrigin = resumeText(e.gitOrigin);
275
+ if (gitOrigin !== undefined)
276
+ signal.gitOrigin = gitOrigin;
277
+ const issuedAt = resumeText(e.issuedAt);
278
+ if (issuedAt !== undefined)
279
+ signal.issuedAt = issuedAt;
280
+ if (Array.isArray(e.results)) {
281
+ const results = [];
282
+ for (const r of e.results.slice(0, RESUME_SIGNAL_RESULTS_MAX)) {
283
+ if (r === null || typeof r !== 'object')
284
+ continue;
285
+ const rr = r;
286
+ const one = {};
287
+ const kind = resumeText(rr.kind);
288
+ if (kind !== undefined)
289
+ one.kind = kind;
290
+ const summary = resumeText(rr.summary);
291
+ if (summary !== undefined)
292
+ one.summary = summary;
293
+ const before = resumeText(rr.before);
294
+ if (before !== undefined)
295
+ one.before = before;
296
+ const after = resumeText(rr.after);
297
+ if (after !== undefined)
298
+ one.after = after;
299
+ if (Object.keys(one).length > 0)
300
+ results.push(one);
301
+ }
302
+ if (results.length > 0)
303
+ signal.results = results;
304
+ }
305
+ out.push(signal);
306
+ if (out.length >= RESUME_SIGNAL_MAX)
307
+ break;
308
+ }
309
+ return out;
310
+ }
224
311
  /** 連続スキップ何回でアイドル誤判定の疑いをWARNするか(観測のみ・送信挙動は変えない)。 */
225
312
  export const RELAY_SKIP_WARN_THRESHOLD = 6;
226
313
  /** 初期状態を作る。 */
@@ -2574,7 +2661,12 @@ export async function fetchControl(cfg) {
2574
2661
  // per-site 子トグル。boolean ならその値、欠落/不正は undefined=ON(稼働)フォールバック。
2575
2662
  const enabledRaw = data?.relayEnabled;
2576
2663
  const relayEnabled = typeof enabledRaw === 'boolean' ? enabledRaw : undefined;
2577
- return { relayHalted: halted, ok: true, detail: '', relayEnabled };
2664
+ // ★再開の合図(取り決め v1)。欄が無い管制では空になる=今までと同じ動きになる。
2665
+ const signals = parseResumeSignals(data?.resumeRequests);
2666
+ return {
2667
+ relayHalted: halted, ok: true, detail: '', relayEnabled,
2668
+ ...(signals.length > 0 ? { resumeRequests: signals } : {}),
2669
+ };
2578
2670
  }
2579
2671
  catch (err) {
2580
2672
  return {
@@ -2584,6 +2676,48 @@ export async function fetchControl(cfg) {
2584
2676
  };
2585
2677
  }
2586
2678
  }
2679
+ /**
2680
+ * 再開の合図に返事をする(取り決め v1)。
2681
+ * POST {url}/api/conductor/resume-signals/{合図ID}/ack
2682
+ * body: { commandId, site, machine, accepted, final, reason?, attempt? }
2683
+ *
2684
+ * 【accepted】起こしたか(true)/起こさなかったか(false)。
2685
+ * 【final】★この合図はもう使わないか。
2686
+ * - true … 畳んでよい(起こした/却下・失敗で起こさないと決めた)。
2687
+ * - false … まだ畳まない(照合できない・中央の状態を確かめられない=次の周でやり直す)。
2688
+ *
2689
+ * 【届かなくても二重には起こさない】「同じ合図を1回だけ」は中継の手元の記録で守る。
2690
+ * だから返事が落ちても、同じ合図で2回起こすことは起きない。返事は「管制が畳むための知らせ」。
2691
+ * ★返事の口が無い管制(404)でも、起こす側の動きは変えない(false を返すだけ)。
2692
+ */
2693
+ export async function ackResumeSignal(cfg, signal, opts) {
2694
+ const url = `${cfg.url}/api/conductor/resume-signals/${encodeURIComponent(signal.id)}/ack`;
2695
+ try {
2696
+ const res = await fetch(url, {
2697
+ method: 'POST',
2698
+ headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
2699
+ body: JSON.stringify({
2700
+ commandId: signal.commandId,
2701
+ site: signal.site,
2702
+ machine: signal.machine,
2703
+ accepted: opts.accepted,
2704
+ final: opts.final,
2705
+ ...(opts.reason !== undefined ? { reason: opts.reason.slice(0, 500) } : {}),
2706
+ ...(opts.attempt !== undefined ? { attempt: opts.attempt } : {}),
2707
+ }),
2708
+ signal: AbortSignal.timeout(RELAY_FETCH_TIMEOUT_MS),
2709
+ });
2710
+ if (!res.ok) {
2711
+ log(`site=${cfg.site} 再開の合図の返事 -> HTTP ${res.status} NG`);
2712
+ return false;
2713
+ }
2714
+ return true;
2715
+ }
2716
+ catch (err) {
2717
+ log(`site=${cfg.site} 再開の合図の返事 -> 失敗: ${err instanceof Error ? err.message : String(err)}`);
2718
+ return false;
2719
+ }
2720
+ }
2587
2721
  /**
2588
2722
  * 自分宛(machine+site)の queued 指示を取得。先頭1件だけ返す(1現場同時1指示)。
2589
2723
  * 空/エラー/不正は null。
package/dist/selfcheck.js CHANGED
@@ -27,7 +27,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync
27
27
  import os from 'node:os';
28
28
  import path from 'node:path';
29
29
  import { fileURLToPath } from 'node:url';
30
- import { CLAUDE_MIN_VERSION, claudeUpdateHint, compareVersions, lastClaudePinStatus, resolveClaudeBin, } from './headless.js';
30
+ import { buildWorkerPath, CLAUDE_MIN_VERSION, claudeUpdateHint, compareVersions, lastClaudePinStatus, resolveClaudeBin, } from './headless.js';
31
31
  import { fetchControl } from './relay.js';
32
32
  import { readDrain } from './autoupdate-flags.js';
33
33
  // ─────────────────────────────────────────────────────────────────────────────
@@ -538,9 +538,7 @@ function probeEnv(d, claudeBin) {
538
538
  const env = { ...d.env };
539
539
  delete env.CLAUDECODE;
540
540
  delete env.ANTHROPIC_API_KEY;
541
- const binDir = path.dirname(claudeBin);
542
- if (!(env.PATH ?? '').split(':').includes(binDir))
543
- env.PATH = env.PATH ? `${binDir}:${env.PATH}` : binDir;
541
+ env.PATH = buildWorkerPath(env.PATH, claudeBin);
544
542
  return env;
545
543
  }
546
544
  /** ⑥ 小さな headless 試験を1回流す。 */
@@ -841,8 +839,31 @@ export async function runSelfcheck(d, trigger) {
841
839
  finally {
842
840
  // ★一時の作業場所は必ず消す(外に書けてしまった物も含めて)。
843
841
  rmSync(runDir, { recursive: true, force: true });
842
+ // ★道具(Claude Code)が作業場所ごとに /tmp/claude-<uid>/ の下へ作る記録も消す(便 e9276b86)。
843
+ // 作業場所は毎回名前が違うので、消さないと自己診断のたびに1つずつ溜まる(ノートPCで9つ)。
844
+ for (const dir of [runDir, path.join(runDir, 'work')]) {
845
+ const trace = claudeTraceDir(dir, d.claudeTmpRoot);
846
+ if (trace)
847
+ rmSync(trace, { recursive: true, force: true });
848
+ }
844
849
  }
845
850
  }
851
+ /**
852
+ * Claude Code が作業場所ごとに作る記録の置き場(★実物 2026-10-03・Claude Code 2.1.287)。
853
+ * `/tmp/claude-<uid>/` の下に、作業場所のパスの英数字以外を `-` に替えた名前で作る
854
+ * (例 /home/u/.local/state/conductor/selfcheck/run-…/work → -home-u--local-state-conductor-selfcheck-run-…-work)。
855
+ * ★消してよいのは自己診断の作業場所の分だけ。名前に selfcheck の run- が入らなければ null(他人の物に触らない)。
856
+ */
857
+ export function claudeTraceDir(workDir, root = defaultClaudeTmpRoot()) {
858
+ const name = workDir.replace(/[^a-zA-Z0-9]/g, '-');
859
+ if (!/-selfcheck-run-/.test(name))
860
+ return null;
861
+ return path.join(root, name);
862
+ }
863
+ function defaultClaudeTmpRoot() {
864
+ const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
865
+ return path.join('/tmp', `claude-${uid}`);
866
+ }
846
867
  /** 画面に出す形(人が読む)。 */
847
868
  export function formatSelfcheck(r) {
848
869
  const lines = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.92",
3
+ "version": "0.12.94",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1290,6 +1290,49 @@ else
1290
1290
  cleanup_stale_units || warn "古いユニットの掃除に失敗しました(インストールは続行します)。"
1291
1291
  fi
1292
1292
 
1293
+ # --- 画面なし方式(headless)の機では、足す現場も画面なし方式で立てる(便 e9276b86) ---
1294
+ # 【なぜ要るか(実物 2026-10-03・TYHLD-GOTO・TYHLD-OTA)】
1295
+ # 1行インストーラ(install.sh)は最初の現場に drop-in(headless.conf)を置いて画面なし方式にする。
1296
+ # ところが2つ目の現場を `conductor setup 現場名` で足すと drop-in が無く、古い方式(tmux)で立った
1297
+ # (管制で「AI担当は休止中」。人が drop-in を手で置いて直した)。⑧の案内も tmux の3行を出していた。
1298
+ # 【決め方】次のどれかなら、この機は画面なし方式=この現場にも install.sh と同じ drop-in を置く。
1299
+ # ① CONDUCTOR_RUNNER_MODE=headless で呼ばれた(install.sh から)。★=tmux と明示されたら置かない。
1300
+ # ② この機のほかの現場(またはテンプレート全体)の drop-in に画面なし方式の指定が在る。
1301
+ # ★どれにも当たらない機(もとから tmux 方式の機)は、1つも変えない。Mac(launchd)も変えない。
1302
+ HEADLESS_LINE='Environment=CONDUCTOR_RUNNER_MODE=headless'
1303
+ HEADLESS_DROPIN_DIR="$HOME/.config/systemd/user/conductor@${SITE}.service.d"
1304
+ HEADLESS_DROPIN="$HEADLESS_DROPIN_DIR/headless.conf"
1305
+ machine_is_headless() {
1306
+ case "${CONDUCTOR_RUNNER_MODE:-}" in
1307
+ headless) return 0 ;;
1308
+ tmux) return 1 ;;
1309
+ esac
1310
+ local f
1311
+ for f in "$HOME"/.config/systemd/user/conductor@*.service.d/*.conf; do
1312
+ [[ -f "$f" ]] || continue
1313
+ grep -qxF "$HEADLESS_LINE" "$f" && return 0
1314
+ done
1315
+ return 1
1316
+ }
1317
+ HEADLESS_ADDED=0
1318
+ if [[ "$OS_KIND" != "mac" ]] && machine_is_headless; then
1319
+ if [[ -f "$HEADLESS_DROPIN" ]] && grep -qxF "$HEADLESS_LINE" "$HEADLESS_DROPIN"; then
1320
+ echo "[setup] 画面なし方式: この現場には設定済みです(${HEADLESS_DROPIN})"
1321
+ else
1322
+ mkdir -p "$HEADLESS_DROPIN_DIR"
1323
+ cat > "$HEADLESS_DROPIN" <<DROPIN
1324
+ # setup.sh が設定: この機は画面なし方式(headless)なので、この現場も同じ方式で動かす。
1325
+ # ★tmux に切り替えたいときはこのファイルを消して systemctl --user daemon-reload。
1326
+ [Service]
1327
+ ${HEADLESS_LINE}
1328
+ DROPIN
1329
+ HEADLESS_ADDED=1
1330
+ echo "[setup] 画面なし方式を設定しました(drop-in: ${HEADLESS_DROPIN})"
1331
+ fi
1332
+ # ⑧ の案内を画面なし方式に切り替える。
1333
+ CONDUCTOR_RUNNER_MODE=headless
1334
+ fi
1335
+
1293
1336
  # ★常駐の設置だけが OS で分かれる(ADR-019・案C)。ここより上も下も1本のまま。
1294
1337
  if [[ "$OS_KIND" == "mac" ]]; then
1295
1338
  SVC_INSTALL="$SCRIPT_DIR/launchd/install.sh"
@@ -1298,6 +1341,10 @@ else
1298
1341
  fi
1299
1342
  if [[ -f "$SVC_INSTALL" ]]; then
1300
1343
  bash "$SVC_INSTALL" "$SITE" "$PROJECT_DIR"
1344
+ # ★もう動いていた現場(setup のやり直し)は enable --now では読み直さないので、入れ直して効かせる。
1345
+ if [[ "$HEADLESS_ADDED" == "1" ]]; then
1346
+ systemctl --user restart "conductor@${SITE}.service" 2>/dev/null || true
1347
+ fi
1301
1348
  else
1302
1349
  warn "常駐の設置スクリプトが見つかりません: $SVC_INSTALL(スキップ)"
1303
1350
  echo "[setup] 手動で設定する場合は sales-template/${SVC_KIND}/ を参照してください。"
@@ -1452,12 +1499,13 @@ if [[ "${CONDUCTOR_RUNNER_MODE:-}" == "headless" ]]; then
1452
1499
  合言葉を入れ直す: rm ~/.conductor.env → そのあと setup.sh をもう一度実行
1453
1500
 
1454
1501
  ──────────────────────────────────────────────────────────
1455
- 職人の画面を手で開きたいとき(tmux 方式・補助)
1502
+ 職人の画面を手で開きたいとき(見るだけ・補助)
1456
1503
  ──────────────────────────────────────────────────────────
1457
1504
 
1458
- tmux new-session -d -s "$SITE" -c "$PROJECT_DIR"
1459
- tmux send-keys -t "$SITE" "unset ANTHROPIC_API_KEY && claude" C-m
1460
- tmux attach -t "$SITE"
1505
+ 画面なし方式では、職人の画面を手で立ち上げません(立ち上げると、便と同じ現場で2人目の職人が動きます)。
1506
+ 職人がいま何をしているかは、管制の画面か、次の1行で見られます(見るだけ・入力はできません):
1507
+
1508
+ kc ${SITE} (kc が無いときは conductor view ${SITE})
1461
1509
 
1462
1510
  GUIDE
1463
1511
  else
@@ -132,6 +132,10 @@ description: 采配くんの便(プロンプト末尾に CONDUCTOR_JOB:<id>
132
132
  - **なぜ(置き場の話)。** 実機 2026-09-03: ai-saku の画面便(PR#258・PR#269)で、検品用の使い捨てページ `_layout-check.html` をリポジトリ直下に作り、`rm -rf` で消そうとして止められ、人が2回消した。**禁止の一覧と番人の制限は正しい(緩めない)。悪いのは置き場。**
133
133
  - **なぜ(後片付けの話を改めた理由)。** 旧い文言は「リポジトリ直下は番人が `rm` を止めるので自分で消さず報告」だった。実測(便 fb248f6e)では、`-f` を付けない `rm -r` と実在のパスの名指しなら、リポジトリ直下の自分の作業ファイルも通る。止まるのは `-f` 付きと変数・`*` 入りだけ。**止まらない形を「止まる」と書いていると、職人が自分の残骸を残したまま便を終える**ので、実物に合わせた(番人・禁止の一覧は1行も変えていない)。
134
134
  - 置き場を `/tmp/claude-1000/` にすれば、止められることも、残骸がコミットに混ざることも起きない(根治=鉄則10)。Playwright の検品ページも同じ(鉄則8-④)。
135
+ - **空振り確認(直しをわざと外して、試験が落ちるのを見る)も、使い捨ての複製の中だけで行う(えふさん確定 2026-10-03・便 4cf71dde の要判断②)。**
136
+ - 形: `git clone --no-hardlinks <リポの実在のパス> /tmp/claude-1000/karaburi-<便IDの頭8文字>` → その中で直しを外す → 試験を流す → 終わったら `rm -r /tmp/claude-1000/karaburi-<便IDの頭8文字>`(`-f` を付けない・名指し)。
137
+ - ★**作業ツリーの中で直しを外さない**(戻し忘れがそのまま PR に入る)。★**`git worktree` を使わない**:囲いが `.git/worktrees` を読み取り専用で重ねるので、`git worktree remove` が「Device or resource busy」で止まり、職人が片付けられない(便 4cf71dde で再現)。複製なら囲いにも番人にも止められずに片付く。
138
+ - 複製の中でも記録に入れるときは名指しで `git add <ファイル>`(鉄則1-④)。複製は GitHub へ push しない。
135
139
  14. **`.env` 類(`.env`・`.env.local`・`.env.example` などの雛形も含む)は職人が書き換えない(えふさん確定 2026-09-18・便 3d11f913)**
136
140
  - **番人は `.env` 本体も `.env.example` などの雛形も、書き込みを止める**(Write/Edit の設定 deny と、Bash の `echo`/`tee`/`sed -i`/`cp`/`mv` などを、経路を問わず同じ結果にそろえた)。読み込みは今までどおり(雛形は読める・本物は読めない)。
137
141
  - **なぜ。** 雛形(`.env.example`)は普通は秘密値を置かないが、「exampleだから常に安全」と番人へ例外を作ると、将来誤って実値が書かれたときの穴になる(利便性より安全側・えふさん確定)。