@tyhld/conductor 0.12.85 → 0.12.87

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/cli.js CHANGED
@@ -26,6 +26,7 @@ import { ensureEnvFilePermissions } from './env-file-perm.js';
26
26
  import { createCentralPorts } from './build-central.js';
27
27
  import { drainReport, readDrain } from './autoupdate-flags.js';
28
28
  import { runSelfcheckCommand } from './selfcheck.js';
29
+ import { runAutoupdateCommand } from './autoupdate.js';
29
30
  import { BUILD_NUDGE_NAME, REQUIRED_ENV, createBuildRunner, createMemoStore, loadBuildRunnerConfig, } from './build-runner.js';
30
31
  /**
31
32
  * relay の状態の参照(生存報告に「いま配れるか」を載せるため)。
@@ -499,8 +500,9 @@ function printUsage() {
499
500
  ' conductor build-runner この機に1本の「司令塔」(試作の段取り役)',
500
501
  ' conductor setup <現場名> [フォルダ] [--profile <default|proto>] 采配くんをこのPCに設置する(初回の1回だけ)',
501
502
  ' conductor view <現場名> [--once] [--detail] その現場の今を見るだけの画面(kc の実体)',
502
- ' conductor selfcheck [--trigger <daily|after-conductor|after-claude|after-rollback|manual>] [--json] [--no-send]',
503
+ ' conductor selfcheck [--trigger <daily|after-conductor|after-claude|after-rollback|manual|candidate>] [--json] [--no-send]',
503
504
  ' この機の自己診断(副作用なし・結果は手元の控えと管制へ)',
505
+ ' conductor autoupdate [--boot] 自社PCの更新係(鍵が無い機では何もしない・systemd から呼ぶ)',
504
506
  '',
505
507
  '見るだけの画面(conductor view = kc 現場名):',
506
508
  ' いま職人が何をしているかを、やさしい日本語で見るだけの画面です。',
@@ -590,6 +592,10 @@ async function main() {
590
592
  // ★自己診断(便 b7bd5e43・A1)。副作用なし(commit・push・PR・合流・配備・公開をしない)。
591
593
  process.exit(await runSelfcheckCommand(process.argv.slice(3)));
592
594
  break;
595
+ case 'autoupdate':
596
+ // ★更新係(便 29babc5d・A3)。鍵(~/.tyhld/bin/autoupdate/enabled)が無い機では何もしない。
597
+ process.exit(await runAutoupdateCommand(process.argv.slice(3)));
598
+ break;
593
599
  case 'view':
594
600
  // ★見るだけの画面。職人も tmux も relay も起動せず、入力も受け付けない。
595
601
  // 既定は「開いたまま流れ続ける」ので、Ctrl+C まで戻らない(--once は1回で戻る)。
package/dist/headless.js CHANGED
@@ -19,7 +19,7 @@ import { createInterface } from 'node:readline';
19
19
  import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
20
20
  import path from 'node:path';
21
21
  import os from 'node:os';
22
- import { CENTRAL_BUSY_HOLD_MAX_MS, RELAY_DELIVER_MAX, RELAY_FETCH_TIMEOUT_MS, RELAY_HEARTBEAT_INTERVAL_MS, RELAY_RESTORE_ORPHANS_ENABLED, buildAnchor, buildSendText, canDeliver, completeWithReport, createRelayState, deliveryReport, fetchCommand, fetchCommandStatus, fetchControl, fetchOrphans, isTerminalCommandStatus, kindOf, markDelivered, noteCentralDown, noteCentralUp, noteDrain, noteReadiness, patchStatus, recoverStuckProcessedReports, reconnectDelayMs, selectOrphanToRestore, sendHeartbeat, } from './relay.js';
22
+ import { CENTRAL_BUSY_HOLD_MAX_MS, RELAY_DELIVER_MAX, RELAY_FETCH_TIMEOUT_MS, RELAY_HEARTBEAT_INTERVAL_MS, RELAY_RESTORE_ORPHANS_ENABLED, buildAnchor, buildSendText, canDeliver, completeWithReport, createRelayState, deliverJobEndFacts, deliveryReport, fetchCommand, fetchCommandStatus, fetchControl, fetchOrphans, flushPendingJobEndFacts, isTerminalCommandStatus, kindOf, markDelivered, noteCentralDown, noteCentralUp, noteDrain, noteReadiness, patchStatus, recoverStuckProcessedReports, reconnectDelayMs, reportPhase, selectOrphanToRestore, sendHeartbeat, } from './relay.js';
23
23
  import { claudePinPath, readClaudePinLine, } from './autoupdate-flags.js';
24
24
  const log = (msg) => {
25
25
  console.log(`[headless] ${msg}`);
@@ -528,6 +528,35 @@ export function maskSecrets(text) {
528
528
  }
529
529
  return masked;
530
530
  }
531
+ /**
532
+ * 便の終わり方を決める(★純粋関数=単体テストできる・便 915694ac)。
533
+ *
534
+ * ★本文を渡せなかったことが先。職人が何も受け取っていないという事実は、
535
+ * ほかのどの終わり方よりも強い(やり直しても同じ結果になる)。
536
+ */
537
+ export function jobEndKindOf(j) {
538
+ if (j.bodyHandoff === 'failed')
539
+ return 'not_started';
540
+ if (j.timedOut)
541
+ return 'timeout';
542
+ if (j.stopped)
543
+ return 'stopped';
544
+ if (j.authError)
545
+ return 'auth_error';
546
+ return j.exitCode === 0 ? 'completed' : 'error';
547
+ }
548
+ /**
549
+ * 本文を渡せなかった便として、走っている処理を止めるか(★純粋関数・便 915694ac)。
550
+ *
551
+ * ★画面なし方式は中継が自分で処理を立ち上げて本文を渡す。
552
+ * ・'done' … 渡せた → 何もしない(★本文未着と申告しない・追跡を続ける)
553
+ * ・'pending' … まだ確かめ中 → 何もしない(迷ったら触らない)
554
+ * ・'failed' … 渡せなかった → 止めて理由つきで管制へ届ける
555
+ * 既に止めた便・上限時間で止めた便は、その終わり方の後始末に任せる。
556
+ */
557
+ export function shouldAbortForBodyHandoff(j) {
558
+ return j.bodyHandoff === 'failed' && !j.stopped && !j.timedOut;
559
+ }
531
560
  /**
532
561
  * いま画面なし方式が追っている便の指示ID(★回収の道から除く物・便 6ebba0d0 の b)。
533
562
  *
@@ -1003,6 +1032,8 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
1003
1032
  stderrLog: logs?.stderrLog ?? null,
1004
1033
  eventsLog: logs?.eventsLog ?? null,
1005
1034
  durationMs: 0,
1035
+ finishedAt: null,
1036
+ bodyHandoff: 'pending',
1006
1037
  };
1007
1038
  // spawn の 'error' イベント(ENOENT 等は非同期で届く。同期例外と両方捕まえる)
1008
1039
  proc.on('error', (err) => {
@@ -1010,22 +1041,46 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
1010
1041
  job.completed = true;
1011
1042
  job.exitCode = -1;
1012
1043
  job.exitSignal = null;
1044
+ job.finishedAt = Date.now();
1013
1045
  job.stderrBuf += `\nspawn error: ${err.message}`;
1014
1046
  }
1015
1047
  });
1016
1048
  // stdin: 指示本文を書いて閉じる
1049
+ //
1050
+ // ★「渡せたか」を事実として残す(えふさん確定 2026-10-02・便 915694ac)。
1051
+ // これまでは stdin のエラーをまるごと握り潰していたので、本文を1バイトも渡せていなくても
1052
+ // 中継は管制へ「配達済み(sent)」と書いていた=職人が受け取っていない便を実行中にしていた。
1053
+ // 逆に、渡せた便を「本文未着」と申告してしまう道も作らない(下の headlessTick を参照)。
1054
+ const noteHandoffFailed = (why) => {
1055
+ if (job.bodyHandoff === 'done')
1056
+ return; // 既に書けている(閉じたあとの後始末のエラーは数えない)
1057
+ job.bodyHandoff = 'failed';
1058
+ job.bodyHandoffError ??= why;
1059
+ };
1017
1060
  if (proc.stdin) {
1018
- // stdin の write エラーは spawn 失敗時に起きうる(握り潰す)
1019
- proc.stdin.on('error', () => { });
1020
- if (isResume) {
1021
- proc.stdin.write(RESUME_PROMPT);
1061
+ // ★エラーは握り潰さず、理由として残す(投げると中継の輪が止まるので、throw はしない)。
1062
+ proc.stdin.on('error', (err) => {
1063
+ noteHandoffFailed(`職人の処理へ本文を渡せませんでした(標準入力: ${err.message})`);
1064
+ });
1065
+ const text = isResume
1066
+ ? RESUME_PROMPT
1067
+ : buildSendText(cmd.body, cfg.site, buildAnchor(cmd.id), kindOf(cfg));
1068
+ try {
1069
+ // ★書き出しが本当に流れ切ってから 'done' にする(返事を待たずに「渡せた」と言わない)。
1070
+ proc.stdin.write(text, (err) => {
1071
+ if (err)
1072
+ noteHandoffFailed(`職人の処理へ本文を渡せませんでした(書き出し: ${err.message})`);
1073
+ else if (job.bodyHandoff === 'pending')
1074
+ job.bodyHandoff = 'done';
1075
+ });
1076
+ proc.stdin.end();
1022
1077
  }
1023
- else {
1024
- const anchor = buildAnchor(cmd.id);
1025
- const text = buildSendText(cmd.body, cfg.site, anchor, kindOf(cfg));
1026
- proc.stdin.write(text);
1078
+ catch (err) {
1079
+ noteHandoffFailed(`職人の処理へ本文を渡せませんでした(${err instanceof Error ? err.message : String(err)})`);
1027
1080
  }
1028
- proc.stdin.end();
1081
+ }
1082
+ else {
1083
+ noteHandoffFailed('職人の処理の入口(標準入力)が開かなかったので、本文を渡せませんでした');
1029
1084
  }
1030
1085
  // stdout: stream-json を行ごとに読み、CommandEvent に変換してバッファへ。ログファイルにも書く。
1031
1086
  if (proc.stdout) {
@@ -1071,6 +1126,7 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
1071
1126
  job.completed = true;
1072
1127
  job.exitCode = code;
1073
1128
  job.exitSignal = signal?.toString() ?? null;
1129
+ job.finishedAt = Date.now();
1074
1130
  job.durationMs = Date.now() - job.startedAt;
1075
1131
  closeLog(job.stdoutLog);
1076
1132
  closeLog(job.stderrLog);
@@ -1174,6 +1230,42 @@ async function checkRunningJob(cfg, state) {
1174
1230
  ...(detail !== undefined ? { detail } : {}),
1175
1231
  });
1176
1232
  };
1233
+ // ★終了の事実を管制へ必ず届ける(えふさん確定 2026-10-02・便 915694ac)。
1234
+ //
1235
+ // 【えふさん確定】便ID付き報告が到着 + Claude Code プロセス終了 + 中継が終了結果を取得
1236
+ // まで揃ってはじめて「職人の作業終了」。だから中継は【終わり方に関係なく】終わった事実を届ける。
1237
+ // 【ここ(分かれ道の手前)で1回だけ送る理由】終わり方ごとの枝の中で送ると、
1238
+ // ・終了状態を書けずに次の周へ持ち越す枝(下の done が書けないとき)では毎周送ってしまう
1239
+ // ・追跡をやめる枝(放棄・試行上限)では送り忘れる
1240
+ // の両方が起きる。★「プロセスが終わった」ことは枝に関係なく1つの事実なので、ここで1回送る。
1241
+ // 【送る物】中継が実際に取れた物だけ(終了コードが無ければ null=つかめていないと伝える)。
1242
+ // ★作り話の終了を送らない。「報告を出せたか」は報告ファイルの有無だけで見る
1243
+ // (★中身の言葉は1文字も読まない=2026-09-23 の確定のまま)。
1244
+ // 【届かなければ】控えが手元に残り、次の周の flushPendingJobEndFacts が届くまでやり直す。
1245
+ if (!job.endFactsTold) {
1246
+ job.endFactsTold = true;
1247
+ const endKind = jobEndKindOf({
1248
+ bodyHandoff: job.bodyHandoff,
1249
+ timedOut: job.timedOut,
1250
+ stopped: job.stopped,
1251
+ authError: isAuthError(job),
1252
+ exitCode: job.exitCode,
1253
+ });
1254
+ await deliverJobEndFacts(cfg, {
1255
+ commandId: job.commandId,
1256
+ site: cfg.site,
1257
+ finishedAt: new Date(job.finishedAt ?? Date.now()).toISOString(),
1258
+ endKind,
1259
+ exitCode: job.exitCode,
1260
+ exitSignal: job.exitSignal,
1261
+ reportDelivered: reportPhase(job.commandId) !== 'none',
1262
+ // ★画面なし方式は中継が自分で立ち上げた処理なので、終わりを直接つかんでいる。
1263
+ // つかめていないときは false を送る(1-3 の「つかめていない」と同じ形)。
1264
+ processEndObserved: job.finishedAt !== null,
1265
+ startedAt: new Date(job.startedAt).toISOString(),
1266
+ detail: job.bodyHandoffError ?? `attempt ${job.attempt}/${HEADLESS_MAX_ATTEMPTS}`,
1267
+ }, { logLine: log });
1268
+ }
1177
1269
  if (job.timedOut) {
1178
1270
  // 上限時間で強制停止した → 管制へ理由つきで failed を記録する
1179
1271
  const elapsed = Math.round((Date.now() - job.startedAt) / 1000);
@@ -1302,6 +1394,28 @@ async function checkRunningJob(cfg, state) {
1302
1394
  }
1303
1395
  // ── 実行中のプロセスの監視 ──
1304
1396
  const now = Date.now();
1397
+ // ★本文を渡せなかったことが後から分かったとき(便 915694ac)。
1398
+ // 画面なし方式は中継が自分で処理を立ち上げて本文を渡す。渡せていなければ職人は何も
1399
+ // 受け取っていないので、動いているふりをさせず、理由つきで管制へ届けて止める。
1400
+ // ★渡せた便('done')・まだ確かめ中の便('pending')には何もしない=追跡を続ける。
1401
+ if (shouldAbortForBodyHandoff(job)) {
1402
+ const why = job.bodyHandoffError ?? '職人の処理へ本文を渡せませんでした';
1403
+ log(`ERROR site=${cfg.site} id=${job.commandId} ${why} → 止めて管制へ知らせます`);
1404
+ await patchStatus(cfg, job.commandId, 'failed', why.slice(0, EVENT_TEXT_MAX));
1405
+ await sendCommandEvents(cfg, job.commandId, [{
1406
+ type: 'error',
1407
+ text: why.slice(0, EVENT_TEXT_MAX),
1408
+ at: new Date().toISOString(),
1409
+ attempt: job.attempt,
1410
+ }]);
1411
+ rs.abandoned.add(job.commandId);
1412
+ // ★stopped を立ててから止める=終了の後始末で「異常終了 → 自動再開」の道へ入らない
1413
+ // (本文が渡っていないので、同じことをやり直しても同じ結果になる。二重に走らせない)。
1414
+ // 終わり方は 'not_started' として届く(上の endKind は本文の未渡しを先に見る)。
1415
+ job.stopped = true;
1416
+ await stopJob(job);
1417
+ return;
1418
+ }
1305
1419
  // stop_requested の確認(間隔を置いて中央へ問い合わせる)
1306
1420
  if (now - job.lastStopCheckAt >= HEADLESS_STOP_CHECK_MS) {
1307
1421
  job.lastStopCheckAt = now;
@@ -1603,6 +1717,10 @@ export async function headlessTick(cfg, state, hooks) {
1603
1717
  log('停止が解除されました。relayを再開します');
1604
1718
  }
1605
1719
  rs.lastHalted = false;
1720
+ // ── 届いていない「終了の事実」の送り直し(★便 915694ac・黙って捨てない) ──
1721
+ // ★停止トグルの確認より後に置く=止められている現場では外へ出ない。
1722
+ // ★追跡をやめた便・「本文未着」と申告された便の分も、ここで届くまでやり直す。
1723
+ await flushPendingJobEndFacts(cfg, { logLine: log });
1606
1724
  // ── 取り残しの後始末(★便 6ebba0d0 の項目4) ──
1607
1725
  // 「実プロセスが無い・報告は渡し済み・管制ではまだ終わっていない」便に done を書く。
1608
1726
  // ★自分の PC・自分の現場の便だけ(判定は recoverStuckProcessedReports 側)。
@@ -1695,6 +1813,35 @@ export async function headlessTick(cfg, state, hooks) {
1695
1813
  log(`ERROR site=${cfg.site} id=${cmd.id} claude が見つからない → 放棄`);
1696
1814
  return;
1697
1815
  }
1816
+ // ★本文を渡せなかったことが起動のその場で分かったとき(入口が開かない・書き出しが例外)。
1817
+ // 「配達済み(sent)」を1バイトも書かずに、理由つきで管制へ届けて放棄する(便 915694ac)。
1818
+ // ★渡せた便・まだ確かめ中の便は、今までどおりそのまま進む(追跡を続ける)。
1819
+ if (job.bodyHandoff === 'failed') {
1820
+ const why = job.bodyHandoffError ?? '職人の処理へ本文を渡せませんでした';
1821
+ await stopJob(job);
1822
+ await patchStatus(cfg, cmd.id, 'failed', why.slice(0, EVENT_TEXT_MAX));
1823
+ await sendCommandEvents(cfg, cmd.id, [{
1824
+ type: 'error',
1825
+ text: why.slice(0, EVENT_TEXT_MAX),
1826
+ at: new Date().toISOString(),
1827
+ attempt,
1828
+ }]);
1829
+ await deliverJobEndFacts(cfg, {
1830
+ commandId: cmd.id,
1831
+ site: cfg.site,
1832
+ finishedAt: new Date().toISOString(),
1833
+ endKind: 'not_started',
1834
+ exitCode: job.exitCode,
1835
+ exitSignal: job.exitSignal,
1836
+ reportDelivered: false,
1837
+ processEndObserved: job.finishedAt !== null,
1838
+ startedAt: new Date(job.startedAt).toISOString(),
1839
+ detail: why,
1840
+ }, { logLine: log });
1841
+ rs.abandoned.add(cmd.id);
1842
+ log(`ERROR site=${cfg.site} id=${cmd.id} ${why} → 放棄(配達済みとは書きません)`);
1843
+ return;
1844
+ }
1698
1845
  state.job = job;
1699
1846
  saveActiveJob(cfg.site, { commandId: cmd.id, body: cmd.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined, pid: job.process.pid, postedAt: cmd.sentAt });
1700
1847
  markDelivered(rs, cmd.id);
package/dist/relay.js CHANGED
@@ -2957,6 +2957,154 @@ export async function sendHeartbeat(cfg, id, busy = false, liveness) {
2957
2957
  log(`site=${cfg.site} id=${id} heartbeat -> 失敗: ${err instanceof Error ? err.message : String(err)}(縮退で安全)`);
2958
2958
  }
2959
2959
  }
2960
+ /** 届けられなかった「終了の事実」の置き場。 */
2961
+ export function jobEndDir(env = process.env) {
2962
+ const base = (env.CONDUCTOR_STATE_DIR ?? '').trim();
2963
+ return base
2964
+ ? path.join(base, 'job-end')
2965
+ : path.join(env.HOME ?? os.homedir(), '.local', 'state', 'conductor', 'job-end');
2966
+ }
2967
+ /** 1便ぶんの控えのファイル名(★現場と指示IDの両方を入れる=別の現場の便と混ざらない)。 */
2968
+ export function jobEndFactsPath(site, commandId, env = process.env) {
2969
+ const safe = (s) => s.replace(/[^A-Za-z0-9._-]/g, '_');
2970
+ return path.join(jobEndDir(env), `${safe(site)}__${safe(commandId)}.json`);
2971
+ }
2972
+ /** 人向けの一言の上限(管制の列を汚さない)。 */
2973
+ const JOB_END_DETAIL_MAX = 500;
2974
+ /**
2975
+ * 管制へ送る本文(★純粋関数=単体テストできる)。
2976
+ *
2977
+ * ★`state` も `busy` も入れない(上の約束④)。
2978
+ * ★項目名は管制の側の便(ai-kana)が読む物なので、勝手に変えない。
2979
+ */
2980
+ export function jobEndFactsBody(f) {
2981
+ const body = {
2982
+ // ★この印が在る生存報告は「終わりの知らせ」。管制が知らないうちは黙って捨てられる。
2983
+ jobEnded: true,
2984
+ finishedAt: f.finishedAt,
2985
+ endKind: f.endKind,
2986
+ exitCode: f.exitCode,
2987
+ exitSignal: f.exitSignal,
2988
+ reportDelivered: f.reportDelivered,
2989
+ processEndObserved: f.processEndObserved,
2990
+ };
2991
+ // ★立ち上がった時刻は管制が既に読める項目(startedAt)なので、分かるときだけ添える。
2992
+ if (f.startedAt !== undefined)
2993
+ body.startedAt = f.startedAt;
2994
+ if (f.detail !== undefined && f.detail !== '')
2995
+ body.jobEndDetail = f.detail.slice(0, JOB_END_DETAIL_MAX);
2996
+ return body;
2997
+ }
2998
+ /** 控えを手元に書く(★届くまで消さない)。書けたか返す。 */
2999
+ export function saveJobEndFacts(f, env = process.env) {
3000
+ try {
3001
+ mkdirSync(jobEndDir(env), { recursive: true });
3002
+ writeFileSync(jobEndFactsPath(f.site, f.commandId, env), JSON.stringify(f) + '\n');
3003
+ return true;
3004
+ }
3005
+ catch {
3006
+ return false;
3007
+ }
3008
+ }
3009
+ /** 控えを消す(★届いたときだけ呼ぶ)。 */
3010
+ export function clearJobEndFacts(site, commandId, env = process.env) {
3011
+ try {
3012
+ const p = jobEndFactsPath(site, commandId, env);
3013
+ if (existsSync(p))
3014
+ unlinkSync(p);
3015
+ }
3016
+ catch {
3017
+ // 消せなくても次の周でもう一度送るだけ(管制の側は冪等)。
3018
+ }
3019
+ }
3020
+ /** その現場の、まだ届いていない「終了の事実」を読む(壊れた控えは飛ばす)。 */
3021
+ export function pendingJobEndFacts(site, env = process.env) {
3022
+ const dir = jobEndDir(env);
3023
+ let names;
3024
+ try {
3025
+ names = readdirSync(dir).filter((n) => n.endsWith('.json'));
3026
+ }
3027
+ catch {
3028
+ return [];
3029
+ }
3030
+ const out = [];
3031
+ for (const n of names.sort()) {
3032
+ try {
3033
+ const raw = readFileSync(path.join(dir, n), 'utf8').trim();
3034
+ if (!raw)
3035
+ continue;
3036
+ const f = JSON.parse(raw);
3037
+ // ★自分の現場の便だけ送る(1台で複数の現場の中継が動くため)。
3038
+ if (!f || f.site !== site || !f.commandId || !f.finishedAt)
3039
+ continue;
3040
+ out.push(f);
3041
+ }
3042
+ catch {
3043
+ // 読めない控えは飛ばす(消さない=人が見つけられるように残す)。
3044
+ }
3045
+ }
3046
+ return out;
3047
+ }
3048
+ /**
3049
+ * 「終了の事実」を管制へ1回送る。届いたら true。
3050
+ * ★失敗は投げない(中継の輪を止めない)。false を返して、呼び出し側が控えを残す。
3051
+ */
3052
+ export async function sendJobEndFacts(cfg, f, opts = {}) {
3053
+ const doFetch = opts.fetchImpl ?? fetch;
3054
+ const logLine = opts.logLine ?? (() => { });
3055
+ try {
3056
+ const res = await doFetch(`${cfg.url}/api/conductor/commands/${encodeURIComponent(f.commandId)}/seen`, {
3057
+ method: 'PATCH',
3058
+ headers: { Authorization: `Bearer ${cfg.token}`, 'Content-Type': 'application/json' },
3059
+ body: JSON.stringify(jobEndFactsBody(f)),
3060
+ signal: AbortSignal.timeout(RELAY_FETCH_TIMEOUT_MS),
3061
+ });
3062
+ if (res.ok)
3063
+ return true;
3064
+ logLine(`site=${cfg.site} id=${f.commandId} 終了の事実 -> HTTP ${res.status}(控えを残して次の周でやり直します)`);
3065
+ return false;
3066
+ }
3067
+ catch (err) {
3068
+ logLine(`site=${cfg.site} id=${f.commandId} 終了の事実 -> 失敗: ` +
3069
+ `${err instanceof Error ? err.message : String(err)}(控えを残して次の周でやり直します)`);
3070
+ return false;
3071
+ }
3072
+ }
3073
+ /**
3074
+ * 「終了の事実」を控えに書いてから送る(★順番が肝)。
3075
+ * 先に控えを書くので、送信中に中継が落ちても事実は失われない。送れたら控えを消す。
3076
+ * 送れなければ残して、次の周の flushPendingJobEndFacts がやり直す。
3077
+ */
3078
+ export async function deliverJobEndFacts(cfg, f, opts = {}) {
3079
+ const env = opts.env ?? process.env;
3080
+ saveJobEndFacts(f, env);
3081
+ const ok = await sendJobEndFacts(cfg, f, opts);
3082
+ if (ok) {
3083
+ clearJobEndFacts(f.site, f.commandId, env);
3084
+ opts.logLine?.(`site=${cfg.site} id=${f.commandId} 終了の事実を管制へ届けました` +
3085
+ `(終わり方=${f.endKind} 終了コード=${f.exitCode ?? '不明'} 報告=${f.reportDelivered === null ? '不明' : f.reportDelivered ? 'あり' : 'なし'})`);
3086
+ }
3087
+ return ok;
3088
+ }
3089
+ /** 届いていない「終了の事実」をまとめて送り直す(★1周に1回・届くまで何周でも)。 */
3090
+ export async function flushPendingJobEndFacts(cfg, opts = {}) {
3091
+ const env = opts.env ?? process.env;
3092
+ let sent = 0;
3093
+ let kept = 0;
3094
+ for (const f of pendingJobEndFacts(cfg.site, env)) {
3095
+ if (await sendJobEndFacts(cfg, f, opts)) {
3096
+ clearJobEndFacts(f.site, f.commandId, env);
3097
+ sent++;
3098
+ }
3099
+ else {
3100
+ kept++;
3101
+ }
3102
+ }
3103
+ if (sent > 0 || kept > 0) {
3104
+ opts.logLine?.(`site=${cfg.site} 終了の事実の送り直し: 届いた ${sent} 件・残り ${kept} 件`);
3105
+ }
3106
+ return { sent, kept };
3107
+ }
2960
3108
  /**
2961
3109
  * いま「作業中です」と手を挙げる(busy:true を載せる)かを決める純粋関数。
2962
3110
  *
@@ -3304,6 +3452,9 @@ export async function relayTick(cfg, state, hooks) {
3304
3452
  // pending 分岐も新規指示の取得も従来どおり動く(pending を落とす目的を殺さない)。
3305
3453
  // 停止中(relayHalted)でも続けるのは、既に送った指示の後始末は進めるという既存方針と同じ。
3306
3454
  await keepaliveTick(cfg, state);
3455
+ // ── 届いていない「終了の事実」の送り直し(★便 915694ac・黙って捨てない) ──
3456
+ // 既に送った便の後始末なので、停止中(relayHalted)でも続ける(keepaliveTick と同じ方針)。
3457
+ await flushPendingJobEndFacts(cfg, { logLine: log });
3307
3458
  // ── 完了マーカー検出(pending があれば最優先・停止中でも継続) ──
3308
3459
  if (state.pending) {
3309
3460
  // ★この周の結末を「配った便の完了待ち」として記録する(案B-1)。ここで1回呼べば、
@@ -3338,6 +3489,23 @@ export async function relayTick(cfg, state, hooks) {
3338
3489
  if (state.pending.ack === 'missing') {
3339
3490
  // ★本文未着と決めた便。職人は仕事を受け取っていないので、完了検出も生存通知もしない。
3340
3491
  // 知らせを送れたら sendAckAtCentral が追跡をやめる。★配り直さない。
3492
+ // ★終了の事実も同じ項目で届ける(便 915694ac・1-1「追跡をやめた便・本文未着と申告した便でも届ける」)。
3493
+ // 画面へ貼る方式では実プロセスを中継が持っていないので、終わりは【つかめていない】と伝える
3494
+ // (processEndObserved:false・終了コードは null)。★作り話の終了を送らない。
3495
+ if (!state.pending.endFactsTold) {
3496
+ state.pending.endFactsTold = true;
3497
+ await deliverJobEndFacts(cfg, {
3498
+ commandId: state.pending.id,
3499
+ site: cfg.site,
3500
+ finishedAt: new Date().toISOString(),
3501
+ endKind: 'not_started',
3502
+ exitCode: null,
3503
+ exitSignal: null,
3504
+ reportDelivered: reportPhase(state.pending.id) !== 'none',
3505
+ processEndObserved: false,
3506
+ detail: '配り役が本文未着と申告した便(職人の処理は中継が持っていない)',
3507
+ }, { logLine: log });
3508
+ }
3341
3509
  await sendAckAtCentral(cfg, state);
3342
3510
  if (state.pending !== null && Date.now() - state.pending.sentAt > RELAY_PENDING_TIMEOUT_MS) {
3343
3511
  // 管制へ知らせられないまま上限を過ぎた。★印も書けていなければ、管制は queued のまま返す
@@ -3365,6 +3533,20 @@ export async function relayTick(cfg, state, hooks) {
3365
3533
  const report = notice.report.trim().length > 0 ? notice.report : null;
3366
3534
  if (await patchStatus(cfg, state.pending.id, 'done', report)) {
3367
3535
  log(`site=${cfg.site} id=${state.pending.id} done(Codexの通知${report != null ? '・報告添付' : ''})`);
3536
+ // ★終了の事実を同じ項目で届ける(便 915694ac・1-3)。この配り方では実プロセスを
3537
+ // 中継が持っていないので、終わりは【つかめていない】と伝える(終了コードは null)。
3538
+ // 届けるのは「中継が完了を確かめた」という事実だけ。★作り話の終了を送らない。
3539
+ await deliverJobEndFacts(cfg, {
3540
+ commandId: state.pending.id,
3541
+ site: cfg.site,
3542
+ finishedAt: new Date().toISOString(),
3543
+ endKind: 'completed',
3544
+ exitCode: null,
3545
+ exitSignal: null,
3546
+ reportDelivered: report != null || reportPhase(state.pending.id) !== 'none',
3547
+ processEndObserved: false,
3548
+ detail: '配り役が完了の知らせで確かめた(職人の処理は中継が持っていない)',
3549
+ }, { logLine: log });
3368
3550
  state.pending = null;
3369
3551
  }
3370
3552
  return;
@@ -3374,8 +3556,22 @@ export async function relayTick(cfg, state, hooks) {
3374
3556
  if (below !== null && hasStandaloneDone(below, cfg.site)) {
3375
3557
  // ★報告は画面から読まない。職人が書いたファイルを読む(relayReportDirs / readReport)。
3376
3558
  // 画面を読む方式は窓が 16〜34 行しかなく、9/5 以降 162 回すべて失敗していた(調査 d1d16a56)。
3559
+ const reportThere = reportPhase(state.pending.id) !== 'none';
3377
3560
  const ok = await completeWithReport(cfg, state.pending.id, log);
3378
3561
  if (ok) {
3562
+ // ★終了の事実を同じ項目で届ける(便 915694ac・1-3)。画面へ貼る方式では実プロセスを
3563
+ // 中継が持っていないので、終わりは【つかめていない】と伝える(終了コードは null)。
3564
+ await deliverJobEndFacts(cfg, {
3565
+ commandId: state.pending.id,
3566
+ site: cfg.site,
3567
+ finishedAt: new Date().toISOString(),
3568
+ endKind: 'completed',
3569
+ exitCode: null,
3570
+ exitSignal: null,
3571
+ reportDelivered: reportThere,
3572
+ processEndObserved: false,
3573
+ detail: '配り役が画面の完了の合図で確かめた(職人の処理は中継が持っていない)',
3574
+ }, { logLine: log });
3379
3575
  state.pending = null;
3380
3576
  }
3381
3577
  return; // sent 中は新規取得しない