agent-dealer 1.2.13 → 1.2.15
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/bundle/server/dist/adapters/git-worktree.js +15 -0
- package/bundle/server/dist/adapters/git-worktree.test.js +30 -0
- package/bundle/server/dist/adapters/linear-inbox.js +97 -0
- package/bundle/server/dist/adapters/linear-inbox.test.js +299 -1
- package/bundle/server/dist/adapters/linear-sync.js +33 -10
- package/bundle/server/dist/capacity/claude-events.js +25 -5
- package/bundle/server/dist/capacity/claude-local-cache.js +159 -13
- package/bundle/server/dist/capacity/claude-local-cache.test.js +191 -3
- package/bundle/server/dist/coordinator/auto-merge.js +10 -0
- package/bundle/server/dist/coordinator/branch-progress.js +5 -4
- package/bundle/server/dist/coordinator/commands.js +37 -3
- package/bundle/server/dist/coordinator/delete-issue.js +473 -0
- package/bundle/server/dist/coordinator/developer-effect.js +33 -6
- package/bundle/server/dist/coordinator/linear-merge-verify.js +382 -0
- package/bundle/server/dist/coordinator/linear-merge-verify.test.js +376 -0
- package/bundle/server/dist/coordinator/prompts.js +9 -1
- package/bundle/server/dist/coordinator/prompts.test.js +93 -0
- package/bundle/server/dist/coordinator/source-attachments.js +496 -0
- package/bundle/server/dist/coordinator/source-attachments.test.js +396 -0
- package/bundle/server/dist/coordinator/source-reload.js +178 -0
- package/bundle/server/dist/db/index.js +4 -0
- package/bundle/server/dist/db/schema.sql +32 -0
- package/bundle/server/dist/docs-linear-attachments.test.js +36 -0
- package/bundle/server/dist/paths.js +12 -0
- package/bundle/server/dist/repository/runtime-capacity.js +19 -3
- package/bundle/server/dist/repository/source-attachments.js +56 -0
- package/bundle/server/dist/routes/human-actions.js +17 -0
- package/bundle/server/dist/routes/issues-delete.test.js +666 -0
- package/bundle/server/dist/routes/issues-execution-contract.test.js +296 -0
- package/bundle/server/dist/routes/issues-source-attachments.test.js +447 -0
- package/bundle/server/dist/routes/issues.js +109 -1
- package/bundle/server/dist/routes/runtime-capacity.test.js +35 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-DVyqblie.js +64 -0
- package/bundle/server/static-ui/index.html +1 -1
- package/bundle/shared/dist/index.d.ts +108 -2
- package/bundle/shared/dist/index.js +12 -1
- package/bundle/shared/dist/issues.d.ts +43 -2
- package/bundle/shared/dist/issues.js +10 -0
- package/bundle/shared/dist/linear-intake.d.ts +85 -0
- package/bundle/shared/dist/linear-intake.js +56 -0
- package/bundle/shared/dist/linear-intake.test.js +55 -1
- package/bundle/shared/dist/runtime-capacity.d.ts +147 -0
- package/bundle/shared/dist/runtime-capacity.js +15 -0
- package/bundle/shared/dist/workflow.d.ts +4 -4
- package/bundle/shared/dist/workflow.js +7 -0
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-BzLjMBI9.js +0 -64
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// NOT-362 acceptance: after Dealer merges a PR for a `source = linear` issue,
|
|
2
|
+
// the coordinator re-reads the Linear issue and either confirms it advanced,
|
|
3
|
+
// advances it once via the conditional fallback, or raises the one human action.
|
|
4
|
+
import { test, before, beforeEach, afterEach } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not362-"));
|
|
11
|
+
process.env.LINEAR_API_KEY = "test-key";
|
|
12
|
+
const { migrate, getDb } = await import("../db/index.js");
|
|
13
|
+
const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
|
|
14
|
+
const { createIssue, getIssue } = await import("../repository/issues.js");
|
|
15
|
+
const { listArtifactsForIssueByKind } = await import("../repository/artifacts-for-issue.js");
|
|
16
|
+
const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
|
|
17
|
+
const { claimWorkItem } = await import("../repository/work-items.js");
|
|
18
|
+
const { startWorkflow, applyCompletion } = await import("./commands.js");
|
|
19
|
+
const { ReviewerResult } = await import("./reviewer-result.js");
|
|
20
|
+
const { setMergePrForTests, clearFinalizeInflightForTests } = await import("./auto-merge.js");
|
|
21
|
+
const { clearWorkflowStateCacheForTests } = await import("../adapters/linear-sync.js");
|
|
22
|
+
const { verifyLinearPostMerge, setPostMergeDelaysForTests, LINEAR_MERGE_VERIFY_ARTIFACT_KIND, linearMergeStaleRequestId, drainLinearPostMergeForTests, resolveLinearMergeStaleAction, isLinearMergeStaleAction, } = await import("./linear-merge-verify.js");
|
|
23
|
+
/** Real local checkout so resolveAutoMergeCwd accepts the default legacy repo. */
|
|
24
|
+
let fixtureRepo = "";
|
|
25
|
+
function initFixtureRepo() {
|
|
26
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not362-repo-"));
|
|
27
|
+
execFileSync("git", ["init", "-b", "main"], { cwd: dir });
|
|
28
|
+
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir });
|
|
29
|
+
execFileSync("git", ["config", "user.name", "Test"], { cwd: dir });
|
|
30
|
+
fs.writeFileSync(path.join(dir, "README.md"), "hi\n");
|
|
31
|
+
execFileSync("git", ["add", "."], { cwd: dir });
|
|
32
|
+
execFileSync("git", ["commit", "-m", "init"], { cwd: dir });
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
before(() => {
|
|
36
|
+
migrate();
|
|
37
|
+
fixtureRepo = initFixtureRepo();
|
|
38
|
+
setPostMergeDelaysForTests([0, 0]);
|
|
39
|
+
});
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
getDb().exec(`
|
|
42
|
+
DELETE FROM work_items;
|
|
43
|
+
DELETE FROM human_actions;
|
|
44
|
+
DELETE FROM workflow_events;
|
|
45
|
+
DELETE FROM findings;
|
|
46
|
+
DELETE FROM review_publications;
|
|
47
|
+
DELETE FROM worker_sessions;
|
|
48
|
+
DELETE FROM artifacts;
|
|
49
|
+
DELETE FROM usage_events;
|
|
50
|
+
DELETE FROM workflow_instances;
|
|
51
|
+
DELETE FROM issues;
|
|
52
|
+
`);
|
|
53
|
+
clearFinalizeInflightForTests();
|
|
54
|
+
clearWorkflowStateCacheForTests();
|
|
55
|
+
setMergePrForTests(async () => ({ ok: true }));
|
|
56
|
+
});
|
|
57
|
+
const realFetch = globalThis.fetch;
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
setMergePrForTests(null);
|
|
60
|
+
clearFinalizeInflightForTests();
|
|
61
|
+
globalThis.fetch = realFetch;
|
|
62
|
+
});
|
|
63
|
+
const PR_URL = "https://github.com/o/r/pull/42";
|
|
64
|
+
function newLinearIssue() {
|
|
65
|
+
return createIssue({
|
|
66
|
+
title: "NOT-362: linear work",
|
|
67
|
+
description: "d",
|
|
68
|
+
acceptanceCriteria: "It works",
|
|
69
|
+
repo: fixtureRepo,
|
|
70
|
+
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
71
|
+
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
72
|
+
baseBranch: "main",
|
|
73
|
+
maxReviewRounds: 3,
|
|
74
|
+
maxInfraAttempts: 3,
|
|
75
|
+
source: "linear",
|
|
76
|
+
externalId: "lin-uuid-1",
|
|
77
|
+
externalLabel: "NOT-362",
|
|
78
|
+
externalUrl: "https://linear.app/not-so-fat/issue/NOT-362/work",
|
|
79
|
+
autoMerge: true,
|
|
80
|
+
}).id;
|
|
81
|
+
}
|
|
82
|
+
const okReview = ReviewerResult.parse({
|
|
83
|
+
verdict: "approved",
|
|
84
|
+
baseSha: "b",
|
|
85
|
+
headSha: "h",
|
|
86
|
+
acceptanceCriteriaAssessment: "ok",
|
|
87
|
+
evidenceAssessment: "ok",
|
|
88
|
+
findings: [],
|
|
89
|
+
risks: [],
|
|
90
|
+
});
|
|
91
|
+
/** Drive the full merged transition: developer handoff + reviewer approve → merge → done. */
|
|
92
|
+
async function mergeThroughReview(issueId) {
|
|
93
|
+
startWorkflow(issueId);
|
|
94
|
+
const handoff = claimWorkItem(`test-${issueId}`, { leaseMs: 60_000 });
|
|
95
|
+
assert.ok(handoff && handoff.issueId === issueId);
|
|
96
|
+
await applyCompletion(handoff.id, handoff.leaseToken, {
|
|
97
|
+
kind: "clean_handoff",
|
|
98
|
+
branch: "issue-1",
|
|
99
|
+
headSha: "abc123",
|
|
100
|
+
baseSha: "base1",
|
|
101
|
+
prNumber: 42,
|
|
102
|
+
prUrl: PR_URL,
|
|
103
|
+
});
|
|
104
|
+
const review = claimWorkItem(`test-${issueId}-r`, { leaseMs: 60_000 });
|
|
105
|
+
assert.ok(review && review.issueId === issueId);
|
|
106
|
+
const result = await applyCompletion(review.id, review.leaseToken, { kind: "verdict", result: okReview });
|
|
107
|
+
// The merged transition triggers the Linear post-merge check fire-and-forget
|
|
108
|
+
// (finalizeAutoMerge must not hold the merge caller for the retry window) —
|
|
109
|
+
// await it here so assertions below do not race it.
|
|
110
|
+
await drainLinearPostMergeForTests();
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
function stubLinear(state) {
|
|
114
|
+
globalThis.fetch = (async (_url, init) => {
|
|
115
|
+
const body = JSON.parse(String(init?.body ?? "{}"));
|
|
116
|
+
const query = body.query ?? "";
|
|
117
|
+
if (query.includes("PostMergeState")) {
|
|
118
|
+
state.calls.reads += 1;
|
|
119
|
+
return {
|
|
120
|
+
ok: true,
|
|
121
|
+
status: 200,
|
|
122
|
+
headers: new Headers(),
|
|
123
|
+
text: async () => JSON.stringify({ data: { issue: state.linearIssue } }),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (query.includes("TeamStates")) {
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
status: 200,
|
|
130
|
+
headers: new Headers(),
|
|
131
|
+
text: async () => JSON.stringify({
|
|
132
|
+
data: {
|
|
133
|
+
team: {
|
|
134
|
+
states: {
|
|
135
|
+
nodes: state.noCompletedState
|
|
136
|
+
? [{ id: "s-todo", name: "Todo" }]
|
|
137
|
+
: [{ id: "s-done", name: "Done" }],
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (query.includes("commentCreate")) {
|
|
145
|
+
state.calls.comments += 1;
|
|
146
|
+
return {
|
|
147
|
+
ok: true,
|
|
148
|
+
status: 200,
|
|
149
|
+
headers: new Headers(),
|
|
150
|
+
text: async () => JSON.stringify({ data: { commentCreate: { success: true } } }),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (query.includes("issueUpdate")) {
|
|
154
|
+
state.calls.stateWrites += 1;
|
|
155
|
+
if (state.failStateWriteWith)
|
|
156
|
+
throw new Error(state.failStateWriteWith);
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
status: 200,
|
|
160
|
+
headers: new Headers(),
|
|
161
|
+
text: async () => JSON.stringify({ data: { issueUpdate: { success: true } } }),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
throw new Error(`unexpected Linear query: ${query.slice(0, 80)}`);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
function freshStub(linearIssue) {
|
|
168
|
+
return { linearIssue, failStateWriteWith: null, noCompletedState: false, calls: { reads: 0, comments: 0, stateWrites: 0 } };
|
|
169
|
+
}
|
|
170
|
+
const startedIssue = () => ({
|
|
171
|
+
identifier: "NOT-362",
|
|
172
|
+
url: "https://linear.app/not-so-fat/issue/NOT-362/work",
|
|
173
|
+
state: { name: "In Progress", type: "started" },
|
|
174
|
+
team: { id: "team-1" },
|
|
175
|
+
attachments: { nodes: [] },
|
|
176
|
+
});
|
|
177
|
+
const staleIssue = (attachments = []) => ({
|
|
178
|
+
identifier: "NOT-362",
|
|
179
|
+
url: "https://linear.app/not-so-fat/issue/NOT-362/work",
|
|
180
|
+
state: { name: "Backlog", type: "backlog" },
|
|
181
|
+
team: { id: "team-1" },
|
|
182
|
+
attachments: { nodes: attachments },
|
|
183
|
+
});
|
|
184
|
+
function verifyArtifacts(issueId) {
|
|
185
|
+
return listArtifactsForIssueByKind(issueId, LINEAR_MERGE_VERIFY_ARTIFACT_KIND);
|
|
186
|
+
}
|
|
187
|
+
test("advanced via state type: merged transition records one artifact, no write, no action", async () => {
|
|
188
|
+
const state = freshStub(startedIssue());
|
|
189
|
+
stubLinear(state);
|
|
190
|
+
const issueId = newLinearIssue();
|
|
191
|
+
await mergeThroughReview(issueId);
|
|
192
|
+
assert.equal(getIssue(issueId).status, "done");
|
|
193
|
+
const artifacts = verifyArtifacts(issueId);
|
|
194
|
+
assert.equal(artifacts.length, 1);
|
|
195
|
+
const content = JSON.parse(artifacts[0].contentJson);
|
|
196
|
+
assert.equal(content.outcome, "advanced");
|
|
197
|
+
assert.equal(content.advancedVia, "state");
|
|
198
|
+
assert.equal(state.calls.stateWrites, 0);
|
|
199
|
+
assert.equal(state.calls.comments, 0);
|
|
200
|
+
assert.deepEqual(listHumanActionsForIssue(issueId).filter((a) => a.status === "open"), []);
|
|
201
|
+
});
|
|
202
|
+
test("advanced via attachment: Backlog state with the merged PR attached verifies", async () => {
|
|
203
|
+
const state = freshStub(staleIssue([{ url: PR_URL }]));
|
|
204
|
+
stubLinear(state);
|
|
205
|
+
const issueId = newLinearIssue();
|
|
206
|
+
await mergeThroughReview(issueId);
|
|
207
|
+
assert.equal(getIssue(issueId).status, "done");
|
|
208
|
+
const artifacts = verifyArtifacts(issueId);
|
|
209
|
+
assert.equal(artifacts.length, 1);
|
|
210
|
+
const content = JSON.parse(artifacts[0].contentJson);
|
|
211
|
+
assert.equal(content.outcome, "advanced");
|
|
212
|
+
assert.equal(content.advancedVia, "attachment");
|
|
213
|
+
assert.equal(state.calls.stateWrites, 0);
|
|
214
|
+
assert.equal(state.calls.comments, 0);
|
|
215
|
+
assert.deepEqual(listHumanActionsForIssue(issueId).filter((a) => a.status === "open"), []);
|
|
216
|
+
});
|
|
217
|
+
test("stale twice: exactly one completed-state write plus one comment, issue stays done", async () => {
|
|
218
|
+
const state = freshStub(staleIssue());
|
|
219
|
+
stubLinear(state);
|
|
220
|
+
const issueId = newLinearIssue();
|
|
221
|
+
const first = await mergeThroughReview(issueId);
|
|
222
|
+
// The merged transition itself succeeds — the post-check never fails it.
|
|
223
|
+
assert.equal(first.applied, true);
|
|
224
|
+
assert.equal(getIssue(issueId).status, "done");
|
|
225
|
+
// Re-running the check must not write twice (the completed issue has no
|
|
226
|
+
// active workflow left, so the re-run goes through the check itself).
|
|
227
|
+
const second = await verifyLinearPostMerge(issueId);
|
|
228
|
+
assert.equal(second.checked, true);
|
|
229
|
+
assert.equal(getIssue(issueId).status, "done");
|
|
230
|
+
assert.equal(state.calls.stateWrites, 1);
|
|
231
|
+
assert.equal(state.calls.comments, 1);
|
|
232
|
+
const artifacts = verifyArtifacts(issueId);
|
|
233
|
+
assert.equal(artifacts.length, 2);
|
|
234
|
+
const contents = artifacts.map((a) => JSON.parse(a.contentJson));
|
|
235
|
+
assert.ok(contents.every((c) => c.outcome === "stale"));
|
|
236
|
+
assert.ok(contents.some((c) => c.fallback === "written"));
|
|
237
|
+
});
|
|
238
|
+
test("concurrent stale checks join one in-flight run: single fallback write", async () => {
|
|
239
|
+
const state = freshStub(staleIssue());
|
|
240
|
+
stubLinear(state);
|
|
241
|
+
// A done issue reached without the merge path (which would already have run
|
|
242
|
+
// the check): overlapping checks must share one in-flight run instead of
|
|
243
|
+
// each writing the fallback.
|
|
244
|
+
const { transitionIssue } = await import("../repository/issues.js");
|
|
245
|
+
const issueId = newLinearIssue();
|
|
246
|
+
transitionIssue(issueId, "developing", { prUrl: PR_URL });
|
|
247
|
+
transitionIssue(issueId, "reviewing");
|
|
248
|
+
transitionIssue(issueId, "final_review");
|
|
249
|
+
transitionIssue(issueId, "done");
|
|
250
|
+
const [first, second] = await Promise.all([
|
|
251
|
+
verifyLinearPostMerge(issueId),
|
|
252
|
+
verifyLinearPostMerge(issueId),
|
|
253
|
+
]);
|
|
254
|
+
assert.equal(first.checked, true);
|
|
255
|
+
assert.equal(second.checked, true);
|
|
256
|
+
assert.equal(getIssue(issueId).status, "done");
|
|
257
|
+
assert.equal(state.calls.stateWrites, 1);
|
|
258
|
+
assert.equal(state.calls.comments, 1);
|
|
259
|
+
});
|
|
260
|
+
test("failed fallback twice: exactly one open human action naming identifier, PR URL, state", async () => {
|
|
261
|
+
const state = freshStub(staleIssue());
|
|
262
|
+
state.failStateWriteWith = "no write access";
|
|
263
|
+
stubLinear(state);
|
|
264
|
+
const issueId = newLinearIssue();
|
|
265
|
+
const first = await mergeThroughReview(issueId);
|
|
266
|
+
// The merged transition itself succeeds — the post-check never fails it.
|
|
267
|
+
assert.equal(first.applied, true);
|
|
268
|
+
assert.equal(getIssue(issueId).status, "done");
|
|
269
|
+
const rerun = await verifyLinearPostMerge(issueId);
|
|
270
|
+
assert.equal(rerun.checked, true);
|
|
271
|
+
assert.equal(getIssue(issueId).status, "done");
|
|
272
|
+
const open = listHumanActionsForIssue(issueId).filter((a) => a.status === "open");
|
|
273
|
+
assert.equal(open.length, 1);
|
|
274
|
+
const action = open[0];
|
|
275
|
+
assert.equal(action.actionType, "policy_escalation");
|
|
276
|
+
assert.equal(action.requestId, linearMergeStaleRequestId(issueId));
|
|
277
|
+
assert.match(action.reason, /NOT-362/);
|
|
278
|
+
assert.ok(action.reason.includes(PR_URL), "names the merged PR URL");
|
|
279
|
+
assert.match(action.reason, /Backlog/);
|
|
280
|
+
});
|
|
281
|
+
test("unavailable fallback (no completed state): one open human action, issue stays done", async () => {
|
|
282
|
+
const state = freshStub(staleIssue());
|
|
283
|
+
state.noCompletedState = true;
|
|
284
|
+
stubLinear(state);
|
|
285
|
+
const issueId = newLinearIssue();
|
|
286
|
+
const first = await mergeThroughReview(issueId);
|
|
287
|
+
// The merged transition itself succeeds — the post-check never fails it.
|
|
288
|
+
assert.equal(first.applied, true);
|
|
289
|
+
await verifyLinearPostMerge(issueId);
|
|
290
|
+
assert.equal(getIssue(issueId).status, "done");
|
|
291
|
+
assert.equal(state.calls.stateWrites, 0);
|
|
292
|
+
const open = listHumanActionsForIssue(issueId).filter((a) => a.status === "open");
|
|
293
|
+
assert.equal(open.length, 1);
|
|
294
|
+
assert.match(open[0].reason, /NOT-362/);
|
|
295
|
+
assert.ok(open[0].reason.includes(PR_URL));
|
|
296
|
+
});
|
|
297
|
+
test("stale notice resolves via acknowledge: closes without touching the done issue", async () => {
|
|
298
|
+
const state = freshStub(staleIssue());
|
|
299
|
+
state.failStateWriteWith = "no write access";
|
|
300
|
+
stubLinear(state);
|
|
301
|
+
const issueId = newLinearIssue();
|
|
302
|
+
await mergeThroughReview(issueId);
|
|
303
|
+
const open = listHumanActionsForIssue(issueId).filter((a) => a.status === "open");
|
|
304
|
+
assert.equal(open.length, 1);
|
|
305
|
+
assert.equal(isLinearMergeStaleAction(open[0]), true);
|
|
306
|
+
const resolved = resolveLinearMergeStaleAction(open[0].id, "tester", "acknowledge");
|
|
307
|
+
assert.equal(resolved.ok, true);
|
|
308
|
+
if (!resolved.ok)
|
|
309
|
+
return;
|
|
310
|
+
assert.equal(resolved.issueStatus, "done");
|
|
311
|
+
assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 0, "acknowledge closes the notice");
|
|
312
|
+
assert.equal(getIssue(issueId).status, "done", "resolving the notice never moves the done issue");
|
|
313
|
+
const again = resolveLinearMergeStaleAction(open[0].id, "tester", "acknowledge");
|
|
314
|
+
assert.equal(again.ok, false);
|
|
315
|
+
if (again.ok)
|
|
316
|
+
return;
|
|
317
|
+
assert.equal(again.code, 409);
|
|
318
|
+
});
|
|
319
|
+
test("stale notice recheck after a hand advance clears with an advanced artifact", async () => {
|
|
320
|
+
const state = freshStub(staleIssue());
|
|
321
|
+
state.failStateWriteWith = "no write access";
|
|
322
|
+
stubLinear(state);
|
|
323
|
+
const issueId = newLinearIssue();
|
|
324
|
+
await mergeThroughReview(issueId);
|
|
325
|
+
const open = listHumanActionsForIssue(issueId).filter((a) => a.status === "open");
|
|
326
|
+
assert.equal(open.length, 1);
|
|
327
|
+
// The human advances the Linear issue by hand; re-check must see it.
|
|
328
|
+
state.linearIssue = startedIssue();
|
|
329
|
+
state.failStateWriteWith = null;
|
|
330
|
+
const rechecked = resolveLinearMergeStaleAction(open[0].id, "tester", "recheck");
|
|
331
|
+
assert.equal(rechecked.ok, true);
|
|
332
|
+
if (!rechecked.ok)
|
|
333
|
+
return;
|
|
334
|
+
assert.equal(rechecked.rechecked, true);
|
|
335
|
+
await drainLinearPostMergeForTests();
|
|
336
|
+
assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 0, "a verified re-check raises no new notice");
|
|
337
|
+
const contents = verifyArtifacts(issueId).map((a) => JSON.parse(a.contentJson));
|
|
338
|
+
assert.ok(contents.some((c) => c.outcome === "stale"), "the original stale verdict is kept");
|
|
339
|
+
assert.ok(contents.some((c) => c.outcome === "advanced" && c.advancedVia === "state"), "the re-check records the hand-advanced verdict");
|
|
340
|
+
assert.equal(getIssue(issueId).status, "done");
|
|
341
|
+
});
|
|
342
|
+
test("stale-notice resolution rejects unknown actions and bad choices", async () => {
|
|
343
|
+
const missing = resolveLinearMergeStaleAction("no-such-action", "tester", "acknowledge");
|
|
344
|
+
assert.equal(missing.ok, false);
|
|
345
|
+
if (missing.ok)
|
|
346
|
+
return;
|
|
347
|
+
assert.equal(missing.code, 404);
|
|
348
|
+
const state = freshStub(staleIssue());
|
|
349
|
+
state.failStateWriteWith = "no write access";
|
|
350
|
+
stubLinear(state);
|
|
351
|
+
const issueId = newLinearIssue();
|
|
352
|
+
await mergeThroughReview(issueId);
|
|
353
|
+
const open = listHumanActionsForIssue(issueId).filter((a) => a.status === "open");
|
|
354
|
+
assert.equal(open.length, 1);
|
|
355
|
+
const badChoice = resolveLinearMergeStaleAction(open[0].id, "tester", "resume");
|
|
356
|
+
assert.equal(badChoice.ok, false);
|
|
357
|
+
if (badChoice.ok)
|
|
358
|
+
return;
|
|
359
|
+
assert.equal(badChoice.code, 400);
|
|
360
|
+
assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 1, "a rejected choice leaves the notice open");
|
|
361
|
+
const { createHumanAction } = await import("../repository/human-actions.js");
|
|
362
|
+
const other = createHumanAction({
|
|
363
|
+
issueId,
|
|
364
|
+
workflowInstanceId: null,
|
|
365
|
+
actionType: "policy_escalation",
|
|
366
|
+
reason: "something else",
|
|
367
|
+
question: "something else?",
|
|
368
|
+
responseOptions: [{ choice: "close", label: "Close" }],
|
|
369
|
+
});
|
|
370
|
+
assert.equal(isLinearMergeStaleAction(other), false);
|
|
371
|
+
const wrongKind = resolveLinearMergeStaleAction(other.id, "tester", "acknowledge");
|
|
372
|
+
assert.equal(wrongKind.ok, false);
|
|
373
|
+
if (wrongKind.ok)
|
|
374
|
+
return;
|
|
375
|
+
assert.equal(wrongKind.code, 400);
|
|
376
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sourceAttachmentsDeveloperSection, sourceAttachmentsReviewerSection, } from "./source-attachments.js";
|
|
1
2
|
import { MUSE_SANDBOX_CAPABILITIES } from "../runners/muse-code-args.js";
|
|
2
3
|
import { checkMuseVisualQa, museVisualQaPromptSection, } from "../adapters/muse-visual-qa.js";
|
|
3
4
|
import { formatVerificationReceiptSection, } from "./verification-receipt.js";
|
|
@@ -260,7 +261,11 @@ export function buildDeveloperPrompt(input) {
|
|
|
260
261
|
// stay byte-for-byte.
|
|
261
262
|
...(input.museDeveloper
|
|
262
263
|
? tagSemanticsDeveloperSection(input.taskSnapshot.acceptanceCriteria)
|
|
263
|
-
: []), ...executionContractSection(input.taskSnapshot.executionContract)
|
|
264
|
+
: []), ...executionContractSection(input.taskSnapshot.executionContract),
|
|
265
|
+
// NOT-364: frozen source-attachment manifest — local file paths plus
|
|
266
|
+
// external-link metadata and the trust boundary. Empty/absent renders
|
|
267
|
+
// nothing so attachment-free prompts stay byte-for-byte.
|
|
268
|
+
...sourceAttachmentsDeveloperSection(input.taskSnapshot.sourceAttachments));
|
|
264
269
|
if (input.findings?.length) {
|
|
265
270
|
parts.push(`## Findings to address`);
|
|
266
271
|
for (const f of input.findings) {
|
|
@@ -385,6 +390,9 @@ export function buildReviewerPrompt(input) {
|
|
|
385
390
|
// the tags, so untagged reviewer prompts stay byte-for-byte.
|
|
386
391
|
...tagSemanticsReviewerSection(input.taskSnapshot.acceptanceCriteria),
|
|
387
392
|
...executionContractSection(input.taskSnapshot.executionContract),
|
|
393
|
+
// NOT-364: the immutable manifest as metadata only — reviewers have no
|
|
394
|
+
// shell, so no file bytes and no worktree path are promised.
|
|
395
|
+
...sourceAttachmentsReviewerSection(input.taskSnapshot.sourceAttachments),
|
|
388
396
|
`## Diff (base ${input.baseSha.slice(0, 8)} → head ${input.headSha.slice(0, 8)})`,
|
|
389
397
|
"```diff",
|
|
390
398
|
formatDiffForPrompt(input.diff).text,
|
|
@@ -483,3 +483,96 @@ test("NOT-316: untagged reviewer prompt carries no tag-judging section", () => {
|
|
|
483
483
|
assert.doesNotMatch(without, /## Criterion tags/);
|
|
484
484
|
assert.equal(buildReviewerPrompt(reviewerBase), without);
|
|
485
485
|
});
|
|
486
|
+
// NOT-364: the frozen attachment manifest renders in the developer prompt as
|
|
487
|
+
// local file paths plus link metadata with the trust boundary — and stays out
|
|
488
|
+
// entirely when the manifest is empty.
|
|
489
|
+
test("NOT-364: developer prompt lists frozen attachment files and links", () => {
|
|
490
|
+
const withManifest = buildDeveloperPrompt({
|
|
491
|
+
taskSnapshot: {
|
|
492
|
+
...taskSnapshot,
|
|
493
|
+
sourceAttachments: [
|
|
494
|
+
{
|
|
495
|
+
linearAttachmentId: "att-file-1",
|
|
496
|
+
kind: "file",
|
|
497
|
+
title: "repro.tar.gz",
|
|
498
|
+
safeFileName: "repro.tar.gz",
|
|
499
|
+
blobPath: "/blobs/repro.tar.gz",
|
|
500
|
+
sizeBytes: 18,
|
|
501
|
+
sha256: "ab".repeat(32),
|
|
502
|
+
url: "https://uploads.linear.app/a/repro.tar.gz",
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
linearAttachmentId: "att-link-1",
|
|
506
|
+
kind: "link",
|
|
507
|
+
title: "Design doc",
|
|
508
|
+
url: "https://docs.example.com/x",
|
|
509
|
+
},
|
|
510
|
+
],
|
|
511
|
+
},
|
|
512
|
+
round: 1,
|
|
513
|
+
});
|
|
514
|
+
assert.match(withManifest, /## Source attachments \(untrusted ticket inputs/);
|
|
515
|
+
assert.match(withManifest, /`\.agent-dealer-inputs\/linear\/repro\.tar\.gz`/);
|
|
516
|
+
assert.match(withManifest, /link: "Design doc" — https:\/\/docs\.example\.com\/x/);
|
|
517
|
+
assert.match(withManifest, /never commit them/);
|
|
518
|
+
assert.match(withManifest, /never extract archives outside a fresh contained directory/);
|
|
519
|
+
// Repair rounds see the same frozen section.
|
|
520
|
+
const repair = buildDeveloperPrompt({
|
|
521
|
+
taskSnapshot: {
|
|
522
|
+
...taskSnapshot,
|
|
523
|
+
sourceAttachments: [
|
|
524
|
+
{
|
|
525
|
+
linearAttachmentId: "att-file-1",
|
|
526
|
+
kind: "file",
|
|
527
|
+
title: "repro.tar.gz",
|
|
528
|
+
safeFileName: "repro.tar.gz",
|
|
529
|
+
blobPath: "/blobs/repro.tar.gz",
|
|
530
|
+
sizeBytes: 18,
|
|
531
|
+
sha256: "ab".repeat(32),
|
|
532
|
+
url: "https://uploads.linear.app/a/repro.tar.gz",
|
|
533
|
+
},
|
|
534
|
+
],
|
|
535
|
+
},
|
|
536
|
+
round: 2,
|
|
537
|
+
});
|
|
538
|
+
assert.match(repair, /`\.agent-dealer-inputs\/linear\/repro\.tar\.gz`/);
|
|
539
|
+
});
|
|
540
|
+
test("NOT-364: attachment-free developer prompts render no source-attachment section", () => {
|
|
541
|
+
const without = buildDeveloperPrompt({ taskSnapshot, round: 1 });
|
|
542
|
+
assert.doesNotMatch(without, /## Source attachments/);
|
|
543
|
+
assert.equal(buildDeveloperPrompt({ taskSnapshot, round: 1 }), without);
|
|
544
|
+
});
|
|
545
|
+
// NOT-364: reviewers get the immutable manifest as metadata only — no
|
|
546
|
+
// worktree path, no server blob path, no file contents.
|
|
547
|
+
test("NOT-364: reviewer prompt carries manifest metadata without file paths", () => {
|
|
548
|
+
const prompt = buildReviewerPrompt({
|
|
549
|
+
...reviewerBase,
|
|
550
|
+
taskSnapshot: {
|
|
551
|
+
...taskSnapshot,
|
|
552
|
+
sourceAttachments: [
|
|
553
|
+
{
|
|
554
|
+
linearAttachmentId: "att-file-1",
|
|
555
|
+
kind: "file",
|
|
556
|
+
title: "repro.tar.gz",
|
|
557
|
+
safeFileName: "repro.tar.gz",
|
|
558
|
+
blobPath: "/blobs/repro.tar.gz",
|
|
559
|
+
sizeBytes: 18,
|
|
560
|
+
sha256: "ab".repeat(32),
|
|
561
|
+
url: "https://uploads.linear.app/a/repro.tar.gz",
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
linearAttachmentId: "att-link-1",
|
|
565
|
+
kind: "link",
|
|
566
|
+
title: "Design doc",
|
|
567
|
+
url: "https://docs.example.com/x",
|
|
568
|
+
},
|
|
569
|
+
],
|
|
570
|
+
},
|
|
571
|
+
});
|
|
572
|
+
assert.match(prompt, /## Source attachments \(frozen manifest — metadata only/);
|
|
573
|
+
assert.match(prompt, /"repro\.tar\.gz"/);
|
|
574
|
+
assert.doesNotMatch(prompt, /\.agent-dealer-inputs/);
|
|
575
|
+
assert.doesNotMatch(prompt, /\/blobs\//);
|
|
576
|
+
const without = buildReviewerPrompt(reviewerBase);
|
|
577
|
+
assert.doesNotMatch(without, /## Source attachments/);
|
|
578
|
+
});
|