@aviaratech/ai-delivery 0.3.5 → 0.3.7
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 +65 -1
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +1 -1
- package/dist/agent.js.map +1 -1
- package/dist/cli.js +35 -1
- package/dist/cli.js.map +1 -1
- package/dist/dispatch.js +21 -1
- package/dist/dispatch.js.map +1 -1
- package/dist/github/commandIdentityPolicy.js +1 -0
- package/dist/github/commandIdentityPolicy.js.map +1 -1
- package/dist/issue.d.ts +1 -0
- package/dist/issue.js +22 -10
- package/dist/issue.js.map +1 -1
- package/dist/lifecycle.test.js +470 -4
- package/dist/lifecycle.test.js.map +1 -1
- package/dist/mcp/index.js +1 -1
- package/dist/mcp/tools.d.ts +31 -0
- package/dist/mcp/tools.js +27 -0
- package/dist/mcp/tools.js.map +1 -1
- package/dist/pr.d.ts +1 -1
- package/dist/services/worktreeRegistry.d.ts +12 -0
- package/dist/services/worktreeRegistry.js +146 -4
- package/dist/services/worktreeRegistry.js.map +1 -1
- package/dist/verification.d.ts +34 -0
- package/dist/verification.js +488 -64
- package/dist/verification.js.map +1 -1
- package/dist/worktree.d.ts +3 -0
- package/dist/worktree.js +38 -8
- package/dist/worktree.js.map +1 -1
- package/dist/worktreeTransition.d.ts +173 -0
- package/dist/worktreeTransition.js +1012 -0
- package/dist/worktreeTransition.js.map +1 -0
- package/dist/worktreeTransition.test.d.ts +1 -0
- package/dist/worktreeTransition.test.js +1179 -0
- package/dist/worktreeTransition.test.js.map +1 -0
- package/package.json +1 -1
- package/plugins/ai-delivery/.claude-plugin/plugin.json +1 -1
- package/plugins/ai-delivery/skills/worktree-lifecycle/SKILL.md +6 -0
package/dist/lifecycle.test.js
CHANGED
|
@@ -2792,9 +2792,12 @@ test('MCP exposes one implementation surface for native lifecycle commands', ()
|
|
|
2792
2792
|
'issue_pr_merge',
|
|
2793
2793
|
'issue_finish',
|
|
2794
2794
|
'issue_worktree_create',
|
|
2795
|
+
'issue_worktree_transition_inspect',
|
|
2796
|
+
'issue_worktree_transition_apply',
|
|
2795
2797
|
]);
|
|
2796
2798
|
const validInputs = {
|
|
2797
2799
|
issue_create: { title: 'Synthetic tracking parent' },
|
|
2800
|
+
issue_update: { issueNumber: 17, park: true },
|
|
2798
2801
|
issue_ready_check: { issueNumber: 17 },
|
|
2799
2802
|
issue_develop: { issueNumber: 17 },
|
|
2800
2803
|
issue_verify: { issueNumber: 17 },
|
|
@@ -2802,7 +2805,17 @@ test('MCP exposes one implementation surface for native lifecycle commands', ()
|
|
|
2802
2805
|
issue_pr_review: { issueNumber: 17, prNumber: 23, artifact: '{}' },
|
|
2803
2806
|
issue_pr_merge: { issueNumber: 17, prNumber: 23, strategy: 'merge' },
|
|
2804
2807
|
issue_finish: { issueNumber: 17, prNumber: 23, strategy: 'merge' },
|
|
2808
|
+
issue_worktree_transition_inspect: { issueNumber: 17, purpose: 'active-resume' },
|
|
2809
|
+
issue_worktree_transition_apply: {
|
|
2810
|
+
authority: 'worktree:transition',
|
|
2811
|
+
planPath: '/synthetic/plan.json',
|
|
2812
|
+
expectedPlanId: `sha256:${'a'.repeat(64)}`,
|
|
2813
|
+
relinquishmentCommentId: 101,
|
|
2814
|
+
acceptanceCommentId: 102,
|
|
2815
|
+
},
|
|
2805
2816
|
};
|
|
2817
|
+
const updateTool = AI_DELIVERY_MCP_TOOLS.find((tool) => tool.name === 'issue_update');
|
|
2818
|
+
assert.equal(updateTool.inputSchema.safeParse({ issueNumber: 17, park: false }).success, false);
|
|
2806
2819
|
for (const [name, input] of Object.entries(validInputs)) {
|
|
2807
2820
|
const tool = AI_DELIVERY_MCP_TOOLS.find((candidate) => candidate.name === name);
|
|
2808
2821
|
assert.ok(tool, `${name} has an MCP definition`);
|
|
@@ -2870,6 +2883,9 @@ async function syntheticLifecycle(routing) {
|
|
|
2870
2883
|
let trackingParent = false;
|
|
2871
2884
|
let failFirstBlockerLink = true;
|
|
2872
2885
|
let failNextProjectSync = false;
|
|
2886
|
+
let interruptNextStatusUpdate = false;
|
|
2887
|
+
let conflictNextStatusReadback = false;
|
|
2888
|
+
let projectStatusWrites = 0;
|
|
2873
2889
|
let checksPassed = true;
|
|
2874
2890
|
let prDraft = true;
|
|
2875
2891
|
let prState = 'open';
|
|
@@ -2976,6 +2992,12 @@ async function syntheticLifecycle(routing) {
|
|
|
2976
2992
|
nodes: projectStatus === null
|
|
2977
2993
|
? []
|
|
2978
2994
|
: [
|
|
2995
|
+
{
|
|
2996
|
+
id: 'UNRELATED-ITEM',
|
|
2997
|
+
isArchived: false,
|
|
2998
|
+
content: { id: 'UNRELATED-ISSUE' },
|
|
2999
|
+
fieldValueByName: { name: 'Active', optionId: 'STATUS-1' },
|
|
3000
|
+
},
|
|
2979
3001
|
{
|
|
2980
3002
|
id: 'ITEM-17',
|
|
2981
3003
|
isArchived: false,
|
|
@@ -2999,19 +3021,29 @@ async function syntheticLifecycle(routing) {
|
|
|
2999
3021
|
return { addProjectV2ItemById: { item: { id: 'ITEM-17' } } };
|
|
3000
3022
|
}
|
|
3001
3023
|
if (query.includes('UpdateProjectDeliveryStatus')) {
|
|
3024
|
+
assert.equal(variables.projectId, 'PROJECT-1');
|
|
3025
|
+
assert.equal(variables.itemId, 'ITEM-17');
|
|
3026
|
+
projectStatusWrites += 1;
|
|
3002
3027
|
projectStatus = statusName(String(variables.optionId)) ?? null;
|
|
3028
|
+
if (interruptNextStatusUpdate) {
|
|
3029
|
+
interruptNextStatusUpdate = false;
|
|
3030
|
+
throw new Error('synthetic ambiguous status write');
|
|
3031
|
+
}
|
|
3003
3032
|
return { updateProjectV2ItemFieldValue: { projectV2Item: { id: 'ITEM-17' } } };
|
|
3004
3033
|
}
|
|
3005
|
-
if (query.includes('ProjectDeliveryItemReadback'))
|
|
3034
|
+
if (query.includes('ProjectDeliveryItemReadback')) {
|
|
3035
|
+
const contentId = conflictNextStatusReadback ? 'UNRELATED-ISSUE' : issue.node_id;
|
|
3036
|
+
conflictNextStatusReadback = false;
|
|
3006
3037
|
return {
|
|
3007
3038
|
node: {
|
|
3008
3039
|
id: 'ITEM-17',
|
|
3009
3040
|
isArchived: false,
|
|
3010
3041
|
project: { id: 'PROJECT-1', number: 1 },
|
|
3011
|
-
content: { id:
|
|
3042
|
+
content: { id: contentId },
|
|
3012
3043
|
fieldValueByName: { name: projectStatus, optionId: statuses[projectStatus] },
|
|
3013
3044
|
},
|
|
3014
3045
|
};
|
|
3046
|
+
}
|
|
3015
3047
|
if (query.includes('blockedBy(first:'))
|
|
3016
3048
|
return {
|
|
3017
3049
|
repository: {
|
|
@@ -3032,6 +3064,10 @@ async function syntheticLifecycle(routing) {
|
|
|
3032
3064
|
blockerOpen = true;
|
|
3033
3065
|
return { addBlockedBy: { issue: { number: issueNumber } } };
|
|
3034
3066
|
}
|
|
3067
|
+
if (query.includes('removeBlockedBy')) {
|
|
3068
|
+
blockerOpen = false;
|
|
3069
|
+
return { removeBlockedBy: { issue: { number: issueNumber } } };
|
|
3070
|
+
}
|
|
3035
3071
|
if (query.includes('RepositoryId'))
|
|
3036
3072
|
return { repository: { id: 'REPO-1' } };
|
|
3037
3073
|
if (query.includes('UpdateExactRefs')) {
|
|
@@ -3328,6 +3364,41 @@ async function syntheticLifecycle(routing) {
|
|
|
3328
3364
|
assert.equal(started.title, issue.title);
|
|
3329
3365
|
const row = started;
|
|
3330
3366
|
assert.equal(projectStatus, 'Active');
|
|
3367
|
+
const parked = await updateIssue(context, { issueNumber, park: true });
|
|
3368
|
+
assert.equal(parked.projectStatus, 'Todo');
|
|
3369
|
+
assert.equal(existsSync(row.path), true);
|
|
3370
|
+
assert.equal((await updateIssue(context, { issueNumber, title: issue.title })).projectStatus, 'Todo');
|
|
3371
|
+
await developIssue(context, issueNumber);
|
|
3372
|
+
assert.equal(projectStatus, 'Active');
|
|
3373
|
+
const blockedUpdate = await updateIssue(context, { issueNumber, blockedBy: [9] });
|
|
3374
|
+
assert.deepEqual(blockedUpdate.blockedBy, [9]);
|
|
3375
|
+
assert.equal(blockedUpdate.projectStatus, 'Blocked');
|
|
3376
|
+
assert.equal(projectStatus, 'Waiting');
|
|
3377
|
+
const blockedWrites = projectStatusWrites;
|
|
3378
|
+
assert.equal((await updateIssue(context, { issueNumber, blockedBy: [9] })).projectStatus, 'Blocked');
|
|
3379
|
+
assert.equal(projectStatusWrites, blockedWrites);
|
|
3380
|
+
assert.equal((await updateIssue(context, { issueNumber, park: true })).projectStatus, 'Blocked');
|
|
3381
|
+
await assert.rejects(developIssue(context, issueNumber), /not ready/u);
|
|
3382
|
+
interruptNextStatusUpdate = true;
|
|
3383
|
+
await assert.rejects(updateIssue(context, { issueNumber, blockedBy: [] }), /ambiguous status write/u);
|
|
3384
|
+
assert.equal(blockerOpen, false);
|
|
3385
|
+
assert.equal(projectStatus, 'Queued');
|
|
3386
|
+
const interruptedWrites = projectStatusWrites;
|
|
3387
|
+
const unblockedUpdate = await updateIssue(context, { issueNumber, blockedBy: [] });
|
|
3388
|
+
assert.equal(projectStatusWrites, interruptedWrites);
|
|
3389
|
+
assert.deepEqual(unblockedUpdate.blockedBy, []);
|
|
3390
|
+
assert.equal(unblockedUpdate.projectStatus, 'Todo');
|
|
3391
|
+
conflictNextStatusReadback = true;
|
|
3392
|
+
await assert.rejects(updateIssue(context, { issueNumber, blockedBy: [9] }), /exact issue item/u);
|
|
3393
|
+
assert.equal(blockerOpen, true);
|
|
3394
|
+
assert.equal((await updateIssue(context, { issueNumber, blockedBy: [9] })).projectStatus, 'Blocked');
|
|
3395
|
+
assert.equal((await updateIssue(context, { issueNumber, state: 'closed' })).projectStatus, 'Done');
|
|
3396
|
+
assert.equal((await updateIssue(context, { issueNumber, park: true })).projectStatus, 'Done');
|
|
3397
|
+
assert.equal((await resumeCreatedIssue(context, { issueNumber })).projectStatus, 'Done');
|
|
3398
|
+
assert.equal((await updateIssue(context, { issueNumber, state: 'open', blockedBy: [] })).projectStatus, 'Done');
|
|
3399
|
+
assert.equal(existsSync(row.path), true);
|
|
3400
|
+
await developIssue(context, issueNumber);
|
|
3401
|
+
assert.equal(projectStatus, 'Active');
|
|
3331
3402
|
writeFileSync(join(row.path, 'change.txt'), 'feature\n');
|
|
3332
3403
|
git(row.path, 'add', 'change.txt');
|
|
3333
3404
|
git(row.path, 'commit', '-qm', 'synthetic change');
|
|
@@ -3550,9 +3621,9 @@ test.each([
|
|
|
3550
3621
|
{ remote: 'upstream', divergentOrigin: false },
|
|
3551
3622
|
{ remote: 'upstream', divergentOrigin: true },
|
|
3552
3623
|
])('synthetic lifecycle finishes with remote $remote and divergent origin $divergentOrigin', syntheticLifecycle, 15_000);
|
|
3553
|
-
test('an unrelated consumer uses public MCP setup through verified configured-author publication, counted review readback, merge and cleanup', async () => {
|
|
3624
|
+
test('an unrelated consumer uses public MCP setup through verified configured-author publication, counted review readback, merge and cleanup', { timeout: 0 }, async () => {
|
|
3554
3625
|
await syntheticLifecycle({ remote: 'origin', divergentOrigin: false, producerOnboarding: true });
|
|
3555
|
-
}
|
|
3626
|
+
});
|
|
3556
3627
|
test.each(['escape', 'broken', 'cycle'])('output observation rejects %s aliases without running policy commands', async (kind) => {
|
|
3557
3628
|
const { root, counter } = await fixture();
|
|
3558
3629
|
const base = realpathSync(mkdtempSync(join(tmpdir(), 'ai-delivery-output-alias-')));
|
|
@@ -3592,4 +3663,399 @@ test.each(['escape', 'broken', 'cycle'])('output observation rejects %s aliases
|
|
|
3592
3663
|
rmSync(base, { recursive: true, force: true });
|
|
3593
3664
|
}
|
|
3594
3665
|
});
|
|
3666
|
+
test('fully cached bounded verification still rejects a newly leaked FIFO', async () => {
|
|
3667
|
+
const { root, counter } = await fixture();
|
|
3668
|
+
const output = realpathSync(mkdtempSync(join(tmpdir(), 'ai-delivery-cached-fifo-')));
|
|
3669
|
+
try {
|
|
3670
|
+
const row = await prepareIssueWorktree({
|
|
3671
|
+
baseRef: 'main',
|
|
3672
|
+
identity: 'synthetic-author',
|
|
3673
|
+
issueNumber: 17,
|
|
3674
|
+
repoRoot: root,
|
|
3675
|
+
});
|
|
3676
|
+
const bounds = {
|
|
3677
|
+
maxAggregateRssBytes: 1_000_000_000,
|
|
3678
|
+
minFreeDiskBytes: 1,
|
|
3679
|
+
maxNewOutputBytes: 1024,
|
|
3680
|
+
outputRoots: [output],
|
|
3681
|
+
};
|
|
3682
|
+
await verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: bounds });
|
|
3683
|
+
assert.equal(readFileSync(counter, 'utf8'), '1');
|
|
3684
|
+
execFileSync('mkfifo', [join(output, 'leaked')]);
|
|
3685
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: bounds }), /unsupported file/u);
|
|
3686
|
+
assert.equal(readFileSync(counter, 'utf8'), '1');
|
|
3687
|
+
}
|
|
3688
|
+
finally {
|
|
3689
|
+
rmSync(output, { recursive: true, force: true });
|
|
3690
|
+
rmSync(root, { recursive: true, force: true });
|
|
3691
|
+
}
|
|
3692
|
+
});
|
|
3693
|
+
function filesystemFixtureScript(output, mode) {
|
|
3694
|
+
return `
|
|
3695
|
+
(async () => {
|
|
3696
|
+
const fs = require('node:fs'), cp = require('node:child_process'), path = require('node:path'), assert = require('node:assert/strict');
|
|
3697
|
+
const { withVerificationFilesystemFixture } = await import(${JSON.stringify(new URL('./agent.js', import.meta.url).href)});
|
|
3698
|
+
const { assertPrivateFile } = await import(${JSON.stringify(new URL('./delivery/common.js', import.meta.url).href)});
|
|
3699
|
+
const output = ${JSON.stringify(output)}, fifo = path.join(output, 'fifo'), events = path.join(output, 'events');
|
|
3700
|
+
const gate = JSON.parse(process.env.AI_DELIVERY_OUTPUT_OBSERVATION_GATE);
|
|
3701
|
+
const trace = value => fs.appendFileSync(events, value + '\\n');
|
|
3702
|
+
await withVerificationFilesystemFixture(async () => {
|
|
3703
|
+
cp.execFileSync('mkfifo', ['-m', '600', fifo]);
|
|
3704
|
+
fs.writeFileSync(path.join(output, 'capability'), JSON.stringify(gate));
|
|
3705
|
+
trace('created');
|
|
3706
|
+
try {
|
|
3707
|
+
if (${JSON.stringify(mode)} === 'abrupt') process.exit(9);
|
|
3708
|
+
if (${JSON.stringify(mode)} === 'cancel') await new Promise(() => { setInterval(() => {}, 1000); });
|
|
3709
|
+
if (${JSON.stringify(mode)} === 'block') fs.readFileSync(fifo);
|
|
3710
|
+
while (fs.readdirSync(path.join(gate.directory, 'requests')).length === 0) await new Promise(resolve => setTimeout(resolve, 10));
|
|
3711
|
+
assert.ok(fs.existsSync(fifo)); trace('scan-queued');
|
|
3712
|
+
assert.throws(() => assertPrivateFile(fifo), /private regular file/); trace('reader-rejected');
|
|
3713
|
+
const regular = path.join(output, 'regular'), hard = path.join(output, 'hard'), alias = path.join(output, 'alias');
|
|
3714
|
+
fs.writeFileSync(regular, 'x'.repeat(128), { mode: 0o600 });
|
|
3715
|
+
fs.linkSync(regular, hard); assert.throws(() => assertPrivateFile(hard), /private regular file/); fs.unlinkSync(hard);
|
|
3716
|
+
fs.symlinkSync('./regular', alias); assert.throws(() => assertPrivateFile(alias), /private regular file/); fs.unlinkSync(alias);
|
|
3717
|
+
if (${JSON.stringify(mode)} === 'assertion') throw new Error('synthetic reader assertion failure');
|
|
3718
|
+
} finally { fs.unlinkSync(fifo); trace('cleaned'); }
|
|
3719
|
+
});
|
|
3720
|
+
trace('released');
|
|
3721
|
+
})().catch(error => { console.error(error.message); process.exitCode = 1; });`;
|
|
3722
|
+
}
|
|
3723
|
+
test.each(['pass', 'assertion'])('filesystem fixture gate deterministically queues a scan through reader rejection and %s teardown', async (mode) => {
|
|
3724
|
+
const output = realpathSync(mkdtempSync(join(tmpdir(), 'ai-delivery-fixture-gate-')));
|
|
3725
|
+
const { root } = await fixture({ firstStageScript: filesystemFixtureScript(output, mode) });
|
|
3726
|
+
try {
|
|
3727
|
+
const row = await prepareIssueWorktree({
|
|
3728
|
+
baseRef: 'main',
|
|
3729
|
+
identity: 'synthetic-author',
|
|
3730
|
+
issueNumber: 17,
|
|
3731
|
+
repoRoot: root,
|
|
3732
|
+
});
|
|
3733
|
+
const run = verifyIssue({
|
|
3734
|
+
issueNumber: 17,
|
|
3735
|
+
repoRoot: row.path,
|
|
3736
|
+
resourceBounds: {
|
|
3737
|
+
maxAggregateRssBytes: 1_000_000_000,
|
|
3738
|
+
minFreeDiskBytes: 1,
|
|
3739
|
+
maxNewOutputBytes: 2048,
|
|
3740
|
+
outputRoots: [output],
|
|
3741
|
+
},
|
|
3742
|
+
});
|
|
3743
|
+
if (mode === 'assertion')
|
|
3744
|
+
await assert.rejects(run, /exit 1/u);
|
|
3745
|
+
else
|
|
3746
|
+
assert.ok((await run).resources.maxSampledNewOutputBytes >= 128);
|
|
3747
|
+
assert.equal(existsSync(join(output, 'fifo')), false);
|
|
3748
|
+
const events = readFileSync(join(output, 'events'), 'utf8').trim().split('\n');
|
|
3749
|
+
assert.deepEqual(events, mode === 'pass'
|
|
3750
|
+
? ['created', 'scan-queued', 'reader-rejected', 'cleaned', 'released']
|
|
3751
|
+
: ['created', 'scan-queued', 'reader-rejected', 'cleaned']);
|
|
3752
|
+
const capability = JSON.parse(readFileSync(join(output, 'capability'), 'utf8'));
|
|
3753
|
+
assert.equal(existsSync(capability.directory), false);
|
|
3754
|
+
assert.equal(readFileSync(join(output, 'regular'), 'utf8').length, 128);
|
|
3755
|
+
}
|
|
3756
|
+
finally {
|
|
3757
|
+
rmSync(root, { recursive: true, force: true });
|
|
3758
|
+
rmSync(output, { recursive: true, force: true });
|
|
3759
|
+
}
|
|
3760
|
+
});
|
|
3761
|
+
test.each(['abrupt', 'block', 'cancel'])('filesystem fixture gate preserves leaked FIFO and unrelated files after %s', async (mode) => {
|
|
3762
|
+
const output = realpathSync(mkdtempSync(join(tmpdir(), 'ai-delivery-fixture-failure-')));
|
|
3763
|
+
writeFileSync(join(output, 'unrelated'), 'preserved');
|
|
3764
|
+
const { root } = await fixture({ firstStageScript: filesystemFixtureScript(output, mode) });
|
|
3765
|
+
const controller = new AbortController();
|
|
3766
|
+
let running;
|
|
3767
|
+
const progress = vi.spyOn(process.stderr, 'write');
|
|
3768
|
+
try {
|
|
3769
|
+
const row = await prepareIssueWorktree({
|
|
3770
|
+
baseRef: 'main',
|
|
3771
|
+
identity: 'synthetic-author',
|
|
3772
|
+
issueNumber: 17,
|
|
3773
|
+
repoRoot: root,
|
|
3774
|
+
});
|
|
3775
|
+
const bounds = {
|
|
3776
|
+
maxAggregateRssBytes: 1_000_000_000,
|
|
3777
|
+
minFreeDiskBytes: 1,
|
|
3778
|
+
maxNewOutputBytes: 2048,
|
|
3779
|
+
outputRoots: [output],
|
|
3780
|
+
};
|
|
3781
|
+
running = verifyIssue({
|
|
3782
|
+
issueNumber: 17,
|
|
3783
|
+
repoRoot: row.path,
|
|
3784
|
+
resourceBounds: bounds,
|
|
3785
|
+
signal: controller.signal,
|
|
3786
|
+
}).catch((error) => error);
|
|
3787
|
+
if (mode === 'cancel') {
|
|
3788
|
+
for (let attempt = 0; attempt < 300 && !existsSync(join(output, 'capability')); attempt++)
|
|
3789
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3790
|
+
assert.ok(existsSync(join(output, 'capability')));
|
|
3791
|
+
const cap = JSON.parse(readFileSync(join(output, 'capability'), 'utf8'));
|
|
3792
|
+
for (let attempt = 0; attempt < 300 && readdirSync(join(cap.directory, 'requests')).length === 0; attempt++)
|
|
3793
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3794
|
+
assert.ok(readdirSync(join(cap.directory, 'requests')).length > 0);
|
|
3795
|
+
controller.abort();
|
|
3796
|
+
}
|
|
3797
|
+
const failure = String(await running);
|
|
3798
|
+
assert.match(failure, mode === 'abrupt' ? /exit 9/u : mode === 'block' ? /reader or teardown is stalled/u : /cancelled/u);
|
|
3799
|
+
assert.doesNotMatch(failure, /cleanup failed|live holder/u);
|
|
3800
|
+
const capability = JSON.parse(readFileSync(join(output, 'capability'), 'utf8'));
|
|
3801
|
+
assert.equal(existsSync(capability.directory), false);
|
|
3802
|
+
assert.ok(existsSync(join(output, 'fifo')));
|
|
3803
|
+
assert.equal(readFileSync(join(output, 'unrelated'), 'utf8'), 'preserved');
|
|
3804
|
+
if (mode !== 'abrupt')
|
|
3805
|
+
assert.ok(progress.mock.calls.filter(([line]) => String(line).includes('sampledAggregateRssBytes')).length > 2);
|
|
3806
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: bounds }), /unsupported file/u);
|
|
3807
|
+
}
|
|
3808
|
+
finally {
|
|
3809
|
+
controller.abort();
|
|
3810
|
+
await running;
|
|
3811
|
+
progress.mockRestore();
|
|
3812
|
+
rmSync(root, { recursive: true, force: true });
|
|
3813
|
+
rmSync(output, { recursive: true, force: true });
|
|
3814
|
+
}
|
|
3815
|
+
});
|
|
3816
|
+
test('filesystem fixture gate still enforces positive byte limits after deterministic contention', async () => {
|
|
3817
|
+
const output = realpathSync(mkdtempSync(join(tmpdir(), 'ai-delivery-fixture-limit-')));
|
|
3818
|
+
const { root } = await fixture({ firstStageScript: filesystemFixtureScript(output, 'pass') });
|
|
3819
|
+
try {
|
|
3820
|
+
const row = await prepareIssueWorktree({
|
|
3821
|
+
baseRef: 'main',
|
|
3822
|
+
identity: 'synthetic-author',
|
|
3823
|
+
issueNumber: 17,
|
|
3824
|
+
repoRoot: root,
|
|
3825
|
+
});
|
|
3826
|
+
await assert.rejects(verifyIssue({
|
|
3827
|
+
issueNumber: 17,
|
|
3828
|
+
repoRoot: row.path,
|
|
3829
|
+
resourceBounds: {
|
|
3830
|
+
maxAggregateRssBytes: 1_000_000_000,
|
|
3831
|
+
minFreeDiskBytes: 1,
|
|
3832
|
+
maxNewOutputBytes: 64,
|
|
3833
|
+
outputRoots: [output],
|
|
3834
|
+
},
|
|
3835
|
+
}), /filesystem output.*exceeded/u);
|
|
3836
|
+
assert.equal(existsSync(join(output, 'fifo')), false);
|
|
3837
|
+
assert.equal(readFileSync(join(output, 'regular'), 'utf8').length, 128);
|
|
3838
|
+
}
|
|
3839
|
+
finally {
|
|
3840
|
+
rmSync(root, { recursive: true, force: true });
|
|
3841
|
+
rmSync(output, { recursive: true, force: true });
|
|
3842
|
+
}
|
|
3843
|
+
});
|
|
3844
|
+
test.each(['verification', 'runtime'])('filesystem fixture gate preserves observation and cancellation after direct %s command closure', async (mode) => {
|
|
3845
|
+
const output = realpathSync(mkdtempSync(join(tmpdir(), 'ai-delivery-fixture-close-')));
|
|
3846
|
+
writeFileSync(join(output, 'unrelated'), 'preserved');
|
|
3847
|
+
const script = `
|
|
3848
|
+
(async () => {
|
|
3849
|
+
const fs = require('node:fs'), cp = require('node:child_process');
|
|
3850
|
+
const child = cp.spawn(process.execPath, ['-e', ${JSON.stringify(filesystemFixtureScript(output, 'cancel'))}], { stdio: 'ignore' });
|
|
3851
|
+
child.unref();
|
|
3852
|
+
fs.writeFileSync(${JSON.stringify(join(output, 'pids'))}, JSON.stringify({ root: process.pid, descendant: child.pid }));
|
|
3853
|
+
while (!fs.existsSync(${JSON.stringify(join(output, 'exit-parent'))})) await new Promise(resolve => setTimeout(resolve, 10));
|
|
3854
|
+
process.exit(0);
|
|
3855
|
+
})().catch(error => { console.error(error.message); process.exitCode = 1; });`;
|
|
3856
|
+
const { root } = await fixture({ firstStageScript: script });
|
|
3857
|
+
const controller = new AbortController();
|
|
3858
|
+
const progress = vi.spyOn(process.stderr, 'write');
|
|
3859
|
+
let running;
|
|
3860
|
+
try {
|
|
3861
|
+
const row = await prepareIssueWorktree({
|
|
3862
|
+
baseRef: 'main',
|
|
3863
|
+
identity: 'synthetic-author',
|
|
3864
|
+
issueNumber: 17,
|
|
3865
|
+
repoRoot: root,
|
|
3866
|
+
});
|
|
3867
|
+
const bounds = {
|
|
3868
|
+
maxAggregateRssBytes: 1_000_000_000,
|
|
3869
|
+
minFreeDiskBytes: 1,
|
|
3870
|
+
maxNewOutputBytes: 4096,
|
|
3871
|
+
outputRoots: [output],
|
|
3872
|
+
};
|
|
3873
|
+
const { withRuntimeSetupWriter } = await import('./verification.js');
|
|
3874
|
+
running = (mode === 'verification'
|
|
3875
|
+
? verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: bounds, signal: controller.signal })
|
|
3876
|
+
: withRuntimeSetupWriter(row.path, (runner) => runner.run([process.execPath, '-e', script], bounds, controller.signal))).catch((error) => error);
|
|
3877
|
+
for (let attempt = 0; attempt < 300 && !existsSync(join(output, 'capability')); attempt++)
|
|
3878
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3879
|
+
assert.ok(existsSync(join(output, 'capability')));
|
|
3880
|
+
const cap = JSON.parse(readFileSync(join(output, 'capability'), 'utf8'));
|
|
3881
|
+
const pids = JSON.parse(readFileSync(join(output, 'pids'), 'utf8'));
|
|
3882
|
+
const writerPath = join(root, '.git', 'ai-delivery', 'writers@1', digestValue(row.path).slice(7) + '.json');
|
|
3883
|
+
const descendantObserved = () => {
|
|
3884
|
+
const state = JSON.parse(readFileSync(writerPath, 'utf8'));
|
|
3885
|
+
return state.command.tracked?.some((member) => member.pid === pids.descendant) === true;
|
|
3886
|
+
};
|
|
3887
|
+
for (let attempt = 0; attempt < 300 && (!descendantObserved() || readdirSync(join(cap.directory, 'requests')).length === 0); attempt++)
|
|
3888
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3889
|
+
assert.ok(descendantObserved());
|
|
3890
|
+
assert.ok(readdirSync(join(cap.directory, 'requests')).length > 0);
|
|
3891
|
+
writeFileSync(join(output, 'exit-parent'), 'exit');
|
|
3892
|
+
const parentAlive = () => spawnSync('ps', ['-p', String(pids.root), '-o', 'stat='], { encoding: 'utf8' }).stdout.trim().length > 0;
|
|
3893
|
+
for (let attempt = 0; attempt < 100 && parentAlive(); attempt++)
|
|
3894
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3895
|
+
assert.equal(parentAlive(), false);
|
|
3896
|
+
const samples = () => progress.mock.calls.filter(([line]) => String(line).includes('sampledAggregateRssBytes')).length;
|
|
3897
|
+
const afterExit = samples();
|
|
3898
|
+
for (let attempt = 0; attempt < 100 && samples() === afterExit; attempt++)
|
|
3899
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3900
|
+
const observedAfterExit = samples() > afterExit;
|
|
3901
|
+
controller.abort();
|
|
3902
|
+
const failure = String(await running);
|
|
3903
|
+
assert.match(failure, /cancelled/u);
|
|
3904
|
+
assert.doesNotMatch(failure, /cleanup failed|live holder/u);
|
|
3905
|
+
assert.ok(observedAfterExit, 'RSS and disk observation continues while the closed command awaits its scan');
|
|
3906
|
+
assert.equal(existsSync(cap.directory), false);
|
|
3907
|
+
assert.ok(existsSync(join(output, 'fifo')));
|
|
3908
|
+
assert.equal(readFileSync(join(output, 'unrelated'), 'utf8'), 'preserved');
|
|
3909
|
+
assert.equal(spawnSync('ps', ['-p', String(pids.descendant), '-o', 'stat='], { encoding: 'utf8' }).stdout.trim().length, 0);
|
|
3910
|
+
assert.equal(progress.mock.calls.some(([line]) => String(line).includes('"state":"completed"')), false);
|
|
3911
|
+
}
|
|
3912
|
+
finally {
|
|
3913
|
+
controller.abort();
|
|
3914
|
+
await running;
|
|
3915
|
+
progress.mockRestore();
|
|
3916
|
+
rmSync(root, { recursive: true, force: true });
|
|
3917
|
+
rmSync(output, { recursive: true, force: true });
|
|
3918
|
+
}
|
|
3919
|
+
});
|
|
3920
|
+
test('filesystem fixture helper runs standalone and fails closed on malformed capability', async () => {
|
|
3921
|
+
const { withVerificationFilesystemFixture } = await import('./agent.js');
|
|
3922
|
+
assert.equal(await withVerificationFilesystemFixture(() => 'standalone'), 'standalone');
|
|
3923
|
+
const prior = process.env['AI_DELIVERY_OUTPUT_OBSERVATION_GATE'];
|
|
3924
|
+
try {
|
|
3925
|
+
process.env['AI_DELIVERY_OUTPUT_OBSERVATION_GATE'] = '{}';
|
|
3926
|
+
let executed = false;
|
|
3927
|
+
await assert.rejects(withVerificationFilesystemFixture(() => {
|
|
3928
|
+
executed = true;
|
|
3929
|
+
}), /invalid or stale/u);
|
|
3930
|
+
assert.equal(executed, false);
|
|
3931
|
+
}
|
|
3932
|
+
finally {
|
|
3933
|
+
if (prior === undefined)
|
|
3934
|
+
delete process.env['AI_DELIVERY_OUTPUT_OBSERVATION_GATE'];
|
|
3935
|
+
else
|
|
3936
|
+
process.env['AI_DELIVERY_OUTPUT_OBSERVATION_GATE'] = prior;
|
|
3937
|
+
}
|
|
3938
|
+
});
|
|
3939
|
+
test('nested runtime setup inherits the outer gate for its baseline and child command', async () => {
|
|
3940
|
+
const output = realpathSync(mkdtempSync(join(tmpdir(), 'ai-delivery-nested-fixture-')));
|
|
3941
|
+
const inner = await fixture();
|
|
3942
|
+
const worker = filesystemFixtureScript(output, 'pass');
|
|
3943
|
+
const { root } = await fixture({
|
|
3944
|
+
firstStageScript: `
|
|
3945
|
+
(async () => {
|
|
3946
|
+
const fs = require('node:fs'), cp = require('node:child_process'), assert = require('node:assert/strict');
|
|
3947
|
+
const { withRuntimeSetupWriter } = await import(${JSON.stringify(new URL('./verification.js', import.meta.url).href)});
|
|
3948
|
+
const capability = process.env.AI_DELIVERY_OUTPUT_OBSERVATION_GATE;
|
|
3949
|
+
const child = cp.spawn(process.execPath, ['-e', ${JSON.stringify(worker)}], { stdio: ['ignore', 'inherit', 'inherit'] });
|
|
3950
|
+
const closed = new Promise((resolve, reject) => child.once('exit', code => code === 0 ? resolve() : reject(new Error('fixture child exit ' + code))));
|
|
3951
|
+
while (!fs.existsSync(${JSON.stringify(join(output, 'capability'))})) await new Promise(resolve => setTimeout(resolve, 10));
|
|
3952
|
+
assert.equal(fs.readFileSync(${JSON.stringify(join(output, 'capability'))}, 'utf8'), capability);
|
|
3953
|
+
await withRuntimeSetupWriter(${JSON.stringify(inner.root)}, async runner => {
|
|
3954
|
+
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)}] });
|
|
3955
|
+
});
|
|
3956
|
+
await closed;
|
|
3957
|
+
})().catch(error => { console.error(error.message); process.exitCode = 1; });`,
|
|
3958
|
+
});
|
|
3959
|
+
try {
|
|
3960
|
+
const row = await prepareIssueWorktree({
|
|
3961
|
+
baseRef: 'main',
|
|
3962
|
+
identity: 'synthetic-author',
|
|
3963
|
+
issueNumber: 17,
|
|
3964
|
+
repoRoot: root,
|
|
3965
|
+
});
|
|
3966
|
+
await verifyIssue({
|
|
3967
|
+
issueNumber: 17,
|
|
3968
|
+
repoRoot: row.path,
|
|
3969
|
+
resourceBounds: {
|
|
3970
|
+
maxAggregateRssBytes: 1_000_000_000,
|
|
3971
|
+
minFreeDiskBytes: 1,
|
|
3972
|
+
maxNewOutputBytes: 2048,
|
|
3973
|
+
outputRoots: [output],
|
|
3974
|
+
},
|
|
3975
|
+
});
|
|
3976
|
+
assert.deepEqual(readFileSync(join(output, 'events'), 'utf8').trim().split('\n'), [
|
|
3977
|
+
'created',
|
|
3978
|
+
'scan-queued',
|
|
3979
|
+
'reader-rejected',
|
|
3980
|
+
'cleaned',
|
|
3981
|
+
'released',
|
|
3982
|
+
]);
|
|
3983
|
+
assert.equal(existsSync(join(inner.root, '.git', 'ai-delivery', 'output-observation')), false);
|
|
3984
|
+
}
|
|
3985
|
+
finally {
|
|
3986
|
+
rmSync(root, { recursive: true, force: true });
|
|
3987
|
+
rmSync(inner.root, { recursive: true, force: true });
|
|
3988
|
+
rmSync(output, { recursive: true, force: true });
|
|
3989
|
+
}
|
|
3990
|
+
});
|
|
3991
|
+
test('interrupted fixture owner recovers its gate, preserves completed work and rejects a leaked FIFO before resume', async () => {
|
|
3992
|
+
const output = realpathSync(mkdtempSync(join(tmpdir(), 'ai-delivery-fixture-recovery-')));
|
|
3993
|
+
const marker = join(output, 'capability');
|
|
3994
|
+
const { root, counter, secondCounter, failSecond } = await fixture({ twoStages: true });
|
|
3995
|
+
let child;
|
|
3996
|
+
let childOutput = '';
|
|
3997
|
+
try {
|
|
3998
|
+
const row = await prepareIssueWorktree({
|
|
3999
|
+
baseRef: 'main',
|
|
4000
|
+
identity: 'synthetic-author',
|
|
4001
|
+
issueNumber: 17,
|
|
4002
|
+
repoRoot: root,
|
|
4003
|
+
});
|
|
4004
|
+
const bounds = {
|
|
4005
|
+
maxAggregateRssBytes: 1_000_000_000,
|
|
4006
|
+
minFreeDiskBytes: 1,
|
|
4007
|
+
maxNewOutputBytes: 2048,
|
|
4008
|
+
outputRoots: [output],
|
|
4009
|
+
};
|
|
4010
|
+
writeFileSync(failSecond, 'fail once');
|
|
4011
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: bounds }), /exit 7/u);
|
|
4012
|
+
assert.equal(readFileSync(counter, 'utf8'), '1');
|
|
4013
|
+
child = spawn(process.execPath, [
|
|
4014
|
+
'-e',
|
|
4015
|
+
`(async () => {
|
|
4016
|
+
const { withRuntimeSetupWriter } = await import(${JSON.stringify(new URL('./verification.js', import.meta.url).href)});
|
|
4017
|
+
await withRuntimeSetupWriter(${JSON.stringify(row.path)}, runner => runner.run([process.execPath, '-e', ${JSON.stringify(filesystemFixtureScript(output, 'cancel'))}], ${JSON.stringify(bounds)}));
|
|
4018
|
+
})().catch(error => { console.error(error.message); process.exitCode = 1; });`,
|
|
4019
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
4020
|
+
const capture = (chunk) => {
|
|
4021
|
+
childOutput += chunk.toString();
|
|
4022
|
+
if (childOutput.length > 1024 * 1024)
|
|
4023
|
+
child.kill('SIGKILL');
|
|
4024
|
+
};
|
|
4025
|
+
child.stdout.on('data', capture);
|
|
4026
|
+
child.stderr.on('data', capture);
|
|
4027
|
+
const closed = new Promise((resolve) => child.once('exit', () => resolve()));
|
|
4028
|
+
for (let attempt = 0; attempt < 500 && !existsSync(marker); attempt++)
|
|
4029
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
4030
|
+
assert.ok(existsSync(marker), childOutput);
|
|
4031
|
+
const priorGate = JSON.parse(readFileSync(marker, 'utf8'));
|
|
4032
|
+
child.kill('SIGKILL');
|
|
4033
|
+
await closed;
|
|
4034
|
+
assert.equal(child.signalCode, 'SIGKILL');
|
|
4035
|
+
// Exercise the existing writer's stale-file recovery after the exact parent has exited.
|
|
4036
|
+
const writerPath = join(root, '.git', 'ai-delivery', 'writers@1', digestValue(row.path).slice(7) + '.json');
|
|
4037
|
+
const stale = new Date(Date.now() - 20_000);
|
|
4038
|
+
utimesSync(writerPath + '.lock', stale, stale);
|
|
4039
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: bounds }), /unsupported file/u);
|
|
4040
|
+
assert.equal(existsSync(priorGate.directory), false);
|
|
4041
|
+
assert.ok(existsSync(join(output, 'fifo')));
|
|
4042
|
+
assert.equal(readFileSync(counter, 'utf8'), '1');
|
|
4043
|
+
unlinkSync(join(output, 'fifo'));
|
|
4044
|
+
unlinkSync(failSecond);
|
|
4045
|
+
const resumed = await verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: bounds });
|
|
4046
|
+
assert.equal(resumed.stageReceipts.length, 2);
|
|
4047
|
+
assert.equal(readFileSync(counter, 'utf8'), '1');
|
|
4048
|
+
assert.equal(readFileSync(secondCounter, 'utf8'), '2');
|
|
4049
|
+
assert.ok(resumed.stageReceipts.every((receipt) => receipt.commands.every((command) => command.exitCode === 0)));
|
|
4050
|
+
const cached = await verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: bounds });
|
|
4051
|
+
assert.deepEqual(cached.stageReceipts.map((receipt) => receipt.receiptId), resumed.stageReceipts.map((receipt) => receipt.receiptId));
|
|
4052
|
+
await verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: { ...bounds, maxNewOutputBytes: 4096 } });
|
|
4053
|
+
assert.equal(readFileSync(counter, 'utf8'), '2');
|
|
4054
|
+
}
|
|
4055
|
+
finally {
|
|
4056
|
+
child?.kill('SIGKILL');
|
|
4057
|
+
rmSync(root, { recursive: true, force: true });
|
|
4058
|
+
rmSync(output, { recursive: true, force: true });
|
|
4059
|
+
}
|
|
4060
|
+
});
|
|
3595
4061
|
//# sourceMappingURL=lifecycle.test.js.map
|