@xioflow/kernel 0.2.0 → 0.4.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.
- package/README.md +116 -15
- package/dist/capability/check.d.ts +32 -0
- package/dist/capability/check.d.ts.map +1 -0
- package/dist/capability/check.js +136 -0
- package/dist/capability/check.js.map +1 -0
- package/dist/capability/index.d.ts +3 -0
- package/dist/capability/index.d.ts.map +1 -0
- package/dist/capability/index.js +3 -0
- package/dist/capability/index.js.map +1 -0
- package/dist/capability/path-utils.d.ts +11 -0
- package/dist/capability/path-utils.d.ts.map +1 -0
- package/dist/capability/path-utils.js +46 -0
- package/dist/capability/path-utils.js.map +1 -0
- package/dist/confinement/bubblewrap.d.ts +8 -0
- package/dist/confinement/bubblewrap.d.ts.map +1 -0
- package/dist/confinement/bubblewrap.js +45 -0
- package/dist/confinement/bubblewrap.js.map +1 -0
- package/dist/confinement/detector.d.ts +4 -0
- package/dist/confinement/detector.d.ts.map +1 -0
- package/dist/confinement/detector.js +35 -0
- package/dist/confinement/detector.js.map +1 -0
- package/dist/confinement/index.d.ts +5 -0
- package/dist/confinement/index.d.ts.map +1 -0
- package/dist/confinement/index.js +5 -0
- package/dist/confinement/index.js.map +1 -0
- package/dist/confinement/sandbox-exec.d.ts +8 -0
- package/dist/confinement/sandbox-exec.d.ts.map +1 -0
- package/dist/confinement/sandbox-exec.js +30 -0
- package/dist/confinement/sandbox-exec.js.map +1 -0
- package/dist/confinement/srt.d.ts +8 -0
- package/dist/confinement/srt.d.ts.map +1 -0
- package/dist/confinement/srt.js +41 -0
- package/dist/confinement/srt.js.map +1 -0
- package/dist/domain.d.ts +46 -2
- package/dist/domain.d.ts.map +1 -1
- package/dist/domain.js +209 -18
- package/dist/domain.js.map +1 -1
- package/dist/driver/node-driver.d.ts.map +1 -1
- package/dist/driver/node-driver.js +46 -36
- package/dist/driver/node-driver.js.map +1 -1
- package/dist/driver/types.d.ts +13 -0
- package/dist/driver/types.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/quick-run.d.ts +27 -0
- package/dist/quick-run.d.ts.map +1 -0
- package/dist/quick-run.js +75 -0
- package/dist/quick-run.js.map +1 -0
- package/dist/recovery/engine.d.ts +7 -0
- package/dist/recovery/engine.d.ts.map +1 -1
- package/dist/recovery/engine.js +174 -4
- package/dist/recovery/engine.js.map +1 -1
- package/dist/snapshot/git-shadow.d.ts +49 -0
- package/dist/snapshot/git-shadow.d.ts.map +1 -0
- package/dist/snapshot/git-shadow.js +359 -0
- package/dist/snapshot/git-shadow.js.map +1 -0
- package/dist/store/schema.d.ts +1 -1
- package/dist/store/schema.d.ts.map +1 -1
- package/dist/store/schema.js +31 -0
- package/dist/store/schema.js.map +1 -1
- package/dist/store/sqlite.d.ts +17 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +193 -58
- package/dist/store/sqlite.js.map +1 -1
- package/dist/supervisor/drainer.d.ts +17 -0
- package/dist/supervisor/drainer.d.ts.map +1 -0
- package/dist/supervisor/drainer.js +200 -0
- package/dist/supervisor/drainer.js.map +1 -0
- package/dist/supervisor/service.d.ts +24 -0
- package/dist/supervisor/service.d.ts.map +1 -0
- package/dist/supervisor/service.js +588 -0
- package/dist/supervisor/service.js.map +1 -0
- package/dist/supervisor/supervisor.d.ts +62 -4
- package/dist/supervisor/supervisor.d.ts.map +1 -1
- package/dist/supervisor/supervisor.js +1037 -562
- package/dist/supervisor/supervisor.js.map +1 -1
- package/dist/testing/contract-suite.d.ts.map +1 -1
- package/dist/testing/contract-suite.js +748 -2
- package/dist/testing/contract-suite.js.map +1 -1
- package/dist/types.d.ts +175 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +39 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
|
-
import { UnsupportedCapabilityError, OperationNotActiveError,
|
|
4
|
+
import { UnsupportedCapabilityError, OperationNotActiveError, OperationIdConflictError, RecoveryRequiredError, ResourceConflictError, } from '../types.js';
|
|
5
|
+
import { NodePlatformDriver } from '../driver/node-driver.js';
|
|
6
|
+
import { GitShadowSnapshotDriver } from '../snapshot/git-shadow.js';
|
|
7
|
+
import { ServiceSupervisor } from './service.js';
|
|
8
|
+
import { setupStreamDrainer } from './drainer.js';
|
|
9
|
+
import { isPathContained } from '../capability/index.js';
|
|
10
|
+
import { createConfinementDriver } from '../confinement/index.js';
|
|
11
|
+
export function normalizeResourceName(res) {
|
|
12
|
+
if (res.startsWith('workspace:write:')) {
|
|
13
|
+
const raw = res.slice('workspace:write:'.length);
|
|
14
|
+
try {
|
|
15
|
+
return `workspace:write:${fs.realpathSync(raw)}`;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return `workspace:write:${path.resolve(raw)}`;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return res;
|
|
22
|
+
}
|
|
5
23
|
/**
|
|
6
24
|
* 规范化计算完整输入的 SHA-256 指纹 (ARCHITECTURE §2 / P0-12)
|
|
7
25
|
* 覆盖 execPath, args, cwd, 键排序的 envWhiteList, inheritEnv, sha256(stdin), 排序后的 requiredResources, timeoutMs, resourceBudget.
|
|
@@ -35,42 +53,26 @@ export function computeInputFingerprint(options) {
|
|
|
35
53
|
: null,
|
|
36
54
|
stdinHash,
|
|
37
55
|
timeoutMs: options.timeoutMs ?? null,
|
|
56
|
+
capabilityId: options.capabilityId ?? null,
|
|
38
57
|
};
|
|
39
58
|
const canonicalJson = JSON.stringify(canonical);
|
|
40
59
|
return crypto.createHash('sha256').update(canonicalJson).digest('hex');
|
|
41
60
|
}
|
|
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
|
-
}
|
|
66
61
|
export class ProcessSupervisor {
|
|
67
62
|
domain;
|
|
68
63
|
driver;
|
|
69
64
|
activeOperations = new Map();
|
|
70
|
-
|
|
65
|
+
serviceSupervisor;
|
|
66
|
+
snapshotDriver;
|
|
67
|
+
confinementDriver;
|
|
68
|
+
constructor(domain, driver = domain.getDriver?.() || new NodePlatformDriver(), snapshotDriver, confinementDriver) {
|
|
71
69
|
this.domain = domain;
|
|
72
70
|
this.driver = driver;
|
|
73
71
|
this.domain.setDriver?.(driver);
|
|
72
|
+
this.serviceSupervisor = new ServiceSupervisor(domain, driver, this);
|
|
73
|
+
this.snapshotDriver = snapshotDriver || new GitShadowSnapshotDriver();
|
|
74
|
+
this.confinementDriver = confinementDriver;
|
|
75
|
+
this.domain.setSnapshotDriver?.(this.snapshotDriver);
|
|
74
76
|
}
|
|
75
77
|
getDomain() {
|
|
76
78
|
return this.domain;
|
|
@@ -78,10 +80,509 @@ export class ProcessSupervisor {
|
|
|
78
80
|
getDriver() {
|
|
79
81
|
return this.driver;
|
|
80
82
|
}
|
|
83
|
+
getSnapshotDriver() {
|
|
84
|
+
return this.snapshotDriver;
|
|
85
|
+
}
|
|
86
|
+
setConfinementDriver(driver) {
|
|
87
|
+
this.confinementDriver = driver;
|
|
88
|
+
}
|
|
89
|
+
getConfinementDriver() {
|
|
90
|
+
return this.confinementDriver;
|
|
91
|
+
}
|
|
92
|
+
async startService(spec) {
|
|
93
|
+
return this.serviceSupervisor.startService(spec);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 快照捕获协议 (ARCHITECTURE §3.5 / 契约 #32, #53)
|
|
97
|
+
*/
|
|
98
|
+
async captureSnapshot(options) {
|
|
99
|
+
const startTime = Date.now();
|
|
100
|
+
let targetRoots = options.roots;
|
|
101
|
+
let capAdmission;
|
|
102
|
+
if (options.capabilityId) {
|
|
103
|
+
capAdmission = this.domain.checkCapabilityAdmission({
|
|
104
|
+
capabilityId: options.capabilityId,
|
|
105
|
+
mutationRoots: targetRoots,
|
|
106
|
+
runId: options.runId,
|
|
107
|
+
opId: options.opId,
|
|
108
|
+
});
|
|
109
|
+
if (!targetRoots || targetRoots.length === 0) {
|
|
110
|
+
targetRoots = capAdmission.effectiveMutationRoots;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!targetRoots || targetRoots.length === 0) {
|
|
114
|
+
throw new Error('captureSnapshot requires at least one root directory');
|
|
115
|
+
}
|
|
116
|
+
const realRoots = targetRoots.map((r) => path.resolve(r));
|
|
117
|
+
const requiredResources = realRoots.map((r) => `workspace:write:${r}`);
|
|
118
|
+
if (this.snapshotDriver instanceof GitShadowSnapshotDriver) {
|
|
119
|
+
try {
|
|
120
|
+
const { commonDir } = await this.snapshotDriver.assertGitRepo(realRoots[0]);
|
|
121
|
+
requiredResources.push(`git:objects:${commonDir}`);
|
|
122
|
+
}
|
|
123
|
+
catch { }
|
|
124
|
+
}
|
|
125
|
+
const inputFingerprint = crypto
|
|
126
|
+
.createHash('sha256')
|
|
127
|
+
.update(JSON.stringify({
|
|
128
|
+
kind: 'snapshot',
|
|
129
|
+
roots: realRoots,
|
|
130
|
+
includeIgnored: !!options.includeIgnored,
|
|
131
|
+
maxTreeSizeBytes: options.maxTreeSizeBytes,
|
|
132
|
+
capabilityId: options.capabilityId ?? null,
|
|
133
|
+
}))
|
|
134
|
+
.digest('hex');
|
|
135
|
+
const op = {
|
|
136
|
+
id: options.opId,
|
|
137
|
+
runId: options.runId,
|
|
138
|
+
kind: 'snapshot',
|
|
139
|
+
name: `snapshot:${realRoots[0]}`,
|
|
140
|
+
inputFingerprint,
|
|
141
|
+
requiredResources,
|
|
142
|
+
mutationRoots: realRoots,
|
|
143
|
+
capabilityId: options.capabilityId,
|
|
144
|
+
timeoutMs: options.timeoutMs,
|
|
145
|
+
status: 'intent_registered',
|
|
146
|
+
};
|
|
147
|
+
this.domain.registerOperationIntent(op);
|
|
148
|
+
this.domain.getStore().updateOperationStatus(options.opId, 'active');
|
|
149
|
+
this.domain.getStore().recordEventAndTransitionState({
|
|
150
|
+
domainId: this.domain.domainId,
|
|
151
|
+
runId: options.runId,
|
|
152
|
+
operationId: options.opId,
|
|
153
|
+
type: 'OPERATION_STATUS_TRANSITION',
|
|
154
|
+
payload: { status: 'active' },
|
|
155
|
+
timestamp: new Date().toISOString(),
|
|
156
|
+
});
|
|
157
|
+
if (options.capabilityId && capAdmission) {
|
|
158
|
+
this.domain.getStore().recordJournalEvent({
|
|
159
|
+
domainId: this.domain.domainId,
|
|
160
|
+
runId: options.runId,
|
|
161
|
+
operationId: options.opId,
|
|
162
|
+
type: 'CAPABILITY_USED',
|
|
163
|
+
payload: {
|
|
164
|
+
capabilityId: options.capabilityId,
|
|
165
|
+
scope: capAdmission.capability.scope,
|
|
166
|
+
actor: capAdmission.capability.issuedBy,
|
|
167
|
+
},
|
|
168
|
+
timestamp: new Date().toISOString(),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const snapRef = await this.snapshotDriver.capture(realRoots, {
|
|
173
|
+
id: options.opId,
|
|
174
|
+
domainId: this.domain.domainId,
|
|
175
|
+
opId: options.opId,
|
|
176
|
+
includeIgnored: options.includeIgnored,
|
|
177
|
+
maxTreeSizeBytes: options.maxTreeSizeBytes,
|
|
178
|
+
});
|
|
179
|
+
let result;
|
|
180
|
+
this.domain.getStore().transaction(() => {
|
|
181
|
+
const seq = this.domain.getStore().recordEventAndTransitionState({
|
|
182
|
+
domainId: this.domain.domainId,
|
|
183
|
+
runId: options.runId,
|
|
184
|
+
operationId: options.opId,
|
|
185
|
+
type: 'SNAPSHOT_CAPTURED',
|
|
186
|
+
payload: {
|
|
187
|
+
snapshotId: snapRef.id,
|
|
188
|
+
roots: snapRef.roots,
|
|
189
|
+
treeFingerprint: snapRef.treeFingerprint,
|
|
190
|
+
commitHash: snapRef.commitHash,
|
|
191
|
+
},
|
|
192
|
+
timestamp: new Date().toISOString(),
|
|
193
|
+
});
|
|
194
|
+
snapRef.journalSeq = seq;
|
|
195
|
+
this.domain.getStore().recordSnapshot(snapRef);
|
|
196
|
+
result = {
|
|
197
|
+
kind: 'snapshot',
|
|
198
|
+
status: 'succeeded',
|
|
199
|
+
snapshot: snapRef,
|
|
200
|
+
capabilityId: options.capabilityId,
|
|
201
|
+
durationMs: Date.now() - startTime,
|
|
202
|
+
completedAt: new Date().toISOString(),
|
|
203
|
+
};
|
|
204
|
+
this.domain.getStore().recordOperationResult(options.opId, result);
|
|
205
|
+
this.domain.getStore().updateOperationStatus(options.opId, 'done');
|
|
206
|
+
this.domain.getStore().recordEventAndTransitionState({
|
|
207
|
+
domainId: this.domain.domainId,
|
|
208
|
+
runId: options.runId,
|
|
209
|
+
operationId: options.opId,
|
|
210
|
+
type: 'OPERATION_RESULT_RECORDED',
|
|
211
|
+
payload: { status: 'succeeded', snapshotId: snapRef.id },
|
|
212
|
+
timestamp: new Date().toISOString(),
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
this.domain.internalReleaseResources(options.opId);
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
const failResult = {
|
|
220
|
+
kind: 'snapshot',
|
|
221
|
+
status: 'failed',
|
|
222
|
+
errorMessage: err.message,
|
|
223
|
+
durationMs: Date.now() - startTime,
|
|
224
|
+
completedAt: new Date().toISOString(),
|
|
225
|
+
};
|
|
226
|
+
let recordError;
|
|
227
|
+
try {
|
|
228
|
+
this.domain.getStore().recordOperationResult(options.opId, failResult);
|
|
229
|
+
this.domain.getStore().updateOperationStatus(options.opId, 'done');
|
|
230
|
+
}
|
|
231
|
+
catch (recErr) {
|
|
232
|
+
recordError = recErr;
|
|
233
|
+
}
|
|
234
|
+
this.domain.internalReleaseResources(options.opId);
|
|
235
|
+
if (recordError !== undefined) {
|
|
236
|
+
// 失败事实没能落盘时不能只抛原始错误:调用方需要知道 journal 里没有这条结果
|
|
237
|
+
const recMsg = recordError instanceof Error ? recordError.message : String(recordError);
|
|
238
|
+
throw new Error(`${err.message} (additionally, recording the failed snapshot result failed: ${recMsg})`, {
|
|
239
|
+
cause: err,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* 回滚协议 (ARCHITECTURE §3.5 / 契约 #28–#30, #56)
|
|
247
|
+
*/
|
|
248
|
+
async rollback(options) {
|
|
249
|
+
const startTime = Date.now();
|
|
250
|
+
const snapshot = this.domain.getStore().getSnapshot(options.snapshotId);
|
|
251
|
+
if (!snapshot) {
|
|
252
|
+
throw new Error(`Snapshot not found: ${options.snapshotId}`);
|
|
253
|
+
}
|
|
254
|
+
let targetRoots = (options.roots || snapshot.roots).map((r) => path.resolve(r));
|
|
255
|
+
let capAdmission;
|
|
256
|
+
if (options.capabilityId) {
|
|
257
|
+
capAdmission = this.domain.checkCapabilityAdmission({
|
|
258
|
+
capabilityId: options.capabilityId,
|
|
259
|
+
mutationRoots: targetRoots,
|
|
260
|
+
runId: options.runId,
|
|
261
|
+
opId: options.opId,
|
|
262
|
+
});
|
|
263
|
+
targetRoots = capAdmission.effectiveMutationRoots;
|
|
264
|
+
}
|
|
265
|
+
// 0. 前置条件检查(契约 #30):目标根目录上不存在 active / stopping / 未裁决 indeterminate 的操作
|
|
266
|
+
for (const root of targetRoots) {
|
|
267
|
+
let realRoot = root;
|
|
268
|
+
try {
|
|
269
|
+
realRoot = fs.realpathSync(root);
|
|
270
|
+
}
|
|
271
|
+
catch { }
|
|
272
|
+
const res1 = `workspace:write:${root}`;
|
|
273
|
+
const res2 = `workspace:write:${realRoot}`;
|
|
274
|
+
if (this.domain.isResourceLocked(res1)) {
|
|
275
|
+
throw new ResourceConflictError(res1, this.domain.getResourceOwner(res1) || 'unknown', options.opId, 0);
|
|
276
|
+
}
|
|
277
|
+
if (this.domain.isResourceLocked(res2)) {
|
|
278
|
+
throw new ResourceConflictError(res2, this.domain.getResourceOwner(res2) || 'unknown', options.opId, 0);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const unfinishedOps = this.domain.getStore().getUnfinishedOperations(this.domain.domainId);
|
|
282
|
+
for (const op of unfinishedOps) {
|
|
283
|
+
if (op.id === options.opId)
|
|
284
|
+
continue;
|
|
285
|
+
for (const root of targetRoots) {
|
|
286
|
+
let realRoot = root;
|
|
287
|
+
try {
|
|
288
|
+
realRoot = fs.realpathSync(root);
|
|
289
|
+
}
|
|
290
|
+
catch { }
|
|
291
|
+
const writeRes1 = `workspace:write:${root}`;
|
|
292
|
+
const writeRes2 = `workspace:write:${realRoot}`;
|
|
293
|
+
if (op.requiredResources.includes(writeRes1) ||
|
|
294
|
+
op.requiredResources.includes(writeRes2) ||
|
|
295
|
+
op.mutationRoots?.some((mr) => {
|
|
296
|
+
const resolved = path.resolve(mr);
|
|
297
|
+
return resolved === root || resolved === realRoot;
|
|
298
|
+
})) {
|
|
299
|
+
throw new ResourceConflictError(writeRes1, op.id, options.opId, 0);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// 检查是否有未裁决的 indeterminate op 占用该 root
|
|
304
|
+
const leases = this.domain.getStore().getPersistedResourceLeases(this.domain.domainId);
|
|
305
|
+
for (const lease of leases) {
|
|
306
|
+
for (const root of targetRoots) {
|
|
307
|
+
let realRoot = root;
|
|
308
|
+
try {
|
|
309
|
+
realRoot = fs.realpathSync(root);
|
|
310
|
+
}
|
|
311
|
+
catch { }
|
|
312
|
+
const writeRes1 = `workspace:write:${root}`;
|
|
313
|
+
const writeRes2 = `workspace:write:${realRoot}`;
|
|
314
|
+
if ((lease.resourceId === writeRes1 || lease.resourceId === writeRes2) && lease.operationId !== options.opId) {
|
|
315
|
+
throw new ResourceConflictError(lease.resourceId, lease.operationId, options.opId, 0);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const requiredResources = targetRoots.map((r) => `workspace:write:${r}`);
|
|
320
|
+
const inputFingerprint = crypto
|
|
321
|
+
.createHash('sha256')
|
|
322
|
+
.update(JSON.stringify({
|
|
323
|
+
kind: 'rollback',
|
|
324
|
+
snapshotId: options.snapshotId,
|
|
325
|
+
targetRoots,
|
|
326
|
+
capabilityId: options.capabilityId ?? null,
|
|
327
|
+
}))
|
|
328
|
+
.digest('hex');
|
|
329
|
+
const op = {
|
|
330
|
+
id: options.opId,
|
|
331
|
+
runId: options.runId,
|
|
332
|
+
kind: 'rollback',
|
|
333
|
+
name: `rollback:${options.snapshotId}`,
|
|
334
|
+
inputFingerprint,
|
|
335
|
+
requiredResources,
|
|
336
|
+
mutationRoots: targetRoots,
|
|
337
|
+
capabilityId: options.capabilityId,
|
|
338
|
+
outputRef: options.snapshotId,
|
|
339
|
+
timeoutMs: options.timeoutMs,
|
|
340
|
+
status: 'intent_registered',
|
|
341
|
+
};
|
|
342
|
+
this.domain.registerOperationIntent(op);
|
|
343
|
+
this.domain.getStore().updateOperationStatus(options.opId, 'active');
|
|
344
|
+
this.domain.getStore().recordEventAndTransitionState({
|
|
345
|
+
domainId: this.domain.domainId,
|
|
346
|
+
runId: options.runId,
|
|
347
|
+
operationId: options.opId,
|
|
348
|
+
type: 'OPERATION_STATUS_TRANSITION',
|
|
349
|
+
payload: { status: 'active' },
|
|
350
|
+
timestamp: new Date().toISOString(),
|
|
351
|
+
});
|
|
352
|
+
if (options.capabilityId && capAdmission) {
|
|
353
|
+
this.domain.getStore().recordJournalEvent({
|
|
354
|
+
domainId: this.domain.domainId,
|
|
355
|
+
runId: options.runId,
|
|
356
|
+
operationId: options.opId,
|
|
357
|
+
type: 'CAPABILITY_USED',
|
|
358
|
+
payload: {
|
|
359
|
+
capabilityId: options.capabilityId,
|
|
360
|
+
scope: capAdmission.capability.scope,
|
|
361
|
+
actor: capAdmission.capability.issuedBy,
|
|
362
|
+
},
|
|
363
|
+
timestamp: new Date().toISOString(),
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
const { unrestoredPaths } = await this.snapshotDriver.restore(snapshot, { force: options.force });
|
|
368
|
+
const currentFp = await this.snapshotDriver.fingerprint(targetRoots, { against: snapshot });
|
|
369
|
+
const isMatch = currentFp === snapshot.treeFingerprint;
|
|
370
|
+
let status;
|
|
371
|
+
if (!isMatch) {
|
|
372
|
+
status = 'failed';
|
|
373
|
+
}
|
|
374
|
+
else if (unrestoredPaths.length > 0) {
|
|
375
|
+
status = 'partial';
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
status = 'restored';
|
|
379
|
+
}
|
|
380
|
+
let allOpsConfined = false;
|
|
381
|
+
const eventsSince = snapshot.journalSeq !== undefined
|
|
382
|
+
? this.domain.getStore().getJournalEvents(this.domain.domainId, snapshot.journalSeq)
|
|
383
|
+
: [];
|
|
384
|
+
const opResults = eventsSince.filter((e) => e.type === 'OPERATION_RESULT_RECORDED' && e.operationId !== options.opId);
|
|
385
|
+
if (opResults.length === 0) {
|
|
386
|
+
allOpsConfined = true;
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
allOpsConfined = opResults.every((e) => {
|
|
390
|
+
const res = e.payload?.result || e.payload;
|
|
391
|
+
const storedOp = this.domain.getStore().getOperation(e.operationId);
|
|
392
|
+
if (storedOp?.kind === 'snapshot' || storedOp?.kind === 'rollback') {
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
if (res?.confined !== true) {
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
const opRoots = storedOp?.mutationRoots || [];
|
|
399
|
+
if (opRoots.length === 0)
|
|
400
|
+
return true;
|
|
401
|
+
return opRoots.every((opr) => targetRoots.some((sr) => isPathContained(sr, opr)));
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
let coverage;
|
|
405
|
+
let outOfScopeEffects;
|
|
406
|
+
if (status !== 'restored') {
|
|
407
|
+
coverage = status === 'partial' ? 'declared_roots' : 'none';
|
|
408
|
+
outOfScopeEffects = 'possible';
|
|
409
|
+
}
|
|
410
|
+
else if (allOpsConfined) {
|
|
411
|
+
coverage = 'complete';
|
|
412
|
+
outOfScopeEffects = 'none_possible';
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
coverage = 'declared_roots';
|
|
416
|
+
outOfScopeEffects = 'possible';
|
|
417
|
+
}
|
|
418
|
+
let result;
|
|
419
|
+
this.domain.getStore().transaction(() => {
|
|
420
|
+
result = {
|
|
421
|
+
kind: 'rollback',
|
|
422
|
+
status,
|
|
423
|
+
snapshotId: options.snapshotId,
|
|
424
|
+
unrestoredPaths: unrestoredPaths.length > 0 ? unrestoredPaths : undefined,
|
|
425
|
+
coverage,
|
|
426
|
+
outOfScopeEffects,
|
|
427
|
+
capabilityId: options.capabilityId,
|
|
428
|
+
durationMs: Date.now() - startTime,
|
|
429
|
+
completedAt: new Date().toISOString(),
|
|
430
|
+
};
|
|
431
|
+
this.domain.getStore().recordOperationResult(options.opId, result);
|
|
432
|
+
this.domain.getStore().updateOperationStatus(options.opId, 'done');
|
|
433
|
+
});
|
|
434
|
+
this.domain.internalReleaseResources(options.opId);
|
|
435
|
+
return result;
|
|
436
|
+
}
|
|
437
|
+
catch (err) {
|
|
438
|
+
this.domain.internalReleaseResources(options.opId);
|
|
439
|
+
throw err;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* 分叉工作区 Materialize (ARCHITECTURE §3.5 / 契约 #54)
|
|
444
|
+
*/
|
|
445
|
+
async materialize(snapshotId, newRoot) {
|
|
446
|
+
const absNewRoot = path.resolve(newRoot);
|
|
447
|
+
const writeRes = `workspace:write:${absNewRoot}`;
|
|
448
|
+
if (this.domain.isResourceLocked(writeRes)) {
|
|
449
|
+
throw new ResourceConflictError(writeRes, this.domain.getResourceOwner(writeRes) || 'unknown', 'materialize');
|
|
450
|
+
}
|
|
451
|
+
const opId = `mat-${snapshotId}-${Date.now()}`;
|
|
452
|
+
this.domain.allocateResources(opId, [writeRes]);
|
|
453
|
+
try {
|
|
454
|
+
if (!this.snapshotDriver.materialize) {
|
|
455
|
+
throw new Error(`Snapshot driver ${this.snapshotDriver.name} does not support materialize`);
|
|
456
|
+
}
|
|
457
|
+
const snapshot = this.domain.getStore().getSnapshot(snapshotId);
|
|
458
|
+
const repoRoot = snapshot?.roots[0];
|
|
459
|
+
return await this.snapshotDriver.materialize(snapshotId, absNewRoot, { repoRoot });
|
|
460
|
+
}
|
|
461
|
+
catch (err) {
|
|
462
|
+
this.domain.internalReleaseResources(opId);
|
|
463
|
+
throw err;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* 回收分叉工作区 Dematerialize (ARCHITECTURE §3.5 / 契约 #54)
|
|
468
|
+
*/
|
|
469
|
+
async dematerialize(newRoot, options) {
|
|
470
|
+
const absNewRoot = path.resolve(newRoot);
|
|
471
|
+
let realNewRoot = absNewRoot;
|
|
472
|
+
try {
|
|
473
|
+
realNewRoot = fs.realpathSync(absNewRoot);
|
|
474
|
+
}
|
|
475
|
+
catch { }
|
|
476
|
+
if (!this.snapshotDriver.dematerialize) {
|
|
477
|
+
throw new Error(`Snapshot driver ${this.snapshotDriver.name} does not support dematerialize`);
|
|
478
|
+
}
|
|
479
|
+
await this.snapshotDriver.dematerialize(absNewRoot, options);
|
|
480
|
+
const writeRes = `workspace:write:${absNewRoot}`;
|
|
481
|
+
const writeResReal = `workspace:write:${realNewRoot}`;
|
|
482
|
+
const owner1 = this.domain.getResourceOwner(writeRes);
|
|
483
|
+
const owner2 = this.domain.getResourceOwner(writeResReal);
|
|
484
|
+
if (owner1) {
|
|
485
|
+
this.domain.internalReleaseResources(owner1, [writeRes]);
|
|
486
|
+
}
|
|
487
|
+
if (owner2 && owner2 !== owner1) {
|
|
488
|
+
this.domain.internalReleaseResources(owner2, [writeResReal]);
|
|
489
|
+
}
|
|
490
|
+
const leases = this.domain.getStore().getPersistedResourceLeases(this.domain.domainId);
|
|
491
|
+
for (const lease of leases) {
|
|
492
|
+
if (lease.resourceId === writeRes || lease.resourceId === writeResReal) {
|
|
493
|
+
this.domain.internalReleaseResources(lease.operationId, [lease.resourceId]);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* 回收快照:删除驱动侧的私有 ref 与 store 记录,并写 SNAPSHOT_PRUNED。
|
|
499
|
+
* 宿主按自己的保留策略调用(例如每轮只保留会话基线与当前轮检查点),否则快照 ref 会无限累积。
|
|
500
|
+
* 未知 id 视为已回收(幂等);驱动删除失败直接抛出,store 记录保留,便于重试。
|
|
501
|
+
*/
|
|
502
|
+
async pruneSnapshots(snapshotIds, options) {
|
|
503
|
+
const store = this.domain.getStore();
|
|
504
|
+
const pruned = [];
|
|
505
|
+
for (const id of snapshotIds) {
|
|
506
|
+
const snapshot = store.getSnapshot(id);
|
|
507
|
+
if (!snapshot)
|
|
508
|
+
continue;
|
|
509
|
+
store.verifyEpochFencing(this.domain.domainId);
|
|
510
|
+
const repoRoot = this.snapshotDriver instanceof GitShadowSnapshotDriver
|
|
511
|
+
? (await this.snapshotDriver.assertGitRepo(snapshot.roots[0])).repoRoot
|
|
512
|
+
: undefined;
|
|
513
|
+
await this.snapshotDriver.prune([id], { repoRoot });
|
|
514
|
+
store.transaction(() => {
|
|
515
|
+
store.deleteSnapshot(id);
|
|
516
|
+
store.recordEventAndTransitionState({
|
|
517
|
+
domainId: this.domain.domainId,
|
|
518
|
+
runId: options?.runId,
|
|
519
|
+
type: 'SNAPSHOT_PRUNED',
|
|
520
|
+
payload: { snapshotId: id, roots: snapshot.roots },
|
|
521
|
+
timestamp: new Date().toISOString(),
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
pruned.push(id);
|
|
525
|
+
}
|
|
526
|
+
return pruned;
|
|
527
|
+
}
|
|
81
528
|
/**
|
|
82
529
|
* 启动协议:准入检查 -> 先持久化意图 -> 请求驱动启动 -> 登记身份并转 active -> 监督运行 -> 记录结果
|
|
83
530
|
*/
|
|
84
531
|
async executeProcess(options) {
|
|
532
|
+
// 0. Capability 准入校验与字段推导(契约 #55)
|
|
533
|
+
let effectiveMutationRoots = options.mutationRoots;
|
|
534
|
+
let capAdmission;
|
|
535
|
+
if (options.capabilityId) {
|
|
536
|
+
let candidateRoots = options.mutationRoots ? [...options.mutationRoots] : [];
|
|
537
|
+
if (candidateRoots.length === 0 && options.command.cwd) {
|
|
538
|
+
candidateRoots = [options.command.cwd];
|
|
539
|
+
}
|
|
540
|
+
capAdmission = this.domain.checkCapabilityAdmission({
|
|
541
|
+
capabilityId: options.capabilityId,
|
|
542
|
+
requiredResources: options.requiredResources,
|
|
543
|
+
mutationRoots: candidateRoots.length > 0 ? candidateRoots : undefined,
|
|
544
|
+
runId: options.runId,
|
|
545
|
+
opId: options.opId,
|
|
546
|
+
});
|
|
547
|
+
options.requiredResources = capAdmission.effectiveResources;
|
|
548
|
+
effectiveMutationRoots = capAdmission.effectiveMutationRoots;
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
options.requiredResources = (options.requiredResources || []).map(normalizeResourceName);
|
|
552
|
+
if (!effectiveMutationRoots || effectiveMutationRoots.length === 0) {
|
|
553
|
+
effectiveMutationRoots = options.requiredResources
|
|
554
|
+
.filter((r) => r.startsWith('workspace:write:'))
|
|
555
|
+
.map((r) => r.slice('workspace:write:'.length));
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
// 0. ConfinementDriver 驱动解析与包装(契约 #56)
|
|
559
|
+
let activeConfinementDriver;
|
|
560
|
+
if (options.confinementDriver) {
|
|
561
|
+
activeConfinementDriver = options.confinementDriver;
|
|
562
|
+
}
|
|
563
|
+
else if (typeof options.confinement === 'string') {
|
|
564
|
+
const driver = createConfinementDriver(options.confinement);
|
|
565
|
+
if (!driver) {
|
|
566
|
+
throw new Error(`Confinement driver '${options.confinement}' is not available on this platform`);
|
|
567
|
+
}
|
|
568
|
+
activeConfinementDriver = driver;
|
|
569
|
+
}
|
|
570
|
+
else if (options.confinement === true) {
|
|
571
|
+
const driver = this.confinementDriver || createConfinementDriver();
|
|
572
|
+
if (!driver) {
|
|
573
|
+
throw new Error('Confinement was requested but no confinement driver is available on this platform');
|
|
574
|
+
}
|
|
575
|
+
activeConfinementDriver = driver;
|
|
576
|
+
}
|
|
577
|
+
else if (this.confinementDriver) {
|
|
578
|
+
activeConfinementDriver = this.confinementDriver;
|
|
579
|
+
}
|
|
580
|
+
let effectiveCommand = options.command;
|
|
581
|
+
const isConfined = activeConfinementDriver !== undefined;
|
|
582
|
+
const confinementDriverName = activeConfinementDriver?.name;
|
|
583
|
+
if (activeConfinementDriver) {
|
|
584
|
+
effectiveCommand = activeConfinementDriver.wrap(options.command, effectiveMutationRoots || []);
|
|
585
|
+
}
|
|
85
586
|
const startTime = Date.now();
|
|
86
587
|
const maxBytes = options.maxOutputBytes ?? 10 * 1024 * 1024; // 默认 10MB
|
|
87
588
|
const drainTimeoutMs = options.drainTimeoutMs ?? 2000; // 默认 2000ms
|
|
@@ -99,439 +600,579 @@ export class ProcessSupervisor {
|
|
|
99
600
|
throw new UnsupportedCapabilityError('cpuLimit', this.driver.name, 'hard');
|
|
100
601
|
}
|
|
101
602
|
}
|
|
102
|
-
|
|
603
|
+
const inputFingerprint = options.inputFingerprint || computeInputFingerprint(options);
|
|
604
|
+
// 0. 验证调用 Run 状态(N3 契约):终态 Run 禁止登记或重放操作
|
|
605
|
+
const callingRun = this.domain.getStore().getRun(options.runId);
|
|
606
|
+
if (!callingRun) {
|
|
607
|
+
throw new Error(`Run "${options.runId}" is not registered in domain "${this.domain.domainId}". ` +
|
|
608
|
+
'Register the task and run first (store.saveTask() + store.saveRun()), then execute operations for that run.');
|
|
609
|
+
}
|
|
610
|
+
if (callingRun.status === 'succeeded' ||
|
|
611
|
+
callingRun.status === 'failed' ||
|
|
612
|
+
callingRun.status === 'cancelled' ||
|
|
613
|
+
callingRun.status === 'indeterminate') {
|
|
614
|
+
throw new Error(`Cannot register operation "${options.opId}" for Run "${options.runId}" because the Run is already finalized with status "${callingRun.status}".`);
|
|
615
|
+
}
|
|
616
|
+
// 1. 在飞操作重放判定(ARCHITECTURE §3.7 / 契约 #46)
|
|
103
617
|
if (this.activeOperations.has(options.opId)) {
|
|
104
|
-
const
|
|
105
|
-
|
|
618
|
+
const inFlight = this.activeOperations.get(options.opId);
|
|
619
|
+
if (inFlight.inputFingerprint !== inputFingerprint) {
|
|
620
|
+
throw new OperationIdConflictError(options.opId, inFlight.inputFingerprint, inputFingerprint, inFlight.phase, inFlight.runId);
|
|
621
|
+
}
|
|
622
|
+
this.domain.getStore().recordReplay(this.domain.domainId, options.opId, 'joined', options.runId);
|
|
623
|
+
if (options.onStreamChunk) {
|
|
624
|
+
inFlight.streamSubscribers.add(options.onStreamChunk);
|
|
625
|
+
}
|
|
626
|
+
if (options.abortSignal) {
|
|
627
|
+
const signal = options.abortSignal;
|
|
628
|
+
if (signal.aborted) {
|
|
629
|
+
if (options.onStreamChunk) {
|
|
630
|
+
inFlight.streamSubscribers.delete(options.onStreamChunk);
|
|
631
|
+
}
|
|
632
|
+
throw signal.reason || new Error('Operation wait aborted');
|
|
633
|
+
}
|
|
634
|
+
return await new Promise((resolve, reject) => {
|
|
635
|
+
const onAbort = () => {
|
|
636
|
+
if (options.onStreamChunk) {
|
|
637
|
+
inFlight.streamSubscribers.delete(options.onStreamChunk);
|
|
638
|
+
}
|
|
639
|
+
reject(signal.reason || new Error('Operation wait aborted'));
|
|
640
|
+
};
|
|
641
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
642
|
+
inFlight.resultPromise.then((res) => {
|
|
643
|
+
signal.removeEventListener('abort', onAbort);
|
|
644
|
+
if (options.onStreamChunk) {
|
|
645
|
+
inFlight.streamSubscribers.delete(options.onStreamChunk);
|
|
646
|
+
}
|
|
647
|
+
resolve({ ...res, replayed: true });
|
|
648
|
+
}, (err) => {
|
|
649
|
+
signal.removeEventListener('abort', onAbort);
|
|
650
|
+
if (options.onStreamChunk) {
|
|
651
|
+
inFlight.streamSubscribers.delete(options.onStreamChunk);
|
|
652
|
+
}
|
|
653
|
+
reject(err);
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
const res = await inFlight.resultPromise;
|
|
658
|
+
if (options.onStreamChunk) {
|
|
659
|
+
inFlight.streamSubscribers.delete(options.onStreamChunk);
|
|
660
|
+
}
|
|
661
|
+
return {
|
|
662
|
+
...res,
|
|
663
|
+
replayed: true,
|
|
664
|
+
};
|
|
106
665
|
}
|
|
666
|
+
// 2. 已持久化操作重放判定(ARCHITECTURE §3.7 / 契约 #45, #47, #48, #49)
|
|
107
667
|
const existingRecorded = this.domain.getStore().getOperation(options.opId);
|
|
108
668
|
if (existingRecorded) {
|
|
109
|
-
|
|
669
|
+
if (existingRecorded.inputFingerprint !== inputFingerprint) {
|
|
670
|
+
throw new OperationIdConflictError(options.opId, existingRecorded.inputFingerprint, inputFingerprint, existingRecorded.status, existingRecorded.runId);
|
|
671
|
+
}
|
|
672
|
+
// 未终结状态且不在内存中 → 必须先执行恢复(崩溃残留现场)
|
|
673
|
+
if (existingRecorded.status !== 'done') {
|
|
674
|
+
throw new RecoveryRequiredError(options.opId, existingRecorded.status, existingRecorded.runId);
|
|
675
|
+
}
|
|
676
|
+
// indeterminate 状态:绝不重跑,原样返回,保留租约
|
|
677
|
+
if (existingRecorded.result?.status === 'indeterminate') {
|
|
678
|
+
this.domain.getStore().recordReplay(this.domain.domainId, options.opId, 'indeterminate', options.runId);
|
|
679
|
+
return {
|
|
680
|
+
...existingRecorded.result,
|
|
681
|
+
replayed: true,
|
|
682
|
+
runId: existingRecorded.runId,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
// 已结清终态 (succeeded / failed / cancelled):返回已持久化结果
|
|
686
|
+
this.domain.getStore().recordReplay(this.domain.domainId, options.opId, 'recorded', options.runId);
|
|
687
|
+
return {
|
|
688
|
+
...existingRecorded.result,
|
|
689
|
+
replayed: true,
|
|
690
|
+
runId: existingRecorded.runId,
|
|
691
|
+
};
|
|
110
692
|
}
|
|
111
693
|
const op = {
|
|
112
694
|
id: options.opId,
|
|
113
695
|
runId: options.runId,
|
|
114
696
|
kind: 'process',
|
|
115
697
|
name: options.name,
|
|
116
|
-
inputFingerprint
|
|
117
|
-
requiredResources: options.requiredResources,
|
|
698
|
+
inputFingerprint,
|
|
699
|
+
requiredResources: options.requiredResources || [],
|
|
700
|
+
mutationRoots: effectiveMutationRoots,
|
|
701
|
+
capabilityId: options.capabilityId,
|
|
118
702
|
timeoutMs: options.timeoutMs,
|
|
119
703
|
resourceBudget: options.resourceBudget,
|
|
120
704
|
status: 'pending',
|
|
121
705
|
};
|
|
706
|
+
const streamSubscribers = new Set();
|
|
707
|
+
if (options.onStreamChunk) {
|
|
708
|
+
streamSubscribers.add(options.onStreamChunk);
|
|
709
|
+
}
|
|
122
710
|
const opState = {
|
|
123
711
|
opId: options.opId,
|
|
124
712
|
runId: options.runId,
|
|
125
713
|
phase: 'waiting_resources',
|
|
126
714
|
startTime,
|
|
715
|
+
inputFingerprint,
|
|
716
|
+
streamSubscribers,
|
|
127
717
|
cancelRequested: false,
|
|
128
718
|
timedOut: false,
|
|
129
719
|
};
|
|
130
720
|
this.activeOperations.set(options.opId, opState);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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)
|
|
140
|
-
try {
|
|
141
|
-
this.domain.getStore().registerOperationIntent(op, this.domain.domainId);
|
|
142
|
-
opState.phase = 'intent_registered';
|
|
143
|
-
}
|
|
144
|
-
catch (err) {
|
|
145
|
-
this.domain.internalReleaseResources(options.opId, options.requiredResources);
|
|
146
|
-
throw err;
|
|
147
|
-
}
|
|
148
|
-
if (opState.cancelRequested) {
|
|
149
|
-
return this.handlePreSpawnCancellation(options, opState, startTime);
|
|
721
|
+
if (options.abortSignal) {
|
|
722
|
+
if (options.abortSignal.aborted) {
|
|
723
|
+
opState.cancelRequested = true;
|
|
150
724
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
// 3. [启动协议步骤 2] 请求平台驱动启动
|
|
155
|
-
handle = await this.driver.spawn(options.command);
|
|
156
|
-
opState.handle = handle;
|
|
157
|
-
opState.command = options.command;
|
|
158
|
-
}
|
|
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(),
|
|
725
|
+
else {
|
|
726
|
+
const onAbort = () => {
|
|
727
|
+
this.cancelOperation(options.opId).catch(() => { });
|
|
176
728
|
};
|
|
177
|
-
|
|
729
|
+
options.abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
178
730
|
}
|
|
179
|
-
|
|
731
|
+
}
|
|
732
|
+
const executionPromise = (async () => {
|
|
733
|
+
let timeoutTimer = null;
|
|
734
|
+
let samplingInterval = null;
|
|
735
|
+
let stdoutDrainer;
|
|
736
|
+
let stderrDrainer;
|
|
180
737
|
try {
|
|
181
|
-
|
|
182
|
-
opState.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
]);
|
|
738
|
+
// 1. [资源分配与排队等待]
|
|
739
|
+
await this.domain.allocateResourcesWithWait(options.opId, options.requiredResources || [], options.waitTimeoutMs ?? 0, options.resourceBudget, () => opState.cancelRequested === true);
|
|
740
|
+
if (opState.cancelRequested) {
|
|
741
|
+
return this.handlePreSpawnCancellation(options, opState, startTime);
|
|
742
|
+
}
|
|
743
|
+
// 2. [启动协议步骤 1] 写入 SQLite (status: intent_registered)
|
|
195
744
|
try {
|
|
196
|
-
|
|
197
|
-
|
|
745
|
+
this.domain.getStore().registerOperationIntent(op, this.domain.domainId);
|
|
746
|
+
opState.phase = 'intent_registered';
|
|
198
747
|
}
|
|
199
|
-
catch {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
748
|
+
catch (err) {
|
|
749
|
+
this.domain.internalReleaseResources(options.opId, options.requiredResources);
|
|
750
|
+
throw err;
|
|
751
|
+
}
|
|
752
|
+
if (opState.cancelRequested) {
|
|
753
|
+
return this.handlePreSpawnCancellation(options, opState, startTime);
|
|
205
754
|
}
|
|
206
|
-
|
|
755
|
+
let handle;
|
|
756
|
+
opState.phase = 'spawning';
|
|
757
|
+
try {
|
|
758
|
+
// 3. [启动协议步骤 2] 请求平台驱动启动
|
|
759
|
+
handle = await this.driver.spawn(effectiveCommand);
|
|
760
|
+
opState.handle = handle;
|
|
761
|
+
opState.command = effectiveCommand;
|
|
762
|
+
}
|
|
763
|
+
catch (spawnError) {
|
|
764
|
+
opState.phase = 'done';
|
|
765
|
+
opState.stopResolve?.({ stopped: 'confirmed_stopped', scope: 'direct_child', errorDetails: spawnError?.message });
|
|
766
|
+
opState.stopResolve = undefined;
|
|
767
|
+
// 启动失败(如可执行文件不存在),不产生假运行状态,直接失败收尾并释放资源
|
|
207
768
|
const failResult = {
|
|
208
769
|
kind: 'process',
|
|
209
770
|
status: 'failed',
|
|
210
|
-
exitCode:
|
|
771
|
+
exitCode: 127,
|
|
211
772
|
signal: null,
|
|
212
773
|
stdout: '',
|
|
213
|
-
stderr:
|
|
214
|
-
spawnFailure:
|
|
774
|
+
stderr: spawnError?.message || String(spawnError),
|
|
775
|
+
spawnFailure: spawnError?.message || String(spawnError),
|
|
776
|
+
capabilityId: options.capabilityId,
|
|
777
|
+
confined: isConfined,
|
|
778
|
+
confinementDriver: confinementDriverName,
|
|
215
779
|
isTruncated: false,
|
|
216
780
|
identityVerification: 'not_original_process',
|
|
217
781
|
durationMs: Date.now() - startTime,
|
|
218
782
|
completedAt: new Date().toISOString(),
|
|
219
783
|
};
|
|
220
|
-
this.finalizeOperation(options.opId, failResult, options.requiredResources, true);
|
|
784
|
+
return this.finalizeOperation(options.opId, failResult, options.requiredResources, true);
|
|
221
785
|
}
|
|
222
|
-
|
|
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)) {
|
|
786
|
+
// 4. [启动协议步骤 3] 登记执行身份,状态推进为 active
|
|
239
787
|
try {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
788
|
+
this.domain.getStore().updateOperationStatus(options.opId, 'active', handle.identity);
|
|
789
|
+
opState.phase = 'active';
|
|
790
|
+
handle.releaseGate?.();
|
|
791
|
+
if (options.capabilityId && capAdmission) {
|
|
792
|
+
this.domain.getStore().recordJournalEvent({
|
|
793
|
+
domainId: this.domain.domainId,
|
|
794
|
+
runId: options.runId,
|
|
795
|
+
operationId: options.opId,
|
|
796
|
+
type: 'CAPABILITY_USED',
|
|
797
|
+
payload: {
|
|
798
|
+
capabilityId: options.capabilityId,
|
|
799
|
+
scope: capAdmission.capability.scope,
|
|
800
|
+
actor: capAdmission.capability.issuedBy,
|
|
801
|
+
},
|
|
802
|
+
timestamp: new Date().toISOString(),
|
|
803
|
+
});
|
|
804
|
+
}
|
|
244
805
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
806
|
+
catch (statusError) {
|
|
807
|
+
handle.destroyGate?.();
|
|
808
|
+
// N4: spawn 成功但登记 active 失败,防止产生孤儿进程
|
|
809
|
+
opState.phase = 'stopping';
|
|
810
|
+
const stopRes = await this.driver.terminate(handle.identity, 2000);
|
|
811
|
+
// 有界等待退出与关闭流,防止管道悬挂
|
|
812
|
+
await Promise.race([
|
|
813
|
+
(handle.onRootExit ?? handle.onExit).catch(() => { }),
|
|
814
|
+
new Promise((resolve) => setTimeout(resolve, 500)),
|
|
815
|
+
]);
|
|
816
|
+
try {
|
|
817
|
+
handle.stdout.destroy?.();
|
|
818
|
+
handle.stderr.destroy?.();
|
|
819
|
+
}
|
|
820
|
+
catch { }
|
|
821
|
+
opState.phase = 'done';
|
|
822
|
+
if (opState.stopResolve) {
|
|
823
|
+
const resolveFn = opState.stopResolve;
|
|
824
|
+
opState.stopResolve = undefined;
|
|
825
|
+
resolveFn(stopRes);
|
|
826
|
+
}
|
|
827
|
+
if (stopRes.stopped === 'confirmed_stopped') {
|
|
828
|
+
const failResult = {
|
|
829
|
+
kind: 'process',
|
|
830
|
+
status: 'failed',
|
|
831
|
+
exitCode: null,
|
|
832
|
+
signal: null,
|
|
833
|
+
stdout: '',
|
|
834
|
+
stderr: `Failed to register active status: ${statusError?.message || String(statusError)}`,
|
|
835
|
+
spawnFailure: statusError?.message || String(statusError),
|
|
836
|
+
isTruncated: false,
|
|
837
|
+
identityVerification: 'not_original_process',
|
|
838
|
+
durationMs: Date.now() - startTime,
|
|
839
|
+
completedAt: new Date().toISOString(),
|
|
840
|
+
};
|
|
841
|
+
this.finalizeOperation(options.opId, failResult, options.requiredResources, true);
|
|
842
|
+
}
|
|
843
|
+
else {
|
|
844
|
+
const indetResult = {
|
|
845
|
+
kind: 'indeterminate',
|
|
846
|
+
status: 'indeterminate',
|
|
847
|
+
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'}`,
|
|
848
|
+
recoveryGuidance: 'Residual PID detected. Inspect system processes manually before releasing resources.',
|
|
849
|
+
durationMs: Date.now() - startTime,
|
|
850
|
+
completedAt: new Date().toISOString(),
|
|
851
|
+
};
|
|
852
|
+
this.finalizeOperation(options.opId, indetResult, options.requiredResources, false);
|
|
853
|
+
}
|
|
854
|
+
throw statusError;
|
|
258
855
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (!
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
856
|
+
// 准备 artifacts 溢出转储目录
|
|
857
|
+
const artifactsDir = options.artifactsDir || path.join(this.domain.domainPath, 'artifacts');
|
|
858
|
+
let artifactsDirError = undefined;
|
|
859
|
+
if (!fs.existsSync(artifactsDir)) {
|
|
860
|
+
try {
|
|
861
|
+
fs.mkdirSync(artifactsDir, { recursive: true });
|
|
862
|
+
}
|
|
863
|
+
catch (dirErr) {
|
|
864
|
+
artifactsDirError = dirErr.message || String(dirErr);
|
|
865
|
+
}
|
|
266
866
|
}
|
|
267
|
-
|
|
268
|
-
|
|
867
|
+
const stdoutSpillPath = path.join(artifactsDir, `${options.opId}-stdout.log`);
|
|
868
|
+
const stderrSpillPath = path.join(artifactsDir, `${options.opId}-stderr.log`);
|
|
869
|
+
// 5. [正常监督、有界排空与流式转储 (Spill to Artifacts)]
|
|
870
|
+
let totalOutputBytes = 0;
|
|
871
|
+
const onChunk = (bytes) => {
|
|
872
|
+
totalOutputBytes += bytes;
|
|
873
|
+
if (options.resourceBudget?.enforcement === 'soft' &&
|
|
874
|
+
options.resourceBudget.maxOutputBytes &&
|
|
875
|
+
totalOutputBytes > options.resourceBudget.maxOutputBytes &&
|
|
876
|
+
!opState.terminationReason) {
|
|
877
|
+
opState.terminationReason = 'output_exceeded';
|
|
878
|
+
this.handleStopPipeline(options.opId, 'output_exceeded', 1000).catch(() => { });
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
let streamCallbackError;
|
|
882
|
+
const forwardChunk = (stream) => (chunk) => {
|
|
883
|
+
for (const subscriber of opState.streamSubscribers) {
|
|
884
|
+
try {
|
|
885
|
+
subscriber(stream, chunk);
|
|
886
|
+
}
|
|
887
|
+
catch (err) {
|
|
888
|
+
streamCallbackError ??= err instanceof Error ? err.message : String(err);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
stdoutDrainer = setupStreamDrainer(handle.stdout, maxBytes, stdoutSpillPath, onChunk, forwardChunk('stdout'), artifactsDirError);
|
|
893
|
+
stderrDrainer = setupStreamDrainer(handle.stderr, maxBytes, stderrSpillPath, onChunk, forwardChunk('stderr'), artifactsDirError);
|
|
894
|
+
// 检查在 spawn 途中是否已被请求取消:若已请求取消,立即触发停止流水线向底层发送终止信号
|
|
895
|
+
if (opState.cancelRequested) {
|
|
896
|
+
this.handleStopPipeline(options.opId, 'user_cancelled', opState.cancelGraceMs ?? 2000).catch(() => { });
|
|
269
897
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
898
|
+
const timeoutPromise = options.timeoutMs && options.timeoutMs > 0
|
|
899
|
+
? new Promise((resolve) => {
|
|
900
|
+
timeoutTimer = setTimeout(() => resolve('timeout'), options.timeoutMs);
|
|
901
|
+
})
|
|
902
|
+
: new Promise(() => { });
|
|
903
|
+
// Soft / Observe 模式资源采样治理
|
|
904
|
+
let peakMemoryBytes = 0;
|
|
905
|
+
let peakCpuTimeMs = 0;
|
|
906
|
+
if (this.driver.sampleMetrics &&
|
|
907
|
+
options.resourceBudget &&
|
|
908
|
+
(options.resourceBudget.enforcement === 'soft' || options.resourceBudget.enforcement === 'observe')) {
|
|
909
|
+
samplingInterval = setInterval(async () => {
|
|
910
|
+
try {
|
|
911
|
+
const metrics = await this.driver.sampleMetrics(handle.identity);
|
|
912
|
+
peakMemoryBytes = Math.max(peakMemoryBytes, metrics.rssBytes);
|
|
913
|
+
peakCpuTimeMs = Math.max(peakCpuTimeMs, metrics.cpuTimeMs);
|
|
914
|
+
if (options.resourceBudget?.enforcement === 'soft') {
|
|
915
|
+
if (options.resourceBudget.maxMemoryBytes && metrics.rssBytes > options.resourceBudget.maxMemoryBytes) {
|
|
916
|
+
opState.terminationReason = 'memory_exceeded';
|
|
917
|
+
if (samplingInterval)
|
|
918
|
+
clearInterval(samplingInterval);
|
|
919
|
+
await this.handleStopPipeline(options.opId, 'memory_exceeded', 1000);
|
|
920
|
+
}
|
|
921
|
+
else if (options.resourceBudget.maxCpuTimeMs && metrics.cpuTimeMs > options.resourceBudget.maxCpuTimeMs) {
|
|
922
|
+
opState.terminationReason = 'cpu_exceeded';
|
|
923
|
+
if (samplingInterval)
|
|
924
|
+
clearInterval(samplingInterval);
|
|
925
|
+
await this.handleStopPipeline(options.opId, 'cpu_exceeded', 1000);
|
|
926
|
+
}
|
|
927
|
+
else if (options.resourceBudget.maxPids && metrics.pidsCount > options.resourceBudget.maxPids) {
|
|
928
|
+
opState.terminationReason = 'pids_exceeded';
|
|
929
|
+
if (samplingInterval)
|
|
930
|
+
clearInterval(samplingInterval);
|
|
931
|
+
await this.handleStopPipeline(options.opId, 'pids_exceeded', 1000);
|
|
932
|
+
}
|
|
305
933
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
934
|
+
}
|
|
935
|
+
catch { }
|
|
936
|
+
}, 100);
|
|
937
|
+
}
|
|
938
|
+
let notifyStop;
|
|
939
|
+
const stopTriggerPromise = new Promise((resolve) => {
|
|
940
|
+
notifyStop = resolve;
|
|
941
|
+
});
|
|
942
|
+
const previousResolve = opState.stopResolve;
|
|
943
|
+
opState.stopResolve = (res) => {
|
|
944
|
+
previousResolve?.(res);
|
|
945
|
+
notifyStop?.(res);
|
|
946
|
+
};
|
|
947
|
+
// 等待根进程退出、超时触发、或外部停止完成
|
|
948
|
+
const rootExitPromise = handle.onRootExit ?? handle.onExit;
|
|
949
|
+
const stopCandidate = opState.stopPromise ? opState.stopPromise : stopTriggerPromise;
|
|
950
|
+
const exitOrTimeout = await Promise.race([
|
|
951
|
+
rootExitPromise.then((res) => ({ type: 'exit', res, stopRes: null })),
|
|
952
|
+
timeoutPromise.then((type) => ({ type, res: null, stopRes: null })),
|
|
953
|
+
stopCandidate.then((stopRes) => ({ type: 'stop', res: null, stopRes })),
|
|
954
|
+
]);
|
|
955
|
+
if (timeoutTimer)
|
|
956
|
+
clearTimeout(timeoutTimer);
|
|
957
|
+
if (samplingInterval)
|
|
958
|
+
clearInterval(samplingInterval);
|
|
959
|
+
let exitResult;
|
|
960
|
+
if (exitOrTimeout.type === 'stop') {
|
|
961
|
+
const stopRes = exitOrTimeout.stopRes;
|
|
962
|
+
if (stopRes.stopped !== 'confirmed_stopped') {
|
|
963
|
+
if (handle.rawProcess && typeof handle.rawProcess.kill === 'function') {
|
|
964
|
+
try {
|
|
965
|
+
handle.rawProcess.kill('SIGKILL');
|
|
311
966
|
}
|
|
967
|
+
catch { }
|
|
312
968
|
}
|
|
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
|
-
// 等待根进程退出、超时触发、或外部停止完成
|
|
327
|
-
const rootExitPromise = handle.onRootExit ?? handle.onExit;
|
|
328
|
-
const stopCandidate = opState.stopPromise ? opState.stopPromise : stopTriggerPromise;
|
|
329
|
-
const exitOrTimeout = await Promise.race([
|
|
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 })),
|
|
333
|
-
]);
|
|
334
|
-
if (timeoutTimer)
|
|
335
|
-
clearTimeout(timeoutTimer);
|
|
336
|
-
if (samplingInterval)
|
|
337
|
-
clearInterval(samplingInterval);
|
|
338
|
-
let exitResult;
|
|
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
969
|
try {
|
|
344
|
-
handle.
|
|
970
|
+
handle.stdout.destroy?.();
|
|
971
|
+
handle.stderr.destroy?.();
|
|
345
972
|
}
|
|
346
973
|
catch { }
|
|
974
|
+
const stored = this.domain.getStore().getOperation(options.opId);
|
|
975
|
+
if (stored && stored.status === 'done') {
|
|
976
|
+
return (stored.result ?? null);
|
|
977
|
+
}
|
|
978
|
+
const indetResult = {
|
|
979
|
+
kind: 'indeterminate',
|
|
980
|
+
status: 'indeterminate',
|
|
981
|
+
reason: `Process ${handle.identity.pid} was cancelled or stopped but could not be confirmed: ${stopRes.errorDetails || 'residual processes still alive'}`,
|
|
982
|
+
recoveryGuidance: 'Residual PID detected. Inspect system processes manually before releasing resources.',
|
|
983
|
+
durationMs: Date.now() - startTime,
|
|
984
|
+
completedAt: new Date().toISOString(),
|
|
985
|
+
};
|
|
986
|
+
return this.finalizeOperation(options.opId, indetResult, options.requiredResources, false);
|
|
347
987
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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);
|
|
988
|
+
const rootExitWaitMs = 1000;
|
|
989
|
+
const rootExitOrTimeout = await Promise.race([
|
|
990
|
+
rootExitPromise.then((res) => ({ exited: true, res })),
|
|
991
|
+
new Promise((resolve) => setTimeout(() => resolve({ exited: false }), rootExitWaitMs)),
|
|
992
|
+
]);
|
|
993
|
+
exitResult = rootExitOrTimeout.exited ? rootExitOrTimeout.res : { exitCode: null, signal: 'SIGKILL' };
|
|
366
994
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
995
|
+
else if (exitOrTimeout.type === 'timeout') {
|
|
996
|
+
opState.timedOut = true;
|
|
997
|
+
opState.terminationReason = 'timed_out';
|
|
998
|
+
const stopRes = await this.handleStopPipeline(options.opId, 'timed_out', 1500);
|
|
999
|
+
// 超时路径:根进程已由停止流水线终止,等待根进程退出事实(避免被持有管道的逃逸后代挂死)
|
|
1000
|
+
// 关键防护(问题 4):给等待根进程退出增加有界上限(1000ms),
|
|
1001
|
+
// 若驱动停止未确认(stopped !== 'confirmed_stopped')或根进程超出上限仍未退出,转入 indeterminate
|
|
1002
|
+
const rootExitWaitMs = 1000;
|
|
1003
|
+
const rootExitOrTimeout = await Promise.race([
|
|
1004
|
+
(handle.onRootExit ?? handle.onExit).then((res) => ({ exited: true, res })),
|
|
1005
|
+
new Promise((resolve) => setTimeout(() => resolve({ exited: false }), rootExitWaitMs)),
|
|
1006
|
+
]);
|
|
1007
|
+
if (stopRes.stopped !== 'confirmed_stopped' || !rootExitOrTimeout.exited) {
|
|
1008
|
+
const stored = this.domain.getStore().getOperation(options.opId);
|
|
1009
|
+
if (stored && stored.status === 'done' && stored.result?.status === 'indeterminate') {
|
|
1010
|
+
return stored.result;
|
|
1011
|
+
}
|
|
1012
|
+
const indetResult = {
|
|
1013
|
+
kind: 'indeterminate',
|
|
1014
|
+
status: 'indeterminate',
|
|
1015
|
+
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'}`,
|
|
1016
|
+
recoveryGuidance: 'Residual PID detected. Inspect system processes manually before releasing resources.',
|
|
1017
|
+
durationMs: Date.now() - startTime,
|
|
1018
|
+
completedAt: new Date().toISOString(),
|
|
1019
|
+
};
|
|
1020
|
+
return this.finalizeOperation(options.opId, indetResult, options.requiredResources, false);
|
|
390
1021
|
}
|
|
391
|
-
|
|
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);
|
|
1022
|
+
exitResult = rootExitOrTimeout.res;
|
|
400
1023
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
else {
|
|
404
|
-
exitResult = exitOrTimeout.res;
|
|
405
|
-
}
|
|
406
|
-
// 有界等待流排空
|
|
407
|
-
const drainPromise = Promise.all([stdoutDrainer.finishPromise, stderrDrainer.finishPromise]);
|
|
408
|
-
const drainTimeoutPromise = new Promise((resolve) => setTimeout(resolve, drainTimeoutMs));
|
|
409
|
-
let drained = await Promise.race([
|
|
410
|
-
drainPromise.then(() => true),
|
|
411
|
-
drainTimeoutPromise.then(() => false),
|
|
412
|
-
]);
|
|
413
|
-
// 排空超时说明根进程已退出但仍有后代持有管道:回收整个进程组,
|
|
414
|
-
// 否则操作会一直挂到超时,且 root 的真实退出事实会被超时掩盖。
|
|
415
|
-
let residualProcessesReaped = false;
|
|
416
|
-
if (!drained && exitOrTimeout.type === 'exit') {
|
|
417
|
-
const reapResult = await this.driver.terminate(handle.identity, 1500);
|
|
418
|
-
if (reapResult.stopped !== 'confirmed_stopped') {
|
|
419
|
-
const indetResult = {
|
|
420
|
-
kind: 'indeterminate',
|
|
421
|
-
status: 'indeterminate',
|
|
422
|
-
reason: `Process ${handle.identity.pid} exited but residual descendants could not be confirmed stopped: ${reapResult.errorDetails}`,
|
|
423
|
-
recoveryGuidance: 'Residual PID detected. Inspect system processes manually before releasing resources.',
|
|
424
|
-
durationMs: Date.now() - startTime,
|
|
425
|
-
completedAt: new Date().toISOString(),
|
|
426
|
-
};
|
|
427
|
-
return this.finalizeOperation(options.opId, indetResult, options.requiredResources, false);
|
|
1024
|
+
else {
|
|
1025
|
+
exitResult = exitOrTimeout.res;
|
|
428
1026
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
1027
|
+
// 有界等待流排空
|
|
1028
|
+
const drainPromise = Promise.all([stdoutDrainer.finishPromise, stderrDrainer.finishPromise]);
|
|
1029
|
+
const drainTimeoutPromise = new Promise((resolve) => setTimeout(resolve, drainTimeoutMs));
|
|
1030
|
+
let drained = await Promise.race([
|
|
432
1031
|
drainPromise.then(() => true),
|
|
433
|
-
|
|
1032
|
+
drainTimeoutPromise.then(() => false),
|
|
434
1033
|
]);
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
opState.terminationReason === 'memory_exceeded' ||
|
|
451
|
-
opState.terminationReason === 'cpu_exceeded' ||
|
|
452
|
-
opState.terminationReason === 'pids_exceeded' ||
|
|
453
|
-
opState.terminationReason === 'output_exceeded';
|
|
454
|
-
const isSucceeded = exitResult.exitCode === 0 &&
|
|
455
|
-
!isResourceOrTimeoutStopped &&
|
|
456
|
-
!opState.cancelRequested;
|
|
457
|
-
const finalStatus = isResourceOrTimeoutStopped
|
|
458
|
-
? 'failed'
|
|
459
|
-
: opState.cancelRequested
|
|
460
|
-
? 'cancelled'
|
|
461
|
-
: isSucceeded
|
|
462
|
-
? 'succeeded'
|
|
463
|
-
: 'failed';
|
|
464
|
-
const terminationReason = opState.terminationReason || (finalStatus === 'succeeded' ? 'completed' : undefined);
|
|
465
|
-
const result = {
|
|
466
|
-
kind: 'process',
|
|
467
|
-
status: finalStatus,
|
|
468
|
-
exitCode: exitResult.exitCode,
|
|
469
|
-
signal: exitResult.signal,
|
|
470
|
-
stdout: stdoutData.content,
|
|
471
|
-
stderr: stderrData.content,
|
|
472
|
-
isTruncated,
|
|
473
|
-
stdoutTruncated: stdoutData.isTruncated,
|
|
474
|
-
stderrTruncated: stderrData.isTruncated,
|
|
475
|
-
stdoutRef: !stdoutData.spillError ? stdoutData.outputRef : undefined,
|
|
476
|
-
stderrRef: !stderrData.spillError ? stderrData.outputRef : undefined,
|
|
477
|
-
stdoutBytes: stdoutData.bytesSeen,
|
|
478
|
-
stderrBytes: stderrData.bytesSeen,
|
|
479
|
-
stdoutHash: !stdoutData.spillError ? stdoutData.outputHash : undefined,
|
|
480
|
-
stderrHash: !stderrData.spillError ? stderrData.outputHash : undefined,
|
|
481
|
-
...(residualProcessesReaped ? { residualProcessesReaped: true } : {}),
|
|
482
|
-
...(streamCallbackError ? { streamCallbackError } : {}),
|
|
483
|
-
...(spillError ? { spillError } : {}),
|
|
484
|
-
...(stdoutData.spillError ? { stdoutSpillError: stdoutData.spillError } : {}),
|
|
485
|
-
...(stderrData.spillError ? { stderrSpillError: stderrData.spillError } : {}),
|
|
486
|
-
outputRef,
|
|
487
|
-
outputHash,
|
|
488
|
-
terminationReason,
|
|
489
|
-
peakMemoryBytes,
|
|
490
|
-
cpuTimeMs: peakCpuTimeMs,
|
|
491
|
-
identityVerification: idVerify,
|
|
492
|
-
durationMs: Date.now() - startTime,
|
|
493
|
-
completedAt: new Date().toISOString(),
|
|
494
|
-
};
|
|
495
|
-
if (opState.stopPromise) {
|
|
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);
|
|
1034
|
+
// 排空超时说明根进程已退出但仍有后代持有管道:回收整个进程组,
|
|
1035
|
+
// 否则操作会一直挂到超时,且 root 的真实退出事实会被超时掩盖。
|
|
1036
|
+
let residualProcessesReaped = false;
|
|
1037
|
+
if (!drained && exitOrTimeout.type === 'exit') {
|
|
1038
|
+
const reapResult = await this.driver.terminate(handle.identity, 1500);
|
|
1039
|
+
if (reapResult.stopped !== 'confirmed_stopped') {
|
|
1040
|
+
const indetResult = {
|
|
1041
|
+
kind: 'indeterminate',
|
|
1042
|
+
status: 'indeterminate',
|
|
1043
|
+
reason: `Process ${handle.identity.pid} exited but residual descendants could not be confirmed stopped: ${reapResult.errorDetails}`,
|
|
1044
|
+
recoveryGuidance: 'Residual PID detected. Inspect system processes manually before releasing resources.',
|
|
1045
|
+
durationMs: Date.now() - startTime,
|
|
1046
|
+
completedAt: new Date().toISOString(),
|
|
1047
|
+
};
|
|
1048
|
+
return this.finalizeOperation(options.opId, indetResult, options.requiredResources, false);
|
|
501
1049
|
}
|
|
1050
|
+
residualProcessesReaped = true;
|
|
1051
|
+
// 被杀死的后代释放管道后,给排空最后一次机会
|
|
1052
|
+
drained = await Promise.race([
|
|
1053
|
+
drainPromise.then(() => true),
|
|
1054
|
+
new Promise((resolve) => setTimeout(() => resolve(false), 250)),
|
|
1055
|
+
]);
|
|
1056
|
+
}
|
|
1057
|
+
if (!drained) {
|
|
1058
|
+
// 排空超时路径强制调用 fsyncSync + closeSync 结清刷盘
|
|
1059
|
+
stdoutDrainer.forceFinalize();
|
|
1060
|
+
stderrDrainer.forceFinalize();
|
|
1061
|
+
// 走到这里时进程组已被确认停止,管道写端却仍被持有:
|
|
1062
|
+
// 必然存在一个驱动未观测到的逃逸进程(例如 setsid 后被 init 收养、采样器没来得及记录)。
|
|
1063
|
+
// 无法确认隔离,不能报告 succeeded / residualProcessesReaped,必须如实判 indeterminate 并保留租约。
|
|
502
1064
|
const indetResult = {
|
|
503
1065
|
kind: 'indeterminate',
|
|
504
1066
|
status: 'indeterminate',
|
|
505
|
-
reason: `Process ${handle.identity.pid}
|
|
506
|
-
recoveryGuidance: '
|
|
1067
|
+
reason: `Process ${handle.identity.pid} exited (exitCode=${exitResult.exitCode}, signal=${exitResult.signal}) and its process group was confirmed stopped, but stdout/stderr pipes are still held by an unobserved escaped process`,
|
|
1068
|
+
recoveryGuidance: 'An escaped process outside the observed process tree still holds the output pipes. Inspect system processes manually before adjudicating and releasing resources.',
|
|
1069
|
+
capabilityId: options.capabilityId,
|
|
507
1070
|
durationMs: Date.now() - startTime,
|
|
508
1071
|
completedAt: new Date().toISOString(),
|
|
509
1072
|
};
|
|
510
1073
|
return this.finalizeOperation(options.opId, indetResult, options.requiredResources, false);
|
|
511
1074
|
}
|
|
1075
|
+
const stdoutData = stdoutDrainer.getResult();
|
|
1076
|
+
const stderrData = stderrDrainer.getResult();
|
|
1077
|
+
const isTruncated = stdoutData.isTruncated || stderrData.isTruncated;
|
|
1078
|
+
const spillError = stdoutData.spillError || stderrData.spillError || artifactsDirError;
|
|
1079
|
+
const outputRef = (!spillError && (stdoutData.outputRef || stderrData.outputRef)) || undefined;
|
|
1080
|
+
const outputHash = (!spillError && (stdoutData.outputHash || stderrData.outputHash)) || undefined;
|
|
1081
|
+
// 校验进程身份
|
|
1082
|
+
const idVerify = await this.driver.verifyIdentity(handle.identity);
|
|
1083
|
+
const isResourceOrTimeoutStopped = opState.timedOut ||
|
|
1084
|
+
opState.terminationReason === 'memory_exceeded' ||
|
|
1085
|
+
opState.terminationReason === 'cpu_exceeded' ||
|
|
1086
|
+
opState.terminationReason === 'pids_exceeded' ||
|
|
1087
|
+
opState.terminationReason === 'output_exceeded';
|
|
1088
|
+
const isSucceeded = exitResult.exitCode === 0 &&
|
|
1089
|
+
!isResourceOrTimeoutStopped &&
|
|
1090
|
+
!opState.cancelRequested;
|
|
1091
|
+
const finalStatus = isResourceOrTimeoutStopped
|
|
1092
|
+
? 'failed'
|
|
1093
|
+
: opState.cancelRequested
|
|
1094
|
+
? 'cancelled'
|
|
1095
|
+
: isSucceeded
|
|
1096
|
+
? 'succeeded'
|
|
1097
|
+
: 'failed';
|
|
1098
|
+
const terminationReason = opState.terminationReason || (finalStatus === 'succeeded' ? 'completed' : undefined);
|
|
1099
|
+
const result = {
|
|
1100
|
+
kind: 'process',
|
|
1101
|
+
status: finalStatus,
|
|
1102
|
+
exitCode: exitResult.exitCode,
|
|
1103
|
+
signal: exitResult.signal,
|
|
1104
|
+
stdout: stdoutData.content,
|
|
1105
|
+
stderr: stderrData.content,
|
|
1106
|
+
capabilityId: options.capabilityId,
|
|
1107
|
+
confined: isConfined,
|
|
1108
|
+
confinementDriver: confinementDriverName,
|
|
1109
|
+
isTruncated,
|
|
1110
|
+
stdoutTruncated: stdoutData.isTruncated,
|
|
1111
|
+
stderrTruncated: stderrData.isTruncated,
|
|
1112
|
+
stdoutRef: !stdoutData.spillError ? stdoutData.outputRef : undefined,
|
|
1113
|
+
stderrRef: !stderrData.spillError ? stderrData.outputRef : undefined,
|
|
1114
|
+
stdoutBytes: stdoutData.bytesSeen,
|
|
1115
|
+
stderrBytes: stderrData.bytesSeen,
|
|
1116
|
+
stdoutHash: !stdoutData.spillError ? stdoutData.outputHash : undefined,
|
|
1117
|
+
stderrHash: !stderrData.spillError ? stderrData.outputHash : undefined,
|
|
1118
|
+
...(residualProcessesReaped ? { residualProcessesReaped: true } : {}),
|
|
1119
|
+
...(streamCallbackError ? { streamCallbackError } : {}),
|
|
1120
|
+
...(spillError ? { spillError } : {}),
|
|
1121
|
+
...(stdoutData.spillError ? { stdoutSpillError: stdoutData.spillError } : {}),
|
|
1122
|
+
...(stderrData.spillError ? { stderrSpillError: stderrData.spillError } : {}),
|
|
1123
|
+
outputRef,
|
|
1124
|
+
outputHash,
|
|
1125
|
+
terminationReason,
|
|
1126
|
+
peakMemoryBytes,
|
|
1127
|
+
cpuTimeMs: peakCpuTimeMs,
|
|
1128
|
+
identityVerification: idVerify,
|
|
1129
|
+
durationMs: Date.now() - startTime,
|
|
1130
|
+
completedAt: new Date().toISOString(),
|
|
1131
|
+
};
|
|
1132
|
+
if (opState.stopPromise) {
|
|
1133
|
+
const stopRes = await opState.stopPromise;
|
|
1134
|
+
if (stopRes.stopped !== 'confirmed_stopped') {
|
|
1135
|
+
const stored = this.domain.getStore().getOperation(options.opId);
|
|
1136
|
+
if (stored && stored.status === 'done') {
|
|
1137
|
+
return (stored.result ?? null);
|
|
1138
|
+
}
|
|
1139
|
+
const indetResult = {
|
|
1140
|
+
kind: 'indeterminate',
|
|
1141
|
+
status: 'indeterminate',
|
|
1142
|
+
reason: `Process ${handle.identity.pid} was cancelled or stopped but could not be confirmed: ${stopRes.errorDetails || 'residual processes still alive'}`,
|
|
1143
|
+
recoveryGuidance: 'Residual PID detected. Inspect system processes manually before releasing resources.',
|
|
1144
|
+
capabilityId: options.capabilityId,
|
|
1145
|
+
durationMs: Date.now() - startTime,
|
|
1146
|
+
completedAt: new Date().toISOString(),
|
|
1147
|
+
};
|
|
1148
|
+
return this.finalizeOperation(options.opId, indetResult, options.requiredResources, false);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
// 6. [事务提交结果与释放资源 - Single Writer]
|
|
1152
|
+
// 统一由 finalizeOperation 写入事实与处理资源
|
|
1153
|
+
return this.finalizeOperation(options.opId, result, options.requiredResources, true, artifactsDir);
|
|
512
1154
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
errorDetails: opState.phase === 'done' ? undefined : 'Operation terminated before process activation',
|
|
531
|
-
});
|
|
1155
|
+
finally {
|
|
1156
|
+
stdoutDrainer?.forceFinalize();
|
|
1157
|
+
stderrDrainer?.forceFinalize();
|
|
1158
|
+
if (timeoutTimer)
|
|
1159
|
+
clearTimeout(timeoutTimer);
|
|
1160
|
+
if (samplingInterval)
|
|
1161
|
+
clearInterval(samplingInterval);
|
|
1162
|
+
if (opState.stopResolve) {
|
|
1163
|
+
const resolveFn = opState.stopResolve;
|
|
1164
|
+
opState.stopResolve = undefined;
|
|
1165
|
+
resolveFn({
|
|
1166
|
+
stopped: 'confirmed_stopped',
|
|
1167
|
+
scope: 'direct_child',
|
|
1168
|
+
errorDetails: opState.phase === 'done' ? undefined : 'Operation terminated before process activation',
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
this.activeOperations.delete(options.opId);
|
|
532
1172
|
}
|
|
533
|
-
|
|
534
|
-
|
|
1173
|
+
})();
|
|
1174
|
+
opState.resultPromise = executionPromise;
|
|
1175
|
+
return await executionPromise;
|
|
535
1176
|
}
|
|
536
1177
|
/**
|
|
537
1178
|
* 停止确认流水线 (Stopping Pipeline)
|
|
@@ -627,6 +1268,7 @@ export class ProcessSupervisor {
|
|
|
627
1268
|
signal: null,
|
|
628
1269
|
stdout: '',
|
|
629
1270
|
stderr: '',
|
|
1271
|
+
capabilityId: options.capabilityId,
|
|
630
1272
|
isTruncated: false,
|
|
631
1273
|
terminationReason: 'user_cancelled',
|
|
632
1274
|
evidence: 'unobserved',
|
|
@@ -662,6 +1304,12 @@ export class ProcessSupervisor {
|
|
|
662
1304
|
if (existing && existing.status === 'done') {
|
|
663
1305
|
throw new Error(`Cannot finalize operation "${opId}": operation is already finalized with status "${existing.status}"`);
|
|
664
1306
|
}
|
|
1307
|
+
if (existing) {
|
|
1308
|
+
result.runId = existing.runId;
|
|
1309
|
+
if (existing.capabilityId && !result.capabilityId) {
|
|
1310
|
+
result.capabilityId = existing.capabilityId;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
665
1313
|
if (result.kind === 'process') {
|
|
666
1314
|
const procRes = result;
|
|
667
1315
|
const targetDir = artifactsDir || path.join(this.domain.domainPath, 'artifacts');
|
|
@@ -700,178 +1348,5 @@ export class ProcessSupervisor {
|
|
|
700
1348
|
}
|
|
701
1349
|
return result;
|
|
702
1350
|
}
|
|
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;
|
|
710
|
-
let bytesSeen = 0;
|
|
711
|
-
let isTruncated = false;
|
|
712
|
-
let spillFd = null;
|
|
713
|
-
let spillError = spillInitError;
|
|
714
|
-
const hash = crypto.createHash('sha256');
|
|
715
|
-
let isFinalized = false;
|
|
716
|
-
let outputHash = undefined;
|
|
717
|
-
const tailRing = tailMaxBytes > 0 ? Buffer.alloc(tailMaxBytes) : null;
|
|
718
|
-
let tailHead = 0;
|
|
719
|
-
let tailCount = 0;
|
|
720
|
-
if (spillFilePath && !spillError) {
|
|
721
|
-
try {
|
|
722
|
-
spillFd = fs.openSync(spillFilePath, 'w');
|
|
723
|
-
}
|
|
724
|
-
catch (openErr) {
|
|
725
|
-
spillError = openErr.message || String(openErr);
|
|
726
|
-
spillFd = null;
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
const doFinalize = () => {
|
|
730
|
-
if (isFinalized)
|
|
731
|
-
return;
|
|
732
|
-
isFinalized = true;
|
|
733
|
-
if (spillFd !== null) {
|
|
734
|
-
try {
|
|
735
|
-
fs.fsyncSync(spillFd);
|
|
736
|
-
fs.closeSync(spillFd);
|
|
737
|
-
}
|
|
738
|
-
catch (syncErr) {
|
|
739
|
-
spillError = spillError || syncErr.message || String(syncErr);
|
|
740
|
-
}
|
|
741
|
-
spillFd = null;
|
|
742
|
-
}
|
|
743
|
-
if (!spillError) {
|
|
744
|
-
try {
|
|
745
|
-
outputHash = hash.digest('hex');
|
|
746
|
-
}
|
|
747
|
-
catch { }
|
|
748
|
-
}
|
|
749
|
-
else {
|
|
750
|
-
outputHash = undefined;
|
|
751
|
-
}
|
|
752
|
-
};
|
|
753
|
-
const finishPromise = new Promise((resolve) => {
|
|
754
|
-
const finalize = () => {
|
|
755
|
-
doFinalize();
|
|
756
|
-
resolve();
|
|
757
|
-
};
|
|
758
|
-
stream.on('data', (chunk) => {
|
|
759
|
-
if (isFinalized) {
|
|
760
|
-
// finalize 之后,流继续读走数据避免堵塞或 SIGPIPE,但跳过 hash、落盘和转发
|
|
761
|
-
return;
|
|
762
|
-
}
|
|
763
|
-
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
764
|
-
bytesSeen += buf.length;
|
|
765
|
-
if (onChunk) {
|
|
766
|
-
try {
|
|
767
|
-
onChunk(buf.length);
|
|
768
|
-
}
|
|
769
|
-
catch { }
|
|
770
|
-
}
|
|
771
|
-
// 持续落盘转储全部流与写盘同链哈希 (P0-9)
|
|
772
|
-
if (spillFd !== null) {
|
|
773
|
-
try {
|
|
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);
|
|
789
|
-
}
|
|
790
|
-
catch { }
|
|
791
|
-
}
|
|
792
|
-
if (onData) {
|
|
793
|
-
onData(buf);
|
|
794
|
-
}
|
|
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;
|
|
803
|
-
if (remaining > 0) {
|
|
804
|
-
headChunks.push(buf.subarray(0, remaining));
|
|
805
|
-
headBytes += remaining;
|
|
806
|
-
}
|
|
807
|
-
isTruncated = true;
|
|
808
|
-
}
|
|
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
|
-
}
|
|
831
|
-
});
|
|
832
|
-
stream.on('end', finalize);
|
|
833
|
-
stream.on('close', finalize);
|
|
834
|
-
stream.on('error', finalize);
|
|
835
|
-
});
|
|
836
|
-
return {
|
|
837
|
-
getResult: () => {
|
|
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
|
-
}
|
|
863
|
-
return {
|
|
864
|
-
content,
|
|
865
|
-
isTruncated,
|
|
866
|
-
outputRef: isTruncated && spillFilePath && !spillError ? spillFilePath : undefined,
|
|
867
|
-
outputHash: !spillError ? outputHash : undefined,
|
|
868
|
-
bytesSeen,
|
|
869
|
-
spillError,
|
|
870
|
-
};
|
|
871
|
-
},
|
|
872
|
-
finishPromise,
|
|
873
|
-
forceFinalize: doFinalize,
|
|
874
|
-
};
|
|
875
|
-
}
|
|
876
1351
|
}
|
|
877
1352
|
//# sourceMappingURL=supervisor.js.map
|