@tyhld/conductor 0.12.96 → 0.12.98

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/headless.js CHANGED
@@ -19,7 +19,7 @@ import { createInterface } from 'node:readline';
19
19
  import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
20
20
  import path from 'node:path';
21
21
  import os from 'node:os';
22
- import { CENTRAL_BUSY_HOLD_MAX_MS, RELAY_DELIVER_MAX, RELAY_FETCH_TIMEOUT_MS, RELAY_HEARTBEAT_INTERVAL_MS, RELAY_RESTORE_ORPHANS_ENABLED, ackResumeSignal, buildAnchor, buildSendText, canDeliver, completeWithReport, createRelayState, deliverJobEndFacts, deliveryReport, fetchCommand, fetchCommandStatus, fetchControl, fetchOrphans, flushPendingJobEndFacts, isTerminalCommandStatus, kindOf, markDelivered, noteCentralDown, noteCentralUp, noteDrain, noteReadiness, patchStatus, recoverStuckProcessedReports, reconnectDelayMs, FINISH_GATE_KINDS, reportDelivery, 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, ackResumeSignal, buildAnchor, buildSendText, canDeliver, completeWithReport, createRelayState, deliverJobEndFacts, deliveryReport, fetchCommand, fetchCommandStatus, fetchControl, fetchOrphans, flushPendingJobEndFacts, isTerminalCommandStatus, kindOf, markDelivered, noteCentralDown, noteCentralUp, noteDrain, noteReadiness, patchStatus, recoverStuckProcessedReports, reconnectDelayMs, WAIT_CLOSED_STATUS, FINISH_GATE_KINDS, reportDelivery, selectOrphanToRestore, sendHeartbeat, } from './relay.js';
23
23
  import { claudePinPath, readClaudePinLine, } from './autoupdate-flags.js';
24
24
  const log = (msg) => {
25
25
  console.log(`[headless] ${msg}`);
@@ -1792,8 +1792,10 @@ export const RESUME_MEMO_MAX = 200;
1792
1792
  * perm_wait … 人の手番で止まっている(本番操作待ちの便はここで待つ)。この現場を占有し続ける。
1793
1793
  * sent … 配達済みのまま(管制が戻した・まだ閉じていない)。
1794
1794
  * ★done / failed / dismissed / queued では起こさない(終わった便・まだ配っていない便を起こさない)。
1795
+ * ★待ちの綴りは1か所(relay.ts の WAIT_CLOSED_STATUS)。「起こせる状態」と「完了を書かない状態」が
1796
+ * 食い違うと、起こせない便を完了にする/完了にできる便を起こそうとする、のどちらかが起きる。
1795
1797
  */
1796
- export const RESUMABLE_CENTRAL_STATUSES = new Set(['perm_wait', 'sent']);
1798
+ export const RESUMABLE_CENTRAL_STATUSES = new Set([WAIT_CLOSED_STATUS, 'sent']);
1797
1799
  /** 「この便は続きから起こせる」の控えを覚えておく件数の上限。 */
1798
1800
  export const RESUMABLE_MEMO_MAX = 20;
1799
1801
  /** 現場ごとの「使い終わった合図」の記録ファイル。 */
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)
@@ -1436,6 +1588,38 @@ export function isCentralFinished(status) {
1436
1588
  const s = status.trim().toLowerCase();
1437
1589
  return isTerminalCommandStatus(s) || CENTRAL_EXTRA_TERMINAL_STATUSES.has(s);
1438
1590
  }
1591
+ /**
1592
+ * 管制が「人の手番で待っている(=まだ終わっていない)」と言うときの status(★綴りは1か所)。
1593
+ *
1594
+ * 本番操作の承認が長引いた便は、職人が `conductor_complete` を `waitForProdOp:true` で呼ぶと
1595
+ * `done` にならずこの状態のまま閉じられる(管制 ai-kana PR #425)。本番操作が済むと管制の合図で
1596
+ * 【同じ便の続き】から起きる(headless.ts の RESUMABLE_CENTRAL_STATUSES も同じ1語を使う)。
1597
+ */
1598
+ export const WAIT_CLOSED_STATUS = 'perm_wait';
1599
+ /**
1600
+ * 中央が「この便は人の手番で待っている=まだ終わっていない」と言っているか(純粋関数)。
1601
+ *
1602
+ * 【なぜ要るか(便 f20dd761)】待ちのまま閉じた便を、職人の `DONE:` の1行やプロセスの正常終了で
1603
+ * 配り役が `done` にしてしまうと、合図では二度と起きない(「終端は起こさない=Fail Closed」は
1604
+ * 正しいので緩めない)。だから【完了を書く前に】中央へ聞いて、待ちで閉じられていれば書かない。
1605
+ *
1606
+ * 【★確かめられる材料はこれだけ】配り役がいま使っている口
1607
+ * `GET /api/conductor/commands/{id}` は status しか返さない(statusNote も依頼の行も返らない)。
1608
+ * つまり「本番操作の待ち」か「それ以外の人待ち」かは、この口では見分けられない。
1609
+ * 【どちらへ倒すか=書かない側】理由は取り返しのつきかた:
1610
+ * ・書いてしまうと終端=二度と起こせない(作業がまるごと失われる)。
1611
+ * ・書かないでおけば、この状態は管制の RECOVERABLE_STATUSES に入っているので、職人の
1612
+ * `conductor_complete` でも人の操作でも、あとから `done` へ回収できる。
1613
+ * =「待ちでない便の完了」は取りこぼしても回収できるが、「待ちの便の完了」は回収できない。
1614
+ * 【★中央不達(null)は今までどおり】status を読めないときは false=従来の動きへ無損失で縮退する
1615
+ * (読めないだけで全部の便が終われなくなる形にしない)。この穴を塞ぐには管制側が要る
1616
+ * (報告の「要判断」に、どの口に何が要るかを書いた)。
1617
+ */
1618
+ export function isWaitClosedCommandStatus(status) {
1619
+ if (status === null)
1620
+ return false;
1621
+ return status.trim().toLowerCase() === WAIT_CLOSED_STATUS;
1622
+ }
1439
1623
  /**
1440
1624
  * sent孤児を pending 構造へ変換する純粋関数(★送信はしない)。
1441
1625
  * - anchor は buildAnchor(id) で再生成(id 不変=アンカー不変)=完了検出の境界が復元前と揃う。
@@ -1605,7 +1789,17 @@ export function fullCompletionGuide(site, commandId) {
1605
1789
  `reportに理由を書いてください。ブランチを切らない読み取り専用の作業では outcome=no_change を渡してください` +
1606
1790
  `(branchは省略可)。` +
1607
1791
  // ★nextWork(次の独立作業候補)は必ず書く(conductor_complete の説明と同じ・便 a5306df2 で両方にそろえた)。
1608
- `nextWork(次の独立作業候補)は必ず書いてください(無ければ「なし」と理由)。\n\n`);
1792
+ `nextWork(次の独立作業候補)は必ず書いてください(無ければ「なし」と理由)。\n\n` +
1793
+ // ★本番操作の承認待ちで「待ち」のまま閉じる手番だけの例外(便 f20dd761・管制 PR #425)。
1794
+ // ここに書いてある決まりは、鉄則の「便の終え方」の⑤と中身を同じに保つ(等しさの試験が見る)。
1795
+ `★本番操作の承認が長引いて、便を「待ち」のまま閉じるときだけは例外です。` +
1796
+ `conductor_complete を waitForProdOp:true(分かれば prodOpRequestId も)で呼ぶと、` +
1797
+ `便は完了にならず「本番操作の承認待ち」のまま閉じます。` +
1798
+ `★この手番では ${marker} の行を出さないでください` +
1799
+ `(出すと配り役の完了検出がこの便を完了=終端にしてしまい、本番操作が済んでも続きから起こせなくなります)。` +
1800
+ `報告ファイルは置いてよいです(報告ファイルだけでは便は完了になりません)。` +
1801
+ `本番操作が済むと、管制の合図で同じ便の続きから起きます。続きを行い、` +
1802
+ `本当に終わったときに今までどおり(${marker} も出して)終えてください。\n\n`);
1609
1803
  }
1610
1804
  /**
1611
1805
  * Codex の職人へ付ける完了案内(便 cfa9eeec・★Claude 向けは shortCompletionLine)。
@@ -3099,8 +3293,27 @@ export async function fetchCommand(cfg) {
3099
3293
  * null/未指定なら report は送らず status のみ(従来どおり)。
3100
3294
  * - 409(他が sent 中などの安全弁)→ false(送らない)。
3101
3295
  * - 200 → true。
3296
+ *
3297
+ * 【★完了(done)だけは、書く前に中央へ聞く(便 f20dd761)】
3298
+ * 待ちのまま閉じられた便(isWaitClosedCommandStatus)に `done` を書くと、その便は終端になり
3299
+ * 管制の合図では二度と起きない。職人の作法(「待ちで閉じた手番では DONE: を出さない」)だけに
3300
+ * 頼らず、★完了を書く道そのものを1か所でふさぐ(ここを通らずに done を書く道は無い)。
3301
+ * ・`done` 以外(delivering / sent / failed)は1バイトも動きが変わらない。
3302
+ * ★failed は塞がない:起こし直しに失敗した等の「動かせない事実」は管制へ届かないと困る
3303
+ * (headless の再開の道は、起こせなかったとき perm_wait の便へ failed と理由を書く)。
3304
+ * ・返りは true=「書かなかったが、やり直す意味も無い(便の状態は管制が持っている)」。
3305
+ * 呼び出し側は追跡を畳んでよい。false(=次の周でやり直す)に倒すと、画面なし方式が
3306
+ * 上限回数のあとに `failed` を書いてしまい、待ちの便を殺す。
3102
3307
  */
3103
3308
  export async function patchStatus(cfg, id, status, report) {
3309
+ if (status === 'done') {
3310
+ const held = await fetchCommandStatus(cfg, id);
3311
+ if (isWaitClosedCommandStatus(held)) {
3312
+ log(`site=${cfg.site} id=${id} 完了は書きません(管制は status=${held}=人の手番で待っています)` +
3313
+ '→本番操作が済めば管制の合図で同じ便の続きから起きます');
3314
+ return true;
3315
+ }
3316
+ }
3104
3317
  try {
3105
3318
  const payload = report != null ? { status, report } : { status };
3106
3319
  const res = await fetch(`${cfg.url}/api/conductor/commands/${encodeURIComponent(id)}`, {
@@ -3178,6 +3391,18 @@ export async function completeWithReport(cfg, id, logLine = log) {
3178
3391
  : '→relayからは送りません(報告は控えとして処理済みにしました)'));
3179
3392
  return true;
3180
3393
  }
3394
+ if (isWaitClosedCommandStatus(already)) {
3395
+ // ★待ちのまま閉じられた便(本番操作の承認待ち・管制 PR #425)。★完了を1バイトも書かない。
3396
+ // 職人の作法(「待ちで閉じた手番では DONE: を出さない」)が守られなくても、画面の DONE でも
3397
+ // プロセスの正常終了でも、ここで止まる=合図で同じ便の続きから起こせる状態を壊さない。
3398
+ // ★報告ファイルは「処理済み」に移さない。移すと職人が続きで書き直した報告と混ざる
3399
+ // (移さなくても回収の道が送る=その口は便を完了にしない)。
3400
+ // ★返りは true=追跡を畳んでよい。管制はこの便で現場を占有し続けるので、次の便は配られない。
3401
+ logLine(`site=${cfg.site} id=${id} は待ちのまま閉じられています(status=${already})` +
3402
+ '→完了は書きません(本番操作が済めば管制の合図で同じ便の続きから起きます)' +
3403
+ (phase === 'none' ? '' : `/報告は手元の控えに残します(${phase})`));
3404
+ return true;
3405
+ }
3181
3406
  if (phase === 'processed') {
3182
3407
  // ★①は済み・②は未了。本文は渡してあるので、状態だけを書く(開発履歴を2件にしない)。
3183
3408
  // 書けなければ false=次の周でやり直す(完了を落とさない)。
@@ -4090,8 +4315,25 @@ export async function relayTick(cfg, state, hooks) {
4090
4315
  // ★Codex は完了と報告が notify でデータとして届く(ADR-021)。画面より先にそちらを見る。
4091
4316
  // 届いていれば、そのまま【既存の完了経路】へ橋渡しする(道は1本のまま)。
4092
4317
  if (kindOf(cfg) === 'codex') {
4093
- const notice = takeCodexNotice(cfg.site);
4318
+ // ★その便の物だと確かめられた知らせだけを受け取る(便 99fc49c0)。確かめられない知らせでは
4319
+ // どの便も完了にしない(捨てずに、理由つきで控えへ残す=pickCodexNoticeFor)。
4320
+ const pick = pickCodexNoticeFor(cfg.site, {
4321
+ id: state.pending.id,
4322
+ sentAt: state.pending.sentAt,
4323
+ });
4324
+ if (pick.notice !== null || pick.skipped.length > 0)
4325
+ state.codexNoticeSeen = true;
4326
+ for (const s of pick.skipped) {
4327
+ log(`WARN site=${cfg.site} id=${state.pending.id} 終わりの知らせを完了に使いませんでした` +
4328
+ `(${s.why}・turn=${s.notice.turnId ?? '不明'})` +
4329
+ `${pick.skippedFile === null ? '' : `→ 控え: ${pick.skippedFile}`}`);
4330
+ }
4331
+ const notice = pick.notice;
4094
4332
  if (notice !== null) {
4333
+ // ★この便のターンは終わった=「前のターンが途中」の札を下ろす。
4334
+ if (state.codexTurnOpen !== null && state.codexTurnOpen.jobId === state.pending.id) {
4335
+ state.codexTurnOpen = null;
4336
+ }
4095
4337
  // ★最後の返事の囲みを読み、職人が書く物と同じ形の報告ファイルを組み立てる(便 cfa9eeec)。
4096
4338
  // 書けたら【いまの完了の道】へそのまま流す(受け側の口は1つも変えない)。
4097
4339
  // 囲みが無い・読めないときは、今までと同じ動き(返事をそのまま報告にして完了)に倒す。
@@ -4336,6 +4578,26 @@ export async function relayTick(cfg, state, hooks) {
4336
4578
  // ── 入れ替え待ちの札(便 b7bd5e43)──札が在るあいだは新しい便を取りに行かない。
4337
4579
  if (!noteDrain(state, cfg.site))
4338
4580
  return;
4581
+ // ── Codex のターンの始末(★便 99fc49c0・根治の要) ──
4582
+ // 追っている便が無いあいだに届いた終わりの知らせは、【どの便の完了にも使わない】。
4583
+ // 便を閉じたあとに終わったターンの知らせがここで受け皿から取り除かれるので、次の便を
4584
+ // 配ったときに受け皿に残っていて誤って取り込まれる、という筋道が構造的に消える。
4585
+ // ★併せて「前のターンがまだ途中」の札を下ろす(終わったと分かったので次を配れる)。
4586
+ if (kindOf(cfg) === 'codex') {
4587
+ const left = pickCodexNoticeFor(cfg.site, null);
4588
+ if (left.skipped.length > 0) {
4589
+ state.codexNoticeSeen = true;
4590
+ log(`site=${cfg.site} 追っている便が無いあいだに届いた終わりの知らせ ${left.skipped.length} 件は、` +
4591
+ `どの便の完了にも使わず理由つきで記録しました` +
4592
+ `${left.skippedFile === null ? '' : `(控え: ${left.skippedFile})`}`);
4593
+ const open = state.codexTurnOpen;
4594
+ if (open !== null &&
4595
+ left.skipped.some((s) => codexReportIdMatches(s.notice.jobId ?? null, open.jobId))) {
4596
+ state.codexTurnOpen = null;
4597
+ log(`site=${cfg.site} id=${open.jobId} のターンが終わったと分かりました(次の便を配れます)`);
4598
+ }
4599
+ }
4600
+ }
4339
4601
  // ── 新規指示の取得 ──
4340
4602
  const cmd = await fetchCommand(cfg);
4341
4603
  if (!cmd) {
@@ -4357,6 +4619,23 @@ export async function relayTick(cfg, state, hooks) {
4357
4619
  '以後この指示は配達しない(無限ループの構造的封じ込め)');
4358
4620
  return;
4359
4621
  }
4622
+ // ── Codex の前のターンが終わるまで配らない(★便 99fc49c0) ──
4623
+ // 画面の読みとは別に、配り役が自分で配った便のターンが終わったことをデータで確かめる。
4624
+ // ★受け口の知らせを一度も見ていない現場では止めない(受け口が入っていない機体で
4625
+ // 現場が永久にふさがらないため)。上限を過ぎたときも札を下ろして進む。
4626
+ if (kindOf(cfg) === 'codex' && state.codexTurnOpen !== null && state.codexNoticeSeen) {
4627
+ const open = state.codexTurnOpen;
4628
+ const held = Date.now() - open.since;
4629
+ if (held < RELAY_CODEX_TURN_HOLD_MAX_MS) {
4630
+ noteReadiness(state, 'busy', cmd.id);
4631
+ log(`site=${cfg.site} id=${cmd.id} 前の便(id=${open.jobId})のターンがまだ終わっていないので配りません` +
4632
+ `(${Math.round(held / 1000)}秒待っています)`);
4633
+ return;
4634
+ }
4635
+ log(`WARN site=${cfg.site} id=${cmd.id} 前の便(id=${open.jobId})のターンの終わりの知らせが` +
4636
+ `${Math.round(RELAY_CODEX_TURN_HOLD_MAX_MS / 60000)}分来ないので、札を下ろして配ります`);
4637
+ state.codexTurnOpen = null;
4638
+ }
4360
4639
  // ── アイドル判定(保守的・二重チェック) ──
4361
4640
  if (!tmuxHasSession(cfg.site)) {
4362
4641
  log(`site=${cfg.site} id=${cmd.id} tmux未起動のため送らずスキップ`);
@@ -4421,6 +4700,19 @@ export async function relayTick(cfg, state, hooks) {
4421
4700
  ? { parentCommandId: cmd.parentCommandId ?? null, decisions: cmd.decisions ?? [] }
4422
4701
  : undefined;
4423
4702
  const text = buildSendText(cmd.body, cfg.site, anchor, kindOf(cfg), context);
4703
+ // ── 配る直前に受け皿を空にする(★便 99fc49c0・取り違えの根を断つ) ──
4704
+ // ここに残っている知らせは、どれも【この便を配る前に終わったターン】の物。完了に使わず、
4705
+ // 理由つきで控えへ回す。こうしておけば、以後この便の待ちで受け皿に現れる知らせは
4706
+ // 「この便を配ったあとに届いた物」だけになる=古い形(印も時刻も無い)でも取り違えない。
4707
+ if (kindOf(cfg) === 'codex') {
4708
+ const stale = pickCodexNoticeFor(cfg.site, null);
4709
+ if (stale.skipped.length > 0) {
4710
+ state.codexNoticeSeen = true;
4711
+ log(`site=${cfg.site} id=${cmd.id} 配る前に受け皿に在った終わりの知らせ ${stale.skipped.length} 件は、` +
4712
+ `完了に使わず理由つきで記録して空にしました` +
4713
+ `${stale.skippedFile === null ? '' : `(控え: ${stale.skippedFile})`}`);
4714
+ }
4715
+ }
4424
4716
  const sendStartedAt = Date.now();
4425
4717
  // ★Claude の職人へは受領確認を使う(便 7d810bbb)。Codex は会話の記録の形が違うので今までどおり。
4426
4718
  const useAck = kindOf(cfg) === 'claude';
@@ -4469,6 +4761,9 @@ export async function relayTick(cfg, state, hooks) {
4469
4761
  };
4470
4762
  // 累計配達回数を通算(絶対上限 RELAY_DELIVER_MAX の基準)。
4471
4763
  markDelivered(state, cmd.id);
4764
+ // ★Codex へ配った=この便のターンが始まった。終わりの知らせを確かめるまで次は配らない。
4765
+ if (kindOf(cfg) === 'codex')
4766
+ state.codexTurnOpen = { jobId: cmd.id, since: sentAt };
4472
4767
  // 新しい指示の生存ハートビートは先頭から数え直す(前指示のスロットルを引き継がない)。
4473
4768
  state.lastHeartbeatAt = null;
4474
4769
  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.98",
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
  )
@@ -37,6 +37,8 @@
37
37
  ・人が答えるべき問いかけで終わった … 人の判断が先(ADR-024 §3 の取りこぼしを作らない)
38
38
  ・便の本文に「合流は人が行う」と書いてある … (1) を見ない(ADR-007)
39
39
  ・PR を作っていない便・読むだけの便 … (1) は当たらない(開いた PR が無いので何も起きない)
40
+ ・★本番操作の承認待ちで「待ち」のまま閉じた手番 … 便が終わる手番ではないので (1)(2) を見ない
41
+ (便 f20dd761・closed_as_prod_op_wait)。(3) は今までどおり見る=背景の待ちは殺されるため。
40
42
  """
41
43
  import json
42
44
  import os
@@ -357,6 +359,55 @@ def background_residue(task_ids):
357
359
  + '・'.join(task_ids[:5]) + ')')
358
360
 
359
361
 
362
+ # ─────────────────────────────────────────────────────────────────────────────
363
+ # ★本番操作の承認待ちで「待ち」のまま閉じた手番(便 f20dd761・管制 PR #425)
364
+ #
365
+ # 【なぜ要るか】本番操作の承認が長引いたとき、職人は `conductor_complete` を
366
+ # `waitForProdOp:true` で呼び、便を完了にせず「本番操作の承認待ち」のまま閉じて手番を終える。
367
+ # この手番は【便が終わる手番ではない】ので、関門が「終え方を満たしていない」として
368
+ # 差し戻してはいけない。差し戻すと、承認を待つために閉じたい手番が閉じられず、
369
+ # 上限まで止めたあと「途中で終わった」の控えだけが残る(実際には何も途中ではない)。
370
+ #
371
+ # 【何を見ないことにするか】
372
+ # ・report(報告が無い・下書き) … 便はまだ終わっていない=最終版の報告を求める場面ではない。
373
+ # ・pr(PR が開いたまま) … 承認待ちの本番操作が PR の中身そのものであることがある。
374
+ # 【★background は今までどおり見る】手番を閉じれば背景の待ちは殺される。これは待ちで閉じても
375
+ # 同じなので、ここだけは差し戻す(起きたときに同じ所からやり直せる)。
376
+ #
377
+ # 【どう見分けるか=実物だけ】会話の記録に残る道具の返りを見る。管制は待ちで閉じたとき
378
+ # `waitClosed: true` と `doneLineForbidden: true` を返す(便IDも同じ返りに入る)。
379
+ # ★3つが【同じ1行(=同じ記録)】にそろったときだけ「待ちで閉じた」と読む
380
+ # =報告の文章の中でこの機能を説明しても、取り違えない(便 99fc49c0 と同じ作法で便IDに縛る)。
381
+ # ─────────────────────────────────────────────────────────────────────────────
382
+ #: 管制が「待ちのまま閉じた」と返す印(★字面は ai-kana の conductor_complete の返りと同じ)。
383
+ #: ★会話の記録では、道具の返りが「JSON の中の文字列」として入ることがあり、そのときは
384
+ #: 引用符が `\"` に escape されている。どちらの形でも読めるようにする(実物2通り)。
385
+ _WAIT_CLOSED_RE = re.compile(r'waitClosed[\\"\s]*:[\\"\s]*true')
386
+ #: 「この手番では DONE: を出さないでください」の機械で読む印。
387
+ _DONE_FORBIDDEN_RE = re.compile(r'doneLineForbidden[\\"\s]*:[\\"\s]*true')
388
+
389
+
390
+ def closed_as_prod_op_wait(transcript_path, command_id):
391
+ """この手番が「本番操作の承認待ちで閉じた」ものか(読めなければ False=今までどおり見る)。"""
392
+ if not transcript_path or not os.path.isfile(transcript_path) or not command_id:
393
+ return False
394
+ needle = str(command_id)
395
+ try:
396
+ with open(transcript_path, encoding='utf-8', errors='replace') as f:
397
+ for line in f:
398
+ if needle not in line:
399
+ continue
400
+ if _WAIT_CLOSED_RE.search(line) and _DONE_FORBIDDEN_RE.search(line):
401
+ return True
402
+ except Exception: # noqa: BLE001
403
+ return False
404
+ return False
405
+
406
+
407
+ #: 待ちで閉じた手番では見ない残りの種類(★background はここに入れない)。
408
+ WAIT_CLOSED_SKIP_KINDS = ('report', 'pr')
409
+
410
+
360
411
  # ─────────────────────────────────────────────────────────────────────────────
361
412
  # (1) PR が開いたまま・合流の予約中のまま
362
413
  # ─────────────────────────────────────────────────────────────────────────────
@@ -269,16 +269,23 @@ def collect_residues(data, command_id, env=None):
269
269
  work_dir = (env.get('CONDUCTOR_WORK_DIR') or '').strip() or (data.get('cwd') or '')
270
270
  residues = {}
271
271
 
272
+ # ★本番操作の承認待ちで「待ち」のまま閉じた手番か(便 f20dd761)。
273
+ # 便が終わる手番ではないので、報告と PR は見ない(背景の待ちだけは今までどおり見る)。
274
+ wait_closed = finish_gate.closed_as_prod_op_wait(
275
+ data.get('transcript_path'), command_id)
276
+
272
277
  # (2) 報告 … 置き場の実物を見る。
273
- residues['report'] = finish_gate.report_residue(
274
- finish_gate.read_report_text(command_id, env))
278
+ if not wait_closed:
279
+ residues['report'] = finish_gate.report_residue(
280
+ finish_gate.read_report_text(command_id, env))
275
281
 
276
282
  # (3) 背景に回した待ち … 会話の記録の実物を見る。
283
+ # ★待ちで閉じた手番でも見る(手番を閉じれば背景の待ちは殺される=同じ事故が起きる)。
277
284
  residues['background'] = finish_gate.background_residue(
278
285
  finish_gate.outstanding_background_tasks(data.get('transcript_path')))
279
286
 
280
287
  # (1) PR … 便の本文が「合流は人が行う/合流しない」と言っていれば見ない(ADR-007)。
281
- if not finish_gate.human_merge_requested(load_job_body(site, env)) and work_dir:
288
+ if not wait_closed and not finish_gate.human_merge_requested(load_job_body(site, env)) and work_dir:
282
289
  residues['pr'] = finish_gate.open_pr_residue(
283
290
  finish_gate.read_open_prs(work_dir, finish_gate.current_branch(work_dir)))
284
291
  return {k: v for k, v in residues.items() if v}
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env python3
2
+ """便の終わりの関門は「本番操作の承認待ちで閉じた手番」を差し戻さない(便 f20dd761)。
3
+
4
+ 実行: python3 scripts/hooks/test_gate_prod_op_wait.py
5
+ 1件でも期待と違えば exit 1。
6
+
7
+ 【なぜ要るか】
8
+ 管制(ai-kana PR #425)は、本番操作の承認が長引いた便を `conductor_complete` の
9
+ `waitForProdOp:true` で【完了にせず】「本番操作の承認待ち」のまま閉じられるようにした。
10
+ その手番は【便が終わる手番ではない】。ところが関門は、報告が最終版でないこと・PR が
11
+ 開いたままであることを「終え方を満たしていない」として差し戻す。
12
+ =承認を待つために閉じたい手番が閉じられず、上限まで止めたあとで「途中で終わった」の
13
+ 控えだけが残る(実際には何も途中ではない)。
14
+
15
+ 【ここで固定すること】
16
+ (A) 待ちで閉じた印の読み方 … 便ID・waitClosed・doneLineForbidden が【同じ1行】に
17
+ そろったときだけ「待ちで閉じた」と読む(★よその便の印・説明の文では読まない)
18
+ (B) 待ちで閉じた手番は、報告が無い/下書き/PR が開いたままでも差し戻さない
19
+ (C) ★背景に回した待ちは、待ちで閉じた手番でも今までどおり差し戻す
20
+ (手番を閉じれば背景の待ちは殺される=同じ事故が起きる)
21
+ (D) ★空振り確認:印が無い(今までの便の)手番は、今までどおり差し戻す
22
+
23
+ ★空振り確認(この試験の中だけ):
24
+ ・印の行を外す → (B) が落ちる(=(B) は印が効いていることを見ている)
25
+ ・closed_as_prod_op_wait を常に False にする → (B) が落ちる
26
+ ・WAIT_CLOSED_SKIP_KINDS に 'background' を足す → (C) が落ちる
27
+ """
28
+ import json
29
+ import os
30
+ import sys
31
+ import tempfile
32
+
33
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
34
+
35
+ import finish_gate # noqa: E402
36
+ import stop_hook # noqa: E402
37
+
38
+ failures = []
39
+
40
+
41
+ def check(label, got, want):
42
+ ok = got == want
43
+ if not ok:
44
+ failures.append(f'{label}: got={got!r} want={want!r}')
45
+ print(f'{"OK " if ok else "NG "} {label:66} {str(got)[:46]}')
46
+
47
+
48
+ def truthy(label, got):
49
+ ok = bool(got)
50
+ if not ok:
51
+ failures.append(f'{label}: got={got!r} want=真')
52
+ print(f'{"OK " if ok else "NG "} {label:66} {str(got)[:46]}')
53
+
54
+
55
+ SANDBOX = tempfile.mkdtemp(prefix='gate-prod-op-wait-')
56
+ ENV = {'HOME': SANDBOX, 'XDG_STATE_HOME': os.path.join(SANDBOX, 'state'),
57
+ 'CONDUCTOR_RUNNER_MODE': 'headless', 'CONDUCTOR_SITE': 'demo'}
58
+ JOB = 'f20dd761-a8e9-4696-aefe-b639b0834bc2'
59
+ OTHER = '97f99a17-4941-4479-8e56-f1e735cb0b90'
60
+
61
+ JOB_LINE = f'{{"text":"CONDUCTOR_JOB:{JOB}"}}'
62
+ #: 背景の仕事が始まった印(★test_finish_gate.py と同じ、会話の記録に残る実物の形)。
63
+ START = '{"toolUseResult":{"stdout":"","backgroundTaskId":"%s"}}'
64
+
65
+ #: 管制が待ちで閉じたときに返す1行(★実物と同じ字面=ai-kana の conductor_complete の返り)。
66
+ #: 形① 道具の返りが「JSON の中の文字列」として入る(引用符が \" に escape される)。
67
+ WAIT_REPLY = (
68
+ '{"type":"tool_result","content":"{\\"ok\\":true,\\"commandId\\":\\"%s\\",'
69
+ '\\"completed\\":false,\\"waitClosed\\":true,\\"status\\":\\"perm_wait\\",'
70
+ '\\"waitLabel\\":\\"本番操作の承認待ち\\",\\"doneLineForbidden\\":true}"}'
71
+ )
72
+ #: 形② 道具の返りが素の JSON として入る(escape なし)。
73
+ WAIT_REPLY_PLAIN = (
74
+ '{"toolUseResult":{"ok":true,"commandId":"%s","completed":false,'
75
+ '"waitClosed":true,"status":"perm_wait","doneLineForbidden":true}}'
76
+ )
77
+
78
+
79
+ def transcript(lines, name):
80
+ p = os.path.join(SANDBOX, name)
81
+ with open(p, 'w', encoding='utf-8') as f:
82
+ f.write('\n'.join(lines) + '\n')
83
+ return p
84
+
85
+
86
+ def put_job_body(body, site='demo'):
87
+ d = os.path.join(ENV['XDG_STATE_HOME'], 'conductor')
88
+ os.makedirs(d, exist_ok=True)
89
+ with open(os.path.join(d, f'active-job-{site}.json'), 'w', encoding='utf-8') as f:
90
+ json.dump({'commandId': JOB, 'body': body, 'site': site}, f)
91
+
92
+
93
+ def drop_report(command_id=JOB):
94
+ for f in finish_gate.report_files(command_id, ENV):
95
+ if os.path.isfile(f):
96
+ os.remove(f)
97
+
98
+
99
+ def put_report(text, command_id=JOB):
100
+ d = os.path.join(ENV['XDG_STATE_HOME'], 'conductor', 'reports')
101
+ os.makedirs(d, exist_ok=True)
102
+ with open(os.path.join(d, f'{command_id}.md'), 'w', encoding='utf-8') as f:
103
+ f.write(text)
104
+
105
+
106
+ def clear_counts(command_id=JOB):
107
+ for p in (finish_gate.counts_path(command_id, ENV), finish_gate.residue_path(command_id, ENV)):
108
+ if os.path.isfile(p):
109
+ os.remove(p)
110
+
111
+
112
+ def block(t):
113
+ return stop_hook.finish_gate_block({'transcript_path': t, 'cwd': SANDBOX}, ENV)
114
+
115
+
116
+ put_job_body('本番操作の承認を待って、待ちのまま閉じてください')
117
+
118
+ # ─────────────────────────────────────────────────────────────────────────────
119
+ print('=== (A) 待ちで閉じた印の読み方(★便IDに縛る) ===')
120
+ # ─────────────────────────────────────────────────────────────────────────────
121
+ t_ok = transcript([JOB_LINE, WAIT_REPLY % JOB], 'a-ok.jsonl')
122
+ check('便ID+waitClosed+doneLineForbidden が同じ1行 → 待ちで閉じた',
123
+ finish_gate.closed_as_prod_op_wait(t_ok, JOB), True)
124
+
125
+ t_plainjson = transcript([JOB_LINE, WAIT_REPLY_PLAIN % JOB], 'a-plain.jsonl')
126
+ check('★escape されていない形(素の JSON)でも読める',
127
+ finish_gate.closed_as_prod_op_wait(t_plainjson, JOB), True)
128
+
129
+ t_other = transcript([JOB_LINE, WAIT_REPLY % OTHER], 'a-other.jsonl')
130
+ check('★よその便の印では読まない',
131
+ finish_gate.closed_as_prod_op_wait(t_other, JOB), False)
132
+
133
+ t_split = transcript([JOB_LINE,
134
+ '{"text":"この便は \\"waitClosed\\": true で閉じました"}',
135
+ f'{{"text":"{JOB} の話"}}'], 'a-split.jsonl')
136
+ check('★印が別の行に散っている(説明の文)→ 読まない',
137
+ finish_gate.closed_as_prod_op_wait(t_split, JOB), False)
138
+
139
+ t_half = transcript([JOB_LINE,
140
+ '{"type":"tool_result","content":"{\\"commandId\\":\\"%s\\",'
141
+ '\\"waitClosed\\":true}"}' % JOB], 'a-half.jsonl')
142
+ check('★片方の印だけ(doneLineForbidden が無い)→ 読まない',
143
+ finish_gate.closed_as_prod_op_wait(t_half, JOB), False)
144
+
145
+ check('会話の記録が無い → 読まない(今までどおり見る側へ倒す)',
146
+ finish_gate.closed_as_prod_op_wait(os.path.join(SANDBOX, 'nope.jsonl'), JOB), False)
147
+ check('便IDが分からない → 読まない',
148
+ finish_gate.closed_as_prod_op_wait(t_ok, ''), False)
149
+ check('見ない残りは報告と PR の2つだけ(背景は入れない)',
150
+ sorted(finish_gate.WAIT_CLOSED_SKIP_KINDS), ['pr', 'report'])
151
+
152
+ # ─────────────────────────────────────────────────────────────────────────────
153
+ print('\n=== (B) 待ちで閉じた手番は差し戻さない ===')
154
+ # ─────────────────────────────────────────────────────────────────────────────
155
+ clear_counts()
156
+ drop_report()
157
+ check('報告が1つも無くても止めない', block(t_ok), None)
158
+
159
+ clear_counts()
160
+ put_report('---\ninstruction_id: x\n---\n\n報告(作成中)この見出しは最終版で差し替える')
161
+ check('報告が下書きのままでも止めない', block(t_ok), None)
162
+
163
+ clear_counts()
164
+ put_report('やりました。') # 囲みが無い=形が整っていない
165
+ check('報告の形が整っていなくても止めない', block(t_ok), None)
166
+
167
+ clear_counts()
168
+ check('★残りを集める段で、報告と PR が1つも入っていない',
169
+ sorted(stop_hook.collect_residues({'transcript_path': t_ok, 'cwd': SANDBOX}, JOB, ENV)),
170
+ [])
171
+
172
+ # ─────────────────────────────────────────────────────────────────────────────
173
+ print('\n=== (C) ★背景に回した待ちは、待ちで閉じた手番でも差し戻す ===')
174
+ # ─────────────────────────────────────────────────────────────────────────────
175
+ clear_counts()
176
+ drop_report()
177
+ t_bg = transcript([JOB_LINE, WAIT_REPLY % JOB, START % 'b32iul78k'], 'c-bg.jsonl')
178
+ msg = block(t_bg)
179
+ truthy('背景の待ちが残っていれば止める', msg)
180
+ truthy(' 背景の待ちだと言う', '背景' in (msg or ''))
181
+ check('★止める理由は背景だけ(報告・PR は混ざらない)',
182
+ sorted(stop_hook.collect_residues({'transcript_path': t_bg, 'cwd': SANDBOX}, JOB, ENV)),
183
+ ['background'])
184
+
185
+ # ─────────────────────────────────────────────────────────────────────────────
186
+ print('\n=== (D) ★空振り確認:印が無い手番は今までどおり差し戻す ===')
187
+ # ─────────────────────────────────────────────────────────────────────────────
188
+ clear_counts()
189
+ drop_report()
190
+ t_plain = transcript([JOB_LINE], 'd-plain.jsonl')
191
+ truthy('印が無い+報告が無い → 止める(関門は効いたまま)', block(t_plain))
192
+
193
+ clear_counts()
194
+ put_report('---\ninstruction_id: x\n---\n\n報告(作成中)差し替える')
195
+ truthy('印が無い+下書き → 止める', block(t_plain))
196
+
197
+ clear_counts()
198
+ drop_report()
199
+ truthy('★印の行を外すと (B) の形でも止まる=(B) は印が効いていることを見ている',
200
+ block(transcript([JOB_LINE], 'd-noflag.jsonl')))
201
+
202
+ print('\n' + '=' * 72)
203
+ if failures:
204
+ print(f'NG {len(failures)} 件')
205
+ for f in failures:
206
+ print(' - ' + f)
207
+ sys.exit(1)
208
+ print('OK すべて期待どおり')
@@ -201,5 +201,12 @@ description: 采配くんの便(プロンプト末尾に CONDUCTOR_JOB:<id>
201
201
  - 可能なら次も一緒に渡す(すべて任意・分かる範囲でよく、渡さなくても完了する):`branch=`作った作業ブランチ名(`git rev-parse --abbrev-ref HEAD`)、`baseBranch=`分岐元(通常 main)、`headSha=`最後のコミット(短縮可)、`pushed=`push したか(true/false)、`filesChanged=`変更したファイル数、`prTitle=`お客さま(非エンジニア)がそのまま読む題名(rebase/squash/lint 等の専門用語を避け、やさしい日本語で)、`prNumber=`既に PR がある場合のみ、`outcome=`pushed(変更して push した)/no_change(変更不要だった)/blocked(衝突・要判断・許可待ち等で完了できなかった)のどれか。
202
202
  - **blocked のときも必ず conductor_complete を呼び、report に理由を書く。**
203
203
  - ブランチを切らない**読み取り専用の作業では outcome=no_change** を渡す(branch は省略可)。
204
+ ⑤ **★本番操作の承認を待って「待ち」のまま閉じる手番だけは、`DONE:` の行を出さない**(えふさん確定 2026-10-04 の 3・便 97f99a17)。
205
+ - **どんな手番か** … 本番操作(本番の反映など)の承認が長引き、便をいったん「待ち」にして手番を閉じるとき。④を `waitForProdOp:true`(分かれば `prodOpRequestId` も)で呼ぶ。便は `done` にならず**本番操作の承認待ち**のまま閉じ、返事に `waitClosed: true` と `doneLineForbidden: true` が返る。
206
+ - **この手番ですること** … ①②のとおり**報告ファイルは置いてよい**(報告ファイルだけでは便は完了にならない)。④は `waitForProdOp:true` で呼ぶ。★**③の `DONE:<現場>` の行は出さない。**
207
+ - **なぜ出さないか** … `DONE:` を出すと配り役の完了検出がこの便を完了(終端)にしてしまい、本番操作が済んでも**続きから起こせなくなる**(「終端は起こさない」は正しい決まりなので緩めない)。
208
+ - **そのあと** … 本番操作が済むと、管制の**合図で同じ便の続きから起きる**。続きを行い、**本当に終わったときに ⓪〜④ のとおり(`DONE:<現場>` も出して)終える**。★却下・失敗・時間切れでは起きない(理由つきで人の手番で止まる)。
209
+ - ★**待ちで閉じていない便の終え方は1つも変わらない。** `waitForProdOp` を渡さない呼び出しは今までどおり(③の `DONE:<現場>` も出す)。
210
+ - ★**便の終わりの関門は、待ちで閉じた手番を「報告が無い」「PR が開いたまま」では差し戻さない**(背景に回した待ちが残っていれば、今までどおり差し戻す=手番を閉じると殺されるのは待ちで閉じても同じ)。
204
211
 
205
212
  **作業中の確認の一言説明** — 作業中にユーザー確認(許可を求めるプロンプト)が出る操作では、その一言説明(description)を英語ではなく日本語で、「プルリクエストを作成する」「テストを実行する」のように「何をするか」が一目で分かる簡潔な形(〜を作成する/実行する/削除する 等)で書く。この説明は管制の確認カードにそのまま表示され、非エンジニアのお客さまが読んで許可を判断する。