@aviaratech/ai-delivery 0.3.3 → 0.3.5

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.
Files changed (60) hide show
  1. package/CONTRIBUTING.md +36 -9
  2. package/README.md +134 -21
  3. package/dist/agent.d.ts +2 -0
  4. package/dist/agent.js +1 -0
  5. package/dist/agent.js.map +1 -1
  6. package/dist/cli.js +43 -2
  7. package/dist/cli.js.map +1 -1
  8. package/dist/config/deliveryConfig.d.ts +4 -1
  9. package/dist/config/deliveryConfig.js +5 -2
  10. package/dist/config/deliveryConfig.js.map +1 -1
  11. package/dist/delivery/common.d.ts +1 -1
  12. package/dist/delivery/common.js +39 -15
  13. package/dist/delivery/common.js.map +1 -1
  14. package/dist/delivery/delivery.test.js +251 -2
  15. package/dist/delivery/delivery.test.js.map +1 -1
  16. package/dist/delivery/policy.d.ts +19 -4
  17. package/dist/delivery/policy.js +18 -5
  18. package/dist/delivery/policy.js.map +1 -1
  19. package/dist/delivery/stage.d.ts +56 -8
  20. package/dist/delivery/stage.js +60 -30
  21. package/dist/delivery/stage.js.map +1 -1
  22. package/dist/dispatch.js +85 -4
  23. package/dist/dispatch.js.map +1 -1
  24. package/dist/fixtures/discovery.d.ts +1 -1
  25. package/dist/github/client.d.ts +1 -0
  26. package/dist/github/client.js +4 -1
  27. package/dist/github/client.js.map +1 -1
  28. package/dist/github/commandIdentityPolicy.js +2 -0
  29. package/dist/github/commandIdentityPolicy.js.map +1 -1
  30. package/dist/issue.js +2 -0
  31. package/dist/issue.js.map +1 -1
  32. package/dist/lifecycle.test.js +1159 -66
  33. package/dist/lifecycle.test.js.map +1 -1
  34. package/dist/mcp/index.js +1 -1
  35. package/dist/mcp/tools.d.ts +32 -0
  36. package/dist/mcp/tools.js +39 -0
  37. package/dist/mcp/tools.js.map +1 -1
  38. package/dist/pr.d.ts +4 -4
  39. package/dist/pr.js +10 -6
  40. package/dist/pr.js.map +1 -1
  41. package/dist/review.test.js +163 -0
  42. package/dist/review.test.js.map +1 -1
  43. package/dist/services/deliveryAdmission.d.ts +13 -2
  44. package/dist/services/deliveryAdmission.js +41 -2
  45. package/dist/services/deliveryAdmission.js.map +1 -1
  46. package/dist/setup.d.ts +58 -0
  47. package/dist/setup.js +415 -0
  48. package/dist/setup.js.map +1 -0
  49. package/dist/setup.test.d.ts +1 -0
  50. package/dist/setup.test.js +744 -0
  51. package/dist/setup.test.js.map +1 -0
  52. package/dist/utils/atomicJson.js +11 -2
  53. package/dist/utils/atomicJson.js.map +1 -1
  54. package/dist/verification.d.ts +10 -1
  55. package/dist/verification.js +462 -111
  56. package/dist/verification.js.map +1 -1
  57. package/package.json +1 -1
  58. package/plugins/ai-delivery/.claude-plugin/plugin.json +1 -1
  59. package/plugins/ai-delivery/README.md +1 -1
  60. package/plugins/ai-delivery/skills/worktree-lifecycle/SKILL.md +3 -0
@@ -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, realpathSync, 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({
@@ -151,7 +155,18 @@ function scanOutputRoots(roots) {
151
155
  throw error;
152
156
  }
153
157
  if (metadata.isSymbolicLink()) {
154
- throw new DeliveryError('Filesystem output observation encountered a symbolic link.');
158
+ let target;
159
+ try {
160
+ target = realpathSync(path);
161
+ }
162
+ catch {
163
+ throw new DeliveryError('Filesystem output observation encountered a broken or cyclic symbolic link.');
164
+ }
165
+ if (!roots.some((root) => target === root || target.startsWith(`${root}${sep}`))) {
166
+ throw new DeliveryError('Filesystem output observation encountered an escaping symbolic link.');
167
+ }
168
+ // Observe physical targets through their declared root only, never traverse aliases.
169
+ return;
155
170
  }
156
171
  if (metadata.isDirectory()) {
157
172
  for (const name of readdirSync(path))
@@ -165,8 +180,11 @@ function scanOutputRoots(roots) {
165
180
  if (files.size > 1_000_000)
166
181
  throw new DeliveryError('Filesystem output observation exceeded its file limit.');
167
182
  };
168
- for (const root of roots)
183
+ for (const root of roots) {
184
+ if (existsSync(root) && realpathSync(root) !== root)
185
+ throw new DeliveryError('Filesystem output roots must not be symbolic links.');
169
186
  visit(root);
187
+ }
170
188
  return files;
171
189
  }
172
190
  function outputBaseline(repoRoot, common, classificationReceiptId, bounds) {
@@ -418,6 +436,149 @@ async function confirmOwnedCleanup(state) {
418
436
  await new Promise((resolve) => setTimeout(resolve, 100));
419
437
  }
420
438
  }
439
+ /** Signal only birth identities established by this command, including observed detached children. */
440
+ function terminateOwnedProcesses(state) {
441
+ if (!state.sampled || state.rootIdentity === undefined)
442
+ throw new DeliveryError('Owned process identity is unknown.');
443
+ const snapshot = processSnapshot();
444
+ const root = snapshot.get(state.rootPid);
445
+ if (root !== undefined && root.identity !== state.rootIdentity)
446
+ throw new DeliveryError('Owned process group identity changed before cleanup.');
447
+ const alive = [...state.tracked.values()].flatMap((previous) => {
448
+ const current = snapshot.get(previous.pid);
449
+ if (current === undefined || current.status.startsWith('Z'))
450
+ return [];
451
+ if (current.identity !== previous.identity)
452
+ throw new DeliveryError('Tracked process identity changed before cleanup.');
453
+ return [current];
454
+ });
455
+ const group = [...snapshot.values()].filter((member) => member.pgid === state.rootPid && !member.status.startsWith('Z'));
456
+ if (group.length > 0 && !alive.some((member) => member.pgid === state.rootPid))
457
+ throw new DeliveryError('Owned process group has no surviving recorded identity; cleanup is ambiguous.');
458
+ const signal = (pid) => {
459
+ try {
460
+ process.kill(pid, 'SIGKILL');
461
+ }
462
+ catch (error) {
463
+ if (error.code !== 'ESRCH')
464
+ throw error;
465
+ }
466
+ };
467
+ if (group.length > 0)
468
+ signal(-state.rootPid);
469
+ for (const member of alive.reverse())
470
+ signal(member.pid);
471
+ }
472
+ const ProcessIdentitySchema = z.strictObject({ pid: z.number().int().positive(), identity: z.string().min(1) });
473
+ const WriterStateSchema = z
474
+ .strictObject({
475
+ schemaVersion: z.literal('ai-delivery.verification-writer@1'),
476
+ writerId: z.uuid(),
477
+ worktreeDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/u),
478
+ owner: ProcessIdentitySchema,
479
+ command: z.union([
480
+ z.strictObject({ phase: z.literal('idle') }),
481
+ z.strictObject({ phase: z.literal('starting') }),
482
+ z.strictObject({
483
+ phase: z.literal('running'),
484
+ root: ProcessIdentitySchema,
485
+ tracked: z.array(z.strictObject({ ...ProcessIdentitySchema.shape, pgid: z.number().int().positive() })),
486
+ }),
487
+ ]),
488
+ stateId: z.string().regex(/^sha256:[a-f0-9]{64}$/u),
489
+ })
490
+ .superRefine((state, ctx) => {
491
+ const { stateId, ...content } = state;
492
+ if (stateId !== digestValue(content))
493
+ ctx.addIssue({ code: 'custom', message: 'Writer state is corrupt.' });
494
+ });
495
+ async function withVerificationWriter(root, operation) {
496
+ const digest = worktreeDigest(root);
497
+ const path = join(gitCommonDir(root), 'ai-delivery', 'writers@1', `${digest.slice(7)}.json`);
498
+ ensurePrivateDirectoryDurably(dirname(path));
499
+ return withLock(path, {
500
+ projectRoot: root,
501
+ timeout: 200,
502
+ operation: async () => {
503
+ const snapshot = processSnapshot();
504
+ const owner = snapshot.get(process.pid);
505
+ if (owner === undefined || owner.status.startsWith('Z'))
506
+ throw new DeliveryError('Verification writer identity is unknown.');
507
+ if (existsSync(path)) {
508
+ const previous = WriterStateSchema.parse(JSON.parse(assertPrivateFile(path).toString('utf8')));
509
+ if (previous.worktreeDigest !== digest)
510
+ throw new DeliveryError('Verification writer belongs to another worktree.');
511
+ const priorOwner = snapshot.get(previous.owner.pid);
512
+ if (priorOwner?.identity === previous.owner.identity && !priorOwner.status.startsWith('Z'))
513
+ throw new DeliveryError('A live verification writer still owns this worktree.');
514
+ if (previous.command.phase === 'starting')
515
+ throw new DeliveryError('Interrupted writer has unknown command ownership; reconcile before retrying.');
516
+ if (previous.command.phase === 'running') {
517
+ const state = {
518
+ rootPid: previous.command.root.pid,
519
+ rootIdentity: previous.command.root.identity,
520
+ sampled: true,
521
+ tracked: new Map(previous.command.tracked.map((member) => [member.pid, { ...member, ppid: 0, rssBytes: 0, status: '' }])),
522
+ };
523
+ terminateOwnedProcesses(state);
524
+ await confirmOwnedCleanup(state);
525
+ reportVerificationProgress({
526
+ state: 'recovered',
527
+ stageId: 'writer',
528
+ completedStages: 0,
529
+ reusedStages: 0,
530
+ remainingStages: 0,
531
+ elapsedMs: 0,
532
+ reason: 'Recorded interrupted command cleanup confirmed; compatible completed stages remain available.',
533
+ });
534
+ }
535
+ }
536
+ let state = {
537
+ schemaVersion: 'ai-delivery.verification-writer@1',
538
+ writerId: randomUUID(),
539
+ worktreeDigest: digest,
540
+ owner: { pid: owner.pid, identity: owner.identity },
541
+ command: { phase: 'idle' },
542
+ };
543
+ const persist = (command) => {
544
+ state = { ...state, command };
545
+ writePrivateJsonFileAtomically(path, { ...state, stateId: digestValue(state) });
546
+ };
547
+ persist({ phase: 'idle' });
548
+ const writer = {
549
+ starting: () => persist({ phase: 'starting' }),
550
+ observed: (owned) => {
551
+ if (owned.rootIdentity === undefined)
552
+ throw new DeliveryError('Cannot persist an unknown command owner.');
553
+ persist({
554
+ phase: 'running',
555
+ root: { pid: owned.rootPid, identity: owned.rootIdentity },
556
+ tracked: [...owned.tracked.values()].map(({ pid, identity, pgid }) => ({ pid, identity, pgid })),
557
+ });
558
+ },
559
+ idle: () => persist({ phase: 'idle' }),
560
+ assertQuiescent: () => {
561
+ const current = WriterStateSchema.parse(JSON.parse(assertPrivateFile(path).toString('utf8')));
562
+ if (current.writerId !== state.writerId || current.command.phase !== 'idle')
563
+ throw new DeliveryError('Verification writer command quiescence is not confirmed.');
564
+ },
565
+ };
566
+ const releaseWriter = () => {
567
+ const current = WriterStateSchema.parse(JSON.parse(assertPrivateFile(path).toString('utf8')));
568
+ if (current.writerId !== state.writerId)
569
+ throw new DeliveryError('Verification writer ownership changed.');
570
+ if (current.command.phase === 'idle')
571
+ unlinkSync(path);
572
+ };
573
+ try {
574
+ return await operation(writer);
575
+ }
576
+ finally {
577
+ releaseWriter();
578
+ }
579
+ },
580
+ });
581
+ }
421
582
  function primaryRoot(worktreeRoot) {
422
583
  const common = gitCommonDir(worktreeRoot);
423
584
  if (basename(common) !== '.git')
@@ -433,8 +594,64 @@ function assertRegisteredIssue(worktreeRoot, issueNumber, allowMerged = false) {
433
594
  }
434
595
  assertClean(worktreeRoot);
435
596
  }
436
- function manifestPath(common, headSha) {
437
- return join(common, 'ai-delivery', 'runs', `${headSha}.json`);
597
+ function worktreeDigest(root) {
598
+ return digestValue(resolve(root));
599
+ }
600
+ function producerDigest() {
601
+ const root = dirname(fileURLToPath(import.meta.url));
602
+ const code = [];
603
+ const collect = (directory) => {
604
+ for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
605
+ const path = join(directory, entry.name);
606
+ if (entry.isDirectory())
607
+ collect(path);
608
+ else if (entry.isFile() && entry.name.endsWith('.js') && !entry.name.endsWith('.test.js'))
609
+ code.push({ path: path.slice(root.length + 1), digest: digestBytes(readFileSync(path)) });
610
+ }
611
+ };
612
+ collect(root);
613
+ const dependencies = new Map();
614
+ const collectDependencies = (manifestPath) => {
615
+ if (dependencies.has(manifestPath))
616
+ return;
617
+ const bytes = readFileSync(manifestPath);
618
+ const manifest = z
619
+ .object({ dependencies: z.record(z.string(), z.string()).optional() })
620
+ .parse(JSON.parse(bytes.toString('utf8')));
621
+ dependencies.set(manifestPath, digestBytes(bytes));
622
+ const require = createRequire(manifestPath);
623
+ for (const name of Object.keys(manifest.dependencies ?? {}).sort()) {
624
+ let resolved;
625
+ try {
626
+ resolved = require.resolve(`${name}/package.json`);
627
+ }
628
+ catch {
629
+ resolved = require.resolve(name);
630
+ }
631
+ let directory = dirname(resolved);
632
+ for (;;) {
633
+ const path = join(directory, 'package.json');
634
+ if (existsSync(path)) {
635
+ const candidate = z.object({ name: z.string().optional() }).parse(JSON.parse(readFileSync(path, 'utf8')));
636
+ if (candidate.name === name) {
637
+ collectDependencies(path);
638
+ break;
639
+ }
640
+ }
641
+ const parent = dirname(directory);
642
+ if (parent === directory)
643
+ throw new DeliveryError(`Producer dependency identity is unavailable for ${name}.`);
644
+ directory = parent;
645
+ }
646
+ }
647
+ };
648
+ collectDependencies(join(dirname(root), 'package.json'));
649
+ return digestValue({ code, dependencies: [...dependencies].sort(([a], [b]) => a.localeCompare(b)) });
650
+ }
651
+ function manifestPath(common, headSha, root) {
652
+ return root === undefined
653
+ ? join(common, 'ai-delivery', 'runs', `${headSha}.json`)
654
+ : join(common, 'ai-delivery', 'runs@2', worktreeDigest(root).slice(7), `${headSha}.json`);
438
655
  }
439
656
  function parseRun(value) {
440
657
  if (typeof value !== 'object' || value === null || Array.isArray(value))
@@ -443,68 +660,115 @@ function parseRun(value) {
443
660
  const classification = RepositoryClassificationReceiptSchema.parse(input.classification);
444
661
  const aggregate = RepositoryStageAggregateSchema.parse(input.aggregate);
445
662
  const stageReceipts = RepositoryStageReceiptSchema.array().parse(input.stageReceipts);
446
- if (input.schemaVersion !== 'ai-delivery.run@1' && input.schemaVersion !== 'ai-delivery.run@2') {
663
+ if (!['ai-delivery.run@1', 'ai-delivery.run@2', 'ai-delivery.run@3'].includes(String(input.schemaVersion)))
447
664
  throw new DeliveryError('Run manifest schema version is unsupported.');
448
- }
449
- if (input.schemaVersion === 'ai-delivery.run@1' && input.resources !== undefined) {
665
+ if (input.schemaVersion === 'ai-delivery.run@1' && input.resources !== undefined)
450
666
  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;
667
+ const resources = input.resources === undefined ? undefined : ResourceSummarySchema.parse(input.resources);
668
+ if (input.schemaVersion === 'ai-delivery.run@2' && resources === undefined)
669
+ throw new DeliveryError('Historical bounded run lacks resource evidence.');
670
+ const writer = input.schemaVersion === 'ai-delivery.run@3'
671
+ ? z
672
+ .strictObject({
673
+ worktreeDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/u),
674
+ producerDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/u),
675
+ })
676
+ .parse(input.writer)
677
+ : undefined;
453
678
  const content = {
454
679
  aggregate,
455
680
  classification,
456
681
  completedAt: input.completedAt,
457
682
  ...(resources === undefined ? {} : { resources }),
683
+ ...(writer === undefined ? {} : { writer }),
458
684
  schemaVersion: input.schemaVersion,
459
685
  stageReceipts,
460
686
  };
461
- if (typeof input.completedAt !== 'string' || input.manifestId !== digestValue(content))
462
- throw new DeliveryError('Run manifest identity is invalid.');
687
+ if (typeof input.completedAt !== 'string' ||
688
+ input.manifestId !== digestValue(content) ||
689
+ createRepositoryStageAggregate({ classification, receipts: stageReceipts }).aggregateId !== aggregate.aggregateId)
690
+ throw new DeliveryError('Run manifest identity or aggregate is invalid.');
463
691
  return { ...content, completedAt: input.completedAt, manifestId: input.manifestId };
464
692
  }
465
- function loadRun(common, headSha) {
693
+ function loadRun(common, headSha, root, historical = false) {
466
694
  let raw;
467
695
  try {
468
- raw = JSON.parse(readFileSync(manifestPath(common, headSha), 'utf8'));
696
+ raw = JSON.parse(readFileSync(manifestPath(common, headSha, root), 'utf8'));
469
697
  }
470
- catch {
471
- throw new DeliveryError('Exact-head verification run is missing or corrupt.');
698
+ catch (error) {
699
+ if (!historical || error.code !== 'ENOENT')
700
+ throw new DeliveryError('Exact-head worktree verification run is missing or corrupt; verify with the current producer.');
701
+ raw = JSON.parse(readFileSync(manifestPath(common, headSha), 'utf8'));
472
702
  }
473
- return parseRun(raw);
703
+ const run = parseRun(raw);
704
+ if (run.classification.head.sha !== headSha ||
705
+ (run.writer !== undefined &&
706
+ (run.writer.worktreeDigest !== worktreeDigest(root) ||
707
+ (!historical && run.writer.producerDigest !== producerDigest()))) ||
708
+ (!historical && run.writer === undefined))
709
+ throw new DeliveryError('Verification run belongs to another writer or producer.');
710
+ const expectedEnvironment = historical ? undefined : verificationEnvironmentDigest(root, run.resources);
711
+ if (expectedEnvironment !== undefined &&
712
+ run.stageReceipts.some((receipt) => receipt.input.environmentDigest !== expectedEnvironment))
713
+ throw new DeliveryError('Verification stage environment belongs to a foreign worktree or producer.');
714
+ return run;
474
715
  }
475
- function stageEnvironmentDigest() {
716
+ function stageEnvironmentDigest(root, producer = producerDigest()) {
476
717
  return digestValue({
477
718
  arch: process.arch,
478
719
  node: process.version,
479
720
  path: process.env.PATH ?? '',
480
721
  platform: process.platform,
722
+ worktreeDigest: worktreeDigest(root),
723
+ producerDigest: producer,
481
724
  });
482
725
  }
726
+ function verificationEnvironmentDigest(root, resources, producer = producerDigest()) {
727
+ const environment = stageEnvironmentDigest(root, producer);
728
+ return resources === undefined
729
+ ? environment
730
+ : digestValue({
731
+ environment,
732
+ ...(resources.outputBaselineId === undefined ? {} : { outputBaselineId: resources.outputBaselineId }),
733
+ resourceBounds: resources.bounds,
734
+ resourceRunner: digestBytes(readFileSync(fileURLToPath(import.meta.url))),
735
+ });
736
+ }
483
737
  function reportVerificationProgress(input) {
484
738
  process.stderr.write(`ai-delivery.verify ${JSON.stringify(input)}\n`);
485
739
  }
486
- function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds, baseline, onResourceSample) {
740
+ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds, baseline, onResourceSample, writer) {
487
741
  const [executable, ...args] = argv;
488
742
  if (!executable)
489
743
  throw new DeliveryError('Policy selected an empty stage command.');
490
744
  if (abortSignal?.aborted)
491
745
  throw new DeliveryError('Selected policy stage command cancelled.');
746
+ writer?.starting();
492
747
  return new Promise((resolve, reject) => {
493
- const child = spawn(executable, args, {
748
+ // The shell waits for our ownership receipt, then exec preserves its PID, birth identity and process group.
749
+ const child = spawn('/bin/sh', [
750
+ '-c',
751
+ 'IFS= read -r ready || exit 125; [ "$ready" = run ] || exit 125; exec "$@" </dev/null',
752
+ 'ai-delivery-command',
753
+ executable,
754
+ ...args,
755
+ ], {
494
756
  cwd: repoRoot,
495
757
  detached: process.platform !== 'win32',
496
- stdio: ['ignore', 'pipe', 'pipe'],
758
+ stdio: ['pipe', 'pipe', 'pipe'],
497
759
  });
498
760
  const stdout = [];
499
761
  const stderr = [];
500
- const owned = resourceBounds === undefined || child.pid === undefined
762
+ const owned = process.platform === 'win32' || child.pid === undefined
501
763
  ? undefined
502
764
  : { rootPid: child.pid, sampled: false, tracked: new Map() };
503
765
  let outputBytes = 0;
504
766
  let failure;
505
767
  let cleanupFailure;
768
+ let writerFailure;
506
769
  let closed = false;
507
770
  let settled = false;
771
+ let commandReleased = false;
508
772
  let closeTimer;
509
773
  let exitPipeTimer;
510
774
  let resourceTimer;
@@ -523,57 +787,37 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
523
787
  const killOwned = () => {
524
788
  if (child.pid === undefined)
525
789
  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
790
  if (owned?.sampled) {
533
791
  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
- }
792
+ observeOwnedProcesses(owned, processSnapshot());
793
+ }
794
+ catch (error) {
795
+ cleanupFailure ??= error instanceof Error ? error.message : String(error);
796
+ }
797
+ try {
798
+ writer?.observed(owned);
799
+ }
800
+ catch (error) {
801
+ writerFailure ??= error instanceof Error ? error.message : String(error);
802
+ }
803
+ // Durable state failure cannot prevent identity-checked termination of known-owned processes.
804
+ try {
805
+ terminateOwnedProcesses(owned);
559
806
  }
560
807
  catch (error) {
561
808
  cleanupFailure ??= error instanceof Error ? error.message : String(error);
562
- groupIsOwned = child.exitCode === null && child.signalCode === null;
563
809
  }
564
810
  }
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) {
811
+ else {
570
812
  try {
571
- process.kill(-child.pid, 'SIGKILL');
813
+ // The still-owned direct child is the only safe signal target before identity observation.
814
+ if (child.exitCode === null && child.signalCode === null)
815
+ child.kill('SIGKILL');
816
+ if (commandReleased)
817
+ cleanupFailure ??= 'owned process identity was not observed before cleanup';
572
818
  }
573
819
  catch (error) {
574
- if (error.code !== 'ESRCH') {
575
- cleanupFailure ??= error instanceof Error ? error.message : String(error);
576
- }
820
+ cleanupFailure ??= error instanceof Error ? error.message : String(error);
577
821
  }
578
822
  }
579
823
  };
@@ -591,7 +835,7 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
591
835
  child.stdout.destroy();
592
836
  child.stderr.destroy();
593
837
  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).`));
838
+ 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
839
  }, 2_000);
596
840
  }
597
841
  };
@@ -607,7 +851,12 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
607
851
  child.stderr.on('data', (chunk) => capture(stderr, chunk));
608
852
  child.stdout.once('error', (error) => stop(error.message));
609
853
  child.stderr.once('error', (error) => stop(error.message));
610
- child.once('error', (error) => stop(error.message));
854
+ child.stdin.once('error', (error) => stop(error.message));
855
+ child.once('error', (error) => {
856
+ if (child.pid === undefined)
857
+ writer?.idle();
858
+ stop(error.message);
859
+ });
611
860
  child.once('exit', (code, signal) => {
612
861
  if (code !== 0 || signal !== null)
613
862
  stop(`exit ${String(code)}${signal === null ? '' : ` signal ${signal}`}`);
@@ -618,27 +867,33 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
618
867
  }, 1_000);
619
868
  }
620
869
  });
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.');
870
+ child.once('spawn', () => {
871
+ const sample = () => {
872
+ if (failure !== undefined || child.pid === undefined)
873
+ return;
874
+ try {
875
+ if (owned === undefined)
876
+ throw new DeliveryError('Owned process identity is unavailable on this platform.');
877
+ observeOwnedProcesses(owned, processSnapshot());
878
+ writer?.observed(owned);
879
+ if (resourceBounds !== undefined) {
629
880
  const result = sampleOwnedTree(owned, repoRoot, resourceBounds, baseline);
630
881
  onResourceSample?.(result);
631
882
  onRunning?.(outputBytes, result);
632
883
  }
633
- catch (error) {
634
- stop(error instanceof Error ? error.message : String(error));
884
+ if (!commandReleased) {
885
+ commandReleased = true;
886
+ child.stdin.end('run\n');
635
887
  }
636
- };
637
- sample();
638
- resourceTimer = setInterval(sample, 1_000);
639
- resourceTimer.unref();
640
- });
641
- }
888
+ }
889
+ catch (error) {
890
+ stop(error instanceof Error ? error.message : String(error));
891
+ }
892
+ };
893
+ sample();
894
+ resourceTimer = setInterval(sample, 1_000);
895
+ resourceTimer.unref();
896
+ });
642
897
  const progressTimer = setInterval(() => onRunning?.(outputBytes), 5_000);
643
898
  progressTimer.unref();
644
899
  const onAbort = () => stop('cancelled');
@@ -656,12 +911,13 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
656
911
  clearWatchers();
657
912
  if (failure === undefined && owned !== undefined) {
658
913
  try {
659
- if (!owned.sampled)
660
- throw new DeliveryError('Owned process resource observation was unavailable.');
661
914
  if (resourceBounds !== undefined) {
662
- const result = sampleOwnedTree(owned, repoRoot, resourceBounds, baseline);
663
- onResourceSample?.(result);
915
+ const sample = sampleOwnedTree(owned, repoRoot, resourceBounds, baseline);
916
+ onResourceSample?.(sample);
917
+ onRunning?.(outputBytes, sample);
664
918
  }
919
+ if (!owned.sampled)
920
+ throw new DeliveryError('Owned process resource observation was unavailable.');
665
921
  const remaining = observeOwnedProcesses(owned, processSnapshot());
666
922
  if (remaining.length > 0)
667
923
  throw new DeliveryError('A sampled owned descendant survived command exit.');
@@ -672,18 +928,29 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
672
928
  }
673
929
  if (failure !== undefined || code !== 0 || signal !== null) {
674
930
  const reason = failure ?? `exit ${String(code)}${signal === null ? '' : ` signal ${signal}`}`;
675
- if (owned !== undefined) {
931
+ let cleanupConfirmed = child.pid === undefined || !commandReleased;
932
+ if (owned !== undefined && owned.sampled) {
676
933
  try {
677
934
  await confirmOwnedCleanup(owned);
935
+ cleanupConfirmed = true;
678
936
  }
679
937
  catch (error) {
680
938
  cleanupFailure ??= error instanceof Error ? error.message : String(error);
681
939
  }
682
940
  }
941
+ if (cleanupConfirmed) {
942
+ try {
943
+ writer?.idle();
944
+ }
945
+ catch (error) {
946
+ writerFailure ??= error instanceof Error ? error.message : String(error);
947
+ }
948
+ }
683
949
  settled = true;
684
- reject(new DeliveryError(`Selected policy stage command failed (${reason}).${cleanupFailure === undefined ? '' : ` Owned process cleanup failed (${cleanupFailure}).`}`));
950
+ reject(new DeliveryError(`Selected policy stage command failed (${reason}).${writerFailure === undefined ? '' : ` Writer state persistence failed (${writerFailure}).`}${cleanupFailure === undefined ? '' : ` Owned process cleanup failed (${cleanupFailure}).`}`));
685
951
  return;
686
952
  }
953
+ writer?.idle();
687
954
  settled = true;
688
955
  resolve(Buffer.concat([...stdout, ...stderr], outputBytes));
689
956
  };
@@ -696,6 +963,10 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
696
963
  });
697
964
  }
698
965
  export async function verifyIssue(input) {
966
+ const root = gitRoot(input.repoRoot);
967
+ return withVerificationWriter(root, (writer) => verifyIssueOwned(input, writer));
968
+ }
969
+ async function verifyIssueOwned(input, writer) {
699
970
  const assertNotCancelled = () => {
700
971
  if (input.signal?.aborted)
701
972
  throw new DeliveryError('Verification cancelled.');
@@ -732,6 +1003,21 @@ export async function verifyIssue(input) {
732
1003
  const admitted = new Set(input.admittedResourceClasses ?? ['source_only']);
733
1004
  const startedAtMs = Date.now();
734
1005
  let reusedStages = 0;
1006
+ let completedCommands = 0;
1007
+ let reusedCommands = 0;
1008
+ let lastCompletedAtMs = startedAtMs;
1009
+ const totalCommands = classification.requiredStages.reduce((total, stage) => total + stage.commands.length, 0);
1010
+ const report = (progress) => {
1011
+ const elapsedMs = Date.now() - startedAtMs;
1012
+ reportVerificationProgress({
1013
+ ...progress,
1014
+ completedCommands,
1015
+ remainingCommands: totalCommands - completedCommands,
1016
+ reusedCommands,
1017
+ executedCommandsPerSecond: elapsedMs === 0 ? 0 : ((completedCommands - reusedCommands) * 1000) / elapsedMs,
1018
+ lastCompletedWorkAgeMs: Date.now() - lastCompletedAtMs,
1019
+ });
1020
+ };
735
1021
  const normalizedBounds = input.resourceBounds === undefined
736
1022
  ? undefined
737
1023
  : {
@@ -753,14 +1039,8 @@ export async function verifyIssue(input) {
753
1039
  sampleCount: 0,
754
1040
  });
755
1041
  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
- });
1042
+ const producer = producerDigest();
1043
+ const environmentDigest = verificationEnvironmentDigest(root, resources, producer);
764
1044
  let stageResources;
765
1045
  const recordResourceSample = (sample) => {
766
1046
  if (resources === undefined)
@@ -784,10 +1064,11 @@ export async function verifyIssue(input) {
784
1064
  const stageInput = createRepositoryStageInput({
785
1065
  classification,
786
1066
  environmentDigest,
787
- semanticInputs: stage.semanticInputKeys.map((key) => ({
788
- digest: digestValue({ key, evidenceId: classification.policyEvidence.evidenceId }),
789
- key,
790
- })),
1067
+ semanticInputs: stage.semanticInputs ??
1068
+ stage.semanticInputKeys.map((key) => ({
1069
+ digest: digestValue({ key, evidenceId: classification.policyEvidence.evidenceId }),
1070
+ key,
1071
+ })),
791
1072
  stageId: stage.id,
792
1073
  upstream: stage.dependsOn.map((stageId) => {
793
1074
  const receipt = receipts.find((value) => value.input.stageId === stageId);
@@ -830,7 +1111,10 @@ export async function verifyIssue(input) {
830
1111
  if (cached) {
831
1112
  receipts.push(cached);
832
1113
  reusedStages += 1;
833
- reportVerificationProgress({
1114
+ completedCommands += cached.commands.length;
1115
+ reusedCommands += cached.commands.length;
1116
+ lastCompletedAtMs = Date.now();
1117
+ report({
834
1118
  state: 'reused',
835
1119
  stageId: stage.id,
836
1120
  completedStages: receipts.length,
@@ -845,9 +1129,10 @@ export async function verifyIssue(input) {
845
1129
  const startedAt = new Date().toISOString();
846
1130
  const commands = [];
847
1131
  for (const command of stage.commands) {
1132
+ const commandStartedAtMs = Date.now();
848
1133
  if (input.resourceBounds !== undefined)
849
1134
  assertDiskHeadroom(root, input.resourceBounds, baseline);
850
- const progress = (capturedOutputBytes, sample) => reportVerificationProgress({
1135
+ const progress = (capturedOutputBytes, sample) => report({
851
1136
  state: 'running',
852
1137
  stageId: stage.id,
853
1138
  commandLabel: command.label,
@@ -856,6 +1141,7 @@ export async function verifyIssue(input) {
856
1141
  reusedStages,
857
1142
  elapsedMs: Date.now() - startedAtMs,
858
1143
  capturedOutputBytes,
1144
+ commandElapsedMs: Date.now() - commandStartedAtMs,
859
1145
  ...(sample === undefined
860
1146
  ? {}
861
1147
  : {
@@ -868,10 +1154,10 @@ export async function verifyIssue(input) {
868
1154
  progress(0);
869
1155
  let bytes;
870
1156
  try {
871
- bytes = await runStageCommand(root, command.argv, input.signal, progress, input.resourceBounds, baseline, recordResourceSample);
1157
+ bytes = await runStageCommand(root, command.argv, input.signal, progress, input.resourceBounds, baseline, recordResourceSample, writer);
872
1158
  }
873
1159
  catch (error) {
874
- reportVerificationProgress({
1160
+ report({
875
1161
  state: 'failed',
876
1162
  stageId: stage.id,
877
1163
  commandLabel: command.label,
@@ -884,6 +1170,8 @@ export async function verifyIssue(input) {
884
1170
  throw error;
885
1171
  }
886
1172
  assertNotCancelled();
1173
+ completedCommands += 1;
1174
+ lastCompletedAtMs = Date.now();
887
1175
  commands.push({
888
1176
  exitCode: 0,
889
1177
  label: command.label,
@@ -912,7 +1200,8 @@ export async function verifyIssue(input) {
912
1200
  writeRepositoryStageCheckpoint({ gitCommonDir: common, receipt, repoRoot: root });
913
1201
  stageResources = undefined;
914
1202
  receipts.push(receipt);
915
- reportVerificationProgress({
1203
+ lastCompletedAtMs = Date.now();
1204
+ report({
916
1205
  state: 'completed',
917
1206
  stageId: stage.id,
918
1207
  completedStages: receipts.length,
@@ -922,6 +1211,16 @@ export async function verifyIssue(input) {
922
1211
  });
923
1212
  }
924
1213
  assertNotCancelled();
1214
+ assertRepositoryClassificationCurrent({
1215
+ classification,
1216
+ configDigest: loaded.configDigest,
1217
+ policySourcePath: loaded.config.policy.module,
1218
+ repoRoot: root,
1219
+ });
1220
+ if (coordinate(root).sha !== head.sha || coordinate(root, defaultBaseRef(root, loaded.remote)).sha !== base.sha)
1221
+ throw new DeliveryError('Verification source changed during execution; classify and resume the current source.');
1222
+ if (producerDigest() !== producer)
1223
+ throw new DeliveryError('Verification producer changed during execution.');
925
1224
  const aggregate = createRepositoryStageAggregate({ classification, receipts });
926
1225
  writeRepositoryStageAggregate({ gitCommonDir: common, aggregate });
927
1226
  const content = {
@@ -929,11 +1228,12 @@ export async function verifyIssue(input) {
929
1228
  classification,
930
1229
  completedAt: new Date().toISOString(),
931
1230
  ...(resources === undefined ? {} : { resources: ResourceSummarySchema.parse(resources) }),
932
- schemaVersion: resources === undefined ? 'ai-delivery.run@1' : 'ai-delivery.run@2',
1231
+ schemaVersion: 'ai-delivery.run@3',
1232
+ writer: { worktreeDigest: worktreeDigest(root), producerDigest: producer },
933
1233
  stageReceipts: receipts,
934
1234
  };
935
1235
  const run = { ...content, manifestId: digestValue(content) };
936
- writePrivateJsonFileAtomically(manifestPath(common, head.sha), run);
1236
+ writePrivateJsonFileAtomically(manifestPath(common, head.sha, root), run);
937
1237
  return run;
938
1238
  }
939
1239
  export async function createIssuePhaseEvidence(input) {
@@ -944,7 +1244,7 @@ export async function createIssuePhaseEvidence(input) {
944
1244
  repoRoot: root,
945
1245
  policySourcePath: loaded.config.policy.module,
946
1246
  });
947
- const run = loadRun(gitCommonDir(root), coordinate(root).sha);
1247
+ const run = loadRun(gitCommonDir(root), coordinate(root).sha, root);
948
1248
  const evidence = createRepositoryDeliveryEvidence({
949
1249
  aggregate: run.aggregate,
950
1250
  ...(input.approval === undefined ? {} : { approval: input.approval }),
@@ -970,7 +1270,8 @@ export async function createIssuePhaseEvidence(input) {
970
1270
  export function loadVerifiedRun(repoRoot, issueNumber) {
971
1271
  const root = gitRoot(repoRoot);
972
1272
  assertRegisteredIssue(root, issueNumber, true);
973
- return loadRun(gitCommonDir(root), coordinate(root).sha);
1273
+ const historical = getIssueWorktreeStrict(issueNumber, primaryRoot(root)).status === 'merged';
1274
+ return loadRun(gitCommonDir(root), coordinate(root).sha, root, historical);
974
1275
  }
975
1276
  /** Recover an exact merged run after worktree removal interrupted terminal recording. */
976
1277
  export function loadRemovedMergedRun(primaryRepoRoot, issueNumber) {
@@ -980,10 +1281,60 @@ export function loadRemovedMergedRun(primaryRepoRoot, issueNumber) {
980
1281
  throw new DeliveryError('Removed-run recovery requires the exact registered merged issue.');
981
1282
  }
982
1283
  const headSha = git(root, 'rev-parse', `refs/heads/${row.branch}`);
983
- const run = loadRun(gitCommonDir(root), headSha);
1284
+ const run = loadRun(gitCommonDir(root), headSha, row.path, true);
984
1285
  if (run.classification.head.sha !== headSha) {
985
1286
  throw new DeliveryError('Recovered merged run disagrees with the registered branch.');
986
1287
  }
987
1288
  return run;
988
1289
  }
1290
+ /** @internal Runtime setup shares the existing writer, ownership handshake and bounded command runner. */
1291
+ export async function withRuntimeSetupWriter(root, operation) {
1292
+ return withVerificationWriter(root, async (writer) => operation({
1293
+ assertQuiescent: () => writer.assertQuiescent(),
1294
+ run: async (argv, bounds, signal) => {
1295
+ assertResourceBounds(bounds);
1296
+ const roots = bounds.outputRoots?.map((path) => resolve(root, path)).sort();
1297
+ if (roots !== undefined) {
1298
+ for (let i = 1; i < roots.length; i++)
1299
+ if (roots[i] === roots[i - 1] || roots[i].startsWith(`${roots[i - 1]}${sep}`))
1300
+ throw new DeliveryError('Filesystem output roots must not overlap.');
1301
+ }
1302
+ const files = roots === undefined ? undefined : scanOutputRoots(roots);
1303
+ const baseline = roots === undefined || files === undefined
1304
+ ? undefined
1305
+ : {
1306
+ roots,
1307
+ files,
1308
+ baselineId: digestValue([...files]),
1309
+ };
1310
+ assertDiskHeadroom(root, bounds, baseline);
1311
+ const started = Date.now();
1312
+ const output = await runStageCommand(root, argv, signal, (capturedOutputBytes, sample) => reportVerificationProgress({
1313
+ state: 'running',
1314
+ stageId: 'runtime-setup',
1315
+ completedStages: 0,
1316
+ reusedStages: 0,
1317
+ remainingStages: 1,
1318
+ elapsedMs: Date.now() - started,
1319
+ capturedOutputBytes,
1320
+ ...(sample === undefined
1321
+ ? {}
1322
+ : {
1323
+ sampledAggregateRssBytes: sample.aggregateRssBytes,
1324
+ sampledFreeDiskBytes: sample.freeDiskBytes,
1325
+ sampledNewOutputBytes: sample.newOutputBytes,
1326
+ }),
1327
+ }), bounds, baseline, undefined, writer);
1328
+ reportVerificationProgress({
1329
+ state: 'completed',
1330
+ stageId: 'runtime-setup',
1331
+ completedStages: 1,
1332
+ reusedStages: 0,
1333
+ remainingStages: 0,
1334
+ elapsedMs: Date.now() - started,
1335
+ });
1336
+ return output;
1337
+ },
1338
+ }));
1339
+ }
989
1340
  //# sourceMappingURL=verification.js.map