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.
@@ -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
- const PAGE_SIZE = 50;
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 nodes = [];
97
- let after;
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 data = (await linearQuery("listLinearCandidates", `query PollIssues($filter: IssueFilter, $after: String) {
100
- issues(filter: $filter, first: ${PAGE_SIZE}, after: $after) {
101
- nodes { ${ISSUE_FIELDS} }
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
- }`, { filter, after: after ?? null }));
105
- nodes.push(...data.issues.nodes);
106
- if (!data.issues.pageInfo.hasNextPage || !data.issues.pageInfo.endCursor)
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 = data.issues.pageInfo.endCursor;
153
+ after = page.teams.pageInfo.endCursor;
109
154
  }
110
- // NOT-260: mappings load once per list operation, not once per candidate.
111
- const mappings = listRepositoryMappings();
112
- return nodes.map((n) => nodeToCandidate(n, mappings));
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 { buildIssueFilter, nodeToCandidate, parseLinearIssueRef } from "./linear-inbox.js";
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
- if (action.actionType === "attempts_exhausted" && resolution.choice === "retry") {
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 {