@aviaratech/ai-delivery 0.3.3 → 0.3.4
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/CONTRIBUTING.md +36 -9
- package/README.md +48 -3
- package/dist/cli.js +1 -1
- package/dist/config/deliveryConfig.d.ts +4 -1
- package/dist/config/deliveryConfig.js +5 -2
- package/dist/config/deliveryConfig.js.map +1 -1
- package/dist/delivery/common.d.ts +1 -1
- package/dist/delivery/common.js +39 -15
- package/dist/delivery/common.js.map +1 -1
- package/dist/delivery/delivery.test.js +251 -2
- package/dist/delivery/delivery.test.js.map +1 -1
- package/dist/delivery/policy.d.ts +19 -4
- package/dist/delivery/policy.js +18 -5
- package/dist/delivery/policy.js.map +1 -1
- package/dist/delivery/stage.d.ts +56 -8
- package/dist/delivery/stage.js +60 -30
- package/dist/delivery/stage.js.map +1 -1
- package/dist/dispatch.js +67 -4
- package/dist/dispatch.js.map +1 -1
- package/dist/fixtures/discovery.d.ts +1 -1
- package/dist/lifecycle.test.js +737 -39
- package/dist/lifecycle.test.js.map +1 -1
- package/dist/pr.d.ts +1 -1
- package/dist/utils/atomicJson.js +11 -2
- package/dist/utils/atomicJson.js.map +1 -1
- package/dist/verification.d.ts +5 -1
- package/dist/verification.js +391 -109
- package/dist/verification.js.map +1 -1
- package/package.json +1 -1
- package/plugins/ai-delivery/.claude-plugin/plugin.json +1 -1
package/dist/verification.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { existsSync, unlinkSync, lstatSync, readFileSync, readdirSync, statfsSync } from 'node:fs';
|
|
3
5
|
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
4
6
|
import { fileURLToPath } from 'node:url';
|
|
5
7
|
import { z } from 'zod';
|
|
6
8
|
import { loadDeliveryConfig } from './config/deliveryConfig.js';
|
|
7
|
-
import {
|
|
9
|
+
import { assertPrivateFile } from './delivery/common.js';
|
|
10
|
+
import { classifyRepositoryExactRange, assertRepositoryClassificationCurrent, createRepositoryDeliveryEvidence, createRepositoryStageAggregate, createRepositoryStageInput, createRepositoryStageReceipt, digestBytes, digestValue, assertRepositoryStageProof, loadRepositoryStageCheckpoint, loadSelectedRepositoryPolicy, RepositoryClassificationReceiptSchema, RepositoryStageAggregateSchema, RepositoryStageReceiptSchema, writeRepositoryCommandOutput, writeRepositoryDeliveryEvidence, writeRepositoryStageAggregate, writeRepositoryStageCheckpoint, } from './delivery/index.js';
|
|
8
11
|
import { DeliveryError } from './errors.js';
|
|
9
12
|
import { assertClean, changedPaths, coordinate, defaultBaseRef, git, gitCommonDir, gitRoot } from './git.js';
|
|
10
13
|
import { getIssueWorktreeStrict } from './services/worktreeRegistry.js';
|
|
11
|
-
import {
|
|
14
|
+
import { withLock } from './utils/lockfile.js';
|
|
15
|
+
import { ensurePrivateDirectoryDurably, writePrivateJsonFileAtomically } from './utils/atomicJson.js';
|
|
12
16
|
const ResourceSummarySchema = z
|
|
13
17
|
.strictObject({
|
|
14
18
|
bounds: z.strictObject({
|
|
@@ -418,6 +422,144 @@ async function confirmOwnedCleanup(state) {
|
|
|
418
422
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
419
423
|
}
|
|
420
424
|
}
|
|
425
|
+
/** Signal only birth identities established by this command, including observed detached children. */
|
|
426
|
+
function terminateOwnedProcesses(state) {
|
|
427
|
+
if (!state.sampled || state.rootIdentity === undefined)
|
|
428
|
+
throw new DeliveryError('Owned process identity is unknown.');
|
|
429
|
+
const snapshot = processSnapshot();
|
|
430
|
+
const root = snapshot.get(state.rootPid);
|
|
431
|
+
if (root !== undefined && root.identity !== state.rootIdentity)
|
|
432
|
+
throw new DeliveryError('Owned process group identity changed before cleanup.');
|
|
433
|
+
const alive = [...state.tracked.values()].flatMap((previous) => {
|
|
434
|
+
const current = snapshot.get(previous.pid);
|
|
435
|
+
if (current === undefined || current.status.startsWith('Z'))
|
|
436
|
+
return [];
|
|
437
|
+
if (current.identity !== previous.identity)
|
|
438
|
+
throw new DeliveryError('Tracked process identity changed before cleanup.');
|
|
439
|
+
return [current];
|
|
440
|
+
});
|
|
441
|
+
const group = [...snapshot.values()].filter((member) => member.pgid === state.rootPid && !member.status.startsWith('Z'));
|
|
442
|
+
if (group.length > 0 && !alive.some((member) => member.pgid === state.rootPid))
|
|
443
|
+
throw new DeliveryError('Owned process group has no surviving recorded identity; cleanup is ambiguous.');
|
|
444
|
+
const signal = (pid) => {
|
|
445
|
+
try {
|
|
446
|
+
process.kill(pid, 'SIGKILL');
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
if (error.code !== 'ESRCH')
|
|
450
|
+
throw error;
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
if (group.length > 0)
|
|
454
|
+
signal(-state.rootPid);
|
|
455
|
+
for (const member of alive.reverse())
|
|
456
|
+
signal(member.pid);
|
|
457
|
+
}
|
|
458
|
+
const ProcessIdentitySchema = z.strictObject({ pid: z.number().int().positive(), identity: z.string().min(1) });
|
|
459
|
+
const WriterStateSchema = z
|
|
460
|
+
.strictObject({
|
|
461
|
+
schemaVersion: z.literal('ai-delivery.verification-writer@1'),
|
|
462
|
+
writerId: z.uuid(),
|
|
463
|
+
worktreeDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/u),
|
|
464
|
+
owner: ProcessIdentitySchema,
|
|
465
|
+
command: z.union([
|
|
466
|
+
z.strictObject({ phase: z.literal('idle') }),
|
|
467
|
+
z.strictObject({ phase: z.literal('starting') }),
|
|
468
|
+
z.strictObject({
|
|
469
|
+
phase: z.literal('running'),
|
|
470
|
+
root: ProcessIdentitySchema,
|
|
471
|
+
tracked: z.array(z.strictObject({ ...ProcessIdentitySchema.shape, pgid: z.number().int().positive() })),
|
|
472
|
+
}),
|
|
473
|
+
]),
|
|
474
|
+
stateId: z.string().regex(/^sha256:[a-f0-9]{64}$/u),
|
|
475
|
+
})
|
|
476
|
+
.superRefine((state, ctx) => {
|
|
477
|
+
const { stateId, ...content } = state;
|
|
478
|
+
if (stateId !== digestValue(content))
|
|
479
|
+
ctx.addIssue({ code: 'custom', message: 'Writer state is corrupt.' });
|
|
480
|
+
});
|
|
481
|
+
async function withVerificationWriter(root, operation) {
|
|
482
|
+
const digest = worktreeDigest(root);
|
|
483
|
+
const path = join(gitCommonDir(root), 'ai-delivery', 'writers@1', `${digest.slice(7)}.json`);
|
|
484
|
+
ensurePrivateDirectoryDurably(dirname(path));
|
|
485
|
+
return withLock(path, {
|
|
486
|
+
projectRoot: root,
|
|
487
|
+
timeout: 200,
|
|
488
|
+
operation: async () => {
|
|
489
|
+
const snapshot = processSnapshot();
|
|
490
|
+
const owner = snapshot.get(process.pid);
|
|
491
|
+
if (owner === undefined || owner.status.startsWith('Z'))
|
|
492
|
+
throw new DeliveryError('Verification writer identity is unknown.');
|
|
493
|
+
if (existsSync(path)) {
|
|
494
|
+
const previous = WriterStateSchema.parse(JSON.parse(assertPrivateFile(path).toString('utf8')));
|
|
495
|
+
if (previous.worktreeDigest !== digest)
|
|
496
|
+
throw new DeliveryError('Verification writer belongs to another worktree.');
|
|
497
|
+
const priorOwner = snapshot.get(previous.owner.pid);
|
|
498
|
+
if (priorOwner?.identity === previous.owner.identity && !priorOwner.status.startsWith('Z'))
|
|
499
|
+
throw new DeliveryError('A live verification writer still owns this worktree.');
|
|
500
|
+
if (previous.command.phase === 'starting')
|
|
501
|
+
throw new DeliveryError('Interrupted writer has unknown command ownership; reconcile before retrying.');
|
|
502
|
+
if (previous.command.phase === 'running') {
|
|
503
|
+
const state = {
|
|
504
|
+
rootPid: previous.command.root.pid,
|
|
505
|
+
rootIdentity: previous.command.root.identity,
|
|
506
|
+
sampled: true,
|
|
507
|
+
tracked: new Map(previous.command.tracked.map((member) => [member.pid, { ...member, ppid: 0, rssBytes: 0, status: '' }])),
|
|
508
|
+
};
|
|
509
|
+
terminateOwnedProcesses(state);
|
|
510
|
+
await confirmOwnedCleanup(state);
|
|
511
|
+
reportVerificationProgress({
|
|
512
|
+
state: 'recovered',
|
|
513
|
+
stageId: 'writer',
|
|
514
|
+
completedStages: 0,
|
|
515
|
+
reusedStages: 0,
|
|
516
|
+
remainingStages: 0,
|
|
517
|
+
elapsedMs: 0,
|
|
518
|
+
reason: 'Recorded interrupted command cleanup confirmed; compatible completed stages remain available.',
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
let state = {
|
|
523
|
+
schemaVersion: 'ai-delivery.verification-writer@1',
|
|
524
|
+
writerId: randomUUID(),
|
|
525
|
+
worktreeDigest: digest,
|
|
526
|
+
owner: { pid: owner.pid, identity: owner.identity },
|
|
527
|
+
command: { phase: 'idle' },
|
|
528
|
+
};
|
|
529
|
+
const persist = (command) => {
|
|
530
|
+
state = { ...state, command };
|
|
531
|
+
writePrivateJsonFileAtomically(path, { ...state, stateId: digestValue(state) });
|
|
532
|
+
};
|
|
533
|
+
persist({ phase: 'idle' });
|
|
534
|
+
const writer = {
|
|
535
|
+
starting: () => persist({ phase: 'starting' }),
|
|
536
|
+
observed: (owned) => {
|
|
537
|
+
if (owned.rootIdentity === undefined)
|
|
538
|
+
throw new DeliveryError('Cannot persist an unknown command owner.');
|
|
539
|
+
persist({
|
|
540
|
+
phase: 'running',
|
|
541
|
+
root: { pid: owned.rootPid, identity: owned.rootIdentity },
|
|
542
|
+
tracked: [...owned.tracked.values()].map(({ pid, identity, pgid }) => ({ pid, identity, pgid })),
|
|
543
|
+
});
|
|
544
|
+
},
|
|
545
|
+
idle: () => persist({ phase: 'idle' }),
|
|
546
|
+
};
|
|
547
|
+
const releaseWriter = () => {
|
|
548
|
+
const current = WriterStateSchema.parse(JSON.parse(assertPrivateFile(path).toString('utf8')));
|
|
549
|
+
if (current.writerId !== state.writerId)
|
|
550
|
+
throw new DeliveryError('Verification writer ownership changed.');
|
|
551
|
+
if (current.command.phase === 'idle')
|
|
552
|
+
unlinkSync(path);
|
|
553
|
+
};
|
|
554
|
+
try {
|
|
555
|
+
return await operation(writer);
|
|
556
|
+
}
|
|
557
|
+
finally {
|
|
558
|
+
releaseWriter();
|
|
559
|
+
}
|
|
560
|
+
},
|
|
561
|
+
});
|
|
562
|
+
}
|
|
421
563
|
function primaryRoot(worktreeRoot) {
|
|
422
564
|
const common = gitCommonDir(worktreeRoot);
|
|
423
565
|
if (basename(common) !== '.git')
|
|
@@ -433,8 +575,64 @@ function assertRegisteredIssue(worktreeRoot, issueNumber, allowMerged = false) {
|
|
|
433
575
|
}
|
|
434
576
|
assertClean(worktreeRoot);
|
|
435
577
|
}
|
|
436
|
-
function
|
|
437
|
-
return
|
|
578
|
+
function worktreeDigest(root) {
|
|
579
|
+
return digestValue(resolve(root));
|
|
580
|
+
}
|
|
581
|
+
function producerDigest() {
|
|
582
|
+
const root = dirname(fileURLToPath(import.meta.url));
|
|
583
|
+
const code = [];
|
|
584
|
+
const collect = (directory) => {
|
|
585
|
+
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
586
|
+
const path = join(directory, entry.name);
|
|
587
|
+
if (entry.isDirectory())
|
|
588
|
+
collect(path);
|
|
589
|
+
else if (entry.isFile() && entry.name.endsWith('.js') && !entry.name.endsWith('.test.js'))
|
|
590
|
+
code.push({ path: path.slice(root.length + 1), digest: digestBytes(readFileSync(path)) });
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
collect(root);
|
|
594
|
+
const dependencies = new Map();
|
|
595
|
+
const collectDependencies = (manifestPath) => {
|
|
596
|
+
if (dependencies.has(manifestPath))
|
|
597
|
+
return;
|
|
598
|
+
const bytes = readFileSync(manifestPath);
|
|
599
|
+
const manifest = z
|
|
600
|
+
.object({ dependencies: z.record(z.string(), z.string()).optional() })
|
|
601
|
+
.parse(JSON.parse(bytes.toString('utf8')));
|
|
602
|
+
dependencies.set(manifestPath, digestBytes(bytes));
|
|
603
|
+
const require = createRequire(manifestPath);
|
|
604
|
+
for (const name of Object.keys(manifest.dependencies ?? {}).sort()) {
|
|
605
|
+
let resolved;
|
|
606
|
+
try {
|
|
607
|
+
resolved = require.resolve(`${name}/package.json`);
|
|
608
|
+
}
|
|
609
|
+
catch {
|
|
610
|
+
resolved = require.resolve(name);
|
|
611
|
+
}
|
|
612
|
+
let directory = dirname(resolved);
|
|
613
|
+
for (;;) {
|
|
614
|
+
const path = join(directory, 'package.json');
|
|
615
|
+
if (existsSync(path)) {
|
|
616
|
+
const candidate = z.object({ name: z.string().optional() }).parse(JSON.parse(readFileSync(path, 'utf8')));
|
|
617
|
+
if (candidate.name === name) {
|
|
618
|
+
collectDependencies(path);
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
const parent = dirname(directory);
|
|
623
|
+
if (parent === directory)
|
|
624
|
+
throw new DeliveryError(`Producer dependency identity is unavailable for ${name}.`);
|
|
625
|
+
directory = parent;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
collectDependencies(join(dirname(root), 'package.json'));
|
|
630
|
+
return digestValue({ code, dependencies: [...dependencies].sort(([a], [b]) => a.localeCompare(b)) });
|
|
631
|
+
}
|
|
632
|
+
function manifestPath(common, headSha, root) {
|
|
633
|
+
return root === undefined
|
|
634
|
+
? join(common, 'ai-delivery', 'runs', `${headSha}.json`)
|
|
635
|
+
: join(common, 'ai-delivery', 'runs@2', worktreeDigest(root).slice(7), `${headSha}.json`);
|
|
438
636
|
}
|
|
439
637
|
function parseRun(value) {
|
|
440
638
|
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
@@ -443,68 +641,115 @@ function parseRun(value) {
|
|
|
443
641
|
const classification = RepositoryClassificationReceiptSchema.parse(input.classification);
|
|
444
642
|
const aggregate = RepositoryStageAggregateSchema.parse(input.aggregate);
|
|
445
643
|
const stageReceipts = RepositoryStageReceiptSchema.array().parse(input.stageReceipts);
|
|
446
|
-
if (
|
|
644
|
+
if (!['ai-delivery.run@1', 'ai-delivery.run@2', 'ai-delivery.run@3'].includes(String(input.schemaVersion)))
|
|
447
645
|
throw new DeliveryError('Run manifest schema version is unsupported.');
|
|
448
|
-
|
|
449
|
-
if (input.schemaVersion === 'ai-delivery.run@1' && input.resources !== undefined) {
|
|
646
|
+
if (input.schemaVersion === 'ai-delivery.run@1' && input.resources !== undefined)
|
|
450
647
|
throw new DeliveryError('Historical run manifest has unexpected resource evidence.');
|
|
451
|
-
|
|
452
|
-
|
|
648
|
+
const resources = input.resources === undefined ? undefined : ResourceSummarySchema.parse(input.resources);
|
|
649
|
+
if (input.schemaVersion === 'ai-delivery.run@2' && resources === undefined)
|
|
650
|
+
throw new DeliveryError('Historical bounded run lacks resource evidence.');
|
|
651
|
+
const writer = input.schemaVersion === 'ai-delivery.run@3'
|
|
652
|
+
? z
|
|
653
|
+
.strictObject({
|
|
654
|
+
worktreeDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/u),
|
|
655
|
+
producerDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/u),
|
|
656
|
+
})
|
|
657
|
+
.parse(input.writer)
|
|
658
|
+
: undefined;
|
|
453
659
|
const content = {
|
|
454
660
|
aggregate,
|
|
455
661
|
classification,
|
|
456
662
|
completedAt: input.completedAt,
|
|
457
663
|
...(resources === undefined ? {} : { resources }),
|
|
664
|
+
...(writer === undefined ? {} : { writer }),
|
|
458
665
|
schemaVersion: input.schemaVersion,
|
|
459
666
|
stageReceipts,
|
|
460
667
|
};
|
|
461
|
-
if (typeof input.completedAt !== 'string' ||
|
|
462
|
-
|
|
668
|
+
if (typeof input.completedAt !== 'string' ||
|
|
669
|
+
input.manifestId !== digestValue(content) ||
|
|
670
|
+
createRepositoryStageAggregate({ classification, receipts: stageReceipts }).aggregateId !== aggregate.aggregateId)
|
|
671
|
+
throw new DeliveryError('Run manifest identity or aggregate is invalid.');
|
|
463
672
|
return { ...content, completedAt: input.completedAt, manifestId: input.manifestId };
|
|
464
673
|
}
|
|
465
|
-
function loadRun(common, headSha) {
|
|
674
|
+
function loadRun(common, headSha, root, historical = false) {
|
|
466
675
|
let raw;
|
|
467
676
|
try {
|
|
468
|
-
raw = JSON.parse(readFileSync(manifestPath(common, headSha), 'utf8'));
|
|
677
|
+
raw = JSON.parse(readFileSync(manifestPath(common, headSha, root), 'utf8'));
|
|
469
678
|
}
|
|
470
|
-
catch {
|
|
471
|
-
|
|
679
|
+
catch (error) {
|
|
680
|
+
if (!historical || error.code !== 'ENOENT')
|
|
681
|
+
throw new DeliveryError('Exact-head worktree verification run is missing or corrupt; verify with the current producer.');
|
|
682
|
+
raw = JSON.parse(readFileSync(manifestPath(common, headSha), 'utf8'));
|
|
472
683
|
}
|
|
473
|
-
|
|
684
|
+
const run = parseRun(raw);
|
|
685
|
+
if (run.classification.head.sha !== headSha ||
|
|
686
|
+
(run.writer !== undefined &&
|
|
687
|
+
(run.writer.worktreeDigest !== worktreeDigest(root) ||
|
|
688
|
+
(!historical && run.writer.producerDigest !== producerDigest()))) ||
|
|
689
|
+
(!historical && run.writer === undefined))
|
|
690
|
+
throw new DeliveryError('Verification run belongs to another writer or producer.');
|
|
691
|
+
const expectedEnvironment = historical ? undefined : verificationEnvironmentDigest(root, run.resources);
|
|
692
|
+
if (expectedEnvironment !== undefined &&
|
|
693
|
+
run.stageReceipts.some((receipt) => receipt.input.environmentDigest !== expectedEnvironment))
|
|
694
|
+
throw new DeliveryError('Verification stage environment belongs to a foreign worktree or producer.');
|
|
695
|
+
return run;
|
|
474
696
|
}
|
|
475
|
-
function stageEnvironmentDigest() {
|
|
697
|
+
function stageEnvironmentDigest(root, producer = producerDigest()) {
|
|
476
698
|
return digestValue({
|
|
477
699
|
arch: process.arch,
|
|
478
700
|
node: process.version,
|
|
479
701
|
path: process.env.PATH ?? '',
|
|
480
702
|
platform: process.platform,
|
|
703
|
+
worktreeDigest: worktreeDigest(root),
|
|
704
|
+
producerDigest: producer,
|
|
481
705
|
});
|
|
482
706
|
}
|
|
707
|
+
function verificationEnvironmentDigest(root, resources, producer = producerDigest()) {
|
|
708
|
+
const environment = stageEnvironmentDigest(root, producer);
|
|
709
|
+
return resources === undefined
|
|
710
|
+
? environment
|
|
711
|
+
: digestValue({
|
|
712
|
+
environment,
|
|
713
|
+
...(resources.outputBaselineId === undefined ? {} : { outputBaselineId: resources.outputBaselineId }),
|
|
714
|
+
resourceBounds: resources.bounds,
|
|
715
|
+
resourceRunner: digestBytes(readFileSync(fileURLToPath(import.meta.url))),
|
|
716
|
+
});
|
|
717
|
+
}
|
|
483
718
|
function reportVerificationProgress(input) {
|
|
484
719
|
process.stderr.write(`ai-delivery.verify ${JSON.stringify(input)}\n`);
|
|
485
720
|
}
|
|
486
|
-
function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds, baseline, onResourceSample) {
|
|
721
|
+
function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds, baseline, onResourceSample, writer) {
|
|
487
722
|
const [executable, ...args] = argv;
|
|
488
723
|
if (!executable)
|
|
489
724
|
throw new DeliveryError('Policy selected an empty stage command.');
|
|
490
725
|
if (abortSignal?.aborted)
|
|
491
726
|
throw new DeliveryError('Selected policy stage command cancelled.');
|
|
727
|
+
writer?.starting();
|
|
492
728
|
return new Promise((resolve, reject) => {
|
|
493
|
-
|
|
729
|
+
// The shell waits for our ownership receipt, then exec preserves its PID, birth identity and process group.
|
|
730
|
+
const child = spawn('/bin/sh', [
|
|
731
|
+
'-c',
|
|
732
|
+
'IFS= read -r ready || exit 125; [ "$ready" = run ] || exit 125; exec "$@" </dev/null',
|
|
733
|
+
'ai-delivery-command',
|
|
734
|
+
executable,
|
|
735
|
+
...args,
|
|
736
|
+
], {
|
|
494
737
|
cwd: repoRoot,
|
|
495
738
|
detached: process.platform !== 'win32',
|
|
496
|
-
stdio: ['
|
|
739
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
497
740
|
});
|
|
498
741
|
const stdout = [];
|
|
499
742
|
const stderr = [];
|
|
500
|
-
const owned =
|
|
743
|
+
const owned = process.platform === 'win32' || child.pid === undefined
|
|
501
744
|
? undefined
|
|
502
745
|
: { rootPid: child.pid, sampled: false, tracked: new Map() };
|
|
503
746
|
let outputBytes = 0;
|
|
504
747
|
let failure;
|
|
505
748
|
let cleanupFailure;
|
|
749
|
+
let writerFailure;
|
|
506
750
|
let closed = false;
|
|
507
751
|
let settled = false;
|
|
752
|
+
let commandReleased = false;
|
|
508
753
|
let closeTimer;
|
|
509
754
|
let exitPipeTimer;
|
|
510
755
|
let resourceTimer;
|
|
@@ -523,57 +768,37 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
|
|
|
523
768
|
const killOwned = () => {
|
|
524
769
|
if (child.pid === undefined)
|
|
525
770
|
return;
|
|
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
771
|
if (owned?.sampled) {
|
|
533
772
|
try {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
}
|
|
773
|
+
observeOwnedProcesses(owned, processSnapshot());
|
|
774
|
+
}
|
|
775
|
+
catch (error) {
|
|
776
|
+
cleanupFailure ??= error instanceof Error ? error.message : String(error);
|
|
777
|
+
}
|
|
778
|
+
try {
|
|
779
|
+
writer?.observed(owned);
|
|
780
|
+
}
|
|
781
|
+
catch (error) {
|
|
782
|
+
writerFailure ??= error instanceof Error ? error.message : String(error);
|
|
783
|
+
}
|
|
784
|
+
// Durable state failure cannot prevent identity-checked termination of known-owned processes.
|
|
785
|
+
try {
|
|
786
|
+
terminateOwnedProcesses(owned);
|
|
559
787
|
}
|
|
560
788
|
catch (error) {
|
|
561
789
|
cleanupFailure ??= error instanceof Error ? error.message : String(error);
|
|
562
|
-
groupIsOwned = child.exitCode === null && child.signalCode === null;
|
|
563
790
|
}
|
|
564
791
|
}
|
|
565
|
-
else
|
|
566
|
-
groupIsOwned = child.exitCode === null && child.signalCode === null;
|
|
567
|
-
cleanupFailure ??= 'owned process identity was not observed before cleanup';
|
|
568
|
-
}
|
|
569
|
-
if (groupIsOwned) {
|
|
792
|
+
else {
|
|
570
793
|
try {
|
|
571
|
-
|
|
794
|
+
// The still-owned direct child is the only safe signal target before identity observation.
|
|
795
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
796
|
+
child.kill('SIGKILL');
|
|
797
|
+
if (commandReleased)
|
|
798
|
+
cleanupFailure ??= 'owned process identity was not observed before cleanup';
|
|
572
799
|
}
|
|
573
800
|
catch (error) {
|
|
574
|
-
|
|
575
|
-
cleanupFailure ??= error instanceof Error ? error.message : String(error);
|
|
576
|
-
}
|
|
801
|
+
cleanupFailure ??= error instanceof Error ? error.message : String(error);
|
|
577
802
|
}
|
|
578
803
|
}
|
|
579
804
|
};
|
|
@@ -591,7 +816,7 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
|
|
|
591
816
|
child.stdout.destroy();
|
|
592
817
|
child.stderr.destroy();
|
|
593
818
|
child.unref();
|
|
594
|
-
reject(new DeliveryError(`Selected policy stage command failed (${failure})
|
|
819
|
+
reject(new DeliveryError(`Selected policy stage command failed (${failure}).${writerFailure === undefined ? '' : ` Writer state persistence failed (${writerFailure}).`} Owned process cleanup failed (owned command pipes did not close; descendant cleanup could not be verified).`));
|
|
595
820
|
}, 2_000);
|
|
596
821
|
}
|
|
597
822
|
};
|
|
@@ -607,7 +832,12 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
|
|
|
607
832
|
child.stderr.on('data', (chunk) => capture(stderr, chunk));
|
|
608
833
|
child.stdout.once('error', (error) => stop(error.message));
|
|
609
834
|
child.stderr.once('error', (error) => stop(error.message));
|
|
610
|
-
child.once('error', (error) => stop(error.message));
|
|
835
|
+
child.stdin.once('error', (error) => stop(error.message));
|
|
836
|
+
child.once('error', (error) => {
|
|
837
|
+
if (child.pid === undefined)
|
|
838
|
+
writer?.idle();
|
|
839
|
+
stop(error.message);
|
|
840
|
+
});
|
|
611
841
|
child.once('exit', (code, signal) => {
|
|
612
842
|
if (code !== 0 || signal !== null)
|
|
613
843
|
stop(`exit ${String(code)}${signal === null ? '' : ` signal ${signal}`}`);
|
|
@@ -618,27 +848,33 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
|
|
|
618
848
|
}, 1_000);
|
|
619
849
|
}
|
|
620
850
|
});
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
851
|
+
child.once('spawn', () => {
|
|
852
|
+
const sample = () => {
|
|
853
|
+
if (failure !== undefined || child.pid === undefined)
|
|
854
|
+
return;
|
|
855
|
+
try {
|
|
856
|
+
if (owned === undefined)
|
|
857
|
+
throw new DeliveryError('Owned process identity is unavailable on this platform.');
|
|
858
|
+
observeOwnedProcesses(owned, processSnapshot());
|
|
859
|
+
writer?.observed(owned);
|
|
860
|
+
if (resourceBounds !== undefined) {
|
|
629
861
|
const result = sampleOwnedTree(owned, repoRoot, resourceBounds, baseline);
|
|
630
862
|
onResourceSample?.(result);
|
|
631
863
|
onRunning?.(outputBytes, result);
|
|
632
864
|
}
|
|
633
|
-
|
|
634
|
-
|
|
865
|
+
if (!commandReleased) {
|
|
866
|
+
commandReleased = true;
|
|
867
|
+
child.stdin.end('run\n');
|
|
635
868
|
}
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
}
|
|
641
|
-
|
|
869
|
+
}
|
|
870
|
+
catch (error) {
|
|
871
|
+
stop(error instanceof Error ? error.message : String(error));
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
sample();
|
|
875
|
+
resourceTimer = setInterval(sample, 1_000);
|
|
876
|
+
resourceTimer.unref();
|
|
877
|
+
});
|
|
642
878
|
const progressTimer = setInterval(() => onRunning?.(outputBytes), 5_000);
|
|
643
879
|
progressTimer.unref();
|
|
644
880
|
const onAbort = () => stop('cancelled');
|
|
@@ -656,12 +892,13 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
|
|
|
656
892
|
clearWatchers();
|
|
657
893
|
if (failure === undefined && owned !== undefined) {
|
|
658
894
|
try {
|
|
659
|
-
if (!owned.sampled)
|
|
660
|
-
throw new DeliveryError('Owned process resource observation was unavailable.');
|
|
661
895
|
if (resourceBounds !== undefined) {
|
|
662
|
-
const
|
|
663
|
-
onResourceSample?.(
|
|
896
|
+
const sample = sampleOwnedTree(owned, repoRoot, resourceBounds, baseline);
|
|
897
|
+
onResourceSample?.(sample);
|
|
898
|
+
onRunning?.(outputBytes, sample);
|
|
664
899
|
}
|
|
900
|
+
if (!owned.sampled)
|
|
901
|
+
throw new DeliveryError('Owned process resource observation was unavailable.');
|
|
665
902
|
const remaining = observeOwnedProcesses(owned, processSnapshot());
|
|
666
903
|
if (remaining.length > 0)
|
|
667
904
|
throw new DeliveryError('A sampled owned descendant survived command exit.');
|
|
@@ -672,18 +909,29 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
|
|
|
672
909
|
}
|
|
673
910
|
if (failure !== undefined || code !== 0 || signal !== null) {
|
|
674
911
|
const reason = failure ?? `exit ${String(code)}${signal === null ? '' : ` signal ${signal}`}`;
|
|
675
|
-
|
|
912
|
+
let cleanupConfirmed = child.pid === undefined || !commandReleased;
|
|
913
|
+
if (owned !== undefined && owned.sampled) {
|
|
676
914
|
try {
|
|
677
915
|
await confirmOwnedCleanup(owned);
|
|
916
|
+
cleanupConfirmed = true;
|
|
678
917
|
}
|
|
679
918
|
catch (error) {
|
|
680
919
|
cleanupFailure ??= error instanceof Error ? error.message : String(error);
|
|
681
920
|
}
|
|
682
921
|
}
|
|
922
|
+
if (cleanupConfirmed) {
|
|
923
|
+
try {
|
|
924
|
+
writer?.idle();
|
|
925
|
+
}
|
|
926
|
+
catch (error) {
|
|
927
|
+
writerFailure ??= error instanceof Error ? error.message : String(error);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
683
930
|
settled = true;
|
|
684
|
-
reject(new DeliveryError(`Selected policy stage command failed (${reason}).${cleanupFailure === undefined ? '' : ` Owned process cleanup failed (${cleanupFailure}).`}`));
|
|
931
|
+
reject(new DeliveryError(`Selected policy stage command failed (${reason}).${writerFailure === undefined ? '' : ` Writer state persistence failed (${writerFailure}).`}${cleanupFailure === undefined ? '' : ` Owned process cleanup failed (${cleanupFailure}).`}`));
|
|
685
932
|
return;
|
|
686
933
|
}
|
|
934
|
+
writer?.idle();
|
|
687
935
|
settled = true;
|
|
688
936
|
resolve(Buffer.concat([...stdout, ...stderr], outputBytes));
|
|
689
937
|
};
|
|
@@ -696,6 +944,10 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
|
|
|
696
944
|
});
|
|
697
945
|
}
|
|
698
946
|
export async function verifyIssue(input) {
|
|
947
|
+
const root = gitRoot(input.repoRoot);
|
|
948
|
+
return withVerificationWriter(root, (writer) => verifyIssueOwned(input, writer));
|
|
949
|
+
}
|
|
950
|
+
async function verifyIssueOwned(input, writer) {
|
|
699
951
|
const assertNotCancelled = () => {
|
|
700
952
|
if (input.signal?.aborted)
|
|
701
953
|
throw new DeliveryError('Verification cancelled.');
|
|
@@ -732,6 +984,21 @@ export async function verifyIssue(input) {
|
|
|
732
984
|
const admitted = new Set(input.admittedResourceClasses ?? ['source_only']);
|
|
733
985
|
const startedAtMs = Date.now();
|
|
734
986
|
let reusedStages = 0;
|
|
987
|
+
let completedCommands = 0;
|
|
988
|
+
let reusedCommands = 0;
|
|
989
|
+
let lastCompletedAtMs = startedAtMs;
|
|
990
|
+
const totalCommands = classification.requiredStages.reduce((total, stage) => total + stage.commands.length, 0);
|
|
991
|
+
const report = (progress) => {
|
|
992
|
+
const elapsedMs = Date.now() - startedAtMs;
|
|
993
|
+
reportVerificationProgress({
|
|
994
|
+
...progress,
|
|
995
|
+
completedCommands,
|
|
996
|
+
remainingCommands: totalCommands - completedCommands,
|
|
997
|
+
reusedCommands,
|
|
998
|
+
executedCommandsPerSecond: elapsedMs === 0 ? 0 : ((completedCommands - reusedCommands) * 1000) / elapsedMs,
|
|
999
|
+
lastCompletedWorkAgeMs: Date.now() - lastCompletedAtMs,
|
|
1000
|
+
});
|
|
1001
|
+
};
|
|
735
1002
|
const normalizedBounds = input.resourceBounds === undefined
|
|
736
1003
|
? undefined
|
|
737
1004
|
: {
|
|
@@ -753,14 +1020,8 @@ export async function verifyIssue(input) {
|
|
|
753
1020
|
sampleCount: 0,
|
|
754
1021
|
});
|
|
755
1022
|
const resources = normalizedBounds === undefined ? undefined : emptyResources();
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
: digestValue({
|
|
759
|
-
environment: stageEnvironmentDigest(),
|
|
760
|
-
...(baseline === undefined ? {} : { outputBaselineId: baseline.baselineId }),
|
|
761
|
-
resourceBounds: normalizedBounds,
|
|
762
|
-
resourceRunner: digestBytes(readFileSync(fileURLToPath(import.meta.url))),
|
|
763
|
-
});
|
|
1023
|
+
const producer = producerDigest();
|
|
1024
|
+
const environmentDigest = verificationEnvironmentDigest(root, resources, producer);
|
|
764
1025
|
let stageResources;
|
|
765
1026
|
const recordResourceSample = (sample) => {
|
|
766
1027
|
if (resources === undefined)
|
|
@@ -784,10 +1045,11 @@ export async function verifyIssue(input) {
|
|
|
784
1045
|
const stageInput = createRepositoryStageInput({
|
|
785
1046
|
classification,
|
|
786
1047
|
environmentDigest,
|
|
787
|
-
semanticInputs: stage.
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
1048
|
+
semanticInputs: stage.semanticInputs ??
|
|
1049
|
+
stage.semanticInputKeys.map((key) => ({
|
|
1050
|
+
digest: digestValue({ key, evidenceId: classification.policyEvidence.evidenceId }),
|
|
1051
|
+
key,
|
|
1052
|
+
})),
|
|
791
1053
|
stageId: stage.id,
|
|
792
1054
|
upstream: stage.dependsOn.map((stageId) => {
|
|
793
1055
|
const receipt = receipts.find((value) => value.input.stageId === stageId);
|
|
@@ -830,7 +1092,10 @@ export async function verifyIssue(input) {
|
|
|
830
1092
|
if (cached) {
|
|
831
1093
|
receipts.push(cached);
|
|
832
1094
|
reusedStages += 1;
|
|
833
|
-
|
|
1095
|
+
completedCommands += cached.commands.length;
|
|
1096
|
+
reusedCommands += cached.commands.length;
|
|
1097
|
+
lastCompletedAtMs = Date.now();
|
|
1098
|
+
report({
|
|
834
1099
|
state: 'reused',
|
|
835
1100
|
stageId: stage.id,
|
|
836
1101
|
completedStages: receipts.length,
|
|
@@ -845,9 +1110,10 @@ export async function verifyIssue(input) {
|
|
|
845
1110
|
const startedAt = new Date().toISOString();
|
|
846
1111
|
const commands = [];
|
|
847
1112
|
for (const command of stage.commands) {
|
|
1113
|
+
const commandStartedAtMs = Date.now();
|
|
848
1114
|
if (input.resourceBounds !== undefined)
|
|
849
1115
|
assertDiskHeadroom(root, input.resourceBounds, baseline);
|
|
850
|
-
const progress = (capturedOutputBytes, sample) =>
|
|
1116
|
+
const progress = (capturedOutputBytes, sample) => report({
|
|
851
1117
|
state: 'running',
|
|
852
1118
|
stageId: stage.id,
|
|
853
1119
|
commandLabel: command.label,
|
|
@@ -856,6 +1122,7 @@ export async function verifyIssue(input) {
|
|
|
856
1122
|
reusedStages,
|
|
857
1123
|
elapsedMs: Date.now() - startedAtMs,
|
|
858
1124
|
capturedOutputBytes,
|
|
1125
|
+
commandElapsedMs: Date.now() - commandStartedAtMs,
|
|
859
1126
|
...(sample === undefined
|
|
860
1127
|
? {}
|
|
861
1128
|
: {
|
|
@@ -868,10 +1135,10 @@ export async function verifyIssue(input) {
|
|
|
868
1135
|
progress(0);
|
|
869
1136
|
let bytes;
|
|
870
1137
|
try {
|
|
871
|
-
bytes = await runStageCommand(root, command.argv, input.signal, progress, input.resourceBounds, baseline, recordResourceSample);
|
|
1138
|
+
bytes = await runStageCommand(root, command.argv, input.signal, progress, input.resourceBounds, baseline, recordResourceSample, writer);
|
|
872
1139
|
}
|
|
873
1140
|
catch (error) {
|
|
874
|
-
|
|
1141
|
+
report({
|
|
875
1142
|
state: 'failed',
|
|
876
1143
|
stageId: stage.id,
|
|
877
1144
|
commandLabel: command.label,
|
|
@@ -884,6 +1151,8 @@ export async function verifyIssue(input) {
|
|
|
884
1151
|
throw error;
|
|
885
1152
|
}
|
|
886
1153
|
assertNotCancelled();
|
|
1154
|
+
completedCommands += 1;
|
|
1155
|
+
lastCompletedAtMs = Date.now();
|
|
887
1156
|
commands.push({
|
|
888
1157
|
exitCode: 0,
|
|
889
1158
|
label: command.label,
|
|
@@ -912,7 +1181,8 @@ export async function verifyIssue(input) {
|
|
|
912
1181
|
writeRepositoryStageCheckpoint({ gitCommonDir: common, receipt, repoRoot: root });
|
|
913
1182
|
stageResources = undefined;
|
|
914
1183
|
receipts.push(receipt);
|
|
915
|
-
|
|
1184
|
+
lastCompletedAtMs = Date.now();
|
|
1185
|
+
report({
|
|
916
1186
|
state: 'completed',
|
|
917
1187
|
stageId: stage.id,
|
|
918
1188
|
completedStages: receipts.length,
|
|
@@ -922,6 +1192,16 @@ export async function verifyIssue(input) {
|
|
|
922
1192
|
});
|
|
923
1193
|
}
|
|
924
1194
|
assertNotCancelled();
|
|
1195
|
+
assertRepositoryClassificationCurrent({
|
|
1196
|
+
classification,
|
|
1197
|
+
configDigest: loaded.configDigest,
|
|
1198
|
+
policySourcePath: loaded.config.policy.module,
|
|
1199
|
+
repoRoot: root,
|
|
1200
|
+
});
|
|
1201
|
+
if (coordinate(root).sha !== head.sha || coordinate(root, defaultBaseRef(root, loaded.remote)).sha !== base.sha)
|
|
1202
|
+
throw new DeliveryError('Verification source changed during execution; classify and resume the current source.');
|
|
1203
|
+
if (producerDigest() !== producer)
|
|
1204
|
+
throw new DeliveryError('Verification producer changed during execution.');
|
|
925
1205
|
const aggregate = createRepositoryStageAggregate({ classification, receipts });
|
|
926
1206
|
writeRepositoryStageAggregate({ gitCommonDir: common, aggregate });
|
|
927
1207
|
const content = {
|
|
@@ -929,11 +1209,12 @@ export async function verifyIssue(input) {
|
|
|
929
1209
|
classification,
|
|
930
1210
|
completedAt: new Date().toISOString(),
|
|
931
1211
|
...(resources === undefined ? {} : { resources: ResourceSummarySchema.parse(resources) }),
|
|
932
|
-
schemaVersion:
|
|
1212
|
+
schemaVersion: 'ai-delivery.run@3',
|
|
1213
|
+
writer: { worktreeDigest: worktreeDigest(root), producerDigest: producer },
|
|
933
1214
|
stageReceipts: receipts,
|
|
934
1215
|
};
|
|
935
1216
|
const run = { ...content, manifestId: digestValue(content) };
|
|
936
|
-
writePrivateJsonFileAtomically(manifestPath(common, head.sha), run);
|
|
1217
|
+
writePrivateJsonFileAtomically(manifestPath(common, head.sha, root), run);
|
|
937
1218
|
return run;
|
|
938
1219
|
}
|
|
939
1220
|
export async function createIssuePhaseEvidence(input) {
|
|
@@ -944,7 +1225,7 @@ export async function createIssuePhaseEvidence(input) {
|
|
|
944
1225
|
repoRoot: root,
|
|
945
1226
|
policySourcePath: loaded.config.policy.module,
|
|
946
1227
|
});
|
|
947
|
-
const run = loadRun(gitCommonDir(root), coordinate(root).sha);
|
|
1228
|
+
const run = loadRun(gitCommonDir(root), coordinate(root).sha, root);
|
|
948
1229
|
const evidence = createRepositoryDeliveryEvidence({
|
|
949
1230
|
aggregate: run.aggregate,
|
|
950
1231
|
...(input.approval === undefined ? {} : { approval: input.approval }),
|
|
@@ -970,7 +1251,8 @@ export async function createIssuePhaseEvidence(input) {
|
|
|
970
1251
|
export function loadVerifiedRun(repoRoot, issueNumber) {
|
|
971
1252
|
const root = gitRoot(repoRoot);
|
|
972
1253
|
assertRegisteredIssue(root, issueNumber, true);
|
|
973
|
-
|
|
1254
|
+
const historical = getIssueWorktreeStrict(issueNumber, primaryRoot(root)).status === 'merged';
|
|
1255
|
+
return loadRun(gitCommonDir(root), coordinate(root).sha, root, historical);
|
|
974
1256
|
}
|
|
975
1257
|
/** Recover an exact merged run after worktree removal interrupted terminal recording. */
|
|
976
1258
|
export function loadRemovedMergedRun(primaryRepoRoot, issueNumber) {
|
|
@@ -980,7 +1262,7 @@ export function loadRemovedMergedRun(primaryRepoRoot, issueNumber) {
|
|
|
980
1262
|
throw new DeliveryError('Removed-run recovery requires the exact registered merged issue.');
|
|
981
1263
|
}
|
|
982
1264
|
const headSha = git(root, 'rev-parse', `refs/heads/${row.branch}`);
|
|
983
|
-
const run = loadRun(gitCommonDir(root), headSha);
|
|
1265
|
+
const run = loadRun(gitCommonDir(root), headSha, row.path, true);
|
|
984
1266
|
if (run.classification.head.sha !== headSha) {
|
|
985
1267
|
throw new DeliveryError('Recovered merged run disagrees with the registered branch.');
|
|
986
1268
|
}
|