@tyhld/conductor 0.12.93 → 0.12.95
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 +358 -4
- package/dist/relay.js +425 -9
- package/package.json +1 -1
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
|
-
?
|
|
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
|
-
|
|
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
|
/** 初期状態を作る。 */
|
|
@@ -977,6 +1064,215 @@ export function takeCodexNotice(site, env = process.env) {
|
|
|
977
1064
|
}
|
|
978
1065
|
return latest;
|
|
979
1066
|
}
|
|
1067
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1068
|
+
// Codex の最後の返事に書いてもらう「機械で読める囲み」(便 cfa9eeec)
|
|
1069
|
+
//
|
|
1070
|
+
// 【なぜ要るか(実測 2026-10-04・codex-cli 0.160.0・便 f8ac8674 / 4c9f5049)】
|
|
1071
|
+
// Codex の現場へ配る便の末尾が Claude 用のままで、Codex にはできない2つを求めていた:
|
|
1072
|
+
// ① ai-kana の MCP(conductor_complete)… Codex にはつながっていない=「利用可能なツールに無い」。
|
|
1073
|
+
// 毎便その断りが報告に入り、結果(outcome・PR 番号・次の独立作業候補)が管制に入らなかった。
|
|
1074
|
+
// ② 作業フォルダの外(~/.local/state/conductor/reports/)への報告ファイル … 囲いが止める
|
|
1075
|
+
// (Read-only file system)。毎回書こうとして失敗していた。
|
|
1076
|
+
// どちらも「道具を増やす」のではなく、【いま既に通っている道】=Codex の notify で届く
|
|
1077
|
+
// 「最後の返事」に機械で読める囲みを書いてもらい、配り役がそれを読んで報告ファイルを組み立てる。
|
|
1078
|
+
//
|
|
1079
|
+
// 【★受け側(管制)の口は1つも変えない】配り役が書く報告ファイルは、職人が書く物とまったく同じ形
|
|
1080
|
+
// (6欄の囲み+本文)。だから完了の道(completeWithReport)・回収の道(sendPendingReportFiles)・
|
|
1081
|
+
// 形の確かめ(reportDelivery)は1行も変わらない。
|
|
1082
|
+
//
|
|
1083
|
+
// 【★囲みが無い・読めないときは今までと同じ動きに倒す】最後の返事をそのまま報告として渡し、
|
|
1084
|
+
// 便を完了にする(実測で通っていた道を塞がない)。
|
|
1085
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1086
|
+
/** 囲みの始まりの印(★Codex の便の配達文と同じ字面を使う)。 */
|
|
1087
|
+
export const CODEX_REPORT_BEGIN = '=== CONDUCTOR_REPORT ===';
|
|
1088
|
+
/**
|
|
1089
|
+
* 囲みの終わりの印。
|
|
1090
|
+
* ★`CONDUCTOR_REPORT_END` にしない理由: 画面に報告を出させていた昔の目印(`REPORT_END`)を
|
|
1091
|
+
* 字面で含んでしまい、「古い指示が残っていないか」を見る検査と紛れるため(2026-09-07 に廃止した方式)。
|
|
1092
|
+
*/
|
|
1093
|
+
export const CODEX_REPORT_END = '=== END_CONDUCTOR_REPORT ===';
|
|
1094
|
+
/**
|
|
1095
|
+
* 最後の返事から囲みの中身を切り出す(★印の行は含めない)。見つからなければ null。
|
|
1096
|
+
* - 囲みが閉じていなければ、返事の最後までを囲みの中身として読む(途中で切れた返事を救う)。
|
|
1097
|
+
* - 印の行に飾り(``` ・ ** ・ # ・ 引用の >)が付いていても読む(会話の返事は飾られやすい)。
|
|
1098
|
+
* 返り: inner=囲みの中の行/rest=囲みを外した残り(=報告の本文になる)。
|
|
1099
|
+
*/
|
|
1100
|
+
export function sliceCodexReportBlock(reply) {
|
|
1101
|
+
const lines = String(reply ?? '').replace(/\r/g, '').split('\n');
|
|
1102
|
+
const begin = lines.findIndex((l) => isCodexReportMark(l, 'CONDUCTOR_REPORT'));
|
|
1103
|
+
if (begin < 0)
|
|
1104
|
+
return null;
|
|
1105
|
+
let end = -1;
|
|
1106
|
+
for (let i = begin + 1; i < lines.length; i += 1) {
|
|
1107
|
+
if (isCodexReportMark(lines[i], 'END_CONDUCTOR_REPORT')) {
|
|
1108
|
+
end = i;
|
|
1109
|
+
break;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
const inner = lines.slice(begin + 1, end < 0 ? lines.length : end);
|
|
1113
|
+
const rest = [...lines.slice(0, begin), ...(end < 0 ? [] : lines.slice(end + 1))]
|
|
1114
|
+
.join('\n')
|
|
1115
|
+
.trim();
|
|
1116
|
+
return { inner, rest };
|
|
1117
|
+
}
|
|
1118
|
+
/** 印の行か(飾り・前後の `=` を落として、語そのものと突き合わせる)。 */
|
|
1119
|
+
function isCodexReportMark(line, word) {
|
|
1120
|
+
const bare = String(line ?? '')
|
|
1121
|
+
.trim()
|
|
1122
|
+
.replace(/^[`*#>\s]+/, '')
|
|
1123
|
+
.replace(/[`*\s]+$/, '')
|
|
1124
|
+
.replace(/^=+/, '')
|
|
1125
|
+
.replace(/=+$/, '')
|
|
1126
|
+
.trim()
|
|
1127
|
+
.toUpperCase();
|
|
1128
|
+
return bare === word;
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* 囲みの中身を欄ごとに読む(★読み方は parseReport の囲みと同じ=「key: 値」と「- 」の箇条書き)。
|
|
1132
|
+
* 囲みが無ければ null。
|
|
1133
|
+
*/
|
|
1134
|
+
export function parseCodexReportBlock(reply) {
|
|
1135
|
+
const block = sliceCodexReportBlock(reply);
|
|
1136
|
+
if (block === null)
|
|
1137
|
+
return null;
|
|
1138
|
+
const fields = new Map();
|
|
1139
|
+
const evidence = [];
|
|
1140
|
+
let lastKey = '';
|
|
1141
|
+
for (const line of block.inner) {
|
|
1142
|
+
const bullet = /^\s*[-*]\s+(.*)$/.exec(line);
|
|
1143
|
+
if (bullet !== null) {
|
|
1144
|
+
if (lastKey === 'evidence' && bullet[1].trim() !== '')
|
|
1145
|
+
evidence.push(bullet[1].trim());
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
1148
|
+
const kv = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/.exec(line);
|
|
1149
|
+
if (kv === null)
|
|
1150
|
+
continue; // 読めない行は捨てる(欄の解釈を壊さない)
|
|
1151
|
+
lastKey = kv[1].toLowerCase();
|
|
1152
|
+
const value = kv[2].trim();
|
|
1153
|
+
if (lastKey === 'evidence') {
|
|
1154
|
+
if (value !== '')
|
|
1155
|
+
evidence.push(value);
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
fields.set(lastKey, value);
|
|
1159
|
+
}
|
|
1160
|
+
return {
|
|
1161
|
+
instructionId: nonEmpty(fields.get('instruction_id')),
|
|
1162
|
+
site: nonEmpty(fields.get('site')),
|
|
1163
|
+
outcome: pickOutcome(fields.get('outcome')),
|
|
1164
|
+
createdAt: nonEmpty(fields.get('created_at')),
|
|
1165
|
+
evidence,
|
|
1166
|
+
prNumber: pickPrNumber(fields.get('pr_number')),
|
|
1167
|
+
nextWork: nonEmpty(fields.get('next_work')),
|
|
1168
|
+
rest: block.rest,
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* outcome の語を1つだけ取り出す(★2つ以上書かれていたら「読めない」に倒す)。
|
|
1173
|
+
* 配達文の説明文(「pushed か no_change か blocked のどれか1つ」)をそのまま写した返事を
|
|
1174
|
+
* pushed と読んでしまわないため。
|
|
1175
|
+
*/
|
|
1176
|
+
function pickOutcome(raw) {
|
|
1177
|
+
const text = String(raw ?? '');
|
|
1178
|
+
const hit = REPORT_OUTCOMES.filter((o) => new RegExp(`(^|[^a-z_])${o}([^a-z_]|$)`).test(text));
|
|
1179
|
+
return hit.length === 1 ? hit[0] : null;
|
|
1180
|
+
}
|
|
1181
|
+
/** PR 番号(数字だけ)。「なし」「-」など数字が無ければ null。 */
|
|
1182
|
+
function pickPrNumber(raw) {
|
|
1183
|
+
const m = /(\d{1,7})/.exec(String(raw ?? ''));
|
|
1184
|
+
if (m === null)
|
|
1185
|
+
return null;
|
|
1186
|
+
const n = Number(m[1]);
|
|
1187
|
+
return Number.isSafeInteger(n) && n > 0 ? n : null;
|
|
1188
|
+
}
|
|
1189
|
+
/** 囲みの instruction_id が、いま追っている便の物か(頭だけ書かれていても通す)。 */
|
|
1190
|
+
export function codexReportIdMatches(blockId, commandId) {
|
|
1191
|
+
if (blockId === null)
|
|
1192
|
+
return true; // 書かれていない=配り役が知っている便IDで埋める
|
|
1193
|
+
const got = blockId.trim();
|
|
1194
|
+
if (got === commandId)
|
|
1195
|
+
return true;
|
|
1196
|
+
return got.length >= 8 && commandId.startsWith(got);
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* 囲みから【職人が書く物とまったく同じ形の報告】を組み立てる。読めなければ null。
|
|
1200
|
+
*
|
|
1201
|
+
* 【★読めたと認める条件】囲みが在る/instruction_id が食い違わない/site が食い違わない/
|
|
1202
|
+
* outcome が3つの語のどれか1つ/evidence が1件以上。
|
|
1203
|
+
* ★instruction_id・site は【配り役が知っている値】で書く(職人の写し間違いで別の便の報告にしない)。
|
|
1204
|
+
* 【created_at が無いとき】配り役が受け取った時刻を入れ、入れたことを本文に1行で書く(作り話にしない)。
|
|
1205
|
+
*/
|
|
1206
|
+
export function buildCodexReportText(reply, opts) {
|
|
1207
|
+
const block = parseCodexReportBlock(reply);
|
|
1208
|
+
if (block === null)
|
|
1209
|
+
return null;
|
|
1210
|
+
if (!codexReportIdMatches(block.instructionId, opts.commandId))
|
|
1211
|
+
return null;
|
|
1212
|
+
if (block.site !== null && block.site !== opts.site)
|
|
1213
|
+
return null;
|
|
1214
|
+
if (block.outcome === null)
|
|
1215
|
+
return null;
|
|
1216
|
+
if (block.evidence.length === 0)
|
|
1217
|
+
return null;
|
|
1218
|
+
const filledAt = block.createdAt === null ? new Date(opts.now ?? Date.now()).toISOString() : null;
|
|
1219
|
+
const head = [
|
|
1220
|
+
'---',
|
|
1221
|
+
`instruction_id: ${opts.commandId}`,
|
|
1222
|
+
`site: ${opts.site}`,
|
|
1223
|
+
`outcome: ${block.outcome}`,
|
|
1224
|
+
`created_at: ${block.createdAt ?? filledAt}`,
|
|
1225
|
+
'evidence:',
|
|
1226
|
+
...block.evidence.map((e) => ` - ${e}`),
|
|
1227
|
+
'---',
|
|
1228
|
+
'',
|
|
1229
|
+
];
|
|
1230
|
+
const body = [
|
|
1231
|
+
'(この報告は、職人の最後の返事の囲みから配り役が組み立てました)',
|
|
1232
|
+
`結末(outcome): ${block.outcome}`,
|
|
1233
|
+
`PR番号: ${block.prNumber === null ? 'なし' : block.prNumber}`,
|
|
1234
|
+
`次の独立作業候補: ${block.nextWork ?? '(囲みに書かれていません)'}`,
|
|
1235
|
+
...(filledAt === null ? [] : [`created_at は囲みに無かったので、配り役が受け取った時刻を入れました`]),
|
|
1236
|
+
'',
|
|
1237
|
+
block.rest,
|
|
1238
|
+
];
|
|
1239
|
+
const text = `${head.join('\n')}${body.join('\n')}`.trim();
|
|
1240
|
+
// ★形が整っていない物は書かない(書きかけを完了の材料にしない・便 461be339 と同じ判定を使う)。
|
|
1241
|
+
return reportShapeMissing(text).length === 0 ? text : null;
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* Codex の最後の返事の囲みを読み、報告ファイル(職人が書く物と同じ形)を置く。
|
|
1245
|
+
* - 既に報告ファイルが在るときは【上書きしない】(職人が書けた現場を壊さない)。
|
|
1246
|
+
* - 囲みが無い・読めない・書けないときは wrote:false=呼び出し側は今までの動きに倒す。
|
|
1247
|
+
*/
|
|
1248
|
+
export function intakeCodexReport(reply, opts) {
|
|
1249
|
+
const env = opts.env;
|
|
1250
|
+
if (reportPhase(opts.commandId, env) !== 'none') {
|
|
1251
|
+
return { wrote: false, file: null, note: '報告ファイルが既にあるので囲みからは作りません' };
|
|
1252
|
+
}
|
|
1253
|
+
const text = buildCodexReportText(reply, { commandId: opts.commandId, site: opts.site, now: opts.now });
|
|
1254
|
+
if (text === null) {
|
|
1255
|
+
return {
|
|
1256
|
+
wrote: false,
|
|
1257
|
+
file: null,
|
|
1258
|
+
note: '最後の返事に機械で読める囲みが無い(または読めない)ので、返事をそのまま報告として渡します',
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
const file = path.join(reportWriteDir(env), reportFileName(opts.commandId));
|
|
1262
|
+
try {
|
|
1263
|
+
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
1264
|
+
writeFileSync(file, `${text}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
1265
|
+
}
|
|
1266
|
+
catch (err) {
|
|
1267
|
+
return {
|
|
1268
|
+
wrote: false,
|
|
1269
|
+
file: null,
|
|
1270
|
+
note: '囲みは読めましたが報告ファイルを置けませんでした(返事をそのまま報告として渡します): ' +
|
|
1271
|
+
(err instanceof Error ? err.message : String(err)),
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
return { wrote: true, file, note: `最後の返事の囲みから報告ファイルを組み立てました: ${file}` };
|
|
1275
|
+
}
|
|
980
1276
|
export function sliceForDoneScan(text, anchor, wide) {
|
|
981
1277
|
const below = sliceBelowAnchor(text, anchor);
|
|
982
1278
|
if (below !== null)
|
|
@@ -1156,8 +1452,9 @@ export function buildSendText(body, site, anchor, kind = 'claude', context) {
|
|
|
1156
1452
|
? anchor.slice(SEND_ANCHOR_PREFIX.length + 1)
|
|
1157
1453
|
: anchor;
|
|
1158
1454
|
// ★Claude の職人には「便の終え方」の1行だけを付ける(書き方の正本は鉄則の「便の終え方」の節)。
|
|
1159
|
-
// ★Codex
|
|
1160
|
-
|
|
1455
|
+
// ★Codex の職人には Codex にできることだけを頼む全文を付ける(便 cfa9eeec・codexCompletionGuide)。
|
|
1456
|
+
// 鉄則(~/.claude/skills)は Codex には届かないので、全文で渡すのは今までどおり。
|
|
1457
|
+
const guide = kind === 'codex' ? codexCompletionGuide(site, commandId) : shortCompletionLine(site, commandId);
|
|
1161
1458
|
const full = `${body}\n\n${dispatchContextBlock(context)}${guide}` +
|
|
1162
1459
|
// アンカー(管理用ジョブ印)は必ず本文の最後に置く。完了検出の境界として使う。
|
|
1163
1460
|
`(管理用ジョブ印・変更不要: ${anchor})`;
|
|
@@ -1203,9 +1500,14 @@ export function shortCompletionLine(site, commandId) {
|
|
|
1203
1500
|
`完了の知らせ: conductor_complete を commandId="${commandId}" で1回だけ(nextWork は必ず書く)。`);
|
|
1204
1501
|
}
|
|
1205
1502
|
/**
|
|
1206
|
-
*
|
|
1207
|
-
*
|
|
1208
|
-
*
|
|
1503
|
+
* 便の終え方の【全文=正本の物差し】。
|
|
1504
|
+
*
|
|
1505
|
+
* ★この全文は、もう誰にも送らない(便 cfa9eeec)。本物は次の2つ:
|
|
1506
|
+
* ・Claude の職人 … 1行(shortCompletionLine)+鉄則の「便の終え方」の節
|
|
1507
|
+
* ・Codex の職人 … Codex にできることだけを頼む全文(codexCompletionGuide)
|
|
1508
|
+
* ★残してある理由: 「寄せる前の決まりを1つも失っていない」ことを測る物差しだから
|
|
1509
|
+
* (試験 relay-finish-guide-equivalence が、この全文と「1行+鉄則の節」を1つずつ突き合わせる)。
|
|
1510
|
+
* ここに書いてある決まりは、鉄則の「便の終え方」の節と中身を同じに保つ。
|
|
1209
1511
|
*/
|
|
1210
1512
|
export function fullCompletionGuide(site, commandId) {
|
|
1211
1513
|
const marker = `DONE:${site}`;
|
|
@@ -1261,6 +1563,60 @@ export function fullCompletionGuide(site, commandId) {
|
|
|
1261
1563
|
// ★nextWork(次の独立作業候補)は必ず書く(conductor_complete の説明と同じ・便 a5306df2 で両方にそろえた)。
|
|
1262
1564
|
`nextWork(次の独立作業候補)は必ず書いてください(無ければ「なし」と理由)。\n\n`);
|
|
1263
1565
|
}
|
|
1566
|
+
/**
|
|
1567
|
+
* Codex の職人へ付ける完了案内(便 cfa9eeec・★Claude 向けは shortCompletionLine)。
|
|
1568
|
+
*
|
|
1569
|
+
* 【なぜ分けたか(実測 2026-10-04・codex-cli 0.160.0・便 f8ac8674 / 4c9f5049)】
|
|
1570
|
+
* Claude 用の全文は、Codex にできない2つを求めていた=毎便、無駄な失敗になっていた:
|
|
1571
|
+
* ① ai-kana の MCP(完了の知らせの道具)… Codex にはつながっていない=呼べない。
|
|
1572
|
+
* ② 作業フォルダの外への報告ファイル … 囲いが止める(Read-only file system)。
|
|
1573
|
+
* どちらも頼まない。かわりに【いま既に通っている道】=Codex の notify で届く「最後の返事」に、
|
|
1574
|
+
* 機械で読める囲みを書いてもらう。配り役がそれを読んで、職人が書く物と同じ形の報告ファイルを
|
|
1575
|
+
* 組み立て、いまの完了の道へそのまま流す(intakeCodexReport・受け側の口は1つも変えない)。
|
|
1576
|
+
*
|
|
1577
|
+
* 【★.git への書き込み(実測の違い)】便 f8ac8674 は「止められたら回避せず報告」だけを書いていたので、
|
|
1578
|
+
* Codex は承認を求めずに blocked で終えた。便 4c9f5049 で「正規の手順で承認を1回だけ求めてよい」と
|
|
1579
|
+
* 書いたら、承認されて記録(コミット 04c108f)まで通った。その1〜2文をここに入れておく。
|
|
1580
|
+
*
|
|
1581
|
+
* 【★この文に入れない物(試験で固定)】完了の知らせの道具の名前・報告ファイルの置き場。
|
|
1582
|
+
* どちらも Codex にはできない=書くと毎便失敗して報告が汚れる。
|
|
1583
|
+
*/
|
|
1584
|
+
export function codexCompletionGuide(site, commandId) {
|
|
1585
|
+
const marker = `DONE:${site}`;
|
|
1586
|
+
return (
|
|
1587
|
+
// ★確認カードを非エンジニアが読める日本語にする(調査 2987cb8f)。Codex でも確認は同じ形で出る。
|
|
1588
|
+
`作業中にユーザー確認(許可を求めるプロンプト)が出る操作では、その一言説明(description)を` +
|
|
1589
|
+
`英語ではなく日本語で、「プルリクエストを作成する」「テストを実行する」のように` +
|
|
1590
|
+
`「何をするか」が一目で分かる簡潔な形(〜を作成する/実行する/削除する 等)で書いてください` +
|
|
1591
|
+
`(この説明は管制の確認カードにそのまま表示され、非エンジニアのお客さまが読んで許可を判断します)。\n\n` +
|
|
1592
|
+
// ★記録(.git への書き込み)が囲いに止められたときの進み方(実測 f8ac8674 → 4c9f5049)。
|
|
1593
|
+
`記録(git add・git commit)が囲い(サンドボックス)に止められたときは、正規の手順で承認を1回だけ求めてよいです。` +
|
|
1594
|
+
`断られた・求める手段が無かったときは、別のやり方(設定の書き換え・囲いを外す起動・.git を別の場所に作る 等)で` +
|
|
1595
|
+
`通そうとせず、そのまま報告して終えてください。\n\n` +
|
|
1596
|
+
// ★報告は「最後の返事」に書いてもらう(作業フォルダの外へは書けない=囲いが止める)。
|
|
1597
|
+
`作業がすべて完了したら、報告を【この会話の最後の返事】に書いてください` +
|
|
1598
|
+
`(変更ファイル・要点・チェック結果を簡潔に)。` +
|
|
1599
|
+
`★報告を作業フォルダの外のファイルへ書こうとしないでください(囲いが止めます)。\n` +
|
|
1600
|
+
`★最後の返事の先頭に、次の囲みをそのまま付けてください(ここが機械の読み口です。1行に1項目・左の語と「: 」は変えない)。\n` +
|
|
1601
|
+
`1行目: ${CODEX_REPORT_BEGIN}\n` +
|
|
1602
|
+
`2行目: instruction_id: ${commandId}\n` +
|
|
1603
|
+
`3行目: site: ${site}\n` +
|
|
1604
|
+
`4行目: outcome: pushed / no_change / blocked のどれか1つだけを書く` +
|
|
1605
|
+
`(pushed=変更して送った、no_change=変更不要だった、blocked=衝突・要判断・許可待ち等で完了できなかった。★選んだ1語だけを書く)\n` +
|
|
1606
|
+
`5行目: created_at: 書いた日時(例 2026-10-04T18:00:00+09:00)\n` +
|
|
1607
|
+
`6行目: evidence:(この行は「evidence:」だけにして、次の行から「- 」で始めて1行に1つ書く。` +
|
|
1608
|
+
`例「- PR https://github.com/...」「- CI test pass」「- main に入った」。★1件以上必ず書く)\n` +
|
|
1609
|
+
`次の行: pr_number: PR 番号(数字だけ。無ければ なし)\n` +
|
|
1610
|
+
`次の行: next_work: 次の独立作業候補(無ければ「なし」と、なぜ無いかの理由)\n` +
|
|
1611
|
+
`最後の行: ${CODEX_REPORT_END}\n` +
|
|
1612
|
+
`この囲みの下に、いつもどおりの報告本文を書いてください(本文が空だと未完成と見なされます)。\n` +
|
|
1613
|
+
`そして返事のいちばん最後の行に ${marker} とだけ出力してください。\n\n` +
|
|
1614
|
+
// ★道具は呼ばせない(Codex にはつながっていない)。囲みが書けなかったときの倒れ方も書いておく。
|
|
1615
|
+
`★完了の知らせと、管制へ結果(outcome・PR 番号・次の独立作業候補)を入れる作業は、配り役が行います。` +
|
|
1616
|
+
`あなたは上の囲みと ${marker} を書くだけでよく、道具(MCP)を呼ぶ必要はありません。` +
|
|
1617
|
+
`囲みが書けなかったときも、いつもどおり最後の返事に報告を書いて ${marker} で終えてください` +
|
|
1618
|
+
`(配り役がその返事をそのまま報告として受け取ります)。\n\n`);
|
|
1619
|
+
}
|
|
1264
1620
|
// ───────────────────────────────────────────────────────────────────────────
|
|
1265
1621
|
// tmux I/O(失敗は握りつぶして null/false。送らない側に倒すため)
|
|
1266
1622
|
// ───────────────────────────────────────────────────────────────────────────
|
|
@@ -2574,7 +2930,12 @@ export async function fetchControl(cfg) {
|
|
|
2574
2930
|
// per-site 子トグル。boolean ならその値、欠落/不正は undefined=ON(稼働)フォールバック。
|
|
2575
2931
|
const enabledRaw = data?.relayEnabled;
|
|
2576
2932
|
const relayEnabled = typeof enabledRaw === 'boolean' ? enabledRaw : undefined;
|
|
2577
|
-
|
|
2933
|
+
// ★再開の合図(取り決め v1)。欄が無い管制では空になる=今までと同じ動きになる。
|
|
2934
|
+
const signals = parseResumeSignals(data?.resumeRequests);
|
|
2935
|
+
return {
|
|
2936
|
+
relayHalted: halted, ok: true, detail: '', relayEnabled,
|
|
2937
|
+
...(signals.length > 0 ? { resumeRequests: signals } : {}),
|
|
2938
|
+
};
|
|
2578
2939
|
}
|
|
2579
2940
|
catch (err) {
|
|
2580
2941
|
return {
|
|
@@ -2584,6 +2945,48 @@ export async function fetchControl(cfg) {
|
|
|
2584
2945
|
};
|
|
2585
2946
|
}
|
|
2586
2947
|
}
|
|
2948
|
+
/**
|
|
2949
|
+
* 再開の合図に返事をする(取り決め v1)。
|
|
2950
|
+
* POST {url}/api/conductor/resume-signals/{合図ID}/ack
|
|
2951
|
+
* body: { commandId, site, machine, accepted, final, reason?, attempt? }
|
|
2952
|
+
*
|
|
2953
|
+
* 【accepted】起こしたか(true)/起こさなかったか(false)。
|
|
2954
|
+
* 【final】★この合図はもう使わないか。
|
|
2955
|
+
* - true … 畳んでよい(起こした/却下・失敗で起こさないと決めた)。
|
|
2956
|
+
* - false … まだ畳まない(照合できない・中央の状態を確かめられない=次の周でやり直す)。
|
|
2957
|
+
*
|
|
2958
|
+
* 【届かなくても二重には起こさない】「同じ合図を1回だけ」は中継の手元の記録で守る。
|
|
2959
|
+
* だから返事が落ちても、同じ合図で2回起こすことは起きない。返事は「管制が畳むための知らせ」。
|
|
2960
|
+
* ★返事の口が無い管制(404)でも、起こす側の動きは変えない(false を返すだけ)。
|
|
2961
|
+
*/
|
|
2962
|
+
export async function ackResumeSignal(cfg, signal, opts) {
|
|
2963
|
+
const url = `${cfg.url}/api/conductor/resume-signals/${encodeURIComponent(signal.id)}/ack`;
|
|
2964
|
+
try {
|
|
2965
|
+
const res = await fetch(url, {
|
|
2966
|
+
method: 'POST',
|
|
2967
|
+
headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
|
|
2968
|
+
body: JSON.stringify({
|
|
2969
|
+
commandId: signal.commandId,
|
|
2970
|
+
site: signal.site,
|
|
2971
|
+
machine: signal.machine,
|
|
2972
|
+
accepted: opts.accepted,
|
|
2973
|
+
final: opts.final,
|
|
2974
|
+
...(opts.reason !== undefined ? { reason: opts.reason.slice(0, 500) } : {}),
|
|
2975
|
+
...(opts.attempt !== undefined ? { attempt: opts.attempt } : {}),
|
|
2976
|
+
}),
|
|
2977
|
+
signal: AbortSignal.timeout(RELAY_FETCH_TIMEOUT_MS),
|
|
2978
|
+
});
|
|
2979
|
+
if (!res.ok) {
|
|
2980
|
+
log(`site=${cfg.site} 再開の合図の返事 -> HTTP ${res.status} NG`);
|
|
2981
|
+
return false;
|
|
2982
|
+
}
|
|
2983
|
+
return true;
|
|
2984
|
+
}
|
|
2985
|
+
catch (err) {
|
|
2986
|
+
log(`site=${cfg.site} 再開の合図の返事 -> 失敗: ${err instanceof Error ? err.message : String(err)}`);
|
|
2987
|
+
return false;
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2587
2990
|
/**
|
|
2588
2991
|
* 自分宛(machine+site)の queued 指示を取得。先頭1件だけ返す(1現場同時1指示)。
|
|
2589
2992
|
* 空/エラー/不正は null。
|
|
@@ -3641,9 +4044,21 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
3641
4044
|
if (kindOf(cfg) === 'codex') {
|
|
3642
4045
|
const notice = takeCodexNotice(cfg.site);
|
|
3643
4046
|
if (notice !== null) {
|
|
4047
|
+
// ★最後の返事の囲みを読み、職人が書く物と同じ形の報告ファイルを組み立てる(便 cfa9eeec)。
|
|
4048
|
+
// 書けたら【いまの完了の道】へそのまま流す(受け側の口は1つも変えない)。
|
|
4049
|
+
// 囲みが無い・読めないときは、今までと同じ動き(返事をそのまま報告にして完了)に倒す。
|
|
4050
|
+
const intake = intakeCodexReport(notice.report, {
|
|
4051
|
+
commandId: state.pending.id,
|
|
4052
|
+
site: cfg.site,
|
|
4053
|
+
});
|
|
4054
|
+
log(`site=${cfg.site} id=${state.pending.id} ${intake.note}`);
|
|
3644
4055
|
const report = notice.report.trim().length > 0 ? notice.report : null;
|
|
3645
|
-
|
|
3646
|
-
|
|
4056
|
+
const written = intake.wrote
|
|
4057
|
+
? await completeWithReport(cfg, state.pending.id, log)
|
|
4058
|
+
: await patchStatus(cfg, state.pending.id, 'done', report);
|
|
4059
|
+
if (written) {
|
|
4060
|
+
log(`site=${cfg.site} id=${state.pending.id} done(Codexの通知` +
|
|
4061
|
+
`${intake.wrote ? '・囲みから報告ファイル' : report != null ? '・報告添付' : ''})`);
|
|
3647
4062
|
// ★終了の事実を同じ項目で届ける(便 915694ac・1-3)。この配り方では実プロセスを
|
|
3648
4063
|
// 中継が持っていないので、終わりは【つかめていない】と伝える(終了コードは null)。
|
|
3649
4064
|
// 届けるのは「中継が完了を確かめた」という事実だけ。★作り話の終了を送らない。
|
|
@@ -3656,7 +4071,8 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
3656
4071
|
exitSignal: null,
|
|
3657
4072
|
reportDelivered: report != null || reportDelivery(state.pending.id).delivered,
|
|
3658
4073
|
processEndObserved: false,
|
|
3659
|
-
detail: '配り役が完了の知らせで確かめた(職人の処理は中継が持っていない)'
|
|
4074
|
+
detail: '配り役が完了の知らせで確かめた(職人の処理は中継が持っていない)' +
|
|
4075
|
+
(intake.wrote ? '/最後の返事の囲みから報告ファイルを組み立てた' : ''),
|
|
3660
4076
|
}, { logLine: log });
|
|
3661
4077
|
state.pending = null;
|
|
3662
4078
|
}
|