@tyhld/conductor 0.12.50 → 0.12.52
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/relay.js +305 -32
- package/package.json +1 -1
- package/skills/conductor-craftsman/SKILL.md +69 -0
package/dist/relay.js
CHANGED
|
@@ -925,8 +925,14 @@ export function isTerminalCommandStatus(status) {
|
|
|
925
925
|
* cancelled/canceled/abandoned のみを見るので、再配達の可否ではこの2つを足して判断する。
|
|
926
926
|
* - dismissed: ②画面で人が却下(devlog-tracker: commands/[id]/dismiss/route.ts が status='dismissed')
|
|
927
927
|
* - timeout : 中央 sweep が doneAt を打って終端化(devlog-tracker: lib/conductor.ts の sweep)
|
|
928
|
+
* - instruction_missing : 本文未着(便 7d810bbb)。管制が占有を外した=配り役は追わない
|
|
928
929
|
*/
|
|
929
|
-
const CENTRAL_EXTRA_TERMINAL_STATUSES = new Set(['dismissed', 'timeout']);
|
|
930
|
+
const CENTRAL_EXTRA_TERMINAL_STATUSES = new Set(['dismissed', 'timeout', 'instruction_missing']);
|
|
931
|
+
/**
|
|
932
|
+
* 管制の「本文未着」(★便 7d810bbb・中央 ai-kana 214c474e)。職人は仕事を受け取っていない。
|
|
933
|
+
* 管制は現場の占有を外し、人の判断(配り直す/中身を見る)を待つ。★配り役はこの便を追わない・配り直さない。
|
|
934
|
+
*/
|
|
935
|
+
export const INSTRUCTION_MISSING_STATUS = 'instruction_missing';
|
|
930
936
|
/**
|
|
931
937
|
* 中央がこの指示を「もう終わっている」と言っているか(純粋関数)。
|
|
932
938
|
* done / failed / cancelled / canceled / abandoned / dismissed / timeout のいずれか。
|
|
@@ -1395,11 +1401,31 @@ export function pendingReportIds(env, limit = PENDING_REPORTS_MAX) {
|
|
|
1395
1401
|
// ・★配達役は完了にしない・便の状態の値を書かない(中央も完了にしない=止まった便を「回収必要」まで進めるだけ)。
|
|
1396
1402
|
// ・★配達役がいま追っている便の報告は送らない。それは完了の道(completeWithReport)が読んで完了と一緒に
|
|
1397
1403
|
// 渡す。先に processed/ へ移すと、completeWithReport が「渡し済み」とみなして完了を書かなくなる。
|
|
1404
|
+
// ★例外は「作業なし・本文未着」の報告だけ(isInstructionMissingReport・便 7d810bbb)。この報告は
|
|
1405
|
+
// 完了にしない物なので、完了の道が書かなくなってもよい(むしろ完了にしてはいけない)。
|
|
1398
1406
|
// ───────────────────────────────────────────────────────────────────────────
|
|
1399
1407
|
/** 1回に送る報告ファイルの件数の上限(★中央の FILE_INTAKE_LIMIT と同じ値にすること)。 */
|
|
1400
1408
|
export const REPORT_FILES_BATCH = 20;
|
|
1401
1409
|
/** 1周で送る件数の上限(多すぎる分は次の周に回す=落とさない)。 */
|
|
1402
1410
|
export const REPORT_FILES_MAX_PER_ROUND = 100;
|
|
1411
|
+
/**
|
|
1412
|
+
* 報告ファイルが「作業なし・本文未着」を示しているか(★便 7d810bbb)。純粋関数。
|
|
1413
|
+
*
|
|
1414
|
+
* 【なぜ要るか(実障害 af960f79)】本文を受け取れなかった職人が「本文未着・作業なし」の報告を置いたが、
|
|
1415
|
+
* その便は配り役が追跡中だったので送られず(下の決まり)、30分の時間切れまで管制に届かなかった。
|
|
1416
|
+
* 【★ここで決めるのは「追跡中でも送ってよいか」だけ】完了にはしない・便の状態の値も書かない。
|
|
1417
|
+
* 管制は中身の言葉ではなく便の状態で扱う(中央 214c474e:未着の便は完了にも PASS にもしない)。
|
|
1418
|
+
* 【判定】両方がそろったときだけ true(片方だけでは送らない=通常の完了報告を巻き込まない):
|
|
1419
|
+
* ① 作業なし … `outcome: no_change`(囲みが閉じていない古い形でも行として読む)
|
|
1420
|
+
* ② 本文未着 … 「本文未着」または `instruction_missing` と書かれている
|
|
1421
|
+
*/
|
|
1422
|
+
export function isInstructionMissingReport(raw) {
|
|
1423
|
+
const text = String(raw ?? '');
|
|
1424
|
+
const parsed = parseReport(text);
|
|
1425
|
+
const noWork = parsed.outcome === 'no_change' || /^[ \t]*outcome[ \t]*:[ \t]*no_change[ \t]*$/im.test(text);
|
|
1426
|
+
const missing = /本文未着|instruction_missing/i.test(text);
|
|
1427
|
+
return noWork && missing;
|
|
1428
|
+
}
|
|
1403
1429
|
/**
|
|
1404
1430
|
* 未処理の報告ファイルの全文を中央へ送り、受け取られた物だけ処理済みにする。
|
|
1405
1431
|
* 返り: 送った件数・処理済みにした件数・未処理のまま残した件数。
|
|
@@ -1411,11 +1437,13 @@ export async function sendPendingReportFiles(cfg, opts = {}) {
|
|
|
1411
1437
|
const exclude = new Set(opts.exclude ?? []);
|
|
1412
1438
|
const items = [];
|
|
1413
1439
|
for (const id of pendingReportIds(env, REPORT_FILES_MAX_PER_ROUND).ids) {
|
|
1414
|
-
if (exclude.has(id))
|
|
1415
|
-
continue;
|
|
1416
1440
|
for (const file of pendingCandidates(id, env)) {
|
|
1417
1441
|
const raw = readIfFilled(file);
|
|
1418
1442
|
if (raw !== null) {
|
|
1443
|
+
// ★追跡中の便の報告は送らない(今までどおり)。ただし「作業なし・本文未着」だけは送る
|
|
1444
|
+
// (便 7d810bbb)。完了の道はその便を完了にしないので、ここで送らないと管制に届かない。
|
|
1445
|
+
if (exclude.has(id) && !isInstructionMissingReport(raw))
|
|
1446
|
+
break;
|
|
1419
1447
|
items.push({ id, raw });
|
|
1420
1448
|
break;
|
|
1421
1449
|
}
|
|
@@ -1785,6 +1813,144 @@ export function hasSubmittedRecord(cwd, commandId, sinceMs, env = process.env) {
|
|
|
1785
1813
|
}
|
|
1786
1814
|
return false;
|
|
1787
1815
|
}
|
|
1816
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1817
|
+
// 受領確認(配達開始 → 受領確認 → 実行開始・★えふさん確定 2026-09-23・便 7d810bbb)
|
|
1818
|
+
//
|
|
1819
|
+
// 【なぜ要るか(実障害 2026-09-23・便 af960f79/minoru)】
|
|
1820
|
+
// 会話を新しくした直後に本文の貼り付けが落ち、添え書きだけが送信された。職人は便の印も本文も
|
|
1821
|
+
// 受け取っていないのに、配り役は「送れた=配達した」として中央を【実行中(sent)】にしていたので、
|
|
1822
|
+
// 30分の時間切れまで誰も気づけなかった。中央は 214c474e で「配達中(delivering) → 受領確認 →
|
|
1823
|
+
// 実行中(sent)」と「本文未着(instruction_missing)」の道を用意した。配り役はここでその知らせを送る。
|
|
1824
|
+
//
|
|
1825
|
+
// 【★ACK の条件(えふさん確定・1文字も変えない)】
|
|
1826
|
+
// ACKは「画面に何か出た」ではなく instruction_id 一致を必須にする。
|
|
1827
|
+
// 職人画面に `CONDUCTOR_JOB:<instruction_id>` 相当の便印が実際に現れ、送ったinstruction_idと
|
|
1828
|
+
// 完全一致したことを確認して初めてACK。
|
|
1829
|
+
//
|
|
1830
|
+
// 【★「相当の便印」に会話の記録を使う理由】
|
|
1831
|
+
// ・画面の入力欄に印が見えていても、まだ送信されていないことがある(b69c1608 の実物=Enter が改行に
|
|
1832
|
+
// 取り込まれた)。それを受領と数えると、af960f79 と同じ「受け取っていないのに実行中」が起きる。
|
|
1833
|
+
// ・職人(Claude Code)は全画面表示で、送信された発言の印は数秒で流れて消える(ADR-023)。
|
|
1834
|
+
// ★職人が送信を受け付けた瞬間に、その発言が会話の記録へ書かれる。そこに【この便を貼った後の時刻で】
|
|
1835
|
+
// 【利用者の発言として】印が在り、【指示IDが完全一致】したときだけ受領とみなす。
|
|
1836
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1837
|
+
/** 指示IDとして続きうる文字(これが印の直後に続いたら「別の便の印」=一致とみなさない)。 */
|
|
1838
|
+
const JOB_ID_CHAR = /[0-9A-Za-z_-]/;
|
|
1839
|
+
/**
|
|
1840
|
+
* 文字列の中に、この便の印(`CONDUCTOR_JOB:<id>`)が【完全一致】で在るか。純粋関数。
|
|
1841
|
+
* ・`CONDUCTOR_JOB:<id>` の直後に指示IDの文字が続く物(=別の便の印・前方一致)は数えない。
|
|
1842
|
+
* ・別の便の印がいくつ在っても、この便の印が無ければ false。
|
|
1843
|
+
*/
|
|
1844
|
+
export function hasExactJobStamp(text, commandId) {
|
|
1845
|
+
if (!commandId)
|
|
1846
|
+
return false;
|
|
1847
|
+
const needle = `${SEND_ANCHOR_PREFIX}:${commandId}`;
|
|
1848
|
+
for (let at = text.indexOf(needle); at >= 0; at = text.indexOf(needle, at + 1)) {
|
|
1849
|
+
const next = text.charAt(at + needle.length);
|
|
1850
|
+
if (next === '' || !JOB_ID_CHAR.test(next))
|
|
1851
|
+
return true;
|
|
1852
|
+
}
|
|
1853
|
+
return false;
|
|
1854
|
+
}
|
|
1855
|
+
/**
|
|
1856
|
+
* この便を貼った後(sinceMs 以降)に、職人の会話の記録へ、この便の印が【利用者の発言として】
|
|
1857
|
+
* 【完全一致で】書かれたか。★受領確認(ACK)の唯一の根拠。
|
|
1858
|
+
* ・時刻が sinceMs より前の記録(=前の便・前に配った同じ便の残り)は数えない(猶予も取らない)。
|
|
1859
|
+
* ・時刻の無い記録は数えない(新しく出たと言えない)。
|
|
1860
|
+
* ・利用者の発言でない記録(職人の返答などに印が引用された物)は数えない。
|
|
1861
|
+
* ★中身は照合するだけで、どこにも写さない・記録しない。
|
|
1862
|
+
*/
|
|
1863
|
+
export function hasJobStampSince(cwd, commandId, sinceMs, env = process.env) {
|
|
1864
|
+
const dir = claudeTranscriptDir(cwd, env);
|
|
1865
|
+
const needle = `${SEND_ANCHOR_PREFIX}:${commandId}`;
|
|
1866
|
+
let names;
|
|
1867
|
+
try {
|
|
1868
|
+
names = readdirSync(dir).filter((n) => n.endsWith('.jsonl'));
|
|
1869
|
+
}
|
|
1870
|
+
catch {
|
|
1871
|
+
return false;
|
|
1872
|
+
}
|
|
1873
|
+
for (const n of names) {
|
|
1874
|
+
const file = path.join(dir, n);
|
|
1875
|
+
try {
|
|
1876
|
+
// 貼った後に書かれていないファイルは見ない(★ファイルの時刻は粗いことがあるので、ここだけ5秒の猶予。
|
|
1877
|
+
// 新しく出たかの判定そのものは、下の行ごとの時刻で猶予なしに行う)。
|
|
1878
|
+
if (statSync(file).mtimeMs < sinceMs - 5_000)
|
|
1879
|
+
continue;
|
|
1880
|
+
const text = readFileSync(file, 'utf8');
|
|
1881
|
+
if (!text.includes(needle))
|
|
1882
|
+
continue;
|
|
1883
|
+
for (const line of text.split('\n')) {
|
|
1884
|
+
if (!hasExactJobStamp(line, commandId))
|
|
1885
|
+
continue;
|
|
1886
|
+
try {
|
|
1887
|
+
const o = JSON.parse(line);
|
|
1888
|
+
const at = o.timestamp ? Date.parse(o.timestamp) : NaN;
|
|
1889
|
+
if (o.type === 'user' && Number.isFinite(at) && at >= sinceMs)
|
|
1890
|
+
return true;
|
|
1891
|
+
}
|
|
1892
|
+
catch {
|
|
1893
|
+
// 壊れた行は飛ばす。
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
catch {
|
|
1898
|
+
// 読めないファイルは飛ばす(受け取ったとは言えない)。
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
return false;
|
|
1902
|
+
}
|
|
1903
|
+
/** 印が現れなかったときに貼り直す上限(★えふさん確定「2回まで貼り直し」)。 */
|
|
1904
|
+
export const RELAY_ACK_MAX_REPASTE = 2;
|
|
1905
|
+
/**
|
|
1906
|
+
* 印が現れるのを待ち、現れなければ貼り直す(★2回まで)。★自分では中央へ何も送らない。
|
|
1907
|
+
*
|
|
1908
|
+
* 1回ぶん … 受付の確かめ(confirmSubmitted):印を待ち、入力欄に便が残っていれば Enter だけ押し直す。
|
|
1909
|
+
* 現れない … 次のどれかなら【貼り直さない】(二重に入れない・割り込まない):
|
|
1910
|
+
* ・入力欄にこちらの便が残っている(貼り直すと入力欄に2つ入る)
|
|
1911
|
+
* ・職人がアイドルでない(添え書きだけ受け取って返事をしている最中など)
|
|
1912
|
+
* どれでもなければ本文を貼り直す(上限 RELAY_ACK_MAX_REPASTE 回)。
|
|
1913
|
+
* ★受領とみなすのは stampSeen(送った指示IDと完全一致・貼った後に新しく出た印)が true のときだけ。
|
|
1914
|
+
*/
|
|
1915
|
+
export async function awaitDeliveryAck(d, o = {
|
|
1916
|
+
waitMs: RELAY_SUBMIT_CONFIRM_MS,
|
|
1917
|
+
pollMs: 1_000,
|
|
1918
|
+
maxResend: RELAY_SUBMIT_MAX_RESEND,
|
|
1919
|
+
maxRepaste: RELAY_ACK_MAX_REPASTE,
|
|
1920
|
+
}) {
|
|
1921
|
+
let repasted = 0;
|
|
1922
|
+
let resent = 0;
|
|
1923
|
+
let reason = '';
|
|
1924
|
+
for (let round = 0;; round++) {
|
|
1925
|
+
const r = await confirmSubmitted({ accepted: d.stampSeen, pendingInInput: d.pendingInInput, pressEnter: d.pressEnter, sleep: d.sleep }, o);
|
|
1926
|
+
resent += r.resent;
|
|
1927
|
+
if (r.result !== 'unconfirmed')
|
|
1928
|
+
return { received: true, repasted, resent, reason: '' };
|
|
1929
|
+
if (round >= o.maxRepaste) {
|
|
1930
|
+
reason = reason || `貼り直し${repasted}回のあとも印が現れない`;
|
|
1931
|
+
break;
|
|
1932
|
+
}
|
|
1933
|
+
if (d.pendingInInput()) {
|
|
1934
|
+
reason = '入力欄に便が残ったまま送信されない(貼り直すと二重に入るので貼り直さない)';
|
|
1935
|
+
break;
|
|
1936
|
+
}
|
|
1937
|
+
if (!(await d.idle())) {
|
|
1938
|
+
reason = '職人がアイドルでないので貼り直さなかった';
|
|
1939
|
+
continue; // この回は貼り直さずに、もう一度だけ印を待つ(回数は数える=待ちは有限)
|
|
1940
|
+
}
|
|
1941
|
+
if (d.repaste()) {
|
|
1942
|
+
repasted++;
|
|
1943
|
+
reason = '';
|
|
1944
|
+
}
|
|
1945
|
+
else {
|
|
1946
|
+
reason = '貼り直しに失敗';
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
// ★返す直前にもう一度だけ見る(最後の待ちの直後に印が出た場合を取りこぼさない)。
|
|
1950
|
+
if (d.stampSeen())
|
|
1951
|
+
return { received: true, repasted, resent, reason: '' };
|
|
1952
|
+
return { received: false, repasted, resent, reason };
|
|
1953
|
+
}
|
|
1788
1954
|
/**
|
|
1789
1955
|
* 入力欄(画面のいちばん下の ❯ から次の罫線まで)に、配達役の添え書きが残っているか。
|
|
1790
1956
|
* ★押し直してよいのは「こちらが入れた便が入力欄に残っている」ときだけ、を確かめるために使う。
|
|
@@ -2163,9 +2329,14 @@ export async function markSentAtCentral(cfg, state) {
|
|
|
2163
2329
|
return false;
|
|
2164
2330
|
if (pending.sentMarked)
|
|
2165
2331
|
return true;
|
|
2166
|
-
|
|
2332
|
+
// ★Claude の職人へ配った便は「配達中(delivering)」を書く(便 7d810bbb)。実行中(sent)へ進めるのは
|
|
2333
|
+
// 受領の知らせ(sendAckAtCentral)だけ。★それ以外(Codex・孤児の復元)は今までどおり sent。
|
|
2334
|
+
const mark = pending.markAs ?? 'sent';
|
|
2335
|
+
if (await patchStatus(cfg, pending.id, mark)) {
|
|
2167
2336
|
pending.sentMarked = true;
|
|
2168
|
-
log(
|
|
2337
|
+
log(mark === 'delivering'
|
|
2338
|
+
? `site=${cfg.site} id=${pending.id} 配達中(delivering=受領確認待ち)を管制へ記録`
|
|
2339
|
+
: `site=${cfg.site} id=${pending.id} 配達済み(sent)を管制へ記録`);
|
|
2169
2340
|
return true;
|
|
2170
2341
|
}
|
|
2171
2342
|
// 書けなかった。理由を中央に聞いて、やり直す意味があるかだけ判断する。
|
|
@@ -2178,7 +2349,7 @@ export async function markSentAtCentral(cfg, state) {
|
|
|
2178
2349
|
state.lastHeartbeatAt = null;
|
|
2179
2350
|
return false;
|
|
2180
2351
|
}
|
|
2181
|
-
if (centralStatus === 'sent') {
|
|
2352
|
+
if (centralStatus === 'sent' || (mark === 'delivering' && centralStatus === 'delivering')) {
|
|
2182
2353
|
// 既に印が付いている(別経路/409 の相手が自分自身だった等)。書き直す必要はない。
|
|
2183
2354
|
pending.sentMarked = true;
|
|
2184
2355
|
log(`site=${cfg.site} id=${pending.id} 中央は既に sent=配達済みの記録は完了とみなす`);
|
|
@@ -2188,6 +2359,72 @@ export async function markSentAtCentral(cfg, state) {
|
|
|
2188
2359
|
`(中央status=${centralStatus ?? '不明'})→次tickで再試行(★再配達はしない)`);
|
|
2189
2360
|
return false;
|
|
2190
2361
|
}
|
|
2362
|
+
/**
|
|
2363
|
+
* 受領の知らせを管制へ送る(★便 7d810bbb・中央の受け口は ai-kana 214c474e)。
|
|
2364
|
+
* POST /api/conductor/commands/<id>/ack body { site, received: true | false }
|
|
2365
|
+
* 返り { ok, id, status, applied, reason }(applied:false=管制は二重に動かさなかった)。
|
|
2366
|
+
*
|
|
2367
|
+
* 【送る条件】pending.ack が決まっていて、まだ送っておらず、★「配達中」の印を先に書けていること
|
|
2368
|
+
* (印より先に受領を送ると、管制は queued の便として受け流す=知らせが消える)。
|
|
2369
|
+
* 【★職人の作業を止めない】ここは管制へ知らせるだけ。職人の窓には1文字も触らない。
|
|
2370
|
+
* 管制が応えない・遅い(上限 RELAY_FETCH_TIMEOUT_MS)・5xx のときは false を返し、次tickでやり直す。
|
|
2371
|
+
* 【received:false を送れたら】その便の追跡をやめる(占有を外す)。★配り直しはしない
|
|
2372
|
+
* (配り直すのは人が管制で【配り直す】を押したとき=そのとき管制が queued に戻す)。
|
|
2373
|
+
*
|
|
2374
|
+
* @returns 知らせを送り終えた(または送る物が無い)なら true。
|
|
2375
|
+
*/
|
|
2376
|
+
export async function sendAckAtCentral(cfg, state, logLine = log) {
|
|
2377
|
+
const pending = state.pending;
|
|
2378
|
+
if (pending === null || pending.ack === undefined)
|
|
2379
|
+
return true;
|
|
2380
|
+
if (pending.ackSent)
|
|
2381
|
+
return true;
|
|
2382
|
+
if (!pending.sentMarked)
|
|
2383
|
+
return false; // 配達中の印が先。書けてから送る。
|
|
2384
|
+
const received = pending.ack === 'received';
|
|
2385
|
+
let done = false;
|
|
2386
|
+
try {
|
|
2387
|
+
const res = await fetch(`${cfg.url}/api/conductor/commands/${encodeURIComponent(pending.id)}/ack`, {
|
|
2388
|
+
method: 'POST',
|
|
2389
|
+
headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
|
|
2390
|
+
body: JSON.stringify({ site: cfg.site, received }),
|
|
2391
|
+
// ★上限時間は必須(無応答の管制で輪が止まるのを防ぐ・RELAY_FETCH_TIMEOUT_MS 参照)。
|
|
2392
|
+
signal: AbortSignal.timeout(RELAY_FETCH_TIMEOUT_MS),
|
|
2393
|
+
});
|
|
2394
|
+
const data = (await res.json().catch(() => null));
|
|
2395
|
+
const why = String(data?.reason ?? data?.error ?? '').slice(0, 200);
|
|
2396
|
+
if (res.ok) {
|
|
2397
|
+
done = true;
|
|
2398
|
+
logLine(`site=${cfg.site} id=${pending.id} 受領の知らせ(received:${received}) -> ` +
|
|
2399
|
+
`status=${String(data?.status ?? '?')} applied=${String(data?.applied ?? '?')}${why ? `(${why})` : ''}`);
|
|
2400
|
+
}
|
|
2401
|
+
else if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429) {
|
|
2402
|
+
// 送り直しても変わらない断り(現場ちがい・知らない便・形の誤り)。★繰り返さない。
|
|
2403
|
+
done = true;
|
|
2404
|
+
logLine(`WARN site=${cfg.site} id=${pending.id} 受領の知らせ(received:${received}) -> HTTP ${res.status}` +
|
|
2405
|
+
`(送り直さない)${why ? `: ${why}` : ''}`);
|
|
2406
|
+
}
|
|
2407
|
+
else {
|
|
2408
|
+
logLine(`site=${cfg.site} id=${pending.id} 受領の知らせ(received:${received}) -> HTTP ${res.status}` +
|
|
2409
|
+
'(次の周でやり直す・職人の作業は止めない)');
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
catch (err) {
|
|
2413
|
+
logLine(`site=${cfg.site} id=${pending.id} 受領の知らせ(received:${received}) -> 失敗: ` +
|
|
2414
|
+
`${err instanceof Error ? err.message : String(err)}(次の周でやり直す・職人の作業は止めない)`);
|
|
2415
|
+
}
|
|
2416
|
+
if (!done)
|
|
2417
|
+
return false;
|
|
2418
|
+
pending.ackSent = true;
|
|
2419
|
+
if (!received) {
|
|
2420
|
+
// ★本文未着:追跡をやめる(占有を外す)。★配り直さない=abandoned にも入れない
|
|
2421
|
+
// (人が【配り直す】を押したら管制が queued に戻す。そのときは普通に配る)。
|
|
2422
|
+
logLine(`site=${cfg.site} id=${pending.id} 本文未着として管制へ知らせた→追跡をやめる(配り直すのは人の手番)`);
|
|
2423
|
+
state.pending = null;
|
|
2424
|
+
state.lastHeartbeatAt = null;
|
|
2425
|
+
}
|
|
2426
|
+
return true;
|
|
2427
|
+
}
|
|
2191
2428
|
/**
|
|
2192
2429
|
* このtickで生存ハートビート(bare)を送るかを決める純粋関数(I/Oなし=単体テスト可能)。
|
|
2193
2430
|
*
|
|
@@ -2585,6 +2822,25 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
2585
2822
|
if (state.pending === null)
|
|
2586
2823
|
return; // 中央が終端=追跡を畳んだ。次tickで通常経路へ。
|
|
2587
2824
|
}
|
|
2825
|
+
// ── 受領の知らせ(★便 7d810bbb・送れるまで毎tick やり直す・職人の作業は止めない) ──
|
|
2826
|
+
if (state.pending.ack === 'missing') {
|
|
2827
|
+
// ★本文未着と決めた便。職人は仕事を受け取っていないので、完了検出も生存通知もしない。
|
|
2828
|
+
// 知らせを送れたら sendAckAtCentral が追跡をやめる。★配り直さない。
|
|
2829
|
+
await sendAckAtCentral(cfg, state);
|
|
2830
|
+
if (state.pending !== null && Date.now() - state.pending.sentAt > RELAY_PENDING_TIMEOUT_MS) {
|
|
2831
|
+
// 管制へ知らせられないまま上限を過ぎた。★印も書けていなければ、管制は queued のまま返す
|
|
2832
|
+
// =勝手に配り直さないよう、この常駐の中では二度と配らない(ADR-023 の穴止めと同じ)。
|
|
2833
|
+
if (!state.pending.sentMarked)
|
|
2834
|
+
state.abandoned.add(state.pending.id);
|
|
2835
|
+
log(`WARN site=${cfg.site} id=${state.pending.id} 本文未着の知らせを管制へ送れないまま上限→追跡をやめる(配り直しはしない)`);
|
|
2836
|
+
state.pending = null;
|
|
2837
|
+
state.lastHeartbeatAt = null;
|
|
2838
|
+
}
|
|
2839
|
+
return;
|
|
2840
|
+
}
|
|
2841
|
+
if (state.pending.ack === 'received' && !state.pending.ackSent) {
|
|
2842
|
+
await sendAckAtCentral(cfg, state);
|
|
2843
|
+
}
|
|
2588
2844
|
// アンカー(今回送信の境界)より下だけを見て DONE:{site} 単独行を探す。
|
|
2589
2845
|
// churn で古い DONE が押し出されても・過去ジョブの DONE が窓に残っても、誤検出/見逃しなし。
|
|
2590
2846
|
// ★アンカーが既に流れて消えている場合は、一度アンカーを見ていれば画面全体を見る
|
|
@@ -2627,7 +2883,9 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
2627
2883
|
const idle = looksIdleFor(cap, kindOf(cfg));
|
|
2628
2884
|
if (state.pending.sentMarked && idle) {
|
|
2629
2885
|
const centralStatus = await fetchCommandStatus(cfg, state.pending.id);
|
|
2630
|
-
|
|
2886
|
+
// ★本文未着(instruction_missing)も追跡をやめる(便 7d810bbb)。管制は占有を外して人の判断待ちに
|
|
2887
|
+
// 出している=ここで待ち続けると次の便が拾えない。★配り直さない(人が決める)。
|
|
2888
|
+
if (isTerminalCommandStatus(centralStatus) || centralStatus === INSTRUCTION_MISSING_STATUS) {
|
|
2631
2889
|
log(`site=${cfg.site} id=${state.pending.id} は中央で終端(status=${centralStatus})を確認` +
|
|
2632
2890
|
'(外部完了検出・キュー有無に依らず)→ 追跡解除し次tickで新規取得');
|
|
2633
2891
|
state.pending = null;
|
|
@@ -2858,6 +3116,8 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
2858
3116
|
: undefined;
|
|
2859
3117
|
const text = buildSendText(cmd.body, cfg.site, anchor, kindOf(cfg), context);
|
|
2860
3118
|
const sendStartedAt = Date.now();
|
|
3119
|
+
// ★Claude の職人へは受領確認を使う(便 7d810bbb)。Codex は会話の記録の形が違うので今までどおり。
|
|
3120
|
+
const useAck = kindOf(cfg) === 'claude';
|
|
2861
3121
|
if (!sendToTmux(cfg.site, text)) {
|
|
2862
3122
|
// 送信失敗(sendToTmux は内部で例外も握って false を返す)。status は queued のまま据え置く。
|
|
2863
3123
|
// 失敗の理由(tmux の stderr)は sendToTmux が直前の行に出している。
|
|
@@ -2881,6 +3141,7 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
2881
3141
|
delivered: true,
|
|
2882
3142
|
sentMarked: false,
|
|
2883
3143
|
screenDoneWide: false,
|
|
3144
|
+
...(useAck ? { markAs: 'delivering' } : {}),
|
|
2884
3145
|
};
|
|
2885
3146
|
// 累計配達回数を通算(絶対上限 RELAY_DELIVER_MAX の基準)。
|
|
2886
3147
|
markDelivered(state, cmd.id);
|
|
@@ -2888,33 +3149,45 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
2888
3149
|
state.lastHeartbeatAt = null;
|
|
2889
3150
|
noteReadiness(state, 'delivered', cmd.id);
|
|
2890
3151
|
log(`site=${cfg.site} id=${cmd.id} 配達しました`);
|
|
2891
|
-
//
|
|
2892
|
-
//
|
|
2893
|
-
if (kindOf(cfg) === 'claude') {
|
|
2894
|
-
const cwd = tmuxPaneCwd(cfg.site);
|
|
2895
|
-
if (cwd) {
|
|
2896
|
-
const r = await confirmSubmitted({
|
|
2897
|
-
accepted: () => hasSubmittedRecord(cwd, cmd.id, sendStartedAt),
|
|
2898
|
-
pendingInInput: () => {
|
|
2899
|
-
const c = tmuxCapture(cfg.site);
|
|
2900
|
-
return c !== null && hasPendingDeliveryInInput(c);
|
|
2901
|
-
},
|
|
2902
|
-
pressEnter: () => {
|
|
2903
|
-
try {
|
|
2904
|
-
runTmux(['send-keys', '-t', cfg.site, 'Enter']);
|
|
2905
|
-
}
|
|
2906
|
-
catch {
|
|
2907
|
-
// 押せなくても次の確かめで分かる(受け付けられていなければ unconfirmed で残る)。
|
|
2908
|
-
}
|
|
2909
|
-
},
|
|
2910
|
-
sleep,
|
|
2911
|
-
});
|
|
2912
|
-
log(`site=${cfg.site} id=${cmd.id} 受付の確かめ: ${r.result}(押し直し ${r.resent} 回)`);
|
|
2913
|
-
}
|
|
2914
|
-
}
|
|
2915
|
-
// ★配達の直後に「配達済み(sent)」を管制へ書く。書けなければ次tick以降で書けるまでやり直す
|
|
3152
|
+
// ★配った時点で管制へ印を書く(便 7d810bbb)。Claude の職人は「配達中(受領確認待ち)」、
|
|
3153
|
+
// それ以外は今までどおり「配達済み(sent)」。書けなければ次tick以降で書けるまでやり直す
|
|
2916
3154
|
// (markSentAtCentral)。★書けなくても再配達はしない。
|
|
2917
3155
|
await markSentAtCentral(cfg, state);
|
|
3156
|
+
if (!useAck || state.pending === null)
|
|
3157
|
+
return;
|
|
3158
|
+
// ── 受領確認(★えふさん確定:送った instruction_id と完全一致した印を確かめて初めて ACK) ──
|
|
3159
|
+
// 印を待ち、入力欄に残っていれば Enter だけ押し直し(b69c1608)、現れなければ2回まで貼り直す。
|
|
3160
|
+
// ★一致の確かめに使うのは、この便を貼った後(sendStartedAt 以降)に新しく出た記録だけ。
|
|
3161
|
+
const pending = state.pending;
|
|
3162
|
+
const cwd = tmuxPaneCwd(cfg.site);
|
|
3163
|
+
const r = cwd === null
|
|
3164
|
+
? { received: false, repasted: 0, resent: 0, reason: '職人の作業場所が分からず、印を確かめられない(貼り直さない)' }
|
|
3165
|
+
: await awaitDeliveryAck({
|
|
3166
|
+
stampSeen: () => hasJobStampSince(cwd, cmd.id, sendStartedAt),
|
|
3167
|
+
pendingInInput: () => {
|
|
3168
|
+
const c = tmuxCapture(cfg.site);
|
|
3169
|
+
return c !== null && hasPendingDeliveryInInput(c);
|
|
3170
|
+
},
|
|
3171
|
+
pressEnter: () => {
|
|
3172
|
+
try {
|
|
3173
|
+
runTmux(['send-keys', '-t', cfg.site, 'Enter']);
|
|
3174
|
+
}
|
|
3175
|
+
catch {
|
|
3176
|
+
// 押せなくても次の確かめで分かる(印が出なければ未着として扱う)。
|
|
3177
|
+
}
|
|
3178
|
+
},
|
|
3179
|
+
idle: () => isIdleStable(cfg.site, kindOf(cfg)),
|
|
3180
|
+
// ★同じ本文をそのまま貼り直す(配達回数は数えない=1回の配達の中の貼り直し)。
|
|
3181
|
+
repaste: () => sendToTmux(cfg.site, text),
|
|
3182
|
+
sleep,
|
|
3183
|
+
});
|
|
3184
|
+
log(`site=${cfg.site} id=${cmd.id} 受領の確かめ: ${r.received ? '印が一致(受領)' : `印が現れない(本文未着): ${r.reason}`}` +
|
|
3185
|
+
`(Enter押し直し ${r.resent} 回・貼り直し ${r.repasted} 回)`);
|
|
3186
|
+
if (state.pending !== pending)
|
|
3187
|
+
return; // 待っている間に畳まれていたら何もしない(念のため)。
|
|
3188
|
+
pending.ack = r.received ? 'received' : 'missing';
|
|
3189
|
+
pending.ackSent = false;
|
|
3190
|
+
await sendAckAtCentral(cfg, state);
|
|
2918
3191
|
}
|
|
2919
3192
|
finally {
|
|
2920
3193
|
state.running = false;
|
package/package.json
CHANGED
|
@@ -44,6 +44,66 @@ description: 采配くんの便(プロンプト末尾に CONDUCTOR_JOB:<id>
|
|
|
44
44
|
- ★**合流したかの確かめ方(題名では判定しない・えふさん確定 2026-09-18)。** 合流完了の判定はコミット題名では行わない。GitHub上で対象PRが MERGED であること、merge先が意図したbase branchであること、merge後のbase branchに対象PRの変更内容が含まれることを確認する。(squash merge / merge commit / rebase merge の方式に依存しない確認にする)
|
|
45
45
|
- ★これは共通正本 `claude-code-operations.md §11.3` と、この鉄則7 の2か所を正本にする(各リポの CLAUDE.md へは複製しない)。
|
|
46
46
|
- 確かめ方の例(値は実際の番号・ブランチ名に置き換える):`gh pr view 番号 --json state,baseRefName,mergeCommit` で、`state` が `MERGED`、`baseRefName` が意図した base branch(ふつうは `main`)であることを見る。`git fetch origin` のあと `git merge-base --is-ancestor mergeCommitのoid origin/base` が成功する(=合流でできたコミットが base に含まれる)ことを見る。`gh pr diff 番号` の変更が、`git diff` や `git show origin/base:ファイル` で base branch に入っていることを、変更したファイルごとに見比べる。
|
|
47
|
+
- ★**上の4つ(保護の確認・CI・合流・合流後の確かめ)を1回で流す形**(便 03bef270)。★**確かめは1つも省いていない**:段の名前(`protect` / `ci` / `merge` / `verify`)付きで結果を出し、**落ちた段で止まる**。`--admin` は使わない。確かめを減らしたいときにこの塊を削らない。
|
|
48
|
+
そのままコピーして、1回の実行として流す(`PR=<PR番号>`)。手で1つずつ打つ従来の手順も、そのまま有効。
|
|
49
|
+
```bash
|
|
50
|
+
#!/usr/bin/env bash
|
|
51
|
+
# 鉄則7 の4つの確かめ(保護 → CI → 合流 → 合流後)を1回で流す。確かめは1つも省かない。
|
|
52
|
+
# 使い方: PR=<PR番号> bash <この塊> 落ちた段は NG[段名] で分かる。★--admin は使わない。
|
|
53
|
+
set -uo pipefail
|
|
54
|
+
PR="${PR:?PR=<PR番号> が要る}"
|
|
55
|
+
ng() { printf 'NG[%s] %s\n' "$1" "$2" >&2; exit 1; }
|
|
56
|
+
ok() { printf 'OK[%s] %s\n' "$1" "$2"; }
|
|
57
|
+
V() { gh pr view "$PR" --json "$1" --jq "$2" 2>/dev/null; }
|
|
58
|
+
|
|
59
|
+
# ① 保護 … 必須の検査が1件以上のときだけ「保護あり」。0・404・403・失敗は「保護なし」へ倒す。
|
|
60
|
+
BASE="$(V baseRefName .baseRefName)"; [ -n "$BASE" ] || ng protect '合流先を読めない'
|
|
61
|
+
SLUG="$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null)"
|
|
62
|
+
[ -n "$SLUG" ] || ng protect 'リポジトリを読めない'
|
|
63
|
+
N="$(gh api "repos/$SLUG/branches/$BASE/protection/required_status_checks" --jq '.contexts | length' 2>/dev/null)"
|
|
64
|
+
case "$N" in ''|*[!0-9]*) N=0 ;; esac
|
|
65
|
+
if [ "$N" -ge 1 ]; then PROT=yes; else PROT=no; fi
|
|
66
|
+
ok protect "base=$BASE 保護=$PROT(必須の検査 $N 件)"
|
|
67
|
+
|
|
68
|
+
# ② CI … リンクが /actions/runs/ の行だけを CI と数える(Vercel は数えない)。
|
|
69
|
+
ROLL="$(V statusCheckRollup '[.statusCheckRollup[]? | {u:(.detailsUrl//.targetUrl//""), s:(.status//""), c:((.conclusion//.state//"")|ascii_upcase)} | select(.u|test("/actions/runs/"))]')"
|
|
70
|
+
[ -n "$ROLL" ] || ng ci 'CI の一覧を読めない'
|
|
71
|
+
read -r TOTAL RUNNING RED NOTPASS <<<"$(printf '%s' "$ROLL" | jq -r '[length,
|
|
72
|
+
([.[]|select(.s!="" and .s!="COMPLETED")]|length),
|
|
73
|
+
([.[]|select(.s=="COMPLETED" or .s=="")|select(.c|test("^(SUCCESS|NEUTRAL|SKIPPED)$")|not)]|length),
|
|
74
|
+
([.[]|select(.c|test("^(SUCCESS|NEUTRAL|SKIPPED)$")|not)]|length)] | @tsv')"
|
|
75
|
+
[ "${TOTAL:-0}" -ge 1 ] || ng ci 'このリポに CI が無い(鉄則7-④: 手元で検査を流し、人の判断で合流する)'
|
|
76
|
+
[ "$RED" -eq 0 ] || ng ci "CI が赤($RED 件)"
|
|
77
|
+
if [ "$PROT" = no ]; then
|
|
78
|
+
[ "$RUNNING" -eq 0 ] || ng ci "CI が実行中($RUNNING 件・終わってから確かめ直す)"
|
|
79
|
+
[ "$NOTPASS" -eq 0 ] || ng ci "pass でない CI がある($NOTPASS 件)"
|
|
80
|
+
fi
|
|
81
|
+
ok ci "CI $TOTAL 件・赤 0 件(保護=$PROT)"
|
|
82
|
+
|
|
83
|
+
# ③ 合流 … 保護あり=予約(緑になり次第 GitHub が合流)/保護なし=緑を確かめたうえで合流。
|
|
84
|
+
if [ "$PROT" = yes ]; then
|
|
85
|
+
gh pr merge "$PR" --auto --merge --delete-branch || ng merge '合流の予約に失敗(鉄則7-② で確かめ直す。旗を足さない)'
|
|
86
|
+
ok merge '保護あり=合流を予約した'
|
|
87
|
+
else
|
|
88
|
+
gh pr merge "$PR" --merge --delete-branch || ng merge '合流に失敗'
|
|
89
|
+
ok merge '保護なし=CI の緑を確かめてから合流した'
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
# ④ 合流後 … MERGED・base が意図どおり・変更が base に入っている(題名では判定しない)。
|
|
93
|
+
END=$(( $(date +%s) + ${WAIT:-600} ))
|
|
94
|
+
while :; do
|
|
95
|
+
ST="$(V state .state)"
|
|
96
|
+
[ "$ST" = MERGED ] && break
|
|
97
|
+
[ "$(date +%s)" -lt "$END" ] || ng verify "まだ MERGED でない(state=$ST)"
|
|
98
|
+
sleep 15
|
|
99
|
+
done
|
|
100
|
+
GOT="$(V baseRefName .baseRefName)"
|
|
101
|
+
[ "$GOT" = "$BASE" ] || ng verify "合流先が違う($GOT ≠ $BASE)"
|
|
102
|
+
OID="$(V mergeCommit .mergeCommit.oid)"; [ -n "$OID" ] || ng verify '合流コミットを読めない'
|
|
103
|
+
git fetch origin --quiet || ng verify 'fetch に失敗'
|
|
104
|
+
git merge-base --is-ancestor "$OID" "origin/$BASE" || ng verify "変更が origin/$BASE に入っていない"
|
|
105
|
+
ok verify "MERGED / base=$BASE / $OID が origin/$BASE に入っている"
|
|
106
|
+
```
|
|
47
107
|
8. **ブラウザ検品(Playwright)の作法** — ★取りに行かない。PCに1回だけ入れた共有の道具を指すだけ(ADR-010)。
|
|
48
108
|
- ① **起動は必ず `chromium_sandbox=False`**(`--no-sandbox`)。付けないと起動できない。
|
|
49
109
|
- ② **道具は共有の場所を使う。自分でダウンロードしない。** python は `$HOME/.tyhld/pw-venv/bin/python`、`PLAYWRIGHT_BROWSERS_PATH` は `$HOME/.tyhld/pw-browsers`。**無ければそこで止めて「人の手番=`APPLY=1 bash scripts/pw-browser-install.sh`」と報告する。** `playwright install` も `pip install playwright` も職人は実行しない(毎便まるごとダウンロードが走り、便が遅くなる。通信自体は鉄則12のとおり許可済みだが、取りに行かないのが本筋=ADR-010)。
|
|
@@ -69,6 +129,15 @@ description: 采配くんの便(プロンプト末尾に CONDUCTOR_JOB:<id>
|
|
|
69
129
|
- **雛形(`.env.example` など)を直すときの正しい形=「案を作る→人が1回で反映」。** 職人は変更後の中身を**リポジトリの外(`/tmp/claude-1000/` の下)**に作り、報告に次の2つを載せる。① 変更の**差分**(何を足す/消すか)。② 人が**素のターミナルで1回貼るだけで反映できる1つのコマンド**(例:`cp /tmp/claude-1000/<下書き> <リポ>/.env.example`)。★このコマンドは**人が自分のターミナルで**打つ(職人の番人は人の端末には掛からない)。職人が `.env.example` を直接書き換えようとしても番人が止めるので、**回避せず、この形で案を出す**。
|
|
70
130
|
- **本物の `.env`・`.env.local` は、これまでどおり中身も書き込みも扱わない**(秘密の値は1文字も読まない・書かない=鉄則12の金庫)。
|
|
71
131
|
|
|
132
|
+
## 番人に止められる常連(こう書くと止まる → 代わりにこう書く)
|
|
133
|
+
|
|
134
|
+
止まる書き方を最初から使わなければ、打ち直しの往復が要らない。★番人の判定は変えない(弱めない)。
|
|
135
|
+
|
|
136
|
+
- **cd を `&&` でつなぐ**(`cd 先 && 何か`・cd とリダイレクトを同じ行に混ぜる)→ 止まる。**cd は作業のはじめに1回だけ単独で**打ち、以後は絶対パスか `git -C <実際のパス>`(鉄則2)。
|
|
137
|
+
- **`.npmrc` を読む**(`cat ~/.npmrc` など)→ 止まる。**中身は見に行かない。** 差分は対象を名指しで `git diff -- src docs`、全体で見たいときは除いて `git diff -- . ':(exclude).npmrc'`(鉄則1)。
|
|
138
|
+
- **中身が変わる場所を指す再帰の `rm`**(`rm -rf "$DIR/x"`・`rm -rf build/*` のように変数や `*` を含む)→ 止まる。**消す先を実在のパスで名指しする**(例 `rm -rf /tmp/claude-1000/作業名`・鉄則13)。
|
|
139
|
+
- **作業場所が変数のままの `git push`**(`git -C "$DIR" push`・行き先のブランチを確かめられない形)→ 止まる。**リポの中で、ブランチを名指しして** `git push -u origin <作業ブランチ名>`。
|
|
140
|
+
|
|
72
141
|
## 便の終え方
|
|
73
142
|
|
|
74
143
|
便の最後には、配達役が付けた1行「【便の終え方】…報告の置き場: ~/最終行: DONE:<現場> とだけ出力/完了の知らせ: conductor_complete を commandId="<指示ID>" で1回だけ」と、管理用ジョブ印 `CONDUCTOR_JOB:<指示ID>` がある。作業がすべて終わったら、この節のとおりに次の順で終える(便 a5306df2:以前は便ごとに付いていた全文を、ここへ寄せた)。
|