@aviaratech/ai-delivery 0.3.14 → 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 +173 -12
- package/dist/issue.js.map +1 -1
- package/dist/lifecycle.test.js +695 -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);
|
|
@@ -3530,6 +3556,8 @@ async function syntheticLifecycle(routing) {
|
|
|
3530
3556
|
return { data: {} };
|
|
3531
3557
|
},
|
|
3532
3558
|
update: async (input) => {
|
|
3559
|
+
if (input.body !== undefined)
|
|
3560
|
+
issue.body = input.body;
|
|
3533
3561
|
if (input.state !== undefined)
|
|
3534
3562
|
issue.state = input.state;
|
|
3535
3563
|
return { data: issue };
|
|
@@ -3598,20 +3626,21 @@ async function syntheticLifecycle(routing) {
|
|
|
3598
3626
|
getCombinedStatusForRef: async () => ({ data: { statuses: [], state: 'success' } }),
|
|
3599
3627
|
},
|
|
3600
3628
|
};
|
|
3601
|
-
let authenticatedAuthor =
|
|
3629
|
+
let authenticatedAuthor = historicalActor;
|
|
3630
|
+
let authenticatedCredential = routing.committedDescendantContinuation ? 'user:37' : 'app:201:installation:301';
|
|
3602
3631
|
const context = {
|
|
3603
3632
|
root,
|
|
3604
3633
|
repo: { owner: 'example', repo: 'widget' },
|
|
3605
3634
|
config,
|
|
3606
3635
|
configuration,
|
|
3607
3636
|
clients: {
|
|
3608
|
-
authSource: 'app',
|
|
3637
|
+
authSource: routing.committedDescendantContinuation ? 'personal' : 'app',
|
|
3609
3638
|
role: 'author',
|
|
3610
3639
|
graphql,
|
|
3611
3640
|
rest,
|
|
3612
3641
|
authenticatedAuthor: async () => ({
|
|
3613
3642
|
actorLogin: authenticatedAuthor,
|
|
3614
|
-
credentialIdentity:
|
|
3643
|
+
credentialIdentity: authenticatedCredential,
|
|
3615
3644
|
}),
|
|
3616
3645
|
},
|
|
3617
3646
|
};
|
|
@@ -3622,6 +3651,7 @@ async function syntheticLifecycle(routing) {
|
|
|
3622
3651
|
...(input.role === 'reviewer'
|
|
3623
3652
|
? {
|
|
3624
3653
|
role: 'reviewer',
|
|
3654
|
+
authSource: 'app',
|
|
3625
3655
|
appActorLogin: async () => 'synthetic-reviewer[bot]',
|
|
3626
3656
|
}
|
|
3627
3657
|
: {}),
|
|
@@ -3634,7 +3664,12 @@ async function syntheticLifecycle(routing) {
|
|
|
3634
3664
|
restoreDispatchClient = () => dispatchClient.mockRestore();
|
|
3635
3665
|
return dispatchClient;
|
|
3636
3666
|
};
|
|
3637
|
-
const execution = {
|
|
3667
|
+
const execution = {
|
|
3668
|
+
repoRoot: root,
|
|
3669
|
+
runtimeEntryPath,
|
|
3670
|
+
identity: config.roles.author.identity,
|
|
3671
|
+
...(routing.appAuthorContinuation ? { personalAuth: true } : {}),
|
|
3672
|
+
};
|
|
3638
3673
|
try {
|
|
3639
3674
|
if (routing.legacyNativeMetadata) {
|
|
3640
3675
|
enableDispatch();
|
|
@@ -3799,6 +3834,8 @@ async function syntheticLifecycle(routing) {
|
|
|
3799
3834
|
await assert.rejects(developIssue(context, issueNumber), /closed/u);
|
|
3800
3835
|
issue.state = 'open';
|
|
3801
3836
|
assert.equal((await readyCheck(context, issueNumber)).ready, true);
|
|
3837
|
+
if (routing.committedDescendantContinuation)
|
|
3838
|
+
await prepareIssueWorktree({ identity: 'synthetic-preparer', issueNumber, repoRoot: root });
|
|
3802
3839
|
const started = await startTrackedIssue(context, { issueNumber });
|
|
3803
3840
|
assert.equal(started.mode, 'existing-issue');
|
|
3804
3841
|
assert.equal(started.issueNumber, issueNumber);
|
|
@@ -3829,6 +3866,32 @@ async function syntheticLifecycle(routing) {
|
|
|
3829
3866
|
assert.equal(projectStatusWrites, interruptedWrites);
|
|
3830
3867
|
assert.deepEqual(unblockedUpdate.blockedBy, []);
|
|
3831
3868
|
assert.equal(unblockedUpdate.projectStatus, 'Todo');
|
|
3869
|
+
projectStatus = 'Waiting';
|
|
3870
|
+
parentIssueNumber = 11;
|
|
3871
|
+
const heldStatusWrites = projectStatusWrites;
|
|
3872
|
+
const originalBody = issue.body;
|
|
3873
|
+
const metadataBody = `${originalBody}\n\nUpdated tracking context.`;
|
|
3874
|
+
const heldMetadata = await updateIssue(context, { issueNumber, body: metadataBody });
|
|
3875
|
+
assert.equal(heldMetadata.body, metadataBody);
|
|
3876
|
+
assert.equal(heldMetadata.projectStatus, 'Blocked', 'body-only metadata must preserve a held Project status');
|
|
3877
|
+
assert.equal(heldMetadata.state, 'open');
|
|
3878
|
+
assert.deepEqual(heldMetadata.blockedBy, []);
|
|
3879
|
+
assert.equal(heldMetadata.parentIssueNumber, 11);
|
|
3880
|
+
assert.equal(projectStatusWrites, heldStatusWrites);
|
|
3881
|
+
assert.equal((await updateIssue(context, { issueNumber, title: issue.title })).projectStatus, 'Blocked');
|
|
3882
|
+
assert.equal(projectStatusWrites, heldStatusWrites);
|
|
3883
|
+
await updateIssue(context, { issueNumber, body: originalBody });
|
|
3884
|
+
parentIssueNumber = null;
|
|
3885
|
+
for (const input of [{ park: true }, { blockedBy: [] }, { state: 'open' }]) {
|
|
3886
|
+
projectStatus = 'Waiting';
|
|
3887
|
+
const explicitUpdate = await updateIssue(context, { issueNumber, ...input });
|
|
3888
|
+
assert.equal(explicitUpdate.projectStatus, 'Todo');
|
|
3889
|
+
assert.deepEqual(explicitUpdate.blockedBy, []);
|
|
3890
|
+
}
|
|
3891
|
+
projectStatus = 'Waiting';
|
|
3892
|
+
assert.equal((await updateIssue(context, { issueNumber, state: 'closed' })).projectStatus, 'Done');
|
|
3893
|
+
issue.state = 'open';
|
|
3894
|
+
projectStatus = 'Queued';
|
|
3832
3895
|
conflictNextStatusReadback = true;
|
|
3833
3896
|
await assert.rejects(updateIssue(context, { issueNumber, blockedBy: [9] }), /exact issue item/u);
|
|
3834
3897
|
assert.equal(blockerOpen, true);
|
|
@@ -3907,14 +3970,14 @@ exec "${realGit}" "$@"
|
|
|
3907
3970
|
});
|
|
3908
3971
|
}
|
|
3909
3972
|
assert.equal((await listPrs(context)).pullRequests[0]?.number, prNumber);
|
|
3910
|
-
assert.equal((await prInfo(context, { prNumber })).authorLogin,
|
|
3973
|
+
assert.equal((await prInfo(context, { prNumber })).authorLogin, historicalActor);
|
|
3911
3974
|
assert.equal((await prChecks(context, prNumber)).combinedStatus, 'success');
|
|
3912
3975
|
assert.equal((await prChecks(context, prNumber)).reviewState.status, 'still-required');
|
|
3913
3976
|
await assert.rejects(checkoutPr(context, prNumber), /open in-repository branch/u);
|
|
3914
3977
|
assert.equal(git(remote, 'rev-parse', 'refs/heads/issue/17'), headSha);
|
|
3915
3978
|
await assert.rejects(mergePr(context, { issueNumber, prNumber }), /submitted independent review/);
|
|
3916
3979
|
const artifactContent = {
|
|
3917
|
-
authorIdentity:
|
|
3980
|
+
authorIdentity: config.roles.author.identity,
|
|
3918
3981
|
checks: ['verified exact diff'],
|
|
3919
3982
|
diffScopeHash: digestValue(run.classification.changedPaths),
|
|
3920
3983
|
elapsedMs: 100,
|
|
@@ -3935,7 +3998,12 @@ exec "${realGit}" "$@"
|
|
|
3935
3998
|
const artifact = JSON.stringify({ ...artifactContent, artifactId: digestValue(artifactContent) });
|
|
3936
3999
|
const reviewer = {
|
|
3937
4000
|
...context,
|
|
3938
|
-
clients: {
|
|
4001
|
+
clients: {
|
|
4002
|
+
...context.clients,
|
|
4003
|
+
authSource: 'app',
|
|
4004
|
+
role: 'reviewer',
|
|
4005
|
+
appActorLogin: async () => 'synthetic-reviewer',
|
|
4006
|
+
},
|
|
3939
4007
|
};
|
|
3940
4008
|
const personalReviewer = {
|
|
3941
4009
|
...reviewer,
|
|
@@ -3961,11 +4029,11 @@ exec "${realGit}" "$@"
|
|
|
3961
4029
|
const attemptPath = join(gitCommonDir(root), 'ai-delivery', 'merge-attempts', String(issueNumber), `${headSha}.json`);
|
|
3962
4030
|
const attempt = JSON.parse(readFileSync(attemptPath, 'utf8'));
|
|
3963
4031
|
assert.equal(attempt.operation, 'updateRefs');
|
|
3964
|
-
assert.equal(attempt.actor,
|
|
3965
|
-
assert.equal(attempt.actorLogin,
|
|
4032
|
+
assert.equal(attempt.actor, config.roles.author.identity);
|
|
4033
|
+
assert.equal(attempt.actorLogin, historicalActor);
|
|
3966
4034
|
authenticatedAuthor = 'different-author[bot]';
|
|
3967
4035
|
await assert.rejects(finishIssue(context, { issueNumber, prNumber, strategy: 'merge' }), /Merge attempt disagrees with its exact source, operation or author/u);
|
|
3968
|
-
authenticatedAuthor =
|
|
4036
|
+
authenticatedAuthor = historicalActor;
|
|
3969
4037
|
assert.equal(calls.filter((call) => call === 'git:createCommit').length, 1);
|
|
3970
4038
|
await assert.rejects(finishIssue(context, { issueNumber, prNumber, strategy: 'merge' }), /readback is unresolved/u);
|
|
3971
4039
|
assert.equal(calls.filter((call) => call === 'git:createCommit').length, 1);
|
|
@@ -3984,7 +4052,7 @@ exec "${realGit}" "$@"
|
|
|
3984
4052
|
await assert.rejects(finishIssue(context, { issueNumber, prNumber, strategy: 'merge' }), /exact prepared merge intent/u);
|
|
3985
4053
|
headSha = reviewedHead;
|
|
3986
4054
|
assert.equal(calls.filter((call) => call === 'git:createCommit').length, 1);
|
|
3987
|
-
if (routing.mergedContinuation) {
|
|
4055
|
+
if (routing.mergedContinuation || routing.committedDescendantContinuation) {
|
|
3988
4056
|
enableDispatch();
|
|
3989
4057
|
const terminal = await executeTool('issue_pr_merge', { issueNumber, prNumber, strategy: 'merge' }, execution);
|
|
3990
4058
|
assert.equal(terminal.mergeSha, mergeSha);
|
|
@@ -4018,6 +4086,593 @@ exec "${realGit}" "$@"
|
|
|
4018
4086
|
saveReceipts(join(gitCommonDir(root), 'ai-delivery', name));
|
|
4019
4087
|
const ownerPath = join(gitCommonDir(root), 'ai-delivery/worktree-owners');
|
|
4020
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
|
+
}
|
|
4021
4676
|
const publicationPath = join(gitCommonDir(root), 'ai-delivery/publications', String(issueNumber), `${headSha}.json`);
|
|
4022
4677
|
const publicationBytes = readFileSync(publicationPath);
|
|
4023
4678
|
const { publicationId: _publicationId, ...publicationContent } = JSON.parse(publicationBytes.toString());
|
|
@@ -4156,6 +4811,7 @@ exec "${realGit}" "$@"
|
|
|
4156
4811
|
assert.equal(calls.filter((call) => call === 'issue:create').length, beforeTracking + 1);
|
|
4157
4812
|
}
|
|
4158
4813
|
finally {
|
|
4814
|
+
restoreCheckpointWrites?.();
|
|
4159
4815
|
restoreTransport?.();
|
|
4160
4816
|
process.env.PATH = originalPath;
|
|
4161
4817
|
restoreDispatchClient?.();
|
|
@@ -4168,6 +4824,29 @@ test.each([
|
|
|
4168
4824
|
{ remote: 'upstream', divergentOrigin: true },
|
|
4169
4825
|
])('synthetic lifecycle finishes with remote $remote and divergent origin $divergentOrigin', syntheticLifecycle, 15_000);
|
|
4170
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
|
+
}));
|
|
4171
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 }));
|
|
4172
4851
|
test('an unrelated consumer uses public MCP setup through verified configured-author publication, counted review readback, merge and cleanup', { timeout: 0 }, async () => {
|
|
4173
4852
|
await syntheticLifecycle({ remote: 'origin', divergentOrigin: false, producerOnboarding: true });
|