@tyhld/conductor 0.12.93 → 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) {
@@ -1031,7 +1064,12 @@ export function buildWorkerPath(currentPath, claudeBin, nodeExecPath = process.e
1031
1064
  const rest = (currentPath ?? '').split(':').filter((d) => d !== '' && !front.includes(d));
1032
1065
  return [...front, ...rest].join(':');
1033
1066
  }
1034
- export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
1067
+ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume,
1068
+ /**
1069
+ * resume のときに続行の指示へ足す文(例「本番操作が終わりました。前の値 → 後の値」)。
1070
+ * ★渡さなければ今までと1バイトも同じ続行の指示になる。
1071
+ */
1072
+ resumeNote) {
1035
1073
  // 前の失敗の理由を持ち越さない(理由の無い失敗に古い理由が付かないように)
1036
1074
  lastSpawnError = '';
1037
1075
  const resolved = resolveClaudeBin();
@@ -1159,7 +1197,7 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
1159
1197
  noteHandoffFailed(`職人の処理へ本文を渡せませんでした(標準入力: ${err.message})`);
1160
1198
  });
1161
1199
  const text = isResume
1162
- ? RESUME_PROMPT
1200
+ ? buildResumePrompt(resumeNote)
1163
1201
  : buildSendText(cmd.body, cfg.site, buildAnchor(cmd.id), kindOf(cfg));
1164
1202
  try {
1165
1203
  // ★書き出しが本当に流れ切ってから 'done' にする(返事を待たずに「渡せた」と言わない)。
@@ -1740,6 +1778,306 @@ export async function restoreHeadlessOrphans(cfg, state) {
1740
1778
  `孤児を復元して再開しました (session=${sessionId}, attempt ${attempt}/${HEADLESS_MAX_ATTEMPTS})`);
1741
1779
  }
1742
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
+ // ─────────────────────────────────────────────────────────────────────────────
1743
2081
  // 取り残しの後始末(便 6ebba0d0 の項目4)
1744
2082
  // ─────────────────────────────────────────────────────────────────────────────
1745
2083
  /**
@@ -1834,7 +2172,16 @@ export async function headlessTick(cfg, state, hooks) {
1834
2172
  // 「実プロセスが無い・報告は渡し済み・管制ではまだ終わっていない」便に done を書く。
1835
2173
  // ★自分の PC・自分の現場の便だけ(判定は recoverStuckProcessedReports 側)。
1836
2174
  // ★停止トグルの確認より後に置く=止められている現場では1バイトも書かない。
1837
- 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
+ });
1838
2185
  // ── 異常終了後の自動再開(pendingRetry)──
1839
2186
  if (state.pendingRetry !== null) {
1840
2187
  const retry = state.pendingRetry;
@@ -1873,6 +2220,13 @@ export async function headlessTick(cfg, state, hooks) {
1873
2220
  `自動再開しました (session=${sessionId}, attempt ${retry.attempt}/${HEADLESS_MAX_ATTEMPTS})`);
1874
2221
  return;
1875
2222
  }
2223
+ // ── 再開の合図(本番操作窓口・取り決め v1)──
2224
+ // 管制が「本番操作が終わった」と知らせてきた便を、続きから起こす。
2225
+ // ★起こすのは同じ便の続きだけ(新しい便を機械が選ばない)。
2226
+ // ★合図が無いあいだは何もしない=ここより下は今までと同じ順で動く。
2227
+ // ★自動再開(pendingRetry)の後・新しい便を取る前に置く=走っている便を割り込まない。
2228
+ if (await handleResumeSignals(cfg, state, ctl.resumeRequests))
2229
+ return;
1876
2230
  // ── 入れ替え待ちの札(便 b7bd5e43・A2)──
1877
2231
  // 札が在るあいだは新しい便を【取りに行かない】。管制に積まれた便はそのまま待つ=
1878
2232
  // 失われない・失敗にならない・試行回数も配達回数も使わない。札が外れたら次の周で今までどおり取る。
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.93",
3
+ "version": "0.12.94",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {