@tyhld/conductor 0.12.71 → 0.12.73
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 +11 -0
- package/dist/headless.js +245 -26
- package/dist/relay.js +17 -2
- package/dist/view.js +455 -0
- package/package.json +2 -1
- package/sales-template/install.sh +19 -7
- package/sales-template/setup.sh +18 -0
- package/scripts/hooks/policy.py +29 -4
- package/scripts/hooks/test_policy.py +83 -0
- package/scripts/kc.sh +85 -0
- package/skills/conductor-craftsman/SKILL.md +13 -2
package/dist/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ 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
18
|
import { createHeadlessState, headlessNextDelayMs, headlessTick, parseRunnerMode, } from './headless.js';
|
|
19
|
+
import { runViewCommand } from './view.js';
|
|
19
20
|
import { createRelayRunner } from './relay-runner.js';
|
|
20
21
|
import { installProcessGuards } from './process-guard.js';
|
|
21
22
|
import { registerNudgeTarget, unregisterNudgeTarget, watchNudges, } from './nudge.js';
|
|
@@ -468,6 +469,12 @@ function printUsage() {
|
|
|
468
469
|
' conductor ear この機に1本の「耳」(合図の受け取り役)',
|
|
469
470
|
' conductor build-runner この機に1本の「司令塔」(試作の段取り役)',
|
|
470
471
|
' conductor setup <現場名> [フォルダ] [--profile <default|proto>] 采配くんをこのPCに設置する(初回の1回だけ)',
|
|
472
|
+
' conductor view <現場名> [--detail] その現場の今を見るだけの画面(kc の実体)',
|
|
473
|
+
'',
|
|
474
|
+
'見るだけの画面(conductor view = kc 現場名):',
|
|
475
|
+
' いま職人が何をしているかを、やさしい日本語で見るだけの画面です。',
|
|
476
|
+
' ★職人(Claude Code)・tmux・relay を起動しません。入力も受け付けません(抜けるだけ)。',
|
|
477
|
+
' --detail を付けると、職人の生の記録(秘密は隠したもの)も続けて出します。',
|
|
471
478
|
'',
|
|
472
479
|
'設置の型(--profile。既定は default=今までどおり):',
|
|
473
480
|
' default ふつうの現場。従来と同じ設定を置きます。',
|
|
@@ -545,6 +552,10 @@ async function main() {
|
|
|
545
552
|
// ★引数はそのまま setup.sh へ渡す(judgement は全部インストーラ側)。
|
|
546
553
|
runSetupCommand(process.argv.slice(3));
|
|
547
554
|
break;
|
|
555
|
+
case 'view':
|
|
556
|
+
// ★見るだけの画面。職人も tmux も relay も起動せず、入力も受け付けない。
|
|
557
|
+
process.exit(runViewCommand(process.argv.slice(3)));
|
|
558
|
+
break;
|
|
548
559
|
case 'version':
|
|
549
560
|
case '-v':
|
|
550
561
|
case '--version':
|
package/dist/headless.js
CHANGED
|
@@ -36,6 +36,31 @@ export const HEADLESS_EVENT_FLUSH_MS = 2_000;
|
|
|
36
36
|
export const HEADLESS_STOP_CHECK_MS = 15_000;
|
|
37
37
|
/** イベントの text フィールドの上限(文字)。 */
|
|
38
38
|
export const EVENT_TEXT_MAX = 500;
|
|
39
|
+
/** 記録1行から取り出すイベントの上限(件)。1行に content が大量に入っていても溢れさせない。 */
|
|
40
|
+
export const EVENT_MAX_PER_LINE = 8;
|
|
41
|
+
/** 1つの便で管制へ送るイベントの上限(件)。これを超えた分は捨てる(生の記録の全量は外へ出さない)。 */
|
|
42
|
+
export const EVENT_MAX_PER_JOB = 2_000;
|
|
43
|
+
/**
|
|
44
|
+
* 工程の区切りの1行を拾う印(職人の鉄則が出す形)。
|
|
45
|
+
* 例: `進捗: 既存の画面構成を確認しました` → 「既存の画面構成を確認しました」
|
|
46
|
+
* ★全角コロンも受ける(日本語入力のままでも通る形にする)。
|
|
47
|
+
*/
|
|
48
|
+
export const PROGRESS_MARKER_RE = /^\s*進捗\s*[::]\s*(.+)$/;
|
|
49
|
+
/**
|
|
50
|
+
* 職人の発言から「工程の区切りの1行」を取り出す。印が無ければ null。
|
|
51
|
+
* ★複数行の発言の中に印の行があれば、最初の1本だけを拾う。
|
|
52
|
+
*/
|
|
53
|
+
export function extractProgressLine(text) {
|
|
54
|
+
for (const line of text.split('\n')) {
|
|
55
|
+
const m = PROGRESS_MARKER_RE.exec(line);
|
|
56
|
+
if (m) {
|
|
57
|
+
const body = m[1].trim();
|
|
58
|
+
if (body.length > 0)
|
|
59
|
+
return body;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
39
64
|
/** resume 時に職人へ渡す続行指示。 */
|
|
40
65
|
const RESUME_PROMPT = '前回の作業の続きを行ってください。中断された箇所から再開してください。';
|
|
41
66
|
/** 実行ログの保存先ルート。 */
|
|
@@ -70,6 +95,25 @@ export function resolveGitOrigin(workDir) {
|
|
|
70
95
|
return null;
|
|
71
96
|
}
|
|
72
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* その番号のプロセスが今も動いているかを OS に聞く。
|
|
100
|
+
*
|
|
101
|
+
* ★signal 0 は「送らずに存在と権限だけ確かめる」。動いていれば例外なし、
|
|
102
|
+
* 居なければ ESRCH で例外。番号が無い(起動に失敗した)ときは動いていない扱い。
|
|
103
|
+
* ★「作業中」と出してよいかの判定にこれを使う(えふさん確定 2026-10-02)。
|
|
104
|
+
*/
|
|
105
|
+
export function isProcessAlive(pid) {
|
|
106
|
+
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0)
|
|
107
|
+
return false;
|
|
108
|
+
try {
|
|
109
|
+
process.kill(pid, 0);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
// EPERM = 居るが自分のものではない。ここでは「居る」として扱う。
|
|
114
|
+
return err?.code === 'EPERM';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
73
117
|
/** 実行中のジョブをファイルに書く(中継が死んでも拾い直せるように)。 */
|
|
74
118
|
export function saveActiveJob(site, record) {
|
|
75
119
|
try {
|
|
@@ -89,6 +133,38 @@ export function clearActiveJob(site) {
|
|
|
89
133
|
}
|
|
90
134
|
catch { /* ignore */ }
|
|
91
135
|
}
|
|
136
|
+
/** 現場ごとの「直前に終わった仕事」の記録ファイルのパスを返す。 */
|
|
137
|
+
export function lastJobPath(site) {
|
|
138
|
+
return path.join(ACTIVE_JOB_DIR, `last-job-${site}.json`);
|
|
139
|
+
}
|
|
140
|
+
/** 直前に終わった仕事を書く。失敗しても作業は止めない。 */
|
|
141
|
+
export function saveLastJob(site, record) {
|
|
142
|
+
try {
|
|
143
|
+
mkdirSync(ACTIVE_JOB_DIR, { recursive: true });
|
|
144
|
+
writeFileSync(lastJobPath(site), JSON.stringify(record) + '\n');
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
log(`WARN last-job の保存に失敗: ${err instanceof Error ? err.message : String(err)}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** 直前に終わった仕事を読む。無ければ null。 */
|
|
151
|
+
export function loadLastJob(site) {
|
|
152
|
+
try {
|
|
153
|
+
const p = lastJobPath(site);
|
|
154
|
+
if (!existsSync(p))
|
|
155
|
+
return null;
|
|
156
|
+
const raw = readFileSync(p, 'utf8').trim();
|
|
157
|
+
if (!raw)
|
|
158
|
+
return null;
|
|
159
|
+
const rec = JSON.parse(raw);
|
|
160
|
+
if (!rec.commandId)
|
|
161
|
+
return null;
|
|
162
|
+
return rec;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
92
168
|
/** 起動時に前回の実行中ジョブを読む。無ければ null。 */
|
|
93
169
|
export function loadActiveJob(site) {
|
|
94
170
|
try {
|
|
@@ -288,30 +364,124 @@ export function sessionIdForCommand(commandId, attempt = 1) {
|
|
|
288
364
|
const hex = attempt.toString(16).padStart(4, '0');
|
|
289
365
|
return commandId.slice(0, -4) + hex;
|
|
290
366
|
}
|
|
291
|
-
/**
|
|
292
|
-
|
|
367
|
+
/**
|
|
368
|
+
* 操作1件を人が読める1行にする。
|
|
369
|
+
*
|
|
370
|
+
* ★出どころは職人が書く説明文(description)を第一とする(便 fbaba4b1 のえふさん確定)。
|
|
371
|
+
* description が無い道具(Read / Grep / Skill など)は、道具の名前と、
|
|
372
|
+
* 秘密を含まないと決めた項目(対象のファイル・探す語)だけを添える。
|
|
373
|
+
* ★生のコマンド文字列(input.command)は添えない。全量を PC の外へ出さないため。
|
|
374
|
+
*/
|
|
375
|
+
export function toolUseLabel(name, input) {
|
|
376
|
+
const desc = typeof input?.description === 'string' ? input.description.trim() : '';
|
|
377
|
+
if (desc.length > 0)
|
|
378
|
+
return desc;
|
|
379
|
+
for (const key of ['file_path', 'path', 'notebook_path', 'pattern', 'skill', 'subagent_type']) {
|
|
380
|
+
const v = input?.[key];
|
|
381
|
+
if (typeof v === 'string' && v.trim().length > 0)
|
|
382
|
+
return `${name}: ${v.trim()}`;
|
|
383
|
+
}
|
|
384
|
+
return name;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* 記録の中の「文字か、文字の配列か、text ブロックの配列」を1本の文字列にする。
|
|
388
|
+
* tool_result の content と、result 行の errors(★実物は素の文字列の配列)の両方に使う。
|
|
389
|
+
*/
|
|
390
|
+
function flattenText(content) {
|
|
391
|
+
if (typeof content === 'string')
|
|
392
|
+
return content;
|
|
393
|
+
if (Array.isArray(content)) {
|
|
394
|
+
return content
|
|
395
|
+
.map((b) => {
|
|
396
|
+
if (typeof b === 'string')
|
|
397
|
+
return b;
|
|
398
|
+
if (typeof b === 'object' && b !== null)
|
|
399
|
+
return String(b.text ?? '');
|
|
400
|
+
return '';
|
|
401
|
+
})
|
|
402
|
+
.filter((s) => s.length > 0)
|
|
403
|
+
.join('\n');
|
|
404
|
+
}
|
|
405
|
+
return '';
|
|
406
|
+
}
|
|
407
|
+
/** 秘密を隠して上限で切った CommandEvent を作る。中身が空なら null。 */
|
|
408
|
+
function makeEvent(type, raw, at, attempt) {
|
|
409
|
+
const text = maskSecrets(raw.trim()).slice(0, EVENT_TEXT_MAX);
|
|
410
|
+
if (text.length === 0)
|
|
411
|
+
return null;
|
|
412
|
+
return { type, text, at, attempt };
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* stream-json の1行から CommandEvent を取り出す。無関係な行は空配列。
|
|
416
|
+
*
|
|
417
|
+
* 【実物の形(2026-10-02 この現場の便 fbaba4b1 の attempt-1.jsonl で実測)】
|
|
418
|
+
* ★`type:'assistant'` に subtype は無く、中身は `message.content` の配列。
|
|
419
|
+
* 配列の要素は `{type:'text',text}` / `{type:'thinking'}` / `{type:'tool_use',name,input}`。
|
|
420
|
+
* ★`type:'user'` も `message.content` の配列で、`{type:'tool_result',is_error,content}` が入る。
|
|
421
|
+
* ★`type:'result'` は `subtype:'success'`(result 項目あり)か
|
|
422
|
+
* `subtype:'error_during_execution'`(★result 項目が無く errors がある)。
|
|
423
|
+
*
|
|
424
|
+
* 【なぜ直したか】以前はここが `type:'assistant' && subtype:'text'` を見ていた。
|
|
425
|
+
* 実物にその形は1件も無いため、1便のあいだ送れていたのは最後の result 1件だけで、
|
|
426
|
+
* 管制から「いま何をしているか」が消えていた(=画面なし方式にした時の退行)。
|
|
427
|
+
*
|
|
428
|
+
* 【1行から複数件出る】1つの assistant 行に発言と操作が同時に入るので、配列を返す。
|
|
429
|
+
*/
|
|
430
|
+
export function streamEventsFromLine(parsed, attempt) {
|
|
293
431
|
const now = new Date().toISOString();
|
|
294
432
|
const type = parsed.type;
|
|
295
433
|
const subtype = parsed.subtype;
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if (
|
|
434
|
+
const out = [];
|
|
435
|
+
const push = (e) => {
|
|
436
|
+
if (e !== null && out.length < EVENT_MAX_PER_LINE)
|
|
437
|
+
out.push(e);
|
|
438
|
+
};
|
|
439
|
+
const content = (() => {
|
|
440
|
+
const msg = parsed.message;
|
|
441
|
+
if (typeof msg !== 'object' || msg === null)
|
|
299
442
|
return null;
|
|
300
|
-
|
|
443
|
+
const c = msg.content;
|
|
444
|
+
return Array.isArray(c) ? c : null;
|
|
445
|
+
})();
|
|
446
|
+
if (type === 'assistant' && content !== null) {
|
|
447
|
+
for (const block of content) {
|
|
448
|
+
if (block?.type === 'text') {
|
|
449
|
+
const text = String(block.text ?? '');
|
|
450
|
+
// ★工程の区切りの印が在ればその1行だけを送る(人向けの表示はこれを使う)。
|
|
451
|
+
const marked = extractProgressLine(text);
|
|
452
|
+
push(makeEvent('progress', marked ?? text, now, attempt));
|
|
453
|
+
}
|
|
454
|
+
else if (block?.type === 'tool_use') {
|
|
455
|
+
const input = typeof block.input === 'object' && block.input !== null
|
|
456
|
+
? block.input
|
|
457
|
+
: undefined;
|
|
458
|
+
push(makeEvent('tool', toolUseLabel(String(block.name ?? 'unknown'), input), now, attempt));
|
|
459
|
+
}
|
|
460
|
+
// thinking は送らない(人向けの表示に使わないうえ、量が多い)。
|
|
461
|
+
}
|
|
462
|
+
return out;
|
|
301
463
|
}
|
|
302
|
-
if (type === '
|
|
303
|
-
const
|
|
304
|
-
|
|
464
|
+
if (type === 'user' && content !== null) {
|
|
465
|
+
for (const block of content) {
|
|
466
|
+
// ★失敗した操作だけを送る。成功した操作の生出力は量が多く、外へ出す必要もない。
|
|
467
|
+
if (block?.type === 'tool_result' && block.is_error === true) {
|
|
468
|
+
push(makeEvent('error', flattenText(block.content), now, attempt));
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return out;
|
|
305
472
|
}
|
|
306
473
|
if (type === 'result') {
|
|
307
|
-
if (subtype === '
|
|
308
|
-
|
|
309
|
-
|
|
474
|
+
if (parsed.is_error === true || subtype === 'error_during_execution') {
|
|
475
|
+
// ★error_during_execution には result 項目が無い。errors を使う。
|
|
476
|
+
const text = String(parsed.result ?? '') || flattenText(parsed.errors) || `失敗しました (${subtype ?? 'result'})`;
|
|
477
|
+
push(makeEvent('error', text, now, attempt));
|
|
310
478
|
}
|
|
311
|
-
|
|
312
|
-
|
|
479
|
+
else {
|
|
480
|
+
push(makeEvent('result', String(parsed.result ?? ''), now, attempt));
|
|
481
|
+
}
|
|
482
|
+
return out;
|
|
313
483
|
}
|
|
314
|
-
return
|
|
484
|
+
return out;
|
|
315
485
|
}
|
|
316
486
|
/**
|
|
317
487
|
* stream-json の1行から認証エラーを検出する。
|
|
@@ -372,14 +542,22 @@ export function isAuthError(job) {
|
|
|
372
542
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
373
543
|
// 実行ログ
|
|
374
544
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
375
|
-
/**
|
|
545
|
+
/** 人向けの進捗の記録ファイルのパスを返す(`kc 現場名` の通常表示が読む)。 */
|
|
546
|
+
export function eventsLogPath(commandId, attempt) {
|
|
547
|
+
return path.join(RUNS_DIR, commandId, `attempt-${attempt}.events.jsonl`);
|
|
548
|
+
}
|
|
549
|
+
/** 実行ログのディレクトリを作り、stdout / stderr / 進捗 の WriteStream を返す。 */
|
|
376
550
|
function openRunLogs(commandId, attempt) {
|
|
377
551
|
try {
|
|
378
552
|
const dir = path.join(RUNS_DIR, commandId);
|
|
379
553
|
mkdirSync(dir, { recursive: true });
|
|
380
554
|
const stdoutLog = createWriteStream(path.join(dir, `attempt-${attempt}.jsonl`), { flags: 'a' });
|
|
381
555
|
const stderrLog = createWriteStream(path.join(dir, `attempt-${attempt}.stderr.log`), { flags: 'a' });
|
|
382
|
-
|
|
556
|
+
// ★生の記録には時刻が入っていない(実測 2026-10-02: assistant 行の項目は
|
|
557
|
+
// message/parent_tool_use_id/session_id/type/uuid だけ)。時刻つきの流れを出すには
|
|
558
|
+
// 拾った側で時刻を刻んで残すしかない。ここには秘密を隠して上限で切った物だけを書く。
|
|
559
|
+
const eventsLog = createWriteStream(eventsLogPath(commandId, attempt), { flags: 'a' });
|
|
560
|
+
return { stdoutLog, stderrLog, eventsLog };
|
|
383
561
|
}
|
|
384
562
|
catch (err) {
|
|
385
563
|
log(`WARN 実行ログのディレクトリを作れません: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -427,6 +605,7 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
427
605
|
delete env.ANTHROPIC_API_KEY;
|
|
428
606
|
env.CONDUCTOR_SITE = cfg.site;
|
|
429
607
|
env.CONDUCTOR_MACHINE = cfg.machine;
|
|
608
|
+
env.CONDUCTOR_WORK_DIR = cfg.workDir;
|
|
430
609
|
// ★テスト用: CONDUCTOR_CLAUDE_CONFIG_DIR を設定すると、claude の設定フォルダを差し替える。
|
|
431
610
|
// 本物のログインを壊さずに認証エラーを再現できる(空のフォルダを指す → not logged in)。
|
|
432
611
|
// 本番では設定しない(claude は既定の ~/.claude を使う)。
|
|
@@ -473,12 +652,15 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
473
652
|
stopped: false,
|
|
474
653
|
timedOut: false,
|
|
475
654
|
eventBuffer: [],
|
|
655
|
+
eventsSent: 0,
|
|
656
|
+
lastActivityAt: Date.now(),
|
|
476
657
|
lastFlushAt: Date.now(),
|
|
477
658
|
lastStopCheckAt: Date.now(),
|
|
478
659
|
stderrBuf: '',
|
|
479
660
|
authErrorDetected: false,
|
|
480
661
|
stdoutLog: logs?.stdoutLog ?? null,
|
|
481
662
|
stderrLog: logs?.stderrLog ?? null,
|
|
663
|
+
eventsLog: logs?.eventsLog ?? null,
|
|
482
664
|
durationMs: 0,
|
|
483
665
|
};
|
|
484
666
|
// spawn の 'error' イベント(ENOENT 等は非同期で届く。同期例外と両方捕まえる)
|
|
@@ -509,15 +691,23 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
509
691
|
const rl = createInterface({ input: proc.stdout });
|
|
510
692
|
rl.on('line', (line) => {
|
|
511
693
|
job.stdoutLog?.write(line + '\n');
|
|
694
|
+
// ★記録が1行出たこと自体が「手が動いている」印。管制の「作業中」判定に使う。
|
|
695
|
+
job.lastActivityAt = Date.now();
|
|
512
696
|
try {
|
|
513
697
|
const parsed = JSON.parse(line);
|
|
514
698
|
// ★stream-json から認証エラーを検出(stderr に出ない実物に対応)
|
|
515
699
|
if (!job.authErrorDetected && detectStreamAuthError(parsed)) {
|
|
516
700
|
job.authErrorDetected = true;
|
|
517
701
|
}
|
|
518
|
-
const event
|
|
519
|
-
|
|
702
|
+
for (const event of streamEventsFromLine(parsed, attempt)) {
|
|
703
|
+
// ★1便あたりの件数に上限を持つ(生の記録の全量は PC の外へ送らない)。
|
|
704
|
+
if (job.eventsSent >= EVENT_MAX_PER_JOB)
|
|
705
|
+
break;
|
|
706
|
+
job.eventsSent += 1;
|
|
520
707
|
job.eventBuffer.push(event);
|
|
708
|
+
// 時刻つきで手元にも残す(`kc 現場名` の通常表示がこれを読む)。
|
|
709
|
+
job.eventsLog?.write(JSON.stringify(event) + '\n');
|
|
710
|
+
}
|
|
521
711
|
}
|
|
522
712
|
catch {
|
|
523
713
|
// JSON でないか壊れている行 — 無視
|
|
@@ -543,6 +733,7 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
543
733
|
job.durationMs = Date.now() - job.startedAt;
|
|
544
734
|
closeLog(job.stdoutLog);
|
|
545
735
|
closeLog(job.stderrLog);
|
|
736
|
+
closeLog(job.eventsLog);
|
|
546
737
|
});
|
|
547
738
|
return job;
|
|
548
739
|
}
|
|
@@ -626,7 +817,20 @@ async function checkRunningJob(cfg, state) {
|
|
|
626
817
|
// ── 完了したプロセスの後始末 ──
|
|
627
818
|
if (job.completed) {
|
|
628
819
|
await flushEvents(cfg, job);
|
|
820
|
+
// ★消す前に本文を取る(`kc 現場名` が「直前の仕事」を出すために残す)。
|
|
821
|
+
const savedBody = loadActiveJob(cfg.site)?.body ?? '';
|
|
629
822
|
clearActiveJob(cfg.site);
|
|
823
|
+
const noteLastJob = (outcome, detail) => {
|
|
824
|
+
saveLastJob(cfg.site, {
|
|
825
|
+
commandId: job.commandId,
|
|
826
|
+
body: savedBody,
|
|
827
|
+
outcome,
|
|
828
|
+
startedAt: job.startedAt,
|
|
829
|
+
finishedAt: Date.now(),
|
|
830
|
+
attempt: job.attempt,
|
|
831
|
+
...(detail !== undefined ? { detail } : {}),
|
|
832
|
+
});
|
|
833
|
+
};
|
|
630
834
|
if (job.timedOut) {
|
|
631
835
|
// 上限時間で強制停止した → 管制へ理由つきで failed を記録する
|
|
632
836
|
const elapsed = Math.round((Date.now() - job.startedAt) / 1000);
|
|
@@ -639,6 +843,7 @@ async function checkRunningJob(cfg, state) {
|
|
|
639
843
|
}]);
|
|
640
844
|
await patchStatus(cfg, job.commandId, 'failed', reason.slice(0, EVENT_TEXT_MAX));
|
|
641
845
|
rs.abandoned.add(job.commandId);
|
|
846
|
+
noteLastJob('timeout', reason);
|
|
642
847
|
state.job = null;
|
|
643
848
|
rs.lastHeartbeatAt = null;
|
|
644
849
|
log(`WARN site=${cfg.site} id=${job.commandId} ${reason}`);
|
|
@@ -652,6 +857,7 @@ async function checkRunningJob(cfg, state) {
|
|
|
652
857
|
at: new Date().toISOString(),
|
|
653
858
|
attempt: job.attempt,
|
|
654
859
|
}]);
|
|
860
|
+
noteLastJob('stopped', '管制から止めました');
|
|
655
861
|
state.job = null;
|
|
656
862
|
rs.lastHeartbeatAt = null;
|
|
657
863
|
log(`site=${cfg.site} id=${job.commandId} stop_requested で停止`);
|
|
@@ -672,6 +878,7 @@ async function checkRunningJob(cfg, state) {
|
|
|
672
878
|
// ★中央へ failed を返す(次の便が止まらないように)
|
|
673
879
|
await patchStatus(cfg, job.commandId, 'failed', authMsg);
|
|
674
880
|
rs.abandoned.add(job.commandId);
|
|
881
|
+
noteLastJob('auth_error', authMsg);
|
|
675
882
|
state.job = null;
|
|
676
883
|
rs.lastHeartbeatAt = null;
|
|
677
884
|
log(`WARN site=${cfg.site} id=${job.commandId} 認証エラー → 再試行しない`);
|
|
@@ -688,6 +895,7 @@ async function checkRunningJob(cfg, state) {
|
|
|
688
895
|
await patchStatus(cfg, job.commandId, 'done');
|
|
689
896
|
log(`site=${cfg.site} id=${job.commandId} 完了(exit=0, 報告なし)`);
|
|
690
897
|
}
|
|
898
|
+
noteLastJob('done', ok ? '報告まで出しました' : '報告は出せませんでした');
|
|
691
899
|
state.job = null;
|
|
692
900
|
rs.lastHeartbeatAt = null;
|
|
693
901
|
return;
|
|
@@ -713,6 +921,7 @@ async function checkRunningJob(cfg, state) {
|
|
|
713
921
|
attempt: job.attempt,
|
|
714
922
|
}]);
|
|
715
923
|
rs.abandoned.add(job.commandId);
|
|
924
|
+
noteLastJob('failed', failReason);
|
|
716
925
|
}
|
|
717
926
|
else {
|
|
718
927
|
// ★ pendingRetry に詰めて、次の tick で実際に spawn する(fetchCommand は sent を返さない)
|
|
@@ -746,11 +955,20 @@ async function checkRunningJob(cfg, state) {
|
|
|
746
955
|
await stopJob(job);
|
|
747
956
|
return;
|
|
748
957
|
}
|
|
749
|
-
// 生存ハートビート
|
|
958
|
+
// ── 生存ハートビート ──
|
|
959
|
+
// ★「実プロセスが動いている間だけ」送る(えふさん確定 2026-10-02)。
|
|
960
|
+
// job.completed は exit イベントで立つが、exit が届く前に OS 上のプロセスが
|
|
961
|
+
// 消えていることがある。だから OS に直接聞いて、動いていなければ送らない。
|
|
962
|
+
// 送らなければ中央の lastSeenAt が止まり、管制は「作業中」と出せなくなる。
|
|
963
|
+
const alive = isProcessAlive(job.process.pid);
|
|
750
964
|
const throttleOk = rs.lastHeartbeatAt === null || now - rs.lastHeartbeatAt >= RELAY_HEARTBEAT_INTERVAL_MS;
|
|
751
|
-
if (throttleOk) {
|
|
965
|
+
if (alive && throttleOk) {
|
|
752
966
|
rs.lastHeartbeatAt = now;
|
|
753
|
-
await sendHeartbeat(cfg, job.commandId, true
|
|
967
|
+
await sendHeartbeat(cfg, job.commandId, true, {
|
|
968
|
+
startedAt: new Date(job.startedAt).toISOString(),
|
|
969
|
+
lastActivityAt: new Date(job.lastActivityAt).toISOString(),
|
|
970
|
+
...(job.process.pid !== undefined ? { pid: job.process.pid } : {}),
|
|
971
|
+
});
|
|
754
972
|
}
|
|
755
973
|
// イベントバッファのフラッシュ
|
|
756
974
|
if (now - job.lastFlushAt >= HEADLESS_EVENT_FLUSH_MS && job.eventBuffer.length > 0) {
|
|
@@ -831,6 +1049,7 @@ export async function restoreHeadlessOrphans(cfg, state) {
|
|
|
831
1049
|
return;
|
|
832
1050
|
}
|
|
833
1051
|
log(`site=${cfg.site} id=${saved.commandId} 手元の記録から孤児を検出(中継再起動後の拾い直し)`);
|
|
1052
|
+
const postedAt = saved.postedAt;
|
|
834
1053
|
const pick = { id: saved.commandId, body: saved.body, site: cfg.site, sentAt: saved.startedAt };
|
|
835
1054
|
const attempt = saved.attempt + 1;
|
|
836
1055
|
state.attempts.set(pick.id, attempt);
|
|
@@ -851,7 +1070,7 @@ export async function restoreHeadlessOrphans(cfg, state) {
|
|
|
851
1070
|
return;
|
|
852
1071
|
}
|
|
853
1072
|
state.job = job;
|
|
854
|
-
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 });
|
|
1073
|
+
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, pid: job.process.pid, postedAt });
|
|
855
1074
|
markDelivered(state.relay, pick.id);
|
|
856
1075
|
state.relay.lastHeartbeatAt = null;
|
|
857
1076
|
noteReadiness(state.relay, 'delivered', pick.id);
|
|
@@ -915,7 +1134,7 @@ export async function restoreHeadlessOrphans(cfg, state) {
|
|
|
915
1134
|
return;
|
|
916
1135
|
}
|
|
917
1136
|
state.job = job;
|
|
918
|
-
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 });
|
|
1137
|
+
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, pid: job.process.pid, postedAt: pick.sentAt });
|
|
919
1138
|
markDelivered(state.relay, pick.id);
|
|
920
1139
|
state.relay.lastHeartbeatAt = null;
|
|
921
1140
|
noteReadiness(state.relay, 'delivered', pick.id);
|
|
@@ -1009,7 +1228,7 @@ export async function headlessTick(cfg, state, hooks) {
|
|
|
1009
1228
|
return;
|
|
1010
1229
|
}
|
|
1011
1230
|
state.job = job;
|
|
1012
|
-
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 });
|
|
1231
|
+
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, pid: job.process.pid });
|
|
1013
1232
|
rs.lastHeartbeatAt = null;
|
|
1014
1233
|
noteReadiness(rs, 'delivered', retry.cmd.id);
|
|
1015
1234
|
await sendCommandEvents(cfg, retry.cmd.id, [{
|
|
@@ -1066,7 +1285,7 @@ export async function headlessTick(cfg, state, hooks) {
|
|
|
1066
1285
|
return;
|
|
1067
1286
|
}
|
|
1068
1287
|
state.job = job;
|
|
1069
|
-
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 });
|
|
1288
|
+
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, pid: job.process.pid, postedAt: cmd.sentAt });
|
|
1070
1289
|
markDelivered(rs, cmd.id);
|
|
1071
1290
|
rs.lastHeartbeatAt = null;
|
|
1072
1291
|
noteReadiness(rs, 'delivered', cmd.id);
|
package/dist/relay.js
CHANGED
|
@@ -204,6 +204,17 @@ export const BUSY_GLYPHS = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '
|
|
|
204
204
|
export function kindOf(cfg) {
|
|
205
205
|
return cfg.agentKind ?? 'claude';
|
|
206
206
|
}
|
|
207
|
+
/** 中央が返した日時らしい値を ms に直す。読めないときは undefined。 */
|
|
208
|
+
export function parseSentAt(raw) {
|
|
209
|
+
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0)
|
|
210
|
+
return raw;
|
|
211
|
+
if (typeof raw === 'string' && raw.trim().length > 0) {
|
|
212
|
+
const t = Date.parse(raw);
|
|
213
|
+
if (Number.isFinite(t) && t > 0)
|
|
214
|
+
return t;
|
|
215
|
+
}
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
207
218
|
/**
|
|
208
219
|
* 確定判断の件数と字数の上限(★中央 ai-kana の DISPATCH_CONTEXT_LIMITS と同じ値にすること)。
|
|
209
220
|
* 中央が守っているが、配り役でも同じ上限で守る(受け取った物が多すぎても本文を太らせない)。
|
|
@@ -2375,6 +2386,10 @@ export async function fetchCommand(cfg) {
|
|
|
2375
2386
|
return null;
|
|
2376
2387
|
}
|
|
2377
2388
|
const cmd = { id: String(first.id), body: first.body };
|
|
2389
|
+
// ★投函の時刻は中央が返したときだけ受け取る(無ければ付けない=画面で断定しない)。
|
|
2390
|
+
const sentAt = parseSentAt(first.sentAt) ?? parseSentAt(first.createdAt);
|
|
2391
|
+
if (sentAt !== undefined)
|
|
2392
|
+
cmd.sentAt = sentAt;
|
|
2378
2393
|
// ★親の便・確定判断は、中央が返した物だけを形を確かめて受け取る(違う形は捨てる)。
|
|
2379
2394
|
if (typeof first.parentCommandId === 'string' && first.parentCommandId.trim()) {
|
|
2380
2395
|
cmd.parentCommandId = first.parentCommandId.trim();
|
|
@@ -2669,7 +2684,7 @@ export function decideHeartbeat(alive, throttleOk) {
|
|
|
2669
2684
|
* (=/seen の perm 系)。画面から質問を読む処理ごと撤去したため、送るのは body 無しの生存通知だけ。
|
|
2670
2685
|
* 確認そのものは PermissionRequest フックが別の口(hook-requests)でデータとして届ける。
|
|
2671
2686
|
*/
|
|
2672
|
-
export async function sendHeartbeat(cfg, id, busy = false) {
|
|
2687
|
+
export async function sendHeartbeat(cfg, id, busy = false, liveness) {
|
|
2673
2688
|
try {
|
|
2674
2689
|
// ★busy が【真偽値の true】のときだけ body を載せる。それ以外は今までと1バイトも変えない
|
|
2675
2690
|
// (body 無しの PATCH)。中央も "true" や 1 のような真偽値でない値は無視する仕様。
|
|
@@ -2677,7 +2692,7 @@ export async function sendHeartbeat(cfg, id, busy = false) {
|
|
|
2677
2692
|
? {
|
|
2678
2693
|
method: 'PATCH',
|
|
2679
2694
|
headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
|
|
2680
|
-
body: JSON.stringify({ busy: true }),
|
|
2695
|
+
body: JSON.stringify({ busy: true, ...(liveness ?? {}) }),
|
|
2681
2696
|
}
|
|
2682
2697
|
: { method: 'PATCH', headers: authHeaders(cfg) };
|
|
2683
2698
|
const res = await fetch(`${cfg.url}/api/conductor/commands/${encodeURIComponent(id)}/seen`, {
|