@aviaratech/ai-delivery 0.3.21 → 0.3.23

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 (57) hide show
  1. package/README.md +41 -10
  2. package/dist/agent.d.ts +2 -1
  3. package/dist/agent.js +1 -1
  4. package/dist/agent.js.map +1 -1
  5. package/dist/cli.js +77 -0
  6. package/dist/cli.js.map +1 -1
  7. package/dist/delivery/common.d.ts +4 -1
  8. package/dist/delivery/common.js +49 -6
  9. package/dist/delivery/common.js.map +1 -1
  10. package/dist/delivery/stage.d.ts +6 -0
  11. package/dist/delivery/stage.js +11 -2
  12. package/dist/delivery/stage.js.map +1 -1
  13. package/dist/dispatch.js +6 -1
  14. package/dist/dispatch.js.map +1 -1
  15. package/dist/github/relationships.d.ts +4 -1
  16. package/dist/github/relationships.js +30 -3
  17. package/dist/github/relationships.js.map +1 -1
  18. package/dist/issue.d.ts +64 -1
  19. package/dist/issue.js +350 -3
  20. package/dist/issue.js.map +1 -1
  21. package/dist/issueTracking.test.d.ts +1 -0
  22. package/dist/issueTracking.test.js +624 -0
  23. package/dist/issueTracking.test.js.map +1 -0
  24. package/dist/lifecycle.test.js +4 -0
  25. package/dist/lifecycle.test.js.map +1 -1
  26. package/dist/mcp/index.js +2 -1
  27. package/dist/mcp/index.js.map +1 -1
  28. package/dist/mcp/tools.d.ts +56 -0
  29. package/dist/mcp/tools.js +41 -2
  30. package/dist/mcp/tools.js.map +1 -1
  31. package/dist/pluginInstaller.d.ts +51 -0
  32. package/dist/pluginInstaller.js +774 -0
  33. package/dist/pluginInstaller.js.map +1 -0
  34. package/dist/pluginInstaller.test.d.ts +1 -0
  35. package/dist/pluginInstaller.test.js +543 -0
  36. package/dist/pluginInstaller.test.js.map +1 -0
  37. package/dist/pluginPackaging.test.d.ts +1 -0
  38. package/dist/pluginPackaging.test.js +116 -0
  39. package/dist/pluginPackaging.test.js.map +1 -0
  40. package/dist/setup.d.ts +1 -0
  41. package/dist/setup.js +71 -10
  42. package/dist/setup.js.map +1 -1
  43. package/dist/setup.test.js +584 -199
  44. package/dist/setup.test.js.map +1 -1
  45. package/dist/verification.d.ts +61 -0
  46. package/dist/verification.js +168 -6
  47. package/dist/verification.js.map +1 -1
  48. package/package.json +4 -4
  49. package/plugins/ai-delivery/.claude-plugin/plugin.json +6 -1
  50. package/plugins/ai-delivery/LICENSE +21 -0
  51. package/plugins/ai-delivery/README.md +4 -2
  52. package/plugins/ai-delivery/dist/mcp-launcher.js +19 -8
  53. package/plugins/ai-delivery/mcp.json +10 -0
  54. package/plugins/ai-delivery/plugin.json +10 -0
  55. package/plugins/ai-delivery/runtime/dist/THIRD-PARTY-NOTICES.md +1010 -0
  56. package/plugins/ai-delivery/runtime/dist/cli.js +285 -0
  57. package/plugins/ai-delivery/runtime/package.json +13 -0
@@ -1,20 +1,28 @@
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
6
  import { 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
13
  const syncFailure = vi.hoisted(() => ({ directory: '' }));
14
+ const sharedOutputRead = vi.hoisted(() => ({ path: '', unboundedReads: 0 }));
14
15
  vi.mock('node:fs', async (original) => {
15
16
  const actual = await original();
16
17
  return {
17
18
  ...actual,
19
+ readFileSync: (...args) => {
20
+ if (sharedOutputRead.path && String(args[0]) === sharedOutputRead.path) {
21
+ sharedOutputRead.unboundedReads++;
22
+ throw new Error('Unbounded shared command-output read attempted');
23
+ }
24
+ return actual.readFileSync(...args);
25
+ },
18
26
  fsyncSync: (fd) => {
19
27
  if (syncFailure.directory &&
20
28
  actual.existsSync(syncFailure.directory) &&
@@ -98,7 +106,7 @@ async function producer() {
98
106
  function git(root, ...args) {
99
107
  return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
100
108
  }
101
- async function fixture() {
109
+ async function fixture(native = false) {
102
110
  actors.author = 'host-user';
103
111
  actors.reviewer = 'reviewer[bot]';
104
112
  actors.access = true;
@@ -148,6 +156,11 @@ async function fixture() {
148
156
  deliveryCapabilityVersion: 2,
149
157
  }),
150
158
  };
159
+ if (native) {
160
+ files['plugins/ai-delivery/runtime/package.json'] = files['package.json'];
161
+ files['plugins/ai-delivery/runtime/dist/cli.js'] = files['dist/cli.js'];
162
+ files['plugins/ai-delivery/README.md'] = 'Synthetic copied native plugin.\n';
163
+ }
151
164
  for (const [relative, bytes] of Object.entries(files)) {
152
165
  const path = join(packageRoot, relative);
153
166
  mkdirSync(dirname(path), { recursive: true });
@@ -155,6 +168,9 @@ async function fixture() {
155
168
  }
156
169
  const archivePath = join(base, 'reviewed archive; inert.tgz');
157
170
  execFileSync('tar', ['-czf', archivePath, '-C', dirname(packageRoot), 'package']);
171
+ const nativePluginRoot = join(base, 'native-plugin');
172
+ if (native)
173
+ cpSync(join(packageRoot, 'plugins', 'ai-delivery'), nativePluginRoot, { recursive: true });
158
174
  const bin = join(base, 'bin');
159
175
  mkdirSync(bin);
160
176
  const counter = join(base, 'installs');
@@ -174,6 +190,7 @@ async function fixture() {
174
190
  expectedSourceCommit: git(root, 'rev-parse', 'HEAD'),
175
191
  expectedConfigDigest: (await loadDeliveryConfig(root)).configDigest,
176
192
  runtimeDirectory: join(base, 'staged'),
193
+ ...(native ? { nativePluginRoot } : {}),
177
194
  };
178
195
  return {
179
196
  base,
@@ -188,9 +205,69 @@ async function fixture() {
188
205
  };
189
206
  }
190
207
  function admitInput(input, stageId, prior = null) {
191
- const { archivePath: _archive, expectedArchiveSha256: _digest, packageVersion: _version, ...rest } = input;
208
+ const { archivePath: _archive, expectedArchiveSha256: _digest, packageVersion: _version, nativePluginRoot: _native, ...rest } = input;
192
209
  return { ...rest, authority: 'runtime:admit', stageId, expectedPriorAdmissionSha256: prior };
193
210
  }
211
+ test('native stage binds exact copied runtime paths and bytes, reuses and admits only that completed stage', async () => {
212
+ const f = await fixture(true);
213
+ try {
214
+ const api = await producer();
215
+ const staged = await api.stageRuntime(f.input);
216
+ assert.equal(staged.admission.cliPath, join(f.input.nativePluginRoot, 'runtime', 'dist', 'cli.js'));
217
+ assert.equal(staged.admission.mcpLauncherPath, join(f.input.nativePluginRoot, 'dist', 'mcp-launcher.js'));
218
+ const record = readFileSync(join(f.input.runtimeDirectory, 'runtime-stage.json'));
219
+ assert.equal((await api.stageRuntime(f.input)).reused, true);
220
+ assert.deepEqual(readFileSync(join(f.input.runtimeDirectory, 'runtime-stage.json')), record);
221
+ assert.equal(readFileSync(f.counter, 'utf8'), 'x');
222
+ const other = join(f.base, 'other-native-plugin');
223
+ cpSync(f.input.nativePluginRoot, other, { recursive: true });
224
+ await assert.rejects(api.stageRuntime({ ...f.input, nativePluginRoot: other }), /incompatible setup intent/u);
225
+ const admitted = await api.admitRuntime(admitInput(f.input, staged.stageId));
226
+ assert.equal(admitted.admission.admissionId, staged.admission.admissionId);
227
+ assert.equal((await api.admitRuntime(admitInput(f.input, staged.stageId))).reused, true);
228
+ const readback = await assertDeliveryRuntimeAdmitted({
229
+ repoRoot: f.root,
230
+ runtimeEntryPath: staged.admission.cliPath,
231
+ });
232
+ assert.equal(readback.admissionId, staged.admission.admissionId);
233
+ writeFileSync(join(f.input.nativePluginRoot, 'README.md'), 'Changed native plugin bytes.\n');
234
+ await assert.rejects(api.stageRuntime(f.input), /native plugin.*reviewed/iu);
235
+ await assert.rejects(api.admitRuntime(admitInput(f.input, staged.stageId)), /native plugin.*reviewed/iu);
236
+ }
237
+ finally {
238
+ f.cleanup();
239
+ }
240
+ });
241
+ test('native stage refuses unreviewed cache bytes without completing or mutating the cache', async () => {
242
+ const f = await fixture(true);
243
+ try {
244
+ const api = await producer();
245
+ const target = join(f.input.nativePluginRoot, 'runtime', 'dist', 'cli.js');
246
+ const altered = 'console.log("unreviewed");\n';
247
+ writeFileSync(target, altered);
248
+ await assert.rejects(api.stageRuntime(f.input), /native plugin.*reviewed/iu);
249
+ assert.equal(readFileSync(target, 'utf8'), altered);
250
+ assert.equal(existsSync(join(f.root, '.git', 'ai-delivery', 'runtime-admission.json')), false);
251
+ assert.equal(existsSync(f.input.runtimeDirectory), false);
252
+ }
253
+ finally {
254
+ f.cleanup();
255
+ }
256
+ });
257
+ test('native stage rejects aliased or overlapping cache paths before installing', async () => {
258
+ const f = await fixture(true);
259
+ try {
260
+ const api = await producer();
261
+ const alias = join(f.base, 'native-alias');
262
+ symlinkSync(f.input.nativePluginRoot, alias);
263
+ await assert.rejects(api.stageRuntime({ ...f.input, nativePluginRoot: alias }), /symbolic link/u);
264
+ await assert.rejects(api.stageRuntime({ ...f.input, nativePluginRoot: f.base }), /overlap/iu);
265
+ assert.equal(existsSync(f.counter), false);
266
+ }
267
+ finally {
268
+ f.cleanup();
269
+ }
270
+ });
194
271
  test('runtime-stage controller memory is bounded before authenticated preflight', async () => {
195
272
  const api = await producer();
196
273
  const f = await fixture();
@@ -306,6 +383,137 @@ test('runtime-stage failure logs honor the caller retained-output cap outside th
306
383
  f.cleanup();
307
384
  }
308
385
  });
386
+ test('runtime-stage retries share the retained-output allowance and preserve earlier failure evidence', async () => {
387
+ const api = await producer();
388
+ const f = await fixture();
389
+ const logLimit = 1024 ** 2;
390
+ const input = {
391
+ ...f.input,
392
+ resourceBounds: {
393
+ maxAggregateRssBytes: 512 * 1024 ** 2,
394
+ minFreeDiskBytes: 1,
395
+ maxNewOutputBytes: 128 * 1024 ** 2,
396
+ maxCapturedOutputBytes: logLimit,
397
+ },
398
+ };
399
+ const outputDirectory = join(f.root, '.git/ai-delivery/verification@1/command-output');
400
+ try {
401
+ writeFileSync(join(f.base, 'fail'), 'go');
402
+ writeFileSync(join(f.base, 'log-bytes'), String(600 * 1024));
403
+ await assert.rejects(api.stageRuntime(input), /failed/u);
404
+ const prior = readdirSync(outputDirectory).map((name) => ({
405
+ name,
406
+ bytes: readFileSync(join(outputDirectory, name)),
407
+ }));
408
+ writeFileSync(join(f.base, 'log-bytes'), String(700 * 1024));
409
+ await assert.rejects(api.stageRuntime(input), /failed|captured output exceeded/u);
410
+ const total = readdirSync(outputDirectory).reduce((bytes, name) => bytes + lstatSync(join(outputDirectory, name)).size, 0);
411
+ assert.ok(total <= logLimit, `Retained retry logs ${total} exceed total allowance ${logLimit}.`);
412
+ for (const evidence of prior)
413
+ assert.deepEqual(readFileSync(join(outputDirectory, evidence.name)), evidence.bytes);
414
+ assert.equal(readFileSync(f.counter, 'utf8'), 'xx');
415
+ await assert.rejects(api.stageRuntime(input), /retained.*exhausted/u);
416
+ assert.equal(readFileSync(f.counter, 'utf8'), 'xx', 'exhausted allowance must refuse another installer invocation');
417
+ assert.equal(existsSync(f.input.runtimeDirectory), false);
418
+ assert.equal(digestBytes(readFileSync(f.input.archivePath)), f.input.expectedArchiveSha256);
419
+ }
420
+ finally {
421
+ f.cleanup();
422
+ }
423
+ });
424
+ for (const existing of ['oversized', 'corrupt', 'valid', 'empty']) {
425
+ test(`runtime-stage first publication bounds ${existing} shared output absent from its ledger`, async () => {
426
+ const api = await producer();
427
+ const f = await fixture();
428
+ const bytes = Buffer.alloc(existing === 'empty' ? 0 : 100 * 1024, 65);
429
+ const digest = digestBytes(bytes);
430
+ const directory = join(f.root, '.git/ai-delivery/verification@1/command-output');
431
+ const evidence = join(directory, `${digest.slice(7)}.bin`);
432
+ const ledgerDirectory = join(f.root, '.git/ai-delivery/verification@1/runtime-output', digestValue(f.root).slice(7));
433
+ try {
434
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
435
+ writeFileSync(evidence, existing === 'corrupt' ? Buffer.alloc(bytes.length, 66) : bytes, { mode: 0o600 });
436
+ if (existing === 'oversized')
437
+ truncateSync(evidence, 9 * 1024 ** 2);
438
+ const originalDigest = digestBytes(readFileSync(evidence));
439
+ const originalIdentity = lstatSync(evidence);
440
+ assert.equal(existsSync(ledgerDirectory), false, 'shared evidence must precede this stage ledger');
441
+ writeFileSync(join(f.base, 'fail'), 'go');
442
+ writeFileSync(join(f.base, 'log-bytes'), String(bytes.length));
443
+ sharedOutputRead.path = evidence;
444
+ sharedOutputRead.unboundedReads = 0;
445
+ await assert.rejects(api.stageRuntime(f.input), /exit 7/u);
446
+ assert.equal(sharedOutputRead.unboundedReads, 0, 'publication must bound existing evidence before reading it');
447
+ assert.equal(readFileSync(f.counter, 'utf8'), 'x');
448
+ const ledgerPath = join(ledgerDirectory, readdirSync(ledgerDirectory)[0]);
449
+ const ledger = JSON.parse(readFileSync(ledgerPath, 'utf8'));
450
+ const refused = existing === 'oversized' || existing === 'corrupt';
451
+ assert.deepEqual(ledger.outputs, [{ digest, bytes: bytes.length, status: refused ? 'reserved' : 'complete' }]);
452
+ const retainedLedger = readFileSync(ledgerPath);
453
+ await assert.rejects(api.stageRuntime(f.input), refused ? /corrupt|size.*bound/u : /exit 7/u);
454
+ assert.equal(readFileSync(f.counter, 'utf8'), refused ? 'x' : 'xx');
455
+ assert.equal(sharedOutputRead.unboundedReads, 0);
456
+ assert.deepEqual(readFileSync(ledgerPath), retainedLedger, 'retry must preserve the existing reservation or completed charge');
457
+ sharedOutputRead.path = '';
458
+ assert.equal(digestBytes(readFileSync(evidence)), originalDigest, 'existing shared evidence must remain immutable');
459
+ const after = lstatSync(evidence);
460
+ assert.equal(after.ino, originalIdentity.ino);
461
+ assert.equal(after.size, originalIdentity.size);
462
+ assert.equal(existsSync(f.input.runtimeDirectory), false);
463
+ }
464
+ finally {
465
+ sharedOutputRead.path = '';
466
+ sharedOutputRead.unboundedReads = 0;
467
+ f.cleanup();
468
+ }
469
+ });
470
+ }
471
+ for (const fault of [
472
+ 'corrupt ledger',
473
+ 'incompatible controller',
474
+ 'missing evidence',
475
+ 'corrupt evidence',
476
+ 'oversized evidence',
477
+ ]) {
478
+ test(`runtime-stage retained-output ${fault} refuses another installer without resetting its allowance`, async () => {
479
+ const api = await producer();
480
+ const f = await fixture();
481
+ try {
482
+ writeFileSync(join(f.base, 'fail'), 'go');
483
+ writeFileSync(join(f.base, 'log-bytes'), String(100 * 1024));
484
+ await assert.rejects(api.stageRuntime(f.input), /exit 7/u);
485
+ const directory = join(f.root, '.git/ai-delivery/verification@1/runtime-output', digestValue(f.root).slice(7));
486
+ const path = join(directory, readdirSync(directory)[0]);
487
+ const ledger = JSON.parse(readFileSync(path, 'utf8'));
488
+ const evidence = join(f.root, '.git/ai-delivery/verification@1/command-output', `${ledger.outputs[0].digest.slice(7)}.bin`);
489
+ if (fault === 'corrupt ledger') {
490
+ ledger.contentDigest = `sha256:${'0'.repeat(64)}`;
491
+ writeFileSync(path, JSON.stringify(ledger));
492
+ }
493
+ else if (fault === 'incompatible controller') {
494
+ ledger.producerDigest = `sha256:${'0'.repeat(64)}`;
495
+ const { contentDigest: _digest, ...content } = ledger;
496
+ ledger.contentDigest = digestValue(content);
497
+ writeFileSync(path, JSON.stringify(ledger));
498
+ }
499
+ else if (fault === 'missing evidence')
500
+ rmSync(evidence);
501
+ else if (fault === 'oversized evidence')
502
+ truncateSync(evidence, 9 * 1024 ** 2);
503
+ else
504
+ writeFileSync(evidence, 'changed immutable output');
505
+ const retained = readFileSync(path);
506
+ await assert.rejects(api.stageRuntime(f.input), /corrupt|incompatible|ENOENT|size.*bound/u);
507
+ assert.equal(readFileSync(f.counter, 'utf8'), 'x');
508
+ assert.deepEqual(readFileSync(path), retained, 'failed integrity check must preserve the charged ledger');
509
+ assert.equal(readdirSync(directory).length, 1);
510
+ assert.equal(existsSync(f.input.runtimeDirectory), false);
511
+ }
512
+ finally {
513
+ f.cleanup();
514
+ }
515
+ });
516
+ }
309
517
  test('runtime-stage explicitly isolates npm cache, logs, configuration and temporary destinations', async () => {
310
518
  const api = await producer();
311
519
  const f = await fixture();
@@ -331,6 +539,58 @@ test('runtime-stage explicitly isolates npm cache, logs, configuration and tempo
331
539
  f.cleanup();
332
540
  }
333
541
  });
542
+ test('runtime-stage cancellation shares prior failure charges and identical evidence is reused', async () => {
543
+ const api = await producer();
544
+ const f = await fixture();
545
+ const cancellation = new AbortController();
546
+ const input = {
547
+ ...f.input,
548
+ resourceBounds: {
549
+ maxAggregateRssBytes: 512 * 1024 ** 2,
550
+ minFreeDiskBytes: 1,
551
+ maxNewOutputBytes: 128 * 1024 ** 2,
552
+ maxCapturedOutputBytes: 1024 ** 2,
553
+ },
554
+ };
555
+ const directory = join(f.root, '.git/ai-delivery/verification@1/command-output');
556
+ const write = process.stderr.write.bind(process.stderr);
557
+ let sawCaptured = false;
558
+ const observation = vi.spyOn(process.stderr, 'write').mockImplementation((chunk, ...args) => {
559
+ const text = String(chunk);
560
+ if (text.startsWith('ai-delivery.verify ')) {
561
+ const value = JSON.parse(text.slice('ai-delivery.verify '.length));
562
+ if (value.capturedOutputBytes === 200 * 1024) {
563
+ sawCaptured = true;
564
+ cancellation.abort();
565
+ }
566
+ }
567
+ return write(chunk, ...args);
568
+ });
569
+ try {
570
+ writeFileSync(join(f.base, 'fail'), 'go');
571
+ writeFileSync(join(f.base, 'log-bytes'), String(100 * 1024));
572
+ await assert.rejects(api.stageRuntime(input), /exit 7/u);
573
+ await assert.rejects(api.stageRuntime(input), /exit 7/u);
574
+ assert.equal(readdirSync(directory).length, 1, 'identical complete command evidence must be reused');
575
+ const prior = readFileSync(join(directory, readdirSync(directory)[0]));
576
+ rmSync(join(f.base, 'fail'));
577
+ writeFileSync(join(f.base, 'wait'), 'go');
578
+ writeFileSync(join(f.base, 'log-bytes'), String(200 * 1024));
579
+ await assert.rejects(api.stageRuntime({ ...input, signal: cancellation.signal }), /cancelled/u);
580
+ assert.equal(sawCaptured, true, 'cancel only after the full owned output is captured');
581
+ assert.ok(readdirSync(directory).some((name) => readFileSync(join(directory, name)).equals(prior)));
582
+ const ledgerRoot = join(f.root, '.git/ai-delivery/verification@1/runtime-output', digestValue(f.root).slice(7));
583
+ const ledger = JSON.parse(readFileSync(join(ledgerRoot, readdirSync(ledgerRoot)[0]), 'utf8'));
584
+ assert.equal(ledger.outputs.reduce((total, output) => total + output.bytes, 0), 300 * 1024);
585
+ assert.equal(readFileSync(f.counter, 'utf8'), 'xxx');
586
+ assert.equal(existsSync(f.input.runtimeDirectory), false);
587
+ }
588
+ finally {
589
+ cancellation.abort();
590
+ observation.mockRestore();
591
+ f.cleanup();
592
+ }
593
+ });
334
594
  test('runtime-stage post-install preflight growth cannot publish completion', async () => {
335
595
  const api = await producer();
336
596
  const f = await fixture();
@@ -415,87 +675,114 @@ test('runtime-stage changed resource intent preserves compatible completed bytes
415
675
  f.cleanup();
416
676
  }
417
677
  });
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}/`));
678
+ for (const mode of ['success', 'failure', 'cancel']) {
679
+ test(`real npm ${mode} disables file logs and preserves unrelated host destinations`, async () => {
680
+ const api = await producer();
681
+ const realPath = process.env.PATH;
682
+ const f = await fixture();
683
+ const cancellation = new AbortController();
684
+ let operation;
685
+ const previous = {
686
+ cache: process.env.npm_config_cache,
687
+ logs: process.env.npm_config_logs_dir,
688
+ userconfig: process.env.npm_config_userconfig,
689
+ };
690
+ try {
691
+ process.env.PATH = realPath;
692
+ const outside = join(f.base, 'unrelated-host');
693
+ mkdirSync(outside);
694
+ const cache = join(outside, 'cache');
695
+ const logs = join(outside, 'logs');
696
+ for (const path of [cache, logs]) {
697
+ mkdirSync(path);
698
+ writeFileSync(join(path, 'keep'), 'unrelated');
699
+ }
700
+ const userconfig = join(outside, 'user.npmrc');
701
+ const config = `cache=${cache}\nlogs-dir=${logs}\n`;
702
+ writeFileSync(userconfig, config);
703
+ const nativeBin = join(f.base, 'native-bin');
704
+ mkdirSync(nativeBin);
705
+ const probe = join(f.base, 'native-npm-observation.json');
706
+ const npmRoot = join(dirname(process.execPath), '../lib/node_modules/npm');
707
+ 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`);
708
+ chmodSync(join(nativeBin, 'npm'), 0o755);
709
+ process.env.PATH = `${nativeBin}:${realPath ?? ''}`;
710
+ process.env.npm_config_cache = cache;
711
+ process.env.npm_config_logs_dir = logs;
712
+ process.env.npm_config_userconfig = userconfig;
713
+ operation = api.stageRuntime({
714
+ ...f.input,
715
+ signal: cancellation.signal,
716
+ resourceBounds: {
717
+ maxAggregateRssBytes: 512 * 1024 ** 2,
718
+ minFreeDiskBytes: 1,
719
+ maxNewOutputBytes: 128 * 1024 ** 2,
720
+ maxCapturedOutputBytes: 1024 ** 2,
721
+ },
722
+ });
723
+ void operation.catch(() => undefined);
724
+ if (mode === 'cancel') {
725
+ for (let i = 0; i < 1000 && !existsSync(probe); i++)
726
+ await new Promise((resolve) => setTimeout(resolve, 20));
727
+ assert.equal(existsSync(probe), true, 'actual npm log configuration was not observed');
728
+ cancellation.abort();
729
+ await assert.rejects(operation, /cancelled/u);
730
+ }
731
+ else if (mode === 'failure')
732
+ await assert.rejects(operation, /exit 1/u);
733
+ else {
734
+ const stage = await operation;
735
+ assert.equal(existsSync(f.counter), false, 'synthetic npm must not execute');
736
+ assert.equal(execFileSync(process.execPath, [stage.admission.cliPath, '--version'], { encoding: 'utf8' }).trim(), '0.3.5');
737
+ assert.ok(readdirSync(join(f.input.runtimeDirectory, '.npm/cache')).length > 0);
738
+ assert.deepEqual(readdirSync(join(f.input.runtimeDirectory, '.npm/logs')), []);
739
+ }
740
+ const native = JSON.parse(readFileSync(probe, 'utf8'));
741
+ assert.equal(native.npmVersion, '11.19.0');
742
+ assert.equal(native.cwd, f.input.runtimeDirectory);
743
+ assert.equal(native.tmpdir, join(f.input.runtimeDirectory, '.npm/tmp'));
744
+ assert.equal(native.logsMax, 0);
745
+ assert.equal(native.fileLogBytes, 0);
746
+ assert.deepEqual(native.fileLogs, []);
747
+ if (mode !== 'success')
748
+ assert.equal(existsSync(f.input.runtimeDirectory), false);
749
+ for (const option of ['--cache', '--logs-dir', '--userconfig', '--globalconfig']) {
750
+ assert.ok(native.args.includes(option));
751
+ assert.ok(native.args[native.args.indexOf(option) + 1].startsWith(`${f.input.runtimeDirectory}/`));
752
+ }
753
+ assert.deepEqual(readdirSync(cache), ['keep']);
754
+ assert.deepEqual(readdirSync(logs), ['keep']);
755
+ assert.equal(readFileSync(userconfig, 'utf8'), config);
756
+ assert.equal(existsSync(join(f.root, '.git/ai-delivery/runtime-admission.json')), false);
757
+ console.log('REAL_NPM_ISOLATION_RECEIPT', JSON.stringify({
758
+ mode,
759
+ npmFileLogBytes: native.fileLogBytes,
760
+ npmFileLogsDisabled: true,
761
+ temporaryAndConfigInsideStage: true,
762
+ unrelatedHostPreserved: true,
763
+ controllerInclusiveMemoryBytes: 512 * 1024 ** 2,
764
+ retainedLogCapBytes: 1024 ** 2,
765
+ hostActivated: false,
766
+ sharedAdmissionWritten: false,
767
+ }));
470
768
  }
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;
769
+ finally {
770
+ cancellation.abort();
771
+ await operation?.catch(() => undefined);
772
+ for (const [name, value] of [
773
+ ['npm_config_cache', previous.cache],
774
+ ['npm_config_logs_dir', previous.logs],
775
+ ['npm_config_userconfig', previous.userconfig],
776
+ ]) {
777
+ if (value === undefined)
778
+ delete process.env[name];
779
+ else
780
+ process.env[name] = value;
781
+ }
782
+ f.cleanup();
495
783
  }
496
- f.cleanup();
497
- }
498
- });
784
+ });
785
+ }
499
786
  test('public producer stages exact bytes, explicitly admits and reuses completed work without installing again', async () => {
500
787
  const api = await producer();
501
788
  const f = await fixture();
@@ -741,35 +1028,39 @@ test('post-rename errors reconcile desired bytes and never overwrite third-party
741
1028
  f.cleanup();
742
1029
  }
743
1030
  });
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
- });
1031
+ for (const native of [false, true]) {
1032
+ test(`${native ? 'native ' : ''}failure and cancellation remove only the owned incomplete stage and preserve unrelated output`, async () => {
1033
+ const api = await producer();
1034
+ const f = await fixture(native);
1035
+ const controller = new AbortController();
1036
+ try {
1037
+ const unrelated = join(f.base, 'unrelated');
1038
+ mkdirSync(unrelated);
1039
+ writeFileSync(join(unrelated, 'keep'), 'keep');
1040
+ writeFileSync(join(f.base, 'fail'), 'go');
1041
+ await assert.rejects(api.stageRuntime(f.input), /exit 7/u);
1042
+ assert.equal(existsSync(f.input.runtimeDirectory), false);
1043
+ rmSync(join(f.base, 'fail'));
1044
+ writeFileSync(join(f.base, 'wait'), 'go');
1045
+ const running = api.stageRuntime({ ...f.input, signal: controller.signal });
1046
+ void running.catch(() => undefined);
1047
+ for (let i = 0; i < 100 && !existsSync(join(f.input.runtimeDirectory, 'node_modules/.bin/ai-delivery')); i++)
1048
+ await new Promise((resolve) => setTimeout(resolve, 20));
1049
+ assert.equal(digestBytes(readFileSync(join(f.input.runtimeDirectory, 'reviewed-archive.tgz'))), f.input.expectedArchiveSha256);
1050
+ controller.abort();
1051
+ await assert.rejects(running, /cancelled/u);
1052
+ assert.equal(existsSync(f.input.runtimeDirectory), false);
1053
+ assert.equal(readFileSync(join(unrelated, 'keep'), 'utf8'), 'keep');
1054
+ assert.equal(digestBytes(readFileSync(f.input.archivePath)), f.input.expectedArchiveSha256);
1055
+ if (native)
1056
+ assert.equal(readFileSync(join(f.input.nativePluginRoot, 'README.md'), 'utf8'), 'Synthetic copied native plugin.\n');
1057
+ }
1058
+ finally {
1059
+ controller.abort();
1060
+ f.cleanup();
1061
+ }
1062
+ });
1063
+ }
773
1064
  test('admission reports unknown durability with exact same-stage reconciliation and preserves desired bytes', async () => {
774
1065
  const api = await producer();
775
1066
  const f = await fixture();
@@ -851,98 +1142,192 @@ for (const denied of [
851
1142
  }
852
1143
  test('setup interruption child', { skip: !process.env.AI_DELIVERY_SETUP_INTERRUPT_INPUT }, async () => {
853
1144
  const api = await producer();
1145
+ const phase = process.env.AI_DELIVERY_SETUP_LOG_INTERRUPT_PHASE;
1146
+ if (phase) {
1147
+ const actual = atomicJson.writePrivateJsonFileAtomically;
1148
+ vi.spyOn(atomicJson, 'writePrivateJsonFileAtomically').mockImplementation((path, value) => {
1149
+ const record = value;
1150
+ const interrupt = path.includes('/runtime-output/') &&
1151
+ record.outputs?.[0]?.status === (phase === 'reservation' ? 'reserved' : 'complete');
1152
+ if (!(interrupt && phase === 'publication'))
1153
+ actual(path, value);
1154
+ if (interrupt) {
1155
+ writeFileSync(process.env.AI_DELIVERY_SETUP_LOG_INTERRUPT_MARKER, JSON.stringify({ phase, path, owner: process.pid }));
1156
+ process.kill(process.pid, 'SIGKILL');
1157
+ }
1158
+ });
1159
+ }
854
1160
  await api.stageRuntime(JSON.parse(process.env.AI_DELIVERY_SETUP_INTERRUPT_INPUT));
855
1161
  });
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));
1162
+ for (const phase of ['reservation', 'publication']) {
1163
+ test(`runtime-stage retained-output recovery preserves charges after hard interruption at ${phase}`, async () => {
1164
+ const api = await producer();
1165
+ const f = await fixture();
1166
+ const marker = join(f.base, 'log-interruption-marker');
1167
+ const input = {
1168
+ ...f.input,
1169
+ resourceBounds: {
1170
+ maxAggregateRssBytes: 512 * 1024 ** 2,
1171
+ minFreeDiskBytes: 1,
1172
+ maxNewOutputBytes: 128 * 1024 ** 2,
1173
+ maxCapturedOutputBytes: 1024 ** 2,
1174
+ },
1175
+ };
1176
+ let child;
1177
+ try {
1178
+ writeFileSync(join(f.base, 'fail'), 'go');
1179
+ writeFileSync(join(f.base, 'log-bytes'), String(100 * 1024));
1180
+ child = spawn(process.execPath, [
1181
+ join(process.cwd(), 'node_modules/vitest/vitest.mjs'),
1182
+ 'run',
1183
+ new URL(import.meta.url).pathname,
1184
+ '-t',
1185
+ '^setup interruption child$',
1186
+ '--maxWorkers=1',
1187
+ '--no-file-parallelism',
1188
+ ], {
1189
+ env: {
1190
+ ...process.env,
1191
+ AI_DELIVERY_SETUP_INTERRUPT_INPUT: JSON.stringify(input),
1192
+ AI_DELIVERY_SETUP_LOG_INTERRUPT_PHASE: phase,
1193
+ AI_DELIVERY_SETUP_LOG_INTERRUPT_MARKER: marker,
1194
+ },
1195
+ stdio: 'ignore',
1196
+ });
1197
+ await new Promise((resolve) => child.once('close', () => resolve()));
1198
+ assert.equal(existsSync(marker), true, 'hard interruption must occur at the selected durable log boundary');
1199
+ const writerPath = join(f.root, '.git/ai-delivery/writers@1', `${digestValue(f.root).slice(7)}.json`);
1200
+ const stale = new Date(Date.now() - 20_000);
1201
+ utimesSync(`${writerPath}.lock`, stale, stale);
1202
+ const { path } = JSON.parse(readFileSync(marker, 'utf8'));
1203
+ const prior = JSON.parse(readFileSync(path, 'utf8'));
1204
+ assert.equal(prior.outputs[0].status, 'reserved');
1205
+ assert.equal(prior.outputs[0].bytes, 100 * 1024);
1206
+ const outputDirectory = join(f.root, '.git/ai-delivery/verification@1/command-output');
1207
+ const priorOutput = join(outputDirectory, `${prior.outputs[0].digest.slice(7)}.bin`);
1208
+ assert.equal(existsSync(priorOutput), phase === 'publication');
1209
+ const preserved = phase === 'publication' ? readFileSync(priorOutput) : undefined;
1210
+ writeFileSync(join(f.base, 'log-bytes'), String(128 * 1024));
1211
+ await assert.rejects(api.stageRuntime(input), /exit 7/u);
1212
+ const recovered = JSON.parse(readFileSync(path, 'utf8'));
1213
+ assert.equal(recovered.outputs.reduce((total, output) => total + output.bytes, 0), 228 * 1024);
1214
+ assert.equal(recovered.outputs[0].status, phase === 'publication' ? 'complete' : 'reserved');
1215
+ if (preserved)
1216
+ assert.deepEqual(readFileSync(priorOutput), preserved);
1217
+ if (phase === 'reservation') {
1218
+ writeFileSync(join(f.base, 'log-bytes'), String(100 * 1024));
1219
+ await assert.rejects(api.stageRuntime(input), /identical output cannot be rewritten/u);
1220
+ assert.equal(existsSync(priorOutput), false, 'unresolved reservation must not authorize a second raw-log write');
1221
+ }
1222
+ rmSync(join(f.base, 'fail'));
1223
+ rmSync(join(f.base, 'log-bytes'));
1224
+ const completed = await api.stageRuntime(input);
1225
+ const count = readFileSync(f.counter, 'utf8');
1226
+ assert.equal(completed.reused, false);
1227
+ assert.equal((await api.stageRuntime(input)).reused, true);
1228
+ assert.equal(readFileSync(f.counter, 'utf8'), count, 'completed recovered stage must skip the installer');
1229
+ assert.equal(readFileSync(completed.admission.cliPath, 'utf8'), "#!/usr/bin/env node\nconsole.log('0.3.5');\n");
887
1230
  }
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');
1231
+ finally {
1232
+ if (child?.exitCode === null && child.signalCode === null)
1233
+ child.kill('SIGKILL');
1234
+ f.cleanup();
1235
+ }
1236
+ });
1237
+ }
1238
+ for (const native of [false, true]) {
1239
+ test(`${native ? 'native ' : ''}stopped setup owners stay busy and orphan recovery confirms cleanup before retrying the incomplete install`, async () => {
1240
+ const api = await producer();
1241
+ const f = await fixture(native);
1242
+ let child;
1243
+ let ownedPid;
1244
+ let ownedIdentity;
1245
+ let ownerPid;
1246
+ const peer = spawn(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { detached: true, stdio: 'ignore' });
1247
+ try {
1248
+ writeFileSync(join(f.base, 'wait'), 'go');
1249
+ child = spawn(process.execPath, [
1250
+ join(process.cwd(), 'node_modules/vitest/vitest.mjs'),
1251
+ 'run',
1252
+ 'dist/setup.test.js',
1253
+ '-t',
1254
+ '^setup interruption child$',
1255
+ ], {
1256
+ cwd: process.cwd(),
1257
+ stdio: 'ignore',
1258
+ env: { ...process.env, AI_DELIVERY_SETUP_INTERRUPT_INPUT: JSON.stringify(f.input) },
1259
+ });
1260
+ const closed = new Promise((resolve) => child.once('close', resolve));
1261
+ const writerPath = join(f.root, '.git/ai-delivery/writers@1', `${(await import('./delivery/index.js')).digestValue(f.root).slice(7)}.json`);
1262
+ let record;
1263
+ // Bound this cold installer-start observation, not the recovery run.
1264
+ for (let i = 0; i < 1000; i++) {
1265
+ if (existsSync(writerPath))
1266
+ record = JSON.parse(readFileSync(writerPath, 'utf8'));
1267
+ if (record?.command.phase === 'running' && existsSync(f.marker))
1268
+ break;
1269
+ await new Promise((resolve) => setTimeout(resolve, 20));
1270
+ }
1271
+ assert.equal(record?.command.phase, 'running');
1272
+ assert.equal(existsSync(f.marker), true, 'interrupt only after the owned installer actually starts');
1273
+ const snapshot = join(f.input.runtimeDirectory, 'reviewed-archive.tgz');
1274
+ assert.equal(digestBytes(readFileSync(snapshot)), f.input.expectedArchiveSha256);
1275
+ ownerPid = record.owner.pid;
1276
+ ownedPid = record.command.root.pid;
1277
+ ownedIdentity = record.command.root.identity;
1278
+ process.kill(ownerPid, 'SIGSTOP');
1279
+ const stale = new Date(Date.now() - 20_000);
1280
+ utimesSync(`${writerPath}.lock`, stale, stale);
1281
+ await assert.rejects(api.stageRuntime(f.input), /live verification writer/u);
1282
+ assert.equal(readFileSync(f.counter, 'utf8'), 'x');
933
1283
  process.kill(ownerPid, 'SIGKILL');
1284
+ ownerPid = undefined;
1285
+ await closed;
1286
+ process.kill(ownedPid, 0);
1287
+ writeFileSync(snapshot, 'interrupted incomplete snapshot');
1288
+ rmSync(join(f.base, 'wait'));
1289
+ const recovered = await api.stageRuntime(f.input);
1290
+ assert.equal(recovered.reused, false);
1291
+ assert.equal(readFileSync(f.counter, 'utf8'), 'xx');
1292
+ assert.equal(existsSync(writerPath), false);
1293
+ assert.equal(digestBytes(readFileSync(snapshot)), f.input.expectedArchiveSha256);
1294
+ const completedSnapshot = lstatSync(snapshot);
1295
+ process.kill(peer.pid, 0);
1296
+ const state = spawnSync('/bin/ps', ['-p', String(ownedPid), '-o', 'stat='], { encoding: 'utf8' });
1297
+ assert.ok(state.status === 1 || state.stdout.trim().startsWith('Z'));
1298
+ ownedPid = undefined;
1299
+ assert.equal((await api.stageRuntime(f.input)).reused, true);
1300
+ assert.equal(readFileSync(f.counter, 'utf8'), 'xx');
1301
+ assert.equal(lstatSync(snapshot).ino, completedSnapshot.ino);
1302
+ console.log('SETUP_INTERRUPTION_RECEIPT', JSON.stringify({
1303
+ completedStageId: recovered.stageId,
1304
+ installerExecutions: 2,
1305
+ peerPreserved: true,
1306
+ ownedCleanupConfirmed: true,
1307
+ completedRetrySkipped: true,
1308
+ incompleteSnapshotRebuilt: true,
1309
+ completedSnapshotReused: true,
1310
+ archiveSha256: f.input.expectedArchiveSha256,
1311
+ nativePluginRoot: f.input.nativePluginRoot ?? null,
1312
+ }));
934
1313
  }
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
- });
1314
+ finally {
1315
+ if (ownerPid !== undefined) {
1316
+ process.kill(ownerPid, 'SIGCONT');
1317
+ process.kill(ownerPid, 'SIGKILL');
1318
+ }
1319
+ if (ownedPid !== undefined &&
1320
+ spawnSync('/bin/ps', ['-p', String(ownedPid), '-o', 'lstart='], { encoding: 'utf8' }).stdout.trim() ===
1321
+ ownedIdentity)
1322
+ process.kill(ownedPid, 'SIGKILL');
1323
+ if (peer.exitCode === null && peer.signalCode === null)
1324
+ peer.kill('SIGKILL');
1325
+ if (child?.exitCode === null && child.signalCode === null)
1326
+ child.kill('SIGKILL');
1327
+ f.cleanup();
1328
+ }
1329
+ });
1330
+ }
946
1331
  test('CLI stage and admit execute the same public producer with explicit authority and prior absence', async () => {
947
1332
  const f = await fixture();
948
1333
  const argv = process.argv;