@tyhld/conductor 0.12.77 → 0.12.78

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
@@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url';
15
15
  import os from 'node:os';
16
16
  import path from 'node:path';
17
17
  import { RELAY_FETCH_TIMEOUT_MS, createRelayState, deliveryReport, parseAgentKind, reconnectDelayMs, relayTick, tmuxHasSession, pendingReportIds, sendPendingReportFiles, sweepProcessedReports, } from './relay.js';
18
- import { createHeadlessState, headlessNextDelayMs, headlessTick, parseRunnerMode, } from './headless.js';
18
+ import { createHeadlessState, headlessNextDelayMs, headlessTick, headlessTrackedCommandIds, parseRunnerMode, } from './headless.js';
19
19
  import { runViewCommand } from './view.js';
20
20
  import { createRelayRunner } from './relay-runner.js';
21
21
  import { installProcessGuards } from './process-guard.js';
@@ -30,6 +30,13 @@ import { BUILD_NUDGE_NAME, REQUIRED_ENV, createBuildRunner, createMemoStore, loa
30
30
  * ★relay が無効な機では null のまま=報告の中身は今までと1バイトも変わらない。
31
31
  */
32
32
  let relayStateForReport = null;
33
+ /**
34
+ * 画面なし方式の状態の参照(★回収の道から「いま追っている便」を除くため・便 6ebba0d0)。
35
+ * 画面なし方式では実行中の便は headless の state.job に在り、relay.pending には入らない。
36
+ * relay.pending だけを見ていたので除外が常に空になり、職人が報告を置いた直後に
37
+ * 報告が先に渡ってしまっていた。★画面あり方式では null のまま=今までと同じ。
38
+ */
39
+ let headlessStateForReport = null;
33
40
  /** git コマンドを安全に実行し、失敗したら null を返す(git でない/未インストール等)。 */
34
41
  function git(args) {
35
42
  try {
@@ -225,8 +232,14 @@ async function sendReport(cfg) {
225
232
  // ★未処理の報告ファイルの本文を中央へ届ける(便 79840008)。受け取られた物だけ処理済みにする(消さない)。
226
233
  // ★配達役がいま追っている便の報告は送らない(完了の道が完了と一緒に渡すため)。
227
234
  if (pending.ids.length > 0) {
235
+ // 画面あり方式の除外(pending.id)は今までどおり。
228
236
  const tracking = relayStateForReport?.pending?.id;
229
- await sendPendingReportFiles(cfg, { exclude: tracking ? [tracking] : [], rejectCounts: reportRejectCounts });
237
+ const exclude = tracking ? [tracking] : [];
238
+ // ★画面なし方式は、実行中の便(state.job)と再開待ちの便(pendingRetry)も除く。
239
+ if (headlessStateForReport !== null) {
240
+ exclude.push(...headlessTrackedCommandIds(headlessStateForReport));
241
+ }
242
+ await sendPendingReportFiles(cfg, { exclude, rejectCounts: reportRejectCounts });
230
243
  }
231
244
  }
232
245
  /** `conductor start`: 起動直後に1回 + 以後 intervalSec ごとに報告し続ける。 */
@@ -276,8 +289,6 @@ async function start(args) {
276
289
  // ポーリングの間隔はこれまでどおり(15秒/承認待ち3秒)。耳が死んでいる間の保険として残す。
277
290
  let relayRunner = null;
278
291
  let nudgeWatcher = null;
279
- // ★headless 方式の状態参照(sendReport のアダプタ用)。tmux のときは null のまま。
280
- let headlessStateRef = null;
281
292
  if (cfg.relayEnabled) {
282
293
  if (cfg.runnerMode === 'headless') {
283
294
  // ── headless 方式(画面なし・便ごとに claude -p を起動する) ──
@@ -285,9 +296,10 @@ async function start(args) {
285
296
  console.log(`[headless] 有効: site=${cfg.site} mode=headless workDir=${workDir} ` +
286
297
  `interval=${cfg.relayIntervalSec}s`);
287
298
  const hState = createHeadlessState();
288
- headlessStateRef = hState;
289
299
  // 生存報告に「いま配れるか」を載せるため、内包する RelayState を参照する。
290
300
  relayStateForReport = hState.relay;
301
+ // ★回収の道から「いま追っている便」を除くための参照(便 6ebba0d0)。
302
+ headlessStateForReport = hState;
291
303
  const hCfg = { ...cfg, workDir };
292
304
  relayRunner = createRelayRunner({
293
305
  tick: () => headlessTick(hCfg, hState, {
package/dist/headless.js CHANGED
@@ -19,7 +19,7 @@ import { createInterface } from 'node:readline';
19
19
  import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, 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, noteReadiness, patchStatus, 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, deliveryReport, fetchCommand, fetchCommandStatus, fetchControl, fetchOrphans, isTerminalCommandStatus, kindOf, markDelivered, noteCentralDown, noteCentralUp, noteReadiness, patchStatus, recoverStuckProcessedReports, reconnectDelayMs, selectOrphanToRestore, sendHeartbeat, } from './relay.js';
23
23
  const log = (msg) => {
24
24
  console.log(`[headless] ${msg}`);
25
25
  };
@@ -34,6 +34,20 @@ export const HEADLESS_MAX_ATTEMPTS = 3;
34
34
  export const HEADLESS_EVENT_FLUSH_MS = 2_000;
35
35
  /** 実行中に stop_requested を確かめる間隔(ms)。 */
36
36
  export const HEADLESS_STOP_CHECK_MS = 15_000;
37
+ /**
38
+ * 便の終了状態(done)を管制へ書き直す上限回数(★便 6ebba0d0・定数はここ1か所)。
39
+ *
40
+ * 【根拠】書けなかった便は畳まない=その現場は次の便を配らない。だから上限は
41
+ * 「管制の短い不通なら必ず越えない/書けない便で現場を永久に塞がない」の両立で決める。
42
+ * 便を抱えている間の周回は min(relayIntervalSec, HEADLESS_EVENT_FLUSH_MS=2秒) =約2秒ごと。
43
+ * 300回 ≒ 約10分。ふつうの通信の途切れ(数秒〜数分)は必ず越えない一方、
44
+ * どうしても書けない便(管制が消えた等)でも現場が塞がるのは約10分で終わる。
45
+ * 実障害(7969ea66)は人が気づくまで 9:08 → 10:49 の約1時間40分かかっていたので、
46
+ * 10分は十分短い。越えたときは黙って done にせず、理由つきで failed を書いて残す。
47
+ */
48
+ export const HEADLESS_DONE_WRITE_MAX_TRIES = 300;
49
+ /** 取り残しの後始末を回す間隔(ms)。★起動時は1回必ず回す(間隔の対象外)。 */
50
+ export const HEADLESS_RECOVER_SWEEP_MS = 10 * 60 * 1000;
37
51
  /** イベントの text フィールドの上限(文字)。 */
38
52
  export const EVENT_TEXT_MAX = 500;
39
53
  /** 記録1行から取り出すイベントの上限(件)。1行に content が大量に入っていても溢れさせない。 */
@@ -343,6 +357,24 @@ export function maskSecrets(text) {
343
357
  }
344
358
  return masked;
345
359
  }
360
+ /**
361
+ * いま画面なし方式が追っている便の指示ID(★回収の道から除く物・便 6ebba0d0 の b)。
362
+ *
363
+ * 【なぜ要るか】30秒ごとの生存報告は、未処理の報告ファイルを「回収の道」
364
+ * (POST /api/conductor/report/files・★この口は便を完了にしない)へ送る。
365
+ * 画面あり方式はそこで `relayState.pending.id` を除いていたが、画面なし方式では
366
+ * 実行中の便は `state.job` に在り `relay.pending` は一度も入らない=除外が常に空だった。
367
+ * そのため職人が報告を置いた直後(まだ動いている間)に報告が先に渡ってしまう。
368
+ * ★これは二重の守り。これが効かなくても completeWithReport だけで②③は通る。
369
+ */
370
+ export function headlessTrackedCommandIds(state) {
371
+ const ids = [];
372
+ if (state.job !== null)
373
+ ids.push(state.job.commandId);
374
+ if (state.pendingRetry !== null)
375
+ ids.push(state.pendingRetry.cmd.id);
376
+ return ids;
377
+ }
346
378
  // ─────────────────────────────────────────────────────────────────────────────
347
379
  // ヘルパー
348
380
  // ─────────────────────────────────────────────────────────────────────────────
@@ -934,7 +966,9 @@ async function checkRunningJob(cfg, state) {
934
966
  if (job.completed) {
935
967
  await flushEvents(cfg, job);
936
968
  // ★消す前に本文を取る(`kc 現場名` が「直前の仕事」を出すために残す)。
937
- const savedBody = loadActiveJob(cfg.site)?.body ?? '';
969
+ // ★終了状態を書けずに持ち越した便は、次の周でも本文を失わないように覚えておく(便 6ebba0d0)。
970
+ const savedBody = loadActiveJob(cfg.site)?.body ?? job.savedBody ?? '';
971
+ job.savedBody = savedBody;
938
972
  clearActiveJob(cfg.site);
939
973
  const noteLastJob = (outcome, detail) => {
940
974
  saveLastJob(cfg.site, {
@@ -1001,19 +1035,43 @@ async function checkRunningJob(cfg, state) {
1001
1035
  return;
1002
1036
  }
1003
1037
  if (job.exitCode === 0) {
1004
- // 正常終了 → 完了を中央へ報告
1005
- const ok = await completeWithReport(cfg, job.commandId, log);
1006
- if (ok) {
1038
+ // 正常終了 → 便の終了状態を管制へ確定させる。
1039
+ // ★書けるまでやり直す(便 6ebba0d0)。以前は false のとき patchStatus を1回試し、
1040
+ // 成否を見ずに state.job を畳んでいた=誰も done を書いていないのに便を手放していた。
1041
+ // 畳まない間は次の便を配らない/管制には生存報告と理由が残る。
1042
+ if (await completeWithReport(cfg, job.commandId, log)) {
1043
+ state.doneWriteTries?.delete(job.commandId);
1007
1044
  log(`site=${cfg.site} id=${job.commandId} 完了(exit=0, attempt ${job.attempt})`);
1045
+ noteLastJob('done', '報告まで出しました');
1046
+ state.job = null;
1047
+ rs.lastHeartbeatAt = null;
1048
+ return;
1008
1049
  }
1009
- else {
1010
- // 報告を書けなかった場合でも status=done だけは書く
1011
- await patchStatus(cfg, job.commandId, 'done');
1012
- log(`site=${cfg.site} id=${job.commandId} 完了(exit=0, 報告なし)`);
1050
+ state.doneWriteTries ??= new Map();
1051
+ const tries = (state.doneWriteTries.get(job.commandId) ?? 0) + 1;
1052
+ state.doneWriteTries.set(job.commandId, tries);
1053
+ if (tries < HEADLESS_DONE_WRITE_MAX_TRIES) {
1054
+ // ★畳まない。次の tick でもう一度書く(★完了を落とさない)。
1055
+ log(`WARN site=${cfg.site} id=${job.commandId} 便の終了状態を管制へ書けませんでした ` +
1056
+ `(${tries}/${HEADLESS_DONE_WRITE_MAX_TRIES}回目) → 畳まずに次の周でやり直します`);
1057
+ return;
1013
1058
  }
1014
- noteLastJob('done', ok ? '報告まで出しました' : '報告は出せませんでした');
1059
+ // 上限超え → 黙って done にせず、理由つきで残す(人が見て判断できる形)。
1060
+ const reason = `便の終了状態を管制へ ${HEADLESS_DONE_WRITE_MAX_TRIES} 回書けませんでした` +
1061
+ `(職人は exit=0 で終わっています。報告は手元の控えに残っています)`;
1062
+ await patchStatus(cfg, job.commandId, 'failed', reason.slice(0, EVENT_TEXT_MAX));
1063
+ await sendCommandEvents(cfg, job.commandId, [{
1064
+ type: 'error',
1065
+ text: reason.slice(0, EVENT_TEXT_MAX),
1066
+ at: new Date().toISOString(),
1067
+ attempt: job.attempt,
1068
+ }]);
1069
+ rs.abandoned.add(job.commandId);
1070
+ state.doneWriteTries.delete(job.commandId);
1071
+ noteLastJob('failed', reason);
1015
1072
  state.job = null;
1016
1073
  rs.lastHeartbeatAt = null;
1074
+ log(`ERROR site=${cfg.site} id=${job.commandId} ${reason}`);
1017
1075
  return;
1018
1076
  }
1019
1077
  // 異常終了 → 次の tick で自動再開を試みる
@@ -1264,6 +1322,37 @@ export async function restoreHeadlessOrphans(cfg, state) {
1264
1322
  `孤児を復元して再開しました (session=${sessionId}, attempt ${attempt}/${HEADLESS_MAX_ATTEMPTS})`);
1265
1323
  }
1266
1324
  // ─────────────────────────────────────────────────────────────────────────────
1325
+ // 取り残しの後始末(便 6ebba0d0 の項目4)
1326
+ // ─────────────────────────────────────────────────────────────────────────────
1327
+ /**
1328
+ * 「実プロセスが無い・報告は渡し済み・管制ではまだ終わっていない」便に done を書く。
1329
+ *
1330
+ * 【なぜ要るか】この便の直しより前に起きた取り残し(02b1e532 / 5db25cae / 7969ea66 と同じ形)は、
1331
+ * 誰も done を書かないまま管制に残っている。人が手で完了にするまで次の便が始まらない。
1332
+ * 中継が自分で気づいて、完了の道と同じ形で書き直す。
1333
+ * 【★よそに書かない】見るのは自分の現場の控えだけ(控えの `site:` 欄で判定する)。
1334
+ * いま追っている便にも書かない。管制でまだ配られていない便(queued)にも書かない。
1335
+ * 【間隔】state を渡したときは、起動時に1回+以後 HEADLESS_RECOVER_SWEEP_MS ごと
1336
+ * (毎周 管制へ問い合わせると実行回数が増えるため)。state を渡さなければ必ず1回回す。
1337
+ *
1338
+ * 返り: done を書いた件数。
1339
+ */
1340
+ export async function recoverStuckCommands(cfg, opts = {}) {
1341
+ const now = opts.now ?? Date.now();
1342
+ const state = opts.state;
1343
+ if (state !== undefined) {
1344
+ const last = state.lastRecoverAt ?? 0;
1345
+ if (last > 0 && now - last < HEADLESS_RECOVER_SWEEP_MS)
1346
+ return 0;
1347
+ state.lastRecoverAt = now;
1348
+ }
1349
+ return recoverStuckProcessedReports(cfg, {
1350
+ trackedIds: opts.trackedIds,
1351
+ now,
1352
+ logLine: log,
1353
+ });
1354
+ }
1355
+ // ─────────────────────────────────────────────────────────────────────────────
1267
1356
  // headless tick(relayTick の headless 版)
1268
1357
  // ─────────────────────────────────────────────────────────────────────────────
1269
1358
  /**
@@ -1319,6 +1408,11 @@ export async function headlessTick(cfg, state, hooks) {
1319
1408
  log('停止が解除されました。relayを再開します');
1320
1409
  }
1321
1410
  rs.lastHalted = false;
1411
+ // ── 取り残しの後始末(★便 6ebba0d0 の項目4) ──
1412
+ // 「実プロセスが無い・報告は渡し済み・管制ではまだ終わっていない」便に done を書く。
1413
+ // ★自分の PC・自分の現場の便だけ(判定は recoverStuckProcessedReports 側)。
1414
+ // ★停止トグルの確認より後に置く=止められている現場では1バイトも書かない。
1415
+ await recoverStuckCommands(cfg, { trackedIds: headlessTrackedCommandIds(state), state });
1322
1416
  // ── 異常終了後の自動再開(pendingRetry)──
1323
1417
  if (state.pendingRetry !== null) {
1324
1418
  const retry = state.pendingRetry;
@@ -1433,6 +1527,8 @@ export function createHeadlessState() {
1433
1527
  attempts: new Map(),
1434
1528
  restored: false,
1435
1529
  pendingRetry: null,
1530
+ doneWriteTries: new Map(),
1531
+ lastRecoverAt: 0,
1436
1532
  };
1437
1533
  }
1438
1534
  /**
package/dist/relay.js CHANGED
@@ -2458,32 +2458,51 @@ export async function patchStatus(cfg, id, status, report) {
2458
2458
  * 本文が来ないまま終わると「完了したが報告なし」だけが残った。報告はファイルにあるので、
2459
2459
  * 完了を送るその1回に必ず載せられる。
2460
2460
  *
2461
- * 【★二重に送らない】職人が conductor_complete で先に完了させていたら、relay は何も送らない。
2462
- * 中央へ1回だけ状態を尋ねて確かめる。尋ねられない(不通)ときは送る側に倒す
2463
- * =完了が落ちるより、同じ報告が2度書かれるほうが害が小さい(中央は上書き保存する)。
2461
+ * 【★①②③は別の工程(えふさん確定 2026-10-02・便 6ebba0d0)】
2462
+ * ①報告を送る / ②便を終了状態へ確定する / ③次便を解放する は別の工程である。
2463
+ * ★①が既に済んでいても②③は必ず実行する。
2464
+ * 【実障害 3件(02b1e532 / 5db25cae / 7969ea66)】画面なし方式では、職人が報告ファイルを
2465
+ * 置いた直後(実プロセスはまだ生きている)に 30秒の周の「回収の道」
2466
+ * (POST /api/conductor/report/files・★この口は便を完了にしない)が報告を送り、
2467
+ * `processed/` へ移してしまう。その後 exit=0 でここが呼ばれても、以前の実装は
2468
+ * 「処理済みなら、もう渡してある → 何もしない」で true を返していた。
2469
+ * =管制には誰も done を書かず、便は sent → stalled のまま残り、次の便が永久に始まらない。
2470
+ * (relay.ts の sendPendingReportFiles のコメントにこの落とし穴そのものが書いてあるが、
2471
+ * 画面なし方式では除外が空になるため守られていなかった。)
2472
+ * だから【報告がどの段にあるか】と【便が終わっているか】を分けて見る。
2473
+ *
2474
+ * 【★二重完了にしない・開発履歴を2件にしない】
2475
+ * ・管制が「もう終わっている」と言う便には何も書かない(failed を done へ戻さない)。
2476
+ * ・報告が既に渡っている(処理済み)ときは、done だけを書いて本文は載せない
2477
+ * =同じ報告が開発履歴に2件並ばない。
2464
2478
  *
2465
2479
  * 【後片付け】管制へ書けたときだけ報告を【処理済み】にする(★消さない・2026-09-21)。
2466
2480
  * 書けなければ未処理のまま残す=次tickでやり直せるし、管制も生存報告で気づける。
2467
2481
  *
2468
- * 【★冪等(二重完了にしない)】報告が既に処理済みなら、この便はもう渡し終えている。
2469
- * 中央へ問い合わせる前にそこで打ち切る=MCP と報告ファイルの二経路から同じ便が来ても1回だけ扱う。
2470
- * 返り: 追跡を畳んでよいか(true = 完了が確定した)。
2482
+ * 返り: 追跡を畳んでよいか(true = 便の終了状態が確定した/false = 書けていない=次の周でやり直す)。
2471
2483
  */
2472
2484
  export async function completeWithReport(cfg, id, logLine = log) {
2473
- // ★冪等の要: 処理済みの控えが在る=この便はもう渡してある。何もしない(二重完了にしない)。
2474
- if (reportPhase(id) === 'processed') {
2475
- logLine(`site=${cfg.site} id=${id} の報告は処理済みです→送りません(二重完了にしない)`);
2476
- return true;
2477
- }
2485
+ // ★①報告がどの段にあるか(送る工程の話)。②③の実行可否には使わない。
2486
+ const phase = reportPhase(id);
2487
+ // ★②便が終わっているか(確定の工程の話)。管制の真実だけを見る。
2478
2488
  const already = await fetchCommandStatus(cfg, id);
2479
- if (isTerminalCommandStatus(already)) {
2480
- // 職人が自分で完了させていた。★relay は二重に送らない(報告も上書きしない)。
2481
- // ★控えは消さずに処理済みとして残す(MCP 経由でも同じ控えが残る・えふさん確定 2026-09-21)。
2482
- markReportProcessed(id);
2483
- logLine(`site=${cfg.site} id=${id} は職人が自分で完了させていた(status=${already})` +
2489
+ if (isCentralFinished(already)) {
2490
+ // 既に終端(職人の conductor_complete・人の操作・failed 等)。★何も書かない。
2491
+ // 控えは消さずに処理済みとして残す(MCP 経由でも同じ控えが残る・えふさん確定 2026-09-21)。
2492
+ if (phase === 'pending')
2493
+ markReportProcessed(id);
2494
+ logLine(`site=${cfg.site} id=${id} は管制で既に終わっています(status=${already})` +
2484
2495
  '→relayからは送りません(報告は控えとして処理済みにしました)');
2485
2496
  return true;
2486
2497
  }
2498
+ if (phase === 'processed') {
2499
+ // ★①は済み・②は未了。本文は渡してあるので、状態だけを書く(開発履歴を2件にしない)。
2500
+ // 書けなければ false=次の周でやり直す(完了を落とさない)。
2501
+ if (!(await patchStatus(cfg, id, 'done')))
2502
+ return false;
2503
+ logLine(`site=${cfg.site} id=${id} done(報告は回収の道で渡し済み=本文は添えない)`);
2504
+ return true;
2505
+ }
2487
2506
  const report = readReport(id);
2488
2507
  if (!(await patchStatus(cfg, id, 'done', report)))
2489
2508
  return false;
@@ -2493,6 +2512,98 @@ export async function completeWithReport(cfg, id, logLine = log) {
2493
2512
  (report !== null ? `(報告添付・${report.length}文字)` : '(報告ファイルなし=完了のみ)'));
2494
2513
  return true;
2495
2514
  }
2515
+ /**
2516
+ * 「実プロセスが無い・報告は渡し済み・管制ではまだ終わっていない」便に、便の終了状態だけを書く
2517
+ * (★すでに起きている取り残しの後始末・便 6ebba0d0 の項目4)。
2518
+ *
2519
+ * 【どこから見つけるか】処理済みの控え(`processed/`)に自分の現場の報告が在るのに、管制では
2520
+ * まだ配達中(sent / running / delivering)の便。★控えの `site:` 欄が自分の現場の物だけを見る
2521
+ * =他の PC・他の現場の便には1バイトも書かない。
2522
+ * 【なぜ queued を含めないか】queued はまだ誰にも配られていない=この PC が完了にしてよい便ではない。
2523
+ * 【なぜ failed を含めないか】failed を done へ戻さない(変えない決まり)。isCentralFinished が弾く。
2524
+ *
2525
+ * 返り: done を書いた件数。
2526
+ */
2527
+ export async function recoverStuckProcessedReports(cfg, opts = {}) {
2528
+ const logLine = opts.logLine ?? log;
2529
+ const tracked = new Set(opts.trackedIds ?? []);
2530
+ const ids = processedReportIds({
2531
+ site: cfg.site,
2532
+ env: opts.env,
2533
+ limit: opts.limit ?? RECOVER_STUCK_LIMIT,
2534
+ maxAgeMs: opts.maxAgeMs ?? RECOVER_STUCK_MAX_AGE_MS,
2535
+ now: opts.now,
2536
+ });
2537
+ let written = 0;
2538
+ for (const id of ids) {
2539
+ if (tracked.has(id))
2540
+ continue;
2541
+ const status = await fetchCommandStatus(cfg, id);
2542
+ if (status === null || !RECOVER_STUCK_STATUSES.has(status))
2543
+ continue;
2544
+ // ★完了の道と同じ形で書く(本文は渡し済みなので添えない)。
2545
+ if (!(await patchStatus(cfg, id, 'done')))
2546
+ continue;
2547
+ written++;
2548
+ logLine(`site=${cfg.site} id=${id} 取り残しの後始末: 報告は渡し済み・管制は ${status} のままだったので done を書きました`);
2549
+ }
2550
+ return written;
2551
+ }
2552
+ /** 後始末の対象にする便の状態(★配られた後の状態だけ。queued は含めない)。 */
2553
+ const RECOVER_STUCK_STATUSES = new Set(['sent', 'running', 'delivering']);
2554
+ /** 後始末で1回に見る控えの件数の上限。 */
2555
+ export const RECOVER_STUCK_LIMIT = 20;
2556
+ /** 後始末で見る控えの古さの上限(これより古い控えは見ない)。 */
2557
+ export const RECOVER_STUCK_MAX_AGE_MS = 48 * 60 * 60 * 1000;
2558
+ /**
2559
+ * 処理済みの控え(`processed/`)のうち、指定の現場の物の指示IDを新しい順に返す。
2560
+ * ★控えの `site:` 欄が一致した物だけ(欄が無い古い形の控えは対象外=よそに書かない安全側)。
2561
+ */
2562
+ export function processedReportIds(opts) {
2563
+ const now = opts.now ?? Date.now();
2564
+ const maxAgeMs = opts.maxAgeMs ?? RECOVER_STUCK_MAX_AGE_MS;
2565
+ const limit = Math.max(0, Math.floor(opts.limit ?? RECOVER_STUCK_LIMIT));
2566
+ const found = new Map(); // 指示ID → 処理済みにした時刻(ms)
2567
+ for (const dir of reportStoreDirs(opts.env)) {
2568
+ let names;
2569
+ try {
2570
+ names = readdirSync(path.join(dir, PROCESSED_DIR));
2571
+ }
2572
+ catch {
2573
+ continue;
2574
+ }
2575
+ for (const name of names) {
2576
+ if (!name.endsWith('.md'))
2577
+ continue;
2578
+ const file = path.join(dir, PROCESSED_DIR, name);
2579
+ let mtime;
2580
+ try {
2581
+ const st = statSync(file);
2582
+ if (!st.isFile() || st.size === 0)
2583
+ continue;
2584
+ mtime = st.mtimeMs;
2585
+ }
2586
+ catch {
2587
+ continue;
2588
+ }
2589
+ if (now - mtime > maxAgeMs)
2590
+ continue;
2591
+ const raw = readIfFilled(file);
2592
+ if (raw === null)
2593
+ continue;
2594
+ if (parseReport(raw).site !== opts.site)
2595
+ continue;
2596
+ const id = name.slice(0, -3);
2597
+ const prev = found.get(id);
2598
+ if (prev === undefined || mtime > prev)
2599
+ found.set(id, mtime);
2600
+ }
2601
+ }
2602
+ return [...found.entries()]
2603
+ .sort((a, b) => b[1] - a[1])
2604
+ .map(([id]) => id)
2605
+ .slice(0, limit);
2606
+ }
2496
2607
  /**
2497
2608
  * 指示 {id} 単体の現在 status を中央へ問う(外部完了検出の根治プローブ)。
2498
2609
  * GET /api/conductor/commands/{id}(テナントトークン Bearer)。応答 JSON から status 文字列を拾う。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.77",
3
+ "version": "0.12.78",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {