@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.
Files changed (39) hide show
  1. package/README.md +55 -11
  2. package/dist/cli.js +26 -5
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config/deliveryConfig.d.ts +20 -4
  5. package/dist/config/deliveryConfig.js +41 -15
  6. package/dist/config/deliveryConfig.js.map +1 -1
  7. package/dist/config/deliveryConfig.test.js +103 -2
  8. package/dist/config/deliveryConfig.test.js.map +1 -1
  9. package/dist/dispatch.js +14 -5
  10. package/dist/dispatch.js.map +1 -1
  11. package/dist/fixtures/discovery.d.ts +8 -0
  12. package/dist/github/client.d.ts +2 -1
  13. package/dist/github/client.js +50 -26
  14. package/dist/github/client.js.map +1 -1
  15. package/dist/github/commandIdentityPolicy.js +7 -2
  16. package/dist/github/commandIdentityPolicy.js.map +1 -1
  17. package/dist/lifecycle.test.js +1143 -168
  18. package/dist/lifecycle.test.js.map +1 -1
  19. package/dist/mcp/index.d.ts +1 -1
  20. package/dist/mcp/index.js +25 -8
  21. package/dist/mcp/index.js.map +1 -1
  22. package/dist/mcp/tools.d.ts +6 -0
  23. package/dist/mcp/tools.js +8 -0
  24. package/dist/mcp/tools.js.map +1 -1
  25. package/dist/pr.d.ts +30 -1
  26. package/dist/pr.js +139 -19
  27. package/dist/pr.js.map +1 -1
  28. package/dist/review.d.ts +7 -0
  29. package/dist/review.js +47 -0
  30. package/dist/review.js.map +1 -1
  31. package/dist/review.test.js +147 -6
  32. package/dist/review.test.js.map +1 -1
  33. package/dist/verification.d.ts +27 -1
  34. package/dist/verification.js +663 -32
  35. package/dist/verification.js.map +1 -1
  36. package/package.json +1 -1
  37. package/plugins/ai-delivery/.claude-plugin/plugin.json +1 -1
  38. package/plugins/ai-delivery/README.md +1 -1
  39. package/plugins/ai-delivery/skills/pr-handoff/SKILL.md +14 -9
@@ -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
- ...syntheticDiscoveryClients(),
32
- authSource: 'app',
33
- role: input.role,
34
- rest: new Octokit({
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
- appId: 'AUTHOR_APP_ID',
106
- installationId: 'AUTHOR_INSTALLATION_ID',
107
- privateKeyPath: 'AUTHOR_KEY_PATH',
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('scratch start derives safe names and reports the requested worktree', async () => {
845
- const { root, runtimeEntryPath } = await fixture();
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 first = (await executeTool('issue_start', { scratch: true, request: 'Investigate button layout' }, { repoRoot: root, runtimeEntryPath, identity: 'synthetic-author' }));
848
- assert.equal(first.mode, 'scratch');
849
- assert.equal(first.title, 'Investigate button layout');
850
- assert.equal(first.branch, 'scratch/investigate-button-layout');
851
- assert.ok(first.worktreePath.endsWith('/.worktrees/scratch-investigate-button-layout'));
852
- const overridden = (await executeTool('issue_start', { scratch: true, request: 'Work on parser', branch: 'scratch/custom/parser' }, { repoRoot: root, runtimeEntryPath, identity: 'synthetic-author' }));
853
- assert.equal(overridden.branch, 'scratch/custom/parser');
854
- assert.equal(overridden.worktreePath, join(dirname(first.worktreePath), 'scratch-scratch-custom-parser'));
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('MCP exposes one implementation surface for native lifecycle commands', () => {
861
- assert.deepEqual(AI_DELIVERY_MCP_TOOLS.map((tool) => tool.name), [
862
- 'issue_create',
863
- 'issue_start',
864
- 'issue_update',
865
- 'issue_info',
866
- 'issue_ready_check',
867
- 'issue_develop',
868
- 'issue_verify',
869
- 'issue_pr_create',
870
- 'issue_pr_info',
871
- 'issue_pr_review',
872
- 'issue_pr_merge',
873
- 'issue_finish',
874
- 'issue_worktree_create',
875
- ]);
876
- const validInputs = {
877
- issue_create: { title: 'Synthetic tracking parent' },
878
- issue_ready_check: { issueNumber: 17 },
879
- issue_develop: { issueNumber: 17 },
880
- issue_verify: { issueNumber: 17 },
881
- issue_pr_create: { issueNumber: 17, draft: true },
882
- issue_pr_review: { issueNumber: 17, prNumber: 23, artifact: '{}' },
883
- issue_pr_merge: { issueNumber: 17, prNumber: 23, strategy: 'merge' },
884
- issue_finish: { issueNumber: 17, prNumber: 23, strategy: 'merge' },
885
- };
886
- for (const [name, input] of Object.entries(validInputs)) {
887
- const tool = AI_DELIVERY_MCP_TOOLS.find((candidate) => candidate.name === name);
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('MCP request rejects unsupported legacy options before lifecycle dispatch', async () => {
894
- const server = createAiDeliveryMcpServer({ repoRoot: '/missing-synthetic-repository' });
895
- const client = new Client({ name: 'synthetic-test', version: '1.0.0' });
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 server.connect(serverTransport);
899
- await client.connect(clientTransport);
900
- const result = await client.callTool({
901
- name: 'issue_create',
902
- arguments: {
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
- assert.equal(result.isError, true);
912
- assert.match(JSON.stringify(result.content), /unsupportedLegacyOption/u);
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
- await client.close();
916
- await server.close();
1077
+ progress.mockRestore();
1078
+ rmSync(root, { recursive: true, force: true });
917
1079
  }
918
1080
  });
919
- test.each([
920
- { remote: 'origin', divergentOrigin: false },
921
- { remote: 'upstream', divergentOrigin: false },
922
- { remote: 'upstream', divergentOrigin: true },
923
- ])('synthetic lifecycle finishes with remote $remote and divergent origin $divergentOrigin', async (routing) => {
924
- const { root } = await fixture(routing);
925
- const remoteName = routing.remote;
926
- assert.equal(defaultBaseRef(root, remoteName), `refs/remotes/${remoteName}/main`);
927
- const remote = join(root, '.git', 'remote.git');
928
- const configuration = await loadDeliveryConfig(root);
929
- const config = configuration.config;
930
- const baseSha = git(root, 'rev-parse', 'main');
931
- const issueNumber = 17;
932
- const prNumber = 23;
933
- const issue = {
934
- id: 170,
935
- number: issueNumber,
936
- node_id: 'ISSUE-17',
937
- title: 'Synthetic change',
938
- 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\`.`,
939
- state: 'open',
940
- html_url: 'https://example.test/issues/17',
941
- type: { name: 'Task' },
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
- const graphql = async (query, variables = {}) => {
997
- if (query.includes('ProjectDeliveryConfiguration'))
998
- return {
999
- organization: {
1000
- projectV2: {
1001
- id: 'PROJECT-1',
1002
- number: 1,
1003
- title: 'Delivery',
1004
- viewerCanUpdate: true,
1005
- fields: {
1006
- nodes: [
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 GitHub App installation token/);
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) }), { mode: 0o600 });
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