@tyhld/conductor 0.12.84 → 0.12.86

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.
@@ -0,0 +1,643 @@
1
+ /**
2
+ * @tyhld/conductor — 更新係(`conductor autoupdate`・便 29babc5d・設計 3ed9e2b2 の A3)。
3
+ *
4
+ * 【何のためか】自社PCの自動更新(えふさん確定 2026-10-02)。通常時は PC の電源を入れるだけで、
5
+ * 常駐起動 → 版確認 → 必要なら安全に更新 → 自己診断 → 便を受け取る、まで人の手を要らなくする。
6
+ *
7
+ * 【動くとき】systemd のユーザーサービスから(★sudo なし・番人を通らない)。
8
+ * ・起動時に1回(tyhld-autoupdate-boot.service・--boot)… 常駐(conductor@)より先に動く
9
+ * ・以後は数分ごとのタイマー(tyhld-autoupdate.timer)… 管制が返す checkAgainMin が来た回だけ聞きに行く
10
+ *
11
+ * 【決め(設計 3ed9e2b2 の 1/4・2/4 と管制の口の決まり 2f5eb92c)】
12
+ * ・鍵(~/.tyhld/bin/autoupdate/enabled)が無い機では何もしない(★お客さまの機には置かない)。
13
+ * ・管制に目標の版を聞く。autoUpdate:false・ready:false・止める(halted)・届かない → 何もしない
14
+ * (届かないときは更新しない=安全側)。
15
+ * ・conductor の目標が今と違えば conductor だけを入れ替える。★そのあいだ Claude Code は動かさない
16
+ * (管制も同じ順番を守る。更新係は管制が返した目標だけを見る)。
17
+ * ・入れ替えは全現場が空きのときだけ: 札を置く → 中継の1周ぶん待つ → もう一度空きを確かめる →
18
+ * 版を名指しして入れる(取得先は毎回 https://registry.npmjs.org・@latest にしない)→
19
+ * 全現場の常駐を起こし直す → 札を外す → 自己診断 → 管制へ(自己診断が届ける)。
20
+ * ・空きでなければ次の回に再挑戦。6時間空かなければ札を置いて新しい便を取らない形に移り、
21
+ * 走っている便の終わりを待つ(★走っている便は止めない)。
22
+ * ・自己診断が FAIL → そのPCだけ前の版へ戻す → 自己診断(after-rollback=rolledBack が管制へ届く)。
23
+ * 戻しても FAIL なら、その版のまま止まって🟡(Claude Code・囲い・小さな試験が落ちたままなら
24
+ * 札を残して新しい便を取らない=便を失敗で燃やさない)。人にしかできない原因は自己診断が🔵で届ける。
25
+ * ・Claude Code は公式推奨の導入(https://claude.ai/install.sh)で版を名指しして入れ、
26
+ * 指名(~/.tyhld/bin/autoupdate/claude-pin)を書く。戻すときも指名で戻す。最低版より古い版へは戻さない。
27
+ * ・候補の版(conductor=公開 npm の latest/Claude Code=公式の安定版)を見つけたら、自己診断に
28
+ * 載せて管制へ届ける。★先行機かどうかはこの機では決めない(機体名をコードに書かない・管制が決める)。
29
+ *
30
+ * 【控え】~/.tyhld/bin/autoupdate/state.json(★職人が書けない場所。途中で電源が切れても続きから動く)。
31
+ */
32
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync, copyFileSync, chmodSync, } from 'node:fs';
33
+ import os from 'node:os';
34
+ import path from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+ import { CLAUDE_MIN_VERSION, compareVersions, isProcessAlive, parseClaudeVersion, resolveClaudeBin, } from './headless.js';
37
+ import { defaultExec, fetchClaudeStable, npmBesideNode, pickCandidates } from './selfcheck.js';
38
+ // ─────────────────────────────────────────────────────────────────────────────
39
+ // 定数(★時間の値は設計 3ed9e2b2 の推奨値・管制の ROLLOUT_TIMES と同じ考え方)
40
+ // ─────────────────────────────────────────────────────────────────────────────
41
+ /** 取得先(★毎回これに固定する=手元の設定が何であっても公開 npm を見る)。 */
42
+ export const CONDUCTOR_PKG = '@tyhld/conductor';
43
+ export const CONDUCTOR_REGISTRY_ARG = '--@tyhld:registry=https://registry.npmjs.org';
44
+ /** 公式推奨の導入台本(code.claude.com/docs/en/setup の Native Install (Recommended))。 */
45
+ export const CLAUDE_INSTALL_URL = 'https://claude.ai/install.sh';
46
+ /** 管制が checkAgainMin を返さないときの間隔(分)。 */
47
+ export const DEFAULT_CHECK_MIN = 15;
48
+ /** 空きが来ないとき、札を置いて新しい便を取らない形に移るまでの長さ。 */
49
+ export const BUSY_LIMIT_MS = 6 * 60 * 60 * 1000;
50
+ /** 起こし直した常駐が立ち上がるのを待つ上限。 */
51
+ export const RESTART_WAIT_MS = 120_000;
52
+ /** 起こし直した常駐の様子を見る間隔。 */
53
+ export const RESTART_POLL_MS = 5_000;
54
+ /** 戻しても直らなかった機で、自己診断を測り直す間隔。 */
55
+ export const HELD_RECHECK_MS = 60 * 60 * 1000;
56
+ /** 候補を届けられなかったとき、もう一度届けに行くまでの間隔。 */
57
+ export const CANDIDATE_RETRY_MS = 24 * 60 * 60 * 1000;
58
+ const SEMVER = /^\d+\.\d+\.\d+$/;
59
+ // ─────────────────────────────────────────────────────────────────────────────
60
+ // 置き場
61
+ // ─────────────────────────────────────────────────────────────────────────────
62
+ /** 更新係の置き場(★~/.tyhld/bin の下=番人がどの書き方でも書き込みを止める=職人は書けない)。 */
63
+ export function updaterDir(home) {
64
+ return path.join(home, '.tyhld', 'bin', 'autoupdate');
65
+ }
66
+ /** 鍵(自社PCの印)。★置くのは自社PCの導入台本(scripts/autoupdate-install.sh)だけ。 */
67
+ export function keyPath(home) {
68
+ return path.join(updaterDir(home), 'enabled');
69
+ }
70
+ export function statePath(home) {
71
+ return path.join(updaterDir(home), 'state.json');
72
+ }
73
+ /** 札の場所(★中継が読む場所と同じ=relay.ts の drainFilePath と同じ決め方)。 */
74
+ export function drainPath(home, env) {
75
+ return env.CONDUCTOR_DRAIN_FILE || path.join(updaterDir(home), 'drain');
76
+ }
77
+ /** 指名の場所(★中継が読む場所と同じ=autoupdate-flags.ts の claudePinPath と同じ決め方)。 */
78
+ export function pinPath(home, env) {
79
+ return env.CONDUCTOR_CLAUDE_PIN_FILE || path.join(updaterDir(home), 'claude-pin');
80
+ }
81
+ /** ネイティブ導入の版の置き場(公式の導入台本がここへ入れる)。 */
82
+ export function claudeVersionsDir(home) {
83
+ return path.join(home, '.local', 'share', 'claude', 'versions');
84
+ }
85
+ // ─────────────────────────────────────────────────────────────────────────────
86
+ // 小さな部品
87
+ // ─────────────────────────────────────────────────────────────────────────────
88
+ export function readInstalledVersion(distRoot) {
89
+ try {
90
+ const v = JSON.parse(readFileSync(path.join(distRoot, 'package.json'), 'utf8')).version;
91
+ return typeof v === 'string' ? v : null;
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ }
97
+ export function loadState(home) {
98
+ try {
99
+ const s = JSON.parse(readFileSync(statePath(home), 'utf8'));
100
+ if (s && s.schema === 'tyhld-autoupdate/1')
101
+ return s;
102
+ }
103
+ catch {
104
+ // 無い・壊れている → 初めから
105
+ }
106
+ return { schema: 'tyhld-autoupdate/1', phase: 'idle' };
107
+ }
108
+ /** 控えを書く(★入れ替えの前にも書く=途中で切れても続きが分かる)。原子的に置き換える。 */
109
+ export function saveState(home, s) {
110
+ mkdirSync(updaterDir(home), { recursive: true });
111
+ const p = statePath(home);
112
+ writeFileSync(`${p}.tmp`, `${JSON.stringify(s, null, 2)}\n`);
113
+ renameSync(`${p}.tmp`, p);
114
+ }
115
+ /**
116
+ * 作業中の便がある現場の一覧(★空きの確かめ)。
117
+ * 中継は便の実行中、~/.local/state/conductor/active-job-<現場>.json を置き、終われば消す。
118
+ * 記録の pid が生きていれば作業中。★pid の無い古い形の記録は「作業中」として扱う(迷ったら入れ替えない)。
119
+ */
120
+ export function busySites(home, isAlive) {
121
+ const dir = path.join(home, '.local', 'state', 'conductor');
122
+ let names = [];
123
+ try {
124
+ names = readdirSync(dir);
125
+ }
126
+ catch {
127
+ return [];
128
+ }
129
+ const out = [];
130
+ for (const n of names) {
131
+ const m = n.match(/^active-job(?:-(.+))?\.json$/);
132
+ if (!m)
133
+ continue;
134
+ let pid;
135
+ try {
136
+ const rec = JSON.parse(readFileSync(path.join(dir, n), 'utf8'));
137
+ pid = typeof rec.pid === 'number' ? rec.pid : undefined;
138
+ }
139
+ catch {
140
+ out.push(m[1] ?? '(共有の記録)');
141
+ continue;
142
+ }
143
+ if (pid === undefined || isAlive(pid))
144
+ out.push(m[1] ?? '(共有の記録)');
145
+ }
146
+ return out;
147
+ }
148
+ /** 管制に目標の版を聞く。★届かない・JSON でない・ログイン画面へ回される → null(=何もしない)。 */
149
+ export async function fetchTarget(d, conductorVersion) {
150
+ const url = d.env.CONDUCTOR_URL;
151
+ const token = d.env.CONDUCTOR_TOKEN;
152
+ if (!url || !token)
153
+ return { target: null, detail: '接続設定(CONDUCTOR_URL・CONDUCTOR_TOKEN)がありません' };
154
+ const q = new URLSearchParams({ machine: d.machine });
155
+ if (conductorVersion)
156
+ q.set('conductorVersion', conductorVersion);
157
+ try {
158
+ const res = await d.fetchImpl(`${url}/api/conductor/machines/target?${q.toString()}`, {
159
+ headers: { Authorization: `Bearer ${token}` },
160
+ redirect: 'manual',
161
+ signal: AbortSignal.timeout(20_000),
162
+ });
163
+ const json = (res.headers.get('content-type') ?? '').includes('application/json');
164
+ if (!res.ok || !json)
165
+ return { target: null, detail: `HTTP ${res.status}${json ? '' : '(JSON の答えではない)'}` };
166
+ const b = (await res.json());
167
+ const ver = (v) => (typeof v === 'string' && SEMVER.test(v.trim()) ? v.trim() : null);
168
+ const min = typeof b.checkAgainMin === 'number' && Number.isFinite(b.checkAgainMin) && b.checkAgainMin > 0 ? b.checkAgainMin : DEFAULT_CHECK_MIN;
169
+ return {
170
+ target: {
171
+ autoUpdate: b.autoUpdate === true,
172
+ conductor: ver(b.conductor),
173
+ claudeCode: ver(b.claudeCode),
174
+ halted: b.halted === true,
175
+ reason: typeof b.reason === 'string' ? b.reason : null,
176
+ checkAgainMin: min,
177
+ ready: b.ready === true,
178
+ },
179
+ detail: `HTTP ${res.status}`,
180
+ };
181
+ }
182
+ catch (err) {
183
+ return { target: null, detail: err instanceof Error ? err.message : String(err) };
184
+ }
185
+ }
186
+ function placeDrain(d, s) {
187
+ const p = drainPath(d.home, d.env);
188
+ if (existsSync(p))
189
+ return;
190
+ mkdirSync(path.dirname(p), { recursive: true });
191
+ writeFileSync(p, `${d.now().toISOString()} tyhld-autoupdate\n`);
192
+ s.drainOwned = true;
193
+ d.log(`入れ替え待ちの札を置きました(新しい便を取りません・走っている便は止めません): ${p}`);
194
+ }
195
+ /** 札を外す。★この係が置いた札だけ(人が置いた札は外さない)。 */
196
+ function removeDrain(d, s) {
197
+ if (!s.drainOwned)
198
+ return;
199
+ rmSync(drainPath(d.home, d.env), { force: true });
200
+ s.drainOwned = false;
201
+ d.log('入れ替え待ちの札を外しました(便の受け取りを再開します)');
202
+ }
203
+ function addFailed(s, c, v) {
204
+ if (!v)
205
+ return;
206
+ const list = ((s.failedVersions ??= {})[c] ??= []);
207
+ if (!list.includes(v))
208
+ list.push(v);
209
+ }
210
+ function done(d, s, action, detail) {
211
+ s.lastRun = { at: d.now().toISOString(), action, detail };
212
+ saveState(d.home, s);
213
+ d.log(`${action}: ${detail}`);
214
+ return s;
215
+ }
216
+ // ─────────────────────────────────────────────────────────────────────────────
217
+ // 入れ替えの本体
218
+ // ─────────────────────────────────────────────────────────────────────────────
219
+ /** conductor を版を名指しして入れる(★@latest にしない・取得先は公開 npm に固定)。 */
220
+ function installConductor(d, version, offline) {
221
+ if (!SEMVER.test(version))
222
+ return { ok: false, detail: `版の形が違います(${version})` };
223
+ if (!d.npm)
224
+ return { ok: false, detail: '配布物の node の隣に npm がありません' };
225
+ const env = { ...d.env, PATH: `${path.dirname(d.npm)}:${d.env.PATH ?? ''}` };
226
+ const args = ['install', '-g', `${CONDUCTOR_PKG}@${version}`, CONDUCTOR_REGISTRY_ARG, ...(offline ? ['--prefer-offline'] : [])];
227
+ const r = d.exec(d.npm, args, { env, timeoutMs: 15 * 60_000 });
228
+ const now = readInstalledVersion(d.distRoot);
229
+ if (r.status === 0 && now === version)
230
+ return { ok: true, detail: `${CONDUCTOR_PKG}@${version} を入れました` };
231
+ return { ok: false, detail: `npm install の終了 ${r.status}・入っている版 ${now ?? '不明'}(${(r.error ?? r.stderr).trim().slice(-200)})` };
232
+ }
233
+ /**
234
+ * 更新係・自己診断・常駐の入口を、入れ替えた配布物から写し直す(★在る物だけ・原子的に置き換える)。
235
+ * 常駐の入口(conductor-run・dist-source.sh・skill_sync_notify.py)は毎時同期でも揃うが、
236
+ * 起こし直す前に揃えておく(新しい配布物の探し方で起きる)。
237
+ */
238
+ export function refreshEntries(home, distRoot) {
239
+ const bin = path.join(home, '.tyhld', 'bin');
240
+ const pairs = [
241
+ ['scripts/tyhld-autoupdate.sh', 'tyhld-autoupdate', true],
242
+ ['scripts/tyhld-selfcheck.sh', 'tyhld-selfcheck', true],
243
+ ['scripts/dist-source.sh', 'dist-source.sh', false],
244
+ ['scripts/conductor-run.sh', 'conductor-run', true],
245
+ ['scripts/hooks/skill_sync_notify.py', 'skill_sync_notify.py', false],
246
+ ];
247
+ const out = [];
248
+ for (const [from, to, exe] of pairs) {
249
+ const src = path.join(distRoot, from);
250
+ const dst = path.join(bin, to);
251
+ if (!existsSync(src) || !existsSync(dst))
252
+ continue;
253
+ copyFileSync(src, `${dst}.tmp`);
254
+ if (exe)
255
+ chmodSync(`${dst}.tmp`, 0o755);
256
+ renameSync(`${dst}.tmp`, dst);
257
+ out.push(to);
258
+ }
259
+ return out;
260
+ }
261
+ /** 常駐(conductor@*・耳・司令塔)の一覧。 */
262
+ function relayUnits(d) {
263
+ const r = d.exec('systemctl', ['--user', 'list-units', 'conductor@*.service', 'conductor-ear.service', 'conductor-build-runner.service', '--all', '--no-legend', '--plain'], { timeoutMs: 15_000 });
264
+ return r.stdout
265
+ .split('\n')
266
+ .map((l) => l.trim().split(/\s+/)[0] ?? '')
267
+ .filter((u) => u.endsWith('.service'));
268
+ }
269
+ /**
270
+ * 全現場の常駐を起こし直し、立ち上がるのを待つ。
271
+ * ★起動時の1回では起こし直さない(常駐はこの係の後に、新しい版で起きる)。
272
+ */
273
+ async function restartRelays(d, version) {
274
+ if (d.boot)
275
+ return '起動時なので起こし直しません(常駐はこの後に新しい版で起きます)';
276
+ const units = relayUnits(d);
277
+ if (units.length === 0)
278
+ return '常駐がこの機にありません';
279
+ d.exec('systemctl', ['--user', 'try-restart', '--no-block', ...units], { timeoutMs: 30_000 });
280
+ const rt = path.join(d.home, '.tyhld', 'hooks', 'runtime-source.last');
281
+ // ★待つ上限は回数で決める(時計が進まない形でも、いつまでも回らない)。
282
+ const tries = Math.max(1, Math.ceil(RESTART_WAIT_MS / RESTART_POLL_MS));
283
+ for (let i = 1;; i++) {
284
+ const states = units.map((u) => d.exec('systemctl', ['--user', 'is-active', u], { timeoutMs: 10_000 }).stdout.trim());
285
+ let rtVersion = '';
286
+ try {
287
+ rtVersion = readFileSync(rt, 'utf8').match(/\bversion=(\S+)/)?.[1] ?? '';
288
+ }
289
+ catch {
290
+ // 控えが無い機もある
291
+ }
292
+ if (states.every((s) => s === 'active') && (rtVersion === '' || rtVersion === version)) {
293
+ return `常駐 ${units.length} 本を起こし直しました(版 ${version})`;
294
+ }
295
+ if (i >= tries) {
296
+ return `常駐 ${units.length} 本のうち立ち上がらない物があります(${states.join(',')}・控えの版 ${rtVersion || '無し'})`;
297
+ }
298
+ await d.sleep(RESTART_POLL_MS);
299
+ }
300
+ }
301
+ /** 実体がネイティブ導入(公式推奨方式)の版の置き場の物か。 */
302
+ export function isNativeClaude(home, bin) {
303
+ let real = bin;
304
+ try {
305
+ real = realpathSync(bin);
306
+ }
307
+ catch {
308
+ // 実体を辿れなくても名前で見る
309
+ }
310
+ const dir = claudeVersionsDir(home) + path.sep;
311
+ return real.startsWith(dir) || bin.startsWith(dir);
312
+ }
313
+ /** Claude Code を公式推奨の導入台本で、版を名指しして入れる。 */
314
+ function installClaude(d, version) {
315
+ const bin = path.join(claudeVersionsDir(d.home), version);
316
+ if (!SEMVER.test(version))
317
+ return { ok: false, bin, detail: `版の形が違います(${version})` };
318
+ const r = d.exec('bash', ['-c', `set -o pipefail; curl -fsSL ${CLAUDE_INSTALL_URL} | bash -s "$1"`, 'claude-install', version], { env: d.env, timeoutMs: 15 * 60_000 });
319
+ const v = existsSync(bin) ? parseClaudeVersion(d.exec(bin, ['--version'], { timeoutMs: 15_000 }).stdout) : null;
320
+ if (v === version)
321
+ return { ok: true, bin, detail: `Claude Code ${version} を入れました(${bin})` };
322
+ return { ok: false, bin, detail: `導入台本の終了 ${r.status}・${bin} の版 ${v ?? '読めません'}(${(r.error ?? r.stderr).trim().slice(-200)})` };
323
+ }
324
+ /** 指名を書く(★原子的に置き換える)。 */
325
+ function writePin(d, bin) {
326
+ const p = pinPath(d.home, d.env);
327
+ mkdirSync(path.dirname(p), { recursive: true });
328
+ writeFileSync(`${p}.tmp`, `${bin}\n`);
329
+ renameSync(`${p}.tmp`, p);
330
+ d.log(`Claude Code の指名を書きました: ${bin}`);
331
+ }
332
+ /** 入れ替え(または戻し)の後の自己診断と、その結果の扱い。 */
333
+ async function afterSwap(d, s) {
334
+ const comp = s.component ?? 'conductor';
335
+ const trigger = s.rollback ? 'after-rollback' : comp === 'conductor' ? 'after-conductor' : 'after-claude';
336
+ const r = d.runSelfcheck(trigger);
337
+ if (r.pass) {
338
+ if (s.rollback)
339
+ addFailed(s, comp, s.from ?? undefined);
340
+ const detail = s.rollback
341
+ ? `${comp} を ${s.to} へ戻し、自己診断が通りました(戻したことは管制へ届けました)`
342
+ : `${comp} ${s.to} の自己診断が通りました`;
343
+ s.phase = 'idle';
344
+ delete s.rollback;
345
+ return done(d, s, s.lastRun?.action === 'held' ? 'recovered' : 'pass', detail);
346
+ }
347
+ const failed = r.failedKeys.join(',') || '不明';
348
+ if (!s.rollback) {
349
+ // ★前の版へ戻す(そのPCだけ)。
350
+ addFailed(s, comp, s.to);
351
+ const back = s.from ?? null;
352
+ if (comp === 'claude-code' && back && compareVersions(back, CLAUDE_MIN_VERSION) < 0) {
353
+ s.phase = 'held';
354
+ s.heldRecheckAt = new Date(d.now().getTime() + HELD_RECHECK_MS).toISOString();
355
+ return done(d, s, 'held', `Claude Code ${s.to} の自己診断が落ちました(${failed})。戻す先 ${back} は最低版 ${CLAUDE_MIN_VERSION} より古いので戻しません(🟡)`);
356
+ }
357
+ if (!back) {
358
+ s.phase = 'held';
359
+ s.heldRecheckAt = new Date(d.now().getTime() + HELD_RECHECK_MS).toISOString();
360
+ return done(d, s, 'held', `${comp} ${s.to} の自己診断が落ちました(${failed})。戻す先の版が分からないので戻せません(🟡)`);
361
+ }
362
+ d.log(`${comp} ${s.to} の自己診断が落ちました(${failed})。この機だけ ${back} へ戻します`);
363
+ return rollBack(d, s, back);
364
+ }
365
+ // 戻しても直らない=原因は版ではない。版はそのまま、🟡(人の手番なら自己診断が🔵で届けている)。
366
+ s.phase = 'held';
367
+ s.heldRecheckAt = new Date(d.now().getTime() + HELD_RECHECK_MS).toISOString();
368
+ // ★Claude Code・囲い・小さな試験が落ちたままなら、札を残して新しい便を取らない(便を失敗で燃やさない)。
369
+ const cannotWork = r.failedKeys.some((k) => ['claude', 'sandbox', 'headless'].includes(k));
370
+ if (cannotWork)
371
+ placeDrain(d, s);
372
+ else
373
+ removeDrain(d, s);
374
+ return done(d, s, 'held', `${comp} を ${s.to} へ戻しても自己診断が通りません(${failed})。${cannotWork ? '札を残し、この機は新しい便を受け取りません' : '便の受け取りは続けます'}(🟡)`);
375
+ }
376
+ /** 前の版へ戻す。 */
377
+ async function rollBack(d, s, back) {
378
+ const comp = s.component ?? 'conductor';
379
+ const bad = s.to;
380
+ s.rollback = true;
381
+ s.from = bad;
382
+ s.to = back;
383
+ s.swappedAt = d.now().toISOString();
384
+ saveState(d.home, s);
385
+ placeDrain(d, s);
386
+ if (comp === 'conductor') {
387
+ const r = installConductor(d, back, true);
388
+ d.log(r.detail);
389
+ refreshEntries(d.home, d.distRoot);
390
+ d.log(await restartRelays(d, back));
391
+ }
392
+ else {
393
+ // ★指名で戻す。前の実体が残っていればそれを指す(通信なしで戻せる)。無ければ公式の台本で入れ直す。
394
+ const prev = s.fromBin && existsSync(s.fromBin) ? s.fromBin : path.join(claudeVersionsDir(d.home), back);
395
+ if (existsSync(prev))
396
+ writePin(d, prev);
397
+ else {
398
+ const r = installClaude(d, back);
399
+ d.log(r.detail);
400
+ if (r.ok)
401
+ writePin(d, r.bin);
402
+ }
403
+ }
404
+ removeDrain(d, s);
405
+ return afterSwap(d, s);
406
+ }
407
+ // ─────────────────────────────────────────────────────────────────────────────
408
+ // 1回ぶん
409
+ // ─────────────────────────────────────────────────────────────────────────────
410
+ /**
411
+ * 更新係を1回流す。返り値は控え(試験で中身を見る)。
412
+ * ★鍵が無い機では何も読まず・何も書かずに終わる(null)。
413
+ */
414
+ export async function runUpdater(d) {
415
+ if (!existsSync(keyPath(d.home))) {
416
+ d.log(`自動更新の鍵がありません(${keyPath(d.home)})。この機では何もしません`);
417
+ return null;
418
+ }
419
+ const s = loadState(d.home);
420
+ const now = d.now();
421
+ // ① 入れ替え(戻し)の直後で、自己診断がまだ → 続きから。
422
+ if (s.phase === 'swapped') {
423
+ if (d.boot)
424
+ return done(d, s, 'wait', '入れ替えの後の自己診断は、常駐が起きてから次の回で行います');
425
+ if (s.component === 'conductor') {
426
+ d.log(await restartRelays(d, s.to ?? ''));
427
+ }
428
+ return afterSwap(d, s);
429
+ }
430
+ // ② 戻しても直らなかった機 → 1時間ごとに測り直し、通れば元に戻る。
431
+ if (s.phase === 'held') {
432
+ if (d.boot || (s.heldRecheckAt && now.getTime() < Date.parse(s.heldRecheckAt))) {
433
+ return done(d, s, 'held', `自己診断が通らないまま止まっています(次の測り直し ${s.heldRecheckAt ?? '未定'})`);
434
+ }
435
+ const r = d.runSelfcheck('daily');
436
+ if (r.pass) {
437
+ s.phase = 'idle';
438
+ removeDrain(d, s);
439
+ return done(d, s, 'recovered', '自己診断が通りました。自動更新を続けます');
440
+ }
441
+ s.heldRecheckAt = new Date(now.getTime() + HELD_RECHECK_MS).toISOString();
442
+ return done(d, s, 'held', `自己診断がまだ通りません(${r.failedKeys.join(',') || '不明'})`);
443
+ }
444
+ // ③ 聞きに行く時刻か(起動時は必ず聞く)。
445
+ if (!d.boot && s.nextCheckAt && now.getTime() < Date.parse(s.nextCheckAt)) {
446
+ saveState(d.home, s);
447
+ return s;
448
+ }
449
+ const installed = readInstalledVersion(d.distRoot);
450
+ const { target, detail } = await fetchTarget(d, installed);
451
+ s.nextCheckAt = new Date(now.getTime() + (target?.checkAgainMin ?? DEFAULT_CHECK_MIN) * 60_000).toISOString();
452
+ if (!target) {
453
+ removeDrain(d, s);
454
+ s.busySince = null;
455
+ return done(d, s, 'skip', `管制に目標の版を聞けませんでした(${detail})。更新しません`);
456
+ }
457
+ if (!target.ready || !target.autoUpdate || target.halted) {
458
+ removeDrain(d, s);
459
+ s.busySince = null;
460
+ const why = !target.ready ? '管制はまだ準備中です' : target.halted ? '管制で自動更新が止められています' : 'この機は自動更新の対象ではありません';
461
+ return done(d, s, 'skip', `${why}(${target.reason ?? '理由なし'})。何もしません`);
462
+ }
463
+ // ④ どの部品を動かすか(★conductor の目標が今と違うあいだは Claude Code を動かさない)。
464
+ const claudeNow = d.resolveClaude();
465
+ let comp = null;
466
+ let want = null;
467
+ if (target.conductor && target.conductor !== installed) {
468
+ comp = 'conductor';
469
+ want = target.conductor;
470
+ }
471
+ else if (target.claudeCode && target.claudeCode !== claudeNow?.version) {
472
+ comp = 'claude-code';
473
+ want = target.claudeCode;
474
+ }
475
+ if (comp && want && s.failedVersions?.[comp]?.includes(want)) {
476
+ d.log(`${comp} ${want} はこの機で自己診断が通らなかった版なので入れません`);
477
+ comp = null;
478
+ }
479
+ if (comp === 'claude-code' && want) {
480
+ if (compareVersions(want, CLAUDE_MIN_VERSION) < 0) {
481
+ d.log(`Claude Code ${want} は最低版 ${CLAUDE_MIN_VERSION} より古いので入れません`);
482
+ comp = null;
483
+ }
484
+ else if (!claudeNow || !isNativeClaude(d.home, claudeNow.bin)) {
485
+ d.log(`Claude Code が公式推奨の導入(${claudeVersionsDir(d.home)})ではありません(${claudeNow?.bin ?? '見つかりません'})。` +
486
+ '入れ替えません(人の手番: APPLY=1 bash "$(npm root -g)/@tyhld/conductor/scripts/autoupdate-install.sh" の案内どおりにそろえる)');
487
+ comp = null;
488
+ }
489
+ }
490
+ if (!comp || !want) {
491
+ removeDrain(d, s);
492
+ s.busySince = null;
493
+ await reportCandidates(d, s, installed, claudeNow?.version ?? null);
494
+ return done(d, s, 'none', `目標の版と同じです(conductor ${installed ?? '不明'}・Claude Code ${claudeNow?.version ?? '不明'})`);
495
+ }
496
+ // ⑤ 全現場が空きか。
497
+ const busy = busySites(d.home, d.isAlive);
498
+ if (busy.length > 0) {
499
+ s.busySince ??= now.toISOString();
500
+ const waited = now.getTime() - Date.parse(s.busySince);
501
+ if (waited >= BUSY_LIMIT_MS) {
502
+ placeDrain(d, s);
503
+ return done(d, s, 'draining', `${comp} ${want} へ入れ替えたいが、6時間空きが来ません。札を置いて、走っている便(${busy.join(',')})の終わりを待ちます`);
504
+ }
505
+ return done(d, s, 'busy', `作業中の便があるので入れ替えません(${busy.join(',')})。次の回に再挑戦します`);
506
+ }
507
+ placeDrain(d, s);
508
+ saveState(d.home, s);
509
+ if (!d.boot) {
510
+ // ★札を置いた瞬間に中継が便を取りかけていることがある。1周ぶん待って確かめ直す。
511
+ await d.sleep(d.relayCycleMs);
512
+ const again = busySites(d.home, d.isAlive);
513
+ if (again.length > 0) {
514
+ s.busySince ??= now.toISOString();
515
+ if (now.getTime() - Date.parse(s.busySince) < BUSY_LIMIT_MS)
516
+ removeDrain(d, s);
517
+ return done(d, s, 'busy', `札を置いた直後に便が始まりました(${again.join(',')})。次の回に再挑戦します`);
518
+ }
519
+ }
520
+ // ⑥ 入れ替え。★入れる前に控えを書く(途中で切れても、次の回に自己診断から続ける)。
521
+ s.phase = 'swapped';
522
+ s.component = comp;
523
+ s.from = comp === 'conductor' ? installed : claudeNow?.version ?? null;
524
+ s.fromBin = comp === 'claude-code' ? claudeNow?.bin ?? null : null;
525
+ s.to = want;
526
+ s.rollback = false;
527
+ s.swappedAt = now.toISOString();
528
+ s.busySince = null;
529
+ saveState(d.home, s);
530
+ if (comp === 'conductor') {
531
+ const r = installConductor(d, want, false);
532
+ d.log(r.detail);
533
+ if (!r.ok && readInstalledVersion(d.distRoot) === installed) {
534
+ // 入れられなかった(回線など)=何も変わっていない。版の不具合とは言わない。次の回に再挑戦。
535
+ s.phase = 'idle';
536
+ removeDrain(d, s);
537
+ return done(d, s, 'install-failed', r.detail);
538
+ }
539
+ refreshEntries(d.home, d.distRoot);
540
+ d.log(await restartRelays(d, want));
541
+ }
542
+ else {
543
+ const r = installClaude(d, want);
544
+ d.log(r.detail);
545
+ if (!r.ok) {
546
+ s.phase = 'idle';
547
+ removeDrain(d, s);
548
+ return done(d, s, 'install-failed', r.detail);
549
+ }
550
+ writePin(d, r.bin);
551
+ }
552
+ removeDrain(d, s);
553
+ if (d.boot)
554
+ return done(d, s, 'swapped', `${comp} を ${want} にしました。自己診断は常駐が起きてから次の回で行います`);
555
+ return afterSwap(d, s);
556
+ }
557
+ /**
558
+ * 候補の版を見つけたら、自己診断に載せて管制へ届ける(★先行機かどうかは管制が決める)。
559
+ * 同じ候補は1回だけ(届かなかったときは1日あけて届け直す)。
560
+ */
561
+ async function reportCandidates(d, s, conductorNow, claudeNow) {
562
+ let latest = null;
563
+ if (d.npm) {
564
+ const env = { ...d.env, PATH: `${path.dirname(d.npm)}:${d.env.PATH ?? ''}` };
565
+ const r = d.exec(d.npm, ['view', CONDUCTOR_PKG, 'version', CONDUCTOR_REGISTRY_ARG], { env, timeoutMs: 60_000 });
566
+ const v = r.stdout.trim();
567
+ if (r.status === 0 && SEMVER.test(v))
568
+ latest = v;
569
+ }
570
+ const found = pickCandidates({ conductor: conductorNow, claude: claudeNow }, { conductor: latest, claude: await fetchClaudeStable(d.fetchImpl) });
571
+ const now = d.now().getTime();
572
+ const fresh = found.filter((c) => {
573
+ const prev = s.reported?.[`${c.component}@${c.version}`];
574
+ return !prev || (!prev.sent && now - Date.parse(prev.at) >= CANDIDATE_RETRY_MS);
575
+ });
576
+ if (fresh.length === 0)
577
+ return;
578
+ d.log(`候補の版を見つけました(${fresh.map((c) => `${c.component} ${c.version}`).join('・')})。自己診断に載せて管制へ届けます`);
579
+ const r = d.runSelfcheck('candidate');
580
+ for (const c of fresh)
581
+ (s.reported ??= {})[`${c.component}@${c.version}`] = { at: d.now().toISOString(), sent: r.sent === true };
582
+ }
583
+ // ─────────────────────────────────────────────────────────────────────────────
584
+ // 入口
585
+ // ─────────────────────────────────────────────────────────────────────────────
586
+ /** 自己診断を、入れ替えた後の配布物の入口(~/.tyhld/bin/tyhld-selfcheck)を通して流す。 */
587
+ export function selfcheckViaEntry(home, exec, env) {
588
+ return (trigger) => {
589
+ const entry = env.CONDUCTOR_SELFCHECK_ENTRY || path.join(home, '.tyhld', 'bin', 'tyhld-selfcheck');
590
+ const r = exec('/bin/bash', [entry, '--trigger', trigger, '--json'], { env, timeoutMs: 20 * 60_000 });
591
+ let parsed = {};
592
+ try {
593
+ const i = r.stdout.indexOf('{');
594
+ parsed = JSON.parse(i >= 0 ? r.stdout.slice(i) : '{}');
595
+ }
596
+ catch {
597
+ // 読めない=FAIL として扱う(迷ったら戻す側)
598
+ }
599
+ const failedKeys = (parsed.items ?? []).filter((x) => x.ok === false).map((x) => String(x.key));
600
+ const pass = r.status === 0 && parsed.result === 'PASS';
601
+ if (!pass && failedKeys.length === 0)
602
+ failedKeys.push(`selfcheck(終了 ${r.status})`);
603
+ return { pass, failedKeys, sent: typeof parsed.sent?.sent === 'boolean' ? parsed.sent.sent : null };
604
+ };
605
+ }
606
+ /** `conductor autoupdate [--boot]` の入口。返り値は終了コード(★失敗でも 0=タイマーを止めない。使い方の誤りだけ 2)。 */
607
+ export async function runAutoupdateCommand(argv) {
608
+ let boot = false;
609
+ for (const a of argv) {
610
+ if (a === '--boot')
611
+ boot = true;
612
+ else {
613
+ console.error(`[autoupdate] 知らない引数: ${a}`);
614
+ return 2;
615
+ }
616
+ }
617
+ const home = os.homedir();
618
+ const interval = Number(process.env.CONDUCTOR_RELAY_INTERVAL_SEC ?? '15');
619
+ const d = {
620
+ home,
621
+ env: process.env,
622
+ machine: process.env.CONDUCTOR_MACHINE || os.hostname(),
623
+ now: () => new Date(),
624
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
625
+ fetchImpl: globalThis.fetch,
626
+ exec: defaultExec,
627
+ distRoot: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'),
628
+ npm: npmBesideNode(),
629
+ resolveClaude: () => resolveClaudeBin(),
630
+ runSelfcheck: selfcheckViaEntry(home, defaultExec, process.env),
631
+ isAlive: isProcessAlive,
632
+ log: (m) => console.log(`[autoupdate] ${m}`),
633
+ boot,
634
+ relayCycleMs: ((Number.isFinite(interval) && interval > 0 ? interval : 15) + 10) * 1000,
635
+ };
636
+ try {
637
+ await runUpdater(d);
638
+ }
639
+ catch (err) {
640
+ console.error(`[autoupdate] 思いがけない失敗: ${err instanceof Error ? err.message : String(err)}`);
641
+ }
642
+ return 0;
643
+ }