@aviaratech/ai-delivery 0.3.0 → 0.3.2
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 +7 -3
- package/dist/cli.js +18 -1
- package/dist/cli.js.map +1 -1
- package/dist/dispatch.js +5 -1
- package/dist/dispatch.js.map +1 -1
- package/dist/lifecycle.test.js +752 -1
- package/dist/lifecycle.test.js.map +1 -1
- package/dist/mcp/index.d.ts +1 -1
- package/dist/mcp/index.js +25 -8
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/tools.d.ts +6 -0
- package/dist/mcp/tools.js +8 -0
- package/dist/mcp/tools.js.map +1 -1
- package/dist/verification.d.ts +27 -1
- package/dist/verification.js +663 -32
- package/dist/verification.js.map +1 -1
- package/package.json +1 -1
- package/plugins/ai-delivery/.claude-plugin/plugin.json +1 -1
- package/plugins/ai-delivery/skills/intake-create/SKILL.md +24 -1
package/dist/verification.js
CHANGED
|
@@ -1,12 +1,423 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
3
|
-
import { basename, dirname, join } from 'node:path';
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, statfsSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { z } from 'zod';
|
|
4
6
|
import { loadDeliveryConfig } from './config/deliveryConfig.js';
|
|
5
|
-
import { classifyRepositoryExactRange, createRepositoryDeliveryEvidence, createRepositoryStageAggregate, createRepositoryStageInput, createRepositoryStageReceipt, digestValue, loadRepositoryStageCheckpoint, loadSelectedRepositoryPolicy, RepositoryClassificationReceiptSchema, RepositoryStageAggregateSchema, RepositoryStageReceiptSchema, writeRepositoryCommandOutput, writeRepositoryDeliveryEvidence, writeRepositoryStageAggregate, writeRepositoryStageCheckpoint, } from './delivery/index.js';
|
|
7
|
+
import { classifyRepositoryExactRange, createRepositoryDeliveryEvidence, createRepositoryStageAggregate, createRepositoryStageInput, createRepositoryStageReceipt, digestBytes, digestValue, assertRepositoryStageProof, loadRepositoryStageCheckpoint, loadSelectedRepositoryPolicy, RepositoryClassificationReceiptSchema, RepositoryStageAggregateSchema, RepositoryStageReceiptSchema, writeRepositoryCommandOutput, writeRepositoryDeliveryEvidence, writeRepositoryStageAggregate, writeRepositoryStageCheckpoint, } from './delivery/index.js';
|
|
6
8
|
import { DeliveryError } from './errors.js';
|
|
7
9
|
import { assertClean, changedPaths, coordinate, defaultBaseRef, git, gitCommonDir, gitRoot } from './git.js';
|
|
8
10
|
import { getIssueWorktreeStrict } from './services/worktreeRegistry.js';
|
|
9
11
|
import { writePrivateJsonFileAtomically } from './utils/atomicJson.js';
|
|
12
|
+
const ResourceSummarySchema = z
|
|
13
|
+
.strictObject({
|
|
14
|
+
bounds: z.strictObject({
|
|
15
|
+
maxAggregateRssBytes: z.number().int().positive().safe(),
|
|
16
|
+
maxNewOutputBytes: z.number().int().positive().safe().optional(),
|
|
17
|
+
minFreeDiskBytes: z.number().int().positive().safe(),
|
|
18
|
+
outputRoots: z.array(z.string().min(1)).min(1).optional(),
|
|
19
|
+
}),
|
|
20
|
+
maxSampledAggregateRssBytes: z.number().int().nonnegative().safe().nullable(),
|
|
21
|
+
maxSampledNewOutputBytes: z.number().int().nonnegative().safe().nullable(),
|
|
22
|
+
minSampledFreeDiskBytes: z.number().int().nonnegative().safe().nullable(),
|
|
23
|
+
observation: z.literal('sampled'),
|
|
24
|
+
outputBaselineId: z
|
|
25
|
+
.string()
|
|
26
|
+
.regex(/^sha256:[a-f0-9]{64}$/u)
|
|
27
|
+
.optional(),
|
|
28
|
+
processCoverage: z.literal('observed-processes-only'),
|
|
29
|
+
sampleCount: z.number().int().nonnegative().safe(),
|
|
30
|
+
})
|
|
31
|
+
.superRefine((value, ctx) => {
|
|
32
|
+
if ((value.maxSampledAggregateRssBytes !== null &&
|
|
33
|
+
value.maxSampledAggregateRssBytes > value.bounds.maxAggregateRssBytes) ||
|
|
34
|
+
(value.maxSampledNewOutputBytes !== null &&
|
|
35
|
+
value.bounds.maxNewOutputBytes !== undefined &&
|
|
36
|
+
value.maxSampledNewOutputBytes > value.bounds.maxNewOutputBytes) ||
|
|
37
|
+
(value.minSampledFreeDiskBytes !== null && value.minSampledFreeDiskBytes < value.bounds.minFreeDiskBytes) ||
|
|
38
|
+
(value.sampleCount > 0 &&
|
|
39
|
+
(value.maxSampledAggregateRssBytes === null || value.minSampledFreeDiskBytes === null)) ||
|
|
40
|
+
(value.bounds.maxNewOutputBytes !== undefined &&
|
|
41
|
+
value.sampleCount > 0 &&
|
|
42
|
+
value.maxSampledNewOutputBytes === null) ||
|
|
43
|
+
(value.bounds.maxNewOutputBytes !== undefined) !== (value.outputBaselineId !== undefined))
|
|
44
|
+
ctx.addIssue({ code: 'custom', message: 'Resource samples do not satisfy their declared bounds.' });
|
|
45
|
+
});
|
|
46
|
+
const ResourceStageCheckpointSchema = z.strictObject({
|
|
47
|
+
checkpointId: z.string().regex(/^sha256:[a-f0-9]{64}$/u),
|
|
48
|
+
receipt: RepositoryStageReceiptSchema,
|
|
49
|
+
resources: ResourceSummarySchema,
|
|
50
|
+
schemaVersion: z.literal('ai-delivery.resource-stage@1'),
|
|
51
|
+
});
|
|
52
|
+
function resourceStagePath(common, stageInput) {
|
|
53
|
+
return join(common, 'ai-delivery', 'resource-stages@1', `${stageInput.inputId.slice(7)}.json`);
|
|
54
|
+
}
|
|
55
|
+
function loadResourceStageCheckpoint(common, repoRoot, stageInput, bounds, baselineId) {
|
|
56
|
+
let raw;
|
|
57
|
+
try {
|
|
58
|
+
raw = JSON.parse(readFileSync(resourceStagePath(common, stageInput), 'utf8'));
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (error.code === 'ENOENT')
|
|
62
|
+
return undefined;
|
|
63
|
+
throw new DeliveryError('Resource stage checkpoint is corrupt.');
|
|
64
|
+
}
|
|
65
|
+
const checkpoint = ResourceStageCheckpointSchema.parse(raw);
|
|
66
|
+
const { checkpointId, ...content } = checkpoint;
|
|
67
|
+
if (checkpointId !== digestValue(content) ||
|
|
68
|
+
checkpoint.receipt.input.inputId !== stageInput.inputId ||
|
|
69
|
+
digestValue(checkpoint.resources.bounds) !== digestValue(bounds) ||
|
|
70
|
+
checkpoint.resources.outputBaselineId !== baselineId ||
|
|
71
|
+
checkpoint.resources.sampleCount === 0)
|
|
72
|
+
throw new DeliveryError('Resource stage checkpoint is corrupt or belongs to another input.');
|
|
73
|
+
assertRepositoryStageProof({ gitCommonDir: common, receipt: checkpoint.receipt, repoRoot });
|
|
74
|
+
return checkpoint;
|
|
75
|
+
}
|
|
76
|
+
function writeResourceStageCheckpoint(common, receipt, resources) {
|
|
77
|
+
if (resources.sampleCount === 0)
|
|
78
|
+
throw new DeliveryError('Completed bounded stage lacks resource samples.');
|
|
79
|
+
const content = {
|
|
80
|
+
receipt,
|
|
81
|
+
resources: ResourceSummarySchema.parse(resources),
|
|
82
|
+
schemaVersion: 'ai-delivery.resource-stage@1',
|
|
83
|
+
};
|
|
84
|
+
writePrivateJsonFileAtomically(resourceStagePath(common, receipt.input), {
|
|
85
|
+
...content,
|
|
86
|
+
checkpointId: digestValue(content),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function mergeResourceSummary(target, source) {
|
|
90
|
+
target.sampleCount += source.sampleCount;
|
|
91
|
+
if (!Number.isSafeInteger(target.sampleCount))
|
|
92
|
+
throw new DeliveryError('Resource sample count overflowed.');
|
|
93
|
+
if (source.maxSampledAggregateRssBytes !== null) {
|
|
94
|
+
target.maxSampledAggregateRssBytes = Math.max(target.maxSampledAggregateRssBytes ?? 0, source.maxSampledAggregateRssBytes);
|
|
95
|
+
}
|
|
96
|
+
if (source.maxSampledNewOutputBytes !== null) {
|
|
97
|
+
target.maxSampledNewOutputBytes = Math.max(target.maxSampledNewOutputBytes ?? 0, source.maxSampledNewOutputBytes);
|
|
98
|
+
}
|
|
99
|
+
if (source.minSampledFreeDiskBytes !== null) {
|
|
100
|
+
target.minSampledFreeDiskBytes = Math.min(target.minSampledFreeDiskBytes ?? source.minSampledFreeDiskBytes, source.minSampledFreeDiskBytes);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function assertResourceBounds(bounds) {
|
|
104
|
+
const limits = [
|
|
105
|
+
['maxAggregateRssBytes', bounds.maxAggregateRssBytes],
|
|
106
|
+
['minFreeDiskBytes', bounds.minFreeDiskBytes],
|
|
107
|
+
];
|
|
108
|
+
if (bounds.maxNewOutputBytes !== undefined)
|
|
109
|
+
limits.push(['maxNewOutputBytes', bounds.maxNewOutputBytes]);
|
|
110
|
+
for (const [name, value] of limits) {
|
|
111
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
112
|
+
throw new DeliveryError(`Invalid ${name} resource bound.`);
|
|
113
|
+
}
|
|
114
|
+
if ((bounds.maxNewOutputBytes === undefined) !== (bounds.outputRoots === undefined)) {
|
|
115
|
+
throw new DeliveryError('Filesystem output limit and output roots must be supplied together.');
|
|
116
|
+
}
|
|
117
|
+
if (bounds.outputRoots !== undefined &&
|
|
118
|
+
(bounds.outputRoots.length === 0 || bounds.outputRoots.some((path) => !path))) {
|
|
119
|
+
throw new DeliveryError('Filesystem output roots must be nonempty paths.');
|
|
120
|
+
}
|
|
121
|
+
if (process.platform === 'win32')
|
|
122
|
+
throw new DeliveryError('Resource observation requires POSIX process support.');
|
|
123
|
+
}
|
|
124
|
+
const OutputBaselineCheckpointSchema = z.strictObject({
|
|
125
|
+
baselineId: z.string().regex(/^sha256:[a-f0-9]{64}$/u),
|
|
126
|
+
files: z.array(z.tuple([z.string().min(1), z.number().int().nonnegative().safe()])),
|
|
127
|
+
inputId: z.string().regex(/^sha256:[a-f0-9]{64}$/u),
|
|
128
|
+
roots: z.array(z.string().min(1)).min(1),
|
|
129
|
+
schemaVersion: z.literal('ai-delivery.output-baseline@1'),
|
|
130
|
+
});
|
|
131
|
+
const OutputBaselineStateSchema = z.strictObject({
|
|
132
|
+
baselineId: z
|
|
133
|
+
.string()
|
|
134
|
+
.regex(/^sha256:[a-f0-9]{64}$/u)
|
|
135
|
+
.optional(),
|
|
136
|
+
inputId: z.string().regex(/^sha256:[a-f0-9]{64}$/u),
|
|
137
|
+
schemaVersion: z.literal('ai-delivery.output-baseline-state@1'),
|
|
138
|
+
stateId: z.string().regex(/^sha256:[a-f0-9]{64}$/u),
|
|
139
|
+
status: z.enum(['preparing', 'complete']),
|
|
140
|
+
});
|
|
141
|
+
function scanOutputRoots(roots) {
|
|
142
|
+
const files = new Map();
|
|
143
|
+
const visit = (path) => {
|
|
144
|
+
let metadata;
|
|
145
|
+
try {
|
|
146
|
+
metadata = lstatSync(path);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (error.code === 'ENOENT')
|
|
150
|
+
return;
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
if (metadata.isSymbolicLink()) {
|
|
154
|
+
throw new DeliveryError('Filesystem output observation encountered a symbolic link.');
|
|
155
|
+
}
|
|
156
|
+
if (metadata.isDirectory()) {
|
|
157
|
+
for (const name of readdirSync(path))
|
|
158
|
+
visit(join(path, name));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (!metadata.isFile() || !Number.isSafeInteger(metadata.size)) {
|
|
162
|
+
throw new DeliveryError('Filesystem output observation encountered an unsupported file.');
|
|
163
|
+
}
|
|
164
|
+
files.set(path, metadata.size);
|
|
165
|
+
if (files.size > 1_000_000)
|
|
166
|
+
throw new DeliveryError('Filesystem output observation exceeded its file limit.');
|
|
167
|
+
};
|
|
168
|
+
for (const root of roots)
|
|
169
|
+
visit(root);
|
|
170
|
+
return files;
|
|
171
|
+
}
|
|
172
|
+
function outputBaseline(repoRoot, common, classificationReceiptId, bounds) {
|
|
173
|
+
if (bounds.outputRoots === undefined)
|
|
174
|
+
return undefined;
|
|
175
|
+
const roots = bounds.outputRoots.map((path) => resolve(repoRoot, path)).sort();
|
|
176
|
+
for (let i = 1; i < roots.length; i++) {
|
|
177
|
+
if (roots[i] === roots[i - 1] || roots[i].startsWith(`${roots[i - 1]}${sep}`)) {
|
|
178
|
+
throw new DeliveryError('Filesystem output roots must not overlap.');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const inputId = digestValue({
|
|
182
|
+
bounds: { ...bounds, outputRoots: roots },
|
|
183
|
+
classificationReceiptId,
|
|
184
|
+
resourceRunner: digestBytes(readFileSync(fileURLToPath(import.meta.url))),
|
|
185
|
+
});
|
|
186
|
+
const path = join(common, 'ai-delivery', 'output-baselines@1', `${inputId.slice(7)}.json`);
|
|
187
|
+
const statePath = join(common, 'ai-delivery', 'output-baselines@1', `${inputId.slice(7)}.state.json`);
|
|
188
|
+
const writeState = (status, baselineId) => {
|
|
189
|
+
const content = {
|
|
190
|
+
inputId,
|
|
191
|
+
schemaVersion: 'ai-delivery.output-baseline-state@1',
|
|
192
|
+
status,
|
|
193
|
+
...(baselineId === undefined ? {} : { baselineId }),
|
|
194
|
+
};
|
|
195
|
+
writePrivateJsonFileAtomically(statePath, { ...content, stateId: digestValue(content) });
|
|
196
|
+
};
|
|
197
|
+
let state;
|
|
198
|
+
try {
|
|
199
|
+
state = OutputBaselineStateSchema.parse(JSON.parse(readFileSync(statePath, 'utf8')));
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
if (error.code !== 'ENOENT') {
|
|
203
|
+
throw new DeliveryError('Filesystem output baseline state is corrupt.');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (state === undefined) {
|
|
207
|
+
if (existsSync(path))
|
|
208
|
+
throw new DeliveryError('Filesystem output baseline state is missing.');
|
|
209
|
+
writeState('preparing');
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
const { stateId, ...content } = state;
|
|
213
|
+
if (stateId !== digestValue(content) ||
|
|
214
|
+
state.inputId !== inputId ||
|
|
215
|
+
(state.status === 'complete') !== (state.baselineId !== undefined))
|
|
216
|
+
throw new DeliveryError('Filesystem output baseline state is corrupt.');
|
|
217
|
+
}
|
|
218
|
+
let checkpoint;
|
|
219
|
+
if (state?.status !== 'complete') {
|
|
220
|
+
const content = {
|
|
221
|
+
files: [...scanOutputRoots(roots)].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
|
|
222
|
+
inputId,
|
|
223
|
+
roots,
|
|
224
|
+
schemaVersion: 'ai-delivery.output-baseline@1',
|
|
225
|
+
};
|
|
226
|
+
checkpoint = { ...content, baselineId: digestValue(content) };
|
|
227
|
+
writePrivateJsonFileAtomically(path, checkpoint);
|
|
228
|
+
writeState('complete', checkpoint.baselineId);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
try {
|
|
232
|
+
checkpoint = OutputBaselineCheckpointSchema.parse(JSON.parse(readFileSync(path, 'utf8')));
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
throw new DeliveryError('Filesystem output baseline checkpoint is missing or corrupt.');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const { baselineId, ...content } = checkpoint;
|
|
239
|
+
if (baselineId !== digestValue(content) ||
|
|
240
|
+
(state?.status === 'complete' && state.baselineId !== baselineId) ||
|
|
241
|
+
checkpoint.inputId !== inputId ||
|
|
242
|
+
digestValue(checkpoint.roots) !== digestValue(roots) ||
|
|
243
|
+
checkpoint.files.some(([file], index) => (index > 0 && file <= checkpoint.files[index - 1][0]) ||
|
|
244
|
+
!roots.some((root) => file === root || file.startsWith(`${root}${sep}`))))
|
|
245
|
+
throw new DeliveryError('Filesystem output baseline checkpoint is corrupt or belongs to another input.');
|
|
246
|
+
return { baselineId, roots, files: new Map(checkpoint.files) };
|
|
247
|
+
}
|
|
248
|
+
function positiveNewOutputBytes(baseline) {
|
|
249
|
+
let bytes = 0;
|
|
250
|
+
for (const [path, size] of scanOutputRoots(baseline.roots)) {
|
|
251
|
+
bytes += Math.max(0, size - (baseline.files.get(path) ?? 0));
|
|
252
|
+
if (!Number.isSafeInteger(bytes))
|
|
253
|
+
throw new DeliveryError('Filesystem output observation overflowed.');
|
|
254
|
+
}
|
|
255
|
+
return bytes;
|
|
256
|
+
}
|
|
257
|
+
function availableDiskBytes(path) {
|
|
258
|
+
let existing = path;
|
|
259
|
+
for (;;) {
|
|
260
|
+
try {
|
|
261
|
+
const disk = statfsSync(existing, { bigint: true });
|
|
262
|
+
return disk.bavail * disk.bsize;
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
if (error.code !== 'ENOENT' || dirname(existing) === existing)
|
|
266
|
+
throw error;
|
|
267
|
+
existing = dirname(existing);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function freeDiskBytes(repoRoot, baseline) {
|
|
272
|
+
const roots = baseline === undefined ? [repoRoot] : [repoRoot, ...baseline.roots];
|
|
273
|
+
const minimum = roots.reduce((value, path) => {
|
|
274
|
+
const available = availableDiskBytes(path);
|
|
275
|
+
return available < value ? available : value;
|
|
276
|
+
}, BigInt(Number.MAX_SAFE_INTEGER));
|
|
277
|
+
if (minimum > BigInt(Number.MAX_SAFE_INTEGER))
|
|
278
|
+
throw new DeliveryError('Free disk observation overflowed.');
|
|
279
|
+
return Number(minimum);
|
|
280
|
+
}
|
|
281
|
+
function assertDiskHeadroom(repoRoot, bounds, baseline) {
|
|
282
|
+
if (freeDiskBytes(repoRoot, baseline) < bounds.minFreeDiskBytes) {
|
|
283
|
+
throw new DeliveryError(`Free disk fell below limit ${bounds.minFreeDiskBytes}.`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function processSnapshot() {
|
|
287
|
+
const psPath = existsSync('/usr/bin/ps') ? '/usr/bin/ps' : '/bin/ps';
|
|
288
|
+
const result = spawnSync(psPath, ['-A', '-o', 'pid=,ppid=,pgid=,rss=,stat=,lstart='], {
|
|
289
|
+
encoding: 'utf8',
|
|
290
|
+
env: { ...process.env, LC_ALL: 'C' },
|
|
291
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
292
|
+
timeout: 1_000,
|
|
293
|
+
});
|
|
294
|
+
if (result.status !== 0 || result.error || result.signal || !result.stdout.endsWith('\n')) {
|
|
295
|
+
throw new DeliveryError('Owned process resource observation failed.');
|
|
296
|
+
}
|
|
297
|
+
const snapshot = new Map();
|
|
298
|
+
for (const line of result.stdout.trim().split('\n')) {
|
|
299
|
+
const match = /^(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/u.exec(line.trim());
|
|
300
|
+
if (!match)
|
|
301
|
+
throw new DeliveryError('Owned process resource observation is ambiguous.');
|
|
302
|
+
const observed = {
|
|
303
|
+
pid: Number(match[1]),
|
|
304
|
+
ppid: Number(match[2]),
|
|
305
|
+
pgid: Number(match[3]),
|
|
306
|
+
rssBytes: Number(match[4]) * 1_024,
|
|
307
|
+
status: match[5],
|
|
308
|
+
identity: match[6],
|
|
309
|
+
};
|
|
310
|
+
if (!Number.isSafeInteger(observed.pid) ||
|
|
311
|
+
observed.pid <= 0 ||
|
|
312
|
+
!Number.isSafeInteger(observed.ppid) ||
|
|
313
|
+
!Number.isSafeInteger(observed.pgid) ||
|
|
314
|
+
!Number.isSafeInteger(observed.rssBytes) ||
|
|
315
|
+
snapshot.has(observed.pid))
|
|
316
|
+
throw new DeliveryError('Owned process resource observation is ambiguous.');
|
|
317
|
+
snapshot.set(observed.pid, observed);
|
|
318
|
+
}
|
|
319
|
+
return snapshot;
|
|
320
|
+
}
|
|
321
|
+
function observeOwnedProcesses(state, snapshot) {
|
|
322
|
+
const root = snapshot.get(state.rootPid);
|
|
323
|
+
if (state.rootIdentity === undefined) {
|
|
324
|
+
if (root === undefined || root.status.startsWith('Z') || root.pgid !== state.rootPid) {
|
|
325
|
+
throw new DeliveryError('Owned process identity could not be established before the command exited.');
|
|
326
|
+
}
|
|
327
|
+
state.rootIdentity = root.identity;
|
|
328
|
+
state.tracked.set(root.pid, root);
|
|
329
|
+
}
|
|
330
|
+
else if (root !== undefined && root.identity !== state.rootIdentity) {
|
|
331
|
+
throw new DeliveryError('Owned process identity changed during verification.');
|
|
332
|
+
}
|
|
333
|
+
const selected = new Map();
|
|
334
|
+
for (const [pid, previous] of state.tracked) {
|
|
335
|
+
const current = snapshot.get(pid);
|
|
336
|
+
if (current === undefined || current.status.startsWith('Z'))
|
|
337
|
+
state.tracked.delete(pid);
|
|
338
|
+
else if (current.identity !== previous.identity) {
|
|
339
|
+
throw new DeliveryError('Tracked process identity changed during verification.');
|
|
340
|
+
}
|
|
341
|
+
else
|
|
342
|
+
selected.set(pid, current);
|
|
343
|
+
}
|
|
344
|
+
for (const member of snapshot.values()) {
|
|
345
|
+
if (!member.status.startsWith('Z') && member.pgid === state.rootPid)
|
|
346
|
+
selected.set(member.pid, member);
|
|
347
|
+
}
|
|
348
|
+
const children = new Map();
|
|
349
|
+
for (const member of snapshot.values()) {
|
|
350
|
+
const siblings = children.get(member.ppid) ?? [];
|
|
351
|
+
siblings.push(member);
|
|
352
|
+
children.set(member.ppid, siblings);
|
|
353
|
+
}
|
|
354
|
+
const pending = [...selected.values()];
|
|
355
|
+
for (const parent of pending)
|
|
356
|
+
for (const child of children.get(parent.pid) ?? []) {
|
|
357
|
+
if (!child.status.startsWith('Z') && !selected.has(child.pid)) {
|
|
358
|
+
selected.set(child.pid, child);
|
|
359
|
+
pending.push(child);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
for (const member of selected.values())
|
|
363
|
+
state.tracked.set(member.pid, member);
|
|
364
|
+
state.sampled = true;
|
|
365
|
+
return [...selected.values()];
|
|
366
|
+
}
|
|
367
|
+
function sampleOwnedTree(state, repoRoot, bounds, baseline) {
|
|
368
|
+
const owned = observeOwnedProcesses(state, processSnapshot());
|
|
369
|
+
const rssBytes = owned.reduce((total, member) => total + member.rssBytes, 0);
|
|
370
|
+
if (!Number.isSafeInteger(rssBytes))
|
|
371
|
+
throw new DeliveryError('Owned aggregate RSS is outside the safe integer range.');
|
|
372
|
+
if (rssBytes > bounds.maxAggregateRssBytes) {
|
|
373
|
+
throw new DeliveryError(`Owned aggregate RSS ${rssBytes} exceeded limit ${bounds.maxAggregateRssBytes}.`);
|
|
374
|
+
}
|
|
375
|
+
const available = freeDiskBytes(repoRoot, baseline);
|
|
376
|
+
if (available < bounds.minFreeDiskBytes)
|
|
377
|
+
throw new DeliveryError(`Free disk fell below limit ${bounds.minFreeDiskBytes}.`);
|
|
378
|
+
const newOutputBytes = baseline === undefined ? undefined : positiveNewOutputBytes(baseline);
|
|
379
|
+
if (newOutputBytes !== undefined && !Number.isSafeInteger(newOutputBytes)) {
|
|
380
|
+
throw new DeliveryError('Filesystem output observation overflowed.');
|
|
381
|
+
}
|
|
382
|
+
if (baseline !== undefined && bounds.maxNewOutputBytes !== undefined) {
|
|
383
|
+
if (newOutputBytes !== undefined && newOutputBytes > bounds.maxNewOutputBytes) {
|
|
384
|
+
throw new DeliveryError(`Positive new filesystem output ${newOutputBytes} exceeded limit ${bounds.maxNewOutputBytes}.`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
aggregateRssBytes: rssBytes,
|
|
389
|
+
freeDiskBytes: available,
|
|
390
|
+
...(newOutputBytes === undefined ? {} : { newOutputBytes }),
|
|
391
|
+
ownedProcessCount: owned.length,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
async function confirmOwnedCleanup(state) {
|
|
395
|
+
if (!state.sampled || state.rootIdentity === undefined) {
|
|
396
|
+
throw new DeliveryError('Owned process cleanup cannot be verified without a root identity.');
|
|
397
|
+
}
|
|
398
|
+
const deadline = Date.now() + 2_000;
|
|
399
|
+
for (;;) {
|
|
400
|
+
const snapshot = processSnapshot();
|
|
401
|
+
const root = snapshot.get(state.rootPid);
|
|
402
|
+
if (root !== undefined && root.identity !== state.rootIdentity) {
|
|
403
|
+
throw new DeliveryError('Owned process group identity changed before cleanup readback.');
|
|
404
|
+
}
|
|
405
|
+
const groupAlive = [...snapshot.values()].some((member) => member.pgid === state.rootPid && !member.status.startsWith('Z'));
|
|
406
|
+
const trackedAlive = [...state.tracked.values()].some((previous) => {
|
|
407
|
+
const current = snapshot.get(previous.pid);
|
|
408
|
+
if (current === undefined || current.status.startsWith('Z'))
|
|
409
|
+
return false;
|
|
410
|
+
if (current.identity !== previous.identity)
|
|
411
|
+
throw new DeliveryError('Tracked process identity changed before cleanup readback.');
|
|
412
|
+
return true;
|
|
413
|
+
});
|
|
414
|
+
if (!groupAlive && !trackedAlive)
|
|
415
|
+
return;
|
|
416
|
+
if (Date.now() >= deadline)
|
|
417
|
+
throw new DeliveryError('Owned process cleanup did not release every sampled process.');
|
|
418
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
419
|
+
}
|
|
420
|
+
}
|
|
10
421
|
function primaryRoot(worktreeRoot) {
|
|
11
422
|
const common = gitCommonDir(worktreeRoot);
|
|
12
423
|
if (basename(common) !== '.git')
|
|
@@ -32,16 +443,22 @@ function parseRun(value) {
|
|
|
32
443
|
const classification = RepositoryClassificationReceiptSchema.parse(input.classification);
|
|
33
444
|
const aggregate = RepositoryStageAggregateSchema.parse(input.aggregate);
|
|
34
445
|
const stageReceipts = RepositoryStageReceiptSchema.array().parse(input.stageReceipts);
|
|
446
|
+
if (input.schemaVersion !== 'ai-delivery.run@1' && input.schemaVersion !== 'ai-delivery.run@2') {
|
|
447
|
+
throw new DeliveryError('Run manifest schema version is unsupported.');
|
|
448
|
+
}
|
|
449
|
+
if (input.schemaVersion === 'ai-delivery.run@1' && input.resources !== undefined) {
|
|
450
|
+
throw new DeliveryError('Historical run manifest has unexpected resource evidence.');
|
|
451
|
+
}
|
|
452
|
+
const resources = input.schemaVersion === 'ai-delivery.run@2' ? ResourceSummarySchema.parse(input.resources) : undefined;
|
|
35
453
|
const content = {
|
|
36
454
|
aggregate,
|
|
37
455
|
classification,
|
|
38
456
|
completedAt: input.completedAt,
|
|
457
|
+
...(resources === undefined ? {} : { resources }),
|
|
39
458
|
schemaVersion: input.schemaVersion,
|
|
40
459
|
stageReceipts,
|
|
41
460
|
};
|
|
42
|
-
if (input.
|
|
43
|
-
typeof input.completedAt !== 'string' ||
|
|
44
|
-
input.manifestId !== digestValue(content))
|
|
461
|
+
if (typeof input.completedAt !== 'string' || input.manifestId !== digestValue(content))
|
|
45
462
|
throw new DeliveryError('Run manifest identity is invalid.');
|
|
46
463
|
return { ...content, completedAt: input.completedAt, manifestId: input.manifestId };
|
|
47
464
|
}
|
|
@@ -66,7 +483,7 @@ function stageEnvironmentDigest() {
|
|
|
66
483
|
function reportVerificationProgress(input) {
|
|
67
484
|
process.stderr.write(`ai-delivery.verify ${JSON.stringify(input)}\n`);
|
|
68
485
|
}
|
|
69
|
-
function runStageCommand(repoRoot, argv, abortSignal, onRunning) {
|
|
486
|
+
function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds, baseline, onResourceSample) {
|
|
70
487
|
const [executable, ...args] = argv;
|
|
71
488
|
if (!executable)
|
|
72
489
|
throw new DeliveryError('Policy selected an empty stage command.');
|
|
@@ -80,29 +497,84 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning) {
|
|
|
80
497
|
});
|
|
81
498
|
const stdout = [];
|
|
82
499
|
const stderr = [];
|
|
500
|
+
const owned = resourceBounds === undefined || child.pid === undefined
|
|
501
|
+
? undefined
|
|
502
|
+
: { rootPid: child.pid, sampled: false, tracked: new Map() };
|
|
83
503
|
let outputBytes = 0;
|
|
84
504
|
let failure;
|
|
85
505
|
let cleanupFailure;
|
|
506
|
+
let closed = false;
|
|
507
|
+
let settled = false;
|
|
508
|
+
let closeTimer;
|
|
509
|
+
let exitPipeTimer;
|
|
510
|
+
let resourceTimer;
|
|
511
|
+
const clearWatchers = () => {
|
|
512
|
+
clearInterval(progressTimer);
|
|
513
|
+
if (resourceTimer !== undefined)
|
|
514
|
+
clearInterval(resourceTimer);
|
|
515
|
+
if (closeTimer !== undefined)
|
|
516
|
+
clearTimeout(closeTimer);
|
|
517
|
+
if (exitPipeTimer !== undefined)
|
|
518
|
+
clearTimeout(exitPipeTimer);
|
|
519
|
+
abortSignal?.removeEventListener('abort', onAbort);
|
|
520
|
+
process.removeListener('SIGINT', onSigint);
|
|
521
|
+
process.removeListener('SIGTERM', onSigterm);
|
|
522
|
+
};
|
|
86
523
|
const killOwned = () => {
|
|
87
524
|
if (child.pid === undefined)
|
|
88
525
|
return;
|
|
89
|
-
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
526
|
+
if (process.platform === 'win32') {
|
|
527
|
+
if (!child.kill('SIGKILL') && child.exitCode === null && child.signalCode === null)
|
|
528
|
+
cleanupFailure ??= 'could not signal the owned child';
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
let groupIsOwned = resourceBounds === undefined;
|
|
532
|
+
if (owned?.sampled) {
|
|
533
|
+
try {
|
|
534
|
+
const snapshot = processSnapshot();
|
|
535
|
+
const root = snapshot.get(owned.rootPid);
|
|
536
|
+
if (root !== undefined && root.identity !== owned.rootIdentity) {
|
|
537
|
+
cleanupFailure ??= 'owned process group identity changed before cleanup';
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
groupIsOwned = [...snapshot.values()].some((member) => member.pgid === owned.rootPid && !member.status.startsWith('Z'));
|
|
541
|
+
}
|
|
542
|
+
for (const previous of [...owned.tracked.values()].reverse()) {
|
|
543
|
+
const current = snapshot.get(previous.pid);
|
|
544
|
+
if (current === undefined || current.status.startsWith('Z'))
|
|
545
|
+
continue;
|
|
546
|
+
if (current.identity !== previous.identity) {
|
|
547
|
+
cleanupFailure ??= 'tracked process identity changed before cleanup';
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
try {
|
|
551
|
+
process.kill(previous.pid, 'SIGKILL');
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
if (error.code !== 'ESRCH') {
|
|
555
|
+
cleanupFailure ??= error instanceof Error ? error.message : String(error);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
95
559
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
560
|
+
catch (error) {
|
|
561
|
+
cleanupFailure ??= error instanceof Error ? error.message : String(error);
|
|
562
|
+
groupIsOwned = child.exitCode === null && child.signalCode === null;
|
|
99
563
|
}
|
|
100
564
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
565
|
+
else if (resourceBounds !== undefined) {
|
|
566
|
+
groupIsOwned = child.exitCode === null && child.signalCode === null;
|
|
567
|
+
cleanupFailure ??= 'owned process identity was not observed before cleanup';
|
|
568
|
+
}
|
|
569
|
+
if (groupIsOwned) {
|
|
570
|
+
try {
|
|
571
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
572
|
+
}
|
|
573
|
+
catch (error) {
|
|
574
|
+
if (error.code !== 'ESRCH') {
|
|
575
|
+
cleanupFailure ??= error instanceof Error ? error.message : String(error);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
106
578
|
}
|
|
107
579
|
};
|
|
108
580
|
const stop = (reason) => {
|
|
@@ -110,6 +582,18 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning) {
|
|
|
110
582
|
return;
|
|
111
583
|
failure = reason;
|
|
112
584
|
killOwned();
|
|
585
|
+
if (!closed) {
|
|
586
|
+
closeTimer = setTimeout(() => {
|
|
587
|
+
if (settled || closed)
|
|
588
|
+
return;
|
|
589
|
+
settled = true;
|
|
590
|
+
clearWatchers();
|
|
591
|
+
child.stdout.destroy();
|
|
592
|
+
child.stderr.destroy();
|
|
593
|
+
child.unref();
|
|
594
|
+
reject(new DeliveryError(`Selected policy stage command failed (${failure}). Owned process cleanup failed (owned command pipes did not close; descendant cleanup could not be verified).`));
|
|
595
|
+
}, 2_000);
|
|
596
|
+
}
|
|
113
597
|
};
|
|
114
598
|
const capture = (chunks, chunk) => {
|
|
115
599
|
outputBytes += chunk.length;
|
|
@@ -127,7 +611,34 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning) {
|
|
|
127
611
|
child.once('exit', (code, signal) => {
|
|
128
612
|
if (code !== 0 || signal !== null)
|
|
129
613
|
stop(`exit ${String(code)}${signal === null ? '' : ` signal ${signal}`}`);
|
|
614
|
+
else if (!closed) {
|
|
615
|
+
exitPipeTimer = setTimeout(() => {
|
|
616
|
+
if (!closed)
|
|
617
|
+
stop('owned command pipes did not close after direct child exit');
|
|
618
|
+
}, 1_000);
|
|
619
|
+
}
|
|
130
620
|
});
|
|
621
|
+
if (resourceBounds !== undefined) {
|
|
622
|
+
child.once('spawn', () => {
|
|
623
|
+
const sample = () => {
|
|
624
|
+
if (failure !== undefined || child.pid === undefined)
|
|
625
|
+
return;
|
|
626
|
+
try {
|
|
627
|
+
if (owned === undefined)
|
|
628
|
+
throw new DeliveryError('Owned process identity is unavailable.');
|
|
629
|
+
const result = sampleOwnedTree(owned, repoRoot, resourceBounds, baseline);
|
|
630
|
+
onResourceSample?.(result);
|
|
631
|
+
onRunning?.(outputBytes, result);
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
stop(error instanceof Error ? error.message : String(error));
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
sample();
|
|
638
|
+
resourceTimer = setInterval(sample, 1_000);
|
|
639
|
+
resourceTimer.unref();
|
|
640
|
+
});
|
|
641
|
+
}
|
|
131
642
|
const progressTimer = setInterval(() => onRunning?.(outputBytes), 5_000);
|
|
132
643
|
progressTimer.unref();
|
|
133
644
|
const onAbort = () => stop('cancelled');
|
|
@@ -138,17 +649,49 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning) {
|
|
|
138
649
|
process.once('SIGTERM', onSigterm);
|
|
139
650
|
if (abortSignal?.aborted)
|
|
140
651
|
onAbort();
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
652
|
+
const handleClose = async (code, signal) => {
|
|
653
|
+
closed = true;
|
|
654
|
+
if (settled)
|
|
655
|
+
return;
|
|
656
|
+
clearWatchers();
|
|
657
|
+
if (failure === undefined && owned !== undefined) {
|
|
658
|
+
try {
|
|
659
|
+
if (!owned.sampled)
|
|
660
|
+
throw new DeliveryError('Owned process resource observation was unavailable.');
|
|
661
|
+
if (resourceBounds !== undefined) {
|
|
662
|
+
const result = sampleOwnedTree(owned, repoRoot, resourceBounds, baseline);
|
|
663
|
+
onResourceSample?.(result);
|
|
664
|
+
}
|
|
665
|
+
const remaining = observeOwnedProcesses(owned, processSnapshot());
|
|
666
|
+
if (remaining.length > 0)
|
|
667
|
+
throw new DeliveryError('A sampled owned descendant survived command exit.');
|
|
668
|
+
}
|
|
669
|
+
catch (error) {
|
|
670
|
+
stop(error instanceof Error ? error.message : String(error));
|
|
671
|
+
}
|
|
672
|
+
}
|
|
146
673
|
if (failure !== undefined || code !== 0 || signal !== null) {
|
|
147
674
|
const reason = failure ?? `exit ${String(code)}${signal === null ? '' : ` signal ${signal}`}`;
|
|
675
|
+
if (owned !== undefined) {
|
|
676
|
+
try {
|
|
677
|
+
await confirmOwnedCleanup(owned);
|
|
678
|
+
}
|
|
679
|
+
catch (error) {
|
|
680
|
+
cleanupFailure ??= error instanceof Error ? error.message : String(error);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
settled = true;
|
|
148
684
|
reject(new DeliveryError(`Selected policy stage command failed (${reason}).${cleanupFailure === undefined ? '' : ` Owned process cleanup failed (${cleanupFailure}).`}`));
|
|
149
685
|
return;
|
|
150
686
|
}
|
|
687
|
+
settled = true;
|
|
151
688
|
resolve(Buffer.concat([...stdout, ...stderr], outputBytes));
|
|
689
|
+
};
|
|
690
|
+
child.once('close', (code, signal) => {
|
|
691
|
+
void handleClose(code, signal).catch((error) => {
|
|
692
|
+
settled = true;
|
|
693
|
+
reject(error);
|
|
694
|
+
});
|
|
152
695
|
});
|
|
153
696
|
});
|
|
154
697
|
}
|
|
@@ -159,6 +702,8 @@ export async function verifyIssue(input) {
|
|
|
159
702
|
};
|
|
160
703
|
assertNotCancelled();
|
|
161
704
|
const root = gitRoot(input.repoRoot);
|
|
705
|
+
if (input.resourceBounds !== undefined)
|
|
706
|
+
assertResourceBounds(input.resourceBounds);
|
|
162
707
|
assertRegisteredIssue(root, input.issueNumber);
|
|
163
708
|
const common = gitCommonDir(root);
|
|
164
709
|
const loaded = await loadDeliveryConfig(root, input.personalAuth === undefined ? {} : { personalAuth: input.personalAuth });
|
|
@@ -178,11 +723,59 @@ export async function verifyIssue(input) {
|
|
|
178
723
|
repoRoot: root,
|
|
179
724
|
repository: loaded.config.repository,
|
|
180
725
|
});
|
|
726
|
+
const baseline = input.resourceBounds === undefined
|
|
727
|
+
? undefined
|
|
728
|
+
: outputBaseline(root, common, classification.receiptId, input.resourceBounds);
|
|
729
|
+
if (input.resourceBounds !== undefined)
|
|
730
|
+
assertDiskHeadroom(root, input.resourceBounds, baseline);
|
|
181
731
|
const receipts = [];
|
|
182
732
|
const admitted = new Set(input.admittedResourceClasses ?? ['source_only']);
|
|
183
|
-
const environmentDigest = stageEnvironmentDigest();
|
|
184
733
|
const startedAtMs = Date.now();
|
|
185
734
|
let reusedStages = 0;
|
|
735
|
+
const normalizedBounds = input.resourceBounds === undefined
|
|
736
|
+
? undefined
|
|
737
|
+
: {
|
|
738
|
+
maxAggregateRssBytes: input.resourceBounds.maxAggregateRssBytes,
|
|
739
|
+
minFreeDiskBytes: input.resourceBounds.minFreeDiskBytes,
|
|
740
|
+
...(input.resourceBounds.maxNewOutputBytes === undefined
|
|
741
|
+
? {}
|
|
742
|
+
: { maxNewOutputBytes: input.resourceBounds.maxNewOutputBytes }),
|
|
743
|
+
...(baseline === undefined ? {} : { outputRoots: baseline.roots }),
|
|
744
|
+
};
|
|
745
|
+
const emptyResources = () => ({
|
|
746
|
+
bounds: normalizedBounds,
|
|
747
|
+
maxSampledAggregateRssBytes: null,
|
|
748
|
+
maxSampledNewOutputBytes: null,
|
|
749
|
+
minSampledFreeDiskBytes: null,
|
|
750
|
+
observation: 'sampled',
|
|
751
|
+
...(baseline === undefined ? {} : { outputBaselineId: baseline.baselineId }),
|
|
752
|
+
processCoverage: 'observed-processes-only',
|
|
753
|
+
sampleCount: 0,
|
|
754
|
+
});
|
|
755
|
+
const resources = normalizedBounds === undefined ? undefined : emptyResources();
|
|
756
|
+
const environmentDigest = normalizedBounds === undefined
|
|
757
|
+
? stageEnvironmentDigest()
|
|
758
|
+
: digestValue({
|
|
759
|
+
environment: stageEnvironmentDigest(),
|
|
760
|
+
...(baseline === undefined ? {} : { outputBaselineId: baseline.baselineId }),
|
|
761
|
+
resourceBounds: normalizedBounds,
|
|
762
|
+
resourceRunner: digestBytes(readFileSync(fileURLToPath(import.meta.url))),
|
|
763
|
+
});
|
|
764
|
+
let stageResources;
|
|
765
|
+
const recordResourceSample = (sample) => {
|
|
766
|
+
if (resources === undefined)
|
|
767
|
+
return;
|
|
768
|
+
const measured = {
|
|
769
|
+
...emptyResources(),
|
|
770
|
+
sampleCount: 1,
|
|
771
|
+
maxSampledAggregateRssBytes: sample.aggregateRssBytes,
|
|
772
|
+
maxSampledNewOutputBytes: sample.newOutputBytes ?? null,
|
|
773
|
+
minSampledFreeDiskBytes: sample.freeDiskBytes,
|
|
774
|
+
};
|
|
775
|
+
mergeResourceSummary(resources, measured);
|
|
776
|
+
if (stageResources !== undefined)
|
|
777
|
+
mergeResourceSummary(stageResources, measured);
|
|
778
|
+
};
|
|
186
779
|
for (const stage of classification.requiredStages) {
|
|
187
780
|
assertNotCancelled();
|
|
188
781
|
if (!admitted.has(stage.resourceClass)) {
|
|
@@ -203,7 +796,7 @@ export async function verifyIssue(input) {
|
|
|
203
796
|
return { receiptId: receipt.receiptId, stageId };
|
|
204
797
|
}),
|
|
205
798
|
});
|
|
206
|
-
|
|
799
|
+
let cached = loadRepositoryStageCheckpoint({
|
|
207
800
|
classification,
|
|
208
801
|
configDigest: loaded.configDigest,
|
|
209
802
|
gitCommonDir: common,
|
|
@@ -211,6 +804,29 @@ export async function verifyIssue(input) {
|
|
|
211
804
|
repoRoot: root,
|
|
212
805
|
stageInput,
|
|
213
806
|
});
|
|
807
|
+
if (normalizedBounds !== undefined) {
|
|
808
|
+
const measured = loadResourceStageCheckpoint(common, root, stageInput, normalizedBounds, baseline?.baselineId);
|
|
809
|
+
if (measured !== undefined) {
|
|
810
|
+
if (cached === undefined) {
|
|
811
|
+
writeRepositoryStageCheckpoint({ gitCommonDir: common, receipt: measured.receipt, repoRoot: root });
|
|
812
|
+
cached = loadRepositoryStageCheckpoint({
|
|
813
|
+
classification,
|
|
814
|
+
configDigest: loaded.configDigest,
|
|
815
|
+
gitCommonDir: common,
|
|
816
|
+
policySourcePath: loaded.config.policy.module,
|
|
817
|
+
repoRoot: root,
|
|
818
|
+
stageInput,
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
if (cached?.receiptId !== measured.receipt.receiptId) {
|
|
822
|
+
throw new DeliveryError('Resource stage checkpoint disagrees with its stage receipt.');
|
|
823
|
+
}
|
|
824
|
+
mergeResourceSummary(resources, measured.resources);
|
|
825
|
+
}
|
|
826
|
+
else if (cached !== undefined) {
|
|
827
|
+
throw new DeliveryError('Bounded stage receipt lacks its resource checkpoint.');
|
|
828
|
+
}
|
|
829
|
+
}
|
|
214
830
|
if (cached) {
|
|
215
831
|
receipts.push(cached);
|
|
216
832
|
reusedStages += 1;
|
|
@@ -225,10 +841,13 @@ export async function verifyIssue(input) {
|
|
|
225
841
|
continue;
|
|
226
842
|
}
|
|
227
843
|
assertNotCancelled();
|
|
844
|
+
stageResources = normalizedBounds === undefined ? undefined : emptyResources();
|
|
228
845
|
const startedAt = new Date().toISOString();
|
|
229
846
|
const commands = [];
|
|
230
847
|
for (const command of stage.commands) {
|
|
231
|
-
|
|
848
|
+
if (input.resourceBounds !== undefined)
|
|
849
|
+
assertDiskHeadroom(root, input.resourceBounds, baseline);
|
|
850
|
+
const progress = (capturedOutputBytes, sample) => reportVerificationProgress({
|
|
232
851
|
state: 'running',
|
|
233
852
|
stageId: stage.id,
|
|
234
853
|
commandLabel: command.label,
|
|
@@ -237,11 +856,19 @@ export async function verifyIssue(input) {
|
|
|
237
856
|
reusedStages,
|
|
238
857
|
elapsedMs: Date.now() - startedAtMs,
|
|
239
858
|
capturedOutputBytes,
|
|
859
|
+
...(sample === undefined
|
|
860
|
+
? {}
|
|
861
|
+
: {
|
|
862
|
+
sampledAggregateRssBytes: sample.aggregateRssBytes,
|
|
863
|
+
sampledFreeDiskBytes: sample.freeDiskBytes,
|
|
864
|
+
...(sample.newOutputBytes === undefined ? {} : { sampledNewOutputBytes: sample.newOutputBytes }),
|
|
865
|
+
ownedProcessCount: sample.ownedProcessCount,
|
|
866
|
+
}),
|
|
240
867
|
});
|
|
241
868
|
progress(0);
|
|
242
869
|
let bytes;
|
|
243
870
|
try {
|
|
244
|
-
bytes = await runStageCommand(root, command.argv, input.signal, progress);
|
|
871
|
+
bytes = await runStageCommand(root, command.argv, input.signal, progress, input.resourceBounds, baseline, recordResourceSample);
|
|
245
872
|
}
|
|
246
873
|
catch (error) {
|
|
247
874
|
reportVerificationProgress({
|
|
@@ -280,7 +907,10 @@ export async function verifyIssue(input) {
|
|
|
280
907
|
startedAt,
|
|
281
908
|
});
|
|
282
909
|
assertNotCancelled();
|
|
910
|
+
if (stageResources !== undefined)
|
|
911
|
+
writeResourceStageCheckpoint(common, receipt, stageResources);
|
|
283
912
|
writeRepositoryStageCheckpoint({ gitCommonDir: common, receipt, repoRoot: root });
|
|
913
|
+
stageResources = undefined;
|
|
284
914
|
receipts.push(receipt);
|
|
285
915
|
reportVerificationProgress({
|
|
286
916
|
state: 'completed',
|
|
@@ -298,7 +928,8 @@ export async function verifyIssue(input) {
|
|
|
298
928
|
aggregate,
|
|
299
929
|
classification,
|
|
300
930
|
completedAt: new Date().toISOString(),
|
|
301
|
-
|
|
931
|
+
...(resources === undefined ? {} : { resources: ResourceSummarySchema.parse(resources) }),
|
|
932
|
+
schemaVersion: resources === undefined ? 'ai-delivery.run@1' : 'ai-delivery.run@2',
|
|
302
933
|
stageReceipts: receipts,
|
|
303
934
|
};
|
|
304
935
|
const run = { ...content, manifestId: digestValue(content) };
|