@tyhld/conductor 0.3.0 → 0.7.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 (46) hide show
  1. package/README.md +301 -19
  2. package/dist/cli.js +143 -18
  3. package/dist/ear-routing.js +57 -0
  4. package/dist/ear.js +57 -0
  5. package/dist/env-file-perm.js +67 -0
  6. package/dist/nudge.js +172 -0
  7. package/dist/realtime-parse.js +116 -0
  8. package/dist/realtime.js +251 -0
  9. package/dist/relay-runner.js +65 -0
  10. package/dist/relay.js +911 -134
  11. package/dist/websocket-transport.js +66 -0
  12. package/launchd/ear-install.sh +96 -0
  13. package/package.json +30 -1
  14. package/sales-template/README.md +185 -25
  15. package/sales-template/install.sh +890 -0
  16. package/sales-template/launchd/install.sh +151 -0
  17. package/sales-template/settings.json +26 -97
  18. package/sales-template/setup.sh +754 -117
  19. package/sales-template/systemd/README.md +28 -4
  20. package/sales-template/systemd/install.sh +54 -5
  21. package/sales-template/systemd/paste-cache-prune-install.sh +75 -0
  22. package/sales-template/systemd/tyhld-paste-cache-prune.service +25 -0
  23. package/sales-template/systemd/tyhld-paste-cache-prune.timer +19 -0
  24. package/sales-template/uninstall.sh +245 -0
  25. package/scripts/hooks/README.md +246 -0
  26. package/scripts/hooks/cc2_guard.py +145 -0
  27. package/scripts/hooks/codex-hooks.sample.json +58 -0
  28. package/scripts/hooks/hook_datalink.py +440 -0
  29. package/scripts/hooks/install-codex-hooks.sh +127 -0
  30. package/scripts/hooks/notification_hook.py +167 -0
  31. package/scripts/hooks/permission_request_hook.py +207 -0
  32. package/scripts/hooks/policy.py +759 -0
  33. package/scripts/hooks/settings.sample.json +142 -0
  34. package/scripts/hooks/stop_hook.py +275 -0
  35. package/scripts/hooks/summary_ja.py +155 -0
  36. package/scripts/hooks/test_hook_datalink.py +282 -0
  37. package/scripts/hooks/test_policy.py +1241 -0
  38. package/skills/conductor-craftsman/SKILL.md +40 -0
  39. package/systemd/conductor-ear.service +63 -0
  40. package/systemd/conductor@.service +62 -0
  41. package/systemd/ear-install.sh +131 -0
  42. package/systemd/guard-sync-install.sh +94 -0
  43. package/systemd/tyhld-guard-sync.service +28 -0
  44. package/systemd/tyhld-guard-sync.timer +25 -0
  45. package/sales-template/cc2_guard.py +0 -395
  46. package/sales-template/systemd/conductor@.service +0 -47
@@ -0,0 +1,116 @@
1
+ /**
2
+ * @tyhld/conductor — ADR-066 耳の「読み取りと間合いの計算」だけを集めたモジュール(外部I/Oなし)。
3
+ *
4
+ * 耳の中で判断にあたるのは次の4つで、いずれも入力から結論が決まる純粋関数にしてある:
5
+ * ① realtime-token の応答を読めるか(読めない応答で半端に張りに行かない)
6
+ * ② 合図から宛先(site)だけを読む(★本文は読まない・使わない)
7
+ * ③ いつトークンを取り直すか(失効の手前・下限と上限で挟む)
8
+ * ④ 切れたとき何秒後に張り直すか(指数バックオフ)
9
+ * 実際の接続・通信は src/realtime.ts 側にある。ここを分けてあるのは、判断だけを単体テストで
10
+ * 固定できるようにするため(src/ear-routing.ts と同じ流儀)。
11
+ */
12
+ /** 接続情報を問い合わせるときのタイムアウト(ms)。起動をハングさせないため短く切る。 */
13
+ export const EAR_TOKEN_FETCH_TIMEOUT_MS = 10 * 1000;
14
+ /** 合図が連続したときに取得APIを叩く最小間隔(ms)。束ねても取りこぼさない(本文はAPIから取るため)。 */
15
+ export const EAR_NUDGE_MIN_GAP_MS = 1000;
16
+ /** 失効の何ms手前でトークンを取り直すか。 */
17
+ export const EAR_REFRESH_MARGIN_MS = 10 * 60 * 1000;
18
+ /** 張り直し間隔の下限・上限、および expiresAt が読めないときの既定(9時間より十分手前)。 */
19
+ export const EAR_REFRESH_MIN_MS = 60 * 1000;
20
+ export const EAR_REFRESH_MAX_MS = 8 * 60 * 60 * 1000;
21
+ export const EAR_REFRESH_FALLBACK_MS = 8 * 60 * 60 * 1000;
22
+ /** 再接続の待ち(指数バックオフ)の初期値と上限。 */
23
+ export const EAR_RETRY_BASE_MS = 5 * 1000;
24
+ export const EAR_RETRY_MAX_MS = 5 * 60 * 1000;
25
+ /** 購読設定が無い(unsupported)ときに問い直す間隔(ms)。中央の設定投入に自然に追従するため。 */
26
+ export const EAR_UNSUPPORTED_RETRY_MS = 30 * 60 * 1000;
27
+ /**
28
+ * この機で現場のrelayが1つも動いていないときに、もう一度見るまでの間隔(ms)。
29
+ * 既存APIの realtime-token は現場名を要るので、実在する現場が1つも無い間は張りに行かない
30
+ * (在りもしない現場名を送って中央に弾かれるより、少し待って出そろってから張る方が素直)。
31
+ */
32
+ export const EAR_NO_SITE_RETRY_MS = 60 * 1000;
33
+ // ───────────────────────────────────────────────────────────────────────────
34
+ // 純粋関数(I/Oなし=単体テスト可能)
35
+ // ───────────────────────────────────────────────────────────────────────────
36
+ /** 空でない文字列ならその値、そうでなければ null。 */
37
+ function str(v) {
38
+ return typeof v === 'string' && v.trim() !== '' ? v : null;
39
+ }
40
+ /**
41
+ * realtime-token の応答(JSON)から接続一式を取り出す純粋関数。
42
+ * - 購読に要るもの(token / supabaseUrl / supabaseAnonKey / subscribe.channel・schema・table・event)が
43
+ * 1つでも欠けたら 'unsupported'=耳は生やさずポーリングのみで動く(ADR-066: エラーにしない)。
44
+ * - 鍵の綴りは supabaseAnonKey / anonKey の両方を許容する(中央の応答が育っても壊れないように)。
45
+ */
46
+ export function parseRealtimeConnection(data) {
47
+ if (data === null || typeof data !== 'object') {
48
+ return { kind: 'error', detail: '応答がJSONオブジェクトでない' };
49
+ }
50
+ const d = data;
51
+ const token = str(d.token);
52
+ const supabaseUrl = str(d.supabaseUrl);
53
+ const supabaseAnonKey = str(d.supabaseAnonKey) ?? str(d.anonKey);
54
+ const sub = d.subscribe;
55
+ if (token === null)
56
+ return { kind: 'error', detail: 'token が無い' };
57
+ if (sub === null || typeof sub !== 'object') {
58
+ return { kind: 'unsupported', detail: '応答に subscribe が無い' };
59
+ }
60
+ if (supabaseUrl === null || supabaseAnonKey === null) {
61
+ return { kind: 'unsupported', detail: '応答に接続先(URL/公開鍵)が無い' };
62
+ }
63
+ const s = sub;
64
+ const channel = str(s.channel);
65
+ const schema = str(s.schema);
66
+ const table = str(s.table);
67
+ const event = str(s.event);
68
+ if (channel === null || schema === null || table === null || event === null) {
69
+ return { kind: 'unsupported', detail: 'subscribe の項目が足りない' };
70
+ }
71
+ const filter = str(s.filter);
72
+ return {
73
+ kind: 'ok',
74
+ conn: {
75
+ token,
76
+ expiresAt: str(d.expiresAt),
77
+ supabaseUrl,
78
+ supabaseAnonKey,
79
+ subscribe: filter === null ? { channel, schema, table, event } : { channel, schema, table, event, filter },
80
+ },
81
+ };
82
+ }
83
+ /**
84
+ * 合図から「宛先の現場名」だけを読む純粋関数(★振り分けにのみ使う。本文は読まない)。
85
+ * 合図の行には本文・報告・トークンの列がそもそも存在しない(ADR-066 §3)ので、site を読んでも
86
+ * 秘密は流れない。読めなければ null=「宛先不明」とし、呼び出し側は取りに行く側へ倒す
87
+ * (余計に1回取得APIを叩くだけ=ポーリング1周の前倒しと同じで無害)。
88
+ */
89
+ export function readSignalSite(payload) {
90
+ if (payload === null || typeof payload !== 'object')
91
+ return null;
92
+ const rec = payload.new;
93
+ if (rec === null || typeof rec !== 'object')
94
+ return null;
95
+ return str(rec.site);
96
+ }
97
+ /**
98
+ * トークンを取り直すまでの待ち(ms)を決める純粋関数。
99
+ * 失効時刻の EAR_REFRESH_MARGIN_MS 手前を狙い、下限・上限で挟む。
100
+ * expiresAt が読めない/過去なら既定値(下限まで詰める)へ倒す=「切れたまま気づかない」を作らない。
101
+ */
102
+ export function computeRefreshDelayMs(expiresAt, nowMs) {
103
+ const clamp = (ms) => Math.min(EAR_REFRESH_MAX_MS, Math.max(EAR_REFRESH_MIN_MS, ms));
104
+ if (expiresAt === null)
105
+ return clamp(EAR_REFRESH_FALLBACK_MS);
106
+ const at = Date.parse(expiresAt);
107
+ if (!Number.isFinite(at))
108
+ return clamp(EAR_REFRESH_FALLBACK_MS);
109
+ return clamp(at - nowMs - EAR_REFRESH_MARGIN_MS);
110
+ }
111
+ /** 再接続の待ち(ms)。試行回数に応じて 5s → 10s → 20s …と広げ、5分で頭打ちにする。 */
112
+ export function computeRetryDelayMs(attempt) {
113
+ const n = attempt < 0 ? 0 : attempt;
114
+ const ms = EAR_RETRY_BASE_MS * 2 ** Math.min(n, 20);
115
+ return Math.min(EAR_RETRY_MAX_MS, ms);
116
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * @tyhld/conductor — ADR-066「指示の着信合図」を受け取る耳(Supabase Realtime の購読そのもの)。
3
+ *
4
+ * 【何のためか】
5
+ * これまで relay は 15秒ポーリングだけで指示を拾っていた。指示が積まれてから職人へ届くまで
6
+ * 最大15秒の空白があり、お客さまには「押したのに動かない」時間として見える。
7
+ * 中央(devlog-tracker)側は ConductorCommand への INSERT をトリガで ConductorSignal へ写し、
8
+ * Supabase Realtime で配信するところまで完成している(便2a・本番反映済み)。
9
+ * ここではその合図を受け取り、「ポーリング1周を前倒しする」きっかけだけを作る。
10
+ *
11
+ * 【設計の要(ADR-066 の確定事項。勝手に変えない)】
12
+ * ・合図の本文は使わない。合図は「何か来た」の呼び鈴でしかなく、指示の中身は必ず既存の
13
+ * 取得API(GET /api/conductor/commands)から取り直す。=真実は常に中央のAPIにある。
14
+ * ・接続先(URL・公開鍵)も購読名も、必ず POST /api/conductor/realtime-token の応答から受け取る。
15
+ * このリポに URL・鍵・チャンネル名を書き写さない(2箇所に同じ文字列があると、片方だけ直して
16
+ * 「繋がっているのに届かない」事故になる)。env も足さない。
17
+ * ・購読は machine 単位(filter: machine=eq.<machine>)。宛先の振り分けは呼び出し側(src/ear.ts)の仕事。
18
+ * ・トークンは9時間で失効する。失効の手前で取り直して張り直す。
19
+ * ・切断・エラーは自動再接続。★15秒ポーリングは消さない。線が切れている間はポーリングが
20
+ * そのまま肩代わりする(=耳が全部死んでも従来どおり動く)。
21
+ *
22
+ * 【便2c: この接続を張るのは1台に1本だけ】
23
+ * 便2b では relay プロセスごとにこの耳を張っていたため、接続本数が「PC数 × 現場数」で増えた。
24
+ * 便2c で耳を独立常駐(src/ear.ts / conductor-ear.service)へ移し、この モジュールを呼ぶのは
25
+ * その1プロセスだけにした。relay は Realtime を張らず、耳からのプロセス間の合図(src/nudge.ts)で
26
+ * 1周を前倒しするだけ。=接続本数はソフト数ではなく【PC数】で決まる。
27
+ *
28
+ * 【依存の扱い】@supabase/supabase-js は動的 import で読む。顧客PCで git pull だけして
29
+ * npm install を忘れた状態でも、耳が起動不能になっては困る(起動不能=黙って加速だけ失う方が良い)。
30
+ * 読めなければ「耳なし=各relayのポーリングのみ」で静かに動き続ける。
31
+ */
32
+ import { describeTransport, resolveWebSocketTransport } from './websocket-transport.js';
33
+ import { EAR_NO_SITE_RETRY_MS, EAR_NUDGE_MIN_GAP_MS, EAR_TOKEN_FETCH_TIMEOUT_MS, EAR_UNSUPPORTED_RETRY_MS, computeRefreshDelayMs, computeRetryDelayMs, parseRealtimeConnection, readSignalSite, } from './realtime-parse.js';
34
+ // 読み取りと間合いの計算は src/realtime-parse.ts に分けてある(外部I/Oなし=単体テスト可能)。
35
+ // 従来どおり `./realtime.js` からも使えるよう、ここで通しておく。
36
+ export * from './realtime-parse.js';
37
+ /** ログ(耳の出来事だと分かるよう [ear] にする)。 */
38
+ const now = () => new Date().toLocaleTimeString();
39
+ const log = (msg) => console.log(`[ear] ${now()} ${msg}`);
40
+ // ───────────────────────────────────────────────────────────────────────────
41
+ // I/O
42
+ // ───────────────────────────────────────────────────────────────────────────
43
+ /**
44
+ * 接続情報を中央へ問い合わせる。POST /api/conductor/realtime-token(テナントトークン Bearer)。
45
+ * body に site と machine を送ると、応答に接続先・公開鍵・購読設定が付く(machine を送らない
46
+ * 従来の呼び方では付かない=中央側は後方互換)。
47
+ *
48
+ * ★site は既存APIの仕様上の必須項目。耳はPC単位なので「今このPCで動いている現場」から1つ選んで
49
+ * 添える(購読は machine 単位で、どの現場を添えても受け取る合図は同じ)。中央側の変更は要らない。
50
+ */
51
+ export async function fetchRealtimeConnection(cfg, site) {
52
+ try {
53
+ const res = await fetch(`${cfg.url}/api/conductor/realtime-token`, {
54
+ method: 'POST',
55
+ headers: {
56
+ Authorization: `Bearer ${cfg.token}`,
57
+ 'Content-Type': 'application/json',
58
+ },
59
+ body: JSON.stringify({ site, machine: cfg.machine }),
60
+ signal: AbortSignal.timeout(EAR_TOKEN_FETCH_TIMEOUT_MS),
61
+ });
62
+ if (!res.ok)
63
+ return { kind: 'error', detail: `HTTP ${res.status}` };
64
+ const data = await res.json().catch(() => null);
65
+ return parseRealtimeConnection(data);
66
+ }
67
+ catch (err) {
68
+ return { kind: 'error', detail: err instanceof Error ? err.message : String(err) };
69
+ }
70
+ }
71
+ /** supabase-js の createClient を動的に読む。入っていなければ null(=ポーリングのみで動く)。 */
72
+ async function loadCreateClient() {
73
+ try {
74
+ const mod = (await import('@supabase/supabase-js'));
75
+ return typeof mod.createClient === 'function' ? mod.createClient : null;
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
81
+ /**
82
+ * 指示の着信合図を受け取る耳を1本張る(★このPCに1本だけ=耳プロセスからのみ呼ぶ)。
83
+ *
84
+ * 呼び出し側は各relayのポーリングを止めないこと——耳が死んでいる間はポーリングが肩代わりする。
85
+ */
86
+ export function startRealtimeEar(cfg, handlers) {
87
+ const { siteForToken, onSignal } = handlers;
88
+ let stopped = false;
89
+ let client = null;
90
+ let channel = null;
91
+ let retryTimer = null;
92
+ let refreshTimer = null;
93
+ let attempt = 0;
94
+ let lastNudgeAt = 0;
95
+ /** 直近に束ね判定をした宛先(宛先ごとに束ねるため)。 */
96
+ let lastNudgeSite = null;
97
+ /** 再接続の予約が二重に積まれないようにする(切断イベントは連続で飛んでくる)。 */
98
+ let reconnectArmed = false;
99
+ const clearTimers = () => {
100
+ if (retryTimer) {
101
+ clearTimeout(retryTimer);
102
+ retryTimer = null;
103
+ }
104
+ if (refreshTimer) {
105
+ clearTimeout(refreshTimer);
106
+ refreshTimer = null;
107
+ }
108
+ };
109
+ const teardown = () => {
110
+ try {
111
+ if (client && channel)
112
+ client.removeChannel(channel);
113
+ }
114
+ catch {
115
+ /* 片付けの失敗で耳の再生を止めない */
116
+ }
117
+ try {
118
+ client?.realtime?.disconnect?.();
119
+ }
120
+ catch {
121
+ /* 同上 */
122
+ }
123
+ channel = null;
124
+ client = null;
125
+ };
126
+ /** 一拍おいて張り直す。理由はログに残す(凝らない=最悪はポーリングが拾う)。 */
127
+ const scheduleReconnect = (reason) => {
128
+ if (stopped || reconnectArmed)
129
+ return;
130
+ reconnectArmed = true;
131
+ teardown();
132
+ const delay = computeRetryDelayMs(attempt);
133
+ attempt += 1;
134
+ log(`${reason} → ${Math.round(delay / 1000)}秒後に張り直します(この間は各relayの15秒ポーリングが肩代わり)`);
135
+ clearTimers();
136
+ retryTimer = setTimeout(() => {
137
+ reconnectArmed = false;
138
+ void connect();
139
+ }, delay);
140
+ };
141
+ /** 失効の手前で張り直す(トークンは9時間で切れる)。 */
142
+ const scheduleRefresh = (expiresAt) => {
143
+ if (stopped)
144
+ return;
145
+ const delay = computeRefreshDelayMs(expiresAt, Date.now());
146
+ if (refreshTimer)
147
+ clearTimeout(refreshTimer);
148
+ refreshTimer = setTimeout(() => {
149
+ if (stopped)
150
+ return;
151
+ log('接続の期限が近いので張り直します');
152
+ teardown();
153
+ void connect();
154
+ }, delay);
155
+ };
156
+ const handleSignal = (payload) => {
157
+ // ★payload の中身は使わない。読むのは宛先(site)だけで、指示本文は受け手が取得APIから取り直す。
158
+ const signalSite = readSignalSite(payload);
159
+ const t = Date.now();
160
+ // 同じ宛先への連続した合図だけを束ねる(別の現場宛てが続いたときに取りこぼさないよう宛先ごとに見る)。
161
+ const key = signalSite ?? '';
162
+ if (key === lastNudgeSite && t - lastNudgeAt < EAR_NUDGE_MIN_GAP_MS)
163
+ return;
164
+ lastNudgeSite = key;
165
+ lastNudgeAt = t;
166
+ onSignal(signalSite);
167
+ };
168
+ const connect = async () => {
169
+ if (stopped)
170
+ return;
171
+ const createClient = await loadCreateClient();
172
+ if (createClient === null) {
173
+ log('@supabase/supabase-js が読めないため耳は張りません(各relayの15秒ポーリングでこれまでどおり動きます)');
174
+ return; // 依存は実行中に生えないので再試行しない。
175
+ }
176
+ // ★どの Node(18/20/22) でも耳が張れるようにする(実機 Node 20 の「native WebSocket not found」根治)。
177
+ // 標準の WebSocket が無い機では同梱の ws を supabase-js の transport へ渡す。
178
+ // どちらも用意できなければ耳は張らない——が、プロセスは落とさない(ポーリングが肩代わりする)。
179
+ const transport = await resolveWebSocketTransport();
180
+ if (transport === null) {
181
+ log('この機には標準のWebSocketが無く、同梱のWebSocket部品(ws)も読めないため耳は張りません' +
182
+ '(npm install で解消します。それまでも各relayの15秒ポーリングでこれまでどおり動きます)');
183
+ return; // 部品は実行中に生えないので再試行しない。
184
+ }
185
+ const site = siteForToken();
186
+ if (site === null) {
187
+ log(`この機でまだ現場のrelayが1つも動いていないため、耳は待機します(${Math.round(EAR_NO_SITE_RETRY_MS / 1000)}秒後にもう一度見ます)`);
188
+ clearTimers();
189
+ retryTimer = setTimeout(() => void connect(), EAR_NO_SITE_RETRY_MS);
190
+ return;
191
+ }
192
+ const result = await fetchRealtimeConnection(cfg, site);
193
+ if (result.kind === 'unsupported') {
194
+ log(`中央から購読設定が返らないため耳は張りません(${result.detail})。` +
195
+ `各relayの15秒ポーリングで動き続け、${Math.round(EAR_UNSUPPORTED_RETRY_MS / 60000)}分後にもう一度問い合わせます`);
196
+ clearTimers();
197
+ retryTimer = setTimeout(() => void connect(), EAR_UNSUPPORTED_RETRY_MS);
198
+ return;
199
+ }
200
+ if (result.kind === 'error') {
201
+ scheduleReconnect(`接続情報を取得できません(${result.detail})`);
202
+ return;
203
+ }
204
+ const conn = result.conn;
205
+ try {
206
+ client = createClient(conn.supabaseUrl, conn.supabaseAnonKey, {
207
+ auth: { persistSession: false, autoRefreshToken: false },
208
+ // transport を明示して渡す=supabase-js が自前で標準WebSocketを探して例外を投げる道を通らせない。
209
+ realtime: { transport: transport.ctor },
210
+ });
211
+ client.realtime?.setAuth?.(conn.token);
212
+ const spec = conn.subscribe;
213
+ const filterOpt = {
214
+ event: spec.event,
215
+ schema: spec.schema,
216
+ table: spec.table,
217
+ };
218
+ if (spec.filter !== undefined)
219
+ filterOpt.filter = spec.filter;
220
+ channel = client
221
+ .channel(spec.channel)
222
+ .on('postgres_changes', filterOpt, handleSignal)
223
+ .subscribe((status, err) => {
224
+ if (stopped)
225
+ return;
226
+ if (status === 'SUBSCRIBED') {
227
+ attempt = 0;
228
+ reconnectArmed = false;
229
+ log(`合図の受け取りを開始しました(machine=${cfg.machine} この機に1本 / ${describeTransport(transport)})`);
230
+ scheduleRefresh(conn.expiresAt);
231
+ return;
232
+ }
233
+ if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') {
234
+ const detail = err instanceof Error ? `: ${err.message}` : '';
235
+ scheduleReconnect(`合図の線が切れました(${status}${detail})`);
236
+ }
237
+ });
238
+ }
239
+ catch (err) {
240
+ scheduleReconnect(`購読の開始に失敗(${err instanceof Error ? err.message : String(err)})`);
241
+ }
242
+ };
243
+ void connect();
244
+ return {
245
+ stop: () => {
246
+ stopped = true;
247
+ clearTimers();
248
+ teardown();
249
+ },
250
+ };
251
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @tyhld/conductor — relay の回し役(ポーリング+合図の前倒しを1つの流れにまとめる)。
3
+ *
4
+ * 【なぜ切り出すか】ADR-066 便2b で relay の回り方に「合図(Realtime)で1周を前倒しする」が加わった。
5
+ * ここは一見単純だが、間違えると静かに壊れる場所が2つある:
6
+ * ① 予約を消さずに走ると、走行後に積む予約と元の予約が二重になり、ポーリングが倍・4倍と増える。
7
+ * (中央への問い合わせ回数=Vercel実行回数がじわじわ増える。誰も気づけない類の劣化。)
8
+ * ② tick の走行中に来た合図を捨てると、その tick は合図より前の状態を見て終わっているかも
9
+ * しれないので、指示が15秒待たされる(=耳を付けた意味が消える)。
10
+ * どちらもタイマー絡みで再現が面倒なので、cli.ts に直書きせず単体テストできる形にする。
11
+ *
12
+ * 【約束】
13
+ * - 同時に走る tick は1つだけ(走行中の run() は「終わったらもう1周」の予約として畳む)。
14
+ * - 予約中のタイマーは常に1本だけ(走り出す前に必ず消す)。
15
+ * - tick が投げた例外でループを止めない(1回の失敗で配達も承認も止まってはいけない)。
16
+ * - stop() 後は新しい tick を始めない・新しい予約も積まない。
17
+ */
18
+ export function createRelayRunner(args) {
19
+ const { tick, nextDelayMs, onError } = args;
20
+ let timer = null;
21
+ let busy = false;
22
+ let nudged = false;
23
+ let stopped = false;
24
+ const clearTimer = () => {
25
+ if (timer) {
26
+ clearTimeout(timer);
27
+ timer = null;
28
+ }
29
+ };
30
+ const run = async () => {
31
+ if (stopped)
32
+ return;
33
+ if (busy) {
34
+ nudged = true; // 走行中に来た合図。今の tick が終わったらもう1周して取りに行く。
35
+ return;
36
+ }
37
+ busy = true;
38
+ clearTimer(); // ★予約を残したまま走ると、走行後の予約と二重になりポーリングが倍速化する。
39
+ try {
40
+ do {
41
+ nudged = false;
42
+ try {
43
+ await tick();
44
+ }
45
+ catch (err) {
46
+ onError?.(err);
47
+ }
48
+ } while (nudged && !stopped);
49
+ }
50
+ finally {
51
+ busy = false;
52
+ }
53
+ if (stopped)
54
+ return;
55
+ clearTimer();
56
+ timer = setTimeout(() => void run(), nextDelayMs());
57
+ };
58
+ return {
59
+ run,
60
+ stop: () => {
61
+ stopped = true;
62
+ clearTimer();
63
+ },
64
+ };
65
+ }