@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.
@@ -1,14 +1,18 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
- import { existsSync, lstatSync, readFileSync, readdirSync, statfsSync } from 'node:fs';
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 { classifyRepositoryExactRange, createRepositoryDeliveryEvidence, createRepositoryStageAggregate, createRepositoryStageInput, createRepositoryStageReceipt, digestBytes, digestValue, assertRepositoryStageProof, loadRepositoryStageCheckpoint, loadSelectedRepositoryPolicy, RepositoryClassificationReceiptSchema, RepositoryStageAggregateSchema, RepositoryStageReceiptSchema, writeRepositoryCommandOutput, writeRepositoryDeliveryEvidence, writeRepositoryStageAggregate, writeRepositoryStageCheckpoint, } from './delivery/index.js';
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 { writePrivateJsonFileAtomically } from './utils/atomicJson.js';
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 manifestPath(common, headSha) {
437
- return join(common, 'ai-delivery', 'runs', `${headSha}.json`);
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 (input.schemaVersion !== 'ai-delivery.run@1' && input.schemaVersion !== 'ai-delivery.run@2') {
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
- const resources = input.schemaVersion === 'ai-delivery.run@2' ? ResourceSummarySchema.parse(input.resources) : undefined;
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' || input.manifestId !== digestValue(content))
462
- throw new DeliveryError('Run manifest identity is invalid.');
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
- throw new DeliveryError('Exact-head verification run is missing or corrupt.');
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
- return parseRun(raw);
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
- const child = spawn(executable, args, {
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: ['ignore', 'pipe', 'pipe'],
739
+ stdio: ['pipe', 'pipe', 'pipe'],
497
740
  });
498
741
  const stdout = [];
499
742
  const stderr = [];
500
- const owned = resourceBounds === undefined || child.pid === undefined
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
- 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
- }
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 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) {
792
+ else {
570
793
  try {
571
- process.kill(-child.pid, 'SIGKILL');
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
- if (error.code !== 'ESRCH') {
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}). Owned process cleanup failed (owned command pipes did not close; descendant cleanup could not be verified).`));
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
- 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.');
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
- catch (error) {
634
- stop(error instanceof Error ? error.message : String(error));
865
+ if (!commandReleased) {
866
+ commandReleased = true;
867
+ child.stdin.end('run\n');
635
868
  }
636
- };
637
- sample();
638
- resourceTimer = setInterval(sample, 1_000);
639
- resourceTimer.unref();
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 result = sampleOwnedTree(owned, repoRoot, resourceBounds, baseline);
663
- onResourceSample?.(result);
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
- if (owned !== undefined) {
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 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
- });
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.semanticInputKeys.map((key) => ({
788
- digest: digestValue({ key, evidenceId: classification.policyEvidence.evidenceId }),
789
- key,
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
- reportVerificationProgress({
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) => reportVerificationProgress({
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
- reportVerificationProgress({
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
- reportVerificationProgress({
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: resources === undefined ? 'ai-delivery.run@1' : 'ai-delivery.run@2',
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
- return loadRun(gitCommonDir(root), coordinate(root).sha);
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
  }