agent-dealer 1.2.11 → 1.2.12
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/coordinator/commands.js +204 -5
- package/bundle/server/dist/coordinator/commands.test.js +288 -3
- package/bundle/server/dist/routes/issues.js +54 -7
- package/bundle/server/dist/routes/issues.test.js +326 -7
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-CBdkhTI_.js +64 -0
- package/bundle/server/static-ui/index.html +1 -1
- package/bundle/shared/package.json +1 -1
- package/dist/index.js +2 -0
- package/dist/issue.d.ts +8 -0
- package/dist/issue.js +35 -0
- package/dist/issue.test.js +92 -0
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-Cf4dipuK.js +0 -64
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
// duplicate delivery is a no-op. The effect *work* itself runs elsewhere, through a leased
|
|
8
8
|
// worker whose structured result comes back into applyCompletion.
|
|
9
9
|
import fs from "node:fs";
|
|
10
|
-
import { canTransitionIssue, tryCompileContract } from "@agent-dealer/shared";
|
|
10
|
+
import { canTransitionIssue, ExecutionContractError, tryCompileContract } from "@agent-dealer/shared";
|
|
11
11
|
import { getDb } from "../db/index.js";
|
|
12
|
-
import { getIssue, incrementIssueRound, incrementIssueInfraAttempts, incrementIssueCiAttempts, resetIssueInfraAttempts, resetIssueCiAttempts, grantReviewRetry, transitionIssue, } from "../repository/issues.js";
|
|
12
|
+
import { getIssue, incrementIssueRound, incrementIssueInfraAttempts, incrementIssueCiAttempts, resetIssueInfraAttempts, resetIssueCiAttempts, grantReviewRetry, transitionIssue, updateIssue, } from "../repository/issues.js";
|
|
13
13
|
import { appendWorkflowEvent, completeWorkflowInstance, getActiveWorkflowInstance, getWorkflowInstance, listWorkflowEventsForIssue, startWorkflowInstance, WorkflowAlreadyActiveError, } from "../repository/workflow-events.js";
|
|
14
14
|
import { createHumanAction, findOpenHumanAction, getHumanAction, listHumanActionsForIssue, reopenHumanAction, resolveHumanAction, updateOpenHumanAction, } from "../repository/human-actions.js";
|
|
15
15
|
import { pushLeaseToSha, readRemoteTip } from "../adapters/git-worktree.js";
|
|
@@ -21,7 +21,7 @@ import { githubIssuesSync, invalidateMuseHealthCache } from "../adapters/agent-h
|
|
|
21
21
|
import { recordMuseCapabilityOverride } from "../adapters/muse-capability.js";
|
|
22
22
|
import { createIssueArtifact, latestIssueArtifact } from "../repository/artifacts.js";
|
|
23
23
|
import { cancelWorkItem, enqueueWorkItem, finishWorkItem, getWorkItem, getWorkItemByIdempotencyKey, listWorkItemsForIssue, } from "../repository/work-items.js";
|
|
24
|
-
import { completeSession, getWorkerSession, listWorkerSessionsForIssue } from "../repository/worker-sessions.js";
|
|
24
|
+
import { completeSession, getActiveWorkerSessionForIssue, getWorkerSession, listWorkerSessionsForIssue } from "../repository/worker-sessions.js";
|
|
25
25
|
import { killRunProcess } from "../runners/spawn-cli.js";
|
|
26
26
|
import { buildProfileSnapshot, serializeProfileSnapshot } from "./profile-snapshot.js";
|
|
27
27
|
import { workerSessionPayload } from "./session-progress.js";
|
|
@@ -83,16 +83,130 @@ export function getTaskSnapshot(issue) {
|
|
|
83
83
|
* retried. True only while the workflow is active, the issue is `needs_human` with that
|
|
84
84
|
* action open, and no work item is pending or leased — so no session can be reading the
|
|
85
85
|
* snapshot an edit would supersede.
|
|
86
|
+
*
|
|
87
|
+
* NOT-358: the same parked-edit surface covers an open `policy_escalation` (e.g. after a
|
|
88
|
+
* developer budget failure) so the owner can swap the developer and/or reviewer agent
|
|
89
|
+
* profile and resume in the same worktree instead of recreating the issue.
|
|
86
90
|
*/
|
|
87
91
|
export function canEditParkedIssue(issue) {
|
|
88
92
|
if (issue.status !== "needs_human")
|
|
89
93
|
return false;
|
|
90
94
|
if (!getActiveWorkflowInstance(issue.id))
|
|
91
95
|
return false;
|
|
92
|
-
if (!findOpenHumanAction(issue.id, "attempts_exhausted")
|
|
96
|
+
if (!findOpenHumanAction(issue.id, "attempts_exhausted") &&
|
|
97
|
+
!findOpenHumanAction(issue.id, "policy_escalation")) {
|
|
93
98
|
return false;
|
|
99
|
+
}
|
|
94
100
|
return !listWorkItemsForIssue(issue.id).some((w) => w.status === "pending" || w.status === "leased");
|
|
95
101
|
}
|
|
102
|
+
function parkableWaitFor(item) {
|
|
103
|
+
if (item.status !== "pending" || !item.errorJson)
|
|
104
|
+
return null;
|
|
105
|
+
let parsed;
|
|
106
|
+
try {
|
|
107
|
+
parsed = JSON.parse(item.errorJson);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
if (!parsed || typeof parsed !== "object")
|
|
113
|
+
return null;
|
|
114
|
+
const { kind, until, reason } = parsed;
|
|
115
|
+
if (kind !== "usage_capped" && kind !== "deck_unavailable")
|
|
116
|
+
return null;
|
|
117
|
+
if (typeof until !== "string" || !until || typeof reason !== "string" || !reason)
|
|
118
|
+
return null;
|
|
119
|
+
return { kind, until, reason };
|
|
120
|
+
}
|
|
121
|
+
/** First usage-cap / deck-outage wait on the issue, if any. */
|
|
122
|
+
export function capWaitForIssue(issueId) {
|
|
123
|
+
for (const item of listWorkItemsForIssue(issueId)) {
|
|
124
|
+
const wait = parkableWaitFor(item);
|
|
125
|
+
if (wait)
|
|
126
|
+
return wait;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* NOT-185: the only fields a parked edit may touch. Re-scoping (title/description/
|
|
132
|
+
* criteria) re-freezes the task snapshot on retry; agent profiles re-freeze on the
|
|
133
|
+
* resumed round's profile snapshot (NOT-358). Repo, base branch, review budget and
|
|
134
|
+
* autoMerge stay as the running workflow saw them.
|
|
135
|
+
*/
|
|
136
|
+
export const PARKED_EDITABLE_FIELDS = new Set([
|
|
137
|
+
"title",
|
|
138
|
+
"description",
|
|
139
|
+
"acceptanceCriteria",
|
|
140
|
+
"developerAgentId",
|
|
141
|
+
"reviewerAgentId",
|
|
142
|
+
]);
|
|
143
|
+
/**
|
|
144
|
+
* NOT-358: applies a parked-issue edit (re-scope and/or agent swap) as one short
|
|
145
|
+
* transaction: guard re-check + agent-profile existence + row write + `issue.reassigned`
|
|
146
|
+
* audit. The guard re-reads the row so an edit that races a resume fails closed with
|
|
147
|
+
* 409 instead of landing on a live snapshot. A swap keeps the worktree, branch,
|
|
148
|
+
* `currentRound` and `maxReviewRounds` — `updateIssue` never touches those — and the
|
|
149
|
+
* next resume freezes the new profile into its work item payload.
|
|
150
|
+
*/
|
|
151
|
+
export function applyParkedEdit(issueId, patch) {
|
|
152
|
+
try {
|
|
153
|
+
return getDb().transaction(() => {
|
|
154
|
+
const fresh = getIssue(issueId);
|
|
155
|
+
if (!fresh)
|
|
156
|
+
return { ok: false, code: 404, error: "Issue not found" };
|
|
157
|
+
if (getActiveWorkerSessionForIssue(issueId)) {
|
|
158
|
+
return { ok: false, code: 409, error: "Cannot edit an issue with an actively running session" };
|
|
159
|
+
}
|
|
160
|
+
if (!getActiveWorkflowInstance(issueId) || !canEditParkedIssue(fresh)) {
|
|
161
|
+
return { ok: false, code: 409, error: "Cannot edit an issue with an active workflow" };
|
|
162
|
+
}
|
|
163
|
+
const blocked = Object.keys(patch).filter((k) => !PARKED_EDITABLE_FIELDS.has(k));
|
|
164
|
+
if (blocked.length > 0) {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
code: 409,
|
|
168
|
+
error: `Only title, description, acceptanceCriteria, developerAgentId and reviewerAgentId can be edited while parked (got: ${blocked.join(", ")})`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
for (const role of ["developerAgentId", "reviewerAgentId"]) {
|
|
172
|
+
const agentId = patch[role];
|
|
173
|
+
if (agentId !== undefined && !getAgent(agentId)) {
|
|
174
|
+
return { ok: false, code: 400, error: `Unknown agent profile: ${agentId}` };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const next = updateIssue(issueId, patch);
|
|
178
|
+
// NOT-217/NOT-240: durable configuration audit — only when an execution input
|
|
179
|
+
// actually changed, comparing against the freshly read row so a concurrent edit
|
|
180
|
+
// is attributed exactly.
|
|
181
|
+
if (fresh.repo !== next.repo ||
|
|
182
|
+
fresh.developerAgentId !== next.developerAgentId ||
|
|
183
|
+
fresh.reviewerAgentId !== next.reviewerAgentId) {
|
|
184
|
+
appendWorkflowEvent({
|
|
185
|
+
issueId,
|
|
186
|
+
type: "issue.reassigned",
|
|
187
|
+
actorType: "human",
|
|
188
|
+
stage: next.status,
|
|
189
|
+
payload: {
|
|
190
|
+
fromRepo: fresh.repo,
|
|
191
|
+
toRepo: next.repo,
|
|
192
|
+
fromDeveloperAgentId: fresh.developerAgentId,
|
|
193
|
+
toDeveloperAgentId: next.developerAgentId,
|
|
194
|
+
fromReviewerAgentId: fresh.reviewerAgentId,
|
|
195
|
+
toReviewerAgentId: next.reviewerAgentId,
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return { ok: true, issue: next };
|
|
200
|
+
})();
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
// NOT-306: a contract edit that turns ambiguous/malformed is a 400.
|
|
204
|
+
if (err instanceof ExecutionContractError) {
|
|
205
|
+
return { ok: false, code: 400, error: err.message };
|
|
206
|
+
}
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
96
210
|
/**
|
|
97
211
|
* Re-freezes the snapshot when the operator edited title/description/criteria
|
|
98
212
|
* (or the contract compiled from the description); returns the changed fields
|
|
@@ -1825,7 +1939,10 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
|
|
|
1825
1939
|
const issueNow = getIssue(issue.id);
|
|
1826
1940
|
// NOT-185: freeze before queuing so the next developer prompt and the reviewer both
|
|
1827
1941
|
// read the re-scoped task. An unedited issue writes nothing.
|
|
1828
|
-
|
|
1942
|
+
// NOT-358: a policy_escalation park is editable the same way (title/description/
|
|
1943
|
+
// criteria as well as agent profiles), so its resume re-freezes too.
|
|
1944
|
+
if ((action.actionType === "attempts_exhausted" && resolution.choice === "retry") ||
|
|
1945
|
+
(action.actionType === "policy_escalation" && resolution.choice === "resume")) {
|
|
1829
1946
|
const changedFields = refreshTaskSnapshotIfEdited(issueNow);
|
|
1830
1947
|
if (changedFields.length > 0) {
|
|
1831
1948
|
ev.emit("task_snapshot.refreshed", { actorType: "human", payload: { actionId: action.id, changedFields } });
|
|
@@ -2330,6 +2447,88 @@ export function closeReadyIssue(issueId, closedBy) {
|
|
|
2330
2447
|
}
|
|
2331
2448
|
return { ok: true, ...out };
|
|
2332
2449
|
}
|
|
2450
|
+
export function parkCapWait(issueId, parkedBy) {
|
|
2451
|
+
const issue = getIssue(issueId);
|
|
2452
|
+
if (!issue)
|
|
2453
|
+
return { ok: false, code: 404, error: "Issue not found" };
|
|
2454
|
+
if (issue.status === "done" || issue.status === "closed") {
|
|
2455
|
+
return { ok: false, code: 409, error: `Cannot park an issue that is ${issue.status}` };
|
|
2456
|
+
}
|
|
2457
|
+
if (getActiveWorkerSessionForIssue(issueId)) {
|
|
2458
|
+
return { ok: false, code: 409, error: "Cannot park an issue with an actively running session" };
|
|
2459
|
+
}
|
|
2460
|
+
if (!getActiveWorkflowInstance(issueId)) {
|
|
2461
|
+
return { ok: false, code: 409, error: "Cannot park an issue with no active workflow" };
|
|
2462
|
+
}
|
|
2463
|
+
const items = listWorkItemsForIssue(issueId);
|
|
2464
|
+
if (items.some((w) => w.status === "leased")) {
|
|
2465
|
+
return { ok: false, code: 409, error: "Cannot park an issue with leased work — wait for the attempt to finish" };
|
|
2466
|
+
}
|
|
2467
|
+
if (!capWaitForIssue(issueId)) {
|
|
2468
|
+
return { ok: false, code: 409, error: "Issue is not waiting on a usage-cap or deck-outage window" };
|
|
2469
|
+
}
|
|
2470
|
+
return getDb().transaction(() => {
|
|
2471
|
+
const fresh = getIssue(issueId);
|
|
2472
|
+
if (!fresh)
|
|
2473
|
+
return { ok: false, code: 404, error: "Issue not found" };
|
|
2474
|
+
if (getActiveWorkerSessionForIssue(issueId)) {
|
|
2475
|
+
return { ok: false, code: 409, error: "Cannot park an issue with an actively running session" };
|
|
2476
|
+
}
|
|
2477
|
+
const instance = getActiveWorkflowInstance(issueId);
|
|
2478
|
+
if (!instance)
|
|
2479
|
+
return { ok: false, code: 409, error: "Cannot park an issue with no active workflow" };
|
|
2480
|
+
const liveItems = listWorkItemsForIssue(issueId);
|
|
2481
|
+
if (liveItems.some((w) => w.status === "leased")) {
|
|
2482
|
+
return { ok: false, code: 409, error: "Cannot park an issue with leased work — wait for the attempt to finish" };
|
|
2483
|
+
}
|
|
2484
|
+
const waitItem = liveItems.find((w) => w.status === "pending" && parkableWaitFor(w) != null);
|
|
2485
|
+
if (!waitItem) {
|
|
2486
|
+
return { ok: false, code: 409, error: "Issue is not waiting on a usage-cap or deck-outage window" };
|
|
2487
|
+
}
|
|
2488
|
+
const wait = parkableWaitFor(waitItem);
|
|
2489
|
+
const kindLabel = wait.kind === "usage_capped" ? "usage cap" : "Agent Deck outage";
|
|
2490
|
+
const reason = `Parked by ${parkedBy} while waiting on ${kindLabel}: ${wait.reason} (wait until ${wait.until})`;
|
|
2491
|
+
// A reviewer-role wait resumes as a reviewer at the pinned head (the same
|
|
2492
|
+
// continuation reviewer-origin infra escalations tag); anything else takes the
|
|
2493
|
+
// generic developer resume.
|
|
2494
|
+
const resumeAsReviewer = waitItem.kind === "reviewer" && fresh.headSha != null;
|
|
2495
|
+
for (const item of liveItems) {
|
|
2496
|
+
if (item.status === "pending")
|
|
2497
|
+
cancelWorkItem(item.id);
|
|
2498
|
+
}
|
|
2499
|
+
const ev = eventEmitter(fresh, instance, waitItem.workerSessionId, "needs_human", fresh.currentRound);
|
|
2500
|
+
if (fresh.status === "needs_human") {
|
|
2501
|
+
getDb()
|
|
2502
|
+
.prepare("UPDATE issues SET current_owner = 'human', current_intent = ?, updated_at = ? WHERE id = ?")
|
|
2503
|
+
.run(reason, new Date().toISOString(), issueId);
|
|
2504
|
+
}
|
|
2505
|
+
else {
|
|
2506
|
+
transitionIssue(issueId, "needs_human", { currentOwner: "human", currentIntent: reason });
|
|
2507
|
+
}
|
|
2508
|
+
const action = createHumanAction({
|
|
2509
|
+
issueId,
|
|
2510
|
+
workflowInstanceId: instance.id,
|
|
2511
|
+
actionType: "policy_escalation",
|
|
2512
|
+
reason,
|
|
2513
|
+
question: questionFor("policy_escalation", reason, resumeAsReviewer),
|
|
2514
|
+
evidence: {
|
|
2515
|
+
parkedCapWait: {
|
|
2516
|
+
kind: wait.kind,
|
|
2517
|
+
until: wait.until,
|
|
2518
|
+
reason: wait.reason,
|
|
2519
|
+
parkedBy,
|
|
2520
|
+
parkedAt: new Date().toISOString(),
|
|
2521
|
+
},
|
|
2522
|
+
},
|
|
2523
|
+
continuationPreview: resumeAsReviewer
|
|
2524
|
+
? { resumeRole: "reviewer", resumeHeadSha: fresh.headSha }
|
|
2525
|
+
: undefined,
|
|
2526
|
+
responseOptions: responseOptionsFor("policy_escalation", resumeAsReviewer),
|
|
2527
|
+
});
|
|
2528
|
+
ev.emit("human_action.requested", { payload: { actionType: "policy_escalation", actionId: action.id } });
|
|
2529
|
+
return { ok: true, issueStatus: getIssue(issueId).status, humanActionId: action.id };
|
|
2530
|
+
})();
|
|
2531
|
+
}
|
|
2333
2532
|
export async function abortIssueAsync(issueId, resolvedBy, deps = defaultAbortDeps) {
|
|
2334
2533
|
let externalMergeState;
|
|
2335
2534
|
try {
|
|
@@ -7,14 +7,17 @@ import path from "node:path";
|
|
|
7
7
|
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-cmds-"));
|
|
8
8
|
const { migrate, getDb } = await import("../db/index.js");
|
|
9
9
|
const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
|
|
10
|
+
const { createAgent } = await import("../repository/agents.js");
|
|
11
|
+
const { parseProfileSnapshot } = await import("@agent-dealer/shared");
|
|
12
|
+
const { randomUUID } = await import("node:crypto");
|
|
10
13
|
const { createIssue, getIssue, updateIssue } = await import("../repository/issues.js");
|
|
11
14
|
const { listWorkflowEventsForIssue, getActiveWorkflowInstance } = await import("../repository/workflow-events.js");
|
|
12
15
|
const { createHumanAction, getHumanAction, listHumanActionsForIssue } = await import("../repository/human-actions.js");
|
|
13
16
|
const { setResumeLiveHeadReaderForTests } = await import("./resume-live-head.js");
|
|
14
17
|
const { listFindingsForIssue, reconcileFinding } = await import("../repository/findings.js");
|
|
15
18
|
const { cancelWorkItem, claimWorkItem, listWorkItemsForIssue, getWorkItem, enqueueWorkItem } = await import("../repository/work-items.js");
|
|
16
|
-
const { createWorkerSession, startSession, listWorkerSessionsForIssue } = await import("../repository/worker-sessions.js");
|
|
17
|
-
const { startWorkflow, applyCompletion, resolveHumanActionAndAdvance, resolveHumanActionAndAdvanceAsync, getTaskSnapshot, canEditParkedIssue, TASK_SNAPSHOT_ARTIFACT_KIND, checkIssueReadiness, abortIssue } = await import("./commands.js");
|
|
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");
|
|
18
21
|
const { ReviewerResult } = await import("./reviewer-result.js");
|
|
19
22
|
const { listArtifactsForIssue } = await import("../repository/artifacts-for-issue.js");
|
|
20
23
|
const { setMergePrForTests, clearFinalizeInflightForTests } = await import("./auto-merge.js");
|
|
@@ -330,9 +333,16 @@ async function parkAtAttemptsExhausted() {
|
|
|
330
333
|
return { issueId, actionId: action.id };
|
|
331
334
|
}
|
|
332
335
|
const snapshotArtifacts = (issueId) => listArtifactsForIssue(issueId).filter((a) => a.kind === TASK_SNAPSHOT_ARTIFACT_KIND);
|
|
333
|
-
test("NOT-185: canEditParkedIssue is true only at an open attempts_exhausted with nothing pending or leased", async () => {
|
|
336
|
+
test("NOT-185/NOT-358: canEditParkedIssue is true only at an open attempts_exhausted or policy_escalation with nothing pending or leased", async () => {
|
|
334
337
|
const { issueId } = await parkAtAttemptsExhausted();
|
|
335
338
|
assert.equal(canEditParkedIssue(getIssue(issueId)), true);
|
|
339
|
+
// NOT-358: a policy_escalation park (e.g. after a developer budget failure) is
|
|
340
|
+
// editable the same way, so the owner can swap agents and resume in place.
|
|
341
|
+
const cappedId = newIssue({ maxInfraAttempts: 0 });
|
|
342
|
+
startWorkflow(cappedId);
|
|
343
|
+
await complete(cappedId, { kind: "session_failed" });
|
|
344
|
+
assert.equal(listHumanActionsForIssue(cappedId).find((a) => a.status === "open").actionType, "policy_escalation");
|
|
345
|
+
assert.equal(canEditParkedIssue(getIssue(cappedId)), true);
|
|
336
346
|
const instance = getActiveWorkflowInstance(issueId);
|
|
337
347
|
const item = enqueueWorkItem({
|
|
338
348
|
issueId,
|
|
@@ -940,3 +950,278 @@ test("NOT-184: resolving the escalation with resume continues without re-trigger
|
|
|
940
950
|
assert.equal(getIssue(issueId).status, "needs_human");
|
|
941
951
|
assert.match(openNonConvergence(issueId).reason, /rounds 4, 5, 6/);
|
|
942
952
|
});
|
|
953
|
+
/** Swap-target profiles with runtimes distinct from the builtins the issue starts on. */
|
|
954
|
+
function n358Agents() {
|
|
955
|
+
const deckId = "00000000-0000-4000-a000-000000000358";
|
|
956
|
+
const dev = createAgent({ name: `n358-dev-${Math.random()}`, runtime: "codex_local", deckId });
|
|
957
|
+
const rev = createAgent({ name: `n358-rev-${Math.random()}`, runtime: "cursor_local", deckId });
|
|
958
|
+
return { dev, rev };
|
|
959
|
+
}
|
|
960
|
+
function reassignedEvents(issueId) {
|
|
961
|
+
return listWorkflowEventsForIssue(issueId).filter((e) => e.type === "issue.reassigned");
|
|
962
|
+
}
|
|
963
|
+
/** The frozen profile snapshot a resumed round carries — what the next session spawns from. */
|
|
964
|
+
function pendingSnapshot(issueId, kind) {
|
|
965
|
+
const item = listWorkItemsForIssue(issueId).find((i) => i.status === "pending" && i.kind === kind);
|
|
966
|
+
assert.ok(item, `expected a pending ${kind} work item`);
|
|
967
|
+
const payload = JSON.parse(item.payloadJson);
|
|
968
|
+
const snapshot = parseProfileSnapshot(payload.profileSnapshot);
|
|
969
|
+
assert.ok(snapshot, "the resumed round carries a frozen profile snapshot");
|
|
970
|
+
return snapshot;
|
|
971
|
+
}
|
|
972
|
+
test("NOT-358: applyParkedEdit swaps each role alone and both, keeping worktree, branch and rounds, one reassigned event per swap", async () => {
|
|
973
|
+
const { dev, rev } = n358Agents();
|
|
974
|
+
const { issueId, actionId } = await parkAtAttemptsExhausted();
|
|
975
|
+
const before = getIssue(issueId);
|
|
976
|
+
assert.equal(before.branch, "issue-1");
|
|
977
|
+
const devSwap = applyParkedEdit(issueId, { developerAgentId: dev.id });
|
|
978
|
+
assert.equal(devSwap.ok, true);
|
|
979
|
+
if (devSwap.ok !== true)
|
|
980
|
+
return;
|
|
981
|
+
assert.equal(devSwap.issue.developerAgentId, dev.id);
|
|
982
|
+
assert.equal(devSwap.issue.reviewerAgentId, before.reviewerAgentId);
|
|
983
|
+
assert.equal(devSwap.issue.branch, before.branch);
|
|
984
|
+
assert.deepEqual([devSwap.issue.currentRound, devSwap.issue.maxReviewRounds], [before.currentRound, before.maxReviewRounds]);
|
|
985
|
+
assert.equal(reassignedEvents(issueId).length, 1);
|
|
986
|
+
assert.deepEqual(JSON.parse(reassignedEvents(issueId)[0].payloadJson), {
|
|
987
|
+
fromRepo: before.repo,
|
|
988
|
+
toRepo: before.repo,
|
|
989
|
+
fromDeveloperAgentId: before.developerAgentId,
|
|
990
|
+
toDeveloperAgentId: dev.id,
|
|
991
|
+
fromReviewerAgentId: before.reviewerAgentId,
|
|
992
|
+
toReviewerAgentId: before.reviewerAgentId,
|
|
993
|
+
});
|
|
994
|
+
const revSwap = applyParkedEdit(issueId, { reviewerAgentId: rev.id });
|
|
995
|
+
assert.equal(revSwap.ok, true);
|
|
996
|
+
if (revSwap.ok !== true)
|
|
997
|
+
return;
|
|
998
|
+
assert.equal(revSwap.issue.reviewerAgentId, rev.id);
|
|
999
|
+
assert.equal(revSwap.issue.branch, before.branch);
|
|
1000
|
+
assert.deepEqual([revSwap.issue.currentRound, revSwap.issue.maxReviewRounds], [before.currentRound, before.maxReviewRounds]);
|
|
1001
|
+
assert.equal(reassignedEvents(issueId).length, 2);
|
|
1002
|
+
// Both roles in one call: a single event carrying both changes.
|
|
1003
|
+
const both = await parkAtAttemptsExhausted();
|
|
1004
|
+
const bothSwap = applyParkedEdit(both.issueId, { developerAgentId: dev.id, reviewerAgentId: rev.id });
|
|
1005
|
+
assert.equal(bothSwap.ok, true);
|
|
1006
|
+
if (bothSwap.ok !== true)
|
|
1007
|
+
return;
|
|
1008
|
+
assert.equal(bothSwap.issue.developerAgentId, dev.id);
|
|
1009
|
+
assert.equal(bothSwap.issue.reviewerAgentId, rev.id);
|
|
1010
|
+
assert.equal(reassignedEvents(both.issueId).length, 1);
|
|
1011
|
+
// Resume freezes the NEW developer profile into the next round's payload — the
|
|
1012
|
+
// worker loop builds the spawned session's runtime from exactly this snapshot.
|
|
1013
|
+
const resolved = resolveHumanActionAndAdvance(actionId, "yusuke", "retry");
|
|
1014
|
+
assert.equal(resolved.ok, true);
|
|
1015
|
+
const snapshot = pendingSnapshot(issueId, "developer");
|
|
1016
|
+
assert.equal(snapshot.agentId, dev.id);
|
|
1017
|
+
assert.equal(snapshot.runtime, "codex_local");
|
|
1018
|
+
assert.equal(snapshot.role, "developer");
|
|
1019
|
+
});
|
|
1020
|
+
test("NOT-358: applyParkedEdit rejects unknown agents (400), locked fields, pending items and running sessions (409)", async () => {
|
|
1021
|
+
const { dev } = n358Agents();
|
|
1022
|
+
const { issueId } = await parkAtAttemptsExhausted();
|
|
1023
|
+
const before = getIssue(issueId);
|
|
1024
|
+
const reassignedBefore = reassignedEvents(issueId).length;
|
|
1025
|
+
const unknown = applyParkedEdit(issueId, { developerAgentId: randomUUID() });
|
|
1026
|
+
assert.equal(unknown.ok, false);
|
|
1027
|
+
if (unknown.ok !== false)
|
|
1028
|
+
return;
|
|
1029
|
+
assert.equal(unknown.code, 400);
|
|
1030
|
+
assert.match(unknown.error, /Unknown agent profile/);
|
|
1031
|
+
const unknownRev = applyParkedEdit(issueId, { reviewerAgentId: randomUUID() });
|
|
1032
|
+
assert.equal(unknownRev.ok, false);
|
|
1033
|
+
if (unknownRev.ok !== false)
|
|
1034
|
+
return;
|
|
1035
|
+
assert.equal(unknownRev.code, 400);
|
|
1036
|
+
assert.equal(getIssue(issueId).developerAgentId, before.developerAgentId);
|
|
1037
|
+
assert.equal(getIssue(issueId).reviewerAgentId, before.reviewerAgentId);
|
|
1038
|
+
assert.equal(reassignedEvents(issueId).length, reassignedBefore);
|
|
1039
|
+
for (const patch of [{ repo: "acme/other" }, { maxReviewRounds: 5 }, { autoMerge: true }]) {
|
|
1040
|
+
const blocked = applyParkedEdit(issueId, patch);
|
|
1041
|
+
assert.equal(blocked.ok, false, JSON.stringify(patch));
|
|
1042
|
+
if (blocked.ok !== false)
|
|
1043
|
+
return;
|
|
1044
|
+
assert.equal(blocked.code, 409);
|
|
1045
|
+
}
|
|
1046
|
+
assert.equal(getIssue(issueId).maxReviewRounds, before.maxReviewRounds);
|
|
1047
|
+
// A pending work item blocks the swap; cancelling it re-opens the surface.
|
|
1048
|
+
const instance = getActiveWorkflowInstance(issueId);
|
|
1049
|
+
const item = enqueueWorkItem({
|
|
1050
|
+
issueId,
|
|
1051
|
+
workflowInstanceId: instance.id,
|
|
1052
|
+
kind: "developer",
|
|
1053
|
+
round: 1,
|
|
1054
|
+
payload: {},
|
|
1055
|
+
idempotencyKey: `${instance.id}:test:n358-pending`,
|
|
1056
|
+
});
|
|
1057
|
+
const queued = applyParkedEdit(issueId, { developerAgentId: dev.id });
|
|
1058
|
+
assert.equal(queued.ok, false);
|
|
1059
|
+
if (queued.ok !== false)
|
|
1060
|
+
return;
|
|
1061
|
+
assert.equal(queued.code, 409);
|
|
1062
|
+
cancelWorkItem(item.id);
|
|
1063
|
+
const afterCancel = applyParkedEdit(issueId, { developerAgentId: dev.id });
|
|
1064
|
+
assert.equal(afterCancel.ok, true);
|
|
1065
|
+
// An actively running session blocks the swap even with nothing pending/leased.
|
|
1066
|
+
const session = createWorkerSession({
|
|
1067
|
+
issueId,
|
|
1068
|
+
role: "developer",
|
|
1069
|
+
round: 1,
|
|
1070
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
1071
|
+
runtime: "claude_code",
|
|
1072
|
+
});
|
|
1073
|
+
startSession(session.id);
|
|
1074
|
+
const running = applyParkedEdit(issueId, { reviewerAgentId: BUILTIN_AGENT_CLAUDE_ID });
|
|
1075
|
+
assert.equal(running.ok, false);
|
|
1076
|
+
if (running.ok !== false)
|
|
1077
|
+
return;
|
|
1078
|
+
assert.equal(running.code, 409);
|
|
1079
|
+
assert.match(running.error, /running session/);
|
|
1080
|
+
completeSession(session.id, { status: "done" });
|
|
1081
|
+
const afterSession = applyParkedEdit(issueId, { title: "Renamed at park" });
|
|
1082
|
+
assert.equal(afterSession.ok, true);
|
|
1083
|
+
});
|
|
1084
|
+
test("NOT-358: parkCapWait parks a usage-cap wait for a human; swap + resume continue in the same worktree", async () => {
|
|
1085
|
+
const { dev } = n358Agents();
|
|
1086
|
+
const issueId = newIssue();
|
|
1087
|
+
startWorkflow(issueId);
|
|
1088
|
+
const until = new Date(Date.now() + 300_000).toISOString();
|
|
1089
|
+
const capReason = "claude_code usage capped — five_hour limit rejected";
|
|
1090
|
+
await complete(issueId, { kind: "usage_capped", until, reason: capReason });
|
|
1091
|
+
const wait = capWaitForIssue(issueId);
|
|
1092
|
+
assert.deepEqual(wait, { kind: "usage_capped", until, reason: capReason });
|
|
1093
|
+
// The attempt's session and branch predate the park — both must survive it.
|
|
1094
|
+
const session = createWorkerSession({
|
|
1095
|
+
issueId,
|
|
1096
|
+
role: "developer",
|
|
1097
|
+
round: 1,
|
|
1098
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
1099
|
+
runtime: "claude_code",
|
|
1100
|
+
});
|
|
1101
|
+
startSession(session.id);
|
|
1102
|
+
completeSession(session.id, { status: "done", worktreePath: "/tmp/n358-worktree" });
|
|
1103
|
+
const { transitionIssue } = await import("../repository/issues.js");
|
|
1104
|
+
transitionIssue(issueId, "developing", { branch: "dealer/n358-park" });
|
|
1105
|
+
const parked = parkCapWait(issueId, "op");
|
|
1106
|
+
assert.equal(parked.ok, true);
|
|
1107
|
+
if (parked.ok !== true)
|
|
1108
|
+
return;
|
|
1109
|
+
assert.equal(parked.issueStatus, "needs_human");
|
|
1110
|
+
const issue = getIssue(issueId);
|
|
1111
|
+
assert.equal(issue.currentOwner, "human");
|
|
1112
|
+
assert.match(issue.currentIntent ?? "", /five_hour/);
|
|
1113
|
+
assert.match(issue.currentIntent ?? "", new RegExp(until.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
1114
|
+
assert.equal(issue.branch, "dealer/n358-park");
|
|
1115
|
+
assert.deepEqual([issue.currentRound, issue.maxReviewRounds], [1, 3]);
|
|
1116
|
+
const open = listHumanActionsForIssue(issueId).filter((a) => a.status === "open");
|
|
1117
|
+
assert.equal(open.length, 1);
|
|
1118
|
+
assert.equal(open[0].actionType, "policy_escalation");
|
|
1119
|
+
assert.match(open[0].reason, /five_hour/);
|
|
1120
|
+
assert.match(open[0].reason, /wait until/);
|
|
1121
|
+
assert.equal(capWaitForIssue(issueId), null, "the wait is cancelled, not deferred again");
|
|
1122
|
+
assert.deepEqual(listWorkItemsForIssue(issueId).map((i) => i.status), ["cancelled"], "the deferred wait is cancelled");
|
|
1123
|
+
const keptSession = listWorkerSessionsForIssue(issueId).find((s) => s.id === session.id);
|
|
1124
|
+
assert.equal(keptSession.worktreePath, "/tmp/n358-worktree");
|
|
1125
|
+
// Swap while parked, then resume — the next developer round carries the new profile.
|
|
1126
|
+
const swapped = applyParkedEdit(issueId, { developerAgentId: dev.id });
|
|
1127
|
+
assert.equal(swapped.ok, true);
|
|
1128
|
+
const resolved = resolveHumanActionAndAdvance(open[0].id, "op", "resume");
|
|
1129
|
+
assert.equal(resolved.ok, true);
|
|
1130
|
+
if (resolved.ok !== true)
|
|
1131
|
+
return;
|
|
1132
|
+
assert.equal(resolved.issueStatus, "developing");
|
|
1133
|
+
assert.equal(getIssue(issueId).branch, "dealer/n358-park");
|
|
1134
|
+
const snapshot = pendingSnapshot(issueId, "developer");
|
|
1135
|
+
assert.equal(snapshot.agentId, dev.id);
|
|
1136
|
+
assert.equal(snapshot.runtime, "codex_local");
|
|
1137
|
+
});
|
|
1138
|
+
test("NOT-358: parkCapWait parks a deck-outage wait, and a reviewer wait resumes as reviewer", async () => {
|
|
1139
|
+
const deckId = newIssue();
|
|
1140
|
+
startWorkflow(deckId);
|
|
1141
|
+
await complete(deckId, { kind: "deck_unavailable", reason: "Agent Deck unreachable — connection refused" });
|
|
1142
|
+
assert.equal(capWaitForIssue(deckId)?.kind, "deck_unavailable");
|
|
1143
|
+
const parked = parkCapWait(deckId, "op");
|
|
1144
|
+
assert.equal(parked.ok, true);
|
|
1145
|
+
const open = listHumanActionsForIssue(deckId).filter((a) => a.status === "open");
|
|
1146
|
+
assert.equal(open.length, 1);
|
|
1147
|
+
assert.match(open[0].reason, /Agent Deck outage/);
|
|
1148
|
+
assert.match(open[0].reason, /connection refused/);
|
|
1149
|
+
const resumed = resolveHumanActionAndAdvance(open[0].id, "op", "resume");
|
|
1150
|
+
assert.equal(resumed.ok, true);
|
|
1151
|
+
if (resumed.ok !== true)
|
|
1152
|
+
return;
|
|
1153
|
+
assert.equal(resumed.issueStatus, "developing");
|
|
1154
|
+
// The resume above enqueued a fresh developer item — cancel it so the reviewer flow
|
|
1155
|
+
// below claims its own item (`claim` takes the oldest pending item across issues).
|
|
1156
|
+
for (const item of listWorkItemsForIssue(deckId)) {
|
|
1157
|
+
if (item.status === "pending")
|
|
1158
|
+
cancelWorkItem(item.id);
|
|
1159
|
+
}
|
|
1160
|
+
// A reviewer-role wait tags the continuation so resume re-queues a reviewer at the
|
|
1161
|
+
// pinned head instead of an unrelated developer round.
|
|
1162
|
+
const revId = newIssue();
|
|
1163
|
+
startWorkflow(revId);
|
|
1164
|
+
await complete(revId, cleanHandoff);
|
|
1165
|
+
await complete(revId, { kind: "deck_unavailable", reason: "Agent Deck unreachable — reset by peer" });
|
|
1166
|
+
assert.equal(capWaitForIssue(revId)?.kind, "deck_unavailable");
|
|
1167
|
+
const revParked = parkCapWait(revId, "op");
|
|
1168
|
+
assert.equal(revParked.ok, true);
|
|
1169
|
+
const revOpen = listHumanActionsForIssue(revId).filter((a) => a.status === "open");
|
|
1170
|
+
assert.equal(revOpen.length, 1);
|
|
1171
|
+
const revResumed = resolveHumanActionAndAdvance(revOpen[0].id, "op", "resume");
|
|
1172
|
+
assert.equal(revResumed.ok, true);
|
|
1173
|
+
if (revResumed.ok !== true)
|
|
1174
|
+
return;
|
|
1175
|
+
assert.equal(revResumed.issueStatus, "reviewing");
|
|
1176
|
+
const pending = listWorkItemsForIssue(revId).filter((i) => i.status === "pending");
|
|
1177
|
+
assert.deepEqual(pending.map((i) => i.kind), ["reviewer"]);
|
|
1178
|
+
const payload = JSON.parse(pending[0].payloadJson);
|
|
1179
|
+
assert.equal(payload.inputSha, "abc123");
|
|
1180
|
+
});
|
|
1181
|
+
test("NOT-358: parkCapWait rejects without a wait, with leased work, or with a running session", async () => {
|
|
1182
|
+
const readyId = newIssue();
|
|
1183
|
+
const noInstance = parkCapWait(readyId, "op");
|
|
1184
|
+
assert.equal(noInstance.ok, false);
|
|
1185
|
+
if (noInstance.ok !== false)
|
|
1186
|
+
return;
|
|
1187
|
+
assert.equal(noInstance.code, 409);
|
|
1188
|
+
const plainId = newIssue();
|
|
1189
|
+
startWorkflow(plainId);
|
|
1190
|
+
const noWait = parkCapWait(plainId, "op");
|
|
1191
|
+
assert.equal(noWait.ok, false);
|
|
1192
|
+
if (noWait.ok !== false)
|
|
1193
|
+
return;
|
|
1194
|
+
assert.equal(noWait.code, 409);
|
|
1195
|
+
assert.match(noWait.error, /usage-cap or deck-outage/);
|
|
1196
|
+
const leased = claim(plainId);
|
|
1197
|
+
const leasedPark = parkCapWait(plainId, "op");
|
|
1198
|
+
assert.equal(leasedPark.ok, false);
|
|
1199
|
+
if (leasedPark.ok !== false)
|
|
1200
|
+
return;
|
|
1201
|
+
assert.equal(leasedPark.code, 409);
|
|
1202
|
+
// The failed park changes nothing: the lease is intact and the issue still develops.
|
|
1203
|
+
assert.equal(getIssue(plainId).status, "developing");
|
|
1204
|
+
assert.equal(getWorkItem(leased.id).status, "leased");
|
|
1205
|
+
const until = new Date(Date.now() + 300_000).toISOString();
|
|
1206
|
+
await applyCompletion(leased.id, leased.leaseToken, {
|
|
1207
|
+
kind: "usage_capped",
|
|
1208
|
+
until,
|
|
1209
|
+
reason: "claude_code usage capped — test",
|
|
1210
|
+
});
|
|
1211
|
+
const running = createWorkerSession({
|
|
1212
|
+
issueId: plainId,
|
|
1213
|
+
role: "developer",
|
|
1214
|
+
round: 1,
|
|
1215
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
1216
|
+
runtime: "claude_code",
|
|
1217
|
+
});
|
|
1218
|
+
startSession(running.id);
|
|
1219
|
+
const runningPark = parkCapWait(plainId, "op");
|
|
1220
|
+
assert.equal(runningPark.ok, false);
|
|
1221
|
+
if (runningPark.ok !== false)
|
|
1222
|
+
return;
|
|
1223
|
+
assert.equal(runningPark.code, 409);
|
|
1224
|
+
assert.match(runningPark.error, /running session/);
|
|
1225
|
+
assert.equal(getIssue(plainId).status, "developing");
|
|
1226
|
+
assert.ok(capWaitForIssue(plainId), "the wait survives the rejected park");
|
|
1227
|
+
});
|