@tyhld/conductor 0.12.102 → 0.12.104
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/dist/selfcheck.js +122 -32
- 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
|
}
|
package/dist/selfcheck.js
CHANGED
|
@@ -21,7 +21,13 @@
|
|
|
21
21
|
* 【★落ちたときは理由を残す(便 5a94ba06)】小さな headless 試験が落ちたら、その項目の detail に
|
|
22
22
|
* 「結果の行の本文・stderr の末尾・終了コード」を残す(控えにも管制にも同じ中身が載る)。
|
|
23
23
|
* ④囲い・⑤通信の ✗ が「小さな試験が先に落ちたので確かめられなかった」ときは、そう読める文にする。
|
|
24
|
-
*
|
|
24
|
+
*
|
|
25
|
+
* 【★既知の一時障害1種類だけ、60秒後に1回だけやり直す(えふさん確定 2026-10-05)】
|
|
26
|
+
* 小さな headless 試験が落ちたら理由を分類し、Claude Code の「認証の更新が別の Claude Code の
|
|
27
|
+
* 処理と重なった」と【確かめられた】失敗のときだけ、60秒待って1回だけやり直す。2回目が通れば
|
|
28
|
+
* その自己診断は合格(④囲い・⑤通信も2回目の結果で判定)。再失敗・別種類の失敗は、今までどおり
|
|
29
|
+
* 即 FAIL=戻して配布を止める。★2回目のやり直しは無い。★「2回目が通った=版は正常」とは
|
|
30
|
+
* 一般化しない(この1種類だけの例外)。やり直した事実は ⑥ の detail に残す(控えにも管制にも)。
|
|
25
31
|
*
|
|
26
32
|
* 【結果】手元の控え(~/.local/state/conductor/selfcheck/last.json と history/)に必ず残す。
|
|
27
33
|
* 管制の口が在れば送る(★口が無い・届かないときも落ちない=送れなかったことを控えに書くだけ)。
|
|
@@ -645,6 +651,53 @@ export function headlessFailureDetail(h) {
|
|
|
645
651
|
out.push(`手がかり: 終了コード ${h.exitCode === null ? '不明' : h.exitCode}`);
|
|
646
652
|
return out;
|
|
647
653
|
}
|
|
654
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
655
|
+
// 失敗の分類と、1回だけのやり直し(えふさん確定 2026-10-05)
|
|
656
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
657
|
+
/**
|
|
658
|
+
* 「認証の更新の重なり」と判定できたときだけ待つ間(★60秒・えふさん確定 2026-10-05)。
|
|
659
|
+
* ★やり直しは60秒後に【1回だけ】(2回目のやり直しは無い)。
|
|
660
|
+
*/
|
|
661
|
+
export const SELFCHECK_AUTH_RETRY_WAIT_MS = 60_000;
|
|
662
|
+
/**
|
|
663
|
+
* 「認証の更新の重なり」の文(★実物で確かめた形)。
|
|
664
|
+
*
|
|
665
|
+
* 【実物 2026-10-05】FshimaNP 09:17:28Z(きっかけ after-rollback・0.12.102)と
|
|
666
|
+
* TYHLD-OTA 11:43:43Z(きっかけ after-conductor・0.12.101)の「結果の行の本文」が、どちらも
|
|
667
|
+
* `Failed to refresh OAuth token: another Claude Code process is refreshing it or exited mid-refresh.
|
|
668
|
+
* This is usually transient; retry in a minute, and if it persists close other Claude Code processes
|
|
669
|
+
* or sign in again` だった(終了コード 1・結果の行は subtype=success のまま is_error=true・使用量 0・
|
|
670
|
+
* tool が1つも動かず「AI に届く前に終わった」)。
|
|
671
|
+
* ★2つとも当たることを求める。片方だけ・文が少し違う物は「別種類の失敗」として扱う(やり直さない)。
|
|
672
|
+
*/
|
|
673
|
+
const AUTH_REFRESH_CONFLICT_PATTERNS = [
|
|
674
|
+
/failed to refresh oauth token/i,
|
|
675
|
+
/another\b[^.]{0,60}\bprocess is refreshing it\b/i,
|
|
676
|
+
];
|
|
677
|
+
/**
|
|
678
|
+
* 小さな試験の失敗を分類する(★やり直してよいのは1種類だけ)。
|
|
679
|
+
* 「認証の更新の重なり」と判定するのは、次の【両方】を満たす失敗だけ。
|
|
680
|
+
* (a) 結果の行の本文が、Claude Code の「認証の更新が別の処理と重なった」の文に当たる
|
|
681
|
+
* (b) AI に届く前に終わっている(使用量 0・tool が1つも動いていない)
|
|
682
|
+
* ★迷う物は 'other'(=やり直さない)へ倒す。「2回目が通った=版は正常」とは一般化しない。
|
|
683
|
+
*/
|
|
684
|
+
export function classifyHeadlessFailure(h) {
|
|
685
|
+
// 流していない(claude が無い・雛形を読めない)=この種類ではない。
|
|
686
|
+
if (!h.ran)
|
|
687
|
+
return 'other';
|
|
688
|
+
// (a) 結果の行の本文
|
|
689
|
+
const text = h.summary?.result?.text ?? '';
|
|
690
|
+
if (!text.trim())
|
|
691
|
+
return 'other';
|
|
692
|
+
if (!AUTH_REFRESH_CONFLICT_PATTERNS.every((re) => re.test(text)))
|
|
693
|
+
return 'other';
|
|
694
|
+
// (b) AI に届く前に終わっている
|
|
695
|
+
if (reachedModel(h))
|
|
696
|
+
return 'other';
|
|
697
|
+
if ((h.summary?.toolResults.length ?? 0) > 0)
|
|
698
|
+
return 'other';
|
|
699
|
+
return 'auth-refresh-conflict';
|
|
700
|
+
}
|
|
648
701
|
/** 小さな試験と MCP の確かめで claude に渡す環境(★中継と同じ形・src/headless.ts)。 */
|
|
649
702
|
function probeEnv(d, claudeBin) {
|
|
650
703
|
const env = { ...d.env };
|
|
@@ -653,15 +706,23 @@ function probeEnv(d, claudeBin) {
|
|
|
653
706
|
env.PATH = buildWorkerPath(env.PATH, claudeBin);
|
|
654
707
|
return env;
|
|
655
708
|
}
|
|
656
|
-
/**
|
|
657
|
-
|
|
709
|
+
/**
|
|
710
|
+
* 1回目/やり直しの作業場所の名前(★やり直しは別のフォルダで流す)。
|
|
711
|
+
* 同じフォルダで流すと、1回目に外へ書けてしまった跡(probe.txt)が残り、
|
|
712
|
+
* やり直しの「囲いの外へ書けたか」を誤って open と読む。
|
|
713
|
+
*/
|
|
714
|
+
export function probeWorkName(attempt) {
|
|
715
|
+
return attempt === 1 ? 'work' : 'work-retry2';
|
|
716
|
+
}
|
|
717
|
+
/** ⑥ 小さな headless 試験を1回流す(attempt=2 はやり直しの回)。 */
|
|
718
|
+
function runHeadlessProbe(d, claudeBin, runDir, attempt = 1) {
|
|
658
719
|
const res = { summary: null, exitCode: null, outsideWrite: 'unknown', net: {}, probeRan: false, error: null, ran: false, stderr: '' };
|
|
659
720
|
if (!claudeBin) {
|
|
660
721
|
res.error = 'claude が無いので流していません';
|
|
661
722
|
return res;
|
|
662
723
|
}
|
|
663
|
-
const work = path.join(runDir,
|
|
664
|
-
const outside = path.join(runDir, 'outside');
|
|
724
|
+
const work = path.join(runDir, probeWorkName(attempt));
|
|
725
|
+
const outside = path.join(runDir, attempt === 1 ? 'outside' : 'outside-retry2');
|
|
665
726
|
const outsideFile = path.join(outside, 'probe.txt');
|
|
666
727
|
mkdirSync(path.join(work, '.claude'), { recursive: true });
|
|
667
728
|
mkdirSync(outside, { recursive: true });
|
|
@@ -699,6 +760,38 @@ function runHeadlessProbe(d, claudeBin, runDir) {
|
|
|
699
760
|
res.net[m[1]] = m[2];
|
|
700
761
|
return res;
|
|
701
762
|
}
|
|
763
|
+
/**
|
|
764
|
+
* 小さな試験の1回の結果から ⑥ の項目を作る(★1回目もやり直しも【同じ読み方】で判定する)。
|
|
765
|
+
* 合否は職人の言葉でなく、終了コード・結果の行・tool の結果で決める(便 b7bd5e43 から変えない)。
|
|
766
|
+
*/
|
|
767
|
+
export function judgeHeadlessProbe(h) {
|
|
768
|
+
const item = { key: 'headless', label: '小さな headless 試験', ok: null, detail: [] };
|
|
769
|
+
const sum = h.summary;
|
|
770
|
+
if (h.error)
|
|
771
|
+
note(item, safeDetail(h.error));
|
|
772
|
+
if (!sum || !sum.result)
|
|
773
|
+
fail(item, 'fault', `結果の行がありません(終了 ${h.exitCode})`);
|
|
774
|
+
else {
|
|
775
|
+
if (h.exitCode === 0)
|
|
776
|
+
pass(item, '終了コード 0');
|
|
777
|
+
else
|
|
778
|
+
fail(item, 'fault', `終了コード ${h.exitCode}`);
|
|
779
|
+
if (sum.result.subtype === 'success' && !sum.result.isError)
|
|
780
|
+
pass(item, '結果の行が success');
|
|
781
|
+
else
|
|
782
|
+
fail(item, 'fault', `結果の行が ${sum.result.subtype ?? '不明'}(is_error=${sum.result.isError})`);
|
|
783
|
+
if (h.probeRan)
|
|
784
|
+
pass(item, '囲いの中で Bash の台本が動きました(tool の結果で確認)');
|
|
785
|
+
else
|
|
786
|
+
fail(item, 'fault', 'tool の結果に台本の目印がありません(Bash が動いていない)');
|
|
787
|
+
}
|
|
788
|
+
// ★落ちたときは「なぜ落ちたか」の手がかりを必ず残す(控えにも管制にも・便 5a94ba06)。
|
|
789
|
+
// 通ったときは1行も足さない(出力は今までと同じ)。
|
|
790
|
+
if (item.ok === false)
|
|
791
|
+
for (const line of headlessFailureDetail(h))
|
|
792
|
+
note(item, line);
|
|
793
|
+
return item;
|
|
794
|
+
}
|
|
702
795
|
/** +番人:入っている番人に、止めるべき物と通すべき物を1つずつ判定させる。 */
|
|
703
796
|
function checkGuard(d, work) {
|
|
704
797
|
const item = { key: 'guard', label: '番人', ok: null, detail: [] };
|
|
@@ -856,34 +949,30 @@ export async function runSelfcheck(d, trigger) {
|
|
|
856
949
|
const network = net.item;
|
|
857
950
|
const guard = checkGuard(d, path.join(runDir, 'work'));
|
|
858
951
|
// ⑥ 小さな試験(③の MCP・④の書き込み・⑤の内側の通信もここで見る)
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
952
|
+
//
|
|
953
|
+
// ★1回目が「認証の更新の重なり」と判定できたときだけ、60秒待って【1回だけ】やり直す
|
|
954
|
+
// (えふさん確定 2026-10-05)。2回目が通ればその自己診断は合格=④囲い・⑤通信も2回目の結果で
|
|
955
|
+
// 判定する。2回目が落ちたら(理由が何であっても)FAIL=今までどおり戻す・配布を止める。
|
|
956
|
+
// ★別種類の失敗は1回も待たずに即 FAIL。きっかけ(daily・after-conductor・after-claude・
|
|
957
|
+
// after-rollback)を問わず同じ動きにする。
|
|
958
|
+
let h = runHeadlessProbe(d, claude.bin, runDir, 1);
|
|
959
|
+
let headless = judgeHeadlessProbe(h);
|
|
960
|
+
let probeAttempt = 1;
|
|
961
|
+
if (headless.ok === false && classifyHeadlessFailure(h) === 'auth-refresh-conflict') {
|
|
962
|
+
const sleep = d.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
963
|
+
await sleep(SELFCHECK_AUTH_RETRY_WAIT_MS);
|
|
964
|
+
h = runHeadlessProbe(d, claude.bin, runDir, 2);
|
|
965
|
+
headless = judgeHeadlessProbe(h);
|
|
966
|
+
probeAttempt = 2;
|
|
967
|
+
// ★やり直した事実を残す(控え・管制の両方に・合格した回でも「再試行で通った」と読める)。
|
|
968
|
+
const sec = Math.round(SELFCHECK_AUTH_RETRY_WAIT_MS / 1000);
|
|
969
|
+
note(headless, headless.ok === false
|
|
970
|
+
? `1回目は認証の更新の重なりで落ちました・${sec}秒後の2回目も落ちました`
|
|
971
|
+
: `1回目は認証の更新の重なりで落ちました・${sec}秒後の2回目で通りました`);
|
|
879
972
|
}
|
|
880
|
-
|
|
881
|
-
// 通ったときは1行も足さない(出力は今までと同じ)。
|
|
882
|
-
if (headless.ok === false)
|
|
883
|
-
for (const line of headlessFailureDetail(h))
|
|
884
|
-
note(headless, line);
|
|
973
|
+
const sum = h.summary;
|
|
885
974
|
// ③ MCP(★落ち着くまで確かめる・便が実際に使う登録を見る=settleMcp)
|
|
886
|
-
const mcp = await settleMcp(d, claude.bin, path.join(runDir,
|
|
975
|
+
const mcp = await settleMcp(d, claude.bin, path.join(runDir, probeWorkName(probeAttempt)), sum?.mcp ?? null);
|
|
887
976
|
const seen = mcp.source === 'mcp-list' ? `claude mcp list で確かめた・${mcp.tries}回目` : mcp.source === 'init' ? '小さな試験の最初の行' : '一覧なし';
|
|
888
977
|
const mineOf = (st) => (mcp.list ?? []).filter((s) => /ai-kana/i.test(s.name) && s.status === st).map((s) => s.name);
|
|
889
978
|
if (mcp.state === 'connected') {
|
|
@@ -960,7 +1049,8 @@ export async function runSelfcheck(d, trigger) {
|
|
|
960
1049
|
rmSync(runDir, { recursive: true, force: true });
|
|
961
1050
|
// ★道具(Claude Code)が作業場所ごとに /tmp/claude-<uid>/ の下へ作る記録も消す(便 e9276b86)。
|
|
962
1051
|
// 作業場所は毎回名前が違うので、消さないと自己診断のたびに1つずつ溜まる(ノートPCで9つ)。
|
|
963
|
-
|
|
1052
|
+
// ★やり直しの回の作業場所(work-retry2)も同じように消す。
|
|
1053
|
+
for (const dir of [runDir, path.join(runDir, probeWorkName(1)), path.join(runDir, probeWorkName(2))]) {
|
|
964
1054
|
const trace = claudeTraceDir(dir, d.claudeTmpRoot);
|
|
965
1055
|
if (trace)
|
|
966
1056
|
rmSync(trace, { recursive: true, force: true });
|