agent-dealer 1.2.11 → 1.2.13
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/linear-inbox.js +72 -16
- package/bundle/server/dist/adapters/linear-inbox.test.js +183 -1
- package/bundle/server/dist/coordinator/commands.js +204 -5
- package/bundle/server/dist/coordinator/commands.test.js +288 -3
- package/bundle/server/dist/repository/intake-settings.js +50 -0
- package/bundle/server/dist/repository/intake-settings.test.js +72 -0
- package/bundle/server/dist/routes/index.js +6 -3
- package/bundle/server/dist/routes/intake-linear-config.js +31 -0
- package/bundle/server/dist/routes/intake-linear.test.js +95 -5
- 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-BzLjMBI9.js +64 -0
- package/bundle/server/static-ui/assets/index-COYJxD1y.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/index.d.ts +303 -0
- package/bundle/shared/dist/index.js +47 -0
- package/bundle/shared/dist/linear-intake.d.ts +14 -0
- package/bundle/shared/dist/linear-intake.js +23 -0
- package/bundle/shared/dist/linear-intake.test.js +41 -2
- 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
- package/bundle/server/static-ui/assets/index-K_YcYkQU.css +0 -1
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { resolveLinearRepoWithMappings } from "@agent-dealer/shared";
|
|
2
2
|
import { DEFAULT_LINEAR_STATE_FILTER, getLinearIntakeConfig } from "../repository/intake-settings.js";
|
|
3
3
|
import { listRepositoryMappings } from "../repository/repository-mappings.js";
|
|
4
|
-
import { linearGraphqlRequest } from "./linear-graphql.js";
|
|
4
|
+
import { LinearApiKeyMissingError, linearGraphqlRequest } from "./linear-graphql.js";
|
|
5
5
|
export { DEFAULT_LINEAR_STATE_FILTER };
|
|
6
|
-
|
|
6
|
+
/** NOT-361: first-page bound for the From Linear picker — never walk further cursors. */
|
|
7
|
+
export const LINEAR_CANDIDATE_PAGE_SIZE = 50;
|
|
8
|
+
const PAGE_SIZE = LINEAR_CANDIDATE_PAGE_SIZE;
|
|
7
9
|
function hasApiKey() {
|
|
8
10
|
return Boolean(process.env.LINEAR_API_KEY);
|
|
9
11
|
}
|
|
@@ -81,35 +83,89 @@ export function parseLinearIssueRef(raw) {
|
|
|
81
83
|
}
|
|
82
84
|
return null;
|
|
83
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* NOT-361: at most one GraphQL page of candidates, most recently updated first.
|
|
88
|
+
* Does not walk `pageInfo` cursors — `hasMore` tells the UI when matches remain.
|
|
89
|
+
*/
|
|
84
90
|
export async function listLinearCandidates() {
|
|
85
91
|
if (!hasApiKey())
|
|
86
|
-
return [];
|
|
92
|
+
return { candidates: [], hasMore: false };
|
|
87
93
|
const settings = getLinearIntakeConfig();
|
|
88
94
|
let viewerId;
|
|
89
95
|
if (settings.assigneeMe) {
|
|
90
96
|
const viewer = await getLinearViewer();
|
|
91
97
|
viewerId = viewer?.id;
|
|
92
98
|
if (!viewerId)
|
|
93
|
-
return [];
|
|
99
|
+
return { candidates: [], hasMore: false };
|
|
94
100
|
}
|
|
95
101
|
const filter = buildIssueFilter(settings, viewerId);
|
|
96
|
-
const
|
|
97
|
-
|
|
102
|
+
const data = (await linearQuery("listLinearCandidates", `query PollIssues($filter: IssueFilter) {
|
|
103
|
+
issues(filter: $filter, first: ${PAGE_SIZE}, orderBy: updatedAt) {
|
|
104
|
+
nodes { ${ISSUE_FIELDS} }
|
|
105
|
+
pageInfo { hasNextPage }
|
|
106
|
+
}
|
|
107
|
+
}`, { filter }));
|
|
108
|
+
// NOT-260: mappings load once per list operation, not once per candidate.
|
|
109
|
+
const mappings = listRepositoryMappings();
|
|
110
|
+
return {
|
|
111
|
+
candidates: data.issues.nodes.map((n) => nodeToCandidate(n, mappings)),
|
|
112
|
+
hasMore: Boolean(data.issues.pageInfo.hasNextPage),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** Page size when walking Linear teams for the filter editor metadata. */
|
|
116
|
+
const METADATA_TEAM_PAGE_SIZE = 50;
|
|
117
|
+
/**
|
|
118
|
+
* NOT-361: teams, workflow statuses, and the authenticated Linear viewer for
|
|
119
|
+
* the inline filter editor — never ask the operator for raw UUIDs or CSV text.
|
|
120
|
+
* Teams are walked page-by-page so large workspaces are not truncated at
|
|
121
|
+
* Linear's default first page.
|
|
122
|
+
*/
|
|
123
|
+
export async function fetchLinearIntakeMetadata() {
|
|
124
|
+
if (!hasApiKey())
|
|
125
|
+
throw new LinearApiKeyMissingError();
|
|
126
|
+
const viewerData = (await linearQuery("fetchLinearIntakeMetadataViewer", `query IntakeMetadataViewer { viewer { id name email } }`));
|
|
127
|
+
const teams = [];
|
|
128
|
+
const workflowStates = [];
|
|
129
|
+
let after = null;
|
|
98
130
|
for (;;) {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
nodes {
|
|
131
|
+
const page = (await linearQuery("fetchLinearIntakeMetadataTeams", `query IntakeMetadataTeams($first: Int!, $after: String) {
|
|
132
|
+
teams(first: $first, after: $after) {
|
|
133
|
+
nodes {
|
|
134
|
+
id
|
|
135
|
+
name
|
|
136
|
+
key
|
|
137
|
+
states { nodes { name type } }
|
|
138
|
+
}
|
|
102
139
|
pageInfo { hasNextPage endCursor }
|
|
103
140
|
}
|
|
104
|
-
}`, {
|
|
105
|
-
|
|
106
|
-
|
|
141
|
+
}`, { first: METADATA_TEAM_PAGE_SIZE, after }));
|
|
142
|
+
for (const team of page.teams?.nodes ?? []) {
|
|
143
|
+
teams.push({ id: team.id, name: team.name, key: team.key });
|
|
144
|
+
for (const state of team.states?.nodes ?? []) {
|
|
145
|
+
const name = state.name?.trim();
|
|
146
|
+
if (!name)
|
|
147
|
+
continue;
|
|
148
|
+
workflowStates.push({ name, type: state.type, teamId: team.id });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!page.teams?.pageInfo?.hasNextPage || !page.teams.pageInfo.endCursor)
|
|
107
152
|
break;
|
|
108
|
-
after =
|
|
153
|
+
after = page.teams.pageInfo.endCursor;
|
|
109
154
|
}
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
155
|
+
// Stable team order by name; statuses keep Linear's per-team order, then
|
|
156
|
+
// unique-by-name order is left to the UI when "All teams" is selected.
|
|
157
|
+
teams.sort((a, b) => a.name.localeCompare(b.name));
|
|
158
|
+
return {
|
|
159
|
+
teams,
|
|
160
|
+
workflowStates,
|
|
161
|
+
viewer: viewerData.viewer
|
|
162
|
+
? {
|
|
163
|
+
id: viewerData.viewer.id,
|
|
164
|
+
name: viewerData.viewer.name,
|
|
165
|
+
email: viewerData.viewer.email,
|
|
166
|
+
}
|
|
167
|
+
: null,
|
|
168
|
+
};
|
|
113
169
|
}
|
|
114
170
|
export async function getLinearIssue(issueId) {
|
|
115
171
|
const data = (await linearQuery("getLinearIssue", `query Issue($id: String!) {
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { buildIssueFilter, LINEAR_CANDIDATE_PAGE_SIZE, listLinearCandidates, fetchLinearIntakeMetadata, nodeToCandidate, parseLinearIssueRef, } from "./linear-inbox.js";
|
|
4
7
|
test("parseLinearIssueRef accepts identifier, URL, and UUID", () => {
|
|
5
8
|
assert.equal(parseLinearIssueRef("NOT-103"), "NOT-103");
|
|
6
9
|
assert.equal(parseLinearIssueRef(" not-90 "), "NOT-90");
|
|
@@ -81,3 +84,182 @@ test("buildIssueFilter includes assignee when assigneeMe is true", () => {
|
|
|
81
84
|
assignee: { id: { eq: "viewer-1" } },
|
|
82
85
|
});
|
|
83
86
|
});
|
|
87
|
+
// NOT-361: bounded candidate fetch — AND filters, updatedAt order, first page only.
|
|
88
|
+
test("listLinearCandidates requests AND filters, updatedAt order, and a single page", async () => {
|
|
89
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-linear-inbox-"));
|
|
90
|
+
process.env.LINEAR_API_KEY = "test-key";
|
|
91
|
+
delete process.env.LINEAR_STATE_FILTER;
|
|
92
|
+
delete process.env.LINEAR_TEAM_ID;
|
|
93
|
+
const { migrate } = await import("../db/index.js");
|
|
94
|
+
migrate();
|
|
95
|
+
const { patchLinearIntakeConfig } = await import("../repository/intake-settings.js");
|
|
96
|
+
patchLinearIntakeConfig({
|
|
97
|
+
stateFilter: ["Todo", "In Progress"],
|
|
98
|
+
teamId: "team-9",
|
|
99
|
+
assigneeMe: true,
|
|
100
|
+
});
|
|
101
|
+
const requests = [];
|
|
102
|
+
const realFetch = globalThis.fetch;
|
|
103
|
+
globalThis.fetch = (async (_url, init) => {
|
|
104
|
+
const body = JSON.parse(String(init?.body ?? "{}"));
|
|
105
|
+
requests.push({ query: body.query ?? "", variables: body.variables });
|
|
106
|
+
if ((body.query ?? "").includes("viewer {")) {
|
|
107
|
+
return {
|
|
108
|
+
ok: true,
|
|
109
|
+
status: 200,
|
|
110
|
+
headers: new Headers(),
|
|
111
|
+
text: async () => JSON.stringify({ data: { viewer: { id: "viewer-1", name: "Ada" } } }),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
status: 200,
|
|
117
|
+
headers: new Headers(),
|
|
118
|
+
text: async () => JSON.stringify({
|
|
119
|
+
data: {
|
|
120
|
+
issues: {
|
|
121
|
+
nodes: [
|
|
122
|
+
{
|
|
123
|
+
id: "i1",
|
|
124
|
+
identifier: "NOT-1",
|
|
125
|
+
title: "one",
|
|
126
|
+
url: "https://linear.app/x/issue/NOT-1/one",
|
|
127
|
+
state: { name: "Todo" },
|
|
128
|
+
team: { id: "team-9" },
|
|
129
|
+
labels: { nodes: [] },
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
pageInfo: { hasNextPage: true },
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
try {
|
|
139
|
+
const page = await listLinearCandidates();
|
|
140
|
+
assert.equal(page.candidates.length, 1);
|
|
141
|
+
assert.equal(page.hasMore, true);
|
|
142
|
+
// viewer (assigneeMe) + one issues page — never a second cursor page.
|
|
143
|
+
assert.equal(requests.length, 2);
|
|
144
|
+
const issuesReq = requests.find((r) => r.query.includes("issues("));
|
|
145
|
+
assert.ok(issuesReq, "issues query sent");
|
|
146
|
+
assert.match(issuesReq.query, /orderBy:\s*updatedAt/);
|
|
147
|
+
assert.match(issuesReq.query, new RegExp(`first:\\s*${LINEAR_CANDIDATE_PAGE_SIZE}`));
|
|
148
|
+
assert.ok(!issuesReq.query.includes("$after"), "no cursor variable");
|
|
149
|
+
assert.deepEqual(issuesReq.variables?.filter, {
|
|
150
|
+
state: { name: { in: ["Todo", "In Progress"] } },
|
|
151
|
+
team: { id: { eq: "team-9" } },
|
|
152
|
+
assignee: { id: { eq: "viewer-1" } },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
globalThis.fetch = realFetch;
|
|
157
|
+
delete process.env.LINEAR_API_KEY;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
test("fetchLinearIntakeMetadata returns teams, statuses, and viewer", async () => {
|
|
161
|
+
process.env.LINEAR_API_KEY = "test-key";
|
|
162
|
+
const realFetch = globalThis.fetch;
|
|
163
|
+
const bodies = [];
|
|
164
|
+
globalThis.fetch = (async (_input, init) => {
|
|
165
|
+
bodies.push(JSON.parse(String(init?.body ?? "{}")));
|
|
166
|
+
const op = bodies[bodies.length - 1].query ?? "";
|
|
167
|
+
if (op.includes("IntakeMetadataViewer")) {
|
|
168
|
+
return new Response(JSON.stringify({
|
|
169
|
+
data: { viewer: { id: "v1", name: "Ada", email: "ada@example.com" } },
|
|
170
|
+
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
171
|
+
}
|
|
172
|
+
return new Response(JSON.stringify({
|
|
173
|
+
data: {
|
|
174
|
+
teams: {
|
|
175
|
+
nodes: [
|
|
176
|
+
{
|
|
177
|
+
id: "t1",
|
|
178
|
+
name: "Core",
|
|
179
|
+
key: "COR",
|
|
180
|
+
states: {
|
|
181
|
+
nodes: [
|
|
182
|
+
{ name: "Todo", type: "unstarted" },
|
|
183
|
+
{ name: "Done", type: "completed" },
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
192
|
+
});
|
|
193
|
+
try {
|
|
194
|
+
const meta = await fetchLinearIntakeMetadata();
|
|
195
|
+
assert.equal(meta.viewer?.name, "Ada");
|
|
196
|
+
assert.equal(meta.teams[0]?.name, "Core");
|
|
197
|
+
assert.ok(meta.workflowStates.some((s) => s.name === "Todo" && s.teamId === "t1"));
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
globalThis.fetch = realFetch;
|
|
201
|
+
delete process.env.LINEAR_API_KEY;
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
test("fetchLinearIntakeMetadata walks team pages beyond Linear's default first page", async () => {
|
|
205
|
+
process.env.LINEAR_API_KEY = "test-key";
|
|
206
|
+
const realFetch = globalThis.fetch;
|
|
207
|
+
let teamPageCalls = 0;
|
|
208
|
+
globalThis.fetch = (async (_input, init) => {
|
|
209
|
+
const body = JSON.parse(String(init?.body ?? "{}"));
|
|
210
|
+
if ((body.query ?? "").includes("IntakeMetadataViewer")) {
|
|
211
|
+
return new Response(JSON.stringify({ data: { viewer: { id: "v1", name: "Ada" } } }), {
|
|
212
|
+
status: 200,
|
|
213
|
+
headers: { "Content-Type": "application/json" },
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
teamPageCalls += 1;
|
|
217
|
+
assert.equal(body.variables?.first, 50, "requests a bounded team page");
|
|
218
|
+
if (teamPageCalls === 1) {
|
|
219
|
+
assert.equal(body.variables?.after ?? null, null, "first page has no cursor");
|
|
220
|
+
return new Response(JSON.stringify({
|
|
221
|
+
data: {
|
|
222
|
+
teams: {
|
|
223
|
+
nodes: [
|
|
224
|
+
{
|
|
225
|
+
id: "t1",
|
|
226
|
+
name: "Alpha",
|
|
227
|
+
states: { nodes: [{ name: "Todo", type: "unstarted" }] },
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
pageInfo: { hasNextPage: true, endCursor: "cursor-1" },
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
234
|
+
}
|
|
235
|
+
assert.equal(body.variables?.after, "cursor-1", "follows endCursor");
|
|
236
|
+
return new Response(JSON.stringify({
|
|
237
|
+
data: {
|
|
238
|
+
teams: {
|
|
239
|
+
nodes: [
|
|
240
|
+
{
|
|
241
|
+
id: "t2",
|
|
242
|
+
name: "Zeta",
|
|
243
|
+
states: { nodes: [{ name: "In Progress", type: "started" }] },
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
250
|
+
});
|
|
251
|
+
try {
|
|
252
|
+
const meta = await fetchLinearIntakeMetadata();
|
|
253
|
+
assert.equal(teamPageCalls, 2, "walks a second team page");
|
|
254
|
+
assert.deepEqual(meta.teams.map((t) => t.name), ["Alpha", "Zeta"]);
|
|
255
|
+
assert.ok(meta.workflowStates.some((s) => s.name === "In Progress" && s.teamId === "t2"));
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
globalThis.fetch = realFetch;
|
|
259
|
+
delete process.env.LINEAR_API_KEY;
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
test("fetchLinearIntakeMetadata fails clearly without LINEAR_API_KEY", async () => {
|
|
263
|
+
delete process.env.LINEAR_API_KEY;
|
|
264
|
+
await assert.rejects(() => fetchLinearIntakeMetadata(), /LINEAR_API_KEY/);
|
|
265
|
+
});
|
|
@@ -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 {
|