@winmatrix/supervisor 1.0.5
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/package.json +24 -0
- package/src/blocked-args-filter.mjs +95 -0
- package/src/claude-session-lib.mjs +487 -0
- package/src/deliverable-tracker.mjs +119 -0
- package/src/engine-driver-config.mjs +131 -0
- package/src/env-sanitizer.mjs +112 -0
- package/src/resume-degradation.mjs +117 -0
- package/src/run-agent-engine.mjs +1580 -0
- package/src/run-engine.mjs +192 -0
- package/src/token-usage.mjs +147 -0
|
@@ -0,0 +1,1580 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Agent Engine Supervisor CLI(task 5.1)
|
|
4
|
+
*
|
|
5
|
+
* 工作站容器内常驻 Agent Engine Supervisor,通过 Unix socket 提供统一接口。
|
|
6
|
+
* 实现幂等启动、子进程监管、状态/结果文件、callback、cancel marker。
|
|
7
|
+
*
|
|
8
|
+
* 用法:
|
|
9
|
+
* node run-agent-engine.mjs launch <json-payload> # 启动 Agent Engine
|
|
10
|
+
* node run-agent-engine.mjs status <runKey> # 查询运行状态
|
|
11
|
+
* node run-agent-engine.mjs cancel <runKey> # 取消运行
|
|
12
|
+
* node run-agent-engine.mjs list-active # 列出活跃运行
|
|
13
|
+
* node run-agent-engine.mjs health # 健康检查
|
|
14
|
+
*
|
|
15
|
+
* 设计约束(design D14):
|
|
16
|
+
* - 幂等启动:相同 runKey+fingerprint 返回 existing,不同 fingerprint 拒绝
|
|
17
|
+
* - 状态文件:<runDir>/<runKey>/status.json(running/completed/failed)
|
|
18
|
+
* - 结果文件:<runDir>/<runKey>/result.json
|
|
19
|
+
* - callback:完成后通过 HTTP POST 通知 Server
|
|
20
|
+
* - cancel marker:<runDir>/<runKey>/cancel.marker
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createRequire } from 'module';
|
|
24
|
+
import { createHash } from 'crypto';
|
|
25
|
+
import { fileURLToPath } from 'url';
|
|
26
|
+
// Claude Session 查询公共纯函数(task 7.15)
|
|
27
|
+
import {
|
|
28
|
+
loadSdk,
|
|
29
|
+
getOptValue,
|
|
30
|
+
hasFlag,
|
|
31
|
+
clampInt,
|
|
32
|
+
parseLimit,
|
|
33
|
+
encodeOffsetCursor,
|
|
34
|
+
decodeOffsetCursor,
|
|
35
|
+
toIsoString,
|
|
36
|
+
lastActivityMs,
|
|
37
|
+
buildSessionMeta,
|
|
38
|
+
extractText,
|
|
39
|
+
cloneMessagesForJson,
|
|
40
|
+
buildCliReplayGuide,
|
|
41
|
+
buildDiagnostics,
|
|
42
|
+
maybeTruncate,
|
|
43
|
+
resolveDirViaGlobalListSessions,
|
|
44
|
+
} from './claude-session-lib.mjs';
|
|
45
|
+
|
|
46
|
+
// D11.2-D11.4: Engine Driver 增强模块
|
|
47
|
+
import { getEngineDriverConfig } from './engine-driver-config.mjs';
|
|
48
|
+
import { sanitizeEnv, getFilteredEnvKeys } from './env-sanitizer.mjs';
|
|
49
|
+
import { filterBlockedArgs, formatBlockedArgsAudit } from './blocked-args-filter.mjs';
|
|
50
|
+
import { createDeliverableState, updateDeliverable, extractDeliverable, computeDeliverableFromEvents } from './deliverable-tracker.mjs';
|
|
51
|
+
import { extractTokenUsage } from './token-usage.mjs';
|
|
52
|
+
import { isResumeRejected, buildContinuityNotice, buildResumeDegradedEvent, isResumeFailureOnExit } from './resume-degradation.mjs';
|
|
53
|
+
|
|
54
|
+
const require = createRequire(import.meta.url);
|
|
55
|
+
const fs = require('node:fs');
|
|
56
|
+
const path = require('node:path');
|
|
57
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
58
|
+
const http = require('node:http');
|
|
59
|
+
const readline = require('node:readline');
|
|
60
|
+
const SCRIPT_PATH = fileURLToPath(import.meta.url);
|
|
61
|
+
|
|
62
|
+
// Supervisor 根目录(每个 runKey 一个子目录)
|
|
63
|
+
const SUPERVISOR_ROOT = process.env.WINMATRIX_SUPERVISOR_ROOT || '/tmp/agent-engine-supervisor';
|
|
64
|
+
const CALLBACK_TIMEOUT_MS = Number(process.env.WINMATRIX_CALLBACK_TIMEOUT_MS) || 5000;
|
|
65
|
+
const MAX_ACTIVE_RUNS = Number(process.env.WINMATRIX_MAX_ACTIVE_RUNS) || 100;
|
|
66
|
+
|
|
67
|
+
// 确保根目录存在
|
|
68
|
+
if (!fs.existsSync(SUPERVISOR_ROOT)) {
|
|
69
|
+
fs.mkdirSync(SUPERVISOR_ROOT, { recursive: true });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 运行状态注册表(内存中,重启后从文件系统恢复)
|
|
73
|
+
const activeRuns = new Map();
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 持久化活跃运行列表到 active-runs.json
|
|
77
|
+
*/
|
|
78
|
+
function saveActiveRuns() {
|
|
79
|
+
const activeRunsFile = path.join(SUPERVISOR_ROOT, 'active-runs.json');
|
|
80
|
+
const data = Array.from(activeRuns.entries()).map(([runKey, { metadata }]) => ({
|
|
81
|
+
runKey,
|
|
82
|
+
metadata,
|
|
83
|
+
}));
|
|
84
|
+
fs.writeFileSync(activeRunsFile, JSON.stringify(data, null, 2), 'utf8');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 从 active-runs.json 恢复活跃运行列表
|
|
89
|
+
*/
|
|
90
|
+
function loadActiveRuns() {
|
|
91
|
+
const activeRunsFile = path.join(SUPERVISOR_ROOT, 'active-runs.json');
|
|
92
|
+
if (!fs.existsSync(activeRunsFile)) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const data = JSON.parse(fs.readFileSync(activeRunsFile, 'utf8'));
|
|
97
|
+
for (const { runKey, metadata } of data) {
|
|
98
|
+
// 只恢复 running 状态的 run
|
|
99
|
+
if (metadata.status === 'running') {
|
|
100
|
+
activeRuns.set(runKey, { child: null, metadata });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.error('[run-agent-engine] Failed to load active-runs.json:', error.message);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 启动时恢复活跃运行列表
|
|
109
|
+
loadActiveRuns();
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Run 元数据(持久化到 status.json)
|
|
113
|
+
* @typedef {Object} RunMetadata
|
|
114
|
+
* @property {string} runKey - 运行唯一标识(instanceId:attemptNo)
|
|
115
|
+
* @property {string} invocationFingerprint - 调用指纹(SHA-256)
|
|
116
|
+
* @property {string} engineId - Engine ID(claude-code/codex/hermes)
|
|
117
|
+
* @property {string} status - 运行状态(running/completed/failed/cancelled)
|
|
118
|
+
* @property {number} pid - 子进程 PID
|
|
119
|
+
* @property {string} startTime - 启动时间(ISO 8601)
|
|
120
|
+
* @property {string} [endTime] - 结束时间(ISO 8601)
|
|
121
|
+
* @property {number} [exitCode] - 退出码
|
|
122
|
+
* @property {string} [error] - 错误信息
|
|
123
|
+
*/
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 递归排序对象的所有 key(与 Server 端 canonicalJson 一致)
|
|
127
|
+
* @param {any} obj - 要排序的对象
|
|
128
|
+
* @returns {any} 排序后的对象
|
|
129
|
+
*/
|
|
130
|
+
function canonicalize(obj) {
|
|
131
|
+
if (obj === null || typeof obj !== 'object') {
|
|
132
|
+
return obj;
|
|
133
|
+
}
|
|
134
|
+
if (Array.isArray(obj)) {
|
|
135
|
+
return obj.map(canonicalize);
|
|
136
|
+
}
|
|
137
|
+
const out = {};
|
|
138
|
+
for (const key of Object.keys(obj).sort()) {
|
|
139
|
+
out[key] = canonicalize(obj[key]);
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 计算 invocation fingerprint(SHA-256)
|
|
146
|
+
* 使用递归 key 排序,与 Server 端 canonicalJsonStringify 一致
|
|
147
|
+
* @param {object} launchSpec - Launch spec
|
|
148
|
+
* @returns {string} SHA-256 hash
|
|
149
|
+
*/
|
|
150
|
+
function computeFingerprint(launchSpec) {
|
|
151
|
+
const canonical = canonicalize(launchSpec);
|
|
152
|
+
const json = JSON.stringify(canonical);
|
|
153
|
+
return createHash('sha256').update(json).digest('hex');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 获取 run 目录路径
|
|
158
|
+
* @param {string} runKey - Run key
|
|
159
|
+
* @returns {string} Run directory path
|
|
160
|
+
*/
|
|
161
|
+
function getRunDir(runKey) {
|
|
162
|
+
assertSafeRunKey(runKey);
|
|
163
|
+
// 保留现有目录命名兼容,同时拒绝任何路径分隔符和遍历片段。
|
|
164
|
+
const safeKey = runKey.replace(/:/g, '_');
|
|
165
|
+
return path.join(SUPERVISOR_ROOT, safeKey);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function assertSafeRunKey(runKey) {
|
|
169
|
+
if (typeof runKey !== 'string' || !/^[A-Za-z0-9._-]+:[1-9][0-9]*$/.test(runKey)) {
|
|
170
|
+
throw new Error('Invalid runKey: expected safe <recordId>:<positive-attemptNo>');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function assertSafeSessionId(sessionId) {
|
|
175
|
+
if (typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]{1,256}$/.test(sessionId)) {
|
|
176
|
+
throw new Error('Invalid sessionId');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 保存 run 元数据到 status.json
|
|
182
|
+
* @param {string} runKey - Run key
|
|
183
|
+
* @param {RunMetadata} metadata - Run metadata
|
|
184
|
+
*/
|
|
185
|
+
function saveRunMetadata(runKey, metadata) {
|
|
186
|
+
const runDir = getRunDir(runKey);
|
|
187
|
+
if (!fs.existsSync(runDir)) {
|
|
188
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
189
|
+
}
|
|
190
|
+
const statusFile = path.join(runDir, 'status.json');
|
|
191
|
+
fs.writeFileSync(statusFile, JSON.stringify(metadata, null, 2), 'utf8');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 加载 run 元数据
|
|
196
|
+
* @param {string} runKey - Run key
|
|
197
|
+
* @returns {RunMetadata|null} Run metadata or null
|
|
198
|
+
*/
|
|
199
|
+
function loadRunMetadata(runKey) {
|
|
200
|
+
const runDir = getRunDir(runKey);
|
|
201
|
+
const statusFile = path.join(runDir, 'status.json');
|
|
202
|
+
if (!fs.existsSync(statusFile)) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
return JSON.parse(fs.readFileSync(statusFile, 'utf8'));
|
|
207
|
+
} catch (error) {
|
|
208
|
+
console.error(`[run-agent-engine] Failed to load status.json for ${runKey}:`, error.message);
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 保存结果到 result.json
|
|
215
|
+
* @param {string} runKey - Run key
|
|
216
|
+
* @param {object} result - Result data
|
|
217
|
+
*/
|
|
218
|
+
function saveResult(runKey, result) {
|
|
219
|
+
const runDir = getRunDir(runKey);
|
|
220
|
+
if (!fs.existsSync(runDir)) {
|
|
221
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
222
|
+
}
|
|
223
|
+
const resultFile = path.join(runDir, 'result.json');
|
|
224
|
+
fs.writeFileSync(resultFile, JSON.stringify(result, null, 2), 'utf8');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 加载结果
|
|
229
|
+
* @param {string} runKey - Run key
|
|
230
|
+
* @returns {object|null} Result data or null
|
|
231
|
+
*/
|
|
232
|
+
function loadResult(runKey) {
|
|
233
|
+
const runDir = getRunDir(runKey);
|
|
234
|
+
const resultFile = path.join(runDir, 'result.json');
|
|
235
|
+
if (!fs.existsSync(resultFile)) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
return JSON.parse(fs.readFileSync(resultFile, 'utf8'));
|
|
240
|
+
} catch (error) {
|
|
241
|
+
console.error(`[run-agent-engine] Failed to load result.json for ${runKey}:`, error.message);
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 创建 cancel marker
|
|
248
|
+
* @param {string} runKey - Run key
|
|
249
|
+
*/
|
|
250
|
+
function createCancelMarker(runKey) {
|
|
251
|
+
const runDir = getRunDir(runKey);
|
|
252
|
+
if (!fs.existsSync(runDir)) {
|
|
253
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
254
|
+
}
|
|
255
|
+
const cancelFile = path.join(runDir, 'cancel.marker');
|
|
256
|
+
fs.writeFileSync(cancelFile, new Date().toISOString(), 'utf8');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 检查 cancel marker 是否存在
|
|
261
|
+
* @param {string} runKey - Run key
|
|
262
|
+
* @returns {boolean} True if cancel marker exists
|
|
263
|
+
*/
|
|
264
|
+
function hasCancelMarker(runKey) {
|
|
265
|
+
const runDir = getRunDir(runKey);
|
|
266
|
+
const cancelFile = path.join(runDir, 'cancel.marker');
|
|
267
|
+
return fs.existsSync(cancelFile);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 构建 workstation 任务终态 callback payload(merge-workstation-tools R11 / 9b.5)。
|
|
272
|
+
*
|
|
273
|
+
* 对齐 V1 run-claude-agent.mjs 的字段语义,满足 Server 端
|
|
274
|
+
* /api/v1/workstation-task-callbacks 的 zod schema(recordId 必填、attemptNo 正整数)。
|
|
275
|
+
* runKey 形如 <recordId>:<attemptNo>,据此回解 record 身份。
|
|
276
|
+
*
|
|
277
|
+
* @param {string} runKey - Run key(<recordId>:<attemptNo>)
|
|
278
|
+
* @param {RunMetadata} endMetadata - 终态元数据(status/endTime/exitCode/...)
|
|
279
|
+
* @param {object} result - 终态结果(success/text/sessionId/error/usage)
|
|
280
|
+
* @param {RunMetadata} startMetadata - 启动元数据(startTime)
|
|
281
|
+
* @returns {object} Callback payload
|
|
282
|
+
*/
|
|
283
|
+
function buildTerminalCallbackPayload(runKey, endMetadata, result, startMetadata) {
|
|
284
|
+
const sep = runKey.lastIndexOf(':');
|
|
285
|
+
const recordId = sep > 0 ? runKey.slice(0, sep) : runKey;
|
|
286
|
+
const parsedAttempt = sep > 0 ? Number(runKey.slice(sep + 1)) : NaN;
|
|
287
|
+
const attemptNo = Number.isInteger(parsedAttempt) && parsedAttempt > 0 ? parsedAttempt : 1;
|
|
288
|
+
const runDir = getRunDir(runKey);
|
|
289
|
+
|
|
290
|
+
const startMs = Date.parse(startMetadata?.startTime ?? '');
|
|
291
|
+
const endMs = Date.parse(endMetadata?.endTime ?? '');
|
|
292
|
+
const durationSeconds = Number.isFinite(startMs) && Number.isFinite(endMs) && endMs >= startMs
|
|
293
|
+
? Math.round((endMs - startMs) / 1000)
|
|
294
|
+
: undefined;
|
|
295
|
+
|
|
296
|
+
const text = typeof result?.text === 'string' ? result.text : undefined;
|
|
297
|
+
return {
|
|
298
|
+
recordId,
|
|
299
|
+
attemptNo,
|
|
300
|
+
runKey,
|
|
301
|
+
status: endMetadata.status,
|
|
302
|
+
summary: text ? text.slice(0, 800) : undefined,
|
|
303
|
+
result: text,
|
|
304
|
+
error: typeof result?.error === 'string' ? result.error : undefined,
|
|
305
|
+
durationSeconds,
|
|
306
|
+
claudeSessionId: typeof result?.sessionId === 'string' ? result.sessionId : undefined,
|
|
307
|
+
statusFilePath: path.join(runDir, 'status.json'),
|
|
308
|
+
resultFilePath: path.join(runDir, 'result.json'),
|
|
309
|
+
completedAt: endMetadata.endTime,
|
|
310
|
+
metadata: {
|
|
311
|
+
exitCode: endMetadata.exitCode,
|
|
312
|
+
...(endMetadata.signal ? { signal: endMetadata.signal } : {}),
|
|
313
|
+
...(endMetadata.resumeDegraded ? { resumeDegraded: true } : {}),
|
|
314
|
+
...(result?.usage ? { usage: result.usage } : {}),
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* 发送 callback 到 Server
|
|
321
|
+
* @param {string} callbackUrl - Callback URL
|
|
322
|
+
* @param {string} token - Callback token
|
|
323
|
+
* @param {object} payload - Callback payload
|
|
324
|
+
* @returns {Promise<void>}
|
|
325
|
+
*/
|
|
326
|
+
async function sendCallback(callbackUrl, token, payload) {
|
|
327
|
+
return new Promise((resolve, reject) => {
|
|
328
|
+
const url = new URL(callbackUrl);
|
|
329
|
+
const data = JSON.stringify(payload);
|
|
330
|
+
const options = {
|
|
331
|
+
hostname: url.hostname,
|
|
332
|
+
port: url.port || 80,
|
|
333
|
+
path: url.pathname + url.search,
|
|
334
|
+
method: 'POST',
|
|
335
|
+
headers: {
|
|
336
|
+
'Content-Type': 'application/json',
|
|
337
|
+
'Content-Length': Buffer.byteLength(data),
|
|
338
|
+
'Authorization': `Bearer ${token}`,
|
|
339
|
+
},
|
|
340
|
+
timeout: CALLBACK_TIMEOUT_MS,
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const req = http.request(options, (res) => {
|
|
344
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
345
|
+
console.log(`[run-agent-engine] Callback sent successfully: ${callbackUrl}`);
|
|
346
|
+
resolve();
|
|
347
|
+
} else {
|
|
348
|
+
reject(new Error(`Callback failed with status ${res.statusCode}`));
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
req.on('error', (error) => {
|
|
353
|
+
console.error(`[run-agent-engine] Callback error:`, error.message);
|
|
354
|
+
reject(error);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
req.on('timeout', () => {
|
|
358
|
+
req.destroy();
|
|
359
|
+
reject(new Error('Callback timeout'));
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
req.write(data);
|
|
363
|
+
req.end();
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* 启动 Agent Engine
|
|
369
|
+
* @param {object} payload - Launch payload
|
|
370
|
+
* @returns {Promise<object>} Launch result
|
|
371
|
+
*/
|
|
372
|
+
async function runOwned(payload) {
|
|
373
|
+
const { runKey, invocationFingerprint, launchSpec, privateBindings } = payload;
|
|
374
|
+
|
|
375
|
+
if (!runKey || !invocationFingerprint || !launchSpec) {
|
|
376
|
+
throw new Error('Missing required fields: runKey, invocationFingerprint, launchSpec');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 幂等性检查:相同 runKey 是否已存在
|
|
380
|
+
const existingMetadata = loadRunMetadata(runKey);
|
|
381
|
+
if (existingMetadata) {
|
|
382
|
+
// 验证 fingerprint 是否匹配
|
|
383
|
+
if (existingMetadata.invocationFingerprint === invocationFingerprint) {
|
|
384
|
+
console.log(`[run-agent-engine] Run already exists: ${runKey}`);
|
|
385
|
+
return {
|
|
386
|
+
disposition: 'existing',
|
|
387
|
+
ref: {
|
|
388
|
+
runKey,
|
|
389
|
+
invocationFingerprint,
|
|
390
|
+
engineId: launchSpec.engineId,
|
|
391
|
+
instance: { instanceId: payload.instanceId, hostKind: 'workstation' },
|
|
392
|
+
executionSnapshot: {
|
|
393
|
+
hostProfile: 'coding',
|
|
394
|
+
digitalEmployeeId: payload.instanceId,
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
executionDurability: 'host_persistent',
|
|
398
|
+
eventStreamMode: 'replay',
|
|
399
|
+
};
|
|
400
|
+
} else {
|
|
401
|
+
throw new Error(`Fingerprint mismatch for runKey=${runKey}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// 验证 fingerprint
|
|
406
|
+
const computedFingerprint = computeFingerprint(launchSpec);
|
|
407
|
+
if (computedFingerprint !== invocationFingerprint) {
|
|
408
|
+
throw new Error(`Invalid invocation fingerprint: expected=${computedFingerprint}, got=${invocationFingerprint}`);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// 检查并发限制
|
|
412
|
+
if (activeRuns.size >= MAX_ACTIVE_RUNS) {
|
|
413
|
+
throw new Error(`Max active runs limit reached: ${MAX_ACTIVE_RUNS}`);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// 构建 argv(D11.3: 自动过滤 blocked args)
|
|
417
|
+
const argv = buildEngineArgv(launchSpec);
|
|
418
|
+
|
|
419
|
+
// D11.3: 使用 sanitized 环境变量,过滤 Supervisor 内部标记
|
|
420
|
+
const filteredEnvKeys = getFilteredEnvKeys();
|
|
421
|
+
if (filteredEnvKeys.length > 0) {
|
|
422
|
+
console.log(`[run-agent-engine] Filtering ${filteredEnvKeys.length} env vars from child process: ${filteredEnvKeys.join(', ')}`);
|
|
423
|
+
}
|
|
424
|
+
const env = sanitizeEnv({
|
|
425
|
+
// Gateway/Supervisor owns durable completion and callback delivery. The
|
|
426
|
+
// legacy Claude runner must not mutate project-local hook settings here.
|
|
427
|
+
WIN_AGENT_ENGINE_GATEWAY_RUN: 'true',
|
|
428
|
+
...launchSpec.nonSensitiveEnv,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// 准备 stdin(包含 sensitive bindings)
|
|
432
|
+
const stdinData = privateBindings ? JSON.stringify(privateBindings) : '';
|
|
433
|
+
|
|
434
|
+
// 启动子进程
|
|
435
|
+
const runDir = getRunDir(runKey);
|
|
436
|
+
if (!fs.existsSync(runDir)) {
|
|
437
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const logFile = path.join(runDir, 'stdout.log');
|
|
441
|
+
const errFile = path.join(runDir, 'stderr.log');
|
|
442
|
+
// 先清空旧 stdout 日志
|
|
443
|
+
try { fs.writeFileSync(logFile, '', 'utf8'); } catch { /* ignore */ }
|
|
444
|
+
|
|
445
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
446
|
+
env,
|
|
447
|
+
cwd: launchSpec.workDir || process.cwd(),
|
|
448
|
+
stdio: ['pipe', 'pipe', fs.openSync(errFile, 'a')],
|
|
449
|
+
// D11.2: detached=true 用于创建独立进程组,两阶段关闭时可 SIGTERM 整组
|
|
450
|
+
detached: process.platform !== 'win32',
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// 写入 stdin
|
|
454
|
+
if (stdinData) {
|
|
455
|
+
child.stdin.write(stdinData);
|
|
456
|
+
child.stdin.end();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// 读取 stdout JSONL 事件,同时写入 stdout.log
|
|
460
|
+
const stdoutWriteStream = fs.createWriteStream(logFile, { flags: 'a' });
|
|
461
|
+
const rl = readline.createInterface({ input: child.stdout });
|
|
462
|
+
rl.on('line', (line) => {
|
|
463
|
+
stdoutWriteStream.write(`${line}\n`);
|
|
464
|
+
if (!line.trim()) return;
|
|
465
|
+
let event;
|
|
466
|
+
try {
|
|
467
|
+
event = JSON.parse(line);
|
|
468
|
+
} catch {
|
|
469
|
+
// 非 JSON 行视为 stderr 风格诊断输出,不进入事件流
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
appendEvent(runKey, metadata, event);
|
|
473
|
+
deliverableState = updateDeliverable(deliverableState, event);
|
|
474
|
+
});
|
|
475
|
+
child.stdout.on('close', () => {
|
|
476
|
+
stdoutWriteStream.end();
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// 保存元数据
|
|
480
|
+
const metadata = {
|
|
481
|
+
runKey,
|
|
482
|
+
invocationFingerprint,
|
|
483
|
+
engineId: launchSpec.engineId,
|
|
484
|
+
status: 'running',
|
|
485
|
+
pid: child.pid,
|
|
486
|
+
startTime: new Date().toISOString(),
|
|
487
|
+
instanceId: payload.instanceId,
|
|
488
|
+
lastSequence: 0,
|
|
489
|
+
};
|
|
490
|
+
saveRunMetadata(runKey, metadata);
|
|
491
|
+
activeRuns.set(runKey, { child, metadata });
|
|
492
|
+
saveActiveRuns();
|
|
493
|
+
|
|
494
|
+
console.log(`[run-agent-engine] Launched run: ${runKey} (pid=${child.pid})`);
|
|
495
|
+
|
|
496
|
+
appendEvent(runKey, metadata, { type: 'status', text: 'running' });
|
|
497
|
+
|
|
498
|
+
// D11.2: 获取 Engine driver 配置(gracefulShutdownTimeoutMs 等)
|
|
499
|
+
const driverConfig = getEngineDriverConfig(launchSpec.engineId);
|
|
500
|
+
|
|
501
|
+
// D11.4: 初始化 deliverable tracker
|
|
502
|
+
let deliverableState = createDeliverableState();
|
|
503
|
+
|
|
504
|
+
// D11.5: 跟踪 stderr 用于 resume 降级检测
|
|
505
|
+
let stderrAccumulator = '';
|
|
506
|
+
|
|
507
|
+
// D11.2: 两阶段优雅关闭函数
|
|
508
|
+
function gracefulShutdown(killSignal) {
|
|
509
|
+
if (child.killed || child.exitCode !== null) return;
|
|
510
|
+
|
|
511
|
+
console.log(`[run-agent-engine] D11.2: Phase 1 graceful shutdown for ${runKey} (stdin.close)`);
|
|
512
|
+
|
|
513
|
+
// Phase 1: stdin.Close() → 让 Engine 自行写状态文件并退出
|
|
514
|
+
try {
|
|
515
|
+
child.stdin.end();
|
|
516
|
+
} catch {
|
|
517
|
+
// stdin 可能已经关闭
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Phase 1 超时后进入 Phase 2
|
|
521
|
+
const phase1Timer = setTimeout(() => {
|
|
522
|
+
if (child.exitCode !== null) return; // Engine 已退出
|
|
523
|
+
|
|
524
|
+
console.log(`[run-agent-engine] D11.2: Phase 1 timeout (${driverConfig.gracefulShutdownTimeoutMs}ms), entering Phase 2 (SIGTERM)`);
|
|
525
|
+
|
|
526
|
+
// Phase 2: SIGTERM 进程组
|
|
527
|
+
try {
|
|
528
|
+
if (process.platform !== 'win32' && child.pid) {
|
|
529
|
+
// Unix: 发送到进程组
|
|
530
|
+
try { process.kill(-child.pid, 'SIGTERM'); } catch { /* 进程组可能不存在 */ }
|
|
531
|
+
} else {
|
|
532
|
+
child.kill('SIGTERM');
|
|
533
|
+
}
|
|
534
|
+
} catch {
|
|
535
|
+
// 进程可能已退出
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Phase 2 超时后进入 Phase 3
|
|
539
|
+
const phase2Timer = setTimeout(() => {
|
|
540
|
+
if (child.exitCode !== null) return;
|
|
541
|
+
|
|
542
|
+
console.log(`[run-agent-engine] D11.2: Phase 2 timeout (${driverConfig.phase2TimeoutMs}ms), entering Phase 3 (SIGKILL)`);
|
|
543
|
+
|
|
544
|
+
// Phase 3: SIGKILL 进程组
|
|
545
|
+
try {
|
|
546
|
+
if (process.platform !== 'win32' && child.pid) {
|
|
547
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch { /* 进程组可能不存在 */ }
|
|
548
|
+
} else {
|
|
549
|
+
child.kill('SIGKILL');
|
|
550
|
+
}
|
|
551
|
+
} catch {
|
|
552
|
+
// 进程可能已退出
|
|
553
|
+
}
|
|
554
|
+
}, driverConfig.phase2TimeoutMs);
|
|
555
|
+
|
|
556
|
+
// 确保 phase2Timer 不阻止进程退出
|
|
557
|
+
if (phase2Timer.unref) phase2Timer.unref();
|
|
558
|
+
}, driverConfig.gracefulShutdownTimeoutMs);
|
|
559
|
+
|
|
560
|
+
// 确保 phase1Timer 不阻止进程退出
|
|
561
|
+
if (phase1Timer.unref) phase1Timer.unref();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// D11.2: cancel marker 轮询使用两阶段关闭
|
|
565
|
+
const cancelPoll = setInterval(() => {
|
|
566
|
+
if (hasCancelMarker(runKey) && !child.killed && child.exitCode === null) {
|
|
567
|
+
gracefulShutdown('SIGTERM');
|
|
568
|
+
}
|
|
569
|
+
}, 500);
|
|
570
|
+
|
|
571
|
+
const completion = new Promise((resolve) => child.on('exit', async (code, signal) => {
|
|
572
|
+
clearInterval(cancelPoll);
|
|
573
|
+
console.log(`[run-agent-engine] Run exited: ${runKey} (code=${code}, signal=${signal})`);
|
|
574
|
+
|
|
575
|
+
const cancelled = hasCancelMarker(runKey);
|
|
576
|
+
|
|
577
|
+
// D11.2: Resume 降级检测
|
|
578
|
+
// 检查是否是 resume session 且 Engine 输出包含拒绝模式
|
|
579
|
+
const isResume = launchSpec.session?.mode === 'resume';
|
|
580
|
+
const resumeFailed = isResume && (
|
|
581
|
+
isResumeFailureOnExit(launchSpec.engineId, code, stderrAccumulator)
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
// D11.4: 从事件文件计算 deliverable 和 token usage
|
|
585
|
+
let resultText = '';
|
|
586
|
+
let usage = null;
|
|
587
|
+
let resultSessionId = null;
|
|
588
|
+
try {
|
|
589
|
+
const eventsFile = path.join(getRunDir(runKey), 'events.jsonl');
|
|
590
|
+
if (fs.existsSync(eventsFile)) {
|
|
591
|
+
const eventLines = fs.readFileSync(eventsFile, 'utf8').split('\n').filter(Boolean);
|
|
592
|
+
const events = eventLines.map(line => {
|
|
593
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
594
|
+
}).filter(Boolean);
|
|
595
|
+
|
|
596
|
+
// D11.4: Deliverable 提取
|
|
597
|
+
resultText = computeDeliverableFromEvents(events);
|
|
598
|
+
|
|
599
|
+
// D11.4: Token Usage 多路获取
|
|
600
|
+
usage = extractTokenUsage(events, {
|
|
601
|
+
engineId: launchSpec.engineId,
|
|
602
|
+
sessionId: launchSpec.session?.session?.sessionId,
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
// 提取 fresh run 产生的新 sessionId(最后一个 result 事件携带;
|
|
606
|
+
// 行可能是 envelope({event})或裸事件,与 token-usage 解析保持一致)
|
|
607
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
608
|
+
const evt = events[i]?.event ?? events[i];
|
|
609
|
+
if (evt?.type === 'result' && typeof evt?.result?.sessionId === 'string') {
|
|
610
|
+
resultSessionId = evt.result.sessionId;
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
} catch (err) {
|
|
616
|
+
console.error(`[run-agent-engine] Failed to compute deliverable/usage for ${runKey}:`, err.message);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const endMetadata = {
|
|
620
|
+
...metadata,
|
|
621
|
+
status: cancelled ? 'cancelled' : code === 0 ? 'completed' : 'failed',
|
|
622
|
+
endTime: new Date().toISOString(),
|
|
623
|
+
exitCode: code,
|
|
624
|
+
...(signal ? { signal } : {}),
|
|
625
|
+
...(resumeFailed ? { resumeDegraded: true } : {}),
|
|
626
|
+
};
|
|
627
|
+
saveRunMetadata(runKey, endMetadata);
|
|
628
|
+
activeRuns.delete(runKey);
|
|
629
|
+
saveActiveRuns();
|
|
630
|
+
|
|
631
|
+
// D11.4: 构建包含 deliverable 和 usage 的结果
|
|
632
|
+
const result = {
|
|
633
|
+
success: !cancelled && code === 0,
|
|
634
|
+
text: resultText || undefined,
|
|
635
|
+
...(resultSessionId ? { sessionId: resultSessionId } : {}),
|
|
636
|
+
...(usage ? { usage } : {}),
|
|
637
|
+
...(cancelled ? { error: 'cancelled' } : code === 0 ? {} : { error: `Engine exited with code ${code}` }),
|
|
638
|
+
metadata: {
|
|
639
|
+
exitCode: code,
|
|
640
|
+
...(signal ? { signal } : {}),
|
|
641
|
+
...(resumeFailed ? { resumeDegraded: true } : {}),
|
|
642
|
+
...(usage?.source ? { usageSource: usage.source } : {}),
|
|
643
|
+
},
|
|
644
|
+
};
|
|
645
|
+
saveResult(runKey, result);
|
|
646
|
+
appendEvent(runKey, endMetadata, { type: 'result', result });
|
|
647
|
+
|
|
648
|
+
// 发送 callback(如果有)
|
|
649
|
+
// merge-workstation-tools R11 / 9b.5:payload 对齐 V1 run-claude-agent.mjs
|
|
650
|
+
// 字段语义,满足 /api/v1/workstation-task-callbacks 的 zod schema(recordId 必填),
|
|
651
|
+
// 使 HTTP callback 快路径对 V2 run 也能闭合(此前仅 observer 投影 + reconcile 兜底)。
|
|
652
|
+
if (privateBindings?.callback) {
|
|
653
|
+
sendCallback(
|
|
654
|
+
privateBindings.callback.url,
|
|
655
|
+
privateBindings.callback.token,
|
|
656
|
+
buildTerminalCallbackPayload(runKey, endMetadata, result, metadata),
|
|
657
|
+
).catch((error) => {
|
|
658
|
+
console.error(`[run-agent-engine] Callback failed for ${runKey}:`, error.message);
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
resolve();
|
|
662
|
+
}));
|
|
663
|
+
|
|
664
|
+
return { ack: {
|
|
665
|
+
disposition: 'accepted',
|
|
666
|
+
ref: {
|
|
667
|
+
runKey,
|
|
668
|
+
invocationFingerprint,
|
|
669
|
+
engineId: launchSpec.engineId,
|
|
670
|
+
instance: { instanceId: payload.instanceId, hostKind: 'workstation' },
|
|
671
|
+
executionSnapshot: {
|
|
672
|
+
hostProfile: 'coding',
|
|
673
|
+
digitalEmployeeId: payload.instanceId,
|
|
674
|
+
},
|
|
675
|
+
},
|
|
676
|
+
executionDurability: 'host_persistent',
|
|
677
|
+
eventStreamMode: 'replay',
|
|
678
|
+
eventRetention: { maxEvents: 10000 },
|
|
679
|
+
runtimeVersion: 'run-agent-engine/1',
|
|
680
|
+
protocolVersion: '1',
|
|
681
|
+
}, completion };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function appendEvent(runKey, metadata, event) {
|
|
685
|
+
const sequence = Number(metadata.lastSequence ?? 0) + 1;
|
|
686
|
+
metadata.lastSequence = sequence;
|
|
687
|
+
saveRunMetadata(runKey, metadata);
|
|
688
|
+
const envelope = {
|
|
689
|
+
runKey,
|
|
690
|
+
engineId: metadata.engineId,
|
|
691
|
+
invocationFingerprint: metadata.invocationFingerprint,
|
|
692
|
+
sequence,
|
|
693
|
+
timestamp: new Date().toISOString(),
|
|
694
|
+
event,
|
|
695
|
+
};
|
|
696
|
+
fs.appendFileSync(path.join(getRunDir(runKey), 'events.jsonl'), `${JSON.stringify(envelope)}\n`, 'utf8');
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function buildRunAck(payload, disposition) {
|
|
700
|
+
return {
|
|
701
|
+
disposition,
|
|
702
|
+
ref: {
|
|
703
|
+
runKey: payload.runKey,
|
|
704
|
+
invocationFingerprint: payload.invocationFingerprint,
|
|
705
|
+
engineId: payload.launchSpec.engineId,
|
|
706
|
+
instance: { instanceId: payload.instanceId, hostKind: 'workstation' },
|
|
707
|
+
executionSnapshot: {},
|
|
708
|
+
},
|
|
709
|
+
executionDurability: 'host_persistent',
|
|
710
|
+
eventStreamMode: 'replay',
|
|
711
|
+
eventRetention: { maxEvents: 10000 },
|
|
712
|
+
runtimeVersion: 'run-agent-engine/1',
|
|
713
|
+
protocolVersion: '1',
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async function launch(payload) {
|
|
718
|
+
const { runKey, invocationFingerprint, launchSpec, instanceId } = payload;
|
|
719
|
+
if (!runKey || !invocationFingerprint || !launchSpec || !instanceId) {
|
|
720
|
+
throw new Error('Missing required fields: runKey, invocationFingerprint, launchSpec, instanceId');
|
|
721
|
+
}
|
|
722
|
+
assertSafeRunKey(runKey);
|
|
723
|
+
if (computeFingerprint(launchSpec) !== invocationFingerprint) {
|
|
724
|
+
throw new Error('Invalid invocation fingerprint');
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const existing = loadRunMetadata(runKey);
|
|
728
|
+
if (existing) {
|
|
729
|
+
if (existing.invocationFingerprint !== invocationFingerprint) {
|
|
730
|
+
throw new Error(`Fingerprint mismatch for runKey=${runKey}`);
|
|
731
|
+
}
|
|
732
|
+
return buildRunAck(payload, 'existing');
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const claimFile = path.join(SUPERVISOR_ROOT, `${runKey.replace(/:/g, '_')}.launch`);
|
|
736
|
+
const waitForClaimedRun = async () => {
|
|
737
|
+
const deadline = Date.now() + 2_000;
|
|
738
|
+
while (Date.now() < deadline) {
|
|
739
|
+
const metadata = loadRunMetadata(runKey);
|
|
740
|
+
if (metadata) {
|
|
741
|
+
if (metadata.invocationFingerprint !== invocationFingerprint) {
|
|
742
|
+
throw new Error(`Fingerprint mismatch for runKey=${runKey}`);
|
|
743
|
+
}
|
|
744
|
+
return metadata;
|
|
745
|
+
}
|
|
746
|
+
if (!fs.existsSync(claimFile)) break;
|
|
747
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
748
|
+
}
|
|
749
|
+
throw new Error(`Claimed run did not start: ${runKey}`);
|
|
750
|
+
};
|
|
751
|
+
let claim;
|
|
752
|
+
try {
|
|
753
|
+
claim = fs.openSync(claimFile, 'wx', 0o600);
|
|
754
|
+
fs.writeFileSync(claim, invocationFingerprint, 'utf8');
|
|
755
|
+
} catch (error) {
|
|
756
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
757
|
+
const claimedFingerprint = fs.readFileSync(claimFile, 'utf8');
|
|
758
|
+
if (claimedFingerprint !== invocationFingerprint) {
|
|
759
|
+
throw new Error(`Fingerprint mismatch for runKey=${runKey}`);
|
|
760
|
+
}
|
|
761
|
+
await waitForClaimedRun();
|
|
762
|
+
return buildRunAck(payload, 'existing');
|
|
763
|
+
} finally {
|
|
764
|
+
if (claim !== undefined) fs.closeSync(claim);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (process.env.WINMATRIX_AGENT_ENGINE_TEST_INLINE === 'true') {
|
|
768
|
+
try {
|
|
769
|
+
const owned = await runOwned(payload);
|
|
770
|
+
return owned.ack;
|
|
771
|
+
} catch (error) {
|
|
772
|
+
fs.rmSync(claimFile, { force: true });
|
|
773
|
+
throw error;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const worker = spawn(process.execPath, [SCRIPT_PATH, 'supervise-run'], {
|
|
778
|
+
detached: true,
|
|
779
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
780
|
+
env: process.env,
|
|
781
|
+
});
|
|
782
|
+
worker.stdin.end(JSON.stringify(payload));
|
|
783
|
+
worker.unref();
|
|
784
|
+
const deadline = Date.now() + 2_000;
|
|
785
|
+
while (!loadRunMetadata(runKey) && Date.now() < deadline) {
|
|
786
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
787
|
+
}
|
|
788
|
+
if (!loadRunMetadata(runKey)) {
|
|
789
|
+
fs.rmSync(claimFile, { force: true });
|
|
790
|
+
throw new Error(`Run worker failed to start: ${runKey}`);
|
|
791
|
+
}
|
|
792
|
+
return buildRunAck(payload, 'accepted');
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function listEvents(runKey, afterSequence = 0) {
|
|
796
|
+
const file = path.join(getRunDir(runKey), 'events.jsonl');
|
|
797
|
+
if (!fs.existsSync(file)) return [];
|
|
798
|
+
return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean)
|
|
799
|
+
.map((line) => JSON.parse(line))
|
|
800
|
+
.filter((event) => event.sequence > afterSequence);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* 构建 Engine argv(不包含 sensitive bindings)
|
|
805
|
+
* @param {object} launchSpec - Launch spec
|
|
806
|
+
* @returns {string[]} argv
|
|
807
|
+
*/
|
|
808
|
+
function buildEngineArgv(launchSpec) {
|
|
809
|
+
const supportedEngines = ['claude-code', 'codex', 'hermes', 'openclaw'];
|
|
810
|
+
if (!supportedEngines.includes(launchSpec.engineId)) {
|
|
811
|
+
throw new Error(`Unsupported engineId: ${launchSpec.engineId}`);
|
|
812
|
+
}
|
|
813
|
+
if (process.env.WINMATRIX_AGENT_ENGINE_TEST_HOLD === 'true') {
|
|
814
|
+
const holdMs = Number(process.env.WINMATRIX_AGENT_ENGINE_TEST_HOLD_MS) || 30000;
|
|
815
|
+
return [process.execPath, '-e', `setTimeout(() => process.exit(0), ${holdMs})`];
|
|
816
|
+
}
|
|
817
|
+
const { engineId, model, permissionMode, allowedTools, session, input, timeoutMs, workDir } = launchSpec;
|
|
818
|
+
|
|
819
|
+
const argv = ['node', '/opt/winmatrix/run-engine.mjs', '--engine', engineId];
|
|
820
|
+
|
|
821
|
+
// 添加 model
|
|
822
|
+
if (model) {
|
|
823
|
+
argv.push('--model', model);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// 添加 permission-mode
|
|
827
|
+
if (permissionMode) {
|
|
828
|
+
argv.push('--permission-mode', permissionMode);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// 添加 allowed-tools
|
|
832
|
+
if (allowedTools && allowedTools.length > 0) {
|
|
833
|
+
argv.push('--allowed-tools', allowedTools.join(','));
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// 添加 work-dir
|
|
837
|
+
if (workDir) {
|
|
838
|
+
argv.push('--work-dir', workDir);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// 添加 session
|
|
842
|
+
if (session) {
|
|
843
|
+
if (session.mode === 'resume' && session.session?.sessionId) {
|
|
844
|
+
argv.push('--resume', session.session.sessionId);
|
|
845
|
+
} else if (session.mode === 'fork' && session.session?.sessionId) {
|
|
846
|
+
argv.push('--fork', session.session.sessionId);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// 添加 input(第一个 text block)
|
|
851
|
+
if (input?.blocks) {
|
|
852
|
+
for (const block of input.blocks) {
|
|
853
|
+
if (block.kind === 'text' && block.text) {
|
|
854
|
+
argv.push(block.text);
|
|
855
|
+
break;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// 添加 timeout
|
|
861
|
+
if (timeoutMs) {
|
|
862
|
+
const timeoutSec = Math.floor(timeoutMs / 1000);
|
|
863
|
+
argv.push('--timeout', String(timeoutSec));
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// D11.3: 添加用户 customArgs(过滤 blocked args 后)
|
|
867
|
+
if (launchSpec.engineOptions?.customArgs && Array.isArray(launchSpec.engineOptions.customArgs)) {
|
|
868
|
+
const { filtered, removed } = filterBlockedArgs(engineId, launchSpec.engineOptions.customArgs);
|
|
869
|
+
if (removed.length > 0) {
|
|
870
|
+
const audit = formatBlockedArgsAudit(removed, engineId);
|
|
871
|
+
console.log(`[run-agent-engine] ${audit}`);
|
|
872
|
+
// 记录到审计日志(通过 appendEvent,需要 runKey)
|
|
873
|
+
// 这里只做 console.log,实际审计在 runOwned 中通过 metadata 记录
|
|
874
|
+
}
|
|
875
|
+
argv.push(...filtered);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
return argv;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* 查询运行状态
|
|
883
|
+
* @param {string} runKey - Run key
|
|
884
|
+
* @returns {object} Run status
|
|
885
|
+
*/
|
|
886
|
+
function status(runKey) {
|
|
887
|
+
const metadata = loadRunMetadata(runKey);
|
|
888
|
+
if (!metadata) {
|
|
889
|
+
throw new Error(`Run not found: ${runKey}`);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const result = loadResult(runKey);
|
|
893
|
+
const cancelled = hasCancelMarker(runKey);
|
|
894
|
+
|
|
895
|
+
return {
|
|
896
|
+
ref: {
|
|
897
|
+
instance: { instanceId: metadata.instanceId, hostKind: 'workstation' },
|
|
898
|
+
executionSnapshot: {},
|
|
899
|
+
runKey: metadata.runKey,
|
|
900
|
+
engineId: metadata.engineId,
|
|
901
|
+
invocationFingerprint: metadata.invocationFingerprint,
|
|
902
|
+
},
|
|
903
|
+
status: cancelled ? 'cancelled' : metadata.status,
|
|
904
|
+
executionDurability: 'host_persistent',
|
|
905
|
+
eventStreamMode: 'replay',
|
|
906
|
+
eventRetention: { maxEvents: 10000 },
|
|
907
|
+
lastSequence: Number(metadata.lastSequence ?? 0),
|
|
908
|
+
pendingInteractions: [],
|
|
909
|
+
...(result ? { result } : {}),
|
|
910
|
+
recoverable: true,
|
|
911
|
+
runtimeVersion: 'run-agent-engine/1',
|
|
912
|
+
protocolVersion: '1',
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* 取消运行
|
|
918
|
+
* @param {string} runKey - Run key
|
|
919
|
+
* @returns {object} Cancel result
|
|
920
|
+
*/
|
|
921
|
+
function cancel(runKey) {
|
|
922
|
+
const metadata = loadRunMetadata(runKey);
|
|
923
|
+
if (!metadata) {
|
|
924
|
+
throw new Error(`Run not found: ${runKey}`);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
if (metadata.status !== 'running') {
|
|
928
|
+
return { success: false, message: 'Run is not running' };
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// 创建 cancel marker
|
|
932
|
+
createCancelMarker(runKey);
|
|
933
|
+
|
|
934
|
+
// D11.2: 两阶段优雅关闭(与 cancel poll 使用相同逻辑)
|
|
935
|
+
const activeRun = activeRuns.get(runKey);
|
|
936
|
+
if (activeRun && activeRun.child && activeRun.child.exitCode === null) {
|
|
937
|
+
const driverConfig = getEngineDriverConfig(activeRun.metadata.engineId);
|
|
938
|
+
|
|
939
|
+
// Phase 1: stdin.Close() → 让 Engine 自行写状态文件并退出
|
|
940
|
+
console.log(`[run-agent-engine] D11.2: Cancel Phase 1 for ${runKey} (stdin.close)`);
|
|
941
|
+
try { activeRun.child.stdin.end(); } catch { /* 可能已关闭 */ }
|
|
942
|
+
|
|
943
|
+
// Phase 1 超时后 Phase 2
|
|
944
|
+
setTimeout(() => {
|
|
945
|
+
if (!activeRuns.has(runKey)) return;
|
|
946
|
+
const child = activeRun.child;
|
|
947
|
+
if (!child || child.exitCode !== null) return;
|
|
948
|
+
|
|
949
|
+
console.log(`[run-agent-engine] D11.2: Cancel Phase 2 for ${runKey} (SIGTERM)`);
|
|
950
|
+
try {
|
|
951
|
+
if (process.platform !== 'win32' && child.pid) {
|
|
952
|
+
try { process.kill(-child.pid, 'SIGTERM'); } catch { /* 进程组可能不存在 */ }
|
|
953
|
+
} else {
|
|
954
|
+
child.kill('SIGTERM');
|
|
955
|
+
}
|
|
956
|
+
} catch { /* 进程可能已退出 */ }
|
|
957
|
+
|
|
958
|
+
// Phase 2 超时后 Phase 3
|
|
959
|
+
setTimeout(() => {
|
|
960
|
+
if (!activeRuns.has(runKey)) return;
|
|
961
|
+
if (!child || child.exitCode !== null) return;
|
|
962
|
+
|
|
963
|
+
console.log(`[run-agent-engine] D11.2: Cancel Phase 3 for ${runKey} (SIGKILL)`);
|
|
964
|
+
try {
|
|
965
|
+
if (process.platform !== 'win32' && child.pid) {
|
|
966
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch { /* 进程组可能不存在 */ }
|
|
967
|
+
} else {
|
|
968
|
+
child.kill('SIGKILL');
|
|
969
|
+
}
|
|
970
|
+
} catch { /* 进程可能已退出 */ }
|
|
971
|
+
}, driverConfig.phase2TimeoutMs);
|
|
972
|
+
}, driverConfig.gracefulShutdownTimeoutMs);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
return { success: true, message: 'Cancel signal sent' };
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* 列出活跃运行
|
|
980
|
+
* @returns {object[]} Active runs
|
|
981
|
+
*/
|
|
982
|
+
function listActive() {
|
|
983
|
+
const runs = [];
|
|
984
|
+
for (const [runKey, { metadata }] of activeRuns.entries()) {
|
|
985
|
+
const current = loadRunMetadata(runKey) ?? metadata;
|
|
986
|
+
if (!['running', 'starting', 'accepted', 'waiting_interaction', 'cancel_requested'].includes(current.status)) continue;
|
|
987
|
+
runs.push({
|
|
988
|
+
ref: {
|
|
989
|
+
instance: { instanceId: current.instanceId, hostKind: 'workstation' },
|
|
990
|
+
executionSnapshot: {},
|
|
991
|
+
runKey: current.runKey,
|
|
992
|
+
engineId: current.engineId,
|
|
993
|
+
invocationFingerprint: current.invocationFingerprint,
|
|
994
|
+
},
|
|
995
|
+
status: current.status,
|
|
996
|
+
startedAt: current.startTime,
|
|
997
|
+
lastSequence: Number(current.lastSequence ?? 0),
|
|
998
|
+
pendingInteractionCount: 0,
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
return { runs };
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* 健康检查
|
|
1006
|
+
* @returns {object} Health status
|
|
1007
|
+
*/
|
|
1008
|
+
function health() {
|
|
1009
|
+
return {
|
|
1010
|
+
status: 'ok',
|
|
1011
|
+
activeRuns: activeRuns.size,
|
|
1012
|
+
supervisorRoot: SUPERVISOR_ROOT,
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/* ── Engine 原生 Session 查询子命令(task 7.15) ──
|
|
1017
|
+
*
|
|
1018
|
+
* 这 4 个子命令是无状态、只读(delete 幂等)的 argv-native 入口,
|
|
1019
|
+
* 与 launch/status/cancel/list-active 的耐久性监管正交。
|
|
1020
|
+
* 输出 = protocol DTO JSON(EngineSessionPage / EngineSessionDetail +
|
|
1021
|
+
* metadata / EngineSessionSearchPage / EngineSessionDeleteReceipt)。
|
|
1022
|
+
* Host 透传 instanceId/engineId/sessionId,CLI 仅补充 source/summary 等元数据。
|
|
1023
|
+
*
|
|
1024
|
+
* argv 契约(design §1734-1737):
|
|
1025
|
+
* session-list --engine <id> --limit <n> [--cursor <c>] [--project-id <pid>] [--normalized-work-dir <dir>]
|
|
1026
|
+
* session-get --engine <id> --session-id <sid> --limit <n> [--cursor <c>]
|
|
1027
|
+
* [--include-raw-messages] [--include-system] [--no-truncate] [--normalized-work-dir <dir>]
|
|
1028
|
+
* session-search --engine <id> --query <text> --limit <n> [--cursor <c>] [--project-id <pid>] [--normalized-work-dir <dir>]
|
|
1029
|
+
* session-delete --engine <id> --session-id <sid> --request-id <rid>
|
|
1030
|
+
*/
|
|
1031
|
+
|
|
1032
|
+
/** instanceId / hostKind 由调用方(Sandbox API)通过环境变量注入(同一 Pod 内)。 */
|
|
1033
|
+
const SESSION_INSTANCE_ID = process.env.WINMATRIX_AGENT_ENGINE_INSTANCE_ID || '';
|
|
1034
|
+
const SESSION_HOST_KIND = 'workstation';
|
|
1035
|
+
|
|
1036
|
+
function redactSessionText(value) {
|
|
1037
|
+
let redacted = false;
|
|
1038
|
+
let text = String(value ?? '');
|
|
1039
|
+
const patterns = [
|
|
1040
|
+
/\b(Bearer\s+)[A-Za-z0-9._~+\/-]+=*/gi,
|
|
1041
|
+
/\b(sk-[A-Za-z0-9_-]{8,})\b/g,
|
|
1042
|
+
/\b(password|passwd|token|secret|api[_-]?key)\s*[:=]\s*([^\s,;]+)/gi,
|
|
1043
|
+
];
|
|
1044
|
+
text = text.replace(patterns[0], (_match, prefix) => { redacted = true; return `${prefix}[REDACTED]`; });
|
|
1045
|
+
text = text.replace(patterns[1], () => { redacted = true; return '[REDACTED]'; });
|
|
1046
|
+
text = text.replace(patterns[2], (_match, key) => { redacted = true; return `${key}=[REDACTED]`; });
|
|
1047
|
+
return { text, redacted };
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function deepRedactSessionValue(value, key = '') {
|
|
1051
|
+
if (/password|passwd|token|secret|api[_-]?key/i.test(key)) return '[REDACTED]';
|
|
1052
|
+
if (typeof value === 'string') return redactSessionText(value).text;
|
|
1053
|
+
if (Array.isArray(value)) return value.map((item) => deepRedactSessionValue(item));
|
|
1054
|
+
if (value && typeof value === 'object') {
|
|
1055
|
+
return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [
|
|
1056
|
+
childKey, deepRedactSessionValue(child, childKey),
|
|
1057
|
+
]));
|
|
1058
|
+
}
|
|
1059
|
+
return value;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* 把 SDK 宽松 session 记录适配为结构化 EngineSessionSummary(含 ref)。
|
|
1064
|
+
* instanceId/engineId/sessionId 由 Host 决定,CLI 不覆盖。
|
|
1065
|
+
* @param {object} meta buildSessionMeta 产出的元数据
|
|
1066
|
+
* @param {string} engineId
|
|
1067
|
+
*/
|
|
1068
|
+
function sessionSummary(meta, engineId) {
|
|
1069
|
+
const sessionId = meta.id != null ? String(meta.id) : '';
|
|
1070
|
+
const title =
|
|
1071
|
+
meta.customTitle != null ? meta.customTitle
|
|
1072
|
+
: meta.summary != null ? meta.summary
|
|
1073
|
+
: meta.firstPrompt != null ? meta.firstPrompt
|
|
1074
|
+
: undefined;
|
|
1075
|
+
const workDir = meta.cwd ?? meta.projectPath;
|
|
1076
|
+
|
|
1077
|
+
/** @type {Record<string, unknown>} */
|
|
1078
|
+
const summary = {
|
|
1079
|
+
ref: {
|
|
1080
|
+
instance: { instanceId: SESSION_INSTANCE_ID, hostKind: SESSION_HOST_KIND },
|
|
1081
|
+
engineId,
|
|
1082
|
+
sessionId,
|
|
1083
|
+
},
|
|
1084
|
+
source: 'engine_native',
|
|
1085
|
+
};
|
|
1086
|
+
if (title !== undefined) summary.title = title;
|
|
1087
|
+
if (meta.projectPath !== undefined) summary.projectId = undefined; // protocol projectId 为产品项目 id;此处不暴露
|
|
1088
|
+
if (workDir !== undefined) summary.workDir = workDir;
|
|
1089
|
+
if (meta.createdAt !== undefined) summary.createdAt = meta.createdAt;
|
|
1090
|
+
if (meta.lastActivityAt !== undefined) summary.lastActivityAt = meta.lastActivityAt;
|
|
1091
|
+
if (meta.messageCount !== undefined) summary.messageCount = meta.messageCount;
|
|
1092
|
+
return summary;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* session-list:SDK listSessions({dir?}) → EngineSessionPage。
|
|
1097
|
+
* dir = --normalized-work-dir(语义=SDK 的 dir=projectPath,非 HOME)。
|
|
1098
|
+
*/
|
|
1099
|
+
async function sessionList() {
|
|
1100
|
+
const argv = process.argv;
|
|
1101
|
+
const engineId = getOptValue(argv, '--engine') || 'claude-code';
|
|
1102
|
+
const limit = parseLimit(argv, 80, 200);
|
|
1103
|
+
const cursor = getOptValue(argv, '--cursor');
|
|
1104
|
+
const projectId = getOptValue(argv, '--project-id');
|
|
1105
|
+
const normalizedWorkDir = getOptValue(argv, '--normalized-work-dir');
|
|
1106
|
+
const offset = decodeOffsetCursor(cursor);
|
|
1107
|
+
|
|
1108
|
+
const { sdk, error } = loadSdk();
|
|
1109
|
+
if (!sdk || typeof sdk.listSessions !== 'function') {
|
|
1110
|
+
return { sessions: [], error: error ?? 'SDK 不支持 listSessions' };
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/** @type {Record<string, unknown>} */
|
|
1114
|
+
const opts = { limit: Math.min(400, limit * 2 + 20) };
|
|
1115
|
+
if (normalizedWorkDir) opts.dir = normalizedWorkDir;
|
|
1116
|
+
|
|
1117
|
+
const list = await sdk.listSessions(opts);
|
|
1118
|
+
const arr = Array.isArray(list) ? list : [];
|
|
1119
|
+
const sorted = [...arr].sort(
|
|
1120
|
+
(a, b) =>
|
|
1121
|
+
lastActivityMs(/** @type {Record<string, unknown>} */ (b)) -
|
|
1122
|
+
lastActivityMs(/** @type {Record<string, unknown>} */ (a)),
|
|
1123
|
+
);
|
|
1124
|
+
const sessions = sorted
|
|
1125
|
+
.slice(offset, offset + limit)
|
|
1126
|
+
.map((s) => sessionSummary(buildSessionMeta(/** @type {Record<string, unknown>} */ (s)), engineId));
|
|
1127
|
+
const result = { sessions };
|
|
1128
|
+
if (offset + limit < sorted.length) result.nextCursor = encodeOffsetCursor(offset + limit);
|
|
1129
|
+
return result;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function probeEngine(engineId, instanceId = 'unknown') {
|
|
1133
|
+
const command = engineId === 'claude-code' ? 'claude' : engineId;
|
|
1134
|
+
if (!['claude-code', 'codex', 'hermes'].includes(engineId)) {
|
|
1135
|
+
return {
|
|
1136
|
+
engineId, instanceId, hostKind: 'workstation', checkedAt: new Date().toISOString(),
|
|
1137
|
+
status: 'unavailable', binaryStatus: 'missing', credentialStatus: 'unknown',
|
|
1138
|
+
issues: [{ code: 'unsupported_engine', message: `Unsupported engine: ${engineId}` }],
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
const result = spawnSync(command, ['--version'], { encoding: 'utf8', timeout: 5000 });
|
|
1142
|
+
const found = !result.error || result.error.code !== 'ENOENT';
|
|
1143
|
+
return {
|
|
1144
|
+
engineId, instanceId, hostKind: 'workstation', checkedAt: new Date().toISOString(),
|
|
1145
|
+
status: found ? 'ready' : 'unavailable',
|
|
1146
|
+
binaryStatus: found ? 'found' : 'missing',
|
|
1147
|
+
credentialStatus: 'unknown',
|
|
1148
|
+
...(found ? { version: String(result.stdout || result.stderr || '').trim() || undefined } : {}),
|
|
1149
|
+
issues: found ? [] : [{ code: 'binary_missing', message: `${command} binary not found` }],
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function listEngines() {
|
|
1154
|
+
const engines = ['claude-code', 'codex', 'hermes'].map((engineId) => {
|
|
1155
|
+
const readiness = probeEngine(engineId);
|
|
1156
|
+
return {
|
|
1157
|
+
engineId,
|
|
1158
|
+
availability: readiness.status === 'ready' ? 'ready' : 'unavailable',
|
|
1159
|
+
...(readiness.version ? { version: readiness.version } : {}),
|
|
1160
|
+
};
|
|
1161
|
+
});
|
|
1162
|
+
return { engines };
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function listModels() {
|
|
1166
|
+
// CLI 没有稳定的离线模型发现命令时返回空目录,不用静态列表伪造实例能力。
|
|
1167
|
+
return { models: [] };
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function engineOptions(engineId) {
|
|
1171
|
+
return {
|
|
1172
|
+
engineId,
|
|
1173
|
+
modes: [],
|
|
1174
|
+
configOptions: [],
|
|
1175
|
+
commands: [],
|
|
1176
|
+
checkedAt: new Date().toISOString(),
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* session-get:SDK getSessionInfo + getSessionMessages → EngineSessionDetail + metadata。
|
|
1182
|
+
* metadata 透传富诊断(diagnostics/cliReplayGuide/userPrompts/assistantText/...)。
|
|
1183
|
+
*/
|
|
1184
|
+
async function sessionGet() {
|
|
1185
|
+
const argv = process.argv;
|
|
1186
|
+
const engineId = getOptValue(argv, '--engine') || 'claude-code';
|
|
1187
|
+
const sessionId = getOptValue(argv, '--session-id');
|
|
1188
|
+
if (!sessionId) throw new Error('session-get: --session-id 必填');
|
|
1189
|
+
assertSafeSessionId(sessionId);
|
|
1190
|
+
|
|
1191
|
+
const limit = clampInt(getOptValue(argv, '--limit') ?? '', 100, 1, 1000);
|
|
1192
|
+
const cursor = getOptValue(argv, '--cursor');
|
|
1193
|
+
const offset = decodeOffsetCursor(cursor);
|
|
1194
|
+
const includeRawMessages = hasFlag(argv, '--include-raw-messages');
|
|
1195
|
+
const includeSystem = hasFlag(argv, '--include-system');
|
|
1196
|
+
const noTruncate = hasFlag(argv, '--no-truncate');
|
|
1197
|
+
const normalizedWorkDir = getOptValue(argv, '--normalized-work-dir');
|
|
1198
|
+
|
|
1199
|
+
const { sdk, error } = loadSdk();
|
|
1200
|
+
if (!sdk || typeof sdk.getSessionInfo !== 'function' || typeof sdk.getSessionMessages !== 'function') {
|
|
1201
|
+
return {
|
|
1202
|
+
session: null,
|
|
1203
|
+
messages: [],
|
|
1204
|
+
rawMessagesIncluded: false,
|
|
1205
|
+
metadata: { error: error ?? 'SDK 不支持 getSessionInfo/getSessionMessages' },
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/** @type {Record<string, unknown>} */
|
|
1210
|
+
const infoOpts = {};
|
|
1211
|
+
if (normalizedWorkDir) infoOpts.dir = normalizedWorkDir;
|
|
1212
|
+
|
|
1213
|
+
/** @type {Record<string, unknown>} */
|
|
1214
|
+
const msgOpts = { limit, offset, includeSystemMessages: includeSystem };
|
|
1215
|
+
if (normalizedWorkDir) msgOpts.dir = normalizedWorkDir;
|
|
1216
|
+
|
|
1217
|
+
const info = await sdk.getSessionInfo(sessionId, infoOpts);
|
|
1218
|
+
if (!info) {
|
|
1219
|
+
return {
|
|
1220
|
+
session: null,
|
|
1221
|
+
messages: [],
|
|
1222
|
+
rawMessagesIncluded: false,
|
|
1223
|
+
metadata: { error: `未找到 sessionId=${sessionId}` },
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
const sessionMeta = buildSessionMeta(/** @type {Record<string, unknown>} */ (info));
|
|
1228
|
+
|
|
1229
|
+
// 未显式传 dir 时用 session 元数据路径补齐(与旧 detail mjs 行为一致)
|
|
1230
|
+
/** @type {'cli' | 'sessionMeta' | 'listSessionsGlobal' | undefined} */
|
|
1231
|
+
let dirSource;
|
|
1232
|
+
if (normalizedWorkDir) {
|
|
1233
|
+
dirSource = 'cli';
|
|
1234
|
+
} else {
|
|
1235
|
+
const autoDir = sessionMeta.projectPath || sessionMeta.cwd;
|
|
1236
|
+
if (autoDir) {
|
|
1237
|
+
msgOpts.dir = autoDir;
|
|
1238
|
+
dirSource = 'sessionMeta';
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/** @type {unknown[]} */
|
|
1243
|
+
let messages = [];
|
|
1244
|
+
try {
|
|
1245
|
+
const raw = await sdk.getSessionMessages(sessionId, msgOpts);
|
|
1246
|
+
messages = Array.isArray(raw) ? raw : [];
|
|
1247
|
+
// 三级 dir 回退:sessionMeta 路径取不到时,用 listSessions 全局列表里的 cwd 再试
|
|
1248
|
+
if (messages.length === 0 && typeof sdk.listSessions === 'function') {
|
|
1249
|
+
const fromList = await resolveDirViaGlobalListSessions(sdk, sessionId);
|
|
1250
|
+
const tried = msgOpts.dir ? String(msgOpts.dir).replace(/\/+$/, '') : '';
|
|
1251
|
+
if (fromList && fromList !== tried) {
|
|
1252
|
+
msgOpts.dir = fromList;
|
|
1253
|
+
const raw2 = await sdk.getSessionMessages(sessionId, msgOpts);
|
|
1254
|
+
messages = Array.isArray(raw2) ? raw2 : [];
|
|
1255
|
+
if (messages.length > 0) dirSource = 'listSessionsGlobal';
|
|
1256
|
+
} else if (fromList && !tried) {
|
|
1257
|
+
msgOpts.dir = fromList;
|
|
1258
|
+
const raw3 = await sdk.getSessionMessages(sessionId, msgOpts);
|
|
1259
|
+
messages = Array.isArray(raw3) ? raw3 : [];
|
|
1260
|
+
if (messages.length > 0) dirSource = 'listSessionsGlobal';
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
} catch (e) {
|
|
1264
|
+
return {
|
|
1265
|
+
session: sessionSummary(sessionMeta, engineId),
|
|
1266
|
+
messages: [],
|
|
1267
|
+
rawMessagesIncluded: false,
|
|
1268
|
+
metadata: {
|
|
1269
|
+
sessionRaw: sessionMeta,
|
|
1270
|
+
error: `getSessionMessages 失败: ${e.message}`,
|
|
1271
|
+
sessionMessagesDirSource: dirSource,
|
|
1272
|
+
},
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// 结构化 messages(user/assistant/system 文本块)
|
|
1277
|
+
/** @type {Array<{role: string; content: Array<{kind:'text'; text:string}>; redacted: boolean}>} */
|
|
1278
|
+
const structuredMessages = [];
|
|
1279
|
+
const userPrompts = [];
|
|
1280
|
+
const assistantTexts = [];
|
|
1281
|
+
let lastAssistantText = '';
|
|
1282
|
+
let anyTruncated = false;
|
|
1283
|
+
|
|
1284
|
+
for (const m of messages) {
|
|
1285
|
+
if (m == null || typeof m !== 'object') continue;
|
|
1286
|
+
const rec = /** @type {Record<string, unknown>} */ (m);
|
|
1287
|
+
const type = String(rec.type ?? '');
|
|
1288
|
+
const text = extractText(rec.message);
|
|
1289
|
+
const { text: shownBeforeRedaction, truncated } = maybeTruncate(text, noTruncate);
|
|
1290
|
+
const { text: shown, redacted } = redactSessionText(shownBeforeRedaction);
|
|
1291
|
+
if (truncated) anyTruncated = true;
|
|
1292
|
+
const role = type === 'user' ? 'user' : type === 'assistant' ? 'assistant' : type === 'system' ? 'system' : 'unknown';
|
|
1293
|
+
if (text) {
|
|
1294
|
+
structuredMessages.push({
|
|
1295
|
+
role,
|
|
1296
|
+
content: [{ kind: 'text', text: shown }],
|
|
1297
|
+
redacted,
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
if (type === 'user') userPrompts.push(shown);
|
|
1301
|
+
else if (type === 'assistant') {
|
|
1302
|
+
assistantTexts.push(shown);
|
|
1303
|
+
lastAssistantText = shown;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
const cliReplayGuide = buildCliReplayGuide(sessionMeta, sessionId);
|
|
1308
|
+
const diagnostics = deepRedactSessionValue(buildDiagnostics(messages, noTruncate));
|
|
1309
|
+
|
|
1310
|
+
/** @type {Record<string, unknown>} */
|
|
1311
|
+
const detail = {
|
|
1312
|
+
session: sessionSummary(sessionMeta, engineId),
|
|
1313
|
+
messages: structuredMessages,
|
|
1314
|
+
rawMessagesIncluded: includeRawMessages,
|
|
1315
|
+
};
|
|
1316
|
+
|
|
1317
|
+
/** @type {Record<string, unknown>} */
|
|
1318
|
+
const metadata = {
|
|
1319
|
+
messageCount: messages.length,
|
|
1320
|
+
truncated: anyTruncated,
|
|
1321
|
+
userPrompts,
|
|
1322
|
+
assistantText: assistantTexts.join('\n\n'),
|
|
1323
|
+
lastAssistantText,
|
|
1324
|
+
diagnostics,
|
|
1325
|
+
cliReplayGuide,
|
|
1326
|
+
sessionMessagesProjectDir: msgOpts.dir != null ? String(msgOpts.dir) : undefined,
|
|
1327
|
+
sessionMessagesDirSource: dirSource,
|
|
1328
|
+
};
|
|
1329
|
+
|
|
1330
|
+
if (includeRawMessages) {
|
|
1331
|
+
const { list, ok } = cloneMessagesForJson(messages);
|
|
1332
|
+
metadata.rawMessages = deepRedactSessionValue(list);
|
|
1333
|
+
if (!ok && messages.length > 0) metadata.rawMessagesSerializationFailed = true;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
return { ...detail, metadata };
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/**
|
|
1340
|
+
* session-search:SDK 无原生搜索,用 listSessions 内存过滤。
|
|
1341
|
+
* 命中 summary/customTitle/firstPrompt/cwd/projectName/projectPath/gitBranch/tag(大小写不敏感)。
|
|
1342
|
+
*/
|
|
1343
|
+
async function sessionSearch() {
|
|
1344
|
+
const argv = process.argv;
|
|
1345
|
+
const engineId = getOptValue(argv, '--engine') || 'claude-code';
|
|
1346
|
+
const text = getOptValue(argv, '--query');
|
|
1347
|
+
if (!text) throw new Error('session-search: --query 必填');
|
|
1348
|
+
const limit = parseLimit(argv, 50, 200);
|
|
1349
|
+
const cursor = getOptValue(argv, '--cursor');
|
|
1350
|
+
const offset = decodeOffsetCursor(cursor);
|
|
1351
|
+
const normalizedWorkDir = getOptValue(argv, '--normalized-work-dir');
|
|
1352
|
+
|
|
1353
|
+
const { sdk, error } = loadSdk();
|
|
1354
|
+
if (!sdk || typeof sdk.listSessions !== 'function') {
|
|
1355
|
+
return { hits: [], error: error ?? 'SDK 不支持 listSessions' };
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/** @type {Record<string, unknown>} */
|
|
1359
|
+
const opts = { limit: 800 };
|
|
1360
|
+
if (normalizedWorkDir) opts.dir = normalizedWorkDir;
|
|
1361
|
+
|
|
1362
|
+
const list = await sdk.listSessions(opts);
|
|
1363
|
+
const arr = Array.isArray(list) ? list : [];
|
|
1364
|
+
const needle = text.toLowerCase();
|
|
1365
|
+
const matched = [];
|
|
1366
|
+
for (const raw of arr) {
|
|
1367
|
+
const meta = buildSessionMeta(/** @type {Record<string, unknown>} */ (raw));
|
|
1368
|
+
const fields = [
|
|
1369
|
+
meta.summary, meta.customTitle, meta.firstPrompt, meta.cwd,
|
|
1370
|
+
meta.projectName, meta.projectPath, meta.gitBranch, meta.tag,
|
|
1371
|
+
];
|
|
1372
|
+
const hitField = fields.find((f) => typeof f === 'string' && f.toLowerCase().includes(needle));
|
|
1373
|
+
if (hitField !== undefined) {
|
|
1374
|
+
const summary = sessionSummary(meta, engineId);
|
|
1375
|
+
const snippet = String(hitField);
|
|
1376
|
+
matched.push({
|
|
1377
|
+
session: summary,
|
|
1378
|
+
matches: [{ snippet: snippet.length > 200 ? snippet.slice(0, 200) + '…' : snippet }],
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
const start = Math.min(offset, matched.length);
|
|
1383
|
+
const hits = matched.slice(start, start + limit);
|
|
1384
|
+
const nextOffset = start + hits.length;
|
|
1385
|
+
const result = { hits };
|
|
1386
|
+
if (nextOffset < matched.length) {
|
|
1387
|
+
result.nextCursor = encodeOffsetCursor(nextOffset);
|
|
1388
|
+
}
|
|
1389
|
+
return result;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* session-delete:优先 SDK deleteSession,兜底删 jsonl 文件(幂等 already_deleted)。
|
|
1394
|
+
*/
|
|
1395
|
+
async function sessionDelete() {
|
|
1396
|
+
const argv = process.argv;
|
|
1397
|
+
const sessionId = getOptValue(argv, '--session-id');
|
|
1398
|
+
if (!sessionId) throw new Error('session-delete: --session-id 必填');
|
|
1399
|
+
assertSafeSessionId(sessionId);
|
|
1400
|
+
const requestId = getOptValue(argv, '--request-id');
|
|
1401
|
+
if (!requestId) throw new Error('session-delete: --request-id 必填');
|
|
1402
|
+
|
|
1403
|
+
const { sdk } = loadSdk();
|
|
1404
|
+
|
|
1405
|
+
// 优先 SDK 原生删除(若可用)
|
|
1406
|
+
if (sdk && typeof sdk.deleteSession === 'function') {
|
|
1407
|
+
try {
|
|
1408
|
+
const res = await sdk.deleteSession(sessionId, { requestId });
|
|
1409
|
+
if (res && typeof res === 'object' && 'status' in res) {
|
|
1410
|
+
const status = /** @type {{ status?: string }} */ (res).status;
|
|
1411
|
+
if (status === 'deleted' || status === 'already_deleted' || status === 'rejected') {
|
|
1412
|
+
return { status };
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
} catch (e) {
|
|
1416
|
+
// SDK 删除失败,回退到文件删除
|
|
1417
|
+
void e;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// 兜底:删除 $HOME/.claude/projects/<encoded-cwd>/<sessionId>.jsonl
|
|
1422
|
+
const home = process.env.HOME?.trim();
|
|
1423
|
+
if (!home) {
|
|
1424
|
+
return {
|
|
1425
|
+
status: 'rejected',
|
|
1426
|
+
error: { code: 'no_home', message: 'session-delete: HOME 未设置,无法定位会话文件', retryable: false },
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const projectsDir = path.join(home, '.claude', 'projects');
|
|
1431
|
+
let deleted = false;
|
|
1432
|
+
try {
|
|
1433
|
+
if (fs.existsSync(projectsDir)) {
|
|
1434
|
+
for (const entry of fs.readdirSync(projectsDir)) {
|
|
1435
|
+
const file = path.join(projectsDir, entry, `${sessionId}.jsonl`);
|
|
1436
|
+
if (fs.existsSync(file)) {
|
|
1437
|
+
fs.unlinkSync(file);
|
|
1438
|
+
deleted = true;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
// best-effort 清理 ~/.claude/sessions/*.json 索引
|
|
1443
|
+
const sessionsDir = path.join(home, '.claude', 'sessions');
|
|
1444
|
+
if (fs.existsSync(sessionsDir)) {
|
|
1445
|
+
for (const entry of fs.readdirSync(sessionsDir)) {
|
|
1446
|
+
if (!entry.endsWith('.json')) continue;
|
|
1447
|
+
const idxFile = path.join(sessionsDir, entry);
|
|
1448
|
+
try {
|
|
1449
|
+
const idx = JSON.parse(fs.readFileSync(idxFile, 'utf8'));
|
|
1450
|
+
if (idx && String(idx.session_id ?? idx.sessionId ?? '') === sessionId) {
|
|
1451
|
+
fs.unlinkSync(idxFile);
|
|
1452
|
+
}
|
|
1453
|
+
} catch {
|
|
1454
|
+
// 索引文件损坏,跳过
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
} catch (e) {
|
|
1459
|
+
return {
|
|
1460
|
+
status: 'rejected',
|
|
1461
|
+
error: { code: 'delete_failed', message: `session-delete: ${e.message}`, retryable: true },
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
return { status: deleted ? 'deleted' : 'already_deleted' };
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* CLI 入口
|
|
1470
|
+
*/
|
|
1471
|
+
async function main() {
|
|
1472
|
+
const command = process.argv[2];
|
|
1473
|
+
const arg = process.argv[3];
|
|
1474
|
+
const fileFlag = process.argv[4];
|
|
1475
|
+
|
|
1476
|
+
if (!command) {
|
|
1477
|
+
console.error('[run-agent-engine] Usage: node run-agent-engine.mjs <command> [args]');
|
|
1478
|
+
console.error('Commands: launch <json> | launch --file <path> | status <runKey> | cancel <runKey> | list-active | health');
|
|
1479
|
+
console.error(' session-list | session-get | session-search | session-delete (task 7.15)');
|
|
1480
|
+
process.exit(1);
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
try {
|
|
1484
|
+
let result;
|
|
1485
|
+
switch (command) {
|
|
1486
|
+
case 'launch': {
|
|
1487
|
+
let payload;
|
|
1488
|
+
if (arg === '--file' && fileFlag) {
|
|
1489
|
+
// 从文件读取 JSON payload
|
|
1490
|
+
const fs = await import('node:fs');
|
|
1491
|
+
const content = fs.readFileSync(fileFlag, 'utf8');
|
|
1492
|
+
payload = JSON.parse(content);
|
|
1493
|
+
} else if (arg === '--stdin') {
|
|
1494
|
+
payload = JSON.parse(await readStdin());
|
|
1495
|
+
} else if (arg) {
|
|
1496
|
+
// 直接解析 JSON 参数
|
|
1497
|
+
payload = JSON.parse(arg);
|
|
1498
|
+
} else {
|
|
1499
|
+
throw new Error('Missing JSON payload for launch');
|
|
1500
|
+
}
|
|
1501
|
+
result = await launch(payload);
|
|
1502
|
+
break;
|
|
1503
|
+
}
|
|
1504
|
+
case 'supervise-run': {
|
|
1505
|
+
const payload = JSON.parse(await readStdin());
|
|
1506
|
+
const owned = await runOwned(payload);
|
|
1507
|
+
await owned.completion;
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
case 'status':
|
|
1511
|
+
if (!arg) {
|
|
1512
|
+
throw new Error('Missing runKey for status');
|
|
1513
|
+
}
|
|
1514
|
+
result = status(arg);
|
|
1515
|
+
break;
|
|
1516
|
+
case 'cancel':
|
|
1517
|
+
if (!arg) {
|
|
1518
|
+
throw new Error('Missing runKey for cancel');
|
|
1519
|
+
}
|
|
1520
|
+
result = cancel(arg);
|
|
1521
|
+
break;
|
|
1522
|
+
case 'list-active':
|
|
1523
|
+
result = listActive();
|
|
1524
|
+
break;
|
|
1525
|
+
case 'health':
|
|
1526
|
+
result = health();
|
|
1527
|
+
break;
|
|
1528
|
+
case 'events':
|
|
1529
|
+
if (!arg) throw new Error('Missing runKey for events');
|
|
1530
|
+
result = listEvents(arg, Number(process.argv[4] ?? 0));
|
|
1531
|
+
break;
|
|
1532
|
+
case 'engine-list':
|
|
1533
|
+
result = listEngines();
|
|
1534
|
+
break;
|
|
1535
|
+
case 'model-list':
|
|
1536
|
+
if (!arg) throw new Error('Missing engineId for model-list');
|
|
1537
|
+
result = listModels(arg);
|
|
1538
|
+
break;
|
|
1539
|
+
case 'engine-options':
|
|
1540
|
+
if (!arg) throw new Error('Missing engineId for engine-options');
|
|
1541
|
+
result = engineOptions(arg);
|
|
1542
|
+
break;
|
|
1543
|
+
case 'probe-engine':
|
|
1544
|
+
if (!arg) throw new Error('Missing engineId for probe-engine');
|
|
1545
|
+
result = probeEngine(arg, process.argv[4]);
|
|
1546
|
+
break;
|
|
1547
|
+
case 'session-list':
|
|
1548
|
+
result = await sessionList();
|
|
1549
|
+
break;
|
|
1550
|
+
case 'session-get':
|
|
1551
|
+
result = await sessionGet();
|
|
1552
|
+
break;
|
|
1553
|
+
case 'session-search':
|
|
1554
|
+
result = await sessionSearch();
|
|
1555
|
+
break;
|
|
1556
|
+
case 'session-delete':
|
|
1557
|
+
result = await sessionDelete();
|
|
1558
|
+
break;
|
|
1559
|
+
default:
|
|
1560
|
+
throw new Error(`Unknown command: ${command}`);
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1564
|
+
process.exit(0);
|
|
1565
|
+
} catch (error) {
|
|
1566
|
+
console.error('[run-agent-engine] Error:', error.message);
|
|
1567
|
+
process.exit(1);
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
async function readStdin() {
|
|
1572
|
+
const chunks = [];
|
|
1573
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
1574
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
main().catch((error) => {
|
|
1578
|
+
console.error('[run-agent-engine] Fatal error:', error);
|
|
1579
|
+
process.exit(1);
|
|
1580
|
+
});
|