@aviaratech/ai-delivery 0.3.0 → 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 +7 -3
- package/dist/cli.js +18 -1
- package/dist/cli.js.map +1 -1
- package/dist/dispatch.js +5 -1
- package/dist/dispatch.js.map +1 -1
- package/dist/lifecycle.test.js +747 -1
- 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/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/dist/lifecycle.test.js
CHANGED
|
@@ -245,7 +245,7 @@ export default {
|
|
|
245
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
|
|
246
246
|
? `, { id: 'second', dependsOn: ['check'], semanticInputKeys: ['source'],
|
|
247
247
|
resourceClass: 'source_only', commands: [{ label: 'retry', argv: [process.execPath, '-e',
|
|
248
|
-
${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);`)}] }] }`
|
|
249
249
|
: ''}],
|
|
250
250
|
risk: 'standard' };
|
|
251
251
|
},
|
|
@@ -918,6 +918,65 @@ test('MCP request cancellation stops verification and leaves no stage receipt',
|
|
|
918
918
|
rmSync(root, { recursive: true, force: true });
|
|
919
919
|
}
|
|
920
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
|
+
});
|
|
921
980
|
test('the stage output bound covers both streams and publishes no checkpoint on overflow', async () => {
|
|
922
981
|
const { root } = await fixture({
|
|
923
982
|
firstStageScript: `process.stdout.write(Buffer.alloc(4*1024*1024));process.stderr.write(Buffer.alloc(4*1024*1024+1));`,
|
|
@@ -939,6 +998,693 @@ test('the stage output bound covers both streams and publishes no checkpoint on
|
|
|
939
998
|
rmSync(root, { recursive: true, force: true });
|
|
940
999
|
}
|
|
941
1000
|
});
|
|
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
|
+
});
|
|
1005
|
+
try {
|
|
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);
|
|
1021
|
+
}
|
|
1022
|
+
finally {
|
|
1023
|
+
rmSync(root, { recursive: true, force: true });
|
|
1024
|
+
}
|
|
1025
|
+
});
|
|
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 });
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
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');
|
|
1059
|
+
try {
|
|
1060
|
+
const row = await prepareIssueWorktree({
|
|
1061
|
+
baseRef: 'main',
|
|
1062
|
+
identity: 'synthetic-author',
|
|
1063
|
+
issueNumber: 17,
|
|
1064
|
+
repoRoot: root,
|
|
1065
|
+
});
|
|
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);
|
|
1075
|
+
}
|
|
1076
|
+
finally {
|
|
1077
|
+
progress.mockRestore();
|
|
1078
|
+
rmSync(root, { recursive: true, force: true });
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
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);",
|
|
1105
|
+
});
|
|
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
|
+
});
|
|
942
1688
|
test('public PR dry run retains a configured host author and App reviewer', async () => {
|
|
943
1689
|
const { root } = await fixture({ personalAuthor: true });
|
|
944
1690
|
try {
|