@aviaratech/ai-delivery 0.3.2 → 0.3.4
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/CONTRIBUTING.md +36 -9
- package/README.md +58 -8
- package/dist/cli.js +1 -1
- package/dist/config/deliveryConfig.d.ts +4 -1
- package/dist/config/deliveryConfig.js +5 -2
- package/dist/config/deliveryConfig.js.map +1 -1
- package/dist/delivery/common.d.ts +1 -1
- package/dist/delivery/common.js +39 -15
- package/dist/delivery/common.js.map +1 -1
- package/dist/delivery/delivery.test.js +251 -2
- package/dist/delivery/delivery.test.js.map +1 -1
- package/dist/delivery/policy.d.ts +19 -4
- package/dist/delivery/policy.js +18 -5
- package/dist/delivery/policy.js.map +1 -1
- package/dist/delivery/stage.d.ts +56 -8
- package/dist/delivery/stage.js +60 -30
- package/dist/delivery/stage.js.map +1 -1
- package/dist/dispatch.js +67 -4
- package/dist/dispatch.js.map +1 -1
- package/dist/fixtures/discovery.d.ts +1 -1
- package/dist/github/client.d.ts +1 -0
- package/dist/github/client.js +3 -2
- package/dist/github/client.js.map +1 -1
- package/dist/lifecycle.test.js +764 -45
- package/dist/lifecycle.test.js.map +1 -1
- package/dist/pr.d.ts +3 -2
- package/dist/pr.js +18 -6
- package/dist/pr.js.map +1 -1
- package/dist/review.d.ts +5 -1
- package/dist/review.js +34 -3
- package/dist/review.js.map +1 -1
- package/dist/review.test.js +96 -3
- package/dist/review.test.js.map +1 -1
- package/dist/utils/atomicJson.js +11 -2
- package/dist/utils/atomicJson.js.map +1 -1
- package/dist/verification.d.ts +5 -1
- package/dist/verification.js +391 -109
- package/dist/verification.js.map +1 -1
- package/package.json +1 -1
- package/plugins/ai-delivery/.claude-plugin/plugin.json +1 -1
- package/plugins/ai-delivery/skills/pr-handoff/SKILL.md +2 -2
package/dist/lifecycle.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import { cpSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, readdirSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
4
|
+
import { cpSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, readdirSync, realpathSync, rmSync, symlinkSync, unlinkSync, utimesSync, writeFileSync, } from 'node:fs';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { dirname, join } from 'node:path';
|
|
7
7
|
import { test, vi } from 'vitest';
|
|
@@ -16,12 +16,13 @@ import { digestValue } from './delivery/index.js';
|
|
|
16
16
|
import { executeTool, resumedIssueUpdate, startTrackedIssue } from './dispatch.js';
|
|
17
17
|
import { defaultBaseRef, gitCommonDir } from './git.js';
|
|
18
18
|
import * as githubClient from './github/client.js';
|
|
19
|
+
import * as atomicJson from './utils/atomicJson.js';
|
|
19
20
|
import { createIssue, developIssue, listIssueSubissues, readyCheck, resumeCreatedIssue, updateIssue, } from './issue.js';
|
|
20
21
|
import { checkoutPr, finishIssue, listPrs, mergePr, prChecks, prInfo, publishPr, submitFormalReview } from './pr.js';
|
|
21
|
-
import { updateIssueWorktreeDelivery } from './services/worktreeRegistry.js';
|
|
22
|
+
import { addWorktreeEntry, removeWorktreeEntry, updateIssueWorktreeDelivery } from './services/worktreeRegistry.js';
|
|
22
23
|
import { assertDeliveryRuntimeAdmitted } from './services/deliveryAdmission.js';
|
|
23
24
|
import { getDeliveryRecords } from './services/deliveryRecordService.js';
|
|
24
|
-
import { createIssuePhaseEvidence, verifyIssue } from './verification.js';
|
|
25
|
+
import { createIssuePhaseEvidence, loadVerifiedRun, verifyIssue } from './verification.js';
|
|
25
26
|
import { cleanupNonIssueWorktree, prepareIssueWorktree, preparePrWorktree, prepareStandaloneWorktree, } from './worktree.js';
|
|
26
27
|
const personalRoute = vi.hoisted(() => ({
|
|
27
28
|
active: false,
|
|
@@ -29,6 +30,10 @@ const personalRoute = vi.hoisted(() => ({
|
|
|
29
30
|
headSha: '',
|
|
30
31
|
reviews: [],
|
|
31
32
|
submitted: 0,
|
|
33
|
+
created: false,
|
|
34
|
+
failCreate: false,
|
|
35
|
+
ready: false,
|
|
36
|
+
readyPromotions: 0,
|
|
32
37
|
}));
|
|
33
38
|
vi.mock('./github/client.js', async (importOriginal) => {
|
|
34
39
|
const actual = await importOriginal();
|
|
@@ -43,7 +48,7 @@ vi.mock('./github/client.js', async (importOriginal) => {
|
|
|
43
48
|
head: { sha: personalRoute.headSha, ref: 'issue/17' },
|
|
44
49
|
base: { sha: personalRoute.baseSha, ref: 'main' },
|
|
45
50
|
state: 'open',
|
|
46
|
-
draft:
|
|
51
|
+
draft: !personalRoute.ready,
|
|
47
52
|
user: { login: 'host-user' },
|
|
48
53
|
});
|
|
49
54
|
if (input.role === 'reviewer') {
|
|
@@ -76,13 +81,38 @@ vi.mock('./github/client.js', async (importOriginal) => {
|
|
|
76
81
|
},
|
|
77
82
|
},
|
|
78
83
|
},
|
|
79
|
-
graphql: async (query, variables) =>
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
graphql: async (query, variables) => {
|
|
85
|
+
if (query.includes('DeliveryReviewDecision')) {
|
|
86
|
+
return {
|
|
87
|
+
repository: {
|
|
88
|
+
pullRequest: { headRefOid: personalRoute.headSha, reviewDecision: 'REVIEW_REQUIRED' },
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (query.includes('DeliveryReviewAccess')) {
|
|
93
|
+
return {
|
|
94
|
+
repository: {
|
|
95
|
+
pullRequest: {
|
|
96
|
+
reviews: {
|
|
97
|
+
nodes: personalRoute.reviews.map((review) => ({
|
|
98
|
+
fullDatabaseId: String(review.id),
|
|
99
|
+
author: { login: review.user.login.replace(/\[bot\]$/u, '') },
|
|
100
|
+
authorCanPushToRepository: false,
|
|
101
|
+
commit: { oid: review.commit_id },
|
|
102
|
+
state: review.state,
|
|
103
|
+
})),
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (query.includes('DeliveryDiscovery') ||
|
|
110
|
+
query.includes('DeliveryRepository') ||
|
|
111
|
+
query.includes('ProjectDeliveryConfiguration')) {
|
|
112
|
+
throw Object.assign(new Error('Reviewer cannot discover author routing metadata'), { status: 403 });
|
|
84
113
|
}
|
|
85
|
-
|
|
114
|
+
return syntheticDiscoveryClients().graphql(query, variables);
|
|
115
|
+
},
|
|
86
116
|
};
|
|
87
117
|
}
|
|
88
118
|
assert.equal(input.identity, 'host-author');
|
|
@@ -92,14 +122,29 @@ vi.mock('./github/client.js', async (importOriginal) => {
|
|
|
92
122
|
});
|
|
93
123
|
Object.assign(clients, {
|
|
94
124
|
authenticatedAuthor: async () => ({ actorLogin: 'host-user', credentialIdentity: 'user:37' }),
|
|
95
|
-
graphql:
|
|
125
|
+
graphql: async (query, variables) => {
|
|
126
|
+
if (query.includes('markPullRequestReadyForReview')) {
|
|
127
|
+
personalRoute.ready = true;
|
|
128
|
+
personalRoute.readyPromotions += 1;
|
|
129
|
+
return { markPullRequestReadyForReview: { pullRequest: { id: 'PR-23', isDraft: false } } };
|
|
130
|
+
}
|
|
131
|
+
return syntheticDiscoveryClients().graphql(query, variables);
|
|
132
|
+
},
|
|
96
133
|
rest: {
|
|
97
134
|
issues: { get: async () => ({ data: { title: 'Synthetic issue' } }) },
|
|
98
135
|
git: { getRef: async () => ({ data: { object: { sha: personalRoute.baseSha } } }) },
|
|
99
136
|
pulls: {
|
|
100
|
-
list: async () => ({ data: [] }),
|
|
101
|
-
create: async () =>
|
|
137
|
+
list: async () => ({ data: personalRoute.created ? [pr()] : [] }),
|
|
138
|
+
create: async () => {
|
|
139
|
+
if (personalRoute.failCreate) {
|
|
140
|
+
personalRoute.failCreate = false;
|
|
141
|
+
throw new Error('Synthetic response lost after branch push');
|
|
142
|
+
}
|
|
143
|
+
personalRoute.created = true;
|
|
144
|
+
return { data: pr() };
|
|
145
|
+
},
|
|
102
146
|
get: async () => ({ data: pr() }),
|
|
147
|
+
listReviews: async () => ({ data: personalRoute.reviews }),
|
|
103
148
|
},
|
|
104
149
|
request: async () => Promise.reject(Object.assign(new Error('Rules unreadable'), { status: 403 })),
|
|
105
150
|
},
|
|
@@ -174,6 +219,8 @@ async function fixture(options = {}) {
|
|
|
174
219
|
git(root, 'remote', 'add', remoteName, join(root, '.git', 'remote.git'));
|
|
175
220
|
writeFileSync(join(root, '.gitignore'), '.issue-cli/\n.worktrees/\n');
|
|
176
221
|
writeFileSync(join(root, 'artifact.txt'), 'synthetic proof\n');
|
|
222
|
+
if (options.componentPolicy)
|
|
223
|
+
writeFileSync(join(root, 'component.txt'), 'second component\n');
|
|
177
224
|
const config = {
|
|
178
225
|
commandPolicy: {
|
|
179
226
|
checks: { format: 'REQUIRED', gitClean: 'REQUIRED', lint: 'REQUIRED', test: 'REQUIRED', typecheck: 'REQUIRED' },
|
|
@@ -231,7 +278,7 @@ const bytes = value => 'sha256:' + createHash('sha256').update(value).digest('he
|
|
|
231
278
|
const policyDigest = bytes(readFileSync(fileURLToPath(import.meta.url)));
|
|
232
279
|
export const deliverySettings = ${JSON.stringify({ roles: config.roles, commandPolicy: config.commandPolicy })};
|
|
233
280
|
export default {
|
|
234
|
-
schemaVersion: 'RepositoryDeliveryPolicy@1',
|
|
281
|
+
schemaVersion: '${options.componentPolicy ? 'RepositoryDeliveryPolicy@2' : 'RepositoryDeliveryPolicy@1'}',
|
|
235
282
|
classifyExactRange(input) {
|
|
236
283
|
const content = { artifacts: [{ digest: bytes(readFileSync(new URL('./artifact.txt', import.meta.url))),
|
|
237
284
|
path: 'artifact.txt', producer: 'synthetic' }],
|
|
@@ -241,9 +288,11 @@ export default {
|
|
|
241
288
|
schemaVersion: 'ai-delivery.policy-evidence@1' };
|
|
242
289
|
return { policyDigest, policyEvidence: { ...content, evidenceId: digest(content) },
|
|
243
290
|
requiredStages: [{ id: 'check', dependsOn: [], semanticInputKeys: ['source'],
|
|
291
|
+
${options.componentPolicy ? "semanticInputs: [{ key: 'source', digest: bytes(readFileSync(new URL('./artifact.txt', import.meta.url))) }]," : ''}
|
|
244
292
|
resourceClass: 'source_only', commands: [{ label: 'count', argv: [process.execPath, '-e',
|
|
245
293
|
${JSON.stringify(options.firstStageScript ?? `const fs=require('fs');const p=${JSON.stringify(counter)};fs.writeFileSync(p,String(Number(fs.existsSync(p)?fs.readFileSync(p,'utf8'):0)+1));`)}] }] }${options.twoStages
|
|
246
294
|
? `, { id: 'second', dependsOn: ['check'], semanticInputKeys: ['source'],
|
|
295
|
+
${options.componentPolicy ? "semanticInputs: [{ key: 'source', digest: bytes(readFileSync(new URL('./component.txt', import.meta.url))) }]," : ''}
|
|
247
296
|
resourceClass: 'source_only', commands: [{ label: 'retry', argv: [process.execPath, '-e',
|
|
248
297
|
${JSON.stringify(options.secondStageScript ?? `const fs=require('fs');const p=${JSON.stringify(secondCounter)};fs.writeFileSync(p,String(Number(fs.existsSync(p)?fs.readFileSync(p,'utf8'):0)+1));if(fs.existsSync(${JSON.stringify(failSecond)}))process.exit(7);`)}] }] }`
|
|
249
298
|
: ''}],
|
|
@@ -627,6 +676,443 @@ test('synthetic cutover restores exact pre-write state and resumes an incomplete
|
|
|
627
676
|
rmSync(root, { recursive: true, force: true });
|
|
628
677
|
}
|
|
629
678
|
});
|
|
679
|
+
test('immediate successful commands establish ownership and remain reusable', async () => {
|
|
680
|
+
const { root } = await fixture();
|
|
681
|
+
try {
|
|
682
|
+
const row = await prepareIssueWorktree({
|
|
683
|
+
baseRef: 'main',
|
|
684
|
+
identity: 'synthetic-author',
|
|
685
|
+
issueNumber: 17,
|
|
686
|
+
repoRoot: root,
|
|
687
|
+
});
|
|
688
|
+
const policyPath = join(row.path, 'policy.mjs');
|
|
689
|
+
writeFileSync(policyPath, readFileSync(policyPath, 'utf8').replace("argv: [process.execPath, '-e',", "argv: ['/usr/bin/true',"));
|
|
690
|
+
git(row.path, 'add', 'policy.mjs');
|
|
691
|
+
git(row.path, 'commit', '-qm', 'immediate stage');
|
|
692
|
+
const first = await verifyIssue({ issueNumber: 17, repoRoot: row.path });
|
|
693
|
+
const reused = await verifyIssue({ issueNumber: 17, repoRoot: row.path });
|
|
694
|
+
assert.equal(first.stageReceipts[0].receiptId, reused.stageReceipts[0].receiptId);
|
|
695
|
+
const bounded = await verifyIssue({
|
|
696
|
+
issueNumber: 17,
|
|
697
|
+
repoRoot: row.path,
|
|
698
|
+
resourceBounds: { maxAggregateRssBytes: 1_000_000_000, minFreeDiskBytes: 1 },
|
|
699
|
+
});
|
|
700
|
+
assert.equal(bounded.aggregate.result, 'passed');
|
|
701
|
+
assert.ok(bounded.resources.sampleCount > 0);
|
|
702
|
+
assert.deepEqual(readdirSync(join(root, '.git/ai-delivery/writers@1')), []);
|
|
703
|
+
}
|
|
704
|
+
finally {
|
|
705
|
+
rmSync(root, { recursive: true, force: true });
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
test('component verification recomposes current-source aggregates with only affected stage execution', async () => {
|
|
709
|
+
const { root, counter, secondCounter } = await fixture({ twoStages: true, componentPolicy: true });
|
|
710
|
+
try {
|
|
711
|
+
const row = await prepareIssueWorktree({
|
|
712
|
+
baseRef: 'main',
|
|
713
|
+
identity: 'synthetic-author',
|
|
714
|
+
issueNumber: 17,
|
|
715
|
+
repoRoot: root,
|
|
716
|
+
});
|
|
717
|
+
const resourceBounds = { maxAggregateRssBytes: 1_000_000_000, minFreeDiskBytes: 1 };
|
|
718
|
+
const original = await verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds });
|
|
719
|
+
writeFileSync(join(row.path, 'unrelated.txt'), 'documentation');
|
|
720
|
+
git(row.path, 'add', '.');
|
|
721
|
+
git(row.path, 'commit', '-qm', 'unrelated source');
|
|
722
|
+
const current = await verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds });
|
|
723
|
+
assert.notEqual(current.classification.receiptId, original.classification.receiptId);
|
|
724
|
+
assert.deepEqual(current.stageReceipts.map((r) => r.receiptId), original.stageReceipts.map((r) => r.receiptId));
|
|
725
|
+
assert.equal(current.aggregate.classificationReceiptId, current.classification.receiptId);
|
|
726
|
+
assert.equal(readFileSync(counter, 'utf8'), '1');
|
|
727
|
+
assert.equal(readFileSync(secondCounter, 'utf8'), '1');
|
|
728
|
+
writeFileSync(join(row.path, 'component.txt'), 'changed second component');
|
|
729
|
+
git(row.path, 'add', '.');
|
|
730
|
+
git(row.path, 'commit', '-qm', 'second component');
|
|
731
|
+
const secondChanged = await verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds });
|
|
732
|
+
assert.equal(secondChanged.stageReceipts[0].receiptId, original.stageReceipts[0].receiptId);
|
|
733
|
+
assert.equal(readFileSync(counter, 'utf8'), '1');
|
|
734
|
+
assert.equal(readFileSync(secondCounter, 'utf8'), '2');
|
|
735
|
+
writeFileSync(join(row.path, 'artifact.txt'), 'changed upstream component');
|
|
736
|
+
git(row.path, 'add', '.');
|
|
737
|
+
git(row.path, 'commit', '-qm', 'upstream component');
|
|
738
|
+
await verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds });
|
|
739
|
+
assert.equal(readFileSync(counter, 'utf8'), '2');
|
|
740
|
+
assert.equal(readFileSync(secondCounter, 'utf8'), '3');
|
|
741
|
+
await createIssuePhaseEvidence({ issueNumber: 17, repoRoot: row.path, phase: 'verify' });
|
|
742
|
+
}
|
|
743
|
+
finally {
|
|
744
|
+
rmSync(root, { recursive: true, force: true });
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
test('quiet verification reports completion-based progress and remains cancellable', { timeout: 12_000 }, async () => {
|
|
748
|
+
const { root } = await fixture({ firstStageScript: 'setInterval(()=>{},1000);' });
|
|
749
|
+
const controller = new AbortController();
|
|
750
|
+
const progress = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
751
|
+
let running;
|
|
752
|
+
try {
|
|
753
|
+
const row = await prepareIssueWorktree({
|
|
754
|
+
baseRef: 'main',
|
|
755
|
+
identity: 'synthetic-author',
|
|
756
|
+
issueNumber: 17,
|
|
757
|
+
repoRoot: root,
|
|
758
|
+
});
|
|
759
|
+
running = verifyIssue({ issueNumber: 17, repoRoot: row.path, signal: controller.signal });
|
|
760
|
+
void running.catch(() => undefined);
|
|
761
|
+
let status;
|
|
762
|
+
for (let attempt = 0; attempt < 350; attempt++) {
|
|
763
|
+
const latest = progress.mock.calls
|
|
764
|
+
.map(([chunk]) => String(chunk))
|
|
765
|
+
.filter((line) => line.startsWith('ai-delivery.verify '))
|
|
766
|
+
.at(-1);
|
|
767
|
+
if (latest)
|
|
768
|
+
status = JSON.parse(latest.slice('ai-delivery.verify '.length));
|
|
769
|
+
if (Number(status?.elapsedMs) >= 5_000)
|
|
770
|
+
break;
|
|
771
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
772
|
+
}
|
|
773
|
+
assert.equal(status?.completedCommands, 0);
|
|
774
|
+
assert.equal(status?.remainingCommands, 1);
|
|
775
|
+
assert.equal(status?.completedStages, 0);
|
|
776
|
+
assert.equal(status?.capturedOutputBytes, 0);
|
|
777
|
+
assert.ok(Number(status?.lastCompletedWorkAgeMs) >= 5_000);
|
|
778
|
+
controller.abort();
|
|
779
|
+
await assert.rejects(running, /cancelled/u);
|
|
780
|
+
}
|
|
781
|
+
finally {
|
|
782
|
+
controller.abort();
|
|
783
|
+
await running?.catch(() => undefined);
|
|
784
|
+
vi.restoreAllMocks();
|
|
785
|
+
rmSync(root, { recursive: true, force: true });
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
test('writer persistence failure retains recovery identity while cancellation cleans its owned command', async () => {
|
|
789
|
+
const directory = mkdtempSync(join(tmpdir(), 'ai-delivery-writer-failure-'));
|
|
790
|
+
const marker = join(directory, 'started');
|
|
791
|
+
const { root } = await fixture({
|
|
792
|
+
firstStageScript: `const fs=require('fs');fs.writeFileSync(${JSON.stringify(marker)},String(process.pid));setInterval(()=>{},1000);`,
|
|
793
|
+
});
|
|
794
|
+
const controller = new AbortController();
|
|
795
|
+
const original = atomicJson.writePrivateJsonFileAtomically;
|
|
796
|
+
let failWrites = false;
|
|
797
|
+
const persistence = vi.spyOn(atomicJson, 'writePrivateJsonFileAtomically').mockImplementation((path, value) => {
|
|
798
|
+
if (failWrites && path.includes('/writers@1/'))
|
|
799
|
+
throw new Error('ENOSPC: synthetic writer persistence failure');
|
|
800
|
+
original(path, value);
|
|
801
|
+
});
|
|
802
|
+
let running;
|
|
803
|
+
let owner;
|
|
804
|
+
try {
|
|
805
|
+
const row = await prepareIssueWorktree({
|
|
806
|
+
baseRef: 'main',
|
|
807
|
+
identity: 'synthetic-author',
|
|
808
|
+
issueNumber: 17,
|
|
809
|
+
repoRoot: root,
|
|
810
|
+
});
|
|
811
|
+
const writerPath = join(root, '.git/ai-delivery/writers@1', `${digestValue(row.path).slice(7)}.json`);
|
|
812
|
+
running = verifyIssue({ issueNumber: 17, repoRoot: row.path, signal: controller.signal }).catch((error) => error);
|
|
813
|
+
for (let attempt = 0; attempt < 100 && !existsSync(marker); attempt++)
|
|
814
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
815
|
+
assert.ok(existsSync(marker));
|
|
816
|
+
const recorded = JSON.parse(readFileSync(writerPath, 'utf8'));
|
|
817
|
+
owner = recorded.command.root;
|
|
818
|
+
assert.equal(Number(readFileSync(marker, 'utf8')), owner.pid);
|
|
819
|
+
failWrites = true;
|
|
820
|
+
controller.abort();
|
|
821
|
+
const failure = String(await running);
|
|
822
|
+
assert.match(failure, /cancelled.*Writer state persistence failed.*ENOSPC/u);
|
|
823
|
+
assert.doesNotMatch(failure, /Owned process cleanup failed/u);
|
|
824
|
+
const remaining = spawnSync('ps', ['-p', String(owner.pid), '-o', 'stat='], { encoding: 'utf8' });
|
|
825
|
+
assert.ok(remaining.status === 1 || remaining.stdout.trim().startsWith('Z'));
|
|
826
|
+
assert.deepEqual(JSON.parse(readFileSync(writerPath, 'utf8')).command.root, owner);
|
|
827
|
+
assert.equal(existsSync(`${writerPath}.lock`), false);
|
|
828
|
+
failWrites = false;
|
|
829
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path }), /live verification writer/u);
|
|
830
|
+
assert.equal(existsSync(writerPath), true);
|
|
831
|
+
}
|
|
832
|
+
finally {
|
|
833
|
+
controller.abort();
|
|
834
|
+
await running;
|
|
835
|
+
persistence.mockRestore();
|
|
836
|
+
if (owner !== undefined) {
|
|
837
|
+
const current = spawnSync('ps', ['-p', String(owner.pid), '-o', 'lstart='], { encoding: 'utf8' });
|
|
838
|
+
if (current.status === 0 && current.stdout.trim() === owner.identity)
|
|
839
|
+
process.kill(owner.pid, 'SIGKILL');
|
|
840
|
+
}
|
|
841
|
+
rmSync(root, { recursive: true, force: true });
|
|
842
|
+
rmSync(directory, { recursive: true, force: true });
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
test('verification excludes a second writer in the same worktree before command execution', async () => {
|
|
846
|
+
const marker = join(tmpdir(), `ai-delivery-writer-${String(process.pid)}`);
|
|
847
|
+
const { root } = await fixture({
|
|
848
|
+
firstStageScript: `const fs=require('fs');fs.appendFileSync(${JSON.stringify(marker)},'started\\n');setInterval(()=>{},1000);`,
|
|
849
|
+
});
|
|
850
|
+
const controller = new AbortController();
|
|
851
|
+
let running;
|
|
852
|
+
try {
|
|
853
|
+
const row = await prepareIssueWorktree({
|
|
854
|
+
baseRef: 'main',
|
|
855
|
+
identity: 'synthetic-author',
|
|
856
|
+
issueNumber: 17,
|
|
857
|
+
repoRoot: root,
|
|
858
|
+
});
|
|
859
|
+
running = verifyIssue({ issueNumber: 17, repoRoot: row.path, signal: controller.signal }).catch((error) => error);
|
|
860
|
+
for (let attempt = 0; attempt < 100 && !existsSync(marker); attempt++)
|
|
861
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
862
|
+
assert.ok(existsSync(marker));
|
|
863
|
+
const contender = new AbortController();
|
|
864
|
+
const stopContender = setTimeout(() => contender.abort(), 500);
|
|
865
|
+
try {
|
|
866
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path, signal: contender.signal }), /writer|Lock acquisition/u);
|
|
867
|
+
}
|
|
868
|
+
finally {
|
|
869
|
+
clearTimeout(stopContender);
|
|
870
|
+
}
|
|
871
|
+
assert.equal(readFileSync(marker, 'utf8'), 'started\n');
|
|
872
|
+
controller.abort();
|
|
873
|
+
assert.match(String(await running), /cancelled/u);
|
|
874
|
+
}
|
|
875
|
+
finally {
|
|
876
|
+
controller.abort();
|
|
877
|
+
await running;
|
|
878
|
+
rmSync(root, { recursive: true, force: true });
|
|
879
|
+
rmSync(marker, { force: true });
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
test('live or unknown writer state fails closed even after a file lease becomes stale', async () => {
|
|
883
|
+
const { root, counter } = await fixture();
|
|
884
|
+
try {
|
|
885
|
+
const row = await prepareIssueWorktree({
|
|
886
|
+
baseRef: 'main',
|
|
887
|
+
identity: 'synthetic-author',
|
|
888
|
+
issueNumber: 17,
|
|
889
|
+
repoRoot: root,
|
|
890
|
+
});
|
|
891
|
+
const writerDirectory = join(root, '.git/ai-delivery/writers@1');
|
|
892
|
+
mkdirSync(writerDirectory, { recursive: true });
|
|
893
|
+
const writerPath = join(writerDirectory, `${digestValue(row.path).slice(7)}.json`);
|
|
894
|
+
const state = {
|
|
895
|
+
schemaVersion: 'ai-delivery.verification-writer@1',
|
|
896
|
+
writerId: '00000000-0000-4000-8000-000000000001',
|
|
897
|
+
worktreeDigest: digestValue(row.path),
|
|
898
|
+
owner: {
|
|
899
|
+
pid: process.pid,
|
|
900
|
+
identity: execFileSync('/bin/ps', ['-p', String(process.pid), '-o', 'lstart='], { encoding: 'utf8' }).trim(),
|
|
901
|
+
},
|
|
902
|
+
command: { phase: 'idle' },
|
|
903
|
+
};
|
|
904
|
+
writeFileSync(writerPath, JSON.stringify({ ...state, stateId: digestValue(state) }), { mode: 0o600 });
|
|
905
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path }), /live verification writer/u);
|
|
906
|
+
const unknown = {
|
|
907
|
+
...state,
|
|
908
|
+
owner: { ...state.owner, identity: 'replaced historical process' },
|
|
909
|
+
command: { phase: 'starting' },
|
|
910
|
+
};
|
|
911
|
+
writeFileSync(writerPath, JSON.stringify({ ...unknown, stateId: digestValue(unknown) }));
|
|
912
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path }), /unknown command ownership/u);
|
|
913
|
+
assert.equal(existsSync(counter), false);
|
|
914
|
+
assert.equal(existsSync(writerPath), true);
|
|
915
|
+
}
|
|
916
|
+
finally {
|
|
917
|
+
rmSync(root, { recursive: true, force: true });
|
|
918
|
+
}
|
|
919
|
+
});
|
|
920
|
+
test('same-head worktrees overlap with isolated checkpoints and cancellation preserves the peer', { timeout: 15_000 }, async () => {
|
|
921
|
+
const { root } = await fixture({
|
|
922
|
+
firstStageScript: `const fs=require('fs');const cp=require('child_process');const path=require('path');const dir=cp.execFileSync('git',['rev-parse','--absolute-git-dir'],{encoding:'utf8'}).trim();const marker=path.join(dir,'peer-command');fs.writeFileSync(marker,'running');const wait=setInterval(()=>{if(fs.existsSync(marker+'.release')){clearInterval(wait);fs.writeFileSync(marker,'passed');}},20);`,
|
|
923
|
+
});
|
|
924
|
+
const cancel = new AbortController();
|
|
925
|
+
const parallelCancel = new AbortController();
|
|
926
|
+
let first;
|
|
927
|
+
let second;
|
|
928
|
+
let parallelRuns = [];
|
|
929
|
+
try {
|
|
930
|
+
const a = await prepareIssueWorktree({
|
|
931
|
+
baseRef: 'main',
|
|
932
|
+
identity: 'synthetic-author',
|
|
933
|
+
issueNumber: 17,
|
|
934
|
+
repoRoot: root,
|
|
935
|
+
});
|
|
936
|
+
const b = await prepareIssueWorktree({
|
|
937
|
+
baseRef: 'main',
|
|
938
|
+
identity: 'synthetic-author',
|
|
939
|
+
issueNumber: 18,
|
|
940
|
+
repoRoot: root,
|
|
941
|
+
});
|
|
942
|
+
assert.equal(git(a.path, 'rev-parse', 'HEAD'), git(b.path, 'rev-parse', 'HEAD'));
|
|
943
|
+
first = verifyIssue({ issueNumber: 17, repoRoot: a.path, signal: cancel.signal }).catch((error) => error);
|
|
944
|
+
second = verifyIssue({ issueNumber: 18, repoRoot: b.path });
|
|
945
|
+
void second.catch(() => undefined);
|
|
946
|
+
const markerA = join(git(a.path, 'rev-parse', '--absolute-git-dir'), 'peer-command');
|
|
947
|
+
const markerB = join(git(b.path, 'rev-parse', '--absolute-git-dir'), 'peer-command');
|
|
948
|
+
for (let attempt = 0; attempt < 100 && !(existsSync(markerA) && existsSync(markerB)); attempt++)
|
|
949
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
950
|
+
assert.equal(readFileSync(markerA, 'utf8'), 'running');
|
|
951
|
+
assert.equal(readFileSync(markerB, 'utf8'), 'running');
|
|
952
|
+
cancel.abort();
|
|
953
|
+
assert.match(String(await first), /cancelled/u);
|
|
954
|
+
writeFileSync(`${markerB}.release`, 'ready');
|
|
955
|
+
const peer = await second;
|
|
956
|
+
writeFileSync(`${markerA}.release`, 'ready');
|
|
957
|
+
const retried = await verifyIssue({ issueNumber: 17, repoRoot: a.path });
|
|
958
|
+
assert.notEqual(peer.stageReceipts[0].input.inputId, retried.stageReceipts[0].input.inputId);
|
|
959
|
+
assert.equal(loadVerifiedRun(b.path, 18).manifestId, peer.manifestId);
|
|
960
|
+
assert.equal(loadVerifiedRun(a.path, 17).manifestId, retried.manifestId);
|
|
961
|
+
assert.equal(readFileSync(markerA, 'utf8'), 'passed');
|
|
962
|
+
assert.equal(readFileSync(markerB, 'utf8'), 'passed');
|
|
963
|
+
assert.deepEqual(peer.stageReceipts[0].commands, retried.stageReceipts[0].commands);
|
|
964
|
+
const resourceBounds = { maxAggregateRssBytes: 1_000_000_000, minFreeDiskBytes: 1 };
|
|
965
|
+
const parallelStartedAt = Date.now();
|
|
966
|
+
for (const marker of [markerA, markerB]) {
|
|
967
|
+
rmSync(marker);
|
|
968
|
+
rmSync(`${marker}.release`);
|
|
969
|
+
}
|
|
970
|
+
parallelRuns = [
|
|
971
|
+
verifyIssue({ issueNumber: 17, repoRoot: a.path, resourceBounds, signal: parallelCancel.signal }),
|
|
972
|
+
verifyIssue({ issueNumber: 18, repoRoot: b.path, resourceBounds, signal: parallelCancel.signal }),
|
|
973
|
+
];
|
|
974
|
+
const parallelRun = Promise.all(parallelRuns);
|
|
975
|
+
void parallelRun.catch(() => undefined);
|
|
976
|
+
for (let attempt = 0; attempt < 200 && !(existsSync(markerA) && existsSync(markerB)); attempt++)
|
|
977
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
978
|
+
assert.equal(readFileSync(markerA, 'utf8'), 'running');
|
|
979
|
+
assert.equal(readFileSync(markerB, 'utf8'), 'running');
|
|
980
|
+
for (const marker of [markerA, markerB])
|
|
981
|
+
writeFileSync(`${marker}.release`, 'ready');
|
|
982
|
+
const parallel = await parallelRun;
|
|
983
|
+
const parallelElapsedMs = Date.now() - parallelStartedAt;
|
|
984
|
+
assert.deepEqual(parallel.map((run) => run.stageReceipts[0].commands), [peer.stageReceipts[0].commands, retried.stageReceipts[0].commands]);
|
|
985
|
+
assert.ok(parallel.every((run) => run.resources.sampleCount > 0));
|
|
986
|
+
const reused = await verifyIssue({ issueNumber: 18, repoRoot: b.path, resourceBounds });
|
|
987
|
+
assert.equal(reused.stageReceipts[0].receiptId, parallel[1].stageReceipts[0].receiptId);
|
|
988
|
+
const manifestA = join(root, '.git/ai-delivery/runs@2', digestValue(a.path).slice(7), `${peer.classification.head.sha}.json`);
|
|
989
|
+
const { manifestId: _foreignId, ...foreign } = parallel[1];
|
|
990
|
+
const foreignContent = { ...foreign, writer: { ...foreign.writer, worktreeDigest: digestValue(a.path) } };
|
|
991
|
+
writeFileSync(manifestA, JSON.stringify({ ...foreignContent, manifestId: digestValue(foreignContent) }));
|
|
992
|
+
assert.throws(() => loadVerifiedRun(a.path, 17), /environment|foreign/u);
|
|
993
|
+
writeFileSync(manifestA, JSON.stringify(parallel[0]));
|
|
994
|
+
console.log('WRITER_ISOLATION_RECEIPT', JSON.stringify({
|
|
995
|
+
sameHead: peer.classification.head.sha,
|
|
996
|
+
peerManifest: peer.manifestId,
|
|
997
|
+
retryManifest: retried.manifestId,
|
|
998
|
+
peerReused: true,
|
|
999
|
+
parallelElapsedMs,
|
|
1000
|
+
maxObservedRssBytes: Math.max(...parallel.map((run) => run.resources.maxSampledAggregateRssBytes)),
|
|
1001
|
+
}));
|
|
1002
|
+
}
|
|
1003
|
+
finally {
|
|
1004
|
+
cancel.abort();
|
|
1005
|
+
parallelCancel.abort();
|
|
1006
|
+
await first;
|
|
1007
|
+
await second?.catch(() => undefined);
|
|
1008
|
+
await Promise.allSettled(parallelRuns);
|
|
1009
|
+
rmSync(root, { recursive: true, force: true });
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
1012
|
+
test('interruption child verification', { skip: !process.env.AI_DELIVERY_INTERRUPTION_WORKTREE }, async () => {
|
|
1013
|
+
await verifyIssue({ issueNumber: 17, repoRoot: process.env.AI_DELIVERY_INTERRUPTION_WORKTREE });
|
|
1014
|
+
});
|
|
1015
|
+
test('interrupted writer recovers only its recorded command and resumes completed checkpoints', { timeout: 30_000 }, async () => {
|
|
1016
|
+
const { root, counter, secondCounter } = await fixture({
|
|
1017
|
+
twoStages: true,
|
|
1018
|
+
secondStageScript: `const fs=require('fs');const cp=require('child_process');const path=require('path');const dir=cp.execFileSync('git',['rev-parse','--git-common-dir'],{encoding:'utf8'}).trim();const count=path.join(dir,'second-stage-count.txt');fs.writeFileSync(count,String(Number(fs.existsSync(count)?fs.readFileSync(count,'utf8'):0)+1));if(!fs.existsSync(path.join(dir,'resume-interrupted')))setInterval(()=>{},1000);`,
|
|
1019
|
+
});
|
|
1020
|
+
let child;
|
|
1021
|
+
let workerPid;
|
|
1022
|
+
let ownedPid;
|
|
1023
|
+
let workerIdentity;
|
|
1024
|
+
let ownedIdentity;
|
|
1025
|
+
let peer;
|
|
1026
|
+
try {
|
|
1027
|
+
const row = await prepareIssueWorktree({
|
|
1028
|
+
baseRef: 'main',
|
|
1029
|
+
identity: 'synthetic-author',
|
|
1030
|
+
issueNumber: 17,
|
|
1031
|
+
repoRoot: root,
|
|
1032
|
+
});
|
|
1033
|
+
child = spawn(process.execPath, [
|
|
1034
|
+
join(process.cwd(), 'node_modules/vitest/vitest.mjs'),
|
|
1035
|
+
'run',
|
|
1036
|
+
'dist/lifecycle.test.js',
|
|
1037
|
+
'-t',
|
|
1038
|
+
'^interruption child verification$',
|
|
1039
|
+
], {
|
|
1040
|
+
cwd: process.cwd(),
|
|
1041
|
+
stdio: 'ignore',
|
|
1042
|
+
env: { ...process.env, AI_DELIVERY_INTERRUPTION_WORKTREE: row.path },
|
|
1043
|
+
});
|
|
1044
|
+
const childClosed = new Promise((resolve) => child.once('close', resolve));
|
|
1045
|
+
const writerPath = join(root, '.git/ai-delivery/writers@1', `${digestValue(row.path).slice(7)}.json`);
|
|
1046
|
+
let recorded;
|
|
1047
|
+
for (let attempt = 0; attempt < 200; attempt++) {
|
|
1048
|
+
if (existsSync(writerPath))
|
|
1049
|
+
recorded = JSON.parse(readFileSync(writerPath, 'utf8'));
|
|
1050
|
+
if (recorded?.command.phase === 'running' && existsSync(secondCounter))
|
|
1051
|
+
break;
|
|
1052
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1053
|
+
}
|
|
1054
|
+
assert.equal(readFileSync(counter, 'utf8'), '1');
|
|
1055
|
+
assert.equal(readFileSync(secondCounter, 'utf8'), '1');
|
|
1056
|
+
assert.equal(recorded?.command.phase, 'running');
|
|
1057
|
+
workerPid = recorded.owner.pid;
|
|
1058
|
+
ownedPid = recorded.command.root.pid;
|
|
1059
|
+
workerIdentity = recorded.owner.identity;
|
|
1060
|
+
ownedIdentity = recorded.command.root.identity;
|
|
1061
|
+
const completedDir = join(root, '.git/ai-delivery/verification@1/stages/check');
|
|
1062
|
+
const completedCheckpoint = join(completedDir, readdirSync(completedDir)[0]);
|
|
1063
|
+
const completedDigest = sha256(completedCheckpoint);
|
|
1064
|
+
peer = spawn(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { detached: true, stdio: 'ignore' });
|
|
1065
|
+
assert.equal(spawnSync('ps', ['-p', String(workerPid), '-o', 'lstart='], { encoding: 'utf8' }).stdout.trim(), workerIdentity);
|
|
1066
|
+
process.kill(workerPid, 'SIGKILL');
|
|
1067
|
+
workerPid = undefined;
|
|
1068
|
+
await childClosed;
|
|
1069
|
+
process.kill(ownedPid, 0);
|
|
1070
|
+
// A stale file lease is not permission to forget the durable command owner.
|
|
1071
|
+
const stale = new Date(Date.now() - 20_000);
|
|
1072
|
+
utimesSync(`${writerPath}.lock`, stale, stale);
|
|
1073
|
+
writeFileSync(join(root, '.git/resume-interrupted'), 'ready');
|
|
1074
|
+
const recovered = await verifyIssue({ issueNumber: 17, repoRoot: row.path });
|
|
1075
|
+
assert.equal(readFileSync(counter, 'utf8'), '1');
|
|
1076
|
+
assert.equal(readFileSync(secondCounter, 'utf8'), '2');
|
|
1077
|
+
assert.equal(sha256(completedCheckpoint), completedDigest);
|
|
1078
|
+
assert.equal(recovered.aggregate.result, 'passed');
|
|
1079
|
+
process.kill(peer.pid, 0);
|
|
1080
|
+
assert.equal(existsSync(writerPath), false);
|
|
1081
|
+
assert.equal(existsSync(`${writerPath}.lock`), false);
|
|
1082
|
+
const survivor = spawnSync('/bin/ps', ['-p', String(ownedPid), '-o', 'stat='], { encoding: 'utf8' });
|
|
1083
|
+
assert.ok((survivor.status === 1 && !survivor.stdout.trim()) ||
|
|
1084
|
+
(survivor.status === 0 && survivor.stdout.trim().startsWith('Z')));
|
|
1085
|
+
console.log('INTERRUPTION_RESUME_RECEIPT', JSON.stringify({
|
|
1086
|
+
completedCheckpoint: completedDigest,
|
|
1087
|
+
firstStageExecutions: 1,
|
|
1088
|
+
interruptedStageExecutions: 2,
|
|
1089
|
+
peerPreserved: true,
|
|
1090
|
+
aggregateId: recovered.aggregate.aggregateId,
|
|
1091
|
+
}));
|
|
1092
|
+
ownedPid = undefined;
|
|
1093
|
+
}
|
|
1094
|
+
finally {
|
|
1095
|
+
for (const [pid, identity] of [
|
|
1096
|
+
[workerPid, workerIdentity],
|
|
1097
|
+
[ownedPid, ownedIdentity],
|
|
1098
|
+
])
|
|
1099
|
+
if (pid !== undefined && identity !== undefined) {
|
|
1100
|
+
const current = spawnSync('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8' });
|
|
1101
|
+
if (current.status !== 0 || current.stdout.trim() !== identity)
|
|
1102
|
+
continue;
|
|
1103
|
+
try {
|
|
1104
|
+
process.kill(pid, 'SIGKILL');
|
|
1105
|
+
}
|
|
1106
|
+
catch (error) {
|
|
1107
|
+
assert.equal(error.code, 'ESRCH');
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
for (const processHandle of [peer, child])
|
|
1111
|
+
if (processHandle?.exitCode === null && processHandle.signalCode === null)
|
|
1112
|
+
processHandle.kill('SIGKILL');
|
|
1113
|
+
rmSync(root, { recursive: true, force: true });
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
630
1116
|
test('issue worktree verification resumes a completed stage and rejects changed inputs', async () => {
|
|
631
1117
|
const { root, counter, runtimeEntryPath } = await fixture();
|
|
632
1118
|
try {
|
|
@@ -1123,7 +1609,7 @@ test('a bounded passing run records measured resources in versioned evidence', a
|
|
|
1123
1609
|
repoRoot: row.path,
|
|
1124
1610
|
resourceBounds: { maxAggregateRssBytes: 1_000_000_000, minFreeDiskBytes: 1 },
|
|
1125
1611
|
});
|
|
1126
|
-
assert.equal(run.schemaVersion, 'ai-delivery.run@
|
|
1612
|
+
assert.equal(run.schemaVersion, 'ai-delivery.run@3');
|
|
1127
1613
|
const resources = run.resources;
|
|
1128
1614
|
assert.ok(resources);
|
|
1129
1615
|
assert.ok(resources.sampleCount > 0);
|
|
@@ -1718,25 +2204,46 @@ test('public PR dry run retains a configured host author and App reviewer', asyn
|
|
|
1718
2204
|
rmSync(root, { recursive: true, force: true });
|
|
1719
2205
|
}
|
|
1720
2206
|
}, 15_000);
|
|
1721
|
-
test(
|
|
1722
|
-
|
|
2207
|
+
test.each([
|
|
2208
|
+
{ transition: false, entry: 'source', highRisk: false, mcp: false },
|
|
2209
|
+
{ transition: true, entry: 'primary', highRisk: false, mcp: false },
|
|
2210
|
+
{ transition: true, entry: 'source', highRisk: false, mcp: false },
|
|
2211
|
+
{ transition: true, entry: 'primary', highRisk: true, mcp: false },
|
|
2212
|
+
{ transition: true, entry: 'source', highRisk: true, mcp: false },
|
|
2213
|
+
{ transition: true, entry: 'primary', highRisk: true, mcp: true },
|
|
2214
|
+
])('public dispatch publishes and reviews from $entry across policy transition $transition high risk $highRisk MCP $mcp', async ({ transition, entry, highRisk, mcp }) => {
|
|
2215
|
+
const { root, runtimeEntryPath } = await fixture({ personalAuthor: !transition });
|
|
1723
2216
|
const originalPath = process.env.PATH;
|
|
2217
|
+
const driftEnv = 'AI_DELIVERY_TEST_CANDIDATE_CHECK';
|
|
2218
|
+
const previousDrift = process.env[driftEnv];
|
|
2219
|
+
delete process.env[driftEnv];
|
|
2220
|
+
let closeMcp;
|
|
1724
2221
|
try {
|
|
2222
|
+
const admissionPath = join(root, '.git', 'ai-delivery', 'runtime-admission.json');
|
|
2223
|
+
const admissionBefore = readFileSync(admissionPath, 'utf8');
|
|
2224
|
+
const primaryConfiguration = await loadDeliveryConfig(root);
|
|
1725
2225
|
const row = await prepareIssueWorktree({
|
|
1726
2226
|
baseRef: 'main',
|
|
1727
|
-
identity: 'host-author',
|
|
2227
|
+
identity: transition ? 'synthetic-author' : 'host-author',
|
|
1728
2228
|
issueNumber: 17,
|
|
1729
2229
|
repoRoot: root,
|
|
1730
2230
|
});
|
|
2231
|
+
if (transition) {
|
|
2232
|
+
const policyPath = join(row.path, 'policy.mjs');
|
|
2233
|
+
writeFileSync(policyPath, readFileSync(policyPath, 'utf8')
|
|
2234
|
+
.replace(JSON.stringify(primaryConfiguration.config.roles.author), JSON.stringify({
|
|
2235
|
+
authSource: 'personal',
|
|
2236
|
+
credentialEnv: { token: 'AUTHOR_TOKEN' },
|
|
2237
|
+
identity: 'host-author',
|
|
2238
|
+
}))
|
|
2239
|
+
.replace("risk: 'standard'", `risk: '${highRisk ? 'high' : 'standard'}'`) +
|
|
2240
|
+
`
|
|
2241
|
+
Object.defineProperty(deliverySettings.commandPolicy.checks, 'test', { get: () => process.env.${driftEnv} ?? 'REQUIRED' });
|
|
2242
|
+
`);
|
|
2243
|
+
}
|
|
1731
2244
|
writeFileSync(join(row.path, 'change.txt'), 'host author publication\n');
|
|
1732
|
-
git(row.path, 'add', '
|
|
2245
|
+
git(row.path, 'add', '.');
|
|
1733
2246
|
git(row.path, 'commit', '-qm', 'synthetic host publication');
|
|
1734
|
-
const run = await verifyIssue({ issueNumber: 17, repoRoot: row.path });
|
|
1735
|
-
personalRoute.baseSha = run.classification.base.sha;
|
|
1736
|
-
personalRoute.headSha = run.classification.head.sha;
|
|
1737
|
-
personalRoute.reviews.length = 0;
|
|
1738
|
-
personalRoute.submitted = 0;
|
|
1739
|
-
personalRoute.active = true;
|
|
1740
2247
|
const realGit = (originalPath ?? '')
|
|
1741
2248
|
.split(':')
|
|
1742
2249
|
.map((directory) => join(directory, 'git'))
|
|
@@ -1759,24 +2266,36 @@ exec "${realGit}" "$@"
|
|
|
1759
2266
|
`);
|
|
1760
2267
|
chmodSync(shim, 0o700);
|
|
1761
2268
|
process.env.PATH = `${shimDir}:${originalPath ?? ''}`;
|
|
1762
|
-
const execution = { repoRoot: row.path, runtimeEntryPath, identity: 'host-author' };
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
2269
|
+
const execution = { repoRoot: entry === 'primary' ? root : row.path, runtimeEntryPath, identity: 'host-author' };
|
|
2270
|
+
let call = (name, args) => executeTool(name, args, execution);
|
|
2271
|
+
if (mcp) {
|
|
2272
|
+
const server = createAiDeliveryMcpServer(execution);
|
|
2273
|
+
const client = new Client({ name: 'policy-transition-test', version: '1.0.0' });
|
|
2274
|
+
closeMcp = async () => {
|
|
2275
|
+
await client.close();
|
|
2276
|
+
await server.close();
|
|
2277
|
+
};
|
|
2278
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
2279
|
+
await server.connect(serverTransport);
|
|
2280
|
+
await client.connect(clientTransport);
|
|
2281
|
+
call = async (name, args) => {
|
|
2282
|
+
const result = await client.callTool({ name, arguments: args });
|
|
2283
|
+
const text = result.content.find((block) => block.type === 'text').text;
|
|
2284
|
+
if (result.isError)
|
|
2285
|
+
throw new Error(text);
|
|
2286
|
+
return JSON.parse(text);
|
|
2287
|
+
};
|
|
2288
|
+
}
|
|
2289
|
+
const classified = await verifyIssue({ issueNumber: 17, repoRoot: row.path });
|
|
2290
|
+
const reviewContent = {
|
|
1772
2291
|
authorIdentity: 'host-author',
|
|
1773
2292
|
checks: ['verified exact diff'],
|
|
1774
|
-
diffScopeHash: digestValue(
|
|
2293
|
+
diffScopeHash: digestValue(classified.classification.changedPaths),
|
|
1775
2294
|
elapsedMs: 1,
|
|
1776
2295
|
findings: [],
|
|
1777
|
-
head:
|
|
2296
|
+
head: classified.classification.head,
|
|
1778
2297
|
issueNumber: 17,
|
|
1779
|
-
prNumber:
|
|
2298
|
+
prNumber: null,
|
|
1780
2299
|
readOnly: true,
|
|
1781
2300
|
requestedEffort: 'xhigh',
|
|
1782
2301
|
requestedModel: 'gpt-6-astra',
|
|
@@ -1787,18 +2306,112 @@ exec "${realGit}" "$@"
|
|
|
1787
2306
|
summary: 'Independent exact-head review',
|
|
1788
2307
|
verdict: 'approve',
|
|
1789
2308
|
};
|
|
2309
|
+
const prepublicationReview = join(root, '.git', 'prepublication-review.json');
|
|
2310
|
+
if (highRisk) {
|
|
2311
|
+
await assert.rejects(call('issue_verify', { issueNumber: 17 }), /independent exact-head approval/u);
|
|
2312
|
+
writeFileSync(prepublicationReview, JSON.stringify({ ...reviewContent, artifactId: digestValue(reviewContent) }));
|
|
2313
|
+
}
|
|
2314
|
+
const verified = (await call('issue_verify', {
|
|
2315
|
+
issueNumber: 17,
|
|
2316
|
+
...(highRisk ? { prepublicationReview } : {}),
|
|
2317
|
+
}));
|
|
2318
|
+
const run = loadVerifiedRun(row.path, 17);
|
|
2319
|
+
const candidateConfiguration = await loadDeliveryConfig(row.path);
|
|
2320
|
+
assert.equal(run.classification.configDigest, candidateConfiguration.configDigest);
|
|
2321
|
+
assert.equal(run.classification.policyDigest, sha256(join(row.path, 'policy.mjs')));
|
|
2322
|
+
if (transition)
|
|
2323
|
+
assert.notEqual(run.classification.configDigest, primaryConfiguration.configDigest);
|
|
2324
|
+
assert.equal(readFileSync(admissionPath, 'utf8'), admissionBefore);
|
|
2325
|
+
const phaseEvidence = JSON.parse(readFileSync(join(root, '.git', 'ai-delivery', 'receipts', 'delivery@1', run.classification.head.sha, `${verified.evidenceId.slice(7)}.json`), 'utf8'));
|
|
2326
|
+
assert.equal(phaseEvidence.configDigest, candidateConfiguration.configDigest);
|
|
2327
|
+
assert.equal(phaseEvidence.aggregateId, run.aggregate.aggregateId);
|
|
2328
|
+
if (highRisk) {
|
|
2329
|
+
assert.equal(phaseEvidence.approval?.authorIdentity, 'host-author');
|
|
2330
|
+
assert.equal(phaseEvidence.approval?.reviewerIdentity, 'synthetic-reviewer');
|
|
2331
|
+
}
|
|
2332
|
+
personalRoute.baseSha = run.classification.base.sha;
|
|
2333
|
+
personalRoute.headSha = run.classification.head.sha;
|
|
2334
|
+
personalRoute.reviews.length = 0;
|
|
2335
|
+
personalRoute.submitted = 0;
|
|
2336
|
+
personalRoute.created = false;
|
|
2337
|
+
personalRoute.failCreate = false;
|
|
2338
|
+
personalRoute.ready = false;
|
|
2339
|
+
personalRoute.readyPromotions = 0;
|
|
2340
|
+
personalRoute.active = true;
|
|
2341
|
+
if (transition) {
|
|
2342
|
+
await assert.rejects(executeTool('issue_pr_create', { issueNumber: 17 }, { ...execution, identity: 'synthetic-author' }), /configured author identity/u);
|
|
2343
|
+
writeFileSync(join(row.path, 'dirty.txt'), 'unverified');
|
|
2344
|
+
await assert.rejects(executeTool('issue_pr_create', { issueNumber: 17 }, execution), /clean worktree/u);
|
|
2345
|
+
rmSync(join(row.path, 'dirty.txt'));
|
|
2346
|
+
git(row.path, 'commit', '--allow-empty', '-qm', 'unverified head');
|
|
2347
|
+
await assert.rejects(executeTool('issue_pr_create', { issueNumber: 17 }, execution));
|
|
2348
|
+
assert.equal(existsSync(pushMarker), false);
|
|
2349
|
+
git(row.path, 'reset', '--hard', run.classification.head.sha);
|
|
2350
|
+
personalRoute.baseSha = 'f'.repeat(40);
|
|
2351
|
+
await assert.rejects(call('issue_pr_create', { issueNumber: 17 }), /Remote base changed/u);
|
|
2352
|
+
assert.equal(existsSync(pushMarker), false);
|
|
2353
|
+
personalRoute.baseSha = run.classification.base.sha;
|
|
2354
|
+
personalRoute.failCreate = true;
|
|
2355
|
+
await assert.rejects(call('issue_pr_create', { issueNumber: 17 }), /response lost after branch push/u);
|
|
2356
|
+
assert.equal(existsSync(pushMarker), true);
|
|
2357
|
+
assert.equal(personalRoute.created, false);
|
|
2358
|
+
}
|
|
2359
|
+
const published = (await call('issue_pr_create', { issueNumber: 17 }));
|
|
2360
|
+
assert.equal(published.prNumber, 23);
|
|
2361
|
+
assert.equal(existsSync(pushMarker), true);
|
|
2362
|
+
assert.equal(published.reviewRoute.author.actorLogin, 'host-user');
|
|
2363
|
+
assert.equal(published.reviewRoute.author.credentialSource, 'env:AUTHOR_TOKEN');
|
|
2364
|
+
assert.equal(published.reviewRoute.reviewer.actorLogin, 'synthetic-reviewer[bot]');
|
|
2365
|
+
const info = (await call('issue_pr_info', { issueNumber: 17, prNumber: 23 }));
|
|
2366
|
+
assert.equal(info.authorLogin, 'host-user');
|
|
2367
|
+
assert.equal(readFileSync(admissionPath, 'utf8'), admissionBefore);
|
|
2368
|
+
const artifactContent = { ...reviewContent, prNumber: 23 };
|
|
1790
2369
|
const artifact = JSON.stringify({ ...artifactContent, artifactId: digestValue(artifactContent) });
|
|
1791
2370
|
const reviewInput = { issueNumber: 17, prNumber: 23, identity: 'synthetic-reviewer', artifact };
|
|
1792
|
-
|
|
2371
|
+
if (transition) {
|
|
2372
|
+
personalRoute.headSha = 'b'.repeat(40);
|
|
2373
|
+
await assert.rejects(call('issue_pr_review', reviewInput), /head/u);
|
|
2374
|
+
assert.equal(personalRoute.submitted, 0);
|
|
2375
|
+
personalRoute.headSha = run.classification.head.sha;
|
|
2376
|
+
}
|
|
2377
|
+
if (transition && entry === 'source') {
|
|
2378
|
+
process.env[driftEnv] = 'SKIP';
|
|
2379
|
+
assert.notEqual((await loadDeliveryConfig(row.path)).configDigest, candidateConfiguration.configDigest);
|
|
2380
|
+
assert.equal((await loadDeliveryConfig(root)).configDigest, primaryConfiguration.configDigest);
|
|
2381
|
+
await assert.rejects(call('issue_pr_review', reviewInput), /classification is stale against config/u);
|
|
2382
|
+
assert.equal(personalRoute.submitted, 0);
|
|
2383
|
+
delete process.env[driftEnv];
|
|
2384
|
+
}
|
|
2385
|
+
const first = (await call('issue_pr_review', reviewInput));
|
|
1793
2386
|
assert.equal(first.reviewState.status, 'still-required');
|
|
2387
|
+
assert.equal(first.reviewState.submittedReviewAuthorCanPushToRepository, false);
|
|
1794
2388
|
assert.equal(personalRoute.submitted, 1);
|
|
1795
|
-
|
|
2389
|
+
if (transition && entry === 'primary') {
|
|
2390
|
+
process.env[driftEnv] = 'SKIP';
|
|
2391
|
+
assert.notEqual((await loadDeliveryConfig(row.path)).configDigest, candidateConfiguration.configDigest);
|
|
2392
|
+
assert.equal((await loadDeliveryConfig(root)).configDigest, primaryConfiguration.configDigest);
|
|
2393
|
+
await assert.rejects(call('issue_pr_create', { issueNumber: 17, draft: false }), /classification is stale against config/u);
|
|
2394
|
+
assert.equal(personalRoute.readyPromotions, 0);
|
|
2395
|
+
delete process.env[driftEnv];
|
|
2396
|
+
await call('issue_pr_create', { issueNumber: 17, draft: false });
|
|
2397
|
+
assert.equal(personalRoute.readyPromotions, 1);
|
|
2398
|
+
}
|
|
2399
|
+
const second = (await call('issue_pr_review', reviewInput));
|
|
1796
2400
|
assert.equal(second.receiptId, first.receiptId);
|
|
1797
2401
|
assert.equal(personalRoute.submitted, 1);
|
|
1798
2402
|
}
|
|
1799
2403
|
finally {
|
|
2404
|
+
await closeMcp?.();
|
|
1800
2405
|
personalRoute.active = false;
|
|
2406
|
+
personalRoute.created = false;
|
|
2407
|
+
personalRoute.failCreate = false;
|
|
1801
2408
|
personalRoute.reviews.length = 0;
|
|
2409
|
+
personalRoute.ready = false;
|
|
2410
|
+
personalRoute.readyPromotions = 0;
|
|
2411
|
+
if (previousDrift === undefined)
|
|
2412
|
+
delete process.env[driftEnv];
|
|
2413
|
+
else
|
|
2414
|
+
process.env[driftEnv] = previousDrift;
|
|
1802
2415
|
if (originalPath === undefined)
|
|
1803
2416
|
delete process.env.PATH;
|
|
1804
2417
|
else
|
|
@@ -1806,6 +2419,96 @@ exec "${realGit}" "$@"
|
|
|
1806
2419
|
rmSync(root, { recursive: true, force: true });
|
|
1807
2420
|
}
|
|
1808
2421
|
}, 15_000);
|
|
2422
|
+
test.each(['runtime', 'primary-policy', 'witness', 'sibling', 'foreign-repository', 'missing-source'])('source-bound dispatch rejects invalid %s before running candidate stages', async (invalid) => {
|
|
2423
|
+
const { root, runtimeEntryPath, counter } = await fixture();
|
|
2424
|
+
let foreignRoot;
|
|
2425
|
+
try {
|
|
2426
|
+
const row = await prepareIssueWorktree({
|
|
2427
|
+
baseRef: 'main',
|
|
2428
|
+
identity: 'synthetic-author',
|
|
2429
|
+
issueNumber: 17,
|
|
2430
|
+
repoRoot: root,
|
|
2431
|
+
});
|
|
2432
|
+
let requestedRoot = root;
|
|
2433
|
+
const expected = {
|
|
2434
|
+
runtime: /source, capability or repository admission/u,
|
|
2435
|
+
'primary-policy': /source, capability or repository admission/u,
|
|
2436
|
+
witness: /ownership witness/u,
|
|
2437
|
+
sibling: /primary or exact registered issue checkout/u,
|
|
2438
|
+
'foreign-repository': /primary Git repository/u,
|
|
2439
|
+
'missing-source': /git rev-parse failed/u,
|
|
2440
|
+
}[invalid];
|
|
2441
|
+
if (invalid === 'runtime')
|
|
2442
|
+
writeFileSync(join(dirname(runtimeEntryPath), 'changed.js'), 'unadmitted');
|
|
2443
|
+
if (invalid === 'primary-policy')
|
|
2444
|
+
writeFileSync(join(root, 'policy.mjs'), `${readFileSync(join(root, 'policy.mjs'), 'utf8')}\n// unadmitted controller policy\n`);
|
|
2445
|
+
if (invalid === 'witness')
|
|
2446
|
+
rmSync(join(root, '.git', 'ai-delivery', 'worktree-owners'), { recursive: true });
|
|
2447
|
+
if (invalid === 'sibling')
|
|
2448
|
+
requestedRoot = (await prepareStandaloneWorktree({
|
|
2449
|
+
name: 'sibling',
|
|
2450
|
+
branch: 'scratch/sibling',
|
|
2451
|
+
identity: 'synthetic-author',
|
|
2452
|
+
repoRoot: root,
|
|
2453
|
+
})).path;
|
|
2454
|
+
if (invalid === 'foreign-repository') {
|
|
2455
|
+
foreignRoot = (await fixture()).root;
|
|
2456
|
+
const foreign = await prepareIssueWorktree({
|
|
2457
|
+
baseRef: 'main',
|
|
2458
|
+
identity: 'synthetic-author',
|
|
2459
|
+
issueNumber: 17,
|
|
2460
|
+
repoRoot: foreignRoot,
|
|
2461
|
+
});
|
|
2462
|
+
await removeWorktreeEntry(row.path, root);
|
|
2463
|
+
await addWorktreeEntry(foreign, root);
|
|
2464
|
+
}
|
|
2465
|
+
if (invalid === 'missing-source')
|
|
2466
|
+
git(root, 'worktree', 'remove', row.path);
|
|
2467
|
+
await assert.rejects(executeTool('issue_verify', { issueNumber: 17 }, { repoRoot: requestedRoot, runtimeEntryPath }), expected);
|
|
2468
|
+
assert.equal(existsSync(counter), false);
|
|
2469
|
+
}
|
|
2470
|
+
finally {
|
|
2471
|
+
if (foreignRoot !== undefined)
|
|
2472
|
+
rmSync(foreignRoot, { recursive: true, force: true });
|
|
2473
|
+
rmSync(root, { recursive: true, force: true });
|
|
2474
|
+
}
|
|
2475
|
+
});
|
|
2476
|
+
test('source-bound publication preserves rejection of a legacy personal override for an App author', async () => {
|
|
2477
|
+
const { root, runtimeEntryPath } = await fixture();
|
|
2478
|
+
let restore;
|
|
2479
|
+
try {
|
|
2480
|
+
const row = await prepareIssueWorktree({
|
|
2481
|
+
baseRef: 'main',
|
|
2482
|
+
identity: 'synthetic-author',
|
|
2483
|
+
issueNumber: 17,
|
|
2484
|
+
repoRoot: root,
|
|
2485
|
+
});
|
|
2486
|
+
const run = await verifyIssue({ issueNumber: 17, repoRoot: row.path });
|
|
2487
|
+
const factory = githubClient.createDeliveryGitHubClients;
|
|
2488
|
+
const selected = vi.spyOn(githubClient, 'createDeliveryGitHubClients').mockImplementation(async (input) => {
|
|
2489
|
+
const clients = await factory(input);
|
|
2490
|
+
if (input.role !== 'author')
|
|
2491
|
+
return clients;
|
|
2492
|
+
Object.assign(clients.rest.issues, { get: async () => ({ data: { title: 'Synthetic issue' } }) });
|
|
2493
|
+
Object.assign(clients.rest.git, {
|
|
2494
|
+
getRef: async () => ({ data: { object: { sha: run.classification.base.sha } } }),
|
|
2495
|
+
});
|
|
2496
|
+
return { ...clients, authSource: 'personal', credentialSource: 'legacy:personal' };
|
|
2497
|
+
});
|
|
2498
|
+
restore = () => selected.mockRestore();
|
|
2499
|
+
await assert.rejects(executeTool('issue_pr_create', { issueNumber: 17 }, {
|
|
2500
|
+
repoRoot: root,
|
|
2501
|
+
runtimeEntryPath,
|
|
2502
|
+
identity: 'personal',
|
|
2503
|
+
personalAuth: true,
|
|
2504
|
+
}), /requires the configured author credential/u);
|
|
2505
|
+
assert.equal(git(join(root, '.git', 'remote.git'), 'for-each-ref', '--format=%(refname)', 'refs/heads/issue/17'), '');
|
|
2506
|
+
}
|
|
2507
|
+
finally {
|
|
2508
|
+
restore?.();
|
|
2509
|
+
rmSync(root, { recursive: true, force: true });
|
|
2510
|
+
}
|
|
2511
|
+
});
|
|
1809
2512
|
test('scratch start derives safe names and reports the requested worktree', async () => {
|
|
1810
2513
|
const { root, runtimeEntryPath } = await fixture();
|
|
1811
2514
|
try {
|
|
@@ -1882,7 +2585,7 @@ test('MCP request rejects unsupported legacy options before lifecycle dispatch',
|
|
|
1882
2585
|
}
|
|
1883
2586
|
});
|
|
1884
2587
|
async function syntheticLifecycle(routing) {
|
|
1885
|
-
const { root } = await fixture(routing);
|
|
2588
|
+
const { root, runtimeEntryPath } = await fixture(routing);
|
|
1886
2589
|
const remoteName = routing.remote;
|
|
1887
2590
|
assert.equal(defaultBaseRef(root, remoteName), `refs/remotes/${remoteName}/main`);
|
|
1888
2591
|
const remote = join(root, '.git', 'remote.git');
|
|
@@ -1922,7 +2625,7 @@ async function syntheticLifecycle(routing) {
|
|
|
1922
2625
|
let reviewDecision = 'REVIEW_REQUIRED';
|
|
1923
2626
|
const calls = [];
|
|
1924
2627
|
const createPayloads = [];
|
|
1925
|
-
const statuses = { Queued: '
|
|
2628
|
+
const statuses = { Queued: 'STATUS-0', Active: 'STATUS-1', Waiting: 'STATUS-2', Shipped: 'STATUS-3' };
|
|
1926
2629
|
const statusName = (option) => Object.entries(statuses).find(([, id]) => id === option)?.[0];
|
|
1927
2630
|
const pageInfo = { endCursor: null, hasNextPage: false };
|
|
1928
2631
|
const issueFieldValues = (number = issueNumber) => [
|
|
@@ -2243,6 +2946,7 @@ async function syntheticLifecycle(routing) {
|
|
|
2243
2946
|
}),
|
|
2244
2947
|
},
|
|
2245
2948
|
};
|
|
2949
|
+
let restoreDispatchClient;
|
|
2246
2950
|
try {
|
|
2247
2951
|
await assert.rejects(startTrackedIssue(context, { request: 'Unready task', develop: true }), /New issue is not ready/u);
|
|
2248
2952
|
assert.equal(calls.filter((call) => call === 'issue:create').length, 0);
|
|
@@ -2422,7 +3126,19 @@ async function syntheticLifecycle(routing) {
|
|
|
2422
3126
|
await assert.rejects(finishIssue(context, { issueNumber, prNumber, strategy: 'merge' }), /Delivery record store is invalid/u);
|
|
2423
3127
|
assert.equal(existsSync(row.path), false);
|
|
2424
3128
|
rmSync(blockedRecordPath, { recursive: true });
|
|
2425
|
-
const
|
|
3129
|
+
const dispatchClient = vi.spyOn(githubClient, 'createDeliveryGitHubClients').mockImplementation(async () => ({
|
|
3130
|
+
...context.clients,
|
|
3131
|
+
graphql: (async (query, variables) => query.includes('DeliveryDiscovery') ||
|
|
3132
|
+
query.includes('DeliveryRepository') ||
|
|
3133
|
+
query.includes('ProjectDeliveryConfiguration')
|
|
3134
|
+
? syntheticDiscoveryClients().graphql(query, variables)
|
|
3135
|
+
: graphql(query, variables)),
|
|
3136
|
+
}));
|
|
3137
|
+
restoreDispatchClient = () => dispatchClient.mockRestore();
|
|
3138
|
+
const execution = { repoRoot: root, runtimeEntryPath, identity: 'synthetic-author' };
|
|
3139
|
+
const terminalMerge = (await executeTool('issue_pr_merge', { issueNumber, prNumber, strategy: 'merge' }, execution));
|
|
3140
|
+
assert.equal(terminalMerge.mergeSha, git(remote, 'rev-parse', 'refs/heads/main'));
|
|
3141
|
+
const finished = (await executeTool('issue_finish', { issueNumber, prNumber, strategy: 'merge' }, execution));
|
|
2426
3142
|
assert.equal(finished.issueClosed, true);
|
|
2427
3143
|
assert.equal(finished.cleaned, true);
|
|
2428
3144
|
assert.equal(projectStatus, 'Shipped');
|
|
@@ -2431,7 +3147,9 @@ async function syntheticLifecycle(routing) {
|
|
|
2431
3147
|
assert.equal(getDeliveryRecords(root).length, 1);
|
|
2432
3148
|
assert.equal(getDeliveryRecords(root)[0]?.points, 4);
|
|
2433
3149
|
assert.deepEqual(calls, ['issue:create', 'review:create', 'git:createCommit']);
|
|
2434
|
-
assert.deepEqual(await
|
|
3150
|
+
assert.deepEqual(await executeTool('issue_finish', { issueNumber, prNumber, strategy: 'merge' }, execution), finished);
|
|
3151
|
+
await assert.rejects(executeTool('issue_finish', { issueNumber, prNumber: 24 }, execution), /exact terminal merge receipts/u);
|
|
3152
|
+
dispatchClient.mockRestore();
|
|
2435
3153
|
assert.equal(getDeliveryRecords(root).length, 1);
|
|
2436
3154
|
await assert.rejects(finishIssue(context, { issueNumber, prNumber: 24 }), /exact terminal merge receipts/u);
|
|
2437
3155
|
const newlyStarted = await startTrackedIssue(context, { request: 'New synthetic task' });
|
|
@@ -2470,6 +3188,7 @@ async function syntheticLifecycle(routing) {
|
|
|
2470
3188
|
assert.equal(calls.filter((call) => call === 'issue:create').length, beforeTracking + 1);
|
|
2471
3189
|
}
|
|
2472
3190
|
finally {
|
|
3191
|
+
restoreDispatchClient?.();
|
|
2473
3192
|
rmSync(root, { recursive: true, force: true });
|
|
2474
3193
|
}
|
|
2475
3194
|
}
|