@tyhld/conductor 0.7.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.
- package/dist/relay.js +195 -295
- 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 === '')
|
|
@@ -57,7 +57,30 @@ function envPositiveNumber(name, def) {
|
|
|
57
57
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : def;
|
|
58
58
|
}
|
|
59
59
|
/**
|
|
60
|
-
*
|
|
60
|
+
* ★中央(devlog-tracker)が「手が挙がっていても必ず現場を解放する」上限(ms)。
|
|
61
|
+
*
|
|
62
|
+
* 【この数字の持ち主は中央】便1 PR#250 で中央がこう決めた:
|
|
63
|
+
* 「busy の印が新しい間は30分の時間切れをしない。ただし着信から3時間で必ず解放する
|
|
64
|
+
* (手を挙げ直しても伸びない)」。
|
|
65
|
+
* =relay がどれだけ手を挙げ続けても、3時間を過ぎれば現場は解放される。
|
|
66
|
+
*
|
|
67
|
+
* 【★片方だけ動かさないこと】中央がこの値を変えたら、ここも合わせる。
|
|
68
|
+
* 合っていないと次のどちらかになる:
|
|
69
|
+
* ・ここが短い … 中央はまだ守ってくれるのに職人が先に手を下ろす=割り込まれる
|
|
70
|
+
* (2026-09-04 に実際に起きた。中央3時間・relay 30分だった)
|
|
71
|
+
* ・ここが長い … 中央は既に解放しているのに職人だけ手を挙げ続ける=意味がない
|
|
72
|
+
* ★長い側は下の clampToCentralHold が構造的に潰す(設定を間違えても中央を超えない)。
|
|
73
|
+
*/
|
|
74
|
+
export const CENTRAL_BUSY_HOLD_MAX_MS = 3 * 60 * 60 * 1000; // 3時間(中央 PR#250 と同じ値)
|
|
75
|
+
/**
|
|
76
|
+
* 見張りの上限を中央の上限に収める。★中央より長い値は意味がないので切り詰める。
|
|
77
|
+
* 環境変数で誤って長く設定しても、中央を超えないことをここで保証する。
|
|
78
|
+
*/
|
|
79
|
+
export function clampToCentralHold(ms) {
|
|
80
|
+
return Math.min(ms, CENTRAL_BUSY_HOLD_MAX_MS);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 生存の見張り(keepalive)を打ち切るまでの上限(ms)。★既定は中央と同じ3時間。
|
|
61
84
|
*
|
|
62
85
|
* 【なぜ pending とは別の寿命が要るのか(調査 176aebcf・案①-a)】
|
|
63
86
|
* 上の RELAY_PENDING_TIMEOUT_MS は「配達の追跡」を諦める時計で、固着して次の指示を拾えなく
|
|
@@ -70,42 +93,34 @@ function envPositiveNumber(name, def) {
|
|
|
70
93
|
* そこで「配達の追跡(pending)」と「生存の見張り(keepalive)」の寿命を分ける。pending は従来
|
|
71
94
|
* どおり15分で解除して次の指示を拾えるようにし、生存通知だけをこの時計で続ける。
|
|
72
95
|
*
|
|
73
|
-
* 【なぜ30
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
*/
|
|
91
|
-
export const RELAY_RESEND_DELAY_MS = envPositiveNumber('RELAY_RESEND_DELAY_MS', 30 * 1000);
|
|
92
|
-
export const RELAY_RESEND_INTERVAL_MS = envPositiveNumber('RELAY_RESEND_INTERVAL_MS', 30 * 1000);
|
|
93
|
-
export const RELAY_RESEND_MAX = envPositiveNumber('RELAY_RESEND_MAX', 3);
|
|
94
|
-
export const RELAY_RESEND_SLOW_INTERVAL_MS = envPositiveNumber('RELAY_RESEND_SLOW_INTERVAL_MS', 2 * 60 * 1000);
|
|
96
|
+
* 【なぜ30分→3時間へ広げたか(えふさん決定 2026-09-04)】
|
|
97
|
+
* 便2で「作業中です」と手を挙げる仕掛けを入れたが、この上限が30分のままだと
|
|
98
|
+
* 30分で手が下りて、また割り込まれる。実測 2026-09-04: 15分の pending 解除が18件=
|
|
99
|
+
* 30分を超える便が常態で、割り込まれた便は着信から35分だった。
|
|
100
|
+
* ★中央だけ3時間に伸びていて、職人側が30分で足を引っ張っている状態だったので合わせる。
|
|
101
|
+
*
|
|
102
|
+
* 【★引き受けたリスク(えふさん判断)】職人が固まった場合、その現場は最大3時間ふさがる。
|
|
103
|
+
* だから「時間で下ろす」以外の下ろし方が生命線になる。次の4つは時間を待たずに効く:
|
|
104
|
+
* ① 画面の DONE を検出した (decideKeepalive の 'done')
|
|
105
|
+
* ② 中央で終端になった ('probe' → isCentralFinished)
|
|
106
|
+
* ③ 職人が静止した ('probe' → 手を下ろす。終端でなくても下ろす)
|
|
107
|
+
* ④ relayTick 入口の砦 (pending でも keepalive でもなくなったら必ず下ろす)
|
|
108
|
+
* ★この4つはテスト(test/relay-busy-hold.test.ts)で固定してある。外すと落ちる。
|
|
109
|
+
*
|
|
110
|
+
* 環境変数 RELAY_KEEPALIVE_MAX_MS で上書き可(正の数のみ・★中央の上限までに切り詰める)。
|
|
111
|
+
*/
|
|
112
|
+
export const RELAY_KEEPALIVE_MAX_MS = clampToCentralHold(envPositiveNumber('RELAY_KEEPALIVE_MAX_MS', CENTRAL_BUSY_HOLD_MAX_MS));
|
|
95
113
|
/**
|
|
96
114
|
* 同一 command を relay が配達する累計回数の絶対上限(保険・無限ループの構造的封じ込め)。
|
|
97
|
-
*
|
|
115
|
+
* プロセス内で id 単位に通算する。
|
|
98
116
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* slow再送 …」のサイクルが(resendCount がその都度0にリセットされるため)事実上無限に続く穴が残る。
|
|
103
|
-
* この上限を超えた command はプロセス内で「配達放棄(abandoned)」とし、二度と配達しない(WARNログを残す)。
|
|
117
|
+
* 【★ADR-023 以後の位置づけ】配達は「1指示につき1回だけ」になった(自動再送は撤去)。
|
|
118
|
+
* それでも上限を残すのは、中央が同じ id を queued へ戻す運用(人が差し戻す・中央側の不具合)が
|
|
119
|
+
* あり得るため。ここを超えた command はプロセス内で「配達放棄(abandoned)」とし二度と配達しない。
|
|
104
120
|
*
|
|
105
121
|
* 【スコープ】プロセス再起動でカウンタ/放棄集合はリセットされる(サーバAPIは変更しない方針のため永続化しない)。
|
|
106
|
-
* これは許容範囲:
|
|
107
|
-
*
|
|
108
|
-
* 環境変数 RELAY_DELIVER_MAX で上書き可(正の数のみ)。既定20(初回1+再送19相当)。
|
|
122
|
+
* これは許容範囲: 配達直後に中央が sent になる=サーバが同じ指示を queued として返さない。
|
|
123
|
+
* 環境変数 RELAY_DELIVER_MAX で上書き可(正の数のみ)。既定20。
|
|
109
124
|
*/
|
|
110
125
|
export const RELAY_DELIVER_MAX = envPositiveNumber('RELAY_DELIVER_MAX', 20);
|
|
111
126
|
/**
|
|
@@ -187,7 +202,7 @@ export function createRelayState() {
|
|
|
187
202
|
};
|
|
188
203
|
}
|
|
189
204
|
/**
|
|
190
|
-
* この command
|
|
205
|
+
* この command をまだ配達してよいか。純粋関数(I/Oなし=単体テスト可能)。
|
|
191
206
|
* - 既に放棄済み(abandoned) → false。
|
|
192
207
|
* - 累計配達回数が RELAY_DELIVER_MAX 以上 → false(もう配達しない=無限ループ防止の絶対上限)。
|
|
193
208
|
*/
|
|
@@ -196,7 +211,7 @@ export function canDeliver(state, id) {
|
|
|
196
211
|
return false;
|
|
197
212
|
return (state.deliverCounts.get(id) ?? 0) < RELAY_DELIVER_MAX;
|
|
198
213
|
}
|
|
199
|
-
/** 配達成功を1
|
|
214
|
+
/** 配達成功を1件記録して累計を返す(tmux への投入に成功したときに呼ぶ)。 */
|
|
200
215
|
export function markDelivered(state, id) {
|
|
201
216
|
const n = (state.deliverCounts.get(id) ?? 0) + 1;
|
|
202
217
|
state.deliverCounts.set(id, n);
|
|
@@ -256,7 +271,7 @@ export function busyScanRegion(text) {
|
|
|
256
271
|
* - 走査対象は busyScanRegion=入力枠フッター領域だけ(最後の横罫線より下)。トランスクリプト
|
|
257
272
|
* 本文の busy 相当語で永久 busy 誤判定にならないための恒久修正。判別軸は v2.x 常時描画のフッター
|
|
258
273
|
* 行('esc to interrupt' を含む→busy / '? for shortcuts' を含む→idle)。
|
|
259
|
-
* - busy 判定を looksIdle
|
|
274
|
+
* - busy 判定を looksIdle から切り出して再利用可能にしたもの(生存通知の判定でも使う)。
|
|
260
275
|
* - looksIdle はこれが true なら即アイドル不成立に倒す。
|
|
261
276
|
*/
|
|
262
277
|
export function hasBusySignal(text) {
|
|
@@ -333,7 +348,7 @@ export function looksIdle(text) {
|
|
|
333
348
|
return false;
|
|
334
349
|
// 選択メニュー表示中はアイドルとみなさない(落とし穴ガード・★撤去してはいけない要)。
|
|
335
350
|
// Claude Code の確認メニュー("❯ 1. Yes" 等)も `❯` で始まる行を含むため、下の hasPrompt が
|
|
336
|
-
// true になりアイドル誤判定し得る。メニュー表示中に relay の pending
|
|
351
|
+
// true になりアイドル誤判定し得る。メニュー表示中に relay の pending ループが新規指示を
|
|
337
352
|
// 流し込むと、選択メニューに指示文が打ち込まれて混線する(=作業への割り込み事故)。
|
|
338
353
|
// ★段3で「質問の中身を読む処理」は全部撤去したが、この真偽ガードだけは残す(残す理由は
|
|
339
354
|
// hasChoiceMenu のコメント参照)。答えるのはフック経路の役目で、relay は一切押さない。
|
|
@@ -623,15 +638,18 @@ export function buildAnchor(id) {
|
|
|
623
638
|
* 【なぜアンカーを求めていたのか】前の指示の DONE 行や報告を、今の指示のものと取り違えないため。
|
|
624
639
|
*
|
|
625
640
|
* 【根治】アンカーが見えないときも安全に全画面を見てよい条件がある:
|
|
626
|
-
*
|
|
641
|
+
* ★**アンカーが「一度見えた」あとに「見えなくなった」なら、
|
|
627
642
|
* アンカーより上にあった前の指示の出力は、アンカーより先に画面から消えている。**
|
|
628
643
|
* 画面は上から順に流れて出ていくので、アンカーが消えた時点で、その上にあった
|
|
629
644
|
* 前の指示の DONE 行も必ず消えている。だから取り違えは構造的に起こらない。
|
|
630
645
|
*
|
|
631
646
|
* 返り:
|
|
632
647
|
* - アンカーが見える → アンカーより下(従来どおり・いちばん厳しい)
|
|
633
|
-
* - アンカーが見えない かつ
|
|
634
|
-
* -
|
|
648
|
+
* - アンカーが見えない かつ wide → 画面全体(上記の理由で安全)
|
|
649
|
+
* - それ以外(アンカーを一度も見ていない)→ null(見に行かない)
|
|
650
|
+
*
|
|
651
|
+
* ★第3引数は「画面をどこまで見てよいか」だけを表す(PendingEntry.screenDoneWide)。
|
|
652
|
+
* ★配達したか・中央へ印を書いたかとは別物(混ぜていたのが ADR-023 の穴)。
|
|
635
653
|
*/
|
|
636
654
|
// ───────────────────────────────────────────────────────────────────────────
|
|
637
655
|
// Codex の完了・報告を受け取る口(★ADR-021)
|
|
@@ -693,11 +711,11 @@ export function takeCodexNotice(site, env = process.env) {
|
|
|
693
711
|
}
|
|
694
712
|
return latest;
|
|
695
713
|
}
|
|
696
|
-
export function sliceForDoneScan(text, anchor,
|
|
714
|
+
export function sliceForDoneScan(text, anchor, wide) {
|
|
697
715
|
const below = sliceBelowAnchor(text, anchor);
|
|
698
716
|
if (below !== null)
|
|
699
717
|
return below;
|
|
700
|
-
return
|
|
718
|
+
return wide ? text : null;
|
|
701
719
|
}
|
|
702
720
|
export function sliceBelowAnchor(text, anchor) {
|
|
703
721
|
const lines = text.replace(/\r/g, '').split('\n');
|
|
@@ -733,61 +751,6 @@ export function hasDoneBelowAnchor(text, site, anchor) {
|
|
|
733
751
|
return false;
|
|
734
752
|
return hasStandaloneDone(below, site);
|
|
735
753
|
}
|
|
736
|
-
/**
|
|
737
|
-
* 生存中の職人が動かないときの自動再送の可否を判定する純粋関数(2-a の核心)。
|
|
738
|
-
*
|
|
739
|
-
* 【二重実行を防ぐ冪等性が最優先】判定の順序がそのまま安全の優先順位:
|
|
740
|
-
* 1) アンカー(CONDUCTOR_JOB:{id})が画面に出ている=tmuxへ着信済み。アイドルでも再送しない。
|
|
741
|
-
* (職人が読んで思考中の可能性。これが二重実行防止の核心。経過時間より着信判定を優先する)
|
|
742
|
-
* 2) busy(esc to interrupt / compacting / スピナー)=実処理中なら再送しない(混線回避)。
|
|
743
|
-
* 3) 二重チェックのアイドル(isIdleStable)が不成立なら再送しない(保守的)。
|
|
744
|
-
* 4) 送信間隔が lastSentAt から未経過なら待つ。間隔は「初回=delay / fast相(回数<max)=interval /
|
|
745
|
-
* ★slow相(回数≥max)=slowInterval」。
|
|
746
|
-
* 5) 上を全て満たす=未着信かつ静止かつ閾値経過 → 'resend'。
|
|
747
|
-
*
|
|
748
|
-
* ★起動順レース根治(不具合A): かつては「最大再送回数到達で 'stop'(打ち切り)」だったが、
|
|
749
|
-
* これだと職人が relay より後から起動したケースで二度と届かなかった。本実装は上限到達で
|
|
750
|
-
* 打ち切らず、間隔を slowInterval に広げて低頻度で再配信を継続する(職人が起動して未着信+
|
|
751
|
-
* アイドルになれば次の slow tick で必ず届く)。暴走しないのは、再送が常に「未着信(anchor未可視)
|
|
752
|
-
* かつ idleStable かつ 間隔経過」を満たすときだけ=着信済み/busy/非アイドルの間は一切送らないため。
|
|
753
|
-
* 最終的な打ち切りは外側の pending タイムアウト(RELAY_PENDING_TIMEOUT_MS)に委ねる。
|
|
754
|
-
*
|
|
755
|
-
* capture は単一フレームの画面テキスト(着信判定 anchor とbusy判定に使う)。
|
|
756
|
-
* idleStable は呼び出し側で isIdleStable() を実行した二重チェック済みアイドルの真偽。
|
|
757
|
-
*/
|
|
758
|
-
export function decideResend(args) {
|
|
759
|
-
const { capture, anchor, idleStable, lastSentAt, resendCount, now, policy } = args;
|
|
760
|
-
// (1) 冪等性の核心: 着信済み(アンカーが画面に可視)ならアイドルでも再送しない。
|
|
761
|
-
if (capture.includes(anchor)) {
|
|
762
|
-
return { verdict: 'wait', reason: 'delivered (anchor visible on screen)' };
|
|
763
|
-
}
|
|
764
|
-
// (2) 実処理中シグナルがあれば再送しない(職人が動いている=送信は届いて処理中の可能性)。
|
|
765
|
-
if (hasBusySignal(capture)) {
|
|
766
|
-
return { verdict: 'wait', reason: 'busy (work-in-progress signal)' };
|
|
767
|
-
}
|
|
768
|
-
// (3) 二重チェックのアイドルが不成立なら再送しない。
|
|
769
|
-
if (!idleStable) {
|
|
770
|
-
return { verdict: 'wait', reason: 'not idle-stable' };
|
|
771
|
-
}
|
|
772
|
-
// (4) 送信間隔(初回 delay / fast相 interval / ★slow相 slowInterval)未経過なら待つ。lastSentAt 基準。
|
|
773
|
-
// fast相 = 回数 < maxResend、slow相 = 回数 ≥ maxResend(上限到達後も打ち切らず低頻度継続)。
|
|
774
|
-
const inSlowPhase = resendCount >= policy.maxResend;
|
|
775
|
-
const required = resendCount === 0
|
|
776
|
-
? policy.resendDelayMs
|
|
777
|
-
: inSlowPhase
|
|
778
|
-
? policy.resendSlowIntervalMs
|
|
779
|
-
: policy.resendIntervalMs;
|
|
780
|
-
if (now - lastSentAt < required) {
|
|
781
|
-
return { verdict: 'wait', reason: 'resend interval not elapsed' };
|
|
782
|
-
}
|
|
783
|
-
// (5) 未着信+アイドル+閾値経過 → 再送(上限後も slow相 で低頻度に継続)。
|
|
784
|
-
return {
|
|
785
|
-
verdict: 'resend',
|
|
786
|
-
reason: inSlowPhase
|
|
787
|
-
? 'undelivered & idle-stable & slow-interval elapsed (post-max slow redelivery)'
|
|
788
|
-
: 'undelivered & idle-stable & interval elapsed',
|
|
789
|
-
};
|
|
790
|
-
}
|
|
791
754
|
/**
|
|
792
755
|
* 外部完了(中央で done/failed 化された案件)を検知して pending 追跡を解除すべきかを判定する純粋関数。
|
|
793
756
|
*
|
|
@@ -800,13 +763,13 @@ export function decideResend(args) {
|
|
|
800
763
|
* 【判定の根拠=根治方針「完了の真実は中央にある」への寄せ】
|
|
801
764
|
* 中央は「1現場同時1指示」で、ある指示が sent の間は新規 queued を返さない(fetchCommand=null)。
|
|
802
765
|
* したがって次の条件が揃えば pending.id は中央で既に終端した確証になる:
|
|
803
|
-
* -
|
|
766
|
+
* - sentMarked(中央へ「配達済み(sent)」を書けている=中央はこの指示を queued として返さない)で、
|
|
804
767
|
* - 職人画面がアイドル(looksIdle=処理中でも許可プロンプトでもない・入力待ち)で、
|
|
805
768
|
* - fetchCommand が pending.id とは別の id の指示を返した(=中央がもう次を配れる状態)。
|
|
806
769
|
* このとき追跡を解除して次tickで新規指示を素直に取りに行けば、外部done化でも詰まらない。
|
|
807
770
|
*
|
|
808
771
|
* 【安全側(false=解除しない)に倒すケース】いずれも従来の画面DONE検出/pendingタイムアウトへ無損失縮退:
|
|
809
|
-
* -
|
|
772
|
+
* - sentMarked 前(中央はまだ queued 扱い): 次のqueuedはpending自身なので誤検出しない。
|
|
810
773
|
* - 非アイドル(busy/許可プロンプト等): まだ職人が動作中の可能性 → 解除しない。
|
|
811
774
|
* - 中央不達 or 次指示なし(nextCommandId=null)。
|
|
812
775
|
* - 次指示が pending と同id(通常は起こらないが、念のため同一なら解除しない)。
|
|
@@ -814,8 +777,8 @@ export function decideResend(args) {
|
|
|
814
777
|
* I/O を持たない純粋関数(fetchCommand の結果 id と画面アイドル判定を引数で受ける)=単体テスト可能。
|
|
815
778
|
*/
|
|
816
779
|
export function detectExternalDone(args) {
|
|
817
|
-
if (!args.
|
|
818
|
-
return false; //
|
|
780
|
+
if (!args.sentMarked)
|
|
781
|
+
return false; // 印が未記録=中央はまだ queued。次のqueuedはpending自身。
|
|
819
782
|
if (!args.idle)
|
|
820
783
|
return false; // 職人が動作中/許可待ちの可能性 → 解除しない(保守的)。
|
|
821
784
|
if (args.nextCommandId === null)
|
|
@@ -840,12 +803,6 @@ export function isTerminalCommandStatus(status) {
|
|
|
840
803
|
const s = status.trim().toLowerCase();
|
|
841
804
|
return s === 'done' || s === 'failed' || s === 'cancelled' || s === 'canceled' || s === 'abandoned';
|
|
842
805
|
}
|
|
843
|
-
/**
|
|
844
|
-
* 中央が「まだ配達してよい=生きている」と明示した status(これらだけ再送を許す)。
|
|
845
|
-
* queued=未配達 / sent=配達済み作業中 / stalled=無音だが未終端 / perm_wait=承認待ち / held=保留。
|
|
846
|
-
* held は「中央がまだ流す判断をしていない」=再送してよい状態ではないので skip 側に置く。
|
|
847
|
-
*/
|
|
848
|
-
const CENTRAL_LIVE_STATUSES = new Set(['queued', 'sent', 'stalled', 'perm_wait']);
|
|
849
806
|
/**
|
|
850
807
|
* 中央で dismissed/timeout も終端。isTerminalCommandStatus(既存・変更しない)は done/failed/
|
|
851
808
|
* cancelled/canceled/abandoned のみを見るので、再配達の可否ではこの2つを足して判断する。
|
|
@@ -858,7 +815,7 @@ const CENTRAL_EXTRA_TERMINAL_STATUSES = new Set(['dismissed', 'timeout']);
|
|
|
858
815
|
* done / failed / cancelled / canceled / abandoned / dismissed / timeout のいずれか。
|
|
859
816
|
* null(中央不達・未知)や未知の綴りは false =「終わっているとは言えない」に倒す(保守的)。
|
|
860
817
|
*
|
|
861
|
-
*
|
|
818
|
+
* ★「配達済みの印(sent)を書き続けてよいか」と、生存の見張りを畳む判断(keepalive)で
|
|
862
819
|
* 同じ物差しを使うためにここへ切り出した。判定が2か所に分かれて食い違うのを防ぐ。
|
|
863
820
|
*/
|
|
864
821
|
export function isCentralFinished(status) {
|
|
@@ -868,41 +825,12 @@ export function isCentralFinished(status) {
|
|
|
868
825
|
return isTerminalCommandStatus(s) || CENTRAL_EXTRA_TERMINAL_STATUSES.has(s);
|
|
869
826
|
}
|
|
870
827
|
/**
|
|
871
|
-
*
|
|
872
|
-
*
|
|
873
|
-
*
|
|
874
|
-
*
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
878
|
-
* 結論:
|
|
879
|
-
* - 'drop' 終端(done/failed/cancelled/canceled/abandoned/dismissed/timeout)。再送せず pending を
|
|
880
|
-
* 解放し abandoned へ入れる(以後二度と配達しない)。
|
|
881
|
-
* - 'resend' 中央が生存(queued/sent/stalled/perm_wait)と明示。従来どおり再送してよい。
|
|
882
|
-
* - 'skip' null(中央不達・404・タイムアウト=未知)、または未知の綴り。このtickは何もせず次tickで
|
|
883
|
-
* 再試行する。曖昧なら送らない安全側に倒す(誤再配達より遅延のほうが安い)。
|
|
884
|
-
*
|
|
885
|
-
* 【縮退の注意】中央に GET /api/conductor/commands/{id} が無い環境では常に null→'skip' となり自動再送は
|
|
886
|
-
* 起きない。固着は従来どおり pending タイムアウト(RELAY_PENDING_TIMEOUT_MS)が解除する。
|
|
887
|
-
*/
|
|
888
|
-
export function decideCentralReconcile(centralStatus) {
|
|
889
|
-
if (centralStatus === null)
|
|
890
|
-
return 'skip'; // 未知=照合できず。送らない安全側。
|
|
891
|
-
if (isCentralFinished(centralStatus))
|
|
892
|
-
return 'drop';
|
|
893
|
-
const s = centralStatus.trim().toLowerCase();
|
|
894
|
-
if (CENTRAL_LIVE_STATUSES.has(s))
|
|
895
|
-
return 'resend';
|
|
896
|
-
return 'skip'; // 未知の綴り(将来の新status・held 等)は送らない安全側。
|
|
897
|
-
}
|
|
898
|
-
/**
|
|
899
|
-
* sent孤児を 2-a の pending 構造へ変換する純粋関数(★送信はしない)。
|
|
900
|
-
* - anchor は buildAnchor(id) で再生成(id 不変=アンカー不変)。送信本文末尾のアンカーと一致するため、
|
|
901
|
-
* 復元後に画面へアンカーが残っていれば decideResend が「着信済み」と見て再送しない(二重実行防止)。
|
|
902
|
-
* - resendCount=0 から再開。lastSentAt は取得した sentAt を流用(次の再送間隔判定の基準)。
|
|
903
|
-
* 孤児は sentAt が十分過去(TTL超)なので、未着信なら次tickの decideResend で即 resend 判定になり得る。
|
|
904
|
-
* - delivered=true: 孤児は元々サーバ status=sent=過去に着信確認を経て sent 化済みの段階。
|
|
905
|
-
* 復元後に再度 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 と同じ根拠・従来と同じ挙動)。
|
|
906
834
|
*/
|
|
907
835
|
export function orphanToPending(orphan) {
|
|
908
836
|
return {
|
|
@@ -911,10 +839,9 @@ export function orphanToPending(orphan) {
|
|
|
911
839
|
sentAt: orphan.sentAt,
|
|
912
840
|
anchor: buildAnchor(orphan.id),
|
|
913
841
|
body: orphan.body,
|
|
914
|
-
resendCount: 0,
|
|
915
|
-
lastSentAt: orphan.sentAt,
|
|
916
|
-
resendExhaustedWarned: false,
|
|
917
842
|
delivered: true,
|
|
843
|
+
sentMarked: true,
|
|
844
|
+
screenDoneWide: true,
|
|
918
845
|
};
|
|
919
846
|
}
|
|
920
847
|
/**
|
|
@@ -949,7 +876,7 @@ export function selectOrphanToRestore(orphans, currentPending) {
|
|
|
949
876
|
export function buildSendText(body, site, anchor) {
|
|
950
877
|
const marker = `DONE:${site}`;
|
|
951
878
|
// anchor から素の commandId を取り出す(conductor_complete に渡す引数。接頭辞無しなので新たな
|
|
952
|
-
// アンカー一致を作らず、sliceBelowAnchor
|
|
879
|
+
// アンカー一致を作らず、sliceBelowAnchor の境界判定に干渉しない)。
|
|
953
880
|
const commandId = anchor.startsWith(`${SEND_ANCHOR_PREFIX}:`)
|
|
954
881
|
? anchor.slice(SEND_ANCHOR_PREFIX.length + 1)
|
|
955
882
|
: anchor;
|
|
@@ -1328,7 +1255,7 @@ export async function patchStatus(cfg, id, status, report) {
|
|
|
1328
1255
|
* - status を確定的に読めた場合のみ小文字化して返す。それ以外(非2xx・404・タイムアウト・通信失敗・
|
|
1329
1256
|
* status 欠落/非文字列)はすべて null=「中央不達 or 未知」を意味し、呼び出し側は従来経路
|
|
1330
1257
|
* (detectExternalDone の副シグナル/pendingタイムアウト)へ無損失で縮退する。
|
|
1331
|
-
* -
|
|
1258
|
+
* - 最善努力プローブなので失敗はログを出さず握りつぶす(アイドル毎tickで叩くため冗長ログ回避)。
|
|
1332
1259
|
*
|
|
1333
1260
|
* 【デプロイ順の安全性】このエンドポイントが中央(devlog-tracker)に未実装の間は 404/非2xx→null となり、
|
|
1334
1261
|
* relay は従来どおり動く(無害・無損失)。中央側 PR が入った時点で自動的に根治が効き始める。
|
|
@@ -1354,6 +1281,55 @@ export async function fetchCommandStatus(cfg, id) {
|
|
|
1354
1281
|
return null; // タイムアウト/通信失敗=未知。従来経路へ縮退(安全側)。
|
|
1355
1282
|
}
|
|
1356
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
|
+
}
|
|
1357
1333
|
/**
|
|
1358
1334
|
* このtickで生存ハートビート(bare)を送るかを決める純粋関数(I/Oなし=単体テスト可能)。
|
|
1359
1335
|
*
|
|
@@ -1461,14 +1437,14 @@ export function noteBusyEdge(state, site, id, raise, logLine) {
|
|
|
1461
1437
|
// 管制は「返事がありません」のまま戻らない(2026-09-01 実地)。中央側の戻り道は既にある
|
|
1462
1438
|
// (/seen の body 無し通知が stalled → sent へ戻す)ので、送り手を絶やさないだけで直る。
|
|
1463
1439
|
//
|
|
1464
|
-
//
|
|
1440
|
+
// 【この経路がやらないこと】★配達は一切しない(本文を持たない)。やるのは
|
|
1465
1441
|
// ①生存通知 ②画面の DONE 行を見つけたときの完了報告 ③中央が終端なら畳む、の3つだけ。
|
|
1466
1442
|
// ───────────────────────────────────────────────────────────────────────────
|
|
1467
1443
|
/**
|
|
1468
1444
|
* 見張りを引き継ぐ(pending → keepalive)。★I/Oなし=単体テスト可能。
|
|
1469
1445
|
*
|
|
1470
1446
|
* 引き継がない条件(どちらも「引き継ぐ根拠がない/壊す」ケース):
|
|
1471
|
-
* -
|
|
1447
|
+
* - 未配達(delivered=false): tmux への投入に成功していない=職人に届いていない。
|
|
1472
1448
|
* 生きているかどうかを語る根拠が無いので、生存通知を送ってはいけない。
|
|
1473
1449
|
* - 既に別の見張りがある: 新しい指示で古い見張りを上書きしない(便の縛り)。同一現場に
|
|
1474
1450
|
* 複数の in-flight が並ぶかどうかの整理は中央の inFlight ガードの仕事で、relay 側で
|
|
@@ -1549,7 +1525,7 @@ export async function keepaliveTick(cfg, state, deps = {}) {
|
|
|
1549
1525
|
const expired = now - ka.startedAt >= RELAY_KEEPALIVE_MAX_MS;
|
|
1550
1526
|
// 上限を過ぎているなら画面は見ない(畳むと決まっているので capture の費用をかけない)。
|
|
1551
1527
|
const cap = expired ? null : capture(ka.site);
|
|
1552
|
-
//
|
|
1528
|
+
// ★見張りに入っている=その指示は必ず配達済み(startKeepalive が delivered を要求する)。
|
|
1553
1529
|
// よってアンカーが流れて消えていても、画面全体を見てよい(ADR-020)。
|
|
1554
1530
|
const below = cap === null ? null : sliceForDoneScan(cap, ka.anchor, true);
|
|
1555
1531
|
// ★「作業中です」の印は busy シグナルだけで決める(メニュー中は挙げない・shouldRaiseBusy)。
|
|
@@ -1617,7 +1593,7 @@ export async function keepaliveTick(cfg, state, deps = {}) {
|
|
|
1617
1593
|
* - タイムアウト(RELAY_ORPHAN_FETCH_TIMEOUT_MS)を付け、devlog 到達不能でも起動がハングしない。
|
|
1618
1594
|
* - 空配列/エラー/未到達/不正応答はすべて [] を返す(起動を妨げないフェイルセーフ)。
|
|
1619
1595
|
* - sentAt は数値(ms)でも ISO 文字列でも受け、解釈不能なら現在時刻にフォールバック
|
|
1620
|
-
* (=
|
|
1596
|
+
* (= 安全側)。
|
|
1621
1597
|
*/
|
|
1622
1598
|
export async function fetchOrphans(cfg) {
|
|
1623
1599
|
const u = `${cfg.url}/api/conductor/commands/orphans` +
|
|
@@ -1665,7 +1641,7 @@ export async function fetchOrphans(cfg) {
|
|
|
1665
1641
|
}
|
|
1666
1642
|
/**
|
|
1667
1643
|
* sent孤児を pending へ復元する(2-b・起動後1回)。★ここでは送信しない=pendingに積むだけ。
|
|
1668
|
-
*
|
|
1644
|
+
* ★復元しても配達はしない。役目は「DONE を待つ/生存を伝える/中央の終端を見て畳む」だけ。
|
|
1669
1645
|
* - 無効化(CONDUCTOR_RESTORE_ORPHANS=off)・取得空・pending占有中は何もしない。
|
|
1670
1646
|
* - フェイルセーフ: fetchOrphans が [] を返すケースは静かに通常起動を続ける。
|
|
1671
1647
|
*/
|
|
@@ -1681,7 +1657,7 @@ export async function restoreOrphans(cfg, state) {
|
|
|
1681
1657
|
return;
|
|
1682
1658
|
}
|
|
1683
1659
|
state.pending = orphanToPending(pick);
|
|
1684
|
-
log(`site=${cfg.site} id=${pick.id} を sent孤児から pending
|
|
1660
|
+
log(`site=${cfg.site} id=${pick.id} を sent孤児から pending へ復元(★配達はしない・DONE待ちと生存通知だけ)`);
|
|
1685
1661
|
}
|
|
1686
1662
|
// ───────────────────────────────────────────────────────────────────────────
|
|
1687
1663
|
// relay 本体(1ポーリング分)
|
|
@@ -1694,7 +1670,7 @@ export async function restoreOrphans(cfg, state) {
|
|
|
1694
1670
|
* (sent 中はサーバも新規指示を返さないので取得しない)。停止中でも継続する
|
|
1695
1671
|
* (既に送った指示の後始末は進める)。
|
|
1696
1672
|
* - 緊急停止フラグを確認。停止中なら新規の取得・送信は一切しない。
|
|
1697
|
-
* - pending が無く停止中でもなければ、取得 → アイドル判定 → sent
|
|
1673
|
+
* - pending が無く停止中でもなければ、取得 → アイドル判定 → 配達 →「配達済み(sent)」を管制へ記録。
|
|
1698
1674
|
*
|
|
1699
1675
|
* すべての判定は保守的(少しでも怪しければ送らない/次回再挑戦)。
|
|
1700
1676
|
*/
|
|
@@ -1710,7 +1686,7 @@ export async function relayTick(cfg, state) {
|
|
|
1710
1686
|
// ── sent孤児の復元(2-b・起動後1回) ──
|
|
1711
1687
|
// cli.ts を触らず「起動時1回」を実現するため、最初の tick でだけ復元する。
|
|
1712
1688
|
// 失敗しても再試行しない(フラグを先に立てる=起動を妨げない/多重実行防止)。
|
|
1713
|
-
// ★復元は pending
|
|
1689
|
+
// ★復元は pending に積むだけ(配達はしない)。
|
|
1714
1690
|
if (!state.restored) {
|
|
1715
1691
|
state.restored = true;
|
|
1716
1692
|
await restoreOrphans(cfg, state);
|
|
@@ -1735,26 +1711,25 @@ export async function relayTick(cfg, state) {
|
|
|
1735
1711
|
const cap = tmuxCapture(cfg.site);
|
|
1736
1712
|
if (cap === null)
|
|
1737
1713
|
return; // tmux未取得: 次回再確認
|
|
1738
|
-
// ──
|
|
1739
|
-
//
|
|
1740
|
-
//
|
|
1741
|
-
//
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
// 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で通常経路へ。
|
|
1754
1729
|
}
|
|
1755
1730
|
// アンカー(今回送信の境界)より下だけを見て DONE:{site} 単独行を探す。
|
|
1756
1731
|
// churn で古い DONE が押し出されても・過去ジョブの DONE が窓に残っても、誤検出/見逃しなし。
|
|
1757
|
-
//
|
|
1732
|
+
// ★アンカーが既に流れて消えている場合は、一度アンカーを見ていれば画面全体を見る
|
|
1758
1733
|
// (ADR-020。全画面表示の職人はさかのぼれる行が無く、アンカーが数秒で消えるため)。
|
|
1759
1734
|
// ★Codex は完了と報告が notify でデータとして届く(ADR-021)。画面より先にそちらを見る。
|
|
1760
1735
|
// 届いていれば、そのまま【既存の完了経路】へ橋渡しする(道は1本のまま)。
|
|
@@ -1769,7 +1744,7 @@ export async function relayTick(cfg, state) {
|
|
|
1769
1744
|
return;
|
|
1770
1745
|
}
|
|
1771
1746
|
}
|
|
1772
|
-
const below = sliceForDoneScan(cap, state.pending.anchor, state.pending.
|
|
1747
|
+
const below = sliceForDoneScan(cap, state.pending.anchor, state.pending.screenDoneWide);
|
|
1773
1748
|
if (below !== null && hasStandaloneDone(below, cfg.site)) {
|
|
1774
1749
|
// 報告本文も「アンカーより下」から抜き出す(今回ジョブの報告に限定。過去報告の誤添付を防ぐ)。
|
|
1775
1750
|
const report = extractReport(below);
|
|
@@ -1791,9 +1766,9 @@ export async function relayTick(cfg, state) {
|
|
|
1791
1766
|
// 【副シグナル温存=無損失縮退】中央 status が読めない(null=エンドポイント未実装/不達)ときだけ、
|
|
1792
1767
|
// 従来の detectExternalDone(別idが配れる=終端の確証)へフォールバックする。中央側 status API が
|
|
1793
1768
|
// 入るまではこの副シグナルが従来どおり効き、入った後は主シグナル(status)が確実に固着を断つ。
|
|
1794
|
-
// HTTP
|
|
1769
|
+
// HTTP コストを抑えるため「印が付いている+アイドル」のときだけ probe する(busyな通常作業中は叩かない)。
|
|
1795
1770
|
const idle = looksIdleFor(cap, kindOf(cfg));
|
|
1796
|
-
if (state.pending.
|
|
1771
|
+
if (state.pending.sentMarked && idle) {
|
|
1797
1772
|
const centralStatus = await fetchCommandStatus(cfg, state.pending.id);
|
|
1798
1773
|
if (isTerminalCommandStatus(centralStatus)) {
|
|
1799
1774
|
log(`site=${cfg.site} id=${state.pending.id} は中央で終端(status=${centralStatus})を確認` +
|
|
@@ -1808,7 +1783,7 @@ export async function relayTick(cfg, state) {
|
|
|
1808
1783
|
const next = await fetchCommand(cfg);
|
|
1809
1784
|
const nextId = next?.id ?? null;
|
|
1810
1785
|
if (detectExternalDone({
|
|
1811
|
-
|
|
1786
|
+
sentMarked: state.pending.sentMarked,
|
|
1812
1787
|
pendingId: state.pending.id,
|
|
1813
1788
|
idle,
|
|
1814
1789
|
nextCommandId: nextId,
|
|
@@ -1860,109 +1835,13 @@ export async function relayTick(cfg, state) {
|
|
|
1860
1835
|
noteBusyEdge(state, cfg.site, state.pending.id, raise, log);
|
|
1861
1836
|
await sendHeartbeat(cfg, state.pending.id, raise);
|
|
1862
1837
|
}
|
|
1863
|
-
//
|
|
1864
|
-
//
|
|
1865
|
-
//
|
|
1866
|
-
//
|
|
1867
|
-
//
|
|
1868
|
-
//
|
|
1869
|
-
//
|
|
1870
|
-
// なった指示は、以後ライブ画面で anchor が churn により窓(capture-pane の可視+scrollback)から
|
|
1871
|
-
// 押し出されて見えなくなっても、二度と自動再送しない。従来は decideResend(1) と呼出側の
|
|
1872
|
-
// delivered をいずれも「その場の cap.includes(anchor)」で毎tick再導出していたため、確定済みの
|
|
1873
|
-
// 着信が undelivered へ逆戻りして再送ループ化していた(職人へ同一 commandId が連続着信し、
|
|
1874
|
-
// 既に done でも送り続ける)。着信確認済みは永続フラグを唯一の真とし、完了検出は上の DONE
|
|
1875
|
-
// スクレイプ、固着回避は下の pending タイムアウトに委ねる。
|
|
1876
|
-
// delivered=false の間(初回着信前・起動順レース)は従来どおり未着信+アイドルで再送して配信を担保する。
|
|
1877
|
-
if (!state.pending.delivered) {
|
|
1878
|
-
const delivered = cap.includes(state.pending.anchor);
|
|
1879
|
-
const idleStable = delivered ? false : await isIdleStable(cfg.site);
|
|
1880
|
-
const decision = decideResend({
|
|
1881
|
-
capture: cap,
|
|
1882
|
-
anchor: state.pending.anchor,
|
|
1883
|
-
idleStable,
|
|
1884
|
-
sentAt: state.pending.sentAt,
|
|
1885
|
-
lastSentAt: state.pending.lastSentAt,
|
|
1886
|
-
resendCount: state.pending.resendCount,
|
|
1887
|
-
now: Date.now(),
|
|
1888
|
-
policy: {
|
|
1889
|
-
resendDelayMs: RELAY_RESEND_DELAY_MS,
|
|
1890
|
-
resendIntervalMs: RELAY_RESEND_INTERVAL_MS,
|
|
1891
|
-
maxResend: RELAY_RESEND_MAX,
|
|
1892
|
-
resendSlowIntervalMs: RELAY_RESEND_SLOW_INTERVAL_MS,
|
|
1893
|
-
},
|
|
1894
|
-
});
|
|
1895
|
-
if (decision.verdict === 'resend') {
|
|
1896
|
-
// ── 中央status照合(done済み再配達の根治・2026-07-09 実地バグ) ──
|
|
1897
|
-
// 完了が conductor_complete(MCP直行) で立つと中央は done になるが、relay が画面のDONE行を
|
|
1898
|
-
// 取りこぼすとローカル pending が残り、この経路が同一指示を延々と再配達していた。上の
|
|
1899
|
-
// 「外部完了検出」は delivered+アイドルでしか走らず、この !delivered な再送経路とは条件が
|
|
1900
|
-
// 排他なので一度も中央を見ていなかった。再送しようとする今このtickでだけ照合する
|
|
1901
|
-
// (毎tickのポーリングにはしない=中央への負荷を増やさない)。
|
|
1902
|
-
const centralStatus = await fetchCommandStatus(cfg, state.pending.id);
|
|
1903
|
-
const reconcile = decideCentralReconcile(centralStatus);
|
|
1904
|
-
if (reconcile === 'drop') {
|
|
1905
|
-
state.abandoned.add(state.pending.id); // 以後この id は二度と配達しない。
|
|
1906
|
-
log(`site=${cfg.site} id=${state.pending.id} 中央で${centralStatus}確認` +
|
|
1907
|
-
'→再送中止・ローカル解放(done済み再配達の根治)');
|
|
1908
|
-
state.pending = null;
|
|
1909
|
-
state.lastHeartbeatAt = null;
|
|
1910
|
-
return; // 次tickで halt確認→fetchCommand→通常経路。
|
|
1911
|
-
}
|
|
1912
|
-
if (reconcile === 'skip') {
|
|
1913
|
-
// 中央不達/未知。再送を見送り次tickで再試行(照合失敗で relay 本体は落とさない)。
|
|
1914
|
-
log(`site=${cfg.site} id=${state.pending.id} 中央status照合できず` +
|
|
1915
|
-
`(status=${centralStatus ?? 'null'})→このtickは再送見送り(次tick再試行)`);
|
|
1916
|
-
return;
|
|
1917
|
-
}
|
|
1918
|
-
// ── 配達回数の絶対上限(保険・無限ループの構造的封じ込め) ──
|
|
1919
|
-
// slow相は打ち切らず継続する設計+pendingタイムアウト後の再取得で、職人窓が永久に
|
|
1920
|
-
// 未着信/未DONEだと事実上無限に再配達し得る。累計が上限を超えたらこの command を放棄し
|
|
1921
|
-
// 二度と配達しない(pendingを解除し abandoned へ。完了は職人の conductor_complete か
|
|
1922
|
-
// 画面DONE検出という別経路に委ねる)。
|
|
1923
|
-
if (!canDeliver(state, state.pending.id)) {
|
|
1924
|
-
if (!state.abandoned.has(state.pending.id)) {
|
|
1925
|
-
state.abandoned.add(state.pending.id);
|
|
1926
|
-
log(`WARN site=${cfg.site} id=${state.pending.id} 配達回数が上限(${RELAY_DELIVER_MAX})に到達。` +
|
|
1927
|
-
'再配達を放棄し pending を解除(無限ループの構造的封じ込め。' +
|
|
1928
|
-
'完了通知は職人の conductor_complete か画面DONE検出に委ねる)');
|
|
1929
|
-
}
|
|
1930
|
-
state.pending = null;
|
|
1931
|
-
return;
|
|
1932
|
-
}
|
|
1933
|
-
// 送信直前ガード(looksLikeClaude)も初回送信と同様に尊重し、非claude窓への誤爆を防ぐ。
|
|
1934
|
-
const guardCap = tmuxCapture(cfg.site);
|
|
1935
|
-
if (guardCap === null || !looksLikeAgent(guardCap, kindOf(cfg))) {
|
|
1936
|
-
log(`WARN site=${cfg.site} id=${state.pending.id} 再送直前にClaude Codeの箱を` +
|
|
1937
|
-
'確認できず再送中止(次tick再判定)');
|
|
1938
|
-
return;
|
|
1939
|
-
}
|
|
1940
|
-
// fast相 / slow相 の別(このtickの送信が低頻度再配信フェーズか)。回数++前に判定する。
|
|
1941
|
-
const slow = state.pending.resendCount >= RELAY_RESEND_MAX;
|
|
1942
|
-
// id 不変=anchor 不変なので、初回と同一の送信テキストを再生成する。
|
|
1943
|
-
const text = buildSendText(state.pending.body, cfg.site, state.pending.anchor);
|
|
1944
|
-
if (!sendToTmux(cfg.site, text)) {
|
|
1945
|
-
// 失敗の理由(tmux の stderr)は sendToTmux が直前の行に出している。
|
|
1946
|
-
log(`再送 本文の投入に失敗, keep pending: ${cfg.site}/${state.pending.id}`);
|
|
1947
|
-
return;
|
|
1948
|
-
}
|
|
1949
|
-
state.pending.resendCount += 1;
|
|
1950
|
-
state.pending.lastSentAt = Date.now();
|
|
1951
|
-
// 累計配達回数を通算(pending 入れ替えをまたいで id 単位に数える=絶対上限の基準)。
|
|
1952
|
-
markDelivered(state, state.pending.id);
|
|
1953
|
-
// fast相→slow相への移行ログは一度だけ(多重ログ防止)。打ち切りはせず低頻度で継続する旨を記す。
|
|
1954
|
-
if (slow && !state.pending.resendExhaustedWarned) {
|
|
1955
|
-
state.pending.resendExhaustedWarned = true;
|
|
1956
|
-
log(`site=${cfg.site} id=${state.pending.id} fast再送上限(${RELAY_RESEND_MAX})到達。` +
|
|
1957
|
-
`以後は ${Math.round(RELAY_RESEND_SLOW_INTERVAL_MS / 1000)}s 間隔で低頻度に再配信を継続` +
|
|
1958
|
-
'(職人が後から起動しても届くようにする・打ち切りは pending タイムアウトに委ねる)');
|
|
1959
|
-
}
|
|
1960
|
-
log(`site=${cfg.site} id=${state.pending.id} 自動再送 #${state.pending.resendCount}` +
|
|
1961
|
-
`(${slow ? '低頻度(slow)' : '未着信+アイドル'}: ${decision.reason})`);
|
|
1962
|
-
return; // sent 中は新規取得しない
|
|
1963
|
-
}
|
|
1964
|
-
// 'wait' は再送せずタイムアウト判定へ進む(decideResend は 'stop' を返さない=低頻度継続)。
|
|
1965
|
-
}
|
|
1838
|
+
// ★ここに「未着信+アイドルなら同じ便をもう一度送る」自動再送があった(ADR-023 で撤去)。
|
|
1839
|
+
// 撤去した理由: 「着信したか」を画面のアンカーで判定していたが、職人(Claude Code)の
|
|
1840
|
+
// 全画面表示ではアンカーが数秒で流れて消えるため、実際には届いて作業中の便まで
|
|
1841
|
+
// 「未着信」に見えた。そこへアイドルが一瞬重なると同じ便を二重に配達し得た(実地の危険)。
|
|
1842
|
+
// いまは配達の成否を send-keys の成否で確定し、配達は1指示につき1回だけにしている。
|
|
1843
|
+
// 届かなかった場合の受け皿は、①中央が sent→stalled→timeout として人に見せる
|
|
1844
|
+
// ②下の pending タイムアウトで現場を空ける、の2つ(★勝手に送り直さない)。
|
|
1966
1845
|
// DONE 未達のまま上限時間を超えたら、固着回避のため relay 内部で pending を解除して次へ進む。
|
|
1967
1846
|
// (CC2 の異常終了等で DONE が永遠に来ないケースの根治。サーバ status は戻さない=relay内部のみ。)
|
|
1968
1847
|
//
|
|
@@ -1975,10 +1854,19 @@ export async function relayTick(cfg, state) {
|
|
|
1975
1854
|
log(`WARN site=${cfg.site} id=${state.pending.id} が pending タイムアウト` +
|
|
1976
1855
|
`(${Math.round(elapsedMs / 1000)}s)。DONE未達のため解除し次へ進む` +
|
|
1977
1856
|
(handedOver
|
|
1978
|
-
? `/★生存の見張りは継続(上限${Math.round(RELAY_KEEPALIVE_MAX_MS / 60000)}
|
|
1857
|
+
? `/★生存の見張りは継続(上限${Math.round(RELAY_KEEPALIVE_MAX_MS / 60000)}分・配り直しはしない)`
|
|
1979
1858
|
: state.keepalive !== null
|
|
1980
1859
|
? `/見張りは引き継がず(別の指示 id=${state.keepalive.id} を見張り中)`
|
|
1981
|
-
: '
|
|
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
|
+
}
|
|
1982
1870
|
state.pending = null;
|
|
1983
1871
|
}
|
|
1984
1872
|
return; // sent 中は新規取得しない
|
|
@@ -2014,6 +1902,15 @@ export async function relayTick(cfg, state) {
|
|
|
2014
1902
|
if (state.abandoned.has(cmd.id)) {
|
|
2015
1903
|
return;
|
|
2016
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
|
+
}
|
|
2017
1914
|
// ── アイドル判定(保守的・二重チェック) ──
|
|
2018
1915
|
if (!tmuxHasSession(cfg.site)) {
|
|
2019
1916
|
log(`site=${cfg.site} id=${cmd.id} tmux未起動のため送らずスキップ`);
|
|
@@ -2062,12 +1959,13 @@ export async function relayTick(cfg, state) {
|
|
|
2062
1959
|
log(`本文の投入に失敗, keep queued: ${cfg.site}/${cmd.id}`);
|
|
2063
1960
|
return;
|
|
2064
1961
|
}
|
|
2065
|
-
//
|
|
2066
|
-
// send-keys
|
|
2067
|
-
//
|
|
2068
|
-
//
|
|
2069
|
-
//
|
|
2070
|
-
//
|
|
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
|
+
// 確認済み。だから「送れた=配達できた」と扱ってよい。
|
|
2071
1969
|
const sentAt = Date.now();
|
|
2072
1970
|
state.pending = {
|
|
2073
1971
|
id: cmd.id,
|
|
@@ -2075,16 +1973,18 @@ export async function relayTick(cfg, state) {
|
|
|
2075
1973
|
sentAt,
|
|
2076
1974
|
anchor,
|
|
2077
1975
|
body: cmd.body,
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
delivered: false,
|
|
1976
|
+
delivered: true,
|
|
1977
|
+
sentMarked: false,
|
|
1978
|
+
screenDoneWide: false,
|
|
2082
1979
|
};
|
|
2083
|
-
//
|
|
1980
|
+
// 累計配達回数を通算(絶対上限 RELAY_DELIVER_MAX の基準)。
|
|
2084
1981
|
markDelivered(state, cmd.id);
|
|
2085
1982
|
// 新しい指示の生存ハートビートは先頭から数え直す(前指示のスロットルを引き継がない)。
|
|
2086
1983
|
state.lastHeartbeatAt = null;
|
|
2087
|
-
log(`site=${cfg.site} id=${cmd.id}
|
|
1984
|
+
log(`site=${cfg.site} id=${cmd.id} 配達しました`);
|
|
1985
|
+
// ★配達の直後に「配達済み(sent)」を管制へ書く。書けなければ次tick以降で書けるまでやり直す
|
|
1986
|
+
// (markSentAtCentral)。★書けなくても再配達はしない。
|
|
1987
|
+
await markSentAtCentral(cfg, state);
|
|
2088
1988
|
}
|
|
2089
1989
|
finally {
|
|
2090
1990
|
state.running = false;
|