@aviaratech/ai-delivery 0.3.0 → 0.3.1

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.
@@ -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.schemaVersion !== 'ai-delivery.run@1' ||
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
- try {
90
- if (process.platform === 'win32') {
91
- if (!child.kill('SIGKILL') && child.exitCode === null && child.signalCode === null)
92
- cleanupFailure = 'could not signal the owned child';
93
- else
94
- cleanupFailure = undefined;
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
- else {
97
- process.kill(-child.pid, 'SIGKILL');
98
- cleanupFailure = undefined;
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
- catch (error) {
102
- if (error.code === 'ESRCH')
103
- cleanupFailure = undefined;
104
- else
105
- cleanupFailure = error instanceof Error ? error.message : String(error);
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
- child.once('close', (code, signal) => {
142
- clearInterval(progressTimer);
143
- abortSignal?.removeEventListener('abort', onAbort);
144
- process.removeListener('SIGINT', onSigint);
145
- process.removeListener('SIGTERM', onSigterm);
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
- const cached = loadRepositoryStageCheckpoint({
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
- const progress = (capturedOutputBytes) => reportVerificationProgress({
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
- schemaVersion: 'ai-delivery.run@1',
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) };