@xioflow/kernel 0.1.4 → 0.2.0

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.
Files changed (39) hide show
  1. package/README.md +27 -8
  2. package/dist/domain.d.ts +40 -6
  3. package/dist/domain.d.ts.map +1 -1
  4. package/dist/domain.js +388 -24
  5. package/dist/domain.js.map +1 -1
  6. package/dist/driver/node-driver.d.ts +11 -9
  7. package/dist/driver/node-driver.d.ts.map +1 -1
  8. package/dist/driver/node-driver.js +224 -225
  9. package/dist/driver/node-driver.js.map +1 -1
  10. package/dist/driver/process-facts.d.ts +25 -0
  11. package/dist/driver/process-facts.d.ts.map +1 -0
  12. package/dist/driver/process-facts.js +164 -0
  13. package/dist/driver/process-facts.js.map +1 -0
  14. package/dist/driver/sampler.d.ts +51 -0
  15. package/dist/driver/sampler.d.ts.map +1 -0
  16. package/dist/driver/sampler.js +224 -0
  17. package/dist/driver/sampler.js.map +1 -0
  18. package/dist/driver/types.d.ts +29 -1
  19. package/dist/driver/types.d.ts.map +1 -1
  20. package/dist/recovery/engine.d.ts +2 -0
  21. package/dist/recovery/engine.d.ts.map +1 -1
  22. package/dist/recovery/engine.js +137 -14
  23. package/dist/recovery/engine.js.map +1 -1
  24. package/dist/store/sqlite.d.ts +12 -0
  25. package/dist/store/sqlite.d.ts.map +1 -1
  26. package/dist/store/sqlite.js +162 -12
  27. package/dist/store/sqlite.js.map +1 -1
  28. package/dist/supervisor/supervisor.d.ts +15 -0
  29. package/dist/supervisor/supervisor.d.ts.map +1 -1
  30. package/dist/supervisor/supervisor.js +619 -214
  31. package/dist/supervisor/supervisor.js.map +1 -1
  32. package/dist/testing/contract-suite.d.ts.map +1 -1
  33. package/dist/testing/contract-suite.js +301 -11
  34. package/dist/testing/contract-suite.js.map +1 -1
  35. package/dist/types.d.ts +37 -0
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/types.js +29 -0
  38. package/dist/types.js.map +1 -1
  39. package/package.json +2 -2
@@ -1,7 +1,68 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
- import { UnsupportedCapabilityError, } from '../types.js';
4
+ import { UnsupportedCapabilityError, OperationNotActiveError, DuplicateOperationError, } from '../types.js';
5
+ /**
6
+ * 规范化计算完整输入的 SHA-256 指纹 (ARCHITECTURE §2 / P0-12)
7
+ * 覆盖 execPath, args, cwd, 键排序的 envWhiteList, inheritEnv, sha256(stdin), 排序后的 requiredResources, timeoutMs, resourceBudget.
8
+ */
9
+ export function computeInputFingerprint(options) {
10
+ const cmd = options.command;
11
+ let stdinHash = null;
12
+ if (cmd.stdin !== undefined && cmd.stdin !== null) {
13
+ stdinHash = crypto.createHash('sha256').update(cmd.stdin).digest('hex');
14
+ }
15
+ let envSorted = null;
16
+ if (cmd.envWhiteList) {
17
+ const keys = Object.keys(cmd.envWhiteList).sort();
18
+ envSorted = keys.map((k) => [k, cmd.envWhiteList[k]]);
19
+ }
20
+ const canonical = {
21
+ args: cmd.args || [],
22
+ cwd: cmd.cwd || '',
23
+ envWhiteList: envSorted,
24
+ execPath: cmd.execPath || '',
25
+ inheritEnv: cmd.inheritEnv ?? null,
26
+ requiredResources: [...(options.requiredResources || [])].sort(),
27
+ resourceBudget: options.resourceBudget
28
+ ? {
29
+ enforcement: options.resourceBudget.enforcement ?? null,
30
+ maxCpuTimeMs: options.resourceBudget.maxCpuTimeMs ?? null,
31
+ maxMemoryBytes: options.resourceBudget.maxMemoryBytes ?? null,
32
+ maxOutputBytes: options.resourceBudget.maxOutputBytes ?? null,
33
+ maxPids: options.resourceBudget.maxPids ?? null,
34
+ }
35
+ : null,
36
+ stdinHash,
37
+ timeoutMs: options.timeoutMs ?? null,
38
+ };
39
+ const canonicalJson = JSON.stringify(canonical);
40
+ return crypto.createHash('sha256').update(canonicalJson).digest('hex');
41
+ }
42
+ function trimToValidUtf8(buf) {
43
+ const len = buf.length;
44
+ for (let i = 1; i <= Math.min(3, len); i++) {
45
+ const b = buf[len - i];
46
+ if ((b & 0xc0) === 0xc0) {
47
+ const needed = (b & 0xe0) === 0xc0 ? 2 : (b & 0xf0) === 0xe0 ? 3 : (b & 0xf8) === 0xf0 ? 4 : 1;
48
+ if (i < needed) {
49
+ return Buffer.from(buf.subarray(0, len - i));
50
+ }
51
+ break;
52
+ }
53
+ else if ((b & 0x80) === 0) {
54
+ break;
55
+ }
56
+ }
57
+ return buf;
58
+ }
59
+ function trimStartToValidUtf8(buf) {
60
+ let start = 0;
61
+ while (start < buf.length && (buf[start] & 0xc0) === 0x80) {
62
+ start++;
63
+ }
64
+ return Buffer.from(buf.subarray(start));
65
+ }
5
66
  export class ProcessSupervisor {
6
67
  domain;
7
68
  driver;
@@ -9,6 +70,7 @@ export class ProcessSupervisor {
9
70
  constructor(domain, driver) {
10
71
  this.domain = domain;
11
72
  this.driver = driver;
73
+ this.domain.setDriver?.(driver);
12
74
  }
13
75
  getDomain() {
14
76
  return this.domain;
@@ -23,6 +85,8 @@ export class ProcessSupervisor {
23
85
  const startTime = Date.now();
24
86
  const maxBytes = options.maxOutputBytes ?? 10 * 1024 * 1024; // 默认 10MB
25
87
  const drainTimeoutMs = options.drainTimeoutMs ?? 2000; // 默认 2000ms
88
+ let stdoutDrainer;
89
+ let stderrDrainer;
26
90
  // 0. [准入期强校验 Pre-admission Capability Check]
27
91
  if (options.resourceBudget?.enforcement === 'hard') {
28
92
  if (options.resourceBudget.maxMemoryBytes && !this.driver.capabilities.memoryHardLimit) {
@@ -35,156 +99,306 @@ export class ProcessSupervisor {
35
99
  throw new UnsupportedCapabilityError('cpuLimit', this.driver.name, 'hard');
36
100
  }
37
101
  }
102
+ // N8 守卫(契约 #40):显式拒绝已存在或在飞的 opId,在触碰任何内存状态或 DB 租约之前同步拦截
103
+ if (this.activeOperations.has(options.opId)) {
104
+ const existing = this.activeOperations.get(options.opId);
105
+ throw new DuplicateOperationError(options.opId, existing.phase, existing.runId);
106
+ }
107
+ const existingRecorded = this.domain.getStore().getOperation(options.opId);
108
+ if (existingRecorded) {
109
+ throw new DuplicateOperationError(options.opId, existingRecorded.status, existingRecorded.runId);
110
+ }
38
111
  const op = {
39
112
  id: options.opId,
40
113
  runId: options.runId,
41
114
  kind: 'process',
42
115
  name: options.name,
43
- inputFingerprint: options.inputFingerprint || `${options.command.execPath}:${options.command.args.join(' ')}`,
116
+ inputFingerprint: options.inputFingerprint || computeInputFingerprint(options),
44
117
  requiredResources: options.requiredResources,
45
118
  timeoutMs: options.timeoutMs,
46
119
  resourceBudget: options.resourceBudget,
47
120
  status: 'pending',
48
121
  };
49
- // 1. [资源分配与排队等待]
50
- await this.domain.allocateResourcesWithWait(options.opId, options.requiredResources, options.waitTimeoutMs ?? 0, options.resourceBudget);
51
- // 2. [启动协议步骤 1] 写入 SQLite (status: intent_registered)
52
- try {
53
- this.domain.getStore().registerOperationIntent(op, this.domain.domainId);
54
- }
55
- catch (err) {
56
- this.domain.releaseResources(options.opId, options.requiredResources);
57
- throw err;
58
- }
59
- let handle;
60
- try {
61
- // 3. [启动协议步骤 2] 请求平台驱动启动
62
- handle = await this.driver.spawn(options.command);
63
- }
64
- catch (spawnError) {
65
- // 启动失败(如可执行文件不存在),不产生假运行状态,直接失败收尾并释放资源
66
- const failResult = {
67
- kind: 'process',
68
- status: 'failed',
69
- exitCode: 127,
70
- signal: null,
71
- stdout: '',
72
- stderr: spawnError?.message || String(spawnError),
73
- spawnFailure: spawnError?.message || String(spawnError),
74
- isTruncated: false,
75
- identityVerification: 'not_original_process',
76
- durationMs: Date.now() - startTime,
77
- completedAt: new Date().toISOString(),
78
- };
79
- this.domain.getStore().recordOperationResult(options.opId, failResult, true);
80
- this.domain.releaseResources(options.opId, options.requiredResources);
81
- return failResult;
82
- }
83
- // 4. [启动协议步骤 3] 登记执行身份,状态推进为 active
84
- this.domain.getStore().updateOperationStatus(options.opId, 'active', handle.identity);
85
122
  const opState = {
86
- handle,
87
- command: options.command,
123
+ opId: options.opId,
124
+ runId: options.runId,
125
+ phase: 'waiting_resources',
126
+ startTime,
88
127
  cancelRequested: false,
89
128
  timedOut: false,
90
- terminationReason: undefined,
91
- stopPromise: undefined,
92
129
  };
93
130
  this.activeOperations.set(options.opId, opState);
94
- // 准备 artifacts 溢出转储目录
95
- const artifactsDir = options.artifactsDir || path.join(this.domain.domainPath, 'artifacts');
96
- if (!fs.existsSync(artifactsDir)) {
131
+ let timeoutTimer = null;
132
+ let samplingInterval = null;
133
+ try {
134
+ // 1. [资源分配与排队等待]
135
+ await this.domain.allocateResourcesWithWait(options.opId, options.requiredResources || [], options.waitTimeoutMs ?? 0, options.resourceBudget, () => opState.cancelRequested === true);
136
+ if (opState.cancelRequested) {
137
+ return this.handlePreSpawnCancellation(options, opState, startTime);
138
+ }
139
+ // 2. [启动协议步骤 1] 写入 SQLite (status: intent_registered)
97
140
  try {
98
- fs.mkdirSync(artifactsDir, { recursive: true });
141
+ this.domain.getStore().registerOperationIntent(op, this.domain.domainId);
142
+ opState.phase = 'intent_registered';
99
143
  }
100
- catch { }
101
- }
102
- const stdoutSpillPath = path.join(artifactsDir, `${options.opId}-stdout.log`);
103
- const stderrSpillPath = path.join(artifactsDir, `${options.opId}-stderr.log`);
104
- // 5. [正常监督、有界排空与流式转储 (Spill to Artifacts)]
105
- let totalOutputBytes = 0;
106
- const onChunk = (bytes) => {
107
- totalOutputBytes += bytes;
108
- if (options.resourceBudget?.enforcement === 'soft' &&
109
- options.resourceBudget.maxOutputBytes &&
110
- totalOutputBytes > options.resourceBudget.maxOutputBytes &&
111
- !opState.terminationReason) {
112
- opState.terminationReason = 'output_exceeded';
113
- this.handleStopPipeline(options.opId, 'output_exceeded', 1000).catch(() => { });
144
+ catch (err) {
145
+ this.domain.internalReleaseResources(options.opId, options.requiredResources);
146
+ throw err;
114
147
  }
115
- };
116
- let streamCallbackError;
117
- const forwardChunk = (stream) => (chunk) => {
118
- if (!options.onStreamChunk)
119
- return;
148
+ if (opState.cancelRequested) {
149
+ return this.handlePreSpawnCancellation(options, opState, startTime);
150
+ }
151
+ let handle;
152
+ opState.phase = 'spawning';
120
153
  try {
121
- options.onStreamChunk(stream, chunk);
154
+ // 3. [启动协议步骤 2] 请求平台驱动启动
155
+ handle = await this.driver.spawn(options.command);
156
+ opState.handle = handle;
157
+ opState.command = options.command;
122
158
  }
123
- catch (err) {
124
- streamCallbackError ??= err instanceof Error ? err.message : String(err);
159
+ catch (spawnError) {
160
+ opState.phase = 'done';
161
+ opState.stopResolve?.({ stopped: 'confirmed_stopped', scope: 'direct_child', errorDetails: spawnError?.message });
162
+ opState.stopResolve = undefined;
163
+ // 启动失败(如可执行文件不存在),不产生假运行状态,直接失败收尾并释放资源
164
+ const failResult = {
165
+ kind: 'process',
166
+ status: 'failed',
167
+ exitCode: 127,
168
+ signal: null,
169
+ stdout: '',
170
+ stderr: spawnError?.message || String(spawnError),
171
+ spawnFailure: spawnError?.message || String(spawnError),
172
+ isTruncated: false,
173
+ identityVerification: 'not_original_process',
174
+ durationMs: Date.now() - startTime,
175
+ completedAt: new Date().toISOString(),
176
+ };
177
+ return this.finalizeOperation(options.opId, failResult, options.requiredResources, true);
125
178
  }
126
- };
127
- const stdoutDrainer = this.setupStreamDrainer(handle.stdout, maxBytes, stdoutSpillPath, onChunk, forwardChunk('stdout'));
128
- const stderrDrainer = this.setupStreamDrainer(handle.stderr, maxBytes, stderrSpillPath, onChunk, forwardChunk('stderr'));
129
- let timeoutTimer = null;
130
- const timeoutPromise = options.timeoutMs && options.timeoutMs > 0
131
- ? new Promise((resolve) => {
132
- timeoutTimer = setTimeout(() => resolve('timeout'), options.timeoutMs);
133
- })
134
- : new Promise(() => { });
135
- // Soft / Observe 模式资源采样治理
136
- let peakMemoryBytes = 0;
137
- let peakCpuTimeMs = 0;
138
- let samplingInterval = null;
139
- if (this.driver.sampleMetrics &&
140
- options.resourceBudget &&
141
- (options.resourceBudget.enforcement === 'soft' || options.resourceBudget.enforcement === 'observe')) {
142
- samplingInterval = setInterval(async () => {
179
+ // 4. [启动协议步骤 3] 登记执行身份,状态推进为 active
180
+ try {
181
+ this.domain.getStore().updateOperationStatus(options.opId, 'active', handle.identity);
182
+ opState.phase = 'active';
183
+ handle.releaseGate?.();
184
+ }
185
+ catch (statusError) {
186
+ handle.destroyGate?.();
187
+ // N4: spawn 成功但登记 active 失败,防止产生孤儿进程
188
+ opState.phase = 'stopping';
189
+ const stopRes = await this.driver.terminate(handle.identity, 2000);
190
+ // 有界等待退出与关闭流,防止管道悬挂
191
+ await Promise.race([
192
+ (handle.onRootExit ?? handle.onExit).catch(() => { }),
193
+ new Promise((resolve) => setTimeout(resolve, 500)),
194
+ ]);
143
195
  try {
144
- const metrics = await this.driver.sampleMetrics(handle.identity);
145
- peakMemoryBytes = Math.max(peakMemoryBytes, metrics.rssBytes);
146
- peakCpuTimeMs = Math.max(peakCpuTimeMs, metrics.cpuTimeMs);
147
- if (options.resourceBudget?.enforcement === 'soft') {
148
- if (options.resourceBudget.maxMemoryBytes && metrics.rssBytes > options.resourceBudget.maxMemoryBytes) {
149
- opState.terminationReason = 'memory_exceeded';
150
- if (samplingInterval)
151
- clearInterval(samplingInterval);
152
- await this.handleStopPipeline(options.opId, 'memory_exceeded', 1000);
153
- }
154
- else if (options.resourceBudget.maxCpuTimeMs && metrics.cpuTimeMs > options.resourceBudget.maxCpuTimeMs) {
155
- opState.terminationReason = 'cpu_exceeded';
156
- if (samplingInterval)
157
- clearInterval(samplingInterval);
158
- await this.handleStopPipeline(options.opId, 'cpu_exceeded', 1000);
159
- }
160
- else if (options.resourceBudget.maxPids && metrics.pidsCount > options.resourceBudget.maxPids) {
161
- opState.terminationReason = 'pids_exceeded';
162
- if (samplingInterval)
163
- clearInterval(samplingInterval);
164
- await this.handleStopPipeline(options.opId, 'pids_exceeded', 1000);
165
- }
166
- }
196
+ handle.stdout.destroy?.();
197
+ handle.stderr.destroy?.();
167
198
  }
168
199
  catch { }
169
- }, 100);
170
- }
171
- try {
172
- // 等待根进程退出或超时触发;有 onRootExit 时不等待持有管道的后代
200
+ opState.phase = 'done';
201
+ if (opState.stopResolve) {
202
+ const resolveFn = opState.stopResolve;
203
+ opState.stopResolve = undefined;
204
+ resolveFn(stopRes);
205
+ }
206
+ if (stopRes.stopped === 'confirmed_stopped') {
207
+ const failResult = {
208
+ kind: 'process',
209
+ status: 'failed',
210
+ exitCode: null,
211
+ signal: null,
212
+ stdout: '',
213
+ stderr: `Failed to register active status: ${statusError?.message || String(statusError)}`,
214
+ spawnFailure: statusError?.message || String(statusError),
215
+ isTruncated: false,
216
+ identityVerification: 'not_original_process',
217
+ durationMs: Date.now() - startTime,
218
+ completedAt: new Date().toISOString(),
219
+ };
220
+ this.finalizeOperation(options.opId, failResult, options.requiredResources, true);
221
+ }
222
+ else {
223
+ const indetResult = {
224
+ kind: 'indeterminate',
225
+ status: 'indeterminate',
226
+ reason: `Failed to register active status: ${statusError?.message || String(statusError)}, and process ${handle.identity.pid} could not be confirmed stopped: ${stopRes.errorDetails || 'residual processes still alive'}`,
227
+ recoveryGuidance: 'Residual PID detected. Inspect system processes manually before releasing resources.',
228
+ durationMs: Date.now() - startTime,
229
+ completedAt: new Date().toISOString(),
230
+ };
231
+ this.finalizeOperation(options.opId, indetResult, options.requiredResources, false);
232
+ }
233
+ throw statusError;
234
+ }
235
+ // 准备 artifacts 溢出转储目录
236
+ const artifactsDir = options.artifactsDir || path.join(this.domain.domainPath, 'artifacts');
237
+ let artifactsDirError = undefined;
238
+ if (!fs.existsSync(artifactsDir)) {
239
+ try {
240
+ fs.mkdirSync(artifactsDir, { recursive: true });
241
+ }
242
+ catch (dirErr) {
243
+ artifactsDirError = dirErr.message || String(dirErr);
244
+ }
245
+ }
246
+ const stdoutSpillPath = path.join(artifactsDir, `${options.opId}-stdout.log`);
247
+ const stderrSpillPath = path.join(artifactsDir, `${options.opId}-stderr.log`);
248
+ // 5. [正常监督、有界排空与流式转储 (Spill to Artifacts)]
249
+ let totalOutputBytes = 0;
250
+ const onChunk = (bytes) => {
251
+ totalOutputBytes += bytes;
252
+ if (options.resourceBudget?.enforcement === 'soft' &&
253
+ options.resourceBudget.maxOutputBytes &&
254
+ totalOutputBytes > options.resourceBudget.maxOutputBytes &&
255
+ !opState.terminationReason) {
256
+ opState.terminationReason = 'output_exceeded';
257
+ this.handleStopPipeline(options.opId, 'output_exceeded', 1000).catch(() => { });
258
+ }
259
+ };
260
+ let streamCallbackError;
261
+ const forwardChunk = (stream) => (chunk) => {
262
+ if (!options.onStreamChunk)
263
+ return;
264
+ try {
265
+ options.onStreamChunk(stream, chunk);
266
+ }
267
+ catch (err) {
268
+ streamCallbackError ??= err instanceof Error ? err.message : String(err);
269
+ }
270
+ };
271
+ stdoutDrainer = this.setupStreamDrainer(handle.stdout, maxBytes, stdoutSpillPath, onChunk, forwardChunk('stdout'), artifactsDirError);
272
+ stderrDrainer = this.setupStreamDrainer(handle.stderr, maxBytes, stderrSpillPath, onChunk, forwardChunk('stderr'), artifactsDirError);
273
+ // 检查在 spawn 途中是否已被请求取消:若已请求取消,立即触发停止流水线向底层发送终止信号
274
+ if (opState.cancelRequested) {
275
+ this.handleStopPipeline(options.opId, 'user_cancelled', opState.cancelGraceMs ?? 2000).catch(() => { });
276
+ }
277
+ const timeoutPromise = options.timeoutMs && options.timeoutMs > 0
278
+ ? new Promise((resolve) => {
279
+ timeoutTimer = setTimeout(() => resolve('timeout'), options.timeoutMs);
280
+ })
281
+ : new Promise(() => { });
282
+ // Soft / Observe 模式资源采样治理
283
+ let peakMemoryBytes = 0;
284
+ let peakCpuTimeMs = 0;
285
+ if (this.driver.sampleMetrics &&
286
+ options.resourceBudget &&
287
+ (options.resourceBudget.enforcement === 'soft' || options.resourceBudget.enforcement === 'observe')) {
288
+ samplingInterval = setInterval(async () => {
289
+ try {
290
+ const metrics = await this.driver.sampleMetrics(handle.identity);
291
+ peakMemoryBytes = Math.max(peakMemoryBytes, metrics.rssBytes);
292
+ peakCpuTimeMs = Math.max(peakCpuTimeMs, metrics.cpuTimeMs);
293
+ if (options.resourceBudget?.enforcement === 'soft') {
294
+ if (options.resourceBudget.maxMemoryBytes && metrics.rssBytes > options.resourceBudget.maxMemoryBytes) {
295
+ opState.terminationReason = 'memory_exceeded';
296
+ if (samplingInterval)
297
+ clearInterval(samplingInterval);
298
+ await this.handleStopPipeline(options.opId, 'memory_exceeded', 1000);
299
+ }
300
+ else if (options.resourceBudget.maxCpuTimeMs && metrics.cpuTimeMs > options.resourceBudget.maxCpuTimeMs) {
301
+ opState.terminationReason = 'cpu_exceeded';
302
+ if (samplingInterval)
303
+ clearInterval(samplingInterval);
304
+ await this.handleStopPipeline(options.opId, 'cpu_exceeded', 1000);
305
+ }
306
+ else if (options.resourceBudget.maxPids && metrics.pidsCount > options.resourceBudget.maxPids) {
307
+ opState.terminationReason = 'pids_exceeded';
308
+ if (samplingInterval)
309
+ clearInterval(samplingInterval);
310
+ await this.handleStopPipeline(options.opId, 'pids_exceeded', 1000);
311
+ }
312
+ }
313
+ }
314
+ catch { }
315
+ }, 100);
316
+ }
317
+ let notifyStop;
318
+ const stopTriggerPromise = new Promise((resolve) => {
319
+ notifyStop = resolve;
320
+ });
321
+ const previousResolve = opState.stopResolve;
322
+ opState.stopResolve = (res) => {
323
+ previousResolve?.(res);
324
+ notifyStop?.(res);
325
+ };
326
+ // 等待根进程退出、超时触发、或外部停止完成
173
327
  const rootExitPromise = handle.onRootExit ?? handle.onExit;
328
+ const stopCandidate = opState.stopPromise ? opState.stopPromise : stopTriggerPromise;
174
329
  const exitOrTimeout = await Promise.race([
175
- rootExitPromise.then((res) => ({ type: 'exit', res })),
176
- timeoutPromise.then((type) => ({ type, res: null })),
330
+ rootExitPromise.then((res) => ({ type: 'exit', res, stopRes: null })),
331
+ timeoutPromise.then((type) => ({ type, res: null, stopRes: null })),
332
+ stopCandidate.then((stopRes) => ({ type: 'stop', res: null, stopRes })),
177
333
  ]);
178
334
  if (timeoutTimer)
179
335
  clearTimeout(timeoutTimer);
180
336
  if (samplingInterval)
181
337
  clearInterval(samplingInterval);
182
338
  let exitResult;
183
- if (exitOrTimeout.type === 'timeout') {
339
+ if (exitOrTimeout.type === 'stop') {
340
+ const stopRes = exitOrTimeout.stopRes;
341
+ if (stopRes.stopped !== 'confirmed_stopped') {
342
+ if (handle.rawProcess && typeof handle.rawProcess.kill === 'function') {
343
+ try {
344
+ handle.rawProcess.kill('SIGKILL');
345
+ }
346
+ catch { }
347
+ }
348
+ try {
349
+ handle.stdout.destroy?.();
350
+ handle.stderr.destroy?.();
351
+ }
352
+ catch { }
353
+ const stored = this.domain.getStore().getOperation(options.opId);
354
+ if (stored && stored.status === 'done') {
355
+ return (stored.result ?? null);
356
+ }
357
+ const indetResult = {
358
+ kind: 'indeterminate',
359
+ status: 'indeterminate',
360
+ reason: `Process ${handle.identity.pid} was cancelled or stopped but could not be confirmed: ${stopRes.errorDetails || 'residual processes still alive'}`,
361
+ recoveryGuidance: 'Residual PID detected. Inspect system processes manually before releasing resources.',
362
+ durationMs: Date.now() - startTime,
363
+ completedAt: new Date().toISOString(),
364
+ };
365
+ return this.finalizeOperation(options.opId, indetResult, options.requiredResources, false);
366
+ }
367
+ const rootExitWaitMs = 1000;
368
+ const rootExitOrTimeout = await Promise.race([
369
+ rootExitPromise.then((res) => ({ exited: true, res })),
370
+ new Promise((resolve) => setTimeout(() => resolve({ exited: false }), rootExitWaitMs)),
371
+ ]);
372
+ exitResult = rootExitOrTimeout.exited ? rootExitOrTimeout.res : { exitCode: null, signal: 'SIGKILL' };
373
+ }
374
+ else if (exitOrTimeout.type === 'timeout') {
184
375
  opState.timedOut = true;
185
376
  opState.terminationReason = 'timed_out';
186
- await this.handleStopPipeline(options.opId, 'timed_out', 1500);
187
- exitResult = await handle.onExit;
377
+ const stopRes = await this.handleStopPipeline(options.opId, 'timed_out', 1500);
378
+ // 超时路径:根进程已由停止流水线终止,等待根进程退出事实(避免被持有管道的逃逸后代挂死)
379
+ // 关键防护(问题 4):给等待根进程退出增加有界上限(1000ms),
380
+ // 若驱动停止未确认(stopped !== 'confirmed_stopped')或根进程超出上限仍未退出,转入 indeterminate
381
+ const rootExitWaitMs = 1000;
382
+ const rootExitOrTimeout = await Promise.race([
383
+ (handle.onRootExit ?? handle.onExit).then((res) => ({ exited: true, res })),
384
+ new Promise((resolve) => setTimeout(() => resolve({ exited: false }), rootExitWaitMs)),
385
+ ]);
386
+ if (stopRes.stopped !== 'confirmed_stopped' || !rootExitOrTimeout.exited) {
387
+ const stored = this.domain.getStore().getOperation(options.opId);
388
+ if (stored && stored.status === 'done' && stored.result?.status === 'indeterminate') {
389
+ return stored.result;
390
+ }
391
+ const indetResult = {
392
+ kind: 'indeterminate',
393
+ status: 'indeterminate',
394
+ reason: `Process ${handle.identity.pid} timed out and root process could not be confirmed exited within ${rootExitWaitMs}ms bound: ${stopRes.errorDetails || 'root process still alive'}`,
395
+ recoveryGuidance: 'Residual PID detected. Inspect system processes manually before releasing resources.',
396
+ durationMs: Date.now() - startTime,
397
+ completedAt: new Date().toISOString(),
398
+ };
399
+ return this.finalizeOperation(options.opId, indetResult, options.requiredResources, false);
400
+ }
401
+ exitResult = rootExitOrTimeout.res;
188
402
  }
189
403
  else {
190
404
  exitResult = exitOrTimeout.res;
@@ -201,7 +415,7 @@ export class ProcessSupervisor {
201
415
  let residualProcessesReaped = false;
202
416
  if (!drained && exitOrTimeout.type === 'exit') {
203
417
  const reapResult = await this.driver.terminate(handle.identity, 1500);
204
- if (!reapResult.stopped) {
418
+ if (reapResult.stopped !== 'confirmed_stopped') {
205
419
  const indetResult = {
206
420
  kind: 'indeterminate',
207
421
  status: 'indeterminate',
@@ -210,9 +424,7 @@ export class ProcessSupervisor {
210
424
  durationMs: Date.now() - startTime,
211
425
  completedAt: new Date().toISOString(),
212
426
  };
213
- this.domain.getStore().recordOperationResult(options.opId, indetResult, false);
214
- this.domain.getStore().updateRunStatus(options.runId, 'indeterminate', 'crash_detected');
215
- return indetResult;
427
+ return this.finalizeOperation(options.opId, indetResult, options.requiredResources, false);
216
428
  }
217
429
  residualProcessesReaped = true;
218
430
  // 被杀死的后代释放管道后,给排空最后一次机会
@@ -229,8 +441,9 @@ export class ProcessSupervisor {
229
441
  const stdoutData = stdoutDrainer.getResult();
230
442
  const stderrData = stderrDrainer.getResult();
231
443
  const isTruncated = stdoutData.isTruncated || stderrData.isTruncated;
232
- const outputRef = stdoutData.outputRef || stderrData.outputRef;
233
- const outputHash = stdoutData.outputHash || stderrData.outputHash;
444
+ const spillError = stdoutData.spillError || stderrData.spillError || artifactsDirError;
445
+ const outputRef = (!spillError && (stdoutData.outputRef || stderrData.outputRef)) || undefined;
446
+ const outputHash = (!spillError && (stdoutData.outputHash || stderrData.outputHash)) || undefined;
234
447
  // 校验进程身份
235
448
  const idVerify = await this.driver.verifyIdentity(handle.identity);
236
449
  const isResourceOrTimeoutStopped = opState.timedOut ||
@@ -259,14 +472,17 @@ export class ProcessSupervisor {
259
472
  isTruncated,
260
473
  stdoutTruncated: stdoutData.isTruncated,
261
474
  stderrTruncated: stderrData.isTruncated,
262
- stdoutRef: stdoutData.outputRef,
263
- stderrRef: stderrData.outputRef,
475
+ stdoutRef: !stdoutData.spillError ? stdoutData.outputRef : undefined,
476
+ stderrRef: !stderrData.spillError ? stderrData.outputRef : undefined,
264
477
  stdoutBytes: stdoutData.bytesSeen,
265
478
  stderrBytes: stderrData.bytesSeen,
266
- stdoutHash: stdoutData.outputHash,
267
- stderrHash: stderrData.outputHash,
479
+ stdoutHash: !stdoutData.spillError ? stdoutData.outputHash : undefined,
480
+ stderrHash: !stderrData.spillError ? stderrData.outputHash : undefined,
268
481
  ...(residualProcessesReaped ? { residualProcessesReaped: true } : {}),
269
482
  ...(streamCallbackError ? { streamCallbackError } : {}),
483
+ ...(spillError ? { spillError } : {}),
484
+ ...(stdoutData.spillError ? { stdoutSpillError: stdoutData.spillError } : {}),
485
+ ...(stderrData.spillError ? { stderrSpillError: stderrData.spillError } : {}),
270
486
  outputRef,
271
487
  outputHash,
272
488
  terminationReason,
@@ -277,33 +493,43 @@ export class ProcessSupervisor {
277
493
  completedAt: new Date().toISOString(),
278
494
  };
279
495
  if (opState.stopPromise) {
280
- await opState.stopPromise;
281
- }
282
- const existingOp = this.domain.getStore().getOperation(options.opId);
283
- if (existingOp && existingOp.status === 'done') {
284
- const storedResult = existingOp.result;
285
- if (storedResult?.status === 'indeterminate') {
286
- // 驱动无法确认停止:保留隔离事实与资源锁,绝不覆盖
287
- return existingOp.result;
288
- }
289
- // 内部停止流水线(超时 / 资源超限)会先写一份粗糙终态;这里用本次采集到的
290
- // 真实退出码、输出与耗时覆盖它,避免把已捕获的输出证据丢掉。
291
- this.domain.getStore().recordOperationResult(options.opId, result, true);
292
- return result;
293
- }
294
- // 6. [事务提交结果与释放资源]
295
- this.domain.getStore().recordOperationResult(options.opId, result, true);
296
- this.domain.releaseResources(options.opId, options.requiredResources);
297
- if (terminationReason && terminationReason !== 'completed') {
298
- this.domain.getStore().updateRunStatus(options.runId, finalStatus === 'cancelled' ? 'cancelled' : 'failed', terminationReason);
299
- }
300
- return result;
496
+ const stopRes = await opState.stopPromise;
497
+ if (stopRes.stopped !== 'confirmed_stopped') {
498
+ const stored = this.domain.getStore().getOperation(options.opId);
499
+ if (stored && stored.status === 'done') {
500
+ return (stored.result ?? null);
501
+ }
502
+ const indetResult = {
503
+ kind: 'indeterminate',
504
+ status: 'indeterminate',
505
+ reason: `Process ${handle.identity.pid} was cancelled or stopped but could not be confirmed: ${stopRes.errorDetails || 'residual processes still alive'}`,
506
+ recoveryGuidance: 'Residual PID detected. Inspect system processes manually before releasing resources.',
507
+ durationMs: Date.now() - startTime,
508
+ completedAt: new Date().toISOString(),
509
+ };
510
+ return this.finalizeOperation(options.opId, indetResult, options.requiredResources, false);
511
+ }
512
+ }
513
+ // 6. [事务提交结果与释放资源 - Single Writer]
514
+ // 统一由 finalizeOperation 写入事实与处理资源
515
+ return this.finalizeOperation(options.opId, result, options.requiredResources, true, artifactsDir);
301
516
  }
302
517
  finally {
518
+ stdoutDrainer?.forceFinalize();
519
+ stderrDrainer?.forceFinalize();
303
520
  if (timeoutTimer)
304
521
  clearTimeout(timeoutTimer);
305
522
  if (samplingInterval)
306
523
  clearInterval(samplingInterval);
524
+ if (opState.stopResolve) {
525
+ const resolveFn = opState.stopResolve;
526
+ opState.stopResolve = undefined;
527
+ resolveFn({
528
+ stopped: 'confirmed_stopped',
529
+ scope: 'direct_child',
530
+ errorDetails: opState.phase === 'done' ? undefined : 'Operation terminated before process activation',
531
+ });
532
+ }
307
533
  this.activeOperations.delete(options.opId);
308
534
  }
309
535
  }
@@ -311,52 +537,48 @@ export class ProcessSupervisor {
311
537
  * 停止确认流水线 (Stopping Pipeline)
312
538
  */
313
539
  async cancelOperation(opId, graceMs = 2000) {
314
- return this.handleStopPipeline(opId, 'user_cancelled', graceMs);
540
+ const active = this.ensureActiveOperation(opId);
541
+ active.cancelRequested = true;
542
+ if (active.cancelGraceMs === undefined) {
543
+ active.cancelGraceMs = graceMs;
544
+ }
545
+ active.terminationReason = 'user_cancelled';
546
+ // 若操作仍在等待队列中排队,立刻唤醒
547
+ this.domain.cancelWait(opId);
548
+ if (active.phase === 'active' || active.phase === 'stopping') {
549
+ return this.handleStopPipeline(opId, 'user_cancelled', graceMs);
550
+ }
551
+ // 操作仍在等资源、intent_registered 或 spawn 途中,返回已有的或新建的 stopPromise
552
+ if (!active.stopPromise) {
553
+ active.stopPromise = new Promise((resolve) => {
554
+ active.stopResolve = resolve;
555
+ });
556
+ }
557
+ return active.stopPromise;
315
558
  }
316
559
  async handleStopPipeline(opId, reason, graceMs) {
317
- const active = this.activeOperations.get(opId);
318
- if (!active) {
319
- return { stopped: true, scope: 'direct_child' };
320
- }
321
- if (active.stopPromise) {
560
+ const active = this.ensureActiveOperation(opId);
561
+ if (active.phase === 'stopping' && active.stopPromise) {
322
562
  return active.stopPromise;
323
563
  }
324
- active.stopPromise = (async () => {
325
- if (this.domain.isClosed()) {
326
- return { stopped: true, scope: 'direct_child' };
327
- }
328
- if (reason === 'user_cancelled') {
329
- active.cancelRequested = true;
564
+ active.phase = 'stopping';
565
+ if (reason === 'user_cancelled') {
566
+ active.cancelRequested = true;
567
+ }
568
+ active.terminationReason = reason;
569
+ const existingResolve = active.stopResolve;
570
+ const runPipeline = (async () => {
571
+ if (!active.handle) {
572
+ const res = { stopped: 'confirmed_stopped', scope: 'direct_child' };
573
+ existingResolve?.(res);
574
+ return res;
330
575
  }
331
- active.terminationReason = reason;
332
576
  // 1. 进入 stopping 中间态(保持排他资源锁定,阻断新操作)
333
577
  this.domain.getStore().updateOperationStatus(opId, 'stopping');
334
578
  // 2. 调用驱动停止流水线 (SIGINT -> grace -> SIGTERM -> SIGKILL)
335
579
  const stopResult = await this.driver.terminate(active.handle.identity, graceMs);
336
- if (stopResult.stopped) {
337
- // 3a. 驱动确认停止 -> 记录终态并安全释放资源
338
- const op = this.domain.getStore().getOperation(opId);
339
- if (op && op.status !== 'done') {
340
- const cancelResult = {
341
- kind: 'process',
342
- status: reason === 'user_cancelled' ? 'cancelled' : 'failed',
343
- exitCode: null,
344
- signal: 'SIGKILL',
345
- stdout: '',
346
- stderr: `Operation stopped via stopping pipeline (${reason})`,
347
- isTruncated: false,
348
- terminationReason: reason,
349
- identityVerification: 'not_original_process',
350
- durationMs: 0,
351
- completedAt: new Date().toISOString(),
352
- };
353
- this.domain.getStore().recordOperationResult(opId, cancelResult, true);
354
- this.domain.releaseResources(opId, op.requiredResources);
355
- this.domain.getStore().updateRunStatus(op.runId, reason === 'user_cancelled' ? 'cancelled' : 'failed', reason);
356
- }
357
- }
358
- else {
359
- // 3b. 驱动无法确认完全停止 -> 转入 indeterminate,绝对保留隔离屏障与锁!
580
+ // 3. 驱动无法确认停止 -> 立即转入 indeterminate,绝对保留隔离屏障与锁!
581
+ if (stopResult.stopped !== 'confirmed_stopped') {
360
582
  const op = this.domain.getStore().getOperation(opId);
361
583
  if (op && op.status !== 'done') {
362
584
  const indetResult = {
@@ -364,32 +586,145 @@ export class ProcessSupervisor {
364
586
  status: 'indeterminate',
365
587
  reason: `Process ${active.handle.identity.pid} could not be confirmed stopped: ${stopResult.errorDetails}`,
366
588
  recoveryGuidance: 'Residual PID detected. Inspect system processes manually before releasing resources.',
367
- durationMs: 0,
589
+ durationMs: Date.now() - active.startTime,
368
590
  completedAt: new Date().toISOString(),
369
591
  };
370
- // 传递 releaseResources = false,绝对不释放锁
371
- this.domain.getStore().recordOperationResult(opId, indetResult, false);
372
- this.domain.getStore().updateRunStatus(op.runId, 'indeterminate', 'crash_detected');
592
+ this.finalizeOperation(opId, indetResult, undefined, false);
373
593
  }
374
594
  }
595
+ existingResolve?.(stopResult);
375
596
  return stopResult;
376
597
  })();
377
- return active.stopPromise;
598
+ active.stopPromise = runPipeline;
599
+ return runPipeline;
600
+ }
601
+ ensureActiveOperation(opId) {
602
+ if (this.domain.isClosed()) {
603
+ throw new OperationNotActiveError(opId, 'domain_closed');
604
+ }
605
+ const active = this.activeOperations.get(opId);
606
+ if (!active || active.phase === 'done') {
607
+ const stored = this.domain.getStore().getOperation(opId);
608
+ if (!stored) {
609
+ throw new OperationNotActiveError(opId, 'not_found');
610
+ }
611
+ if (stored.status === 'done') {
612
+ throw new OperationNotActiveError(opId, 'already_completed');
613
+ }
614
+ throw new OperationNotActiveError(opId, 'not_found', `Operation "${opId}" is not active (current status: ${stored.status}).`);
615
+ }
616
+ return active;
617
+ }
618
+ handlePreSpawnCancellation(options, opState, startTime) {
619
+ opState.phase = 'done';
620
+ const stopRes = { stopped: 'confirmed_stopped', scope: 'direct_child' };
621
+ opState.stopResolve?.(stopRes);
622
+ opState.stopResolve = undefined;
623
+ const cancelResult = {
624
+ kind: 'process',
625
+ status: 'cancelled',
626
+ exitCode: null,
627
+ signal: null,
628
+ stdout: '',
629
+ stderr: '',
630
+ isTruncated: false,
631
+ terminationReason: 'user_cancelled',
632
+ evidence: 'unobserved',
633
+ identityVerification: 'not_original_process',
634
+ durationMs: Date.now() - startTime,
635
+ completedAt: new Date().toISOString(),
636
+ };
637
+ const stored = this.domain.getStore().getOperation(options.opId);
638
+ if (!stored) {
639
+ // 此时操作是在 waiting_resources 阶段就被取消,尚未获取资源租约,亦未正常注册意图。
640
+ // 为保证 store 中有该操作记录以便写入 cancelled 结果,直接记录预意图取消操作,
641
+ // 绝不向 resource_leases 写入虚假租约,亦不产生虚假的 OPERATION_INTENT_REGISTERED 事件。
642
+ const op = {
643
+ id: options.opId,
644
+ runId: options.runId,
645
+ kind: 'process',
646
+ name: options.name,
647
+ inputFingerprint: options.inputFingerprint || computeInputFingerprint(options),
648
+ requiredResources: options.requiredResources || [],
649
+ status: 'done',
650
+ };
651
+ this.domain.getStore().recordPreIntentCancelledOperation(op, this.domain.domainId, cancelResult);
652
+ this.domain.internalReleaseResources(options.opId, options.requiredResources);
653
+ return cancelResult;
654
+ }
655
+ return this.finalizeOperation(options.opId, cancelResult, options.requiredResources, true);
656
+ }
657
+ pruneArtifacts(filter) {
658
+ return this.domain.pruneArtifacts(filter);
659
+ }
660
+ finalizeOperation(opId, result, requiredResources, releaseResourceLock = true, artifactsDir) {
661
+ const existing = this.domain.getStore().getOperation(opId);
662
+ if (existing && existing.status === 'done') {
663
+ throw new Error(`Cannot finalize operation "${opId}": operation is already finalized with status "${existing.status}"`);
664
+ }
665
+ if (result.kind === 'process') {
666
+ const procRes = result;
667
+ const targetDir = artifactsDir || path.join(this.domain.domainPath, 'artifacts');
668
+ const stdoutSpillPath = path.join(targetDir, `${opId}-stdout.log`);
669
+ const stderrSpillPath = path.join(targetDir, `${opId}-stderr.log`);
670
+ if (!procRes.stdoutRef && fs.existsSync(stdoutSpillPath)) {
671
+ try {
672
+ fs.unlinkSync(stdoutSpillPath);
673
+ }
674
+ catch (unlinkErr) {
675
+ procRes.spillError = procRes.spillError || unlinkErr.message || String(unlinkErr);
676
+ }
677
+ }
678
+ if (!procRes.stderrRef && fs.existsSync(stderrSpillPath)) {
679
+ try {
680
+ fs.unlinkSync(stderrSpillPath);
681
+ }
682
+ catch (unlinkErr) {
683
+ procRes.spillError = procRes.spillError || unlinkErr.message || String(unlinkErr);
684
+ }
685
+ }
686
+ }
687
+ if (result.status === 'indeterminate') {
688
+ this.domain.getStore().recordOperationResult(opId, result, false);
689
+ const op = this.domain.getStore().getOperation(opId);
690
+ if (op) {
691
+ const termReason = result.terminationReason;
692
+ this.domain.getStore().updateRunStatus(op.runId, 'indeterminate', termReason);
693
+ }
694
+ }
695
+ else {
696
+ this.domain.getStore().recordOperationResult(opId, result, true);
697
+ if (releaseResourceLock) {
698
+ this.domain.internalReleaseResources(opId, requiredResources);
699
+ }
700
+ }
701
+ return result;
378
702
  }
379
- setupStreamDrainer(stream, maxBytes, spillFilePath, onChunk, onData) {
380
- const chunks = [];
381
- let currentBytes = 0;
703
+ setupStreamDrainer(stream, maxBytes, spillFilePath, onChunk, onData, spillInitError) {
704
+ const overhead = maxBytes >= 80 ? Math.min(64, Math.floor(maxBytes / 4)) : 0;
705
+ const effectiveMax = maxBytes - overhead;
706
+ const headMaxBytes = overhead > 0 ? Math.floor(effectiveMax * 0.75) : maxBytes;
707
+ const tailMaxBytes = overhead > 0 ? Math.max(0, effectiveMax - headMaxBytes) : 0;
708
+ const headChunks = [];
709
+ let headBytes = 0;
382
710
  let bytesSeen = 0;
383
711
  let isTruncated = false;
384
712
  let spillFd = null;
713
+ let spillError = spillInitError;
385
714
  const hash = crypto.createHash('sha256');
386
715
  let isFinalized = false;
387
716
  let outputHash = undefined;
388
- if (spillFilePath) {
717
+ const tailRing = tailMaxBytes > 0 ? Buffer.alloc(tailMaxBytes) : null;
718
+ let tailHead = 0;
719
+ let tailCount = 0;
720
+ if (spillFilePath && !spillError) {
389
721
  try {
390
722
  spillFd = fs.openSync(spillFilePath, 'w');
391
723
  }
392
- catch { }
724
+ catch (openErr) {
725
+ spillError = openErr.message || String(openErr);
726
+ spillFd = null;
727
+ }
393
728
  }
394
729
  const doFinalize = () => {
395
730
  if (isFinalized)
@@ -400,13 +735,20 @@ export class ProcessSupervisor {
400
735
  fs.fsyncSync(spillFd);
401
736
  fs.closeSync(spillFd);
402
737
  }
403
- catch { }
738
+ catch (syncErr) {
739
+ spillError = spillError || syncErr.message || String(syncErr);
740
+ }
404
741
  spillFd = null;
405
742
  }
406
- try {
407
- outputHash = hash.digest('hex');
743
+ if (!spillError) {
744
+ try {
745
+ outputHash = hash.digest('hex');
746
+ }
747
+ catch { }
748
+ }
749
+ else {
750
+ outputHash = undefined;
408
751
  }
409
- catch { }
410
752
  };
411
753
  const finishPromise = new Promise((resolve) => {
412
754
  const finalize = () => {
@@ -414,6 +756,10 @@ export class ProcessSupervisor {
414
756
  resolve();
415
757
  };
416
758
  stream.on('data', (chunk) => {
759
+ if (isFinalized) {
760
+ // finalize 之后,流继续读走数据避免堵塞或 SIGPIPE,但跳过 hash、落盘和转发
761
+ return;
762
+ }
417
763
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
418
764
  bytesSeen += buf.length;
419
765
  if (onChunk) {
@@ -422,32 +768,66 @@ export class ProcessSupervisor {
422
768
  }
423
769
  catch { }
424
770
  }
425
- hash.update(buf);
426
- // 持续落盘转储全部流
771
+ // 持续落盘转储全部流与写盘同链哈希 (P0-9)
427
772
  if (spillFd !== null) {
428
773
  try {
429
774
  fs.writeSync(spillFd, buf);
775
+ hash.update(buf);
776
+ }
777
+ catch (writeErr) {
778
+ spillError = spillError || writeErr.message || String(writeErr);
779
+ try {
780
+ fs.closeSync(spillFd);
781
+ }
782
+ catch { }
783
+ spillFd = null;
784
+ }
785
+ }
786
+ else if (!spillFilePath && !spillError) {
787
+ try {
788
+ hash.update(buf);
430
789
  }
431
790
  catch { }
432
791
  }
433
792
  if (onData) {
434
793
  onData(buf);
435
794
  }
436
- // 内存有界保留
437
- if (currentBytes + buf.length <= maxBytes) {
438
- chunks.push(buf);
439
- currentBytes += buf.length;
440
- }
441
- else {
442
- if (!isTruncated) {
443
- const remaining = maxBytes - currentBytes;
795
+ // Head 内存有界保留
796
+ if (headBytes < headMaxBytes) {
797
+ if (headBytes + buf.length <= headMaxBytes) {
798
+ headChunks.push(buf);
799
+ headBytes += buf.length;
800
+ }
801
+ else {
802
+ const remaining = headMaxBytes - headBytes;
444
803
  if (remaining > 0) {
445
- chunks.push(buf.subarray(0, remaining));
446
- currentBytes += remaining;
804
+ headChunks.push(buf.subarray(0, remaining));
805
+ headBytes += remaining;
447
806
  }
448
807
  isTruncated = true;
449
808
  }
450
809
  }
810
+ else {
811
+ isTruncated = true;
812
+ }
813
+ if (bytesSeen > maxBytes) {
814
+ isTruncated = true;
815
+ }
816
+ // Tail 环形缓冲保留
817
+ if (tailRing !== null && tailMaxBytes > 0) {
818
+ if (buf.length >= tailMaxBytes) {
819
+ buf.copy(tailRing, 0, buf.length - tailMaxBytes);
820
+ tailHead = 0;
821
+ tailCount = tailMaxBytes;
822
+ }
823
+ else {
824
+ for (let i = 0; i < buf.length; i++) {
825
+ tailRing[tailHead] = buf[i];
826
+ tailHead = (tailHead + 1) % tailMaxBytes;
827
+ }
828
+ tailCount = Math.min(tailMaxBytes, tailCount + buf.length);
829
+ }
830
+ }
451
831
  });
452
832
  stream.on('end', finalize);
453
833
  stream.on('close', finalize);
@@ -456,12 +836,37 @@ export class ProcessSupervisor {
456
836
  return {
457
837
  getResult: () => {
458
838
  doFinalize();
839
+ let content;
840
+ if (bytesSeen <= maxBytes) {
841
+ content = Buffer.concat(headChunks).toString('utf8');
842
+ }
843
+ else {
844
+ const rawHead = trimToValidUtf8(Buffer.concat(headChunks));
845
+ let cleanTail = Buffer.alloc(0);
846
+ if (tailRing !== null && tailCount > 0) {
847
+ const rawTail = Buffer.alloc(tailCount);
848
+ if (tailCount < tailMaxBytes) {
849
+ tailRing.copy(rawTail, 0, 0, tailCount);
850
+ }
851
+ else {
852
+ const part1 = tailRing.subarray(tailHead, tailMaxBytes);
853
+ const part2 = tailRing.subarray(0, tailHead);
854
+ part1.copy(rawTail, 0);
855
+ part2.copy(rawTail, part1.length);
856
+ }
857
+ cleanTail = trimStartToValidUtf8(rawTail);
858
+ }
859
+ const truncatedBytes = Math.max(0, bytesSeen - rawHead.length - cleanTail.length);
860
+ const marker = `\n[... truncated ${truncatedBytes} bytes ...]\n`;
861
+ content = rawHead.toString('utf8') + marker + cleanTail.toString('utf8');
862
+ }
459
863
  return {
460
- content: Buffer.concat(chunks).toString('utf8'),
864
+ content,
461
865
  isTruncated,
462
- outputRef: isTruncated && spillFilePath ? spillFilePath : undefined,
463
- outputHash,
866
+ outputRef: isTruncated && spillFilePath && !spillError ? spillFilePath : undefined,
867
+ outputHash: !spillError ? outputHash : undefined,
464
868
  bytesSeen,
869
+ spillError,
465
870
  };
466
871
  },
467
872
  finishPromise,