@tyhld/conductor 0.12.96 → 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
  };
@@ -1052,24 +1060,32 @@ export function codexNotifyDir(env = process.env) {
1052
1060
  */
1053
1061
  const CODEX_NOTICE_PARTIAL_WAIT_MS = 5_000;
1054
1062
  /**
1055
- * 受け皿から「まだ渡していない完了」を1件取り出し、ファイルを空にする(=二重に使わない)。
1056
- * 返り: 最新の1件 / null(無い・読めない・まだ書きかけ)。
1063
+ * 受け皿に溜まっている「終わりの知らせ」を【全件】取り出し、ファイルを空にする(=二重に使わない)。
1064
+ * 返り: 古い順の一覧(無い・読めない・まだ書きかけのときは空)。
1057
1065
  * ★読めない・壊れている行は捨てる。relay を止めない(フックと同じ作法)。
1058
1066
  * ★ただし【書きかけの行】は捨てない。捨てて消すと、書き終わったその1行=完了の知らせが
1059
1067
  * 永久に失われる(受け口は1件を1行で書き、書き込みは途中まで見えることがある)。
1060
1068
  * 書きかけが在る間は何も取らずに次の回へ回す(ファイルも消さない)。
1069
+ *
1070
+ * 【★なぜ「全件」にしたか(実測 2026-10-04・便 1c39ac8f / 1427b428)】
1071
+ * 従来は最新の1件だけを返し、同じ回に溜まっていた他の行は黙って消えていた。
1072
+ * ところが受け皿には【どの便にも結び付かないターンの知らせ】が混ざる(Codex 内部の
1073
+ * 会話をまとめ直すターン・人が手で打ったターン・便を閉じたあとに終わったターン)。
1074
+ * 1件に潰してしまうと、それが「どの便の物か」を見分ける材料ごと失われる。
1075
+ * 取り出した全件を judgeCodexNotice で1件ずつ見分ける形にして、使わなかった物は
1076
+ * 理由つきで控えへ書き残す(recordSkippedCodexNotices)。
1061
1077
  */
1062
- export function takeCodexNotice(site, env = process.env) {
1078
+ export function takeCodexNotices(site, env = process.env) {
1063
1079
  const dir = codexNotifyDir(env);
1064
1080
  if (!dir)
1065
- return null;
1081
+ return [];
1066
1082
  const file = path.join(dir, `${site}.jsonl`);
1067
1083
  let raw;
1068
1084
  try {
1069
1085
  raw = readFileSync(file, 'utf8');
1070
1086
  }
1071
1087
  catch {
1072
- return null; // まだ1件も来ていない
1088
+ return []; // まだ1件も来ていない
1073
1089
  }
1074
1090
  if (hasPartialJsonlTail(raw)) {
1075
1091
  let writing = false;
@@ -1080,20 +1096,22 @@ export function takeCodexNotice(site, env = process.env) {
1080
1096
  writing = false; // 時刻が読めなければ、今までどおり捨てて進む
1081
1097
  }
1082
1098
  if (writing)
1083
- return null; // 書き終わってから、次の回で拾う
1099
+ return []; // 書き終わってから、次の回で拾う
1084
1100
  }
1085
1101
  const lines = completeJsonlLines(raw).map((l) => l.trim()).filter((l) => l.length > 0);
1086
- let latest = null;
1102
+ const out = [];
1087
1103
  for (const line of lines) {
1088
1104
  try {
1089
1105
  const o = JSON.parse(line);
1090
1106
  if (typeof o.site === 'string' && o.site === site) {
1091
- latest = {
1107
+ out.push({
1092
1108
  site: o.site,
1093
1109
  report: typeof o.report === 'string' ? o.report : '',
1094
1110
  threadId: o.threadId ?? null,
1095
1111
  turnId: o.turnId ?? null,
1096
- };
1112
+ jobId: typeof o.jobId === 'string' && o.jobId.trim() !== '' ? o.jobId.trim() : null,
1113
+ at: parseNoticeAt(o.at),
1114
+ });
1097
1115
  }
1098
1116
  }
1099
1117
  catch {
@@ -1106,7 +1124,141 @@ export function takeCodexNotice(site, env = process.env) {
1106
1124
  catch {
1107
1125
  /* 消せなくても致命ではない(次回 latest が同じでも patchStatus 側が冪等) */
1108
1126
  }
1109
- 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) };
1110
1262
  }
1111
1263
  // ───────────────────────────────────────────────────────────────────────────
1112
1264
  // Codex の最後の返事に書いてもらう「機械で読める囲み」(便 cfa9eeec)
@@ -4090,8 +4242,25 @@ export async function relayTick(cfg, state, hooks) {
4090
4242
  // ★Codex は完了と報告が notify でデータとして届く(ADR-021)。画面より先にそちらを見る。
4091
4243
  // 届いていれば、そのまま【既存の完了経路】へ橋渡しする(道は1本のまま)。
4092
4244
  if (kindOf(cfg) === 'codex') {
4093
- 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;
4094
4259
  if (notice !== null) {
4260
+ // ★この便のターンは終わった=「前のターンが途中」の札を下ろす。
4261
+ if (state.codexTurnOpen !== null && state.codexTurnOpen.jobId === state.pending.id) {
4262
+ state.codexTurnOpen = null;
4263
+ }
4095
4264
  // ★最後の返事の囲みを読み、職人が書く物と同じ形の報告ファイルを組み立てる(便 cfa9eeec)。
4096
4265
  // 書けたら【いまの完了の道】へそのまま流す(受け側の口は1つも変えない)。
4097
4266
  // 囲みが無い・読めないときは、今までと同じ動き(返事をそのまま報告にして完了)に倒す。
@@ -4336,6 +4505,26 @@ export async function relayTick(cfg, state, hooks) {
4336
4505
  // ── 入れ替え待ちの札(便 b7bd5e43)──札が在るあいだは新しい便を取りに行かない。
4337
4506
  if (!noteDrain(state, cfg.site))
4338
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
+ }
4339
4528
  // ── 新規指示の取得 ──
4340
4529
  const cmd = await fetchCommand(cfg);
4341
4530
  if (!cmd) {
@@ -4357,6 +4546,23 @@ export async function relayTick(cfg, state, hooks) {
4357
4546
  '以後この指示は配達しない(無限ループの構造的封じ込め)');
4358
4547
  return;
4359
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
+ }
4360
4566
  // ── アイドル判定(保守的・二重チェック) ──
4361
4567
  if (!tmuxHasSession(cfg.site)) {
4362
4568
  log(`site=${cfg.site} id=${cmd.id} tmux未起動のため送らずスキップ`);
@@ -4421,6 +4627,19 @@ export async function relayTick(cfg, state, hooks) {
4421
4627
  ? { parentCommandId: cmd.parentCommandId ?? null, decisions: cmd.decisions ?? [] }
4422
4628
  : undefined;
4423
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
+ }
4424
4643
  const sendStartedAt = Date.now();
4425
4644
  // ★Claude の職人へは受領確認を使う(便 7d810bbb)。Codex は会話の記録の形が違うので今までどおり。
4426
4645
  const useAck = kindOf(cfg) === 'claude';
@@ -4469,6 +4688,9 @@ export async function relayTick(cfg, state, hooks) {
4469
4688
  };
4470
4689
  // 累計配達回数を通算(絶対上限 RELAY_DELIVER_MAX の基準)。
4471
4690
  markDelivered(state, cmd.id);
4691
+ // ★Codex へ配った=この便のターンが始まった。終わりの知らせを確かめるまで次は配らない。
4692
+ if (kindOf(cfg) === 'codex')
4693
+ state.codexTurnOpen = { jobId: cmd.id, since: sentAt };
4472
4694
  // 新しい指示の生存ハートビートは先頭から数え直す(前指示のスロットルを引き継がない)。
4473
4695
  state.lastHeartbeatAt = null;
4474
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.96",
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
  )