@aviaratech/ai-delivery 0.3.6 → 0.3.8
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 +17 -0
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +1 -1
- package/dist/agent.js.map +1 -1
- package/dist/issue.js +12 -13
- package/dist/issue.js.map +1 -1
- package/dist/lifecycle.test.js +569 -1
- package/dist/lifecycle.test.js.map +1 -1
- package/dist/verification.d.ts +2 -0
- package/dist/verification.js +346 -52
- package/dist/verification.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
|
@@ -562,7 +562,7 @@ test('an old active issue remains pinned while a new row uses the same canonical
|
|
|
562
562
|
},
|
|
563
563
|
};
|
|
564
564
|
await assert.rejects(createIssue(relationshipContext, { title: 'Synthetic child', parentIssueNumber: 17 }), /ownership witness/u);
|
|
565
|
-
await assert.rejects(updateIssue(relationshipContext, { issueNumber:
|
|
565
|
+
await assert.rejects(updateIssue(relationshipContext, { issueNumber: 17, parentIssueNumber: null }), /ownership witness/u);
|
|
566
566
|
assert.equal(issueWrites, 0);
|
|
567
567
|
await assert.rejects(prepareIssueWorktree({ identity: 'synthetic-author', issueNumber: 17, repoRoot: root }), /ownership witness/u);
|
|
568
568
|
const row = await prepareStandaloneWorktree({
|
|
@@ -580,6 +580,179 @@ test('an old active issue remains pinned while a new row uses the same canonical
|
|
|
580
580
|
rmSync(root, { recursive: true, force: true });
|
|
581
581
|
}
|
|
582
582
|
});
|
|
583
|
+
test.each(['current-blocker', 'new-blocker', 'current-parent', 'new-parent'])('tracking-only relationships preserve the related legacy worktree for %s', async (relationship) => {
|
|
584
|
+
const { root } = await fixture();
|
|
585
|
+
try {
|
|
586
|
+
const legacyPath = join(root, '.worktrees', 'issue-17');
|
|
587
|
+
git(root, 'worktree', 'add', '-b', 'issue/17', legacyPath, 'main');
|
|
588
|
+
writeFileSync(join(legacyPath, 'artifact.txt'), 'legacy source remains untouched\n');
|
|
589
|
+
const evidencePath = join(root, '.git', 'legacy-evidence');
|
|
590
|
+
mkdirSync(evidencePath);
|
|
591
|
+
writeFileSync(join(evidencePath, 'receipt.json'), '{"producer":"synthetic-legacy"}\n');
|
|
592
|
+
const legacy = {
|
|
593
|
+
branch: 'issue/17',
|
|
594
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
595
|
+
identity: 'synthetic-legacy',
|
|
596
|
+
issueNumber: 17,
|
|
597
|
+
path: legacyPath,
|
|
598
|
+
status: 'active',
|
|
599
|
+
type: 'issue',
|
|
600
|
+
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
601
|
+
};
|
|
602
|
+
const registryPath = join(root, '.issue-cli', 'worktrees.json');
|
|
603
|
+
mkdirSync(dirname(registryPath), { recursive: true });
|
|
604
|
+
const registryBytes = JSON.stringify({ worktrees: [legacy] });
|
|
605
|
+
writeFileSync(registryPath, registryBytes);
|
|
606
|
+
const sourceBefore = directoryHash(legacyPath);
|
|
607
|
+
const evidenceBefore = directoryHash(evidencePath);
|
|
608
|
+
const loaded = await loadDeliveryConfig(root);
|
|
609
|
+
const pageInfo = { endCursor: null, hasNextPage: false };
|
|
610
|
+
let parent = relationship === 'current-parent' ? 17 : null;
|
|
611
|
+
let blockers = relationship === 'current-blocker' ? [17] : [];
|
|
612
|
+
let missingRelated = true;
|
|
613
|
+
let malformedRelationships = false;
|
|
614
|
+
let writes = 0;
|
|
615
|
+
let projectOption = 'STATUS-0';
|
|
616
|
+
const statuses = ['Queued', 'Active', 'Waiting', 'Shipped'];
|
|
617
|
+
const projectItem = () => ({
|
|
618
|
+
id: 'ITEM-19',
|
|
619
|
+
isArchived: false,
|
|
620
|
+
content: { id: 'ISSUE-19' },
|
|
621
|
+
fieldValueByName: { name: statuses[Number(projectOption.slice(-1))], optionId: projectOption },
|
|
622
|
+
});
|
|
623
|
+
const context = {
|
|
624
|
+
root,
|
|
625
|
+
repo: { owner: 'example', repo: 'widget' },
|
|
626
|
+
config: loaded.config,
|
|
627
|
+
clients: {
|
|
628
|
+
rest: {
|
|
629
|
+
request: async (route, input) => {
|
|
630
|
+
assert.equal(input.owner, 'example');
|
|
631
|
+
assert.equal(input.repo, 'widget');
|
|
632
|
+
assert.equal(route, 'GET /repos/{owner}/{repo}/issues/{issue_number}/issue-field-values');
|
|
633
|
+
return { data: [] };
|
|
634
|
+
},
|
|
635
|
+
issues: {
|
|
636
|
+
get: async (input) => {
|
|
637
|
+
assert.equal(input.owner, 'example');
|
|
638
|
+
assert.equal(input.repo, 'widget');
|
|
639
|
+
if (input.issue_number === 17 && missingRelated)
|
|
640
|
+
throw new Error('Synthetic issue not found');
|
|
641
|
+
return {
|
|
642
|
+
data: {
|
|
643
|
+
id: input.issue_number * 10,
|
|
644
|
+
number: input.issue_number,
|
|
645
|
+
node_id: `ISSUE-${String(input.issue_number)}`,
|
|
646
|
+
title: 'Synthetic tracking issue',
|
|
647
|
+
state: 'open',
|
|
648
|
+
html_url: `https://example.test/issues/${String(input.issue_number)}`,
|
|
649
|
+
},
|
|
650
|
+
};
|
|
651
|
+
},
|
|
652
|
+
update: async () => {
|
|
653
|
+
writes += 1;
|
|
654
|
+
return { data: {} };
|
|
655
|
+
},
|
|
656
|
+
removeSubIssue: async (input) => {
|
|
657
|
+
assert.equal(input.issue_number, 17);
|
|
658
|
+
assert.equal(input.sub_issue_id, 190);
|
|
659
|
+
writes += 1;
|
|
660
|
+
parent = null;
|
|
661
|
+
return { data: {} };
|
|
662
|
+
},
|
|
663
|
+
addSubIssue: async (input) => {
|
|
664
|
+
assert.equal(input.issue_number, 17);
|
|
665
|
+
assert.equal(input.sub_issue_id, 190);
|
|
666
|
+
writes += 1;
|
|
667
|
+
parent = 17;
|
|
668
|
+
return { data: {} };
|
|
669
|
+
},
|
|
670
|
+
listSubIssues: async () => ({ data: [] }),
|
|
671
|
+
},
|
|
672
|
+
paginate: async () => (parent === 17 ? [{ number: 19 }] : []),
|
|
673
|
+
},
|
|
674
|
+
graphql: async (query, variables) => {
|
|
675
|
+
if (query.includes('blockedBy(first:')) {
|
|
676
|
+
assert.equal(variables.owner, 'example');
|
|
677
|
+
assert.equal(variables.name, 'widget');
|
|
678
|
+
assert.equal(variables.number, 19);
|
|
679
|
+
if (malformedRelationships)
|
|
680
|
+
return { repository: null };
|
|
681
|
+
return {
|
|
682
|
+
repository: {
|
|
683
|
+
issue: {
|
|
684
|
+
parent: parent === null ? null : { id: 'ISSUE-17', number: parent },
|
|
685
|
+
blockedBy: {
|
|
686
|
+
nodes: blockers.map((number) => ({
|
|
687
|
+
id: `ISSUE-${String(number)}`,
|
|
688
|
+
number,
|
|
689
|
+
state: 'OPEN',
|
|
690
|
+
title: 'Synthetic blocker',
|
|
691
|
+
})),
|
|
692
|
+
pageInfo,
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
},
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
if (query.includes('removeBlockedBy') || query.includes('addBlockedBy')) {
|
|
699
|
+
assert.equal(variables.issue, 'ISSUE-19');
|
|
700
|
+
assert.equal(variables.blocking, 'ISSUE-17');
|
|
701
|
+
writes += 1;
|
|
702
|
+
blockers = query.includes('addBlockedBy') ? [17] : [];
|
|
703
|
+
return {};
|
|
704
|
+
}
|
|
705
|
+
if (query.includes('ProjectDeliveryItems'))
|
|
706
|
+
return { organization: { projectV2: { items: { nodes: [projectItem()], pageInfo } } } };
|
|
707
|
+
if (query.includes('UpdateProjectDeliveryStatus')) {
|
|
708
|
+
projectOption = String(variables.optionId);
|
|
709
|
+
writes += 1;
|
|
710
|
+
return { updateProjectV2ItemFieldValue: { projectV2Item: { id: 'ITEM-19' } } };
|
|
711
|
+
}
|
|
712
|
+
if (query.includes('ProjectDeliveryItemReadback'))
|
|
713
|
+
return { node: { ...projectItem(), project: { id: 'PROJECT-1', number: 1 } } };
|
|
714
|
+
return syntheticDiscoveryClients().graphql(query, variables);
|
|
715
|
+
},
|
|
716
|
+
},
|
|
717
|
+
};
|
|
718
|
+
const input = relationship.endsWith('blocker')
|
|
719
|
+
? { issueNumber: 19, blockedBy: relationship === 'new-blocker' ? [17] : [] }
|
|
720
|
+
: { issueNumber: 19, parentIssueNumber: relationship === 'new-parent' ? 17 : null };
|
|
721
|
+
await assert.rejects(updateIssue(context, input), /Unable to resolve (?:blocker|parent) issue/u);
|
|
722
|
+
assert.equal(writes, 0);
|
|
723
|
+
missingRelated = false;
|
|
724
|
+
malformedRelationships = true;
|
|
725
|
+
await assert.rejects(updateIssue(context, input), /invalid blocked-by relationship evidence/u);
|
|
726
|
+
assert.equal(writes, 0);
|
|
727
|
+
malformedRelationships = false;
|
|
728
|
+
await assert.rejects(updateIssue(context, { ...input, issueNumber: 17 }), /ownership witness/u);
|
|
729
|
+
assert.equal(writes, 0);
|
|
730
|
+
writeFileSync(registryPath, JSON.stringify({ worktrees: [{ ...legacy, schemaVersion: 'unknown@99' }] }));
|
|
731
|
+
await assert.rejects(updateIssue(context, input), /unsupported fields/u);
|
|
732
|
+
assert.equal(writes, 0);
|
|
733
|
+
writeFileSync(registryPath, registryBytes);
|
|
734
|
+
const transitionPath = join(root, '.git', 'ai-delivery', 'worktree-owners', 'issue-19.transition.json');
|
|
735
|
+
mkdirSync(dirname(transitionPath), { recursive: true });
|
|
736
|
+
writeFileSync(transitionPath, '{"schemaVersion":"unknown-transition@99"}', { mode: 0o600 });
|
|
737
|
+
await assert.rejects(updateIssue(context, input), /transition is incomplete or unreadable/u);
|
|
738
|
+
assert.equal(writes, 0);
|
|
739
|
+
unlinkSync(transitionPath);
|
|
740
|
+
const result = await updateIssue(context, input);
|
|
741
|
+
assert.equal(result.worktree, null);
|
|
742
|
+
assert.deepEqual(result.blockedBy, relationship === 'new-blocker' ? [17] : []);
|
|
743
|
+
assert.equal(result.parentIssueNumber, relationship === 'new-parent' ? 17 : null);
|
|
744
|
+
assert.equal(result.projectStatus, relationship === 'new-blocker' ? 'Blocked' : 'Todo');
|
|
745
|
+
assert.ok(writes > 0);
|
|
746
|
+
await assert.rejects(prepareIssueWorktree({ identity: 'synthetic-author', issueNumber: 17, repoRoot: root }), /ownership witness/u);
|
|
747
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: legacyPath }), /ownership witness/u);
|
|
748
|
+
assert.equal(readFileSync(registryPath, 'utf8'), registryBytes);
|
|
749
|
+
assert.equal(directoryHash(legacyPath), sourceBefore);
|
|
750
|
+
assert.equal(directoryHash(evidencePath), evidenceBefore);
|
|
751
|
+
}
|
|
752
|
+
finally {
|
|
753
|
+
rmSync(root, { recursive: true, force: true });
|
|
754
|
+
}
|
|
755
|
+
});
|
|
583
756
|
test('synthetic cutover restores exact pre-write state and resumes an incomplete verification stage', async () => {
|
|
584
757
|
const { root, counter, secondCounter, failSecond } = await fixture({ twoStages: true });
|
|
585
758
|
const backup = mkdtempSync(join(tmpdir(), 'ai-delivery-cutover-backup-'));
|
|
@@ -3663,4 +3836,399 @@ test.each(['escape', 'broken', 'cycle'])('output observation rejects %s aliases
|
|
|
3663
3836
|
rmSync(base, { recursive: true, force: true });
|
|
3664
3837
|
}
|
|
3665
3838
|
});
|
|
3839
|
+
test('fully cached bounded verification still rejects a newly leaked FIFO', async () => {
|
|
3840
|
+
const { root, counter } = await fixture();
|
|
3841
|
+
const output = realpathSync(mkdtempSync(join(tmpdir(), 'ai-delivery-cached-fifo-')));
|
|
3842
|
+
try {
|
|
3843
|
+
const row = await prepareIssueWorktree({
|
|
3844
|
+
baseRef: 'main',
|
|
3845
|
+
identity: 'synthetic-author',
|
|
3846
|
+
issueNumber: 17,
|
|
3847
|
+
repoRoot: root,
|
|
3848
|
+
});
|
|
3849
|
+
const bounds = {
|
|
3850
|
+
maxAggregateRssBytes: 1_000_000_000,
|
|
3851
|
+
minFreeDiskBytes: 1,
|
|
3852
|
+
maxNewOutputBytes: 1024,
|
|
3853
|
+
outputRoots: [output],
|
|
3854
|
+
};
|
|
3855
|
+
await verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: bounds });
|
|
3856
|
+
assert.equal(readFileSync(counter, 'utf8'), '1');
|
|
3857
|
+
execFileSync('mkfifo', [join(output, 'leaked')]);
|
|
3858
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: bounds }), /unsupported file/u);
|
|
3859
|
+
assert.equal(readFileSync(counter, 'utf8'), '1');
|
|
3860
|
+
}
|
|
3861
|
+
finally {
|
|
3862
|
+
rmSync(output, { recursive: true, force: true });
|
|
3863
|
+
rmSync(root, { recursive: true, force: true });
|
|
3864
|
+
}
|
|
3865
|
+
});
|
|
3866
|
+
function filesystemFixtureScript(output, mode) {
|
|
3867
|
+
return `
|
|
3868
|
+
(async () => {
|
|
3869
|
+
const fs = require('node:fs'), cp = require('node:child_process'), path = require('node:path'), assert = require('node:assert/strict');
|
|
3870
|
+
const { withVerificationFilesystemFixture } = await import(${JSON.stringify(new URL('./agent.js', import.meta.url).href)});
|
|
3871
|
+
const { assertPrivateFile } = await import(${JSON.stringify(new URL('./delivery/common.js', import.meta.url).href)});
|
|
3872
|
+
const output = ${JSON.stringify(output)}, fifo = path.join(output, 'fifo'), events = path.join(output, 'events');
|
|
3873
|
+
const gate = JSON.parse(process.env.AI_DELIVERY_OUTPUT_OBSERVATION_GATE);
|
|
3874
|
+
const trace = value => fs.appendFileSync(events, value + '\\n');
|
|
3875
|
+
await withVerificationFilesystemFixture(async () => {
|
|
3876
|
+
cp.execFileSync('mkfifo', ['-m', '600', fifo]);
|
|
3877
|
+
fs.writeFileSync(path.join(output, 'capability'), JSON.stringify(gate));
|
|
3878
|
+
trace('created');
|
|
3879
|
+
try {
|
|
3880
|
+
if (${JSON.stringify(mode)} === 'abrupt') process.exit(9);
|
|
3881
|
+
if (${JSON.stringify(mode)} === 'cancel') await new Promise(() => { setInterval(() => {}, 1000); });
|
|
3882
|
+
if (${JSON.stringify(mode)} === 'block') fs.readFileSync(fifo);
|
|
3883
|
+
while (fs.readdirSync(path.join(gate.directory, 'requests')).length === 0) await new Promise(resolve => setTimeout(resolve, 10));
|
|
3884
|
+
assert.ok(fs.existsSync(fifo)); trace('scan-queued');
|
|
3885
|
+
assert.throws(() => assertPrivateFile(fifo), /private regular file/); trace('reader-rejected');
|
|
3886
|
+
const regular = path.join(output, 'regular'), hard = path.join(output, 'hard'), alias = path.join(output, 'alias');
|
|
3887
|
+
fs.writeFileSync(regular, 'x'.repeat(128), { mode: 0o600 });
|
|
3888
|
+
fs.linkSync(regular, hard); assert.throws(() => assertPrivateFile(hard), /private regular file/); fs.unlinkSync(hard);
|
|
3889
|
+
fs.symlinkSync('./regular', alias); assert.throws(() => assertPrivateFile(alias), /private regular file/); fs.unlinkSync(alias);
|
|
3890
|
+
if (${JSON.stringify(mode)} === 'assertion') throw new Error('synthetic reader assertion failure');
|
|
3891
|
+
} finally { fs.unlinkSync(fifo); trace('cleaned'); }
|
|
3892
|
+
});
|
|
3893
|
+
trace('released');
|
|
3894
|
+
})().catch(error => { console.error(error.message); process.exitCode = 1; });`;
|
|
3895
|
+
}
|
|
3896
|
+
test.each(['pass', 'assertion'])('filesystem fixture gate deterministically queues a scan through reader rejection and %s teardown', async (mode) => {
|
|
3897
|
+
const output = realpathSync(mkdtempSync(join(tmpdir(), 'ai-delivery-fixture-gate-')));
|
|
3898
|
+
const { root } = await fixture({ firstStageScript: filesystemFixtureScript(output, mode) });
|
|
3899
|
+
try {
|
|
3900
|
+
const row = await prepareIssueWorktree({
|
|
3901
|
+
baseRef: 'main',
|
|
3902
|
+
identity: 'synthetic-author',
|
|
3903
|
+
issueNumber: 17,
|
|
3904
|
+
repoRoot: root,
|
|
3905
|
+
});
|
|
3906
|
+
const run = verifyIssue({
|
|
3907
|
+
issueNumber: 17,
|
|
3908
|
+
repoRoot: row.path,
|
|
3909
|
+
resourceBounds: {
|
|
3910
|
+
maxAggregateRssBytes: 1_000_000_000,
|
|
3911
|
+
minFreeDiskBytes: 1,
|
|
3912
|
+
maxNewOutputBytes: 2048,
|
|
3913
|
+
outputRoots: [output],
|
|
3914
|
+
},
|
|
3915
|
+
});
|
|
3916
|
+
if (mode === 'assertion')
|
|
3917
|
+
await assert.rejects(run, /exit 1/u);
|
|
3918
|
+
else
|
|
3919
|
+
assert.ok((await run).resources.maxSampledNewOutputBytes >= 128);
|
|
3920
|
+
assert.equal(existsSync(join(output, 'fifo')), false);
|
|
3921
|
+
const events = readFileSync(join(output, 'events'), 'utf8').trim().split('\n');
|
|
3922
|
+
assert.deepEqual(events, mode === 'pass'
|
|
3923
|
+
? ['created', 'scan-queued', 'reader-rejected', 'cleaned', 'released']
|
|
3924
|
+
: ['created', 'scan-queued', 'reader-rejected', 'cleaned']);
|
|
3925
|
+
const capability = JSON.parse(readFileSync(join(output, 'capability'), 'utf8'));
|
|
3926
|
+
assert.equal(existsSync(capability.directory), false);
|
|
3927
|
+
assert.equal(readFileSync(join(output, 'regular'), 'utf8').length, 128);
|
|
3928
|
+
}
|
|
3929
|
+
finally {
|
|
3930
|
+
rmSync(root, { recursive: true, force: true });
|
|
3931
|
+
rmSync(output, { recursive: true, force: true });
|
|
3932
|
+
}
|
|
3933
|
+
});
|
|
3934
|
+
test.each(['abrupt', 'block', 'cancel'])('filesystem fixture gate preserves leaked FIFO and unrelated files after %s', async (mode) => {
|
|
3935
|
+
const output = realpathSync(mkdtempSync(join(tmpdir(), 'ai-delivery-fixture-failure-')));
|
|
3936
|
+
writeFileSync(join(output, 'unrelated'), 'preserved');
|
|
3937
|
+
const { root } = await fixture({ firstStageScript: filesystemFixtureScript(output, mode) });
|
|
3938
|
+
const controller = new AbortController();
|
|
3939
|
+
let running;
|
|
3940
|
+
const progress = vi.spyOn(process.stderr, 'write');
|
|
3941
|
+
try {
|
|
3942
|
+
const row = await prepareIssueWorktree({
|
|
3943
|
+
baseRef: 'main',
|
|
3944
|
+
identity: 'synthetic-author',
|
|
3945
|
+
issueNumber: 17,
|
|
3946
|
+
repoRoot: root,
|
|
3947
|
+
});
|
|
3948
|
+
const bounds = {
|
|
3949
|
+
maxAggregateRssBytes: 1_000_000_000,
|
|
3950
|
+
minFreeDiskBytes: 1,
|
|
3951
|
+
maxNewOutputBytes: 2048,
|
|
3952
|
+
outputRoots: [output],
|
|
3953
|
+
};
|
|
3954
|
+
running = verifyIssue({
|
|
3955
|
+
issueNumber: 17,
|
|
3956
|
+
repoRoot: row.path,
|
|
3957
|
+
resourceBounds: bounds,
|
|
3958
|
+
signal: controller.signal,
|
|
3959
|
+
}).catch((error) => error);
|
|
3960
|
+
if (mode === 'cancel') {
|
|
3961
|
+
for (let attempt = 0; attempt < 300 && !existsSync(join(output, 'capability')); attempt++)
|
|
3962
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3963
|
+
assert.ok(existsSync(join(output, 'capability')));
|
|
3964
|
+
const cap = JSON.parse(readFileSync(join(output, 'capability'), 'utf8'));
|
|
3965
|
+
for (let attempt = 0; attempt < 300 && readdirSync(join(cap.directory, 'requests')).length === 0; attempt++)
|
|
3966
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3967
|
+
assert.ok(readdirSync(join(cap.directory, 'requests')).length > 0);
|
|
3968
|
+
controller.abort();
|
|
3969
|
+
}
|
|
3970
|
+
const failure = String(await running);
|
|
3971
|
+
assert.match(failure, mode === 'abrupt' ? /exit 9/u : mode === 'block' ? /reader or teardown is stalled/u : /cancelled/u);
|
|
3972
|
+
assert.doesNotMatch(failure, /cleanup failed|live holder/u);
|
|
3973
|
+
const capability = JSON.parse(readFileSync(join(output, 'capability'), 'utf8'));
|
|
3974
|
+
assert.equal(existsSync(capability.directory), false);
|
|
3975
|
+
assert.ok(existsSync(join(output, 'fifo')));
|
|
3976
|
+
assert.equal(readFileSync(join(output, 'unrelated'), 'utf8'), 'preserved');
|
|
3977
|
+
if (mode !== 'abrupt')
|
|
3978
|
+
assert.ok(progress.mock.calls.filter(([line]) => String(line).includes('sampledAggregateRssBytes')).length > 2);
|
|
3979
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: bounds }), /unsupported file/u);
|
|
3980
|
+
}
|
|
3981
|
+
finally {
|
|
3982
|
+
controller.abort();
|
|
3983
|
+
await running;
|
|
3984
|
+
progress.mockRestore();
|
|
3985
|
+
rmSync(root, { recursive: true, force: true });
|
|
3986
|
+
rmSync(output, { recursive: true, force: true });
|
|
3987
|
+
}
|
|
3988
|
+
});
|
|
3989
|
+
test('filesystem fixture gate still enforces positive byte limits after deterministic contention', async () => {
|
|
3990
|
+
const output = realpathSync(mkdtempSync(join(tmpdir(), 'ai-delivery-fixture-limit-')));
|
|
3991
|
+
const { root } = await fixture({ firstStageScript: filesystemFixtureScript(output, 'pass') });
|
|
3992
|
+
try {
|
|
3993
|
+
const row = await prepareIssueWorktree({
|
|
3994
|
+
baseRef: 'main',
|
|
3995
|
+
identity: 'synthetic-author',
|
|
3996
|
+
issueNumber: 17,
|
|
3997
|
+
repoRoot: root,
|
|
3998
|
+
});
|
|
3999
|
+
await assert.rejects(verifyIssue({
|
|
4000
|
+
issueNumber: 17,
|
|
4001
|
+
repoRoot: row.path,
|
|
4002
|
+
resourceBounds: {
|
|
4003
|
+
maxAggregateRssBytes: 1_000_000_000,
|
|
4004
|
+
minFreeDiskBytes: 1,
|
|
4005
|
+
maxNewOutputBytes: 64,
|
|
4006
|
+
outputRoots: [output],
|
|
4007
|
+
},
|
|
4008
|
+
}), /filesystem output.*exceeded/u);
|
|
4009
|
+
assert.equal(existsSync(join(output, 'fifo')), false);
|
|
4010
|
+
assert.equal(readFileSync(join(output, 'regular'), 'utf8').length, 128);
|
|
4011
|
+
}
|
|
4012
|
+
finally {
|
|
4013
|
+
rmSync(root, { recursive: true, force: true });
|
|
4014
|
+
rmSync(output, { recursive: true, force: true });
|
|
4015
|
+
}
|
|
4016
|
+
});
|
|
4017
|
+
test.each(['verification', 'runtime'])('filesystem fixture gate preserves observation and cancellation after direct %s command closure', async (mode) => {
|
|
4018
|
+
const output = realpathSync(mkdtempSync(join(tmpdir(), 'ai-delivery-fixture-close-')));
|
|
4019
|
+
writeFileSync(join(output, 'unrelated'), 'preserved');
|
|
4020
|
+
const script = `
|
|
4021
|
+
(async () => {
|
|
4022
|
+
const fs = require('node:fs'), cp = require('node:child_process');
|
|
4023
|
+
const child = cp.spawn(process.execPath, ['-e', ${JSON.stringify(filesystemFixtureScript(output, 'cancel'))}], { stdio: 'ignore' });
|
|
4024
|
+
child.unref();
|
|
4025
|
+
fs.writeFileSync(${JSON.stringify(join(output, 'pids'))}, JSON.stringify({ root: process.pid, descendant: child.pid }));
|
|
4026
|
+
while (!fs.existsSync(${JSON.stringify(join(output, 'exit-parent'))})) await new Promise(resolve => setTimeout(resolve, 10));
|
|
4027
|
+
process.exit(0);
|
|
4028
|
+
})().catch(error => { console.error(error.message); process.exitCode = 1; });`;
|
|
4029
|
+
const { root } = await fixture({ firstStageScript: script });
|
|
4030
|
+
const controller = new AbortController();
|
|
4031
|
+
const progress = vi.spyOn(process.stderr, 'write');
|
|
4032
|
+
let running;
|
|
4033
|
+
try {
|
|
4034
|
+
const row = await prepareIssueWorktree({
|
|
4035
|
+
baseRef: 'main',
|
|
4036
|
+
identity: 'synthetic-author',
|
|
4037
|
+
issueNumber: 17,
|
|
4038
|
+
repoRoot: root,
|
|
4039
|
+
});
|
|
4040
|
+
const bounds = {
|
|
4041
|
+
maxAggregateRssBytes: 1_000_000_000,
|
|
4042
|
+
minFreeDiskBytes: 1,
|
|
4043
|
+
maxNewOutputBytes: 4096,
|
|
4044
|
+
outputRoots: [output],
|
|
4045
|
+
};
|
|
4046
|
+
const { withRuntimeSetupWriter } = await import('./verification.js');
|
|
4047
|
+
running = (mode === 'verification'
|
|
4048
|
+
? verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: bounds, signal: controller.signal })
|
|
4049
|
+
: withRuntimeSetupWriter(row.path, (runner) => runner.run([process.execPath, '-e', script], bounds, controller.signal))).catch((error) => error);
|
|
4050
|
+
for (let attempt = 0; attempt < 300 && !existsSync(join(output, 'capability')); attempt++)
|
|
4051
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
4052
|
+
assert.ok(existsSync(join(output, 'capability')));
|
|
4053
|
+
const cap = JSON.parse(readFileSync(join(output, 'capability'), 'utf8'));
|
|
4054
|
+
const pids = JSON.parse(readFileSync(join(output, 'pids'), 'utf8'));
|
|
4055
|
+
const writerPath = join(root, '.git', 'ai-delivery', 'writers@1', digestValue(row.path).slice(7) + '.json');
|
|
4056
|
+
const descendantObserved = () => {
|
|
4057
|
+
const state = JSON.parse(readFileSync(writerPath, 'utf8'));
|
|
4058
|
+
return state.command.tracked?.some((member) => member.pid === pids.descendant) === true;
|
|
4059
|
+
};
|
|
4060
|
+
for (let attempt = 0; attempt < 300 && (!descendantObserved() || readdirSync(join(cap.directory, 'requests')).length === 0); attempt++)
|
|
4061
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
4062
|
+
assert.ok(descendantObserved());
|
|
4063
|
+
assert.ok(readdirSync(join(cap.directory, 'requests')).length > 0);
|
|
4064
|
+
writeFileSync(join(output, 'exit-parent'), 'exit');
|
|
4065
|
+
const parentAlive = () => spawnSync('ps', ['-p', String(pids.root), '-o', 'stat='], { encoding: 'utf8' }).stdout.trim().length > 0;
|
|
4066
|
+
for (let attempt = 0; attempt < 100 && parentAlive(); attempt++)
|
|
4067
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
4068
|
+
assert.equal(parentAlive(), false);
|
|
4069
|
+
const samples = () => progress.mock.calls.filter(([line]) => String(line).includes('sampledAggregateRssBytes')).length;
|
|
4070
|
+
const afterExit = samples();
|
|
4071
|
+
for (let attempt = 0; attempt < 100 && samples() === afterExit; attempt++)
|
|
4072
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
4073
|
+
const observedAfterExit = samples() > afterExit;
|
|
4074
|
+
controller.abort();
|
|
4075
|
+
const failure = String(await running);
|
|
4076
|
+
assert.match(failure, /cancelled/u);
|
|
4077
|
+
assert.doesNotMatch(failure, /cleanup failed|live holder/u);
|
|
4078
|
+
assert.ok(observedAfterExit, 'RSS and disk observation continues while the closed command awaits its scan');
|
|
4079
|
+
assert.equal(existsSync(cap.directory), false);
|
|
4080
|
+
assert.ok(existsSync(join(output, 'fifo')));
|
|
4081
|
+
assert.equal(readFileSync(join(output, 'unrelated'), 'utf8'), 'preserved');
|
|
4082
|
+
assert.equal(spawnSync('ps', ['-p', String(pids.descendant), '-o', 'stat='], { encoding: 'utf8' }).stdout.trim().length, 0);
|
|
4083
|
+
assert.equal(progress.mock.calls.some(([line]) => String(line).includes('"state":"completed"')), false);
|
|
4084
|
+
}
|
|
4085
|
+
finally {
|
|
4086
|
+
controller.abort();
|
|
4087
|
+
await running;
|
|
4088
|
+
progress.mockRestore();
|
|
4089
|
+
rmSync(root, { recursive: true, force: true });
|
|
4090
|
+
rmSync(output, { recursive: true, force: true });
|
|
4091
|
+
}
|
|
4092
|
+
});
|
|
4093
|
+
test('filesystem fixture helper runs standalone and fails closed on malformed capability', async () => {
|
|
4094
|
+
const { withVerificationFilesystemFixture } = await import('./agent.js');
|
|
4095
|
+
assert.equal(await withVerificationFilesystemFixture(() => 'standalone'), 'standalone');
|
|
4096
|
+
const prior = process.env['AI_DELIVERY_OUTPUT_OBSERVATION_GATE'];
|
|
4097
|
+
try {
|
|
4098
|
+
process.env['AI_DELIVERY_OUTPUT_OBSERVATION_GATE'] = '{}';
|
|
4099
|
+
let executed = false;
|
|
4100
|
+
await assert.rejects(withVerificationFilesystemFixture(() => {
|
|
4101
|
+
executed = true;
|
|
4102
|
+
}), /invalid or stale/u);
|
|
4103
|
+
assert.equal(executed, false);
|
|
4104
|
+
}
|
|
4105
|
+
finally {
|
|
4106
|
+
if (prior === undefined)
|
|
4107
|
+
delete process.env['AI_DELIVERY_OUTPUT_OBSERVATION_GATE'];
|
|
4108
|
+
else
|
|
4109
|
+
process.env['AI_DELIVERY_OUTPUT_OBSERVATION_GATE'] = prior;
|
|
4110
|
+
}
|
|
4111
|
+
});
|
|
4112
|
+
test('nested runtime setup inherits the outer gate for its baseline and child command', async () => {
|
|
4113
|
+
const output = realpathSync(mkdtempSync(join(tmpdir(), 'ai-delivery-nested-fixture-')));
|
|
4114
|
+
const inner = await fixture();
|
|
4115
|
+
const worker = filesystemFixtureScript(output, 'pass');
|
|
4116
|
+
const { root } = await fixture({
|
|
4117
|
+
firstStageScript: `
|
|
4118
|
+
(async () => {
|
|
4119
|
+
const fs = require('node:fs'), cp = require('node:child_process'), assert = require('node:assert/strict');
|
|
4120
|
+
const { withRuntimeSetupWriter } = await import(${JSON.stringify(new URL('./verification.js', import.meta.url).href)});
|
|
4121
|
+
const capability = process.env.AI_DELIVERY_OUTPUT_OBSERVATION_GATE;
|
|
4122
|
+
const child = cp.spawn(process.execPath, ['-e', ${JSON.stringify(worker)}], { stdio: ['ignore', 'inherit', 'inherit'] });
|
|
4123
|
+
const closed = new Promise((resolve, reject) => child.once('exit', code => code === 0 ? resolve() : reject(new Error('fixture child exit ' + code))));
|
|
4124
|
+
while (!fs.existsSync(${JSON.stringify(join(output, 'capability'))})) await new Promise(resolve => setTimeout(resolve, 10));
|
|
4125
|
+
assert.equal(fs.readFileSync(${JSON.stringify(join(output, 'capability'))}, 'utf8'), capability);
|
|
4126
|
+
await withRuntimeSetupWriter(${JSON.stringify(inner.root)}, async runner => {
|
|
4127
|
+
await runner.run([process.execPath, '-e', 'require("node:assert/strict").equal(process.env.AI_DELIVERY_OUTPUT_OBSERVATION_GATE, ' + JSON.stringify(capability) + ')'], { maxAggregateRssBytes: 1000000000, minFreeDiskBytes: 1, maxNewOutputBytes: 2048, outputRoots: [${JSON.stringify(output)}] });
|
|
4128
|
+
});
|
|
4129
|
+
await closed;
|
|
4130
|
+
})().catch(error => { console.error(error.message); process.exitCode = 1; });`,
|
|
4131
|
+
});
|
|
4132
|
+
try {
|
|
4133
|
+
const row = await prepareIssueWorktree({
|
|
4134
|
+
baseRef: 'main',
|
|
4135
|
+
identity: 'synthetic-author',
|
|
4136
|
+
issueNumber: 17,
|
|
4137
|
+
repoRoot: root,
|
|
4138
|
+
});
|
|
4139
|
+
await verifyIssue({
|
|
4140
|
+
issueNumber: 17,
|
|
4141
|
+
repoRoot: row.path,
|
|
4142
|
+
resourceBounds: {
|
|
4143
|
+
maxAggregateRssBytes: 1_000_000_000,
|
|
4144
|
+
minFreeDiskBytes: 1,
|
|
4145
|
+
maxNewOutputBytes: 2048,
|
|
4146
|
+
outputRoots: [output],
|
|
4147
|
+
},
|
|
4148
|
+
});
|
|
4149
|
+
assert.deepEqual(readFileSync(join(output, 'events'), 'utf8').trim().split('\n'), [
|
|
4150
|
+
'created',
|
|
4151
|
+
'scan-queued',
|
|
4152
|
+
'reader-rejected',
|
|
4153
|
+
'cleaned',
|
|
4154
|
+
'released',
|
|
4155
|
+
]);
|
|
4156
|
+
assert.equal(existsSync(join(inner.root, '.git', 'ai-delivery', 'output-observation')), false);
|
|
4157
|
+
}
|
|
4158
|
+
finally {
|
|
4159
|
+
rmSync(root, { recursive: true, force: true });
|
|
4160
|
+
rmSync(inner.root, { recursive: true, force: true });
|
|
4161
|
+
rmSync(output, { recursive: true, force: true });
|
|
4162
|
+
}
|
|
4163
|
+
});
|
|
4164
|
+
test('interrupted fixture owner recovers its gate, preserves completed work and rejects a leaked FIFO before resume', async () => {
|
|
4165
|
+
const output = realpathSync(mkdtempSync(join(tmpdir(), 'ai-delivery-fixture-recovery-')));
|
|
4166
|
+
const marker = join(output, 'capability');
|
|
4167
|
+
const { root, counter, secondCounter, failSecond } = await fixture({ twoStages: true });
|
|
4168
|
+
let child;
|
|
4169
|
+
let childOutput = '';
|
|
4170
|
+
try {
|
|
4171
|
+
const row = await prepareIssueWorktree({
|
|
4172
|
+
baseRef: 'main',
|
|
4173
|
+
identity: 'synthetic-author',
|
|
4174
|
+
issueNumber: 17,
|
|
4175
|
+
repoRoot: root,
|
|
4176
|
+
});
|
|
4177
|
+
const bounds = {
|
|
4178
|
+
maxAggregateRssBytes: 1_000_000_000,
|
|
4179
|
+
minFreeDiskBytes: 1,
|
|
4180
|
+
maxNewOutputBytes: 2048,
|
|
4181
|
+
outputRoots: [output],
|
|
4182
|
+
};
|
|
4183
|
+
writeFileSync(failSecond, 'fail once');
|
|
4184
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: bounds }), /exit 7/u);
|
|
4185
|
+
assert.equal(readFileSync(counter, 'utf8'), '1');
|
|
4186
|
+
child = spawn(process.execPath, [
|
|
4187
|
+
'-e',
|
|
4188
|
+
`(async () => {
|
|
4189
|
+
const { withRuntimeSetupWriter } = await import(${JSON.stringify(new URL('./verification.js', import.meta.url).href)});
|
|
4190
|
+
await withRuntimeSetupWriter(${JSON.stringify(row.path)}, runner => runner.run([process.execPath, '-e', ${JSON.stringify(filesystemFixtureScript(output, 'cancel'))}], ${JSON.stringify(bounds)}));
|
|
4191
|
+
})().catch(error => { console.error(error.message); process.exitCode = 1; });`,
|
|
4192
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
4193
|
+
const capture = (chunk) => {
|
|
4194
|
+
childOutput += chunk.toString();
|
|
4195
|
+
if (childOutput.length > 1024 * 1024)
|
|
4196
|
+
child.kill('SIGKILL');
|
|
4197
|
+
};
|
|
4198
|
+
child.stdout.on('data', capture);
|
|
4199
|
+
child.stderr.on('data', capture);
|
|
4200
|
+
const closed = new Promise((resolve) => child.once('exit', () => resolve()));
|
|
4201
|
+
for (let attempt = 0; attempt < 500 && !existsSync(marker); attempt++)
|
|
4202
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
4203
|
+
assert.ok(existsSync(marker), childOutput);
|
|
4204
|
+
const priorGate = JSON.parse(readFileSync(marker, 'utf8'));
|
|
4205
|
+
child.kill('SIGKILL');
|
|
4206
|
+
await closed;
|
|
4207
|
+
assert.equal(child.signalCode, 'SIGKILL');
|
|
4208
|
+
// Exercise the existing writer's stale-file recovery after the exact parent has exited.
|
|
4209
|
+
const writerPath = join(root, '.git', 'ai-delivery', 'writers@1', digestValue(row.path).slice(7) + '.json');
|
|
4210
|
+
const stale = new Date(Date.now() - 20_000);
|
|
4211
|
+
utimesSync(writerPath + '.lock', stale, stale);
|
|
4212
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: bounds }), /unsupported file/u);
|
|
4213
|
+
assert.equal(existsSync(priorGate.directory), false);
|
|
4214
|
+
assert.ok(existsSync(join(output, 'fifo')));
|
|
4215
|
+
assert.equal(readFileSync(counter, 'utf8'), '1');
|
|
4216
|
+
unlinkSync(join(output, 'fifo'));
|
|
4217
|
+
unlinkSync(failSecond);
|
|
4218
|
+
const resumed = await verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: bounds });
|
|
4219
|
+
assert.equal(resumed.stageReceipts.length, 2);
|
|
4220
|
+
assert.equal(readFileSync(counter, 'utf8'), '1');
|
|
4221
|
+
assert.equal(readFileSync(secondCounter, 'utf8'), '2');
|
|
4222
|
+
assert.ok(resumed.stageReceipts.every((receipt) => receipt.commands.every((command) => command.exitCode === 0)));
|
|
4223
|
+
const cached = await verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: bounds });
|
|
4224
|
+
assert.deepEqual(cached.stageReceipts.map((receipt) => receipt.receiptId), resumed.stageReceipts.map((receipt) => receipt.receiptId));
|
|
4225
|
+
await verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: { ...bounds, maxNewOutputBytes: 4096 } });
|
|
4226
|
+
assert.equal(readFileSync(counter, 'utf8'), '2');
|
|
4227
|
+
}
|
|
4228
|
+
finally {
|
|
4229
|
+
child?.kill('SIGKILL');
|
|
4230
|
+
rmSync(root, { recursive: true, force: true });
|
|
4231
|
+
rmSync(output, { recursive: true, force: true });
|
|
4232
|
+
}
|
|
4233
|
+
});
|
|
3666
4234
|
//# sourceMappingURL=lifecycle.test.js.map
|