agent-dealer 1.3.1 → 1.3.2
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 +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/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/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/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/package.json +2 -2
- package/bundle/server/static-ui/assets/index-DQ5ivcyg.js +64 -0
- package/bundle/server/static-ui/index.html +1 -1
- 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
|
@@ -18,6 +18,17 @@ import { withRepoLock } from "../runners/process-registry.js";
|
|
|
18
18
|
* Kept inside the repo (not `$AGENT_DEALER_HOME`) so `agent-deck use` grants cover the
|
|
19
19
|
* worker cwd and `bind_workspace` to that path can succeed. */
|
|
20
20
|
export const WORKTREES_DIR_NAME = ".agent-dealer-worktrees";
|
|
21
|
+
/** Directory name for coordinator-staged CI visual artifacts inside a reviewer
|
|
22
|
+
* worktree (NOT-384). The reviewer has no shell, so it cannot list the worktree
|
|
23
|
+
* root itself — but its file tools read paths the prompt names, and only paths
|
|
24
|
+
* inside the worktree are readable under Claude Code's `--restricted` file
|
|
25
|
+
* confinement. The directory is untracked and ignored by `isWorktreeClean`
|
|
26
|
+
* below, so staged evidence never reads as reviewer dirt. It only ever exists
|
|
27
|
+
* in detached-HEAD reviewer checkouts (developer sessions never create it); a
|
|
28
|
+
* developer-side file of the same hidden name would be ignored by the clean
|
|
29
|
+
* check too, but salvage (`git add -A`) still sweeps it up, so nothing is lost —
|
|
30
|
+
* and the coordinator never stages it anywhere but a reviewer worktree. */
|
|
31
|
+
export const REVIEWER_VISUAL_STAGING_DIR_NAME = ".agent-dealer-visual";
|
|
21
32
|
const execFileAsync = promisify(execFile);
|
|
22
33
|
const gitCommandObservers = new Set();
|
|
23
34
|
/** Registers `observer` for every adapter `git` invocation; returns the unsubscribe. */
|
|
@@ -214,8 +225,34 @@ export async function removeWorktree(opts) {
|
|
|
214
225
|
async function readOnlyStatus(worktreePath) {
|
|
215
226
|
return (await git(worktreePath, ["--no-optional-locks", "status", "--porcelain"])).stdout;
|
|
216
227
|
}
|
|
228
|
+
/** True when a `git status --porcelain` line names only the coordinator-staged
|
|
229
|
+
* visual-evidence directory (NOT-384) — `?? .agent-dealer-visual/` for the
|
|
230
|
+
* untracked dir, or a path under it. Anything else on the line means real dirt. */
|
|
231
|
+
function isVisualStagingStatusLine(line) {
|
|
232
|
+
const entry = line.slice(0, 2);
|
|
233
|
+
if (entry !== "??")
|
|
234
|
+
return false;
|
|
235
|
+
const shown = line.slice(3).trim().replace(/^"|"$/g, "");
|
|
236
|
+
return shown === REVIEWER_VISUAL_STAGING_DIR_NAME || shown.startsWith(`${REVIEWER_VISUAL_STAGING_DIR_NAME}/`);
|
|
237
|
+
}
|
|
217
238
|
export async function isWorktreeClean(worktreePath) {
|
|
218
|
-
|
|
239
|
+
const out = (await readOnlyStatus(worktreePath)).trim();
|
|
240
|
+
if (!out)
|
|
241
|
+
return true;
|
|
242
|
+
return out
|
|
243
|
+
.split("\n")
|
|
244
|
+
.map((line) => line.trimEnd())
|
|
245
|
+
.filter((line) => line.length > 0)
|
|
246
|
+
.every(isVisualStagingStatusLine);
|
|
247
|
+
}
|
|
248
|
+
/** Repo-relative paths changed between `baseSha` and `headSha` (NOT-384: the
|
|
249
|
+
* UI-affecting classifier's input). Empty when the SHAs match. */
|
|
250
|
+
export async function listChangedFiles(opts) {
|
|
251
|
+
const { stdout } = await git(opts.worktreePath, ["diff", "--name-only", opts.baseSha, opts.headSha]);
|
|
252
|
+
return stdout
|
|
253
|
+
.split("\n")
|
|
254
|
+
.map((line) => line.trim())
|
|
255
|
+
.filter((line) => line.length > 0);
|
|
219
256
|
}
|
|
220
257
|
/** Commit message for a timeout salvage tip (NOT-145). */
|
|
221
258
|
export const SALVAGE_TIMEOUT_MESSAGE = "wip: timeout salvage";
|
|
@@ -334,6 +334,17 @@ export function formatChecksFailureDetails(opts) {
|
|
|
334
334
|
}
|
|
335
335
|
return parts.join("\n");
|
|
336
336
|
}
|
|
337
|
+
export function parseWorkflowRunInfo(raw) {
|
|
338
|
+
if (typeof raw !== "object" || raw === null)
|
|
339
|
+
return null;
|
|
340
|
+
const row = raw;
|
|
341
|
+
if (typeof row.databaseId !== "number" || typeof row.headSha !== "string" || !row.headSha) {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
const conclusion = typeof row.conclusion === "string" && row.conclusion ? row.conclusion : null;
|
|
345
|
+
const status = typeof row.status === "string" && row.status ? row.status : null;
|
|
346
|
+
return { databaseId: row.databaseId, headSha: row.headSha, conclusion, status };
|
|
347
|
+
}
|
|
337
348
|
const NO_COMMITS_PATTERN = /no commits between/i;
|
|
338
349
|
const defaultExec = (args, opts) => run("gh", args, { cwd: opts.cwd, maxBuffer: opts.maxBuffer });
|
|
339
350
|
async function ghPrView(exec, cwd, fields, selector) {
|
|
@@ -407,6 +418,45 @@ export function createGithubAdapter(exec = defaultExec) {
|
|
|
407
418
|
return "UNKNOWN";
|
|
408
419
|
}
|
|
409
420
|
},
|
|
421
|
+
async listWorkflowRunsForCommit({ cwd, workflow, commitSha }) {
|
|
422
|
+
try {
|
|
423
|
+
const { stdout } = await exec([
|
|
424
|
+
"run",
|
|
425
|
+
"list",
|
|
426
|
+
"--workflow",
|
|
427
|
+
workflow,
|
|
428
|
+
"--commit",
|
|
429
|
+
commitSha,
|
|
430
|
+
"--limit",
|
|
431
|
+
"5",
|
|
432
|
+
"--json",
|
|
433
|
+
"databaseId,headSha,conclusion,status",
|
|
434
|
+
], { cwd });
|
|
435
|
+
const rows = JSON.parse(stdout);
|
|
436
|
+
if (!Array.isArray(rows))
|
|
437
|
+
return [];
|
|
438
|
+
const runs = [];
|
|
439
|
+
for (const row of rows) {
|
|
440
|
+
const parsed = parseWorkflowRunInfo(row);
|
|
441
|
+
if (parsed)
|
|
442
|
+
runs.push(parsed);
|
|
443
|
+
}
|
|
444
|
+
return runs;
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
const message = err.stderr ?? err.message;
|
|
448
|
+
throw new Error(`gh run list failed: ${message}`);
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
async downloadRunArtifact({ cwd, runId, name, destDir }) {
|
|
452
|
+
try {
|
|
453
|
+
await exec(["run", "download", String(runId), "--name", name, "--dir", destDir], { cwd });
|
|
454
|
+
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
const message = err.stderr ?? err.message;
|
|
457
|
+
throw new Error(`gh run download failed: ${message}`);
|
|
458
|
+
}
|
|
459
|
+
},
|
|
410
460
|
async publishReview({ cwd, number, event, bodyFilePath }) {
|
|
411
461
|
const result = await runReview(exec, cwd, number, event, bodyFilePath);
|
|
412
462
|
if (result.ok || event === "COMMENT" || !OWN_PR_REVIEW_PATTERN.test(result.reason)) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// packages/server/src/adapters/github.test.ts
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
import assert from "node:assert/strict";
|
|
4
|
-
import { parsePrView, summarizeChecks, pollPrChecks, pollPrChecksDetailed, baseRefHasPullRequestWorkflow, workflowHasPullRequestTrigger, createGithubAdapter, fetchChecksFailureEvidence, sanitizeCiText, sanitizeUrl, extractActionsRunId, buildFailureExcerpt, formatChecksFailureDetails, CHECKS_EVIDENCE_MAX_EXCERPT_CHARS, CHECKS_EVIDENCE_MAX_EXCERPT_LINES, CHECKS_EVIDENCE_MAX_RAW_LOG_CHARS_PER_RUN, CHECKS_EVIDENCE_GH_LOG_MAX_BUFFER, CHECKS_FAILURE_GENERIC_REASON, PR_VIEW_FIELDS, } from "./github.js";
|
|
4
|
+
import { parsePrView, summarizeChecks, pollPrChecks, pollPrChecksDetailed, baseRefHasPullRequestWorkflow, workflowHasPullRequestTrigger, createGithubAdapter, fetchChecksFailureEvidence, sanitizeCiText, sanitizeUrl, extractActionsRunId, buildFailureExcerpt, formatChecksFailureDetails, CHECKS_EVIDENCE_MAX_EXCERPT_CHARS, CHECKS_EVIDENCE_MAX_EXCERPT_LINES, CHECKS_EVIDENCE_MAX_RAW_LOG_CHARS_PER_RUN, CHECKS_EVIDENCE_GH_LOG_MAX_BUFFER, CHECKS_FAILURE_GENERIC_REASON, PR_VIEW_FIELDS, parseWorkflowRunInfo, } from "./github.js";
|
|
5
5
|
test("parsePrView extracts the ground-truth handoff fields, including draft status", () => {
|
|
6
6
|
const view = parsePrView(JSON.stringify({
|
|
7
7
|
number: 7,
|
|
@@ -814,3 +814,91 @@ test("NOT-252: formatChecksFailureDetails labels the excerpt as untrusted, not i
|
|
|
814
814
|
});
|
|
815
815
|
assert.match(noExcerpt, /excerpt unavailable/);
|
|
816
816
|
});
|
|
817
|
+
test("NOT-384: listWorkflowRunsForCommit filters by workflow and exact commit, parsing runs", async () => {
|
|
818
|
+
const { exec, calls } = queuedExec([
|
|
819
|
+
{
|
|
820
|
+
stdout: JSON.stringify([
|
|
821
|
+
{ databaseId: 42, headSha: HEAD_SHA, conclusion: "success", status: "completed" },
|
|
822
|
+
{ databaseId: 41, headSha: "other", conclusion: null, status: "in_progress" },
|
|
823
|
+
]),
|
|
824
|
+
},
|
|
825
|
+
]);
|
|
826
|
+
const runs = await createGithubAdapter(exec).listWorkflowRunsForCommit({
|
|
827
|
+
cwd: "/repo",
|
|
828
|
+
workflow: "visual.yml",
|
|
829
|
+
commitSha: HEAD_SHA,
|
|
830
|
+
});
|
|
831
|
+
assert.deepEqual(calls[0], [
|
|
832
|
+
"run",
|
|
833
|
+
"list",
|
|
834
|
+
"--workflow",
|
|
835
|
+
"visual.yml",
|
|
836
|
+
"--commit",
|
|
837
|
+
HEAD_SHA,
|
|
838
|
+
"--limit",
|
|
839
|
+
"5",
|
|
840
|
+
"--json",
|
|
841
|
+
"databaseId,headSha,conclusion,status",
|
|
842
|
+
]);
|
|
843
|
+
assert.deepEqual(runs, [
|
|
844
|
+
{ databaseId: 42, headSha: HEAD_SHA, conclusion: "success", status: "completed" },
|
|
845
|
+
{ databaseId: 41, headSha: "other", conclusion: null, status: "in_progress" },
|
|
846
|
+
]);
|
|
847
|
+
});
|
|
848
|
+
test("NOT-384: listWorkflowRunsForCommit returns [] when the workflow never ran, skips malformed rows, throws on gh failure", async () => {
|
|
849
|
+
const empty = queuedExec([{ stdout: "[]" }]);
|
|
850
|
+
assert.deepEqual(await createGithubAdapter(empty.exec).listWorkflowRunsForCommit({
|
|
851
|
+
cwd: "/repo",
|
|
852
|
+
workflow: "visual.yml",
|
|
853
|
+
commitSha: HEAD_SHA,
|
|
854
|
+
}), []);
|
|
855
|
+
const malformed = queuedExec([
|
|
856
|
+
{
|
|
857
|
+
stdout: JSON.stringify([
|
|
858
|
+
{ databaseId: "nope", headSha: HEAD_SHA },
|
|
859
|
+
{ databaseId: 42 },
|
|
860
|
+
null,
|
|
861
|
+
{ databaseId: 43, headSha: HEAD_SHA, conclusion: "", status: "" },
|
|
862
|
+
]),
|
|
863
|
+
},
|
|
864
|
+
]);
|
|
865
|
+
assert.deepEqual(await createGithubAdapter(malformed.exec).listWorkflowRunsForCommit({
|
|
866
|
+
cwd: "/repo",
|
|
867
|
+
workflow: "visual.yml",
|
|
868
|
+
commitSha: HEAD_SHA,
|
|
869
|
+
}), [{ databaseId: 43, headSha: HEAD_SHA, conclusion: null, status: null }]);
|
|
870
|
+
const failing = queuedExec([{ error: "HTTP 401" }]);
|
|
871
|
+
await assert.rejects(createGithubAdapter(failing.exec).listWorkflowRunsForCommit({
|
|
872
|
+
cwd: "/repo",
|
|
873
|
+
workflow: "visual.yml",
|
|
874
|
+
commitSha: HEAD_SHA,
|
|
875
|
+
}), /gh run list failed/);
|
|
876
|
+
});
|
|
877
|
+
test("NOT-384: parseWorkflowRunInfo rejects rows without a numeric id and head SHA", () => {
|
|
878
|
+
assert.equal(parseWorkflowRunInfo(null), null);
|
|
879
|
+
assert.equal(parseWorkflowRunInfo({ databaseId: 1 }), null);
|
|
880
|
+
assert.equal(parseWorkflowRunInfo({ databaseId: 1, headSha: "" }), null);
|
|
881
|
+
assert.deepEqual(parseWorkflowRunInfo({ databaseId: 1, headSha: "h" }), {
|
|
882
|
+
databaseId: 1,
|
|
883
|
+
headSha: "h",
|
|
884
|
+
conclusion: null,
|
|
885
|
+
status: null,
|
|
886
|
+
});
|
|
887
|
+
});
|
|
888
|
+
test("NOT-384: downloadRunArtifact downloads one named artifact into the dest dir, throwing on gh failure", async () => {
|
|
889
|
+
const { exec, calls } = queuedExec([{ stdout: "" }]);
|
|
890
|
+
await createGithubAdapter(exec).downloadRunArtifact({
|
|
891
|
+
cwd: "/repo",
|
|
892
|
+
runId: 42,
|
|
893
|
+
name: "ui-diff",
|
|
894
|
+
destDir: "/store/ui-diff",
|
|
895
|
+
});
|
|
896
|
+
assert.deepEqual(calls[0], ["run", "download", "42", "--name", "ui-diff", "--dir", "/store/ui-diff"]);
|
|
897
|
+
const failing = queuedExec([{ error: "no such artifact" }]);
|
|
898
|
+
await assert.rejects(createGithubAdapter(failing.exec).downloadRunArtifact({
|
|
899
|
+
cwd: "/repo",
|
|
900
|
+
runId: 42,
|
|
901
|
+
name: "ui-diff",
|
|
902
|
+
destDir: "/store/ui-diff",
|
|
903
|
+
}), /gh run download failed/);
|
|
904
|
+
});
|
|
@@ -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 } });
|