@aviaratech/ai-delivery 0.3.22 → 0.3.24

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 (131) hide show
  1. package/CONTRIBUTING.md +9 -0
  2. package/README.md +77 -442
  3. package/dist/agent.d.ts +5 -11
  4. package/dist/agent.js +4 -8
  5. package/dist/agent.js.map +1 -1
  6. package/dist/cli.js +59 -107
  7. package/dist/cli.js.map +1 -1
  8. package/dist/config/deliveryConfig.d.ts +44 -55
  9. package/dist/config/deliveryConfig.js +85 -88
  10. package/dist/config/deliveryConfig.js.map +1 -1
  11. package/dist/config/deliveryConfig.test.js +13 -5
  12. package/dist/config/deliveryConfig.test.js.map +1 -1
  13. package/dist/delivery/common.d.ts +4 -1
  14. package/dist/delivery/common.js +56 -8
  15. package/dist/delivery/common.js.map +1 -1
  16. package/dist/delivery/delivery.test.js +2 -3
  17. package/dist/delivery/delivery.test.js.map +1 -1
  18. package/dist/delivery/index.d.ts +0 -6
  19. package/dist/delivery/index.js +0 -3
  20. package/dist/delivery/index.js.map +1 -1
  21. package/dist/delivery/legacy.d.ts +8 -0
  22. package/dist/delivery/legacy.js +5 -0
  23. package/dist/delivery/legacy.js.map +1 -0
  24. package/dist/delivery/policy.js +2 -13
  25. package/dist/delivery/policy.js.map +1 -1
  26. package/dist/delivery/stage.d.ts +6 -0
  27. package/dist/delivery/stage.js +11 -2
  28. package/dist/delivery/stage.js.map +1 -1
  29. package/dist/directoryIndependent.test.d.ts +1 -0
  30. package/dist/directoryIndependent.test.js +262 -0
  31. package/dist/directoryIndependent.test.js.map +1 -0
  32. package/dist/dispatch.d.ts +29 -3
  33. package/dist/dispatch.js +160 -457
  34. package/dist/dispatch.js.map +1 -1
  35. package/dist/genericCompatibility.test.js +0 -35
  36. package/dist/genericCompatibility.test.js.map +1 -1
  37. package/dist/git.js +10 -3
  38. package/dist/git.js.map +1 -1
  39. package/dist/gitProcess.d.ts +6 -0
  40. package/dist/gitProcess.js +56 -0
  41. package/dist/gitProcess.js.map +1 -0
  42. package/dist/github/discovery.d.ts +1 -0
  43. package/dist/github/discovery.js +1 -1
  44. package/dist/github/discovery.js.map +1 -1
  45. package/dist/github/discovery.test.js +25 -20
  46. package/dist/github/discovery.test.js.map +1 -1
  47. package/dist/github/nativeIssueMetadata.d.ts +2 -2
  48. package/dist/github/nativeIssueMetadata.js.map +1 -1
  49. package/dist/github/projectDelivery.d.ts +1 -1
  50. package/dist/github/projectDelivery.js.map +1 -1
  51. package/dist/github/repo.d.ts +7 -1
  52. package/dist/github/repo.js +58 -2
  53. package/dist/github/repo.js.map +1 -1
  54. package/dist/issue.d.ts +11 -6
  55. package/dist/issue.js +136 -319
  56. package/dist/issue.js.map +1 -1
  57. package/dist/issueJournal.d.ts +1 -1
  58. package/dist/lifecycle.test.js +40 -7371
  59. package/dist/lifecycle.test.js.map +1 -1
  60. package/dist/logger.js +4 -4
  61. package/dist/logger.js.map +1 -1
  62. package/dist/mcp/index.js +2 -1
  63. package/dist/mcp/index.js.map +1 -1
  64. package/dist/mcp/tools.d.ts +27 -42
  65. package/dist/mcp/tools.js +34 -42
  66. package/dist/mcp/tools.js.map +1 -1
  67. package/dist/pluginInstaller.d.ts +51 -0
  68. package/dist/pluginInstaller.js +774 -0
  69. package/dist/pluginInstaller.js.map +1 -0
  70. package/dist/pluginInstaller.test.d.ts +1 -0
  71. package/dist/pluginInstaller.test.js +543 -0
  72. package/dist/pluginInstaller.test.js.map +1 -0
  73. package/dist/pluginPackaging.test.d.ts +1 -0
  74. package/dist/pluginPackaging.test.js +245 -0
  75. package/dist/pluginPackaging.test.js.map +1 -0
  76. package/dist/pr.d.ts +66 -114
  77. package/dist/pr.js +288 -1108
  78. package/dist/pr.js.map +1 -1
  79. package/dist/pr.test.js +295 -237
  80. package/dist/pr.test.js.map +1 -1
  81. package/dist/releaseReconciliation.test.d.ts +1 -0
  82. package/dist/releaseReconciliation.test.js +314 -0
  83. package/dist/releaseReconciliation.test.js.map +1 -0
  84. package/dist/review.d.ts +37 -7
  85. package/dist/review.js +106 -45
  86. package/dist/review.js.map +1 -1
  87. package/dist/review.test.js +30 -71
  88. package/dist/review.test.js.map +1 -1
  89. package/dist/services/agentReadinessService.d.ts +1 -1
  90. package/dist/services/agentReadinessService.js +2 -0
  91. package/dist/services/agentReadinessService.js.map +1 -1
  92. package/dist/services/deliveryAdmission.d.ts +4 -4
  93. package/dist/services/deliveryAdmission.js.map +1 -1
  94. package/dist/setup.d.ts +1 -0
  95. package/dist/setup.js +71 -10
  96. package/dist/setup.js.map +1 -1
  97. package/dist/setup.test.js +594 -204
  98. package/dist/setup.test.js.map +1 -1
  99. package/dist/start.test.d.ts +1 -0
  100. package/dist/start.test.js +72 -0
  101. package/dist/start.test.js.map +1 -0
  102. package/dist/stdoutRegression.test.d.ts +1 -0
  103. package/dist/stdoutRegression.test.js +326 -0
  104. package/dist/stdoutRegression.test.js.map +1 -0
  105. package/dist/verification.d.ts +62 -27
  106. package/dist/verification.js +172 -1337
  107. package/dist/verification.js.map +1 -1
  108. package/dist/worktree.d.ts +1 -31
  109. package/dist/worktree.js +5 -200
  110. package/dist/worktree.js.map +1 -1
  111. package/dist/worktreeTransition.d.ts +1 -1
  112. package/dist/worktreeTransition.js +10 -5
  113. package/dist/worktreeTransition.js.map +1 -1
  114. package/dist/worktreeTransition.test.js +2 -3
  115. package/dist/worktreeTransition.test.js.map +1 -1
  116. package/package.json +4 -4
  117. package/plugins/ai-delivery/.claude-plugin/plugin.json +6 -1
  118. package/plugins/ai-delivery/LICENSE +21 -0
  119. package/plugins/ai-delivery/README.md +4 -2
  120. package/plugins/ai-delivery/dist/mcp-launcher.js +19 -8
  121. package/plugins/ai-delivery/mcp.json +10 -0
  122. package/plugins/ai-delivery/plugin.json +10 -0
  123. package/plugins/ai-delivery/runtime/dist/THIRD-PARTY-NOTICES.md +1010 -0
  124. package/plugins/ai-delivery/runtime/dist/cli.js +276 -0
  125. package/plugins/ai-delivery/runtime/package.json +13 -0
  126. package/plugins/ai-delivery/skills/intake-create/SKILL.md +1 -1
  127. package/plugins/ai-delivery/skills/pr-handoff/SKILL.md +14 -21
  128. package/plugins/ai-delivery/skills/worktree-lifecycle/SKILL.md +12 -23
  129. package/dist/services/deliveryRecordService.d.ts +0 -47
  130. package/dist/services/deliveryRecordService.js +0 -199
  131. package/dist/services/deliveryRecordService.js.map +0 -1
@@ -1,20 +1,29 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { execFileSync, spawn, spawnSync } from 'node:child_process';
3
- import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, utimesSync, writeFileSync, } from 'node:fs';
3
+ import { chmodSync, cpSync, existsSync, truncateSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, utimesSync, writeFileSync, } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { dirname, join } from 'node:path';
6
- import { test, vi } from 'vitest';
6
+ import { afterEach, test, vi } from 'vitest';
7
7
  import { loadDeliveryConfig } from './config/deliveryConfig.js';
8
8
  import * as privateFiles from './delivery/common.js';
9
- import { digestBytes } from './delivery/index.js';
9
+ import { digestBytes, digestValue } from './delivery/index.js';
10
10
  import { syntheticDiscoveryClients, syntheticOverrides, syntheticDiscoveryConfig } from './fixtures/discovery.js';
11
11
  import { assertDeliveryRuntimeAdmitted } from './services/deliveryAdmission.js';
12
12
  import * as atomicJson from './utils/atomicJson.js';
13
+ afterEach(() => vi.unstubAllEnvs());
13
14
  const syncFailure = vi.hoisted(() => ({ directory: '' }));
15
+ const sharedOutputRead = vi.hoisted(() => ({ path: '', unboundedReads: 0 }));
14
16
  vi.mock('node:fs', async (original) => {
15
17
  const actual = await original();
16
18
  return {
17
19
  ...actual,
20
+ readFileSync: (...args) => {
21
+ if (sharedOutputRead.path && String(args[0]) === sharedOutputRead.path) {
22
+ sharedOutputRead.unboundedReads++;
23
+ throw new Error('Unbounded shared command-output read attempted');
24
+ }
25
+ return actual.readFileSync(...args);
26
+ },
18
27
  fsyncSync: (fd) => {
19
28
  if (syncFailure.directory &&
20
29
  actual.existsSync(syncFailure.directory) &&
@@ -98,7 +107,7 @@ async function producer() {
98
107
  function git(root, ...args) {
99
108
  return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
100
109
  }
101
- async function fixture() {
110
+ async function fixture(native = false) {
102
111
  actors.author = 'host-user';
103
112
  actors.reviewer = 'reviewer[bot]';
104
113
  actors.access = true;
@@ -128,6 +137,10 @@ async function fixture() {
128
137
  },
129
138
  };
130
139
  writeFileSync(join(root, 'policy.mjs'), `export const deliverySettings=${JSON.stringify({ roles, commandPolicy: { checks: { format: 'REQUIRED', gitClean: 'REQUIRED', lint: 'REQUIRED', test: 'REQUIRED', typecheck: 'REQUIRED' }, timeoutsMs: { lint: 60000, test: 60000, typecheck: 60000 } } })};\n`);
140
+ const { policy: _policy, repository: _repository, remote: _remote, schemaVersion: _schema, ...overrides } = syntheticOverrides(syntheticDiscoveryConfig);
141
+ const userPath = join(base, 'user.json');
142
+ writeFileSync(userPath, JSON.stringify({ schemaVersion: 'ai-delivery.user@1', roles, checkoutRoots: [], ...overrides }));
143
+ vi.stubEnv('AI_DELIVERY_CONFIG', userPath);
131
144
  git(root, 'add', '.');
132
145
  git(root, 'commit', '-qm', 'Synthetic consumer');
133
146
  git(root, 'update-ref', 'refs/remotes/origin/main', 'HEAD');
@@ -148,6 +161,11 @@ async function fixture() {
148
161
  deliveryCapabilityVersion: 2,
149
162
  }),
150
163
  };
164
+ if (native) {
165
+ files['plugins/ai-delivery/runtime/package.json'] = files['package.json'];
166
+ files['plugins/ai-delivery/runtime/dist/cli.js'] = files['dist/cli.js'];
167
+ files['plugins/ai-delivery/README.md'] = 'Synthetic copied native plugin.\n';
168
+ }
151
169
  for (const [relative, bytes] of Object.entries(files)) {
152
170
  const path = join(packageRoot, relative);
153
171
  mkdirSync(dirname(path), { recursive: true });
@@ -155,6 +173,9 @@ async function fixture() {
155
173
  }
156
174
  const archivePath = join(base, 'reviewed archive; inert.tgz');
157
175
  execFileSync('tar', ['-czf', archivePath, '-C', dirname(packageRoot), 'package']);
176
+ const nativePluginRoot = join(base, 'native-plugin');
177
+ if (native)
178
+ cpSync(join(packageRoot, 'plugins', 'ai-delivery'), nativePluginRoot, { recursive: true });
158
179
  const bin = join(base, 'bin');
159
180
  mkdirSync(bin);
160
181
  const counter = join(base, 'installs');
@@ -174,6 +195,7 @@ async function fixture() {
174
195
  expectedSourceCommit: git(root, 'rev-parse', 'HEAD'),
175
196
  expectedConfigDigest: (await loadDeliveryConfig(root)).configDigest,
176
197
  runtimeDirectory: join(base, 'staged'),
198
+ ...(native ? { nativePluginRoot } : {}),
177
199
  };
178
200
  return {
179
201
  base,
@@ -188,9 +210,69 @@ async function fixture() {
188
210
  };
189
211
  }
190
212
  function admitInput(input, stageId, prior = null) {
191
- const { archivePath: _archive, expectedArchiveSha256: _digest, packageVersion: _version, ...rest } = input;
213
+ const { archivePath: _archive, expectedArchiveSha256: _digest, packageVersion: _version, nativePluginRoot: _native, ...rest } = input;
192
214
  return { ...rest, authority: 'runtime:admit', stageId, expectedPriorAdmissionSha256: prior };
193
215
  }
216
+ test('native stage binds exact copied runtime paths and bytes, reuses and admits only that completed stage', async () => {
217
+ const f = await fixture(true);
218
+ try {
219
+ const api = await producer();
220
+ const staged = await api.stageRuntime(f.input);
221
+ assert.equal(staged.admission.cliPath, join(f.input.nativePluginRoot, 'runtime', 'dist', 'cli.js'));
222
+ assert.equal(staged.admission.mcpLauncherPath, join(f.input.nativePluginRoot, 'dist', 'mcp-launcher.js'));
223
+ const record = readFileSync(join(f.input.runtimeDirectory, 'runtime-stage.json'));
224
+ assert.equal((await api.stageRuntime(f.input)).reused, true);
225
+ assert.deepEqual(readFileSync(join(f.input.runtimeDirectory, 'runtime-stage.json')), record);
226
+ assert.equal(readFileSync(f.counter, 'utf8'), 'x');
227
+ const other = join(f.base, 'other-native-plugin');
228
+ cpSync(f.input.nativePluginRoot, other, { recursive: true });
229
+ await assert.rejects(api.stageRuntime({ ...f.input, nativePluginRoot: other }), /incompatible setup intent/u);
230
+ const admitted = await api.admitRuntime(admitInput(f.input, staged.stageId));
231
+ assert.equal(admitted.admission.admissionId, staged.admission.admissionId);
232
+ assert.equal((await api.admitRuntime(admitInput(f.input, staged.stageId))).reused, true);
233
+ const readback = await assertDeliveryRuntimeAdmitted({
234
+ repoRoot: f.root,
235
+ runtimeEntryPath: staged.admission.cliPath,
236
+ });
237
+ assert.equal(readback.admissionId, staged.admission.admissionId);
238
+ writeFileSync(join(f.input.nativePluginRoot, 'README.md'), 'Changed native plugin bytes.\n');
239
+ await assert.rejects(api.stageRuntime(f.input), /native plugin.*reviewed/iu);
240
+ await assert.rejects(api.admitRuntime(admitInput(f.input, staged.stageId)), /native plugin.*reviewed/iu);
241
+ }
242
+ finally {
243
+ f.cleanup();
244
+ }
245
+ });
246
+ test('native stage refuses unreviewed cache bytes without completing or mutating the cache', async () => {
247
+ const f = await fixture(true);
248
+ try {
249
+ const api = await producer();
250
+ const target = join(f.input.nativePluginRoot, 'runtime', 'dist', 'cli.js');
251
+ const altered = 'console.log("unreviewed");\n';
252
+ writeFileSync(target, altered);
253
+ await assert.rejects(api.stageRuntime(f.input), /native plugin.*reviewed/iu);
254
+ assert.equal(readFileSync(target, 'utf8'), altered);
255
+ assert.equal(existsSync(join(f.root, '.git', 'ai-delivery', 'runtime-admission.json')), false);
256
+ assert.equal(existsSync(f.input.runtimeDirectory), false);
257
+ }
258
+ finally {
259
+ f.cleanup();
260
+ }
261
+ });
262
+ test('native stage rejects aliased or overlapping cache paths before installing', async () => {
263
+ const f = await fixture(true);
264
+ try {
265
+ const api = await producer();
266
+ const alias = join(f.base, 'native-alias');
267
+ symlinkSync(f.input.nativePluginRoot, alias);
268
+ await assert.rejects(api.stageRuntime({ ...f.input, nativePluginRoot: alias }), /symbolic link/u);
269
+ await assert.rejects(api.stageRuntime({ ...f.input, nativePluginRoot: f.base }), /overlap/iu);
270
+ assert.equal(existsSync(f.counter), false);
271
+ }
272
+ finally {
273
+ f.cleanup();
274
+ }
275
+ });
194
276
  test('runtime-stage controller memory is bounded before authenticated preflight', async () => {
195
277
  const api = await producer();
196
278
  const f = await fixture();
@@ -306,6 +388,137 @@ test('runtime-stage failure logs honor the caller retained-output cap outside th
306
388
  f.cleanup();
307
389
  }
308
390
  });
391
+ test('runtime-stage retries share the retained-output allowance and preserve earlier failure evidence', async () => {
392
+ const api = await producer();
393
+ const f = await fixture();
394
+ const logLimit = 1024 ** 2;
395
+ const input = {
396
+ ...f.input,
397
+ resourceBounds: {
398
+ maxAggregateRssBytes: 512 * 1024 ** 2,
399
+ minFreeDiskBytes: 1,
400
+ maxNewOutputBytes: 128 * 1024 ** 2,
401
+ maxCapturedOutputBytes: logLimit,
402
+ },
403
+ };
404
+ const outputDirectory = join(f.root, '.git/ai-delivery/verification@1/command-output');
405
+ try {
406
+ writeFileSync(join(f.base, 'fail'), 'go');
407
+ writeFileSync(join(f.base, 'log-bytes'), String(600 * 1024));
408
+ await assert.rejects(api.stageRuntime(input), /failed/u);
409
+ const prior = readdirSync(outputDirectory).map((name) => ({
410
+ name,
411
+ bytes: readFileSync(join(outputDirectory, name)),
412
+ }));
413
+ writeFileSync(join(f.base, 'log-bytes'), String(700 * 1024));
414
+ await assert.rejects(api.stageRuntime(input), /failed|captured output exceeded/u);
415
+ const total = readdirSync(outputDirectory).reduce((bytes, name) => bytes + lstatSync(join(outputDirectory, name)).size, 0);
416
+ assert.ok(total <= logLimit, `Retained retry logs ${total} exceed total allowance ${logLimit}.`);
417
+ for (const evidence of prior)
418
+ assert.deepEqual(readFileSync(join(outputDirectory, evidence.name)), evidence.bytes);
419
+ assert.equal(readFileSync(f.counter, 'utf8'), 'xx');
420
+ await assert.rejects(api.stageRuntime(input), /retained.*exhausted/u);
421
+ assert.equal(readFileSync(f.counter, 'utf8'), 'xx', 'exhausted allowance must refuse another installer invocation');
422
+ assert.equal(existsSync(f.input.runtimeDirectory), false);
423
+ assert.equal(digestBytes(readFileSync(f.input.archivePath)), f.input.expectedArchiveSha256);
424
+ }
425
+ finally {
426
+ f.cleanup();
427
+ }
428
+ });
429
+ for (const existing of ['oversized', 'corrupt', 'valid', 'empty']) {
430
+ test(`runtime-stage first publication bounds ${existing} shared output absent from its ledger`, async () => {
431
+ const api = await producer();
432
+ const f = await fixture();
433
+ const bytes = Buffer.alloc(existing === 'empty' ? 0 : 100 * 1024, 65);
434
+ const digest = digestBytes(bytes);
435
+ const directory = join(f.root, '.git/ai-delivery/verification@1/command-output');
436
+ const evidence = join(directory, `${digest.slice(7)}.bin`);
437
+ const ledgerDirectory = join(f.root, '.git/ai-delivery/verification@1/runtime-output', digestValue(f.root).slice(7));
438
+ try {
439
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
440
+ writeFileSync(evidence, existing === 'corrupt' ? Buffer.alloc(bytes.length, 66) : bytes, { mode: 0o600 });
441
+ if (existing === 'oversized')
442
+ truncateSync(evidence, 9 * 1024 ** 2);
443
+ const originalDigest = digestBytes(readFileSync(evidence));
444
+ const originalIdentity = lstatSync(evidence);
445
+ assert.equal(existsSync(ledgerDirectory), false, 'shared evidence must precede this stage ledger');
446
+ writeFileSync(join(f.base, 'fail'), 'go');
447
+ writeFileSync(join(f.base, 'log-bytes'), String(bytes.length));
448
+ sharedOutputRead.path = evidence;
449
+ sharedOutputRead.unboundedReads = 0;
450
+ await assert.rejects(api.stageRuntime(f.input), /exit 7/u);
451
+ assert.equal(sharedOutputRead.unboundedReads, 0, 'publication must bound existing evidence before reading it');
452
+ assert.equal(readFileSync(f.counter, 'utf8'), 'x');
453
+ const ledgerPath = join(ledgerDirectory, readdirSync(ledgerDirectory)[0]);
454
+ const ledger = JSON.parse(readFileSync(ledgerPath, 'utf8'));
455
+ const refused = existing === 'oversized' || existing === 'corrupt';
456
+ assert.deepEqual(ledger.outputs, [{ digest, bytes: bytes.length, status: refused ? 'reserved' : 'complete' }]);
457
+ const retainedLedger = readFileSync(ledgerPath);
458
+ await assert.rejects(api.stageRuntime(f.input), refused ? /corrupt|size.*bound/u : /exit 7/u);
459
+ assert.equal(readFileSync(f.counter, 'utf8'), refused ? 'x' : 'xx');
460
+ assert.equal(sharedOutputRead.unboundedReads, 0);
461
+ assert.deepEqual(readFileSync(ledgerPath), retainedLedger, 'retry must preserve the existing reservation or completed charge');
462
+ sharedOutputRead.path = '';
463
+ assert.equal(digestBytes(readFileSync(evidence)), originalDigest, 'existing shared evidence must remain immutable');
464
+ const after = lstatSync(evidence);
465
+ assert.equal(after.ino, originalIdentity.ino);
466
+ assert.equal(after.size, originalIdentity.size);
467
+ assert.equal(existsSync(f.input.runtimeDirectory), false);
468
+ }
469
+ finally {
470
+ sharedOutputRead.path = '';
471
+ sharedOutputRead.unboundedReads = 0;
472
+ f.cleanup();
473
+ }
474
+ });
475
+ }
476
+ for (const fault of [
477
+ 'corrupt ledger',
478
+ 'incompatible controller',
479
+ 'missing evidence',
480
+ 'corrupt evidence',
481
+ 'oversized evidence',
482
+ ]) {
483
+ test(`runtime-stage retained-output ${fault} refuses another installer without resetting its allowance`, async () => {
484
+ const api = await producer();
485
+ const f = await fixture();
486
+ try {
487
+ writeFileSync(join(f.base, 'fail'), 'go');
488
+ writeFileSync(join(f.base, 'log-bytes'), String(100 * 1024));
489
+ await assert.rejects(api.stageRuntime(f.input), /exit 7/u);
490
+ const directory = join(f.root, '.git/ai-delivery/verification@1/runtime-output', digestValue(f.root).slice(7));
491
+ const path = join(directory, readdirSync(directory)[0]);
492
+ const ledger = JSON.parse(readFileSync(path, 'utf8'));
493
+ const evidence = join(f.root, '.git/ai-delivery/verification@1/command-output', `${ledger.outputs[0].digest.slice(7)}.bin`);
494
+ if (fault === 'corrupt ledger') {
495
+ ledger.contentDigest = `sha256:${'0'.repeat(64)}`;
496
+ writeFileSync(path, JSON.stringify(ledger));
497
+ }
498
+ else if (fault === 'incompatible controller') {
499
+ ledger.producerDigest = `sha256:${'0'.repeat(64)}`;
500
+ const { contentDigest: _digest, ...content } = ledger;
501
+ ledger.contentDigest = digestValue(content);
502
+ writeFileSync(path, JSON.stringify(ledger));
503
+ }
504
+ else if (fault === 'missing evidence')
505
+ rmSync(evidence);
506
+ else if (fault === 'oversized evidence')
507
+ truncateSync(evidence, 9 * 1024 ** 2);
508
+ else
509
+ writeFileSync(evidence, 'changed immutable output');
510
+ const retained = readFileSync(path);
511
+ await assert.rejects(api.stageRuntime(f.input), /corrupt|incompatible|ENOENT|size.*bound/u);
512
+ assert.equal(readFileSync(f.counter, 'utf8'), 'x');
513
+ assert.deepEqual(readFileSync(path), retained, 'failed integrity check must preserve the charged ledger');
514
+ assert.equal(readdirSync(directory).length, 1);
515
+ assert.equal(existsSync(f.input.runtimeDirectory), false);
516
+ }
517
+ finally {
518
+ f.cleanup();
519
+ }
520
+ });
521
+ }
309
522
  test('runtime-stage explicitly isolates npm cache, logs, configuration and temporary destinations', async () => {
310
523
  const api = await producer();
311
524
  const f = await fixture();
@@ -331,6 +544,58 @@ test('runtime-stage explicitly isolates npm cache, logs, configuration and tempo
331
544
  f.cleanup();
332
545
  }
333
546
  });
547
+ test('runtime-stage cancellation shares prior failure charges and identical evidence is reused', async () => {
548
+ const api = await producer();
549
+ const f = await fixture();
550
+ const cancellation = new AbortController();
551
+ const input = {
552
+ ...f.input,
553
+ resourceBounds: {
554
+ maxAggregateRssBytes: 512 * 1024 ** 2,
555
+ minFreeDiskBytes: 1,
556
+ maxNewOutputBytes: 128 * 1024 ** 2,
557
+ maxCapturedOutputBytes: 1024 ** 2,
558
+ },
559
+ };
560
+ const directory = join(f.root, '.git/ai-delivery/verification@1/command-output');
561
+ const write = process.stderr.write.bind(process.stderr);
562
+ let sawCaptured = false;
563
+ const observation = vi.spyOn(process.stderr, 'write').mockImplementation((chunk, ...args) => {
564
+ const text = String(chunk);
565
+ if (text.startsWith('ai-delivery.verify ')) {
566
+ const value = JSON.parse(text.slice('ai-delivery.verify '.length));
567
+ if (value.capturedOutputBytes === 200 * 1024) {
568
+ sawCaptured = true;
569
+ cancellation.abort();
570
+ }
571
+ }
572
+ return write(chunk, ...args);
573
+ });
574
+ try {
575
+ writeFileSync(join(f.base, 'fail'), 'go');
576
+ writeFileSync(join(f.base, 'log-bytes'), String(100 * 1024));
577
+ await assert.rejects(api.stageRuntime(input), /exit 7/u);
578
+ await assert.rejects(api.stageRuntime(input), /exit 7/u);
579
+ assert.equal(readdirSync(directory).length, 1, 'identical complete command evidence must be reused');
580
+ const prior = readFileSync(join(directory, readdirSync(directory)[0]));
581
+ rmSync(join(f.base, 'fail'));
582
+ writeFileSync(join(f.base, 'wait'), 'go');
583
+ writeFileSync(join(f.base, 'log-bytes'), String(200 * 1024));
584
+ await assert.rejects(api.stageRuntime({ ...input, signal: cancellation.signal }), /cancelled/u);
585
+ assert.equal(sawCaptured, true, 'cancel only after the full owned output is captured');
586
+ assert.ok(readdirSync(directory).some((name) => readFileSync(join(directory, name)).equals(prior)));
587
+ const ledgerRoot = join(f.root, '.git/ai-delivery/verification@1/runtime-output', digestValue(f.root).slice(7));
588
+ const ledger = JSON.parse(readFileSync(join(ledgerRoot, readdirSync(ledgerRoot)[0]), 'utf8'));
589
+ assert.equal(ledger.outputs.reduce((total, output) => total + output.bytes, 0), 300 * 1024);
590
+ assert.equal(readFileSync(f.counter, 'utf8'), 'xxx');
591
+ assert.equal(existsSync(f.input.runtimeDirectory), false);
592
+ }
593
+ finally {
594
+ cancellation.abort();
595
+ observation.mockRestore();
596
+ f.cleanup();
597
+ }
598
+ });
334
599
  test('runtime-stage post-install preflight growth cannot publish completion', async () => {
335
600
  const api = await producer();
336
601
  const f = await fixture();
@@ -415,87 +680,114 @@ test('runtime-stage changed resource intent preserves compatible completed bytes
415
680
  f.cleanup();
416
681
  }
417
682
  });
418
- test('real npm isolation retains cache and logs inside the stage and preserves unrelated host destinations', async () => {
419
- const api = await producer();
420
- const realPath = process.env.PATH;
421
- const f = await fixture();
422
- const previous = {
423
- cache: process.env.npm_config_cache,
424
- logs: process.env.npm_config_logs_dir,
425
- userconfig: process.env.npm_config_userconfig,
426
- };
427
- try {
428
- process.env.PATH = realPath;
429
- const outside = join(f.base, 'unrelated-host');
430
- mkdirSync(outside);
431
- const cache = join(outside, 'cache');
432
- const logs = join(outside, 'logs');
433
- for (const path of [cache, logs]) {
434
- mkdirSync(path);
435
- writeFileSync(join(path, 'keep'), 'unrelated');
436
- }
437
- const userconfig = join(outside, 'user.npmrc');
438
- const config = `cache=${cache}\nlogs-dir=${logs}\n`;
439
- writeFileSync(userconfig, config);
440
- const nativeBin = join(f.base, 'native-bin');
441
- mkdirSync(nativeBin);
442
- const probe = join(f.input.runtimeDirectory, 'native-npm-observation.json');
443
- const npmRoot = join(dirname(process.execPath), '../lib/node_modules/npm');
444
- writeFileSync(join(nativeBin, 'npm'), `#!${process.execPath}\nconst fs=require('node:fs'),os=require('node:os');\nfs.writeFileSync(${JSON.stringify(probe)},JSON.stringify({args:process.argv.slice(2),cwd:process.cwd(),tmpdir:os.tmpdir(),npmVersion:require(${JSON.stringify(join(npmRoot, 'package.json'))}).version}),{mode:0o600});\nrequire(${JSON.stringify(join(npmRoot, 'bin/npm-cli.js'))});\n`);
445
- chmodSync(join(nativeBin, 'npm'), 0o755);
446
- process.env.PATH = `${nativeBin}:${realPath ?? ''}`;
447
- process.env.npm_config_cache = cache;
448
- process.env.npm_config_logs_dir = logs;
449
- process.env.npm_config_userconfig = userconfig;
450
- const stage = await api.stageRuntime({
451
- ...f.input,
452
- resourceBounds: {
453
- maxAggregateRssBytes: 512 * 1024 ** 2,
454
- minFreeDiskBytes: 1,
455
- maxNewOutputBytes: 128 * 1024 ** 2,
456
- maxCapturedOutputBytes: 1024 ** 2,
457
- },
458
- });
459
- assert.equal(existsSync(f.counter), false, 'synthetic npm must not execute');
460
- assert.equal(execFileSync(process.execPath, [stage.admission.cliPath, '--version'], { encoding: 'utf8' }).trim(), '0.3.5');
461
- assert.ok(readdirSync(join(f.input.runtimeDirectory, '.npm/cache')).length > 0);
462
- assert.ok(readdirSync(join(f.input.runtimeDirectory, '.npm/logs')).length > 0);
463
- const native = JSON.parse(readFileSync(probe, 'utf8'));
464
- assert.equal(native.npmVersion, '11.19.0');
465
- assert.equal(native.cwd, f.input.runtimeDirectory);
466
- assert.equal(native.tmpdir, join(f.input.runtimeDirectory, '.npm/tmp'));
467
- for (const option of ['--cache', '--logs-dir', '--userconfig', '--globalconfig']) {
468
- assert.ok(native.args.includes(option));
469
- assert.ok(native.args[native.args.indexOf(option) + 1].startsWith(`${f.input.runtimeDirectory}/`));
683
+ for (const mode of ['success', 'failure', 'cancel']) {
684
+ test(`real npm ${mode} disables file logs and preserves unrelated host destinations`, async () => {
685
+ const api = await producer();
686
+ const realPath = process.env.PATH;
687
+ const f = await fixture();
688
+ const cancellation = new AbortController();
689
+ let operation;
690
+ const previous = {
691
+ cache: process.env.npm_config_cache,
692
+ logs: process.env.npm_config_logs_dir,
693
+ userconfig: process.env.npm_config_userconfig,
694
+ };
695
+ try {
696
+ process.env.PATH = realPath;
697
+ const outside = join(f.base, 'unrelated-host');
698
+ mkdirSync(outside);
699
+ const cache = join(outside, 'cache');
700
+ const logs = join(outside, 'logs');
701
+ for (const path of [cache, logs]) {
702
+ mkdirSync(path);
703
+ writeFileSync(join(path, 'keep'), 'unrelated');
704
+ }
705
+ const userconfig = join(outside, 'user.npmrc');
706
+ const config = `cache=${cache}\nlogs-dir=${logs}\n`;
707
+ writeFileSync(userconfig, config);
708
+ const nativeBin = join(f.base, 'native-bin');
709
+ mkdirSync(nativeBin);
710
+ const probe = join(f.base, 'native-npm-observation.json');
711
+ const npmRoot = join(dirname(process.execPath), '../lib/node_modules/npm');
712
+ writeFileSync(join(nativeBin, 'npm'), `#!${process.execPath}\nconst fs=require('node:fs'),os=require('node:os'),path=require('node:path');\nconst observation={args:process.argv.slice(2),cwd:process.cwd(),tmpdir:os.tmpdir(),npmVersion:require(${JSON.stringify(join(npmRoot, 'package.json'))}).version};\nconst LogFile=require(${JSON.stringify(join(npmRoot, 'lib/utils/log-file.js'))}),load=LogFile.prototype.load;\nconst observe=()=>{const directory=path.join(process.cwd(),'.npm/logs');observation.fileLogs=fs.existsSync(directory)?fs.readdirSync(directory):[];observation.fileLogBytes=observation.fileLogs.reduce((sum,name)=>sum+fs.statSync(path.join(directory,name)).size,0);fs.writeFileSync(${JSON.stringify(probe)},JSON.stringify(observation),{mode:0o600});};\nLogFile.prototype.load=function(options){const result=load.call(this,options);observation.logsMax=options.logsMax;observe();${mode === 'cancel' ? 'setInterval(observe,20);' : ''}return result;};\nprocess.on('exit',observe);\n${mode === 'failure' ? 'process.argv[2]="synthetic-unknown-command";' : ''}\nrequire(${JSON.stringify(join(npmRoot, 'bin/npm-cli.js'))});\n`);
713
+ chmodSync(join(nativeBin, 'npm'), 0o755);
714
+ process.env.PATH = `${nativeBin}:${realPath ?? ''}`;
715
+ process.env.npm_config_cache = cache;
716
+ process.env.npm_config_logs_dir = logs;
717
+ process.env.npm_config_userconfig = userconfig;
718
+ operation = api.stageRuntime({
719
+ ...f.input,
720
+ signal: cancellation.signal,
721
+ resourceBounds: {
722
+ maxAggregateRssBytes: 512 * 1024 ** 2,
723
+ minFreeDiskBytes: 1,
724
+ maxNewOutputBytes: 128 * 1024 ** 2,
725
+ maxCapturedOutputBytes: 1024 ** 2,
726
+ },
727
+ });
728
+ void operation.catch(() => undefined);
729
+ if (mode === 'cancel') {
730
+ for (let i = 0; i < 1000 && !existsSync(probe); i++)
731
+ await new Promise((resolve) => setTimeout(resolve, 20));
732
+ assert.equal(existsSync(probe), true, 'actual npm log configuration was not observed');
733
+ cancellation.abort();
734
+ await assert.rejects(operation, /cancelled/u);
735
+ }
736
+ else if (mode === 'failure')
737
+ await assert.rejects(operation, /exit 1/u);
738
+ else {
739
+ const stage = await operation;
740
+ assert.equal(existsSync(f.counter), false, 'synthetic npm must not execute');
741
+ assert.equal(execFileSync(process.execPath, [stage.admission.cliPath, '--version'], { encoding: 'utf8' }).trim(), '0.3.5');
742
+ assert.ok(readdirSync(join(f.input.runtimeDirectory, '.npm/cache')).length > 0);
743
+ assert.deepEqual(readdirSync(join(f.input.runtimeDirectory, '.npm/logs')), []);
744
+ }
745
+ const native = JSON.parse(readFileSync(probe, 'utf8'));
746
+ assert.equal(native.npmVersion, '11.19.0');
747
+ assert.equal(native.cwd, f.input.runtimeDirectory);
748
+ assert.equal(native.tmpdir, join(f.input.runtimeDirectory, '.npm/tmp'));
749
+ assert.equal(native.logsMax, 0);
750
+ assert.equal(native.fileLogBytes, 0);
751
+ assert.deepEqual(native.fileLogs, []);
752
+ if (mode !== 'success')
753
+ assert.equal(existsSync(f.input.runtimeDirectory), false);
754
+ for (const option of ['--cache', '--logs-dir', '--userconfig', '--globalconfig']) {
755
+ assert.ok(native.args.includes(option));
756
+ assert.ok(native.args[native.args.indexOf(option) + 1].startsWith(`${f.input.runtimeDirectory}/`));
757
+ }
758
+ assert.deepEqual(readdirSync(cache), ['keep']);
759
+ assert.deepEqual(readdirSync(logs), ['keep']);
760
+ assert.equal(readFileSync(userconfig, 'utf8'), config);
761
+ assert.equal(existsSync(join(f.root, '.git/ai-delivery/runtime-admission.json')), false);
762
+ console.log('REAL_NPM_ISOLATION_RECEIPT', JSON.stringify({
763
+ mode,
764
+ npmFileLogBytes: native.fileLogBytes,
765
+ npmFileLogsDisabled: true,
766
+ temporaryAndConfigInsideStage: true,
767
+ unrelatedHostPreserved: true,
768
+ controllerInclusiveMemoryBytes: 512 * 1024 ** 2,
769
+ retainedLogCapBytes: 1024 ** 2,
770
+ hostActivated: false,
771
+ sharedAdmissionWritten: false,
772
+ }));
470
773
  }
471
- assert.deepEqual(readdirSync(cache), ['keep']);
472
- assert.deepEqual(readdirSync(logs), ['keep']);
473
- assert.equal(readFileSync(userconfig, 'utf8'), config);
474
- assert.equal(existsSync(join(f.root, '.git/ai-delivery/runtime-admission.json')), false);
475
- console.log('REAL_NPM_ISOLATION_RECEIPT', JSON.stringify({
476
- cacheAndLogsInsideStage: true,
477
- temporaryAndConfigInsideStage: true,
478
- unrelatedHostPreserved: true,
479
- controllerInclusiveMemoryBytes: 512 * 1024 ** 2,
480
- retainedLogCapBytes: 1024 ** 2,
481
- hostActivated: false,
482
- sharedAdmissionWritten: false,
483
- }));
484
- }
485
- finally {
486
- for (const [name, value] of [
487
- ['npm_config_cache', previous.cache],
488
- ['npm_config_logs_dir', previous.logs],
489
- ['npm_config_userconfig', previous.userconfig],
490
- ]) {
491
- if (value === undefined)
492
- delete process.env[name];
493
- else
494
- process.env[name] = value;
774
+ finally {
775
+ cancellation.abort();
776
+ await operation?.catch(() => undefined);
777
+ for (const [name, value] of [
778
+ ['npm_config_cache', previous.cache],
779
+ ['npm_config_logs_dir', previous.logs],
780
+ ['npm_config_userconfig', previous.userconfig],
781
+ ]) {
782
+ if (value === undefined)
783
+ delete process.env[name];
784
+ else
785
+ process.env[name] = value;
786
+ }
787
+ f.cleanup();
495
788
  }
496
- f.cleanup();
497
- }
498
- });
789
+ });
790
+ }
499
791
  test('public producer stages exact bytes, explicitly admits and reuses completed work without installing again', async () => {
500
792
  const api = await producer();
501
793
  const f = await fixture();
@@ -741,35 +1033,39 @@ test('post-rename errors reconcile desired bytes and never overwrite third-party
741
1033
  f.cleanup();
742
1034
  }
743
1035
  });
744
- test('failure and cancellation remove only the owned incomplete stage and preserve unrelated output', async () => {
745
- const api = await producer();
746
- const f = await fixture();
747
- const controller = new AbortController();
748
- try {
749
- const unrelated = join(f.base, 'unrelated');
750
- mkdirSync(unrelated);
751
- writeFileSync(join(unrelated, 'keep'), 'keep');
752
- writeFileSync(join(f.base, 'fail'), 'go');
753
- await assert.rejects(api.stageRuntime(f.input), /exit 7/u);
754
- assert.equal(existsSync(f.input.runtimeDirectory), false);
755
- rmSync(join(f.base, 'fail'));
756
- writeFileSync(join(f.base, 'wait'), 'go');
757
- const running = api.stageRuntime({ ...f.input, signal: controller.signal });
758
- void running.catch(() => undefined);
759
- for (let i = 0; i < 100 && !existsSync(join(f.input.runtimeDirectory, 'node_modules/.bin/ai-delivery')); i++)
760
- await new Promise((resolve) => setTimeout(resolve, 20));
761
- assert.equal(digestBytes(readFileSync(join(f.input.runtimeDirectory, 'reviewed-archive.tgz'))), f.input.expectedArchiveSha256);
762
- controller.abort();
763
- await assert.rejects(running, /cancelled/u);
764
- assert.equal(existsSync(f.input.runtimeDirectory), false);
765
- assert.equal(readFileSync(join(unrelated, 'keep'), 'utf8'), 'keep');
766
- assert.equal(digestBytes(readFileSync(f.input.archivePath)), f.input.expectedArchiveSha256);
767
- }
768
- finally {
769
- controller.abort();
770
- f.cleanup();
771
- }
772
- });
1036
+ for (const native of [false, true]) {
1037
+ test(`${native ? 'native ' : ''}failure and cancellation remove only the owned incomplete stage and preserve unrelated output`, async () => {
1038
+ const api = await producer();
1039
+ const f = await fixture(native);
1040
+ const controller = new AbortController();
1041
+ try {
1042
+ const unrelated = join(f.base, 'unrelated');
1043
+ mkdirSync(unrelated);
1044
+ writeFileSync(join(unrelated, 'keep'), 'keep');
1045
+ writeFileSync(join(f.base, 'fail'), 'go');
1046
+ await assert.rejects(api.stageRuntime(f.input), /exit 7/u);
1047
+ assert.equal(existsSync(f.input.runtimeDirectory), false);
1048
+ rmSync(join(f.base, 'fail'));
1049
+ writeFileSync(join(f.base, 'wait'), 'go');
1050
+ const running = api.stageRuntime({ ...f.input, signal: controller.signal });
1051
+ void running.catch(() => undefined);
1052
+ for (let i = 0; i < 100 && !existsSync(join(f.input.runtimeDirectory, 'node_modules/.bin/ai-delivery')); i++)
1053
+ await new Promise((resolve) => setTimeout(resolve, 20));
1054
+ assert.equal(digestBytes(readFileSync(join(f.input.runtimeDirectory, 'reviewed-archive.tgz'))), f.input.expectedArchiveSha256);
1055
+ controller.abort();
1056
+ await assert.rejects(running, /cancelled/u);
1057
+ assert.equal(existsSync(f.input.runtimeDirectory), false);
1058
+ assert.equal(readFileSync(join(unrelated, 'keep'), 'utf8'), 'keep');
1059
+ assert.equal(digestBytes(readFileSync(f.input.archivePath)), f.input.expectedArchiveSha256);
1060
+ if (native)
1061
+ assert.equal(readFileSync(join(f.input.nativePluginRoot, 'README.md'), 'utf8'), 'Synthetic copied native plugin.\n');
1062
+ }
1063
+ finally {
1064
+ controller.abort();
1065
+ f.cleanup();
1066
+ }
1067
+ });
1068
+ }
773
1069
  test('admission reports unknown durability with exact same-stage reconciliation and preserves desired bytes', async () => {
774
1070
  const api = await producer();
775
1071
  const f = await fixture();
@@ -834,10 +1130,10 @@ for (const denied of [
834
1130
  if (denied === 'source-drift')
835
1131
  git(f.root, 'commit', '--allow-empty', '-qm', 'source drift');
836
1132
  if (denied === 'config-drift') {
837
- writeFileSync(join(f.root, 'policy.mjs'), readFileSync(join(f.root, 'policy.mjs'), 'utf8') + 'deliverySettings.commandPolicy.checks.test="SKIP";\n');
838
- git(f.root, 'add', '.');
839
- git(f.root, 'commit', '-qm', 'config drift');
840
- f.input.expectedSourceCommit = git(f.root, 'rev-parse', 'HEAD');
1133
+ const userPath = process.env.AI_DELIVERY_CONFIG;
1134
+ const settings = JSON.parse(readFileSync(userPath, 'utf8'));
1135
+ settings.roles.author.identity = 'changed-author';
1136
+ writeFileSync(userPath, JSON.stringify(settings));
841
1137
  }
842
1138
  await assert.rejects(api.admitRuntime(admitInput(f.input, stage.stageId)));
843
1139
  assert.equal(readFileSync(path, 'utf8'), before);
@@ -851,98 +1147,192 @@ for (const denied of [
851
1147
  }
852
1148
  test('setup interruption child', { skip: !process.env.AI_DELIVERY_SETUP_INTERRUPT_INPUT }, async () => {
853
1149
  const api = await producer();
1150
+ const phase = process.env.AI_DELIVERY_SETUP_LOG_INTERRUPT_PHASE;
1151
+ if (phase) {
1152
+ const actual = atomicJson.writePrivateJsonFileAtomically;
1153
+ vi.spyOn(atomicJson, 'writePrivateJsonFileAtomically').mockImplementation((path, value) => {
1154
+ const record = value;
1155
+ const interrupt = path.includes('/runtime-output/') &&
1156
+ record.outputs?.[0]?.status === (phase === 'reservation' ? 'reserved' : 'complete');
1157
+ if (!(interrupt && phase === 'publication'))
1158
+ actual(path, value);
1159
+ if (interrupt) {
1160
+ writeFileSync(process.env.AI_DELIVERY_SETUP_LOG_INTERRUPT_MARKER, JSON.stringify({ phase, path, owner: process.pid }));
1161
+ process.kill(process.pid, 'SIGKILL');
1162
+ }
1163
+ });
1164
+ }
854
1165
  await api.stageRuntime(JSON.parse(process.env.AI_DELIVERY_SETUP_INTERRUPT_INPUT));
855
1166
  });
856
- test('stopped setup owners stay busy and orphan recovery confirms cleanup before retrying the incomplete install', async () => {
857
- const api = await producer();
858
- const f = await fixture();
859
- let child;
860
- let ownedPid;
861
- let ownedIdentity;
862
- let ownerPid;
863
- const peer = spawn(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { detached: true, stdio: 'ignore' });
864
- try {
865
- writeFileSync(join(f.base, 'wait'), 'go');
866
- child = spawn(process.execPath, [
867
- join(process.cwd(), 'node_modules/vitest/vitest.mjs'),
868
- 'run',
869
- 'dist/setup.test.js',
870
- '-t',
871
- '^setup interruption child$',
872
- ], {
873
- cwd: process.cwd(),
874
- stdio: 'ignore',
875
- env: { ...process.env, AI_DELIVERY_SETUP_INTERRUPT_INPUT: JSON.stringify(f.input) },
876
- });
877
- const closed = new Promise((resolve) => child.once('close', resolve));
878
- const writerPath = join(f.root, '.git/ai-delivery/writers@1', `${(await import('./delivery/index.js')).digestValue(f.root).slice(7)}.json`);
879
- let record;
880
- // Bound this cold installer-start observation, not the recovery run.
881
- for (let i = 0; i < 1000; i++) {
882
- if (existsSync(writerPath))
883
- record = JSON.parse(readFileSync(writerPath, 'utf8'));
884
- if (record?.command.phase === 'running' && existsSync(f.marker))
885
- break;
886
- await new Promise((resolve) => setTimeout(resolve, 20));
1167
+ for (const phase of ['reservation', 'publication']) {
1168
+ test(`runtime-stage retained-output recovery preserves charges after hard interruption at ${phase}`, async () => {
1169
+ const api = await producer();
1170
+ const f = await fixture();
1171
+ const marker = join(f.base, 'log-interruption-marker');
1172
+ const input = {
1173
+ ...f.input,
1174
+ resourceBounds: {
1175
+ maxAggregateRssBytes: 512 * 1024 ** 2,
1176
+ minFreeDiskBytes: 1,
1177
+ maxNewOutputBytes: 128 * 1024 ** 2,
1178
+ maxCapturedOutputBytes: 1024 ** 2,
1179
+ },
1180
+ };
1181
+ let child;
1182
+ try {
1183
+ writeFileSync(join(f.base, 'fail'), 'go');
1184
+ writeFileSync(join(f.base, 'log-bytes'), String(100 * 1024));
1185
+ child = spawn(process.execPath, [
1186
+ join(process.cwd(), 'node_modules/vitest/vitest.mjs'),
1187
+ 'run',
1188
+ new URL(import.meta.url).pathname,
1189
+ '-t',
1190
+ '^setup interruption child$',
1191
+ '--maxWorkers=1',
1192
+ '--no-file-parallelism',
1193
+ ], {
1194
+ env: {
1195
+ ...process.env,
1196
+ AI_DELIVERY_SETUP_INTERRUPT_INPUT: JSON.stringify(input),
1197
+ AI_DELIVERY_SETUP_LOG_INTERRUPT_PHASE: phase,
1198
+ AI_DELIVERY_SETUP_LOG_INTERRUPT_MARKER: marker,
1199
+ },
1200
+ stdio: 'ignore',
1201
+ });
1202
+ await new Promise((resolve) => child.once('close', () => resolve()));
1203
+ assert.equal(existsSync(marker), true, 'hard interruption must occur at the selected durable log boundary');
1204
+ const writerPath = join(f.root, '.git/ai-delivery/writers@1', `${digestValue(f.root).slice(7)}.json`);
1205
+ const stale = new Date(Date.now() - 20_000);
1206
+ utimesSync(`${writerPath}.lock`, stale, stale);
1207
+ const { path } = JSON.parse(readFileSync(marker, 'utf8'));
1208
+ const prior = JSON.parse(readFileSync(path, 'utf8'));
1209
+ assert.equal(prior.outputs[0].status, 'reserved');
1210
+ assert.equal(prior.outputs[0].bytes, 100 * 1024);
1211
+ const outputDirectory = join(f.root, '.git/ai-delivery/verification@1/command-output');
1212
+ const priorOutput = join(outputDirectory, `${prior.outputs[0].digest.slice(7)}.bin`);
1213
+ assert.equal(existsSync(priorOutput), phase === 'publication');
1214
+ const preserved = phase === 'publication' ? readFileSync(priorOutput) : undefined;
1215
+ writeFileSync(join(f.base, 'log-bytes'), String(128 * 1024));
1216
+ await assert.rejects(api.stageRuntime(input), /exit 7/u);
1217
+ const recovered = JSON.parse(readFileSync(path, 'utf8'));
1218
+ assert.equal(recovered.outputs.reduce((total, output) => total + output.bytes, 0), 228 * 1024);
1219
+ assert.equal(recovered.outputs[0].status, phase === 'publication' ? 'complete' : 'reserved');
1220
+ if (preserved)
1221
+ assert.deepEqual(readFileSync(priorOutput), preserved);
1222
+ if (phase === 'reservation') {
1223
+ writeFileSync(join(f.base, 'log-bytes'), String(100 * 1024));
1224
+ await assert.rejects(api.stageRuntime(input), /identical output cannot be rewritten/u);
1225
+ assert.equal(existsSync(priorOutput), false, 'unresolved reservation must not authorize a second raw-log write');
1226
+ }
1227
+ rmSync(join(f.base, 'fail'));
1228
+ rmSync(join(f.base, 'log-bytes'));
1229
+ const completed = await api.stageRuntime(input);
1230
+ const count = readFileSync(f.counter, 'utf8');
1231
+ assert.equal(completed.reused, false);
1232
+ assert.equal((await api.stageRuntime(input)).reused, true);
1233
+ assert.equal(readFileSync(f.counter, 'utf8'), count, 'completed recovered stage must skip the installer');
1234
+ assert.equal(readFileSync(completed.admission.cliPath, 'utf8'), "#!/usr/bin/env node\nconsole.log('0.3.5');\n");
887
1235
  }
888
- assert.equal(record?.command.phase, 'running');
889
- assert.equal(existsSync(f.marker), true, 'interrupt only after the owned installer actually starts');
890
- const snapshot = join(f.input.runtimeDirectory, 'reviewed-archive.tgz');
891
- assert.equal(digestBytes(readFileSync(snapshot)), f.input.expectedArchiveSha256);
892
- ownerPid = record.owner.pid;
893
- ownedPid = record.command.root.pid;
894
- ownedIdentity = record.command.root.identity;
895
- process.kill(ownerPid, 'SIGSTOP');
896
- const stale = new Date(Date.now() - 20_000);
897
- utimesSync(`${writerPath}.lock`, stale, stale);
898
- await assert.rejects(api.stageRuntime(f.input), /live verification writer/u);
899
- assert.equal(readFileSync(f.counter, 'utf8'), 'x');
900
- process.kill(ownerPid, 'SIGKILL');
901
- ownerPid = undefined;
902
- await closed;
903
- process.kill(ownedPid, 0);
904
- writeFileSync(snapshot, 'interrupted incomplete snapshot');
905
- rmSync(join(f.base, 'wait'));
906
- const recovered = await api.stageRuntime(f.input);
907
- assert.equal(recovered.reused, false);
908
- assert.equal(readFileSync(f.counter, 'utf8'), 'xx');
909
- assert.equal(existsSync(writerPath), false);
910
- assert.equal(digestBytes(readFileSync(snapshot)), f.input.expectedArchiveSha256);
911
- const completedSnapshot = lstatSync(snapshot);
912
- process.kill(peer.pid, 0);
913
- const state = spawnSync('/bin/ps', ['-p', String(ownedPid), '-o', 'stat='], { encoding: 'utf8' });
914
- assert.ok(state.status === 1 || state.stdout.trim().startsWith('Z'));
915
- ownedPid = undefined;
916
- assert.equal((await api.stageRuntime(f.input)).reused, true);
917
- assert.equal(readFileSync(f.counter, 'utf8'), 'xx');
918
- assert.equal(lstatSync(snapshot).ino, completedSnapshot.ino);
919
- console.log('SETUP_INTERRUPTION_RECEIPT', JSON.stringify({
920
- completedStageId: recovered.stageId,
921
- installerExecutions: 2,
922
- peerPreserved: true,
923
- ownedCleanupConfirmed: true,
924
- completedRetrySkipped: true,
925
- incompleteSnapshotRebuilt: true,
926
- completedSnapshotReused: true,
927
- archiveSha256: f.input.expectedArchiveSha256,
928
- }));
929
- }
930
- finally {
931
- if (ownerPid !== undefined) {
932
- process.kill(ownerPid, 'SIGCONT');
1236
+ finally {
1237
+ if (child?.exitCode === null && child.signalCode === null)
1238
+ child.kill('SIGKILL');
1239
+ f.cleanup();
1240
+ }
1241
+ });
1242
+ }
1243
+ for (const native of [false, true]) {
1244
+ test(`${native ? 'native ' : ''}stopped setup owners stay busy and orphan recovery confirms cleanup before retrying the incomplete install`, async () => {
1245
+ const api = await producer();
1246
+ const f = await fixture(native);
1247
+ let child;
1248
+ let ownedPid;
1249
+ let ownedIdentity;
1250
+ let ownerPid;
1251
+ const peer = spawn(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { detached: true, stdio: 'ignore' });
1252
+ try {
1253
+ writeFileSync(join(f.base, 'wait'), 'go');
1254
+ child = spawn(process.execPath, [
1255
+ join(process.cwd(), 'node_modules/vitest/vitest.mjs'),
1256
+ 'run',
1257
+ 'dist/setup.test.js',
1258
+ '-t',
1259
+ '^setup interruption child$',
1260
+ ], {
1261
+ cwd: process.cwd(),
1262
+ stdio: 'ignore',
1263
+ env: { ...process.env, AI_DELIVERY_SETUP_INTERRUPT_INPUT: JSON.stringify(f.input) },
1264
+ });
1265
+ const closed = new Promise((resolve) => child.once('close', resolve));
1266
+ const writerPath = join(f.root, '.git/ai-delivery/writers@1', `${(await import('./delivery/index.js')).digestValue(f.root).slice(7)}.json`);
1267
+ let record;
1268
+ // Bound this cold installer-start observation, not the recovery run.
1269
+ for (let i = 0; i < 1000; i++) {
1270
+ if (existsSync(writerPath))
1271
+ record = JSON.parse(readFileSync(writerPath, 'utf8'));
1272
+ if (record?.command.phase === 'running' && existsSync(f.marker))
1273
+ break;
1274
+ await new Promise((resolve) => setTimeout(resolve, 20));
1275
+ }
1276
+ assert.equal(record?.command.phase, 'running');
1277
+ assert.equal(existsSync(f.marker), true, 'interrupt only after the owned installer actually starts');
1278
+ const snapshot = join(f.input.runtimeDirectory, 'reviewed-archive.tgz');
1279
+ assert.equal(digestBytes(readFileSync(snapshot)), f.input.expectedArchiveSha256);
1280
+ ownerPid = record.owner.pid;
1281
+ ownedPid = record.command.root.pid;
1282
+ ownedIdentity = record.command.root.identity;
1283
+ process.kill(ownerPid, 'SIGSTOP');
1284
+ const stale = new Date(Date.now() - 20_000);
1285
+ utimesSync(`${writerPath}.lock`, stale, stale);
1286
+ await assert.rejects(api.stageRuntime(f.input), /live verification writer/u);
1287
+ assert.equal(readFileSync(f.counter, 'utf8'), 'x');
933
1288
  process.kill(ownerPid, 'SIGKILL');
1289
+ ownerPid = undefined;
1290
+ await closed;
1291
+ process.kill(ownedPid, 0);
1292
+ writeFileSync(snapshot, 'interrupted incomplete snapshot');
1293
+ rmSync(join(f.base, 'wait'));
1294
+ const recovered = await api.stageRuntime(f.input);
1295
+ assert.equal(recovered.reused, false);
1296
+ assert.equal(readFileSync(f.counter, 'utf8'), 'xx');
1297
+ assert.equal(existsSync(writerPath), false);
1298
+ assert.equal(digestBytes(readFileSync(snapshot)), f.input.expectedArchiveSha256);
1299
+ const completedSnapshot = lstatSync(snapshot);
1300
+ process.kill(peer.pid, 0);
1301
+ const state = spawnSync('/bin/ps', ['-p', String(ownedPid), '-o', 'stat='], { encoding: 'utf8' });
1302
+ assert.ok(state.status === 1 || state.stdout.trim().startsWith('Z'));
1303
+ ownedPid = undefined;
1304
+ assert.equal((await api.stageRuntime(f.input)).reused, true);
1305
+ assert.equal(readFileSync(f.counter, 'utf8'), 'xx');
1306
+ assert.equal(lstatSync(snapshot).ino, completedSnapshot.ino);
1307
+ console.log('SETUP_INTERRUPTION_RECEIPT', JSON.stringify({
1308
+ completedStageId: recovered.stageId,
1309
+ installerExecutions: 2,
1310
+ peerPreserved: true,
1311
+ ownedCleanupConfirmed: true,
1312
+ completedRetrySkipped: true,
1313
+ incompleteSnapshotRebuilt: true,
1314
+ completedSnapshotReused: true,
1315
+ archiveSha256: f.input.expectedArchiveSha256,
1316
+ nativePluginRoot: f.input.nativePluginRoot ?? null,
1317
+ }));
934
1318
  }
935
- if (ownedPid !== undefined &&
936
- spawnSync('/bin/ps', ['-p', String(ownedPid), '-o', 'lstart='], { encoding: 'utf8' }).stdout.trim() ===
937
- ownedIdentity)
938
- process.kill(ownedPid, 'SIGKILL');
939
- if (peer.exitCode === null && peer.signalCode === null)
940
- peer.kill('SIGKILL');
941
- if (child?.exitCode === null && child.signalCode === null)
942
- child.kill('SIGKILL');
943
- f.cleanup();
944
- }
945
- });
1319
+ finally {
1320
+ if (ownerPid !== undefined) {
1321
+ process.kill(ownerPid, 'SIGCONT');
1322
+ process.kill(ownerPid, 'SIGKILL');
1323
+ }
1324
+ if (ownedPid !== undefined &&
1325
+ spawnSync('/bin/ps', ['-p', String(ownedPid), '-o', 'lstart='], { encoding: 'utf8' }).stdout.trim() ===
1326
+ ownedIdentity)
1327
+ process.kill(ownedPid, 'SIGKILL');
1328
+ if (peer.exitCode === null && peer.signalCode === null)
1329
+ peer.kill('SIGKILL');
1330
+ if (child?.exitCode === null && child.signalCode === null)
1331
+ child.kill('SIGKILL');
1332
+ f.cleanup();
1333
+ }
1334
+ });
1335
+ }
946
1336
  test('CLI stage and admit execute the same public producer with explicit authority and prior absence', async () => {
947
1337
  const f = await fixture();
948
1338
  const argv = process.argv;