@winmatrix/supervisor 1.0.5 → 1.0.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@winmatrix/supervisor",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "type": "module",
5
5
  "main": "src/run-agent-engine.mjs",
6
6
  "bin": {
@@ -14,10 +14,6 @@
14
14
  "build": "node -e \"console.log('no build step')\"",
15
15
  "test": "node --test tests/**/*.test.mjs"
16
16
  },
17
- "dependencies": {
18
- "@winmatrix/agent-sdk": "^1.0.5",
19
- "@winmatrix/protocol": "^1.0.5"
20
- },
21
17
  "engines": {
22
18
  "node": ">=20"
23
19
  }
@@ -15,6 +15,7 @@ import { createRequire } from 'module';
15
15
  const require = createRequire(import.meta.url);
16
16
 
17
17
  export const TRUNCATE_LEN = 4096;
18
+ export const SESSION_DETAIL_TRANSPORT_MAX_BYTES = 48 * 1024;
18
19
  export const REDACTED = '[REDACTED]';
19
20
  export const SENSITIVE_KEY_PATTERN =
20
21
  /(authorization|password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|credential|session[_-]?key|jwt)/i;
@@ -326,6 +327,89 @@ export function previewValue(value, max = 1000) {
326
327
  return text.length > max ? text.slice(0, max) + `…[${text.length - max} chars]` : text;
327
328
  }
328
329
 
330
+ /**
331
+ * 将 session-get 结果压缩到 Sandbox exec 的安全传输预算内,避免 stdout 截断后产生非法 JSON。
332
+ * @param {Record<string, unknown>} detail
333
+ * @param {number} maxBytes
334
+ * @returns {Record<string, unknown>}
335
+ */
336
+ export function compactSessionDetailForTransport(detail, maxBytes = SESSION_DETAIL_TRANSPORT_MAX_BYTES) {
337
+ const serializedBytes = (value) => Buffer.byteLength(JSON.stringify(value));
338
+ if (serializedBytes(detail) <= maxBytes) return detail;
339
+
340
+ const compacted = {
341
+ ...detail,
342
+ messages: Array.isArray(detail.messages)
343
+ ? detail.messages.map((message) => ({
344
+ ...message,
345
+ content: Array.isArray(message.content)
346
+ ? message.content.map((block) => ({
347
+ ...block,
348
+ ...(typeof block.text === 'string' && block.text.length > 1200
349
+ ? { text: `${block.text.slice(0, 1200)}…[transport-truncated]` }
350
+ : {}),
351
+ }))
352
+ : message.content,
353
+ }))
354
+ : [],
355
+ metadata: detail.metadata && typeof detail.metadata === 'object'
356
+ ? {
357
+ ...detail.metadata,
358
+ ...(typeof detail.metadata.assistantText === 'string'
359
+ ? { assistantText: detail.metadata.assistantText.slice(0, 8000) }
360
+ : {}),
361
+ ...(Array.isArray(detail.metadata.userPrompts)
362
+ ? { userPrompts: detail.metadata.userPrompts.slice(-20).map((prompt) => (
363
+ typeof prompt === 'string' && prompt.length > 800
364
+ ? `${prompt.slice(0, 800)}…[transport-truncated]`
365
+ : prompt
366
+ )) }
367
+ : {}),
368
+ ...(detail.metadata.diagnostics && typeof detail.metadata.diagnostics === 'object'
369
+ ? {
370
+ diagnostics: {
371
+ ...detail.metadata.diagnostics,
372
+ ...(Array.isArray(detail.metadata.diagnostics.toolCalls)
373
+ ? { toolCalls: detail.metadata.diagnostics.toolCalls.slice(-20) }
374
+ : {}),
375
+ ...(Array.isArray(detail.metadata.diagnostics.toolResults)
376
+ ? { toolResults: detail.metadata.diagnostics.toolResults.slice(-20) }
377
+ : {}),
378
+ ...(Array.isArray(detail.metadata.diagnostics.timeline)
379
+ ? { timeline: detail.metadata.diagnostics.timeline.slice(-40) }
380
+ : {}),
381
+ },
382
+ }
383
+ : {}),
384
+ ...(Array.isArray(detail.metadata.rawMessages) ? { rawMessages: undefined, rawMessagesIncluded: false } : {}),
385
+ transportCompacted: true,
386
+ }
387
+ : detail.metadata,
388
+ };
389
+
390
+ if (serializedBytes(compacted) <= maxBytes) return compacted;
391
+
392
+ const minimal = {
393
+ ...compacted,
394
+ messages: [],
395
+ metadata: compacted.metadata && typeof compacted.metadata === 'object'
396
+ ? {
397
+ ...compacted.metadata,
398
+ assistantText: typeof compacted.metadata.assistantText === 'string'
399
+ ? compacted.metadata.assistantText.slice(0, 4000)
400
+ : compacted.metadata.assistantText,
401
+ userPrompts: Array.isArray(compacted.metadata.userPrompts)
402
+ ? compacted.metadata.userPrompts.map((prompt) => (
403
+ typeof prompt === 'string' ? prompt.slice(0, 800) : prompt
404
+ ))
405
+ : compacted.metadata.userPrompts,
406
+ diagnostics: undefined,
407
+ }
408
+ : compacted.metadata,
409
+ };
410
+ return minimal;
411
+ }
412
+
329
413
  /** @param {string} text */
330
414
  export function redactPreviewText(text) {
331
415
  return text
@@ -0,0 +1,118 @@
1
+ /**
2
+ * 工作站引擎终态成败:不得只看进程 exitCode。
3
+ *
4
+ * recover-workstation-context-overflow D2:SDK result 帧 `is_error` /
5
+ * 失败 `subtype`(至少 error_during_execution / error_max_turns)须写成失败,
6
+ * 失败原因进 error,可见正文仍保留。缺字段时回退 exitCode(旧镜像靠 server P0 短语)。
7
+ */
8
+
9
+ /** @type {ReadonlySet<string>} */
10
+ export const SDK_FAILURE_SUBTYPES = Object.freeze(new Set([
11
+ 'error_during_execution',
12
+ 'error_max_turns',
13
+ ]));
14
+
15
+ /**
16
+ * @param {unknown} subtype
17
+ * @returns {subtype is string}
18
+ */
19
+ export function isSdkFailureSubtype(subtype) {
20
+ return typeof subtype === 'string' && SDK_FAILURE_SUBTYPES.has(subtype);
21
+ }
22
+
23
+ /**
24
+ * 从 events.jsonl 条目(envelope 或裸事件)提取最后一条 result 上的 SDK 成败信号。
25
+ *
26
+ * @param {unknown[]} events
27
+ * @returns {{
28
+ * isError: boolean,
29
+ * subtype: string | undefined,
30
+ * visibleText: string | undefined,
31
+ * error: string | undefined,
32
+ * sessionId: string | undefined,
33
+ * }}
34
+ */
35
+ export function extractSdkResultSignals(events) {
36
+ const empty = {
37
+ isError: false,
38
+ subtype: undefined,
39
+ visibleText: undefined,
40
+ error: undefined,
41
+ sessionId: undefined,
42
+ };
43
+ if (!Array.isArray(events)) return empty;
44
+
45
+ for (let i = events.length - 1; i >= 0; i -= 1) {
46
+ const raw = events[i];
47
+ const evt = raw && typeof raw === 'object' && raw.event && typeof raw.event === 'object'
48
+ ? raw.event
49
+ : raw;
50
+ if (!evt || typeof evt !== 'object' || evt.type !== 'result') continue;
51
+
52
+ const payload = evt.result && typeof evt.result === 'object' && !Array.isArray(evt.result)
53
+ ? evt.result
54
+ : evt;
55
+
56
+ const isError = payload.is_error === true || payload.isError === true;
57
+ const subtype = typeof payload.subtype === 'string'
58
+ ? payload.subtype
59
+ : typeof payload.sdkSubtype === 'string'
60
+ ? payload.sdkSubtype
61
+ : undefined;
62
+ const visibleText = typeof payload.text === 'string'
63
+ ? payload.text
64
+ : typeof payload.content === 'string'
65
+ ? payload.content
66
+ : undefined;
67
+ const error = typeof payload.error === 'string' ? payload.error : undefined;
68
+ const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId : undefined;
69
+
70
+ return { isError, subtype, visibleText, error, sessionId };
71
+ }
72
+
73
+ return empty;
74
+ }
75
+
76
+ /**
77
+ * @param {object} input
78
+ * @param {boolean} [input.cancelled]
79
+ * @param {number | null | undefined} [input.exitCode]
80
+ * @param {boolean} [input.isError]
81
+ * @param {string} [input.subtype]
82
+ * @param {string} [input.visibleText]
83
+ * @param {string} [input.existingError]
84
+ * @returns {{ success: boolean, status: 'cancelled' | 'completed' | 'failed', error?: string }}
85
+ */
86
+ export function resolveEngineRunOutcome({
87
+ cancelled = false,
88
+ exitCode,
89
+ isError = false,
90
+ subtype,
91
+ visibleText,
92
+ existingError,
93
+ } = {}) {
94
+ if (cancelled) {
95
+ return { success: false, status: 'cancelled', error: 'cancelled' };
96
+ }
97
+
98
+ const sdkFailed = isError === true || isSdkFailureSubtype(subtype);
99
+ if (sdkFailed) {
100
+ const trimmedVisible = typeof visibleText === 'string' ? visibleText.trim() : '';
101
+ const trimmedExisting = typeof existingError === 'string' ? existingError.trim() : '';
102
+ const error = trimmedExisting
103
+ || trimmedVisible
104
+ || (isSdkFailureSubtype(subtype) ? `SDK result subtype=${subtype}` : 'SDK result is_error=true');
105
+ return { success: false, status: 'failed', error };
106
+ }
107
+
108
+ if (exitCode !== 0) {
109
+ const trimmedExisting = typeof existingError === 'string' ? existingError.trim() : '';
110
+ return {
111
+ success: false,
112
+ status: 'failed',
113
+ error: trimmedExisting || `Engine exited with code ${exitCode}`,
114
+ };
115
+ }
116
+
117
+ return { success: true, status: 'completed' };
118
+ }
@@ -69,6 +69,10 @@ export function isFilteredEnvKey(key) {
69
69
  // 精确匹配的过滤项(优先级最高)
70
70
  if (FILTERED_EXACT_KEYS.has(key)) return true;
71
71
 
72
+ // Pod 创建时注入的 Claude 持久根目录必须和 HOME 一起传给 Engine。
73
+ // 其余 CLAUDE_CODE_* 仍按下方规则过滤,避免 Supervisor 内部标记泄漏。
74
+ if (/^CLAUDE_CODE_HOME$/i.test(key)) return false;
75
+
72
76
  // 先检查是否匹配过滤模式(过滤优先于白名单)
73
77
  // 这样 CLAUDE_CODE_ENTRYPOINT 被过滤,即使 CLAUDE_ 在白名单中
74
78
  for (const pattern of FILTERED_ENV_PATTERNS) {
@@ -39,6 +39,7 @@ import {
39
39
  cloneMessagesForJson,
40
40
  buildCliReplayGuide,
41
41
  buildDiagnostics,
42
+ compactSessionDetailForTransport,
42
43
  maybeTruncate,
43
44
  resolveDirViaGlobalListSessions,
44
45
  } from './claude-session-lib.mjs';
@@ -50,19 +51,29 @@ import { filterBlockedArgs, formatBlockedArgsAudit } from './blocked-args-filter
50
51
  import { createDeliverableState, updateDeliverable, extractDeliverable, computeDeliverableFromEvents } from './deliverable-tracker.mjs';
51
52
  import { extractTokenUsage } from './token-usage.mjs';
52
53
  import { isResumeRejected, buildContinuityNotice, buildResumeDegradedEvent, isResumeFailureOnExit } from './resume-degradation.mjs';
54
+ import { extractSdkResultSignals, resolveEngineRunOutcome } from './engine-run-success.mjs';
53
55
 
54
56
  const require = createRequire(import.meta.url);
55
57
  const fs = require('node:fs');
56
58
  const path = require('node:path');
57
59
  const { spawn, spawnSync } = require('node:child_process');
58
60
  const http = require('node:http');
61
+ const https = require('node:https');
59
62
  const readline = require('node:readline');
60
63
  const SCRIPT_PATH = fileURLToPath(import.meta.url);
61
64
 
62
- // Supervisor 根目录(每个 runKey 一个子目录)
63
- const SUPERVISOR_ROOT = process.env.WINMATRIX_SUPERVISOR_ROOT || '/tmp/agent-engine-supervisor';
65
+ // Supervisor 根目录(每个 runKey 一个子目录)。必须位于 Pod 的持久 agent-home,
66
+ // 容器重建后可继续读取状态与事件;禁止回退到 /tmp,避免表面成功但状态丢失。
67
+ const SUPERVISOR_HOME = process.env.HOME?.trim();
68
+ const SUPERVISOR_ROOT = process.env.WINMATRIX_SUPERVISOR_ROOT?.trim()
69
+ || (SUPERVISOR_HOME ? path.join(SUPERVISOR_HOME, '.winmatrix', 'agent-engine-supervisor') : '');
70
+ if (!SUPERVISOR_ROOT) {
71
+ throw new Error('WINMATRIX_SUPERVISOR_ROOT 或 HOME 必须指向持久 agent-home');
72
+ }
64
73
  const CALLBACK_TIMEOUT_MS = Number(process.env.WINMATRIX_CALLBACK_TIMEOUT_MS) || 5000;
65
74
  const MAX_ACTIVE_RUNS = Number(process.env.WINMATRIX_MAX_ACTIVE_RUNS) || 100;
75
+ /** 单次 events 请求返回的最大事件数,避免大量事件堆积导致响应超限。 */
76
+ const MAX_EVENTS_PAGE_SIZE = 500;
66
77
 
67
78
  // 确保根目录存在
68
79
  if (!fs.existsSync(SUPERVISOR_ROOT)) {
@@ -316,6 +327,9 @@ function buildTerminalCallbackPayload(runKey, endMetadata, result, startMetadata
316
327
  };
317
328
  }
318
329
 
330
+ /** 重定向最大跳数,防重定向环。 */
331
+ const MAX_CALLBACK_REDIRECTS = 5;
332
+
319
333
  /**
320
334
  * 发送 callback 到 Server
321
335
  * @param {string} callbackUrl - Callback URL
@@ -324,44 +338,76 @@ function buildTerminalCallbackPayload(runKey, endMetadata, result, startMetadata
324
338
  * @returns {Promise<void>}
325
339
  */
326
340
  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
- };
341
+ const data = JSON.stringify(payload);
342
+ let currentUrl = new URL(callbackUrl);
343
+ const originalHostname = currentUrl.hostname;
344
+
345
+ for (let redirects = 0; redirects <= MAX_CALLBACK_REDIRECTS; redirects += 1) {
346
+ const result = await new Promise((resolve, reject) => {
347
+ const isHttps = currentUrl.protocol === 'https:';
348
+ const client = isHttps ? https : http;
349
+ const options = {
350
+ hostname: currentUrl.hostname,
351
+ port: currentUrl.port || (isHttps ? 443 : 80),
352
+ path: currentUrl.pathname + currentUrl.search,
353
+ method: 'POST',
354
+ headers: {
355
+ 'Content-Type': 'application/json',
356
+ 'Content-Length': Buffer.byteLength(data),
357
+ 'Authorization': `Bearer ${token}`,
358
+ },
359
+ timeout: CALLBACK_TIMEOUT_MS,
360
+ };
342
361
 
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
- });
362
+ const req = client.request(options, (res) => {
363
+ const redirectStatus = [301, 302, 307, 308];
364
+ const location = res.headers.location;
365
+ if (redirectStatus.includes(res.statusCode) && location) {
366
+ res.resume();
367
+ resolve({ redirect: location });
368
+ return;
369
+ }
370
+ if (res.statusCode >= 200 && res.statusCode < 300) {
371
+ res.resume();
372
+ resolve({ ok: true });
373
+ return;
374
+ }
375
+ res.resume();
376
+ resolve({ ok: false, status: res.statusCode });
377
+ });
351
378
 
352
- req.on('error', (error) => {
353
- console.error(`[run-agent-engine] Callback error:`, error.message);
354
- reject(error);
355
- });
379
+ req.on('error', (error) => {
380
+ reject(error);
381
+ });
382
+
383
+ req.on('timeout', () => {
384
+ req.destroy();
385
+ reject(new Error('Callback timeout'));
386
+ });
356
387
 
357
- req.on('timeout', () => {
358
- req.destroy();
359
- reject(new Error('Callback timeout'));
388
+ req.write(data);
389
+ req.end();
360
390
  });
361
391
 
362
- req.write(data);
363
- req.end();
364
- });
392
+ if (result.ok) {
393
+ console.log(`[run-agent-engine] Callback sent successfully: ${callbackUrl}`);
394
+ return;
395
+ }
396
+ if (result.ok === false) {
397
+ throw new Error(`Callback failed with status ${result.status}`);
398
+ }
399
+
400
+ // 跟随重定向:仅允许同 hostname 的 http↔https 跳转(防 SSRF)。
401
+ const nextUrl = new URL(result.redirect, currentUrl);
402
+ if (nextUrl.hostname !== originalHostname) {
403
+ throw new Error(`Callback redirect to different host rejected: ${nextUrl.hostname}`);
404
+ }
405
+ if (redirects === MAX_CALLBACK_REDIRECTS) {
406
+ throw new Error(`Callback redirect limit exceeded (${MAX_CALLBACK_REDIRECTS})`);
407
+ }
408
+ currentUrl = nextUrl;
409
+ }
410
+ throw new Error(`Callback redirect limit exceeded (${MAX_CALLBACK_REDIRECTS})`);
365
411
  }
366
412
 
367
413
  /**
@@ -449,12 +495,12 @@ async function runOwned(payload) {
449
495
  // D11.2: detached=true 用于创建独立进程组,两阶段关闭时可 SIGTERM 整组
450
496
  detached: process.platform !== 'win32',
451
497
  });
498
+ // 链路日志走 stderr:stdout 是协议通道(ack JSON),console.log 会污染
499
+ console.error(`[run-agent-engine] runKey=${runKey} engine spawn: engineId=${launchSpec.engineId} pid=${child.pid} argv=${argv.join(' ')} workDir=${launchSpec.workDir || '(cwd)'}`);
452
500
 
453
501
  // 写入 stdin
454
- if (stdinData) {
455
- child.stdin.write(stdinData);
456
- child.stdin.end();
457
- }
502
+ if (stdinData) child.stdin.write(stdinData);
503
+ child.stdin.end();
458
504
 
459
505
  // 读取 stdout JSONL 事件,同时写入 stdout.log
460
506
  const stdoutWriteStream = fs.createWriteStream(logFile, { flags: 'a' });
@@ -585,6 +631,7 @@ async function runOwned(payload) {
585
631
  let resultText = '';
586
632
  let usage = null;
587
633
  let resultSessionId = null;
634
+ let sdkSignals = extractSdkResultSignals([]);
588
635
  try {
589
636
  const eventsFile = path.join(getRunDir(runKey), 'events.jsonl');
590
637
  if (fs.existsSync(eventsFile)) {
@@ -611,14 +658,30 @@ async function runOwned(payload) {
611
658
  break;
612
659
  }
613
660
  }
661
+
662
+ sdkSignals = extractSdkResultSignals(events);
663
+ if (!resultSessionId && sdkSignals.sessionId) {
664
+ resultSessionId = sdkSignals.sessionId;
665
+ }
666
+ if (!resultText?.trim() && sdkSignals.visibleText) {
667
+ resultText = sdkSignals.visibleText;
668
+ }
614
669
  }
615
670
  } catch (err) {
616
671
  console.error(`[run-agent-engine] Failed to compute deliverable/usage for ${runKey}:`, err.message);
617
672
  }
673
+ const outcome = resolveEngineRunOutcome({
674
+ cancelled,
675
+ exitCode: code,
676
+ isError: sdkSignals.isError,
677
+ subtype: sdkSignals.subtype,
678
+ visibleText: resultText,
679
+ existingError: sdkSignals.error,
680
+ });
618
681
 
619
682
  const endMetadata = {
620
683
  ...metadata,
621
- status: cancelled ? 'cancelled' : code === 0 ? 'completed' : 'failed',
684
+ status: outcome.status,
622
685
  endTime: new Date().toISOString(),
623
686
  exitCode: code,
624
687
  ...(signal ? { signal } : {}),
@@ -628,35 +691,56 @@ async function runOwned(payload) {
628
691
  activeRuns.delete(runKey);
629
692
  saveActiveRuns();
630
693
 
631
- // D11.4: 构建包含 deliverable usage 的结果
694
+ // D2:success 综合 SDK is_error / 失败 subtype,exitCode === 0 不得单独判成功
632
695
  const result = {
633
- success: !cancelled && code === 0,
696
+ success: outcome.success,
634
697
  text: resultText || undefined,
635
698
  ...(resultSessionId ? { sessionId: resultSessionId } : {}),
636
699
  ...(usage ? { usage } : {}),
637
- ...(cancelled ? { error: 'cancelled' } : code === 0 ? {} : { error: `Engine exited with code ${code}` }),
700
+ ...(outcome.error ? { error: outcome.error } : {}),
638
701
  metadata: {
639
702
  exitCode: code,
640
703
  ...(signal ? { signal } : {}),
641
704
  ...(resumeFailed ? { resumeDegraded: true } : {}),
642
705
  ...(usage?.source ? { usageSource: usage.source } : {}),
706
+ ...(sdkSignals.isError ? { is_error: true } : {}),
707
+ ...(sdkSignals.subtype ? { subtype: sdkSignals.subtype } : {}),
643
708
  },
644
709
  };
645
710
  saveResult(runKey, result);
646
711
  appendEvent(runKey, endMetadata, { type: 'result', result });
712
+ console.error(`[run-agent-engine] runKey=${runKey} run terminal: status=${endMetadata.status} exitCode=${code}${signal ? ` signal=${signal}` : ''} success=${result.success} deliverableLen=${(resultText || '').length} sessionId=${resultSessionId ?? 'none'}${resumeFailed ? ' resumeDegraded=true' : ''}`);
647
713
 
648
714
  // 发送 callback(如果有)
649
715
  // merge-workstation-tools R11 / 9b.5:payload 对齐 V1 run-claude-agent.mjs
650
716
  // 字段语义,满足 /api/v1/workstation-task-callbacks 的 zod schema(recordId 必填),
651
717
  // 使 HTTP callback 快路径对 V2 run 也能闭合(此前仅 observer 投影 + reconcile 兜底)。
652
718
  if (privateBindings?.callback) {
719
+ console.error(`[run-agent-engine] runKey=${runKey} callback 发送: url=${privateBindings.callback.url} status=${endMetadata.status}`);
653
720
  sendCallback(
654
721
  privateBindings.callback.url,
655
722
  privateBindings.callback.token,
656
723
  buildTerminalCallbackPayload(runKey, endMetadata, result, metadata),
657
724
  ).catch((error) => {
658
- console.error(`[run-agent-engine] Callback failed for ${runKey}:`, error.message);
725
+ // callback 失败必须可观测:失败原因写入既有 result.json(reconcile 已读该文件),
726
+ // 而非仅 console.error(worker stdio 被上层 ignore,会静默吞掉)。
727
+ const errorMsg = error instanceof Error ? error.message : String(error);
728
+ console.error(`[run-agent-engine] Callback failed for ${runKey}:`, errorMsg);
729
+ try {
730
+ const current = loadResult(runKey) ?? {};
731
+ saveResult(runKey, {
732
+ ...current,
733
+ metadata: {
734
+ ...(current.metadata && typeof current.metadata === 'object' ? current.metadata : {}),
735
+ callbackError: errorMsg,
736
+ },
737
+ });
738
+ } catch (persistError) {
739
+ console.error(`[run-agent-engine] Callback failed to persist for ${runKey}:`, persistError.message);
740
+ }
659
741
  });
742
+ } else {
743
+ console.error(`[run-agent-engine] runKey=${runKey} 无 callback 配置,终态依赖 observer 投影/reconcile`);
660
744
  }
661
745
  resolve();
662
746
  }));
@@ -797,7 +881,8 @@ function listEvents(runKey, afterSequence = 0) {
797
881
  if (!fs.existsSync(file)) return [];
798
882
  return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean)
799
883
  .map((line) => JSON.parse(line))
800
- .filter((event) => event.sequence > afterSequence);
884
+ .filter((event) => event.sequence > afterSequence)
885
+ .slice(0, MAX_EVENTS_PAGE_SIZE);
801
886
  }
802
887
 
803
888
  /**
@@ -847,6 +932,11 @@ function buildEngineArgv(launchSpec) {
847
932
  }
848
933
  }
849
934
 
935
+ // G9: 透传上下文预算
936
+ if (launchSpec.contextBudget) {
937
+ argv.push('--context-budget', JSON.stringify(launchSpec.contextBudget));
938
+ }
939
+
850
940
  // 添加 input(第一个 text block)
851
941
  if (input?.blocks) {
852
942
  for (const block of input.blocks) {
@@ -886,7 +976,26 @@ function buildEngineArgv(launchSpec) {
886
976
  function status(runKey) {
887
977
  const metadata = loadRunMetadata(runKey);
888
978
  if (!metadata) {
889
- throw new Error(`Run not found: ${runKey}`);
979
+ // status.json 尚未写入,说明 run 还在启动阶段(launch 中 saveRunMetadata 之前的耗时操作)。
980
+ // 返回 starting 状态而非抛异常,避免 inspect 轮询返回 502。
981
+ return {
982
+ ref: {
983
+ instance: { instanceId: '', hostKind: 'workstation' },
984
+ executionSnapshot: {},
985
+ runKey,
986
+ engineId: '',
987
+ invocationFingerprint: '',
988
+ },
989
+ status: 'starting',
990
+ executionDurability: 'host_persistent',
991
+ eventStreamMode: 'replay',
992
+ eventRetention: { maxEvents: 10000 },
993
+ lastSequence: 0,
994
+ pendingInteractions: [],
995
+ recoverable: true,
996
+ runtimeVersion: 'run-agent-engine/1',
997
+ protocolVersion: '1',
998
+ };
890
999
  }
891
1000
 
892
1001
  const result = loadResult(runKey);
@@ -1194,6 +1303,8 @@ async function sessionGet() {
1194
1303
  const includeRawMessages = hasFlag(argv, '--include-raw-messages');
1195
1304
  const includeSystem = hasFlag(argv, '--include-system');
1196
1305
  const noTruncate = hasFlag(argv, '--no-truncate');
1306
+ const noMessages = hasFlag(argv, '--no-messages');
1307
+ const joinedMessages = hasFlag(argv, '--joined-messages');
1197
1308
  const normalizedWorkDir = getOptValue(argv, '--normalized-work-dir');
1198
1309
 
1199
1310
  const { sdk, error } = loadSdk();
@@ -1239,6 +1350,20 @@ async function sessionGet() {
1239
1350
  }
1240
1351
  }
1241
1352
 
1353
+ // --no-messages:仅返回 session 元数据,跳过消息体(避免大 payload 触发 sandbox stdout 截断)。
1354
+ if (noMessages) {
1355
+ return {
1356
+ session: sessionSummary(sessionMeta, engineId),
1357
+ messages: [],
1358
+ rawMessagesIncluded: false,
1359
+ metadata: {
1360
+ messageCount: sessionMeta.messageCount,
1361
+ sessionMessagesProjectDir: msgOpts.dir != null ? String(msgOpts.dir) : undefined,
1362
+ sessionMessagesDirSource: dirSource,
1363
+ },
1364
+ };
1365
+ }
1366
+
1242
1367
  /** @type {unknown[]} */
1243
1368
  let messages = [];
1244
1369
  try {
@@ -1307,12 +1432,34 @@ async function sessionGet() {
1307
1432
  const cliReplayGuide = buildCliReplayGuide(sessionMeta, sessionId);
1308
1433
  const diagnostics = deepRedactSessionValue(buildDiagnostics(messages, noTruncate));
1309
1434
 
1435
+ // --joined-messages:拼接消息文本,替代结构化 messages[],避免大会话 stdout 截断。
1436
+ let joinedMessagesText = '';
1437
+ if (joinedMessages) {
1438
+ const parts = [];
1439
+ for (const m of messages) {
1440
+ if (m == null || typeof m !== 'object') continue;
1441
+ const rec = /** @type {Record<string, unknown>} */ (m);
1442
+ const type = String(rec.type ?? '');
1443
+ const text = extractText(rec.message);
1444
+ if (text) {
1445
+ parts.push(`${type}: ${text}`);
1446
+ }
1447
+ }
1448
+ joinedMessagesText = parts.join('\n\n');
1449
+ }
1450
+
1310
1451
  /** @type {Record<string, unknown>} */
1311
1452
  const detail = {
1312
1453
  session: sessionSummary(sessionMeta, engineId),
1313
- messages: structuredMessages,
1314
1454
  rawMessagesIncluded: includeRawMessages,
1315
1455
  };
1456
+ // joinedMessages 模式:messages 置空数组(schema 必填),正文走 joinedMessages 文本。
1457
+ if (joinedMessages) {
1458
+ detail.messages = [];
1459
+ detail.joinedMessages = joinedMessagesText;
1460
+ } else {
1461
+ detail.messages = structuredMessages;
1462
+ }
1316
1463
 
1317
1464
  /** @type {Record<string, unknown>} */
1318
1465
  const metadata = {
@@ -1333,7 +1480,7 @@ async function sessionGet() {
1333
1480
  if (!ok && messages.length > 0) metadata.rawMessagesSerializationFailed = true;
1334
1481
  }
1335
1482
 
1336
- return { ...detail, metadata };
1483
+ return compactSessionDetailForTransport({ ...detail, metadata });
1337
1484
  }
1338
1485
 
1339
1486
  /**
@@ -1349,7 +1496,6 @@ async function sessionSearch() {
1349
1496
  const cursor = getOptValue(argv, '--cursor');
1350
1497
  const offset = decodeOffsetCursor(cursor);
1351
1498
  const normalizedWorkDir = getOptValue(argv, '--normalized-work-dir');
1352
-
1353
1499
  const { sdk, error } = loadSdk();
1354
1500
  if (!sdk || typeof sdk.listSessions !== 'function') {
1355
1501
  return { hits: [], error: error ?? 'SDK 不支持 listSessions' };
@@ -6,6 +6,7 @@
6
6
  * --engine <claude-code|codex|hermes|openclaw> \
7
7
  * [--model <model>] [--permission-mode <mode>] \
8
8
  * [--allowed-tools <tool1,tool2>] [--resume <sessionId>|--fork <sessionId>] \
9
+ * [--context-budget <json>] \
9
10
  * [--timeout <seconds>] [--work-dir <dir>] \
10
11
  * <prompt>
11
12
  *
@@ -13,11 +14,75 @@
13
14
  * 输出到 stdout,供 run-agent-engine.mjs 解析并写入 events.jsonl。
14
15
  */
15
16
  import { createRequire } from 'node:module';
17
+ import { isSdkFailureSubtype } from './engine-run-success.mjs';
16
18
 
17
19
  const require = createRequire(import.meta.url);
18
20
 
19
21
  const SDK_PATH = process.env.WINMATRIX_AGENT_SDK_PATH
20
22
  || '/home/node/.local/lib/node_modules/@winmatrix/agent-sdk';
23
+ const MAX_PRIVATE_BINDINGS_BYTES = 64 * 1024;
24
+ const MCP_DYNAMIC_SECRET_KEYS = new Set([
25
+ 'WINMATRIX_TOOL_PROXY_URL',
26
+ 'WINMATRIX_TOOL_PROXY_TOKEN',
27
+ 'WINMATRIX_TOOL_PROXY_PROJECT_ID',
28
+ ]);
29
+
30
+ function normalizeEventText(value) {
31
+ if (typeof value === 'string') return value;
32
+ if (value === null || value === undefined) return '';
33
+ if (Array.isArray(value)) {
34
+ return value.map(normalizeEventText).filter(Boolean).join('\n');
35
+ }
36
+ if (typeof value === 'object') {
37
+ if (typeof value.text === 'string') return value.text;
38
+ try {
39
+ return JSON.stringify(value);
40
+ } catch {
41
+ return '';
42
+ }
43
+ }
44
+ return String(value);
45
+ }
46
+
47
+ async function readMcpBinding() {
48
+ const chunks = [];
49
+ let size = 0;
50
+ for await (const chunk of process.stdin) {
51
+ size += chunk.length;
52
+ if (size > MAX_PRIVATE_BINDINGS_BYTES) {
53
+ return { error: 'MCP_BINDING_REQUIRED: 私有控制绑定超过允许大小。' };
54
+ }
55
+ chunks.push(chunk);
56
+ }
57
+ if (chunks.length === 0) return {};
58
+
59
+ try {
60
+ const raw = JSON.parse(Buffer.concat(chunks).toString('utf8'));
61
+ if (!raw || raw.hostKind !== 'workstation') {
62
+ return { error: 'MCP_BINDING_REQUIRED: 私有控制绑定 hostKind 无效。' };
63
+ }
64
+ const dynamicSecrets = raw.dynamicSecrets;
65
+ if (dynamicSecrets === undefined) {
66
+ return { error: 'MCP_BINDING_REQUIRED: 工作站缺少 MCP 绑定。' };
67
+ }
68
+ if (!dynamicSecrets || typeof dynamicSecrets !== 'object' || Array.isArray(dynamicSecrets)) {
69
+ return { error: 'MCP_BINDING_REQUIRED: MCP 绑定格式无效。' };
70
+ }
71
+ for (const [key, value] of Object.entries(dynamicSecrets)) {
72
+ if (!MCP_DYNAMIC_SECRET_KEYS.has(key) || typeof value !== 'string') {
73
+ return { error: 'MCP_BINDING_REQUIRED: MCP 绑定包含不允许字段。' };
74
+ }
75
+ }
76
+ const url = dynamicSecrets.WINMATRIX_TOOL_PROXY_URL?.trim();
77
+ const token = dynamicSecrets.WINMATRIX_TOOL_PROXY_TOKEN?.trim();
78
+ if (!url || !token) {
79
+ return { error: 'MCP_BINDING_REQUIRED: MCP 绑定不完整。' };
80
+ }
81
+ return { binding: { url, token } };
82
+ } catch {
83
+ return { error: 'MCP_BINDING_REQUIRED: 私有控制绑定不是有效 JSON。' };
84
+ }
85
+ }
21
86
 
22
87
  function parseArgs(argv) {
23
88
  const options = {
@@ -29,6 +94,7 @@ function parseArgs(argv) {
29
94
  fork: undefined,
30
95
  timeoutSec: undefined,
31
96
  workDir: undefined,
97
+ contextBudget: undefined,
32
98
  };
33
99
  const positionals = [];
34
100
 
@@ -50,6 +116,18 @@ function parseArgs(argv) {
50
116
  options.timeoutSec = argv[++i];
51
117
  } else if (arg === '--work-dir') {
52
118
  options.workDir = argv[++i];
119
+ } else if (arg === '--context-budget') {
120
+ const raw = argv[++i];
121
+ try {
122
+ const parsed = JSON.parse(raw);
123
+ if (parsed && typeof parsed === 'object') {
124
+ options.contextBudget = parsed;
125
+ } else {
126
+ console.error('[run-engine] --context-budget 不是有效 JSON 对象, 已忽略');
127
+ }
128
+ } catch {
129
+ console.error('[run-engine] --context-budget JSON 解析失败, 已忽略');
130
+ }
53
131
  } else if (arg.startsWith('--')) {
54
132
  // 忽略未来可能透传的 engine-specific 选项;后续可按 engineId 路由
55
133
  console.error(`[run-engine] 忽略未知选项: ${arg}`);
@@ -78,6 +156,13 @@ async function main() {
78
156
  process.exit(1);
79
157
  }
80
158
 
159
+ const mcpBinding = await readMcpBinding();
160
+ if (mcpBinding.error) {
161
+ writeEvent({ type: 'error', error: mcpBinding.error, code: 'MCP_CONNECTION_FAILED' });
162
+ process.exitCode = 1;
163
+ return;
164
+ }
165
+
81
166
  let sdk;
82
167
  try {
83
168
  sdk = require(SDK_PATH);
@@ -92,8 +177,19 @@ async function main() {
92
177
  CodexAdapter,
93
178
  HermesAdapter,
94
179
  OpenClawAdapter,
180
+ validateMcpBridgeUrl,
95
181
  } = sdk;
96
182
 
183
+ if (mcpBinding.binding) {
184
+ try {
185
+ validateMcpBridgeUrl(mcpBinding.binding.url);
186
+ } catch (err) {
187
+ writeEvent({ type: 'error', error: `MCP_BINDING_REQUIRED: MCP URL 无效: ${err.message}`, code: 'MCP_CONNECTION_FAILED' });
188
+ process.exitCode = 1;
189
+ return;
190
+ }
191
+ }
192
+
97
193
  const registry = new AdapterRegistry();
98
194
  registry.register('claude-code', ClaudeAdapter);
99
195
  registry.register('codex', CodexAdapter);
@@ -105,7 +201,7 @@ async function main() {
105
201
  process.exit(1);
106
202
  }
107
203
 
108
- const adapterConfig = {};
204
+ const adapterConfig = mcpBinding.binding ? { mcpBridgeUrl: mcpBinding.binding.url } : {};
109
205
  const adapter = registry.create(options.engineId, adapterConfig);
110
206
 
111
207
  /** @type {import('@winmatrix/agent-sdk').AgentTaskContext} */
@@ -116,9 +212,13 @@ async function main() {
116
212
  engineId: options.engineId,
117
213
  workDir: options.workDir,
118
214
  };
215
+ if (mcpBinding.binding) task.mcpToken = mcpBinding.binding.token;
216
+ if (options.contextBudget) task.contextBudget = options.contextBudget;
119
217
 
120
218
  if (options.engineId === 'claude-code') {
121
- task.claude = {};
219
+ // Coding workstation 使用 Agent SDK 路径:任务 MCP 仅随 query() 显式传入,
220
+ // 不落盘到 artifact,也不改写 Pod 注入的持久 agent-home。
221
+ task.claude = { useAgentSdk: true };
122
222
  if (options.permissionMode) task.claude.permissionMode = options.permissionMode;
123
223
  if (options.allowedTools) task.claude.tools = options.allowedTools.split(',').map((s) => s.trim());
124
224
  } else if (options.engineId === 'hermes') {
@@ -150,28 +250,53 @@ async function main() {
150
250
  }
151
251
 
152
252
  try {
253
+ const startedAt = Date.now();
254
+ // 链路日志走 stderr:stdout 是 JSONL 协议通道
255
+ console.error(`[run-engine] pid=${process.pid} adapter.execute 开始: engineId=${options.engineId} workDir=${options.workDir ?? '(cwd)'} resume=${options.resume ?? 'none'} fork=${options.fork ?? 'none'} timeoutSec=${options.timeoutSec ?? 'none'}`);
256
+ let eventCount = 0;
153
257
  for await (const event of adapter.execute(task, abortController?.signal)) {
258
+ eventCount += 1;
154
259
  switch (event.type) {
155
260
  case 'delta':
156
- writeEvent({ type: 'content_delta', text: event.content });
261
+ if (event.engineEvent) {
262
+ writeEvent(event.engineEvent);
263
+ } else {
264
+ writeEvent({ type: 'content_delta', text: normalizeEventText(event.content) });
265
+ }
157
266
  break;
158
267
  case 'thinking':
159
- writeEvent({ type: 'thinking_delta', text: event.content });
268
+ if (event.engineEvent) {
269
+ writeEvent(event.engineEvent);
270
+ } else {
271
+ writeEvent({ type: 'thinking_delta', text: normalizeEventText(event.content) });
272
+ }
160
273
  break;
161
274
  case 'tool_call':
162
275
  writeEvent({ type: 'tool_call', name: event.name, input: event.args });
163
276
  break;
164
- case 'result':
277
+ case 'result': {
278
+ const isError = event.isError === true;
279
+ const subtype = typeof event.sdkSubtype === 'string' ? event.sdkSubtype : undefined;
280
+ const sdkFailed = isError || isSdkFailureSubtype(subtype);
281
+ const text = normalizeEventText(event.content);
282
+ const error = sdkFailed
283
+ ? (text.trim() || (subtype ? `SDK result subtype=${subtype}` : 'SDK result is_error=true'))
284
+ : undefined;
285
+ console.error(`[run-engine] pid=${process.pid} adapter.execute 结束: engineId=${options.engineId} events=${eventCount} durationMs=${Date.now() - startedAt} sessionId=${event.sessionId ?? 'none'} success=${!sdkFailed} subtype=${subtype ?? 'none'}`);
165
286
  writeEvent({
166
287
  type: 'result',
167
288
  result: {
168
- success: true,
169
- text: event.content,
289
+ success: !sdkFailed,
290
+ text,
170
291
  ...(event.sessionId ? { sessionId: event.sessionId } : {}),
171
292
  ...(event.usage ? { usage: event.usage } : {}),
293
+ ...(isError ? { is_error: true } : {}),
294
+ ...(subtype ? { subtype } : {}),
295
+ ...(error ? { error } : {}),
172
296
  },
173
297
  });
174
298
  break;
299
+ }
175
300
  case 'error':
176
301
  writeEvent({ type: 'error', error: event.message, code: event.code });
177
302
  break;
@@ -182,6 +307,7 @@ async function main() {
182
307
  }
183
308
  process.exit(0);
184
309
  } catch (e) {
310
+ console.error(`[run-engine] pid=${process.pid} adapter.execute 异常: engineId=${options.engineId} error=${e instanceof Error ? e.message : String(e)}`);
185
311
  writeEvent({ type: 'error', error: e instanceof Error ? e.message : String(e) });
186
312
  process.exit(1);
187
313
  } finally {