@tyhld/conductor 0.11.1 → 0.11.2
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/README.md +11 -0
- package/dist/cli.js +27 -5
- package/dist/process-guard.js +63 -0
- package/dist/relay-runner.js +31 -4
- package/dist/relay.js +125 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -160,6 +160,17 @@ conductor start -a "ログイン画面を実装中"
|
|
|
160
160
|
| `CONDUCTOR_RELAY_ENABLED` | – | `true` で relay(指示の取得→アイドル時のみ tmux 送信→完了検出)を有効化 | `false` |
|
|
161
161
|
| `CONDUCTOR_RELAY_INTERVAL_SEC` | – | relay のポーリング間隔(秒) | `15` |
|
|
162
162
|
| `CONDUCTOR_NUDGE_DIR` | – | 耳 → relay の呼び鈴(空ファイル)の置き場 | `$XDG_RUNTIME_DIR/conductor` |
|
|
163
|
+
| `RELAY_FETCH_TIMEOUT_MS` | – | 管制への問い合わせ1回の上限(ミリ秒)。返らない通信で relay が固まらないための上限 | `20000` |
|
|
164
|
+
| `RELAY_RECONNECT_BASE_MS` | – | 管制へつながらないときの、最初の再試行までの待ち(ミリ秒)。以後 倍々に伸ばす | `5000` |
|
|
165
|
+
| `RELAY_RECONNECT_MAX_MS` | – | 再試行の待ちの上限(ミリ秒)。★上限に達したあとも永久に試し続けます | `60000` |
|
|
166
|
+
|
|
167
|
+
> **管制につながらないとき(★relay は止まりません)**:管制(中央)が落ちる・切れる・応答しない・
|
|
168
|
+
> 401 や 5xx を返す——このいずれでも relay は**止まらず待って再試行**します。待ちは
|
|
169
|
+
> 5秒 → 10 → 20 → 40 → 60秒 と伸び、60秒で頭打ち(以後は60秒おきに永久に)。つながらない間は
|
|
170
|
+
> 画面に `管制につながらない・再試行中(N回目・次は◯秒後)` を**毎回1行**出し続け(黙りません)、
|
|
171
|
+
> つながったら `管制に再接続しました` を1行出して**通常の間隔(15秒)へ自動で戻り**、
|
|
172
|
+
> 自分の生存報告を1回送り直します。**安全のため、つながらない間は職人へ便を配りません**
|
|
173
|
+
> (管制と話せないのに送るのは危険)。人が exit → 再起動する必要はありません。
|
|
163
174
|
|
|
164
175
|
> **relay(ADR-029 段階3 / 既定 off)**:`CONDUCTOR_RELAY_ENABLED=true` のときだけ、
|
|
165
176
|
> 中央から自分宛(machine+site)の指示を取得し、**送信先のtmuxセッション(名前 = 現場名 site)が
|
package/dist/cli.js
CHANGED
|
@@ -13,8 +13,9 @@ import { execFileSync, spawnSync } from 'node:child_process';
|
|
|
13
13
|
import { existsSync, readFileSync } from 'node:fs';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
import os from 'node:os';
|
|
16
|
-
import { createRelayState, parseAgentKind, relayTick, tmuxHasSession } from './relay.js';
|
|
16
|
+
import { RELAY_FETCH_TIMEOUT_MS, createRelayState, parseAgentKind, reconnectDelayMs, relayTick, tmuxHasSession, } from './relay.js';
|
|
17
17
|
import { createRelayRunner } from './relay-runner.js';
|
|
18
|
+
import { installProcessGuards } from './process-guard.js';
|
|
18
19
|
import { registerNudgeTarget, unregisterNudgeTarget, watchNudges, } from './nudge.js';
|
|
19
20
|
import { startEar } from './ear.js';
|
|
20
21
|
import { loadEarConfig } from './ear-routing.js';
|
|
@@ -169,6 +170,9 @@ async function sendReport(cfg) {
|
|
|
169
170
|
'Content-Type': 'application/json',
|
|
170
171
|
},
|
|
171
172
|
body: JSON.stringify(body),
|
|
173
|
+
// ★上限時間(relay の fetch と同じ理由)。管制が「つながるが何も返さない」状態のとき、
|
|
174
|
+
// 上限が無いと送信が返らず、30秒ごとの報告が返らないまま積み上がる。
|
|
175
|
+
signal: AbortSignal.timeout(RELAY_FETCH_TIMEOUT_MS),
|
|
172
176
|
});
|
|
173
177
|
const ok = res.ok ? 'OK' : 'NG';
|
|
174
178
|
console.log(`[conductor] ${time} site=${cfg.site} status=${cfg.status} -> HTTP ${res.status} ${ok}`);
|
|
@@ -186,6 +190,9 @@ async function sendReport(cfg) {
|
|
|
186
190
|
/** `conductor start`: 起動直後に1回 + 以後 intervalSec ごとに報告し続ける。 */
|
|
187
191
|
async function start(args) {
|
|
188
192
|
const cfg = loadConfig(args);
|
|
193
|
+
// ★例外1発で常駐が黙って死ぬ道を塞ぐ(2026-09-06 の根治・src/process-guard.ts)。
|
|
194
|
+
// 受けて1行出して続ける。★職人(CC2)が落ちた場合の肩代わりはしない(それは人へ見せる)。
|
|
195
|
+
installProcessGuards({ tag: 'relay' });
|
|
189
196
|
console.log(`[conductor] 開始: machine=${cfg.machine} site=${cfg.site} status=${cfg.status} ` +
|
|
190
197
|
`interval=${cfg.intervalSec}s url=${cfg.url}`);
|
|
191
198
|
console.log('[conductor] 停止するには Ctrl+C を押してください。');
|
|
@@ -216,10 +223,20 @@ async function start(args) {
|
|
|
216
223
|
`(tmuxセッション "${cfg.site}" がアイドルのときだけ送信します)`);
|
|
217
224
|
const relayState = createRelayState();
|
|
218
225
|
relayRunner = createRelayRunner({
|
|
219
|
-
tick: () => relayTick(cfg, relayState
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
226
|
+
tick: () => relayTick(cfg, relayState, {
|
|
227
|
+
// ★管制へつながらない状態から戻った瞬間に1回だけ呼ばれる。
|
|
228
|
+
// 既にある口(現場の生存報告)を1回叩き、管制の一覧の札をすぐ正しく戻す。
|
|
229
|
+
onCentralReconnect: () => {
|
|
230
|
+
console.log('[relay] 管制へ現場の生存報告を1回送り直します(再接続の直後)');
|
|
231
|
+
void sendReport(cfg);
|
|
232
|
+
},
|
|
233
|
+
}),
|
|
234
|
+
// ★管制へつながらない間だけ、再試行の間隔(5秒→10→20→40→60秒・上限後は60秒のまま永久に)。
|
|
235
|
+
// つながっていれば従来どおり: 承認プロンプト待ちの間だけ短い間隔、それ以外は通常間隔
|
|
236
|
+
// (★間隔は耳を付けても変えない=線が切れている間はこのポーリングがそのまま肩代わりする保険)。
|
|
237
|
+
nextDelayMs: () => relayState.central.failures > 0
|
|
238
|
+
? reconnectDelayMs(relayState.central.failures)
|
|
239
|
+
: (relayState.promptPending ? cfg.promptIntervalSec : cfg.relayIntervalSec) * 1000,
|
|
223
240
|
onError: (err) => {
|
|
224
241
|
// ループは絶対に止めない(1回の失敗で relay が死ぬと配達も承認も止まる)。
|
|
225
242
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -389,6 +406,11 @@ function printUsage() {
|
|
|
389
406
|
' CONDUCTOR_RELAY_INTERVAL_SEC relayポーリング間隔・秒(既定: 15)',
|
|
390
407
|
' CONDUCTOR_RELAY_PROMPT_INTERVAL_SEC 承認待ちの間だけ使う短い間隔・秒(既定: 3)',
|
|
391
408
|
'',
|
|
409
|
+
'管制につながらないとき(★relayは止まりません。待って再試行し、戻ったら自動で通常へ):',
|
|
410
|
+
' RELAY_FETCH_TIMEOUT_MS 管制への問い合わせ1回の上限・ミリ秒(既定: 20000)',
|
|
411
|
+
' RELAY_RECONNECT_BASE_MS 再試行の最初の待ち・ミリ秒(既定: 5000。以後 倍々に伸ばす)',
|
|
412
|
+
' RELAY_RECONNECT_MAX_MS 再試行の待ちの上限・ミリ秒(既定: 60000。★上限後も永久に試し続けます)',
|
|
413
|
+
'',
|
|
392
414
|
'耳(ADR-066 / この機に1本だけ動かす常駐。systemd の conductor-ear.service 推奨):',
|
|
393
415
|
' conductor ear は、指示が積まれた合図(Supabase Realtime)をPCにつき1本の接続で受け取り、',
|
|
394
416
|
' その現場の relay へ「今すぐ1周」だけを伝えます(合図に指示本文もトークンも載りません)。',
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @tyhld/conductor — 常駐(relay)が「例外1発で黙って死ぬ」道を塞ぐ番人。
|
|
3
|
+
*
|
|
4
|
+
* 【なぜ要るか(実地 2026-09-06)】
|
|
5
|
+
* relay の中の1周(relayTick)が投げた例外は回し役(createRelayRunner)が受けて続行するが、
|
|
6
|
+
* その【外】で起きた失敗は誰も受けない:
|
|
7
|
+
* ① 未処理の Promise 拒否(unhandledRejection)
|
|
8
|
+
* `void something()` の形で投げっぱなしにした非同期処理が転ぶと、Node 15 以降は
|
|
9
|
+
* 既定でプロセスごと終了する。relay には `void relayRunner.run()`、耳の合図受け、
|
|
10
|
+
* 停止処理など「投げっぱなし」の呼び出しが複数ある。
|
|
11
|
+
* ② 捕まえていない例外(uncaughtException)
|
|
12
|
+
* タイマーのコールバックやイベントハンドラの中で投げた例外は try/catch を素通りする。
|
|
13
|
+
* どちらも起きるとプロセスが消える=その現場の便が誰にも配られなくなる。
|
|
14
|
+
*
|
|
15
|
+
* 【どう塞ぐか】
|
|
16
|
+
* 受けて、1行出して、続ける。relay の仕事は「便を運ぶこと」で、1つの失敗で運行そのものを
|
|
17
|
+
* 止める理由にはならない。次の1周が来れば普通は何事もなかったように動く。
|
|
18
|
+
*
|
|
19
|
+
* 【★肩代わりしないもの(えふさん決定 2026-09-06)】
|
|
20
|
+
* ここが守るのは【relay 自身のプロセス】だけ。職人(CC2 / claude)が本当に落ちた場合は、
|
|
21
|
+
* relay が代わりに仕事を進めたり、落ちたことを隠したりしない。職人が居ない現場では
|
|
22
|
+
* これまでどおり「tmux未起動のため送らずスキップ」と出して止まり、人に見えるようにする。
|
|
23
|
+
*
|
|
24
|
+
* 【多重登録しない】
|
|
25
|
+
* 同じプロセスで2回呼ばれても増えないようにする(登録が増えると1つの失敗で何行も出る)。
|
|
26
|
+
*/
|
|
27
|
+
/** ログの体裁は relay と揃える([relay] 時刻 本文)。 */
|
|
28
|
+
const stamp = () => new Date().toLocaleTimeString();
|
|
29
|
+
/** このプロセスで既に登録したか(多重登録の防止)。 */
|
|
30
|
+
let installed = false;
|
|
31
|
+
/** テスト用: 登録済みの印を戻す。本番の経路からは呼ばない。 */
|
|
32
|
+
export function resetProcessGuardsForTest() {
|
|
33
|
+
installed = false;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 「例外で黙って死ぬ」2つの道を塞ぐ。登録できた事象名を返す。
|
|
37
|
+
*
|
|
38
|
+
* ★ここで process.exit() は絶対に呼ばない。落とすために作った番人ではない。
|
|
39
|
+
*/
|
|
40
|
+
export function installProcessGuards(opts = {}) {
|
|
41
|
+
const logger = opts.logger ?? ((msg) => console.log(msg));
|
|
42
|
+
const target = opts.target ?? process;
|
|
43
|
+
const tag = opts.tag ?? 'relay';
|
|
44
|
+
if (installed)
|
|
45
|
+
return { installed: false, events: [] };
|
|
46
|
+
installed = true;
|
|
47
|
+
const describe = (err) => {
|
|
48
|
+
if (err instanceof Error) {
|
|
49
|
+
const stack = (err.stack ?? '').split('\n').slice(0, 3).join(' / ');
|
|
50
|
+
return stack.trim() || err.message;
|
|
51
|
+
}
|
|
52
|
+
return String(err);
|
|
53
|
+
};
|
|
54
|
+
target.on('unhandledRejection', (reason) => {
|
|
55
|
+
logger(`[${tag}] ${stamp()} WARN 未処理の失敗を受け止めました(${describe(reason)})` +
|
|
56
|
+
'/★止まらずこのまま続けます');
|
|
57
|
+
});
|
|
58
|
+
target.on('uncaughtException', (err) => {
|
|
59
|
+
logger(`[${tag}] ${stamp()} WARN 捕まえていない例外を受け止めました(${describe(err)})` +
|
|
60
|
+
'/★止まらずこのまま続けます');
|
|
61
|
+
});
|
|
62
|
+
return { installed: true, events: ['unhandledRejection', 'uncaughtException'] };
|
|
63
|
+
}
|
package/dist/relay-runner.js
CHANGED
|
@@ -13,8 +13,16 @@
|
|
|
13
13
|
* - 同時に走る tick は1つだけ(走行中の run() は「終わったらもう1周」の予約として畳む)。
|
|
14
14
|
* - 予約中のタイマーは常に1本だけ(走り出す前に必ず消す)。
|
|
15
15
|
* - tick が投げた例外でループを止めない(1回の失敗で配達も承認も止まってはいけない)。
|
|
16
|
+
* - ★stop() を呼ばれない限り、次の1周の予約は【必ず】積まれる(nextDelayMs が投げても保険の
|
|
17
|
+
* 間隔で積み直す)。予約が1回でも消えると relay は誰にも気づかれずに永久に止まる。
|
|
16
18
|
* - stop() 後は新しい tick を始めない・新しい予約も積まない。
|
|
17
19
|
*/
|
|
20
|
+
/**
|
|
21
|
+
* nextDelayMs() が答えを返せなかったときに使う保険の間隔(ms)。
|
|
22
|
+
* ★「次を積まない」という選択肢は持たない。予約が消えた瞬間に relay は黙って止まるので、
|
|
23
|
+
* どんな異常でも必ず何秒か後にもう一度回す。値は通常のポーリング間隔(15秒)に合わせる。
|
|
24
|
+
*/
|
|
25
|
+
export const RELAY_RUNNER_FALLBACK_DELAY_MS = 15 * 1000;
|
|
18
26
|
export function createRelayRunner(args) {
|
|
19
27
|
const { tick, nextDelayMs, onError } = args;
|
|
20
28
|
let timer = null;
|
|
@@ -27,6 +35,26 @@ export function createRelayRunner(args) {
|
|
|
27
35
|
timer = null;
|
|
28
36
|
}
|
|
29
37
|
};
|
|
38
|
+
/**
|
|
39
|
+
* 次の1周を必ず予約する。★ここで例外を出して予約を失うと、relay は【黙って永久に止まる】。
|
|
40
|
+
* nextDelayMs() は呼び出し側の関数なので、いつか投げるかもしれない前提で扱う。
|
|
41
|
+
* 投げたら理由を報せたうえで保険の間隔(RELAY_RUNNER_FALLBACK_DELAY_MS)で必ず積み直す。
|
|
42
|
+
*/
|
|
43
|
+
const scheduleNext = () => {
|
|
44
|
+
if (stopped)
|
|
45
|
+
return;
|
|
46
|
+
clearTimer();
|
|
47
|
+
let delay = RELAY_RUNNER_FALLBACK_DELAY_MS;
|
|
48
|
+
try {
|
|
49
|
+
const wanted = nextDelayMs();
|
|
50
|
+
if (Number.isFinite(wanted) && wanted >= 0)
|
|
51
|
+
delay = wanted;
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
onError?.(err);
|
|
55
|
+
}
|
|
56
|
+
timer = setTimeout(() => void run(), delay);
|
|
57
|
+
};
|
|
30
58
|
const run = async () => {
|
|
31
59
|
if (stopped)
|
|
32
60
|
return;
|
|
@@ -48,12 +76,11 @@ export function createRelayRunner(args) {
|
|
|
48
76
|
} while (nudged && !stopped);
|
|
49
77
|
}
|
|
50
78
|
finally {
|
|
79
|
+
// ★予約は finally の中で積む。busy を戻したあと予約する前に何かが起きても
|
|
80
|
+
// 「走ってもいない・予約も無い」で静止する道を残さないため。
|
|
51
81
|
busy = false;
|
|
82
|
+
scheduleNext();
|
|
52
83
|
}
|
|
53
|
-
if (stopped)
|
|
54
|
-
return;
|
|
55
|
-
clearTimer();
|
|
56
|
-
timer = setTimeout(() => void run(), nextDelayMs());
|
|
57
84
|
};
|
|
58
85
|
return {
|
|
59
86
|
run,
|
package/dist/relay.js
CHANGED
|
@@ -147,6 +147,36 @@ export const RELAY_STATUS_FETCH_TIMEOUT_MS = envPositiveNumber('RELAY_STATUS_FET
|
|
|
147
147
|
* 環境変数 RELAY_HEARTBEAT_INTERVAL_MS で上書き可(正の数のみ)。
|
|
148
148
|
*/
|
|
149
149
|
export const RELAY_HEARTBEAT_INTERVAL_MS = envPositiveNumber('RELAY_HEARTBEAT_INTERVAL_MS', 150 * 1000);
|
|
150
|
+
/**
|
|
151
|
+
* 管制(devlog-tracker)への問い合わせ1回あたりの上限時間(ms)。既定20秒。
|
|
152
|
+
*
|
|
153
|
+
* 【なぜ要るか=★「黙って止まる」の根っこ(実地 2026-09-06・1日2回)】
|
|
154
|
+
* 以前 fetchControl / fetchCommand / patchStatus / sendHeartbeat の fetch には
|
|
155
|
+
* 【時間の上限が1つも無かった】。管制が落ちて「つながるけれど何も返さない」状態になると、
|
|
156
|
+
* その fetch は返ってこない。すると:
|
|
157
|
+
* ① relayTick が終わらない(state.running が true のまま)
|
|
158
|
+
* ② 回し役(createRelayRunner)の busy が true のまま=【次の予約が二度と積まれない】
|
|
159
|
+
* =プロセスは生きているのに relay の輪が永久に止まる。systemd から見れば「動いている」ので
|
|
160
|
+
* 自動再起動もかからず、ログにも何も出ない。人が exit → 全現場再起動しないと戻らない。
|
|
161
|
+
* これが「管制が落ちる→relayが止まる→便が『待っています』のまま溜まる」の正体。
|
|
162
|
+
*
|
|
163
|
+
* 【根治】全ての管制向け fetch に上限時間を付ける。返ってこない通信は必ず例外になり、
|
|
164
|
+
* 既存の catch が受けて「失敗」として扱われる=輪は必ず次へ進む。
|
|
165
|
+
* 環境変数 RELAY_FETCH_TIMEOUT_MS で上書き可(正の数のみ)。
|
|
166
|
+
*/
|
|
167
|
+
export const RELAY_FETCH_TIMEOUT_MS = envPositiveNumber('RELAY_FETCH_TIMEOUT_MS', 20 * 1000);
|
|
168
|
+
/**
|
|
169
|
+
* 管制へつながらないときの、再試行の【最初の】待ち時間(ms)。既定5秒。
|
|
170
|
+
* 短く始めて、失敗のたびに倍にし、RELAY_RECONNECT_MAX_MS で頭打ちにする(reconnectDelayMs)。
|
|
171
|
+
* 環境変数 RELAY_RECONNECT_BASE_MS で上書き可(正の数のみ)。
|
|
172
|
+
*/
|
|
173
|
+
export const RELAY_RECONNECT_BASE_MS = envPositiveNumber('RELAY_RECONNECT_BASE_MS', 5 * 1000);
|
|
174
|
+
/**
|
|
175
|
+
* 再試行の待ち時間の上限(ms)。既定60秒。★上限に達したあとも【永久に】60秒ごとに試し続ける。
|
|
176
|
+
* 諦めて止まる道は作らない(止まると人が全現場を再起動する羽目になる=今回の事故そのもの)。
|
|
177
|
+
* 環境変数 RELAY_RECONNECT_MAX_MS で上書き可(正の数のみ)。
|
|
178
|
+
*/
|
|
179
|
+
export const RELAY_RECONNECT_MAX_MS = envPositiveNumber('RELAY_RECONNECT_MAX_MS', 60 * 1000);
|
|
150
180
|
/**
|
|
151
181
|
* 「実際に処理中のときだけ出る、確実なシグナル」だけを持つ(小文字化して部分一致で判定)。
|
|
152
182
|
* 一つでも末尾付近にあれば送らない。
|
|
@@ -199,8 +229,55 @@ export function createRelayState() {
|
|
|
199
229
|
abandoned: new Set(),
|
|
200
230
|
promptPending: false,
|
|
201
231
|
busyRaisedId: null,
|
|
232
|
+
central: { failures: 0, detail: '', downSince: null },
|
|
202
233
|
};
|
|
203
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* 管制へつながらないときの、次の再試行までの待ち時間(ms)。純粋関数(I/Oなし=単体テスト可能)。
|
|
237
|
+
*
|
|
238
|
+
* failures=1 → 5秒 / 2 → 10秒 / 3 → 20秒 / 4 → 40秒 / 5以降 → 60秒(上限)
|
|
239
|
+
*
|
|
240
|
+
* ★上限に達しても null は返さない=「もう試さない」という答えを持たない。
|
|
241
|
+
* 管制はいつか必ず戻る。戻るまで60秒おきに叩き続けるのが正しい(諦めたら人が全現場を再起動する)。
|
|
242
|
+
* ★failures=0(つながっている)を渡すのは呼び出し側の誤りなので、最初の待ち時間を返す(安全側)。
|
|
243
|
+
*/
|
|
244
|
+
export function reconnectDelayMs(failures) {
|
|
245
|
+
const n = Math.max(1, Math.floor(failures));
|
|
246
|
+
// 2^(n-1) 倍。指数が大きいと Infinity になり得るので、先に上限で切ってから返す。
|
|
247
|
+
const raw = RELAY_RECONNECT_BASE_MS * 2 ** (n - 1);
|
|
248
|
+
return Number.isFinite(raw) ? Math.min(raw, RELAY_RECONNECT_MAX_MS) : RELAY_RECONNECT_MAX_MS;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* 管制へ届かなかったことを1回記録し、画面に出す1行を作る(★黙らないための口)。
|
|
252
|
+
* 返す文字列をそのままログに出す想定。★呼ぶたびに出す=つながらない間ずっと出続ける。
|
|
253
|
+
*/
|
|
254
|
+
export function noteCentralDown(state, detail, nowMs) {
|
|
255
|
+
const link = state.central;
|
|
256
|
+
if (link.failures === 0)
|
|
257
|
+
link.downSince = nowMs;
|
|
258
|
+
link.failures += 1;
|
|
259
|
+
link.detail = detail;
|
|
260
|
+
const waitSec = Math.round(reconnectDelayMs(link.failures) / 1000);
|
|
261
|
+
const downSec = link.downSince === null ? 0 : Math.max(0, Math.round((nowMs - link.downSince) / 1000));
|
|
262
|
+
return (`WARN 管制につながらない・再試行中(${link.failures}回目・次は${waitSec}秒後)` +
|
|
263
|
+
`${downSec > 0 ? `/切れてから${downSec}秒` : ''}(理由: ${detail})` +
|
|
264
|
+
'★relayは止まっていません。つながり次第そのまま再開します');
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* 管制へ届いたことを記録する。切れていたなら「戻った」1行を返し、そうでなければ null。
|
|
268
|
+
* ★戻った瞬間に1行出す=人が「復旧した」と分かる。通常時は何も出さない(ログを埋めない)。
|
|
269
|
+
*/
|
|
270
|
+
export function noteCentralUp(state, nowMs) {
|
|
271
|
+
const link = state.central;
|
|
272
|
+
if (link.failures === 0)
|
|
273
|
+
return null; // ずっとつながっている=何も言うことはない。
|
|
274
|
+
const downSec = link.downSince === null ? 0 : Math.max(0, Math.round((nowMs - link.downSince) / 1000));
|
|
275
|
+
const tries = link.failures;
|
|
276
|
+
link.failures = 0;
|
|
277
|
+
link.detail = '';
|
|
278
|
+
link.downSince = null;
|
|
279
|
+
return `管制に再接続しました(${tries}回の再試行・${downSec}秒ぶり)。通常の間隔に戻ります`;
|
|
280
|
+
}
|
|
204
281
|
/**
|
|
205
282
|
* この command をまだ配達してよいか。純粋関数(I/Oなし=単体テスト可能)。
|
|
206
283
|
* - 既に放棄済み(abandoned) → false。
|
|
@@ -1162,7 +1239,11 @@ export async function fetchControl(cfg) {
|
|
|
1162
1239
|
// ★段3: 許可応答(permAnswer)の宛先特定用だった ?machine= は、答えを届ける役目ごと撤去したので
|
|
1163
1240
|
// 付けない。応答に permAnswer が載っていても relay は読まない(中央側の受け口整理は段3-B)。
|
|
1164
1241
|
const url = `${cfg.url}/api/conductor/control?site=${encodeURIComponent(cfg.site)}`;
|
|
1165
|
-
|
|
1242
|
+
// ★上限時間は必須(RELAY_FETCH_TIMEOUT_MS のコメント参照)。無いと管制の無応答で輪が永久に止まる。
|
|
1243
|
+
const res = await fetch(url, {
|
|
1244
|
+
headers: authHeaders(cfg),
|
|
1245
|
+
signal: AbortSignal.timeout(RELAY_FETCH_TIMEOUT_MS),
|
|
1246
|
+
});
|
|
1166
1247
|
if (!res.ok) {
|
|
1167
1248
|
return { relayHalted: true, ok: false, detail: `HTTP ${res.status}` };
|
|
1168
1249
|
}
|
|
@@ -1192,7 +1273,11 @@ export async function fetchCommand(cfg) {
|
|
|
1192
1273
|
const u = `${cfg.url}/api/conductor/commands` +
|
|
1193
1274
|
`?machine=${encodeURIComponent(cfg.machine)}&site=${encodeURIComponent(cfg.site)}`;
|
|
1194
1275
|
try {
|
|
1195
|
-
|
|
1276
|
+
// ★上限時間は必須(無応答の管制で輪が永久に止まるのを防ぐ・RELAY_FETCH_TIMEOUT_MS 参照)。
|
|
1277
|
+
const res = await fetch(u, {
|
|
1278
|
+
headers: authHeaders(cfg),
|
|
1279
|
+
signal: AbortSignal.timeout(RELAY_FETCH_TIMEOUT_MS),
|
|
1280
|
+
});
|
|
1196
1281
|
if (!res.ok) {
|
|
1197
1282
|
log(`site=${cfg.site} commands取得 -> HTTP ${res.status} NG`);
|
|
1198
1283
|
return null;
|
|
@@ -1232,6 +1317,8 @@ export async function patchStatus(cfg, id, status, report) {
|
|
|
1232
1317
|
'Content-Type': 'application/json',
|
|
1233
1318
|
},
|
|
1234
1319
|
body: JSON.stringify(payload),
|
|
1320
|
+
// ★上限時間は必須(無応答の管制で輪が永久に止まるのを防ぐ・RELAY_FETCH_TIMEOUT_MS 参照)。
|
|
1321
|
+
signal: AbortSignal.timeout(RELAY_FETCH_TIMEOUT_MS),
|
|
1235
1322
|
});
|
|
1236
1323
|
if (res.status === 409) {
|
|
1237
1324
|
log(`site=${cfg.site} id=${id} ${status}遷移 -> 409 安全弁により中止`);
|
|
@@ -1379,7 +1466,11 @@ export async function sendHeartbeat(cfg, id, busy = false) {
|
|
|
1379
1466
|
body: JSON.stringify({ busy: true }),
|
|
1380
1467
|
}
|
|
1381
1468
|
: { method: 'PATCH', headers: authHeaders(cfg) };
|
|
1382
|
-
const res = await fetch(`${cfg.url}/api/conductor/commands/${encodeURIComponent(id)}/seen`,
|
|
1469
|
+
const res = await fetch(`${cfg.url}/api/conductor/commands/${encodeURIComponent(id)}/seen`, {
|
|
1470
|
+
...init,
|
|
1471
|
+
// ★上限時間は必須(無応答の管制で輪が永久に止まるのを防ぐ・RELAY_FETCH_TIMEOUT_MS 参照)。
|
|
1472
|
+
signal: AbortSignal.timeout(RELAY_FETCH_TIMEOUT_MS),
|
|
1473
|
+
});
|
|
1383
1474
|
if (!res.ok) {
|
|
1384
1475
|
log(`site=${cfg.site} id=${id} heartbeat -> HTTP ${res.status} NG(次回再試行・縮退で安全)`);
|
|
1385
1476
|
}
|
|
@@ -1659,22 +1750,7 @@ export async function restoreOrphans(cfg, state) {
|
|
|
1659
1750
|
state.pending = orphanToPending(pick);
|
|
1660
1751
|
log(`site=${cfg.site} id=${pick.id} を sent孤児から pending へ復元(★配達はしない・DONE待ちと生存通知だけ)`);
|
|
1661
1752
|
}
|
|
1662
|
-
|
|
1663
|
-
// relay 本体(1ポーリング分)
|
|
1664
|
-
// ───────────────────────────────────────────────────────────────────────────
|
|
1665
|
-
/**
|
|
1666
|
-
* relay の1tick。CONDUCTOR_RELAY_INTERVAL_SEC ごとに呼ばれる想定。
|
|
1667
|
-
*
|
|
1668
|
-
* 流れ:
|
|
1669
|
-
* - pending(送信済みDONE待ち)があれば、完了マーカー検出だけ行う
|
|
1670
|
-
* (sent 中はサーバも新規指示を返さないので取得しない)。停止中でも継続する
|
|
1671
|
-
* (既に送った指示の後始末は進める)。
|
|
1672
|
-
* - 緊急停止フラグを確認。停止中なら新規の取得・送信は一切しない。
|
|
1673
|
-
* - pending が無く停止中でもなければ、取得 → アイドル判定 → 配達 →「配達済み(sent)」を管制へ記録。
|
|
1674
|
-
*
|
|
1675
|
-
* すべての判定は保守的(少しでも怪しければ送らない/次回再挑戦)。
|
|
1676
|
-
*/
|
|
1677
|
-
export async function relayTick(cfg, state) {
|
|
1753
|
+
export async function relayTick(cfg, state, hooks) {
|
|
1678
1754
|
if (state.running)
|
|
1679
1755
|
return; // 前回の tick が走行中ならスキップ
|
|
1680
1756
|
state.running = true;
|
|
@@ -1871,19 +1947,41 @@ export async function relayTick(cfg, state) {
|
|
|
1871
1947
|
}
|
|
1872
1948
|
return; // sent 中は新規取得しない
|
|
1873
1949
|
}
|
|
1950
|
+
// ── 管制との線を確かめる(★止まらない・黙らないの要・2026-09-06 の根治) ──
|
|
1951
|
+
// 通信不能・切断・タイムアウト・401・5xx は【すべてここ】に来る(fetchControl が ok=false にする)。
|
|
1952
|
+
// ・配達は安全側で止める(管制と話せないのに職人へ送るのは危険)=これは従来どおり。
|
|
1953
|
+
// ・★relay 自体は止まらない。毎tick 1行出しながら、5秒→10→20→40→60秒 と間隔を伸ばして
|
|
1954
|
+
// 永久に試し続ける(間隔の決定は cli.ts の適応スケジューラが state.central を見て行う)。
|
|
1955
|
+
// ・つながったら回数を0に戻し、通常の間隔(15秒)へ自動で戻る。
|
|
1956
|
+
const ctl = await fetchControl(cfg);
|
|
1957
|
+
if (!ctl.ok) {
|
|
1958
|
+
log(noteCentralDown(state, ctl.detail, Date.now()));
|
|
1959
|
+
// ★lastHalted(管制のスイッチの記憶)はここでは触らない。通信の失敗はスイッチではないので、
|
|
1960
|
+
// 混ぜると復帰時に「停止が解除されました」という無関係な行が出る(人を混乱させる)。
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
const reconnected = noteCentralUp(state, Date.now());
|
|
1964
|
+
if (reconnected !== null) {
|
|
1965
|
+
log(reconnected);
|
|
1966
|
+
// ★戻ったら「自分は生きている」を管制へ1回知らせる(管制の一覧の札を正しく戻すため)。
|
|
1967
|
+
// 使うのは既にある口だけ(新しいAPIは作らない):
|
|
1968
|
+
// ① 追跡中の指示があれば、次tickの生存通知をスロットル無しで出せるよう時計を戻す。
|
|
1969
|
+
// ② 現場の生存報告(POST /api/conductor/report)を1回。呼び出し側(cli.ts)が持っている。
|
|
1970
|
+
state.lastHeartbeatAt = null;
|
|
1971
|
+
if (state.keepalive !== null)
|
|
1972
|
+
state.keepalive.lastHeartbeatAt = null;
|
|
1973
|
+
hooks?.onCentralReconnect?.();
|
|
1974
|
+
}
|
|
1874
1975
|
// ── 停止フラグ確認(新規の取得・送信のみ止める) ──
|
|
1875
|
-
// 停止条件は
|
|
1876
|
-
//
|
|
1976
|
+
// 停止条件は2つ: ①グローバル緊急停止 relayHalted(後方互換)
|
|
1977
|
+
// ②この現場の子トグル relayEnabled=false(ADR-038)。relayEnabled が undefined(未取得)は
|
|
1877
1978
|
// ON(稼働)扱いでフォールバックする(停止しない)。ログは状態が変わった時だけ出す。
|
|
1878
|
-
const ctl = await fetchControl(cfg);
|
|
1879
1979
|
const haltedBySite = ctl.relayEnabled === false;
|
|
1880
1980
|
if (ctl.relayHalted || haltedBySite) {
|
|
1881
1981
|
if (state.lastHalted !== true) {
|
|
1882
|
-
log(
|
|
1883
|
-
?
|
|
1884
|
-
:
|
|
1885
|
-
? `この現場(${cfg.site})の relay送信トグルがOFF(停止)のため送信しません`
|
|
1886
|
-
: 'relay停止中(緊急停止スイッチON)のため送信しません');
|
|
1982
|
+
log(haltedBySite
|
|
1983
|
+
? `この現場(${cfg.site})の relay送信トグルがOFF(停止)のため送信しません`
|
|
1984
|
+
: 'relay停止中(緊急停止スイッチON)のため送信しません');
|
|
1887
1985
|
}
|
|
1888
1986
|
state.lastHalted = true;
|
|
1889
1987
|
return;
|