@tyhld/conductor 0.12.61 → 0.12.63
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 +222 -21
- package/package.json +1 -1
package/dist/headless.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { spawn } from 'node:child_process';
|
|
18
18
|
import { createInterface } from 'node:readline';
|
|
19
|
-
import { createWriteStream, existsSync, mkdirSync } from 'node:fs';
|
|
19
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
import os from 'node:os';
|
|
22
22
|
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';
|
|
@@ -42,6 +42,97 @@ const RESUME_PROMPT = '前回の作業の続きを行ってください。中断
|
|
|
42
42
|
export const RUNS_DIR = path.join(os.homedir(), '.local', 'state', 'conductor', 'runs');
|
|
43
43
|
/** セッションありと見なす最低実行時間(ms)。これより短いとセッションファイルが無い可能性が高い。 */
|
|
44
44
|
export const SESSION_MIN_DURATION_MS = 30_000;
|
|
45
|
+
/** 実行中のジョブを永続化するディレクトリ。中継再起動後の拾い直しに使う。 */
|
|
46
|
+
export const ACTIVE_JOB_DIR = path.join(os.homedir(), '.local', 'state', 'conductor');
|
|
47
|
+
/**
|
|
48
|
+
* 旧: PCで1つの共有パス(0.12.62 以前)。
|
|
49
|
+
* 現場ごとに分けたあとも、古い記録が残っている PC で誤って拾わないように参照する。
|
|
50
|
+
*/
|
|
51
|
+
export const ACTIVE_JOB_PATH_LEGACY = path.join(ACTIVE_JOB_DIR, 'active-job.json');
|
|
52
|
+
/** 現場ごとの記録ファイルのパスを返す。 */
|
|
53
|
+
export function activeJobPath(site) {
|
|
54
|
+
return path.join(ACTIVE_JOB_DIR, `active-job-${site}.json`);
|
|
55
|
+
}
|
|
56
|
+
/** 実行中のジョブをファイルに書く(中継が死んでも拾い直せるように)。 */
|
|
57
|
+
export function saveActiveJob(site, record) {
|
|
58
|
+
try {
|
|
59
|
+
mkdirSync(ACTIVE_JOB_DIR, { recursive: true });
|
|
60
|
+
writeFileSync(activeJobPath(site), JSON.stringify(record) + '\n');
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
log(`WARN active-job の保存に失敗: ${err instanceof Error ? err.message : String(err)}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** 実行中のジョブ記録を消す(ジョブが完了・放棄したとき)。 */
|
|
67
|
+
export function clearActiveJob(site) {
|
|
68
|
+
try {
|
|
69
|
+
const p = activeJobPath(site);
|
|
70
|
+
if (existsSync(p))
|
|
71
|
+
unlinkSync(p);
|
|
72
|
+
}
|
|
73
|
+
catch { /* ignore */ }
|
|
74
|
+
}
|
|
75
|
+
/** 起動時に前回の実行中ジョブを読む。無ければ null。 */
|
|
76
|
+
export function loadActiveJob(site) {
|
|
77
|
+
try {
|
|
78
|
+
const p = activeJobPath(site);
|
|
79
|
+
if (!existsSync(p))
|
|
80
|
+
return null;
|
|
81
|
+
const raw = readFileSync(p, 'utf8').trim();
|
|
82
|
+
if (!raw)
|
|
83
|
+
return null;
|
|
84
|
+
const rec = JSON.parse(raw);
|
|
85
|
+
if (!rec.commandId)
|
|
86
|
+
return null;
|
|
87
|
+
return rec;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 旧形式(PCで1つの共有 active-job.json)を読み、在れば消す。
|
|
95
|
+
* 4点がそろわない古い記録は再開に使わない。
|
|
96
|
+
*/
|
|
97
|
+
export function loadAndClearLegacyActiveJob() {
|
|
98
|
+
try {
|
|
99
|
+
if (!existsSync(ACTIVE_JOB_PATH_LEGACY))
|
|
100
|
+
return null;
|
|
101
|
+
const raw = readFileSync(ACTIVE_JOB_PATH_LEGACY, 'utf8').trim();
|
|
102
|
+
unlinkSync(ACTIVE_JOB_PATH_LEGACY); // 読んだら必ず消す
|
|
103
|
+
if (!raw)
|
|
104
|
+
return null;
|
|
105
|
+
const rec = JSON.parse(raw);
|
|
106
|
+
if (!rec.commandId)
|
|
107
|
+
return null;
|
|
108
|
+
return rec;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* 手元の記録が自分(この中継)のものか4点照合する。
|
|
116
|
+
* 1点でも不一致なら理由を返す。すべて一致なら null。
|
|
117
|
+
*/
|
|
118
|
+
export function validateActiveJobOwnership(rec, cfg) {
|
|
119
|
+
const mismatches = [];
|
|
120
|
+
if (rec.site !== undefined && rec.site !== cfg.site) {
|
|
121
|
+
mismatches.push(`site: 記録=${rec.site} 自分=${cfg.site}`);
|
|
122
|
+
}
|
|
123
|
+
if (rec.machine !== undefined && rec.machine !== cfg.machine) {
|
|
124
|
+
mismatches.push(`machine: 記録=${rec.machine} 自分=${cfg.machine}`);
|
|
125
|
+
}
|
|
126
|
+
if (rec.workDir !== undefined && rec.workDir !== cfg.workDir) {
|
|
127
|
+
mismatches.push(`workDir: 記録=${rec.workDir} 自分=${cfg.workDir}`);
|
|
128
|
+
}
|
|
129
|
+
// 4点がそろわない古い記録(0.12.62 以前)は site/machine/workDir が undefined。
|
|
130
|
+
// 照合できない = 安全に拾えない。
|
|
131
|
+
if (rec.site === undefined || rec.machine === undefined || rec.workDir === undefined) {
|
|
132
|
+
mismatches.push('4点の照合項目が不足(0.12.62 以前の古い記録)');
|
|
133
|
+
}
|
|
134
|
+
return mismatches.length > 0 ? mismatches.join(', ') : null;
|
|
135
|
+
}
|
|
45
136
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
46
137
|
// claude の実行ファイルの探索
|
|
47
138
|
//
|
|
@@ -134,11 +225,23 @@ export function maskSecrets(text) {
|
|
|
134
225
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
135
226
|
// ヘルパー
|
|
136
227
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
137
|
-
/**
|
|
138
|
-
*
|
|
228
|
+
/**
|
|
229
|
+
* commandId と attempt 番号からセッション ID を作る(決定的な値)。
|
|
230
|
+
*
|
|
231
|
+
* 【なぜ attempt ごとに変えるか(実測 2026-10-01・便 03d3649f)】
|
|
232
|
+
* attempt 1 で `--session-id UUID` → セッションが作られる。
|
|
233
|
+
* attempt 2 で同じ UUID を `--session-id UUID` で渡すと "already in use" で即死。
|
|
234
|
+
* attempt 2 で `--session-id UUID --resume` だと "requires --fork-session" で即死。
|
|
235
|
+
* ★resume のときは `--resume UUID` で前回のセッションIDを渡す(--session-id は使わない)。
|
|
236
|
+
* fresh start のときは新しいセッション ID を使う。
|
|
139
237
|
*/
|
|
140
|
-
export function sessionIdForCommand(commandId) {
|
|
141
|
-
|
|
238
|
+
export function sessionIdForCommand(commandId, attempt = 1) {
|
|
239
|
+
if (attempt <= 1)
|
|
240
|
+
return commandId;
|
|
241
|
+
// attempt 2+ は UUID v4 の範囲で決定的に作る(末尾4桁を attempt に置き換え)。
|
|
242
|
+
// UUID の形を保つ(claude が UUID を要求するため)。
|
|
243
|
+
const hex = attempt.toString(16).padStart(4, '0');
|
|
244
|
+
return commandId.slice(0, -4) + hex;
|
|
142
245
|
}
|
|
143
246
|
/** stream-json の1行を CommandEvent に変換する。無関係な行は null。 */
|
|
144
247
|
export function streamEventToCommandEvent(parsed, attempt) {
|
|
@@ -172,7 +275,11 @@ export function isAuthError(job) {
|
|
|
172
275
|
s.includes('unauthorized') ||
|
|
173
276
|
s.includes('authentication failed') ||
|
|
174
277
|
s.includes('invalid api key') ||
|
|
175
|
-
s.includes('invalid_api_key')
|
|
278
|
+
s.includes('invalid_api_key') ||
|
|
279
|
+
s.includes('not logged in') ||
|
|
280
|
+
s.includes('login required') ||
|
|
281
|
+
s.includes('session expired') ||
|
|
282
|
+
s.includes('token expired'));
|
|
176
283
|
}
|
|
177
284
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
178
285
|
// 実行ログ
|
|
@@ -232,15 +339,27 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
232
339
|
delete env.ANTHROPIC_API_KEY;
|
|
233
340
|
env.CONDUCTOR_SITE = cfg.site;
|
|
234
341
|
env.CONDUCTOR_MACHINE = cfg.machine;
|
|
342
|
+
// ★テスト用: CONDUCTOR_CLAUDE_CONFIG_DIR を設定すると、claude の設定フォルダを差し替える。
|
|
343
|
+
// 本物のログインを壊さずに認証エラーを再現できる(空のフォルダを指す → not logged in)。
|
|
344
|
+
// 本番では設定しない(claude は既定の ~/.claude を使う)。
|
|
345
|
+
if (process.env.CONDUCTOR_CLAUDE_CONFIG_DIR) {
|
|
346
|
+
env.CLAUDE_CONFIG_DIR = process.env.CONDUCTOR_CLAUDE_CONFIG_DIR;
|
|
347
|
+
}
|
|
235
348
|
const args = [
|
|
236
349
|
'-p',
|
|
237
350
|
'--output-format', 'stream-json',
|
|
238
351
|
'--verbose',
|
|
239
352
|
'--permission-mode', cfg.permissionMode ?? 'acceptEdits',
|
|
240
|
-
'--session-id', sessionId,
|
|
241
353
|
];
|
|
242
|
-
if (isResume)
|
|
243
|
-
|
|
354
|
+
if (isResume) {
|
|
355
|
+
// ★ --resume <前回のセッションID> で前回の続きから再開する。
|
|
356
|
+
// --session-id と --resume を同時に使うと --fork-session が必要になるため、
|
|
357
|
+
// --resume の引数としてセッション ID を渡す(実測 2026-10-01)。
|
|
358
|
+
args.push('--resume', sessionId);
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
args.push('--session-id', sessionId);
|
|
362
|
+
}
|
|
244
363
|
let proc;
|
|
245
364
|
try {
|
|
246
365
|
proc = spawn(claudeBin, args, {
|
|
@@ -413,6 +532,7 @@ async function checkRunningJob(cfg, state) {
|
|
|
413
532
|
// ── 完了したプロセスの後始末 ──
|
|
414
533
|
if (job.completed) {
|
|
415
534
|
await flushEvents(cfg, job);
|
|
535
|
+
clearActiveJob(cfg.site);
|
|
416
536
|
if (job.stopped) {
|
|
417
537
|
// stop_requested で止めた
|
|
418
538
|
await sendCommandEvents(cfg, job.commandId, [{
|
|
@@ -428,13 +548,15 @@ async function checkRunningJob(cfg, state) {
|
|
|
428
548
|
}
|
|
429
549
|
if (isAuthError(job)) {
|
|
430
550
|
// 認証エラー → 再試行しない
|
|
551
|
+
const authMsg = maskSecrets(`認証エラー (exit=${job.exitCode}): ${job.stderrBuf.slice(0, 300)}`).slice(0, EVENT_TEXT_MAX);
|
|
431
552
|
await sendCommandEvents(cfg, job.commandId, [{
|
|
432
553
|
type: 'auth_error',
|
|
433
|
-
text:
|
|
554
|
+
text: authMsg,
|
|
434
555
|
at: new Date().toISOString(),
|
|
435
556
|
attempt: job.attempt,
|
|
436
557
|
}]);
|
|
437
|
-
//
|
|
558
|
+
// ★中央へ failed を返す(次の便が止まらないように)
|
|
559
|
+
await patchStatus(cfg, job.commandId, 'failed', authMsg);
|
|
438
560
|
rs.abandoned.add(job.commandId);
|
|
439
561
|
state.job = null;
|
|
440
562
|
rs.lastHeartbeatAt = null;
|
|
@@ -467,6 +589,15 @@ async function checkRunningJob(cfg, state) {
|
|
|
467
589
|
const nextAttempt = (state.attempts.get(job.commandId) ?? job.attempt) + 1;
|
|
468
590
|
if (nextAttempt > HEADLESS_MAX_ATTEMPTS) {
|
|
469
591
|
log(`WARN site=${cfg.site} id=${job.commandId} 試行回数上限(${HEADLESS_MAX_ATTEMPTS}) → 放棄`);
|
|
592
|
+
// ★中央へ failed を返す(次の便が止まらないように)
|
|
593
|
+
const failReason = maskSecrets(`${HEADLESS_MAX_ATTEMPTS}回すべて異常終了 (最後: exit=${job.exitCode}, signal=${job.exitSignal}): ${stderrTail}`).slice(0, EVENT_TEXT_MAX);
|
|
594
|
+
await patchStatus(cfg, job.commandId, 'failed', failReason);
|
|
595
|
+
await sendCommandEvents(cfg, job.commandId, [{
|
|
596
|
+
type: 'error',
|
|
597
|
+
text: failReason,
|
|
598
|
+
at: new Date().toISOString(),
|
|
599
|
+
attempt: job.attempt,
|
|
600
|
+
}]);
|
|
470
601
|
rs.abandoned.add(job.commandId);
|
|
471
602
|
}
|
|
472
603
|
else {
|
|
@@ -518,25 +649,84 @@ async function checkRunningJob(cfg, state) {
|
|
|
518
649
|
/**
|
|
519
650
|
* sent孤児を1件取得し、--resume で再開する(起動時1回)。
|
|
520
651
|
*
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
652
|
+
* 【2つの経路で孤児を見つける】
|
|
653
|
+
* 1. 手元の記録(active-job.json):中継が起動して、まだ終わっていない便。
|
|
654
|
+
* 中継の再起動(子プロセスが一緒に死ぬ)後に、中央の生存報告に頼らず拾える。
|
|
655
|
+
* 2. 中央(fetchOrphans):生存報告が途切れた現場の sent 便。PC 再起動後の復元。
|
|
525
656
|
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
528
|
-
* のではなく、直接 resume する)。
|
|
657
|
+
* ★手元の記録を先に見る(中継再起動 = 生存報告が新しく、中央は孤児と見なさないため)。
|
|
658
|
+
* ★二重起動しない: state.job が null のときだけ動く(④を壊さない)。
|
|
529
659
|
*/
|
|
530
660
|
export async function restoreHeadlessOrphans(cfg, state) {
|
|
531
661
|
if (!RELAY_RESTORE_ORPHANS_ENABLED)
|
|
532
662
|
return;
|
|
533
663
|
if (state.job !== null)
|
|
534
664
|
return; // 既にジョブ実行中
|
|
665
|
+
// ── 経路1: 手元の記録から拾い直す ──
|
|
666
|
+
// ★現場ごとのファイルを先に読む。古い共有ファイルも消す(取り違え防止)。
|
|
667
|
+
let saved = loadActiveJob(cfg.site);
|
|
668
|
+
if (saved !== null) {
|
|
669
|
+
clearActiveJob(cfg.site); // 拾ったら消す(二重起動防止)
|
|
670
|
+
}
|
|
671
|
+
// 古い共有の記録(0.12.62 以前)が残っていれば読んで必ず消す
|
|
672
|
+
const legacy = loadAndClearLegacyActiveJob();
|
|
673
|
+
if (saved === null && legacy !== null) {
|
|
674
|
+
saved = legacy; // 新しい現場ごとの記録が無いときだけ候補にする
|
|
675
|
+
}
|
|
676
|
+
else if (legacy !== null) {
|
|
677
|
+
log(`site=${cfg.site} 古い共有の active-job.json を消しました(現場ごとの記録を優先)`);
|
|
678
|
+
}
|
|
679
|
+
if (saved !== null) {
|
|
680
|
+
// ★4点照合: machine, site, commandId, workDir がすべて一致しなければ再開しない
|
|
681
|
+
const mismatch = validateActiveJobOwnership(saved, cfg);
|
|
682
|
+
if (mismatch !== null) {
|
|
683
|
+
log(`WARN site=${cfg.site} id=${saved.commandId} 手元の記録は別の中継のもの → 再開しない(不一致: ${mismatch})`);
|
|
684
|
+
await sendCommandEvents(cfg, saved.commandId, [{
|
|
685
|
+
type: 'error',
|
|
686
|
+
text: `手元の記録の4点照合で不一致 → 再開しない(${mismatch})`.slice(0, EVENT_TEXT_MAX),
|
|
687
|
+
at: new Date().toISOString(),
|
|
688
|
+
attempt: saved.attempt,
|
|
689
|
+
}]);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
log(`site=${cfg.site} id=${saved.commandId} 手元の記録から孤児を検出(中継再起動後の拾い直し)`);
|
|
693
|
+
const pick = { id: saved.commandId, body: saved.body, site: cfg.site, sentAt: saved.startedAt };
|
|
694
|
+
const attempt = saved.attempt + 1;
|
|
695
|
+
state.attempts.set(pick.id, attempt);
|
|
696
|
+
if (attempt > HEADLESS_MAX_ATTEMPTS) {
|
|
697
|
+
state.relay.abandoned.add(pick.id);
|
|
698
|
+
await patchStatus(cfg, pick.id, 'failed', `中継再起動後の拾い直し: 試行回数上限(${HEADLESS_MAX_ATTEMPTS})`);
|
|
699
|
+
log(`WARN site=${cfg.site} id=${pick.id} 手元の孤児復元: 試行回数上限 → 放棄`);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
// 前回のセッション ID で再開
|
|
703
|
+
const sessionId = sessionIdForCommand(pick.id, saved.attempt);
|
|
704
|
+
const cmd = { id: pick.id, body: pick.body };
|
|
705
|
+
const job = spawnClaudeProcess(cfg, cmd, sessionId, attempt, true);
|
|
706
|
+
if (job === null) {
|
|
707
|
+
state.relay.abandoned.add(pick.id);
|
|
708
|
+
log(`ERROR site=${cfg.site} id=${pick.id} 手元の孤児復元: claude が見つからない → 放棄`);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
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 });
|
|
713
|
+
markDelivered(state.relay, pick.id);
|
|
714
|
+
state.relay.lastHeartbeatAt = null;
|
|
715
|
+
noteReadiness(state.relay, 'delivered', pick.id);
|
|
716
|
+
await sendCommandEvents(cfg, pick.id, [{
|
|
717
|
+
type: 'resumed',
|
|
718
|
+
text: `中継再起動後の拾い直し: attempt ${attempt}/${HEADLESS_MAX_ATTEMPTS}`,
|
|
719
|
+
at: new Date().toISOString(),
|
|
720
|
+
attempt,
|
|
721
|
+
}]);
|
|
722
|
+
log(`site=${cfg.site} id=${pick.id} 手元の記録から復元して再開しました (attempt ${attempt}/${HEADLESS_MAX_ATTEMPTS})`);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
// ── 経路2: 中央から孤児を拾う(PC 再起動後) ──
|
|
535
726
|
const orphans = await fetchOrphans(cfg);
|
|
536
727
|
const pick = selectOrphanToRestore(orphans, null);
|
|
537
728
|
if (pick === null)
|
|
538
729
|
return;
|
|
539
|
-
const sessionId = sessionIdForCommand(pick.id);
|
|
540
730
|
const attempt = (state.attempts.get(pick.id) ?? 0) + 1;
|
|
541
731
|
state.attempts.set(pick.id, attempt);
|
|
542
732
|
if (attempt > HEADLESS_MAX_ATTEMPTS) {
|
|
@@ -545,6 +735,8 @@ export async function restoreHeadlessOrphans(cfg, state) {
|
|
|
545
735
|
return;
|
|
546
736
|
}
|
|
547
737
|
// ★孤児は resume で再開する(元の指示本文ではなく続行プロンプトを渡す)。
|
|
738
|
+
// 前回のセッション ID を渡す(attempt - 1 で作った ID で再開)。
|
|
739
|
+
const sessionId = sessionIdForCommand(pick.id, attempt > 1 ? attempt - 1 : 1);
|
|
548
740
|
const cmd = { id: pick.id, body: pick.body };
|
|
549
741
|
const job = spawnClaudeProcess(cfg, cmd, sessionId, attempt, true);
|
|
550
742
|
if (job === null) {
|
|
@@ -559,6 +751,7 @@ export async function restoreHeadlessOrphans(cfg, state) {
|
|
|
559
751
|
return;
|
|
560
752
|
}
|
|
561
753
|
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 });
|
|
562
755
|
markDelivered(state.relay, pick.id);
|
|
563
756
|
state.relay.lastHeartbeatAt = null;
|
|
564
757
|
noteReadiness(state.relay, 'delivered', pick.id);
|
|
@@ -632,9 +825,12 @@ export async function headlessTick(cfg, state, hooks) {
|
|
|
632
825
|
const retry = state.pendingRetry;
|
|
633
826
|
state.pendingRetry = null;
|
|
634
827
|
state.attempts.set(retry.cmd.id, retry.attempt);
|
|
635
|
-
const sessionId = sessionIdForCommand(retry.cmd.id);
|
|
636
828
|
// 前回がセッションを作れないほど短命(< 30秒)なら fresh start、そうでなければ resume
|
|
637
829
|
const isResume = retry.attempt > 1 && retry.durationMs >= SESSION_MIN_DURATION_MS;
|
|
830
|
+
// resume: 前回のセッション ID を渡す。fresh start: 新しいセッション ID を作る。
|
|
831
|
+
const sessionId = isResume
|
|
832
|
+
? sessionIdForCommand(retry.cmd.id, retry.attempt - 1)
|
|
833
|
+
: sessionIdForCommand(retry.cmd.id, retry.attempt);
|
|
638
834
|
const job = spawnClaudeProcess(cfg, retry.cmd, sessionId, retry.attempt, isResume);
|
|
639
835
|
if (job === null) {
|
|
640
836
|
await sendCommandEvents(cfg, retry.cmd.id, [{
|
|
@@ -648,6 +844,7 @@ export async function headlessTick(cfg, state, hooks) {
|
|
|
648
844
|
return;
|
|
649
845
|
}
|
|
650
846
|
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 });
|
|
651
848
|
rs.lastHeartbeatAt = null;
|
|
652
849
|
noteReadiness(rs, 'delivered', retry.cmd.id);
|
|
653
850
|
await sendCommandEvents(cfg, retry.cmd.id, [{
|
|
@@ -684,8 +881,11 @@ export async function headlessTick(cfg, state, hooks) {
|
|
|
684
881
|
return;
|
|
685
882
|
}
|
|
686
883
|
// ── claude -p を起動 ──
|
|
687
|
-
const sessionId = sessionIdForCommand(cmd.id);
|
|
688
884
|
const isResume = attempt > 1;
|
|
885
|
+
// resume: 前回のセッション ID を渡す。fresh start: 新しいセッション ID を作る。
|
|
886
|
+
const sessionId = isResume
|
|
887
|
+
? sessionIdForCommand(cmd.id, attempt - 1)
|
|
888
|
+
: sessionIdForCommand(cmd.id, attempt);
|
|
689
889
|
const job = spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume);
|
|
690
890
|
if (job === null) {
|
|
691
891
|
// ★起動失敗を管制へ知らせる(「起動しました」とは言わない)
|
|
@@ -700,6 +900,7 @@ export async function headlessTick(cfg, state, hooks) {
|
|
|
700
900
|
return;
|
|
701
901
|
}
|
|
702
902
|
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 });
|
|
703
904
|
markDelivered(rs, cmd.id);
|
|
704
905
|
rs.lastHeartbeatAt = null;
|
|
705
906
|
noteReadiness(rs, 'delivered', cmd.id);
|