@tyhld/conductor 0.12.63 → 0.12.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/headless.js +101 -11
- package/package.json +1 -1
package/dist/headless.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* 同じ relayTick に分岐を詰め込むと壊れやすいので、独立したモジュールにする。
|
|
15
15
|
* 共通の管制との通信(fetchControl, fetchCommand, patchStatus 等)は relay.ts から使う。
|
|
16
16
|
*/
|
|
17
|
-
import { spawn } from 'node:child_process';
|
|
17
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
18
18
|
import { createInterface } from 'node:readline';
|
|
19
19
|
import { createWriteStream, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
20
20
|
import path from 'node:path';
|
|
@@ -53,6 +53,23 @@ export const ACTIVE_JOB_PATH_LEGACY = path.join(ACTIVE_JOB_DIR, 'active-job.json
|
|
|
53
53
|
export function activeJobPath(site) {
|
|
54
54
|
return path.join(ACTIVE_JOB_DIR, `active-job-${site}.json`);
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* 作業ディレクトリの git remote origin URL を返す。
|
|
58
|
+
* git リポジトリでない・origin が無い場合は null。
|
|
59
|
+
* ★パスの文字が同じでも別のリポなら止める(リポの同一性の照合に使う)。
|
|
60
|
+
*/
|
|
61
|
+
export function resolveGitOrigin(workDir) {
|
|
62
|
+
try {
|
|
63
|
+
return execFileSync('git', ['-C', workDir, 'config', '--get', 'remote.origin.url'], {
|
|
64
|
+
encoding: 'utf8',
|
|
65
|
+
timeout: 5_000,
|
|
66
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
67
|
+
}).trim() || null;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
56
73
|
/** 実行中のジョブをファイルに書く(中継が死んでも拾い直せるように)。 */
|
|
57
74
|
export function saveActiveJob(site, record) {
|
|
58
75
|
try {
|
|
@@ -112,8 +129,12 @@ export function loadAndClearLegacyActiveJob() {
|
|
|
112
129
|
}
|
|
113
130
|
}
|
|
114
131
|
/**
|
|
115
|
-
*
|
|
132
|
+
* 手元の記録が自分(この中継)のものか照合する。
|
|
133
|
+
* site, machine, workDir, gitOrigin を比べる。
|
|
116
134
|
* 1点でも不一致なら理由を返す。すべて一致なら null。
|
|
135
|
+
*
|
|
136
|
+
* ★commandId は「この便が中央でまだ sent か」の確認(ネットワーク照合)で別に行う。
|
|
137
|
+
* ここではローカルの所有者照合のみ。
|
|
117
138
|
*/
|
|
118
139
|
export function validateActiveJobOwnership(rec, cfg) {
|
|
119
140
|
const mismatches = [];
|
|
@@ -126,10 +147,16 @@ export function validateActiveJobOwnership(rec, cfg) {
|
|
|
126
147
|
if (rec.workDir !== undefined && rec.workDir !== cfg.workDir) {
|
|
127
148
|
mismatches.push(`workDir: 記録=${rec.workDir} 自分=${cfg.workDir}`);
|
|
128
149
|
}
|
|
129
|
-
//
|
|
130
|
-
|
|
150
|
+
// ★git リポの同一性: パスが同じでも別のリポなら止める
|
|
151
|
+
if (rec.gitOrigin !== undefined) {
|
|
152
|
+
const currentOrigin = resolveGitOrigin(cfg.workDir);
|
|
153
|
+
if (currentOrigin !== null && rec.gitOrigin !== currentOrigin) {
|
|
154
|
+
mismatches.push(`gitOrigin: 記録=${rec.gitOrigin} 自分=${currentOrigin}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// 照合項目がそろわない古い記録は安全に拾えない。
|
|
131
158
|
if (rec.site === undefined || rec.machine === undefined || rec.workDir === undefined) {
|
|
132
|
-
mismatches.push('
|
|
159
|
+
mismatches.push('照合項目が不足(古い記録)');
|
|
133
160
|
}
|
|
134
161
|
return mismatches.length > 0 ? mismatches.join(', ') : null;
|
|
135
162
|
}
|
|
@@ -268,8 +295,51 @@ export function streamEventToCommandEvent(parsed, attempt) {
|
|
|
268
295
|
}
|
|
269
296
|
return null;
|
|
270
297
|
}
|
|
271
|
-
/**
|
|
298
|
+
/**
|
|
299
|
+
* stream-json の1行から認証エラーを検出する。
|
|
300
|
+
*
|
|
301
|
+
* 実物(2026-10-01 メインPC Fshima・ログイン切れ):
|
|
302
|
+
* assistant 行: error="authentication_failed", text="Not logged in · Please run /login"
|
|
303
|
+
* result 行: subtype="success", is_error=true, result="Not logged in · Please run /login"
|
|
304
|
+
* stderr: 空。exit code: 1。
|
|
305
|
+
*
|
|
306
|
+
* ★stderr には何も出ない。stdout の stream-json でだけ分かる。
|
|
307
|
+
*/
|
|
308
|
+
export function detectStreamAuthError(parsed) {
|
|
309
|
+
// assistant 行の error フィールド(最も確実な判定)
|
|
310
|
+
if (parsed.type === 'assistant') {
|
|
311
|
+
const msg = parsed.message;
|
|
312
|
+
if (msg) {
|
|
313
|
+
// assistant 直下にも error がある場合がある(実物で確認)
|
|
314
|
+
}
|
|
315
|
+
if (parsed.error === 'authentication_failed')
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
// assistant.message.content[].text or parsed.error on assistant envelope
|
|
319
|
+
if (parsed.type === 'assistant' && typeof parsed.message === 'object' && parsed.message !== null) {
|
|
320
|
+
const msg = parsed.message;
|
|
321
|
+
if (msg.error === 'authentication_failed')
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
// result 行: is_error=true かつ result テキストにログイン切れの文言
|
|
325
|
+
if (parsed.type === 'result' && parsed.is_error === true) {
|
|
326
|
+
const result = String(parsed.result ?? '').toLowerCase();
|
|
327
|
+
if (result.includes('not logged in') ||
|
|
328
|
+
result.includes('please run /login') ||
|
|
329
|
+
result.includes('login required') ||
|
|
330
|
+
result.includes('session expired') ||
|
|
331
|
+
result.includes('token expired')) {
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
/** 子プロセスの stderr と stream-json の両方から認証エラーを検出する。 */
|
|
272
338
|
export function isAuthError(job) {
|
|
339
|
+
// ★stream-json(stdout)で検出済み
|
|
340
|
+
if (job.authErrorDetected)
|
|
341
|
+
return true;
|
|
342
|
+
// stderr からも検出(従来の判定。API キーの認証エラーなど)
|
|
273
343
|
const s = job.stderrBuf.toLowerCase();
|
|
274
344
|
return (s.includes('401') ||
|
|
275
345
|
s.includes('unauthorized') ||
|
|
@@ -387,6 +457,7 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
387
457
|
lastFlushAt: Date.now(),
|
|
388
458
|
lastStopCheckAt: Date.now(),
|
|
389
459
|
stderrBuf: '',
|
|
460
|
+
authErrorDetected: false,
|
|
390
461
|
stdoutLog: logs?.stdoutLog ?? null,
|
|
391
462
|
stderrLog: logs?.stderrLog ?? null,
|
|
392
463
|
durationMs: 0,
|
|
@@ -421,6 +492,10 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
421
492
|
job.stdoutLog?.write(line + '\n');
|
|
422
493
|
try {
|
|
423
494
|
const parsed = JSON.parse(line);
|
|
495
|
+
// ★stream-json から認証エラーを検出(stderr に出ない実物に対応)
|
|
496
|
+
if (!job.authErrorDetected && detectStreamAuthError(parsed)) {
|
|
497
|
+
job.authErrorDetected = true;
|
|
498
|
+
}
|
|
424
499
|
const event = streamEventToCommandEvent(parsed, attempt);
|
|
425
500
|
if (event)
|
|
426
501
|
job.eventBuffer.push(event);
|
|
@@ -548,7 +623,10 @@ async function checkRunningJob(cfg, state) {
|
|
|
548
623
|
}
|
|
549
624
|
if (isAuthError(job)) {
|
|
550
625
|
// 認証エラー → 再試行しない
|
|
551
|
-
const
|
|
626
|
+
const detail = job.authErrorDetected
|
|
627
|
+
? 'ログイン切れ(stream-json で検出)'
|
|
628
|
+
: job.stderrBuf.slice(0, 300);
|
|
629
|
+
const authMsg = maskSecrets(`認証エラー (exit=${job.exitCode}): ${detail}`).slice(0, EVENT_TEXT_MAX);
|
|
552
630
|
await sendCommandEvents(cfg, job.commandId, [{
|
|
553
631
|
type: 'auth_error',
|
|
554
632
|
text: authMsg,
|
|
@@ -689,6 +767,18 @@ export async function restoreHeadlessOrphans(cfg, state) {
|
|
|
689
767
|
}]);
|
|
690
768
|
return;
|
|
691
769
|
}
|
|
770
|
+
// ★便IDの照合: 中央でまだ sent のまま残っている便か確かめる
|
|
771
|
+
const centralStatus = await fetchCommandStatus(cfg, saved.commandId);
|
|
772
|
+
if (centralStatus !== null && centralStatus !== 'sent') {
|
|
773
|
+
log(`WARN site=${cfg.site} id=${saved.commandId} 手元の記録の便は中央で ${centralStatus} → 再開しない`);
|
|
774
|
+
await sendCommandEvents(cfg, saved.commandId, [{
|
|
775
|
+
type: 'error',
|
|
776
|
+
text: `便IDの照合: 中央の状態が sent ではない(${centralStatus})→ 再開しない`.slice(0, EVENT_TEXT_MAX),
|
|
777
|
+
at: new Date().toISOString(),
|
|
778
|
+
attempt: saved.attempt,
|
|
779
|
+
}]);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
692
782
|
log(`site=${cfg.site} id=${saved.commandId} 手元の記録から孤児を検出(中継再起動後の拾い直し)`);
|
|
693
783
|
const pick = { id: saved.commandId, body: saved.body, site: cfg.site, sentAt: saved.startedAt };
|
|
694
784
|
const attempt = saved.attempt + 1;
|
|
@@ -709,7 +799,7 @@ export async function restoreHeadlessOrphans(cfg, state) {
|
|
|
709
799
|
return;
|
|
710
800
|
}
|
|
711
801
|
state.job = job;
|
|
712
|
-
saveActiveJob(cfg.site, { commandId: pick.id, body: pick.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir });
|
|
802
|
+
saveActiveJob(cfg.site, { commandId: pick.id, body: pick.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined });
|
|
713
803
|
markDelivered(state.relay, pick.id);
|
|
714
804
|
state.relay.lastHeartbeatAt = null;
|
|
715
805
|
noteReadiness(state.relay, 'delivered', pick.id);
|
|
@@ -751,7 +841,7 @@ export async function restoreHeadlessOrphans(cfg, state) {
|
|
|
751
841
|
return;
|
|
752
842
|
}
|
|
753
843
|
state.job = job;
|
|
754
|
-
saveActiveJob(cfg.site, { commandId: pick.id, body: pick.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir });
|
|
844
|
+
saveActiveJob(cfg.site, { commandId: pick.id, body: pick.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined });
|
|
755
845
|
markDelivered(state.relay, pick.id);
|
|
756
846
|
state.relay.lastHeartbeatAt = null;
|
|
757
847
|
noteReadiness(state.relay, 'delivered', pick.id);
|
|
@@ -844,7 +934,7 @@ export async function headlessTick(cfg, state, hooks) {
|
|
|
844
934
|
return;
|
|
845
935
|
}
|
|
846
936
|
state.job = job;
|
|
847
|
-
saveActiveJob(cfg.site, { commandId: retry.cmd.id, body: retry.cmd.body, sessionId, attempt: retry.attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir });
|
|
937
|
+
saveActiveJob(cfg.site, { commandId: retry.cmd.id, body: retry.cmd.body, sessionId, attempt: retry.attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined });
|
|
848
938
|
rs.lastHeartbeatAt = null;
|
|
849
939
|
noteReadiness(rs, 'delivered', retry.cmd.id);
|
|
850
940
|
await sendCommandEvents(cfg, retry.cmd.id, [{
|
|
@@ -900,7 +990,7 @@ export async function headlessTick(cfg, state, hooks) {
|
|
|
900
990
|
return;
|
|
901
991
|
}
|
|
902
992
|
state.job = job;
|
|
903
|
-
saveActiveJob(cfg.site, { commandId: cmd.id, body: cmd.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir });
|
|
993
|
+
saveActiveJob(cfg.site, { commandId: cmd.id, body: cmd.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined });
|
|
904
994
|
markDelivered(rs, cmd.id);
|
|
905
995
|
rs.lastHeartbeatAt = null;
|
|
906
996
|
noteReadiness(rs, 'delivered', cmd.id);
|