@tyhld/conductor 0.12.95 → 0.12.97

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
@@ -310,6 +310,12 @@ export function parseResumeSignals(raw) {
310
310
  }
311
311
  /** 連続スキップ何回でアイドル誤判定の疑いをWARNするか(観測のみ・送信挙動は変えない)。 */
312
312
  export const RELAY_SKIP_WARN_THRESHOLD = 6;
313
+ /**
314
+ * Codex の前のターンが終わるのを待つ上限(これを過ぎたら札を下ろして配る)。
315
+ * ★「終わりの知らせが来ない機体でも現場が永久にふさがらない」ための最後の砦。
316
+ * 中央が便を抱える上限(CENTRAL_BUSY_HOLD_MAX_MS=3時間)と同じ値にそろえる。
317
+ */
318
+ export const RELAY_CODEX_TURN_HOLD_MAX_MS = CENTRAL_BUSY_HOLD_MAX_MS;
313
319
  /** 初期状態を作る。 */
314
320
  export function createRelayState() {
315
321
  return {
@@ -329,6 +335,8 @@ export function createRelayState() {
329
335
  busyRaisedId: null,
330
336
  nudgedAt: null,
331
337
  lastNudgeLogAt: null,
338
+ codexTurnOpen: null,
339
+ codexNoticeSeen: false,
332
340
  central: { failures: 0, detail: '', downSince: null },
333
341
  readiness: createReadiness(),
334
342
  };
@@ -532,6 +540,31 @@ export function markDelivered(state, id) {
532
540
  // 純粋関数(外部I/Oなし=単体テスト/ドライランしやすい)
533
541
  // ───────────────────────────────────────────────────────────────────────────
534
542
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
543
+ // ── 行ごとの記録(jsonl)の読み方 ───────────────────────────────────────────
544
+ //
545
+ // 【なぜ要るか(実測 2026-10-04・0.12.95 の公開の流れ)】
546
+ // 行ごとの記録は「書き手が1行を足している最中」に読めてしまう。1行が数千文字あると、
547
+ // 書き込みが途中まで見える瞬間があり、その切れた行を JSON として読むと必ず失敗する
548
+ // (SyntaxError: Unterminated string in JSON)。
549
+ // 【考え方(これ1つで通す)】★読むのは【最後の改行まで】。
550
+ // 改行で終わっていない末尾の行は「まだ書きかけ」として扱い、次に読むときに拾う。
551
+ // =落ちない・取りこぼさない。見張りの流し読み(view-stream の readNew)と同じ考え。
552
+ // 【どこに効かせるか】★読んだあとに【消す】所(Codex の受け皿)だけは、これが無いと
553
+ // 書きかけの1行を捨てたうえにファイルを消すので、その完了が永久に失われる。
554
+ // 一方、行ごとに try/catch で飛ばして次の回に読み直す所(受領確認・受付の確かめ)は
555
+ // それ自体で落ちないので変えない(最後の1行に改行が無い記録を読み落とさないため)。
556
+ /**
557
+ * 書き終わった行(改行で終わっている行)だけを古い順に返す純粋関数。
558
+ * 末尾の書きかけ(改行で終わっていない残り)は返さない(=次に読むときに拾う)。
559
+ */
560
+ export function completeJsonlLines(text) {
561
+ const end = text.lastIndexOf('\n');
562
+ return end < 0 ? [] : text.slice(0, end).split('\n');
563
+ }
564
+ /** 末尾に書きかけの行(改行で終わっていない残り)が在るか。純粋関数。 */
565
+ export function hasPartialJsonlTail(text) {
566
+ return text.length > 0 && !text.endsWith('\n');
567
+ }
535
568
  /** 末尾の空行を除いた「末尾 RELAY_TAIL_LINES 行」を文字列で返す。静止比較の単位。 */
536
569
  export function tailOf(text) {
537
570
  const lines = text.replace(/\r/g, '').split('\n');
@@ -1022,34 +1055,63 @@ export function codexNotifyDir(env = process.env) {
1022
1055
  return home ? path.join(home, '.tyhld', 'codex-notify') : '';
1023
1056
  }
1024
1057
  /**
1025
- * 受け皿から「まだ渡していない完了」を1件取り出し、ファイルを空にする(=二重に使わない)。
1026
- * 返り: 最新の1件 / null(無い・読めない)。
1058
+ * 書きかけの行が末尾に在るとき、書き終わるのを待つ長さ。これを過ぎても改行が来なければ
1059
+ * 書き手が落ちた残骸とみなし、今までどおり捨てて進む(=受け皿が詰まらない)。
1060
+ */
1061
+ const CODEX_NOTICE_PARTIAL_WAIT_MS = 5_000;
1062
+ /**
1063
+ * 受け皿に溜まっている「終わりの知らせ」を【全件】取り出し、ファイルを空にする(=二重に使わない)。
1064
+ * 返り: 古い順の一覧(無い・読めない・まだ書きかけのときは空)。
1027
1065
  * ★読めない・壊れている行は捨てる。relay を止めない(フックと同じ作法)。
1066
+ * ★ただし【書きかけの行】は捨てない。捨てて消すと、書き終わったその1行=完了の知らせが
1067
+ * 永久に失われる(受け口は1件を1行で書き、書き込みは途中まで見えることがある)。
1068
+ * 書きかけが在る間は何も取らずに次の回へ回す(ファイルも消さない)。
1069
+ *
1070
+ * 【★なぜ「全件」にしたか(実測 2026-10-04・便 1c39ac8f / 1427b428)】
1071
+ * 従来は最新の1件だけを返し、同じ回に溜まっていた他の行は黙って消えていた。
1072
+ * ところが受け皿には【どの便にも結び付かないターンの知らせ】が混ざる(Codex 内部の
1073
+ * 会話をまとめ直すターン・人が手で打ったターン・便を閉じたあとに終わったターン)。
1074
+ * 1件に潰してしまうと、それが「どの便の物か」を見分ける材料ごと失われる。
1075
+ * 取り出した全件を judgeCodexNotice で1件ずつ見分ける形にして、使わなかった物は
1076
+ * 理由つきで控えへ書き残す(recordSkippedCodexNotices)。
1028
1077
  */
1029
- export function takeCodexNotice(site, env = process.env) {
1078
+ export function takeCodexNotices(site, env = process.env) {
1030
1079
  const dir = codexNotifyDir(env);
1031
1080
  if (!dir)
1032
- return null;
1081
+ return [];
1033
1082
  const file = path.join(dir, `${site}.jsonl`);
1034
1083
  let raw;
1035
1084
  try {
1036
1085
  raw = readFileSync(file, 'utf8');
1037
1086
  }
1038
1087
  catch {
1039
- return null; // まだ1件も来ていない
1088
+ return []; // まだ1件も来ていない
1040
1089
  }
1041
- const lines = raw.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
1042
- let latest = null;
1090
+ if (hasPartialJsonlTail(raw)) {
1091
+ let writing = false;
1092
+ try {
1093
+ writing = Date.now() - statSync(file).mtimeMs < CODEX_NOTICE_PARTIAL_WAIT_MS;
1094
+ }
1095
+ catch {
1096
+ writing = false; // 時刻が読めなければ、今までどおり捨てて進む
1097
+ }
1098
+ if (writing)
1099
+ return []; // 書き終わってから、次の回で拾う
1100
+ }
1101
+ const lines = completeJsonlLines(raw).map((l) => l.trim()).filter((l) => l.length > 0);
1102
+ const out = [];
1043
1103
  for (const line of lines) {
1044
1104
  try {
1045
1105
  const o = JSON.parse(line);
1046
1106
  if (typeof o.site === 'string' && o.site === site) {
1047
- latest = {
1107
+ out.push({
1048
1108
  site: o.site,
1049
1109
  report: typeof o.report === 'string' ? o.report : '',
1050
1110
  threadId: o.threadId ?? null,
1051
1111
  turnId: o.turnId ?? null,
1052
- };
1112
+ jobId: typeof o.jobId === 'string' && o.jobId.trim() !== '' ? o.jobId.trim() : null,
1113
+ at: parseNoticeAt(o.at),
1114
+ });
1053
1115
  }
1054
1116
  }
1055
1117
  catch {
@@ -1062,7 +1124,141 @@ export function takeCodexNotice(site, env = process.env) {
1062
1124
  catch {
1063
1125
  /* 消せなくても致命ではない(次回 latest が同じでも patchStatus 側が冪等) */
1064
1126
  }
1065
- return latest;
1127
+ return out;
1128
+ }
1129
+ /** 受け口が書いた時刻(ISO の文字列・ミリ秒の数)を ms に読む。読めなければ null。 */
1130
+ function parseNoticeAt(raw) {
1131
+ if (typeof raw === 'number' && Number.isFinite(raw))
1132
+ return raw;
1133
+ if (typeof raw === 'string' && raw.trim() !== '') {
1134
+ const ms = Date.parse(raw.trim());
1135
+ if (Number.isFinite(ms))
1136
+ return ms;
1137
+ }
1138
+ return null;
1139
+ }
1140
+ /**
1141
+ * 受け皿から「まだ渡していない完了」を1件取り出す(最新の1件・ファイルは空にする)。
1142
+ * ★takeCodexNotices の薄い包み。どの便の物かの見分けはしない=relay の本線では使わない
1143
+ * (本線は pickCodexNoticeFor。こちらは受け皿の読み方そのものを確かめるために残す)。
1144
+ */
1145
+ export function takeCodexNotice(site, env = process.env) {
1146
+ const all = takeCodexNotices(site, env);
1147
+ return all.length === 0 ? null : all[all.length - 1];
1148
+ }
1149
+ /** 控えに残す行数の上限(古い物から落とす=受け皿の隣で際限なく育たせない)。 */
1150
+ export const CODEX_SKIPPED_NOTICE_MAX_LINES = 200;
1151
+ /**
1152
+ * 完了に使わなかった知らせを、理由つきで控えへ書き残す(★捨てない)。
1153
+ * 置き場: 受け皿と同じフォルダの `<現場>.skipped.jsonl`。返り: 書けたファイル/null。
1154
+ *
1155
+ * 【なぜ要るか】「別の便の知らせだったので完了にしなかった」を、人があとから1か所で確かめられる
1156
+ * ようにする。relay の記録(ログ)には理由の1行だけを出し、報告の中身はこちらに残す。
1157
+ */
1158
+ export function recordSkippedCodexNotices(site, entries, env = process.env) {
1159
+ if (entries.length === 0)
1160
+ return null;
1161
+ const dir = codexNotifyDir(env);
1162
+ if (!dir)
1163
+ return null;
1164
+ const file = path.join(dir, `${site}.skipped.jsonl`);
1165
+ let kept = [];
1166
+ try {
1167
+ kept = completeJsonlLines(readFileSync(file, 'utf8')).filter((l) => l.trim() !== '');
1168
+ }
1169
+ catch {
1170
+ kept = []; // まだ控えが無い
1171
+ }
1172
+ const added = entries.map((e) => JSON.stringify({
1173
+ skippedAt: new Date().toISOString(),
1174
+ why: e.why,
1175
+ site: e.notice.site,
1176
+ jobId: e.notice.jobId ?? null,
1177
+ at: e.notice.at === null || e.notice.at === undefined ? null : new Date(e.notice.at).toISOString(),
1178
+ threadId: e.notice.threadId ?? null,
1179
+ turnId: e.notice.turnId ?? null,
1180
+ report: e.notice.report,
1181
+ }));
1182
+ const lines = [...kept, ...added].slice(-CODEX_SKIPPED_NOTICE_MAX_LINES);
1183
+ try {
1184
+ mkdirSync(dir, { recursive: true });
1185
+ writeFileSync(file, `${lines.join('\n')}\n`, 'utf8');
1186
+ return file;
1187
+ }
1188
+ catch {
1189
+ return null; // 控えが書けなくても完了の道は止めない(理由はログに1行出してある)
1190
+ }
1191
+ }
1192
+ /** 印を短く見せる(記録に出す用)。 */
1193
+ function shortJobId(raw) {
1194
+ const s = (raw ?? '').trim();
1195
+ return s === '' ? '無し' : s.slice(0, 8);
1196
+ }
1197
+ /**
1198
+ * 終わりの知らせが【その便の物か】を見分ける純粋関数。
1199
+ *
1200
+ * 【根拠(実測 2026-10-04・現場 codex-probe・配り役 0.12.92)】
1201
+ * 便 1c39ac8f は、Codex 内部の「会話をまとめ直す」ターンの知らせ(本文が
1202
+ * `{"summary":…,"next_action":null}`)で、まだ作業中なのに done になった。
1203
+ * その便の【本当のターン】はそのあと終わり、知らせは受け皿に残ったまま誰も取らなかった。
1204
+ * 次の便 1427b428 を配った19秒後、配り役はその残り物を読んで、
1205
+ * **前の便の停止報告を next の便の完了として取り込んだ**。
1206
+ * 従来の判定は「現場が一致・最新の1件」だけで、便の印も時刻も見ていなかった。
1207
+ *
1208
+ * 【見分け方(上から順に効く)】
1209
+ * ① 便の印(受け口が指示本文の `CONDUCTOR_JOB:<id>` から写した jobId)が在れば、それで決める。
1210
+ * ② 報告の囲みに書かれた便IDが食い違えば、別の便の知らせ。
1211
+ * ③ 印が無ければ時刻で決める=「その便を配ったあとに終わったターンか」。
1212
+ * ④ 印も時刻も無い古い形は、この便の物として扱う(配達の直前に受け皿を空にしてあるので、
1213
+ * 残っている物はこの便を配ったあとに届いた物だけ=取りこぼさない)。
1214
+ */
1215
+ export function judgeCodexNotice(notice, job) {
1216
+ if (job === null) {
1217
+ return { accept: false, why: '追っている便が無いあいだに届いた知らせ' };
1218
+ }
1219
+ const jobId = (notice.jobId ?? '').trim();
1220
+ if (jobId !== '' && !codexReportIdMatches(jobId, job.id)) {
1221
+ return { accept: false, why: `別の便のターンの知らせ(便の印=${shortJobId(jobId)})` };
1222
+ }
1223
+ const block = parseCodexReportBlock(notice.report);
1224
+ if (block !== null && !codexReportIdMatches(block.instructionId, job.id)) {
1225
+ return {
1226
+ accept: false,
1227
+ why: `報告の囲みの便IDが別の便(囲みの印=${shortJobId(block.instructionId)})`,
1228
+ };
1229
+ }
1230
+ if (jobId !== '')
1231
+ return { accept: true, why: '便の印が一致した' };
1232
+ const at = notice.at ?? null;
1233
+ if (at !== null) {
1234
+ return at >= job.sentAt
1235
+ ? { accept: true, why: 'この便を配ったあとに終わったターンの知らせ' }
1236
+ : { accept: false, why: 'この便を配る前に終わったターンの知らせ' };
1237
+ }
1238
+ return {
1239
+ accept: true,
1240
+ why: '便の印も時刻も無い古い形(配る直前に受け皿を空にしてあるので、この便のターンとして扱う)',
1241
+ };
1242
+ }
1243
+ /**
1244
+ * 受け皿を空にして、【その便の物だと確かめられた知らせ】だけを返す。
1245
+ * 確かめられなかった知らせは完了に使わず、理由つきで控えへ書き残す(捨てない)。
1246
+ * job に null を渡すと「全件を控えへ回す」=受け皿を空にするだけになる(配る直前の空にし/始末)。
1247
+ */
1248
+ export function pickCodexNoticeFor(site, job, env = process.env) {
1249
+ const all = takeCodexNotices(site, env);
1250
+ let notice = null;
1251
+ const skipped = [];
1252
+ for (const n of all) {
1253
+ const v = judgeCodexNotice(n, job);
1254
+ if (v.accept) {
1255
+ notice = n; // 2件以上あれば新しい方を使う(古い順に見ている)
1256
+ }
1257
+ else {
1258
+ skipped.push({ notice: n, why: v.why });
1259
+ }
1260
+ }
1261
+ return { notice, skipped, skippedFile: recordSkippedCodexNotices(site, skipped, env) };
1066
1262
  }
1067
1263
  // ───────────────────────────────────────────────────────────────────────────
1068
1264
  // Codex の最後の返事に書いてもらう「機械で読める囲み」(便 cfa9eeec)
@@ -2606,6 +2802,8 @@ export function hasSubmittedRecord(cwd, commandId, sinceMs, env = process.env) {
2606
2802
  const text = readFileSync(file, 'utf8');
2607
2803
  if (!text.includes(needle))
2608
2804
  continue;
2805
+ // ★書きかけの行(改行が来ていない末尾)は JSON として読めないので下の catch で飛ばす=
2806
+ // relay は落ちず、次の回に書き終わってから拾う(★最後の1行に改行が無い記録も読み落とさない)。
2609
2807
  for (const line of text.split('\n')) {
2610
2808
  if (!line.includes(needle))
2611
2809
  continue;
@@ -2693,6 +2891,8 @@ export function hasJobStampSince(cwd, commandId, sinceMs, env = process.env) {
2693
2891
  const text = readFileSync(file, 'utf8');
2694
2892
  if (!text.includes(needle))
2695
2893
  continue;
2894
+ // ★書きかけの行(改行が来ていない末尾)は JSON として読めないので下の catch で飛ばす=
2895
+ // relay は落ちず、次の回に書き終わってから拾う(★最後の1行に改行が無い記録も読み落とさない)。
2696
2896
  for (const line of text.split('\n')) {
2697
2897
  if (!hasExactJobStamp(line, commandId))
2698
2898
  continue;
@@ -4042,8 +4242,25 @@ export async function relayTick(cfg, state, hooks) {
4042
4242
  // ★Codex は完了と報告が notify でデータとして届く(ADR-021)。画面より先にそちらを見る。
4043
4243
  // 届いていれば、そのまま【既存の完了経路】へ橋渡しする(道は1本のまま)。
4044
4244
  if (kindOf(cfg) === 'codex') {
4045
- const notice = takeCodexNotice(cfg.site);
4245
+ // ★その便の物だと確かめられた知らせだけを受け取る(便 99fc49c0)。確かめられない知らせでは
4246
+ // どの便も完了にしない(捨てずに、理由つきで控えへ残す=pickCodexNoticeFor)。
4247
+ const pick = pickCodexNoticeFor(cfg.site, {
4248
+ id: state.pending.id,
4249
+ sentAt: state.pending.sentAt,
4250
+ });
4251
+ if (pick.notice !== null || pick.skipped.length > 0)
4252
+ state.codexNoticeSeen = true;
4253
+ for (const s of pick.skipped) {
4254
+ log(`WARN site=${cfg.site} id=${state.pending.id} 終わりの知らせを完了に使いませんでした` +
4255
+ `(${s.why}・turn=${s.notice.turnId ?? '不明'})` +
4256
+ `${pick.skippedFile === null ? '' : `→ 控え: ${pick.skippedFile}`}`);
4257
+ }
4258
+ const notice = pick.notice;
4046
4259
  if (notice !== null) {
4260
+ // ★この便のターンは終わった=「前のターンが途中」の札を下ろす。
4261
+ if (state.codexTurnOpen !== null && state.codexTurnOpen.jobId === state.pending.id) {
4262
+ state.codexTurnOpen = null;
4263
+ }
4047
4264
  // ★最後の返事の囲みを読み、職人が書く物と同じ形の報告ファイルを組み立てる(便 cfa9eeec)。
4048
4265
  // 書けたら【いまの完了の道】へそのまま流す(受け側の口は1つも変えない)。
4049
4266
  // 囲みが無い・読めないときは、今までと同じ動き(返事をそのまま報告にして完了)に倒す。
@@ -4288,6 +4505,26 @@ export async function relayTick(cfg, state, hooks) {
4288
4505
  // ── 入れ替え待ちの札(便 b7bd5e43)──札が在るあいだは新しい便を取りに行かない。
4289
4506
  if (!noteDrain(state, cfg.site))
4290
4507
  return;
4508
+ // ── Codex のターンの始末(★便 99fc49c0・根治の要) ──
4509
+ // 追っている便が無いあいだに届いた終わりの知らせは、【どの便の完了にも使わない】。
4510
+ // 便を閉じたあとに終わったターンの知らせがここで受け皿から取り除かれるので、次の便を
4511
+ // 配ったときに受け皿に残っていて誤って取り込まれる、という筋道が構造的に消える。
4512
+ // ★併せて「前のターンがまだ途中」の札を下ろす(終わったと分かったので次を配れる)。
4513
+ if (kindOf(cfg) === 'codex') {
4514
+ const left = pickCodexNoticeFor(cfg.site, null);
4515
+ if (left.skipped.length > 0) {
4516
+ state.codexNoticeSeen = true;
4517
+ log(`site=${cfg.site} 追っている便が無いあいだに届いた終わりの知らせ ${left.skipped.length} 件は、` +
4518
+ `どの便の完了にも使わず理由つきで記録しました` +
4519
+ `${left.skippedFile === null ? '' : `(控え: ${left.skippedFile})`}`);
4520
+ const open = state.codexTurnOpen;
4521
+ if (open !== null &&
4522
+ left.skipped.some((s) => codexReportIdMatches(s.notice.jobId ?? null, open.jobId))) {
4523
+ state.codexTurnOpen = null;
4524
+ log(`site=${cfg.site} id=${open.jobId} のターンが終わったと分かりました(次の便を配れます)`);
4525
+ }
4526
+ }
4527
+ }
4291
4528
  // ── 新規指示の取得 ──
4292
4529
  const cmd = await fetchCommand(cfg);
4293
4530
  if (!cmd) {
@@ -4309,6 +4546,23 @@ export async function relayTick(cfg, state, hooks) {
4309
4546
  '以後この指示は配達しない(無限ループの構造的封じ込め)');
4310
4547
  return;
4311
4548
  }
4549
+ // ── Codex の前のターンが終わるまで配らない(★便 99fc49c0) ──
4550
+ // 画面の読みとは別に、配り役が自分で配った便のターンが終わったことをデータで確かめる。
4551
+ // ★受け口の知らせを一度も見ていない現場では止めない(受け口が入っていない機体で
4552
+ // 現場が永久にふさがらないため)。上限を過ぎたときも札を下ろして進む。
4553
+ if (kindOf(cfg) === 'codex' && state.codexTurnOpen !== null && state.codexNoticeSeen) {
4554
+ const open = state.codexTurnOpen;
4555
+ const held = Date.now() - open.since;
4556
+ if (held < RELAY_CODEX_TURN_HOLD_MAX_MS) {
4557
+ noteReadiness(state, 'busy', cmd.id);
4558
+ log(`site=${cfg.site} id=${cmd.id} 前の便(id=${open.jobId})のターンがまだ終わっていないので配りません` +
4559
+ `(${Math.round(held / 1000)}秒待っています)`);
4560
+ return;
4561
+ }
4562
+ log(`WARN site=${cfg.site} id=${cmd.id} 前の便(id=${open.jobId})のターンの終わりの知らせが` +
4563
+ `${Math.round(RELAY_CODEX_TURN_HOLD_MAX_MS / 60000)}分来ないので、札を下ろして配ります`);
4564
+ state.codexTurnOpen = null;
4565
+ }
4312
4566
  // ── アイドル判定(保守的・二重チェック) ──
4313
4567
  if (!tmuxHasSession(cfg.site)) {
4314
4568
  log(`site=${cfg.site} id=${cmd.id} tmux未起動のため送らずスキップ`);
@@ -4373,6 +4627,19 @@ export async function relayTick(cfg, state, hooks) {
4373
4627
  ? { parentCommandId: cmd.parentCommandId ?? null, decisions: cmd.decisions ?? [] }
4374
4628
  : undefined;
4375
4629
  const text = buildSendText(cmd.body, cfg.site, anchor, kindOf(cfg), context);
4630
+ // ── 配る直前に受け皿を空にする(★便 99fc49c0・取り違えの根を断つ) ──
4631
+ // ここに残っている知らせは、どれも【この便を配る前に終わったターン】の物。完了に使わず、
4632
+ // 理由つきで控えへ回す。こうしておけば、以後この便の待ちで受け皿に現れる知らせは
4633
+ // 「この便を配ったあとに届いた物」だけになる=古い形(印も時刻も無い)でも取り違えない。
4634
+ if (kindOf(cfg) === 'codex') {
4635
+ const stale = pickCodexNoticeFor(cfg.site, null);
4636
+ if (stale.skipped.length > 0) {
4637
+ state.codexNoticeSeen = true;
4638
+ log(`site=${cfg.site} id=${cmd.id} 配る前に受け皿に在った終わりの知らせ ${stale.skipped.length} 件は、` +
4639
+ `完了に使わず理由つきで記録して空にしました` +
4640
+ `${stale.skippedFile === null ? '' : `(控え: ${stale.skippedFile})`}`);
4641
+ }
4642
+ }
4376
4643
  const sendStartedAt = Date.now();
4377
4644
  // ★Claude の職人へは受領確認を使う(便 7d810bbb)。Codex は会話の記録の形が違うので今までどおり。
4378
4645
  const useAck = kindOf(cfg) === 'claude';
@@ -4421,6 +4688,9 @@ export async function relayTick(cfg, state, hooks) {
4421
4688
  };
4422
4689
  // 累計配達回数を通算(絶対上限 RELAY_DELIVER_MAX の基準)。
4423
4690
  markDelivered(state, cmd.id);
4691
+ // ★Codex へ配った=この便のターンが始まった。終わりの知らせを確かめるまで次は配らない。
4692
+ if (kindOf(cfg) === 'codex')
4693
+ state.codexTurnOpen = { jobId: cmd.id, since: sentAt };
4424
4694
  // 新しい指示の生存ハートビートは先頭から数え直す(前指示のスロットルを引き継がない)。
4425
4695
  state.lastHeartbeatAt = null;
4426
4696
  noteReadiness(state, 'delivered', cmd.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.95",
3
+ "version": "0.12.97",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,25 +14,49 @@
14
14
  "input-messages":["<送った指示>"],
15
15
  "last-assistant-message":"<職人の最後の発言>"}
16
16
 
17
- 【★ふるい分け(実測に基づく)】1回の作業で通知が2件飛ぶ。
18
- 1件は Codex 内部の「会話に題名を付ける」ターンで、次の特徴を持つ:
17
+ 【★ふるい分け(実測に基づく)】1回の作業で通知が2件以上飛ぶ。
18
+ 人の指示の完了ではない「Codex 内部のターン」が混ざり、次の特徴を持つ:
19
19
  ・input-messages[0] が英文の指示で始まる
20
20
  (実測値: "Generate a concise, single-line task title of at most 36 characters…")
21
- ・last-assistant-message が {"title":"…"} という JSON 文字列
22
- この2つのどちらかに当たる通知は【捨てる】。人が出した指示の完了だけを残す。
21
+ ・last-assistant-message が JSON の物そのもの
22
+ (実測値: {"title":"…"} / {"summary":"…","next_action":null})
23
+ これに当たる通知は【捨てる】。人が出した指示の完了だけを残す。
24
+
25
+ ★`{"summary":…}`(会話をまとめ直すターン)を落とすようにしたのは 2026-10-04 の実地の事故から。
26
+ 便 1c39ac8f は、このターンの知らせを「便の完了」として取り込まれ、Codex がまだ作業中の
27
+ 13:09:40 に done になった(本文は前の仕事の要約だった)。受け皿の実物にはこの形が5件
28
+ 溜まっていた(~/.tyhld/codex-notify/video-lab.jsonl)。
29
+ 判定は「本文まるごとが JSON の物(dict)なら内部のターン」に一般化した。職人の報告は
30
+ 地の文なので当たらない。★ただし管制の報告の囲みを含む本文は、念のため必ず残す。
31
+
32
+ 【★便の印と時刻を書く(2026-10-04・便 99fc49c0)】
33
+ 受け皿の1行に、次の2つを足す。
34
+ ・jobId … 届いた指示本文の末尾の管理用ジョブ印 `CONDUCTOR_JOB:<id>` から写した便のID。
35
+ 人が手で打ったターンには印が無いので None。
36
+ ・at … この行を書いた時刻(ISO8601・UTC)。
37
+ これが無かったせいで、配り役は「最新の1行=いま追っている便の完了」としか読めず、
38
+ 前の便のターンの知らせを次の便の完了として取り込んだ(便 1427b428 が19秒で done)。
23
39
 
24
40
  【やること】残す通知だけを、現場ごとの受け皿へ1行足す。
25
41
  置き場: $CONDUCTOR_CODEX_NOTIFY_DIR(既定 $HOME/.tyhld/codex-notify)/<現場>.jsonl
26
42
  ★中央へは送らない。送るのは relay の仕事(完了の経路は1本に保つ)。
27
43
  ★何があっても exit 0(Codex の動作を止めない)。
28
44
  """
45
+ import datetime
29
46
  import json
30
47
  import os
48
+ import re
31
49
  import sys
32
50
 
33
51
  # 内部の「題名を付ける」ターンの目印(実測値の先頭部分)。
34
52
  TITLE_TURN_PROMPT = 'Generate a concise, single-line task title'
35
53
 
54
+ # 管制の報告の囲み(この字面を含む本文は、形が JSON に見えても内部のターンとして落とさない)。
55
+ REPORT_MARK = 'CONDUCTOR_REPORT'
56
+
57
+ # 指示本文の末尾に付く管理用ジョブ印。
58
+ JOB_STAMP = re.compile(r'CONDUCTOR_JOB:\s*([0-9A-Za-z][0-9A-Za-z-]{7,})')
59
+
36
60
 
37
61
  def notify_dir(env=None):
38
62
  env = os.environ if env is None else env
@@ -49,7 +73,7 @@ def site_of(cwd):
49
73
 
50
74
 
51
75
  def is_internal_turn(payload):
52
- """Codex 内部の「題名を付ける」ターンなら True(=人の指示の完了ではない)。"""
76
+ """Codex 内部のターン(題名を付ける・会話をまとめ直す)なら True(=人の指示の完了ではない)。"""
53
77
  msgs = payload.get('input-messages')
54
78
  if isinstance(msgs, list) and msgs and isinstance(msgs[0], str):
55
79
  if TITLE_TURN_PROMPT in msgs[0]:
@@ -57,11 +81,32 @@ def is_internal_turn(payload):
57
81
  last = payload.get('last-assistant-message')
58
82
  if isinstance(last, str):
59
83
  stripped = last.strip()
60
- if stripped.startswith('{') and '"title"' in stripped:
61
- return True
84
+ if REPORT_MARK in stripped:
85
+ return False # 管制の報告の囲みが在る本文は、必ず残す
86
+ if stripped.startswith('{') and stripped.endswith('}'):
87
+ try:
88
+ parsed = json.loads(stripped)
89
+ except Exception: # noqa: BLE001
90
+ parsed = None
91
+ if isinstance(parsed, dict):
92
+ return True # 本文まるごとが JSON の物=内部のターン(title / summary など)
62
93
  return False
63
94
 
64
95
 
96
+ def job_id_of(payload):
97
+ """届いた指示本文の末尾の管理用ジョブ印から便のIDを読む。無ければ None。"""
98
+ msgs = payload.get('input-messages')
99
+ if not isinstance(msgs, list):
100
+ return None
101
+ found = None
102
+ for m in msgs:
103
+ if not isinstance(m, str):
104
+ continue
105
+ for hit in JOB_STAMP.finditer(m):
106
+ found = hit.group(1) # 同じ本文に複数在れば最後の物(末尾の印)
107
+ return found
108
+
109
+
65
110
  def decide(payload):
66
111
  """受け取った通知をどう扱うか。返り: (残すか, 現場名, 報告本文, 理由)"""
67
112
  if not isinstance(payload, dict):
@@ -69,7 +114,7 @@ def decide(payload):
69
114
  if payload.get('type') != 'agent-turn-complete':
70
115
  return False, '', '', 'other-type'
71
116
  if is_internal_turn(payload):
72
- return False, '', '', 'internal-title-turn'
117
+ return False, '', '', 'internal-turn'
73
118
  site = site_of(payload.get('cwd'))
74
119
  if not site:
75
120
  return False, '', '', 'no-site'
@@ -99,6 +144,9 @@ def main(argv):
99
144
  'report': report,
100
145
  'threadId': payload.get('thread-id'),
101
146
  'turnId': payload.get('turn-id'),
147
+ # ★どの便のターンの知らせかを配り役が確かめられるようにする(便 99fc49c0)。
148
+ 'jobId': job_id_of(payload),
149
+ 'at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
102
150
  },
103
151
  ensure_ascii=False,
104
152
  )