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.
Files changed (44) hide show
  1. package/bundle/server/dist/adapters/agent-health.test.js +52 -0
  2. package/bundle/server/dist/adapters/git-worktree.js +38 -1
  3. package/bundle/server/dist/adapters/github.js +50 -0
  4. package/bundle/server/dist/adapters/github.test.js +89 -1
  5. package/bundle/server/dist/capacity/service.js +66 -6
  6. package/bundle/server/dist/capacity/service.test.js +220 -0
  7. package/bundle/server/dist/coordinator/auto-merge.js +46 -9
  8. package/bundle/server/dist/coordinator/commands.js +89 -9
  9. package/bundle/server/dist/coordinator/commands.test.js +228 -2
  10. package/bundle/server/dist/coordinator/developer-effect.js +36 -0
  11. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +102 -1
  12. package/bundle/server/dist/coordinator/product-scope-contract.test.js +249 -0
  13. package/bundle/server/dist/coordinator/prompts.js +57 -2
  14. package/bundle/server/dist/coordinator/prompts.test.js +236 -2
  15. package/bundle/server/dist/coordinator/reviewer-browser-probe.js +89 -10
  16. package/bundle/server/dist/coordinator/reviewer-browser-probe.test.js +100 -0
  17. package/bundle/server/dist/coordinator/reviewer-effect.js +30 -1
  18. package/bundle/server/dist/coordinator/reviewer-effect.test.js +54 -0
  19. package/bundle/server/dist/coordinator/reviewer-visual-evidence.js +594 -0
  20. package/bundle/server/dist/coordinator/reviewer-visual-evidence.test.js +496 -0
  21. package/bundle/server/dist/coordinator/source-reload.js +44 -9
  22. package/bundle/server/dist/coordinator/visual-qa-collection.test.js +619 -0
  23. package/bundle/server/dist/coordinator/visual-qa.js +630 -0
  24. package/bundle/server/dist/coordinator/visual-qa.test.js +493 -0
  25. package/bundle/server/dist/db/index.js +8 -0
  26. package/bundle/server/dist/db/schema.sql +5 -1
  27. package/bundle/server/dist/paths.js +24 -0
  28. package/bundle/server/dist/repository/issues.js +8 -1
  29. package/bundle/server/dist/repository/issues.test.js +34 -0
  30. package/bundle/server/dist/repository/runtime-availability.js +29 -11
  31. package/bundle/server/dist/routes/issues-execution-contract.test.js +293 -0
  32. package/bundle/server/dist/routes/issues-source-attachments.test.js +277 -0
  33. package/bundle/server/dist/routes/issues.js +23 -8
  34. package/bundle/server/dist/routes/issues.test.js +80 -0
  35. package/bundle/server/dist/runners/usage-cap.js +66 -15
  36. package/bundle/server/dist/runners/usage-cap.test.js +96 -2
  37. package/bundle/server/package.json +2 -2
  38. package/bundle/server/static-ui/assets/index-B_pNUDRY.js +64 -0
  39. package/bundle/server/static-ui/index.html +1 -1
  40. package/bundle/shared/dist/runtime-capacity.d.ts +85 -0
  41. package/bundle/shared/dist/runtime-capacity.js +19 -0
  42. package/bundle/shared/package.json +1 -1
  43. package/package.json +1 -1
  44. 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
- * Returns null when the merge may proceed (no operator criteria, a recorded
351
- * result for this head, or no workflow to gate). Idempotent: a second finalize
352
- * for the same head reuses the open action instead of raising another.
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 reason = `Reviewer approved ${shortHead}, but ${criteria.length} acceptance ` +
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\nPaste the probe output to record verification, waive with a reason, ` +
486
- `or send the work back for another repair round?`,
487
- evidence: { operatorVerification: { criteria, headSha: head } },
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 blocked = Object.keys(patch).filter((k) => !PARKED_EDITABLE_FIELDS.has(k));
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 title, description, acceptanceCriteria, developerAgentId and reviewerAgentId can be edited while parked (got: ${blocked.join(", ")})`,
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
- * Re-freezes the snapshot when the operator edited title/description/criteria
228
- * (or the contract compiled from the description); returns the changed fields
229
- * (empty = no-op). Source and compiled contract always re-freeze atomically —
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 refreshTaskSnapshotIfEdited(issue) {
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,