@tyhld/conductor 0.12.98 → 0.12.100
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/autoupdate.js +14 -4
- package/dist/relay.js +318 -7
- package/dist/selfcheck.js +98 -4
- package/package.json +1 -1
- package/scripts/codex/codex-notify.py +215 -21
package/dist/autoupdate.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* ・入れ替えは全現場が空きのときだけ: 札を置く → 中継の1周ぶん待つ → もう一度空きを確かめる →
|
|
18
18
|
* 版を名指しして入れる(取得先は毎回 https://registry.npmjs.org・@latest にしない)→
|
|
19
19
|
* 全現場の常駐を起こし直す → 札を外す → 自己診断 → 管制へ(自己診断が届ける)。
|
|
20
|
-
* ・空きでなければ次の回に再挑戦。
|
|
20
|
+
* ・空きでなければ次の回に再挑戦。30分空かなければ札を置いて新しい便を取らない形に移り、
|
|
21
21
|
* 走っている便の終わりを待つ(★走っている便は止めない)。
|
|
22
22
|
* ・自己診断が FAIL → そのPCだけ前の版へ戻す → 自己診断(after-rollback=rolledBack が管制へ届く)。
|
|
23
23
|
* 戻しても FAIL なら、その版のまま止まって🟡(Claude Code・囲い・小さな試験が落ちたままなら
|
|
@@ -47,8 +47,18 @@ export const CONDUCTOR_REGISTRY_ARG = '--@tyhld:registry=https://registry.npmjs.
|
|
|
47
47
|
export const CLAUDE_INSTALL_URL = 'https://claude.ai/install.sh';
|
|
48
48
|
/** 管制が checkAgainMin を返さないときの間隔(分)。 */
|
|
49
49
|
export const DEFAULT_CHECK_MIN = 15;
|
|
50
|
-
/**
|
|
51
|
-
|
|
50
|
+
/**
|
|
51
|
+
* 空きが来ないとき、札を置いて新しい便を取らない形に移るまでの長さ。
|
|
52
|
+
*
|
|
53
|
+
* 【30分にした理由(えふさん確定 2026-10-05・便 684ac785)】
|
|
54
|
+
* 以前は6時間。便が続いている機体(メインPC)は「全現場が同時に空く時」が来ないので、
|
|
55
|
+
* 新しい版への入れ替えが6時間待たされた。実物: メインPCの配り役が 0.12.92 のままで、
|
|
56
|
+
* Codex 向けの配達文(0.12.95 から入った)が便に入らなかった。
|
|
57
|
+
* 札を置けば新しい便を取らなくなるので、走っている便が終わった時点で空きになる=
|
|
58
|
+
* 30分で札を置くほうが、入れ替えまでの時間がはるかに短くなる。
|
|
59
|
+
* ★走っている便は止めない(今までどおり)。札は入れ替えが済んだら外す(今までどおり)。
|
|
60
|
+
*/
|
|
61
|
+
export const BUSY_LIMIT_MS = 30 * 60 * 1000;
|
|
52
62
|
/** 起こし直した常駐が立ち上がるのを待つ上限。 */
|
|
53
63
|
export const RESTART_WAIT_MS = 120_000;
|
|
54
64
|
/** 起こし直した常駐の様子を見る間隔。 */
|
|
@@ -569,7 +579,7 @@ export async function runUpdater(d) {
|
|
|
569
579
|
const waited = now.getTime() - Date.parse(s.busySince);
|
|
570
580
|
if (waited >= BUSY_LIMIT_MS) {
|
|
571
581
|
placeDrain(d, s);
|
|
572
|
-
return done(d, s, 'draining', `${comp} ${want} へ入れ替えたいが、
|
|
582
|
+
return done(d, s, 'draining', `${comp} ${want} へ入れ替えたいが、30分空きが来ません。札を置いて、走っている便(${busy.join(',')})の終わりを待ちます`);
|
|
573
583
|
}
|
|
574
584
|
return done(d, s, 'busy', `作業中の便があるので入れ替えません(${busy.join(',')})。次の回に再挑戦します`);
|
|
575
585
|
}
|
package/dist/relay.js
CHANGED
|
@@ -316,6 +316,11 @@ 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
|
+
* ★配達の判断には1バイトも関わらない。フォルダを見に行くので毎周はやらない。
|
|
322
|
+
*/
|
|
323
|
+
export const RELAY_CODEX_TRAY_TIDY_MS = 10 * 60 * 1000;
|
|
319
324
|
/** 初期状態を作る。 */
|
|
320
325
|
export function createRelayState() {
|
|
321
326
|
return {
|
|
@@ -337,6 +342,7 @@ export function createRelayState() {
|
|
|
337
342
|
lastNudgeLogAt: null,
|
|
338
343
|
codexTurnOpen: null,
|
|
339
344
|
codexNoticeSeen: false,
|
|
345
|
+
codexTrayTidiedAt: null,
|
|
340
346
|
central: { failures: 0, detail: '', downSince: null },
|
|
341
347
|
readiness: createReadiness(),
|
|
342
348
|
};
|
|
@@ -1046,11 +1052,22 @@ export function buildAnchor(id) {
|
|
|
1046
1052
|
// 現場ごとの受け皿へ1行書く。relay はそれを読み、**既存の完了経路**(patchStatus done+report)
|
|
1047
1053
|
// へそのまま橋渡しする。=完了を中央へ伝える道は今までどおり1本のまま。
|
|
1048
1054
|
// ───────────────────────────────────────────────────────────────────────────
|
|
1049
|
-
/**
|
|
1055
|
+
/**
|
|
1056
|
+
* Codex の受け皿の置き場。受け口スクリプトと同じ決め方にする。
|
|
1057
|
+
*
|
|
1058
|
+
* ★試験のあいだは、置き場を名指ししていなければ【使い捨ての置き場】へ倒す(便 684ac785)。
|
|
1059
|
+
* 受け皿には本物の便の完了が入っている(~/.tyhld/codex-notify)。試験が置き場を渡し忘れると、
|
|
1060
|
+
* 本物の受け皿を読んで消す・控えや登録簿を書き足す、が起こり得る。tmux の隔てと同じ考えで、
|
|
1061
|
+
* 「試験を書く人が渡し忘れても本物に触れない」ようにしておく(人の約束に頼らない)。
|
|
1062
|
+
*/
|
|
1050
1063
|
export function codexNotifyDir(env = process.env) {
|
|
1051
1064
|
const explicit = (env.CONDUCTOR_CODEX_NOTIFY_DIR ?? '').trim();
|
|
1052
1065
|
if (explicit)
|
|
1053
1066
|
return explicit;
|
|
1067
|
+
if (isUnderTest(env)) {
|
|
1068
|
+
// ★プロセスごとに別の置き場(試験ファイルは別プロセスで走るので、互いに混ざらない)。
|
|
1069
|
+
return path.join(os.tmpdir(), `conductor-test-codex-notify-${process.pid}`);
|
|
1070
|
+
}
|
|
1054
1071
|
const home = (env.HOME ?? '').trim();
|
|
1055
1072
|
return home ? path.join(home, '.tyhld', 'codex-notify') : '';
|
|
1056
1073
|
}
|
|
@@ -1261,6 +1278,254 @@ export function pickCodexNoticeFor(site, job, env = process.env) {
|
|
|
1261
1278
|
return { notice, skipped, skippedFile: recordSkippedCodexNotices(site, skipped, env) };
|
|
1262
1279
|
}
|
|
1263
1280
|
// ───────────────────────────────────────────────────────────────────────────
|
|
1281
|
+
// 現場の登録簿と、読まれない受け皿の始末(★便 684ac785)
|
|
1282
|
+
//
|
|
1283
|
+
// 【何が起きていたか(実物 2026-10-04)】受け口(scripts/codex/codex-notify.py)は現場名を
|
|
1284
|
+
// 「Codex を起動したフォルダの名前」から決めていた。Codex を【現場の下のフォルダ】で
|
|
1285
|
+
// 起動すると、配り役が読まない名前の受け皿ができる。
|
|
1286
|
+
// 現場 codex-probe(配り役は codex-probe.jsonl を読む)
|
|
1287
|
+
// ~/codex-probe で起動 → codex-probe.jsonl … 読まれる
|
|
1288
|
+
// ~/codex-probe/video-lab で起動 → video-lab.jsonl … ★誰にも読まれない(30件・36KB)
|
|
1289
|
+
// 同じずれは「現場名とフォルダ名が違う現場」でも起きる(sites.sh: plus1 → plus1-foryou)。
|
|
1290
|
+
//
|
|
1291
|
+
// 【根治の2本立て】
|
|
1292
|
+
// ① 受け口が【配り役の現場名】で書く。そのために配り役が現場名と作業フォルダを
|
|
1293
|
+
// 登録簿(<受け皿の置き場>/sites.json)へ書き出す。受け口は配達文の `DONE:<現場>` を
|
|
1294
|
+
// 第一に見て、無いときこの登録簿を引く(codex-notify.py の resolve_site)。
|
|
1295
|
+
// ② 既に溜まっている【読まれない受け皿】を、人の手を借りずに控えへ回す
|
|
1296
|
+
// (sweepStrayCodexNotices)。★消さない=中身は1行も失わずに控えへ移す。
|
|
1297
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1298
|
+
/** 現場の登録簿のファイル名(★codex-notify.py の SITES_REGISTRY と同じ名前)。 */
|
|
1299
|
+
export const CODEX_SITES_REGISTRY = 'sites.json';
|
|
1300
|
+
/** 現場名として使える字(★scripts/sites.sh の is_valid_site_name と同じ)。 */
|
|
1301
|
+
const SITE_NAME_OK = /^[A-Za-z0-9_-]+$/;
|
|
1302
|
+
/** 登録簿のパス。受け皿の置き場が分からなければ ''。 */
|
|
1303
|
+
export function codexSitesRegistryPath(env = process.env) {
|
|
1304
|
+
const dir = codexNotifyDir(env);
|
|
1305
|
+
return dir ? path.join(dir, CODEX_SITES_REGISTRY) : '';
|
|
1306
|
+
}
|
|
1307
|
+
/** 登録簿を読む(無い・壊れている → 空)。 */
|
|
1308
|
+
export function readCodexSiteRegistry(env = process.env) {
|
|
1309
|
+
const file = codexSitesRegistryPath(env);
|
|
1310
|
+
if (!file)
|
|
1311
|
+
return [];
|
|
1312
|
+
let raw;
|
|
1313
|
+
try {
|
|
1314
|
+
raw = readFileSync(file, 'utf8');
|
|
1315
|
+
}
|
|
1316
|
+
catch {
|
|
1317
|
+
return [];
|
|
1318
|
+
}
|
|
1319
|
+
try {
|
|
1320
|
+
const o = JSON.parse(raw);
|
|
1321
|
+
if (!Array.isArray(o.sites))
|
|
1322
|
+
return [];
|
|
1323
|
+
const out = [];
|
|
1324
|
+
for (const e of o.sites) {
|
|
1325
|
+
if (typeof e !== 'object' || e === null)
|
|
1326
|
+
continue;
|
|
1327
|
+
const r = e;
|
|
1328
|
+
const site = typeof r.site === 'string' ? r.site.trim() : '';
|
|
1329
|
+
const workDir = typeof r.workDir === 'string' ? r.workDir.trim() : '';
|
|
1330
|
+
if (!SITE_NAME_OK.test(site) || workDir === '')
|
|
1331
|
+
continue;
|
|
1332
|
+
out.push({
|
|
1333
|
+
site,
|
|
1334
|
+
workDir,
|
|
1335
|
+
machine: typeof r.machine === 'string' ? r.machine : undefined,
|
|
1336
|
+
at: typeof r.at === 'string' ? r.at : undefined,
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
return out;
|
|
1340
|
+
}
|
|
1341
|
+
catch {
|
|
1342
|
+
return []; // 壊れていたら空から書き直す(配達の道は止めない)
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* 自分の現場を登録簿へ書き込む(ほかの現場の行は1つも落とさない)。
|
|
1347
|
+
* 返り: 実際に書いたか(中身が同じなら書かない=毎周の書き込みを増やさない)。
|
|
1348
|
+
* ★失敗しても false を返すだけ(登録簿が無くても受け口は ③④ の決め方へ倒れる)。
|
|
1349
|
+
*/
|
|
1350
|
+
export function recordCodexSite(entry, env = process.env, now = new Date()) {
|
|
1351
|
+
const file = codexSitesRegistryPath(env);
|
|
1352
|
+
if (!file)
|
|
1353
|
+
return false;
|
|
1354
|
+
if (!SITE_NAME_OK.test(entry.site) || entry.workDir.trim() === '')
|
|
1355
|
+
return false;
|
|
1356
|
+
const current = readCodexSiteRegistry(env);
|
|
1357
|
+
const mine = current.find((e) => e.site === entry.site);
|
|
1358
|
+
if (mine && mine.workDir === entry.workDir && mine.machine === entry.machine)
|
|
1359
|
+
return false;
|
|
1360
|
+
const next = [
|
|
1361
|
+
...current.filter((e) => e.site !== entry.site),
|
|
1362
|
+
{ site: entry.site, workDir: entry.workDir, machine: entry.machine, at: now.toISOString() },
|
|
1363
|
+
].sort((a, b) => (a.site < b.site ? -1 : a.site > b.site ? 1 : 0));
|
|
1364
|
+
try {
|
|
1365
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
1366
|
+
const tmp = `${file}.tmp`;
|
|
1367
|
+
writeFileSync(tmp, `${JSON.stringify({ sites: next }, null, 2)}\n`, 'utf8');
|
|
1368
|
+
renameSync(tmp, file); // 置き換えは原子的=受け口が書きかけを読まない
|
|
1369
|
+
return true;
|
|
1370
|
+
}
|
|
1371
|
+
catch {
|
|
1372
|
+
return false;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* その現場の作業フォルダを決める(登録簿に書く値・★純粋関数)。
|
|
1377
|
+
*
|
|
1378
|
+
* paneCwd(職人の窓がいま居る場所)から上へたどり、名前が現場名と同じ先祖が在ればそこ。
|
|
1379
|
+
* 無ければ、現場の置き場の定番(`~/projects/<現場>` → `~/<現場>`)のうち paneCwd を含む物。
|
|
1380
|
+
* どれでもなければ paneCwd そのまま(★その下で動く Codex の知らせは正しく届く)。
|
|
1381
|
+
* paneCwd が読めなければ `~/projects/<現場>`(headless と同じ既定)。
|
|
1382
|
+
*/
|
|
1383
|
+
export function codexSiteWorkDir(site, paneCwd, home) {
|
|
1384
|
+
const fallback = path.join(home, 'projects', site);
|
|
1385
|
+
const cwd = (paneCwd ?? '').trim();
|
|
1386
|
+
if (cwd === '' || !path.isAbsolute(cwd))
|
|
1387
|
+
return fallback;
|
|
1388
|
+
const norm = path.normalize(cwd);
|
|
1389
|
+
for (let cur = norm;;) {
|
|
1390
|
+
if (path.basename(cur) === site)
|
|
1391
|
+
return cur;
|
|
1392
|
+
const parent = path.dirname(cur);
|
|
1393
|
+
if (parent === cur)
|
|
1394
|
+
break;
|
|
1395
|
+
cur = parent;
|
|
1396
|
+
}
|
|
1397
|
+
const under = (dir) => norm === dir || norm.startsWith(`${dir}${path.sep}`);
|
|
1398
|
+
for (const cand of [path.join(home, 'projects', site), path.join(home, site)]) {
|
|
1399
|
+
if (under(cand))
|
|
1400
|
+
return cand;
|
|
1401
|
+
}
|
|
1402
|
+
return norm;
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* 【配り役が読まない名前の受け皿】を、控えへ回す(★消さない・人が手で片付けなくて済む形)。
|
|
1406
|
+
*
|
|
1407
|
+
* 対象の見分け方: 受け皿の置き場に在る `<名前>.jsonl` のうち、
|
|
1408
|
+
* ・自分の受け皿(`<現場>.jsonl`)ではない
|
|
1409
|
+
* ・控え(`*.skipped.jsonl`)でも登録簿でもない
|
|
1410
|
+
* ・★`<名前>` が【この現場の作業フォルダの下に実在するフォルダの名前】
|
|
1411
|
+
* =古い受け口が「現場の下のフォルダで起動した Codex」のために作ってしまった受け皿だけ。
|
|
1412
|
+
* ほかの現場の受け皿を巻き込まない(名前が自分の作業フォルダの下に無ければ触らない)。
|
|
1413
|
+
*
|
|
1414
|
+
* 中身は1行も捨てず `<現場>.skipped.jsonl` へ理由つきで移し、空になった受け皿を取り除く。
|
|
1415
|
+
* ★どの便の完了にも使わない(控えは配り役が完了として読む場所ではない)。
|
|
1416
|
+
*/
|
|
1417
|
+
export function sweepStrayCodexNotices(site, workDir, env = process.env) {
|
|
1418
|
+
const dir = codexNotifyDir(env);
|
|
1419
|
+
if (!dir || workDir.trim() === '')
|
|
1420
|
+
return [];
|
|
1421
|
+
let names;
|
|
1422
|
+
try {
|
|
1423
|
+
names = readdirSync(dir);
|
|
1424
|
+
}
|
|
1425
|
+
catch {
|
|
1426
|
+
return [];
|
|
1427
|
+
}
|
|
1428
|
+
const subNames = subdirNamesUnder(workDir);
|
|
1429
|
+
const out = [];
|
|
1430
|
+
for (const name of names) {
|
|
1431
|
+
if (!name.endsWith('.jsonl'))
|
|
1432
|
+
continue;
|
|
1433
|
+
if (name.endsWith('.skipped.jsonl'))
|
|
1434
|
+
continue;
|
|
1435
|
+
const base = name.slice(0, -'.jsonl'.length);
|
|
1436
|
+
if (base === site || !SITE_NAME_OK.test(base))
|
|
1437
|
+
continue;
|
|
1438
|
+
if (!subNames.has(base))
|
|
1439
|
+
continue;
|
|
1440
|
+
const file = path.join(dir, name);
|
|
1441
|
+
let lines;
|
|
1442
|
+
try {
|
|
1443
|
+
lines = completeJsonlLines(readFileSync(file, 'utf8')).filter((l) => l.trim() !== '');
|
|
1444
|
+
}
|
|
1445
|
+
catch {
|
|
1446
|
+
continue;
|
|
1447
|
+
}
|
|
1448
|
+
const entries = lines.map((line) => {
|
|
1449
|
+
let report = '';
|
|
1450
|
+
let jobId = null;
|
|
1451
|
+
let threadId = null;
|
|
1452
|
+
let turnId = null;
|
|
1453
|
+
let at = null;
|
|
1454
|
+
try {
|
|
1455
|
+
const o = JSON.parse(line);
|
|
1456
|
+
report = typeof o.report === 'string' ? o.report : line;
|
|
1457
|
+
jobId = typeof o.jobId === 'string' && o.jobId.trim() !== '' ? o.jobId.trim() : null;
|
|
1458
|
+
threadId = o.threadId ?? null;
|
|
1459
|
+
turnId = o.turnId ?? null;
|
|
1460
|
+
at = parseNoticeAt(o.at);
|
|
1461
|
+
}
|
|
1462
|
+
catch {
|
|
1463
|
+
report = line; // 読めない行も中身をそのまま残す(捨てない)
|
|
1464
|
+
}
|
|
1465
|
+
return {
|
|
1466
|
+
notice: { site, report, threadId, turnId, jobId, at },
|
|
1467
|
+
why: `配り役が読まない名前の受け皿に入っていた知らせ(受け口の古い版・受け皿=${name})`,
|
|
1468
|
+
};
|
|
1469
|
+
});
|
|
1470
|
+
recordSkippedCodexNotices(site, entries, env);
|
|
1471
|
+
try {
|
|
1472
|
+
unlinkSync(file); // 中身は控えへ移してある=1行も失わない
|
|
1473
|
+
}
|
|
1474
|
+
catch {
|
|
1475
|
+
/* 消せなくても次の回にまた移すだけ(控えは同じ内容が増えるが、完了には使われない) */
|
|
1476
|
+
}
|
|
1477
|
+
out.push({ tray: name, moved: entries.length });
|
|
1478
|
+
}
|
|
1479
|
+
return out;
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* 受け皿の名前をそろえる(1周ぶん・★Codex の現場だけ)。
|
|
1483
|
+
* ① この現場の「現場名 ↔ 作業フォルダ」を登録簿へ書く(受け口がこれを引いて受け皿の名前を決める)。
|
|
1484
|
+
* ② 配り役が読まない名前の受け皿を控えへ回す(中身は1行も捨てない)。
|
|
1485
|
+
* ★RELAY_CODEX_TRAY_TIDY_MS ごとに1回だけ。配達の判断には関わらない(失敗しても配達は進む)。
|
|
1486
|
+
*/
|
|
1487
|
+
export function tidyCodexTrays(cfg, state, deps = {}) {
|
|
1488
|
+
const now = deps.now ?? Date.now();
|
|
1489
|
+
if (state.codexTrayTidiedAt !== null && now - state.codexTrayTidiedAt < RELAY_CODEX_TRAY_TIDY_MS) {
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
state.codexTrayTidiedAt = now;
|
|
1493
|
+
const logLine = deps.logLine ?? log;
|
|
1494
|
+
const home = deps.home ?? os.homedir();
|
|
1495
|
+
const paneCwd = (deps.paneCwd ?? tmuxPaneCwd)(cfg.site);
|
|
1496
|
+
const workDir = codexSiteWorkDir(cfg.site, paneCwd, home);
|
|
1497
|
+
if (recordCodexSite({ site: cfg.site, workDir, machine: cfg.machine }, deps.env)) {
|
|
1498
|
+
logLine(`site=${cfg.site} 現場の登録簿を書きました(作業フォルダ=${workDir})` +
|
|
1499
|
+
'=この下のどのフォルダで Codex が動いても、知らせはこの現場の受け皿に入ります');
|
|
1500
|
+
}
|
|
1501
|
+
for (const swept of sweepStrayCodexNotices(cfg.site, workDir, deps.env)) {
|
|
1502
|
+
logLine(`site=${cfg.site} 配り役が読まない受け皿 ${swept.tray} の ${swept.moved} 件を、` +
|
|
1503
|
+
'どの便の完了にも使わず控えへ回しました(中身は消していません)');
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
/** 作業フォルダの下(深さ2まで)に在るフォルダの名前。読めなければ空。 */
|
|
1507
|
+
function subdirNamesUnder(workDir) {
|
|
1508
|
+
const out = new Set();
|
|
1509
|
+
const walk = (dir, depth) => {
|
|
1510
|
+
let kids;
|
|
1511
|
+
try {
|
|
1512
|
+
kids = readdirSync(dir, { withFileTypes: true })
|
|
1513
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith('.') && d.name !== 'node_modules')
|
|
1514
|
+
.map((d) => d.name);
|
|
1515
|
+
}
|
|
1516
|
+
catch {
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
for (const k of kids) {
|
|
1520
|
+
out.add(k);
|
|
1521
|
+
if (depth > 1)
|
|
1522
|
+
walk(path.join(dir, k), depth - 1);
|
|
1523
|
+
}
|
|
1524
|
+
};
|
|
1525
|
+
walk(workDir, 2);
|
|
1526
|
+
return out;
|
|
1527
|
+
}
|
|
1528
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1264
1529
|
// Codex の最後の返事に書いてもらう「機械で読める囲み」(便 cfa9eeec)
|
|
1265
1530
|
//
|
|
1266
1531
|
// 【なぜ要るか(実測 2026-10-04・codex-cli 0.160.0・便 f8ac8674 / 4c9f5049)】
|
|
@@ -1857,11 +2122,54 @@ export function codexCompletionGuide(site, commandId) {
|
|
|
1857
2122
|
}
|
|
1858
2123
|
// ───────────────────────────────────────────────────────────────────────────
|
|
1859
2124
|
// tmux I/O(失敗は握りつぶして null/false。送らない側に倒すため)
|
|
2125
|
+
//
|
|
2126
|
+
// 【★試験が本物の職人の画面へ1文字も送れない作り(便 684ac785)】
|
|
2127
|
+
// 実地の事故 2026-10-04(便 99fc49c0): 配達の道を通る試験を【実物と同じ現場名】
|
|
2128
|
+
// (codex-probe)で書いてしまい、動いている Codex の画面へ試験の文が貼られ、
|
|
2129
|
+
// Codex が 38 秒の追加ターンを走らせた。
|
|
2130
|
+
// そのときの直しは「試験の現場名を存在しない名前に変える」=★人の約束だった。
|
|
2131
|
+
// 約束は破られる(次に試験を書く人が実物の名前を使えば、また届く)。
|
|
2132
|
+
//
|
|
2133
|
+
// ★だから【送り先そのもの】を仕組みで隔てる。tmux は `-L <名前>` で
|
|
2134
|
+
// 【別のサーバ(別のソケット)】に話しかける。本物の職人は【既定のサーバ】に居るので、
|
|
2135
|
+
// 試験のあいだ必ず `-L` を挟めば、同じ現場名を書いても本物には構造的に届かない。
|
|
2136
|
+
// ★この挟み込みは、tmux を呼ぶ道を1本(tmuxExec)に寄せたうえで、そこだけで行う。
|
|
2137
|
+
// 「試験かどうか」は Node の試験ランナーが自分で立てる印(NODE_TEST_CONTEXT)で測る=
|
|
2138
|
+
// 試験を書く人が設定を書き忘れても必ず効く(約束に頼らない)。
|
|
1860
2139
|
// ───────────────────────────────────────────────────────────────────────────
|
|
2140
|
+
/** 試験のあいだ使う tmux サーバの名前(★本物の職人が居る既定のサーバとは別物)。 */
|
|
2141
|
+
export const TMUX_TEST_SERVER = 'conductor-test';
|
|
2142
|
+
/**
|
|
2143
|
+
* いま試験の中で動いているか。★Node の試験ランナー(`node --test`)が子プロセスへ必ず渡す印を見る。
|
|
2144
|
+
* 実測(node 24.15.0): 試験ファイルのプロセスでは `NODE_TEST_CONTEXT=child-v8`。
|
|
2145
|
+
* =試験を書く人が何も書かなくても効く(人の約束に頼らない)。
|
|
2146
|
+
*/
|
|
2147
|
+
export function isUnderTest(env = process.env) {
|
|
2148
|
+
return (env.NODE_TEST_CONTEXT ?? '').trim() !== '';
|
|
2149
|
+
}
|
|
2150
|
+
/**
|
|
2151
|
+
* tmux を呼ぶときに必ず前へ置く引数(どのサーバに話しかけるか)。
|
|
2152
|
+
* - `CONDUCTOR_TMUX_SERVER` が在れば、その名前のサーバ(試験が自分で立てたサーバを指すため)。
|
|
2153
|
+
* - 無く、かつ試験の中なら、試験用のサーバ(★本物には届かない)。
|
|
2154
|
+
* - ふだんの運用では空=既定のサーバ(今までと1バイトも変わらない)。
|
|
2155
|
+
*/
|
|
2156
|
+
export function tmuxServerArgs(env = process.env) {
|
|
2157
|
+
const explicit = (env.CONDUCTOR_TMUX_SERVER ?? '').trim();
|
|
2158
|
+
if (explicit !== '')
|
|
2159
|
+
return ['-L', explicit];
|
|
2160
|
+
return isUnderTest(env) ? ['-L', TMUX_TEST_SERVER] : [];
|
|
2161
|
+
}
|
|
2162
|
+
/**
|
|
2163
|
+
* tmux を呼ぶ【唯一の口】。★ここ以外から tmux を起動しない
|
|
2164
|
+
* (見張りの試験 relay-test-tmux-isolation が、口が1つのままかを字面で確かめる)。
|
|
2165
|
+
*/
|
|
2166
|
+
function tmuxExec(args, opts) {
|
|
2167
|
+
return execFileSync('tmux', [...tmuxServerArgs(), ...args], opts);
|
|
2168
|
+
}
|
|
1861
2169
|
/** tmux セッション {site} が存在するか。tmux 未インストール時も false。 */
|
|
1862
2170
|
export function tmuxHasSession(site) {
|
|
1863
2171
|
try {
|
|
1864
|
-
|
|
2172
|
+
tmuxExec(['has-session', '-t', site], { stdio: 'ignore' });
|
|
1865
2173
|
return true;
|
|
1866
2174
|
}
|
|
1867
2175
|
catch {
|
|
@@ -1883,7 +2191,7 @@ export const RELAY_CAPTURE_SCROLLBACK_LINES = 2000;
|
|
|
1883
2191
|
*/
|
|
1884
2192
|
export function tmuxCapture(site) {
|
|
1885
2193
|
try {
|
|
1886
|
-
return
|
|
2194
|
+
return tmuxExec(['capture-pane', '-t', site, '-p', '-S', `-${RELAY_CAPTURE_SCROLLBACK_LINES}`], {
|
|
1887
2195
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1888
2196
|
encoding: 'utf8',
|
|
1889
2197
|
});
|
|
@@ -2542,7 +2850,7 @@ function describeExecError(err) {
|
|
|
2542
2850
|
}
|
|
2543
2851
|
/** tmux を1回呼ぶ。失敗時は stderr を積んだ例外がそのまま飛ぶ。 */
|
|
2544
2852
|
function runTmux(args) {
|
|
2545
|
-
|
|
2853
|
+
tmuxExec(args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
2546
2854
|
}
|
|
2547
2855
|
/**
|
|
2548
2856
|
* tmux へ本文を1メッセージとして送り、貼り付けの枠の外に添え書き(RELAY_DELIVERY_NOTE)を打ち、
|
|
@@ -2605,7 +2913,7 @@ function pasteToTmux(site, text) {
|
|
|
2605
2913
|
}
|
|
2606
2914
|
catch (err) {
|
|
2607
2915
|
try {
|
|
2608
|
-
|
|
2916
|
+
tmuxExec(['delete-buffer', '-b', bufferName], { stdio: 'ignore' });
|
|
2609
2917
|
}
|
|
2610
2918
|
catch {
|
|
2611
2919
|
// 貼れていれば -d で既に消えている。無いバッファを消そうとしたエラーは無視してよい。
|
|
@@ -2807,10 +3115,10 @@ export const RELAY_SUBMIT_MAX_RESEND = 3;
|
|
|
2807
3115
|
/** tmux のペインがいま居る場所(職人の作業場所)。取れなければ null。 */
|
|
2808
3116
|
export function tmuxPaneCwd(site) {
|
|
2809
3117
|
try {
|
|
2810
|
-
const out =
|
|
3118
|
+
const out = String(tmuxExec(['display-message', '-p', '-t', site, '#{pane_current_path}'], {
|
|
2811
3119
|
encoding: 'utf8',
|
|
2812
3120
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
2813
|
-
}).trim();
|
|
3121
|
+
})).trim();
|
|
2814
3122
|
return out || null;
|
|
2815
3123
|
}
|
|
2816
3124
|
catch {
|
|
@@ -4584,6 +4892,9 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
4584
4892
|
// 配ったときに受け皿に残っていて誤って取り込まれる、という筋道が構造的に消える。
|
|
4585
4893
|
// ★併せて「前のターンがまだ途中」の札を下ろす(終わったと分かったので次を配れる)。
|
|
4586
4894
|
if (kindOf(cfg) === 'codex') {
|
|
4895
|
+
// ★受け皿の名前をそろえる(登録簿の書き出し・読まれない受け皿の始末・便 684ac785)。
|
|
4896
|
+
// 配達の判断には関わらない=ここで何が起きても、この下の始末と配達は今までどおり進む。
|
|
4897
|
+
tidyCodexTrays(cfg, state);
|
|
4587
4898
|
const left = pickCodexNoticeFor(cfg.site, null);
|
|
4588
4899
|
if (left.skipped.length > 0) {
|
|
4589
4900
|
state.codexNoticeSeen = true;
|
package/dist/selfcheck.js
CHANGED
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
* 【★副作用なし】commit・push・PR・合流・配備・npm 公開をしない。作業場所は一時フォルダ
|
|
19
19
|
* (~/.local/state/conductor/selfcheck/run-<時刻>・git の中ではない)で、終わったら消す。
|
|
20
20
|
*
|
|
21
|
+
* 【★落ちたときは理由を残す(便 5a94ba06)】小さな headless 試験が落ちたら、その項目の detail に
|
|
22
|
+
* 「結果の行の本文・stderr の末尾・終了コード」を残す(控えにも管制にも同じ中身が載る)。
|
|
23
|
+
* ④囲い・⑤通信の ✗ が「小さな試験が先に落ちたので確かめられなかった」ときは、そう読める文にする。
|
|
24
|
+
* ★合否と、落ちたら戻す動きは1つも変えない(FAIL は FAIL・やり直しを足さない)。
|
|
25
|
+
*
|
|
21
26
|
* 【結果】手元の控え(~/.local/state/conductor/selfcheck/last.json と history/)に必ず残す。
|
|
22
27
|
* 管制の口が在れば送る(★口が無い・届かないときも落ちない=送れなかったことを控えに書くだけ)。
|
|
23
28
|
* 送る中身は buildSelfcheckPayload の1か所(口の形は ai-kana の便 2f5eb92c が決めた形・便 29babc5d で合わせた)。
|
|
@@ -81,6 +86,46 @@ export const SELFCHECK_MODEL = 'haiku';
|
|
|
81
86
|
/** 小さな試験の最後の答えを示す目印(★職人の言葉ではなく、tool の結果の中で探す)。 */
|
|
82
87
|
export const PROBE_OK_MARK = 'SELFCHECK_PROBE_OK';
|
|
83
88
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
89
|
+
// 落ちたときの手がかり(便 5a94ba06)
|
|
90
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
91
|
+
/**
|
|
92
|
+
* 控えと管制に残す手がかり1つあたりの長さの上限(★長い出力をそのまま流し込まない)。
|
|
93
|
+
*/
|
|
94
|
+
export const SELFCHECK_DETAIL_MAX = 500;
|
|
95
|
+
/**
|
|
96
|
+
* 鍵・トークンらしい並びを伏せる(★控えにも管制にも残す文なので、残す前に必ず通す)。
|
|
97
|
+
* ここで見るのは「見ただけで鍵と分かる形」だけ。分からない並びは伏せない代わりに、
|
|
98
|
+
* 手がかりは長さを切って残す(safeDetail)。
|
|
99
|
+
*/
|
|
100
|
+
const SECRET_PATTERNS = [
|
|
101
|
+
{ re: /Bearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, to: 'Bearer ***' },
|
|
102
|
+
{ re: /\beyJ[A-Za-z0-9._-]{12,}/g, to: 'eyJ***' },
|
|
103
|
+
{ re: /\b(sk|pk|rk)-[A-Za-z0-9._-]{8,}/gi, to: '$1-***' },
|
|
104
|
+
{ re: /\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{8,}/g, to: '$1_***' },
|
|
105
|
+
{ re: /\bgithub_pat_[A-Za-z0-9_]{8,}/g, to: 'github_pat_***' },
|
|
106
|
+
{ re: /\bnpm_[A-Za-z0-9]{8,}/g, to: 'npm_***' },
|
|
107
|
+
{ re: /\bxox[abeporsu]-[A-Za-z0-9-]{8,}/gi, to: 'xox-***' },
|
|
108
|
+
{ re: /\bAKIA[0-9A-Z]{12,}/g, to: 'AKIA***' },
|
|
109
|
+
];
|
|
110
|
+
/** 秘密らしい並びを伏せる。 */
|
|
111
|
+
export function redactSecrets(raw) {
|
|
112
|
+
let s = raw;
|
|
113
|
+
for (const p of SECRET_PATTERNS)
|
|
114
|
+
s = s.replace(p.re, p.to);
|
|
115
|
+
return s;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* 手がかりを残せる形にする(伏せる → 1行にする → 長さを切る)。
|
|
119
|
+
* from='tail' は末尾を残す(stderr は終わりに理由が出るため)。
|
|
120
|
+
* ★返り値は必ず max 字以下。
|
|
121
|
+
*/
|
|
122
|
+
export function safeDetail(raw, from = 'head', max = SELFCHECK_DETAIL_MAX) {
|
|
123
|
+
const one = redactSecrets(raw).replace(/\s+/g, ' ').trim();
|
|
124
|
+
if (one.length <= max)
|
|
125
|
+
return one;
|
|
126
|
+
return from === 'tail' ? `…${one.slice(one.length - (max - 1))}` : `${one.slice(0, max - 1)}…`;
|
|
127
|
+
}
|
|
128
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
84
129
|
// 小さな試験(⑥)の中身と読み方
|
|
85
130
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
86
131
|
/**
|
|
@@ -146,6 +191,7 @@ export function summarizeStream(stdout) {
|
|
|
146
191
|
out.result = {
|
|
147
192
|
subtype: typeof ev.subtype === 'string' ? ev.subtype : null,
|
|
148
193
|
isError: ev.is_error === true,
|
|
194
|
+
text: typeof ev.result === 'string' ? ev.result : typeof ev.error === 'string' ? ev.error : null,
|
|
149
195
|
costUsd: num(ev.total_cost_usd),
|
|
150
196
|
inputTokens: num(usage.input_tokens) === null
|
|
151
197
|
? null
|
|
@@ -533,6 +579,47 @@ function siteSettingsFor(distRoot) {
|
|
|
533
579
|
return null;
|
|
534
580
|
}
|
|
535
581
|
}
|
|
582
|
+
/** 小さな試験が AI まで届いたか(使用量が1つでも在れば届いた)。 */
|
|
583
|
+
function reachedModel(h) {
|
|
584
|
+
const u = h.summary?.result;
|
|
585
|
+
if (!u)
|
|
586
|
+
return false;
|
|
587
|
+
return (u.inputTokens ?? 0) > 0 || (u.outputTokens ?? 0) > 0 || (u.costUsd ?? 0) > 0;
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* 小さな試験が落ちたときに、④囲い・⑤通信の ✗ に添える文(便 5a94ba06 の2)。
|
|
591
|
+
* ★「確かめられなかった」のは、小さな試験が先に落ちたせい=そう読める文にする。
|
|
592
|
+
* ★判定(FAIL)は1つも変えない。変えるのは読む文だけ。
|
|
593
|
+
*/
|
|
594
|
+
export function unverifiedReason(h) {
|
|
595
|
+
if (!h.ran)
|
|
596
|
+
return `小さな試験を流していないため、確かめられませんでした(${safeDetail(h.error ?? '理由不明')})`;
|
|
597
|
+
if (h.probeRan)
|
|
598
|
+
return '小さな試験は動きましたが、台本の出力にこの行がなかったため、確かめられませんでした';
|
|
599
|
+
if (!reachedModel(h))
|
|
600
|
+
return '小さな試験が AI に届く前に終わったため、確かめられませんでした';
|
|
601
|
+
return '小さな試験の中で台本が動かなかったため、確かめられませんでした';
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* 小さな試験が落ちたときの手がかり(便 5a94ba06 の1)。
|
|
605
|
+
*
|
|
606
|
+
* 【なぜ要るか(実物 2026-10-04 18:23Z・TYHLD-OTA)】0.12.98 の自己診断が落ちて 0.12.92 へ自動で戻ったが、
|
|
607
|
+
* 落ちた3項目(sandbox・network・headless)はどれも「小さな試験1回」の結果から出ていて、
|
|
608
|
+
* その1回が【なぜ】落ちたかの文が、手元の控えにも管制にも1つも残っていなかった
|
|
609
|
+
* (終了コード 1・結果の行が success で is_error=true・使用量 0)。そのため版の不具合か
|
|
610
|
+
* その場かぎりの失敗かを決められなかった。ここで残すのは、次の3つだけ。
|
|
611
|
+
* ・結果の行の本文(result の文) ・stderr の末尾 ・終了コード
|
|
612
|
+
* ★残す文は safeDetail を通す(鍵・トークンらしい並びは伏せ、1つ 500字で切る)。
|
|
613
|
+
* ★合否は1つも変えない(これは detail に足す文だけ)。通ったときは1行も足さない。
|
|
614
|
+
*/
|
|
615
|
+
export function headlessFailureDetail(h) {
|
|
616
|
+
const out = [];
|
|
617
|
+
const text = h.summary?.result?.text ?? '';
|
|
618
|
+
out.push(text.trim() ? `手がかり: 結果の行の本文 ${safeDetail(text)}` : '手がかり: 結果の行に本文がありません');
|
|
619
|
+
out.push(h.stderr.trim() ? `手がかり: stderr の末尾 ${safeDetail(h.stderr, 'tail')}` : '手がかり: stderr は空です');
|
|
620
|
+
out.push(`手がかり: 終了コード ${h.exitCode === null ? '不明' : h.exitCode}`);
|
|
621
|
+
return out;
|
|
622
|
+
}
|
|
536
623
|
/** 小さな試験と MCP の確かめで claude に渡す環境(★中継と同じ形・src/headless.ts)。 */
|
|
537
624
|
function probeEnv(d, claudeBin) {
|
|
538
625
|
const env = { ...d.env };
|
|
@@ -543,7 +630,7 @@ function probeEnv(d, claudeBin) {
|
|
|
543
630
|
}
|
|
544
631
|
/** ⑥ 小さな headless 試験を1回流す。 */
|
|
545
632
|
function runHeadlessProbe(d, claudeBin, runDir) {
|
|
546
|
-
const res = { summary: null, exitCode: null, outsideWrite: 'unknown', net: {}, probeRan: false, error: null };
|
|
633
|
+
const res = { summary: null, exitCode: null, outsideWrite: 'unknown', net: {}, probeRan: false, error: null, ran: false, stderr: '' };
|
|
547
634
|
if (!claudeBin) {
|
|
548
635
|
res.error = 'claude が無いので流していません';
|
|
549
636
|
return res;
|
|
@@ -570,7 +657,9 @@ function runHeadlessProbe(d, claudeBin, runDir) {
|
|
|
570
657
|
// ★中継と同じ許可(src/headless.ts の既定)=番人がそのまま効く。迂回の旗は付けない。
|
|
571
658
|
'--permission-mode', 'acceptEdits',
|
|
572
659
|
], { cwd: work, env, timeoutMs: SELFCHECK_HEADLESS_TIMEOUT_MS });
|
|
660
|
+
res.ran = true;
|
|
573
661
|
res.exitCode = r.status;
|
|
662
|
+
res.stderr = r.stderr;
|
|
574
663
|
if (r.error)
|
|
575
664
|
res.error = r.error;
|
|
576
665
|
res.summary = summarizeStream(r.stdout);
|
|
@@ -746,7 +835,7 @@ export async function runSelfcheck(d, trigger) {
|
|
|
746
835
|
const h = runHeadlessProbe(d, claude.bin, runDir);
|
|
747
836
|
const sum = h.summary;
|
|
748
837
|
if (h.error)
|
|
749
|
-
note(headless, h.error);
|
|
838
|
+
note(headless, safeDetail(h.error));
|
|
750
839
|
if (!sum || !sum.result)
|
|
751
840
|
fail(headless, 'fault', `結果の行がありません(終了 ${h.exitCode})`);
|
|
752
841
|
else {
|
|
@@ -763,6 +852,11 @@ export async function runSelfcheck(d, trigger) {
|
|
|
763
852
|
else
|
|
764
853
|
fail(headless, 'fault', 'tool の結果に台本の目印がありません(Bash が動いていない)');
|
|
765
854
|
}
|
|
855
|
+
// ★落ちたときは「なぜ落ちたか」の手がかりを必ず残す(控えにも管制にも・便 5a94ba06)。
|
|
856
|
+
// 通ったときは1行も足さない(出力は今までと同じ)。
|
|
857
|
+
if (headless.ok === false)
|
|
858
|
+
for (const line of headlessFailureDetail(h))
|
|
859
|
+
note(headless, line);
|
|
766
860
|
// ③ MCP(★落ち着くまで確かめる・便が実際に使う登録を見る=settleMcp)
|
|
767
861
|
const mcp = await settleMcp(d, claude.bin, path.join(runDir, 'work'), sum?.mcp ?? null);
|
|
768
862
|
const seen = mcp.source === 'mcp-list' ? `claude mcp list で確かめた・${mcp.tries}回目` : mcp.source === 'init' ? '小さな試験の最初の行' : '一覧なし';
|
|
@@ -797,11 +891,11 @@ export async function runSelfcheck(d, trigger) {
|
|
|
797
891
|
else if (h.outsideWrite === 'open')
|
|
798
892
|
fail(sandbox, 'fault', '★囲いの中から作業場所の外へ書けました(囲いが効いていない)');
|
|
799
893
|
else
|
|
800
|
-
fail(sandbox, 'fault',
|
|
894
|
+
fail(sandbox, 'fault', `囲いの中の書き込み: ${unverifiedReason(h)}`);
|
|
801
895
|
// ⑤ 内側の通信
|
|
802
896
|
const codes = Object.entries(h.net);
|
|
803
897
|
if (codes.length === 0)
|
|
804
|
-
fail(network, 'fault',
|
|
898
|
+
fail(network, 'fault', `囲いの内側からの通信: ${unverifiedReason(h)}`);
|
|
805
899
|
for (const [u, c] of codes) {
|
|
806
900
|
if (/^[23]\d\d$/.test(c))
|
|
807
901
|
pass(network, `囲いの内側から ${u} → ${c}`);
|
package/package.json
CHANGED
|
@@ -37,6 +37,28 @@
|
|
|
37
37
|
これが無かったせいで、配り役は「最新の1行=いま追っている便の完了」としか読めず、
|
|
38
38
|
前の便のターンの知らせを次の便の完了として取り込んだ(便 1427b428 が19秒で done)。
|
|
39
39
|
|
|
40
|
+
【★受け皿の名前を、配り役が読む現場名に必ず合わせる(2026-10-05・便 684ac785)】
|
|
41
|
+
旧: 現場名=`os.path.basename(cwd)`。Codex を【現場の下のフォルダ】で起動すると、
|
|
42
|
+
配り役が読まない名前の受け皿ができ、そこへ入れた知らせは誰にも読まれなかった。
|
|
43
|
+
実物: 現場 codex-probe(配り役は codex-probe.jsonl を読む)で、Codex を
|
|
44
|
+
~/codex-probe/video-lab で起動 → video-lab.jsonl に 30 件・36KB が溜まった。
|
|
45
|
+
★同じずれは「現場名とフォルダ名が違う現場」でも起きる(sites.sh の対応表:
|
|
46
|
+
plus1 → ~/projects/plus1-foryou)。だから【フォルダ名から推測しない】のが根治。
|
|
47
|
+
新: 上から順に決める(resolve_site)。
|
|
48
|
+
① 配達文に書かれた現場名 … 配り役は便の本文へ必ず完了の合図 `DONE:<現場>` を書く
|
|
49
|
+
(relay.ts の buildSendText → shortCompletionLine / codexCompletionGuide)。
|
|
50
|
+
★これは配り役が「自分の現場名」として書いた字そのもの=受け皿の名前の正本。
|
|
51
|
+
案内は本文の【後ろ】に足されるので、本文が別の現場名を引用していても
|
|
52
|
+
【最後の DONE:】が配り役の現場名になる。
|
|
53
|
+
② 現場の登録簿 … 配り役が書く `<受け皿の置き場>/sites.json`(現場名 ↔ 作業フォルダ)。
|
|
54
|
+
cwd を含む作業フォルダの現場名を引く(いちばん長く一致する物)。
|
|
55
|
+
★人が手で打ったターン(配達文が無い)でも、ここで正しい現場に入る。
|
|
56
|
+
③ git の頂上フォルダ名 … 配り役の現場名の決め方(cli.ts の detectSite)と同じ。
|
|
57
|
+
★ホームより上へはたどらない(現場の作業フォルダは必ずホームの下に在るので、
|
|
58
|
+
ホームより上に置かれた `.git` を現場名として拾わない)。
|
|
59
|
+
④ それも分からなければ作業フォルダの名前(今までどおり)。
|
|
60
|
+
★どれでも決められない知らせは【捨てない】。理由つきで控え(UNRESOLVED_FILE)へ残す。
|
|
61
|
+
|
|
40
62
|
【やること】残す通知だけを、現場ごとの受け皿へ1行足す。
|
|
41
63
|
置き場: $CONDUCTOR_CODEX_NOTIFY_DIR(既定 $HOME/.tyhld/codex-notify)/<現場>.jsonl
|
|
42
64
|
★中央へは送らない。送るのは relay の仕事(完了の経路は1本に保つ)。
|
|
@@ -57,6 +79,25 @@ REPORT_MARK = 'CONDUCTOR_REPORT'
|
|
|
57
79
|
# 指示本文の末尾に付く管理用ジョブ印。
|
|
58
80
|
JOB_STAMP = re.compile(r'CONDUCTOR_JOB:\s*([0-9A-Za-z][0-9A-Za-z-]{7,})')
|
|
59
81
|
|
|
82
|
+
# 配達文に必ず入る完了の合図(`DONE:<現場>`)。★配り役が書いた現場名そのもの。
|
|
83
|
+
# 空白は挟まない形だけを見る(配り役が出す字は必ずこの形。地の文の「DONE: 〜」を拾わない)。
|
|
84
|
+
SITE_STAMP = re.compile(r'DONE:([A-Za-z0-9_-]+)')
|
|
85
|
+
|
|
86
|
+
# 現場名として使える字(scripts/sites.sh の is_valid_site_name と同じ)。
|
|
87
|
+
# ★受け皿の名前はこの字だけ=パスの区切りや `..` が名前に入らない(置き場の外へ出さない)。
|
|
88
|
+
SITE_NAME_OK = re.compile(r'^[A-Za-z0-9_-]+$')
|
|
89
|
+
|
|
90
|
+
# 配り役が書く現場の登録簿(現場名 ↔ 作業フォルダ)。★relay.ts の CODEX_SITES_REGISTRY と同じ名前。
|
|
91
|
+
SITES_REGISTRY = 'sites.json'
|
|
92
|
+
|
|
93
|
+
# 現場を1つに決められなかった知らせの控え。
|
|
94
|
+
# ★`.skipped.jsonl` で終わる名前にする=配り役は受け皿として読まない・始末の対象にもしない。
|
|
95
|
+
UNRESOLVED_FILE = '_unresolved.skipped.jsonl'
|
|
96
|
+
|
|
97
|
+
# 控えに残す行数の上限(古い物から落とす=受け皿の隣で際限なく育たせない)。
|
|
98
|
+
# ★relay.ts の CODEX_SKIPPED_NOTICE_MAX_LINES と同じ値にそろえる。
|
|
99
|
+
UNRESOLVED_MAX_LINES = 200
|
|
100
|
+
|
|
60
101
|
|
|
61
102
|
def notify_dir(env=None):
|
|
62
103
|
env = os.environ if env is None else env
|
|
@@ -68,10 +109,115 @@ def notify_dir(env=None):
|
|
|
68
109
|
|
|
69
110
|
|
|
70
111
|
def site_of(cwd):
|
|
71
|
-
"""
|
|
112
|
+
"""作業フォルダの名前(★これ単体では現場名にならない。resolve_site の最後の手段)。"""
|
|
72
113
|
return os.path.basename(str(cwd or '').rstrip('/')) or ''
|
|
73
114
|
|
|
74
115
|
|
|
116
|
+
def site_from_instruction(payload):
|
|
117
|
+
"""配達文に書かれた現場名(`DONE:<現場>` の【最後】の出現)。無ければ ''。
|
|
118
|
+
|
|
119
|
+
配り役は便の本文のうしろへ完了の合図を必ず足すので、本文が別の現場名を引用していても
|
|
120
|
+
最後の出現が配り役の現場名になる(relay.ts の buildSendText の組み立て順)。
|
|
121
|
+
"""
|
|
122
|
+
msgs = payload.get('input-messages')
|
|
123
|
+
if not isinstance(msgs, list):
|
|
124
|
+
return ''
|
|
125
|
+
found = ''
|
|
126
|
+
for m in msgs:
|
|
127
|
+
if not isinstance(m, str):
|
|
128
|
+
continue
|
|
129
|
+
for hit in SITE_STAMP.finditer(m):
|
|
130
|
+
found = hit.group(1)
|
|
131
|
+
return found
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def site_from_registry(cwd, env=None):
|
|
135
|
+
"""配り役の登録簿から、cwd を含む作業フォルダの現場名を引く。引けなければ ''。
|
|
136
|
+
|
|
137
|
+
★いちばん長く一致する作業フォルダの現場を選ぶ(入れ子の現場でも取り違えない)。
|
|
138
|
+
"""
|
|
139
|
+
target = str(cwd or '').strip()
|
|
140
|
+
if not target:
|
|
141
|
+
return ''
|
|
142
|
+
target = os.path.normpath(target)
|
|
143
|
+
base = notify_dir(env)
|
|
144
|
+
if not base:
|
|
145
|
+
return ''
|
|
146
|
+
try:
|
|
147
|
+
with open(os.path.join(base, SITES_REGISTRY), encoding='utf-8') as f:
|
|
148
|
+
data = json.load(f)
|
|
149
|
+
except Exception: # noqa: BLE001
|
|
150
|
+
return ''
|
|
151
|
+
entries = data.get('sites') if isinstance(data, dict) else None
|
|
152
|
+
if not isinstance(entries, list):
|
|
153
|
+
return ''
|
|
154
|
+
best_site, best_len = '', -1
|
|
155
|
+
for e in entries:
|
|
156
|
+
if not isinstance(e, dict):
|
|
157
|
+
continue
|
|
158
|
+
name = str(e.get('site') or '').strip()
|
|
159
|
+
work = str(e.get('workDir') or '').strip()
|
|
160
|
+
if not SITE_NAME_OK.match(name) or not work:
|
|
161
|
+
continue
|
|
162
|
+
work = os.path.normpath(work)
|
|
163
|
+
if target == work or target.startswith(work.rstrip(os.sep) + os.sep):
|
|
164
|
+
if len(work) > best_len:
|
|
165
|
+
best_site, best_len = name, len(work)
|
|
166
|
+
return best_site
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def git_top_name(cwd, env=None):
|
|
170
|
+
"""git の頂上フォルダの名前(配り役の detectSite と同じ決め方)。無ければ ''。
|
|
171
|
+
|
|
172
|
+
★git は起動しない(受け口は Codex の終わりに毎回走るので、速く・絶対に転ばないこと)。
|
|
173
|
+
`.git` が在るいちばん近い先祖をたどるだけ。worktree(`.git` がファイル)も同じに見つかる。
|
|
174
|
+
★cwd がホームの下に在るときは、ホームより上へは出ない。現場の作業フォルダは必ずホームの下に在る
|
|
175
|
+
(sites.sh: `$HOME/projects/<現場>` か `$HOME/<現場>`)ので、ホームより上に置かれた `.git`
|
|
176
|
+
(例 `/tmp/.git`)を現場名として拾ってしまわないため。
|
|
177
|
+
"""
|
|
178
|
+
env = os.environ if env is None else env
|
|
179
|
+
cur = str(cwd or '').strip()
|
|
180
|
+
if not cur or not os.path.isabs(cur):
|
|
181
|
+
return ''
|
|
182
|
+
cur = os.path.normpath(cur)
|
|
183
|
+
home = (env.get('HOME') or '').strip()
|
|
184
|
+
home = os.path.normpath(home) if home else ''
|
|
185
|
+
stop_at_home = bool(home) and (cur == home or cur.startswith(home.rstrip(os.sep) + os.sep))
|
|
186
|
+
while True:
|
|
187
|
+
if os.path.exists(os.path.join(cur, '.git')):
|
|
188
|
+
return os.path.basename(cur)
|
|
189
|
+
if stop_at_home and cur == home:
|
|
190
|
+
return ''
|
|
191
|
+
parent = os.path.dirname(cur)
|
|
192
|
+
if parent == cur:
|
|
193
|
+
return ''
|
|
194
|
+
cur = parent
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def resolve_site(payload, env=None):
|
|
198
|
+
"""受け皿の名前(=配り役が読む現場名)を決める。返り: (現場名, 決め方/決められない理由)。
|
|
199
|
+
|
|
200
|
+
決められなければ ('', 理由)。★呼ぶ側はその知らせを捨てず、控えへ残す。
|
|
201
|
+
"""
|
|
202
|
+
stamped = site_from_instruction(payload)
|
|
203
|
+
if stamped and SITE_NAME_OK.match(stamped):
|
|
204
|
+
return stamped, 'job-stamp'
|
|
205
|
+
cwd = str(payload.get('cwd') or '')
|
|
206
|
+
from_reg = site_from_registry(cwd, env)
|
|
207
|
+
if from_reg:
|
|
208
|
+
return from_reg, 'site-registry'
|
|
209
|
+
top = git_top_name(cwd, env)
|
|
210
|
+
if top and SITE_NAME_OK.match(top):
|
|
211
|
+
return top, 'git-toplevel'
|
|
212
|
+
base = site_of(cwd)
|
|
213
|
+
if base and SITE_NAME_OK.match(base):
|
|
214
|
+
return base, 'cwd-basename'
|
|
215
|
+
return '', (
|
|
216
|
+
'現場を1つに決められない(配達文の DONE: も登録簿も git の頂上も無く、'
|
|
217
|
+
f'作業フォルダの名前も現場名に使えない: cwd={cwd!r})'
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
75
221
|
def is_internal_turn(payload):
|
|
76
222
|
"""Codex 内部のターン(題名を付ける・会話をまとめ直す)なら True(=人の指示の完了ではない)。"""
|
|
77
223
|
msgs = payload.get('input-messages')
|
|
@@ -107,19 +253,76 @@ def job_id_of(payload):
|
|
|
107
253
|
return found
|
|
108
254
|
|
|
109
255
|
|
|
110
|
-
def decide(payload):
|
|
111
|
-
"""受け取った通知をどう扱うか。返り: (残すか, 現場名, 報告本文, 理由)
|
|
256
|
+
def decide(payload, env=None):
|
|
257
|
+
"""受け取った通知をどう扱うか。返り: (残すか, 現場名, 報告本文, 理由)
|
|
258
|
+
|
|
259
|
+
★現場名が決まらなかったときは (False, '', 報告本文, 理由) を返す。呼ぶ側は【捨てずに】
|
|
260
|
+
控えへ残す(理由が 'unresolved-site:' で始まる物がそれ)。
|
|
261
|
+
"""
|
|
112
262
|
if not isinstance(payload, dict):
|
|
113
263
|
return False, '', '', 'not-json-object'
|
|
114
264
|
if payload.get('type') != 'agent-turn-complete':
|
|
115
265
|
return False, '', '', 'other-type'
|
|
116
266
|
if is_internal_turn(payload):
|
|
117
267
|
return False, '', '', 'internal-turn'
|
|
118
|
-
site = site_of(payload.get('cwd'))
|
|
119
|
-
if not site:
|
|
120
|
-
return False, '', '', 'no-site'
|
|
121
268
|
report = payload.get('last-assistant-message')
|
|
122
|
-
|
|
269
|
+
report = report if isinstance(report, str) else ''
|
|
270
|
+
site, how = resolve_site(payload, env)
|
|
271
|
+
if not site:
|
|
272
|
+
return False, '', report, f'unresolved-site:{how}'
|
|
273
|
+
return True, site, report, how
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def notice_line(payload, site, report):
|
|
277
|
+
"""受け皿へ書く1行。"""
|
|
278
|
+
return json.dumps(
|
|
279
|
+
{
|
|
280
|
+
'site': site,
|
|
281
|
+
'report': report,
|
|
282
|
+
'threadId': payload.get('thread-id'),
|
|
283
|
+
'turnId': payload.get('turn-id'),
|
|
284
|
+
# ★どの便のターンの知らせかを配り役が確かめられるようにする(便 99fc49c0)。
|
|
285
|
+
'jobId': job_id_of(payload),
|
|
286
|
+
'at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
287
|
+
},
|
|
288
|
+
ensure_ascii=False,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def keep_unresolved(payload, report, why, env=None):
|
|
293
|
+
"""現場を決められなかった知らせを、理由つきで控えへ残す(★捨てない)。
|
|
294
|
+
|
|
295
|
+
返り: 書けた控えのパス/''。古い行から落として UNRESOLVED_MAX_LINES 行までに収める。
|
|
296
|
+
"""
|
|
297
|
+
target_dir = notify_dir(env)
|
|
298
|
+
if not target_dir:
|
|
299
|
+
return ''
|
|
300
|
+
file = os.path.join(target_dir, UNRESOLVED_FILE)
|
|
301
|
+
line = json.dumps(
|
|
302
|
+
{
|
|
303
|
+
'skippedAt': datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
304
|
+
'why': why,
|
|
305
|
+
'site': None,
|
|
306
|
+
'cwd': payload.get('cwd'),
|
|
307
|
+
'jobId': job_id_of(payload),
|
|
308
|
+
'threadId': payload.get('thread-id'),
|
|
309
|
+
'turnId': payload.get('turn-id'),
|
|
310
|
+
'report': report,
|
|
311
|
+
},
|
|
312
|
+
ensure_ascii=False,
|
|
313
|
+
)
|
|
314
|
+
try:
|
|
315
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
316
|
+
kept = []
|
|
317
|
+
if os.path.exists(file):
|
|
318
|
+
with open(file, encoding='utf-8') as f:
|
|
319
|
+
kept = [l for l in f.read().split('\n') if l.strip()]
|
|
320
|
+
lines = (kept + [line])[-UNRESOLVED_MAX_LINES:]
|
|
321
|
+
with open(file, 'w', encoding='utf-8') as f:
|
|
322
|
+
f.write('\n'.join(lines) + '\n')
|
|
323
|
+
return file
|
|
324
|
+
except Exception: # noqa: BLE001
|
|
325
|
+
return ''
|
|
123
326
|
|
|
124
327
|
|
|
125
328
|
def main(argv):
|
|
@@ -130,28 +333,19 @@ def main(argv):
|
|
|
130
333
|
payload = json.loads(raw)
|
|
131
334
|
except Exception: # noqa: BLE001
|
|
132
335
|
return 0 # 読めない通知は黙って捨てる(Codex を止めない)
|
|
133
|
-
keep, site, report,
|
|
336
|
+
keep, site, report, reason = decide(payload)
|
|
134
337
|
if not keep:
|
|
338
|
+
# ★現場を決められなかった知らせだけは、捨てずに控えへ残す(便 684ac785)。
|
|
339
|
+
if reason.startswith('unresolved-site:'):
|
|
340
|
+
keep_unresolved(payload, report, reason[len('unresolved-site:'):])
|
|
135
341
|
return 0
|
|
136
342
|
target_dir = notify_dir()
|
|
137
343
|
if not target_dir:
|
|
138
344
|
return 0
|
|
139
345
|
try:
|
|
140
346
|
os.makedirs(target_dir, exist_ok=True)
|
|
141
|
-
line = json.dumps(
|
|
142
|
-
{
|
|
143
|
-
'site': site,
|
|
144
|
-
'report': report,
|
|
145
|
-
'threadId': payload.get('thread-id'),
|
|
146
|
-
'turnId': payload.get('turn-id'),
|
|
147
|
-
# ★どの便のターンの知らせかを配り役が確かめられるようにする(便 99fc49c0)。
|
|
148
|
-
'jobId': job_id_of(payload),
|
|
149
|
-
'at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
150
|
-
},
|
|
151
|
-
ensure_ascii=False,
|
|
152
|
-
)
|
|
153
347
|
with open(os.path.join(target_dir, f'{site}.jsonl'), 'a', encoding='utf-8') as f:
|
|
154
|
-
f.write(
|
|
348
|
+
f.write(notice_line(payload, site, report) + '\n')
|
|
155
349
|
except Exception: # noqa: BLE001
|
|
156
350
|
return 0
|
|
157
351
|
return 0
|