@tyhld/conductor 0.12.77 → 0.12.79
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/cli.js +17 -5
- package/dist/headless.js +207 -34
- package/dist/relay.js +127 -16
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
15
15
|
import os from 'node:os';
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { RELAY_FETCH_TIMEOUT_MS, createRelayState, deliveryReport, parseAgentKind, reconnectDelayMs, relayTick, tmuxHasSession, pendingReportIds, sendPendingReportFiles, sweepProcessedReports, } from './relay.js';
|
|
18
|
-
import { createHeadlessState, headlessNextDelayMs, headlessTick, parseRunnerMode, } from './headless.js';
|
|
18
|
+
import { createHeadlessState, headlessNextDelayMs, headlessTick, headlessTrackedCommandIds, parseRunnerMode, } from './headless.js';
|
|
19
19
|
import { runViewCommand } from './view.js';
|
|
20
20
|
import { createRelayRunner } from './relay-runner.js';
|
|
21
21
|
import { installProcessGuards } from './process-guard.js';
|
|
@@ -30,6 +30,13 @@ import { BUILD_NUDGE_NAME, REQUIRED_ENV, createBuildRunner, createMemoStore, loa
|
|
|
30
30
|
* ★relay が無効な機では null のまま=報告の中身は今までと1バイトも変わらない。
|
|
31
31
|
*/
|
|
32
32
|
let relayStateForReport = null;
|
|
33
|
+
/**
|
|
34
|
+
* 画面なし方式の状態の参照(★回収の道から「いま追っている便」を除くため・便 6ebba0d0)。
|
|
35
|
+
* 画面なし方式では実行中の便は headless の state.job に在り、relay.pending には入らない。
|
|
36
|
+
* relay.pending だけを見ていたので除外が常に空になり、職人が報告を置いた直後に
|
|
37
|
+
* 報告が先に渡ってしまっていた。★画面あり方式では null のまま=今までと同じ。
|
|
38
|
+
*/
|
|
39
|
+
let headlessStateForReport = null;
|
|
33
40
|
/** git コマンドを安全に実行し、失敗したら null を返す(git でない/未インストール等)。 */
|
|
34
41
|
function git(args) {
|
|
35
42
|
try {
|
|
@@ -225,8 +232,14 @@ async function sendReport(cfg) {
|
|
|
225
232
|
// ★未処理の報告ファイルの本文を中央へ届ける(便 79840008)。受け取られた物だけ処理済みにする(消さない)。
|
|
226
233
|
// ★配達役がいま追っている便の報告は送らない(完了の道が完了と一緒に渡すため)。
|
|
227
234
|
if (pending.ids.length > 0) {
|
|
235
|
+
// 画面あり方式の除外(pending.id)は今までどおり。
|
|
228
236
|
const tracking = relayStateForReport?.pending?.id;
|
|
229
|
-
|
|
237
|
+
const exclude = tracking ? [tracking] : [];
|
|
238
|
+
// ★画面なし方式は、実行中の便(state.job)と再開待ちの便(pendingRetry)も除く。
|
|
239
|
+
if (headlessStateForReport !== null) {
|
|
240
|
+
exclude.push(...headlessTrackedCommandIds(headlessStateForReport));
|
|
241
|
+
}
|
|
242
|
+
await sendPendingReportFiles(cfg, { exclude, rejectCounts: reportRejectCounts });
|
|
230
243
|
}
|
|
231
244
|
}
|
|
232
245
|
/** `conductor start`: 起動直後に1回 + 以後 intervalSec ごとに報告し続ける。 */
|
|
@@ -276,8 +289,6 @@ async function start(args) {
|
|
|
276
289
|
// ポーリングの間隔はこれまでどおり(15秒/承認待ち3秒)。耳が死んでいる間の保険として残す。
|
|
277
290
|
let relayRunner = null;
|
|
278
291
|
let nudgeWatcher = null;
|
|
279
|
-
// ★headless 方式の状態参照(sendReport のアダプタ用)。tmux のときは null のまま。
|
|
280
|
-
let headlessStateRef = null;
|
|
281
292
|
if (cfg.relayEnabled) {
|
|
282
293
|
if (cfg.runnerMode === 'headless') {
|
|
283
294
|
// ── headless 方式(画面なし・便ごとに claude -p を起動する) ──
|
|
@@ -285,9 +296,10 @@ async function start(args) {
|
|
|
285
296
|
console.log(`[headless] 有効: site=${cfg.site} mode=headless workDir=${workDir} ` +
|
|
286
297
|
`interval=${cfg.relayIntervalSec}s`);
|
|
287
298
|
const hState = createHeadlessState();
|
|
288
|
-
headlessStateRef = hState;
|
|
289
299
|
// 生存報告に「いま配れるか」を載せるため、内包する RelayState を参照する。
|
|
290
300
|
relayStateForReport = hState.relay;
|
|
301
|
+
// ★回収の道から「いま追っている便」を除くための参照(便 6ebba0d0)。
|
|
302
|
+
headlessStateForReport = hState;
|
|
291
303
|
const hCfg = { ...cfg, workDir };
|
|
292
304
|
relayRunner = createRelayRunner({
|
|
293
305
|
tick: () => headlessTick(hCfg, hState, {
|
package/dist/headless.js
CHANGED
|
@@ -19,7 +19,7 @@ import { createInterface } from 'node:readline';
|
|
|
19
19
|
import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, 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, buildAnchor, buildSendText, canDeliver, completeWithReport, createRelayState, deliveryReport, fetchCommand, fetchCommandStatus, fetchControl, fetchOrphans, isTerminalCommandStatus, kindOf, markDelivered, noteCentralDown, noteCentralUp, noteReadiness, patchStatus, reconnectDelayMs, 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, buildAnchor, buildSendText, canDeliver, completeWithReport, createRelayState, deliveryReport, fetchCommand, fetchCommandStatus, fetchControl, fetchOrphans, isTerminalCommandStatus, kindOf, markDelivered, noteCentralDown, noteCentralUp, noteReadiness, patchStatus, recoverStuckProcessedReports, reconnectDelayMs, selectOrphanToRestore, sendHeartbeat, } from './relay.js';
|
|
23
23
|
const log = (msg) => {
|
|
24
24
|
console.log(`[headless] ${msg}`);
|
|
25
25
|
};
|
|
@@ -34,6 +34,20 @@ export const HEADLESS_MAX_ATTEMPTS = 3;
|
|
|
34
34
|
export const HEADLESS_EVENT_FLUSH_MS = 2_000;
|
|
35
35
|
/** 実行中に stop_requested を確かめる間隔(ms)。 */
|
|
36
36
|
export const HEADLESS_STOP_CHECK_MS = 15_000;
|
|
37
|
+
/**
|
|
38
|
+
* 便の終了状態(done)を管制へ書き直す上限回数(★便 6ebba0d0・定数はここ1か所)。
|
|
39
|
+
*
|
|
40
|
+
* 【根拠】書けなかった便は畳まない=その現場は次の便を配らない。だから上限は
|
|
41
|
+
* 「管制の短い不通なら必ず越えない/書けない便で現場を永久に塞がない」の両立で決める。
|
|
42
|
+
* 便を抱えている間の周回は min(relayIntervalSec, HEADLESS_EVENT_FLUSH_MS=2秒) =約2秒ごと。
|
|
43
|
+
* 300回 ≒ 約10分。ふつうの通信の途切れ(数秒〜数分)は必ず越えない一方、
|
|
44
|
+
* どうしても書けない便(管制が消えた等)でも現場が塞がるのは約10分で終わる。
|
|
45
|
+
* 実障害(7969ea66)は人が気づくまで 9:08 → 10:49 の約1時間40分かかっていたので、
|
|
46
|
+
* 10分は十分短い。越えたときは黙って done にせず、理由つきで failed を書いて残す。
|
|
47
|
+
*/
|
|
48
|
+
export const HEADLESS_DONE_WRITE_MAX_TRIES = 300;
|
|
49
|
+
/** 取り残しの後始末を回す間隔(ms)。★起動時は1回必ず回す(間隔の対象外)。 */
|
|
50
|
+
export const HEADLESS_RECOVER_SWEEP_MS = 10 * 60 * 1000;
|
|
37
51
|
/** イベントの text フィールドの上限(文字)。 */
|
|
38
52
|
export const EVENT_TEXT_MAX = 500;
|
|
39
53
|
/** 記録1行から取り出すイベントの上限(件)。1行に content が大量に入っていても溢れさせない。 */
|
|
@@ -259,6 +273,53 @@ export function validateActiveJobOwnership(rec, cfg) {
|
|
|
259
273
|
// PATH に居ないため spawn('claude') が ENOENT で失敗する(実測 2026-10-01)。
|
|
260
274
|
// conductor-run が node を探すのと同じ考え方で、既知の場所を順に探す。
|
|
261
275
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
276
|
+
// ── Claude Code の版管理 ─────────────────────────────────────────────────
|
|
277
|
+
//
|
|
278
|
+
// 1台に複数の Claude Code が入っていると、古い方を使って通信の許可 "*" が
|
|
279
|
+
// 効かない等の不具合が起きる(実測 2026-10-02 メインPC)。
|
|
280
|
+
// 候補のうち実在する物の版を確かめ、いちばん新しい版を使う。
|
|
281
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
282
|
+
/**
|
|
283
|
+
* 必要な最低の版。これより古い Claude Code では職人を起動しない。
|
|
284
|
+
*
|
|
285
|
+
* 根拠: Claude Code 2.1.x 系で画面なし方式(-p, --output-format stream-json)と
|
|
286
|
+
* 通信の許可 "*"(allowedTools: "*")が動くことが確かめられたのは太田さんの PC の
|
|
287
|
+
* 2.1.286(便 bcce7be9)。2.1.86 では "*" が効かない疑い(便 a9754912、メインPC)。
|
|
288
|
+
* 安全側に倒して 2.1.200 を最低とする(200 未満は "*" 対応が不明確な版)。
|
|
289
|
+
* ★確かめられた版は 2.1.286 だけなので、200 は推定の境界。
|
|
290
|
+
*/
|
|
291
|
+
export const CLAUDE_MIN_VERSION = '2.1.200';
|
|
292
|
+
/** claude --version の出力から版番号を取り出す。取れなければ null。 */
|
|
293
|
+
export function parseClaudeVersion(output) {
|
|
294
|
+
// "2.1.286 (Claude Code)" → "2.1.286"
|
|
295
|
+
const m = output.match(/(\d+\.\d+\.\d+)/);
|
|
296
|
+
return m ? m[1] : null;
|
|
297
|
+
}
|
|
298
|
+
/** 版番号を比べる。a > b なら正、a < b なら負、同じなら 0。 */
|
|
299
|
+
export function compareVersions(a, b) {
|
|
300
|
+
const pa = a.split('.').map(Number);
|
|
301
|
+
const pb = b.split('.').map(Number);
|
|
302
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
303
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
304
|
+
if (diff !== 0)
|
|
305
|
+
return diff;
|
|
306
|
+
}
|
|
307
|
+
return 0;
|
|
308
|
+
}
|
|
309
|
+
/** claude の実行ファイルから版番号を取得する。取れなければ null。 */
|
|
310
|
+
export function getClaudeVersion(binPath) {
|
|
311
|
+
try {
|
|
312
|
+
const out = execFileSync(binPath, ['--version'], {
|
|
313
|
+
timeout: 5_000,
|
|
314
|
+
encoding: 'utf8',
|
|
315
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
316
|
+
});
|
|
317
|
+
return parseClaudeVersion(out);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
262
323
|
/** claude の実行ファイルの既知の候補を返す(新しい node の版から順)。 */
|
|
263
324
|
function claudeBinCandidates() {
|
|
264
325
|
const h = os.homedir();
|
|
@@ -293,27 +354,42 @@ function claudeBinCandidates() {
|
|
|
293
354
|
return candidates;
|
|
294
355
|
}
|
|
295
356
|
/**
|
|
296
|
-
* claude
|
|
357
|
+
* claude の実行ファイルのフルパスと版を返す。見つからなければ null。
|
|
297
358
|
*
|
|
298
359
|
* 探す順:
|
|
299
|
-
* 1. 環境変数 CONDUCTOR_CLAUDE_BIN
|
|
300
|
-
* 2.
|
|
301
|
-
* 3.
|
|
302
|
-
* 4. ~/.claude/local/claude
|
|
303
|
-
* 5. /usr/local/bin/claude, /usr/bin/claude
|
|
360
|
+
* 1. 環境変数 CONDUCTOR_CLAUDE_BIN(テスト用の差し替え口)→ 指定があればそれだけ
|
|
361
|
+
* 2. 候補のうち実在する物の版を確かめ、いちばん新しい版を使う
|
|
362
|
+
* 3. 版を確かめられない物は後回し(版が分かる物が1つもないときだけ使う)
|
|
304
363
|
*/
|
|
305
364
|
export function resolveClaudeBin() {
|
|
306
365
|
const override = process.env.CONDUCTOR_CLAUDE_BIN;
|
|
307
366
|
if (override) {
|
|
308
|
-
if (existsSync(override))
|
|
309
|
-
return
|
|
367
|
+
if (!existsSync(override))
|
|
368
|
+
return null;
|
|
369
|
+
return { bin: override, version: getClaudeVersion(override) };
|
|
370
|
+
}
|
|
371
|
+
const existing = claudeBinCandidates().filter(c => existsSync(c));
|
|
372
|
+
if (existing.length === 0)
|
|
310
373
|
return null;
|
|
374
|
+
// 版が分かる候補と分からない候補を分ける
|
|
375
|
+
const withVersion = [];
|
|
376
|
+
const withoutVersion = [];
|
|
377
|
+
for (const bin of existing) {
|
|
378
|
+
const ver = getClaudeVersion(bin);
|
|
379
|
+
if (ver) {
|
|
380
|
+
withVersion.push({ bin, version: ver });
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
withoutVersion.push(bin);
|
|
384
|
+
}
|
|
311
385
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
386
|
+
// 版が分かる候補の中でいちばん新しい物を選ぶ
|
|
387
|
+
if (withVersion.length > 0) {
|
|
388
|
+
withVersion.sort((a, b) => compareVersions(b.version, a.version));
|
|
389
|
+
return withVersion[0];
|
|
315
390
|
}
|
|
316
|
-
|
|
391
|
+
// 版を確かめられない物だけ → 最初の候補を返す(後方互換)
|
|
392
|
+
return { bin: withoutVersion[0], version: null };
|
|
317
393
|
}
|
|
318
394
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
319
395
|
// 秘密のマスク
|
|
@@ -343,6 +419,24 @@ export function maskSecrets(text) {
|
|
|
343
419
|
}
|
|
344
420
|
return masked;
|
|
345
421
|
}
|
|
422
|
+
/**
|
|
423
|
+
* いま画面なし方式が追っている便の指示ID(★回収の道から除く物・便 6ebba0d0 の b)。
|
|
424
|
+
*
|
|
425
|
+
* 【なぜ要るか】30秒ごとの生存報告は、未処理の報告ファイルを「回収の道」
|
|
426
|
+
* (POST /api/conductor/report/files・★この口は便を完了にしない)へ送る。
|
|
427
|
+
* 画面あり方式はそこで `relayState.pending.id` を除いていたが、画面なし方式では
|
|
428
|
+
* 実行中の便は `state.job` に在り `relay.pending` は一度も入らない=除外が常に空だった。
|
|
429
|
+
* そのため職人が報告を置いた直後(まだ動いている間)に報告が先に渡ってしまう。
|
|
430
|
+
* ★これは二重の守り。これが効かなくても completeWithReport だけで②③は通る。
|
|
431
|
+
*/
|
|
432
|
+
export function headlessTrackedCommandIds(state) {
|
|
433
|
+
const ids = [];
|
|
434
|
+
if (state.job !== null)
|
|
435
|
+
ids.push(state.job.commandId);
|
|
436
|
+
if (state.pendingRetry !== null)
|
|
437
|
+
ids.push(state.pendingRetry.cmd.id);
|
|
438
|
+
return ids;
|
|
439
|
+
}
|
|
346
440
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
347
441
|
// ヘルパー
|
|
348
442
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -692,15 +786,26 @@ function closeLog(ws) {
|
|
|
692
786
|
* 【stdout】stream-json 形式。行ごとに JSON を出す。readline で1行ずつ読む。
|
|
693
787
|
* 【stderr】蓄積して認証エラーの検出に使う。
|
|
694
788
|
*
|
|
695
|
-
* 【起動失敗】claude が見つからない(ENOENT)・実行権限がない(EACCES
|
|
696
|
-
*
|
|
789
|
+
* 【起動失敗】claude が見つからない(ENOENT)・実行権限がない(EACCES)・
|
|
790
|
+
* 版が古すぎるなどで起動しない場合、null を返す。呼び出し側はエラーイベントを管制へ送る。
|
|
791
|
+
* 失敗の理由は lastSpawnError に入る。
|
|
697
792
|
*/
|
|
793
|
+
export let lastSpawnError = '';
|
|
698
794
|
export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
699
|
-
const
|
|
700
|
-
if (
|
|
701
|
-
|
|
795
|
+
const resolved = resolveClaudeBin();
|
|
796
|
+
if (resolved === null) {
|
|
797
|
+
lastSpawnError = 'claude が見つかりません。探した場所: nvm の各版の bin, ~/.local/bin, ~/.claude/local, /usr/local/bin, /usr/bin';
|
|
798
|
+
log(`ERROR ${lastSpawnError}`);
|
|
702
799
|
return null;
|
|
703
800
|
}
|
|
801
|
+
const { bin: claudeBin, version: claudeVersion } = resolved;
|
|
802
|
+
// ── 最低版チェック ──
|
|
803
|
+
if (claudeVersion && compareVersions(claudeVersion, CLAUDE_MIN_VERSION) < 0) {
|
|
804
|
+
lastSpawnError = `Claude Code が古すぎます(${claudeBin} = ${claudeVersion}、最低 ${CLAUDE_MIN_VERSION})。npm install -g @anthropic-ai/claude-code で更新してください`;
|
|
805
|
+
log(`ERROR ${lastSpawnError}`);
|
|
806
|
+
return null;
|
|
807
|
+
}
|
|
808
|
+
log(`claude bin=${claudeBin} version=${claudeVersion ?? 'unknown'}`);
|
|
704
809
|
const env = { ...process.env };
|
|
705
810
|
delete env.CLAUDECODE;
|
|
706
811
|
delete env.ANTHROPIC_API_KEY;
|
|
@@ -762,6 +867,8 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
762
867
|
sessionId,
|
|
763
868
|
startedAt: Date.now(),
|
|
764
869
|
attempt,
|
|
870
|
+
claudeBin,
|
|
871
|
+
claudeVersion,
|
|
765
872
|
completed: false,
|
|
766
873
|
exitCode: null,
|
|
767
874
|
exitSignal: null,
|
|
@@ -934,7 +1041,9 @@ async function checkRunningJob(cfg, state) {
|
|
|
934
1041
|
if (job.completed) {
|
|
935
1042
|
await flushEvents(cfg, job);
|
|
936
1043
|
// ★消す前に本文を取る(`kc 現場名` が「直前の仕事」を出すために残す)。
|
|
937
|
-
|
|
1044
|
+
// ★終了状態を書けずに持ち越した便は、次の周でも本文を失わないように覚えておく(便 6ebba0d0)。
|
|
1045
|
+
const savedBody = loadActiveJob(cfg.site)?.body ?? job.savedBody ?? '';
|
|
1046
|
+
job.savedBody = savedBody;
|
|
938
1047
|
clearActiveJob(cfg.site);
|
|
939
1048
|
const noteLastJob = (outcome, detail) => {
|
|
940
1049
|
saveLastJob(cfg.site, {
|
|
@@ -1001,19 +1110,43 @@ async function checkRunningJob(cfg, state) {
|
|
|
1001
1110
|
return;
|
|
1002
1111
|
}
|
|
1003
1112
|
if (job.exitCode === 0) {
|
|
1004
|
-
// 正常終了 →
|
|
1005
|
-
|
|
1006
|
-
|
|
1113
|
+
// 正常終了 → 便の終了状態を管制へ確定させる。
|
|
1114
|
+
// ★書けるまでやり直す(便 6ebba0d0)。以前は false のとき patchStatus を1回試し、
|
|
1115
|
+
// 成否を見ずに state.job を畳んでいた=誰も done を書いていないのに便を手放していた。
|
|
1116
|
+
// 畳まない間は次の便を配らない/管制には生存報告と理由が残る。
|
|
1117
|
+
if (await completeWithReport(cfg, job.commandId, log)) {
|
|
1118
|
+
state.doneWriteTries?.delete(job.commandId);
|
|
1007
1119
|
log(`site=${cfg.site} id=${job.commandId} 完了(exit=0, attempt ${job.attempt})`);
|
|
1120
|
+
noteLastJob('done', '報告まで出しました');
|
|
1121
|
+
state.job = null;
|
|
1122
|
+
rs.lastHeartbeatAt = null;
|
|
1123
|
+
return;
|
|
1008
1124
|
}
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1125
|
+
state.doneWriteTries ??= new Map();
|
|
1126
|
+
const tries = (state.doneWriteTries.get(job.commandId) ?? 0) + 1;
|
|
1127
|
+
state.doneWriteTries.set(job.commandId, tries);
|
|
1128
|
+
if (tries < HEADLESS_DONE_WRITE_MAX_TRIES) {
|
|
1129
|
+
// ★畳まない。次の tick でもう一度書く(★完了を落とさない)。
|
|
1130
|
+
log(`WARN site=${cfg.site} id=${job.commandId} 便の終了状態を管制へ書けませんでした ` +
|
|
1131
|
+
`(${tries}/${HEADLESS_DONE_WRITE_MAX_TRIES}回目) → 畳まずに次の周でやり直します`);
|
|
1132
|
+
return;
|
|
1013
1133
|
}
|
|
1014
|
-
|
|
1134
|
+
// 上限超え → 黙って done にせず、理由つきで残す(人が見て判断できる形)。
|
|
1135
|
+
const reason = `便の終了状態を管制へ ${HEADLESS_DONE_WRITE_MAX_TRIES} 回書けませんでした` +
|
|
1136
|
+
`(職人は exit=0 で終わっています。報告は手元の控えに残っています)`;
|
|
1137
|
+
await patchStatus(cfg, job.commandId, 'failed', reason.slice(0, EVENT_TEXT_MAX));
|
|
1138
|
+
await sendCommandEvents(cfg, job.commandId, [{
|
|
1139
|
+
type: 'error',
|
|
1140
|
+
text: reason.slice(0, EVENT_TEXT_MAX),
|
|
1141
|
+
at: new Date().toISOString(),
|
|
1142
|
+
attempt: job.attempt,
|
|
1143
|
+
}]);
|
|
1144
|
+
rs.abandoned.add(job.commandId);
|
|
1145
|
+
state.doneWriteTries.delete(job.commandId);
|
|
1146
|
+
noteLastJob('failed', reason);
|
|
1015
1147
|
state.job = null;
|
|
1016
1148
|
rs.lastHeartbeatAt = null;
|
|
1149
|
+
log(`ERROR site=${cfg.site} id=${job.commandId} ${reason}`);
|
|
1017
1150
|
return;
|
|
1018
1151
|
}
|
|
1019
1152
|
// 異常終了 → 次の tick で自動再開を試みる
|
|
@@ -1084,6 +1217,8 @@ async function checkRunningJob(cfg, state) {
|
|
|
1084
1217
|
startedAt: new Date(job.startedAt).toISOString(),
|
|
1085
1218
|
lastActivityAt: new Date(job.lastActivityAt).toISOString(),
|
|
1086
1219
|
...(job.process.pid !== undefined ? { pid: job.process.pid } : {}),
|
|
1220
|
+
claudeBin: job.claudeBin,
|
|
1221
|
+
...(job.claudeVersion ? { claudeVersion: job.claudeVersion } : {}),
|
|
1087
1222
|
});
|
|
1088
1223
|
}
|
|
1089
1224
|
// イベントバッファのフラッシュ
|
|
@@ -1180,7 +1315,7 @@ export async function restoreHeadlessOrphans(cfg, state) {
|
|
|
1180
1315
|
const cmd = { id: pick.id, body: pick.body };
|
|
1181
1316
|
const job = spawnClaudeProcess(cfg, cmd, sessionId, attempt, true);
|
|
1182
1317
|
if (job === null) {
|
|
1183
|
-
await patchStatus(cfg, pick.id, 'failed', 'claude
|
|
1318
|
+
await patchStatus(cfg, pick.id, 'failed', `${lastSpawnError || 'claude が見つかりません'}(手元の孤児復元を中止)`);
|
|
1184
1319
|
state.relay.abandoned.add(pick.id);
|
|
1185
1320
|
log(`ERROR site=${cfg.site} id=${pick.id} 手元の孤児復元: claude が見つからない → 放棄`);
|
|
1186
1321
|
return;
|
|
@@ -1238,10 +1373,10 @@ export async function restoreHeadlessOrphans(cfg, state) {
|
|
|
1238
1373
|
const cmd = { id: pick.id, body: pick.body };
|
|
1239
1374
|
const job = spawnClaudeProcess(cfg, cmd, sessionId, attempt, true);
|
|
1240
1375
|
if (job === null) {
|
|
1241
|
-
await patchStatus(cfg, pick.id, 'failed', 'claude
|
|
1376
|
+
await patchStatus(cfg, pick.id, 'failed', `${lastSpawnError || 'claude が見つかりません'}(孤児復元を中止)`);
|
|
1242
1377
|
await sendCommandEvents(cfg, pick.id, [{
|
|
1243
1378
|
type: 'error',
|
|
1244
|
-
text: 'claude
|
|
1379
|
+
text: `${lastSpawnError || 'claude が見つかりません'}(孤児復元を中止)`,
|
|
1245
1380
|
at: new Date().toISOString(),
|
|
1246
1381
|
attempt,
|
|
1247
1382
|
}]);
|
|
@@ -1264,6 +1399,37 @@ export async function restoreHeadlessOrphans(cfg, state) {
|
|
|
1264
1399
|
`孤児を復元して再開しました (session=${sessionId}, attempt ${attempt}/${HEADLESS_MAX_ATTEMPTS})`);
|
|
1265
1400
|
}
|
|
1266
1401
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1402
|
+
// 取り残しの後始末(便 6ebba0d0 の項目4)
|
|
1403
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1404
|
+
/**
|
|
1405
|
+
* 「実プロセスが無い・報告は渡し済み・管制ではまだ終わっていない」便に done を書く。
|
|
1406
|
+
*
|
|
1407
|
+
* 【なぜ要るか】この便の直しより前に起きた取り残し(02b1e532 / 5db25cae / 7969ea66 と同じ形)は、
|
|
1408
|
+
* 誰も done を書かないまま管制に残っている。人が手で完了にするまで次の便が始まらない。
|
|
1409
|
+
* 中継が自分で気づいて、完了の道と同じ形で書き直す。
|
|
1410
|
+
* 【★よそに書かない】見るのは自分の現場の控えだけ(控えの `site:` 欄で判定する)。
|
|
1411
|
+
* いま追っている便にも書かない。管制でまだ配られていない便(queued)にも書かない。
|
|
1412
|
+
* 【間隔】state を渡したときは、起動時に1回+以後 HEADLESS_RECOVER_SWEEP_MS ごと
|
|
1413
|
+
* (毎周 管制へ問い合わせると実行回数が増えるため)。state を渡さなければ必ず1回回す。
|
|
1414
|
+
*
|
|
1415
|
+
* 返り: done を書いた件数。
|
|
1416
|
+
*/
|
|
1417
|
+
export async function recoverStuckCommands(cfg, opts = {}) {
|
|
1418
|
+
const now = opts.now ?? Date.now();
|
|
1419
|
+
const state = opts.state;
|
|
1420
|
+
if (state !== undefined) {
|
|
1421
|
+
const last = state.lastRecoverAt ?? 0;
|
|
1422
|
+
if (last > 0 && now - last < HEADLESS_RECOVER_SWEEP_MS)
|
|
1423
|
+
return 0;
|
|
1424
|
+
state.lastRecoverAt = now;
|
|
1425
|
+
}
|
|
1426
|
+
return recoverStuckProcessedReports(cfg, {
|
|
1427
|
+
trackedIds: opts.trackedIds,
|
|
1428
|
+
now,
|
|
1429
|
+
logLine: log,
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1267
1433
|
// headless tick(relayTick の headless 版)
|
|
1268
1434
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1269
1435
|
/**
|
|
@@ -1319,6 +1485,11 @@ export async function headlessTick(cfg, state, hooks) {
|
|
|
1319
1485
|
log('停止が解除されました。relayを再開します');
|
|
1320
1486
|
}
|
|
1321
1487
|
rs.lastHalted = false;
|
|
1488
|
+
// ── 取り残しの後始末(★便 6ebba0d0 の項目4) ──
|
|
1489
|
+
// 「実プロセスが無い・報告は渡し済み・管制ではまだ終わっていない」便に done を書く。
|
|
1490
|
+
// ★自分の PC・自分の現場の便だけ(判定は recoverStuckProcessedReports 側)。
|
|
1491
|
+
// ★停止トグルの確認より後に置く=止められている現場では1バイトも書かない。
|
|
1492
|
+
await recoverStuckCommands(cfg, { trackedIds: headlessTrackedCommandIds(state), state });
|
|
1322
1493
|
// ── 異常終了後の自動再開(pendingRetry)──
|
|
1323
1494
|
if (state.pendingRetry !== null) {
|
|
1324
1495
|
const retry = state.pendingRetry;
|
|
@@ -1332,10 +1503,10 @@ export async function headlessTick(cfg, state, hooks) {
|
|
|
1332
1503
|
: sessionIdForCommand(retry.cmd.id, retry.attempt);
|
|
1333
1504
|
const job = spawnClaudeProcess(cfg, retry.cmd, sessionId, retry.attempt, isResume);
|
|
1334
1505
|
if (job === null) {
|
|
1335
|
-
await patchStatus(cfg, retry.cmd.id, 'failed', 'claude
|
|
1506
|
+
await patchStatus(cfg, retry.cmd.id, 'failed', `${lastSpawnError || 'claude が見つかりません'}(自動再開を中止)`);
|
|
1336
1507
|
await sendCommandEvents(cfg, retry.cmd.id, [{
|
|
1337
1508
|
type: 'error',
|
|
1338
|
-
text: 'claude
|
|
1509
|
+
text: `${lastSpawnError || 'claude が見つかりません'}(自動再開を中止)`,
|
|
1339
1510
|
at: new Date().toISOString(),
|
|
1340
1511
|
attempt: retry.attempt,
|
|
1341
1512
|
}]);
|
|
@@ -1389,10 +1560,10 @@ export async function headlessTick(cfg, state, hooks) {
|
|
|
1389
1560
|
const job = spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume);
|
|
1390
1561
|
if (job === null) {
|
|
1391
1562
|
// ★起動失敗を管制へ知らせる(「起動しました」とは言わない)
|
|
1392
|
-
await patchStatus(cfg, cmd.id, 'failed', 'claude
|
|
1563
|
+
await patchStatus(cfg, cmd.id, 'failed', `${lastSpawnError || 'claude が見つかりません'}(起動できませんでした)`);
|
|
1393
1564
|
await sendCommandEvents(cfg, cmd.id, [{
|
|
1394
1565
|
type: 'error',
|
|
1395
|
-
text: 'claude
|
|
1566
|
+
text: `${lastSpawnError || 'claude が見つかりません'}(起動できませんでした)`,
|
|
1396
1567
|
at: new Date().toISOString(),
|
|
1397
1568
|
attempt,
|
|
1398
1569
|
}]);
|
|
@@ -1433,6 +1604,8 @@ export function createHeadlessState() {
|
|
|
1433
1604
|
attempts: new Map(),
|
|
1434
1605
|
restored: false,
|
|
1435
1606
|
pendingRetry: null,
|
|
1607
|
+
doneWriteTries: new Map(),
|
|
1608
|
+
lastRecoverAt: 0,
|
|
1436
1609
|
};
|
|
1437
1610
|
}
|
|
1438
1611
|
/**
|
package/dist/relay.js
CHANGED
|
@@ -2458,32 +2458,51 @@ export async function patchStatus(cfg, id, status, report) {
|
|
|
2458
2458
|
* 本文が来ないまま終わると「完了したが報告なし」だけが残った。報告はファイルにあるので、
|
|
2459
2459
|
* 完了を送るその1回に必ず載せられる。
|
|
2460
2460
|
*
|
|
2461
|
-
*
|
|
2462
|
-
*
|
|
2463
|
-
*
|
|
2461
|
+
* 【★①②③は別の工程(えふさん確定 2026-10-02・便 6ebba0d0)】
|
|
2462
|
+
* ①報告を送る / ②便を終了状態へ確定する / ③次便を解放する は別の工程である。
|
|
2463
|
+
* ★①が既に済んでいても②③は必ず実行する。
|
|
2464
|
+
* 【実障害 3件(02b1e532 / 5db25cae / 7969ea66)】画面なし方式では、職人が報告ファイルを
|
|
2465
|
+
* 置いた直後(実プロセスはまだ生きている)に 30秒の周の「回収の道」
|
|
2466
|
+
* (POST /api/conductor/report/files・★この口は便を完了にしない)が報告を送り、
|
|
2467
|
+
* `processed/` へ移してしまう。その後 exit=0 でここが呼ばれても、以前の実装は
|
|
2468
|
+
* 「処理済みなら、もう渡してある → 何もしない」で true を返していた。
|
|
2469
|
+
* =管制には誰も done を書かず、便は sent → stalled のまま残り、次の便が永久に始まらない。
|
|
2470
|
+
* (relay.ts の sendPendingReportFiles のコメントにこの落とし穴そのものが書いてあるが、
|
|
2471
|
+
* 画面なし方式では除外が空になるため守られていなかった。)
|
|
2472
|
+
* だから【報告がどの段にあるか】と【便が終わっているか】を分けて見る。
|
|
2473
|
+
*
|
|
2474
|
+
* 【★二重完了にしない・開発履歴を2件にしない】
|
|
2475
|
+
* ・管制が「もう終わっている」と言う便には何も書かない(failed を done へ戻さない)。
|
|
2476
|
+
* ・報告が既に渡っている(処理済み)ときは、done だけを書いて本文は載せない
|
|
2477
|
+
* =同じ報告が開発履歴に2件並ばない。
|
|
2464
2478
|
*
|
|
2465
2479
|
* 【後片付け】管制へ書けたときだけ報告を【処理済み】にする(★消さない・2026-09-21)。
|
|
2466
2480
|
* 書けなければ未処理のまま残す=次tickでやり直せるし、管制も生存報告で気づける。
|
|
2467
2481
|
*
|
|
2468
|
-
*
|
|
2469
|
-
* 中央へ問い合わせる前にそこで打ち切る=MCP と報告ファイルの二経路から同じ便が来ても1回だけ扱う。
|
|
2470
|
-
* 返り: 追跡を畳んでよいか(true = 完了が確定した)。
|
|
2482
|
+
* 返り: 追跡を畳んでよいか(true = 便の終了状態が確定した/false = 書けていない=次の周でやり直す)。
|
|
2471
2483
|
*/
|
|
2472
2484
|
export async function completeWithReport(cfg, id, logLine = log) {
|
|
2473
|
-
//
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
return true;
|
|
2477
|
-
}
|
|
2485
|
+
// ★①報告がどの段にあるか(送る工程の話)。②③の実行可否には使わない。
|
|
2486
|
+
const phase = reportPhase(id);
|
|
2487
|
+
// ★②便が終わっているか(確定の工程の話)。管制の真実だけを見る。
|
|
2478
2488
|
const already = await fetchCommandStatus(cfg, id);
|
|
2479
|
-
if (
|
|
2480
|
-
//
|
|
2481
|
-
//
|
|
2482
|
-
|
|
2483
|
-
|
|
2489
|
+
if (isCentralFinished(already)) {
|
|
2490
|
+
// 既に終端(職人の conductor_complete・人の操作・failed 等)。★何も書かない。
|
|
2491
|
+
// 控えは消さずに処理済みとして残す(MCP 経由でも同じ控えが残る・えふさん確定 2026-09-21)。
|
|
2492
|
+
if (phase === 'pending')
|
|
2493
|
+
markReportProcessed(id);
|
|
2494
|
+
logLine(`site=${cfg.site} id=${id} は管制で既に終わっています(status=${already})` +
|
|
2484
2495
|
'→relayからは送りません(報告は控えとして処理済みにしました)');
|
|
2485
2496
|
return true;
|
|
2486
2497
|
}
|
|
2498
|
+
if (phase === 'processed') {
|
|
2499
|
+
// ★①は済み・②は未了。本文は渡してあるので、状態だけを書く(開発履歴を2件にしない)。
|
|
2500
|
+
// 書けなければ false=次の周でやり直す(完了を落とさない)。
|
|
2501
|
+
if (!(await patchStatus(cfg, id, 'done')))
|
|
2502
|
+
return false;
|
|
2503
|
+
logLine(`site=${cfg.site} id=${id} done(報告は回収の道で渡し済み=本文は添えない)`);
|
|
2504
|
+
return true;
|
|
2505
|
+
}
|
|
2487
2506
|
const report = readReport(id);
|
|
2488
2507
|
if (!(await patchStatus(cfg, id, 'done', report)))
|
|
2489
2508
|
return false;
|
|
@@ -2493,6 +2512,98 @@ export async function completeWithReport(cfg, id, logLine = log) {
|
|
|
2493
2512
|
(report !== null ? `(報告添付・${report.length}文字)` : '(報告ファイルなし=完了のみ)'));
|
|
2494
2513
|
return true;
|
|
2495
2514
|
}
|
|
2515
|
+
/**
|
|
2516
|
+
* 「実プロセスが無い・報告は渡し済み・管制ではまだ終わっていない」便に、便の終了状態だけを書く
|
|
2517
|
+
* (★すでに起きている取り残しの後始末・便 6ebba0d0 の項目4)。
|
|
2518
|
+
*
|
|
2519
|
+
* 【どこから見つけるか】処理済みの控え(`processed/`)に自分の現場の報告が在るのに、管制では
|
|
2520
|
+
* まだ配達中(sent / running / delivering)の便。★控えの `site:` 欄が自分の現場の物だけを見る
|
|
2521
|
+
* =他の PC・他の現場の便には1バイトも書かない。
|
|
2522
|
+
* 【なぜ queued を含めないか】queued はまだ誰にも配られていない=この PC が完了にしてよい便ではない。
|
|
2523
|
+
* 【なぜ failed を含めないか】failed を done へ戻さない(変えない決まり)。isCentralFinished が弾く。
|
|
2524
|
+
*
|
|
2525
|
+
* 返り: done を書いた件数。
|
|
2526
|
+
*/
|
|
2527
|
+
export async function recoverStuckProcessedReports(cfg, opts = {}) {
|
|
2528
|
+
const logLine = opts.logLine ?? log;
|
|
2529
|
+
const tracked = new Set(opts.trackedIds ?? []);
|
|
2530
|
+
const ids = processedReportIds({
|
|
2531
|
+
site: cfg.site,
|
|
2532
|
+
env: opts.env,
|
|
2533
|
+
limit: opts.limit ?? RECOVER_STUCK_LIMIT,
|
|
2534
|
+
maxAgeMs: opts.maxAgeMs ?? RECOVER_STUCK_MAX_AGE_MS,
|
|
2535
|
+
now: opts.now,
|
|
2536
|
+
});
|
|
2537
|
+
let written = 0;
|
|
2538
|
+
for (const id of ids) {
|
|
2539
|
+
if (tracked.has(id))
|
|
2540
|
+
continue;
|
|
2541
|
+
const status = await fetchCommandStatus(cfg, id);
|
|
2542
|
+
if (status === null || !RECOVER_STUCK_STATUSES.has(status))
|
|
2543
|
+
continue;
|
|
2544
|
+
// ★完了の道と同じ形で書く(本文は渡し済みなので添えない)。
|
|
2545
|
+
if (!(await patchStatus(cfg, id, 'done')))
|
|
2546
|
+
continue;
|
|
2547
|
+
written++;
|
|
2548
|
+
logLine(`site=${cfg.site} id=${id} 取り残しの後始末: 報告は渡し済み・管制は ${status} のままだったので done を書きました`);
|
|
2549
|
+
}
|
|
2550
|
+
return written;
|
|
2551
|
+
}
|
|
2552
|
+
/** 後始末の対象にする便の状態(★配られた後の状態だけ。queued は含めない)。 */
|
|
2553
|
+
const RECOVER_STUCK_STATUSES = new Set(['sent', 'running', 'delivering']);
|
|
2554
|
+
/** 後始末で1回に見る控えの件数の上限。 */
|
|
2555
|
+
export const RECOVER_STUCK_LIMIT = 20;
|
|
2556
|
+
/** 後始末で見る控えの古さの上限(これより古い控えは見ない)。 */
|
|
2557
|
+
export const RECOVER_STUCK_MAX_AGE_MS = 48 * 60 * 60 * 1000;
|
|
2558
|
+
/**
|
|
2559
|
+
* 処理済みの控え(`processed/`)のうち、指定の現場の物の指示IDを新しい順に返す。
|
|
2560
|
+
* ★控えの `site:` 欄が一致した物だけ(欄が無い古い形の控えは対象外=よそに書かない安全側)。
|
|
2561
|
+
*/
|
|
2562
|
+
export function processedReportIds(opts) {
|
|
2563
|
+
const now = opts.now ?? Date.now();
|
|
2564
|
+
const maxAgeMs = opts.maxAgeMs ?? RECOVER_STUCK_MAX_AGE_MS;
|
|
2565
|
+
const limit = Math.max(0, Math.floor(opts.limit ?? RECOVER_STUCK_LIMIT));
|
|
2566
|
+
const found = new Map(); // 指示ID → 処理済みにした時刻(ms)
|
|
2567
|
+
for (const dir of reportStoreDirs(opts.env)) {
|
|
2568
|
+
let names;
|
|
2569
|
+
try {
|
|
2570
|
+
names = readdirSync(path.join(dir, PROCESSED_DIR));
|
|
2571
|
+
}
|
|
2572
|
+
catch {
|
|
2573
|
+
continue;
|
|
2574
|
+
}
|
|
2575
|
+
for (const name of names) {
|
|
2576
|
+
if (!name.endsWith('.md'))
|
|
2577
|
+
continue;
|
|
2578
|
+
const file = path.join(dir, PROCESSED_DIR, name);
|
|
2579
|
+
let mtime;
|
|
2580
|
+
try {
|
|
2581
|
+
const st = statSync(file);
|
|
2582
|
+
if (!st.isFile() || st.size === 0)
|
|
2583
|
+
continue;
|
|
2584
|
+
mtime = st.mtimeMs;
|
|
2585
|
+
}
|
|
2586
|
+
catch {
|
|
2587
|
+
continue;
|
|
2588
|
+
}
|
|
2589
|
+
if (now - mtime > maxAgeMs)
|
|
2590
|
+
continue;
|
|
2591
|
+
const raw = readIfFilled(file);
|
|
2592
|
+
if (raw === null)
|
|
2593
|
+
continue;
|
|
2594
|
+
if (parseReport(raw).site !== opts.site)
|
|
2595
|
+
continue;
|
|
2596
|
+
const id = name.slice(0, -3);
|
|
2597
|
+
const prev = found.get(id);
|
|
2598
|
+
if (prev === undefined || mtime > prev)
|
|
2599
|
+
found.set(id, mtime);
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
return [...found.entries()]
|
|
2603
|
+
.sort((a, b) => b[1] - a[1])
|
|
2604
|
+
.map(([id]) => id)
|
|
2605
|
+
.slice(0, limit);
|
|
2606
|
+
}
|
|
2496
2607
|
/**
|
|
2497
2608
|
* 指示 {id} 単体の現在 status を中央へ問う(外部完了検出の根治プローブ)。
|
|
2498
2609
|
* GET /api/conductor/commands/{id}(テナントトークン Bearer)。応答 JSON から status 文字列を拾う。
|