@aviaratech/ai-delivery 0.3.17 → 0.3.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -0
- package/dist/agent.d.ts +2 -1
- package/dist/agent.js +1 -1
- package/dist/agent.js.map +1 -1
- package/dist/lifecycle.test.js +1611 -8
- package/dist/lifecycle.test.js.map +1 -1
- package/dist/pr.js +80 -1
- package/dist/pr.js.map +1 -1
- package/dist/utils/lockfile.d.ts +3 -1
- package/dist/utils/lockfile.js +6 -2
- package/dist/utils/lockfile.js.map +1 -1
- package/dist/verification.d.ts +139 -0
- package/dist/verification.js +1012 -13
- package/dist/verification.js.map +1 -1
- package/dist/worktreeTransition.test.js +13 -0
- package/dist/worktreeTransition.test.js.map +1 -1
- package/package.json +1 -1
- package/plugins/ai-delivery/.claude-plugin/plugin.json +1 -1
package/dist/lifecycle.test.js
CHANGED
|
@@ -15,6 +15,7 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
|
15
15
|
import { AI_DELIVERY_MCP_TOOLS } from './mcp/tools.js';
|
|
16
16
|
import { createAiDeliveryMcpServer } from './mcp/index.js';
|
|
17
17
|
import { loadDeliveryConfig, parseDeliveryConfig } from './config/deliveryConfig.js';
|
|
18
|
+
import * as deliveryConfiguration from './config/deliveryConfig.js';
|
|
18
19
|
import { digestValue, stableJson } from './delivery/index.js';
|
|
19
20
|
import { contextFor, executeTool, resumedIssueUpdate, startTrackedIssue } from './dispatch.js';
|
|
20
21
|
import { defaultBaseRef, gitCommonDir } from './git.js';
|
|
@@ -23,7 +24,7 @@ import * as atomicJson from './utils/atomicJson.js';
|
|
|
23
24
|
import { createIssue, developIssue, listIssueSubissues, readyCheck, resumeCreatedIssue, updateIssue, } from './issue.js';
|
|
24
25
|
import { checkoutPr, finishIssue, listPrs, mergePr, prChecks, prInfo, publishPr, submitFormalReview } from './pr.js';
|
|
25
26
|
import { addWorktreeEntry, assertNativeIssueTrackingAdmission, getIssueWorktreeStrict, removeWorktreeEntry, updateIssueWorktreeDelivery, } from './services/worktreeRegistry.js';
|
|
26
|
-
import { assertDeliveryRuntimeAdmitted } from './services/deliveryAdmission.js';
|
|
27
|
+
import { assertDeliveryRuntimeAdmitted, buildRuntimeAdmission, } from './services/deliveryAdmission.js';
|
|
27
28
|
import { getDeliveryRecords } from './services/deliveryRecordService.js';
|
|
28
29
|
import { createIssuePhaseEvidence, loadVerifiedRun, verifyIssue } from './verification.js';
|
|
29
30
|
import { cleanupNonIssueWorktree, prepareIssueWorktree, preparePrWorktree, prepareStandaloneWorktree, } from './worktree.js';
|
|
@@ -369,6 +370,1360 @@ export default {
|
|
|
369
370
|
throw error;
|
|
370
371
|
}
|
|
371
372
|
}
|
|
373
|
+
async function sourcePhaseFixture(options = {}) {
|
|
374
|
+
const fixtureResult = await fixture(options);
|
|
375
|
+
const { root } = fixtureResult;
|
|
376
|
+
const identity = (await loadDeliveryConfig(root)).config.roles.author.identity;
|
|
377
|
+
const sdkRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
378
|
+
const runtimeEntryPath = join(sdkRoot, 'dist', 'cli.js');
|
|
379
|
+
const packageVersion = JSON.parse(readFileSync(join(sdkRoot, 'package.json'), 'utf8'))
|
|
380
|
+
.version;
|
|
381
|
+
const selectedAdmission = buildRuntimeAdmission({
|
|
382
|
+
cliPath: runtimeEntryPath,
|
|
383
|
+
mcpLauncherPath: join(sdkRoot, 'plugins', 'ai-delivery', 'dist', 'mcp-launcher.js'),
|
|
384
|
+
pluginManifestPath: join(sdkRoot, 'plugins', 'ai-delivery', '.claude-plugin', 'plugin.json'),
|
|
385
|
+
packageVersion,
|
|
386
|
+
sourceArchiveSha256: digestValue('synthetic source-phase archive'),
|
|
387
|
+
sourceCommit: git(sdkRoot, 'rev-parse', 'HEAD'),
|
|
388
|
+
configuration: await loadDeliveryConfig(root),
|
|
389
|
+
});
|
|
390
|
+
writeFileSync(join(root, '.git', 'ai-delivery', 'runtime-admission.json'), JSON.stringify(selectedAdmission), {
|
|
391
|
+
mode: 0o600,
|
|
392
|
+
});
|
|
393
|
+
const row = await prepareIssueWorktree({
|
|
394
|
+
identity: options.custodianIdentity ?? identity,
|
|
395
|
+
issueNumber: 17,
|
|
396
|
+
repoRoot: root,
|
|
397
|
+
});
|
|
398
|
+
const outputRoot = join(root, '.git', 'source-phase-output');
|
|
399
|
+
const callerPath = join(root, '.git', 'source-phase-caller.mjs');
|
|
400
|
+
const authorizationPath = join(root, '.git', 'source-phase-authorization.json');
|
|
401
|
+
writeFileSync(callerPath, '// frozen synthetic source caller\n', { mode: 0o600 });
|
|
402
|
+
writeFileSync(authorizationPath, '{"source":"synthetic reviewed source allocation"}\n', { mode: 0o600 });
|
|
403
|
+
const executable = realpathSync(process.execPath);
|
|
404
|
+
const stat = lstatSync(executable);
|
|
405
|
+
const commands = [0, 1, 2].map((index) => ({
|
|
406
|
+
argv: [executable, '-e', `console.log(process.env.SOURCE_PHASE_VALUE + ':${String(index)}')`],
|
|
407
|
+
cwd: row.path,
|
|
408
|
+
executable: {
|
|
409
|
+
path: executable,
|
|
410
|
+
digest: sha256(executable),
|
|
411
|
+
device: stat.dev,
|
|
412
|
+
inode: stat.ino,
|
|
413
|
+
uid: stat.uid,
|
|
414
|
+
mode: stat.mode,
|
|
415
|
+
},
|
|
416
|
+
}));
|
|
417
|
+
const overrides = { SOURCE_PHASE_VALUE: 'frozen' };
|
|
418
|
+
const environment = { ...process.env, ...overrides };
|
|
419
|
+
delete environment.AI_DELIVERY_OUTPUT_OBSERVATION_GATE;
|
|
420
|
+
const admission = await assertDeliveryRuntimeAdmitted({ repoRoot: root, runtimeEntryPath });
|
|
421
|
+
const input = {
|
|
422
|
+
repoRoot: root,
|
|
423
|
+
issueNumber: 17,
|
|
424
|
+
identity,
|
|
425
|
+
rowDigest: digestValue(row),
|
|
426
|
+
controller: {
|
|
427
|
+
head: git(root, 'rev-parse', 'HEAD'),
|
|
428
|
+
configDigest: (await loadDeliveryConfig(root)).configDigest,
|
|
429
|
+
admissionId: admission.admissionId,
|
|
430
|
+
runtimeEntryPath,
|
|
431
|
+
packageVersion: admission.packageVersion,
|
|
432
|
+
archiveDigest: admission.sourceArchiveSha256,
|
|
433
|
+
},
|
|
434
|
+
source: {
|
|
435
|
+
path: row.path,
|
|
436
|
+
branch: row.branch,
|
|
437
|
+
head: git(row.path, 'rev-parse', 'HEAD'),
|
|
438
|
+
indexTree: git(row.path, 'write-tree'),
|
|
439
|
+
configDigest: (await loadDeliveryConfig(row.path)).configDigest,
|
|
440
|
+
dirty: [],
|
|
441
|
+
effect: { kind: 'preserve' },
|
|
442
|
+
},
|
|
443
|
+
caller: { path: callerPath, digest: sha256(callerPath) },
|
|
444
|
+
authorization: { path: authorizationPath, digest: sha256(authorizationPath) },
|
|
445
|
+
inputs: [],
|
|
446
|
+
commands,
|
|
447
|
+
commandGraphDigest: digestValue(commands),
|
|
448
|
+
environment: { digest: digestValue(environment), overrides },
|
|
449
|
+
bounds: {
|
|
450
|
+
maxAggregateRssBytes: 1024 ** 3,
|
|
451
|
+
maxNewOutputBytes: 4 * 1024 ** 2,
|
|
452
|
+
minFreeDiskBytes: 1,
|
|
453
|
+
outputRoots: [outputRoot, join(root, '.git', 'ai-delivery')],
|
|
454
|
+
},
|
|
455
|
+
completionArtifacts: [join(outputRoot, 'metadata.json')],
|
|
456
|
+
};
|
|
457
|
+
return { ...fixtureResult, runtimeEntryPath, row, outputRoot, input, selectedAdmission };
|
|
458
|
+
}
|
|
459
|
+
function sourcePhaseRecords(root) {
|
|
460
|
+
const directory = join(gitCommonDir(root), 'ai-delivery', 'receipts', 'source-phase@1');
|
|
461
|
+
return readdirSync(directory).flatMap((subject) => readdirSync(join(directory, subject)).map((name) => JSON.parse(readFileSync(join(directory, subject, name), 'utf8'))));
|
|
462
|
+
}
|
|
463
|
+
test('supported issue source phase freezes three ordered commands and reuses only the completed whole phase', async () => {
|
|
464
|
+
const { root, outputRoot, input, selectedAdmission } = await sourcePhaseFixture();
|
|
465
|
+
try {
|
|
466
|
+
const agent = await import('./agent.js');
|
|
467
|
+
assert.equal(typeof agent.withIssueSourcePhase, 'function', 'the source capability must be public');
|
|
468
|
+
assert.notEqual(selectedAdmission.sourceCommit, input.controller.head, 'producer and consumer are separate repositories');
|
|
469
|
+
let entered = 0;
|
|
470
|
+
const receipt = await agent.withIssueSourcePhase(input, async (context) => {
|
|
471
|
+
entered += 1;
|
|
472
|
+
mkdirSync(outputRoot, { mode: 0o700 });
|
|
473
|
+
writeFileSync(join(outputRoot, 'metadata.json'), '{"owned":true}\n');
|
|
474
|
+
const inherited = process.env.SOURCE_PHASE_VALUE;
|
|
475
|
+
process.env.SOURCE_PHASE_VALUE = 'changed-after-freeze';
|
|
476
|
+
try {
|
|
477
|
+
for (let index = 0; index < input.commands.length; index++) {
|
|
478
|
+
assert.equal((await context.run(index)).toString().trim(), `frozen:${String(index)}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
finally {
|
|
482
|
+
if (inherited === undefined)
|
|
483
|
+
delete process.env.SOURCE_PHASE_VALUE;
|
|
484
|
+
else
|
|
485
|
+
process.env.SOURCE_PHASE_VALUE = inherited;
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
assert.equal(receipt.status, 'complete');
|
|
489
|
+
assert.equal(receipt.source.head, input.source.head);
|
|
490
|
+
assert.equal(receipt.commands.length, 3);
|
|
491
|
+
assert.equal(sourcePhaseRecords(root)[0]?.status, 'complete');
|
|
492
|
+
const reused = await agent.withIssueSourcePhase(input, async () => {
|
|
493
|
+
entered += 1;
|
|
494
|
+
});
|
|
495
|
+
assert.equal(reused.recordId, receipt.recordId);
|
|
496
|
+
assert.equal(entered, 1);
|
|
497
|
+
assert.equal(readdirSync(join(gitCommonDir(root), 'ai-delivery', 'writers@1')).some((name) => name.endsWith('.json')), false);
|
|
498
|
+
}
|
|
499
|
+
finally {
|
|
500
|
+
rmSync(root, { recursive: true, force: true });
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
test('supported issue source phase preserves historical custody under the configured authenticated author', async () => {
|
|
504
|
+
const { root, row, outputRoot, input } = await sourcePhaseFixture({
|
|
505
|
+
personalAuthor: true,
|
|
506
|
+
custodianIdentity: 'historical-builder',
|
|
507
|
+
});
|
|
508
|
+
const owners = join(gitCommonDir(root), 'ai-delivery', 'worktree-owners');
|
|
509
|
+
const witnessNames = readdirSync(owners);
|
|
510
|
+
const witnesses = witnessNames.map((name) => ({ path: join(owners, name), bytes: readFileSync(join(owners, name)) }));
|
|
511
|
+
const registryPath = join(root, '.issue-cli', 'worktrees.json');
|
|
512
|
+
const registryBytes = readFileSync(registryPath);
|
|
513
|
+
const registered = getIssueWorktreeStrict(input.issueNumber, root);
|
|
514
|
+
let entered = 0;
|
|
515
|
+
try {
|
|
516
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
517
|
+
assert.equal(row.identity, 'historical-builder');
|
|
518
|
+
assert.equal(input.identity, 'host-author');
|
|
519
|
+
const receipt = await withIssueSourcePhase(input, async (context) => {
|
|
520
|
+
entered += 1;
|
|
521
|
+
assert.equal(context.actor.identity, input.identity);
|
|
522
|
+
mkdirSync(outputRoot);
|
|
523
|
+
writeFileSync(join(outputRoot, 'metadata.json'), '{}');
|
|
524
|
+
for (let index = 0; index < input.commands.length; index++)
|
|
525
|
+
await context.run(index);
|
|
526
|
+
});
|
|
527
|
+
assert.equal(receipt.status, 'complete');
|
|
528
|
+
assert.equal(receipt.commands.length, input.commands.length);
|
|
529
|
+
const record = sourcePhaseRecords(root).find((value) => value.recordId === receipt.recordId);
|
|
530
|
+
assert.equal(record.actor.identity, 'host-author');
|
|
531
|
+
assert.equal(record.binding.rowDigest, digestValue(registered));
|
|
532
|
+
assert.deepEqual(getIssueWorktreeStrict(input.issueNumber, root), registered);
|
|
533
|
+
assert.ok(readFileSync(registryPath).equals(registryBytes));
|
|
534
|
+
assert.deepEqual(readdirSync(owners), witnessNames);
|
|
535
|
+
for (const witness of witnesses)
|
|
536
|
+
assert.ok(readFileSync(witness.path).equals(witness.bytes));
|
|
537
|
+
const reused = await withIssueSourcePhase(input, async () => {
|
|
538
|
+
entered += 1;
|
|
539
|
+
});
|
|
540
|
+
assert.equal(reused.recordId, receipt.recordId);
|
|
541
|
+
assert.equal(entered, 1);
|
|
542
|
+
assert.deepEqual(getIssueWorktreeStrict(input.issueNumber, root), registered);
|
|
543
|
+
assert.ok(readFileSync(registryPath).equals(registryBytes));
|
|
544
|
+
for (const witness of witnesses)
|
|
545
|
+
assert.ok(readFileSync(witness.path).equals(witness.bytes));
|
|
546
|
+
}
|
|
547
|
+
finally {
|
|
548
|
+
rmSync(root, { recursive: true, force: true });
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
test.each(['author', 'issue', 'worktree', 'row', 'missing-witness', 'changed-witness'])('supported issue source phase preserves historical custody fences for wrong %s', async (mode) => {
|
|
552
|
+
const { root, row, outputRoot, input } = await sourcePhaseFixture({
|
|
553
|
+
personalAuthor: true,
|
|
554
|
+
custodianIdentity: 'historical-builder',
|
|
555
|
+
});
|
|
556
|
+
let entered = false;
|
|
557
|
+
try {
|
|
558
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
559
|
+
if (mode === 'author')
|
|
560
|
+
input.identity = 'historical-builder';
|
|
561
|
+
if (mode === 'issue')
|
|
562
|
+
input.issueNumber += 1;
|
|
563
|
+
if (mode === 'worktree')
|
|
564
|
+
input.source.path = root;
|
|
565
|
+
if (mode === 'row')
|
|
566
|
+
input.rowDigest = digestValue({ ...row, identity: input.identity });
|
|
567
|
+
if (mode === 'missing-witness' || mode === 'changed-witness') {
|
|
568
|
+
const owners = join(gitCommonDir(root), 'ai-delivery', 'worktree-owners');
|
|
569
|
+
const names = readdirSync(owners);
|
|
570
|
+
assert.equal(names.length, 1);
|
|
571
|
+
const witness = join(owners, names[0]);
|
|
572
|
+
if (mode === 'missing-witness')
|
|
573
|
+
unlinkSync(witness);
|
|
574
|
+
else {
|
|
575
|
+
const stored = JSON.parse(readFileSync(witness, 'utf8'));
|
|
576
|
+
stored.ownerId = digestValue('tampered witness');
|
|
577
|
+
writeFileSync(witness, JSON.stringify(stored));
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
await assert.rejects(withIssueSourcePhase(input, async () => {
|
|
581
|
+
entered = true;
|
|
582
|
+
}), mode === 'author'
|
|
583
|
+
? /actor.*binding changed/u
|
|
584
|
+
: mode === 'issue'
|
|
585
|
+
? /canonical registry row/u
|
|
586
|
+
: mode === 'worktree' || mode === 'row'
|
|
587
|
+
? /exact registered issue owner/u
|
|
588
|
+
: /ownership witness/u);
|
|
589
|
+
assert.equal(entered, false);
|
|
590
|
+
assert.equal(existsSync(outputRoot), false, 'no source command or callback output was created');
|
|
591
|
+
}
|
|
592
|
+
finally {
|
|
593
|
+
rmSync(root, { recursive: true, force: true });
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
test('supported issue source phase refuses a linked worktree as its primary controller', async () => {
|
|
597
|
+
const { root, row, input } = await sourcePhaseFixture();
|
|
598
|
+
input.repoRoot = row.path;
|
|
599
|
+
let entered = false;
|
|
600
|
+
try {
|
|
601
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
602
|
+
await assert.rejects(withIssueSourcePhase(input, async () => {
|
|
603
|
+
entered = true;
|
|
604
|
+
}), /canonical primary controller/u);
|
|
605
|
+
assert.equal(entered, false);
|
|
606
|
+
}
|
|
607
|
+
finally {
|
|
608
|
+
rmSync(root, { recursive: true, force: true });
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
test('supported issue source phase authenticates its configured author before callback entry', async () => {
|
|
612
|
+
const { root, input } = await sourcePhaseFixture();
|
|
613
|
+
let entered = false;
|
|
614
|
+
const unavailable = vi
|
|
615
|
+
.spyOn(githubClient, 'createDeliveryGitHubClients')
|
|
616
|
+
.mockRejectedValueOnce(new Error('Missing selected author credentials'));
|
|
617
|
+
try {
|
|
618
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
619
|
+
await assert.rejects(withIssueSourcePhase(input, async () => {
|
|
620
|
+
entered = true;
|
|
621
|
+
}), /Missing selected author credentials/u);
|
|
622
|
+
assert.equal(entered, false);
|
|
623
|
+
}
|
|
624
|
+
finally {
|
|
625
|
+
unavailable.mockRestore();
|
|
626
|
+
rmSync(root, { recursive: true, force: true });
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
test('supported issue source phase refuses an admitted CLI from a different executing installation', async () => {
|
|
630
|
+
const { root, input, selectedAdmission } = await sourcePhaseFixture();
|
|
631
|
+
const selectedRoot = join(root, '.git', 'different-selected-runtime');
|
|
632
|
+
mkdirSync(selectedRoot);
|
|
633
|
+
const sdkRoot = dirname(dirname(input.controller.runtimeEntryPath));
|
|
634
|
+
cpSync(join(sdkRoot, 'dist'), join(selectedRoot, 'dist'), { recursive: true });
|
|
635
|
+
cpSync(join(sdkRoot, 'package.json'), join(selectedRoot, 'package.json'));
|
|
636
|
+
const admission = buildRuntimeAdmission({
|
|
637
|
+
...selectedAdmission,
|
|
638
|
+
cliPath: join(selectedRoot, 'dist', 'cli.js'),
|
|
639
|
+
configuration: await loadDeliveryConfig(root),
|
|
640
|
+
});
|
|
641
|
+
writeFileSync(join(root, '.git', 'ai-delivery', 'runtime-admission.json'), JSON.stringify(admission), {
|
|
642
|
+
mode: 0o600,
|
|
643
|
+
});
|
|
644
|
+
input.controller.runtimeEntryPath = admission.cliPath;
|
|
645
|
+
input.controller.admissionId = admission.admissionId;
|
|
646
|
+
let entered = false;
|
|
647
|
+
try {
|
|
648
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
649
|
+
await assert.rejects(withIssueSourcePhase(input, async () => {
|
|
650
|
+
entered = true;
|
|
651
|
+
}), /executing.*installation/u);
|
|
652
|
+
assert.equal(entered, false);
|
|
653
|
+
}
|
|
654
|
+
finally {
|
|
655
|
+
rmSync(root, { recursive: true, force: true });
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
test('supported issue source phase refuses a matching existing commit without completed phase proof', async () => {
|
|
659
|
+
const { root, row, outputRoot, input } = await sourcePhaseFixture();
|
|
660
|
+
input.source.effect = { kind: 'commitOnce', parent: input.source.head, tree: input.source.indexTree };
|
|
661
|
+
const committed = git(row.path, 'commit-tree', input.source.indexTree, '-p', input.source.head, '-m', 'Previously published source');
|
|
662
|
+
git(row.path, 'update-ref', `refs/heads/${row.branch}`, committed);
|
|
663
|
+
let entered = false;
|
|
664
|
+
try {
|
|
665
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
666
|
+
await assert.rejects(withIssueSourcePhase(input, async (context) => {
|
|
667
|
+
entered = true;
|
|
668
|
+
mkdirSync(outputRoot);
|
|
669
|
+
writeFileSync(join(outputRoot, 'metadata.json'), '{}');
|
|
670
|
+
for (let index = 0; index < input.commands.length; index++)
|
|
671
|
+
await context.run(index);
|
|
672
|
+
}), /initial source/u);
|
|
673
|
+
assert.equal(entered, false);
|
|
674
|
+
}
|
|
675
|
+
finally {
|
|
676
|
+
rmSync(root, { recursive: true, force: true });
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
test('supported issue source phase cannot replay a failed command even when its callback catches the error', async () => {
|
|
680
|
+
const { root, outputRoot, input } = await sourcePhaseFixture();
|
|
681
|
+
const counter = join(outputRoot, 'attempts.txt');
|
|
682
|
+
input.commands[0].argv = [
|
|
683
|
+
realpathSync(process.execPath),
|
|
684
|
+
'-e',
|
|
685
|
+
`const fs=require('fs');fs.appendFileSync(${JSON.stringify(counter)},'x');if(fs.readFileSync(${JSON.stringify(counter)},'utf8').length===1)process.exit(7);`,
|
|
686
|
+
];
|
|
687
|
+
input.commandGraphDigest = digestValue(input.commands);
|
|
688
|
+
try {
|
|
689
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
690
|
+
await assert.rejects(withIssueSourcePhase(input, async (context) => {
|
|
691
|
+
mkdirSync(outputRoot, { mode: 0o700 });
|
|
692
|
+
writeFileSync(join(outputRoot, 'metadata.json'), '{}');
|
|
693
|
+
await context.run(0).catch(() => undefined);
|
|
694
|
+
await context.run(0);
|
|
695
|
+
await context.run(1);
|
|
696
|
+
await context.run(2);
|
|
697
|
+
}), /ordered|single-use|failed|command/u);
|
|
698
|
+
assert.equal(readFileSync(counter, 'utf8'), 'x');
|
|
699
|
+
}
|
|
700
|
+
finally {
|
|
701
|
+
rmSync(root, { recursive: true, force: true });
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
test('supported issue source phase retains complete rejected bootstrap metadata through corrected attempts', async () => {
|
|
705
|
+
const { root, outputRoot, input } = await sourcePhaseFixture();
|
|
706
|
+
const originalGraph = input.commandGraphDigest;
|
|
707
|
+
try {
|
|
708
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
709
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
710
|
+
input.commandGraphDigest = digestValue(`invalid graph ${String(attempt)}`);
|
|
711
|
+
await assert.rejects(withIssueSourcePhase(input, async () => {
|
|
712
|
+
assert.fail('rejected inputs must not enter callback');
|
|
713
|
+
}), /binding changed/u);
|
|
714
|
+
const records = sourcePhaseRecords(root);
|
|
715
|
+
const predecessor = records.find((record) => !records.some((later) => later.reconciliation?.phaseId === record.phaseId));
|
|
716
|
+
assert.equal(predecessor.status, 'rejected-before-work');
|
|
717
|
+
assert.ok(Number(predecessor.bootstrapBytes) >= Buffer.byteLength(`${JSON.stringify(predecessor, null, 2)}\n`), 'charge the complete retained rejection');
|
|
718
|
+
assert.ok(predecessor.binding, 'preserve rejected requested allocation and input bindings');
|
|
719
|
+
input.reconciliation = {
|
|
720
|
+
phaseId: String(predecessor.phaseId),
|
|
721
|
+
recordId: String(predecessor.recordId),
|
|
722
|
+
authorizationDigest: input.authorization.digest,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
const rejected = sourcePhaseRecords(root);
|
|
726
|
+
const retainedBootstrap = rejected.reduce((sum, record) => sum + Number(record.bootstrapBytes), 0);
|
|
727
|
+
input.commandGraphDigest = originalGraph;
|
|
728
|
+
const completed = await withIssueSourcePhase(input, async (context) => {
|
|
729
|
+
mkdirSync(outputRoot);
|
|
730
|
+
writeFileSync(join(outputRoot, 'metadata.json'), '{}');
|
|
731
|
+
for (let index = 0; index < input.commands.length; index++)
|
|
732
|
+
await context.run(index);
|
|
733
|
+
});
|
|
734
|
+
const result = sourcePhaseRecords(root).find((record) => record.recordId === completed.recordId);
|
|
735
|
+
assert.ok(Number(result.result.newOutputBytes) >= retainedBootstrap);
|
|
736
|
+
assert.equal(sourcePhaseRecords(root).length, 4, 'retain every rejection');
|
|
737
|
+
}
|
|
738
|
+
finally {
|
|
739
|
+
rmSync(root, { recursive: true, force: true });
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
test('supported issue source phase marks changed frozen inputs unresolved after callback failure', async () => {
|
|
743
|
+
const { root, input } = await sourcePhaseFixture();
|
|
744
|
+
const frozen = join(root, '.git', 'frozen-input.json');
|
|
745
|
+
writeFileSync(frozen, '{}');
|
|
746
|
+
input.inputs = [{ path: frozen, digest: sha256(frozen) }];
|
|
747
|
+
try {
|
|
748
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
749
|
+
await assert.rejects(withIssueSourcePhase(input, async () => {
|
|
750
|
+
writeFileSync(frozen, '{"changed":true}');
|
|
751
|
+
throw new Error('callback failed');
|
|
752
|
+
}), /unresolved/u);
|
|
753
|
+
assert.equal(sourcePhaseRecords(root)[0]?.status, 'unresolved');
|
|
754
|
+
}
|
|
755
|
+
finally {
|
|
756
|
+
rmSync(root, { recursive: true, force: true });
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
test('supported issue source phase reconciliation cannot forget original frozen inputs', async () => {
|
|
760
|
+
const { root, input } = await sourcePhaseFixture();
|
|
761
|
+
const frozen = join(root, '.git', 'frozen-input.json');
|
|
762
|
+
writeFileSync(frozen, '{}');
|
|
763
|
+
input.inputs = [{ path: frozen, digest: sha256(frozen) }];
|
|
764
|
+
try {
|
|
765
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
766
|
+
await assert.rejects(withIssueSourcePhase(input, async () => {
|
|
767
|
+
throw new Error('quiescent failure');
|
|
768
|
+
}), /failed-quiescent/u);
|
|
769
|
+
const predecessor = sourcePhaseRecords(root)[0];
|
|
770
|
+
const baseline = predecessor.baseline;
|
|
771
|
+
input.reconciliation = {
|
|
772
|
+
phaseId: String(predecessor.phaseId),
|
|
773
|
+
recordId: String(predecessor.recordId),
|
|
774
|
+
baselineId: baseline.baselineId,
|
|
775
|
+
authorizationDigest: input.authorization.digest,
|
|
776
|
+
};
|
|
777
|
+
input.inputs = [];
|
|
778
|
+
let entered = false;
|
|
779
|
+
await assert.rejects(withIssueSourcePhase(input, async () => {
|
|
780
|
+
entered = true;
|
|
781
|
+
}), /compatible|retained.*input/u);
|
|
782
|
+
assert.equal(entered, false);
|
|
783
|
+
}
|
|
784
|
+
finally {
|
|
785
|
+
rmSync(root, { recursive: true, force: true });
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
test('supported issue source phase stops streaming capture at its remaining cumulative allowance', async () => {
|
|
789
|
+
const { root, outputRoot, input } = await sourcePhaseFixture();
|
|
790
|
+
input.bounds.maxNewOutputBytes = 128 * 1024;
|
|
791
|
+
input.commands[0].argv = [
|
|
792
|
+
realpathSync(process.execPath),
|
|
793
|
+
'-e',
|
|
794
|
+
'process.stdout.write(Buffer.alloc(256*1024,120));setInterval(()=>{},1000)',
|
|
795
|
+
];
|
|
796
|
+
input.commandGraphDigest = digestValue(input.commands);
|
|
797
|
+
input.completionArtifacts = [];
|
|
798
|
+
const cancellation = new AbortController();
|
|
799
|
+
input.signal = cancellation.signal;
|
|
800
|
+
const diagnosticDeadline = setTimeout(() => cancellation.abort(), 3_000);
|
|
801
|
+
try {
|
|
802
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
803
|
+
await assert.rejects(withIssueSourcePhase(input, async (context) => {
|
|
804
|
+
mkdirSync(outputRoot);
|
|
805
|
+
await context.run(0);
|
|
806
|
+
}), /captured.*allowance/u);
|
|
807
|
+
assert.equal(cancellation.signal.aborted, false, 'streaming bound stops the waiting command before diagnostic cancellation');
|
|
808
|
+
assert.equal(readdirSync(join(gitCommonDir(root), 'ai-delivery', 'writers@1')).some((name) => name.endsWith('.json')), false);
|
|
809
|
+
}
|
|
810
|
+
finally {
|
|
811
|
+
clearTimeout(diagnosticDeadline);
|
|
812
|
+
rmSync(root, { recursive: true, force: true });
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
test('supported issue source phase controls stale-lock compromise while a real competing writer is excluded', async () => {
|
|
816
|
+
const { root, row, outputRoot, input } = await sourcePhaseFixture({
|
|
817
|
+
personalAuthor: true,
|
|
818
|
+
custodianIdentity: 'historical-builder',
|
|
819
|
+
});
|
|
820
|
+
const paused = join(root, '.git', 'source-phase-validation-paused');
|
|
821
|
+
const originalLoad = deliveryConfiguration.loadDeliveryConfig;
|
|
822
|
+
let pauseOnce = true;
|
|
823
|
+
const slowValidation = vi.spyOn(deliveryConfiguration, 'loadDeliveryConfig').mockImplementation(async (...args) => {
|
|
824
|
+
if (pauseOnce && args[0] === root) {
|
|
825
|
+
pauseOnce = false;
|
|
826
|
+
writeFileSync(paused, 'writer fence is live');
|
|
827
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 12_500);
|
|
828
|
+
}
|
|
829
|
+
return originalLoad(...args);
|
|
830
|
+
});
|
|
831
|
+
const contender = spawn(process.execPath, [
|
|
832
|
+
'-e',
|
|
833
|
+
`(async()=>{
|
|
834
|
+
const fs=await import('node:fs');
|
|
835
|
+
while(!fs.existsSync(${JSON.stringify(paused)}))await new Promise(r=>setTimeout(r,20));
|
|
836
|
+
await new Promise(r=>setTimeout(r,11_000));
|
|
837
|
+
const {withRuntimeSetupWriter}=await import(${JSON.stringify(new URL('./verification.js', import.meta.url).href)});
|
|
838
|
+
await withRuntimeSetupWriter(${JSON.stringify(row.path)},async()=>{console.log('COMPETING_CALLBACK_ENTERED')});
|
|
839
|
+
})().catch(e=>{console.log(e.message);process.exitCode=1});`,
|
|
840
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
841
|
+
let contenderOutput = '';
|
|
842
|
+
let entered = false;
|
|
843
|
+
contender.stdout.on('data', (bytes) => {
|
|
844
|
+
contenderOutput += bytes.toString();
|
|
845
|
+
});
|
|
846
|
+
contender.stderr.on('data', (bytes) => {
|
|
847
|
+
contenderOutput += bytes.toString();
|
|
848
|
+
});
|
|
849
|
+
const closed = new Promise((resolve) => contender.once('close', () => resolve()));
|
|
850
|
+
try {
|
|
851
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
852
|
+
await assert.rejects(withIssueSourcePhase(input, async (context) => {
|
|
853
|
+
entered = true;
|
|
854
|
+
mkdirSync(outputRoot);
|
|
855
|
+
writeFileSync(join(outputRoot, 'metadata.json'), '{}');
|
|
856
|
+
for (let index = 0; index < input.commands.length; index++)
|
|
857
|
+
await context.run(index);
|
|
858
|
+
}), /unresolved/u);
|
|
859
|
+
await closed;
|
|
860
|
+
assert.match(contenderOutput, /unsealed source phase/u);
|
|
861
|
+
assert.equal(entered, false, 'compromise cancels before callback entry');
|
|
862
|
+
assert.doesNotMatch(contenderOutput, /COMPETING_CALLBACK_ENTERED/u);
|
|
863
|
+
const record = sourcePhaseRecords(root)[0];
|
|
864
|
+
assert.equal(record.status, 'unresolved');
|
|
865
|
+
assert.ok(record.failure.lockRelease);
|
|
866
|
+
const writers = join(gitCommonDir(root), 'ai-delivery', 'writers@1');
|
|
867
|
+
assert.equal(readdirSync(writers).length, 0, 'writer and reclaimed lock are absent after controlled failure');
|
|
868
|
+
}
|
|
869
|
+
finally {
|
|
870
|
+
slowValidation.mockRestore();
|
|
871
|
+
if (contender.exitCode === null && contender.signalCode === null)
|
|
872
|
+
contender.kill('SIGKILL');
|
|
873
|
+
await closed;
|
|
874
|
+
rmSync(root, { recursive: true, force: true });
|
|
875
|
+
}
|
|
876
|
+
}, 30_000);
|
|
877
|
+
test('supported issue source phase runs fixture tests, an enabled-hook commit and classification with separate controller source', async () => {
|
|
878
|
+
const { root, row, outputRoot, input } = await sourcePhaseFixture({
|
|
879
|
+
personalAuthor: true,
|
|
880
|
+
custodianIdentity: 'historical-builder',
|
|
881
|
+
});
|
|
882
|
+
const hook = join(root, '.git', 'source-phase-hooks', 'pre-commit');
|
|
883
|
+
mkdirSync(dirname(hook));
|
|
884
|
+
writeFileSync(hook, `#!/bin/sh\nprintf 'enabled\\n' >> ${JSON.stringify(join(outputRoot, 'hook-count.txt'))}\n`, {
|
|
885
|
+
mode: 0o700,
|
|
886
|
+
});
|
|
887
|
+
writeFileSync(join(row.path, 'artifact.txt'), 'frozen staged source change\n');
|
|
888
|
+
git(row.path, 'add', 'artifact.txt');
|
|
889
|
+
input.source.indexTree = git(row.path, 'write-tree');
|
|
890
|
+
input.source.dirty = [{ path: 'artifact.txt', digest: sha256(join(row.path, 'artifact.txt')) }];
|
|
891
|
+
input.source.effect = { kind: 'commitOnce', parent: input.source.head, tree: input.source.indexTree };
|
|
892
|
+
input.inputs = [{ path: hook, digest: sha256(hook) }];
|
|
893
|
+
const gitPath = realpathSync(execFileSync('/usr/bin/which', ['git'], { encoding: 'utf8' }).trim());
|
|
894
|
+
const stat = lstatSync(gitPath);
|
|
895
|
+
input.commands[0].argv = [
|
|
896
|
+
realpathSync(process.execPath),
|
|
897
|
+
'-e',
|
|
898
|
+
"for(let i=0;i<18;i++)console.log('fixture '+i+' passed')",
|
|
899
|
+
];
|
|
900
|
+
input.commands[1] = {
|
|
901
|
+
cwd: row.path,
|
|
902
|
+
argv: [gitPath, '-c', `core.hooksPath=${dirname(hook)}`, 'commit', '-m', 'Reviewed source phase'],
|
|
903
|
+
executable: {
|
|
904
|
+
path: gitPath,
|
|
905
|
+
digest: sha256(gitPath),
|
|
906
|
+
device: stat.dev,
|
|
907
|
+
inode: stat.ino,
|
|
908
|
+
uid: stat.uid,
|
|
909
|
+
mode: stat.mode,
|
|
910
|
+
},
|
|
911
|
+
};
|
|
912
|
+
input.commands[2].argv = [
|
|
913
|
+
realpathSync(process.execPath),
|
|
914
|
+
'-e',
|
|
915
|
+
"const c=require('child_process');if(c.execFileSync('git',['status','--porcelain'],{encoding:'utf8'}).trim())process.exit(8);console.log('source classification passed')",
|
|
916
|
+
];
|
|
917
|
+
input.commandGraphDigest = digestValue(input.commands);
|
|
918
|
+
try {
|
|
919
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
920
|
+
let entered = 0;
|
|
921
|
+
const receipt = await withIssueSourcePhase(input, async (context) => {
|
|
922
|
+
entered += 1;
|
|
923
|
+
mkdirSync(outputRoot);
|
|
924
|
+
writeFileSync(join(outputRoot, 'metadata.json'), '{}');
|
|
925
|
+
assert.equal((await context.run(0)).toString().trim().split('\n').length, 18);
|
|
926
|
+
await context.run(1);
|
|
927
|
+
await context.run(2);
|
|
928
|
+
});
|
|
929
|
+
assert.notEqual(receipt.source.head, input.source.head);
|
|
930
|
+
assert.equal(git(root, 'rev-parse', 'HEAD'), input.controller.head);
|
|
931
|
+
assert.equal(git(row.path, 'rev-parse', 'HEAD^'), input.source.head);
|
|
932
|
+
assert.equal(receipt.source.indexTree, input.source.indexTree);
|
|
933
|
+
assert.equal(readFileSync(join(outputRoot, 'hook-count.txt'), 'utf8'), 'enabled\n');
|
|
934
|
+
const { environment, ...legacyBinding } = input;
|
|
935
|
+
assert.equal(receipt.phaseId, digestValue({ ...legacyBinding, environmentDigest: environment.digest }));
|
|
936
|
+
assert.equal(Object.hasOwn(input.source.effect, 'configDigest'), false);
|
|
937
|
+
const completedPath = join(gitCommonDir(root), 'ai-delivery', 'receipts', 'source-phase@1', digestValue(row.path).slice(7), `${receipt.phaseId.slice(7)}.json`);
|
|
938
|
+
const completedBytes = readFileSync(completedPath);
|
|
939
|
+
const completed = JSON.parse(completedBytes.toString());
|
|
940
|
+
assert.equal(Object.hasOwn(completed.binding.source.effect, 'configDigest'), false);
|
|
941
|
+
const reused = await withIssueSourcePhase(input, async () => {
|
|
942
|
+
entered += 1;
|
|
943
|
+
});
|
|
944
|
+
assert.equal(reused.recordId, receipt.recordId);
|
|
945
|
+
assert.equal(entered, 1);
|
|
946
|
+
assert.deepEqual(readFileSync(completedPath), completedBytes);
|
|
947
|
+
}
|
|
948
|
+
finally {
|
|
949
|
+
rmSync(root, { recursive: true, force: true });
|
|
950
|
+
}
|
|
951
|
+
});
|
|
952
|
+
async function sourceConfigurationTransitionFixture() {
|
|
953
|
+
const result = await sourcePhaseFixture({ personalAuthor: true, custodianIdentity: 'historical-builder' });
|
|
954
|
+
const { root, row, outputRoot, input } = result;
|
|
955
|
+
const policy = join(row.path, 'policy.mjs');
|
|
956
|
+
const original = readFileSync(policy, 'utf8');
|
|
957
|
+
const revised = `${original}\n// Declared candidate policy revision.\n`;
|
|
958
|
+
writeFileSync(policy, revised);
|
|
959
|
+
const finalConfiguration = await loadDeliveryConfig(row.path);
|
|
960
|
+
const finalPolicyDigest = sha256(policy);
|
|
961
|
+
git(row.path, 'add', 'policy.mjs');
|
|
962
|
+
const tree = git(row.path, 'write-tree');
|
|
963
|
+
writeFileSync(policy, original);
|
|
964
|
+
git(row.path, 'add', 'policy.mjs');
|
|
965
|
+
assert.equal(git(row.path, 'write-tree'), input.source.indexTree);
|
|
966
|
+
assert.equal(git(row.path, 'status', '--porcelain'), '');
|
|
967
|
+
assert.equal((await loadDeliveryConfig(row.path)).configDigest, input.source.configDigest);
|
|
968
|
+
assert.notEqual(finalConfiguration.configDigest, input.source.configDigest);
|
|
969
|
+
assert.notEqual(finalConfiguration.configDigest, finalPolicyDigest);
|
|
970
|
+
const effect = {
|
|
971
|
+
kind: 'commitOnce',
|
|
972
|
+
parent: input.source.head,
|
|
973
|
+
tree,
|
|
974
|
+
configDigest: finalConfiguration.configDigest,
|
|
975
|
+
};
|
|
976
|
+
input.source.effect = effect;
|
|
977
|
+
const hook = join(root, '.git', 'source-phase-hooks', 'pre-commit');
|
|
978
|
+
mkdirSync(dirname(hook));
|
|
979
|
+
writeFileSync(hook, `#!/bin/sh\nprintf 'enabled\\n' >> ${JSON.stringify(join(outputRoot, 'hook-count.txt'))}\n`, {
|
|
980
|
+
mode: 0o700,
|
|
981
|
+
});
|
|
982
|
+
const gitPath = realpathSync(execFileSync('/usr/bin/which', ['git'], { encoding: 'utf8' }).trim());
|
|
983
|
+
input.inputs = [hook, gitPath].map((path) => ({ path, digest: sha256(path) }));
|
|
984
|
+
const transition = `const fs=require('node:fs'),c=require('node:child_process');
|
|
985
|
+
fs.writeFileSync(${JSON.stringify(policy)},${JSON.stringify(revised)});
|
|
986
|
+
const git=(...args)=>c.execFileSync(${JSON.stringify(gitPath)},args,{stdio:'inherit'});
|
|
987
|
+
git('add','policy.mjs');
|
|
988
|
+
git('-c',${JSON.stringify(`core.hooksPath=${dirname(hook)}`)},'commit','-m','Reviewed candidate policy transition');`;
|
|
989
|
+
input.commands[1].argv = [realpathSync(process.execPath), '-e', transition];
|
|
990
|
+
input.commandGraphDigest = digestValue(input.commands);
|
|
991
|
+
return { ...result, policy, original, revised, effect, transition };
|
|
992
|
+
}
|
|
993
|
+
test('supported issue source phase binds initial and final resolved candidate configurations across one normal-hook commit', async () => {
|
|
994
|
+
const { root, row, outputRoot, input, effect } = await sourceConfigurationTransitionFixture();
|
|
995
|
+
try {
|
|
996
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
997
|
+
let entered = 0;
|
|
998
|
+
const receipt = await withIssueSourcePhase(input, async (context) => {
|
|
999
|
+
entered += 1;
|
|
1000
|
+
assert.equal(context.source.configDigest, input.source.configDigest);
|
|
1001
|
+
mkdirSync(outputRoot);
|
|
1002
|
+
writeFileSync(join(outputRoot, 'metadata.json'), '{}');
|
|
1003
|
+
for (let index = 0; index < input.commands.length; index++)
|
|
1004
|
+
await context.run(index);
|
|
1005
|
+
});
|
|
1006
|
+
assert.equal(receipt.source.configDigest, effect.configDigest);
|
|
1007
|
+
assert.equal(receipt.source.indexTree, effect.tree);
|
|
1008
|
+
assert.equal(git(row.path, 'rev-list', '--parents', '-n', '1', 'HEAD'), `${receipt.source.head} ${input.source.head}`);
|
|
1009
|
+
assert.equal(git(row.path, 'status', '--porcelain'), '');
|
|
1010
|
+
assert.equal(git(root, 'rev-parse', 'HEAD'), input.controller.head);
|
|
1011
|
+
assert.equal((await loadDeliveryConfig(root)).configDigest, input.controller.configDigest);
|
|
1012
|
+
assert.equal(readFileSync(join(outputRoot, 'hook-count.txt'), 'utf8'), 'enabled\n');
|
|
1013
|
+
const completed = sourcePhaseRecords(root).find((record) => record.phaseId === receipt.phaseId);
|
|
1014
|
+
assert.deepEqual(completed?.binding?.source?.effect, effect);
|
|
1015
|
+
const reused = await withIssueSourcePhase(input, async () => {
|
|
1016
|
+
entered += 1;
|
|
1017
|
+
});
|
|
1018
|
+
assert.equal(reused.recordId, receipt.recordId);
|
|
1019
|
+
assert.deepEqual(sourcePhaseRecords(root).find((record) => record.phaseId === receipt.phaseId), completed);
|
|
1020
|
+
assert.equal(entered, 1);
|
|
1021
|
+
assert.equal(readFileSync(join(outputRoot, 'hook-count.txt'), 'utf8'), 'enabled\n');
|
|
1022
|
+
for (const digest of ['initial', 'final']) {
|
|
1023
|
+
const changed = structuredClone(input);
|
|
1024
|
+
if (digest === 'initial')
|
|
1025
|
+
changed.source.configDigest = digestValue('different initial configuration');
|
|
1026
|
+
else
|
|
1027
|
+
changed.source.effect = { ...effect, configDigest: digestValue('different final configuration') };
|
|
1028
|
+
await assert.rejects(withIssueSourcePhase(changed, async () => assert.fail('changed binding cannot reuse completion or enter')), /initial source|configuration|reconciliation/u);
|
|
1029
|
+
assert.deepEqual(sourcePhaseRecords(root).find((record) => record.phaseId === receipt.phaseId), completed);
|
|
1030
|
+
assert.equal(readFileSync(join(outputRoot, 'hook-count.txt'), 'utf8'), 'enabled\n');
|
|
1031
|
+
}
|
|
1032
|
+
writeFileSync(join(row.path, 'policy.mjs'), 'export const deliverySettings = {};\n');
|
|
1033
|
+
await assert.rejects(withIssueSourcePhase(input, async () => assert.fail('changed final source cannot reuse')));
|
|
1034
|
+
assert.deepEqual(sourcePhaseRecords(root).find((record) => record.phaseId === receipt.phaseId), completed);
|
|
1035
|
+
}
|
|
1036
|
+
finally {
|
|
1037
|
+
rmSync(root, { recursive: true, force: true });
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
test.each(['preserve', 'invalid digest', 'unknown field'])('supported issue source phase strictly refuses a final configuration declaration with %s', async (invalid) => {
|
|
1041
|
+
const { root, input, effect } = await sourceConfigurationTransitionFixture();
|
|
1042
|
+
const declaration = invalid === 'preserve'
|
|
1043
|
+
? { kind: 'preserve', configDigest: effect.configDigest }
|
|
1044
|
+
: invalid === 'invalid digest'
|
|
1045
|
+
? { ...effect, configDigest: 'invalid' }
|
|
1046
|
+
: { ...effect, finalConfigDigest: effect.configDigest };
|
|
1047
|
+
try {
|
|
1048
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
1049
|
+
await assert.rejects(withIssueSourcePhase({ ...input, source: { ...input.source, effect: declaration } }, async () => assert.fail('invalid declaration cannot enter')));
|
|
1050
|
+
assert.equal(existsSync(join(gitCommonDir(root), 'ai-delivery', 'receipts', 'source-phase@1')), false);
|
|
1051
|
+
}
|
|
1052
|
+
finally {
|
|
1053
|
+
rmSync(root, { recursive: true, force: true });
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
1056
|
+
test('supported issue source phase refuses the final configuration at the initial head before work', async () => {
|
|
1057
|
+
const { root, input, policy, revised } = await sourceConfigurationTransitionFixture();
|
|
1058
|
+
writeFileSync(policy, revised);
|
|
1059
|
+
try {
|
|
1060
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
1061
|
+
await assert.rejects(withIssueSourcePhase(input, async () => assert.fail('initial head requires initial configuration')), /configuration/u);
|
|
1062
|
+
}
|
|
1063
|
+
finally {
|
|
1064
|
+
rmSync(root, { recursive: true, force: true });
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
1067
|
+
test.each([
|
|
1068
|
+
'omitted digest',
|
|
1069
|
+
'wrong digest',
|
|
1070
|
+
'wrong tree',
|
|
1071
|
+
'wrong parent',
|
|
1072
|
+
'second commit',
|
|
1073
|
+
'dirty final',
|
|
1074
|
+
'controller configuration',
|
|
1075
|
+
'seal configuration',
|
|
1076
|
+
])('supported issue source phase refuses a candidate configuration transition with %s without replay', async (invalid) => {
|
|
1077
|
+
const { root, row, outputRoot, input, effect, transition, policy, original } = await sourceConfigurationTransitionFixture();
|
|
1078
|
+
if (invalid === 'omitted digest') {
|
|
1079
|
+
input.source.effect = { kind: 'commitOnce', parent: effect.parent, tree: effect.tree };
|
|
1080
|
+
}
|
|
1081
|
+
else if (invalid === 'wrong digest') {
|
|
1082
|
+
input.source.effect = { ...effect, configDigest: input.source.configDigest };
|
|
1083
|
+
}
|
|
1084
|
+
else if (invalid === 'wrong tree') {
|
|
1085
|
+
input.source.effect = { ...effect, tree: input.source.indexTree };
|
|
1086
|
+
}
|
|
1087
|
+
else if (invalid === 'wrong parent') {
|
|
1088
|
+
input.source.effect = { ...effect, parent: '1'.repeat(40) };
|
|
1089
|
+
}
|
|
1090
|
+
else if (invalid === 'second commit') {
|
|
1091
|
+
input.commands[1].argv[2] = `${transition}\ngit('commit','--allow-empty','-m','Undeclared second commit');`;
|
|
1092
|
+
}
|
|
1093
|
+
else if (invalid === 'dirty final') {
|
|
1094
|
+
input.commands[1].argv[2] = `${transition}\nfs.writeFileSync('artifact.txt','undeclared dirty final');`;
|
|
1095
|
+
}
|
|
1096
|
+
else if (invalid === 'controller configuration') {
|
|
1097
|
+
input.commands[1].argv[2] = `${transition}\nfs.appendFileSync(${JSON.stringify(join(root, 'policy.mjs'))},'\\n// Undeclared controller change.\\n');`;
|
|
1098
|
+
}
|
|
1099
|
+
else {
|
|
1100
|
+
input.commands[2].argv = [
|
|
1101
|
+
realpathSync(process.execPath),
|
|
1102
|
+
'-e',
|
|
1103
|
+
`require('node:fs').writeFileSync(${JSON.stringify(policy)},${JSON.stringify(original)});`,
|
|
1104
|
+
];
|
|
1105
|
+
}
|
|
1106
|
+
input.commandGraphDigest = digestValue(input.commands);
|
|
1107
|
+
try {
|
|
1108
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
1109
|
+
let entered = 0;
|
|
1110
|
+
await assert.rejects(withIssueSourcePhase(input, async (context) => {
|
|
1111
|
+
entered += 1;
|
|
1112
|
+
mkdirSync(outputRoot);
|
|
1113
|
+
writeFileSync(join(outputRoot, 'metadata.json'), '{}');
|
|
1114
|
+
for (let index = 0; index < input.commands.length; index++)
|
|
1115
|
+
await context.run(index);
|
|
1116
|
+
}), invalid === 'controller configuration' ? /repository admission/u : /configuration|parent, tree/u);
|
|
1117
|
+
assert.equal(entered, 1);
|
|
1118
|
+
assert.notEqual(git(row.path, 'rev-parse', 'HEAD'), input.source.head);
|
|
1119
|
+
assert.equal(sourcePhaseRecords(root)[0]?.status, 'unresolved');
|
|
1120
|
+
const head = git(row.path, 'rev-parse', 'HEAD');
|
|
1121
|
+
const hooks = readFileSync(join(outputRoot, 'hook-count.txt'));
|
|
1122
|
+
await assert.rejects(withIssueSourcePhase(input, async () => assert.fail('failed commit cannot replay')));
|
|
1123
|
+
assert.equal(git(row.path, 'rev-parse', 'HEAD'), head);
|
|
1124
|
+
assert.deepEqual(readFileSync(join(outputRoot, 'hook-count.txt')), hooks);
|
|
1125
|
+
}
|
|
1126
|
+
finally {
|
|
1127
|
+
rmSync(root, { recursive: true, force: true });
|
|
1128
|
+
}
|
|
1129
|
+
});
|
|
1130
|
+
test('supported issue source phase never replays a commit followed by callback failure', async () => {
|
|
1131
|
+
const { root, row, input } = await sourcePhaseFixture();
|
|
1132
|
+
input.source.effect = { kind: 'commitOnce', parent: input.source.head, tree: input.source.indexTree };
|
|
1133
|
+
input.completionArtifacts = [];
|
|
1134
|
+
try {
|
|
1135
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
1136
|
+
await assert.rejects(withIssueSourcePhase(input, async () => {
|
|
1137
|
+
const committed = git(row.path, 'commit-tree', input.source.indexTree, '-p', input.source.head, '-m', 'Uncertain commit');
|
|
1138
|
+
git(row.path, 'update-ref', `refs/heads/${row.branch}`, committed);
|
|
1139
|
+
throw new Error('failure after commit');
|
|
1140
|
+
}), /unresolved/u);
|
|
1141
|
+
const old = sourcePhaseRecords(root)[0];
|
|
1142
|
+
assert.equal(old.status, 'unresolved');
|
|
1143
|
+
let entered = false;
|
|
1144
|
+
await assert.rejects(withIssueSourcePhase(input, async () => {
|
|
1145
|
+
entered = true;
|
|
1146
|
+
}), /incomplete|Unresolved/u);
|
|
1147
|
+
assert.equal(entered, false);
|
|
1148
|
+
assert.deepEqual(sourcePhaseRecords(root)[0], old, 'uncertain evidence is immutable');
|
|
1149
|
+
}
|
|
1150
|
+
finally {
|
|
1151
|
+
rmSync(root, { recursive: true, force: true });
|
|
1152
|
+
}
|
|
1153
|
+
});
|
|
1154
|
+
test('supported issue source phase rejects skipped and concurrent commands and expires escaped capabilities', async () => {
|
|
1155
|
+
for (const mode of ['skipped', 'concurrent', 'escaped']) {
|
|
1156
|
+
const { root, outputRoot, input } = await sourcePhaseFixture();
|
|
1157
|
+
let escaped;
|
|
1158
|
+
try {
|
|
1159
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
1160
|
+
const operation = withIssueSourcePhase(input, async (context) => {
|
|
1161
|
+
escaped = context.run;
|
|
1162
|
+
mkdirSync(outputRoot);
|
|
1163
|
+
writeFileSync(join(outputRoot, 'metadata.json'), '{}');
|
|
1164
|
+
if (mode === 'skipped')
|
|
1165
|
+
await context.run(1);
|
|
1166
|
+
else if (mode === 'concurrent')
|
|
1167
|
+
await Promise.all([context.run(0), context.run(1)]);
|
|
1168
|
+
else
|
|
1169
|
+
for (let index = 0; index < input.commands.length; index++)
|
|
1170
|
+
await context.run(index);
|
|
1171
|
+
});
|
|
1172
|
+
if (mode === 'escaped')
|
|
1173
|
+
await operation;
|
|
1174
|
+
else
|
|
1175
|
+
await assert.rejects(operation, /ordered|single-use|cancelled|unawaited/u);
|
|
1176
|
+
await assert.rejects(escaped(0), /expired/u);
|
|
1177
|
+
assert.equal(readdirSync(join(gitCommonDir(root), 'ai-delivery', 'writers@1')).some((name) => name.endsWith('.json')), false);
|
|
1178
|
+
}
|
|
1179
|
+
finally {
|
|
1180
|
+
rmSync(root, { recursive: true, force: true });
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
});
|
|
1184
|
+
test('supported issue source phase cancels unawaited commands and preserves unrelated output', async () => {
|
|
1185
|
+
const { root, outputRoot, input } = await sourcePhaseFixture();
|
|
1186
|
+
const unrelated = join(root, '.git', 'unrelated-preserved.txt');
|
|
1187
|
+
writeFileSync(unrelated, 'another run');
|
|
1188
|
+
input.commands[0].argv = [realpathSync(process.execPath), '-e', 'setInterval(()=>{},1000)'];
|
|
1189
|
+
input.commandGraphDigest = digestValue(input.commands);
|
|
1190
|
+
try {
|
|
1191
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
1192
|
+
await assert.rejects(withIssueSourcePhase(input, async (context) => {
|
|
1193
|
+
mkdirSync(outputRoot);
|
|
1194
|
+
void context.run(0).catch(() => undefined);
|
|
1195
|
+
}), /unawaited/u);
|
|
1196
|
+
assert.equal(readFileSync(unrelated, 'utf8'), 'another run');
|
|
1197
|
+
const record = sourcePhaseRecords(root)[0];
|
|
1198
|
+
assert.equal(record.status, 'failed-quiescent');
|
|
1199
|
+
assert.equal(record.failure.cleanup, undefined);
|
|
1200
|
+
assert.equal(readdirSync(join(gitCommonDir(root), 'ai-delivery', 'writers@1')).length, 0);
|
|
1201
|
+
}
|
|
1202
|
+
finally {
|
|
1203
|
+
rmSync(root, { recursive: true, force: true });
|
|
1204
|
+
}
|
|
1205
|
+
});
|
|
1206
|
+
test('supported issue source phase refuses interrupted intent without replay or changing its retained checkpoint', async () => {
|
|
1207
|
+
const { root, outputRoot, input } = await sourcePhaseFixture({ personalAuthor: true });
|
|
1208
|
+
input.environment.overrides.AUTHOR_TOKEN = 'synthetic-selected-author';
|
|
1209
|
+
const environment = { ...process.env };
|
|
1210
|
+
delete environment.AI_DELIVERY_OUTPUT_OBSERVATION_GATE;
|
|
1211
|
+
for (const [name, value] of Object.entries(input.environment.overrides)) {
|
|
1212
|
+
if (value === null)
|
|
1213
|
+
delete environment[name];
|
|
1214
|
+
else
|
|
1215
|
+
environment[name] = value;
|
|
1216
|
+
}
|
|
1217
|
+
input.environment.digest = digestValue(environment);
|
|
1218
|
+
const completedUnit = join(outputRoot, 'completed-unit.json');
|
|
1219
|
+
const child = spawn(process.execPath, [
|
|
1220
|
+
'-e',
|
|
1221
|
+
`(async()=>{
|
|
1222
|
+
const {syntheticDiscoveryClients}=await import(${JSON.stringify(new URL('./fixtures/discovery.js', import.meta.url).href)});
|
|
1223
|
+
globalThis.fetch=async(url,request)=>{
|
|
1224
|
+
const address=String(url);
|
|
1225
|
+
if(address.endsWith('/graphql')){
|
|
1226
|
+
const body=JSON.parse(request.body);const data=await syntheticDiscoveryClients().graphql(body.query,body.variables);
|
|
1227
|
+
return new Response(JSON.stringify({data}),{status:200,headers:{'content-type':'application/json'}});
|
|
1228
|
+
}
|
|
1229
|
+
if(address.endsWith('/user'))return new Response(JSON.stringify({login:'host-user',id:37}),{status:200,headers:{'content-type':'application/json'}});
|
|
1230
|
+
throw new Error('Unexpected synthetic native request: '+address);
|
|
1231
|
+
};
|
|
1232
|
+
const {withIssueSourcePhase}=await import(${JSON.stringify(new URL('./agent.js', import.meta.url).href)});
|
|
1233
|
+
const fs=await import('node:fs');
|
|
1234
|
+
await withIssueSourcePhase(${JSON.stringify(input)},async context=>{
|
|
1235
|
+
fs.mkdirSync(${JSON.stringify(outputRoot)});
|
|
1236
|
+
await context.run(0);
|
|
1237
|
+
fs.writeFileSync(${JSON.stringify(completedUnit)},JSON.stringify({unit:0,output:'frozen:0'}));
|
|
1238
|
+
await new Promise(()=>setInterval(()=>{},1000));
|
|
1239
|
+
});
|
|
1240
|
+
})().catch(e=>{console.error(e.message);process.exitCode=1});`,
|
|
1241
|
+
], { env: environment, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1242
|
+
let output = '';
|
|
1243
|
+
const capture = (bytes) => {
|
|
1244
|
+
output += bytes.toString();
|
|
1245
|
+
if (output.length > 64 * 1024)
|
|
1246
|
+
child.kill('SIGKILL');
|
|
1247
|
+
};
|
|
1248
|
+
child.stdout.on('data', capture);
|
|
1249
|
+
child.stderr.on('data', capture);
|
|
1250
|
+
const closed = new Promise((resolve) => child.once('close', () => resolve()));
|
|
1251
|
+
try {
|
|
1252
|
+
for (let attempt = 0; attempt < 500 && !existsSync(completedUnit) && child.exitCode === null; attempt++)
|
|
1253
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1254
|
+
assert.ok(existsSync(completedUnit), output);
|
|
1255
|
+
child.kill('SIGKILL');
|
|
1256
|
+
await closed;
|
|
1257
|
+
assert.equal(child.signalCode, 'SIGKILL');
|
|
1258
|
+
const record = sourcePhaseRecords(root)[0];
|
|
1259
|
+
assert.equal(record.status, 'intent');
|
|
1260
|
+
assert.equal(record.commands.length, 1);
|
|
1261
|
+
const writer = join(gitCommonDir(root), 'ai-delivery', 'writers@1', `${digestValue(input.source.path).slice(7)}.json`);
|
|
1262
|
+
const beforeWriter = readFileSync(writer);
|
|
1263
|
+
const stale = new Date(Date.now() - 20_000);
|
|
1264
|
+
utimesSync(`${writer}.lock`, stale, stale);
|
|
1265
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
1266
|
+
let entered = false;
|
|
1267
|
+
await assert.rejects(withIssueSourcePhase(input, async () => {
|
|
1268
|
+
entered = true;
|
|
1269
|
+
}), /unsealed source phase/u);
|
|
1270
|
+
assert.equal(entered, false);
|
|
1271
|
+
assert.deepEqual(sourcePhaseRecords(root)[0], record);
|
|
1272
|
+
assert.ok(readFileSync(writer).equals(beforeWriter));
|
|
1273
|
+
assert.equal(readFileSync(completedUnit, 'utf8'), '{"unit":0,"output":"frozen:0"}');
|
|
1274
|
+
}
|
|
1275
|
+
finally {
|
|
1276
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
1277
|
+
child.kill('SIGKILL');
|
|
1278
|
+
await closed;
|
|
1279
|
+
rmSync(root, { recursive: true, force: true });
|
|
1280
|
+
}
|
|
1281
|
+
}, 20_000);
|
|
1282
|
+
test('supported issue source phase charges output before, between and after its frozen commands', async () => {
|
|
1283
|
+
for (const timing of ['before', 'between', 'after']) {
|
|
1284
|
+
const { root, outputRoot, input } = await sourcePhaseFixture();
|
|
1285
|
+
const counter = join(outputRoot, 'started.txt');
|
|
1286
|
+
input.bounds.maxNewOutputBytes = 64 * 1024;
|
|
1287
|
+
for (let index = 0; index < input.commands.length; index++)
|
|
1288
|
+
input.commands[index].argv = [
|
|
1289
|
+
realpathSync(process.execPath),
|
|
1290
|
+
'-e',
|
|
1291
|
+
`require('fs').appendFileSync(${JSON.stringify(counter)},${JSON.stringify(String(index))})`,
|
|
1292
|
+
];
|
|
1293
|
+
input.commandGraphDigest = digestValue(input.commands);
|
|
1294
|
+
input.completionArtifacts = [];
|
|
1295
|
+
try {
|
|
1296
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
1297
|
+
await assert.rejects(withIssueSourcePhase(input, async (context) => {
|
|
1298
|
+
mkdirSync(outputRoot);
|
|
1299
|
+
if (timing === 'before')
|
|
1300
|
+
writeFileSync(join(outputRoot, 'before.bin'), Buffer.alloc(80 * 1024));
|
|
1301
|
+
if (timing === 'between')
|
|
1302
|
+
writeFileSync(join(outputRoot, 'first.bin'), Buffer.alloc(30 * 1024));
|
|
1303
|
+
await context.run(0);
|
|
1304
|
+
if (timing === 'between')
|
|
1305
|
+
writeFileSync(join(outputRoot, 'second.bin'), Buffer.alloc(40 * 1024));
|
|
1306
|
+
await context.run(1);
|
|
1307
|
+
await context.run(2);
|
|
1308
|
+
if (timing === 'after')
|
|
1309
|
+
writeFileSync(join(outputRoot, 'after.bin'), Buffer.alloc(80 * 1024));
|
|
1310
|
+
}), /output|allowance/u);
|
|
1311
|
+
assert.equal(existsSync(counter) ? readFileSync(counter, 'utf8') : '', timing === 'before' ? '' : timing === 'between' ? '0' : '012');
|
|
1312
|
+
assert.notEqual(sourcePhaseRecords(root)[0]?.status, 'complete');
|
|
1313
|
+
}
|
|
1314
|
+
finally {
|
|
1315
|
+
rmSync(root, { recursive: true, force: true });
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
});
|
|
1319
|
+
test('supported issue source phase resumes a quiescent failure with its original baseline and ends the predecessor chain', async () => {
|
|
1320
|
+
const { root, outputRoot, input } = await sourcePhaseFixture();
|
|
1321
|
+
input.bounds.maxNewOutputBytes = 256 * 1024;
|
|
1322
|
+
try {
|
|
1323
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
1324
|
+
await assert.rejects(withIssueSourcePhase(input, async () => {
|
|
1325
|
+
mkdirSync(outputRoot);
|
|
1326
|
+
writeFileSync(join(outputRoot, 'retained.bin'), Buffer.alloc(40 * 1024, 1));
|
|
1327
|
+
throw new Error('bounded preparation failure');
|
|
1328
|
+
}), /failed-quiescent/u);
|
|
1329
|
+
const original = sourcePhaseRecords(root)[0];
|
|
1330
|
+
const baseline = original.baseline;
|
|
1331
|
+
input.reconciliation = {
|
|
1332
|
+
phaseId: String(original.phaseId),
|
|
1333
|
+
recordId: String(original.recordId),
|
|
1334
|
+
baselineId: baseline.baselineId,
|
|
1335
|
+
authorizationDigest: input.authorization.digest,
|
|
1336
|
+
};
|
|
1337
|
+
const nextCaller = join(root, '.git', 'next-source-caller.mjs');
|
|
1338
|
+
writeFileSync(nextCaller, '// expressly authorized corrected caller\n');
|
|
1339
|
+
input.caller = { path: nextCaller, digest: sha256(nextCaller) };
|
|
1340
|
+
const receipt = await withIssueSourcePhase(input, async (context) => {
|
|
1341
|
+
writeFileSync(join(outputRoot, 'new.bin'), Buffer.alloc(80 * 1024, 2));
|
|
1342
|
+
writeFileSync(join(outputRoot, 'metadata.json'), '{}');
|
|
1343
|
+
for (let index = 0; index < input.commands.length; index++)
|
|
1344
|
+
await context.run(index);
|
|
1345
|
+
});
|
|
1346
|
+
const completed = sourcePhaseRecords(root).find((record) => record.recordId === receipt.recordId);
|
|
1347
|
+
assert.equal(completed.baseline.baselineId, baseline.baselineId);
|
|
1348
|
+
assert.ok(completed.result.newOutputBytes >= 120 * 1024);
|
|
1349
|
+
assert.deepEqual(sourcePhaseRecords(root).find((record) => record.recordId === original.recordId), original);
|
|
1350
|
+
const futureCaller = join(root, '.git', 'future-source-caller.mjs');
|
|
1351
|
+
writeFileSync(futureCaller, '// independent later authorized phase\n');
|
|
1352
|
+
input.caller = { path: futureCaller, digest: sha256(futureCaller) };
|
|
1353
|
+
delete input.reconciliation;
|
|
1354
|
+
const future = await withIssueSourcePhase(input, async (context) => {
|
|
1355
|
+
for (let index = 0; index < input.commands.length; index++)
|
|
1356
|
+
await context.run(index);
|
|
1357
|
+
});
|
|
1358
|
+
assert.equal(future.status, 'complete', 'terminal success consumes its failed predecessor chain');
|
|
1359
|
+
}
|
|
1360
|
+
finally {
|
|
1361
|
+
rmSync(root, { recursive: true, force: true });
|
|
1362
|
+
}
|
|
1363
|
+
});
|
|
1364
|
+
test('supported issue source phase refuses changed retained output on an expressly authorized retry', async () => {
|
|
1365
|
+
const { root, outputRoot, input } = await sourcePhaseFixture();
|
|
1366
|
+
const retained = join(outputRoot, 'retained.json');
|
|
1367
|
+
try {
|
|
1368
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
1369
|
+
await assert.rejects(withIssueSourcePhase(input, async () => {
|
|
1370
|
+
mkdirSync(outputRoot);
|
|
1371
|
+
writeFileSync(retained, '{"unit":1}');
|
|
1372
|
+
throw new Error('quiescent failure');
|
|
1373
|
+
}), /failed-quiescent/u);
|
|
1374
|
+
const original = sourcePhaseRecords(root)[0];
|
|
1375
|
+
input.reconciliation = {
|
|
1376
|
+
phaseId: String(original.phaseId),
|
|
1377
|
+
recordId: String(original.recordId),
|
|
1378
|
+
baselineId: original.baseline.baselineId,
|
|
1379
|
+
authorizationDigest: input.authorization.digest,
|
|
1380
|
+
};
|
|
1381
|
+
const graph = input.commandGraphDigest;
|
|
1382
|
+
input.commandGraphDigest = digestValue('invalid intermediate retry graph');
|
|
1383
|
+
await assert.rejects(withIssueSourcePhase(input, async () => assert.fail('invalid intermediate graph cannot enter callback')), /binding changed/u);
|
|
1384
|
+
const intermediate = sourcePhaseRecords(root).find((record) => record.phaseId !== original.phaseId);
|
|
1385
|
+
input.commandGraphDigest = graph;
|
|
1386
|
+
input.reconciliation = {
|
|
1387
|
+
...input.reconciliation,
|
|
1388
|
+
phaseId: String(intermediate.phaseId),
|
|
1389
|
+
recordId: String(intermediate.recordId),
|
|
1390
|
+
};
|
|
1391
|
+
writeFileSync(retained, '{"unit":2}');
|
|
1392
|
+
let entered = false;
|
|
1393
|
+
await assert.rejects(withIssueSourcePhase(input, async () => {
|
|
1394
|
+
entered = true;
|
|
1395
|
+
}), /retained output artifact identity/u);
|
|
1396
|
+
assert.equal(entered, false);
|
|
1397
|
+
assert.deepEqual(sourcePhaseRecords(root).find((record) => record.recordId === original.recordId), original);
|
|
1398
|
+
}
|
|
1399
|
+
finally {
|
|
1400
|
+
rmSync(root, { recursive: true, force: true });
|
|
1401
|
+
}
|
|
1402
|
+
});
|
|
1403
|
+
test('supported issue source phase cancellation cleans the observed command and descendant identities', async () => {
|
|
1404
|
+
const { root, outputRoot, input } = await sourcePhaseFixture();
|
|
1405
|
+
const pidsPath = join(outputRoot, 'pids.json');
|
|
1406
|
+
input.commands[0].argv = [
|
|
1407
|
+
realpathSync(process.execPath),
|
|
1408
|
+
'-e',
|
|
1409
|
+
`const fs=require('fs'),cp=require('child_process');const child=cp.spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'});fs.writeFileSync(${JSON.stringify(pidsPath)},JSON.stringify({root:process.pid,descendant:child.pid}));setInterval(()=>{},1000);`,
|
|
1410
|
+
];
|
|
1411
|
+
input.commandGraphDigest = digestValue(input.commands);
|
|
1412
|
+
input.completionArtifacts = [];
|
|
1413
|
+
const cancellation = new AbortController();
|
|
1414
|
+
input.signal = cancellation.signal;
|
|
1415
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
1416
|
+
const operation = withIssueSourcePhase(input, async (context) => {
|
|
1417
|
+
mkdirSync(outputRoot);
|
|
1418
|
+
await context.run(0);
|
|
1419
|
+
}).catch((error) => error);
|
|
1420
|
+
try {
|
|
1421
|
+
for (let attempt = 0; attempt < 500 && !existsSync(pidsPath); attempt++)
|
|
1422
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1423
|
+
assert.ok(existsSync(pidsPath));
|
|
1424
|
+
const pids = JSON.parse(readFileSync(pidsPath, 'utf8'));
|
|
1425
|
+
const writer = join(gitCommonDir(root), 'ai-delivery', 'writers@1', `${digestValue(input.source.path).slice(7)}.json`);
|
|
1426
|
+
const observed = () => JSON.parse(readFileSync(writer, 'utf8')).command.tracked?.some((member) => member.pid === pids.descendant) === true;
|
|
1427
|
+
for (let attempt = 0; attempt < 500 && !observed(); attempt++)
|
|
1428
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1429
|
+
assert.ok(observed(), 'record actual descendant birth identity before cancellation');
|
|
1430
|
+
cancellation.abort();
|
|
1431
|
+
assert.match(String(await operation), /cancelled/u);
|
|
1432
|
+
for (const pid of [pids.root, pids.descendant]) {
|
|
1433
|
+
const status = spawnSync('ps', ['-p', String(pid), '-o', 'stat='], { encoding: 'utf8' }).stdout.trim();
|
|
1434
|
+
assert.ok(status === '' || status.startsWith('Z'), 'owned identity is no longer running');
|
|
1435
|
+
}
|
|
1436
|
+
const record = sourcePhaseRecords(root)[0];
|
|
1437
|
+
assert.equal(record.failure.cleanup, undefined);
|
|
1438
|
+
assert.equal(readdirSync(join(gitCommonDir(root), 'ai-delivery', 'writers@1')).length, 0);
|
|
1439
|
+
}
|
|
1440
|
+
finally {
|
|
1441
|
+
cancellation.abort();
|
|
1442
|
+
await operation;
|
|
1443
|
+
rmSync(root, { recursive: true, force: true });
|
|
1444
|
+
}
|
|
1445
|
+
}, 20_000);
|
|
1446
|
+
test('supported issue source phase reports writer and lock release failures separately from process cleanup', async () => {
|
|
1447
|
+
for (const released of ['writer', 'lock']) {
|
|
1448
|
+
const { root, outputRoot, input } = await sourcePhaseFixture();
|
|
1449
|
+
try {
|
|
1450
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
1451
|
+
await assert.rejects(withIssueSourcePhase(input, async (context) => {
|
|
1452
|
+
mkdirSync(outputRoot);
|
|
1453
|
+
writeFileSync(join(outputRoot, 'metadata.json'), '{}');
|
|
1454
|
+
for (let index = 0; index < input.commands.length; index++)
|
|
1455
|
+
await context.run(index);
|
|
1456
|
+
const writer = join(gitCommonDir(root), 'ai-delivery', 'writers@1', `${digestValue(input.source.path).slice(7)}.json`);
|
|
1457
|
+
const state = JSON.parse(readFileSync(writer, 'utf8'));
|
|
1458
|
+
assert.equal(state.owner.pid, process.pid, 'fixture only alters its own writer');
|
|
1459
|
+
if (released === 'writer')
|
|
1460
|
+
unlinkSync(writer);
|
|
1461
|
+
else
|
|
1462
|
+
writeFileSync(join(`${writer}.lock`, 'owned-release-obstruction'), 'fixture-owned release failure');
|
|
1463
|
+
}), /unresolved/u);
|
|
1464
|
+
const record = sourcePhaseRecords(root)[0];
|
|
1465
|
+
const failure = record.failure;
|
|
1466
|
+
assert.equal(failure.cleanup, undefined);
|
|
1467
|
+
if (released === 'writer')
|
|
1468
|
+
assert.ok(failure.writerRelease);
|
|
1469
|
+
else {
|
|
1470
|
+
assert.ok(failure.lockRelease);
|
|
1471
|
+
assert.equal(failure.writerRelease, undefined);
|
|
1472
|
+
}
|
|
1473
|
+
assert.equal(record.status, 'unresolved');
|
|
1474
|
+
}
|
|
1475
|
+
finally {
|
|
1476
|
+
rmSync(root, { recursive: true, force: true });
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
});
|
|
1480
|
+
test('supported issue source phase retains exclusion through terminal sealing against a real writer', async () => {
|
|
1481
|
+
for (const outcome of ['complete', 'failed-quiescent']) {
|
|
1482
|
+
const { root, row, outputRoot, input } = await sourcePhaseFixture();
|
|
1483
|
+
const writer = join(gitCommonDir(root), 'ai-delivery', 'writers@1', `${digestValue(row.path).slice(7)}.json`);
|
|
1484
|
+
const metadata = join(outputRoot, 'metadata.json');
|
|
1485
|
+
const originalLoad = deliveryConfiguration.loadDeliveryConfig;
|
|
1486
|
+
let contender;
|
|
1487
|
+
const inspectRelease = vi.spyOn(deliveryConfiguration, 'loadDeliveryConfig').mockImplementation(async (...args) => {
|
|
1488
|
+
if (args[0] === root &&
|
|
1489
|
+
contender === undefined &&
|
|
1490
|
+
!existsSync(writer) &&
|
|
1491
|
+
sourcePhaseRecords(root).some((record) => record.status === 'intent')) {
|
|
1492
|
+
contender = spawnSync(process.execPath, [
|
|
1493
|
+
'--input-type=module',
|
|
1494
|
+
'-e',
|
|
1495
|
+
`import {writeFileSync} from 'node:fs';
|
|
1496
|
+
import {withRuntimeSetupWriter} from ${JSON.stringify(new URL('./verification.js', import.meta.url).href)};
|
|
1497
|
+
await withRuntimeSetupWriter(${JSON.stringify(row.path)},async()=>{
|
|
1498
|
+
writeFileSync(${JSON.stringify(metadata)},'competing writer changed completion bytes');
|
|
1499
|
+
});`,
|
|
1500
|
+
], { encoding: 'utf8', timeout: 10_000, maxBuffer: 64 * 1024 });
|
|
1501
|
+
}
|
|
1502
|
+
return originalLoad(...args);
|
|
1503
|
+
});
|
|
1504
|
+
try {
|
|
1505
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
1506
|
+
const phase = withIssueSourcePhase(input, async (context) => {
|
|
1507
|
+
mkdirSync(outputRoot);
|
|
1508
|
+
writeFileSync(metadata, '{}');
|
|
1509
|
+
for (let index = 0; index < input.commands.length; index++)
|
|
1510
|
+
await context.run(index);
|
|
1511
|
+
if (outcome === 'failed-quiescent')
|
|
1512
|
+
throw new Error('quiescent callback failure');
|
|
1513
|
+
});
|
|
1514
|
+
if (outcome === 'complete')
|
|
1515
|
+
await phase;
|
|
1516
|
+
else
|
|
1517
|
+
await assert.rejects(phase, /failed-quiescent/u);
|
|
1518
|
+
assert.ok(contender, 'competing process attempted entry after writer release and before terminal seal');
|
|
1519
|
+
assert.equal(contender.error, undefined);
|
|
1520
|
+
assert.equal(contender.status, 1);
|
|
1521
|
+
assert.match(String(contender.stderr), /unsealed source phase/u);
|
|
1522
|
+
assert.equal(readFileSync(metadata, 'utf8'), '{}');
|
|
1523
|
+
assert.equal(sourcePhaseRecords(root)[0]?.status, outcome);
|
|
1524
|
+
const afterSeal = spawnSync(process.execPath, [
|
|
1525
|
+
'--input-type=module',
|
|
1526
|
+
'-e',
|
|
1527
|
+
`import {withRuntimeSetupWriter} from ${JSON.stringify(new URL('./verification.js', import.meta.url).href)};
|
|
1528
|
+
await withRuntimeSetupWriter(${JSON.stringify(row.path)},async()=>console.log('writer entered after seal'));`,
|
|
1529
|
+
], { encoding: 'utf8', timeout: 10_000, maxBuffer: 64 * 1024 });
|
|
1530
|
+
assert.equal(afterSeal.error, undefined);
|
|
1531
|
+
assert.equal(afterSeal.status, 0, String(afterSeal.stderr));
|
|
1532
|
+
assert.match(String(afterSeal.stdout), /writer entered after seal/u);
|
|
1533
|
+
}
|
|
1534
|
+
finally {
|
|
1535
|
+
inspectRelease.mockRestore();
|
|
1536
|
+
rmSync(root, { recursive: true, force: true });
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
}, 30_000);
|
|
1540
|
+
test('supported issue source phase corrects invalid reconciliation while retaining every rejected charge', async () => {
|
|
1541
|
+
for (const initial of ['rejected-before-work', 'failed-quiescent']) {
|
|
1542
|
+
const { root, outputRoot, input } = await sourcePhaseFixture();
|
|
1543
|
+
const graph = input.commandGraphDigest;
|
|
1544
|
+
try {
|
|
1545
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
1546
|
+
if (initial === 'rejected-before-work')
|
|
1547
|
+
input.commandGraphDigest = digestValue('initial invalid graph');
|
|
1548
|
+
await assert.rejects(withIssueSourcePhase(input, async () => {
|
|
1549
|
+
if (initial === 'rejected-before-work')
|
|
1550
|
+
assert.fail('invalid graph cannot enter callback');
|
|
1551
|
+
mkdirSync(outputRoot);
|
|
1552
|
+
writeFileSync(join(outputRoot, 'retained.bin'), Buffer.alloc(40 * 1024));
|
|
1553
|
+
throw new Error('original quiescent failure');
|
|
1554
|
+
}), initial === 'rejected-before-work' ? /binding changed/u : /failed-quiescent/u);
|
|
1555
|
+
const original = sourcePhaseRecords(root)[0];
|
|
1556
|
+
input.commandGraphDigest = graph;
|
|
1557
|
+
const reconciliation = {
|
|
1558
|
+
phaseId: String(original.phaseId),
|
|
1559
|
+
recordId: String(original.recordId),
|
|
1560
|
+
authorizationDigest: input.authorization.digest,
|
|
1561
|
+
...(original.baseline === undefined
|
|
1562
|
+
? {}
|
|
1563
|
+
: { baselineId: original.baseline.baselineId }),
|
|
1564
|
+
};
|
|
1565
|
+
for (const invalid of ['missing', 'phase', 'record', 'authorization']) {
|
|
1566
|
+
const caller = join(root, '.git', `source-phase-invalid-${invalid}.mjs`);
|
|
1567
|
+
writeFileSync(caller, `// frozen ${invalid} retry caller\n`);
|
|
1568
|
+
input.caller = { path: caller, digest: sha256(caller) };
|
|
1569
|
+
if (invalid === 'missing')
|
|
1570
|
+
delete input.reconciliation;
|
|
1571
|
+
else
|
|
1572
|
+
input.reconciliation = {
|
|
1573
|
+
...reconciliation,
|
|
1574
|
+
...(invalid === 'phase' ? { phaseId: digestValue('wrong predecessor phase') } : {}),
|
|
1575
|
+
...(invalid === 'record' ? { recordId: digestValue('wrong predecessor record') } : {}),
|
|
1576
|
+
...(invalid === 'authorization' ? { authorizationDigest: digestValue('wrong retry authorization') } : {}),
|
|
1577
|
+
};
|
|
1578
|
+
await assert.rejects(withIssueSourcePhase(input, async () => assert.fail('invalid reconciliation cannot enter callback')), /reconciliation|predecessor/u);
|
|
1579
|
+
}
|
|
1580
|
+
const retained = sourcePhaseRecords(root);
|
|
1581
|
+
assert.equal(retained.length, 5);
|
|
1582
|
+
assert.equal(retained.filter((record) => record.status === 'rejected-before-work').length, initial === 'rejected-before-work' ? 5 : 4);
|
|
1583
|
+
input.reconciliation = reconciliation;
|
|
1584
|
+
const completed = await withIssueSourcePhase(input, async (context) => {
|
|
1585
|
+
mkdirSync(outputRoot, { recursive: true });
|
|
1586
|
+
writeFileSync(join(outputRoot, 'metadata.json'), '{}');
|
|
1587
|
+
for (let index = 0; index < input.commands.length; index++)
|
|
1588
|
+
await context.run(index);
|
|
1589
|
+
});
|
|
1590
|
+
const finalRecords = sourcePhaseRecords(root);
|
|
1591
|
+
const complete = finalRecords.find((record) => record.recordId === completed.recordId);
|
|
1592
|
+
const bootstrap = retained.reduce((sum, record) => sum + Number(record.bootstrapBytes), 0);
|
|
1593
|
+
assert.ok(Number(complete.result.newOutputBytes) >=
|
|
1594
|
+
bootstrap + (initial === 'failed-quiescent' ? 40 * 1024 : 0));
|
|
1595
|
+
for (const record of retained)
|
|
1596
|
+
assert.deepEqual(finalRecords.find((current) => current.phaseId === record.phaseId), record, 'rejected and failed evidence stays immutable');
|
|
1597
|
+
delete input.reconciliation;
|
|
1598
|
+
const nextCaller = join(root, '.git', 'source-phase-independent-next.mjs');
|
|
1599
|
+
writeFileSync(nextCaller, '// independent later authorized graph\n');
|
|
1600
|
+
input.caller = { path: nextCaller, digest: sha256(nextCaller) };
|
|
1601
|
+
await withIssueSourcePhase(input, async (context) => {
|
|
1602
|
+
for (let index = 0; index < input.commands.length; index++)
|
|
1603
|
+
await context.run(index);
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
finally {
|
|
1607
|
+
rmSync(root, { recursive: true, force: true });
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
}, 40_000);
|
|
1611
|
+
test('supported issue source phase cannot derive a larger allocation or changed roots from rejected siblings', async () => {
|
|
1612
|
+
const { root, outputRoot, input } = await sourcePhaseFixture();
|
|
1613
|
+
input.bounds.maxNewOutputBytes = 64 * 1024;
|
|
1614
|
+
input.bounds.minFreeDiskBytes = 2;
|
|
1615
|
+
const originalBounds = structuredClone(input.bounds);
|
|
1616
|
+
const graph = input.commandGraphDigest;
|
|
1617
|
+
const replacementRoot = join(root, '.git', 'replacement-source-output');
|
|
1618
|
+
try {
|
|
1619
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
1620
|
+
input.commandGraphDigest = digestValue('initial rejected graph with original allocation');
|
|
1621
|
+
await assert.rejects(withIssueSourcePhase(input, async () => assert.fail('invalid graph')), /binding changed/u);
|
|
1622
|
+
const original = sourcePhaseRecords(root)[0];
|
|
1623
|
+
input.commandGraphDigest = graph;
|
|
1624
|
+
const caller = join(root, '.git', 'source-phase-increased-request.mjs');
|
|
1625
|
+
writeFileSync(caller, '// rejected request cannot grant larger bounds\n');
|
|
1626
|
+
input.caller = { path: caller, digest: sha256(caller) };
|
|
1627
|
+
input.bounds = {
|
|
1628
|
+
maxAggregateRssBytes: 2 * originalBounds.maxAggregateRssBytes,
|
|
1629
|
+
maxNewOutputBytes: 1024 * 1024,
|
|
1630
|
+
minFreeDiskBytes: 1,
|
|
1631
|
+
outputRoots: [replacementRoot, join(root, '.git', 'ai-delivery')],
|
|
1632
|
+
};
|
|
1633
|
+
input.completionArtifacts = [join(replacementRoot, 'metadata.json')];
|
|
1634
|
+
await assert.rejects(withIssueSourcePhase(input, async () => assert.fail('missing reconciliation cannot enter')), /reconciliation|original.*allowance/u);
|
|
1635
|
+
const sibling = sourcePhaseRecords(root).find((record) => record.phaseId !== original.phaseId);
|
|
1636
|
+
input.reconciliation = {
|
|
1637
|
+
phaseId: String(sibling.phaseId),
|
|
1638
|
+
recordId: String(sibling.recordId),
|
|
1639
|
+
authorizationDigest: input.authorization.digest,
|
|
1640
|
+
};
|
|
1641
|
+
let entered = false;
|
|
1642
|
+
await assert.rejects(withIssueSourcePhase(input, async (context) => {
|
|
1643
|
+
entered = true;
|
|
1644
|
+
mkdirSync(replacementRoot);
|
|
1645
|
+
writeFileSync(join(replacementRoot, 'metadata.json'), '{}');
|
|
1646
|
+
writeFileSync(join(replacementRoot, 'beyond-original-allowance.bin'), Buffer.alloc(96 * 1024));
|
|
1647
|
+
for (let index = 0; index < input.commands.length; index++)
|
|
1648
|
+
await context.run(index);
|
|
1649
|
+
}), /original.*allowance|original output baseline/u);
|
|
1650
|
+
assert.equal(entered, false, 'a rejected sibling cannot authorize original-bound increases or new roots');
|
|
1651
|
+
input.bounds = structuredClone(originalBounds);
|
|
1652
|
+
input.completionArtifacts = [join(outputRoot, 'metadata.json')];
|
|
1653
|
+
const completed = await withIssueSourcePhase(input, async (context) => {
|
|
1654
|
+
mkdirSync(outputRoot);
|
|
1655
|
+
writeFileSync(join(outputRoot, 'metadata.json'), '{}');
|
|
1656
|
+
for (let index = 0; index < input.commands.length; index++)
|
|
1657
|
+
await context.run(index);
|
|
1658
|
+
});
|
|
1659
|
+
const records = sourcePhaseRecords(root);
|
|
1660
|
+
for (const record of records)
|
|
1661
|
+
assert.deepEqual(record.allocation, originalBounds);
|
|
1662
|
+
assert.equal(records.find((record) => record.recordId === completed.recordId)?.status, 'complete');
|
|
1663
|
+
assert.deepEqual(records.find((record) => record.recordId === original.recordId), original);
|
|
1664
|
+
assert.equal(existsSync(replacementRoot), false);
|
|
1665
|
+
}
|
|
1666
|
+
finally {
|
|
1667
|
+
rmSync(root, { recursive: true, force: true });
|
|
1668
|
+
}
|
|
1669
|
+
});
|
|
1670
|
+
test('supported issue source phase charges invalid attempts against the original cumulative allowance', async () => {
|
|
1671
|
+
const { root, input } = await sourcePhaseFixture();
|
|
1672
|
+
input.bounds.maxNewOutputBytes = 32 * 1024;
|
|
1673
|
+
const originalBounds = structuredClone(input.bounds);
|
|
1674
|
+
try {
|
|
1675
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
1676
|
+
input.commandGraphDigest = digestValue('invalid frozen graph');
|
|
1677
|
+
await assert.rejects(withIssueSourcePhase(input, async () => assert.fail('invalid graph')), /binding changed/u);
|
|
1678
|
+
input.bounds.maxNewOutputBytes = 1024 * 1024;
|
|
1679
|
+
let exhausted;
|
|
1680
|
+
for (let attempt = 0; attempt < 12; attempt++) {
|
|
1681
|
+
const caller = join(root, '.git', `source-phase-missing-predecessor-${String(attempt)}.mjs`);
|
|
1682
|
+
writeFileSync(caller, `// immutable invalid retry ${String(attempt)}\n`);
|
|
1683
|
+
input.caller = { path: caller, digest: sha256(caller) };
|
|
1684
|
+
await assert.rejects(withIssueSourcePhase(input, async () => assert.fail('invalid retry cannot enter callback')), /reconciliation|unresolved|original.*allowance/u);
|
|
1685
|
+
const record = sourcePhaseRecords(root).find((value) => value.binding?.caller?.path === caller);
|
|
1686
|
+
if (record.status === 'unresolved') {
|
|
1687
|
+
exhausted = record;
|
|
1688
|
+
break;
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
assert.ok(exhausted, 'cumulative rejected bootstrap must exhaust the original allocation before twelve resets');
|
|
1692
|
+
assert.deepEqual(exhausted.allocation, originalBounds);
|
|
1693
|
+
assert.match(exhausted.failure.accounting, /original output allowance/u);
|
|
1694
|
+
assert.equal(exhausted.result, undefined);
|
|
1695
|
+
const count = sourcePhaseRecords(root).length;
|
|
1696
|
+
const caller = join(root, '.git', 'source-phase-after-exhaustion.mjs');
|
|
1697
|
+
writeFileSync(caller, '// no new persistent phase metadata after unresolved exhaustion\n');
|
|
1698
|
+
input.caller = { path: caller, digest: sha256(caller) };
|
|
1699
|
+
await assert.rejects(withIssueSourcePhase(input, async () => assert.fail('unresolved allowance cannot admit more work')), /[Uu]nresolved source phase/u);
|
|
1700
|
+
assert.equal(sourcePhaseRecords(root).length, count);
|
|
1701
|
+
}
|
|
1702
|
+
finally {
|
|
1703
|
+
rmSync(root, { recursive: true, force: true });
|
|
1704
|
+
}
|
|
1705
|
+
});
|
|
1706
|
+
test('supported issue source phase refuses undefined callback rejection after successful commands', async () => {
|
|
1707
|
+
const { root, outputRoot, input } = await sourcePhaseFixture();
|
|
1708
|
+
try {
|
|
1709
|
+
const { withIssueSourcePhase } = await import('./agent.js');
|
|
1710
|
+
await assert.rejects(withIssueSourcePhase(input, async (context) => {
|
|
1711
|
+
mkdirSync(outputRoot);
|
|
1712
|
+
writeFileSync(join(outputRoot, 'metadata.json'), '{}');
|
|
1713
|
+
for (let index = 0; index < input.commands.length; index++)
|
|
1714
|
+
await context.run(index);
|
|
1715
|
+
await Promise.reject(undefined);
|
|
1716
|
+
}), /failed-quiescent/u);
|
|
1717
|
+
const record = sourcePhaseRecords(root)[0];
|
|
1718
|
+
assert.equal(record.status, 'failed-quiescent');
|
|
1719
|
+
assert.equal(record.commands.length, 3);
|
|
1720
|
+
assert.equal(record.failure.operation, 'undefined');
|
|
1721
|
+
assert.equal(record.result, undefined);
|
|
1722
|
+
}
|
|
1723
|
+
finally {
|
|
1724
|
+
rmSync(root, { recursive: true, force: true });
|
|
1725
|
+
}
|
|
1726
|
+
});
|
|
372
1727
|
test('published package and bundled plugin agree on the admission version', () => {
|
|
373
1728
|
const packageManifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
374
1729
|
const pluginManifest = JSON.parse(readFileSync(new URL('../plugins/ai-delivery/.claude-plugin/plugin.json', import.meta.url), 'utf8'));
|
|
@@ -1458,6 +2813,126 @@ test('cancelling verification stops its owned descendant and preserves an unrela
|
|
|
1458
2813
|
rmSync(root, { recursive: true, force: true });
|
|
1459
2814
|
}
|
|
1460
2815
|
});
|
|
2816
|
+
test.skipIf(process.platform !== 'darwin').each(['exited group', 'live root', 'live group member'])('owned process cleanup rechecks EPERM for %s', async (disposition) => {
|
|
2817
|
+
const output = realpathSync(mkdtempSync(join(tmpdir(), 'ad-group-exit-')));
|
|
2818
|
+
const marker = join(output, 'processes'), release = join(output, 'release');
|
|
2819
|
+
const { root } = await fixture({
|
|
2820
|
+
firstStageScript: `const fs=require('node:fs');const {spawn}=require('node:child_process');
|
|
2821
|
+
const descendant=${JSON.stringify(disposition)}==='live group member'?spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'}):undefined;
|
|
2822
|
+
fs.writeFileSync(${JSON.stringify(marker)},JSON.stringify([process.pid,...(descendant?[descendant.pid]:[])]));
|
|
2823
|
+
setInterval(()=>{if(fs.existsSync(${JSON.stringify(release)}))process.exit(0);},10);`,
|
|
2824
|
+
});
|
|
2825
|
+
const originalKill = process.kill.bind(process);
|
|
2826
|
+
const owned = new Map();
|
|
2827
|
+
const unrelated = spawn(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { stdio: 'ignore' });
|
|
2828
|
+
const identity = (pid) => spawnSync('/bin/ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8' }).stdout.trim();
|
|
2829
|
+
const unrelatedIdentity = identity(unrelated.pid);
|
|
2830
|
+
let restoreKill;
|
|
2831
|
+
let permissionExit;
|
|
2832
|
+
const controller = new AbortController();
|
|
2833
|
+
let run;
|
|
2834
|
+
try {
|
|
2835
|
+
const row = await prepareIssueWorktree({
|
|
2836
|
+
baseRef: 'main',
|
|
2837
|
+
identity: 'synthetic-author',
|
|
2838
|
+
issueNumber: 17,
|
|
2839
|
+
repoRoot: root,
|
|
2840
|
+
});
|
|
2841
|
+
writeFileSync(join(row.path, 'change.txt'), 'group exit\n');
|
|
2842
|
+
git(row.path, 'add', 'change.txt');
|
|
2843
|
+
git(row.path, 'commit', '-qm', 'synthetic group exit');
|
|
2844
|
+
run = verifyIssue({ issueNumber: 17, repoRoot: row.path, signal: controller.signal });
|
|
2845
|
+
const rejection = assert.rejects(run, (error) => {
|
|
2846
|
+
assert.match(String(error), /cancelled/u);
|
|
2847
|
+
if (disposition === 'exited group')
|
|
2848
|
+
assert.doesNotMatch(String(error), /cleanup failed/u);
|
|
2849
|
+
else
|
|
2850
|
+
assert.match(String(error), /Owned process cleanup failed \(synthetic live EPERM\)/u);
|
|
2851
|
+
return true;
|
|
2852
|
+
});
|
|
2853
|
+
const readyDeadline = Date.now() + 2_000;
|
|
2854
|
+
while (!existsSync(marker) && Date.now() < readyDeadline)
|
|
2855
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
2856
|
+
assert.equal(existsSync(marker), true);
|
|
2857
|
+
const pids = JSON.parse(readFileSync(marker, 'utf8'));
|
|
2858
|
+
for (const pid of pids) {
|
|
2859
|
+
const birth = identity(pid);
|
|
2860
|
+
assert.ok(birth);
|
|
2861
|
+
owned.set(pid, birth);
|
|
2862
|
+
}
|
|
2863
|
+
const leader = pids[0];
|
|
2864
|
+
let observedEperm = false;
|
|
2865
|
+
const kill = vi.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
|
2866
|
+
assert.notEqual(pid, unrelated.pid);
|
|
2867
|
+
if (pid !== -leader || signal !== 'SIGKILL')
|
|
2868
|
+
return originalKill(pid, signal);
|
|
2869
|
+
if (disposition !== 'live root') {
|
|
2870
|
+
writeFileSync(release, 'exit');
|
|
2871
|
+
const exitDeadline = Date.now() + 2_000;
|
|
2872
|
+
for (;;) {
|
|
2873
|
+
assert.equal(identity(leader), owned.get(leader));
|
|
2874
|
+
const state = spawnSync('/bin/ps', ['-p', String(leader), '-o', 'stat='], {
|
|
2875
|
+
encoding: 'utf8',
|
|
2876
|
+
}).stdout.trim();
|
|
2877
|
+
if (state.startsWith('Z'))
|
|
2878
|
+
break;
|
|
2879
|
+
assert.ok(Date.now() < exitDeadline, 'owned leader did not exit at the signal boundary');
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
if (disposition === 'exited group') {
|
|
2883
|
+
try {
|
|
2884
|
+
return originalKill(pid, signal);
|
|
2885
|
+
}
|
|
2886
|
+
catch (error) {
|
|
2887
|
+
assert.equal(error.code, 'EPERM');
|
|
2888
|
+
observedEperm = true;
|
|
2889
|
+
throw error;
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
if (disposition === 'live group member') {
|
|
2893
|
+
const member = pids[1];
|
|
2894
|
+
assert.equal(identity(member), owned.get(member));
|
|
2895
|
+
assert.doesNotMatch(spawnSync('/bin/ps', ['-p', String(member), '-o', 'stat='], { encoding: 'utf8' }).stdout, /^\s*Z/u);
|
|
2896
|
+
}
|
|
2897
|
+
observedEperm = true;
|
|
2898
|
+
if (disposition === 'live root')
|
|
2899
|
+
permissionExit = setTimeout(() => writeFileSync(release, 'exit'), 500);
|
|
2900
|
+
throw Object.assign(new Error('synthetic live EPERM'), { code: 'EPERM' });
|
|
2901
|
+
});
|
|
2902
|
+
restoreKill = () => kill.mockRestore();
|
|
2903
|
+
controller.abort();
|
|
2904
|
+
await rejection;
|
|
2905
|
+
assert.equal(observedEperm, true);
|
|
2906
|
+
assert.equal(identity(unrelated.pid), unrelatedIdentity);
|
|
2907
|
+
assert.equal(existsSync(join(root, '.git', 'ai-delivery', 'verification@1', 'stages', 'check')), false);
|
|
2908
|
+
}
|
|
2909
|
+
finally {
|
|
2910
|
+
restoreKill?.();
|
|
2911
|
+
clearTimeout(permissionExit);
|
|
2912
|
+
controller.abort();
|
|
2913
|
+
for (const [pid, birth] of owned) {
|
|
2914
|
+
if (identity(pid) === birth) {
|
|
2915
|
+
try {
|
|
2916
|
+
originalKill(pid, 'SIGKILL');
|
|
2917
|
+
}
|
|
2918
|
+
catch (error) {
|
|
2919
|
+
assert.equal(error.code, 'ESRCH');
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
if (unrelated.pid !== undefined && identity(unrelated.pid) === unrelatedIdentity)
|
|
2924
|
+
originalKill(unrelated.pid, 'SIGKILL');
|
|
2925
|
+
await run?.catch(() => undefined);
|
|
2926
|
+
await new Promise((resolve) => {
|
|
2927
|
+
if (unrelated.exitCode !== null || unrelated.signalCode !== null)
|
|
2928
|
+
resolve();
|
|
2929
|
+
else
|
|
2930
|
+
unrelated.once('close', () => resolve());
|
|
2931
|
+
});
|
|
2932
|
+
rmSync(root, { recursive: true, force: true });
|
|
2933
|
+
rmSync(output, { recursive: true, force: true });
|
|
2934
|
+
}
|
|
2935
|
+
});
|
|
1461
2936
|
test.each([
|
|
1462
2937
|
{ name: 'nonzero exit', terminate: 'process.exit(7)', expected: /exit 7/u },
|
|
1463
2938
|
{ name: 'signal', terminate: "process.kill(process.pid,'SIGTERM')", expected: /signal SIGTERM/u },
|
|
@@ -3278,6 +4753,13 @@ async function syntheticLifecycle(routing) {
|
|
|
3278
4753
|
const historicalActor = routing.committedDescendantContinuation ? 'host-user' : 'synthetic-author[bot]';
|
|
3279
4754
|
let nativePrAuthor = historicalActor;
|
|
3280
4755
|
let nativeClosingIssue = issueNumber;
|
|
4756
|
+
let nativeClosingConnectionEmpty = false;
|
|
4757
|
+
let nativeClosingHasNextPage = false;
|
|
4758
|
+
let nativeRefsBody = `Refs #${String(issueNumber)}`;
|
|
4759
|
+
let nativeTimelinePages;
|
|
4760
|
+
let nativeTimelineFailurePage;
|
|
4761
|
+
let nativeTimelineNextLink = false;
|
|
4762
|
+
let nativeTimelineCalls = 0;
|
|
3281
4763
|
let nativePrRepository;
|
|
3282
4764
|
const continuationComments = [];
|
|
3283
4765
|
const createPayloads = [];
|
|
@@ -3304,6 +4786,8 @@ async function syntheticLifecycle(routing) {
|
|
|
3304
4786
|
node_id: 'PR-23',
|
|
3305
4787
|
title: 'Synthetic change',
|
|
3306
4788
|
html_url: 'https://example.test/pulls/23',
|
|
4789
|
+
url: 'https://api.example.test/repos/example/widget/pulls/23',
|
|
4790
|
+
body: nativeRefsBody,
|
|
3307
4791
|
state: stale ? 'open' : prState,
|
|
3308
4792
|
draft: prDraft,
|
|
3309
4793
|
merged_at: !stale && prState === 'closed' ? mergedAt : null,
|
|
@@ -3313,16 +4797,39 @@ async function syntheticLifecycle(routing) {
|
|
|
3313
4797
|
mergeable_state: 'clean',
|
|
3314
4798
|
user: { login: nativePrAuthor, id: 37, type: routing.committedDescendantContinuation ? 'User' : 'Bot' },
|
|
3315
4799
|
head: { sha: headSha, ref: 'issue/17', ...(nativePrRepository ? { repo: { full_name: nativePrRepository } } : {}) },
|
|
3316
|
-
base: {
|
|
4800
|
+
base: {
|
|
4801
|
+
sha: baseSha,
|
|
4802
|
+
ref: 'main',
|
|
4803
|
+
...(nativePrRepository
|
|
4804
|
+
? { repo: { full_name: nativePrRepository, url: `https://api.example.test/repos/${nativePrRepository}` } }
|
|
4805
|
+
: {}),
|
|
4806
|
+
},
|
|
4807
|
+
});
|
|
4808
|
+
const nativeReference = () => ({
|
|
4809
|
+
event: 'cross-referenced',
|
|
4810
|
+
actor: { login: historicalActor, id: 37, type: 'User' },
|
|
4811
|
+
created_at: new Date(Date.parse(mergedAt) - 60_000).toISOString(),
|
|
4812
|
+
source: {
|
|
4813
|
+
type: 'issue',
|
|
4814
|
+
issue: {
|
|
4815
|
+
number: prNumber,
|
|
4816
|
+
user: { login: historicalActor, id: 37, type: 'User' },
|
|
4817
|
+
repository_url: 'https://api.example.test/repos/example/widget',
|
|
4818
|
+
html_url: 'https://example.test/pulls/23',
|
|
4819
|
+
pull_request: { url: 'https://api.example.test/repos/example/widget/pulls/23' },
|
|
4820
|
+
},
|
|
4821
|
+
},
|
|
3317
4822
|
});
|
|
3318
4823
|
const graphql = async (query, variables = {}) => {
|
|
3319
|
-
if (query.includes('WorktreeTransitionLineage'))
|
|
4824
|
+
if (query.includes('WorktreeTransitionLineage') || query.includes('CommittedContinuationLineage'))
|
|
3320
4825
|
return {
|
|
3321
4826
|
repository: {
|
|
3322
4827
|
pullRequest: {
|
|
3323
4828
|
closingIssuesReferences: {
|
|
3324
|
-
nodes:
|
|
3325
|
-
|
|
4829
|
+
nodes: nativeClosingConnectionEmpty
|
|
4830
|
+
? []
|
|
4831
|
+
: [{ number: nativeClosingIssue, repository: { nameWithOwner: nativePrRepository } }],
|
|
4832
|
+
pageInfo: { ...pageInfo, hasNextPage: nativeClosingHasNextPage },
|
|
3326
4833
|
},
|
|
3327
4834
|
},
|
|
3328
4835
|
},
|
|
@@ -3525,6 +5032,18 @@ async function syntheticLifecycle(routing) {
|
|
|
3525
5032
|
throw new Error(`Unexpected REST route: ${route}`);
|
|
3526
5033
|
},
|
|
3527
5034
|
issues: {
|
|
5035
|
+
listEventsForTimeline: async (input) => {
|
|
5036
|
+
assert.equal(input.issue_number, issueNumber);
|
|
5037
|
+
nativeTimelineCalls += 1;
|
|
5038
|
+
if (input.page === nativeTimelineFailurePage)
|
|
5039
|
+
throw new Error('Synthetic native timeline unavailable');
|
|
5040
|
+
return {
|
|
5041
|
+
headers: nativeTimelineNextLink && input.page === 1
|
|
5042
|
+
? { link: '<https://api.example.test/repos/example/widget/issues/17/timeline?page=2>; rel="next"' }
|
|
5043
|
+
: {},
|
|
5044
|
+
data: nativeTimelinePages?.[input.page - 1] ?? (input.page === 1 ? [nativeReference()] : []),
|
|
5045
|
+
};
|
|
5046
|
+
},
|
|
3528
5047
|
listComments: async () => ({ data: continuationComments }),
|
|
3529
5048
|
getComment: async (input) => ({
|
|
3530
5049
|
data: continuationComments.find((comment) => comment.id === input.comment_id),
|
|
@@ -4386,6 +5905,78 @@ exec "${realGit}" "$@"
|
|
|
4386
5905
|
});
|
|
4387
5906
|
assert.equal(crashIndex, 2);
|
|
4388
5907
|
}
|
|
5908
|
+
if (routing.nativeRefsContinuation) {
|
|
5909
|
+
nativeClosingConnectionEmpty = true;
|
|
5910
|
+
nativeClosingHasNextPage = true;
|
|
5911
|
+
await refuse(/Native PR lineage/u);
|
|
5912
|
+
nativeClosingHasNextPage = false;
|
|
5913
|
+
nativeClosingConnectionEmpty = false;
|
|
5914
|
+
nativeClosingIssue = 18;
|
|
5915
|
+
await refuse(/Native PR lineage/u);
|
|
5916
|
+
nativeClosingIssue = issueNumber;
|
|
5917
|
+
nativeClosingConnectionEmpty = true;
|
|
5918
|
+
for (const body of ['', 'Refs #18', 'Refs #170', 'Refs #17letters', 'Refs #17_extra', 'Mention #17']) {
|
|
5919
|
+
nativeRefsBody = body;
|
|
5920
|
+
await refuse(/Native PR lineage/u);
|
|
5921
|
+
}
|
|
5922
|
+
nativeRefsBody = `Refs #${String(issueNumber)}`;
|
|
5923
|
+
const event = nativeReference();
|
|
5924
|
+
const source = event.source;
|
|
5925
|
+
const sourceIssue = source.issue;
|
|
5926
|
+
for (const invalid of [
|
|
5927
|
+
undefined,
|
|
5928
|
+
{ ...event, actor: { ...event.actor, id: 99 } },
|
|
5929
|
+
{ ...event, actor: { ...event.actor, login: 'different-user' } },
|
|
5930
|
+
{ ...event, actor: { ...event.actor, type: 'Bot' } },
|
|
5931
|
+
{ ...event, created_at: mergedAt },
|
|
5932
|
+
{ ...event, created_at: new Date(Date.parse(mergedAt) + 1000).toISOString() },
|
|
5933
|
+
{ ...event, created_at: 'unknown' },
|
|
5934
|
+
{ ...event, source: { ...source, type: 'pull_request' } },
|
|
5935
|
+
{ ...event, source: { ...source, issue: { ...sourceIssue, number: 24 } } },
|
|
5936
|
+
{
|
|
5937
|
+
...event,
|
|
5938
|
+
source: {
|
|
5939
|
+
...source,
|
|
5940
|
+
issue: { ...sourceIssue, repository_url: 'https://api.example.test/repos/other/widget' },
|
|
5941
|
+
},
|
|
5942
|
+
},
|
|
5943
|
+
{ ...event, source: { ...source, issue: { ...sourceIssue, html_url: 'https://example.test/pulls/24' } } },
|
|
5944
|
+
{
|
|
5945
|
+
...event,
|
|
5946
|
+
source: {
|
|
5947
|
+
...source,
|
|
5948
|
+
issue: {
|
|
5949
|
+
...sourceIssue,
|
|
5950
|
+
pull_request: { url: 'https://api.example.test/repos/example/widget/pulls/24' },
|
|
5951
|
+
},
|
|
5952
|
+
},
|
|
5953
|
+
},
|
|
5954
|
+
{ ...event, source: { ...source, issue: { ...sourceIssue, pull_request: undefined } } },
|
|
5955
|
+
{ ...event, source: { ...source, issue: { ...sourceIssue, user: { ...sourceIssue.user, id: 99 } } } },
|
|
5956
|
+
]) {
|
|
5957
|
+
nativeTimelinePages = [invalid === undefined ? [] : [invalid]];
|
|
5958
|
+
await refuse(/Native PR lineage/u);
|
|
5959
|
+
}
|
|
5960
|
+
nativeTimelinePages = [[event]];
|
|
5961
|
+
nativeTimelineFailurePage = 1;
|
|
5962
|
+
await refuse(/Synthetic native timeline unavailable/u);
|
|
5963
|
+
nativeTimelineFailurePage = 2;
|
|
5964
|
+
nativeTimelineNextLink = true;
|
|
5965
|
+
const before = nativeTimelineCalls;
|
|
5966
|
+
await refuse(/Synthetic native timeline unavailable/u);
|
|
5967
|
+
assert.equal(nativeTimelineCalls - before, 2, 'matching first page cannot hide a missing next page');
|
|
5968
|
+
nativeTimelineFailurePage = undefined;
|
|
5969
|
+
nativeTimelineNextLink = false;
|
|
5970
|
+
nativeTimelinePages = [Array.from({ length: 100 }, () => ({ event: 'commented' })), [event]];
|
|
5971
|
+
unlinkSync(witness.path);
|
|
5972
|
+
try {
|
|
5973
|
+
await refuse(/ownership witness/u);
|
|
5974
|
+
}
|
|
5975
|
+
finally {
|
|
5976
|
+
writeFileSync(witness.path, witness.bytes, { mode: 0o600 });
|
|
5977
|
+
}
|
|
5978
|
+
nativeRefsBody = `Refs #${String(issueNumber)}. Keep the issue open for retirement and supported consumer cutover.`;
|
|
5979
|
+
}
|
|
4389
5980
|
const first = await executeTool('issue_develop', { issueNumber }, execution).catch((error) => error);
|
|
4390
5981
|
assert.ok(first instanceof Error);
|
|
4391
5982
|
assert.match(first.message, /exact native.*acceptance/u);
|
|
@@ -4834,6 +6425,13 @@ test('supported develop reconciles authenticated host custody for an already com
|
|
|
4834
6425
|
assert.ok(uninterrupted.value);
|
|
4835
6426
|
assert.deepEqual(recovered.value, uninterrupted.value);
|
|
4836
6427
|
});
|
|
6428
|
+
test('supported develop preserves native Refs lineage for a committed descendant', { timeout: 0 }, async () => syntheticLifecycle({
|
|
6429
|
+
remote: 'origin',
|
|
6430
|
+
divergentOrigin: false,
|
|
6431
|
+
committedDescendantContinuation: true,
|
|
6432
|
+
nativeRefsContinuation: true,
|
|
6433
|
+
interruptContinuation: false,
|
|
6434
|
+
}));
|
|
4837
6435
|
test('committed descendant checkpoint publication recovers from SIGKILL before and after every rename', { timeout: 0 }, async () => syntheticLifecycle({
|
|
4838
6436
|
remote: 'origin',
|
|
4839
6437
|
divergentOrigin: false,
|
|
@@ -4992,7 +6590,8 @@ test.each([
|
|
|
4992
6590
|
fs.symlinkSync('./other', ${JSON.stringify(intermediate)});
|
|
4993
6591
|
}
|
|
4994
6592
|
if (${JSON.stringify(mode)} === 'chain-cycle') {
|
|
4995
|
-
|
|
6593
|
+
const replacement = ${JSON.stringify(join(output, 'next-intermediate'))};
|
|
6594
|
+
fs.symlinkSync('./cli', replacement); fs.renameSync(replacement, ${JSON.stringify(intermediate)});
|
|
4996
6595
|
}
|
|
4997
6596
|
for (let unit = 0; unit < 32; unit++) {
|
|
4998
6597
|
fs.appendFileSync(${JSON.stringify(join(second, 'work'))}, 'unit\\n');
|
|
@@ -5020,13 +6619,17 @@ test.each([
|
|
|
5020
6619
|
? /unsupported file/u
|
|
5021
6620
|
: mode === 'permission'
|
|
5022
6621
|
? /readdir failed.*EACCES/u
|
|
5023
|
-
: mode === 'cycle'
|
|
6622
|
+
: mode === 'cycle'
|
|
5024
6623
|
? /ELOOP/u
|
|
5025
|
-
:
|
|
6624
|
+
: mode === 'chain-cycle'
|
|
6625
|
+
? /ELOOP|alias identity changed/u
|
|
6626
|
+
: /identity changed/u);
|
|
5026
6627
|
assert.match(message, /First filesystem observation:.*ENOENT.*Last filesystem observation:/u);
|
|
5027
6628
|
assert.doesNotMatch(message, /cleanup failed/u);
|
|
5028
6629
|
return true;
|
|
5029
6630
|
});
|
|
6631
|
+
if (mode === 'cycle' || mode === 'chain-cycle')
|
|
6632
|
+
assert.throws(() => realpathSync(alias), (error) => error.code === 'ELOOP');
|
|
5030
6633
|
assert.ok(observations.some((line) => line.includes('ENOENT')));
|
|
5031
6634
|
assert.equal(observations.some((line) => line.includes('"state":"completed"')), false);
|
|
5032
6635
|
assert.equal(readFileSync(join(second, 'unrelated'), 'utf8'), 'preserved');
|