@tyhld/conductor 0.8.0 → 0.9.0

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 +154 -289
  2. package/package.json +1 -1
package/dist/relay.js CHANGED
@@ -15,7 +15,7 @@
15
15
  * 【役割(段3で「配達係」に絞った)】
16
16
  * relay がするのは「指示を職人へ届け、終わったことを中央へ書き戻す」ことだけ。
17
17
  * ① 指示の取得 ② 本文の投入 ③ アイドル判定 ④ Claude箱の確認 ⑤ tmux在否
18
- * ⑦⑧ 完了検出 ⑪ 生存報告 ⑰〜㉑ 再送・中央照合・孤児復元 ㉒ 緊急停止・現場トグル ㉓ 適応ポーリング
18
+ * ⑦⑧ 完了検出 ⑪ 生存報告 ⑲〜㉑ 中央照合・孤児復元 ㉒ 緊急停止・現場トグル ㉓ 適応ポーリング
19
19
  * ★職人の画面から「質問の中身」を読んで意味を解釈すること、職人の端末でキーを押すことはしない。
20
20
  * 許可の確認と応答は PermissionRequest フック(scripts/hooks/permission_request_hook.py)が
21
21
  * データ経路(POST /api/conductor/hook-requests)で担う。画面スクレイプに依存しない。
@@ -48,7 +48,7 @@ export const RELAY_PENDING_TIMEOUT_MS = (() => {
48
48
  const parsed = Number(raw);
49
49
  return Number.isFinite(parsed) && parsed > 0 ? parsed : def;
50
50
  })();
51
- /** 環境変数を正の数として読む(空/不正は既定へフォールバック)。再送ポリシーの定数で使う。 */
51
+ /** 環境変数を正の数として読む(空/不正は既定へフォールバック)。各種の時間・回数の定数で使う。 */
52
52
  function envPositiveNumber(name, def) {
53
53
  const raw = (process.env[name] ?? '').trim();
54
54
  if (raw === '')
@@ -110,37 +110,17 @@ export function clampToCentralHold(ms) {
110
110
  * 環境変数 RELAY_KEEPALIVE_MAX_MS で上書き可(正の数のみ・★中央の上限までに切り詰める)。
111
111
  */
112
112
  export const RELAY_KEEPALIVE_MAX_MS = clampToCentralHold(envPositiveNumber('RELAY_KEEPALIVE_MAX_MS', CENTRAL_BUSY_HOLD_MAX_MS));
113
- /**
114
- * 自動再送(2-a)のポリシー。生存中の職人が動かないときに同一指示を再送する。
115
- * - RELAY_RESEND_DELAY_MS: 初回送信(sentAt)から最初の再送までの待機(ms)。既定30秒。
116
- * - RELAY_RESEND_INTERVAL_MS: 2回目以降(fast相)の再送間隔(ms)。直近送信(lastSentAt)基準。既定30秒。
117
- * - RELAY_RESEND_MAX: fast再送の回数。これを超えたら fast を打ち切り、★以後は低頻度(slow相)で
118
- * 再配信を継続する(従来のように完全には止めない)。既定3。
119
- * - RELAY_RESEND_SLOW_INTERVAL_MS: fast上限到達後の低頻度再配信の間隔(ms)。既定2分。
120
- * 起動順レース(職人が relay より後から起動)で fast を使い切った後でも、職人が起動して
121
- * 未着信+アイドルになれば次の slow tick で必ず届くようにする根治。最終打ち切りは pending
122
- * タイムアウト(RELAY_PENDING_TIMEOUT_MS)に委ねる。暴走防止は decideResend が
123
- * 「未着信(anchor未可視)かつ idleStable かつ 間隔経過」のときだけ送ることで担保。
124
- * いずれも環境変数で上書き可(正の数のみ採用)。
125
- */
126
- export const RELAY_RESEND_DELAY_MS = envPositiveNumber('RELAY_RESEND_DELAY_MS', 30 * 1000);
127
- export const RELAY_RESEND_INTERVAL_MS = envPositiveNumber('RELAY_RESEND_INTERVAL_MS', 30 * 1000);
128
- export const RELAY_RESEND_MAX = envPositiveNumber('RELAY_RESEND_MAX', 3);
129
- export const RELAY_RESEND_SLOW_INTERVAL_MS = envPositiveNumber('RELAY_RESEND_SLOW_INTERVAL_MS', 2 * 60 * 1000);
130
113
  /**
131
114
  * 同一 command を relay が配達する累計回数の絶対上限(保険・無限ループの構造的封じ込め)。
132
- * 初回送信+自動再送+sent孤児復元後の再送を、プロセス内で id 単位に通算する。
115
+ * プロセス内で id 単位に通算する。
133
116
  *
134
- * 【なぜ必要か】RELAY_RESEND_MAX は fast相→slow相の“間隔”を切り替えるだけで、slow相は打ち切らず
135
- * 低頻度に再配信を継続する設計(起動順レース根治)。そのため職人窓が永久に未着信/未DONEのとき、
136
- * 「slow再送 pending タイムアウト(15分)で解除 → fetchCommand が同じ queued を再取得 → 再び pending →
137
- * slow再送 …」のサイクルが(resendCount がその都度0にリセットされるため)事実上無限に続く穴が残る。
138
- * この上限を超えた command はプロセス内で「配達放棄(abandoned)」とし、二度と配達しない(WARNログを残す)。
117
+ * 【★ADR-023 以後の位置づけ】配達は「1指示につき1回だけ」になった(自動再送は撤去)。
118
+ * それでも上限を残すのは、中央が同じ id を queued へ戻す運用(人が差し戻す・中央側の不具合)が
119
+ * あり得るため。ここを超えた command はプロセス内で「配達放棄(abandoned)」とし二度と配達しない。
139
120
  *
140
121
  * 【スコープ】プロセス再起動でカウンタ/放棄集合はリセットされる(サーバAPIは変更しない方針のため永続化しない)。
141
- * これは許容範囲: DONE 到達済みの command はそもそもサーバが queued を返さない=再取得されない。
142
- * 本上限は「完了通知が単線で切れて詰まった」異常時に無限ループを構造的に不可能化するための保険。
143
- * 環境変数 RELAY_DELIVER_MAX で上書き可(正の数のみ)。既定20(初回1+再送19相当)。
122
+ * これは許容範囲: 配達直後に中央が sent になる=サーバが同じ指示を queued として返さない。
123
+ * 環境変数 RELAY_DELIVER_MAX で上書き可(正の数のみ)。既定20。
144
124
  */
145
125
  export const RELAY_DELIVER_MAX = envPositiveNumber('RELAY_DELIVER_MAX', 20);
146
126
  /**
@@ -222,7 +202,7 @@ export function createRelayState() {
222
202
  };
223
203
  }
224
204
  /**
225
- * この command を(初回/再送問わず)まだ配達してよいか。純粋関数(I/Oなし=単体テスト可能)。
205
+ * この command をまだ配達してよいか。純粋関数(I/Oなし=単体テスト可能)。
226
206
  * - 既に放棄済み(abandoned) → false。
227
207
  * - 累計配達回数が RELAY_DELIVER_MAX 以上 → false(もう配達しない=無限ループ防止の絶対上限)。
228
208
  */
@@ -231,7 +211,7 @@ export function canDeliver(state, id) {
231
211
  return false;
232
212
  return (state.deliverCounts.get(id) ?? 0) < RELAY_DELIVER_MAX;
233
213
  }
234
- /** 配達成功を1件記録して累計を返す(初回送信・再送の両方の成功時に呼ぶ)。 */
214
+ /** 配達成功を1件記録して累計を返す(tmux への投入に成功したときに呼ぶ)。 */
235
215
  export function markDelivered(state, id) {
236
216
  const n = (state.deliverCounts.get(id) ?? 0) + 1;
237
217
  state.deliverCounts.set(id, n);
@@ -291,7 +271,7 @@ export function busyScanRegion(text) {
291
271
  * - 走査対象は busyScanRegion=入力枠フッター領域だけ(最後の横罫線より下)。トランスクリプト
292
272
  * 本文の busy 相当語で永久 busy 誤判定にならないための恒久修正。判別軸は v2.x 常時描画のフッター
293
273
  * 行('esc to interrupt' を含む→busy / '? for shortcuts' を含む→idle)。
294
- * - busy 判定を looksIdle から切り出して再利用可能にしたもの(自動再送の可否判定でも使う)。
274
+ * - busy 判定を looksIdle から切り出して再利用可能にしたもの(生存通知の判定でも使う)。
295
275
  * - looksIdle はこれが true なら即アイドル不成立に倒す。
296
276
  */
297
277
  export function hasBusySignal(text) {
@@ -368,7 +348,7 @@ export function looksIdle(text) {
368
348
  return false;
369
349
  // 選択メニュー表示中はアイドルとみなさない(落とし穴ガード・★撤去してはいけない要)。
370
350
  // Claude Code の確認メニュー("❯ 1. Yes" 等)も `❯` で始まる行を含むため、下の hasPrompt が
371
- // true になりアイドル誤判定し得る。メニュー表示中に relay の pending ループが新規指示や再送を
351
+ // true になりアイドル誤判定し得る。メニュー表示中に relay の pending ループが新規指示を
372
352
  // 流し込むと、選択メニューに指示文が打ち込まれて混線する(=作業への割り込み事故)。
373
353
  // ★段3で「質問の中身を読む処理」は全部撤去したが、この真偽ガードだけは残す(残す理由は
374
354
  // hasChoiceMenu のコメント参照)。答えるのはフック経路の役目で、relay は一切押さない。
@@ -658,15 +638,18 @@ export function buildAnchor(id) {
658
638
  * 【なぜアンカーを求めていたのか】前の指示の DONE 行や報告を、今の指示のものと取り違えないため。
659
639
  *
660
640
  * 【根治】アンカーが見えないときも安全に全画面を見てよい条件がある:
661
- * ★**アンカーが「一度見えた(delivered)」あとに「見えなくなった」なら、
641
+ * ★**アンカーが「一度見えた」あとに「見えなくなった」なら、
662
642
  * アンカーより上にあった前の指示の出力は、アンカーより先に画面から消えている。**
663
643
  * 画面は上から順に流れて出ていくので、アンカーが消えた時点で、その上にあった
664
644
  * 前の指示の DONE 行も必ず消えている。だから取り違えは構造的に起こらない。
665
645
  *
666
646
  * 返り:
667
647
  * - アンカーが見える → アンカーより下(従来どおり・いちばん厳しい)
668
- * - アンカーが見えない かつ delivered → 画面全体(上記の理由で安全)
669
- * - アンカーを一度も見ていない null(まだ届いていない=見に行かない)
648
+ * - アンカーが見えない かつ wide → 画面全体(上記の理由で安全)
649
+ * - それ以外(アンカーを一度も見ていない)→ null(見に行かない)
650
+ *
651
+ * ★第3引数は「画面をどこまで見てよいか」だけを表す(PendingEntry.screenDoneWide)。
652
+ * ★配達したか・中央へ印を書いたかとは別物(混ぜていたのが ADR-023 の穴)。
670
653
  */
671
654
  // ───────────────────────────────────────────────────────────────────────────
672
655
  // Codex の完了・報告を受け取る口(★ADR-021)
@@ -728,11 +711,11 @@ export function takeCodexNotice(site, env = process.env) {
728
711
  }
729
712
  return latest;
730
713
  }
731
- export function sliceForDoneScan(text, anchor, delivered) {
714
+ export function sliceForDoneScan(text, anchor, wide) {
732
715
  const below = sliceBelowAnchor(text, anchor);
733
716
  if (below !== null)
734
717
  return below;
735
- return delivered ? text : null;
718
+ return wide ? text : null;
736
719
  }
737
720
  export function sliceBelowAnchor(text, anchor) {
738
721
  const lines = text.replace(/\r/g, '').split('\n');
@@ -768,61 +751,6 @@ export function hasDoneBelowAnchor(text, site, anchor) {
768
751
  return false;
769
752
  return hasStandaloneDone(below, site);
770
753
  }
771
- /**
772
- * 生存中の職人が動かないときの自動再送の可否を判定する純粋関数(2-a の核心)。
773
- *
774
- * 【二重実行を防ぐ冪等性が最優先】判定の順序がそのまま安全の優先順位:
775
- * 1) アンカー(CONDUCTOR_JOB:{id})が画面に出ている=tmuxへ着信済み。アイドルでも再送しない。
776
- * (職人が読んで思考中の可能性。これが二重実行防止の核心。経過時間より着信判定を優先する)
777
- * 2) busy(esc to interrupt / compacting / スピナー)=実処理中なら再送しない(混線回避)。
778
- * 3) 二重チェックのアイドル(isIdleStable)が不成立なら再送しない(保守的)。
779
- * 4) 送信間隔が lastSentAt から未経過なら待つ。間隔は「初回=delay / fast相(回数<max)=interval /
780
- * ★slow相(回数≥max)=slowInterval」。
781
- * 5) 上を全て満たす=未着信かつ静止かつ閾値経過 → 'resend'。
782
- *
783
- * ★起動順レース根治(不具合A): かつては「最大再送回数到達で 'stop'(打ち切り)」だったが、
784
- * これだと職人が relay より後から起動したケースで二度と届かなかった。本実装は上限到達で
785
- * 打ち切らず、間隔を slowInterval に広げて低頻度で再配信を継続する(職人が起動して未着信+
786
- * アイドルになれば次の slow tick で必ず届く)。暴走しないのは、再送が常に「未着信(anchor未可視)
787
- * かつ idleStable かつ 間隔経過」を満たすときだけ=着信済み/busy/非アイドルの間は一切送らないため。
788
- * 最終的な打ち切りは外側の pending タイムアウト(RELAY_PENDING_TIMEOUT_MS)に委ねる。
789
- *
790
- * capture は単一フレームの画面テキスト(着信判定 anchor とbusy判定に使う)。
791
- * idleStable は呼び出し側で isIdleStable() を実行した二重チェック済みアイドルの真偽。
792
- */
793
- export function decideResend(args) {
794
- const { capture, anchor, idleStable, lastSentAt, resendCount, now, policy } = args;
795
- // (1) 冪等性の核心: 着信済み(アンカーが画面に可視)ならアイドルでも再送しない。
796
- if (capture.includes(anchor)) {
797
- return { verdict: 'wait', reason: 'delivered (anchor visible on screen)' };
798
- }
799
- // (2) 実処理中シグナルがあれば再送しない(職人が動いている=送信は届いて処理中の可能性)。
800
- if (hasBusySignal(capture)) {
801
- return { verdict: 'wait', reason: 'busy (work-in-progress signal)' };
802
- }
803
- // (3) 二重チェックのアイドルが不成立なら再送しない。
804
- if (!idleStable) {
805
- return { verdict: 'wait', reason: 'not idle-stable' };
806
- }
807
- // (4) 送信間隔(初回 delay / fast相 interval / ★slow相 slowInterval)未経過なら待つ。lastSentAt 基準。
808
- // fast相 = 回数 < maxResend、slow相 = 回数 ≥ maxResend(上限到達後も打ち切らず低頻度継続)。
809
- const inSlowPhase = resendCount >= policy.maxResend;
810
- const required = resendCount === 0
811
- ? policy.resendDelayMs
812
- : inSlowPhase
813
- ? policy.resendSlowIntervalMs
814
- : policy.resendIntervalMs;
815
- if (now - lastSentAt < required) {
816
- return { verdict: 'wait', reason: 'resend interval not elapsed' };
817
- }
818
- // (5) 未着信+アイドル+閾値経過 → 再送(上限後も slow相 で低頻度に継続)。
819
- return {
820
- verdict: 'resend',
821
- reason: inSlowPhase
822
- ? 'undelivered & idle-stable & slow-interval elapsed (post-max slow redelivery)'
823
- : 'undelivered & idle-stable & interval elapsed',
824
- };
825
- }
826
754
  /**
827
755
  * 外部完了(中央で done/failed 化された案件)を検知して pending 追跡を解除すべきかを判定する純粋関数。
828
756
  *
@@ -835,13 +763,13 @@ export function decideResend(args) {
835
763
  * 【判定の根拠=根治方針「完了の真実は中央にある」への寄せ】
836
764
  * 中央は「1現場同時1指示」で、ある指示が sent の間は新規 queued を返さない(fetchCommand=null)。
837
765
  * したがって次の条件が揃えば pending.id は中央で既に終端した確証になる:
838
- * - delivered(着信確認済み=relay status を 'sent' に遷移済み)で、
766
+ * - sentMarked(中央へ「配達済み(sent)」を書けている=中央はこの指示を queued として返さない)で、
839
767
  * - 職人画面がアイドル(looksIdle=処理中でも許可プロンプトでもない・入力待ち)で、
840
768
  * - fetchCommand が pending.id とは別の id の指示を返した(=中央がもう次を配れる状態)。
841
769
  * このとき追跡を解除して次tickで新規指示を素直に取りに行けば、外部done化でも詰まらない。
842
770
  *
843
771
  * 【安全側(false=解除しない)に倒すケース】いずれも従来の画面DONE検出/pendingタイムアウトへ無損失縮退:
844
- * - delivered 前(まだ queued 据置・着信未確認): 次のqueuedはpending自身なので誤検出しない。
772
+ * - sentMarked 前(中央はまだ queued 扱い): 次のqueuedはpending自身なので誤検出しない。
845
773
  * - 非アイドル(busy/許可プロンプト等): まだ職人が動作中の可能性 → 解除しない。
846
774
  * - 中央不達 or 次指示なし(nextCommandId=null)。
847
775
  * - 次指示が pending と同id(通常は起こらないが、念のため同一なら解除しない)。
@@ -849,8 +777,8 @@ export function decideResend(args) {
849
777
  * I/O を持たない純粋関数(fetchCommand の結果 id と画面アイドル判定を引数で受ける)=単体テスト可能。
850
778
  */
851
779
  export function detectExternalDone(args) {
852
- if (!args.delivered)
853
- return false; // 着信未確認=中央はまだ queued。次のqueuedはpending自身。
780
+ if (!args.sentMarked)
781
+ return false; // 印が未記録=中央はまだ queued。次のqueuedはpending自身。
854
782
  if (!args.idle)
855
783
  return false; // 職人が動作中/許可待ちの可能性 → 解除しない(保守的)。
856
784
  if (args.nextCommandId === null)
@@ -875,12 +803,6 @@ export function isTerminalCommandStatus(status) {
875
803
  const s = status.trim().toLowerCase();
876
804
  return s === 'done' || s === 'failed' || s === 'cancelled' || s === 'canceled' || s === 'abandoned';
877
805
  }
878
- /**
879
- * 中央が「まだ配達してよい=生きている」と明示した status(これらだけ再送を許す)。
880
- * queued=未配達 / sent=配達済み作業中 / stalled=無音だが未終端 / perm_wait=承認待ち / held=保留。
881
- * held は「中央がまだ流す判断をしていない」=再送してよい状態ではないので skip 側に置く。
882
- */
883
- const CENTRAL_LIVE_STATUSES = new Set(['queued', 'sent', 'stalled', 'perm_wait']);
884
806
  /**
885
807
  * 中央で dismissed/timeout も終端。isTerminalCommandStatus(既存・変更しない)は done/failed/
886
808
  * cancelled/canceled/abandoned のみを見るので、再配達の可否ではこの2つを足して判断する。
@@ -893,7 +815,7 @@ const CENTRAL_EXTRA_TERMINAL_STATUSES = new Set(['dismissed', 'timeout']);
893
815
  * done / failed / cancelled / canceled / abandoned / dismissed / timeout のいずれか。
894
816
  * null(中央不達・未知)や未知の綴りは false =「終わっているとは言えない」に倒す(保守的)。
895
817
  *
896
- * ★再配達の可否(decideCentralReconcile)と、生存の見張りを畳む判断(keepalive)で
818
+ * ★「配達済みの印(sent)を書き続けてよいか」と、生存の見張りを畳む判断(keepalive)で
897
819
  * 同じ物差しを使うためにここへ切り出した。判定が2か所に分かれて食い違うのを防ぐ。
898
820
  */
899
821
  export function isCentralFinished(status) {
@@ -903,41 +825,12 @@ export function isCentralFinished(status) {
903
825
  return isTerminalCommandStatus(s) || CENTRAL_EXTRA_TERMINAL_STATUSES.has(s);
904
826
  }
905
827
  /**
906
- * 「今から同一指示を再送しようとしている」tick で、中央 status と照合して最終判断する純粋関数。
907
- *
908
- * 【なぜ必要か】完了通知が conductor_complete(MCP直行)で立つと中央は done になるが、relay が画面の
909
- * DONE 行を取りこぼすとローカル pending が残る。従来この再送経路(!delivered)は中央を一度も見ずに
910
- * 同一指示を送り続けていた(既存の中央照合は delivered+アイドルの分岐にしか無く、!delivered
911
- * 再送経路とは条件が排他だった)。ここで照合を挟むことで done 済みの再配達を構造的に断つ。
912
- *
913
- * 結論:
914
- * - 'drop' 終端(done/failed/cancelled/canceled/abandoned/dismissed/timeout)。再送せず pending を
915
- * 解放し abandoned へ入れる(以後二度と配達しない)。
916
- * - 'resend' 中央が生存(queued/sent/stalled/perm_wait)と明示。従来どおり再送してよい。
917
- * - 'skip' null(中央不達・404・タイムアウト=未知)、または未知の綴り。このtickは何もせず次tickで
918
- * 再試行する。曖昧なら送らない安全側に倒す(誤再配達より遅延のほうが安い)。
919
- *
920
- * 【縮退の注意】中央に GET /api/conductor/commands/{id} が無い環境では常に null→'skip' となり自動再送は
921
- * 起きない。固着は従来どおり pending タイムアウト(RELAY_PENDING_TIMEOUT_MS)が解除する。
922
- */
923
- export function decideCentralReconcile(centralStatus) {
924
- if (centralStatus === null)
925
- return 'skip'; // 未知=照合できず。送らない安全側。
926
- if (isCentralFinished(centralStatus))
927
- return 'drop';
928
- const s = centralStatus.trim().toLowerCase();
929
- if (CENTRAL_LIVE_STATUSES.has(s))
930
- return 'resend';
931
- return 'skip'; // 未知の綴り(将来の新status・held 等)は送らない安全側。
932
- }
933
- /**
934
- * sent孤児を 2-a の pending 構造へ変換する純粋関数(★送信はしない)。
935
- * - anchor は buildAnchor(id) で再生成(id 不変=アンカー不変)。送信本文末尾のアンカーと一致するため、
936
- * 復元後に画面へアンカーが残っていれば decideResend が「着信済み」と見て再送しない(二重実行防止)。
937
- * - resendCount=0 から再開。lastSentAt は取得した sentAt を流用(次の再送間隔判定の基準)。
938
- * 孤児は sentAt が十分過去(TTL超)なので、未着信なら次tickの decideResend で即 resend 判定になり得る。
939
- * - delivered=true: 孤児は元々サーバ status=sent=過去に着信確認を経て sent 化済みの段階。
940
- * 復元後に再度 patchStatus('sent') する必要はなく、DONE 待ち/再送のみを担う。
828
+ * sent孤児を pending 構造へ変換する純粋関数(★送信はしない)。
829
+ * - anchor は buildAnchor(id) で再生成(id 不変=アンカー不変)=完了検出の境界が復元前と揃う。
830
+ * - delivered=true / sentMarked=true: 孤児は中央 status=sent=既に配達され、印も付いている段階。
831
+ * 復元後の役目は「DONE を待つ/生存を伝える」だけで、★配達は二度としない。
832
+ * - screenDoneWide=true: 孤児は送信から TTL 超=前の指示の出力はとうに画面から流れている。
833
+ * アンカーが見えなくても全画面から DONE を拾ってよい(ADR-020 と同じ根拠・従来と同じ挙動)。
941
834
  */
942
835
  export function orphanToPending(orphan) {
943
836
  return {
@@ -946,10 +839,9 @@ export function orphanToPending(orphan) {
946
839
  sentAt: orphan.sentAt,
947
840
  anchor: buildAnchor(orphan.id),
948
841
  body: orphan.body,
949
- resendCount: 0,
950
- lastSentAt: orphan.sentAt,
951
- resendExhaustedWarned: false,
952
842
  delivered: true,
843
+ sentMarked: true,
844
+ screenDoneWide: true,
953
845
  };
954
846
  }
955
847
  /**
@@ -984,7 +876,7 @@ export function selectOrphanToRestore(orphans, currentPending) {
984
876
  export function buildSendText(body, site, anchor) {
985
877
  const marker = `DONE:${site}`;
986
878
  // anchor から素の commandId を取り出す(conductor_complete に渡す引数。接頭辞無しなので新たな
987
- // アンカー一致を作らず、sliceBelowAnchor/decideResend の境界判定に干渉しない)。
879
+ // アンカー一致を作らず、sliceBelowAnchor の境界判定に干渉しない)。
988
880
  const commandId = anchor.startsWith(`${SEND_ANCHOR_PREFIX}:`)
989
881
  ? anchor.slice(SEND_ANCHOR_PREFIX.length + 1)
990
882
  : anchor;
@@ -1363,7 +1255,7 @@ export async function patchStatus(cfg, id, status, report) {
1363
1255
  * - status を確定的に読めた場合のみ小文字化して返す。それ以外(非2xx・404・タイムアウト・通信失敗・
1364
1256
  * status 欠落/非文字列)はすべて null=「中央不達 or 未知」を意味し、呼び出し側は従来経路
1365
1257
  * (detectExternalDone の副シグナル/pendingタイムアウト)へ無損失で縮退する。
1366
- * - 最善努力プローブなので失敗はログを出さず握りつぶす(delivered+アイドル毎tickで叩くため冗長ログ回避)。
1258
+ * - 最善努力プローブなので失敗はログを出さず握りつぶす(アイドル毎tickで叩くため冗長ログ回避)。
1367
1259
  *
1368
1260
  * 【デプロイ順の安全性】このエンドポイントが中央(devlog-tracker)に未実装の間は 404/非2xx→null となり、
1369
1261
  * relay は従来どおり動く(無害・無損失)。中央側 PR が入った時点で自動的に根治が効き始める。
@@ -1389,6 +1281,55 @@ export async function fetchCommandStatus(cfg, id) {
1389
1281
  return null; // タイムアウト/通信失敗=未知。従来経路へ縮退(安全側)。
1390
1282
  }
1391
1283
  }
1284
+ /**
1285
+ * 配達済みの指示に、中央(管制)で「配達済み(status=sent)」の印を付ける。★ADR-023 根治の要。
1286
+ *
1287
+ * 【なぜ要るか=実地の穴 2026-09-04〜05】以前は「アンカー(CONDUCTOR_JOB:{id})が職人の画面に
1288
+ * エコーされたのを relay が見たら sent にする」だった。ところが職人(Claude Code)は
1289
+ * 全画面表示=tmux から見ると代替画面で【さかのぼれる行が1行も無い】ため、アンカーは数秒で
1290
+ * 流れて消える。次の tick(15秒後)に見えていなければ、その便は永久に queued のまま=
1291
+ * 管制の札が「待っています」から動かない。実測: 2026-09-03〜05 の配達 251 件中 6 件がこれ。
1292
+ * そして queued のまま残った便は、職人が一瞬アイドルに見えた瞬間に再配達され得た(二重配達)。
1293
+ *
1294
+ * 【根治】配達したかどうかは send-keys の成否で決める(画面を読まない)。この関数は
1295
+ * 「その事実を中央へ書く」だけを担い、書けるまで毎tick やり直す。★書けなくても再配達はしない。
1296
+ *
1297
+ * 中央が「もう終わっている」と言ったときだけ pending を畳む(印を書き続けても意味がないため)。
1298
+ * すでに中央が sent(他経路で印が付いた・409 で弾かれた等)なら、印は付いているとみなして完了。
1299
+ *
1300
+ * @returns 印が付いた(または既に付いていた)なら true。
1301
+ */
1302
+ export async function markSentAtCentral(cfg, state) {
1303
+ const pending = state.pending;
1304
+ if (pending === null)
1305
+ return false;
1306
+ if (pending.sentMarked)
1307
+ return true;
1308
+ if (await patchStatus(cfg, pending.id, 'sent')) {
1309
+ pending.sentMarked = true;
1310
+ log(`site=${cfg.site} id=${pending.id} 配達済み(sent)を管制へ記録`);
1311
+ return true;
1312
+ }
1313
+ // 書けなかった。理由を中央に聞いて、やり直す意味があるかだけ判断する。
1314
+ const centralStatus = await fetchCommandStatus(cfg, pending.id);
1315
+ if (isCentralFinished(centralStatus)) {
1316
+ // もう終わっている指示に印を付ける意味はない。追跡を畳んで次へ進む(固着させない)。
1317
+ log(`site=${cfg.site} id=${pending.id} は中央で終端(status=${centralStatus})` +
1318
+ '→配達済みの記録は不要。追跡を解除する');
1319
+ state.pending = null;
1320
+ state.lastHeartbeatAt = null;
1321
+ return false;
1322
+ }
1323
+ if (centralStatus === 'sent') {
1324
+ // 既に印が付いている(別経路/409 の相手が自分自身だった等)。書き直す必要はない。
1325
+ pending.sentMarked = true;
1326
+ log(`site=${cfg.site} id=${pending.id} 中央は既に sent=配達済みの記録は完了とみなす`);
1327
+ return true;
1328
+ }
1329
+ log(`WARN site=${cfg.site} id=${pending.id} 配達済み(sent)を管制へ書けなかった` +
1330
+ `(中央status=${centralStatus ?? '不明'})→次tickで再試行(★再配達はしない)`);
1331
+ return false;
1332
+ }
1392
1333
  /**
1393
1334
  * このtickで生存ハートビート(bare)を送るかを決める純粋関数(I/Oなし=単体テスト可能)。
1394
1335
  *
@@ -1496,14 +1437,14 @@ export function noteBusyEdge(state, site, id, raise, logLine) {
1496
1437
  // 管制は「返事がありません」のまま戻らない(2026-09-01 実地)。中央側の戻り道は既にある
1497
1438
  // (/seen の body 無し通知が stalled → sent へ戻す)ので、送り手を絶やさないだけで直る。
1498
1439
  //
1499
- // 【この経路がやらないこと】★再送は一切しない(本文も再送回数も持たない)。やるのは
1440
+ // 【この経路がやらないこと】★配達は一切しない(本文を持たない)。やるのは
1500
1441
  // ①生存通知 ②画面の DONE 行を見つけたときの完了報告 ③中央が終端なら畳む、の3つだけ。
1501
1442
  // ───────────────────────────────────────────────────────────────────────────
1502
1443
  /**
1503
1444
  * 見張りを引き継ぐ(pending → keepalive)。★I/Oなし=単体テスト可能。
1504
1445
  *
1505
1446
  * 引き継がない条件(どちらも「引き継ぐ根拠がない/壊す」ケース):
1506
- * - 未着信(delivered=false): アンカーが画面に出ていない=職人に届いた確証がない。
1447
+ * - 未配達(delivered=false): tmux への投入に成功していない=職人に届いていない。
1507
1448
  * 生きているかどうかを語る根拠が無いので、生存通知を送ってはいけない。
1508
1449
  * - 既に別の見張りがある: 新しい指示で古い見張りを上書きしない(便の縛り)。同一現場に
1509
1450
  * 複数の in-flight が並ぶかどうかの整理は中央の inFlight ガードの仕事で、relay 側で
@@ -1584,7 +1525,7 @@ export async function keepaliveTick(cfg, state, deps = {}) {
1584
1525
  const expired = now - ka.startedAt >= RELAY_KEEPALIVE_MAX_MS;
1585
1526
  // 上限を過ぎているなら画面は見ない(畳むと決まっているので capture の費用をかけない)。
1586
1527
  const cap = expired ? null : capture(ka.site);
1587
- // ★見張りに入っている=その指示は必ず着信済み(startKeepalive が delivered を要求する)。
1528
+ // ★見張りに入っている=その指示は必ず配達済み(startKeepalive が delivered を要求する)。
1588
1529
  // よってアンカーが流れて消えていても、画面全体を見てよい(ADR-020)。
1589
1530
  const below = cap === null ? null : sliceForDoneScan(cap, ka.anchor, true);
1590
1531
  // ★「作業中です」の印は busy シグナルだけで決める(メニュー中は挙げない・shouldRaiseBusy)。
@@ -1652,7 +1593,7 @@ export async function keepaliveTick(cfg, state, deps = {}) {
1652
1593
  * - タイムアウト(RELAY_ORPHAN_FETCH_TIMEOUT_MS)を付け、devlog 到達不能でも起動がハングしない。
1653
1594
  * - 空配列/エラー/未到達/不正応答はすべて [] を返す(起動を妨げないフェイルセーフ)。
1654
1595
  * - sentAt は数値(ms)でも ISO 文字列でも受け、解釈不能なら現在時刻にフォールバック
1655
- * (= 次の再送まで delay 待ちになる安全側)。
1596
+ * (= 安全側)。
1656
1597
  */
1657
1598
  export async function fetchOrphans(cfg) {
1658
1599
  const u = `${cfg.url}/api/conductor/commands/orphans` +
@@ -1700,7 +1641,7 @@ export async function fetchOrphans(cfg) {
1700
1641
  }
1701
1642
  /**
1702
1643
  * sent孤児を pending へ復元する(2-b・起動後1回)。★ここでは送信しない=pendingに積むだけ。
1703
- * 実際の再送可否は次の relayTick の decideResend(着信済みなら再送しない等の冪等ガード)に委ねる。
1644
+ * ★復元しても配達はしない。役目は「DONE を待つ/生存を伝える/中央の終端を見て畳む」だけ。
1704
1645
  * - 無効化(CONDUCTOR_RESTORE_ORPHANS=off)・取得空・pending占有中は何もしない。
1705
1646
  * - フェイルセーフ: fetchOrphans が [] を返すケースは静かに通常起動を続ける。
1706
1647
  */
@@ -1716,7 +1657,7 @@ export async function restoreOrphans(cfg, state) {
1716
1657
  return;
1717
1658
  }
1718
1659
  state.pending = orphanToPending(pick);
1719
- log(`site=${cfg.site} id=${pick.id} を sent孤児から pending へ復元(★送信せず・次tickで2-a判定)`);
1660
+ log(`site=${cfg.site} id=${pick.id} を sent孤児から pending へ復元(★配達はしない・DONE待ちと生存通知だけ)`);
1720
1661
  }
1721
1662
  // ───────────────────────────────────────────────────────────────────────────
1722
1663
  // relay 本体(1ポーリング分)
@@ -1729,7 +1670,7 @@ export async function restoreOrphans(cfg, state) {
1729
1670
  * (sent 中はサーバも新規指示を返さないので取得しない)。停止中でも継続する
1730
1671
  * (既に送った指示の後始末は進める)。
1731
1672
  * - 緊急停止フラグを確認。停止中なら新規の取得・送信は一切しない。
1732
- * - pending が無く停止中でもなければ、取得 → アイドル判定 → sent遷移 → 送信。
1673
+ * - pending が無く停止中でもなければ、取得 → アイドル判定 → 配達 →「配達済み(sent)」を管制へ記録。
1733
1674
  *
1734
1675
  * すべての判定は保守的(少しでも怪しければ送らない/次回再挑戦)。
1735
1676
  */
@@ -1745,7 +1686,7 @@ export async function relayTick(cfg, state) {
1745
1686
  // ── sent孤児の復元(2-b・起動後1回) ──
1746
1687
  // cli.ts を触らず「起動時1回」を実現するため、最初の tick でだけ復元する。
1747
1688
  // 失敗しても再試行しない(フラグを先に立てる=起動を妨げない/多重実行防止)。
1748
- // ★復元は pending に積むだけ。再送可否は下の pending 分岐 = decideResend に一元化する。
1689
+ // ★復元は pending に積むだけ(配達はしない)。
1749
1690
  if (!state.restored) {
1750
1691
  state.restored = true;
1751
1692
  await restoreOrphans(cfg, state);
@@ -1770,26 +1711,25 @@ export async function relayTick(cfg, state) {
1770
1711
  const cap = tmuxCapture(cfg.site);
1771
1712
  if (cap === null)
1772
1713
  return; // tmux未取得: 次回再確認
1773
- // ── 着信確認 → 初めて sent 化(不具合B 根治・案: アンカー可視を確認してから sent) ──
1774
- // 初回 send-keys 成功だけでは「送ったが職人画面に入っていない」(起動順レース/モーダル等)が
1775
- // あり得るため、サーバ status は送信直後ではなく「アンカー(CONDUCTOR_JOB:{id})が画面に
1776
- // エコーされた=確かに着信した」を確認してから 'sent' にする。それまでは queued 据置=
1777
- // 管制で「送信済」と誤表示しない。delivered になるまでは下の自動再送が(未着信+アイドル時に)
1778
- // 配信を担保する。DONE:{site} はアンカーより下に出る=着信確認はDONE検出より必ず先に成立する。
1779
- if (!state.pending.delivered && cap.includes(state.pending.anchor)) {
1780
- if (await patchStatus(cfg, state.pending.id, 'sent')) {
1781
- state.pending.delivered = true;
1782
- // DONE 待ちの上限時間は「着信=作業開始」時点から数え直す(送信〜着信の遅延で
1783
- // 早期に pending タイムアウトしないように)。再送間隔基準の lastSentAt は触らない。
1784
- state.pending.sentAt = Date.now();
1785
- state.lastHeartbeatAt = null;
1786
- log(`site=${cfg.site} id=${state.pending.id} 着信確認 → sent`);
1787
- }
1788
- // patchStatus 失敗時は delivered=false のまま(次tick再確認)。queued 据置で安全側。
1714
+ // ── 完了検出の走査範囲を広げてよいかの観測(★状態更新には使わない・ADR-023) ──
1715
+ // アンカーを一度でも画面で見たら、以後アンカーが流れて消えても全画面から DONE を拾ってよい
1716
+ // (ADR-020 の根拠)。★ここで観測するのは「どこまで見てよいか」だけで、配達の成否や
1717
+ // 中央の status には一切関与しない。
1718
+ if (!state.pending.screenDoneWide && cap.includes(state.pending.anchor)) {
1719
+ state.pending.screenDoneWide = true;
1720
+ }
1721
+ // ── 「配達済み」の印を管制へ書く(★ADR-023 根治の要・書けるまで毎tick再試行) ──
1722
+ // 配達したという事実は send-keys の成否で確定しており(下の初回送信部)、画面には頼らない。
1723
+ // ここは「その事実を管制へ書けたか」だけを追う。書けていなければ毎tick やり直す。
1724
+ // ★書けなくても再配達はしない(配達は済んでいる。足りないのは印だけ)。
1725
+ if (!state.pending.sentMarked) {
1726
+ await markSentAtCentral(cfg, state);
1727
+ if (state.pending === null)
1728
+ return; // 中央が終端=追跡を畳んだ。次tickで通常経路へ。
1789
1729
  }
1790
1730
  // アンカー(今回送信の境界)より下だけを見て DONE:{site} 単独行を探す。
1791
1731
  // churn で古い DONE が押し出されても・過去ジョブの DONE が窓に残っても、誤検出/見逃しなし。
1792
- // ★アンカーが既に流れて消えている場合は、着信を確認済み(delivered)なら画面全体を見る
1732
+ // ★アンカーが既に流れて消えている場合は、一度アンカーを見ていれば画面全体を見る
1793
1733
  // (ADR-020。全画面表示の職人はさかのぼれる行が無く、アンカーが数秒で消えるため)。
1794
1734
  // ★Codex は完了と報告が notify でデータとして届く(ADR-021)。画面より先にそちらを見る。
1795
1735
  // 届いていれば、そのまま【既存の完了経路】へ橋渡しする(道は1本のまま)。
@@ -1804,7 +1744,7 @@ export async function relayTick(cfg, state) {
1804
1744
  return;
1805
1745
  }
1806
1746
  }
1807
- const below = sliceForDoneScan(cap, state.pending.anchor, state.pending.delivered);
1747
+ const below = sliceForDoneScan(cap, state.pending.anchor, state.pending.screenDoneWide);
1808
1748
  if (below !== null && hasStandaloneDone(below, cfg.site)) {
1809
1749
  // 報告本文も「アンカーより下」から抜き出す(今回ジョブの報告に限定。過去報告の誤添付を防ぐ)。
1810
1750
  const report = extractReport(below);
@@ -1826,9 +1766,9 @@ export async function relayTick(cfg, state) {
1826
1766
  // 【副シグナル温存=無損失縮退】中央 status が読めない(null=エンドポイント未実装/不達)ときだけ、
1827
1767
  // 従来の detectExternalDone(別idが配れる=終端の確証)へフォールバックする。中央側 status API が
1828
1768
  // 入るまではこの副シグナルが従来どおり効き、入った後は主シグナル(status)が確実に固着を断つ。
1829
- // HTTP コストを抑えるため delivered+アイドルのときだけ probe する(busyな通常作業中は叩かない)。
1769
+ // HTTP コストを抑えるため「印が付いている+アイドル」のときだけ probe する(busyな通常作業中は叩かない)。
1830
1770
  const idle = looksIdleFor(cap, kindOf(cfg));
1831
- if (state.pending.delivered && idle) {
1771
+ if (state.pending.sentMarked && idle) {
1832
1772
  const centralStatus = await fetchCommandStatus(cfg, state.pending.id);
1833
1773
  if (isTerminalCommandStatus(centralStatus)) {
1834
1774
  log(`site=${cfg.site} id=${state.pending.id} は中央で終端(status=${centralStatus})を確認` +
@@ -1843,7 +1783,7 @@ export async function relayTick(cfg, state) {
1843
1783
  const next = await fetchCommand(cfg);
1844
1784
  const nextId = next?.id ?? null;
1845
1785
  if (detectExternalDone({
1846
- delivered: state.pending.delivered,
1786
+ sentMarked: state.pending.sentMarked,
1847
1787
  pendingId: state.pending.id,
1848
1788
  idle,
1849
1789
  nextCommandId: nextId,
@@ -1895,109 +1835,13 @@ export async function relayTick(cfg, state) {
1895
1835
  noteBusyEdge(state, cfg.site, state.pending.id, raise, log);
1896
1836
  await sendHeartbeat(cfg, state.pending.id, raise);
1897
1837
  }
1898
- // ── 生存中の自動再送(2-a)。DONE 未達のとき、未着信+アイドルなら同一指示を再送する ──
1899
- // 冪等性最優先: 着信判定(anchor可視)は cap から即わかる。アイドルの二重チェック
1900
- // (isIdleStable=1.5s sleep) は「未着信」のときだけ実行し、着信済みの通常経路では待たない。
1901
- // 判定そのものは純粋関数 decideResend に委譲(着信済み/busy/非アイドル/間隔を一括で結論)。
1902
- // 上限到達後は打ち切らず slow相 で低頻度に再配信を継続する(起動順レース根治)。
1903
- //
1904
- // ★delivered 永続ガード(不具合A・再送ループ根治): 一度「着信確認 → sent」で delivered=true に
1905
- // なった指示は、以後ライブ画面で anchor が churn により窓(capture-pane の可視+scrollback)から
1906
- // 押し出されて見えなくなっても、二度と自動再送しない。従来は decideResend(1) と呼出側の
1907
- // delivered をいずれも「その場の cap.includes(anchor)」で毎tick再導出していたため、確定済みの
1908
- // 着信が undelivered へ逆戻りして再送ループ化していた(職人へ同一 commandId が連続着信し、
1909
- // 既に done でも送り続ける)。着信確認済みは永続フラグを唯一の真とし、完了検出は上の DONE
1910
- // スクレイプ、固着回避は下の pending タイムアウトに委ねる。
1911
- // delivered=false の間(初回着信前・起動順レース)は従来どおり未着信+アイドルで再送して配信を担保する。
1912
- if (!state.pending.delivered) {
1913
- const delivered = cap.includes(state.pending.anchor);
1914
- const idleStable = delivered ? false : await isIdleStable(cfg.site);
1915
- const decision = decideResend({
1916
- capture: cap,
1917
- anchor: state.pending.anchor,
1918
- idleStable,
1919
- sentAt: state.pending.sentAt,
1920
- lastSentAt: state.pending.lastSentAt,
1921
- resendCount: state.pending.resendCount,
1922
- now: Date.now(),
1923
- policy: {
1924
- resendDelayMs: RELAY_RESEND_DELAY_MS,
1925
- resendIntervalMs: RELAY_RESEND_INTERVAL_MS,
1926
- maxResend: RELAY_RESEND_MAX,
1927
- resendSlowIntervalMs: RELAY_RESEND_SLOW_INTERVAL_MS,
1928
- },
1929
- });
1930
- if (decision.verdict === 'resend') {
1931
- // ── 中央status照合(done済み再配達の根治・2026-07-09 実地バグ) ──
1932
- // 完了が conductor_complete(MCP直行) で立つと中央は done になるが、relay が画面のDONE行を
1933
- // 取りこぼすとローカル pending が残り、この経路が同一指示を延々と再配達していた。上の
1934
- // 「外部完了検出」は delivered+アイドルでしか走らず、この !delivered な再送経路とは条件が
1935
- // 排他なので一度も中央を見ていなかった。再送しようとする今このtickでだけ照合する
1936
- // (毎tickのポーリングにはしない=中央への負荷を増やさない)。
1937
- const centralStatus = await fetchCommandStatus(cfg, state.pending.id);
1938
- const reconcile = decideCentralReconcile(centralStatus);
1939
- if (reconcile === 'drop') {
1940
- state.abandoned.add(state.pending.id); // 以後この id は二度と配達しない。
1941
- log(`site=${cfg.site} id=${state.pending.id} 中央で${centralStatus}確認` +
1942
- '→再送中止・ローカル解放(done済み再配達の根治)');
1943
- state.pending = null;
1944
- state.lastHeartbeatAt = null;
1945
- return; // 次tickで halt確認→fetchCommand→通常経路。
1946
- }
1947
- if (reconcile === 'skip') {
1948
- // 中央不達/未知。再送を見送り次tickで再試行(照合失敗で relay 本体は落とさない)。
1949
- log(`site=${cfg.site} id=${state.pending.id} 中央status照合できず` +
1950
- `(status=${centralStatus ?? 'null'})→このtickは再送見送り(次tick再試行)`);
1951
- return;
1952
- }
1953
- // ── 配達回数の絶対上限(保険・無限ループの構造的封じ込め) ──
1954
- // slow相は打ち切らず継続する設計+pendingタイムアウト後の再取得で、職人窓が永久に
1955
- // 未着信/未DONEだと事実上無限に再配達し得る。累計が上限を超えたらこの command を放棄し
1956
- // 二度と配達しない(pendingを解除し abandoned へ。完了は職人の conductor_complete か
1957
- // 画面DONE検出という別経路に委ねる)。
1958
- if (!canDeliver(state, state.pending.id)) {
1959
- if (!state.abandoned.has(state.pending.id)) {
1960
- state.abandoned.add(state.pending.id);
1961
- log(`WARN site=${cfg.site} id=${state.pending.id} 配達回数が上限(${RELAY_DELIVER_MAX})に到達。` +
1962
- '再配達を放棄し pending を解除(無限ループの構造的封じ込め。' +
1963
- '完了通知は職人の conductor_complete か画面DONE検出に委ねる)');
1964
- }
1965
- state.pending = null;
1966
- return;
1967
- }
1968
- // 送信直前ガード(looksLikeClaude)も初回送信と同様に尊重し、非claude窓への誤爆を防ぐ。
1969
- const guardCap = tmuxCapture(cfg.site);
1970
- if (guardCap === null || !looksLikeAgent(guardCap, kindOf(cfg))) {
1971
- log(`WARN site=${cfg.site} id=${state.pending.id} 再送直前にClaude Codeの箱を` +
1972
- '確認できず再送中止(次tick再判定)');
1973
- return;
1974
- }
1975
- // fast相 / slow相 の別(このtickの送信が低頻度再配信フェーズか)。回数++前に判定する。
1976
- const slow = state.pending.resendCount >= RELAY_RESEND_MAX;
1977
- // id 不変=anchor 不変なので、初回と同一の送信テキストを再生成する。
1978
- const text = buildSendText(state.pending.body, cfg.site, state.pending.anchor);
1979
- if (!sendToTmux(cfg.site, text)) {
1980
- // 失敗の理由(tmux の stderr)は sendToTmux が直前の行に出している。
1981
- log(`再送 本文の投入に失敗, keep pending: ${cfg.site}/${state.pending.id}`);
1982
- return;
1983
- }
1984
- state.pending.resendCount += 1;
1985
- state.pending.lastSentAt = Date.now();
1986
- // 累計配達回数を通算(pending 入れ替えをまたいで id 単位に数える=絶対上限の基準)。
1987
- markDelivered(state, state.pending.id);
1988
- // fast相→slow相への移行ログは一度だけ(多重ログ防止)。打ち切りはせず低頻度で継続する旨を記す。
1989
- if (slow && !state.pending.resendExhaustedWarned) {
1990
- state.pending.resendExhaustedWarned = true;
1991
- log(`site=${cfg.site} id=${state.pending.id} fast再送上限(${RELAY_RESEND_MAX})到達。` +
1992
- `以後は ${Math.round(RELAY_RESEND_SLOW_INTERVAL_MS / 1000)}s 間隔で低頻度に再配信を継続` +
1993
- '(職人が後から起動しても届くようにする・打ち切りは pending タイムアウトに委ねる)');
1994
- }
1995
- log(`site=${cfg.site} id=${state.pending.id} 自動再送 #${state.pending.resendCount}` +
1996
- `(${slow ? '低頻度(slow)' : '未着信+アイドル'}: ${decision.reason})`);
1997
- return; // sent 中は新規取得しない
1998
- }
1999
- // 'wait' は再送せずタイムアウト判定へ進む(decideResend は 'stop' を返さない=低頻度継続)。
2000
- }
1838
+ // ★ここに「未着信+アイドルなら同じ便をもう一度送る」自動再送があった(ADR-023 で撤去)。
1839
+ // 撤去した理由: 「着信したか」を画面のアンカーで判定していたが、職人(Claude Code)の
1840
+ // 全画面表示ではアンカーが数秒で流れて消えるため、実際には届いて作業中の便まで
1841
+ // 「未着信」に見えた。そこへアイドルが一瞬重なると同じ便を二重に配達し得た(実地の危険)。
1842
+ // いまは配達の成否を send-keys の成否で確定し、配達は1指示につき1回だけにしている。
1843
+ // 届かなかった場合の受け皿は、①中央が sent→stalled→timeout として人に見せる
1844
+ // ②下の pending タイムアウトで現場を空ける、の2つ(★勝手に送り直さない)。
2001
1845
  // DONE 未達のまま上限時間を超えたら、固着回避のため relay 内部で pending を解除して次へ進む。
2002
1846
  // (CC2 の異常終了等で DONE が永遠に来ないケースの根治。サーバ status は戻さない=relay内部のみ。)
2003
1847
  //
@@ -2010,10 +1854,19 @@ export async function relayTick(cfg, state) {
2010
1854
  log(`WARN site=${cfg.site} id=${state.pending.id} が pending タイムアウト` +
2011
1855
  `(${Math.round(elapsedMs / 1000)}s)。DONE未達のため解除し次へ進む` +
2012
1856
  (handedOver
2013
- ? `/★生存の見張りは継続(上限${Math.round(RELAY_KEEPALIVE_MAX_MS / 60000)}分・再送はしない)`
1857
+ ? `/★生存の見張りは継続(上限${Math.round(RELAY_KEEPALIVE_MAX_MS / 60000)}分・配り直しはしない)`
2014
1858
  : state.keepalive !== null
2015
1859
  ? `/見張りは引き継がず(別の指示 id=${state.keepalive.id} を見張り中)`
2016
- : '/見張りは引き継がず(未着信=生きている根拠がないため)'));
1860
+ : '/見張りは引き継がず(未配達=生きている根拠がないため)'));
1861
+ // ★印を書けないまま追跡を畳む場合の穴止め(ADR-023)。
1862
+ // 印が無いと中央はこの指示を queued のまま返す=次tickで【同じ便をもう一度配ってしまう】。
1863
+ // 配達は済んでいる(職人は受け取って動いている)ので、二度と配らないと決める。
1864
+ // 完了は職人の conductor_complete か画面の DONE 検出に委ねる。
1865
+ if (!state.pending.sentMarked) {
1866
+ state.abandoned.add(state.pending.id);
1867
+ log(`WARN site=${cfg.site} id=${state.pending.id} は配達済みだが管制へ印を書けなかった。` +
1868
+ '★二重配達を避けるため、この指示は以後配達しない(完了は職人の conductor_complete に委ねる)');
1869
+ }
2017
1870
  state.pending = null;
2018
1871
  }
2019
1872
  return; // sent 中は新規取得しない
@@ -2049,6 +1902,15 @@ export async function relayTick(cfg, state) {
2049
1902
  if (state.abandoned.has(cmd.id)) {
2050
1903
  return;
2051
1904
  }
1905
+ // ── 配達回数の絶対上限(保険) ──
1906
+ // 通常は1指示につき1回しか配達しない(自動再送は ADR-023 で撤去)。それでも中央が同じ id を
1907
+ // 何度も queued へ戻す異常が起きたときに、際限なく配り続けないための最後の砦。
1908
+ if (!canDeliver(state, cmd.id)) {
1909
+ state.abandoned.add(cmd.id);
1910
+ log(`WARN site=${cfg.site} id=${cmd.id} 配達回数が上限(${RELAY_DELIVER_MAX})に到達。` +
1911
+ '以後この指示は配達しない(無限ループの構造的封じ込め)');
1912
+ return;
1913
+ }
2052
1914
  // ── アイドル判定(保守的・二重チェック) ──
2053
1915
  if (!tmuxHasSession(cfg.site)) {
2054
1916
  log(`site=${cfg.site} id=${cmd.id} tmux未起動のため送らずスキップ`);
@@ -2097,12 +1959,13 @@ export async function relayTick(cfg, state) {
2097
1959
  log(`本文の投入に失敗, keep queued: ${cfg.site}/${cmd.id}`);
2098
1960
  return;
2099
1961
  }
2100
- // 送信成功 pending を立てて DONE を待つ。★ただし sent 化はここではしない(不具合B 根治)。
2101
- // send-keys 成功=tmuxへキー送出に成功、だけでは「職人画面に本文が入った」保証にならない
2102
- // (起動順レースで relay が職人より先に走り旧/未準備セッションへ送る等)。そのため status は
2103
- // queued 据置のまま delivered=false で開始し、次tick以降にアンカーが画面へエコーされた=
2104
- // 確かに着信した、を確認してから patchStatus('sent') する(上の delivered 分岐)。
2105
- // 着信するまでは decideResend が(未着信+アイドル時に)fast→slow で再配信を継続する。
1962
+ // ── 送信成功=★この時点で「配達した」(ADR-023 根治) ──
1963
+ // 配達したかどうかは send-keys(load-buffer→paste-buffer→Enter)の成否だけで決める。
1964
+ // ★画面のスクレイプ(アンカーが見えたか)には頼らない。職人(Claude Code)は全画面表示で
1965
+ // さかのぼれる行が無く、アンカーは数秒で流れて消えるため、見えないことは「届いていない」
1966
+ // の根拠にならない(実測 2026-09-03〜05・これが札が「待っています」のまま動かない穴だった)。
1967
+ // 届く先が本物の職人の箱であることは、直前の tmuxHasSession / isIdleStable / looksLikeAgent が
1968
+ // 確認済み。だから「送れた=配達できた」と扱ってよい。
2106
1969
  const sentAt = Date.now();
2107
1970
  state.pending = {
2108
1971
  id: cmd.id,
@@ -2110,16 +1973,18 @@ export async function relayTick(cfg, state) {
2110
1973
  sentAt,
2111
1974
  anchor,
2112
1975
  body: cmd.body,
2113
- resendCount: 0,
2114
- lastSentAt: sentAt,
2115
- resendExhaustedWarned: false,
2116
- delivered: false,
1976
+ delivered: true,
1977
+ sentMarked: false,
1978
+ screenDoneWide: false,
2117
1979
  };
2118
- // 累計配達回数を通算(初回送信も1件として数える=絶対上限 RELAY_DELIVER_MAX の基準)。
1980
+ // 累計配達回数を通算(絶対上限 RELAY_DELIVER_MAX の基準)。
2119
1981
  markDelivered(state, cmd.id);
2120
1982
  // 新しい指示の生存ハートビートは先頭から数え直す(前指示のスロットルを引き継がない)。
2121
1983
  state.lastHeartbeatAt = null;
2122
- log(`site=${cfg.site} id=${cmd.id} 送信(着信確認待ち・queued据置)`);
1984
+ log(`site=${cfg.site} id=${cmd.id} 配達しました`);
1985
+ // ★配達の直後に「配達済み(sent)」を管制へ書く。書けなければ次tick以降で書けるまでやり直す
1986
+ // (markSentAtCentral)。★書けなくても再配達はしない。
1987
+ await markSentAtCentral(cfg, state);
2123
1988
  }
2124
1989
  finally {
2125
1990
  state.running = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {