@aviaratech/ai-delivery 0.3.15 → 0.3.16
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 +19 -0
- package/dist/dispatch.d.ts +1 -1
- package/dist/dispatch.js +6 -6
- package/dist/dispatch.js.map +1 -1
- package/dist/issue.d.ts +1 -1
- package/dist/issue.js +170 -6
- package/dist/issue.js.map +1 -1
- package/dist/lifecycle.test.js +667 -16
- package/dist/lifecycle.test.js.map +1 -1
- package/dist/pr.d.ts +13 -1
- package/dist/pr.js +90 -38
- package/dist/pr.js.map +1 -1
- package/dist/services/worktreeRegistry.d.ts +272 -1
- package/dist/services/worktreeRegistry.js +304 -5
- package/dist/services/worktreeRegistry.js.map +1 -1
- package/dist/verification.d.ts +5 -0
- package/dist/verification.js +8 -0
- package/dist/verification.js.map +1 -1
- package/dist/worktreeTransition.d.ts +38 -0
- package/dist/worktreeTransition.js +38 -18
- package/dist/worktreeTransition.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
|
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
|
|
2
2
|
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { syncBuiltinESMExports } from 'node:module';
|
|
5
|
-
import { cpSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, readdirSync, realpathSync, rmSync, symlinkSync, unlinkSync, utimesSync, writeFileSync, } from 'node:fs';
|
|
5
|
+
import { cpSync, chmodSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, readdirSync, realpathSync, rmSync, symlinkSync, unlinkSync, utimesSync, writeFileSync, } from 'node:fs';
|
|
6
6
|
import { tmpdir } from 'node:os';
|
|
7
7
|
import { createServer } from 'node:net';
|
|
8
8
|
import { dirname, join } from 'node:path';
|
|
@@ -3224,11 +3224,16 @@ test('MCP request rejects unsupported legacy options before lifecycle dispatch',
|
|
|
3224
3224
|
}
|
|
3225
3225
|
});
|
|
3226
3226
|
async function syntheticLifecycle(routing) {
|
|
3227
|
-
const created = await fixture({
|
|
3227
|
+
const created = await fixture({
|
|
3228
|
+
...routing,
|
|
3229
|
+
personalAuthor: routing.committedDescendantContinuation === true,
|
|
3230
|
+
omitRuntimeAdmission: routing.producerOnboarding === true,
|
|
3231
|
+
});
|
|
3228
3232
|
const root = created.root;
|
|
3229
3233
|
let runtimeEntryPath = created.runtimeEntryPath;
|
|
3230
3234
|
const originalPath = process.env.PATH;
|
|
3231
3235
|
let restoreTransport;
|
|
3236
|
+
let restoreCheckpointWrites;
|
|
3232
3237
|
const remoteName = routing.remote;
|
|
3233
3238
|
assert.equal(defaultBaseRef(root, remoteName), `refs/remotes/${remoteName}/main`);
|
|
3234
3239
|
const remote = join(root, '.git', 'remote.git');
|
|
@@ -3270,6 +3275,11 @@ async function syntheticLifecycle(routing) {
|
|
|
3270
3275
|
let review = null;
|
|
3271
3276
|
let reviewDecision = 'REVIEW_REQUIRED';
|
|
3272
3277
|
const calls = [];
|
|
3278
|
+
const historicalActor = routing.committedDescendantContinuation ? 'host-user' : 'synthetic-author[bot]';
|
|
3279
|
+
let nativePrAuthor = historicalActor;
|
|
3280
|
+
let nativeClosingIssue = issueNumber;
|
|
3281
|
+
let nativePrRepository;
|
|
3282
|
+
const continuationComments = [];
|
|
3273
3283
|
const createPayloads = [];
|
|
3274
3284
|
const statuses = { Queued: 'STATUS-0', Active: 'STATUS-1', Waiting: 'STATUS-2', Shipped: 'STATUS-3' };
|
|
3275
3285
|
const statusName = (option) => Object.entries(statuses).find(([, id]) => id === option)?.[0];
|
|
@@ -3297,14 +3307,26 @@ async function syntheticLifecycle(routing) {
|
|
|
3297
3307
|
state: stale ? 'open' : prState,
|
|
3298
3308
|
draft: prDraft,
|
|
3299
3309
|
merged_at: !stale && prState === 'closed' ? mergedAt : null,
|
|
3310
|
+
merged: !stale && prState === 'closed',
|
|
3300
3311
|
merge_commit_sha: mergeSha || null,
|
|
3301
3312
|
mergeable: true,
|
|
3302
3313
|
mergeable_state: 'clean',
|
|
3303
|
-
user: { login: '
|
|
3304
|
-
head: { sha: headSha, ref: 'issue/17' },
|
|
3305
|
-
base: { sha: baseSha, ref: 'main' },
|
|
3314
|
+
user: { login: nativePrAuthor, id: 37, type: routing.committedDescendantContinuation ? 'User' : 'Bot' },
|
|
3315
|
+
head: { sha: headSha, ref: 'issue/17', ...(nativePrRepository ? { repo: { full_name: nativePrRepository } } : {}) },
|
|
3316
|
+
base: { sha: baseSha, ref: 'main', ...(nativePrRepository ? { repo: { full_name: nativePrRepository } } : {}) },
|
|
3306
3317
|
});
|
|
3307
3318
|
const graphql = async (query, variables = {}) => {
|
|
3319
|
+
if (query.includes('WorktreeTransitionLineage'))
|
|
3320
|
+
return {
|
|
3321
|
+
repository: {
|
|
3322
|
+
pullRequest: {
|
|
3323
|
+
closingIssuesReferences: {
|
|
3324
|
+
nodes: [{ number: nativeClosingIssue, repository: { nameWithOwner: nativePrRepository } }],
|
|
3325
|
+
pageInfo,
|
|
3326
|
+
},
|
|
3327
|
+
},
|
|
3328
|
+
},
|
|
3329
|
+
};
|
|
3308
3330
|
if (query.includes('DeliveryReviewDecision'))
|
|
3309
3331
|
return { repository: { pullRequest: { headRefOid: headSha, reviewDecision } } };
|
|
3310
3332
|
if (query.includes('ProjectDeliveryConfiguration'))
|
|
@@ -3503,6 +3525,10 @@ async function syntheticLifecycle(routing) {
|
|
|
3503
3525
|
throw new Error(`Unexpected REST route: ${route}`);
|
|
3504
3526
|
},
|
|
3505
3527
|
issues: {
|
|
3528
|
+
listComments: async () => ({ data: continuationComments }),
|
|
3529
|
+
getComment: async (input) => ({
|
|
3530
|
+
data: continuationComments.find((comment) => comment.id === input.comment_id),
|
|
3531
|
+
}),
|
|
3506
3532
|
create: async (input) => {
|
|
3507
3533
|
calls.push('issue:create');
|
|
3508
3534
|
createPayloads.push(input);
|
|
@@ -3600,20 +3626,21 @@ async function syntheticLifecycle(routing) {
|
|
|
3600
3626
|
getCombinedStatusForRef: async () => ({ data: { statuses: [], state: 'success' } }),
|
|
3601
3627
|
},
|
|
3602
3628
|
};
|
|
3603
|
-
let authenticatedAuthor =
|
|
3629
|
+
let authenticatedAuthor = historicalActor;
|
|
3630
|
+
let authenticatedCredential = routing.committedDescendantContinuation ? 'user:37' : 'app:201:installation:301';
|
|
3604
3631
|
const context = {
|
|
3605
3632
|
root,
|
|
3606
3633
|
repo: { owner: 'example', repo: 'widget' },
|
|
3607
3634
|
config,
|
|
3608
3635
|
configuration,
|
|
3609
3636
|
clients: {
|
|
3610
|
-
authSource: 'app',
|
|
3637
|
+
authSource: routing.committedDescendantContinuation ? 'personal' : 'app',
|
|
3611
3638
|
role: 'author',
|
|
3612
3639
|
graphql,
|
|
3613
3640
|
rest,
|
|
3614
3641
|
authenticatedAuthor: async () => ({
|
|
3615
3642
|
actorLogin: authenticatedAuthor,
|
|
3616
|
-
credentialIdentity:
|
|
3643
|
+
credentialIdentity: authenticatedCredential,
|
|
3617
3644
|
}),
|
|
3618
3645
|
},
|
|
3619
3646
|
};
|
|
@@ -3624,6 +3651,7 @@ async function syntheticLifecycle(routing) {
|
|
|
3624
3651
|
...(input.role === 'reviewer'
|
|
3625
3652
|
? {
|
|
3626
3653
|
role: 'reviewer',
|
|
3654
|
+
authSource: 'app',
|
|
3627
3655
|
appActorLogin: async () => 'synthetic-reviewer[bot]',
|
|
3628
3656
|
}
|
|
3629
3657
|
: {}),
|
|
@@ -3636,7 +3664,12 @@ async function syntheticLifecycle(routing) {
|
|
|
3636
3664
|
restoreDispatchClient = () => dispatchClient.mockRestore();
|
|
3637
3665
|
return dispatchClient;
|
|
3638
3666
|
};
|
|
3639
|
-
const execution = {
|
|
3667
|
+
const execution = {
|
|
3668
|
+
repoRoot: root,
|
|
3669
|
+
runtimeEntryPath,
|
|
3670
|
+
identity: config.roles.author.identity,
|
|
3671
|
+
...(routing.appAuthorContinuation ? { personalAuth: true } : {}),
|
|
3672
|
+
};
|
|
3640
3673
|
try {
|
|
3641
3674
|
if (routing.legacyNativeMetadata) {
|
|
3642
3675
|
enableDispatch();
|
|
@@ -3801,6 +3834,8 @@ async function syntheticLifecycle(routing) {
|
|
|
3801
3834
|
await assert.rejects(developIssue(context, issueNumber), /closed/u);
|
|
3802
3835
|
issue.state = 'open';
|
|
3803
3836
|
assert.equal((await readyCheck(context, issueNumber)).ready, true);
|
|
3837
|
+
if (routing.committedDescendantContinuation)
|
|
3838
|
+
await prepareIssueWorktree({ identity: 'synthetic-preparer', issueNumber, repoRoot: root });
|
|
3804
3839
|
const started = await startTrackedIssue(context, { issueNumber });
|
|
3805
3840
|
assert.equal(started.mode, 'existing-issue');
|
|
3806
3841
|
assert.equal(started.issueNumber, issueNumber);
|
|
@@ -3935,14 +3970,14 @@ exec "${realGit}" "$@"
|
|
|
3935
3970
|
});
|
|
3936
3971
|
}
|
|
3937
3972
|
assert.equal((await listPrs(context)).pullRequests[0]?.number, prNumber);
|
|
3938
|
-
assert.equal((await prInfo(context, { prNumber })).authorLogin,
|
|
3973
|
+
assert.equal((await prInfo(context, { prNumber })).authorLogin, historicalActor);
|
|
3939
3974
|
assert.equal((await prChecks(context, prNumber)).combinedStatus, 'success');
|
|
3940
3975
|
assert.equal((await prChecks(context, prNumber)).reviewState.status, 'still-required');
|
|
3941
3976
|
await assert.rejects(checkoutPr(context, prNumber), /open in-repository branch/u);
|
|
3942
3977
|
assert.equal(git(remote, 'rev-parse', 'refs/heads/issue/17'), headSha);
|
|
3943
3978
|
await assert.rejects(mergePr(context, { issueNumber, prNumber }), /submitted independent review/);
|
|
3944
3979
|
const artifactContent = {
|
|
3945
|
-
authorIdentity:
|
|
3980
|
+
authorIdentity: config.roles.author.identity,
|
|
3946
3981
|
checks: ['verified exact diff'],
|
|
3947
3982
|
diffScopeHash: digestValue(run.classification.changedPaths),
|
|
3948
3983
|
elapsedMs: 100,
|
|
@@ -3963,7 +3998,12 @@ exec "${realGit}" "$@"
|
|
|
3963
3998
|
const artifact = JSON.stringify({ ...artifactContent, artifactId: digestValue(artifactContent) });
|
|
3964
3999
|
const reviewer = {
|
|
3965
4000
|
...context,
|
|
3966
|
-
clients: {
|
|
4001
|
+
clients: {
|
|
4002
|
+
...context.clients,
|
|
4003
|
+
authSource: 'app',
|
|
4004
|
+
role: 'reviewer',
|
|
4005
|
+
appActorLogin: async () => 'synthetic-reviewer',
|
|
4006
|
+
},
|
|
3967
4007
|
};
|
|
3968
4008
|
const personalReviewer = {
|
|
3969
4009
|
...reviewer,
|
|
@@ -3989,11 +4029,11 @@ exec "${realGit}" "$@"
|
|
|
3989
4029
|
const attemptPath = join(gitCommonDir(root), 'ai-delivery', 'merge-attempts', String(issueNumber), `${headSha}.json`);
|
|
3990
4030
|
const attempt = JSON.parse(readFileSync(attemptPath, 'utf8'));
|
|
3991
4031
|
assert.equal(attempt.operation, 'updateRefs');
|
|
3992
|
-
assert.equal(attempt.actor,
|
|
3993
|
-
assert.equal(attempt.actorLogin,
|
|
4032
|
+
assert.equal(attempt.actor, config.roles.author.identity);
|
|
4033
|
+
assert.equal(attempt.actorLogin, historicalActor);
|
|
3994
4034
|
authenticatedAuthor = 'different-author[bot]';
|
|
3995
4035
|
await assert.rejects(finishIssue(context, { issueNumber, prNumber, strategy: 'merge' }), /Merge attempt disagrees with its exact source, operation or author/u);
|
|
3996
|
-
authenticatedAuthor =
|
|
4036
|
+
authenticatedAuthor = historicalActor;
|
|
3997
4037
|
assert.equal(calls.filter((call) => call === 'git:createCommit').length, 1);
|
|
3998
4038
|
await assert.rejects(finishIssue(context, { issueNumber, prNumber, strategy: 'merge' }), /readback is unresolved/u);
|
|
3999
4039
|
assert.equal(calls.filter((call) => call === 'git:createCommit').length, 1);
|
|
@@ -4012,7 +4052,7 @@ exec "${realGit}" "$@"
|
|
|
4012
4052
|
await assert.rejects(finishIssue(context, { issueNumber, prNumber, strategy: 'merge' }), /exact prepared merge intent/u);
|
|
4013
4053
|
headSha = reviewedHead;
|
|
4014
4054
|
assert.equal(calls.filter((call) => call === 'git:createCommit').length, 1);
|
|
4015
|
-
if (routing.mergedContinuation) {
|
|
4055
|
+
if (routing.mergedContinuation || routing.committedDescendantContinuation) {
|
|
4016
4056
|
enableDispatch();
|
|
4017
4057
|
const terminal = await executeTool('issue_pr_merge', { issueNumber, prNumber, strategy: 'merge' }, execution);
|
|
4018
4058
|
assert.equal(terminal.mergeSha, mergeSha);
|
|
@@ -4046,6 +4086,593 @@ exec "${realGit}" "$@"
|
|
|
4046
4086
|
saveReceipts(join(gitCommonDir(root), 'ai-delivery', name));
|
|
4047
4087
|
const ownerPath = join(gitCommonDir(root), 'ai-delivery/worktree-owners');
|
|
4048
4088
|
const ownerHash = directoryHash(ownerPath);
|
|
4089
|
+
if (routing.committedDescendantContinuation) {
|
|
4090
|
+
const ownerFiles = readdirSync(ownerPath).map((name) => ({
|
|
4091
|
+
path: join(ownerPath, name),
|
|
4092
|
+
bytes: readFileSync(join(ownerPath, name)),
|
|
4093
|
+
}));
|
|
4094
|
+
writeFileSync(join(row.path, 'change.txt'), 'already committed continuation\n');
|
|
4095
|
+
git(row.path, 'add', 'change.txt');
|
|
4096
|
+
git(row.path, 'commit', '-qm', 'commit the unfinished continuation');
|
|
4097
|
+
const descendant = git(row.path, 'rev-parse', 'HEAD');
|
|
4098
|
+
const descendantTree = git(row.path, 'rev-parse', 'HEAD^{tree}');
|
|
4099
|
+
nativePrRepository = 'example/widget';
|
|
4100
|
+
const refuse = async (pattern) => {
|
|
4101
|
+
await assert.rejects(executeTool('issue_develop', { issueNumber }, execution), pattern);
|
|
4102
|
+
const currentRegistry = JSON.parse(readFileSync(registryPath, 'utf8'));
|
|
4103
|
+
assert.deepEqual(currentRegistry.worktrees.filter((entry) => entry.issueNumber === issueNumber), [previous]);
|
|
4104
|
+
assert.equal(git(row.path, 'rev-parse', 'HEAD'), descendant);
|
|
4105
|
+
};
|
|
4106
|
+
if (routing.appAuthorContinuation) {
|
|
4107
|
+
const policyPath = join(root, 'policy.mjs');
|
|
4108
|
+
writeFileSync(policyPath, readFileSync(policyPath, 'utf8').replace(/export const deliverySettings = .*;/u, `export const deliverySettings = ${JSON.stringify({
|
|
4109
|
+
commandPolicy: config.commandPolicy,
|
|
4110
|
+
roles: {
|
|
4111
|
+
...config.roles,
|
|
4112
|
+
author: {
|
|
4113
|
+
authSource: 'app',
|
|
4114
|
+
credentialEnv: {
|
|
4115
|
+
appId: 'AUTHOR_APP_ID',
|
|
4116
|
+
installationId: 'AUTHOR_INSTALLATION_ID',
|
|
4117
|
+
privateKeyPath: 'AUTHOR_KEY_PATH',
|
|
4118
|
+
},
|
|
4119
|
+
identity: config.roles.author.identity,
|
|
4120
|
+
},
|
|
4121
|
+
},
|
|
4122
|
+
})};`));
|
|
4123
|
+
const admissionPath = join(gitCommonDir(root), 'ai-delivery/runtime-admission.json');
|
|
4124
|
+
const { admissionId: _id, ...admission } = JSON.parse(readFileSync(admissionPath, 'utf8'));
|
|
4125
|
+
admission.configDigest = (await loadDeliveryConfig(root, { personalAuth: true })).configDigest;
|
|
4126
|
+
execution.identity = 'personal';
|
|
4127
|
+
writeFileSync(admissionPath, JSON.stringify({ ...admission, admissionId: digestValue(admission) }));
|
|
4128
|
+
await refuse(/requires configured personal author/u);
|
|
4129
|
+
for (const savedReceipt of [...oldReceipts, ...ownerFiles])
|
|
4130
|
+
assert.deepEqual(readFileSync(savedReceipt.path), savedReceipt.bytes);
|
|
4131
|
+
return;
|
|
4132
|
+
}
|
|
4133
|
+
authenticatedAuthor = 'different-user';
|
|
4134
|
+
await refuse(/authenticated host author/u);
|
|
4135
|
+
authenticatedAuthor = historicalActor;
|
|
4136
|
+
authenticatedCredential = 'user:99';
|
|
4137
|
+
await refuse(/authenticated host author/u);
|
|
4138
|
+
authenticatedCredential = 'unattested';
|
|
4139
|
+
await refuse(/user identity.*unavailable/u);
|
|
4140
|
+
authenticatedCredential = 'user:37';
|
|
4141
|
+
nativePrAuthor = 'different-user';
|
|
4142
|
+
await refuse(/authenticated host author/u);
|
|
4143
|
+
nativePrAuthor = historicalActor;
|
|
4144
|
+
nativeClosingIssue = 18;
|
|
4145
|
+
await refuse(/Native PR lineage/u);
|
|
4146
|
+
nativeClosingIssue = issueNumber;
|
|
4147
|
+
nativePrRepository = 'other/widget';
|
|
4148
|
+
await refuse(/Native PR lineage/u);
|
|
4149
|
+
nativePrRepository = 'example/widget';
|
|
4150
|
+
for (const directory of custody) {
|
|
4151
|
+
const file = oldReceipts.find((file) => file.path.includes(`/${directory}/`) && file.path.endsWith(`/${headSha}.json`));
|
|
4152
|
+
assert.ok(file, `historical ${directory} fixture`);
|
|
4153
|
+
unlinkSync(file.path);
|
|
4154
|
+
try {
|
|
4155
|
+
await refuse(/(?:ENOENT|missing|lacks|manifest|publication|terminal|review|intent|custody)/u);
|
|
4156
|
+
}
|
|
4157
|
+
finally {
|
|
4158
|
+
writeFileSync(file.path, file.bytes, { mode: 0o600 });
|
|
4159
|
+
}
|
|
4160
|
+
const forged = JSON.parse(file.bytes.toString());
|
|
4161
|
+
const seal = Object.keys(forged).find((key) => /^(?:manifest|publication|receipt|intent|attempt|result|merge)Id$/u.test(key));
|
|
4162
|
+
assert.ok(seal);
|
|
4163
|
+
forged[seal] = `sha256:${'f'.repeat(64)}`;
|
|
4164
|
+
writeFileSync(file.path, JSON.stringify(forged));
|
|
4165
|
+
try {
|
|
4166
|
+
await refuse(/(?:identity|invalid|disagrees|not bound|corrupt)/iu);
|
|
4167
|
+
}
|
|
4168
|
+
finally {
|
|
4169
|
+
writeFileSync(file.path, file.bytes);
|
|
4170
|
+
}
|
|
4171
|
+
}
|
|
4172
|
+
const witness = ownerFiles.find((file) => JSON.parse(file.bytes.toString()).identity === 'synthetic-preparer');
|
|
4173
|
+
assert.ok(witness);
|
|
4174
|
+
unlinkSync(witness.path);
|
|
4175
|
+
try {
|
|
4176
|
+
await refuse(/ownership witness/u);
|
|
4177
|
+
}
|
|
4178
|
+
finally {
|
|
4179
|
+
writeFileSync(witness.path, witness.bytes, { mode: 0o600 });
|
|
4180
|
+
}
|
|
4181
|
+
writeFileSync(join(row.path, 'change.txt'), 'dirty continuation\n');
|
|
4182
|
+
await refuse(/clean|uncommitted|dirty/iu);
|
|
4183
|
+
git(row.path, 'restore', 'change.txt');
|
|
4184
|
+
const unrelatedHead = git(row.path, 'commit-tree', descendantTree, '-m', 'unrelated root');
|
|
4185
|
+
git(row.path, 'reset', '--hard', unrelatedHead);
|
|
4186
|
+
try {
|
|
4187
|
+
await assert.rejects(executeTool('issue_develop', { issueNumber }, execution), /strict committed descendant/u);
|
|
4188
|
+
}
|
|
4189
|
+
finally {
|
|
4190
|
+
git(row.path, 'reset', '--hard', descendant);
|
|
4191
|
+
}
|
|
4192
|
+
const registryData = JSON.parse(registryBytes.toString());
|
|
4193
|
+
for (const conflicting of [previous, { ...previous, type: 'standalone', issueNumber: 18 }]) {
|
|
4194
|
+
writeFileSync(registryPath, JSON.stringify({ worktrees: [...registryData.worktrees, conflicting] }));
|
|
4195
|
+
try {
|
|
4196
|
+
await assert.rejects(executeTool('issue_develop', { issueNumber }, execution), /duplicate|overlaps/u);
|
|
4197
|
+
}
|
|
4198
|
+
finally {
|
|
4199
|
+
writeFileSync(registryPath, registryBytes);
|
|
4200
|
+
}
|
|
4201
|
+
}
|
|
4202
|
+
const writerPath = join(gitCommonDir(root), 'ai-delivery/writers@1', `${digestValue(row.path).slice(7)}.json`);
|
|
4203
|
+
atomicJson.writePrivateJsonFileAtomically(writerPath, { unknown: 'writer-custody' });
|
|
4204
|
+
const writerBytes = readFileSync(writerPath);
|
|
4205
|
+
try {
|
|
4206
|
+
await refuse(/writer absence/u);
|
|
4207
|
+
assert.deepEqual(readFileSync(writerPath), writerBytes);
|
|
4208
|
+
}
|
|
4209
|
+
finally {
|
|
4210
|
+
unlinkSync(writerPath);
|
|
4211
|
+
}
|
|
4212
|
+
const legacyIntentPath = join(ownerPath, 'issue-17.transition.json');
|
|
4213
|
+
atomicJson.writePrivateJsonFileAtomically(legacyIntentPath, { unknown: 'legacy-transition' });
|
|
4214
|
+
try {
|
|
4215
|
+
await refuse(/transition.*incomplete/u);
|
|
4216
|
+
}
|
|
4217
|
+
finally {
|
|
4218
|
+
unlinkSync(legacyIntentPath);
|
|
4219
|
+
}
|
|
4220
|
+
let crashIndex = 0;
|
|
4221
|
+
const publishedCheckpoints = [];
|
|
4222
|
+
const withDirectorySyncProbe = async (directory, failure, operation) => {
|
|
4223
|
+
const fs = (await import('node:fs')).default;
|
|
4224
|
+
const originalOpen = fs.openSync;
|
|
4225
|
+
const originalSync = fs.fsyncSync;
|
|
4226
|
+
const originalClose = fs.closeSync;
|
|
4227
|
+
const directories = new Set();
|
|
4228
|
+
const probe = { attempts: 0, fail: true };
|
|
4229
|
+
const opened = vi.spyOn(fs, 'openSync').mockImplementation((path, flags, mode) => {
|
|
4230
|
+
const descriptor = originalOpen(path, flags, mode);
|
|
4231
|
+
if (path === directory)
|
|
4232
|
+
directories.add(descriptor);
|
|
4233
|
+
return descriptor;
|
|
4234
|
+
});
|
|
4235
|
+
const synced = vi.spyOn(fs, 'fsyncSync').mockImplementation((descriptor) => {
|
|
4236
|
+
if (directories.has(descriptor)) {
|
|
4237
|
+
probe.attempts += 1;
|
|
4238
|
+
if (probe.fail)
|
|
4239
|
+
throw new Error(failure);
|
|
4240
|
+
}
|
|
4241
|
+
originalSync(descriptor);
|
|
4242
|
+
});
|
|
4243
|
+
const closed = vi.spyOn(fs, 'closeSync').mockImplementation((descriptor) => {
|
|
4244
|
+
directories.delete(descriptor);
|
|
4245
|
+
originalClose(descriptor);
|
|
4246
|
+
});
|
|
4247
|
+
syncBuiltinESMExports();
|
|
4248
|
+
try {
|
|
4249
|
+
await operation(probe);
|
|
4250
|
+
}
|
|
4251
|
+
finally {
|
|
4252
|
+
closed.mockRestore();
|
|
4253
|
+
synced.mockRestore();
|
|
4254
|
+
opened.mockRestore();
|
|
4255
|
+
syncBuiltinESMExports();
|
|
4256
|
+
}
|
|
4257
|
+
};
|
|
4258
|
+
const crashAfterDirectoryCreation = (directory, marker) => {
|
|
4259
|
+
assert.equal(existsSync(directory), false);
|
|
4260
|
+
const directoryChild = spawnSync(process.execPath, [
|
|
4261
|
+
'--input-type=module',
|
|
4262
|
+
'-e',
|
|
4263
|
+
`
|
|
4264
|
+
import fs from 'node:fs';
|
|
4265
|
+
import { syncBuiltinESMExports } from 'node:module';
|
|
4266
|
+
const mkdir = fs.mkdirSync;
|
|
4267
|
+
fs.mkdirSync = (path, options) => {
|
|
4268
|
+
const result = mkdir(path, options);
|
|
4269
|
+
if (path === ${JSON.stringify(directory)}) {
|
|
4270
|
+
const stat = fs.lstatSync(path);
|
|
4271
|
+
fs.writeFileSync(${JSON.stringify(marker)}, JSON.stringify({
|
|
4272
|
+
path, pid: process.pid, dev: stat.dev, ino: stat.ino, uid: stat.uid, mode: stat.mode,
|
|
4273
|
+
}), { mode: 0o600 });
|
|
4274
|
+
process.kill(process.pid, 'SIGKILL');
|
|
4275
|
+
}
|
|
4276
|
+
return result;
|
|
4277
|
+
};
|
|
4278
|
+
syncBuiltinESMExports();
|
|
4279
|
+
const { ensurePrivateDirectoryDurably } = await import(${JSON.stringify(new URL('./utils/atomicJson.js', import.meta.url).href)});
|
|
4280
|
+
ensurePrivateDirectoryDurably(${JSON.stringify(directory)});
|
|
4281
|
+
`,
|
|
4282
|
+
], { encoding: 'utf8', maxBuffer: 1024 * 1024 });
|
|
4283
|
+
assert.equal(directoryChild.signal, 'SIGKILL', directoryChild.stderr);
|
|
4284
|
+
const directoryProof = JSON.parse(readFileSync(marker, 'utf8'));
|
|
4285
|
+
assert.equal(directoryProof.path, directory);
|
|
4286
|
+
assert.equal(directoryProof.pid, directoryChild.pid);
|
|
4287
|
+
assert.throws(() => process.kill(directoryProof.pid, 0), /ESRCH/u);
|
|
4288
|
+
const directoryStat = lstatSync(directory);
|
|
4289
|
+
assert.ok(directoryStat.isDirectory() && !directoryStat.isSymbolicLink());
|
|
4290
|
+
assert.equal(directoryStat.dev, directoryProof.dev);
|
|
4291
|
+
assert.equal(directoryStat.ino, directoryProof.ino);
|
|
4292
|
+
assert.equal(directoryStat.uid, directoryProof.uid);
|
|
4293
|
+
assert.equal(directoryStat.mode & 0o077, 0);
|
|
4294
|
+
console.info('continuation directory crash evidence', JSON.stringify(directoryProof));
|
|
4295
|
+
};
|
|
4296
|
+
if (routing.crashCheckpoints) {
|
|
4297
|
+
const continuationRoot = join(gitCommonDir(root), 'ai-delivery/continuations');
|
|
4298
|
+
assert.equal(existsSync(continuationRoot), false);
|
|
4299
|
+
crashAfterDirectoryCreation(continuationRoot, join(gitCommonDir(root), 'continuation-root-crash.json'));
|
|
4300
|
+
const kinds = ['plan', 'intent', 'witness', 'completion'];
|
|
4301
|
+
const originalWrite = atomicJson.writePrivateJsonFileAtomically;
|
|
4302
|
+
const crashing = vi.spyOn(atomicJson, 'writePrivateJsonFileAtomically').mockImplementation((path, value) => {
|
|
4303
|
+
const kind = path.includes('/worktree-owners/') ? 'witness' : path.split('.').at(-2);
|
|
4304
|
+
if (kind !== kinds[Math.floor(crashIndex / 2)])
|
|
4305
|
+
return originalWrite(path, value);
|
|
4306
|
+
const after = crashIndex % 2 === 1;
|
|
4307
|
+
const marker = join(gitCommonDir(root), `continuation-crash-${String(crashIndex)}.json`);
|
|
4308
|
+
const child = spawnSync(process.execPath, [
|
|
4309
|
+
'--input-type=module',
|
|
4310
|
+
'-e',
|
|
4311
|
+
`
|
|
4312
|
+
import fs from 'node:fs';
|
|
4313
|
+
import { createHash } from 'node:crypto';
|
|
4314
|
+
import { syncBuiltinESMExports } from 'node:module';
|
|
4315
|
+
const path = ${JSON.stringify(path)};
|
|
4316
|
+
for (const method of ['renameSync', 'linkSync']) {
|
|
4317
|
+
const publish = fs[method];
|
|
4318
|
+
fs[method] = (temporary, destination) => {
|
|
4319
|
+
if (destination !== path) return publish(temporary, destination);
|
|
4320
|
+
const stat = fs.lstatSync(temporary);
|
|
4321
|
+
const digest = createHash('sha256').update(fs.readFileSync(temporary)).digest('hex');
|
|
4322
|
+
if (${String(after)}) publish(temporary, destination);
|
|
4323
|
+
fs.writeFileSync(${JSON.stringify(marker)}, JSON.stringify({
|
|
4324
|
+
path, temporary, pid: process.pid, dev: stat.dev, ino: stat.ino,
|
|
4325
|
+
uid: stat.uid, mode: stat.mode, digest,
|
|
4326
|
+
}), { mode: 0o600 });
|
|
4327
|
+
process.kill(process.pid, 'SIGKILL');
|
|
4328
|
+
throw new Error('SIGKILL did not terminate checkpoint publisher');
|
|
4329
|
+
};
|
|
4330
|
+
}
|
|
4331
|
+
syncBuiltinESMExports();
|
|
4332
|
+
const { writeCommittedContinuationCheckpoint } = await import(${JSON.stringify(new URL('./services/worktreeRegistry.js', import.meta.url).href)});
|
|
4333
|
+
writeCommittedContinuationCheckpoint(path, ${JSON.stringify(value)});
|
|
4334
|
+
`,
|
|
4335
|
+
], { encoding: 'utf8', maxBuffer: 1024 * 1024 });
|
|
4336
|
+
assert.equal(child.signal, 'SIGKILL', child.stderr);
|
|
4337
|
+
const proof = JSON.parse(readFileSync(marker, 'utf8'));
|
|
4338
|
+
assert.equal(proof.pid, child.pid);
|
|
4339
|
+
assert.equal(proof.path, path);
|
|
4340
|
+
assert.throws(() => process.kill(proof.pid, 0), /ESRCH/u);
|
|
4341
|
+
if (after) {
|
|
4342
|
+
const stat = lstatSync(path);
|
|
4343
|
+
assert.ok(stat.isFile() && !stat.isSymbolicLink());
|
|
4344
|
+
assert.equal(stat.nlink, 1, `${kind} publication must retain a single link after process death`);
|
|
4345
|
+
assert.equal(stat.mode & 0o077, 0);
|
|
4346
|
+
assert.equal(stat.dev, proof.dev);
|
|
4347
|
+
assert.equal(stat.ino, proof.ino);
|
|
4348
|
+
assert.equal(stableJson(JSON.parse(readFileSync(path, 'utf8'))), stableJson(value));
|
|
4349
|
+
assert.equal(existsSync(proof.temporary), false);
|
|
4350
|
+
publishedCheckpoints.push({ path, bytes: readFileSync(path) });
|
|
4351
|
+
}
|
|
4352
|
+
else {
|
|
4353
|
+
assert.equal(existsSync(path), false);
|
|
4354
|
+
const stat = lstatSync(proof.temporary);
|
|
4355
|
+
assert.ok(stat.isFile() && !stat.isSymbolicLink());
|
|
4356
|
+
assert.equal(stat.nlink, 1);
|
|
4357
|
+
assert.equal(stat.uid, proof.uid);
|
|
4358
|
+
assert.equal(stat.mode, proof.mode);
|
|
4359
|
+
assert.equal(stat.dev, proof.dev);
|
|
4360
|
+
assert.equal(stat.ino, proof.ino);
|
|
4361
|
+
assert.equal(createHash('sha256').update(readFileSync(proof.temporary)).digest('hex'), proof.digest);
|
|
4362
|
+
unlinkSync(proof.temporary);
|
|
4363
|
+
}
|
|
4364
|
+
console.info('continuation checkpoint crash recovered', JSON.stringify({
|
|
4365
|
+
kind,
|
|
4366
|
+
after,
|
|
4367
|
+
signal: child.signal,
|
|
4368
|
+
proof,
|
|
4369
|
+
temporaryRemoved: !existsSync(proof.temporary),
|
|
4370
|
+
targetExists: existsSync(path),
|
|
4371
|
+
}));
|
|
4372
|
+
crashIndex += 1;
|
|
4373
|
+
throw new Error('synthetic SIGKILL checkpoint publication');
|
|
4374
|
+
});
|
|
4375
|
+
restoreCheckpointWrites = () => crashing.mockRestore();
|
|
4376
|
+
for (const directory of [dirname(continuationRoot), gitCommonDir(root)])
|
|
4377
|
+
await withDirectorySyncProbe(directory, 'synthetic continuation namespace parent sync failure', async (probe) => {
|
|
4378
|
+
await refuse(/synthetic continuation namespace parent sync failure/u);
|
|
4379
|
+
assert.ok(probe.attempts > 0);
|
|
4380
|
+
assert.deepEqual(readdirSync(continuationRoot), [String(issueNumber)]);
|
|
4381
|
+
assert.equal(readdirSync(join(continuationRoot, String(issueNumber))).length, 0);
|
|
4382
|
+
probe.fail = false;
|
|
4383
|
+
const beforeRetry = probe.attempts;
|
|
4384
|
+
await refuse(/synthetic SIGKILL checkpoint publication/u);
|
|
4385
|
+
assert.ok(probe.attempts > beforeRetry, 'supported replay must sync the existing namespace parent');
|
|
4386
|
+
});
|
|
4387
|
+
assert.equal(crashIndex, 2);
|
|
4388
|
+
}
|
|
4389
|
+
const first = await executeTool('issue_develop', { issueNumber }, execution).catch((error) => error);
|
|
4390
|
+
assert.ok(first instanceof Error);
|
|
4391
|
+
assert.match(first.message, /exact native.*acceptance/u);
|
|
4392
|
+
const planPath = first.message.split('Saved plan: ')[1];
|
|
4393
|
+
const saved = JSON.parse(readFileSync(planPath, 'utf8'));
|
|
4394
|
+
if (routing.crashCheckpoints) {
|
|
4395
|
+
const foreignLink = join(gitCommonDir(root), 'foreign-continuation-plan-link.json');
|
|
4396
|
+
linkSync(planPath, foreignLink);
|
|
4397
|
+
try {
|
|
4398
|
+
await refuse(/private regular file/u);
|
|
4399
|
+
assert.equal(lstatSync(planPath).ino, lstatSync(foreignLink).ino);
|
|
4400
|
+
}
|
|
4401
|
+
finally {
|
|
4402
|
+
unlinkSync(foreignLink);
|
|
4403
|
+
}
|
|
4404
|
+
}
|
|
4405
|
+
assert.deepEqual(readFileSync(registryPath), registryBytes);
|
|
4406
|
+
const unrelated = {
|
|
4407
|
+
branch: 'unrelated/retained',
|
|
4408
|
+
createdAt: previous.createdAt,
|
|
4409
|
+
identity: 'separate-worker',
|
|
4410
|
+
path: join(root, '.worktrees/unrelated-retained'),
|
|
4411
|
+
status: 'active',
|
|
4412
|
+
type: 'standalone',
|
|
4413
|
+
updatedAt: previous.updatedAt,
|
|
4414
|
+
};
|
|
4415
|
+
writeFileSync(registryPath, JSON.stringify({ worktrees: [...registryData.worktrees, unrelated] }));
|
|
4416
|
+
const subject = 'https://api.github.com/repos/example/widget/issues/17';
|
|
4417
|
+
continuationComments.push({
|
|
4418
|
+
id: 101,
|
|
4419
|
+
issue_url: subject,
|
|
4420
|
+
user: { login: 'host-user', id: 37, type: 'User' },
|
|
4421
|
+
body: saved.operatorBody,
|
|
4422
|
+
});
|
|
4423
|
+
const acceptance = { ...JSON.parse(saved.reviewerBody), authorityCommentId: 101 };
|
|
4424
|
+
continuationComments.push({
|
|
4425
|
+
id: 102,
|
|
4426
|
+
issue_url: subject,
|
|
4427
|
+
user: { login: 'synthetic-reviewer[bot]', id: 38, type: 'Bot' },
|
|
4428
|
+
body: stableJson(acceptance),
|
|
4429
|
+
});
|
|
4430
|
+
for (const [index, patch] of [
|
|
4431
|
+
[0, { issue_url: 'https://api.github.com/repos/example/widget/issues/18' }],
|
|
4432
|
+
[0, { user: { login: 'host-user', id: 99, type: 'User' } }],
|
|
4433
|
+
[0, { body: '{}' }],
|
|
4434
|
+
[1, { user: { login: 'different-reviewer[bot]', id: 38, type: 'Bot' } }],
|
|
4435
|
+
[1, { user: { login: 'synthetic-reviewer[bot]', id: 38, type: 'User' } }],
|
|
4436
|
+
[1, { body: '{}' }],
|
|
4437
|
+
]) {
|
|
4438
|
+
const original = continuationComments[index];
|
|
4439
|
+
continuationComments[index] = { ...original, ...patch };
|
|
4440
|
+
try {
|
|
4441
|
+
await refuse(/(?:exact native.*acceptance|Native.*(?:missing|changed))/u);
|
|
4442
|
+
}
|
|
4443
|
+
finally {
|
|
4444
|
+
continuationComments[index] = original;
|
|
4445
|
+
}
|
|
4446
|
+
}
|
|
4447
|
+
continuationComments.push({
|
|
4448
|
+
id: 103,
|
|
4449
|
+
issue_url: subject,
|
|
4450
|
+
user: { login: 'host-user', id: 37, type: 'User' },
|
|
4451
|
+
body: stableJson({
|
|
4452
|
+
schemaVersion: 'ai-delivery.worktree-continuation-revocation@1',
|
|
4453
|
+
planId: saved.plan.planId,
|
|
4454
|
+
authorityCommentId: 101,
|
|
4455
|
+
}),
|
|
4456
|
+
});
|
|
4457
|
+
try {
|
|
4458
|
+
await refuse(/revoked/u);
|
|
4459
|
+
assert.equal(existsSync(planPath.replace(/\.plan\.json$/u, '.intent.json')), false);
|
|
4460
|
+
}
|
|
4461
|
+
finally {
|
|
4462
|
+
continuationComments.pop();
|
|
4463
|
+
}
|
|
4464
|
+
const refuseDifferentRuntime = async () => {
|
|
4465
|
+
const admissionPath = join(gitCommonDir(root), 'ai-delivery/runtime-admission.json');
|
|
4466
|
+
const originalAdmission = readFileSync(admissionPath);
|
|
4467
|
+
const { admissionId: _id, ...content } = JSON.parse(originalAdmission.toString());
|
|
4468
|
+
const changed = { ...content, sourceArchiveSha256: digestValue('another valid admitted archive') };
|
|
4469
|
+
const before = readFileSync(registryPath);
|
|
4470
|
+
writeFileSync(admissionPath, JSON.stringify({ ...changed, admissionId: digestValue(changed) }));
|
|
4471
|
+
try {
|
|
4472
|
+
const admitted = await assertDeliveryRuntimeAdmitted({ repoRoot: root, runtimeEntryPath });
|
|
4473
|
+
assert.equal(admitted.sourceArchiveSha256, changed.sourceArchiveSha256);
|
|
4474
|
+
await assert.rejects(executeTool('issue_develop', { issueNumber }, execution), /runtime admission.*(?:changed|drifted)|plan drifted/u);
|
|
4475
|
+
assert.deepEqual(readFileSync(registryPath), before);
|
|
4476
|
+
for (const savedReceipt of [...oldReceipts, ...ownerFiles])
|
|
4477
|
+
assert.deepEqual(readFileSync(savedReceipt.path), savedReceipt.bytes);
|
|
4478
|
+
}
|
|
4479
|
+
finally {
|
|
4480
|
+
writeFileSync(admissionPath, originalAdmission);
|
|
4481
|
+
}
|
|
4482
|
+
};
|
|
4483
|
+
await refuseDifferentRuntime();
|
|
4484
|
+
if (routing.crashCheckpoints) {
|
|
4485
|
+
const evidenceDirectory = join(gitCommonDir(root), 'ai-delivery/continuations/evidence');
|
|
4486
|
+
crashAfterDirectoryCreation(evidenceDirectory, join(gitCommonDir(root), 'continuation-directory-crash.json'));
|
|
4487
|
+
const retainedDirectory = join(gitCommonDir(root), 'retained-continuation-evidence');
|
|
4488
|
+
const fs = (await import('node:fs')).default;
|
|
4489
|
+
fs.renameSync(evidenceDirectory, retainedDirectory);
|
|
4490
|
+
symlinkSync(retainedDirectory, evidenceDirectory, 'dir');
|
|
4491
|
+
try {
|
|
4492
|
+
await refuse(/evidence requires a private directory/u);
|
|
4493
|
+
assert.equal(readdirSync(retainedDirectory).length, 0);
|
|
4494
|
+
}
|
|
4495
|
+
finally {
|
|
4496
|
+
unlinkSync(evidenceDirectory);
|
|
4497
|
+
fs.renameSync(retainedDirectory, evidenceDirectory);
|
|
4498
|
+
}
|
|
4499
|
+
await withDirectorySyncProbe(dirname(evidenceDirectory), 'synthetic evidence directory sync failure', async (probe) => {
|
|
4500
|
+
await refuse(/synthetic evidence directory sync failure/u);
|
|
4501
|
+
assert.ok(probe.attempts > 0);
|
|
4502
|
+
assert.equal(existsSync(planPath.replace(/\.plan\.json$/u, '.intent.json')), false);
|
|
4503
|
+
assert.equal(readdirSync(evidenceDirectory).length, 0);
|
|
4504
|
+
probe.fail = false;
|
|
4505
|
+
const beforeRetry = probe.attempts;
|
|
4506
|
+
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
4507
|
+
await assert.rejects(executeTool('issue_develop', { issueNumber }, execution), /synthetic SIGKILL checkpoint publication/u);
|
|
4508
|
+
assert.equal(git(row.path, 'rev-parse', 'HEAD'), descendant);
|
|
4509
|
+
assert.equal(git(row.path, 'rev-parse', 'HEAD^{tree}'), descendantTree);
|
|
4510
|
+
for (const savedReceipt of [...oldReceipts, ...ownerFiles])
|
|
4511
|
+
assert.deepEqual(readFileSync(savedReceipt.path), savedReceipt.bytes);
|
|
4512
|
+
if (attempt < 5 && existsSync(planPath.replace(/\.plan\.json$/u, '.intent.json')))
|
|
4513
|
+
await assert.rejects(executeTool('issue_verify', { issueNumber }, execution), /continuation.*(?:pending|incomplete)/u);
|
|
4514
|
+
}
|
|
4515
|
+
assert.ok(probe.attempts > beforeRetry, 'retry must durably publish the existing evidence directory');
|
|
4516
|
+
});
|
|
4517
|
+
assert.equal(crashIndex, 8);
|
|
4518
|
+
for (const checkpoint of publishedCheckpoints)
|
|
4519
|
+
assert.deepEqual(readFileSync(checkpoint.path), checkpoint.bytes);
|
|
4520
|
+
restoreCheckpointWrites?.();
|
|
4521
|
+
restoreCheckpointWrites = undefined;
|
|
4522
|
+
const before = readFileSync(registryPath);
|
|
4523
|
+
fs.renameSync(evidenceDirectory, retainedDirectory);
|
|
4524
|
+
symlinkSync(retainedDirectory, evidenceDirectory, 'dir');
|
|
4525
|
+
try {
|
|
4526
|
+
for (const tool of ['issue_develop', 'issue_verify'])
|
|
4527
|
+
await assert.rejects(executeTool(tool, { issueNumber }, execution), /evidence requires a private directory/u);
|
|
4528
|
+
assert.deepEqual(readFileSync(registryPath), before);
|
|
4529
|
+
for (const checkpoint of publishedCheckpoints)
|
|
4530
|
+
assert.deepEqual(readFileSync(checkpoint.path), checkpoint.bytes);
|
|
4531
|
+
}
|
|
4532
|
+
finally {
|
|
4533
|
+
unlinkSync(evidenceDirectory);
|
|
4534
|
+
fs.renameSync(retainedDirectory, evidenceDirectory);
|
|
4535
|
+
}
|
|
4536
|
+
for (const directory of [
|
|
4537
|
+
gitCommonDir(root),
|
|
4538
|
+
join(gitCommonDir(root), 'ai-delivery'),
|
|
4539
|
+
dirname(dirname(planPath)),
|
|
4540
|
+
dirname(planPath),
|
|
4541
|
+
])
|
|
4542
|
+
await withDirectorySyncProbe(directory, 'synthetic completion directory sync failure', async (probe) => {
|
|
4543
|
+
for (const tool of ['issue_develop', 'issue_verify'])
|
|
4544
|
+
await assert.rejects(executeTool(tool, { issueNumber }, execution), /synthetic completion directory sync failure/u);
|
|
4545
|
+
assert.ok(probe.attempts > 0);
|
|
4546
|
+
assert.deepEqual(readFileSync(registryPath), before);
|
|
4547
|
+
for (const checkpoint of publishedCheckpoints)
|
|
4548
|
+
assert.deepEqual(readFileSync(checkpoint.path), checkpoint.bytes);
|
|
4549
|
+
for (const savedReceipt of [...oldReceipts, ...ownerFiles])
|
|
4550
|
+
assert.deepEqual(readFileSync(savedReceipt.path), savedReceipt.bytes);
|
|
4551
|
+
probe.fail = false;
|
|
4552
|
+
const beforeRetry = probe.attempts;
|
|
4553
|
+
await executeTool('issue_develop', { issueNumber }, execution);
|
|
4554
|
+
assert.ok(probe.attempts > beforeRetry, 'supported replay must sync the existing completion directory');
|
|
4555
|
+
});
|
|
4556
|
+
}
|
|
4557
|
+
if (routing.interruptContinuation !== false) {
|
|
4558
|
+
const originalWrite = atomicJson.writeJsonFileAtomically;
|
|
4559
|
+
for (const afterWrite of [false, true]) {
|
|
4560
|
+
const interrupted = vi.spyOn(atomicJson, 'writeJsonFileAtomically').mockImplementation((...args) => {
|
|
4561
|
+
if (args[0] === registryPath) {
|
|
4562
|
+
if (afterWrite)
|
|
4563
|
+
originalWrite(...args);
|
|
4564
|
+
throw new Error('synthetic continuation registry interruption');
|
|
4565
|
+
}
|
|
4566
|
+
return originalWrite(...args);
|
|
4567
|
+
});
|
|
4568
|
+
try {
|
|
4569
|
+
await assert.rejects(executeTool('issue_develop', { issueNumber }, execution), /synthetic continuation registry interruption/u);
|
|
4570
|
+
}
|
|
4571
|
+
finally {
|
|
4572
|
+
interrupted.mockRestore();
|
|
4573
|
+
}
|
|
4574
|
+
await assert.rejects(executeTool('issue_verify', { issueNumber }, execution), /continuation.*(?:pending|incomplete)/u);
|
|
4575
|
+
await assert.rejects(executeTool('issue_update', { issueNumber, body: issue.body }, execution), /continuation.*(?:pending|incomplete)/u);
|
|
4576
|
+
await assert.rejects(executeTool('issue_start', { issueNumber, resumeCreated: true, develop: true }, execution), /continuation.*(?:pending|incomplete)/u);
|
|
4577
|
+
assert.equal(git(row.path, 'rev-parse', 'HEAD'), descendant);
|
|
4578
|
+
assert.equal(git(row.path, 'rev-parse', 'HEAD^{tree}'), descendantTree);
|
|
4579
|
+
for (const savedReceipt of [...oldReceipts, ...ownerFiles])
|
|
4580
|
+
assert.deepEqual(readFileSync(savedReceipt.path), savedReceipt.bytes);
|
|
4581
|
+
}
|
|
4582
|
+
const intentPath = planPath.replace(/\.plan\.json$/u, '.intent.json');
|
|
4583
|
+
await refuseDifferentRuntime();
|
|
4584
|
+
const intentBytes = readFileSync(intentPath);
|
|
4585
|
+
writeFileSync(intentPath, '{}');
|
|
4586
|
+
try {
|
|
4587
|
+
await assert.rejects(executeTool('issue_develop', { issueNumber }, execution), /invalid|expected/iu);
|
|
4588
|
+
}
|
|
4589
|
+
finally {
|
|
4590
|
+
writeFileSync(intentPath, intentBytes);
|
|
4591
|
+
}
|
|
4592
|
+
const beforeimageDirectory = join(gitCommonDir(root), 'ai-delivery/continuations/evidence');
|
|
4593
|
+
const beforeimage = join(beforeimageDirectory, readdirSync(beforeimageDirectory)[0]);
|
|
4594
|
+
const beforeimageBytes = readFileSync(beforeimage);
|
|
4595
|
+
writeFileSync(beforeimage, 'corrupt original');
|
|
4596
|
+
try {
|
|
4597
|
+
await assert.rejects(executeTool('issue_develop', { issueNumber }, execution), /(?:evidence|beforeimage).*changed/u);
|
|
4598
|
+
}
|
|
4599
|
+
finally {
|
|
4600
|
+
writeFileSync(beforeimage, beforeimageBytes);
|
|
4601
|
+
}
|
|
4602
|
+
writeFileSync(join(row.path, 'change.txt'), 'incompatible replay source\n');
|
|
4603
|
+
git(row.path, 'add', 'change.txt');
|
|
4604
|
+
git(row.path, 'commit', '-qm', 'incompatible replay');
|
|
4605
|
+
try {
|
|
4606
|
+
await assert.rejects(executeTool('issue_develop', { issueNumber }, execution), /plan drifted/u);
|
|
4607
|
+
}
|
|
4608
|
+
finally {
|
|
4609
|
+
git(row.path, 'reset', '--hard', descendant);
|
|
4610
|
+
}
|
|
4611
|
+
const authorityUser = continuationComments[0].user;
|
|
4612
|
+
continuationComments[0].user = { login: 'host-user', id: 99, type: 'User' };
|
|
4613
|
+
try {
|
|
4614
|
+
await assert.rejects(executeTool('issue_develop', { issueNumber }, execution), /operator.*(?:missing|changed)/u);
|
|
4615
|
+
}
|
|
4616
|
+
finally {
|
|
4617
|
+
continuationComments[0].user = authorityUser;
|
|
4618
|
+
}
|
|
4619
|
+
const reviewBody = continuationComments[1].body;
|
|
4620
|
+
continuationComments[1].body = '{}';
|
|
4621
|
+
await assert.rejects(executeTool('issue_develop', { issueNumber }, execution), /acceptance.*(?:missing|changed)/u);
|
|
4622
|
+
continuationComments[1].body = reviewBody;
|
|
4623
|
+
continuationComments.push({
|
|
4624
|
+
id: 103,
|
|
4625
|
+
issue_url: subject,
|
|
4626
|
+
user: { login: 'host-user', id: 37, type: 'User' },
|
|
4627
|
+
body: stableJson({
|
|
4628
|
+
schemaVersion: 'ai-delivery.worktree-continuation-revocation@1',
|
|
4629
|
+
planId: saved.plan.planId,
|
|
4630
|
+
authorityCommentId: 101,
|
|
4631
|
+
}),
|
|
4632
|
+
});
|
|
4633
|
+
await assert.rejects(executeTool('issue_develop', { issueNumber }, execution), /revoked/u);
|
|
4634
|
+
continuationComments.pop();
|
|
4635
|
+
}
|
|
4636
|
+
projectStatus = null;
|
|
4637
|
+
failNextProjectSync = true;
|
|
4638
|
+
await assert.rejects(executeTool('issue_start', { issueNumber }, execution), /synthetic Project interruption/u);
|
|
4639
|
+
const resumed = await executeTool('issue_develop', { issueNumber }, execution);
|
|
4640
|
+
assert.deepEqual(resumed, { path: previous.path, branch: previous.branch });
|
|
4641
|
+
const current = getIssueWorktreeStrict(issueNumber, root);
|
|
4642
|
+
assert.equal(current.identity, 'host-author');
|
|
4643
|
+
assert.equal(current.status, 'active');
|
|
4644
|
+
assert.equal(current.prNumber, undefined);
|
|
4645
|
+
assert.equal(current.createdAt, previous.createdAt);
|
|
4646
|
+
assert.equal(git(row.path, 'rev-parse', 'HEAD'), descendant);
|
|
4647
|
+
assert.equal(git(row.path, 'status', '--porcelain'), '');
|
|
4648
|
+
assert.equal(existsSync(join(gitCommonDir(root), 'ai-delivery/merges', '17', `${descendant}.json`)), false);
|
|
4649
|
+
const continued = await verifyIssue({ issueNumber, repoRoot: row.path });
|
|
4650
|
+
assert.equal(continued.classification.head.sha, descendant);
|
|
4651
|
+
writeFileSync(join(row.path, 'change.txt'), 'later ordinary commit\n');
|
|
4652
|
+
git(row.path, 'add', 'change.txt');
|
|
4653
|
+
git(row.path, 'commit', '-qm', 'ordinary work after completed continuation');
|
|
4654
|
+
const futureHead = git(row.path, 'rev-parse', 'HEAD');
|
|
4655
|
+
await executeTool('issue_verify', { issueNumber }, execution);
|
|
4656
|
+
assert.equal(loadVerifiedRun(row.path, issueNumber).classification.head.sha, futureHead);
|
|
4657
|
+
for (const savedReceipt of [...oldReceipts, ...ownerFiles])
|
|
4658
|
+
assert.deepEqual(readFileSync(savedReceipt.path), savedReceipt.bytes);
|
|
4659
|
+
const finalRegistry = JSON.parse(readFileSync(registryPath, 'utf8'));
|
|
4660
|
+
assert.deepEqual(finalRegistry.worktrees.find((entry) => entry.branch === unrelated.branch), unrelated);
|
|
4661
|
+
assert.equal(calls.filter((call) => call === 'git:createCommit').length, 1);
|
|
4662
|
+
if (routing.continuationResult)
|
|
4663
|
+
routing.continuationResult.value = {
|
|
4664
|
+
identity: current.identity,
|
|
4665
|
+
status: current.status,
|
|
4666
|
+
prNumber: current.prNumber,
|
|
4667
|
+
createdAtPreserved: current.createdAt === previous.createdAt,
|
|
4668
|
+
issueRows: finalRegistry.worktrees.filter((entry) => entry.branch === previous.branch).length,
|
|
4669
|
+
unrelatedPreserved: true,
|
|
4670
|
+
originalBytesPreserved: true,
|
|
4671
|
+
descendantVerified: continued.classification.head.sha === descendant,
|
|
4672
|
+
futureVerified: loadVerifiedRun(row.path, issueNumber).classification.head.sha === futureHead,
|
|
4673
|
+
};
|
|
4674
|
+
return;
|
|
4675
|
+
}
|
|
4049
4676
|
const publicationPath = join(gitCommonDir(root), 'ai-delivery/publications', String(issueNumber), `${headSha}.json`);
|
|
4050
4677
|
const publicationBytes = readFileSync(publicationPath);
|
|
4051
4678
|
const { publicationId: _publicationId, ...publicationContent } = JSON.parse(publicationBytes.toString());
|
|
@@ -4184,6 +4811,7 @@ exec "${realGit}" "$@"
|
|
|
4184
4811
|
assert.equal(calls.filter((call) => call === 'issue:create').length, beforeTracking + 1);
|
|
4185
4812
|
}
|
|
4186
4813
|
finally {
|
|
4814
|
+
restoreCheckpointWrites?.();
|
|
4187
4815
|
restoreTransport?.();
|
|
4188
4816
|
process.env.PATH = originalPath;
|
|
4189
4817
|
restoreDispatchClient?.();
|
|
@@ -4196,6 +4824,29 @@ test.each([
|
|
|
4196
4824
|
{ remote: 'upstream', divergentOrigin: true },
|
|
4197
4825
|
])('synthetic lifecycle finishes with remote $remote and divergent origin $divergentOrigin', syntheticLifecycle, 15_000);
|
|
4198
4826
|
test('supported develop resumes an open merged issue under the same owner and preserves prior receipts', { timeout: 0 }, async () => syntheticLifecycle({ remote: 'origin', divergentOrigin: false, mergedContinuation: true }));
|
|
4827
|
+
test('supported develop reconciles authenticated host custody for an already committed descendant', { timeout: 0 }, async () => {
|
|
4828
|
+
const input = { remote: 'origin', divergentOrigin: false, committedDescendantContinuation: true };
|
|
4829
|
+
const recovered = {};
|
|
4830
|
+
const uninterrupted = {};
|
|
4831
|
+
await syntheticLifecycle({ ...input, continuationResult: recovered });
|
|
4832
|
+
await syntheticLifecycle({ ...input, interruptContinuation: false, continuationResult: uninterrupted });
|
|
4833
|
+
assert.ok(recovered.value);
|
|
4834
|
+
assert.ok(uninterrupted.value);
|
|
4835
|
+
assert.deepEqual(recovered.value, uninterrupted.value);
|
|
4836
|
+
});
|
|
4837
|
+
test('committed descendant checkpoint publication recovers from SIGKILL before and after every rename', { timeout: 0 }, async () => syntheticLifecycle({
|
|
4838
|
+
remote: 'origin',
|
|
4839
|
+
divergentOrigin: false,
|
|
4840
|
+
committedDescendantContinuation: true,
|
|
4841
|
+
interruptContinuation: false,
|
|
4842
|
+
crashCheckpoints: true,
|
|
4843
|
+
}));
|
|
4844
|
+
test('committed descendant continuation refuses configured App authors using a personal override', { timeout: 0 }, async () => syntheticLifecycle({
|
|
4845
|
+
remote: 'origin',
|
|
4846
|
+
divergentOrigin: false,
|
|
4847
|
+
committedDescendantContinuation: true,
|
|
4848
|
+
appAuthorContinuation: true,
|
|
4849
|
+
}));
|
|
4199
4850
|
test('supported native metadata updates a legacy target without adopting its source or receipts', { timeout: 0 }, async () => syntheticLifecycle({ remote: 'origin', divergentOrigin: false, legacyNativeMetadata: true }));
|
|
4200
4851
|
test('an unrelated consumer uses public MCP setup through verified configured-author publication, counted review readback, merge and cleanup', { timeout: 0 }, async () => {
|
|
4201
4852
|
await syntheticLifecycle({ remote: 'origin', divergentOrigin: false, producerOnboarding: true });
|