@tyhld/conductor 0.12.102 → 0.12.103
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 +316 -8
- package/package.json +1 -1
package/dist/relay.js
CHANGED
|
@@ -316,6 +316,111 @@ export const RELAY_SKIP_WARN_THRESHOLD = 6;
|
|
|
316
316
|
* 中央が便を抱える上限(CENTRAL_BUSY_HOLD_MAX_MS=3時間)と同じ値にそろえる。
|
|
317
317
|
*/
|
|
318
318
|
export const RELAY_CODEX_TURN_HOLD_MAX_MS = CENTRAL_BUSY_HOLD_MAX_MS;
|
|
319
|
+
/**
|
|
320
|
+
* 配り役が現場ごとの控えを置く場所(★再起動で消えない・報告の置き場と同じ親の下)。
|
|
321
|
+
* 環境変数 `CONDUCTOR_RELAY_STATE_DIR` を指定したときはそこだけ(試験・特殊な機体向け)。
|
|
322
|
+
*/
|
|
323
|
+
export function relayStateDir(env = process.env) {
|
|
324
|
+
const explicit = (env.CONDUCTOR_RELAY_STATE_DIR ?? '').trim();
|
|
325
|
+
if (explicit)
|
|
326
|
+
return explicit;
|
|
327
|
+
const home = env.HOME ?? os.homedir();
|
|
328
|
+
const state = (env.XDG_STATE_HOME ?? '').trim() || path.join(home, '.local', 'state');
|
|
329
|
+
return path.join(state, 'conductor', 'relay');
|
|
330
|
+
}
|
|
331
|
+
/** この現場の「Codex のターンの札」の控えのパス。 */
|
|
332
|
+
export function codexTurnFile(site, env = process.env) {
|
|
333
|
+
const safe = String(site ?? '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 128) || 'unknown';
|
|
334
|
+
return path.join(relayStateDir(env), `codex-turn-${safe}.json`);
|
|
335
|
+
}
|
|
336
|
+
/** 控えを読む。無い・読めない・形が違うときは null(★配り役を止めない)。 */
|
|
337
|
+
export function readCodexTurnRecord(site, env = process.env) {
|
|
338
|
+
try {
|
|
339
|
+
const o = JSON.parse(readFileSync(codexTurnFile(site, env), 'utf8'));
|
|
340
|
+
const jobId = typeof o.jobId === 'string' && o.jobId.trim() !== '' ? o.jobId.trim() : null;
|
|
341
|
+
const since = typeof o.since === 'number' && Number.isFinite(o.since) ? o.since : null;
|
|
342
|
+
return {
|
|
343
|
+
jobId,
|
|
344
|
+
since: jobId === null ? null : since,
|
|
345
|
+
noticeSeen: o.noticeSeen === true,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/** 控えを書く(★書けなくても配り役は止まらない=今までどおりメモリの札で動く)。 */
|
|
353
|
+
export function writeCodexTurnRecord(site, rec, env = process.env) {
|
|
354
|
+
const file = codexTurnFile(site, env);
|
|
355
|
+
try {
|
|
356
|
+
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
357
|
+
writeFileSync(file, `${JSON.stringify(rec)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* 「Codex の前のターンの札」を下ろしてよいかを決める純粋関数(I/Oなし=単体テスト可能)。
|
|
366
|
+
*
|
|
367
|
+
* 【なぜ知らせ1本に頼らないか(調査 b9f6f53e の重大発見1・2)】札が下りる唯一の道は
|
|
368
|
+
* 「終わりの知らせが受け皿に届くこと」だった。実測 2026-10-05、走行中のターンへ人が
|
|
369
|
+
* 手で文を入れた便(9fb59241)では知らせが1行も出ず、現場が最長3時間ふさがった。
|
|
370
|
+
* そこで出口を1本足す:★画面が待機(作業中の印が無い)で、かつその便が管制で終端
|
|
371
|
+
* (done / failed / timeout など)なら、ターンは終わっている。
|
|
372
|
+
* ★画面が作業中に見えるあいだは下ろさない=長いターンの途中で次の便を割り込ませない
|
|
373
|
+
* (この札の本来の目的は1バイトも弱めない)。
|
|
374
|
+
* ★画面が読めない・管制に聞けないときも下ろさない(断定しない=保守側)。
|
|
375
|
+
*/
|
|
376
|
+
export function decideCodexTurnRelease(input) {
|
|
377
|
+
if (input.heldMs >= RELAY_CODEX_TURN_HOLD_MAX_MS)
|
|
378
|
+
return 'release-max';
|
|
379
|
+
if (input.screenIdle === true && input.centralFinished === true)
|
|
380
|
+
return 'release-finished';
|
|
381
|
+
return 'hold';
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* 札の控えを読み込む(★起動後の最初の1周だけ・Codex の現場だけ)。
|
|
385
|
+
* 控えに「まだ途中のターン」が在れば、メモリの札として引き継ぐ=配り役を入れ直しても
|
|
386
|
+
* 長いターンの途中に次の便を割り込ませない。受け口の知らせを見た事実も引き継ぐ。
|
|
387
|
+
*/
|
|
388
|
+
export function loadCodexTurnOnce(cfg, state, env = process.env, logLine = log) {
|
|
389
|
+
if (state.codexTurnLoaded)
|
|
390
|
+
return;
|
|
391
|
+
state.codexTurnLoaded = true;
|
|
392
|
+
if (kindOf(cfg) !== 'codex')
|
|
393
|
+
return;
|
|
394
|
+
const rec = readCodexTurnRecord(cfg.site, env);
|
|
395
|
+
if (rec === null)
|
|
396
|
+
return;
|
|
397
|
+
state.codexTurnDisk = JSON.stringify(rec);
|
|
398
|
+
if (rec.noticeSeen)
|
|
399
|
+
state.codexNoticeSeen = true;
|
|
400
|
+
if (rec.jobId !== null && state.codexTurnOpen === null) {
|
|
401
|
+
state.codexTurnOpen = { jobId: rec.jobId, since: rec.since ?? Date.now() };
|
|
402
|
+
logLine(`site=${cfg.site} id=${rec.jobId} のターンは控えでまだ途中でした` +
|
|
403
|
+
'(札を引き継ぎます=入れ直しても状態が食い違わない)');
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/** いまの札を控えへ写す(★中身が変わっていなければ書かない)。書けなくても配り役は止まらない。 */
|
|
407
|
+
export function saveCodexTurn(cfg, state, env = process.env) {
|
|
408
|
+
if (kindOf(cfg) !== 'codex')
|
|
409
|
+
return;
|
|
410
|
+
const rec = {
|
|
411
|
+
jobId: state.codexTurnOpen?.jobId ?? null,
|
|
412
|
+
since: state.codexTurnOpen?.since ?? null,
|
|
413
|
+
noticeSeen: state.codexNoticeSeen,
|
|
414
|
+
};
|
|
415
|
+
const text = JSON.stringify(rec);
|
|
416
|
+
if (text === state.codexTurnDisk)
|
|
417
|
+
return;
|
|
418
|
+
// まだ控えが無く、書くことも何も無い(札なし・知らせ未観測)=ファイルを作らない。
|
|
419
|
+
if (state.codexTurnDisk === null && rec.jobId === null && !rec.noticeSeen)
|
|
420
|
+
return;
|
|
421
|
+
if (writeCodexTurnRecord(cfg.site, rec, env))
|
|
422
|
+
state.codexTurnDisk = text;
|
|
423
|
+
}
|
|
319
424
|
/**
|
|
320
425
|
* 受け皿の名前をそろえる仕事(登録簿の書き出し・読まれない受け皿の始末)の間隔。
|
|
321
426
|
* ★配達の判断には1バイトも関わらない。フォルダを見に行くので毎周はやらない。
|
|
@@ -342,6 +447,8 @@ export function createRelayState() {
|
|
|
342
447
|
lastNudgeLogAt: null,
|
|
343
448
|
codexTurnOpen: null,
|
|
344
449
|
codexNoticeSeen: false,
|
|
450
|
+
codexTurnLoaded: false,
|
|
451
|
+
codexTurnDisk: null,
|
|
345
452
|
codexTrayTidiedAt: null,
|
|
346
453
|
central: { failures: 0, detail: '', downSince: null },
|
|
347
454
|
readiness: createReadiness(),
|
|
@@ -1163,6 +1270,89 @@ export function takeCodexNotice(site, env = process.env) {
|
|
|
1163
1270
|
const all = takeCodexNotices(site, env);
|
|
1164
1271
|
return all.length === 0 ? null : all[all.length - 1];
|
|
1165
1272
|
}
|
|
1273
|
+
/**
|
|
1274
|
+
* 受け皿から【便の印がこの便と一致する知らせ】だけを取り出す(★ほかの行は受け皿に残す)。
|
|
1275
|
+
*
|
|
1276
|
+
* 【なぜ「全件を空にする」takeCodexNotices と別に要るか(便 b4747dd7)】
|
|
1277
|
+
* 見張り(keepalive)へ移った便の報告本文を拾うために受け皿を見るが、そのとき受け皿には
|
|
1278
|
+
* 【いま追跡中(pending)の別の便の知らせ】が混ざっていることがある。全件を空にすると、
|
|
1279
|
+
* 追跡中の便の知らせを横から奪ってその便の報告を落としてしまう。だから一致した行だけを
|
|
1280
|
+
* 取り、残りはそのまま受け皿に戻す(★本線の pickCodexNoticeFor の取り分を1行も減らさない)。
|
|
1281
|
+
* ★一致の判定は【便の印(jobId)】だけ。印が無い行は取らない(別の便の知らせを取り違えない)。
|
|
1282
|
+
* ★書きかけの行は取らない・消さない(takeCodexNotices と同じ守り)。
|
|
1283
|
+
*/
|
|
1284
|
+
export function takeCodexNoticesForJobId(site, commandId, env = process.env) {
|
|
1285
|
+
const dir = codexNotifyDir(env);
|
|
1286
|
+
if (!dir)
|
|
1287
|
+
return [];
|
|
1288
|
+
const file = path.join(dir, `${site}.jsonl`);
|
|
1289
|
+
let raw;
|
|
1290
|
+
try {
|
|
1291
|
+
raw = readFileSync(file, 'utf8');
|
|
1292
|
+
}
|
|
1293
|
+
catch {
|
|
1294
|
+
return []; // まだ1件も来ていない
|
|
1295
|
+
}
|
|
1296
|
+
if (hasPartialJsonlTail(raw)) {
|
|
1297
|
+
let writing = false;
|
|
1298
|
+
try {
|
|
1299
|
+
writing = Date.now() - statSync(file).mtimeMs < CODEX_NOTICE_PARTIAL_WAIT_MS;
|
|
1300
|
+
}
|
|
1301
|
+
catch {
|
|
1302
|
+
writing = false; // 時刻が読めなければ、今までどおり進む
|
|
1303
|
+
}
|
|
1304
|
+
if (writing)
|
|
1305
|
+
return []; // 書き終わってから、次の回で拾う
|
|
1306
|
+
}
|
|
1307
|
+
const whole = completeJsonlLines(raw);
|
|
1308
|
+
// 末尾の書きかけ(改行で終わっていない残り)は、そのまま受け皿に残す。
|
|
1309
|
+
const partial = hasPartialJsonlTail(raw) ? raw.slice(raw.lastIndexOf('\n') + 1) : '';
|
|
1310
|
+
const mine = [];
|
|
1311
|
+
const keep = [];
|
|
1312
|
+
for (const line of whole) {
|
|
1313
|
+
const t = line.trim();
|
|
1314
|
+
if (t === '')
|
|
1315
|
+
continue;
|
|
1316
|
+
let o = null;
|
|
1317
|
+
try {
|
|
1318
|
+
o = JSON.parse(t);
|
|
1319
|
+
}
|
|
1320
|
+
catch {
|
|
1321
|
+
o = null;
|
|
1322
|
+
}
|
|
1323
|
+
const jobId = typeof o?.jobId === 'string' ? o.jobId.trim() : '';
|
|
1324
|
+
if (o !== null &&
|
|
1325
|
+
o.site === site &&
|
|
1326
|
+
jobId !== '' &&
|
|
1327
|
+
codexReportIdMatches(jobId, commandId)) {
|
|
1328
|
+
mine.push({
|
|
1329
|
+
site,
|
|
1330
|
+
report: typeof o.report === 'string' ? o.report : '',
|
|
1331
|
+
threadId: o.threadId ?? null,
|
|
1332
|
+
turnId: o.turnId ?? null,
|
|
1333
|
+
jobId,
|
|
1334
|
+
at: parseNoticeAt(o.at),
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
else {
|
|
1338
|
+
keep.push(line); // ★自分の物でない行は1字も変えずに受け皿へ戻す
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
if (mine.length === 0)
|
|
1342
|
+
return []; // 何も取らなかった=受け皿も書き換えない
|
|
1343
|
+
const rest = keep.length === 0 && partial === '' ? '' : `${keep.map((l) => `${l}\n`).join('')}${partial}`;
|
|
1344
|
+
try {
|
|
1345
|
+
if (rest === '')
|
|
1346
|
+
unlinkSync(file);
|
|
1347
|
+
else
|
|
1348
|
+
writeFileSync(file, rest, { encoding: 'utf8', mode: 0o600 });
|
|
1349
|
+
}
|
|
1350
|
+
catch {
|
|
1351
|
+
// 書き戻せなくても、取った知らせはそのまま使う(完了を落とさない)。
|
|
1352
|
+
// ★同じ行をもう一度読んでも、完了の書き込み自体は冪等(patchStatus / completeWithReport)。
|
|
1353
|
+
}
|
|
1354
|
+
return mine;
|
|
1355
|
+
}
|
|
1166
1356
|
/** 控えに残す行数の上限(古い物から落とす=受け皿の隣で際限なく育たせない)。 */
|
|
1167
1357
|
export const CODEX_SKIPPED_NOTICE_MAX_LINES = 200;
|
|
1168
1358
|
/**
|
|
@@ -1759,6 +1949,41 @@ export function hasStandaloneDone(text, site) {
|
|
|
1759
1949
|
const lines = text.replace(/\r/g, '').split('\n');
|
|
1760
1950
|
return lines.some((line) => isStandaloneDoneLine(line, marker));
|
|
1761
1951
|
}
|
|
1952
|
+
/** テキスト中の「DONE:{site} 単独行」の数(★床(doneFloor)の基準に使う)。 */
|
|
1953
|
+
export function countStandaloneDone(text, site) {
|
|
1954
|
+
const marker = `DONE:${site}`;
|
|
1955
|
+
const lines = text.replace(/\r/g, '').split('\n');
|
|
1956
|
+
let n = 0;
|
|
1957
|
+
for (const line of lines)
|
|
1958
|
+
if (isStandaloneDoneLine(line, marker))
|
|
1959
|
+
n += 1;
|
|
1960
|
+
return n;
|
|
1961
|
+
}
|
|
1962
|
+
/**
|
|
1963
|
+
* 追跡中(pending)の便について、画面から完了の合図を読む(★I/Oなし=単体テスト可能)。
|
|
1964
|
+
*
|
|
1965
|
+
* 【3つの道(上から順に効く)】
|
|
1966
|
+
* ① アンカーが画面に見える … 今までどおり「アンカーより下」だけを見る(Claude の道・1文字も変えない)。
|
|
1967
|
+
* ② アンカーが見えず、床が無い … 今までどおり(wide なら全画面/そうでなければ false)。
|
|
1968
|
+
* ③ アンカーが見えず、床が在る(★Codex・便 b4747dd7)… 全画面の `DONE:{site}` 単独行を数え、
|
|
1969
|
+
* 【配った時点の数(床)より増えていたら】完了とみなす。増えていなければ、観測した数まで
|
|
1970
|
+
* 床を下げる(古い DONE 行が履歴から流れたぶんだけ下がる=そのあと出た合図を取りこぼさない)。
|
|
1971
|
+
*
|
|
1972
|
+
* ★床を上げないのが要。上げてしまうと、この便が出した DONE 行が床に混ざり、完了が永久に成立しない。
|
|
1973
|
+
*/
|
|
1974
|
+
export function scanDoneForPending(cap, site, anchor, opts) {
|
|
1975
|
+
const below = sliceBelowAnchor(cap, anchor);
|
|
1976
|
+
if (below !== null)
|
|
1977
|
+
return { done: hasStandaloneDone(below, site), floor: opts.doneFloor };
|
|
1978
|
+
if (!opts.wide)
|
|
1979
|
+
return { done: false, floor: opts.doneFloor };
|
|
1980
|
+
if (opts.doneFloor === null)
|
|
1981
|
+
return { done: hasStandaloneDone(cap, site), floor: null };
|
|
1982
|
+
const seen = countStandaloneDone(cap, site);
|
|
1983
|
+
if (seen > opts.doneFloor)
|
|
1984
|
+
return { done: true, floor: opts.doneFloor };
|
|
1985
|
+
return { done: false, floor: Math.min(opts.doneFloor, seen) };
|
|
1986
|
+
}
|
|
1762
1987
|
/**
|
|
1763
1988
|
* アンカー基準の完了判定(根治の中核)。
|
|
1764
1989
|
* 「アンカーより下の領域」に DONE:{site} 単独行があれば完了とみなす。
|
|
@@ -4330,6 +4555,8 @@ const DEFAULT_KEEPALIVE_DEPS = {
|
|
|
4330
4555
|
capture: (site) => tmuxCapture(site),
|
|
4331
4556
|
heartbeat: (cfg, id, busy) => sendHeartbeat(cfg, id, busy === true),
|
|
4332
4557
|
complete: (cfg, id, report) => patchStatus(cfg, id, 'done', report),
|
|
4558
|
+
completeFromFile: (cfg, id) => completeWithReport(cfg, id),
|
|
4559
|
+
takeNotices: (site, id) => takeCodexNoticesForJobId(site, id),
|
|
4333
4560
|
centralStatus: (cfg, id) => fetchCommandStatus(cfg, id),
|
|
4334
4561
|
now: () => Date.now(),
|
|
4335
4562
|
logLine: (msg) => log(msg),
|
|
@@ -4344,10 +4571,53 @@ export async function keepaliveTick(cfg, state, deps = {}) {
|
|
|
4344
4571
|
const ka = state.keepalive;
|
|
4345
4572
|
if (!ka)
|
|
4346
4573
|
return;
|
|
4347
|
-
const { capture, heartbeat, complete, centralStatus: getStatus, now: clock, logLine } = {
|
|
4574
|
+
const { capture, heartbeat, complete, completeFromFile, takeNotices, centralStatus: getStatus, now: clock, logLine, } = {
|
|
4348
4575
|
...DEFAULT_KEEPALIVE_DEPS,
|
|
4349
4576
|
...deps,
|
|
4350
4577
|
};
|
|
4578
|
+
// ── 見張りに移った便の「終わりの知らせ」を取り込む(★Codex・便 b4747dd7) ──
|
|
4579
|
+
// 15分を超えたターンの便は、追跡(pending)が切れたあとに知らせが届く。従来はその知らせを
|
|
4580
|
+
// 「追っている便が無い」として控えへ回していたので、報告本文が1文字も管制に載らなかった
|
|
4581
|
+
// (実測 2026-10-05: f7059eb5 は 15分13秒・dc60bf38 は 19分24秒で、どちらも本文なし)。
|
|
4582
|
+
// ★取るのは【便の印がこの便と一致する行】だけ。ほかの行は受け皿に残す(追跡中の便の取り分を奪わない)。
|
|
4583
|
+
if (kindOf(cfg) === 'codex') {
|
|
4584
|
+
const mine = takeNotices(ka.site, ka.id);
|
|
4585
|
+
// 2件以上あれば【新しい方】を使う(受け皿は古い順)。
|
|
4586
|
+
const notice = mine.length === 0 ? null : mine[mine.length - 1];
|
|
4587
|
+
if (notice !== null) {
|
|
4588
|
+
state.codexNoticeSeen = true;
|
|
4589
|
+
// ★この便のターンは終わった=「前のターンが途中」の札も下ろす。
|
|
4590
|
+
if (state.codexTurnOpen !== null && state.codexTurnOpen.jobId === ka.id) {
|
|
4591
|
+
state.codexTurnOpen = null;
|
|
4592
|
+
logLine(`site=${cfg.site} id=${ka.id} のターンが終わったと分かりました(次の便を配れます)`);
|
|
4593
|
+
}
|
|
4594
|
+
const intake = intakeCodexReport(notice.report, { commandId: ka.id, site: cfg.site });
|
|
4595
|
+
logLine(`site=${cfg.site} id=${ka.id} ${intake.note}`);
|
|
4596
|
+
const body = notice.report.trim().length > 0 ? notice.report : null;
|
|
4597
|
+
const ok = intake.wrote
|
|
4598
|
+
? await completeFromFile(cfg, ka.id)
|
|
4599
|
+
: await complete(cfg, ka.id, body);
|
|
4600
|
+
if (!ok)
|
|
4601
|
+
return; // 書けなければ畳まない(次の周でやり直す。知らせは使い切っている=囲みは報告ファイルに残る)
|
|
4602
|
+
logLine(`site=${cfg.site} id=${ka.id} 見張り終了: 追跡が切れたあとに届いた終わりの知らせで完了を報告` +
|
|
4603
|
+
`(印が一致${intake.wrote ? '・囲みから報告ファイル' : body !== null ? '・報告添付' : ''})`);
|
|
4604
|
+
await deliverJobEndFacts(cfg, {
|
|
4605
|
+
commandId: ka.id,
|
|
4606
|
+
site: cfg.site,
|
|
4607
|
+
finishedAt: new Date().toISOString(),
|
|
4608
|
+
endKind: 'completed',
|
|
4609
|
+
exitCode: null,
|
|
4610
|
+
exitSignal: null,
|
|
4611
|
+
reportDelivered: body != null || reportDelivery(ka.id).delivered,
|
|
4612
|
+
processEndObserved: false,
|
|
4613
|
+
detail: '配り役が完了の知らせで確かめた(追跡が切れたあとに届いた知らせ・職人の処理は中継が持っていない)' +
|
|
4614
|
+
(intake.wrote ? '/最後の返事の囲みから報告ファイルを組み立てた' : ''),
|
|
4615
|
+
}, { logLine });
|
|
4616
|
+
noteBusyEdge(state, cfg.site, ka.id, false, logLine);
|
|
4617
|
+
state.keepalive = null;
|
|
4618
|
+
return;
|
|
4619
|
+
}
|
|
4620
|
+
}
|
|
4351
4621
|
// ★窓の持ち主が替わったら、画面からの完了検出はやめる(取り違えた完了を構造的に防ぐ)。
|
|
4352
4622
|
// 別の指示が同じ窓で動くと、その出力も古いアンカーより下に並ぶため、そのまま読むと
|
|
4353
4623
|
// 新しい指示の DONE 行と報告を、見張っている古い指示の完了として送ってしまう。
|
|
@@ -4546,6 +4816,9 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
4546
4816
|
// 次のtick間隔が自動的に通常(15秒)へ戻る。★判定ロジックはこの値を一切参照しない(観測専用)。
|
|
4547
4817
|
state.promptPending = false;
|
|
4548
4818
|
try {
|
|
4819
|
+
// ── Codex のターンの札の控えを読み込む(★起動後1回・便 b4747dd7) ──
|
|
4820
|
+
// 配り役を入れ直しても、長いターンの途中に次の便を割り込ませない(loadCodexTurnOnce の説明)。
|
|
4821
|
+
loadCodexTurnOnce(cfg, state);
|
|
4549
4822
|
// ── sent孤児の復元(2-b・起動後1回) ──
|
|
4550
4823
|
// cli.ts を触らず「起動時1回」を実現するため、最初の tick でだけ復元する。
|
|
4551
4824
|
// 失敗しても再試行しない(フラグを先に立てる=起動を妨げない/多重実行防止)。
|
|
@@ -4700,8 +4973,22 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
4700
4973
|
return;
|
|
4701
4974
|
}
|
|
4702
4975
|
}
|
|
4703
|
-
|
|
4704
|
-
|
|
4976
|
+
// ★Codex はアンカーが画面に出ない(長い貼り付けを畳んで表示する)ので、床(doneFloor)を
|
|
4977
|
+
// 使って「配った時点より DONE 行が増えたか」で見る(scanDoneForPending の説明・便 b4747dd7)。
|
|
4978
|
+
// Claude は従来どおり「アンカーより下」だけを見る=動きは1バイトも変わらない。
|
|
4979
|
+
if (state.pending.doneFloor != null && state.pending.doneFloorSettled !== true) {
|
|
4980
|
+
// 配達のエコーを含んだ数も床に取り込む(PendingEntry.doneFloorSettled の説明)。
|
|
4981
|
+
state.pending.doneFloor = Math.max(state.pending.doneFloor, countStandaloneDone(cap, cfg.site));
|
|
4982
|
+
state.pending.doneFloorSettled = true;
|
|
4983
|
+
}
|
|
4984
|
+
const scan = scanDoneForPending(cap, cfg.site, state.pending.anchor, {
|
|
4985
|
+
wide: state.pending.screenDoneWide,
|
|
4986
|
+
doneFloor: state.pending.doneFloor ?? null,
|
|
4987
|
+
});
|
|
4988
|
+
if (state.pending.doneFloor != null && scan.floor !== state.pending.doneFloor) {
|
|
4989
|
+
state.pending.doneFloor = scan.floor;
|
|
4990
|
+
}
|
|
4991
|
+
if (scan.done) {
|
|
4705
4992
|
// ★報告は画面から読まない。職人が書いたファイルを読む(relayReportDirs / readReport)。
|
|
4706
4993
|
// 画面を読む方式は窓が 16〜34 行しかなく、9/5 以降 162 回すべて失敗していた(調査 d1d16a56)。
|
|
4707
4994
|
// ★「届いた」は形まで見る(便 461be339)。整っていなければ理由も一緒に届ける。
|
|
@@ -4960,14 +5247,26 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
4960
5247
|
if (kindOf(cfg) === 'codex' && state.codexTurnOpen !== null && state.codexNoticeSeen) {
|
|
4961
5248
|
const open = state.codexTurnOpen;
|
|
4962
5249
|
const held = Date.now() - open.since;
|
|
4963
|
-
|
|
5250
|
+
// ★知らせ1本に頼らない出口(便 b4747dd7・調査 b9f6f53e の直す案②)。
|
|
5251
|
+
// 画面が待機で、かつその便が管制で終端なら、ターンは終わっている=札を下ろして配る。
|
|
5252
|
+
// ★画面が作業中に見えるあいだは下ろさない(長いターンの途中に割り込まない守りは残す)。
|
|
5253
|
+
const holdCap = (hooks?.capture ?? tmuxCapture)(cfg.site);
|
|
5254
|
+
const screenIdle = holdCap === null ? null : looksIdleFor(holdCap, kindOf(cfg));
|
|
5255
|
+
const centralFinished = screenIdle === true ? isCentralFinished(await fetchCommandStatus(cfg, open.jobId)) : null;
|
|
5256
|
+
const release = decideCodexTurnRelease({ heldMs: held, screenIdle, centralFinished });
|
|
5257
|
+
if (release === 'hold') {
|
|
4964
5258
|
noteReadiness(state, 'busy', cmd.id);
|
|
4965
5259
|
log(`site=${cfg.site} id=${cmd.id} 前の便(id=${open.jobId})のターンがまだ終わっていないので配りません` +
|
|
4966
|
-
`(${Math.round(held / 1000)}
|
|
5260
|
+
`(${Math.round(held / 1000)}秒待っています・画面=` +
|
|
5261
|
+
`${screenIdle === null ? '読めない' : screenIdle ? '待機' : '作業中'}` +
|
|
5262
|
+
`${screenIdle === true ? '・管制=終端でない' : ''})`);
|
|
4967
5263
|
return;
|
|
4968
5264
|
}
|
|
4969
|
-
log(
|
|
4970
|
-
|
|
5265
|
+
log(release === 'release-finished'
|
|
5266
|
+
? `site=${cfg.site} id=${cmd.id} 前の便(id=${open.jobId})は画面が待機で管制でも終端です` +
|
|
5267
|
+
'→札を下ろして配ります(終わりの知らせは来ていません)'
|
|
5268
|
+
: `WARN site=${cfg.site} id=${cmd.id} 前の便(id=${open.jobId})のターンの終わりの知らせが` +
|
|
5269
|
+
`${Math.round(RELAY_CODEX_TURN_HOLD_MAX_MS / 60000)}分来ないので、札を下ろして配ります`);
|
|
4971
5270
|
state.codexTurnOpen = null;
|
|
4972
5271
|
}
|
|
4973
5272
|
// ── アイドル判定(保守的・二重チェック) ──
|
|
@@ -5082,6 +5381,12 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
5082
5381
|
// 届く先が本物の職人の箱であることは、直前の tmuxHasSession / isIdleStable / looksLikeAgent が
|
|
5083
5382
|
// 確認済み。だから「送れた=配達できた」と扱ってよい。
|
|
5084
5383
|
const sentAt = Date.now();
|
|
5384
|
+
// ★Codex はアンカーが画面に出ない(長い貼り付けを畳んで表示する)=アンカーより下を見る
|
|
5385
|
+
// 完了検出が原理的に成立せず、毎回 15分の上限を待ち切っていた(調査 b9f6f53e・直す案①)。
|
|
5386
|
+
// 配達したという事実は send-keys の成否で確定しているので、見張りと同じ理屈で全画面を
|
|
5387
|
+
// 見てよい。取り違えを防ぐため【配った時点に見えていた DONE 行の数】を床として覚え、
|
|
5388
|
+
// 床より増えたときだけ完了とみなす(PendingEntry.doneFloor の説明)。
|
|
5389
|
+
const codexFloor = kindOf(cfg) === 'codex' ? countStandaloneDone(guardCap, cfg.site) : null;
|
|
5085
5390
|
state.pending = {
|
|
5086
5391
|
id: cmd.id,
|
|
5087
5392
|
site: cfg.site,
|
|
@@ -5090,7 +5395,8 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
5090
5395
|
body: cmd.body,
|
|
5091
5396
|
delivered: true,
|
|
5092
5397
|
sentMarked: false,
|
|
5093
|
-
screenDoneWide:
|
|
5398
|
+
screenDoneWide: codexFloor !== null,
|
|
5399
|
+
...(codexFloor !== null ? { doneFloor: codexFloor, doneFloorSettled: false } : {}),
|
|
5094
5400
|
...(useAck ? { markAs: 'delivering' } : {}),
|
|
5095
5401
|
};
|
|
5096
5402
|
// 累計配達回数を通算(絶対上限 RELAY_DELIVER_MAX の基準)。
|
|
@@ -5160,6 +5466,8 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
5160
5466
|
await sendAckAtCentral(cfg, state);
|
|
5161
5467
|
}
|
|
5162
5468
|
finally {
|
|
5469
|
+
// ★どの道で抜けても、札の中身を控えへ写す(中身が変わっていなければ書かない)。
|
|
5470
|
+
saveCodexTurn(cfg, state);
|
|
5163
5471
|
state.running = false;
|
|
5164
5472
|
}
|
|
5165
5473
|
}
|