@tyhld/conductor 0.12.52 → 0.12.54
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 +61 -30
- package/dist/headless.js +570 -0
- package/dist/relay.js +224 -38
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -13,7 +13,9 @@ import { execFileSync, spawnSync } from 'node:child_process';
|
|
|
13
13
|
import { existsSync, readFileSync } from 'node:fs';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
16
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';
|
|
17
19
|
import { createRelayRunner } from './relay-runner.js';
|
|
18
20
|
import { installProcessGuards } from './process-guard.js';
|
|
19
21
|
import { registerNudgeTarget, unregisterNudgeTarget, watchNudges, } from './nudge.js';
|
|
@@ -144,6 +146,9 @@ function loadConfig(args) {
|
|
|
144
146
|
}
|
|
145
147
|
promptIntervalSec = parsed;
|
|
146
148
|
}
|
|
149
|
+
// ★配達方式(えふさん確定)。既定 'tmux'。'headless' で画面なし方式に切り替わる。
|
|
150
|
+
// 秘密ではないので ~/.conductor.env には入れない(systemd の drop-in か直接の export)。
|
|
151
|
+
const runnerMode = parseRunnerMode(process.env.CONDUCTOR_RUNNER_MODE);
|
|
147
152
|
return {
|
|
148
153
|
url: url.replace(/\/+$/, ''),
|
|
149
154
|
token,
|
|
@@ -157,6 +162,7 @@ function loadConfig(args) {
|
|
|
157
162
|
promptIntervalSec,
|
|
158
163
|
agentKind,
|
|
159
164
|
freshContext,
|
|
165
|
+
runnerMode,
|
|
160
166
|
};
|
|
161
167
|
}
|
|
162
168
|
/** 1回分の報告を送る。送信結果(HTTPステータス)をログ表示する。 */
|
|
@@ -167,7 +173,8 @@ async function sendReport(cfg) {
|
|
|
167
173
|
branch: detectBranch(),
|
|
168
174
|
status: cfg.status,
|
|
169
175
|
lastAction: cfg.lastAction,
|
|
170
|
-
|
|
176
|
+
// headless 方式では tmux セッションは使わない。ジョブ実行中または空きで待機中なら ready。
|
|
177
|
+
artisanReady: cfg.runnerMode === 'headless' ? cfg.relayEnabled : tmuxHasSession(cfg.site),
|
|
171
178
|
};
|
|
172
179
|
// ★relay が動いている機だけ、2段目(いま配れるか)を足す。
|
|
173
180
|
// 未配達でないときは秒数と指示idを載せない=管制で「遅れ」を数えない。
|
|
@@ -266,39 +273,60 @@ async function start(args) {
|
|
|
266
273
|
// ポーリングの間隔はこれまでどおり(15秒/承認待ち3秒)。耳が死んでいる間の保険として残す。
|
|
267
274
|
let relayRunner = null;
|
|
268
275
|
let nudgeWatcher = null;
|
|
276
|
+
// ★headless 方式の状態参照(sendReport のアダプタ用)。tmux のときは null のまま。
|
|
277
|
+
let headlessStateRef = null;
|
|
269
278
|
if (cfg.relayEnabled) {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
279
|
+
if (cfg.runnerMode === 'headless') {
|
|
280
|
+
// ── headless 方式(画面なし・便ごとに claude -p を起動する) ──
|
|
281
|
+
const workDir = path.resolve(os.homedir(), 'projects', cfg.site);
|
|
282
|
+
console.log(`[headless] 有効: site=${cfg.site} mode=headless workDir=${workDir} ` +
|
|
283
|
+
`interval=${cfg.relayIntervalSec}s`);
|
|
284
|
+
const hState = createHeadlessState();
|
|
285
|
+
headlessStateRef = hState;
|
|
286
|
+
// 生存報告に「いま配れるか」を載せるため、内包する RelayState を参照する。
|
|
287
|
+
relayStateForReport = hState.relay;
|
|
288
|
+
const hCfg = { ...cfg, workDir };
|
|
289
|
+
relayRunner = createRelayRunner({
|
|
290
|
+
tick: () => headlessTick(hCfg, hState, {
|
|
291
|
+
onCentralReconnect: () => {
|
|
292
|
+
console.log('[headless] 管制へ現場の生存報告を1回送り直します(再接続の直後)');
|
|
293
|
+
void sendReport(cfg);
|
|
294
|
+
},
|
|
295
|
+
}),
|
|
296
|
+
nextDelayMs: () => headlessNextDelayMs(hState, cfg.relayIntervalSec),
|
|
297
|
+
onError: (err) => {
|
|
298
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
299
|
+
console.log(`[headless] site=${cfg.site} tick で例外: ${msg}(次回に再試行)`);
|
|
283
300
|
},
|
|
284
|
-
})
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
// ── tmux 方式(従来どおり) ──
|
|
305
|
+
console.log(`[relay] 有効: site=${cfg.site} mode=tmux interval=${cfg.relayIntervalSec}s ` +
|
|
306
|
+
`(承認待ちの間だけ ${cfg.promptIntervalSec}s に短縮)` +
|
|
307
|
+
`(tmuxセッション "${cfg.site}" がアイドルのときだけ送信します)`);
|
|
308
|
+
const relayState = createRelayState();
|
|
309
|
+
// 生存報告に「いま配れるか」を載せるための参照(便 600d2a6a)。
|
|
310
|
+
relayStateForReport = relayState;
|
|
311
|
+
relayRunner = createRelayRunner({
|
|
312
|
+
tick: () => relayTick(cfg, relayState, {
|
|
313
|
+
onCentralReconnect: () => {
|
|
314
|
+
console.log('[relay] 管制へ現場の生存報告を1回送り直します(再接続の直後)');
|
|
315
|
+
void sendReport(cfg);
|
|
316
|
+
},
|
|
317
|
+
}),
|
|
318
|
+
nextDelayMs: () => relayState.central.failures > 0
|
|
319
|
+
? reconnectDelayMs(relayState.central.failures)
|
|
320
|
+
: (relayState.promptPending ? cfg.promptIntervalSec : cfg.relayIntervalSec) * 1000,
|
|
321
|
+
onError: (err) => {
|
|
322
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
323
|
+
console.log(`[relay] site=${cfg.site} tick で例外: ${msg}(次回に再試行)`);
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
}
|
|
297
327
|
void relayRunner.run(); // 起動直後に1回。以後は自分で次を積む。
|
|
298
328
|
// ADR-066 便2c: 耳の常駐(conductor ear / この機に1本)からの呼び鈴を受ける。
|
|
299
|
-
//
|
|
300
|
-
// ・呼び鈴が鳴ったらポーリング1周を前倒しするだけ。合図に中身は無く、指示は従来どおり取得APIから取る。
|
|
301
|
-
// ・呼び鈴が来なくても・耳が落ちていても、上のポーリングがそのまま動く(耳は加速装置であって依存先ではない)。
|
|
329
|
+
// ★headless でも同じ呼び鈴を受ける(指示取得を前倒しする仕組みは方式を問わない)。
|
|
302
330
|
const registered = registerNudgeTarget(cfg.site);
|
|
303
331
|
if (registered !== null) {
|
|
304
332
|
console.log(`[relay] 合図の受け口: ${registered}(耳からの「今すぐ1周」を受けます)`);
|
|
@@ -457,6 +485,9 @@ function printUsage() {
|
|
|
457
485
|
' CONDUCTOR_RELAY_INTERVAL_SEC relayポーリング間隔・秒(既定: 15)',
|
|
458
486
|
' CONDUCTOR_RELAY_PROMPT_INTERVAL_SEC 承認待ちの間だけ使う短い間隔・秒(既定: 3)',
|
|
459
487
|
'',
|
|
488
|
+
'配達方式の切替(★PCごとに設定。relay を再起動すると切り替わる):',
|
|
489
|
+
' CONDUCTOR_RUNNER_MODE tmux(既定)か headless。headless は便ごとに claude -p を起動する画面なし方式',
|
|
490
|
+
'',
|
|
460
491
|
'管制につながらないとき(★relayは止まりません。待って再試行し、戻ったら自動で通常へ):',
|
|
461
492
|
' RELAY_FETCH_TIMEOUT_MS 管制への問い合わせ1回の上限・ミリ秒(既定: 20000)',
|
|
462
493
|
' RELAY_RECONNECT_BASE_MS 再試行の最初の待ち・ミリ秒(既定: 5000。以後 倍々に伸ばす)',
|
package/dist/headless.js
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @tyhld/conductor — headless runner(画面なし方式)。
|
|
3
|
+
*
|
|
4
|
+
* tmux の代わりに、便ごとに `claude -p` を子プロセスで起動する。
|
|
5
|
+
*
|
|
6
|
+
* 【同時1指示】走る子プロセスは常に1つだけ。tmux 方式と同じ「1現場同時1指示」の不変条件を守る。
|
|
7
|
+
* 【入れ子防止】CLAUDECODE=1 は子プロセスの環境から外す(Claude Code が入れ子起動をブロックするため)。
|
|
8
|
+
* 【鍵の保護】ANTHROPIC_API_KEY も外す(今の tmux 起動と同じ。子プロセスは自分の鍵を使う)。
|
|
9
|
+
* 【秘密のマスク】進捗イベントを管制へ送る前に、鍵・トークン・パスワードをマスクする。
|
|
10
|
+
*
|
|
11
|
+
* 【なぜ切り出すか】headless 方式は tmux 方式とは配達・完了検出・状態管理が根本的に違う。
|
|
12
|
+
* tmux: 「常駐する職人の画面へ貼り付ける → 画面の DONE 行を監視する」
|
|
13
|
+
* headless: 「便ごとにプロセスを起動する → プロセスの終了を検出する」
|
|
14
|
+
* 同じ relayTick に分岐を詰め込むと壊れやすいので、独立したモジュールにする。
|
|
15
|
+
* 共通の管制との通信(fetchControl, fetchCommand, patchStatus 等)は relay.ts から使う。
|
|
16
|
+
*/
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { createInterface } from 'node:readline';
|
|
19
|
+
import { RELAY_DELIVER_MAX, RELAY_FETCH_TIMEOUT_MS, RELAY_HEARTBEAT_INTERVAL_MS, RELAY_PENDING_TIMEOUT_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';
|
|
20
|
+
const log = (msg) => {
|
|
21
|
+
console.log(`[headless] ${msg}`);
|
|
22
|
+
};
|
|
23
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
// 定数
|
|
25
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
/** 子プロセスへ SIGTERM を送ってから SIGKILL までの猶予(ms)。 */
|
|
27
|
+
export const HEADLESS_KILL_GRACE_MS = 10_000;
|
|
28
|
+
/** 1つの便を自動再開する最大試行回数(これを超えたら failed にして諦める)。 */
|
|
29
|
+
export const HEADLESS_MAX_ATTEMPTS = 3;
|
|
30
|
+
/** 進捗イベントのバッチ送信間隔(ms)。この間隔でバッファを管制へ流す。 */
|
|
31
|
+
export const HEADLESS_EVENT_FLUSH_MS = 2_000;
|
|
32
|
+
/** 実行中に stop_requested を確かめる間隔(ms)。 */
|
|
33
|
+
export const HEADLESS_STOP_CHECK_MS = 15_000;
|
|
34
|
+
/** イベントの text フィールドの上限(文字)。 */
|
|
35
|
+
export const EVENT_TEXT_MAX = 500;
|
|
36
|
+
/** resume 時に職人へ渡す続行指示。 */
|
|
37
|
+
const RESUME_PROMPT = '前回の作業の続きを行ってください。中断された箇所から再開してください。';
|
|
38
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
// 秘密のマスク
|
|
40
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
/** 秘密の値を検出するパターン。マッチした部分を *** に置換してからイベントを送る。 */
|
|
42
|
+
const SECRET_PATTERNS = [
|
|
43
|
+
/(?:CONDUCTOR_TOKEN|ANTHROPIC_API_KEY|API_KEY|SECRET_KEY|PASSWORD|DATABASE_URL|SUPABASE_SERVICE_ROLE_KEY)\s*[=:]\s*\S+/gi,
|
|
44
|
+
/Bearer\s+[A-Za-z0-9._~+/=-]{10,}/g,
|
|
45
|
+
/sk-ant-[A-Za-z0-9-]{20,}/g,
|
|
46
|
+
/sk-[A-Za-z0-9]{20,}/g,
|
|
47
|
+
];
|
|
48
|
+
export function maskSecrets(text) {
|
|
49
|
+
let masked = text;
|
|
50
|
+
for (const re of SECRET_PATTERNS) {
|
|
51
|
+
// 毎回 lastIndex をリセット(gフラグ付き RegExp の再利用に必要)。
|
|
52
|
+
re.lastIndex = 0;
|
|
53
|
+
masked = masked.replace(re, (m) => {
|
|
54
|
+
const sepIdx = m.search(/[=:]\s*/);
|
|
55
|
+
if (sepIdx >= 0) {
|
|
56
|
+
const afterSep = m.indexOf(m[sepIdx]) + 1;
|
|
57
|
+
return m.slice(0, afterSep) + '***';
|
|
58
|
+
}
|
|
59
|
+
if (/^Bearer\s/i.test(m))
|
|
60
|
+
return 'Bearer ***';
|
|
61
|
+
return '***';
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return masked;
|
|
65
|
+
}
|
|
66
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
// ヘルパー
|
|
68
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
/** commandId からセッション ID を作る(resume で再利用する決定的な値)。 */
|
|
70
|
+
export function sessionIdForCommand(commandId) {
|
|
71
|
+
return `conductor-${commandId}`;
|
|
72
|
+
}
|
|
73
|
+
/** stream-json の1行を CommandEvent に変換する。無関係な行は null。 */
|
|
74
|
+
export function streamEventToCommandEvent(parsed, attempt) {
|
|
75
|
+
const now = new Date().toISOString();
|
|
76
|
+
const type = parsed.type;
|
|
77
|
+
const subtype = parsed.subtype;
|
|
78
|
+
if (type === 'assistant' && subtype === 'text') {
|
|
79
|
+
const text = String(parsed.text ?? '').slice(0, EVENT_TEXT_MAX);
|
|
80
|
+
if (text.length === 0)
|
|
81
|
+
return null;
|
|
82
|
+
return { type: 'progress', text: maskSecrets(text), at: now, attempt };
|
|
83
|
+
}
|
|
84
|
+
if (type === 'assistant' && subtype === 'tool_use') {
|
|
85
|
+
const name = String(parsed.name ?? 'unknown');
|
|
86
|
+
return { type: 'tool', text: maskSecrets(`tool: ${name}`).slice(0, EVENT_TEXT_MAX), at: now, attempt };
|
|
87
|
+
}
|
|
88
|
+
if (type === 'result') {
|
|
89
|
+
if (subtype === 'error') {
|
|
90
|
+
const errText = String(parsed.error ?? '').slice(0, EVENT_TEXT_MAX);
|
|
91
|
+
return { type: 'error', text: maskSecrets(errText), at: now, attempt };
|
|
92
|
+
}
|
|
93
|
+
const resultText = String(parsed.result ?? '').slice(0, EVENT_TEXT_MAX);
|
|
94
|
+
return { type: 'result', text: maskSecrets(resultText), at: now, attempt };
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
/** 子プロセスの stderr から認証エラーを検出する。 */
|
|
99
|
+
export function isAuthError(job) {
|
|
100
|
+
const s = job.stderrBuf.toLowerCase();
|
|
101
|
+
return (s.includes('401') ||
|
|
102
|
+
s.includes('unauthorized') ||
|
|
103
|
+
s.includes('authentication failed') ||
|
|
104
|
+
s.includes('invalid api key') ||
|
|
105
|
+
s.includes('invalid_api_key'));
|
|
106
|
+
}
|
|
107
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
108
|
+
// 子プロセスの起動と停止
|
|
109
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
110
|
+
/**
|
|
111
|
+
* claude -p を子プロセスとして起動する。
|
|
112
|
+
*
|
|
113
|
+
* 【環境変数】
|
|
114
|
+
* CLAUDECODE を外す … 入れ子防止の解除。これが残っていると claude -p が起動しない。
|
|
115
|
+
* ANTHROPIC_API_KEY を外す … 今の tmux 起動と同じ。子プロセスは自分の鍵で認証する。
|
|
116
|
+
* CONDUCTOR_SITE, CONDUCTOR_MACHINE を渡す … フック(hooks)が管制と話すために使う。
|
|
117
|
+
*
|
|
118
|
+
* 【stdin】
|
|
119
|
+
* 初回: 指示本文(buildSendText で組み立てた1つのメッセージ)を書いて閉じる。
|
|
120
|
+
* resume: 続行指示を書いて閉じる。
|
|
121
|
+
*
|
|
122
|
+
* 【stdout】stream-json 形式。行ごとに JSON を出す。readline で1行ずつ読む。
|
|
123
|
+
* 【stderr】蓄積して認証エラーの検出に使う。
|
|
124
|
+
*/
|
|
125
|
+
export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
126
|
+
const env = { ...process.env };
|
|
127
|
+
delete env.CLAUDECODE;
|
|
128
|
+
delete env.ANTHROPIC_API_KEY;
|
|
129
|
+
env.CONDUCTOR_SITE = cfg.site;
|
|
130
|
+
env.CONDUCTOR_MACHINE = cfg.machine;
|
|
131
|
+
const args = [
|
|
132
|
+
'-p',
|
|
133
|
+
'--output-format', 'stream-json',
|
|
134
|
+
'--permission-mode', cfg.permissionMode ?? 'acceptEdits',
|
|
135
|
+
'--session-id', sessionId,
|
|
136
|
+
];
|
|
137
|
+
if (isResume)
|
|
138
|
+
args.push('--resume');
|
|
139
|
+
const proc = spawn('claude', args, {
|
|
140
|
+
cwd: cfg.workDir,
|
|
141
|
+
env,
|
|
142
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
143
|
+
});
|
|
144
|
+
const job = {
|
|
145
|
+
commandId: cmd.id,
|
|
146
|
+
process: proc,
|
|
147
|
+
sessionId,
|
|
148
|
+
startedAt: Date.now(),
|
|
149
|
+
attempt,
|
|
150
|
+
completed: false,
|
|
151
|
+
exitCode: null,
|
|
152
|
+
exitSignal: null,
|
|
153
|
+
stopped: false,
|
|
154
|
+
eventBuffer: [],
|
|
155
|
+
lastFlushAt: Date.now(),
|
|
156
|
+
lastStopCheckAt: Date.now(),
|
|
157
|
+
stderrBuf: '',
|
|
158
|
+
};
|
|
159
|
+
// stdin: 指示本文を書いて閉じる
|
|
160
|
+
if (proc.stdin) {
|
|
161
|
+
if (isResume) {
|
|
162
|
+
proc.stdin.write(RESUME_PROMPT);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
const anchor = buildAnchor(cmd.id);
|
|
166
|
+
const text = buildSendText(cmd.body, cfg.site, anchor, kindOf(cfg));
|
|
167
|
+
proc.stdin.write(text);
|
|
168
|
+
}
|
|
169
|
+
proc.stdin.end();
|
|
170
|
+
}
|
|
171
|
+
// stdout: stream-json を行ごとに読み、CommandEvent に変換してバッファへ
|
|
172
|
+
if (proc.stdout) {
|
|
173
|
+
const rl = createInterface({ input: proc.stdout });
|
|
174
|
+
rl.on('line', (line) => {
|
|
175
|
+
try {
|
|
176
|
+
const parsed = JSON.parse(line);
|
|
177
|
+
const event = streamEventToCommandEvent(parsed, attempt);
|
|
178
|
+
if (event)
|
|
179
|
+
job.eventBuffer.push(event);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// JSON でないか壊れている行 — 無視
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
// stderr: 蓄積(認証エラー検出用。上限5000字で先頭を切り落とす)
|
|
187
|
+
if (proc.stderr) {
|
|
188
|
+
proc.stderr.on('data', (chunk) => {
|
|
189
|
+
job.stderrBuf += chunk.toString('utf8');
|
|
190
|
+
if (job.stderrBuf.length > 5000) {
|
|
191
|
+
job.stderrBuf = job.stderrBuf.slice(-3000);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
// 終了検出
|
|
196
|
+
proc.on('exit', (code, signal) => {
|
|
197
|
+
job.completed = true;
|
|
198
|
+
job.exitCode = code;
|
|
199
|
+
job.exitSignal = signal?.toString() ?? null;
|
|
200
|
+
});
|
|
201
|
+
return job;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* 子プロセスを停止する。SIGTERM → 猶予 → SIGKILL。
|
|
205
|
+
* プロセスが既に終わっていれば何もしない。
|
|
206
|
+
*/
|
|
207
|
+
export async function stopJob(job) {
|
|
208
|
+
if (job.completed)
|
|
209
|
+
return;
|
|
210
|
+
const proc = job.process;
|
|
211
|
+
proc.kill('SIGTERM');
|
|
212
|
+
// 猶予の間に終了するのを待つ
|
|
213
|
+
const died = await Promise.race([
|
|
214
|
+
new Promise((resolve) => {
|
|
215
|
+
proc.once('exit', () => resolve(true));
|
|
216
|
+
}),
|
|
217
|
+
new Promise((resolve) => {
|
|
218
|
+
setTimeout(() => resolve(false), HEADLESS_KILL_GRACE_MS);
|
|
219
|
+
}),
|
|
220
|
+
]);
|
|
221
|
+
if (!died && !job.completed) {
|
|
222
|
+
proc.kill('SIGKILL');
|
|
223
|
+
// SIGKILL は必ず効くので短く待つ
|
|
224
|
+
await new Promise((resolve) => {
|
|
225
|
+
const t = setTimeout(resolve, 1000);
|
|
226
|
+
proc.once('exit', () => {
|
|
227
|
+
clearTimeout(t);
|
|
228
|
+
resolve();
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
234
|
+
// イベント送信
|
|
235
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
236
|
+
/**
|
|
237
|
+
* 管制の events API へイベントを送る。
|
|
238
|
+
* POST /api/conductor/commands/{commandId}/events
|
|
239
|
+
*/
|
|
240
|
+
export async function sendCommandEvents(cfg, commandId, events) {
|
|
241
|
+
if (events.length === 0)
|
|
242
|
+
return true;
|
|
243
|
+
try {
|
|
244
|
+
const res = await fetch(`${cfg.url}/api/conductor/commands/${commandId}/events`, {
|
|
245
|
+
method: 'POST',
|
|
246
|
+
headers: {
|
|
247
|
+
Authorization: `Bearer ${cfg.token}`,
|
|
248
|
+
'Content-Type': 'application/json',
|
|
249
|
+
},
|
|
250
|
+
body: JSON.stringify({ events }),
|
|
251
|
+
signal: AbortSignal.timeout(RELAY_FETCH_TIMEOUT_MS),
|
|
252
|
+
});
|
|
253
|
+
return res.ok;
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** バッファに溜まったイベントを管制へ送り、バッファを空にする。 */
|
|
260
|
+
export async function flushEvents(cfg, job) {
|
|
261
|
+
if (job.eventBuffer.length === 0)
|
|
262
|
+
return;
|
|
263
|
+
const events = job.eventBuffer.splice(0);
|
|
264
|
+
job.lastFlushAt = Date.now();
|
|
265
|
+
// 送信失敗は握り潰す(最善努力。進捗が欠けても作業は止まらない)。
|
|
266
|
+
await sendCommandEvents(cfg, job.commandId, events);
|
|
267
|
+
}
|
|
268
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
269
|
+
// 実行中ジョブの監視
|
|
270
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
271
|
+
/**
|
|
272
|
+
* 実行中のジョブを1tick分チェックする。
|
|
273
|
+
* 完了・停止・タイムアウト・生存通知・イベントフラッシュを処理する。
|
|
274
|
+
*/
|
|
275
|
+
async function checkRunningJob(cfg, state) {
|
|
276
|
+
const job = state.job;
|
|
277
|
+
if (job === null)
|
|
278
|
+
return;
|
|
279
|
+
const rs = state.relay;
|
|
280
|
+
// ── 完了したプロセスの後始末 ──
|
|
281
|
+
if (job.completed) {
|
|
282
|
+
await flushEvents(cfg, job);
|
|
283
|
+
if (job.stopped) {
|
|
284
|
+
// stop_requested で止めた
|
|
285
|
+
await sendCommandEvents(cfg, job.commandId, [{
|
|
286
|
+
type: 'stopped',
|
|
287
|
+
text: `stop_requested により停止 (exit=${job.exitCode}, signal=${job.exitSignal})`,
|
|
288
|
+
at: new Date().toISOString(),
|
|
289
|
+
attempt: job.attempt,
|
|
290
|
+
}]);
|
|
291
|
+
state.job = null;
|
|
292
|
+
rs.lastHeartbeatAt = null;
|
|
293
|
+
log(`site=${cfg.site} id=${job.commandId} stop_requested で停止`);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (isAuthError(job)) {
|
|
297
|
+
// 認証エラー → 再試行しない
|
|
298
|
+
await sendCommandEvents(cfg, job.commandId, [{
|
|
299
|
+
type: 'auth_error',
|
|
300
|
+
text: maskSecrets(`認証エラー (exit=${job.exitCode}): ${job.stderrBuf.slice(0, 300)}`).slice(0, EVENT_TEXT_MAX),
|
|
301
|
+
at: new Date().toISOString(),
|
|
302
|
+
attempt: job.attempt,
|
|
303
|
+
}]);
|
|
304
|
+
// ★patchStatus は 'failed' を受け付けないので、放棄だけして中央の stalled/timeout に委ねる。
|
|
305
|
+
rs.abandoned.add(job.commandId);
|
|
306
|
+
state.job = null;
|
|
307
|
+
rs.lastHeartbeatAt = null;
|
|
308
|
+
log(`WARN site=${cfg.site} id=${job.commandId} 認証エラー → 再試行しない`);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (job.exitCode === 0) {
|
|
312
|
+
// 正常終了 → 完了を中央へ報告
|
|
313
|
+
const ok = await completeWithReport(cfg, job.commandId, log);
|
|
314
|
+
if (ok) {
|
|
315
|
+
log(`site=${cfg.site} id=${job.commandId} 完了(exit=0, attempt ${job.attempt})`);
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
// 報告を書けなかった場合でも status=done だけは書く
|
|
319
|
+
await patchStatus(cfg, job.commandId, 'done');
|
|
320
|
+
log(`site=${cfg.site} id=${job.commandId} 完了(exit=0, 報告なし)`);
|
|
321
|
+
}
|
|
322
|
+
state.job = null;
|
|
323
|
+
rs.lastHeartbeatAt = null;
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// 異常終了 → 次の tick で自動再開を試みる
|
|
327
|
+
await sendCommandEvents(cfg, job.commandId, [{
|
|
328
|
+
type: 'error',
|
|
329
|
+
text: maskSecrets(`異常終了 (exit=${job.exitCode}, signal=${job.exitSignal}): ${job.stderrBuf.slice(0, 300)}`).slice(0, EVENT_TEXT_MAX),
|
|
330
|
+
at: new Date().toISOString(),
|
|
331
|
+
attempt: job.attempt,
|
|
332
|
+
}]);
|
|
333
|
+
const nextAttempt = (state.attempts.get(job.commandId) ?? job.attempt) + 1;
|
|
334
|
+
if (nextAttempt > HEADLESS_MAX_ATTEMPTS) {
|
|
335
|
+
log(`WARN site=${cfg.site} id=${job.commandId} 試行回数上限(${HEADLESS_MAX_ATTEMPTS}) → 放棄`);
|
|
336
|
+
rs.abandoned.add(job.commandId);
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
log(`WARN site=${cfg.site} id=${job.commandId} 異常終了(exit=${job.exitCode}) → ` +
|
|
340
|
+
`次の tick で再開を試みます (attempt ${nextAttempt}/${HEADLESS_MAX_ATTEMPTS})`);
|
|
341
|
+
}
|
|
342
|
+
state.job = null;
|
|
343
|
+
rs.lastHeartbeatAt = null;
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
// ── 実行中のプロセスの監視 ──
|
|
347
|
+
const now = Date.now();
|
|
348
|
+
// stop_requested の確認(間隔を置いて中央へ問い合わせる)
|
|
349
|
+
if (now - job.lastStopCheckAt >= HEADLESS_STOP_CHECK_MS) {
|
|
350
|
+
job.lastStopCheckAt = now;
|
|
351
|
+
const status = await fetchCommandStatus(cfg, job.commandId);
|
|
352
|
+
if (status === 'stop_requested' || isTerminalCommandStatus(status)) {
|
|
353
|
+
log(`site=${cfg.site} id=${job.commandId} ${status} → SIGTERM`);
|
|
354
|
+
job.stopped = true;
|
|
355
|
+
await stopJob(job);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// タイムアウト
|
|
360
|
+
if (now - job.startedAt > RELAY_PENDING_TIMEOUT_MS) {
|
|
361
|
+
log(`WARN site=${cfg.site} id=${job.commandId} タイムアウト(${Math.round((now - job.startedAt) / 1000)}s) → SIGTERM`);
|
|
362
|
+
job.stopped = true;
|
|
363
|
+
await stopJob(job);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
// 生存ハートビート
|
|
367
|
+
const throttleOk = rs.lastHeartbeatAt === null || now - rs.lastHeartbeatAt >= RELAY_HEARTBEAT_INTERVAL_MS;
|
|
368
|
+
if (throttleOk) {
|
|
369
|
+
rs.lastHeartbeatAt = now;
|
|
370
|
+
await sendHeartbeat(cfg, job.commandId, true);
|
|
371
|
+
}
|
|
372
|
+
// イベントバッファのフラッシュ
|
|
373
|
+
if (now - job.lastFlushAt >= HEADLESS_EVENT_FLUSH_MS && job.eventBuffer.length > 0) {
|
|
374
|
+
await flushEvents(cfg, job);
|
|
375
|
+
}
|
|
376
|
+
// 可観測性
|
|
377
|
+
noteReadiness(rs, 'busy', job.commandId);
|
|
378
|
+
}
|
|
379
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
380
|
+
// PC 再起動後の孤児復元
|
|
381
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
382
|
+
/**
|
|
383
|
+
* sent孤児を1件取得し、--resume で再開する(起動時1回)。
|
|
384
|
+
*
|
|
385
|
+
* PC 再起動後、「やっています(sent)」のまま残った便を中央から見つけ、
|
|
386
|
+
* 決定的なセッション ID(conductor-{commandId})で resume する。
|
|
387
|
+
* Claude Code はセッションを ~/.claude/ に保存するので、同じセッション ID で
|
|
388
|
+
* 再開すれば前回の途中から続けられる。
|
|
389
|
+
*
|
|
390
|
+
* ★tmux 方式の restoreOrphans と同じ呼び出し経路(fetchOrphans → selectOrphanToRestore)
|
|
391
|
+
* を使う。headless では復元 = 即座に子プロセスを起動する(tmux のように pending に積んで待つ
|
|
392
|
+
* のではなく、直接 resume する)。
|
|
393
|
+
*/
|
|
394
|
+
export async function restoreHeadlessOrphans(cfg, state) {
|
|
395
|
+
if (!RELAY_RESTORE_ORPHANS_ENABLED)
|
|
396
|
+
return;
|
|
397
|
+
if (state.job !== null)
|
|
398
|
+
return; // 既にジョブ実行中
|
|
399
|
+
const orphans = await fetchOrphans(cfg);
|
|
400
|
+
const pick = selectOrphanToRestore(orphans, null);
|
|
401
|
+
if (pick === null)
|
|
402
|
+
return;
|
|
403
|
+
const sessionId = sessionIdForCommand(pick.id);
|
|
404
|
+
const attempt = (state.attempts.get(pick.id) ?? 0) + 1;
|
|
405
|
+
state.attempts.set(pick.id, attempt);
|
|
406
|
+
if (attempt > HEADLESS_MAX_ATTEMPTS) {
|
|
407
|
+
state.relay.abandoned.add(pick.id);
|
|
408
|
+
log(`WARN site=${cfg.site} id=${pick.id} 孤児復元: 試行回数上限 → 放棄`);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
// ★孤児は resume で再開する(元の指示本文ではなく続行プロンプトを渡す)。
|
|
412
|
+
const cmd = { id: pick.id, body: pick.body };
|
|
413
|
+
const job = spawnClaudeProcess(cfg, cmd, sessionId, attempt, true);
|
|
414
|
+
state.job = job;
|
|
415
|
+
markDelivered(state.relay, pick.id);
|
|
416
|
+
state.relay.lastHeartbeatAt = null;
|
|
417
|
+
noteReadiness(state.relay, 'delivered', pick.id);
|
|
418
|
+
await sendCommandEvents(cfg, pick.id, [{
|
|
419
|
+
type: 'resumed',
|
|
420
|
+
text: `孤児復元: attempt ${attempt}/${HEADLESS_MAX_ATTEMPTS} (PC再起動後の自動再開)`,
|
|
421
|
+
at: new Date().toISOString(),
|
|
422
|
+
attempt,
|
|
423
|
+
}]);
|
|
424
|
+
log(`site=${cfg.site} id=${pick.id} ` +
|
|
425
|
+
`孤児を復元して再開しました (session=${sessionId}, attempt ${attempt}/${HEADLESS_MAX_ATTEMPTS})`);
|
|
426
|
+
}
|
|
427
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
428
|
+
// headless tick(relayTick の headless 版)
|
|
429
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
430
|
+
/**
|
|
431
|
+
* headless 方式の1tick。relayTick の代わりに createRelayRunner へ渡す。
|
|
432
|
+
*
|
|
433
|
+
* 【共通の管制通信】fetchControl, fetchCommand, patchStatus は relay.ts のものをそのまま使う。
|
|
434
|
+
* 【違い】配達は tmux ではなく子プロセスの起動。完了検出はプロセスの終了。
|
|
435
|
+
*/
|
|
436
|
+
export async function headlessTick(cfg, state, hooks) {
|
|
437
|
+
const rs = state.relay;
|
|
438
|
+
if (rs.running)
|
|
439
|
+
return;
|
|
440
|
+
rs.running = true;
|
|
441
|
+
rs.promptPending = false;
|
|
442
|
+
try {
|
|
443
|
+
// ── 起動時1回の孤児復元(PC再起動後の拾い直し) ──
|
|
444
|
+
// cli.ts を触らず「起動時1回」を実現するため、最初の tick でだけ復元する。
|
|
445
|
+
// 失敗しても再試行しない(フラグを先に立てる=起動を妨げない/多重実行防止)。
|
|
446
|
+
if (!state.restored) {
|
|
447
|
+
state.restored = true;
|
|
448
|
+
await restoreHeadlessOrphans(cfg, state);
|
|
449
|
+
}
|
|
450
|
+
// ── 実行中のジョブの監視 ──
|
|
451
|
+
if (state.job !== null) {
|
|
452
|
+
await checkRunningJob(cfg, state);
|
|
453
|
+
return; // ジョブ実行中は新規取得しない
|
|
454
|
+
}
|
|
455
|
+
// ── 管制との線を確かめる ──
|
|
456
|
+
const ctl = await fetchControl(cfg);
|
|
457
|
+
if (!ctl.ok) {
|
|
458
|
+
log(noteCentralDown(rs, ctl.detail, Date.now()));
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const reconnected = noteCentralUp(rs, Date.now());
|
|
462
|
+
if (reconnected !== null) {
|
|
463
|
+
log(reconnected);
|
|
464
|
+
rs.lastHeartbeatAt = null;
|
|
465
|
+
hooks?.onCentralReconnect?.();
|
|
466
|
+
}
|
|
467
|
+
// ── 停止フラグ確認 ──
|
|
468
|
+
const haltedBySite = ctl.relayEnabled === false;
|
|
469
|
+
if (ctl.relayHalted || haltedBySite) {
|
|
470
|
+
if (rs.lastHalted !== true) {
|
|
471
|
+
log(haltedBySite
|
|
472
|
+
? `この現場(${cfg.site})の relay送信トグルがOFF(停止)のため送信しません`
|
|
473
|
+
: 'relay停止中(緊急停止スイッチON)のため送信しません');
|
|
474
|
+
}
|
|
475
|
+
rs.lastHalted = true;
|
|
476
|
+
noteReadiness(rs, 'halted', null);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (rs.lastHalted === true) {
|
|
480
|
+
log('停止が解除されました。relayを再開します');
|
|
481
|
+
}
|
|
482
|
+
rs.lastHalted = false;
|
|
483
|
+
// ── 新規指示の取得 ──
|
|
484
|
+
const cmd = await fetchCommand(cfg);
|
|
485
|
+
if (!cmd) {
|
|
486
|
+
noteReadiness(rs, 'no-command', null);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
// ── 放棄ガード ──
|
|
490
|
+
if (rs.abandoned.has(cmd.id))
|
|
491
|
+
return;
|
|
492
|
+
// ── 配達回数上限 ──
|
|
493
|
+
if (!canDeliver(rs, cmd.id)) {
|
|
494
|
+
rs.abandoned.add(cmd.id);
|
|
495
|
+
log(`WARN site=${cfg.site} id=${cmd.id} 配達回数上限(${RELAY_DELIVER_MAX}) → 以後配達しない`);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
// ── 試行回数 ──
|
|
499
|
+
const attempt = (state.attempts.get(cmd.id) ?? 0) + 1;
|
|
500
|
+
state.attempts.set(cmd.id, attempt);
|
|
501
|
+
if (attempt > HEADLESS_MAX_ATTEMPTS) {
|
|
502
|
+
rs.abandoned.add(cmd.id);
|
|
503
|
+
log(`WARN site=${cfg.site} id=${cmd.id} 試行回数上限(${HEADLESS_MAX_ATTEMPTS}) → 放棄`);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
// ── claude -p を起動 ──
|
|
507
|
+
const sessionId = sessionIdForCommand(cmd.id);
|
|
508
|
+
const isResume = attempt > 1;
|
|
509
|
+
const job = spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume);
|
|
510
|
+
state.job = job;
|
|
511
|
+
markDelivered(rs, cmd.id);
|
|
512
|
+
rs.lastHeartbeatAt = null;
|
|
513
|
+
noteReadiness(rs, 'delivered', cmd.id);
|
|
514
|
+
// ★管制へ配達の印を書く
|
|
515
|
+
await patchStatus(cfg, cmd.id, 'sent');
|
|
516
|
+
if (isResume) {
|
|
517
|
+
await sendCommandEvents(cfg, cmd.id, [{
|
|
518
|
+
type: 'resumed',
|
|
519
|
+
text: `attempt ${attempt}/${HEADLESS_MAX_ATTEMPTS} (自動再開)`,
|
|
520
|
+
at: new Date().toISOString(),
|
|
521
|
+
attempt,
|
|
522
|
+
}]);
|
|
523
|
+
}
|
|
524
|
+
log(`site=${cfg.site} id=${cmd.id} ` +
|
|
525
|
+
`${isResume ? '再開' : '起動'}しました (session=${sessionId}, attempt ${attempt}/${HEADLESS_MAX_ATTEMPTS})`);
|
|
526
|
+
}
|
|
527
|
+
finally {
|
|
528
|
+
rs.running = false;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
532
|
+
// 状態の生成とアダプタ
|
|
533
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
534
|
+
/** headless 用の初期状態を作る。内包する RelayState は createRelayState() で作る。 */
|
|
535
|
+
export function createHeadlessState() {
|
|
536
|
+
return {
|
|
537
|
+
relay: createRelayState(),
|
|
538
|
+
job: null,
|
|
539
|
+
attempts: new Map(),
|
|
540
|
+
restored: false,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* headless 方式の配達状況を生存報告用に返す(cli.ts の sendReport が使う)。
|
|
545
|
+
* RelayState の deliveryReport をそのまま返す。
|
|
546
|
+
*/
|
|
547
|
+
export function headlessDeliveryReport(state) {
|
|
548
|
+
return deliveryReport(state.relay);
|
|
549
|
+
}
|
|
550
|
+
/** headless 方式の次の tick までの待ち(ms)。cli.ts の適応スケジューラが使う。 */
|
|
551
|
+
export function headlessNextDelayMs(state, intervalSec) {
|
|
552
|
+
const rs = state.relay;
|
|
553
|
+
// 管制へつながらない間は再試行の間隔(指数バックオフ)
|
|
554
|
+
if (rs.central.failures > 0) {
|
|
555
|
+
return reconnectDelayMs(rs.central.failures);
|
|
556
|
+
}
|
|
557
|
+
// ジョブ実行中はイベントフラッシュ間隔で早く回す
|
|
558
|
+
if (state.job !== null) {
|
|
559
|
+
return Math.min(intervalSec * 1000, HEADLESS_EVENT_FLUSH_MS);
|
|
560
|
+
}
|
|
561
|
+
// 通常間隔
|
|
562
|
+
return intervalSec * 1000;
|
|
563
|
+
}
|
|
564
|
+
/** 環境変数から runner mode を読む。未設定・不明な値は 'tmux'(既定・後退させない)。 */
|
|
565
|
+
export function parseRunnerMode(raw) {
|
|
566
|
+
const v = (raw ?? '').trim().toLowerCase();
|
|
567
|
+
if (v === 'headless')
|
|
568
|
+
return 'headless';
|
|
569
|
+
return 'tmux';
|
|
570
|
+
}
|
package/dist/relay.js
CHANGED
|
@@ -1697,12 +1697,8 @@ function runTmux(args) {
|
|
|
1697
1697
|
* ★tmux の言い分(stderr)をログに出す。以前は `failed` としか出ず原因が追えなかった。
|
|
1698
1698
|
*/
|
|
1699
1699
|
export function sendToTmux(site, text) {
|
|
1700
|
-
const bufferName = `${RELAY_SEND_BUFFER_PREFIX}-${site}-${process.pid}-${Date.now()}`;
|
|
1701
|
-
let file = null;
|
|
1702
1700
|
try {
|
|
1703
|
-
|
|
1704
|
-
runTmux(['load-buffer', '-b', bufferName, file]);
|
|
1705
|
-
runTmux(['paste-buffer', '-d', '-p', '-b', bufferName, '-t', site]);
|
|
1701
|
+
pasteToTmux(site, text);
|
|
1706
1702
|
// 貼り付けの枠の外に、打った字として添え書きを置く(RELAY_DELIVERY_NOTE の説明)。
|
|
1707
1703
|
runTmux(['send-keys', '-t', site, '-l', ` ${RELAY_DELIVERY_NOTE}`]);
|
|
1708
1704
|
// ★Enter の前に一拍おく(RELAY_SUBMIT_PAUSE_MS の説明・便 b69c1608)。
|
|
@@ -1712,13 +1708,30 @@ export function sendToTmux(site, text) {
|
|
|
1712
1708
|
}
|
|
1713
1709
|
catch (err) {
|
|
1714
1710
|
log(`WARN site=${site} 本文の投入に失敗(${text.length}文字): ${describeExecError(err)}`);
|
|
1711
|
+
return false;
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
/**
|
|
1715
|
+
* 本文を一時ファイル → tmux のバッファ → 職人の窓へ貼る(貼るだけ。添え書きも Enter も送らない)。
|
|
1716
|
+
* 失敗したら例外がそのまま飛ぶ。一時ファイルは必ず消し、貼る前に転んだバッファも消す
|
|
1717
|
+
* (sendToTmux の【後始末】の説明)。
|
|
1718
|
+
*/
|
|
1719
|
+
function pasteToTmux(site, text) {
|
|
1720
|
+
const bufferName = `${RELAY_SEND_BUFFER_PREFIX}-${site}-${process.pid}-${Date.now()}`;
|
|
1721
|
+
let file = null;
|
|
1722
|
+
try {
|
|
1723
|
+
file = writeSendTmpFile(text);
|
|
1724
|
+
runTmux(['load-buffer', '-b', bufferName, file]);
|
|
1725
|
+
runTmux(['paste-buffer', '-d', '-p', '-b', bufferName, '-t', site]);
|
|
1726
|
+
}
|
|
1727
|
+
catch (err) {
|
|
1715
1728
|
try {
|
|
1716
1729
|
execFileSync('tmux', ['delete-buffer', '-b', bufferName], { stdio: 'ignore' });
|
|
1717
1730
|
}
|
|
1718
1731
|
catch {
|
|
1719
1732
|
// 貼れていれば -d で既に消えている。無いバッファを消そうとしたエラーは無視してよい。
|
|
1720
1733
|
}
|
|
1721
|
-
|
|
1734
|
+
throw err;
|
|
1722
1735
|
}
|
|
1723
1736
|
finally {
|
|
1724
1737
|
if (file !== null) {
|
|
@@ -1732,6 +1745,165 @@ export function sendToTmux(site, text) {
|
|
|
1732
1745
|
}
|
|
1733
1746
|
}
|
|
1734
1747
|
// ───────────────────────────────────────────────────────────────────────────
|
|
1748
|
+
// 貼り付けが入力欄に入ったのを確かめてから送る(便 963ddca3・★添え書きだけが届く事故の根治)
|
|
1749
|
+
//
|
|
1750
|
+
// 【何が起きたか(実障害 2026-09-23 minoru 便 af960f79/2026-09-24 ai-kana 便 0b637256)】
|
|
1751
|
+
// 職人の会話の記録(実物)を読むと、どちらも利用者の発言として記録されたのは
|
|
1752
|
+
// 「 (配達役より)上の貼り付けは…」の75字だけで、貼り付け(本文と便印)は入力欄に一度も入って
|
|
1753
|
+
// いなかった。★配達役は貼り付けが入ったかを確かめず、続けて添え書きを打って Enter を押していた。
|
|
1754
|
+
// だから職人が貼り付けを受け取れなかった瞬間に、添え書きだけが単独で送信された。
|
|
1755
|
+
// 2件とも職人の tmux ができてから 11〜30 秒の、職人を起動した直後の最初の便だった
|
|
1756
|
+
// (同じ期間の会話を新しくした後の配達 12 件はすべて届いている)。
|
|
1757
|
+
// 【なぜ貼り付けが消えるか(試験用の tmux で本物の Claude Code 2.1.281 に当てて再現)】
|
|
1758
|
+
// 貼り付けと添え書きが【1回の読み込みでまとめて】職人に届くと、Claude Code は貼り付けを捨て、
|
|
1759
|
+
// 添え書きだけを入力欄に残す(入力欄は「 (配達役より)…」=実障害の記録と1字も違わない)。
|
|
1760
|
+
// 配達役は貼り付けの直後に添え書きを打つので、職人が起動直後で読み込みが遅れていると、両方が
|
|
1761
|
+
// たまって一度に読まれる(★ここは推定:実障害2件はどちらも起動直後の最初の便で、/clear から記録
|
|
1762
|
+
// までが 8.8・9.2 秒と、届いた便のほとんど(6.1〜7.9 秒)より遅かった。職人の読み込みの遅れそのものは
|
|
1763
|
+
// 記録に残っていない)。
|
|
1764
|
+
// そこへ1秒後の Enter が別に届き、添え書きだけが送信された。
|
|
1765
|
+
// 【根治】「貼る → 入力欄に入ったのを画面で確かめる → 添え書きを打つ → 入力欄に貼り付けと添え書きが
|
|
1766
|
+
// そろっているのを確かめる → Enter」。★そろっていなければ Enter を押さない。
|
|
1767
|
+
// =貼り付けが入らないかぎり、何も送信されない(添え書きだけが先に・単独で届く形が無い)。
|
|
1768
|
+
// 入らなければ、画面が入力待ちに戻るのを確かめてから貼り直す(上限あり)。時間で決め打ちしない。
|
|
1769
|
+
// ★入力欄に人が打ちかけた字があれば貼らない(人の入力に混ぜない)。
|
|
1770
|
+
// ★消すのは、配達役が打った添え書きの字だけ(貼り付けが入らず、添え書きだけが入力欄に残ったとき)。
|
|
1771
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1772
|
+
/** 貼ってから入力欄に入るのを待つ長さ(1回ぶん・ミリ秒)。★待ちの上限であって、決め打ちの間ではない。 */
|
|
1773
|
+
export const RELAY_PASTE_LAND_MS = 5_000;
|
|
1774
|
+
/** 1回の配達の中で貼る回数の上限(入力欄に入らなかったときの貼り直しを含む)。 */
|
|
1775
|
+
export const RELAY_PASTE_MAX_TRIES = 3;
|
|
1776
|
+
/**
|
|
1777
|
+
* 画面のいちばん下の入力欄(最後の ❯ の行から次の罫線まで)の文字。入力欄が無ければ null。
|
|
1778
|
+
* ★折り返しは元に戻さない(呼び出し側で joinWrapped する)。
|
|
1779
|
+
*/
|
|
1780
|
+
export function inputBlock(cap) {
|
|
1781
|
+
const lines = cap.split('\n');
|
|
1782
|
+
let start = -1;
|
|
1783
|
+
for (let k = lines.length - 1; k >= 0; k--) {
|
|
1784
|
+
if (/^\s*❯/.test(lines[k])) {
|
|
1785
|
+
start = k;
|
|
1786
|
+
break;
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
if (start < 0)
|
|
1790
|
+
return null;
|
|
1791
|
+
const block = [];
|
|
1792
|
+
for (let k = start; k < lines.length; k++) {
|
|
1793
|
+
if (k > start && /^─{10,}/.test(lines[k].trim()))
|
|
1794
|
+
break;
|
|
1795
|
+
block.push(lines[k]);
|
|
1796
|
+
}
|
|
1797
|
+
return block.join('\n');
|
|
1798
|
+
}
|
|
1799
|
+
/** 画面の折り返しを戻す(改行と、続きの行の頭の空白を取る)。 */
|
|
1800
|
+
function joinWrapped(s) {
|
|
1801
|
+
return s.replace(/\n[ \t]*/g, '');
|
|
1802
|
+
}
|
|
1803
|
+
/**
|
|
1804
|
+
* 入力欄に、この便の貼り付けが入っているか。
|
|
1805
|
+
* ・長い貼り付けは Claude Code が `[Pasted text #1 +N lines]` と畳んで見せる(実測)。
|
|
1806
|
+
* ・短い貼り付けはそのまま見える=本文の末尾の便印(CONDUCTOR_JOB:<id>)が完全一致で在るか。
|
|
1807
|
+
*/
|
|
1808
|
+
export function hasPasteInInput(cap, commandId) {
|
|
1809
|
+
const block = inputBlock(cap);
|
|
1810
|
+
if (block === null)
|
|
1811
|
+
return false;
|
|
1812
|
+
if (/\[Pasted text #\d+/.test(block))
|
|
1813
|
+
return true;
|
|
1814
|
+
return hasExactJobStamp(joinWrapped(block), commandId);
|
|
1815
|
+
}
|
|
1816
|
+
/** 入力欄に、配達役の添え書きが(全部)入っているか。 */
|
|
1817
|
+
function hasNoteInInput(cap) {
|
|
1818
|
+
const block = inputBlock(cap);
|
|
1819
|
+
return block !== null && block.replace(/\s/g, '').includes(RELAY_DELIVERY_NOTE.replace(/\s/g, ''));
|
|
1820
|
+
}
|
|
1821
|
+
/**
|
|
1822
|
+
* 入力欄が「配達役が貼ってよい」状態か:入力欄が在り、貼り付け・添え書き・便印のどれも無い。
|
|
1823
|
+
* ★人が打ちかけた字は画面の上では Claude Code の薄い字の例(Try "…")と見分けられないので、
|
|
1824
|
+
* ここでは「配達役の物が残っていない」ことだけを見る(人の打ちかけは受付の確かめが別に守る)。
|
|
1825
|
+
*/
|
|
1826
|
+
function inputIsClear(cap, commandId) {
|
|
1827
|
+
const block = inputBlock(cap);
|
|
1828
|
+
if (block === null)
|
|
1829
|
+
return false;
|
|
1830
|
+
return !hasPasteInInput(cap, commandId) && !block.includes('(配達役より)');
|
|
1831
|
+
}
|
|
1832
|
+
/**
|
|
1833
|
+
* 貼り付けが入力欄に入ったのを確かめてから、添え書きを打って送る(Claude の職人向け)。
|
|
1834
|
+
* ★Enter を押すのは「入力欄に貼り付けと添え書きの両方がある」ときだけ。
|
|
1835
|
+
*/
|
|
1836
|
+
export async function deliverToTmux(site, text, commandId, d = {
|
|
1837
|
+
capture: () => tmuxCapture(site),
|
|
1838
|
+
paste: (t) => pasteToTmux(site, t),
|
|
1839
|
+
typeNote: () => runTmux(['send-keys', '-t', site, '-l', ` ${RELAY_DELIVERY_NOTE}`]),
|
|
1840
|
+
// 打った字の数だけ BSpace(先頭の空白1字を含む)。★配達役が打った字しか消さない。
|
|
1841
|
+
eraseNote: () => runTmux(['send-keys', '-t', site, '-N', String([...` ${RELAY_DELIVERY_NOTE}`].length), 'BSpace']),
|
|
1842
|
+
enter: () => runTmux(['send-keys', '-t', site, 'Enter']),
|
|
1843
|
+
pause: pauseSync,
|
|
1844
|
+
sleep,
|
|
1845
|
+
ready: () => isIdleStable(site, 'claude'),
|
|
1846
|
+
}, o = {
|
|
1847
|
+
landMs: RELAY_PASTE_LAND_MS,
|
|
1848
|
+
pollMs: 250,
|
|
1849
|
+
maxTries: RELAY_PASTE_MAX_TRIES,
|
|
1850
|
+
}) {
|
|
1851
|
+
let pastes = 0;
|
|
1852
|
+
const landed = async () => {
|
|
1853
|
+
for (let t = 0;; t += o.pollMs) {
|
|
1854
|
+
const cap = d.capture();
|
|
1855
|
+
if (cap !== null && hasPasteInInput(cap, commandId))
|
|
1856
|
+
return true;
|
|
1857
|
+
if (t >= o.landMs)
|
|
1858
|
+
return false;
|
|
1859
|
+
await d.sleep(o.pollMs);
|
|
1860
|
+
}
|
|
1861
|
+
};
|
|
1862
|
+
try {
|
|
1863
|
+
for (let round = 0; round < o.maxTries; round++) {
|
|
1864
|
+
// ★入力を受け取れる状態(入力待ちで落ち着いた画面)を確かめてから触る。
|
|
1865
|
+
if (!(await d.ready())) {
|
|
1866
|
+
await d.sleep(o.pollMs);
|
|
1867
|
+
continue;
|
|
1868
|
+
}
|
|
1869
|
+
const cap = d.capture();
|
|
1870
|
+
if (cap === null)
|
|
1871
|
+
return { result: 'failed', pastes };
|
|
1872
|
+
// ★まだ1度も貼っていないのに入力欄に貼り付けがあるなら、それが配達役の物だと言えるのは
|
|
1873
|
+
// この便の印(完全一致)が見えるときだけ。畳まれた貼り付けは誰の物か分からないので触らない。
|
|
1874
|
+
if (pastes === 0 && hasPasteInInput(cap, commandId) && !hasExactJobStamp(joinWrapped(inputBlock(cap) ?? ''), commandId)) {
|
|
1875
|
+
return { result: 'not-clear', pastes };
|
|
1876
|
+
}
|
|
1877
|
+
if (!hasPasteInInput(cap, commandId)) {
|
|
1878
|
+
// 前の回の貼り付けも入っていない。配達役の物ではない残りがあれば貼らない。
|
|
1879
|
+
if (!inputIsClear(cap, commandId))
|
|
1880
|
+
return { result: 'not-clear', pastes };
|
|
1881
|
+
d.paste(text);
|
|
1882
|
+
pastes++;
|
|
1883
|
+
if (!(await landed()))
|
|
1884
|
+
continue; // ★入らなかった=何も打たずに次の回へ
|
|
1885
|
+
}
|
|
1886
|
+
if (!hasNoteInInput(d.capture() ?? '')) {
|
|
1887
|
+
d.typeNote();
|
|
1888
|
+
d.pause(RELAY_SUBMIT_PAUSE_MS);
|
|
1889
|
+
}
|
|
1890
|
+
const before = d.capture();
|
|
1891
|
+
if (before !== null && hasPasteInInput(before, commandId) && hasNoteInInput(before)) {
|
|
1892
|
+
d.enter();
|
|
1893
|
+
return { result: 'submitted', pastes };
|
|
1894
|
+
}
|
|
1895
|
+
// ★そろっていない=Enter を押さない。添え書きだけが残っていれば、打った字だけ消す。
|
|
1896
|
+
if (before !== null && !hasPasteInInput(before, commandId) && hasNoteInInput(before))
|
|
1897
|
+
d.eraseNote();
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
catch (err) {
|
|
1901
|
+
log(`WARN site=${site} 本文の投入に失敗(${text.length}文字): ${describeExecError(err)}`);
|
|
1902
|
+
return { result: 'failed', pastes };
|
|
1903
|
+
}
|
|
1904
|
+
return { result: 'not-landed', pastes };
|
|
1905
|
+
}
|
|
1906
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1735
1907
|
// 受付の確かめ(便 b69c1608)
|
|
1736
1908
|
//
|
|
1737
1909
|
// 【なぜ要るか】送信の Enter が職人に届いても、Claude Code が送信として受け付けたとは限らない
|
|
@@ -1938,7 +2110,7 @@ export async function awaitDeliveryAck(d, o = {
|
|
|
1938
2110
|
reason = '職人がアイドルでないので貼り直さなかった';
|
|
1939
2111
|
continue; // この回は貼り直さずに、もう一度だけ印を待つ(回数は数える=待ちは有限)
|
|
1940
2112
|
}
|
|
1941
|
-
if (d.repaste()) {
|
|
2113
|
+
if (await d.repaste()) {
|
|
1942
2114
|
repasted++;
|
|
1943
2115
|
reason = '';
|
|
1944
2116
|
}
|
|
@@ -1956,23 +2128,8 @@ export async function awaitDeliveryAck(d, o = {
|
|
|
1956
2128
|
* ★押し直してよいのは「こちらが入れた便が入力欄に残っている」ときだけ、を確かめるために使う。
|
|
1957
2129
|
*/
|
|
1958
2130
|
export function hasPendingDeliveryInInput(cap) {
|
|
1959
|
-
const
|
|
1960
|
-
|
|
1961
|
-
for (let k = lines.length - 1; k >= 0; k--) {
|
|
1962
|
-
if (/^\s*❯/.test(lines[k])) {
|
|
1963
|
-
start = k;
|
|
1964
|
-
break;
|
|
1965
|
-
}
|
|
1966
|
-
}
|
|
1967
|
-
if (start < 0)
|
|
1968
|
-
return false;
|
|
1969
|
-
const block = [];
|
|
1970
|
-
for (let k = start; k < lines.length; k++) {
|
|
1971
|
-
if (k > start && /^─{10,}/.test(lines[k].trim()))
|
|
1972
|
-
break;
|
|
1973
|
-
block.push(lines[k]);
|
|
1974
|
-
}
|
|
1975
|
-
return block.join('\n').includes('(配達役より)');
|
|
2131
|
+
const block = inputBlock(cap);
|
|
2132
|
+
return block !== null && block.includes('(配達役より)');
|
|
1976
2133
|
}
|
|
1977
2134
|
// ───────────────────────────────────────────────────────────────────────────
|
|
1978
2135
|
// 1便1context:配る直前に職人の会話を新しくする(便 87a1a28a・★既定オフ)
|
|
@@ -1989,13 +2146,10 @@ export function hasPendingDeliveryInInput(cap) {
|
|
|
1989
2146
|
// ───────────────────────────────────────────────────────────────────────────
|
|
1990
2147
|
/** 会話を新しくできたかを待つ長さ(ミリ秒)。 */
|
|
1991
2148
|
export const RELAY_FRESH_CONFIRM_MS = 10_000;
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
* 届いていない」と答えて止まった)。間を置いたうえで、画面が入力待ちで落ち着いたことも確かめる。
|
|
1997
|
-
*/
|
|
1998
|
-
export const RELAY_FRESH_SETTLE_MS = 3_000;
|
|
2149
|
+
// ★会話が新しくなった後に「決め打ちの間」は置かない(便 963ddca3)。以前は 3 秒待っていたが、
|
|
2150
|
+
// 職人を起動した直後の最初の便では 3 秒待っても貼り付けが落ちた(実障害2件)。入力を受け取れる
|
|
2151
|
+
// 状態かは時間では分からないので、①画面が入力待ちで落ち着くのを待ち、②貼った後に入力欄へ入った
|
|
2152
|
+
// のを画面で確かめる(deliverToTmux)。入らなければ何も送らずに貼り直す。
|
|
1999
2153
|
/** その作業場所の会話の記録のファイル名の一覧。読めなければ空。 */
|
|
2000
2154
|
export function listTranscripts(cwd, env = process.env) {
|
|
2001
2155
|
try {
|
|
@@ -2025,9 +2179,14 @@ export async function freshenContext(site, cwd, d = {
|
|
|
2025
2179
|
}
|
|
2026
2180
|
for (let t = 0;; t += o.pollMs) {
|
|
2027
2181
|
if (d.listSessions().some((n) => !before.has(n))) {
|
|
2028
|
-
//
|
|
2029
|
-
|
|
2030
|
-
|
|
2182
|
+
// ★新しい会話ができたら、画面が入力待ちで落ち着くのを待つ(上限は waitMs・決め打ちの間は置かない)。
|
|
2183
|
+
for (let u = 0;; u += o.pollMs) {
|
|
2184
|
+
if (await d.ready())
|
|
2185
|
+
return true;
|
|
2186
|
+
if (u >= o.waitMs)
|
|
2187
|
+
return false;
|
|
2188
|
+
await d.sleep(o.pollMs);
|
|
2189
|
+
}
|
|
2031
2190
|
}
|
|
2032
2191
|
if (t >= o.waitMs)
|
|
2033
2192
|
return false;
|
|
@@ -3118,7 +3277,25 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
3118
3277
|
const sendStartedAt = Date.now();
|
|
3119
3278
|
// ★Claude の職人へは受領確認を使う(便 7d810bbb)。Codex は会話の記録の形が違うので今までどおり。
|
|
3120
3279
|
const useAck = kindOf(cfg) === 'claude';
|
|
3121
|
-
|
|
3280
|
+
// ★Claude の職人へは「貼り付けが入力欄に入ったのを確かめてから添え書きと Enter」(便 963ddca3)。
|
|
3281
|
+
// 入らなければ何も送信しない=添え書きだけが届く形が無い。
|
|
3282
|
+
let landed = true;
|
|
3283
|
+
if (useAck) {
|
|
3284
|
+
const d = await deliverToTmux(cfg.site, text, cmd.id);
|
|
3285
|
+
if (d.result === 'failed' || d.result === 'not-clear') {
|
|
3286
|
+
// 職人には何も送っていない。status は queued のまま据え置く(今までの送信失敗と同じ)。
|
|
3287
|
+
log(d.result === 'failed'
|
|
3288
|
+
? `本文の投入に失敗, keep queued: ${cfg.site}/${cmd.id}`
|
|
3289
|
+
: `WARN site=${cfg.site} id=${cmd.id} 入力欄に配達役の物ではない字が残っているので貼らずに次の周で確かめ直します`);
|
|
3290
|
+
return;
|
|
3291
|
+
}
|
|
3292
|
+
landed = d.result === 'submitted';
|
|
3293
|
+
if (!landed) {
|
|
3294
|
+
log(`WARN site=${cfg.site} id=${cmd.id} 貼り付けが入力欄に入らない(${d.pastes}回貼った)。` +
|
|
3295
|
+
'★何も送信していません→本文未着として管制へ知らせます');
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3298
|
+
else if (!sendToTmux(cfg.site, text)) {
|
|
3122
3299
|
// 送信失敗(sendToTmux は内部で例外も握って false を返す)。status は queued のまま据え置く。
|
|
3123
3300
|
// 失敗の理由(tmux の stderr)は sendToTmux が直前の行に出している。
|
|
3124
3301
|
log(`本文の投入に失敗, keep queued: ${cfg.site}/${cmd.id}`);
|
|
@@ -3148,13 +3325,21 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
3148
3325
|
// 新しい指示の生存ハートビートは先頭から数え直す(前指示のスロットルを引き継がない)。
|
|
3149
3326
|
state.lastHeartbeatAt = null;
|
|
3150
3327
|
noteReadiness(state, 'delivered', cmd.id);
|
|
3151
|
-
log(`site=${cfg.site} id=${cmd.id}
|
|
3328
|
+
log(`site=${cfg.site} id=${cmd.id} ${landed ? '配達しました' : '配達を試みましたが貼り付けが入らず、何も送信していません'}`);
|
|
3152
3329
|
// ★配った時点で管制へ印を書く(便 7d810bbb)。Claude の職人は「配達中(受領確認待ち)」、
|
|
3153
3330
|
// それ以外は今までどおり「配達済み(sent)」。書けなければ次tick以降で書けるまでやり直す
|
|
3154
3331
|
// (markSentAtCentral)。★書けなくても再配達はしない。
|
|
3155
3332
|
await markSentAtCentral(cfg, state);
|
|
3156
3333
|
if (!useAck || state.pending === null)
|
|
3157
3334
|
return;
|
|
3335
|
+
if (!landed) {
|
|
3336
|
+
// ★貼り付けが入らなかった=職人は何も受け取っていない。本文未着として知らせ、追跡を外す
|
|
3337
|
+
// (7d810bbb の道)。★配り直さない・ほかの便も配らない(配り直すのは人の手番)。
|
|
3338
|
+
state.pending.ack = 'missing';
|
|
3339
|
+
state.pending.ackSent = false;
|
|
3340
|
+
await sendAckAtCentral(cfg, state);
|
|
3341
|
+
return;
|
|
3342
|
+
}
|
|
3158
3343
|
// ── 受領確認(★えふさん確定:送った instruction_id と完全一致した印を確かめて初めて ACK) ──
|
|
3159
3344
|
// 印を待ち、入力欄に残っていれば Enter だけ押し直し(b69c1608)、現れなければ2回まで貼り直す。
|
|
3160
3345
|
// ★一致の確かめに使うのは、この便を貼った後(sendStartedAt 以降)に新しく出た記録だけ。
|
|
@@ -3177,8 +3362,9 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
3177
3362
|
}
|
|
3178
3363
|
},
|
|
3179
3364
|
idle: () => isIdleStable(cfg.site, kindOf(cfg)),
|
|
3180
|
-
//
|
|
3181
|
-
|
|
3365
|
+
// ★同じ本文を貼り直す(配達回数は数えない=1回の配達の中の貼り直し)。★貼り直しも、入力欄に
|
|
3366
|
+
// 入ったのを確かめてからでないと添え書きも Enter も送らない(便 963ddca3)。
|
|
3367
|
+
repaste: async () => (await deliverToTmux(cfg.site, text, cmd.id)).result === 'submitted',
|
|
3182
3368
|
sleep,
|
|
3183
3369
|
});
|
|
3184
3370
|
log(`site=${cfg.site} id=${cmd.id} 受領の確かめ: ${r.received ? '印が一致(受領)' : `印が現れない(本文未着): ${r.reason}`}` +
|