@aviaratech/ai-delivery 0.2.1 → 0.3.1
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 +55 -11
- package/dist/cli.js +26 -5
- package/dist/cli.js.map +1 -1
- package/dist/config/deliveryConfig.d.ts +20 -4
- package/dist/config/deliveryConfig.js +41 -15
- package/dist/config/deliveryConfig.js.map +1 -1
- package/dist/config/deliveryConfig.test.js +103 -2
- package/dist/config/deliveryConfig.test.js.map +1 -1
- package/dist/dispatch.js +14 -5
- package/dist/dispatch.js.map +1 -1
- package/dist/fixtures/discovery.d.ts +8 -0
- package/dist/github/client.d.ts +2 -1
- package/dist/github/client.js +50 -26
- package/dist/github/client.js.map +1 -1
- package/dist/github/commandIdentityPolicy.js +7 -2
- package/dist/github/commandIdentityPolicy.js.map +1 -1
- package/dist/lifecycle.test.js +1143 -168
- package/dist/lifecycle.test.js.map +1 -1
- package/dist/mcp/index.d.ts +1 -1
- package/dist/mcp/index.js +25 -8
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/tools.d.ts +6 -0
- package/dist/mcp/tools.js +8 -0
- package/dist/mcp/tools.js.map +1 -1
- package/dist/pr.d.ts +30 -1
- package/dist/pr.js +139 -19
- package/dist/pr.js.map +1 -1
- package/dist/review.d.ts +7 -0
- package/dist/review.js +47 -0
- package/dist/review.js.map +1 -1
- package/dist/review.test.js +147 -6
- package/dist/review.test.js.map +1 -1
- package/dist/verification.d.ts +27 -1
- package/dist/verification.js +663 -32
- 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/README.md +1 -1
- package/plugins/ai-delivery/skills/pr-handoff/SKILL.md +14 -9
package/dist/lifecycle.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import { execFileSync, spawn } from 'node:child_process';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import { cpSync, 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, 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';
|
|
@@ -17,28 +17,124 @@ import { executeTool, resumedIssueUpdate, startTrackedIssue } from './dispatch.j
|
|
|
17
17
|
import { defaultBaseRef, gitCommonDir } from './git.js';
|
|
18
18
|
import * as githubClient from './github/client.js';
|
|
19
19
|
import { createIssue, developIssue, listIssueSubissues, readyCheck, resumeCreatedIssue, updateIssue, } from './issue.js';
|
|
20
|
-
import { checkoutPr, finishIssue, listPrs, mergePr, prChecks, publishPr, submitFormalReview } from './pr.js';
|
|
20
|
+
import { checkoutPr, finishIssue, listPrs, mergePr, prChecks, prInfo, publishPr, submitFormalReview } from './pr.js';
|
|
21
21
|
import { updateIssueWorktreeDelivery } from './services/worktreeRegistry.js';
|
|
22
22
|
import { assertDeliveryRuntimeAdmitted } from './services/deliveryAdmission.js';
|
|
23
23
|
import { getDeliveryRecords } from './services/deliveryRecordService.js';
|
|
24
24
|
import { createIssuePhaseEvidence, verifyIssue } from './verification.js';
|
|
25
25
|
import { cleanupNonIssueWorktree, prepareIssueWorktree, preparePrWorktree, prepareStandaloneWorktree, } from './worktree.js';
|
|
26
|
+
const personalRoute = vi.hoisted(() => ({
|
|
27
|
+
active: false,
|
|
28
|
+
baseSha: '',
|
|
29
|
+
headSha: '',
|
|
30
|
+
reviews: [],
|
|
31
|
+
submitted: 0,
|
|
32
|
+
}));
|
|
26
33
|
vi.mock('./github/client.js', async (importOriginal) => {
|
|
27
34
|
const actual = await importOriginal();
|
|
28
35
|
return {
|
|
29
36
|
...actual,
|
|
30
|
-
createDeliveryGitHubClients: async (input) =>
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
createDeliveryGitHubClients: async (input) => {
|
|
38
|
+
if (personalRoute.active && input.config.roles.author.authSource === 'personal') {
|
|
39
|
+
const pr = () => ({
|
|
40
|
+
number: 23,
|
|
41
|
+
node_id: 'PR-23',
|
|
42
|
+
html_url: 'https://github.com/example/widget/pull/23',
|
|
43
|
+
head: { sha: personalRoute.headSha, ref: 'issue/17' },
|
|
44
|
+
base: { sha: personalRoute.baseSha, ref: 'main' },
|
|
45
|
+
state: 'open',
|
|
46
|
+
draft: true,
|
|
47
|
+
user: { login: 'host-user' },
|
|
48
|
+
});
|
|
49
|
+
if (input.role === 'reviewer') {
|
|
50
|
+
assert.equal(input.identity, 'synthetic-reviewer');
|
|
51
|
+
return {
|
|
52
|
+
...syntheticDiscoveryClients(),
|
|
53
|
+
authSource: 'app',
|
|
54
|
+
credentialSource: 'app:reviewer',
|
|
55
|
+
role: 'reviewer',
|
|
56
|
+
appActorLogin: async () => 'synthetic-reviewer[bot]',
|
|
57
|
+
rest: {
|
|
58
|
+
repos: { get: async () => ({ data: { full_name: 'example/widget' } }) },
|
|
59
|
+
pulls: {
|
|
60
|
+
get: async () => ({ data: pr() }),
|
|
61
|
+
listReviews: async () => ({ data: personalRoute.reviews }),
|
|
62
|
+
createReview: async (request) => {
|
|
63
|
+
assert.equal(request.event, 'APPROVE');
|
|
64
|
+
assert.equal(request.commit_id, personalRoute.headSha);
|
|
65
|
+
personalRoute.submitted += 1;
|
|
66
|
+
const review = {
|
|
67
|
+
id: 31,
|
|
68
|
+
body: request.body,
|
|
69
|
+
state: 'APPROVED',
|
|
70
|
+
commit_id: personalRoute.headSha,
|
|
71
|
+
user: { login: 'synthetic-reviewer[bot]' },
|
|
72
|
+
html_url: 'https://github.com/example/widget/pull/23#pullrequestreview-31',
|
|
73
|
+
};
|
|
74
|
+
personalRoute.reviews.push(review);
|
|
75
|
+
return { data: review };
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
graphql: async (query, variables) => query.includes('DeliveryReviewDecision')
|
|
80
|
+
? {
|
|
81
|
+
repository: {
|
|
82
|
+
pullRequest: { headRefOid: personalRoute.headSha, reviewDecision: 'REVIEW_REQUIRED' },
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
: syntheticDiscoveryClients().graphql(query, variables),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
assert.equal(input.identity, 'host-author');
|
|
89
|
+
const clients = await actual.createDeliveryGitHubClients({
|
|
90
|
+
...input,
|
|
91
|
+
env: { AUTHOR_TOKEN: 'selected-personal-token', GH_TOKEN: 'ambient-token' },
|
|
92
|
+
});
|
|
93
|
+
Object.assign(clients, {
|
|
94
|
+
authenticatedAuthor: async () => ({ actorLogin: 'host-user', credentialIdentity: 'user:37' }),
|
|
95
|
+
graphql: syntheticDiscoveryClients().graphql,
|
|
96
|
+
rest: {
|
|
97
|
+
issues: { get: async () => ({ data: { title: 'Synthetic issue' } }) },
|
|
98
|
+
git: { getRef: async () => ({ data: { object: { sha: personalRoute.baseSha } } }) },
|
|
99
|
+
pulls: {
|
|
100
|
+
list: async () => ({ data: [] }),
|
|
101
|
+
create: async () => ({ data: pr() }),
|
|
102
|
+
get: async () => ({ data: pr() }),
|
|
103
|
+
},
|
|
104
|
+
request: async () => Promise.reject(Object.assign(new Error('Rules unreadable'), { status: 403 })),
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
return clients;
|
|
108
|
+
}
|
|
109
|
+
const rest = new Octokit({
|
|
35
110
|
request: {
|
|
36
111
|
fetch: async () => {
|
|
37
112
|
throw new Error('Unexpected network request in synthetic lifecycle test.');
|
|
38
113
|
},
|
|
39
114
|
},
|
|
40
|
-
})
|
|
41
|
-
|
|
115
|
+
});
|
|
116
|
+
if (input.role === 'reviewer') {
|
|
117
|
+
Object.assign(rest.repos, { get: async () => ({ data: { full_name: 'example/widget' } }) });
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
...syntheticDiscoveryClients(),
|
|
121
|
+
authSource: input.role === 'author' && input.config.roles.author.authSource === 'personal' ? 'personal' : 'app',
|
|
122
|
+
role: input.role,
|
|
123
|
+
...(input.role === 'author'
|
|
124
|
+
? {
|
|
125
|
+
authenticatedAuthor: async () => ({
|
|
126
|
+
actorLogin: input.config.roles.author.authSource === 'personal' ? 'host-user' : 'synthetic-author[bot]',
|
|
127
|
+
credentialIdentity: input.config.roles.author.authSource === 'personal' ? 'user:37' : 'app:201',
|
|
128
|
+
}),
|
|
129
|
+
credentialSource: input.config.roles.author.authSource === 'personal' ? 'env:AUTHOR_TOKEN' : 'app:author',
|
|
130
|
+
}
|
|
131
|
+
: {}),
|
|
132
|
+
...(input.role === 'reviewer'
|
|
133
|
+
? { appActorLogin: async () => 'synthetic-reviewer', credentialSource: 'app:reviewer' }
|
|
134
|
+
: {}),
|
|
135
|
+
rest,
|
|
136
|
+
};
|
|
137
|
+
},
|
|
42
138
|
};
|
|
43
139
|
});
|
|
44
140
|
function git(root, ...args) {
|
|
@@ -100,14 +196,16 @@ async function fixture(options = {}) {
|
|
|
100
196
|
policy: { contract: 'RepositoryDeliveryPolicy@1', module: './policy.mjs' },
|
|
101
197
|
repository: 'example/widget',
|
|
102
198
|
roles: {
|
|
103
|
-
author:
|
|
104
|
-
credentialEnv: {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
199
|
+
author: options.personalAuthor
|
|
200
|
+
? { authSource: 'personal', credentialEnv: { token: 'AUTHOR_TOKEN' }, identity: 'host-author' }
|
|
201
|
+
: {
|
|
202
|
+
credentialEnv: {
|
|
203
|
+
appId: 'AUTHOR_APP_ID',
|
|
204
|
+
installationId: 'AUTHOR_INSTALLATION_ID',
|
|
205
|
+
privateKeyPath: 'AUTHOR_KEY_PATH',
|
|
206
|
+
},
|
|
207
|
+
identity: 'synthetic-author',
|
|
108
208
|
},
|
|
109
|
-
identity: 'synthetic-author',
|
|
110
|
-
},
|
|
111
209
|
reviewer: {
|
|
112
210
|
credentialEnv: {
|
|
113
211
|
appId: 'REVIEWER_APP_ID',
|
|
@@ -147,7 +245,7 @@ export default {
|
|
|
147
245
|
${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
|
|
148
246
|
? `, { id: 'second', dependsOn: ['check'], semanticInputKeys: ['source'],
|
|
149
247
|
resourceClass: 'source_only', commands: [{ label: 'retry', argv: [process.execPath, '-e',
|
|
150
|
-
${JSON.stringify(`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);`)}] }] }`
|
|
248
|
+
${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);`)}] }] }`
|
|
151
249
|
: ''}],
|
|
152
250
|
risk: 'standard' };
|
|
153
251
|
},
|
|
@@ -820,6 +918,65 @@ test('MCP request cancellation stops verification and leaves no stage receipt',
|
|
|
820
918
|
rmSync(root, { recursive: true, force: true });
|
|
821
919
|
}
|
|
822
920
|
});
|
|
921
|
+
test('MCP server shutdown cancellation waits for owned verification cleanup', async () => {
|
|
922
|
+
const marker = join(tmpdir(), `ai-delivery-mcp-shutdown-${process.pid}-${Date.now()}`);
|
|
923
|
+
const heartbeat = `${marker}-heartbeat`;
|
|
924
|
+
const { root, runtimeEntryPath } = await fixture({
|
|
925
|
+
firstStageScript: `const fs=require('fs');const {spawn}=require('child_process');const child=spawn(process.execPath,['-e',${JSON.stringify(`const fs=require('fs');setInterval(()=>fs.appendFileSync(${JSON.stringify(heartbeat)},'x'),20);`)}],{stdio:'ignore'});child.unref();fs.writeFileSync(${JSON.stringify(marker)},String(child.pid));setTimeout(()=>{},1000);`,
|
|
926
|
+
});
|
|
927
|
+
const shutdown = new AbortController();
|
|
928
|
+
const server = createAiDeliveryMcpServer({
|
|
929
|
+
repoRoot: root,
|
|
930
|
+
runtimeEntryPath,
|
|
931
|
+
identity: 'synthetic-author',
|
|
932
|
+
signal: shutdown.signal,
|
|
933
|
+
});
|
|
934
|
+
const client = new Client({ name: 'synthetic-shutdown-client', version: '1.0.0' });
|
|
935
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
936
|
+
let descendantPid;
|
|
937
|
+
try {
|
|
938
|
+
const row = await prepareIssueWorktree({
|
|
939
|
+
baseRef: 'main',
|
|
940
|
+
identity: 'synthetic-author',
|
|
941
|
+
issueNumber: 17,
|
|
942
|
+
repoRoot: root,
|
|
943
|
+
});
|
|
944
|
+
writeFileSync(join(row.path, 'change.txt'), 'shutdown\n');
|
|
945
|
+
git(row.path, 'add', 'change.txt');
|
|
946
|
+
git(row.path, 'commit', '-qm', 'synthetic MCP shutdown');
|
|
947
|
+
await server.connect(serverTransport);
|
|
948
|
+
await client.connect(clientTransport);
|
|
949
|
+
const call = client.callTool({ name: 'issue_verify', arguments: { issueNumber: 17 } });
|
|
950
|
+
const deadline = Date.now() + 2_000;
|
|
951
|
+
while (!existsSync(marker) && Date.now() < deadline)
|
|
952
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
953
|
+
assert.equal(existsSync(marker), true);
|
|
954
|
+
descendantPid = Number(readFileSync(marker, 'utf8'));
|
|
955
|
+
shutdown.abort();
|
|
956
|
+
const result = await call;
|
|
957
|
+
assert.equal(result.isError, true);
|
|
958
|
+
assert.match(JSON.stringify(result.content), /cancelled/u);
|
|
959
|
+
const size = existsSync(heartbeat) ? readFileSync(heartbeat).length : 0;
|
|
960
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
961
|
+
assert.equal(existsSync(heartbeat) ? readFileSync(heartbeat).length : 0, size);
|
|
962
|
+
assert.equal(existsSync(join(root, '.git', 'ai-delivery', 'verification@1', 'stages', 'check')), false);
|
|
963
|
+
}
|
|
964
|
+
finally {
|
|
965
|
+
await client.close();
|
|
966
|
+
await server.close();
|
|
967
|
+
if (descendantPid !== undefined) {
|
|
968
|
+
try {
|
|
969
|
+
process.kill(descendantPid, 'SIGKILL');
|
|
970
|
+
}
|
|
971
|
+
catch {
|
|
972
|
+
/* already stopped */
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
rmSync(marker, { force: true });
|
|
976
|
+
rmSync(heartbeat, { force: true });
|
|
977
|
+
rmSync(root, { recursive: true, force: true });
|
|
978
|
+
}
|
|
979
|
+
});
|
|
823
980
|
test('the stage output bound covers both streams and publishes no checkpoint on overflow', async () => {
|
|
824
981
|
const { root } = await fixture({
|
|
825
982
|
firstStageScript: `process.stdout.write(Buffer.alloc(4*1024*1024));process.stderr.write(Buffer.alloc(4*1024*1024+1));`,
|
|
@@ -841,169 +998,971 @@ test('the stage output bound covers both streams and publishes no checkpoint on
|
|
|
841
998
|
rmSync(root, { recursive: true, force: true });
|
|
842
999
|
}
|
|
843
1000
|
});
|
|
844
|
-
test('
|
|
845
|
-
const { root
|
|
1001
|
+
test('a consumer RSS ceiling stops a running stage before it can publish a checkpoint', async () => {
|
|
1002
|
+
const { root } = await fixture({
|
|
1003
|
+
firstStageScript: 'const held=Buffer.alloc(32*1024*1024,1);setTimeout(()=>process.stdout.write(String(held.length)),500);',
|
|
1004
|
+
});
|
|
846
1005
|
try {
|
|
847
|
-
const
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1006
|
+
const row = await prepareIssueWorktree({
|
|
1007
|
+
baseRef: 'main',
|
|
1008
|
+
identity: 'synthetic-author',
|
|
1009
|
+
issueNumber: 17,
|
|
1010
|
+
repoRoot: root,
|
|
1011
|
+
});
|
|
1012
|
+
writeFileSync(join(row.path, 'change.txt'), 'bounded stage\n');
|
|
1013
|
+
git(row.path, 'add', 'change.txt');
|
|
1014
|
+
git(row.path, 'commit', '-qm', 'synthetic resource bound');
|
|
1015
|
+
await assert.rejects(verifyIssue({
|
|
1016
|
+
issueNumber: 17,
|
|
1017
|
+
repoRoot: row.path,
|
|
1018
|
+
resourceBounds: { maxAggregateRssBytes: 1, minFreeDiskBytes: 1 },
|
|
1019
|
+
}), /aggregate RSS.*limit/u);
|
|
1020
|
+
assert.equal(existsSync(join(root, '.git', 'ai-delivery', 'verification@1', 'stages', 'check')), false);
|
|
855
1021
|
}
|
|
856
1022
|
finally {
|
|
857
1023
|
rmSync(root, { recursive: true, force: true });
|
|
858
1024
|
}
|
|
859
1025
|
});
|
|
860
|
-
test('
|
|
861
|
-
|
|
862
|
-
'
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
'
|
|
872
|
-
'
|
|
873
|
-
'
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
assert.ok(tool, `${name} has an MCP definition`);
|
|
889
|
-
assert.equal(tool.inputSchema.safeParse(input).success, true, `${name} accepts its lifecycle input`);
|
|
890
|
-
assert.equal(tool.inputSchema.safeParse({ ...input, unexpected: true }).success, false, `${name} rejects unrelated fields`);
|
|
1026
|
+
test('a consumer filesystem-output ceiling measures new files apart from captured stdout', async () => {
|
|
1027
|
+
const { root } = await fixture({
|
|
1028
|
+
firstStageScript: "const fs=require('fs');fs.writeFileSync('generated.bin',Buffer.alloc(4096));setTimeout(()=>{},500);",
|
|
1029
|
+
});
|
|
1030
|
+
try {
|
|
1031
|
+
const row = await prepareIssueWorktree({
|
|
1032
|
+
baseRef: 'main',
|
|
1033
|
+
identity: 'synthetic-author',
|
|
1034
|
+
issueNumber: 17,
|
|
1035
|
+
repoRoot: root,
|
|
1036
|
+
});
|
|
1037
|
+
writeFileSync(join(row.path, 'change.txt'), 'filesystem output\n');
|
|
1038
|
+
git(row.path, 'add', 'change.txt');
|
|
1039
|
+
git(row.path, 'commit', '-qm', 'synthetic filesystem output');
|
|
1040
|
+
await assert.rejects(verifyIssue({
|
|
1041
|
+
issueNumber: 17,
|
|
1042
|
+
repoRoot: row.path,
|
|
1043
|
+
resourceBounds: {
|
|
1044
|
+
maxAggregateRssBytes: 1_000_000_000,
|
|
1045
|
+
minFreeDiskBytes: 1,
|
|
1046
|
+
maxNewOutputBytes: 1_024,
|
|
1047
|
+
outputRoots: ['.'],
|
|
1048
|
+
},
|
|
1049
|
+
}), /new filesystem output.*limit/u);
|
|
1050
|
+
assert.equal(existsSync(join(root, '.git', 'ai-delivery', 'verification@1', 'stages', 'check')), false);
|
|
1051
|
+
}
|
|
1052
|
+
finally {
|
|
1053
|
+
rmSync(root, { recursive: true, force: true });
|
|
891
1054
|
}
|
|
892
1055
|
});
|
|
893
|
-
test('
|
|
894
|
-
const
|
|
895
|
-
const
|
|
896
|
-
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
1056
|
+
test('insufficient free disk refuses admission before starting a selected command', async () => {
|
|
1057
|
+
const { root } = await fixture({ firstStageScript: 'setTimeout(()=>{},500);' });
|
|
1058
|
+
const progress = vi.spyOn(process.stderr, 'write');
|
|
897
1059
|
try {
|
|
898
|
-
await
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
body: 'tracked work',
|
|
904
|
-
issueType: 'Task',
|
|
905
|
-
points: 1,
|
|
906
|
-
priority: 'High',
|
|
907
|
-
title: 'Synthetic change',
|
|
908
|
-
unsupportedLegacyOption: true,
|
|
909
|
-
},
|
|
1060
|
+
const row = await prepareIssueWorktree({
|
|
1061
|
+
baseRef: 'main',
|
|
1062
|
+
identity: 'synthetic-author',
|
|
1063
|
+
issueNumber: 17,
|
|
1064
|
+
repoRoot: root,
|
|
910
1065
|
});
|
|
911
|
-
|
|
912
|
-
|
|
1066
|
+
writeFileSync(join(row.path, 'change.txt'), 'disk admission\n');
|
|
1067
|
+
git(row.path, 'add', 'change.txt');
|
|
1068
|
+
git(row.path, 'commit', '-qm', 'synthetic disk admission');
|
|
1069
|
+
await assert.rejects(verifyIssue({
|
|
1070
|
+
issueNumber: 17,
|
|
1071
|
+
repoRoot: row.path,
|
|
1072
|
+
resourceBounds: { maxAggregateRssBytes: 1_000_000_000, minFreeDiskBytes: Number.MAX_SAFE_INTEGER },
|
|
1073
|
+
}), /Free disk fell below limit/u);
|
|
1074
|
+
assert.equal(progress.mock.calls.some(([message]) => String(message).includes('"state":"running"')), false);
|
|
913
1075
|
}
|
|
914
1076
|
finally {
|
|
915
|
-
|
|
916
|
-
|
|
1077
|
+
progress.mockRestore();
|
|
1078
|
+
rmSync(root, { recursive: true, force: true });
|
|
917
1079
|
}
|
|
918
1080
|
});
|
|
919
|
-
test
|
|
920
|
-
{
|
|
921
|
-
{
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
let issuePoints;
|
|
944
|
-
let parentPoints = 1;
|
|
945
|
-
let parentIssueNumber = null;
|
|
946
|
-
let parentChildren = [];
|
|
947
|
-
let issuePriority;
|
|
948
|
-
let projectStatus = null;
|
|
949
|
-
let blockerOpen = false;
|
|
950
|
-
let trackingParent = false;
|
|
951
|
-
let failFirstBlockerLink = true;
|
|
952
|
-
let failNextProjectSync = false;
|
|
953
|
-
let checksPassed = true;
|
|
954
|
-
let prDraft = true;
|
|
955
|
-
let prState = 'open';
|
|
956
|
-
let mergedAt = '';
|
|
957
|
-
let headSha = '';
|
|
958
|
-
let mergeSha = '';
|
|
959
|
-
let stalePrReadbacks = 0;
|
|
960
|
-
let review = null;
|
|
961
|
-
const calls = [];
|
|
962
|
-
const createPayloads = [];
|
|
963
|
-
const statuses = { Queued: 'todo', Active: 'active', Waiting: 'blocked', Shipped: 'done' };
|
|
964
|
-
const statusName = (option) => Object.entries(statuses).find(([, id]) => id === option)?.[0];
|
|
965
|
-
const pageInfo = { endCursor: null, hasNextPage: false };
|
|
966
|
-
const issueFieldValues = (number = issueNumber) => [
|
|
967
|
-
...((number === 11 ? parentPoints : issuePoints) === undefined
|
|
968
|
-
? []
|
|
969
|
-
: [
|
|
970
|
-
{
|
|
971
|
-
data_type: 'single_select',
|
|
972
|
-
issue_field_id: 101,
|
|
973
|
-
issue_field_name: 'Estimate',
|
|
974
|
-
value: String(number === 11 ? parentPoints : issuePoints),
|
|
975
|
-
},
|
|
976
|
-
]),
|
|
977
|
-
...(issuePriority === undefined
|
|
978
|
-
? []
|
|
979
|
-
: [{ data_type: 'single_select', issue_field_id: 102, issue_field_name: 'Urgency', value: issuePriority }]),
|
|
980
|
-
];
|
|
981
|
-
const pr = (stale = false) => ({
|
|
982
|
-
number: prNumber,
|
|
983
|
-
node_id: 'PR-23',
|
|
984
|
-
title: 'Synthetic change',
|
|
985
|
-
html_url: 'https://example.test/pulls/23',
|
|
986
|
-
state: stale ? 'open' : prState,
|
|
987
|
-
draft: prDraft,
|
|
988
|
-
merged_at: !stale && prState === 'closed' ? mergedAt : null,
|
|
989
|
-
merge_commit_sha: mergeSha || null,
|
|
990
|
-
mergeable: true,
|
|
991
|
-
mergeable_state: 'clean',
|
|
992
|
-
user: { login: 'synthetic-author' },
|
|
993
|
-
head: { sha: headSha, ref: 'issue/17' },
|
|
994
|
-
base: { sha: baseSha, ref: 'main' },
|
|
1081
|
+
test('the issue_verify request forwards consumer resource limits to the maintained runner', async () => {
|
|
1082
|
+
const { root, runtimeEntryPath } = await fixture();
|
|
1083
|
+
try {
|
|
1084
|
+
const row = await prepareIssueWorktree({
|
|
1085
|
+
baseRef: 'main',
|
|
1086
|
+
identity: 'synthetic-author',
|
|
1087
|
+
issueNumber: 17,
|
|
1088
|
+
repoRoot: root,
|
|
1089
|
+
});
|
|
1090
|
+
writeFileSync(join(row.path, 'change.txt'), 'request resource limits\n');
|
|
1091
|
+
git(row.path, 'add', 'change.txt');
|
|
1092
|
+
git(row.path, 'commit', '-qm', 'synthetic request resource limits');
|
|
1093
|
+
await assert.rejects(executeTool('issue_verify', {
|
|
1094
|
+
issueNumber: 17,
|
|
1095
|
+
resourceBounds: { maxAggregateRssBytes: 1_000_000_000, minFreeDiskBytes: Number.MAX_SAFE_INTEGER },
|
|
1096
|
+
}, { repoRoot: root, runtimeEntryPath, identity: 'synthetic-author' }), /Free disk fell below limit/u);
|
|
1097
|
+
}
|
|
1098
|
+
finally {
|
|
1099
|
+
rmSync(root, { recursive: true, force: true });
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
test('a bounded passing run records measured resources in versioned evidence', async () => {
|
|
1103
|
+
const { root, runtimeEntryPath } = await fixture({
|
|
1104
|
+
firstStageScript: "setTimeout(()=>process.stdout.write('done'),500);",
|
|
995
1105
|
});
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1106
|
+
try {
|
|
1107
|
+
const row = await prepareIssueWorktree({
|
|
1108
|
+
baseRef: 'main',
|
|
1109
|
+
identity: 'synthetic-author',
|
|
1110
|
+
issueNumber: 17,
|
|
1111
|
+
repoRoot: root,
|
|
1112
|
+
});
|
|
1113
|
+
writeFileSync(join(row.path, 'change.txt'), 'resource evidence\n');
|
|
1114
|
+
git(row.path, 'add', 'change.txt');
|
|
1115
|
+
git(row.path, 'commit', '-qm', 'synthetic resource evidence');
|
|
1116
|
+
const run = await verifyIssue({
|
|
1117
|
+
issueNumber: 17,
|
|
1118
|
+
repoRoot: row.path,
|
|
1119
|
+
resourceBounds: { maxAggregateRssBytes: 1_000_000_000, minFreeDiskBytes: 1 },
|
|
1120
|
+
});
|
|
1121
|
+
assert.equal(run.schemaVersion, 'ai-delivery.run@2');
|
|
1122
|
+
const resources = run.resources;
|
|
1123
|
+
assert.ok(resources);
|
|
1124
|
+
assert.ok(resources.sampleCount > 0);
|
|
1125
|
+
assert.ok((resources.maxSampledAggregateRssBytes ?? 0) > 0);
|
|
1126
|
+
assert.ok((resources.minSampledFreeDiskBytes ?? 0) > 0);
|
|
1127
|
+
assert.equal(resources.observation, 'sampled');
|
|
1128
|
+
assert.equal(resources.processCoverage, 'observed-processes-only');
|
|
1129
|
+
const evidence = await createIssuePhaseEvidence({ issueNumber: 17, phase: 'verify', repoRoot: row.path });
|
|
1130
|
+
assert.equal(evidence.aggregateId, run.aggregate.aggregateId);
|
|
1131
|
+
const dispatched = (await executeTool('issue_verify', {
|
|
1132
|
+
issueNumber: 17,
|
|
1133
|
+
resourceBounds: { maxAggregateRssBytes: 1_000_000_000, minFreeDiskBytes: 1 },
|
|
1134
|
+
}, { repoRoot: root, runtimeEntryPath, identity: 'synthetic-author' }));
|
|
1135
|
+
assert.equal(dispatched.resources?.processCoverage, 'observed-processes-only');
|
|
1136
|
+
}
|
|
1137
|
+
finally {
|
|
1138
|
+
rmSync(root, { recursive: true, force: true });
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1141
|
+
test('bounded verification reruns unmeasured work and reuses only matching measured stages', async () => {
|
|
1142
|
+
const { root, counter, secondCounter, failSecond } = await fixture({ twoStages: true });
|
|
1143
|
+
try {
|
|
1144
|
+
const row = await prepareIssueWorktree({
|
|
1145
|
+
baseRef: 'main',
|
|
1146
|
+
identity: 'synthetic-author',
|
|
1147
|
+
issueNumber: 17,
|
|
1148
|
+
repoRoot: root,
|
|
1149
|
+
});
|
|
1150
|
+
writeFileSync(join(row.path, 'change.txt'), 'resource resume\n');
|
|
1151
|
+
git(row.path, 'add', 'change.txt');
|
|
1152
|
+
git(row.path, 'commit', '-qm', 'synthetic resource resume');
|
|
1153
|
+
await verifyIssue({ issueNumber: 17, repoRoot: row.path });
|
|
1154
|
+
assert.equal(readFileSync(counter, 'utf8'), '1');
|
|
1155
|
+
assert.equal(readFileSync(secondCounter, 'utf8'), '1');
|
|
1156
|
+
const bounds = { maxAggregateRssBytes: 1_000_000_000, minFreeDiskBytes: 1 };
|
|
1157
|
+
writeFileSync(failSecond, 'fail once\n');
|
|
1158
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: bounds }), /exit 7/u);
|
|
1159
|
+
assert.equal(readFileSync(counter, 'utf8'), '2');
|
|
1160
|
+
assert.equal(readFileSync(secondCounter, 'utf8'), '2');
|
|
1161
|
+
rmSync(failSecond);
|
|
1162
|
+
const resumed = await verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: bounds });
|
|
1163
|
+
assert.equal(readFileSync(counter, 'utf8'), '2');
|
|
1164
|
+
assert.equal(readFileSync(secondCounter, 'utf8'), '3');
|
|
1165
|
+
assert.ok((resumed.resources?.sampleCount ?? 0) > 0);
|
|
1166
|
+
const repeated = await verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: bounds });
|
|
1167
|
+
assert.equal(readFileSync(counter, 'utf8'), '2');
|
|
1168
|
+
assert.equal(readFileSync(secondCounter, 'utf8'), '3');
|
|
1169
|
+
assert.ok((repeated.resources?.sampleCount ?? 0) > 0);
|
|
1170
|
+
const stageInputId = repeated.stageReceipts[0].input.inputId.slice(7);
|
|
1171
|
+
const stagePath = join(root, '.git', 'ai-delivery', 'verification@1', 'stages', 'check', `${stageInputId}.json`);
|
|
1172
|
+
rmSync(stagePath);
|
|
1173
|
+
await verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: bounds });
|
|
1174
|
+
assert.equal(readFileSync(counter, 'utf8'), '2');
|
|
1175
|
+
assert.equal(existsSync(stagePath), true);
|
|
1176
|
+
await verifyIssue({
|
|
1177
|
+
issueNumber: 17,
|
|
1178
|
+
repoRoot: row.path,
|
|
1179
|
+
resourceBounds: { ...bounds, maxAggregateRssBytes: 900_000_000 },
|
|
1180
|
+
});
|
|
1181
|
+
assert.equal(readFileSync(counter, 'utf8'), '3');
|
|
1182
|
+
assert.equal(readFileSync(secondCounter, 'utf8'), '4');
|
|
1183
|
+
const sidecar = join(root, '.git', 'ai-delivery', 'resource-stages@1', `${stageInputId}.json`);
|
|
1184
|
+
writeFileSync(sidecar, 'corrupt\n');
|
|
1185
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: bounds }), /Resource stage checkpoint is corrupt/u);
|
|
1186
|
+
}
|
|
1187
|
+
finally {
|
|
1188
|
+
rmSync(root, { recursive: true, force: true });
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
test('filesystem output already measured in a resumed stage counts toward the run limit', async () => {
|
|
1192
|
+
const { root } = await fixture({
|
|
1193
|
+
twoStages: true,
|
|
1194
|
+
firstStageScript: "const fs=require('fs');fs.mkdirSync('.issue-cli',{recursive:true});fs.writeFileSync('.issue-cli/a.bin',Buffer.alloc(700));const p='.issue-cli/count';fs.writeFileSync(p,String(Number(fs.existsSync(p)?fs.readFileSync(p,'utf8'):0)+1));setTimeout(()=>{},100);",
|
|
1195
|
+
secondStageScript: "const fs=require('fs');if(fs.existsSync('.issue-cli/fail'))process.exit(7);fs.writeFileSync('.issue-cli/b.bin',Buffer.alloc(700));setTimeout(()=>{},100);",
|
|
1196
|
+
});
|
|
1197
|
+
try {
|
|
1198
|
+
const row = await prepareIssueWorktree({
|
|
1199
|
+
baseRef: 'main',
|
|
1200
|
+
identity: 'synthetic-author',
|
|
1201
|
+
issueNumber: 17,
|
|
1202
|
+
repoRoot: root,
|
|
1203
|
+
});
|
|
1204
|
+
writeFileSync(join(row.path, 'change.txt'), 'cumulative output\n');
|
|
1205
|
+
git(row.path, 'add', 'change.txt');
|
|
1206
|
+
git(row.path, 'commit', '-qm', 'synthetic cumulative output');
|
|
1207
|
+
mkdirSync(join(row.path, '.issue-cli'));
|
|
1208
|
+
writeFileSync(join(row.path, '.issue-cli', 'fail'), 'x');
|
|
1209
|
+
const resourceBounds = {
|
|
1210
|
+
maxAggregateRssBytes: 1_000_000_000,
|
|
1211
|
+
minFreeDiskBytes: 1,
|
|
1212
|
+
maxNewOutputBytes: 1_000,
|
|
1213
|
+
outputRoots: ['.issue-cli'],
|
|
1214
|
+
};
|
|
1215
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds }), /exit 7/u);
|
|
1216
|
+
assert.equal(readFileSync(join(row.path, '.issue-cli', 'count'), 'utf8'), '1');
|
|
1217
|
+
rmSync(join(row.path, '.issue-cli', 'fail'));
|
|
1218
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds }), /new filesystem output.*limit/u);
|
|
1219
|
+
assert.equal(readFileSync(join(row.path, '.issue-cli', 'count'), 'utf8'), '1');
|
|
1220
|
+
}
|
|
1221
|
+
finally {
|
|
1222
|
+
rmSync(root, { recursive: true, force: true });
|
|
1223
|
+
}
|
|
1224
|
+
});
|
|
1225
|
+
test('failed-stage filesystem output remains charged on retry', async () => {
|
|
1226
|
+
const { root } = await fixture({
|
|
1227
|
+
firstStageScript: "const fs=require('fs');fs.mkdirSync('.issue-cli',{recursive:true});fs.appendFileSync('.issue-cli/output.bin',Buffer.alloc(700));if(fs.existsSync('.issue-cli/fail'))process.exit(7);setTimeout(()=>{},100);",
|
|
1228
|
+
});
|
|
1229
|
+
try {
|
|
1230
|
+
const row = await prepareIssueWorktree({
|
|
1231
|
+
baseRef: 'main',
|
|
1232
|
+
identity: 'synthetic-author',
|
|
1233
|
+
issueNumber: 17,
|
|
1234
|
+
repoRoot: root,
|
|
1235
|
+
});
|
|
1236
|
+
writeFileSync(join(row.path, 'change.txt'), 'failed output\n');
|
|
1237
|
+
git(row.path, 'add', 'change.txt');
|
|
1238
|
+
git(row.path, 'commit', '-qm', 'synthetic failed output');
|
|
1239
|
+
mkdirSync(join(row.path, '.issue-cli'));
|
|
1240
|
+
const fail = join(row.path, '.issue-cli', 'fail');
|
|
1241
|
+
writeFileSync(fail, 'x');
|
|
1242
|
+
const resourceBounds = {
|
|
1243
|
+
maxAggregateRssBytes: 1_000_000_000,
|
|
1244
|
+
minFreeDiskBytes: 1,
|
|
1245
|
+
maxNewOutputBytes: 1_000,
|
|
1246
|
+
outputRoots: ['.issue-cli'],
|
|
1247
|
+
};
|
|
1248
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds }), /exit 7/u);
|
|
1249
|
+
assert.equal(readFileSync(join(row.path, '.issue-cli', 'output.bin')).length, 700);
|
|
1250
|
+
rmSync(fail);
|
|
1251
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds }), /new filesystem output.*limit/u);
|
|
1252
|
+
assert.equal(readFileSync(join(row.path, '.issue-cli', 'output.bin')).length, 1_400);
|
|
1253
|
+
assert.equal(existsSync(join(root, '.git', 'ai-delivery', 'verification@1', 'stages', 'check')), false);
|
|
1254
|
+
rmSync(join(row.path, '.issue-cli', 'output.bin'));
|
|
1255
|
+
const completed = await verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds });
|
|
1256
|
+
assert.equal(readFileSync(join(row.path, '.issue-cli', 'output.bin')).length, 700);
|
|
1257
|
+
assert.ok((completed.resources?.maxSampledNewOutputBytes ?? 0) < 1_000);
|
|
1258
|
+
const baselineDirectory = join(root, '.git', 'ai-delivery', 'output-baselines@1');
|
|
1259
|
+
const snapshot = readdirSync(baselineDirectory).find((name) => name.endsWith('.json') && !name.endsWith('.state.json'));
|
|
1260
|
+
assert.ok(snapshot);
|
|
1261
|
+
rmSync(join(baselineDirectory, snapshot));
|
|
1262
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds }), /Filesystem output baseline checkpoint is missing or corrupt/u);
|
|
1263
|
+
}
|
|
1264
|
+
finally {
|
|
1265
|
+
rmSync(root, { recursive: true, force: true });
|
|
1266
|
+
}
|
|
1267
|
+
});
|
|
1268
|
+
test('mixed-case output filenames round-trip through the durable baseline', async () => {
|
|
1269
|
+
const { root } = await fixture();
|
|
1270
|
+
try {
|
|
1271
|
+
const row = await prepareIssueWorktree({
|
|
1272
|
+
baseRef: 'main',
|
|
1273
|
+
identity: 'synthetic-author',
|
|
1274
|
+
issueNumber: 17,
|
|
1275
|
+
repoRoot: root,
|
|
1276
|
+
});
|
|
1277
|
+
writeFileSync(join(row.path, 'change.txt'), 'mixed-case output\n');
|
|
1278
|
+
git(row.path, 'add', 'change.txt');
|
|
1279
|
+
git(row.path, 'commit', '-qm', 'synthetic mixed-case output');
|
|
1280
|
+
const output = join(row.path, '.issue-cli');
|
|
1281
|
+
mkdirSync(output);
|
|
1282
|
+
writeFileSync(join(output, 'a.bin'), 'a');
|
|
1283
|
+
writeFileSync(join(output, 'B.bin'), 'B');
|
|
1284
|
+
const run = await verifyIssue({
|
|
1285
|
+
issueNumber: 17,
|
|
1286
|
+
repoRoot: row.path,
|
|
1287
|
+
resourceBounds: {
|
|
1288
|
+
maxAggregateRssBytes: 1_000_000_000,
|
|
1289
|
+
minFreeDiskBytes: 1,
|
|
1290
|
+
maxNewOutputBytes: 1_000,
|
|
1291
|
+
outputRoots: ['.issue-cli'],
|
|
1292
|
+
},
|
|
1293
|
+
});
|
|
1294
|
+
assert.equal(run.resources?.maxSampledNewOutputBytes, 0);
|
|
1295
|
+
}
|
|
1296
|
+
finally {
|
|
1297
|
+
rmSync(root, { recursive: true, force: true });
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
test('aggregate RSS crossing stops three individually under-limit children', async () => {
|
|
1301
|
+
const marker = join(tmpdir(), `ai-delivery-rss-children-${process.pid}-${Date.now()}`);
|
|
1302
|
+
const limit = 200_000_000;
|
|
1303
|
+
const worker = `const fs=require('fs');const held=Buffer.alloc(32*1024*1024,1);fs.writeFileSync(${JSON.stringify(marker)}+'-'+process.pid,String(process.memoryUsage().rss));setInterval(()=>{void held[0]},1000);`;
|
|
1304
|
+
const { root } = await fixture({
|
|
1305
|
+
firstStageScript: `const {spawn}=require('child_process');for(let i=0;i<3;i++)spawn(process.execPath,['-e',${JSON.stringify(worker)}],{stdio:'ignore'});setTimeout(()=>{},3000);`,
|
|
1306
|
+
});
|
|
1307
|
+
try {
|
|
1308
|
+
const row = await prepareIssueWorktree({
|
|
1309
|
+
baseRef: 'main',
|
|
1310
|
+
identity: 'synthetic-author',
|
|
1311
|
+
issueNumber: 17,
|
|
1312
|
+
repoRoot: root,
|
|
1313
|
+
});
|
|
1314
|
+
writeFileSync(join(row.path, 'change.txt'), 'aggregate RSS\n');
|
|
1315
|
+
git(row.path, 'add', 'change.txt');
|
|
1316
|
+
git(row.path, 'commit', '-qm', 'synthetic aggregate RSS');
|
|
1317
|
+
await assert.rejects(verifyIssue({
|
|
1318
|
+
issueNumber: 17,
|
|
1319
|
+
repoRoot: row.path,
|
|
1320
|
+
resourceBounds: { maxAggregateRssBytes: limit, minFreeDiskBytes: 1 },
|
|
1321
|
+
}), /aggregate RSS.*limit/u);
|
|
1322
|
+
const rows = readdirSync(tmpdir()).filter((name) => name.startsWith(`${marker.split('/').at(-1)}-`));
|
|
1323
|
+
assert.equal(rows.length, 3);
|
|
1324
|
+
for (const name of rows)
|
|
1325
|
+
assert.ok(Number(readFileSync(join(tmpdir(), name), 'utf8')) < limit);
|
|
1326
|
+
assert.equal(existsSync(join(root, '.git', 'ai-delivery', 'verification@1', 'stages', 'check')), false);
|
|
1327
|
+
}
|
|
1328
|
+
finally {
|
|
1329
|
+
for (const name of readdirSync(tmpdir()).filter((value) => value.startsWith(`${marker.split('/').at(-1)}-`))) {
|
|
1330
|
+
const pid = Number(name.slice(name.lastIndexOf('-') + 1));
|
|
1331
|
+
try {
|
|
1332
|
+
process.kill(pid, 'SIGKILL');
|
|
1333
|
+
}
|
|
1334
|
+
catch {
|
|
1335
|
+
/* already stopped */
|
|
1336
|
+
}
|
|
1337
|
+
rmSync(join(tmpdir(), name), { force: true });
|
|
1338
|
+
}
|
|
1339
|
+
rmSync(root, { recursive: true, force: true });
|
|
1340
|
+
}
|
|
1341
|
+
});
|
|
1342
|
+
test('filesystem output from a sampled detached child crosses its declared root limit', async () => {
|
|
1343
|
+
const marker = join(tmpdir(), `ai-delivery-detached-output-${process.pid}-${Date.now()}`);
|
|
1344
|
+
const heartbeat = `${marker}-heartbeat`;
|
|
1345
|
+
const worker = `const fs=require('fs');fs.writeFileSync('generated.bin',Buffer.alloc(4096));setInterval(()=>fs.appendFileSync(${JSON.stringify(heartbeat)},'x'),20);`;
|
|
1346
|
+
const { root } = await fixture({
|
|
1347
|
+
firstStageScript: `const fs=require('fs');const {spawn}=require('child_process');const child=spawn(process.execPath,['-e',${JSON.stringify(worker)}],{detached:true,stdio:'ignore'});child.unref();fs.writeFileSync(${JSON.stringify(marker)},String(child.pid));setTimeout(()=>{},2500);`,
|
|
1348
|
+
});
|
|
1349
|
+
let descendantPid;
|
|
1350
|
+
try {
|
|
1351
|
+
const row = await prepareIssueWorktree({
|
|
1352
|
+
baseRef: 'main',
|
|
1353
|
+
identity: 'synthetic-author',
|
|
1354
|
+
issueNumber: 17,
|
|
1355
|
+
repoRoot: root,
|
|
1356
|
+
});
|
|
1357
|
+
writeFileSync(join(row.path, 'change.txt'), 'detached output\n');
|
|
1358
|
+
git(row.path, 'add', 'change.txt');
|
|
1359
|
+
git(row.path, 'commit', '-qm', 'synthetic detached output');
|
|
1360
|
+
await assert.rejects(verifyIssue({
|
|
1361
|
+
issueNumber: 17,
|
|
1362
|
+
repoRoot: row.path,
|
|
1363
|
+
resourceBounds: {
|
|
1364
|
+
maxAggregateRssBytes: 1_000_000_000,
|
|
1365
|
+
minFreeDiskBytes: 1,
|
|
1366
|
+
maxNewOutputBytes: 1_024,
|
|
1367
|
+
outputRoots: ['.'],
|
|
1368
|
+
},
|
|
1369
|
+
}), /new filesystem output.*limit/u);
|
|
1370
|
+
descendantPid = Number(readFileSync(marker, 'utf8'));
|
|
1371
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
1372
|
+
const stoppedSize = existsSync(heartbeat) ? readFileSync(heartbeat).length : 0;
|
|
1373
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1374
|
+
assert.equal(existsSync(heartbeat) ? readFileSync(heartbeat).length : 0, stoppedSize);
|
|
1375
|
+
assert.equal(existsSync(join(root, '.git', 'ai-delivery', 'verification@1', 'stages', 'check')), false);
|
|
1376
|
+
}
|
|
1377
|
+
finally {
|
|
1378
|
+
if (descendantPid !== undefined) {
|
|
1379
|
+
try {
|
|
1380
|
+
process.kill(descendantPid, 'SIGKILL');
|
|
1381
|
+
}
|
|
1382
|
+
catch {
|
|
1383
|
+
/* already stopped */
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
rmSync(marker, { force: true });
|
|
1387
|
+
rmSync(heartbeat, { force: true });
|
|
1388
|
+
rmSync(root, { recursive: true, force: true });
|
|
1389
|
+
}
|
|
1390
|
+
});
|
|
1391
|
+
test('a symbolic link in declared output roots fails before publishing sampled output evidence', async () => {
|
|
1392
|
+
const { root } = await fixture();
|
|
1393
|
+
const outside = mkdtempSync(join(tmpdir(), 'ai-delivery-output-link-'));
|
|
1394
|
+
try {
|
|
1395
|
+
const row = await prepareIssueWorktree({
|
|
1396
|
+
baseRef: 'main',
|
|
1397
|
+
identity: 'synthetic-author',
|
|
1398
|
+
issueNumber: 17,
|
|
1399
|
+
repoRoot: root,
|
|
1400
|
+
});
|
|
1401
|
+
writeFileSync(join(row.path, 'change.txt'), 'linked output\n');
|
|
1402
|
+
git(row.path, 'add', 'change.txt');
|
|
1403
|
+
git(row.path, 'commit', '-qm', 'synthetic linked output');
|
|
1404
|
+
mkdirSync(join(outside, 'target'));
|
|
1405
|
+
symlinkSync(join(outside, 'target'), join(outside, 'declared'));
|
|
1406
|
+
await assert.rejects(verifyIssue({
|
|
1407
|
+
issueNumber: 17,
|
|
1408
|
+
repoRoot: row.path,
|
|
1409
|
+
resourceBounds: {
|
|
1410
|
+
maxAggregateRssBytes: 1_000_000_000,
|
|
1411
|
+
minFreeDiskBytes: 1,
|
|
1412
|
+
maxNewOutputBytes: 1_024,
|
|
1413
|
+
outputRoots: [join(outside, 'declared')],
|
|
1414
|
+
},
|
|
1415
|
+
}), /symbolic link/u);
|
|
1416
|
+
}
|
|
1417
|
+
finally {
|
|
1418
|
+
rmSync(outside, { recursive: true, force: true });
|
|
1419
|
+
rmSync(root, { recursive: true, force: true });
|
|
1420
|
+
}
|
|
1421
|
+
});
|
|
1422
|
+
test('an unobserved detached pipe holder cannot keep a cancelled stage pending indefinitely', async () => {
|
|
1423
|
+
const marker = join(tmpdir(), `ai-delivery-escaped-pipe-${process.pid}-${Date.now()}`);
|
|
1424
|
+
const { root } = await fixture({
|
|
1425
|
+
firstStageScript: `const fs=require('fs');const {spawn}=require('child_process');const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true,stdio:['ignore','inherit','inherit']});child.unref();fs.writeFileSync(${JSON.stringify(marker)},String(child.pid));process.exit(0);`,
|
|
1426
|
+
});
|
|
1427
|
+
let descendantPid;
|
|
1428
|
+
let run;
|
|
1429
|
+
try {
|
|
1430
|
+
const row = await prepareIssueWorktree({
|
|
1431
|
+
baseRef: 'main',
|
|
1432
|
+
identity: 'synthetic-author',
|
|
1433
|
+
issueNumber: 17,
|
|
1434
|
+
repoRoot: root,
|
|
1435
|
+
});
|
|
1436
|
+
writeFileSync(join(row.path, 'change.txt'), 'escaped pipe\n');
|
|
1437
|
+
git(row.path, 'add', 'change.txt');
|
|
1438
|
+
git(row.path, 'commit', '-qm', 'synthetic escaped pipe');
|
|
1439
|
+
const controller = new AbortController();
|
|
1440
|
+
run = verifyIssue({
|
|
1441
|
+
issueNumber: 17,
|
|
1442
|
+
repoRoot: row.path,
|
|
1443
|
+
signal: controller.signal,
|
|
1444
|
+
resourceBounds: { maxAggregateRssBytes: 1_000_000_000, minFreeDiskBytes: 1 },
|
|
1445
|
+
});
|
|
1446
|
+
const deadline = Date.now() + 2_000;
|
|
1447
|
+
while (!existsSync(marker) && Date.now() < deadline)
|
|
1448
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1449
|
+
assert.equal(existsSync(marker), true);
|
|
1450
|
+
descendantPid = Number(readFileSync(marker, 'utf8'));
|
|
1451
|
+
controller.abort();
|
|
1452
|
+
await assert.rejects(Promise.race([
|
|
1453
|
+
run,
|
|
1454
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('cleanup remained pending')), 3_000)),
|
|
1455
|
+
]), /owned command pipes did not close/u);
|
|
1456
|
+
assert.equal(existsSync(join(root, '.git', 'ai-delivery', 'verification@1', 'stages', 'check')), false);
|
|
1457
|
+
}
|
|
1458
|
+
finally {
|
|
1459
|
+
if (descendantPid !== undefined) {
|
|
1460
|
+
try {
|
|
1461
|
+
process.kill(descendantPid, 'SIGKILL');
|
|
1462
|
+
}
|
|
1463
|
+
catch {
|
|
1464
|
+
/* already stopped */
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
await run?.catch(() => undefined);
|
|
1468
|
+
rmSync(marker, { force: true });
|
|
1469
|
+
rmSync(root, { recursive: true, force: true });
|
|
1470
|
+
}
|
|
1471
|
+
});
|
|
1472
|
+
test('a direct child exit with inherited pipes still open fails within a bounded cleanup interval', async () => {
|
|
1473
|
+
const marker = join(tmpdir(), `ai-delivery-exited-pipe-${process.pid}-${Date.now()}`);
|
|
1474
|
+
const { root } = await fixture({
|
|
1475
|
+
firstStageScript: `const fs=require('fs');const {spawn}=require('child_process');const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true,stdio:['ignore','inherit','inherit']});child.unref();fs.writeFileSync(${JSON.stringify(marker)},String(child.pid));process.exit(0);`,
|
|
1476
|
+
});
|
|
1477
|
+
let descendantPid;
|
|
1478
|
+
try {
|
|
1479
|
+
const row = await prepareIssueWorktree({
|
|
1480
|
+
baseRef: 'main',
|
|
1481
|
+
identity: 'synthetic-author',
|
|
1482
|
+
issueNumber: 17,
|
|
1483
|
+
repoRoot: root,
|
|
1484
|
+
});
|
|
1485
|
+
writeFileSync(join(row.path, 'change.txt'), 'exited pipe\n');
|
|
1486
|
+
git(row.path, 'add', 'change.txt');
|
|
1487
|
+
git(row.path, 'commit', '-qm', 'synthetic exited pipe');
|
|
1488
|
+
const run = verifyIssue({
|
|
1489
|
+
issueNumber: 17,
|
|
1490
|
+
repoRoot: row.path,
|
|
1491
|
+
resourceBounds: { maxAggregateRssBytes: 1_000_000_000, minFreeDiskBytes: 1 },
|
|
1492
|
+
});
|
|
1493
|
+
const deadline = Date.now() + 2_000;
|
|
1494
|
+
while (!existsSync(marker) && Date.now() < deadline)
|
|
1495
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1496
|
+
assert.equal(existsSync(marker), true);
|
|
1497
|
+
descendantPid = Number(readFileSync(marker, 'utf8'));
|
|
1498
|
+
await assert.rejects(Promise.race([
|
|
1499
|
+
run,
|
|
1500
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('stage remained pending')), 3_500)),
|
|
1501
|
+
]), /owned command pipes did not close/u);
|
|
1502
|
+
assert.equal(existsSync(join(root, '.git', 'ai-delivery', 'verification@1', 'stages', 'check')), false);
|
|
1503
|
+
}
|
|
1504
|
+
finally {
|
|
1505
|
+
if (descendantPid !== undefined) {
|
|
1506
|
+
try {
|
|
1507
|
+
process.kill(descendantPid, 'SIGKILL');
|
|
1508
|
+
}
|
|
1509
|
+
catch {
|
|
1510
|
+
/* already stopped */
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
rmSync(marker, { force: true });
|
|
1514
|
+
rmSync(root, { recursive: true, force: true });
|
|
1515
|
+
}
|
|
1516
|
+
});
|
|
1517
|
+
test('bounded cancellation cleans a sampled detached descendant and preserves an unrelated process', async () => {
|
|
1518
|
+
const marker = join(tmpdir(), `ai-delivery-detached-child-${process.pid}-${Date.now()}`);
|
|
1519
|
+
const heartbeat = `${marker}-heartbeat`;
|
|
1520
|
+
const unrelatedHeartbeat = `${marker}-unrelated`;
|
|
1521
|
+
const { root } = await fixture({
|
|
1522
|
+
firstStageScript: `const fs=require('fs');const {spawn}=require('child_process');const child=spawn(process.execPath,['-e',${JSON.stringify(`const fs=require('fs');setInterval(()=>fs.appendFileSync(${JSON.stringify(heartbeat)},'x'),20);`)}],{detached:true,stdio:'ignore'});child.unref();fs.writeFileSync(${JSON.stringify(marker)},String(child.pid));setTimeout(()=>{},2500);`,
|
|
1523
|
+
});
|
|
1524
|
+
const unrelated = spawn(process.execPath, ['-e', `const fs=require('fs');setInterval(()=>fs.appendFileSync(${JSON.stringify(unrelatedHeartbeat)},'x'),20);`], { detached: true, stdio: 'ignore' });
|
|
1525
|
+
unrelated.unref();
|
|
1526
|
+
let descendantPid;
|
|
1527
|
+
try {
|
|
1528
|
+
const row = await prepareIssueWorktree({
|
|
1529
|
+
baseRef: 'main',
|
|
1530
|
+
identity: 'synthetic-author',
|
|
1531
|
+
issueNumber: 17,
|
|
1532
|
+
repoRoot: root,
|
|
1533
|
+
});
|
|
1534
|
+
writeFileSync(join(row.path, 'change.txt'), 'detached child\n');
|
|
1535
|
+
git(row.path, 'add', 'change.txt');
|
|
1536
|
+
git(row.path, 'commit', '-qm', 'synthetic detached child');
|
|
1537
|
+
const controller = new AbortController();
|
|
1538
|
+
const run = verifyIssue({
|
|
1539
|
+
issueNumber: 17,
|
|
1540
|
+
repoRoot: row.path,
|
|
1541
|
+
signal: controller.signal,
|
|
1542
|
+
resourceBounds: { maxAggregateRssBytes: 1_000_000_000, minFreeDiskBytes: 1 },
|
|
1543
|
+
});
|
|
1544
|
+
const deadline = Date.now() + 2_000;
|
|
1545
|
+
while (!existsSync(marker) && Date.now() < deadline)
|
|
1546
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1547
|
+
assert.equal(existsSync(marker), true);
|
|
1548
|
+
descendantPid = Number(readFileSync(marker, 'utf8'));
|
|
1549
|
+
await new Promise((resolve) => setTimeout(resolve, 1_200));
|
|
1550
|
+
controller.abort();
|
|
1551
|
+
await assert.rejects(run, /cancelled/u);
|
|
1552
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
1553
|
+
const stoppedSize = existsSync(heartbeat) ? readFileSync(heartbeat).length : 0;
|
|
1554
|
+
const unrelatedSize = existsSync(unrelatedHeartbeat) ? readFileSync(unrelatedHeartbeat).length : 0;
|
|
1555
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1556
|
+
assert.equal(existsSync(heartbeat) ? readFileSync(heartbeat).length : 0, stoppedSize);
|
|
1557
|
+
assert.ok(readFileSync(unrelatedHeartbeat).length > unrelatedSize);
|
|
1558
|
+
}
|
|
1559
|
+
finally {
|
|
1560
|
+
if (descendantPid !== undefined) {
|
|
1561
|
+
try {
|
|
1562
|
+
process.kill(descendantPid, 'SIGKILL');
|
|
1563
|
+
}
|
|
1564
|
+
catch {
|
|
1565
|
+
/* already stopped */
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
if (unrelated.pid !== undefined) {
|
|
1569
|
+
try {
|
|
1570
|
+
process.kill(unrelated.pid, 'SIGKILL');
|
|
1571
|
+
}
|
|
1572
|
+
catch {
|
|
1573
|
+
/* already stopped */
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
rmSync(marker, { force: true });
|
|
1577
|
+
rmSync(heartbeat, { force: true });
|
|
1578
|
+
rmSync(unrelatedHeartbeat, { force: true });
|
|
1579
|
+
rmSync(root, { recursive: true, force: true });
|
|
1580
|
+
}
|
|
1581
|
+
});
|
|
1582
|
+
test('sampled evidence identifies the escape boundary for a detached pipe-free child', async () => {
|
|
1583
|
+
const marker = join(tmpdir(), `ai-delivery-late-detach-${process.pid}-${Date.now()}`);
|
|
1584
|
+
const heartbeat = `${marker}-heartbeat`;
|
|
1585
|
+
const trigger = `${marker}-trigger`;
|
|
1586
|
+
const { root } = await fixture({
|
|
1587
|
+
firstStageScript: `const fs=require('fs');const {spawn}=require('child_process');const wait=setInterval(()=>{if(!fs.existsSync(${JSON.stringify(trigger)}))return;clearInterval(wait);setTimeout(()=>{const child=spawn(process.execPath,['-e',${JSON.stringify(`const fs=require('fs');setInterval(()=>fs.appendFileSync(${JSON.stringify(heartbeat)},'x'),20);`)}],{detached:true,stdio:'ignore'});child.unref();fs.writeFileSync(${JSON.stringify(marker)},String(child.pid));},250);setTimeout(()=>process.exit(0),500);},5);`,
|
|
1588
|
+
});
|
|
1589
|
+
let descendantPid;
|
|
1590
|
+
const controller = new AbortController();
|
|
1591
|
+
const progress = vi.spyOn(process.stderr, 'write');
|
|
1592
|
+
let run;
|
|
1593
|
+
try {
|
|
1594
|
+
const row = await prepareIssueWorktree({
|
|
1595
|
+
baseRef: 'main',
|
|
1596
|
+
identity: 'synthetic-author',
|
|
1597
|
+
issueNumber: 17,
|
|
1598
|
+
repoRoot: root,
|
|
1599
|
+
});
|
|
1600
|
+
writeFileSync(join(row.path, 'change.txt'), 'late detach\n');
|
|
1601
|
+
git(row.path, 'add', 'change.txt');
|
|
1602
|
+
git(row.path, 'commit', '-qm', 'synthetic late detach');
|
|
1603
|
+
run = verifyIssue({
|
|
1604
|
+
issueNumber: 17,
|
|
1605
|
+
repoRoot: row.path,
|
|
1606
|
+
signal: controller.signal,
|
|
1607
|
+
resourceBounds: { maxAggregateRssBytes: 1_000_000_000, minFreeDiskBytes: 1 },
|
|
1608
|
+
});
|
|
1609
|
+
const deadline = Date.now() + 2_000;
|
|
1610
|
+
while (!progress.mock.calls.some(([message]) => String(message).includes('"sampledAggregateRssBytes"')) &&
|
|
1611
|
+
Date.now() < deadline)
|
|
1612
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1613
|
+
assert.ok(progress.mock.calls.some(([message]) => String(message).includes('"sampledAggregateRssBytes"')));
|
|
1614
|
+
writeFileSync(trigger, 'go');
|
|
1615
|
+
const result = await run;
|
|
1616
|
+
assert.equal(result.resources?.processCoverage, 'observed-processes-only');
|
|
1617
|
+
assert.equal(result.aggregate.result, 'passed');
|
|
1618
|
+
descendantPid = Number(readFileSync(marker, 'utf8'));
|
|
1619
|
+
const size = existsSync(heartbeat) ? readFileSync(heartbeat).length : 0;
|
|
1620
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1621
|
+
assert.ok((existsSync(heartbeat) ? readFileSync(heartbeat).length : 0) > size);
|
|
1622
|
+
}
|
|
1623
|
+
finally {
|
|
1624
|
+
controller.abort();
|
|
1625
|
+
await run?.catch(() => undefined);
|
|
1626
|
+
progress.mockRestore();
|
|
1627
|
+
if (descendantPid !== undefined) {
|
|
1628
|
+
try {
|
|
1629
|
+
process.kill(descendantPid, 'SIGKILL');
|
|
1630
|
+
}
|
|
1631
|
+
catch {
|
|
1632
|
+
/* already stopped */
|
|
1633
|
+
}
|
|
1634
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
1635
|
+
const stopped = existsSync(heartbeat) ? readFileSync(heartbeat).length : 0;
|
|
1636
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
1637
|
+
assert.equal(existsSync(heartbeat) ? readFileSync(heartbeat).length : 0, stopped);
|
|
1638
|
+
}
|
|
1639
|
+
rmSync(marker, { force: true });
|
|
1640
|
+
rmSync(heartbeat, { force: true });
|
|
1641
|
+
rmSync(trigger, { force: true });
|
|
1642
|
+
rmSync(root, { recursive: true, force: true });
|
|
1643
|
+
}
|
|
1644
|
+
});
|
|
1645
|
+
test('a successful command cannot publish while its sampled detached descendant survives', async () => {
|
|
1646
|
+
const marker = join(tmpdir(), `ai-delivery-left-behind-${process.pid}-${Date.now()}`);
|
|
1647
|
+
const heartbeat = `${marker}-heartbeat`;
|
|
1648
|
+
const { root } = await fixture({
|
|
1649
|
+
firstStageScript: `const fs=require('fs');const {spawn}=require('child_process');const child=spawn(process.execPath,['-e',${JSON.stringify(`const fs=require('fs');setInterval(()=>fs.appendFileSync(${JSON.stringify(heartbeat)},'x'),20);`)}],{detached:true,stdio:'ignore'});child.unref();fs.writeFileSync(${JSON.stringify(marker)},String(child.pid));setTimeout(()=>{},1400);`,
|
|
1650
|
+
});
|
|
1651
|
+
let descendantPid;
|
|
1652
|
+
try {
|
|
1653
|
+
const row = await prepareIssueWorktree({
|
|
1654
|
+
baseRef: 'main',
|
|
1655
|
+
identity: 'synthetic-author',
|
|
1656
|
+
issueNumber: 17,
|
|
1657
|
+
repoRoot: root,
|
|
1658
|
+
});
|
|
1659
|
+
writeFileSync(join(row.path, 'change.txt'), 'left behind\n');
|
|
1660
|
+
git(row.path, 'add', 'change.txt');
|
|
1661
|
+
git(row.path, 'commit', '-qm', 'synthetic left behind descendant');
|
|
1662
|
+
await assert.rejects(verifyIssue({
|
|
1663
|
+
issueNumber: 17,
|
|
1664
|
+
repoRoot: row.path,
|
|
1665
|
+
resourceBounds: { maxAggregateRssBytes: 1_000_000_000, minFreeDiskBytes: 1 },
|
|
1666
|
+
}), /owned descendant survived/u);
|
|
1667
|
+
descendantPid = Number(readFileSync(marker, 'utf8'));
|
|
1668
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
1669
|
+
const stoppedSize = existsSync(heartbeat) ? readFileSync(heartbeat).length : 0;
|
|
1670
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1671
|
+
assert.equal(existsSync(heartbeat) ? readFileSync(heartbeat).length : 0, stoppedSize);
|
|
1672
|
+
assert.equal(existsSync(join(root, '.git', 'ai-delivery', 'verification@1', 'stages', 'check')), false);
|
|
1673
|
+
}
|
|
1674
|
+
finally {
|
|
1675
|
+
if (descendantPid !== undefined) {
|
|
1676
|
+
try {
|
|
1677
|
+
process.kill(descendantPid, 'SIGKILL');
|
|
1678
|
+
}
|
|
1679
|
+
catch {
|
|
1680
|
+
/* already stopped */
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
rmSync(marker, { force: true });
|
|
1684
|
+
rmSync(heartbeat, { force: true });
|
|
1685
|
+
rmSync(root, { recursive: true, force: true });
|
|
1686
|
+
}
|
|
1687
|
+
});
|
|
1688
|
+
test('public PR dry run retains a configured host author and App reviewer', async () => {
|
|
1689
|
+
const { root } = await fixture({ personalAuthor: true });
|
|
1690
|
+
try {
|
|
1691
|
+
const row = await prepareIssueWorktree({
|
|
1692
|
+
baseRef: 'main',
|
|
1693
|
+
identity: 'host-author',
|
|
1694
|
+
issueNumber: 17,
|
|
1695
|
+
repoRoot: root,
|
|
1696
|
+
});
|
|
1697
|
+
writeFileSync(join(row.path, 'change.txt'), 'host author change\n');
|
|
1698
|
+
git(row.path, 'add', 'change.txt');
|
|
1699
|
+
git(row.path, 'commit', '-qm', 'synthetic host author change');
|
|
1700
|
+
await verifyIssue({ issueNumber: 17, repoRoot: row.path });
|
|
1701
|
+
const result = (await executeTool('issue_pr_create', { issueNumber: 17, dryRun: true }, { repoRoot: row.path, identity: 'host-author' }));
|
|
1702
|
+
assert.equal(result.dryRun, true);
|
|
1703
|
+
assert.deepEqual(result.reviewRoute.author, {
|
|
1704
|
+
actorLogin: 'host-user',
|
|
1705
|
+
authSource: 'personal',
|
|
1706
|
+
credentialSource: 'env:AUTHOR_TOKEN',
|
|
1707
|
+
identity: 'host-author',
|
|
1708
|
+
});
|
|
1709
|
+
assert.equal(result.reviewRoute.reviewer.actorLogin, 'synthetic-reviewer');
|
|
1710
|
+
assert.equal(result.reviewRoute.approvalEligibility, 'unknown');
|
|
1711
|
+
}
|
|
1712
|
+
finally {
|
|
1713
|
+
rmSync(root, { recursive: true, force: true });
|
|
1714
|
+
}
|
|
1715
|
+
}, 15_000);
|
|
1716
|
+
test('public dispatch publishes with selected host token and submits the configured App review once', async () => {
|
|
1717
|
+
const { root, runtimeEntryPath } = await fixture({ personalAuthor: true });
|
|
1718
|
+
const originalPath = process.env.PATH;
|
|
1719
|
+
try {
|
|
1720
|
+
const row = await prepareIssueWorktree({
|
|
1721
|
+
baseRef: 'main',
|
|
1722
|
+
identity: 'host-author',
|
|
1723
|
+
issueNumber: 17,
|
|
1724
|
+
repoRoot: root,
|
|
1725
|
+
});
|
|
1726
|
+
writeFileSync(join(row.path, 'change.txt'), 'host author publication\n');
|
|
1727
|
+
git(row.path, 'add', 'change.txt');
|
|
1728
|
+
git(row.path, 'commit', '-qm', 'synthetic host publication');
|
|
1729
|
+
const run = await verifyIssue({ issueNumber: 17, repoRoot: row.path });
|
|
1730
|
+
personalRoute.baseSha = run.classification.base.sha;
|
|
1731
|
+
personalRoute.headSha = run.classification.head.sha;
|
|
1732
|
+
personalRoute.reviews.length = 0;
|
|
1733
|
+
personalRoute.submitted = 0;
|
|
1734
|
+
personalRoute.active = true;
|
|
1735
|
+
const realGit = (originalPath ?? '')
|
|
1736
|
+
.split(':')
|
|
1737
|
+
.map((directory) => join(directory, 'git'))
|
|
1738
|
+
.find(existsSync);
|
|
1739
|
+
if (!realGit)
|
|
1740
|
+
throw new Error('Git executable unavailable for public transport test.');
|
|
1741
|
+
git(root, 'config', '--local', '--unset', `url.${join(root, '.git', 'remote.git')}.insteadOf`);
|
|
1742
|
+
const shimDir = join(root, '.git', 'git-shim');
|
|
1743
|
+
const pushMarker = join(root, '.git', 'selected-host-push');
|
|
1744
|
+
mkdirSync(shimDir);
|
|
1745
|
+
const shim = join(shimDir, 'git');
|
|
1746
|
+
writeFileSync(shim, `#!/bin/sh
|
|
1747
|
+
if [ "$1" = -c ] && [ "$2" = credential.helper= ] && [ "$3" = push ]; then
|
|
1748
|
+
[ "$AI_DELIVERY_GIT_TOKEN" = selected-personal-token ] || exit 91
|
|
1749
|
+
[ "$("$GIT_ASKPASS" Password)" = selected-personal-token ] || exit 92
|
|
1750
|
+
: > "${pushMarker}"
|
|
1751
|
+
exit 0
|
|
1752
|
+
fi
|
|
1753
|
+
exec "${realGit}" "$@"
|
|
1754
|
+
`);
|
|
1755
|
+
chmodSync(shim, 0o700);
|
|
1756
|
+
process.env.PATH = `${shimDir}:${originalPath ?? ''}`;
|
|
1757
|
+
const execution = { repoRoot: row.path, runtimeEntryPath, identity: 'host-author' };
|
|
1758
|
+
const published = (await executeTool('issue_pr_create', { issueNumber: 17 }, execution));
|
|
1759
|
+
assert.equal(published.prNumber, 23);
|
|
1760
|
+
assert.equal(existsSync(pushMarker), true);
|
|
1761
|
+
assert.equal(published.reviewRoute.author.actorLogin, 'host-user');
|
|
1762
|
+
assert.equal(published.reviewRoute.author.credentialSource, 'env:AUTHOR_TOKEN');
|
|
1763
|
+
assert.equal(published.reviewRoute.reviewer.actorLogin, 'synthetic-reviewer[bot]');
|
|
1764
|
+
const info = (await executeTool('issue_pr_info', { prNumber: 23 }, execution));
|
|
1765
|
+
assert.equal(info.authorLogin, 'host-user');
|
|
1766
|
+
const artifactContent = {
|
|
1767
|
+
authorIdentity: 'host-author',
|
|
1768
|
+
checks: ['verified exact diff'],
|
|
1769
|
+
diffScopeHash: digestValue(run.classification.changedPaths),
|
|
1770
|
+
elapsedMs: 1,
|
|
1771
|
+
findings: [],
|
|
1772
|
+
head: run.classification.head,
|
|
1773
|
+
issueNumber: 17,
|
|
1774
|
+
prNumber: 23,
|
|
1775
|
+
readOnly: true,
|
|
1776
|
+
requestedEffort: 'xhigh',
|
|
1777
|
+
requestedModel: 'gpt-6-astra',
|
|
1778
|
+
effectiveEffort: 'xhigh',
|
|
1779
|
+
effectiveModel: 'gpt-6-astra',
|
|
1780
|
+
reviewerIdentity: 'synthetic-reviewer',
|
|
1781
|
+
schemaVersion: 'ai-delivery.review-artifact@1',
|
|
1782
|
+
summary: 'Independent exact-head review',
|
|
1783
|
+
verdict: 'approve',
|
|
1784
|
+
};
|
|
1785
|
+
const artifact = JSON.stringify({ ...artifactContent, artifactId: digestValue(artifactContent) });
|
|
1786
|
+
const reviewInput = { issueNumber: 17, prNumber: 23, identity: 'synthetic-reviewer', artifact };
|
|
1787
|
+
const first = (await executeTool('issue_pr_review', reviewInput, execution));
|
|
1788
|
+
assert.equal(first.reviewState.status, 'still-required');
|
|
1789
|
+
assert.equal(personalRoute.submitted, 1);
|
|
1790
|
+
const second = (await executeTool('issue_pr_review', reviewInput, execution));
|
|
1791
|
+
assert.equal(second.receiptId, first.receiptId);
|
|
1792
|
+
assert.equal(personalRoute.submitted, 1);
|
|
1793
|
+
}
|
|
1794
|
+
finally {
|
|
1795
|
+
personalRoute.active = false;
|
|
1796
|
+
personalRoute.reviews.length = 0;
|
|
1797
|
+
if (originalPath === undefined)
|
|
1798
|
+
delete process.env.PATH;
|
|
1799
|
+
else
|
|
1800
|
+
process.env.PATH = originalPath;
|
|
1801
|
+
rmSync(root, { recursive: true, force: true });
|
|
1802
|
+
}
|
|
1803
|
+
}, 15_000);
|
|
1804
|
+
test('scratch start derives safe names and reports the requested worktree', async () => {
|
|
1805
|
+
const { root, runtimeEntryPath } = await fixture();
|
|
1806
|
+
try {
|
|
1807
|
+
const first = (await executeTool('issue_start', { scratch: true, request: 'Investigate button layout' }, { repoRoot: root, runtimeEntryPath, identity: 'synthetic-author' }));
|
|
1808
|
+
assert.equal(first.mode, 'scratch');
|
|
1809
|
+
assert.equal(first.title, 'Investigate button layout');
|
|
1810
|
+
assert.equal(first.branch, 'scratch/investigate-button-layout');
|
|
1811
|
+
assert.ok(first.worktreePath.endsWith('/.worktrees/scratch-investigate-button-layout'));
|
|
1812
|
+
const overridden = (await executeTool('issue_start', { scratch: true, request: 'Work on parser', branch: 'scratch/custom/parser' }, { repoRoot: root, runtimeEntryPath, identity: 'synthetic-author' }));
|
|
1813
|
+
assert.equal(overridden.branch, 'scratch/custom/parser');
|
|
1814
|
+
assert.equal(overridden.worktreePath, join(dirname(first.worktreePath), 'scratch-scratch-custom-parser'));
|
|
1815
|
+
}
|
|
1816
|
+
finally {
|
|
1817
|
+
rmSync(root, { recursive: true, force: true });
|
|
1818
|
+
}
|
|
1819
|
+
});
|
|
1820
|
+
test('MCP exposes one implementation surface for native lifecycle commands', () => {
|
|
1821
|
+
assert.deepEqual(AI_DELIVERY_MCP_TOOLS.map((tool) => tool.name), [
|
|
1822
|
+
'issue_create',
|
|
1823
|
+
'issue_start',
|
|
1824
|
+
'issue_update',
|
|
1825
|
+
'issue_info',
|
|
1826
|
+
'issue_ready_check',
|
|
1827
|
+
'issue_develop',
|
|
1828
|
+
'issue_verify',
|
|
1829
|
+
'issue_pr_create',
|
|
1830
|
+
'issue_pr_info',
|
|
1831
|
+
'issue_pr_review',
|
|
1832
|
+
'issue_pr_merge',
|
|
1833
|
+
'issue_finish',
|
|
1834
|
+
'issue_worktree_create',
|
|
1835
|
+
]);
|
|
1836
|
+
const validInputs = {
|
|
1837
|
+
issue_create: { title: 'Synthetic tracking parent' },
|
|
1838
|
+
issue_ready_check: { issueNumber: 17 },
|
|
1839
|
+
issue_develop: { issueNumber: 17 },
|
|
1840
|
+
issue_verify: { issueNumber: 17 },
|
|
1841
|
+
issue_pr_create: { issueNumber: 17, draft: true },
|
|
1842
|
+
issue_pr_review: { issueNumber: 17, prNumber: 23, artifact: '{}' },
|
|
1843
|
+
issue_pr_merge: { issueNumber: 17, prNumber: 23, strategy: 'merge' },
|
|
1844
|
+
issue_finish: { issueNumber: 17, prNumber: 23, strategy: 'merge' },
|
|
1845
|
+
};
|
|
1846
|
+
for (const [name, input] of Object.entries(validInputs)) {
|
|
1847
|
+
const tool = AI_DELIVERY_MCP_TOOLS.find((candidate) => candidate.name === name);
|
|
1848
|
+
assert.ok(tool, `${name} has an MCP definition`);
|
|
1849
|
+
assert.equal(tool.inputSchema.safeParse(input).success, true, `${name} accepts its lifecycle input`);
|
|
1850
|
+
assert.equal(tool.inputSchema.safeParse({ ...input, unexpected: true }).success, false, `${name} rejects unrelated fields`);
|
|
1851
|
+
}
|
|
1852
|
+
});
|
|
1853
|
+
test('MCP request rejects unsupported legacy options before lifecycle dispatch', async () => {
|
|
1854
|
+
const server = createAiDeliveryMcpServer({ repoRoot: '/missing-synthetic-repository' });
|
|
1855
|
+
const client = new Client({ name: 'synthetic-test', version: '1.0.0' });
|
|
1856
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
1857
|
+
try {
|
|
1858
|
+
await server.connect(serverTransport);
|
|
1859
|
+
await client.connect(clientTransport);
|
|
1860
|
+
const result = await client.callTool({
|
|
1861
|
+
name: 'issue_create',
|
|
1862
|
+
arguments: {
|
|
1863
|
+
body: 'tracked work',
|
|
1864
|
+
issueType: 'Task',
|
|
1865
|
+
points: 1,
|
|
1866
|
+
priority: 'High',
|
|
1867
|
+
title: 'Synthetic change',
|
|
1868
|
+
unsupportedLegacyOption: true,
|
|
1869
|
+
},
|
|
1870
|
+
});
|
|
1871
|
+
assert.equal(result.isError, true);
|
|
1872
|
+
assert.match(JSON.stringify(result.content), /unsupportedLegacyOption/u);
|
|
1873
|
+
}
|
|
1874
|
+
finally {
|
|
1875
|
+
await client.close();
|
|
1876
|
+
await server.close();
|
|
1877
|
+
}
|
|
1878
|
+
});
|
|
1879
|
+
async function syntheticLifecycle(routing) {
|
|
1880
|
+
const { root } = await fixture(routing);
|
|
1881
|
+
const remoteName = routing.remote;
|
|
1882
|
+
assert.equal(defaultBaseRef(root, remoteName), `refs/remotes/${remoteName}/main`);
|
|
1883
|
+
const remote = join(root, '.git', 'remote.git');
|
|
1884
|
+
const configuration = await loadDeliveryConfig(root);
|
|
1885
|
+
const config = configuration.config;
|
|
1886
|
+
const baseSha = git(root, 'rev-parse', 'main');
|
|
1887
|
+
const issueNumber = 17;
|
|
1888
|
+
const prNumber = 23;
|
|
1889
|
+
const issue = {
|
|
1890
|
+
id: 170,
|
|
1891
|
+
number: issueNumber,
|
|
1892
|
+
node_id: 'ISSUE-17',
|
|
1893
|
+
title: 'Synthetic change',
|
|
1894
|
+
body: `## Outcome\nDeliver a verified synthetic change to the local repository.\n\n## Scope\n- \`artifact.txt\`\n\n## Acceptance Criteria\n- [ ] Change is verified\n- [ ] Review and merge are recorded\n\n## Verification\nRun \`node --version\`.`,
|
|
1895
|
+
state: 'open',
|
|
1896
|
+
html_url: 'https://example.test/issues/17',
|
|
1897
|
+
type: { name: 'Task' },
|
|
1898
|
+
};
|
|
1899
|
+
let issuePoints;
|
|
1900
|
+
let parentPoints = 1;
|
|
1901
|
+
let parentIssueNumber = null;
|
|
1902
|
+
let parentChildren = [];
|
|
1903
|
+
let issuePriority;
|
|
1904
|
+
let projectStatus = null;
|
|
1905
|
+
let blockerOpen = false;
|
|
1906
|
+
let trackingParent = false;
|
|
1907
|
+
let failFirstBlockerLink = true;
|
|
1908
|
+
let failNextProjectSync = false;
|
|
1909
|
+
let checksPassed = true;
|
|
1910
|
+
let prDraft = true;
|
|
1911
|
+
let prState = 'open';
|
|
1912
|
+
let mergedAt = '';
|
|
1913
|
+
let headSha = '';
|
|
1914
|
+
let mergeSha = '';
|
|
1915
|
+
let stalePrReadbacks = 0;
|
|
1916
|
+
let review = null;
|
|
1917
|
+
let reviewDecision = 'REVIEW_REQUIRED';
|
|
1918
|
+
const calls = [];
|
|
1919
|
+
const createPayloads = [];
|
|
1920
|
+
const statuses = { Queued: 'todo', Active: 'active', Waiting: 'blocked', Shipped: 'done' };
|
|
1921
|
+
const statusName = (option) => Object.entries(statuses).find(([, id]) => id === option)?.[0];
|
|
1922
|
+
const pageInfo = { endCursor: null, hasNextPage: false };
|
|
1923
|
+
const issueFieldValues = (number = issueNumber) => [
|
|
1924
|
+
...((number === 11 ? parentPoints : issuePoints) === undefined
|
|
1925
|
+
? []
|
|
1926
|
+
: [
|
|
1927
|
+
{
|
|
1928
|
+
data_type: 'single_select',
|
|
1929
|
+
issue_field_id: 101,
|
|
1930
|
+
issue_field_name: 'Estimate',
|
|
1931
|
+
value: String(number === 11 ? parentPoints : issuePoints),
|
|
1932
|
+
},
|
|
1933
|
+
]),
|
|
1934
|
+
...(issuePriority === undefined
|
|
1935
|
+
? []
|
|
1936
|
+
: [{ data_type: 'single_select', issue_field_id: 102, issue_field_name: 'Urgency', value: issuePriority }]),
|
|
1937
|
+
];
|
|
1938
|
+
const pr = (stale = false) => ({
|
|
1939
|
+
number: prNumber,
|
|
1940
|
+
node_id: 'PR-23',
|
|
1941
|
+
title: 'Synthetic change',
|
|
1942
|
+
html_url: 'https://example.test/pulls/23',
|
|
1943
|
+
state: stale ? 'open' : prState,
|
|
1944
|
+
draft: prDraft,
|
|
1945
|
+
merged_at: !stale && prState === 'closed' ? mergedAt : null,
|
|
1946
|
+
merge_commit_sha: mergeSha || null,
|
|
1947
|
+
mergeable: true,
|
|
1948
|
+
mergeable_state: 'clean',
|
|
1949
|
+
user: { login: 'synthetic-author[bot]' },
|
|
1950
|
+
head: { sha: headSha, ref: 'issue/17' },
|
|
1951
|
+
base: { sha: baseSha, ref: 'main' },
|
|
1952
|
+
});
|
|
1953
|
+
const graphql = async (query, variables = {}) => {
|
|
1954
|
+
if (query.includes('DeliveryReviewDecision'))
|
|
1955
|
+
return { repository: { pullRequest: { headRefOid: headSha, reviewDecision } } };
|
|
1956
|
+
if (query.includes('ProjectDeliveryConfiguration'))
|
|
1957
|
+
return {
|
|
1958
|
+
organization: {
|
|
1959
|
+
projectV2: {
|
|
1960
|
+
id: 'PROJECT-1',
|
|
1961
|
+
number: 1,
|
|
1962
|
+
title: 'Delivery',
|
|
1963
|
+
viewerCanUpdate: true,
|
|
1964
|
+
fields: {
|
|
1965
|
+
nodes: [
|
|
1007
1966
|
{
|
|
1008
1967
|
__typename: 'ProjectV2SingleSelectField',
|
|
1009
1968
|
id: 'FIELD-FLOW',
|
|
@@ -1128,6 +2087,10 @@ test.each([
|
|
|
1128
2087
|
};
|
|
1129
2088
|
const rest = {
|
|
1130
2089
|
request: async (route, parameters) => {
|
|
2090
|
+
if (route === 'GET /repos/{owner}/{repo}/rules/branches/{branch}')
|
|
2091
|
+
return { data: [{ type: 'pull_request', parameters: { required_approving_review_count: 1 } }] };
|
|
2092
|
+
if (route === 'GET /repos/{owner}/{repo}/branches/{branch}/protection')
|
|
2093
|
+
throw Object.assign(new Error('Synthetic protected branch visibility unavailable'), { status: 403 });
|
|
1131
2094
|
if (route === 'GET /orgs/{org}/issue-fields')
|
|
1132
2095
|
return {
|
|
1133
2096
|
data: [
|
|
@@ -1347,7 +2310,7 @@ test.each([
|
|
|
1347
2310
|
headSha = git(row.path, 'rev-parse', 'HEAD');
|
|
1348
2311
|
const run = await verifyIssue({ issueNumber, repoRoot: row.path });
|
|
1349
2312
|
await assert.rejects(publishPr(context, { issueNumber, body: 'Closes #17' }), /Delivery Impact/);
|
|
1350
|
-
await assert.rejects(publishPr(context, { issueNumber }), /Git push requires the selected author
|
|
2313
|
+
await assert.rejects(publishPr(context, { issueNumber }), /Git push requires the selected author credential token/);
|
|
1351
2314
|
// The public push requires a real author App installation token. Bind a synthetic
|
|
1352
2315
|
// publication at that external boundary so the remaining public phases run offline.
|
|
1353
2316
|
git(row.path, 'push', '-q', remoteName, `${headSha}:refs/heads/issue/17`);
|
|
@@ -1372,7 +2335,9 @@ test.each([
|
|
|
1372
2335
|
status: 'pr-published',
|
|
1373
2336
|
});
|
|
1374
2337
|
assert.equal((await listPrs(context)).pullRequests[0]?.number, prNumber);
|
|
2338
|
+
assert.equal((await prInfo(context, { prNumber })).authorLogin, 'synthetic-author[bot]');
|
|
1375
2339
|
assert.equal((await prChecks(context, prNumber)).combinedStatus, 'success');
|
|
2340
|
+
assert.equal((await prChecks(context, prNumber)).reviewState.status, 'still-required');
|
|
1376
2341
|
await assert.rejects(checkoutPr(context, prNumber), /open in-repository branch/u);
|
|
1377
2342
|
assert.equal(git(remote, 'rev-parse', 'refs/heads/issue/17'), headSha);
|
|
1378
2343
|
await assert.rejects(mergePr(context, { issueNumber, prNumber }), /submitted independent review/);
|
|
@@ -1407,8 +2372,11 @@ test.each([
|
|
|
1407
2372
|
await assert.rejects(submitFormalReview(personalReviewer, { artifact, issueNumber, prNumber }), /reviewer GitHub App role/);
|
|
1408
2373
|
const reviewResult = await submitFormalReview(reviewer, { artifact, issueNumber, prNumber });
|
|
1409
2374
|
assert.equal(reviewResult.githubReviewId, 31);
|
|
2375
|
+
assert.equal(reviewResult.reviewState.status, 'still-required');
|
|
1410
2376
|
await publishPr(context, { issueNumber, draft: false });
|
|
1411
2377
|
assert.equal(prDraft, false);
|
|
2378
|
+
await assert.rejects(mergePr(context, { issueNumber, prNumber }), /submitted APPROVED review.*still requires/u);
|
|
2379
|
+
reviewDecision = 'APPROVED';
|
|
1412
2380
|
checksPassed = false;
|
|
1413
2381
|
await assert.rejects(mergePr(context, { issueNumber, prNumber }), /failed or pending checks/);
|
|
1414
2382
|
checksPassed = true;
|
|
@@ -1434,7 +2402,9 @@ test.each([
|
|
|
1434
2402
|
const result = JSON.parse(originalResult);
|
|
1435
2403
|
const { resultId: _resultId, ...resultContent } = result;
|
|
1436
2404
|
const wrongResult = { ...resultContent, mergeSha: 'a'.repeat(40) };
|
|
1437
|
-
writeFileSync(resultPath, JSON.stringify({ ...wrongResult, resultId: digestValue(wrongResult) }), {
|
|
2405
|
+
writeFileSync(resultPath, JSON.stringify({ ...wrongResult, resultId: digestValue(wrongResult) }), {
|
|
2406
|
+
mode: 0o600,
|
|
2407
|
+
});
|
|
1438
2408
|
await assert.rejects(finishIssue(context, { issueNumber, prNumber, strategy: 'merge' }), /exact prepared merge intent/u);
|
|
1439
2409
|
writeFileSync(resultPath, originalResult, { mode: 0o600 });
|
|
1440
2410
|
const reviewedHead = headSha;
|
|
@@ -1497,5 +2467,10 @@ test.each([
|
|
|
1497
2467
|
finally {
|
|
1498
2468
|
rmSync(root, { recursive: true, force: true });
|
|
1499
2469
|
}
|
|
1500
|
-
}
|
|
2470
|
+
}
|
|
2471
|
+
test.each([
|
|
2472
|
+
{ remote: 'origin', divergentOrigin: false },
|
|
2473
|
+
{ remote: 'upstream', divergentOrigin: false },
|
|
2474
|
+
{ remote: 'upstream', divergentOrigin: true },
|
|
2475
|
+
])('synthetic lifecycle finishes with remote $remote and divergent origin $divergentOrigin', syntheticLifecycle, 15_000);
|
|
1501
2476
|
//# sourceMappingURL=lifecycle.test.js.map
|