agent-dealer 1.3.1 → 1.3.3
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/agent-health.test.js +52 -0
- package/bundle/server/dist/adapters/git-worktree.js +38 -1
- package/bundle/server/dist/adapters/github.js +50 -0
- package/bundle/server/dist/adapters/github.test.js +89 -1
- package/bundle/server/dist/capacity/service.js +66 -6
- package/bundle/server/dist/capacity/service.test.js +220 -0
- package/bundle/server/dist/coordinator/auto-merge.js +46 -9
- package/bundle/server/dist/coordinator/commands.js +89 -9
- package/bundle/server/dist/coordinator/commands.test.js +228 -2
- package/bundle/server/dist/coordinator/developer-effect.js +36 -0
- package/bundle/server/dist/coordinator/operator-verification.integration.test.js +102 -1
- package/bundle/server/dist/coordinator/product-scope-contract.test.js +249 -0
- package/bundle/server/dist/coordinator/prompts.js +57 -2
- package/bundle/server/dist/coordinator/prompts.test.js +236 -2
- package/bundle/server/dist/coordinator/reviewer-browser-probe.js +89 -10
- package/bundle/server/dist/coordinator/reviewer-browser-probe.test.js +100 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +30 -1
- package/bundle/server/dist/coordinator/reviewer-effect.test.js +54 -0
- package/bundle/server/dist/coordinator/reviewer-visual-evidence.js +594 -0
- package/bundle/server/dist/coordinator/reviewer-visual-evidence.test.js +496 -0
- package/bundle/server/dist/coordinator/source-reload.js +44 -9
- package/bundle/server/dist/coordinator/visual-qa-collection.test.js +619 -0
- package/bundle/server/dist/coordinator/visual-qa.js +630 -0
- package/bundle/server/dist/coordinator/visual-qa.test.js +493 -0
- package/bundle/server/dist/db/index.js +8 -0
- package/bundle/server/dist/db/schema.sql +5 -1
- package/bundle/server/dist/paths.js +24 -0
- package/bundle/server/dist/repository/issues.js +8 -1
- package/bundle/server/dist/repository/issues.test.js +34 -0
- package/bundle/server/dist/repository/runtime-availability.js +29 -11
- package/bundle/server/dist/routes/issues-execution-contract.test.js +293 -0
- package/bundle/server/dist/routes/issues-source-attachments.test.js +277 -0
- package/bundle/server/dist/routes/issues.js +23 -8
- package/bundle/server/dist/routes/issues.test.js +80 -0
- package/bundle/server/dist/runners/usage-cap.js +66 -15
- package/bundle/server/dist/runners/usage-cap.test.js +96 -2
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-B_pNUDRY.js +64 -0
- package/bundle/server/static-ui/index.html +1 -1
- package/bundle/shared/dist/runtime-capacity.d.ts +85 -0
- package/bundle/shared/dist/runtime-capacity.js +19 -0
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-Bz_c-UbO.js +0 -64
|
@@ -34,6 +34,7 @@ import { triggerLinearPostMerge } from "./linear-merge-verify.js";
|
|
|
34
34
|
import { startBaseAdvancedScan } from "./base-advanced-scan.js";
|
|
35
35
|
import { runHeldWork } from "../power/host-awake-lifecycle.js";
|
|
36
36
|
import { OPERATOR_VERIFICATION_RESPONSE_OPTIONS, formatOperatorCriteria, getOperatorCriteriaForIssue, hasOperatorVerificationForHead, operatorVerificationRequestId, } from "./operator-criteria.js";
|
|
37
|
+
import { readVisualEvidenceHold } from "./reviewer-visual-evidence.js";
|
|
37
38
|
const run = promisify(execFile);
|
|
38
39
|
/** Bound each `gh` shell-out so a hang cannot freeze the coordinator process. */
|
|
39
40
|
export const GH_MERGE_TIMEOUT_MS = 20_000;
|
|
@@ -339,6 +340,12 @@ async function finalizeAutoMergeOnce(issueId) {
|
|
|
339
340
|
startBaseAdvancedScan(getIssue(issueId) ?? issue);
|
|
340
341
|
return merged;
|
|
341
342
|
}
|
|
343
|
+
/** One visual-evidence hold, for the gate's reason/question. */
|
|
344
|
+
function formatVisualHold(hold) {
|
|
345
|
+
return (`CI visual evidence for this head is ${hold.state}: ${hold.reason} ` +
|
|
346
|
+
`Head: \`${hold.headSha}\`. ` +
|
|
347
|
+
`Missing/unusable artifacts: ${hold.missingArtifacts.map((name) => `\`${name}\``).join(", ")}.`);
|
|
348
|
+
}
|
|
342
349
|
/**
|
|
343
350
|
* NOT-314: the `[operator]` merge gate. After reviewer approve and before the
|
|
344
351
|
* `gh` merge: when the frozen snapshot holds operator criteria and no result is
|
|
@@ -347,9 +354,16 @@ async function finalizeAutoMergeOnce(issueId) {
|
|
|
347
354
|
* unmerged and the issue parks in `needs_human` with an `operator_verification`
|
|
348
355
|
* action listing each criterion and its command.
|
|
349
356
|
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
357
|
+
* NOT-384: a UI-affecting head whose recorded CI visual evidence is `missing`
|
|
358
|
+
* or `failed` holds at the same gate — same action type, same intent, same
|
|
359
|
+
* verified/waive/repair resolutions. One open action covers the whole head
|
|
360
|
+
* (criteria and visual items together), so one recorded result releases it;
|
|
361
|
+
* no repair round is ever queued for missing evidence alone.
|
|
362
|
+
*
|
|
363
|
+
* Returns null when the merge may proceed (no operator criteria, no visual
|
|
364
|
+
* hold, a recorded result for this head, or no workflow to gate). Idempotent:
|
|
365
|
+
* a second finalize for the same head reuses the open action instead of
|
|
366
|
+
* raising another.
|
|
353
367
|
*
|
|
354
368
|
* Fail-closed: when operator criteria exist, every non-proceed path returns a
|
|
355
369
|
* result (never null), so a missing head SHA or an unexpected status blocks the
|
|
@@ -364,9 +378,10 @@ export function gateOperatorVerification(issueId) {
|
|
|
364
378
|
if (!active)
|
|
365
379
|
return null;
|
|
366
380
|
const criteria = getOperatorCriteriaForIssue(current);
|
|
367
|
-
if (criteria.length === 0)
|
|
368
|
-
return null;
|
|
369
381
|
const head = current.headSha;
|
|
382
|
+
const visualHold = head ? readVisualEvidenceHold(issueId, head) : null;
|
|
383
|
+
if (criteria.length === 0 && !visualHold)
|
|
384
|
+
return null;
|
|
370
385
|
if (head && hasOperatorVerificationForHead(issueId, head))
|
|
371
386
|
return null;
|
|
372
387
|
if (current.status === "done")
|
|
@@ -447,6 +462,8 @@ export function gateOperatorVerification(issueId) {
|
|
|
447
462
|
triggerReflect: false,
|
|
448
463
|
};
|
|
449
464
|
}
|
|
465
|
+
// One open action covers the whole head — criteria items, visual hold, or
|
|
466
|
+
// both — so reuse is by head alone, never per hold flavor.
|
|
450
467
|
const existing = findOpenHumanAction(issueId, "operator_verification");
|
|
451
468
|
if (existing && operatorActionHeadSha(existing) === head) {
|
|
452
469
|
if (current.status !== "needs_human") {
|
|
@@ -465,15 +482,30 @@ export function gateOperatorVerification(issueId) {
|
|
|
465
482
|
};
|
|
466
483
|
}
|
|
467
484
|
const shortHead = head.slice(0, 8);
|
|
468
|
-
const
|
|
485
|
+
const criteriaReason = `Reviewer approved ${shortHead}, but ${criteria.length} acceptance ` +
|
|
469
486
|
`${criteria.length === 1 ? "criterion requires" : "criteria require"} a human operator ` +
|
|
470
487
|
`to verify (tagged [operator] in the frozen task snapshot) and no result is recorded ` +
|
|
471
488
|
`for this head. The PR stays unmerged until the result is recorded:\n` +
|
|
472
489
|
formatOperatorCriteria(criteria).join("\n");
|
|
490
|
+
// Criteria-only keeps the exact NOT-314 text; a visual hold appends its
|
|
491
|
+
// own section (or stands alone), always naming the head SHA and the
|
|
492
|
+
// missing artifacts.
|
|
493
|
+
const reason = !visualHold
|
|
494
|
+
? criteriaReason
|
|
495
|
+
: criteria.length === 0
|
|
496
|
+
? `Reviewer approved ${shortHead}, but the head changes the web app and no result is recorded ` +
|
|
497
|
+
`for this head. The PR stays unmerged until the result is recorded:\n` +
|
|
498
|
+
formatVisualHold(visualHold)
|
|
499
|
+
: `${criteriaReason}\nAdditionally, the head changes the web app: ${formatVisualHold(visualHold)}`;
|
|
473
500
|
transitionIssue(issueId, "needs_human", {
|
|
474
501
|
currentOwner: "human",
|
|
475
502
|
currentIntent: OPERATOR_VERIFICATION_INTENT,
|
|
476
503
|
});
|
|
504
|
+
const questionSuffix = visualHold && criteria.length === 0
|
|
505
|
+
? `Verify the rendered UI for this head by hand and paste what you checked, waive with a reason, ` +
|
|
506
|
+
`or send the work back for another repair round?`
|
|
507
|
+
: `Paste the probe output to record verification, waive with a reason, ` +
|
|
508
|
+
`or send the work back for another repair round?`;
|
|
477
509
|
// Head-pinned request_id: a concurrent finalize for the same head dedupes
|
|
478
510
|
// onto this action through createHumanAction's open-request conflict path;
|
|
479
511
|
// new commits after the gate get a fresh action with their own commands.
|
|
@@ -482,9 +514,14 @@ export function gateOperatorVerification(issueId) {
|
|
|
482
514
|
workflowInstanceId: active.id,
|
|
483
515
|
actionType: "operator_verification",
|
|
484
516
|
reason,
|
|
485
|
-
question: `${reason}\n\
|
|
486
|
-
|
|
487
|
-
|
|
517
|
+
question: `${reason}\n\n${questionSuffix}`,
|
|
518
|
+
evidence: {
|
|
519
|
+
operatorVerification: {
|
|
520
|
+
criteria,
|
|
521
|
+
headSha: head,
|
|
522
|
+
...(visualHold ? { visualEvidence: visualHold } : {}),
|
|
523
|
+
},
|
|
524
|
+
},
|
|
488
525
|
responseOptions: [...OPERATOR_VERIFICATION_RESPONSE_OPTIONS],
|
|
489
526
|
requestId: operatorVerificationRequestId(head),
|
|
490
527
|
});
|
|
@@ -103,6 +103,12 @@ export function getTaskSnapshot(issue) {
|
|
|
103
103
|
* NOT-358: the same parked-edit surface covers an open `policy_escalation` (e.g. after a
|
|
104
104
|
* developer budget failure) so the owner can swap the developer and/or reviewer agent
|
|
105
105
|
* profile and resume in the same worktree instead of recreating the issue.
|
|
106
|
+
*
|
|
107
|
+
* NOT-422: an open `product_scope_decision` on an active workflow is contract-editable
|
|
108
|
+
* the same way — title/description/acceptance-criteria only (no agent swap, see
|
|
109
|
+
* `parkedEditableFields`) — so the operator can adopt an amended contract and resume
|
|
110
|
+
* without aborting. A running session is checked separately by `applyParkedEdit` and
|
|
111
|
+
* the PATCH guard, which fail closed with 409.
|
|
106
112
|
*/
|
|
107
113
|
export function canEditParkedIssue(issue) {
|
|
108
114
|
if (issue.status !== "needs_human")
|
|
@@ -110,7 +116,8 @@ export function canEditParkedIssue(issue) {
|
|
|
110
116
|
if (!getActiveWorkflowInstance(issue.id))
|
|
111
117
|
return false;
|
|
112
118
|
if (!findOpenHumanAction(issue.id, "attempts_exhausted") &&
|
|
113
|
-
!findOpenHumanAction(issue.id, "policy_escalation")
|
|
119
|
+
!findOpenHumanAction(issue.id, "policy_escalation") &&
|
|
120
|
+
!findOpenHumanAction(issue.id, "product_scope_decision")) {
|
|
114
121
|
return false;
|
|
115
122
|
}
|
|
116
123
|
return !listWorkItemsForIssue(issue.id).some((w) => w.status === "pending" || w.status === "leased");
|
|
@@ -156,6 +163,36 @@ export const PARKED_EDITABLE_FIELDS = new Set([
|
|
|
156
163
|
"developerAgentId",
|
|
157
164
|
"reviewerAgentId",
|
|
158
165
|
]);
|
|
166
|
+
/**
|
|
167
|
+
* NOT-422: the task-text subset of `PARKED_EDITABLE_FIELDS`. A product-scope pause
|
|
168
|
+
* adopts an amended contract only — repository, agents, policy, rounds, PR, branch
|
|
169
|
+
* and worktree all stay exactly as the running workflow saw them, so an agent swap
|
|
170
|
+
* at that park is a 409, not a silent accept.
|
|
171
|
+
*/
|
|
172
|
+
export const PARKED_TASK_EDITABLE_FIELDS = new Set([
|
|
173
|
+
"title",
|
|
174
|
+
"description",
|
|
175
|
+
"acceptanceCriteria",
|
|
176
|
+
]);
|
|
177
|
+
/**
|
|
178
|
+
* NOT-422: which parked-edit fields the issue's open actions allow. An open
|
|
179
|
+
* `attempts_exhausted` or `policy_escalation` keeps the full re-scope-plus-swap
|
|
180
|
+
* surface; a pure `product_scope_decision` park (neither of those open) allows
|
|
181
|
+
* the task-text subset only.
|
|
182
|
+
*/
|
|
183
|
+
export function parkedEditableFields(issueId) {
|
|
184
|
+
if (findOpenHumanAction(issueId, "attempts_exhausted") ||
|
|
185
|
+
findOpenHumanAction(issueId, "policy_escalation")) {
|
|
186
|
+
return PARKED_EDITABLE_FIELDS;
|
|
187
|
+
}
|
|
188
|
+
return PARKED_TASK_EDITABLE_FIELDS;
|
|
189
|
+
}
|
|
190
|
+
/** Human-readable field list for the parked-edit 409, matching the allowed set. */
|
|
191
|
+
export function parkedEditableFieldsLabel(issueId) {
|
|
192
|
+
return parkedEditableFields(issueId) === PARKED_EDITABLE_FIELDS
|
|
193
|
+
? "title, description, acceptanceCriteria, developerAgentId and reviewerAgentId"
|
|
194
|
+
: "title, description and acceptanceCriteria";
|
|
195
|
+
}
|
|
159
196
|
/**
|
|
160
197
|
* NOT-358: applies a parked-issue edit (re-scope and/or agent swap) as one short
|
|
161
198
|
* transaction: guard re-check + agent-profile existence + row write + `issue.reassigned`
|
|
@@ -176,12 +213,13 @@ export function applyParkedEdit(issueId, patch) {
|
|
|
176
213
|
if (!getActiveWorkflowInstance(issueId) || !canEditParkedIssue(fresh)) {
|
|
177
214
|
return { ok: false, code: 409, error: "Cannot edit an issue with an active workflow" };
|
|
178
215
|
}
|
|
179
|
-
const
|
|
216
|
+
const allowed = parkedEditableFields(issueId);
|
|
217
|
+
const blocked = Object.keys(patch).filter((k) => !allowed.has(k));
|
|
180
218
|
if (blocked.length > 0) {
|
|
181
219
|
return {
|
|
182
220
|
ok: false,
|
|
183
221
|
code: 409,
|
|
184
|
-
error: `Only
|
|
222
|
+
error: `Only ${parkedEditableFieldsLabel(issueId)} can be edited while parked (got: ${blocked.join(", ")})`,
|
|
185
223
|
};
|
|
186
224
|
}
|
|
187
225
|
for (const role of ["developerAgentId", "reviewerAgentId"]) {
|
|
@@ -224,12 +262,30 @@ export function applyParkedEdit(issueId, patch) {
|
|
|
224
262
|
}
|
|
225
263
|
}
|
|
226
264
|
/**
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
* one artifact write carries both, so they can never diverge.
|
|
265
|
+
* Stable identity-plus-bytes key for one attachment manifest entry. blobPath is
|
|
266
|
+
* excluded: it is content-addressed from the compared fields under an
|
|
267
|
+
* environment-local root, so it must never count as a task change.
|
|
231
268
|
*/
|
|
232
|
-
function
|
|
269
|
+
function attachmentDiffKey(r) {
|
|
270
|
+
return JSON.stringify({
|
|
271
|
+
linearAttachmentId: r.linearAttachmentId,
|
|
272
|
+
kind: r.kind,
|
|
273
|
+
title: r.title,
|
|
274
|
+
safeFileName: r.safeFileName ?? null,
|
|
275
|
+
contentType: r.contentType ?? null,
|
|
276
|
+
sizeBytes: r.sizeBytes ?? null,
|
|
277
|
+
sha256: r.sha256 ?? null,
|
|
278
|
+
url: r.url,
|
|
279
|
+
subtitle: r.subtitle ?? null,
|
|
280
|
+
source: r.source ?? null,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Live-vs-frozen task diff behind both the resume re-freeze and the Issue Detail
|
|
285
|
+
* pending-snapshot notice: the fields a resume would re-freeze (empty = in sync).
|
|
286
|
+
* Read-only — freezing stays with `refreshTaskSnapshotIfEdited` below.
|
|
287
|
+
*/
|
|
288
|
+
export function diffTaskSnapshotFromLive(issue) {
|
|
233
289
|
const frozen = getTaskSnapshot(issue);
|
|
234
290
|
const liveContract = tryCompileContract(issue.description);
|
|
235
291
|
const changed = [];
|
|
@@ -242,6 +298,26 @@ function refreshTaskSnapshotIfEdited(issue) {
|
|
|
242
298
|
if (JSON.stringify(frozen.executionContract ?? null) !== JSON.stringify(liveContract ?? null)) {
|
|
243
299
|
changed.push("executionContract");
|
|
244
300
|
}
|
|
301
|
+
// NOT-422: a parked Linear reload can swap the attachment rows without
|
|
302
|
+
// touching the text. Without this, an attachment-only change never
|
|
303
|
+
// re-freezes on resume — the next round keeps the stale manifest (and its
|
|
304
|
+
// pruned blobs) and the amended inputs are never shown as pending.
|
|
305
|
+
const frozenManifest = (frozen.sourceAttachments ?? []).map(attachmentDiffKey).sort();
|
|
306
|
+
const liveManifest = listSourceAttachments(issue.id).map(attachmentDiffKey).sort();
|
|
307
|
+
if (JSON.stringify(frozenManifest) !== JSON.stringify(liveManifest)) {
|
|
308
|
+
changed.push("sourceAttachments");
|
|
309
|
+
}
|
|
310
|
+
return changed;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Re-freezes the snapshot when the operator edited title/description/criteria,
|
|
314
|
+
* the attachment manifest (or the contract compiled from the description);
|
|
315
|
+
* returns the changed fields (empty = no-op). Source and compiled contract
|
|
316
|
+
* always re-freeze atomically — one artifact write carries both, so they can
|
|
317
|
+
* never diverge.
|
|
318
|
+
*/
|
|
319
|
+
function refreshTaskSnapshotIfEdited(issue) {
|
|
320
|
+
const changed = diffTaskSnapshotFromLive(issue);
|
|
245
321
|
if (changed.length > 0)
|
|
246
322
|
freezeTaskSnapshot(issue);
|
|
247
323
|
return changed;
|
|
@@ -2057,8 +2133,12 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
|
|
|
2057
2133
|
// read the re-scoped task. An unedited issue writes nothing.
|
|
2058
2134
|
// NOT-358: a policy_escalation park is editable the same way (title/description/
|
|
2059
2135
|
// criteria as well as agent profiles), so its resume re-freezes too.
|
|
2136
|
+
// NOT-422: a product_scope_decision resume re-freezes the amended contract the
|
|
2137
|
+
// same way — the pre-start branch returned above, so this is the active-workflow
|
|
2138
|
+
// pause only. The one-shot scope note still rides the next developer payload.
|
|
2060
2139
|
if ((action.actionType === "attempts_exhausted" && resolution.choice === "retry") ||
|
|
2061
|
-
(action.actionType === "policy_escalation" && resolution.choice === "resume")
|
|
2140
|
+
(action.actionType === "policy_escalation" && resolution.choice === "resume") ||
|
|
2141
|
+
(action.actionType === "product_scope_decision" && resolution.choice === "resume")) {
|
|
2062
2142
|
const changedFields = refreshTaskSnapshotIfEdited(issueNow);
|
|
2063
2143
|
if (changedFields.length > 0) {
|
|
2064
2144
|
ev.emit("task_snapshot.refreshed", { actorType: "human", payload: { actionId: action.id, changedFields } });
|
|
@@ -12,12 +12,13 @@ const { parseProfileSnapshot } = await import("@agent-dealer/shared");
|
|
|
12
12
|
const { randomUUID } = await import("node:crypto");
|
|
13
13
|
const { createIssue, getIssue, updateIssue } = await import("../repository/issues.js");
|
|
14
14
|
const { listWorkflowEventsForIssue, getActiveWorkflowInstance } = await import("../repository/workflow-events.js");
|
|
15
|
-
const { createHumanAction, getHumanAction, listHumanActionsForIssue } = await import("../repository/human-actions.js");
|
|
15
|
+
const { createHumanAction, getHumanAction, listHumanActionsForIssue, resolveHumanAction } = await import("../repository/human-actions.js");
|
|
16
16
|
const { setResumeLiveHeadReaderForTests } = await import("./resume-live-head.js");
|
|
17
17
|
const { listFindingsForIssue, reconcileFinding } = await import("../repository/findings.js");
|
|
18
18
|
const { cancelWorkItem, claimWorkItem, listWorkItemsForIssue, getWorkItem, enqueueWorkItem } = await import("../repository/work-items.js");
|
|
19
19
|
const { createWorkerSession, completeSession, startSession, listWorkerSessionsForIssue } = await import("../repository/worker-sessions.js");
|
|
20
|
-
const { startWorkflow, applyCompletion, applyParkedEdit, capWaitForIssue, resolveHumanActionAndAdvance, resolveHumanActionAndAdvanceAsync, getTaskSnapshot, canEditParkedIssue, parkCapWait, TASK_SNAPSHOT_ARTIFACT_KIND, checkIssueReadiness, abortIssue } = await import("./commands.js");
|
|
20
|
+
const { startWorkflow, applyCompletion, applyParkedEdit, capWaitForIssue, resolveHumanActionAndAdvance, resolveHumanActionAndAdvanceAsync, getTaskSnapshot, diffTaskSnapshotFromLive, canEditParkedIssue, parkCapWait, TASK_SNAPSHOT_ARTIFACT_KIND, checkIssueReadiness, abortIssue } = await import("./commands.js");
|
|
21
|
+
const { replaceSourceAttachments, listSourceAttachments } = await import("../repository/source-attachments.js");
|
|
21
22
|
const { ReviewerResult } = await import("./reviewer-result.js");
|
|
22
23
|
const { listArtifactsForIssue } = await import("../repository/artifacts-for-issue.js");
|
|
23
24
|
const { setMergePrForTests, clearFinalizeInflightForTests } = await import("./auto-merge.js");
|
|
@@ -397,6 +398,231 @@ test("NOT-185: closing an edited attempts_exhausted issue does not re-freeze the
|
|
|
397
398
|
assert.equal(resolved.ok, true);
|
|
398
399
|
assert.equal(snapshotArtifacts(issueId).length, 1);
|
|
399
400
|
});
|
|
401
|
+
/** NOT-422: park at an active-workflow `product_scope_decision` the way a reviewer's escalated verdict does. */
|
|
402
|
+
async function parkAtProductScope() {
|
|
403
|
+
const issueId = newIssue();
|
|
404
|
+
startWorkflow(issueId);
|
|
405
|
+
await complete(issueId, cleanHandoff);
|
|
406
|
+
await complete(issueId, {
|
|
407
|
+
kind: "verdict",
|
|
408
|
+
result: { ...okReview("escalated"), productScopeQuestion: "Should deleted users retain sessions?" },
|
|
409
|
+
});
|
|
410
|
+
const action = listHumanActionsForIssue(issueId).find((a) => a.actionType === "product_scope_decision");
|
|
411
|
+
return { issueId, actionId: action.id };
|
|
412
|
+
}
|
|
413
|
+
test("NOT-422: canEditParkedIssue is true at an open product_scope_decision with nothing pending or leased", async () => {
|
|
414
|
+
const { issueId } = await parkAtProductScope();
|
|
415
|
+
assert.equal(canEditParkedIssue(getIssue(issueId)), true);
|
|
416
|
+
const instance = getActiveWorkflowInstance(issueId);
|
|
417
|
+
const item = enqueueWorkItem({
|
|
418
|
+
issueId,
|
|
419
|
+
workflowInstanceId: instance.id,
|
|
420
|
+
kind: "developer",
|
|
421
|
+
round: 1,
|
|
422
|
+
payload: {},
|
|
423
|
+
idempotencyKey: `${instance.id}:test:n422-pending`,
|
|
424
|
+
});
|
|
425
|
+
assert.equal(canEditParkedIssue(getIssue(issueId)), false, "a pending work item blocks the edit");
|
|
426
|
+
const leased = claim(issueId);
|
|
427
|
+
assert.equal(leased.id, item.id);
|
|
428
|
+
assert.equal(canEditParkedIssue(getIssue(issueId)), false, "a leased work item blocks the edit");
|
|
429
|
+
cancelWorkItem(item.id);
|
|
430
|
+
assert.equal(canEditParkedIssue(getIssue(issueId)), true);
|
|
431
|
+
});
|
|
432
|
+
test("NOT-422: other open action types keep the parked-edit surface closed", async () => {
|
|
433
|
+
const { issueId, actionId } = await parkAtProductScope();
|
|
434
|
+
// Close the scope decision without advancing (repository-level): needs_human +
|
|
435
|
+
// active workflow + no open action is not contract-editable.
|
|
436
|
+
resolveHumanAction(actionId, "test", { choice: "resume" });
|
|
437
|
+
assert.equal(getIssue(issueId).status, "needs_human");
|
|
438
|
+
assert.equal(canEditParkedIssue(getIssue(issueId)), false, "no open action");
|
|
439
|
+
// And a non-contract-editable open action does not open it either.
|
|
440
|
+
createHumanAction({
|
|
441
|
+
issueId,
|
|
442
|
+
actionType: "final_review",
|
|
443
|
+
reason: "r",
|
|
444
|
+
question: "q",
|
|
445
|
+
responseOptions: [{ choice: "merge", label: "Merge" }],
|
|
446
|
+
});
|
|
447
|
+
assert.equal(canEditParkedIssue(getIssue(issueId)), false, "final_review is not contract-editable");
|
|
448
|
+
// applyParkedEdit fails closed the same way, with no row or event change.
|
|
449
|
+
const before = getIssue(issueId);
|
|
450
|
+
const eventsBefore = listWorkflowEventsForIssue(issueId).length;
|
|
451
|
+
const refused = applyParkedEdit(issueId, { title: "Nope" });
|
|
452
|
+
assert.equal(refused.ok, false);
|
|
453
|
+
if (refused.ok !== false)
|
|
454
|
+
return;
|
|
455
|
+
assert.equal(refused.code, 409);
|
|
456
|
+
assert.deepEqual(getIssue(issueId), before);
|
|
457
|
+
assert.equal(listWorkflowEventsForIssue(issueId).length, eventsBefore);
|
|
458
|
+
});
|
|
459
|
+
test("NOT-422: applyParkedEdit at a scope pause accepts task text but rejects swaps, locked fields, pending items and running sessions", async () => {
|
|
460
|
+
const { issueId, actionId } = await parkAtProductScope();
|
|
461
|
+
const before = getIssue(issueId);
|
|
462
|
+
const edited = applyParkedEdit(issueId, {
|
|
463
|
+
title: "Rescoped title",
|
|
464
|
+
description: "d2",
|
|
465
|
+
acceptanceCriteria: "It works, and also does Y",
|
|
466
|
+
});
|
|
467
|
+
assert.equal(edited.ok, true);
|
|
468
|
+
if (edited.ok !== true)
|
|
469
|
+
return;
|
|
470
|
+
assert.equal(edited.issue.title, "Rescoped title");
|
|
471
|
+
assert.equal(edited.issue.description, "d2");
|
|
472
|
+
assert.equal(edited.issue.acceptanceCriteria, "It works, and also does Y");
|
|
473
|
+
// Everything else stays as the running workflow saw it, the scope action
|
|
474
|
+
// stays open, and the snapshot still carries the old text until Resume.
|
|
475
|
+
assert.equal(edited.issue.repo, before.repo);
|
|
476
|
+
assert.equal(edited.issue.baseBranch, before.baseBranch);
|
|
477
|
+
assert.equal(edited.issue.developerAgentId, before.developerAgentId);
|
|
478
|
+
assert.equal(edited.issue.reviewerAgentId, before.reviewerAgentId);
|
|
479
|
+
assert.equal(edited.issue.branch, before.branch);
|
|
480
|
+
assert.equal(edited.issue.headSha, before.headSha);
|
|
481
|
+
assert.equal(getHumanAction(actionId).status, "open");
|
|
482
|
+
assert.equal(snapshotArtifacts(issueId).length, 1);
|
|
483
|
+
assert.equal(getTaskSnapshot(getIssue(issueId)).description, "d");
|
|
484
|
+
// An agent swap at a scope pause is a 409 — the task-text allowlist names no roles.
|
|
485
|
+
const swap = applyParkedEdit(issueId, { developerAgentId: BUILTIN_AGENT_CURSOR_ID });
|
|
486
|
+
assert.equal(swap.ok, false);
|
|
487
|
+
if (swap.ok !== false)
|
|
488
|
+
return;
|
|
489
|
+
assert.equal(swap.code, 409);
|
|
490
|
+
assert.match(swap.error, /Only title, description and acceptanceCriteria can be edited while parked/);
|
|
491
|
+
assert.equal(getIssue(issueId).developerAgentId, before.developerAgentId);
|
|
492
|
+
for (const patch of [{ repo: "acme/other" }, { maxReviewRounds: 5 }, { autoMerge: true }]) {
|
|
493
|
+
const blocked = applyParkedEdit(issueId, patch);
|
|
494
|
+
assert.equal(blocked.ok, false, JSON.stringify(patch));
|
|
495
|
+
if (blocked.ok !== false)
|
|
496
|
+
return;
|
|
497
|
+
assert.equal(blocked.code, 409);
|
|
498
|
+
}
|
|
499
|
+
// A pending work item blocks the edit; cancelling it re-opens the surface.
|
|
500
|
+
const instance = getActiveWorkflowInstance(issueId);
|
|
501
|
+
const item = enqueueWorkItem({
|
|
502
|
+
issueId,
|
|
503
|
+
workflowInstanceId: instance.id,
|
|
504
|
+
kind: "developer",
|
|
505
|
+
round: 1,
|
|
506
|
+
payload: {},
|
|
507
|
+
idempotencyKey: `${instance.id}:test:n422-pending-2`,
|
|
508
|
+
});
|
|
509
|
+
const queued = applyParkedEdit(issueId, { title: "Racing edit" });
|
|
510
|
+
assert.equal(queued.ok, false);
|
|
511
|
+
if (queued.ok !== false)
|
|
512
|
+
return;
|
|
513
|
+
assert.equal(queued.code, 409);
|
|
514
|
+
cancelWorkItem(item.id);
|
|
515
|
+
assert.equal(applyParkedEdit(issueId, { title: "After cancel" }).ok, true);
|
|
516
|
+
// An actively running session blocks the edit even with nothing pending/leased.
|
|
517
|
+
const session = createWorkerSession({
|
|
518
|
+
issueId,
|
|
519
|
+
role: "developer",
|
|
520
|
+
round: 1,
|
|
521
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
522
|
+
runtime: "claude_code",
|
|
523
|
+
});
|
|
524
|
+
startSession(session.id);
|
|
525
|
+
const running = applyParkedEdit(issueId, { title: "Racing session" });
|
|
526
|
+
assert.equal(running.ok, false);
|
|
527
|
+
if (running.ok !== false)
|
|
528
|
+
return;
|
|
529
|
+
assert.equal(running.code, 409);
|
|
530
|
+
assert.match(running.error, /actively running session/);
|
|
531
|
+
completeSession(session.id, { status: "done" });
|
|
532
|
+
const afterSession = applyParkedEdit(issueId, { title: "After session" });
|
|
533
|
+
assert.equal(afterSession.ok, true);
|
|
534
|
+
});
|
|
535
|
+
test("NOT-422: product-scope resume after an edit freezes one new snapshot before repair.started, note still riding", async () => {
|
|
536
|
+
const { issueId, actionId } = await parkAtProductScope();
|
|
537
|
+
assert.equal(snapshotArtifacts(issueId).length, 1);
|
|
538
|
+
updateIssue(issueId, { description: "d2", acceptanceCriteria: "It works, and also does Y" });
|
|
539
|
+
const resolved = resolveHumanActionAndAdvance(actionId, "yusuke", "resume", { note: "Scope says Y" });
|
|
540
|
+
assert.equal(resolved.ok, true);
|
|
541
|
+
assert.equal(snapshotArtifacts(issueId).length, 2);
|
|
542
|
+
const snap = getTaskSnapshot(getIssue(issueId));
|
|
543
|
+
assert.equal(snap.description, "d2");
|
|
544
|
+
assert.equal(snap.acceptanceCriteria, "It works, and also does Y");
|
|
545
|
+
assert.equal(snap.title, "Coordinate me");
|
|
546
|
+
const events = listWorkflowEventsForIssue(issueId);
|
|
547
|
+
const refreshed = events.filter((e) => e.type === "task_snapshot.refreshed");
|
|
548
|
+
assert.equal(refreshed.length, 1);
|
|
549
|
+
assert.deepEqual(JSON.parse(refreshed[0].payloadJson).changedFields, ["description", "acceptanceCriteria"]);
|
|
550
|
+
assert.equal(JSON.parse(refreshed[0].payloadJson).actionId, actionId);
|
|
551
|
+
assert.equal(refreshed[0].actorType, "human");
|
|
552
|
+
const types = events.map((e) => e.type);
|
|
553
|
+
assert.ok(types.indexOf("task_snapshot.refreshed") < types.lastIndexOf("repair.started"));
|
|
554
|
+
const pending = listWorkItemsForIssue(issueId).filter((i) => i.status === "pending");
|
|
555
|
+
assert.deepEqual(pending.map((i) => [i.kind, i.round]), [["developer", 1]]);
|
|
556
|
+
// The one-shot scope-decision note still accompanies that same work item.
|
|
557
|
+
assert.equal(JSON.parse(pending[0].payloadJson).scopeDecisionNote, "Scope says Y");
|
|
558
|
+
});
|
|
559
|
+
test("NOT-422: product-scope resume with no edit creates no new snapshot artifact or refresh event", async () => {
|
|
560
|
+
const { issueId, actionId } = await parkAtProductScope();
|
|
561
|
+
const resolved = resolveHumanActionAndAdvance(actionId, "yusuke", "resume");
|
|
562
|
+
assert.equal(resolved.ok, true);
|
|
563
|
+
assert.equal(snapshotArtifacts(issueId).length, 1);
|
|
564
|
+
assert.equal(listWorkflowEventsForIssue(issueId).filter((e) => e.type === "task_snapshot.refreshed").length, 0);
|
|
565
|
+
});
|
|
566
|
+
test("NOT-422: attachment-only row change re-freezes the manifest on product-scope resume", async () => {
|
|
567
|
+
const { issueId, actionId } = await parkAtProductScope();
|
|
568
|
+
assert.equal(snapshotArtifacts(issueId).length, 1);
|
|
569
|
+
// The post-reload row state with the text untouched: a new file manifest.
|
|
570
|
+
replaceSourceAttachments(issueId, [
|
|
571
|
+
{
|
|
572
|
+
linearAttachmentId: "att-file-1",
|
|
573
|
+
kind: "file",
|
|
574
|
+
title: "repro.tar.gz",
|
|
575
|
+
safeFileName: "repro.tar.gz",
|
|
576
|
+
blobPath: "/blobs/repro.tar.gz",
|
|
577
|
+
contentType: "application/gzip",
|
|
578
|
+
sizeBytes: 12,
|
|
579
|
+
sha256: "v2-sha",
|
|
580
|
+
url: "https://uploads.linear.app/abc/repro.tar.gz",
|
|
581
|
+
},
|
|
582
|
+
]);
|
|
583
|
+
assert.deepEqual(diffTaskSnapshotFromLive(getIssue(issueId)), ["sourceAttachments"]);
|
|
584
|
+
const resolved = resolveHumanActionAndAdvance(actionId, "yusuke", "resume");
|
|
585
|
+
assert.equal(resolved.ok, true);
|
|
586
|
+
assert.equal(snapshotArtifacts(issueId).length, 2);
|
|
587
|
+
assert.deepEqual(getTaskSnapshot(getIssue(issueId)).sourceAttachments, listSourceAttachments(issueId));
|
|
588
|
+
const events = listWorkflowEventsForIssue(issueId);
|
|
589
|
+
const refreshed = events.filter((e) => e.type === "task_snapshot.refreshed");
|
|
590
|
+
assert.equal(refreshed.length, 1);
|
|
591
|
+
assert.deepEqual(JSON.parse(refreshed[0].payloadJson).changedFields, ["sourceAttachments"]);
|
|
592
|
+
const types = events.map((e) => e.type);
|
|
593
|
+
assert.ok(types.indexOf("task_snapshot.refreshed") < types.lastIndexOf("repair.started"));
|
|
594
|
+
});
|
|
595
|
+
test("NOT-422: product-scope resume with an unchanged attachment manifest creates no refresh", async () => {
|
|
596
|
+
const issueId = newIssue();
|
|
597
|
+
replaceSourceAttachments(issueId, [
|
|
598
|
+
{
|
|
599
|
+
linearAttachmentId: "att-file-1",
|
|
600
|
+
kind: "file",
|
|
601
|
+
title: "repro.tar.gz",
|
|
602
|
+
safeFileName: "repro.tar.gz",
|
|
603
|
+
blobPath: "/blobs/repro.tar.gz",
|
|
604
|
+
contentType: "application/gzip",
|
|
605
|
+
sizeBytes: 12,
|
|
606
|
+
sha256: "v1-sha",
|
|
607
|
+
url: "https://uploads.linear.app/abc/repro.tar.gz",
|
|
608
|
+
},
|
|
609
|
+
{ linearAttachmentId: "att-link-1", kind: "link", title: "Design doc", url: "https://docs.example.com/spec" },
|
|
610
|
+
]);
|
|
611
|
+
startWorkflow(issueId);
|
|
612
|
+
await complete(issueId, cleanHandoff);
|
|
613
|
+
await complete(issueId, {
|
|
614
|
+
kind: "verdict",
|
|
615
|
+
result: { ...okReview("escalated"), productScopeQuestion: "Still needed?" },
|
|
616
|
+
});
|
|
617
|
+
const action = listHumanActionsForIssue(issueId).find((a) => a.actionType === "product_scope_decision");
|
|
618
|
+
// Rows frozen at start, untouched since — the diff must be empty, not a
|
|
619
|
+
// false-positive manifest change from the row round-trip.
|
|
620
|
+
assert.deepEqual(diffTaskSnapshotFromLive(getIssue(issueId)), []);
|
|
621
|
+
const resolved = resolveHumanActionAndAdvance(action.id, "yusuke", "resume");
|
|
622
|
+
assert.equal(resolved.ok, true);
|
|
623
|
+
assert.equal(snapshotArtifacts(issueId).length, 1);
|
|
624
|
+
assert.equal(listWorkflowEventsForIssue(issueId).filter((e) => e.type === "task_snapshot.refreshed").length, 0);
|
|
625
|
+
});
|
|
400
626
|
test("resolving policy_escalation:resume resets the infra-attempt budget without spending a review round", async () => {
|
|
401
627
|
const issueId = newIssue({ maxInfraAttempts: 1 });
|
|
402
628
|
startWorkflow(issueId);
|
|
@@ -22,6 +22,8 @@ import { getTaskSnapshot } from "./commands.js";
|
|
|
22
22
|
import { materializeSourceAttachments, SourceAttachmentError, } from "./source-attachments.js";
|
|
23
23
|
import { extractOperatorCriteria } from "./operator-criteria.js";
|
|
24
24
|
import { buildDeveloperPrompt } from "./prompts.js";
|
|
25
|
+
import { VISUAL_QA_EXCLUDE_LINE, collectDeveloperVisualQa } from "./visual-qa.js";
|
|
26
|
+
import { ensureWorktreeExcluded } from "../adapters/worktree-exclude.js";
|
|
25
27
|
import { guidanceForNextSession } from "./guidance.js";
|
|
26
28
|
import { realDeveloperSpawn, developerSessionLogPath } from "./spawn.js";
|
|
27
29
|
import { ensureWorktreeDeps, worktreeDepsTimeoutMs, } from "../adapters/worktree-deps.js";
|
|
@@ -1093,6 +1095,16 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
|
|
|
1093
1095
|
: `source attachment materialization failed: ${String(err)}`;
|
|
1094
1096
|
return { kind: "adapter_failure", reason };
|
|
1095
1097
|
}
|
|
1098
|
+
// NOT-381: git-ignore the transient visual-QA directory before the session
|
|
1099
|
+
// spawns, so worker screenshots neither dirty the tree (which would break the
|
|
1100
|
+
// verification receipt's clean gate and the handoff's clean check) nor get
|
|
1101
|
+
// swept into a salvage commit — screenshots must never reach the product repo.
|
|
1102
|
+
try {
|
|
1103
|
+
ensureWorktreeExcluded(worktreePath, VISUAL_QA_EXCLUDE_LINE);
|
|
1104
|
+
}
|
|
1105
|
+
catch (err) {
|
|
1106
|
+
return { kind: "adapter_failure", reason: `visual-QA worktree exclude failed: ${String(err)}` };
|
|
1107
|
+
}
|
|
1096
1108
|
// NOT-172: capture the worktree HEAD as this session's input-SHA baseline before
|
|
1097
1109
|
// the agent spawns — developer work items are never enqueued with an input SHA,
|
|
1098
1110
|
// so without this the sampler's HEAD diff (and the session_end fallback) have
|
|
@@ -1255,6 +1267,9 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
|
|
|
1255
1267
|
worktreePath,
|
|
1256
1268
|
deckId: snapshot?.deckId ?? null,
|
|
1257
1269
|
museDeveloper: isMuse,
|
|
1270
|
+
// NOT-381: runtime-aware visual-QA guidance (Muse preflight vs the
|
|
1271
|
+
// capable-runtime in-session attempt).
|
|
1272
|
+
runtime,
|
|
1258
1273
|
guidance: guidance.length ? guidance : undefined,
|
|
1259
1274
|
scopeDecisionNote,
|
|
1260
1275
|
conflictRepair,
|
|
@@ -1947,6 +1962,27 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
|
|
|
1947
1962
|
publishable: { branch: branchName },
|
|
1948
1963
|
};
|
|
1949
1964
|
}
|
|
1965
|
+
// NOT-381: collect the structured Visual QA receipt against the just-pushed
|
|
1966
|
+
// head, before the checks wait. Accepted screenshots move to Dealer-owned
|
|
1967
|
+
// storage and the transient worktree directory is removed; a rejection is
|
|
1968
|
+
// persisted loudly but never blocks the handoff — visual QA is evidence
|
|
1969
|
+
// for review, not a gate, so a bad receipt must not burn another round.
|
|
1970
|
+
try {
|
|
1971
|
+
const visual = collectDeveloperVisualQa({
|
|
1972
|
+
issueId: issue.id,
|
|
1973
|
+
sessionId,
|
|
1974
|
+
worktreePath,
|
|
1975
|
+
conclusion: extractConclusion(spawned.transcript),
|
|
1976
|
+
expectedHeadSha: localHead,
|
|
1977
|
+
});
|
|
1978
|
+
if (visual.kind === "rejected") {
|
|
1979
|
+
console.warn(`[dealer] visual QA receipt rejected for ${issue.id}: ${visual.reason}`);
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
catch (err) {
|
|
1983
|
+
// Evidence must never fail the handoff it describes.
|
|
1984
|
+
console.warn(`[dealer] visual QA collection failed for ${issue.id}: ${String(err)}`);
|
|
1985
|
+
}
|
|
1950
1986
|
milestone("checks.started", `Developer · waiting on checks (round ${round})`, {
|
|
1951
1987
|
prNumber: prView.number,
|
|
1952
1988
|
headSha: prView.headRefOid,
|