@tyhld/conductor 0.12.51 → 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.
Files changed (2) hide show
  1. package/dist/relay.js +305 -32
  2. package/package.json +1 -1
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
- if (await patchStatus(cfg, pending.id, 'sent')) {
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(`site=${cfg.site} id=${pending.id} 配達済み(sent)を管制へ記録`);
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
- if (isTerminalCommandStatus(centralStatus)) {
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
- // ★職人が本当に受け付けたかを、職人の会話の記録(指示ID)で確かめる。未受付のときだけ Enter を
2892
- // 押し直す(便 b69c1608)。★確かめの結果で配達済みの扱いは変えない(二重に配らない)。
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.51",
3
+ "version": "0.12.52",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {