@tyhld/conductor 0.12.106 → 0.12.108

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 CHANGED
@@ -421,6 +421,250 @@ export function saveCodexTurn(cfg, state, env = process.env) {
421
421
  if (writeCodexTurnRecord(cfg.site, rec, env))
422
422
  state.codexTurnDisk = text;
423
423
  }
424
+ // ───────────────────────────────────────────────────────────────────────────
425
+ // 画面の合図で閉じる前に知らせを待ち、閉じた後に届いた知らせも結び直す(★便 36236d41)
426
+ //
427
+ // 【何が起きていたか(実物 2026-10-05・調査 f567f8d8 の形B)】
428
+ // 便 351dea1c は 12:12:22 に「done(報告ファイルなし=完了のみ)」で閉じ、その 16 秒後の
429
+ // 12:12:38 に終わりの知らせが届いた。閉じた便には追跡(pending)が無いので、知らせは
430
+ // judgeCodexNotice の「追っている便が無いあいだに届いた知らせ」として控えへ回り、
431
+ // 報告の本文は控えに在るのに管制には1文字も載らなかった。
432
+ //
433
+ // 【直し方(2つ)】
434
+ // ① 画面の合図を拾っても【すぐには閉じない】。その便の印が合う知らせを
435
+ // RELAY_CODEX_DONE_NOTICE_WAIT_MS だけ待つ(待つあいだも毎周、知らせを先に見る=
436
+ // 届けば今までの「知らせの道」で本文つきに閉じる)。★待つあいだ次の便は配らない
437
+ // (追跡が外れないので relayTick が取得へ進まない=同じ画面で便が重ならない)。
438
+ // ② 待っても来なければ今までどおり「完了のみ」で閉じるが、その便を
439
+ // RELAY_CODEX_CLOSED_REMEMBER_MS のあいだ覚えておく(★現場ごとの控えに持つ=
440
+ // 配り役を入れ直しても引き継ぐ)。そのあいだに印が合う知らせが届いたら、控えへ回さずに
441
+ // 報告ファイルを組み立て、【いま在る「後から届ける道」】(sendPendingReportFiles)で
442
+ // 管制へ届ける。★便の状態は1文字も書かない(完了にしない・二重完了にしない)。
443
+ //
444
+ // 【★取り違えない守りは1つも緩めない】結び直すのは「便の印(jobId)が一致した行」だけ
445
+ // (takeCodexNoticesForJobId)。印が無い・合わない知らせは今までどおりどの便にも結び付けない。
446
+ // 使った行は受け皿から取り除くので同じ知らせを2回使わない。既に報告が在る便には
447
+ // 2つ目の本文を足さない(intakeCodexLateReport が reportPhase で止める)。
448
+ // ───────────────────────────────────────────────────────────────────────────
449
+ /**
450
+ * 画面の合図を拾ってから、終わりの知らせを待つ上限(★1か所の定数)。
451
+ * 実物(便 351dea1c)では 16 秒後に届いた。余裕を見て 60 秒。
452
+ */
453
+ export const RELAY_CODEX_DONE_NOTICE_WAIT_MS = 60 * 1000;
454
+ /** 「完了のみ」で閉じた便を覚えておく長さ(★1か所の定数)。 */
455
+ export const RELAY_CODEX_CLOSED_REMEMBER_MS = 15 * 60 * 1000;
456
+ /** 覚えておく便の件数の上限(古い物から落とす=控えが際限なく育たない)。 */
457
+ export const RELAY_CODEX_CLOSED_REMEMBER_MAX = 20;
458
+ /** 「知らせ0件」のまま閉じた便が何件続いたら管制へ知らせるか(★1か所の定数)。 */
459
+ export const RELAY_CODEX_SILENT_CLOSE_ALERT_AT = 2;
460
+ /** 空の控え(読めない・無いときの既定)。 */
461
+ export function emptyCodexWatchRecord() {
462
+ return { closed: [], silent: 0, told: false };
463
+ }
464
+ /** この現場の見張りの控えのパス。 */
465
+ export function codexWatchFile(site, env = process.env) {
466
+ const safe = String(site ?? '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 128) || 'unknown';
467
+ return path.join(relayStateDir(env), `codex-watch-${safe}.json`);
468
+ }
469
+ /** 控えを読む(無い・読めない・形が違うときは空の控え=★配り役を止めない)。 */
470
+ export function readCodexWatchRecord(site, env = process.env) {
471
+ try {
472
+ const o = JSON.parse(readFileSync(codexWatchFile(site, env), 'utf8'));
473
+ const closed = [];
474
+ if (Array.isArray(o.closed)) {
475
+ for (const e of o.closed) {
476
+ if (typeof e !== 'object' || e === null)
477
+ continue;
478
+ const r = e;
479
+ const id = typeof r.id === 'string' ? r.id.trim() : '';
480
+ const at = typeof r.at === 'number' && Number.isFinite(r.at) ? r.at : null;
481
+ if (id === '' || at === null)
482
+ continue;
483
+ closed.push({ id, at });
484
+ }
485
+ }
486
+ const silent = typeof o.silent === 'number' && Number.isFinite(o.silent)
487
+ ? Math.max(0, Math.floor(o.silent))
488
+ : 0;
489
+ return { closed, silent, told: o.told === true };
490
+ }
491
+ catch {
492
+ return emptyCodexWatchRecord();
493
+ }
494
+ }
495
+ /** 控えを書く(★書けなくても配り役は止まらない=今までどおりメモリの中身で動く)。 */
496
+ export function writeCodexWatchRecord(site, rec, env = process.env) {
497
+ const file = codexWatchFile(site, env);
498
+ try {
499
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
500
+ writeFileSync(file, `${JSON.stringify(rec)}\n`, { encoding: 'utf8', mode: 0o600 });
501
+ return true;
502
+ }
503
+ catch {
504
+ return false;
505
+ }
506
+ }
507
+ /**
508
+ * 覚えておく便の一覧を整える純粋関数(I/Oなし)。
509
+ * - 覚えておく長さ(RELAY_CODEX_CLOSED_REMEMBER_MS)を過ぎた便を落とす。
510
+ * - 件数の上限(RELAY_CODEX_CLOSED_REMEMBER_MAX)を超えた分は、古い物から落とす。
511
+ * - 同じ便IDは1件に畳む(新しい時刻を残す)。
512
+ */
513
+ export function pruneClosedCodexJobs(closed, now) {
514
+ const byId = new Map();
515
+ for (const c of closed) {
516
+ if (now - c.at >= RELAY_CODEX_CLOSED_REMEMBER_MS)
517
+ continue;
518
+ const prev = byId.get(c.id);
519
+ if (prev === undefined || c.at > prev)
520
+ byId.set(c.id, c.at);
521
+ }
522
+ return [...byId.entries()]
523
+ .map(([id, at]) => ({ id, at }))
524
+ .sort((a, b) => a.at - b.at)
525
+ .slice(-RELAY_CODEX_CLOSED_REMEMBER_MAX);
526
+ }
527
+ /**
528
+ * 画面の合図を拾った周に「すぐ閉じるか・知らせを待つか」を決める純粋関数(I/Oなし)。
529
+ *
530
+ * ★Codex の現場だけ。Claude の現場は 'close'=今までどおり1バイトも変わらない。
531
+ * ★`screenDoneSince === null`(合図を見つけた最初の周)は必ず 'wait'=その周では閉じない。
532
+ */
533
+ export function decideCodexDoneHold(args) {
534
+ if (args.kind !== 'codex')
535
+ return 'close';
536
+ if (args.screenDoneSince === null)
537
+ return 'wait';
538
+ return args.now - args.screenDoneSince >= RELAY_CODEX_DONE_NOTICE_WAIT_MS ? 'close' : 'wait';
539
+ }
540
+ /**
541
+ * 「この現場の Codex から終わりの知らせが届かない」を管制へ知らせるかを決める純粋関数。
542
+ *
543
+ * ★その現場につき1回だけ(told)。知らせが戻ると呼ぶ側が told を false に戻すので、
544
+ * 次に途切れたときにまた1回だけ鳴る。★3件目・4件目では鳴らさない。
545
+ */
546
+ export function decideCodexSilentAlert(args) {
547
+ if (args.told)
548
+ return false;
549
+ return args.count >= RELAY_CODEX_SILENT_CLOSE_ALERT_AT;
550
+ }
551
+ /** 管制へ知らせる1行(★純粋関数・秘密も便の状態も1文字も入れない)。 */
552
+ export function codexSilentCloseNoticeText(site, count) {
553
+ return (`この現場(${site})の Codex から、終わりの知らせが届いていません(${count}便 続けて)。` +
554
+ '報告の本文が管制に載りません。Codex の立ち上げ直しが要るかもしれません。');
555
+ }
556
+ /**
557
+ * 見張りの控えをメモリへ読み込む(★Codex の現場だけ・無ければ空から始める)。
558
+ * 既に読み込んであれば何もしない(毎周ファイルを読まない)。
559
+ */
560
+ export function loadCodexWatch(cfg, state, env = process.env) {
561
+ if (kindOf(cfg) !== 'codex')
562
+ return null;
563
+ if (state.codexWatch === null) {
564
+ state.codexWatch = readCodexWatchRecord(cfg.site, env);
565
+ state.codexWatchDisk = JSON.stringify(state.codexWatch);
566
+ }
567
+ return state.codexWatch;
568
+ }
569
+ /** いまの見張りの控えをファイルへ写す(★中身が変わっていなければ書かない)。 */
570
+ export function saveCodexWatch(cfg, state, env = process.env) {
571
+ if (kindOf(cfg) !== 'codex' || state.codexWatch === null)
572
+ return;
573
+ const text = JSON.stringify(state.codexWatch);
574
+ if (text === state.codexWatchDisk)
575
+ return;
576
+ // まだ控えが無く、書くことも何も無い=ファイルを作らない。
577
+ if (state.codexWatchDisk === null &&
578
+ state.codexWatch.closed.length === 0 &&
579
+ state.codexWatch.silent === 0 &&
580
+ !state.codexWatch.told) {
581
+ return;
582
+ }
583
+ if (writeCodexWatchRecord(cfg.site, state.codexWatch, env))
584
+ state.codexWatchDisk = text;
585
+ }
586
+ /**
587
+ * 「終わりの知らせが1件でも届いた」を記録する(★数えを 0 に戻す・便 36236d41)。
588
+ * 次に途切れたときに、また1回だけ管制へ知らせられるよう told も戻す。
589
+ */
590
+ export function noteCodexNoticeHeard(cfg, state, env = process.env) {
591
+ const w = loadCodexWatch(cfg, state, env);
592
+ if (w === null)
593
+ return;
594
+ w.silent = 0;
595
+ w.told = false;
596
+ }
597
+ /**
598
+ * 「知らせ0件のまま便を閉じた」を数え、その便を覚えておく(★便 36236d41)。
599
+ * 返り: 続いた件数と、管制へ知らせるべきか。
600
+ *
601
+ * ★ここでは管制へ何も送らない・便の状態も書かない(送るのは呼ぶ側=sendCommandNotice)。
602
+ */
603
+ export function noteCodexSilentClose(cfg, state, jobId, opts = {}) {
604
+ const now = opts.now ?? Date.now();
605
+ const w = loadCodexWatch(cfg, state, opts.env);
606
+ if (w === null)
607
+ return { count: 0, tell: false };
608
+ w.closed = pruneClosedCodexJobs([...w.closed, { id: jobId, at: now }], now);
609
+ w.silent += 1;
610
+ const tell = decideCodexSilentAlert({ count: w.silent, told: w.told });
611
+ if (tell)
612
+ w.told = true;
613
+ return { count: w.silent, tell };
614
+ }
615
+ /**
616
+ * 「完了のみ」で閉じた便へ後から届いた知らせを、その便の報告として結び直す(★便 36236d41)。
617
+ *
618
+ * ★取るのは【便の印が一致した行】だけ(takeCodexNoticesForJobId)。印が無い・合わない知らせは
619
+ * 1行も触らない=受け皿に残り、今までどおりどの便にも結び付かない。
620
+ * ★便の状態は1文字も書かない(完了にしない・二重完了にしない)。報告ファイルを置くだけで、
621
+ * 管制へ渡すのは既にある「後から届ける道」(sendPendingReportFiles)の仕事。
622
+ * ★一度結び直した便は覚えている一覧から外す=同じ知らせを2回使わない。
623
+ */
624
+ export function relinkClosedCodexNotices(cfg, state, deps = {}, env = process.env) {
625
+ if (kindOf(cfg) !== 'codex')
626
+ return;
627
+ const take = deps.takeNotices ?? ((site, id) => takeCodexNoticesForJobId(site, id, env));
628
+ const intake = deps.intake ??
629
+ ((reply, o) => intakeCodexLateReport(reply, { ...o, env }));
630
+ const clock = deps.now ?? (() => Date.now());
631
+ const logLine = deps.logLine ?? log;
632
+ const w = loadCodexWatch(cfg, state, env);
633
+ if (w === null)
634
+ return;
635
+ const now = clock();
636
+ const pruned = pruneClosedCodexJobs(w.closed, now);
637
+ if (pruned.length !== w.closed.length) {
638
+ const dropped = w.closed.length - pruned.length;
639
+ w.closed = pruned;
640
+ logLine(`site=${cfg.site} 「完了のみ」で閉じた便 ${dropped} 件は、覚えておく` +
641
+ `${Math.round(RELAY_CODEX_CLOSED_REMEMBER_MS / 60000)}分を過ぎたので忘れます` +
642
+ '(以後その便の知らせは、今までどおり控えへ回ります)');
643
+ }
644
+ if (w.closed.length === 0)
645
+ return;
646
+ for (const job of [...w.closed]) {
647
+ const mine = take(cfg.site, job.id);
648
+ if (mine.length === 0)
649
+ continue;
650
+ // 2件以上あれば【新しい方】を使う(受け皿は古い順)。
651
+ const notice = mine[mine.length - 1];
652
+ state.codexNoticeSeen = true;
653
+ // ★知らせが戻った=「届かない」の数えを 0 に戻す(次に途切れたらまた1回鳴る)。
654
+ w.silent = 0;
655
+ w.told = false;
656
+ // ★同じ知らせを2回使わない=取り込みの成否にかかわらず、覚えている一覧から外す。
657
+ w.closed = w.closed.filter((c) => c.id !== job.id);
658
+ const got = intake(notice.report, {
659
+ commandId: job.id,
660
+ site: cfg.site,
661
+ turnId: notice.turnId ?? null,
662
+ });
663
+ logLine(`site=${cfg.site} id=${job.id} 便を閉じたあとに届いた終わりの知らせを、この便の報告として` +
664
+ `結び直しました(印が一致・閉じてから${Math.round((now - job.at) / 1000)}秒): ${got.note}` +
665
+ '/★便の状態は書き換えていません(本文は後から届ける道で管制へ渡します)');
666
+ }
667
+ }
424
668
  /**
425
669
  * 受け皿の名前をそろえる仕事(登録簿の書き出し・読まれない受け皿の始末)の間隔。
426
670
  * ★配達の判断には1バイトも関わらない。フォルダを見に行くので毎周はやらない。
@@ -450,6 +694,8 @@ export function createRelayState() {
450
694
  codexTurnLoaded: false,
451
695
  codexTurnDisk: null,
452
696
  codexTrayTidiedAt: null,
697
+ codexWatch: null,
698
+ codexWatchDisk: null,
453
699
  central: { failures: 0, detail: '', downSince: null },
454
700
  readiness: createReadiness(),
455
701
  };
@@ -1924,6 +2170,95 @@ export function intakeCodexReport(reply, opts) {
1924
2170
  }
1925
2171
  return { wrote: true, file, note: `最後の返事の囲みから報告ファイルを組み立てました: ${file}` };
1926
2172
  }
2173
+ /**
2174
+ * 囲みが読めない返事を、そのまま本文にした報告ファイルの中身にする(★便 36236d41)。
2175
+ * 返事が空なら null(作り話の報告を置かない)。
2176
+ *
2177
+ * 【なぜ要るか】「完了のみ」で閉じた便へ後から届いた知らせを管制へ渡す道は、既にある
2178
+ * 「後から届ける道」(sendPendingReportFiles)しかない。その道は【形が整った報告ファイル】
2179
+ * しか送らない(reportShapeMissing)。だから囲みが無い返事も、配り役が知っている値で
2180
+ * 6欄の形にそろえる必要がある。
2181
+ * 【★作り話にしない】配り役が埋めたのは instruction_id・site・created_at・outcome だけで、
2182
+ * どれも「配り役が知っている事」か「埋めたと本文に書いた事」に限る。
2183
+ * ★outcome は職人が書いていないので `blocked`(人の判断が要る)に倒し、
2184
+ * 「職人は結末を書いていない」と本文に明記する。pushed(済んだ)へ倒さない。
2185
+ */
2186
+ export function buildCodexReplyOnlyReportText(reply, opts) {
2187
+ const body = String(reply ?? '').trim();
2188
+ if (body === '')
2189
+ return null;
2190
+ const at = new Date(opts.now ?? Date.now()).toISOString();
2191
+ const turn = (opts.turnId ?? '').toString().trim();
2192
+ const text = [
2193
+ '---',
2194
+ `instruction_id: ${opts.commandId}`,
2195
+ `site: ${opts.site}`,
2196
+ // ★職人が結末を書いていない=人の判断が要る物として渡す(済んだことにしない)。
2197
+ 'outcome: blocked',
2198
+ `created_at: ${at}`,
2199
+ 'evidence:',
2200
+ ` - 配り役が、便を閉じたあとに届いた終わりの知らせを、この便の報告として結び直しました${turn === '' ? '' : `(ターン=${turn})`}`,
2201
+ '---',
2202
+ '',
2203
+ '(この報告は、職人の最後の返事を配り役がそのまま本文として渡した物です)',
2204
+ '★職人の返事に機械で読める囲みが無かったので、結末(outcome)は職人が書いていません。',
2205
+ ` 配り役は人の判断が要る物として blocked に倒し、created_at は受け取った時刻(${at})を入れました。`,
2206
+ '',
2207
+ body,
2208
+ ].join('\n');
2209
+ return reportShapeMissing(text).length === 0 ? text : null;
2210
+ }
2211
+ /**
2212
+ * 便を閉じたあとに届いた知らせから、報告ファイルを置く(★便 36236d41)。
2213
+ * ① 囲みが読めれば、職人が書く物とまったく同じ形で置く(intakeCodexReport と同じ)。
2214
+ * ② 囲みが読めなければ、返事をそのまま本文にして置く(buildCodexReplyOnlyReportText)。
2215
+ * ★既に報告が在る便には何も書かない(2つ目の本文を足さない・職人が書けた物を壊さない)。
2216
+ * ★便の状態は1文字も書かない。管制へ渡すのは既にある「後から届ける道」の仕事。
2217
+ */
2218
+ export function intakeCodexLateReport(reply, opts) {
2219
+ const env = opts.env;
2220
+ if (reportPhase(opts.commandId, env) !== 'none') {
2221
+ return {
2222
+ wrote: false,
2223
+ file: null,
2224
+ note: '報告ファイルが既にあるので、後から届いた知らせからは作りません(2つ目の本文を足さない)',
2225
+ };
2226
+ }
2227
+ const fromBlock = intakeCodexReport(reply, opts);
2228
+ if (fromBlock.wrote)
2229
+ return fromBlock;
2230
+ const text = buildCodexReplyOnlyReportText(reply, {
2231
+ commandId: opts.commandId,
2232
+ site: opts.site,
2233
+ turnId: opts.turnId ?? null,
2234
+ now: opts.now,
2235
+ });
2236
+ if (text === null) {
2237
+ return {
2238
+ wrote: false,
2239
+ file: null,
2240
+ note: '後から届いた知らせに本文が無いので、報告ファイルは作りません',
2241
+ };
2242
+ }
2243
+ const file = path.join(reportWriteDir(env), reportFileName(opts.commandId));
2244
+ try {
2245
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
2246
+ writeFileSync(file, `${text}\n`, { encoding: 'utf8', mode: 0o600 });
2247
+ }
2248
+ catch (err) {
2249
+ return {
2250
+ wrote: false,
2251
+ file: null,
2252
+ note: '後から届いた知らせの報告ファイルを置けませんでした: ' +
2253
+ (err instanceof Error ? err.message : String(err)),
2254
+ };
2255
+ }
2256
+ return {
2257
+ wrote: true,
2258
+ file,
2259
+ note: `後から届いた知らせの返事をそのまま本文にして報告ファイルを組み立てました: ${file}`,
2260
+ };
2261
+ }
1927
2262
  export function sliceForDoneScan(text, anchor, wide) {
1928
2263
  const below = sliceBelowAnchor(text, anchor);
1929
2264
  if (below !== null)
@@ -1959,6 +2294,73 @@ export function countStandaloneDone(text, site) {
1959
2294
  n += 1;
1960
2295
  return n;
1961
2296
  }
2297
+ // ───────────────────────────────────────────────────────────────────────────
2298
+ // 終わりの印が【行の中】に描かれても拾う(★Codex だけ・便 0029ef4d)
2299
+ //
2300
+ // 【何が起きたか(実物 2026-10-05 夜・便 cd7d9ea8・配り役 0.12.101)】
2301
+ // Codex は 23:33 に返事を終え、入力待ち(`› Ask Codex to do anything`)に戻っていた。
2302
+ // ところが返事の本文が表(マークダウンの table)で終わっていたため、Codex の画面は
2303
+ // そのあとの2行まで【表の行として】描いた:
2304
+ // 型 =====原文ここまで===== そのまま使える物 …
2305
+ // 型 DONE:codex-probe
2306
+ // 既存の判定(isStandaloneDoneLine)は「印と装飾を除いて何も残らない行」だけを完了とみなすので、
2307
+ // 行の頭に残った `型` のせいで永久に成立しない。配り役の記録には 178 秒めから
2308
+ // 「画面=待機」と出ていたのに、完了は 15 分の上限まで拾えず、報告本文も載らなかった。
2309
+ //
2310
+ // 【直し方の筋】印の【描かれ方】だけに頼らない。「この行は印で終わっている」+「職人は入力待ちに
2311
+ // 戻っている」の2つで読む。印の【前】に何が付いていてもよく、印の【後ろ】に字が残っていたら拾わない。
2312
+ //
2313
+ // 【★指示文のこだま(画面に出る指示の写し)を終わりと取り違えない】守りは3重で、1つも緩めない:
2314
+ // ① 印の【後ろ】に字が残る行は拾わない … 配達文の「最終行: DONE:{現場} とだけ出力」は
2315
+ // 印の後ろに字が続くので、この1つ目で落ちる。
2316
+ // ② 入力欄に写った行(行頭が Codex のカーソル `›`)・アンカーを含む行は拾わない。
2317
+ // 貼った本文の写しは必ずこの形で描かれる(実測)。
2318
+ // ③ 床(配った時点の数)より増えたときだけ完了とみなす … 端末の折り返しがたまたま印の直後で
2319
+ // 切れて①②をすり抜けても、配った直後の周で床に取り込まれるので完了にならない。
2320
+ // ───────────────────────────────────────────────────────────────────────────
2321
+ /**
2322
+ * 印の【後ろ】に残っていても「字が無い」とみなす飾り。
2323
+ * 既存の単独行の判定(isStandaloneDoneLine)の飾りに、表・区切りの罫線と Codex のカーソルを足す。
2324
+ * ★既存の判定そのものは1文字も変えない(Claude の道を動かさないため)。
2325
+ */
2326
+ const TRAILING_DONE_DECOR_RE = /[│|>›⏺●○•*·─━┃┆┊╎—–\s]/g;
2327
+ /**
2328
+ * 1行が「`DONE:{site}` で終わっている行」か(★印の前に何が付いていてもよい)。
2329
+ * - 印より後ろに、飾りと空白以外が1字も残っていないこと。
2330
+ * - 行頭が Codex のカーソル(`›`)の行=入力欄に写った指示文は拾わない。
2331
+ * - アンカー(`CONDUCTOR_JOB`)を含む行=貼った本文の写しは拾わない。
2332
+ */
2333
+ export function isTrailingDoneLine(line, marker) {
2334
+ const at = line.lastIndexOf(marker);
2335
+ if (at < 0)
2336
+ return false;
2337
+ if (line.slice(at + marker.length).replace(TRAILING_DONE_DECOR_RE, '').length > 0)
2338
+ return false;
2339
+ const head = line.trim();
2340
+ if (head.startsWith(CODEX_MENU_CURSOR))
2341
+ return false;
2342
+ if (line.includes(SEND_ANCHOR_PREFIX))
2343
+ return false;
2344
+ return true;
2345
+ }
2346
+ /** テキスト中に「`DONE:{site}` で終わる行」が1つでもあるか。 */
2347
+ export function hasTrailingDone(text, site) {
2348
+ const marker = `DONE:${site}`;
2349
+ return text
2350
+ .replace(/\r/g, '')
2351
+ .split('\n')
2352
+ .some((line) => isTrailingDoneLine(line, marker));
2353
+ }
2354
+ /** テキスト中の「`DONE:{site}` で終わる行」の数(★床の基準に使う)。 */
2355
+ export function countTrailingDone(text, site) {
2356
+ const marker = `DONE:${site}`;
2357
+ const lines = text.replace(/\r/g, '').split('\n');
2358
+ let n = 0;
2359
+ for (const line of lines)
2360
+ if (isTrailingDoneLine(line, marker))
2361
+ n += 1;
2362
+ return n;
2363
+ }
1962
2364
  /**
1963
2365
  * 追跡中(pending)の便について、画面から完了の合図を読む(★I/Oなし=単体テスト可能)。
1964
2366
  *
@@ -1984,6 +2386,25 @@ export function scanDoneForPending(cap, site, anchor, opts) {
1984
2386
  return { done: true, floor: opts.doneFloor };
1985
2387
  return { done: false, floor: Math.min(opts.doneFloor, seen) };
1986
2388
  }
2389
+ /**
2390
+ * 「職人は入力待ちに戻っている」ときだけ、行の中に描かれた終わりの印を読む(★I/Oなし)。
2391
+ *
2392
+ * ★上の scanDoneForPending(今までの道)は1バイトも変えない。これは【それが空振りしたときだけ】
2393
+ * 足す2本目の道で、Codex の便で `idle:true` の周にしか効かない。
2394
+ * - `idle:false`(まだ手を動かしている・Claude の現場)… 何も読まない・床も動かさない。
2395
+ * - 床が在る … 配った時点の数より増えたときだけ完了。増えていなければ観測した数まで床を下げる。
2396
+ * - 床が無い(孤児の復元など)… 今までの単独行の判定と同じ危険度で、1本でも在れば完了。
2397
+ */
2398
+ export function scanTrailingDoneForPending(cap, site, opts) {
2399
+ if (!opts.idle)
2400
+ return { done: false, floor: opts.floor };
2401
+ if (opts.floor === null)
2402
+ return { done: hasTrailingDone(cap, site), floor: null };
2403
+ const seen = countTrailingDone(cap, site);
2404
+ if (seen > opts.floor)
2405
+ return { done: true, floor: opts.floor };
2406
+ return { done: false, floor: Math.min(opts.floor, seen) };
2407
+ }
1987
2408
  /**
1988
2409
  * アンカー基準の完了判定(根治の中核)。
1989
2410
  * 「アンカーより下の領域」に DONE:{site} 単独行があれば完了とみなす。
@@ -4289,6 +4710,71 @@ export async function sendHeartbeat(cfg, id, busy = false, liveness) {
4289
4710
  log(`site=${cfg.site} id=${id} heartbeat -> 失敗: ${err instanceof Error ? err.message : String(err)}(縮退で安全)`);
4290
4711
  }
4291
4712
  }
4713
+ // ───────────────────────────────────────────────────────────────────────────
4714
+ // 「職人は入力待ちに戻っているが、終わりの印が見つからない」を管制へ知らせる(便 0029ef4d)
4715
+ //
4716
+ // 【なぜ要るか(実物 2026-10-05 夜・便 cd7d9ea8)】配り役の記録には 178 秒めから
4717
+ // 「完了待ち(画面=待機)」と出ていた。=配り役は「職人の手は止まっている」と分かっていたのに、
4718
+ // その事実は手元の記録にしか残らず、管制には何も届かなかった。管制の画面では
4719
+ // 「返事待ち(15分)→ 確認しています(30分)→ 回収必要」と時間だけが進み、人が Codex の画面を
4720
+ // 見るまで誰も気づけなかった。
4721
+ //
4722
+ // 【どの口を使うか(★管制のリポは触らない)】既にある出来事の口
4723
+ // `POST /api/conductor/commands/{id}/events`(画面なし方式の職人が進捗を送っている口)へ、
4724
+ // 既にある種類 `progress` で1行送るだけ。★便の状態は1文字も書かない(完了にしない・
4725
+ // `lastSeenAt` も動かさない=管制の時間切れの働きを遅らせない)。
4726
+ // ───────────────────────────────────────────────────────────────────────────
4727
+ /** 「入力待ちなのに印が無い」を管制へ知らせるまでの待ち(★短すぎると普通の間(ま)で鳴る)。 */
4728
+ export const RELAY_IDLE_NO_DONE_NOTICE_MS = 5 * 60 * 1000;
4729
+ /**
4730
+ * この周で「入力待ちなのに終わりの印が無い」を管制へ知らせるかを決める純粋関数。
4731
+ *
4732
+ * ★1便に1回だけ(told)。★Codex の現場だけ(Claude は画面なし方式=終わりを実プロセスで掴める)。
4733
+ * ★印が見つかった周・まだ手を動かしている周では鳴らさない(idleNoDoneSince が null に戻る)。
4734
+ */
4735
+ export function decideIdleNoDoneNotice(args) {
4736
+ if (args.kind !== 'codex')
4737
+ return false;
4738
+ if (args.told)
4739
+ return false;
4740
+ if (!args.idle || args.doneSeen)
4741
+ return false;
4742
+ if (args.idleNoDoneSince === null)
4743
+ return false;
4744
+ return args.now - args.idleNoDoneSince >= RELAY_IDLE_NO_DONE_NOTICE_MS;
4745
+ }
4746
+ /** 管制へ知らせる1行の本文(★純粋関数・秘密は1文字も入れない)。 */
4747
+ export function idleNoDoneNoticeText(site, idleSeconds) {
4748
+ return (`職人(Codex)は入力待ちに戻っていますが、終わりの印(DONE:${site})が画面に見つかりません` +
4749
+ `(入力待ちになってから約${Math.max(0, Math.round(idleSeconds))}秒)。` +
4750
+ '返事を書き終えているのに印の書き方が違う/印が表の行として描かれた、のどちらかの可能性があります。');
4751
+ }
4752
+ /**
4753
+ * 管制の出来事の口へ1行送る(★最善努力。失敗は握りつぶしてログだけ)。
4754
+ * 便の状態は書かない・報告も送らない=この呼び出しで便が完了になることは無い。
4755
+ */
4756
+ export async function sendCommandNotice(cfg, commandId, text, opts = {}) {
4757
+ const doFetch = opts.fetchImpl ?? fetch;
4758
+ const logLine = opts.logLine ?? log;
4759
+ const at = new Date(opts.now?.() ?? Date.now()).toISOString();
4760
+ try {
4761
+ const res = await doFetch(`${cfg.url}/api/conductor/commands/${encodeURIComponent(commandId)}/events`, {
4762
+ method: 'POST',
4763
+ headers: { Authorization: `Bearer ${cfg.token}`, 'Content-Type': 'application/json' },
4764
+ body: JSON.stringify({ events: [{ type: 'progress', text, at, attempt: 1 }] }),
4765
+ signal: AbortSignal.timeout(RELAY_FETCH_TIMEOUT_MS),
4766
+ });
4767
+ if (res.ok)
4768
+ return true;
4769
+ logLine(`site=${cfg.site} id=${commandId} 気づきの知らせ -> HTTP ${res.status}(送れなくても作業は止めません)`);
4770
+ return false;
4771
+ }
4772
+ catch (err) {
4773
+ logLine(`site=${cfg.site} id=${commandId} 気づきの知らせ -> 失敗: ` +
4774
+ `${err instanceof Error ? err.message : String(err)}(送れなくても作業は止めません)`);
4775
+ return false;
4776
+ }
4777
+ }
4292
4778
  /** 便の終わりの関門の種類(控えの数え方の見出し。★`finish_gate.py` の GATE_KINDS と同じ語)。 */
4293
4779
  export const FINISH_GATE_KINDS = ['report', 'pr', 'background'];
4294
4780
  /** 届けられなかった「終了の事実」の置き場。 */
@@ -4520,6 +5006,8 @@ export function startKeepalive(state, pending, now) {
4520
5006
  lastHeartbeatAt: null,
4521
5007
  lastCentralProbeAt: null,
4522
5008
  screenDoneUsable: true,
5009
+ // ★2本目の物差しの床を引き継ぐ(★引き継がないと、履歴に残る前の便の印を拾いかねない)。
5010
+ ...(pending.doneFloorLoose != null ? { doneFloorLoose: pending.doneFloorLoose } : {}),
4523
5011
  };
4524
5012
  return true;
4525
5013
  }
@@ -4558,6 +5046,7 @@ const DEFAULT_KEEPALIVE_DEPS = {
4558
5046
  completeFromFile: (cfg, id) => completeWithReport(cfg, id),
4559
5047
  takeNotices: (site, id) => takeCodexNoticesForJobId(site, id),
4560
5048
  centralStatus: (cfg, id) => fetchCommandStatus(cfg, id),
5049
+ notice: (cfg, id, text) => sendCommandNotice(cfg, id, text),
4561
5050
  now: () => Date.now(),
4562
5051
  logLine: (msg) => log(msg),
4563
5052
  };
@@ -4571,7 +5060,7 @@ export async function keepaliveTick(cfg, state, deps = {}) {
4571
5060
  const ka = state.keepalive;
4572
5061
  if (!ka)
4573
5062
  return;
4574
- const { capture, heartbeat, complete, completeFromFile, takeNotices, centralStatus: getStatus, now: clock, logLine, } = {
5063
+ const { capture, heartbeat, complete, completeFromFile, takeNotices, centralStatus: getStatus, notice: sendNotice, now: clock, logLine, } = {
4575
5064
  ...DEFAULT_KEEPALIVE_DEPS,
4576
5065
  ...deps,
4577
5066
  };
@@ -4586,6 +5075,8 @@ export async function keepaliveTick(cfg, state, deps = {}) {
4586
5075
  const notice = mine.length === 0 ? null : mine[mine.length - 1];
4587
5076
  if (notice !== null) {
4588
5077
  state.codexNoticeSeen = true;
5078
+ // ★知らせが届いた=「この現場から知らせが来ない」の数えを 0 に戻す(便 36236d41)。
5079
+ noteCodexNoticeHeard(cfg, state);
4589
5080
  // ★この便のターンは終わった=「前のターンが途中」の札も下ろす。
4590
5081
  if (state.codexTurnOpen !== null && state.codexTurnOpen.jobId === ka.id) {
4591
5082
  state.codexTurnOpen = null;
@@ -4637,12 +5128,26 @@ export async function keepaliveTick(cfg, state, deps = {}) {
4637
5128
  // 生存通知の可否(alive)は従来どおり busy ∪ メニュー=1文字も変えない。
4638
5129
  const kaBusySignal = cap !== null && hasBusySignalFor(cap, kindOf(cfg));
4639
5130
  const kaMenu = cap !== null && hasChoiceMenuFor(cap, kindOf(cfg));
5131
+ const kaIdle = cap !== null && looksIdleFor(cap, kindOf(cfg));
5132
+ // ★見張りでも、今までの道(単独行)が空振りしたら2本目の道を見る(便 0029ef4d)。
5133
+ // Codex の便で入力待ちの周だけ・引き継いだ床より増えたときだけ=守りは1つも緩めない。
5134
+ const kaTrailingScan = ka.screenDoneUsable && below !== null && kindOf(cfg) === 'codex'
5135
+ ? scanTrailingDoneForPending(below, ka.site, {
5136
+ idle: kaIdle,
5137
+ floor: ka.doneFloorLoose ?? null,
5138
+ })
5139
+ : null;
5140
+ // ★床は下がるだけ(古い印の行が履歴から流れたぶんだけ下げる=あとで出た合図を取りこぼさない)。
5141
+ if (kaTrailingScan !== null && ka.doneFloorLoose != null && kaTrailingScan.floor !== ka.doneFloorLoose) {
5142
+ ka.doneFloorLoose = kaTrailingScan.floor;
5143
+ }
5144
+ const kaTrailingDone = kaTrailingScan?.done === true;
4640
5145
  const action = decideKeepalive({
4641
5146
  expired,
4642
5147
  capOk: cap !== null,
4643
- doneSeen: ka.screenDoneUsable && below !== null && hasStandaloneDone(below, ka.site),
5148
+ doneSeen: (ka.screenDoneUsable && below !== null && hasStandaloneDone(below, ka.site)) || kaTrailingDone,
4644
5149
  alive: cap !== null && (kaBusySignal || kaMenu),
4645
- idle: cap !== null && looksIdleFor(cap, kindOf(cfg)),
5150
+ idle: kaIdle,
4646
5151
  throttleOk: ka.lastHeartbeatAt === null || now - ka.lastHeartbeatAt >= RELAY_HEARTBEAT_INTERVAL_MS,
4647
5152
  // ★中央への答え合わせのしぼり。生存通知と同じ150秒(RELAY_HEARTBEAT_INTERVAL_MS)。
4648
5153
  centralProbeOk: ka.lastCentralProbeAt === null || now - ka.lastCentralProbeAt >= RELAY_HEARTBEAT_INTERVAL_MS,
@@ -4683,6 +5188,25 @@ export async function keepaliveTick(cfg, state, deps = {}) {
4683
5188
  return;
4684
5189
  }
4685
5190
  if (action === 'done') {
5191
+ // ── 画面の合図で【すぐには閉じない】(★Codex だけ・便 36236d41) ──
5192
+ // 追跡(pending)と同じ race が見張りでも起きる(合図の直後に知らせが届く)。
5193
+ // 合図を拾っても閉じず、短く待つ=上の受け皿の読みが毎周いちばん先に走るので、
5194
+ // 届いたらそちらの道で本文つきに閉じる。★待っても来なければ今までどおり閉じる。
5195
+ if (kindOf(cfg) === 'codex') {
5196
+ const kaHold = decideCodexDoneHold({
5197
+ kind: 'codex',
5198
+ screenDoneSince: ka.screenDoneSince ?? null,
5199
+ now,
5200
+ });
5201
+ if (kaHold === 'wait') {
5202
+ if (ka.screenDoneSince == null) {
5203
+ ka.screenDoneSince = now;
5204
+ logLine(`site=${cfg.site} id=${ka.id} 見張りで画面の終わりの合図を見つけました。` +
5205
+ `終わりの知らせを最大${Math.round(RELAY_CODEX_DONE_NOTICE_WAIT_MS / 1000)}秒待ちます`);
5206
+ }
5207
+ return;
5208
+ }
5209
+ }
4686
5210
  // ★冪等(2026-09-21): 報告が既に処理済み=この便はもう渡してある。送らずに畳むだけ。
4687
5211
  // 実測 2026-09-20: 職人が MCP で完了した便に、見張りが画面の DONE でもう一度完了を送っていた。
4688
5212
  if (reportPhase(ka.id) === 'processed') {
@@ -4702,7 +5226,22 @@ export async function keepaliveTick(cfg, state, deps = {}) {
4702
5226
  if (report !== null)
4703
5227
  markReportProcessed(ka.id); // ★消さずに処理済みへ(二度送らない)
4704
5228
  logLine(`site=${cfg.site} id=${ka.id} 見張り終了: 画面のDONEを検出し完了を報告` +
5229
+ // ★どちらの物差しで拾ったかを残す(便 0029ef4d・あとで原因を追えるように)。
5230
+ `${kaTrailingDone ? '(印は行の中に描かれていました)' : ''}` +
4705
5231
  `${report != null ? '(報告添付)' : kaDelivery.phase === 'none' ? '' : `(${kaDelivery.note})`}`);
5232
+ // ── 「知らせ0件」のまま閉じた(★便 36236d41) ──
5233
+ // この道を通った=この便の終わりの知らせは1件も来なかった(来ていれば上の受け皿の道で
5234
+ // 閉じている)。現場ごとに数え、続いたら管制へ1回だけ知らせる。あわせて便を覚えておく。
5235
+ if (kindOf(cfg) === 'codex') {
5236
+ const silent = noteCodexSilentClose(cfg, state, ka.id, { now });
5237
+ logLine(`site=${cfg.site} id=${ka.id} 終わりの知らせが1件も来ないまま閉じました` +
5238
+ `(この現場で ${silent.count} 便 続けて)`);
5239
+ if (silent.tell) {
5240
+ const text = codexSilentCloseNoticeText(cfg.site, silent.count);
5241
+ logLine(`WARN site=${cfg.site} id=${ka.id} ${text}`);
5242
+ await sendNotice(cfg, ka.id, text);
5243
+ }
5244
+ }
4706
5245
  // ★終わったら必ず手を下ろす(挙げっぱなしにすると現場が長くふさがる)。
4707
5246
  noteBusyEdge(state, cfg.site, ka.id, false, logLine);
4708
5247
  state.keepalive = null;
@@ -4842,6 +5381,12 @@ export async function relayTick(cfg, state, hooks) {
4842
5381
  // pending 分岐も新規指示の取得も従来どおり動く(pending を落とす目的を殺さない)。
4843
5382
  // 停止中(relayHalted)でも続けるのは、既に送った指示の後始末は進めるという既存方針と同じ。
4844
5383
  await keepaliveTick(cfg, state);
5384
+ // ── 「完了のみ」で閉じた便へ後から届いた知らせを結び直す(★便 36236d41) ──
5385
+ // ★追跡中でも見張り中でも、どの周でも必ず通す。ここを通さないと、下の pending 分岐の
5386
+ // 受け皿の読み(pickCodexNoticeFor は受け皿を空にする)や、配る直前の空にしで、
5387
+ // 閉じた便の知らせが控えへ回ってしまい、本文が永久に管制へ載らない。
5388
+ // ★取るのは印が一致した行だけ・便の状態は1文字も書かない(relinkClosedCodexNotices)。
5389
+ relinkClosedCodexNotices(cfg, state);
4845
5390
  // ── 届いていない「終了の事実」の送り直し(★便 915694ac・黙って捨てない) ──
4846
5391
  // 既に送った便の後始末なので、停止中(relayHalted)でも続ける(keepaliveTick と同じ方針)。
4847
5392
  await flushPendingJobEndFacts(cfg, { logLine: log });
@@ -4934,6 +5479,8 @@ export async function relayTick(cfg, state, hooks) {
4934
5479
  }
4935
5480
  const notice = pick.notice;
4936
5481
  if (notice !== null) {
5482
+ // ★知らせが届いた=「この現場から知らせが来ない」の数えを 0 に戻す(便 36236d41)。
5483
+ noteCodexNoticeHeard(cfg, state);
4937
5484
  // ★この便のターンは終わった=「前のターンが途中」の札を下ろす。
4938
5485
  if (state.codexTurnOpen !== null && state.codexTurnOpen.jobId === state.pending.id) {
4939
5486
  state.codexTurnOpen = null;
@@ -4979,6 +5526,8 @@ export async function relayTick(cfg, state, hooks) {
4979
5526
  if (state.pending.doneFloor != null && state.pending.doneFloorSettled !== true) {
4980
5527
  // 配達のエコーを含んだ数も床に取り込む(PendingEntry.doneFloorSettled の説明)。
4981
5528
  state.pending.doneFloor = Math.max(state.pending.doneFloor, countStandaloneDone(cap, cfg.site));
5529
+ // ★2本目の物差し(行の中に描かれた印)の床も、同じ周に決め終える(PendingEntry.doneFloorLoose)。
5530
+ state.pending.doneFloorLoose = Math.max(state.pending.doneFloorLoose ?? 0, countTrailingDone(cap, cfg.site));
4982
5531
  state.pending.doneFloorSettled = true;
4983
5532
  }
4984
5533
  const scan = scanDoneForPending(cap, cfg.site, state.pending.anchor, {
@@ -4988,7 +5537,74 @@ export async function relayTick(cfg, state, hooks) {
4988
5537
  if (state.pending.doneFloor != null && scan.floor !== state.pending.doneFloor) {
4989
5538
  state.pending.doneFloor = scan.floor;
4990
5539
  }
4991
- if (scan.done) {
5540
+ // ★今までの道が空振りしたときだけ、2本目の道を見る(便 0029ef4d)。
5541
+ // 「職人は入力待ちに戻っている」+「この行は印で終わっている」の2つで読むので、
5542
+ // 印が表の行として描かれても(行の頭に別の字が付いても)拾える。
5543
+ // ★Codex の便で入力待ちの周にしか効かない=Claude の現場は1バイトも変わらない。
5544
+ const idle = looksIdleFor(cap, kindOf(cfg));
5545
+ const looseScan = scanTrailingDoneForPending(cap, cfg.site, {
5546
+ idle: kindOf(cfg) === 'codex' && idle && !scan.done,
5547
+ floor: state.pending.doneFloorLoose ?? null,
5548
+ });
5549
+ if (state.pending.doneFloorLoose != null && looseScan.floor !== state.pending.doneFloorLoose) {
5550
+ state.pending.doneFloorLoose = looseScan.floor;
5551
+ }
5552
+ if (looseScan.done && !scan.done) {
5553
+ log(`site=${cfg.site} id=${state.pending.id} 終わりの印を【行の中】で見つけました` +
5554
+ '(職人は入力待ちに戻っています・印が表の行として描かれた形)');
5555
+ }
5556
+ // ── 「入力待ちなのに印が見つからない」を管制へ1回だけ知らせる(便 0029ef4d) ──
5557
+ // ★便の状態は書かない(完了にしない)。知らせるだけ。
5558
+ const doneSeenNow = scan.done || looseScan.done;
5559
+ if (!idle || doneSeenNow) {
5560
+ state.pending.idleNoDoneSince = null;
5561
+ }
5562
+ else if (state.pending.idleNoDoneSince == null) {
5563
+ state.pending.idleNoDoneSince = Date.now();
5564
+ }
5565
+ if (decideIdleNoDoneNotice({
5566
+ kind: kindOf(cfg),
5567
+ idle,
5568
+ doneSeen: doneSeenNow,
5569
+ idleNoDoneSince: state.pending.idleNoDoneSince ?? null,
5570
+ now: Date.now(),
5571
+ told: state.pending.idleNoDoneTold === true,
5572
+ })) {
5573
+ state.pending.idleNoDoneTold = true;
5574
+ const idleSec = (Date.now() - (state.pending.idleNoDoneSince ?? Date.now())) / 1000;
5575
+ const text = idleNoDoneNoticeText(cfg.site, idleSec);
5576
+ log(`WARN site=${cfg.site} id=${state.pending.id} ${text}`);
5577
+ await sendCommandNotice(cfg, state.pending.id, text, { logLine: log });
5578
+ if (state.pending === null)
5579
+ return; // 念のため(送信中に追跡が畳まれた場合)
5580
+ }
5581
+ if (doneSeenNow) {
5582
+ // ── 画面の合図で【すぐには閉じない】(★Codex だけ・便 36236d41) ──
5583
+ // 実物 351dea1c は画面の合図で閉じた 16 秒後に知らせが届き、本文が控えへ回った。
5584
+ // そこで合図を拾っても閉じず、その便の印が合う知らせを短く待つ(上の受け皿の読みが
5585
+ // 毎周いちばん先に走るので、届いたらそちらの道で本文つきに閉じる)。
5586
+ // ★追跡は外れないので、待つあいだ次の便は配られない(同じ画面で便が重ならない)。
5587
+ // ★ここで return するので生存通知も送らない=管制の時間切れの働きを1秒も遅らせない。
5588
+ const hold = decideCodexDoneHold({
5589
+ kind: kindOf(cfg),
5590
+ screenDoneSince: state.pending.screenDoneSince ?? null,
5591
+ now: Date.now(),
5592
+ });
5593
+ if (hold === 'wait') {
5594
+ if (state.pending.screenDoneSince == null) {
5595
+ state.pending.screenDoneSince = Date.now();
5596
+ log(`site=${cfg.site} id=${state.pending.id} 画面の終わりの合図を見つけました。` +
5597
+ `終わりの知らせを最大${Math.round(RELAY_CODEX_DONE_NOTICE_WAIT_MS / 1000)}秒待ちます` +
5598
+ '(待つあいだ次の便は配りません)');
5599
+ }
5600
+ return;
5601
+ }
5602
+ if (kindOf(cfg) === 'codex') {
5603
+ log(`WARN site=${cfg.site} id=${state.pending.id} 終わりの知らせが` +
5604
+ `${Math.round(RELAY_CODEX_DONE_NOTICE_WAIT_MS / 1000)}秒来なかったので「完了のみ」で閉じます` +
5605
+ `(この便を${Math.round(RELAY_CODEX_CLOSED_REMEMBER_MS / 60000)}分のあいだ覚えておき、` +
5606
+ 'あとで届いた知らせはこの便の報告として結び直します)');
5607
+ }
4992
5608
  // ★報告は画面から読まない。職人が書いたファイルを読む(relayReportDirs / readReport)。
4993
5609
  // 画面を読む方式は窓が 16〜34 行しかなく、9/5 以降 162 回すべて失敗していた(調査 d1d16a56)。
4994
5610
  // ★「届いた」は形まで見る(便 461be339)。整っていなければ理由も一緒に届ける。
@@ -5009,6 +5625,22 @@ export async function relayTick(cfg, state, hooks) {
5009
5625
  detail: '配り役が画面の完了の合図で確かめた(職人の処理は中継が持っていない)'
5010
5626
  + (doneDelivery.note !== null ? `/${doneDelivery.note}` : ''),
5011
5627
  }, { logLine: log });
5628
+ // ── 「知らせ0件」のまま閉じた(★便 36236d41) ──
5629
+ // この道を通った=この便の終わりの知らせは1件も来なかった(来ていれば上の
5630
+ // 受け皿の道で閉じている)。現場ごとに数え、続いたら管制へ1回だけ知らせる。
5631
+ // ★あわせてこの便を覚えておく=あとで届いた知らせを報告として結び直せる。
5632
+ if (kindOf(cfg) === 'codex') {
5633
+ const closedId = state.pending.id;
5634
+ const silent = noteCodexSilentClose(cfg, state, closedId);
5635
+ log(`site=${cfg.site} id=${closedId} 終わりの知らせが1件も来ないまま閉じました` +
5636
+ `(この現場で ${silent.count} 便 続けて)`);
5637
+ if (silent.tell) {
5638
+ const text = codexSilentCloseNoticeText(cfg.site, silent.count);
5639
+ log(`WARN site=${cfg.site} id=${closedId} ${text}`);
5640
+ // ★便の状態は1文字も書かない(出来事の口へ1行送るだけ)。
5641
+ await sendCommandNotice(cfg, closedId, text, { logLine: log });
5642
+ }
5643
+ }
5012
5644
  state.pending = null;
5013
5645
  }
5014
5646
  return; // sent 中は新規取得しない
@@ -5035,7 +5667,7 @@ export async function relayTick(cfg, state, hooks) {
5035
5667
  // ・それ以外の周 … 150秒(RELAY_HEARTBEAT_INTERVAL_MS・生存通知と同じ時計)に1回
5036
5668
  // =HTTP の増分は1指示あたり最大で150秒に1回。画面の読み取りが外れても、中央で終わった便は
5037
5669
  // 必ず150秒以内の周で追跡解除され、次の便が配られる。
5038
- const idle = looksIdleFor(cap, kindOf(cfg));
5670
+ // ★画面の待機の読み(idle)は、上の完了検出の手前で1回だけ測って使い回す(値は同じ・純粋関数)。
5039
5671
  const probeBase = state.pending.lastStatusProbeAt ?? state.pending.sentAt;
5040
5672
  const probeDue = Date.now() - probeBase >= RELAY_HEARTBEAT_INTERVAL_MS;
5041
5673
  const askedCentral = state.pending.sentMarked && (idle || probeDue);
@@ -5396,7 +6028,14 @@ export async function relayTick(cfg, state, hooks) {
5396
6028
  delivered: true,
5397
6029
  sentMarked: false,
5398
6030
  screenDoneWide: codexFloor !== null,
5399
- ...(codexFloor !== null ? { doneFloor: codexFloor, doneFloorSettled: false } : {}),
6031
+ ...(codexFloor !== null
6032
+ ? {
6033
+ doneFloor: codexFloor,
6034
+ // ★2本目の物差し(行の中に描かれた印)の床も、同じ画面から数えて覚える(便 0029ef4d)。
6035
+ doneFloorLoose: countTrailingDone(guardCap, cfg.site),
6036
+ doneFloorSettled: false,
6037
+ }
6038
+ : {}),
5400
6039
  ...(useAck ? { markAs: 'delivering' } : {}),
5401
6040
  };
5402
6041
  // 累計配達回数を通算(絶対上限 RELAY_DELIVER_MAX の基準)。
@@ -5468,6 +6107,9 @@ export async function relayTick(cfg, state, hooks) {
5468
6107
  finally {
5469
6108
  // ★どの道で抜けても、札の中身を控えへ写す(中身が変わっていなければ書かない)。
5470
6109
  saveCodexTurn(cfg, state);
6110
+ // ★見張りの控え(覚えている便・知らせが来ない数え)も同じ作法で写す(便 36236d41)。
6111
+ // 配り役を入れ直しても引き継ぐ=閉じた便の結び直しと数えが途切れない。
6112
+ saveCodexWatch(cfg, state);
5471
6113
  state.running = false;
5472
6114
  }
5473
6115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.106",
3
+ "version": "0.12.108",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -59,6 +59,19 @@
59
59
  ④ それも分からなければ作業フォルダの名前(今までどおり)。
60
60
  ★どれでも決められない知らせは【捨てない】。理由つきで控え(UNRESOLVED_FILE)へ残す。
61
61
 
62
+ 【★呼ばれた事を1行だけ残す(2026-10-06・便 36236d41)】
63
+ 調査 f567f8d8 で分かったいちばん痛い所は、「受け口が呼ばれたが黙って終わった」と
64
+ 「受け口が一度も呼ばれていない」を、実物で分けられないことだった(受け口は自分の動いた
65
+ 記録を1行も残していなかった)。だから Codex が知らせを出していないのか、受け口が
66
+ 捨てているのかを、人が確かめる材料が無かった。
67
+ そこで、呼ばれるたびに受け皿の置き場の中の1つのファイル(CALLS_FILE)へ1行だけ足す。
68
+ ・時刻/type/どう扱ったか(CALL_HOW_*)/現場名/便の印が在ったか/本文の字数
69
+ ★本文・指示文・作業フォルダより下の中身は【1文字も書かない】(字数と有無だけ)。
70
+ ★名前は `_calls.log`=配り役は受け皿(`<現場>.jsonl`)として読まないし、
71
+ 読まれない受け皿の始末(relay.ts の sweepStrayCodexNotices)の対象にもならない。
72
+ ★行数に上限(CALLS_MAX_LINES)を付け、古い物から落とす。
73
+ ★記録を書けなくても、知らせの本体は今までどおり書く・終了コードは 0 のまま。
74
+
62
75
  【やること】残す通知だけを、現場ごとの受け皿へ1行足す。
63
76
  置き場: $CONDUCTOR_CODEX_NOTIFY_DIR(既定 $HOME/.tyhld/codex-notify)/<現場>.jsonl
64
77
  ★中央へは送らない。送るのは relay の仕事(完了の経路は1本に保つ)。
@@ -98,6 +111,24 @@ UNRESOLVED_FILE = '_unresolved.skipped.jsonl'
98
111
  # ★relay.ts の CODEX_SKIPPED_NOTICE_MAX_LINES と同じ値にそろえる。
99
112
  UNRESOLVED_MAX_LINES = 200
100
113
 
114
+ # 「呼ばれた事」の記録(★受け皿でも控えでもない・配り役は読まない名前にする)。
115
+ # `.jsonl` で終わらない=relay.ts の takeCodexNotices(`<現場>.jsonl`)も
116
+ # sweepStrayCodexNotices(`.jsonl` だけを見る)も、このファイルには一切触らない。
117
+ CALLS_FILE = '_calls.log'
118
+
119
+ # 記録に残す行数の上限(古い物から落とす)。
120
+ CALLS_MAX_LINES = 500
121
+
122
+ # どう扱ったか(★この5通りだけ)。
123
+ CALL_HOW_TRAY = 'tray' # 受け皿へ書いた
124
+ CALL_HOW_INTERNAL = 'internal-turn' # Codex 内部のターンなので捨てた
125
+ CALL_HOW_UNRESOLVED = 'unresolved-site' # 現場を決められず控えへ回した
126
+ CALL_HOW_BAD_ARGS = 'bad-args' # この受け口が読めない引数だった
127
+ CALL_HOW_WRITE_FAILED = 'write-failed' # 書けなかった
128
+
129
+ # type に書いてよい字(★届いた値をそのまま書かない=記録の行を壊させない)。
130
+ TYPE_OK = re.compile(r'[^A-Za-z0-9._-]')
131
+
101
132
 
102
133
  def notify_dir(env=None):
103
134
  env = os.environ if env is None else env
@@ -325,29 +356,95 @@ def keep_unresolved(payload, report, why, env=None):
325
356
  return ''
326
357
 
327
358
 
359
+ def call_line(how, why, payload=None, site='', report=''):
360
+ """「呼ばれた事」の1行(★本文・指示文・作業フォルダは1文字も入れない)。"""
361
+ raw_type = ''
362
+ if isinstance(payload, dict):
363
+ raw_type = TYPE_OK.sub('', str(payload.get('type') or ''))[:40]
364
+ return json.dumps(
365
+ {
366
+ 'at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
367
+ 'type': raw_type,
368
+ 'how': how,
369
+ 'why': why,
370
+ 'site': site or None,
371
+ # ★印そのものは書かない(有無だけ)。
372
+ 'jobStamp': job_id_of(payload) is not None if isinstance(payload, dict) else False,
373
+ # ★本文は書かない(字数だけ)。
374
+ 'reportChars': len(report or ''),
375
+ },
376
+ ensure_ascii=False,
377
+ )
378
+
379
+
380
+ def record_call(how, why, payload=None, site='', report='', env=None):
381
+ """呼ばれた事を記録に1行足す。返り: 書けたファイル/''。
382
+
383
+ ★ここで何が起きても例外を外に出さない・終了コードを変えない(Codex を止めない)。
384
+ 記録が書けないことは、知らせの本体を書かない理由にはしない(呼ぶ順も本体の【後】)。
385
+ """
386
+ try:
387
+ target_dir = notify_dir(env)
388
+ if not target_dir:
389
+ return ''
390
+ file = os.path.join(target_dir, CALLS_FILE)
391
+ line = call_line(how, why, payload, site, report)
392
+ os.makedirs(target_dir, exist_ok=True)
393
+ kept = []
394
+ if os.path.exists(file):
395
+ with open(file, encoding='utf-8') as f:
396
+ kept = [l for l in f.read().split('\n') if l.strip()]
397
+ lines = (kept + [line])[-CALLS_MAX_LINES:]
398
+ with open(file, 'w', encoding='utf-8') as f:
399
+ f.write('\n'.join(lines) + '\n')
400
+ return file
401
+ except Exception: # noqa: BLE001
402
+ return ''
403
+
404
+
328
405
  def main(argv):
329
406
  raw = ' '.join(argv[1:]).strip()
330
407
  if not raw:
408
+ record_call(CALL_HOW_BAD_ARGS, 'no-args')
331
409
  return 0
332
410
  try:
333
411
  payload = json.loads(raw)
334
412
  except Exception: # noqa: BLE001
413
+ record_call(CALL_HOW_BAD_ARGS, 'not-json')
335
414
  return 0 # 読めない通知は黙って捨てる(Codex を止めない)
336
415
  keep, site, report, reason = decide(payload)
337
416
  if not keep:
338
417
  # ★現場を決められなかった知らせだけは、捨てずに控えへ残す(便 684ac785)。
339
418
  if reason.startswith('unresolved-site:'):
340
- keep_unresolved(payload, report, reason[len('unresolved-site:'):])
419
+ wrote = keep_unresolved(payload, report, reason[len('unresolved-site:'):])
420
+ record_call(
421
+ CALL_HOW_UNRESOLVED if wrote else CALL_HOW_WRITE_FAILED,
422
+ reason if wrote else 'unresolved-unwritable',
423
+ payload,
424
+ report=report,
425
+ )
426
+ return 0
427
+ record_call(
428
+ CALL_HOW_INTERNAL if reason == 'internal-turn' else CALL_HOW_BAD_ARGS,
429
+ reason,
430
+ payload,
431
+ report=report,
432
+ )
341
433
  return 0
342
434
  target_dir = notify_dir()
343
435
  if not target_dir:
436
+ record_call(CALL_HOW_WRITE_FAILED, 'no-notify-dir', payload, site, report)
344
437
  return 0
345
438
  try:
346
439
  os.makedirs(target_dir, exist_ok=True)
347
440
  with open(os.path.join(target_dir, f'{site}.jsonl'), 'a', encoding='utf-8') as f:
348
441
  f.write(notice_line(payload, site, report) + '\n')
349
442
  except Exception: # noqa: BLE001
443
+ # ★知らせの本体を書けなかった事も記録に残す(「呼ばれていない」と区別できるようにする)。
444
+ record_call(CALL_HOW_WRITE_FAILED, 'tray-write-error', payload, site, report)
350
445
  return 0
446
+ # ★記録は本体を書いた【後】に足す(記録が書けなくても本体は残る)。
447
+ record_call(CALL_HOW_TRAY, reason, payload, site, report)
351
448
  return 0
352
449
 
353
450