agent-dealer 1.2.13 → 1.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/bundle/server/dist/adapters/git-worktree.js +15 -0
  2. package/bundle/server/dist/adapters/git-worktree.test.js +30 -0
  3. package/bundle/server/dist/adapters/linear-inbox.js +97 -0
  4. package/bundle/server/dist/adapters/linear-inbox.test.js +299 -1
  5. package/bundle/server/dist/adapters/linear-sync.js +33 -10
  6. package/bundle/server/dist/capacity/claude-events.js +25 -5
  7. package/bundle/server/dist/capacity/claude-local-cache.js +159 -13
  8. package/bundle/server/dist/capacity/claude-local-cache.test.js +191 -3
  9. package/bundle/server/dist/coordinator/auto-merge.js +10 -0
  10. package/bundle/server/dist/coordinator/branch-progress.js +5 -4
  11. package/bundle/server/dist/coordinator/commands.js +37 -3
  12. package/bundle/server/dist/coordinator/delete-issue.js +473 -0
  13. package/bundle/server/dist/coordinator/developer-effect.js +33 -6
  14. package/bundle/server/dist/coordinator/linear-merge-verify.js +382 -0
  15. package/bundle/server/dist/coordinator/linear-merge-verify.test.js +376 -0
  16. package/bundle/server/dist/coordinator/prompts.js +9 -1
  17. package/bundle/server/dist/coordinator/prompts.test.js +93 -0
  18. package/bundle/server/dist/coordinator/source-attachments.js +496 -0
  19. package/bundle/server/dist/coordinator/source-attachments.test.js +396 -0
  20. package/bundle/server/dist/coordinator/source-reload.js +178 -0
  21. package/bundle/server/dist/db/index.js +4 -0
  22. package/bundle/server/dist/db/schema.sql +32 -0
  23. package/bundle/server/dist/docs-linear-attachments.test.js +36 -0
  24. package/bundle/server/dist/paths.js +12 -0
  25. package/bundle/server/dist/repository/runtime-capacity.js +19 -3
  26. package/bundle/server/dist/repository/source-attachments.js +56 -0
  27. package/bundle/server/dist/routes/human-actions.js +17 -0
  28. package/bundle/server/dist/routes/issues-delete.test.js +666 -0
  29. package/bundle/server/dist/routes/issues-execution-contract.test.js +296 -0
  30. package/bundle/server/dist/routes/issues-source-attachments.test.js +447 -0
  31. package/bundle/server/dist/routes/issues.js +109 -1
  32. package/bundle/server/dist/routes/runtime-capacity.test.js +35 -0
  33. package/bundle/server/package.json +2 -2
  34. package/bundle/server/static-ui/assets/index-DVyqblie.js +64 -0
  35. package/bundle/server/static-ui/index.html +1 -1
  36. package/bundle/shared/dist/index.d.ts +108 -2
  37. package/bundle/shared/dist/index.js +12 -1
  38. package/bundle/shared/dist/issues.d.ts +43 -2
  39. package/bundle/shared/dist/issues.js +10 -0
  40. package/bundle/shared/dist/linear-intake.d.ts +85 -0
  41. package/bundle/shared/dist/linear-intake.js +56 -0
  42. package/bundle/shared/dist/linear-intake.test.js +55 -1
  43. package/bundle/shared/dist/runtime-capacity.d.ts +147 -0
  44. package/bundle/shared/dist/runtime-capacity.js +15 -0
  45. package/bundle/shared/dist/workflow.d.ts +4 -4
  46. package/bundle/shared/dist/workflow.js +7 -0
  47. package/bundle/shared/package.json +1 -1
  48. package/package.json +1 -1
  49. package/bundle/server/static-ui/assets/index-BzLjMBI9.js +0 -64
@@ -0,0 +1,382 @@
1
+ // packages/server/src/coordinator/linear-merge-verify.ts
2
+ //
3
+ // NOT-362: when Dealer merges an approved PR for a `source = linear` issue, the
4
+ // Linear issue it came from must either advance, or a human must be told it did
5
+ // not. Linear's GitHub integration normally drives that (attaching the PR and
6
+ // moving the issue out of Backlog), but on repositories the integration does
7
+ // not cover nothing fires and Dealer recorded nothing — the issue sat stale
8
+ // with zero evidence anywhere.
9
+ //
10
+ // The check runs after the coordinator's terminal merged transition
11
+ // (finalizeAutoMerge's `done`, plus the external-merge close-as-`done` paths):
12
+ // re-read the Linear issue over a bounded retry window and treat it as advanced
13
+ // when its state type left `backlog`/`unstarted` or its attachments contain the
14
+ // merged PR URL. Every outcome is recorded as an issue artifact (both
15
+ // directions — a stale source issue appears in history instead of being
16
+ // inferred from absence). A stale issue with `linear.syncEnabled` gets exactly
17
+ // one fallback write to the team's completed state plus the Dealer comment,
18
+ // reusing linear-sync's writer; when the fallback is unavailable or fails, one
19
+ // open `policy_escalation` names the Linear identifier, the merged PR URL and
20
+ // the observed state (idempotent re-raise by stable request id).
21
+ //
22
+ // Safety: this module never touches the Dealer issue's status, never blocks
23
+ // the merge, and never throws — every `done` landing triggers it fire-and-forget
24
+ // (`triggerLinearPostMerge`), so the bounded retry window never holds the merge
25
+ // caller. The raised action is a notice, not a gate: it resolves through a
26
+ // dedicated acknowledge/re-check resolution (no active workflow to advance on a
27
+ // `done` issue), and advancing the Linear issue by hand then re-checking clears
28
+ // it when the re-read verifies.
29
+ import { createIssueArtifact } from "../repository/artifacts.js";
30
+ import { listArtifactsForIssueByKind } from "../repository/artifacts-for-issue.js";
31
+ import { createHumanAction, findOpenHumanActionByRequestId, getHumanAction, resolveHumanAction, } from "../repository/human-actions.js";
32
+ import { getIssue } from "../repository/issues.js";
33
+ import { getLinearIntakeConfig } from "../repository/intake-settings.js";
34
+ import { linearGraphqlRequest } from "../adapters/linear-graphql.js";
35
+ import { buildDoneComment, dealerIssueUrl, postLinearIssueComment, resolveCompletedStateId, setLinearIssueState, } from "../adapters/linear-sync.js";
36
+ /** Issue-artifact kind for every post-merge verification outcome. */
37
+ export const LINEAR_MERGE_VERIFY_ARTIFACT_KIND = "linear_merge_verify";
38
+ /** Stable request id for the stale-source human action — re-runs dedupe onto it. */
39
+ export function linearMergeStaleRequestId(issueId) {
40
+ return `linear-merge-stale:${issueId}`;
41
+ }
42
+ /** Bounded retry window so the GitHub integration has time to act. */
43
+ export const LINEAR_POST_MERGE_READ_DELAYS_MS = [0, 10_000, 30_000];
44
+ async function defaultPostMergeReader(externalId) {
45
+ const data = (await linearGraphqlRequest({
46
+ operation: "readLinearPostMergeState",
47
+ query: `query PostMergeState($id: String!) {
48
+ issue(id: $id) {
49
+ identifier
50
+ url
51
+ state { name type }
52
+ team { id }
53
+ attachments(first: 50) { nodes { url } }
54
+ }
55
+ }`,
56
+ variables: { id: externalId },
57
+ timeoutMs: 15_000,
58
+ }));
59
+ if (!data.issue)
60
+ throw new Error(`Linear issue not found: ${externalId}`);
61
+ const urls = (data.issue.attachments?.nodes ?? [])
62
+ .map((n) => n?.url)
63
+ .filter((u) => typeof u === "string" && u.length > 0);
64
+ return {
65
+ identifier: data.issue.identifier ?? null,
66
+ linearUrl: data.issue.url ?? null,
67
+ stateName: data.issue.state?.name ?? null,
68
+ stateType: data.issue.state?.type ?? null,
69
+ teamId: data.issue.team?.id ?? null,
70
+ attachmentUrls: urls,
71
+ prAttached: false,
72
+ advancedViaState: false,
73
+ };
74
+ }
75
+ let postMergeReader = defaultPostMergeReader;
76
+ /** Test hook — inject a fake Linear reader so tests never hit the network. */
77
+ export function setPostMergeReaderForTests(fn) {
78
+ postMergeReader = fn ?? defaultPostMergeReader;
79
+ }
80
+ let readDelaysMs = LINEAR_POST_MERGE_READ_DELAYS_MS;
81
+ /** Test hook — shrink the retry window (e.g. `[0, 0]`) so tests stay fast. */
82
+ export function setPostMergeDelaysForTests(delays) {
83
+ readDelaysMs = delays ?? LINEAR_POST_MERGE_READ_DELAYS_MS;
84
+ }
85
+ function sleep(ms) {
86
+ return new Promise((resolve) => {
87
+ // Unref'd: this check is fire-and-forget off every `done` landing and must
88
+ // never hold the merge caller — or a test runner's process — open on its own.
89
+ const timer = setTimeout(resolve, ms);
90
+ if (typeof timer.unref === "function")
91
+ timer.unref();
92
+ });
93
+ }
94
+ /** State types that still mean "the integration has not acted". */
95
+ function isStaleStateType(stateType) {
96
+ const t = (stateType ?? "").toLowerCase();
97
+ return t === "" || t === "backlog" || t === "unstarted";
98
+ }
99
+ function fallbackAlreadyWritten(issueId) {
100
+ for (const artifact of listArtifactsForIssueByKind(issueId, LINEAR_MERGE_VERIFY_ARTIFACT_KIND)) {
101
+ try {
102
+ if (JSON.parse(artifact.contentJson ?? "null")?.fallback === "written") {
103
+ return true;
104
+ }
105
+ }
106
+ catch {
107
+ // A row this module did not write — ignore, never treat as a guard.
108
+ }
109
+ }
110
+ return false;
111
+ }
112
+ function dismissOpenStaleAction(issueId, note) {
113
+ const open = findOpenHumanActionByRequestId(issueId, "policy_escalation", linearMergeStaleRequestId(issueId));
114
+ if (open) {
115
+ try {
116
+ resolveHumanAction(open.id, "system", { choice: "dismissed", note });
117
+ }
118
+ catch {
119
+ // Resolving the notice must never fail the verification.
120
+ }
121
+ }
122
+ }
123
+ function staleReasonText(opts) {
124
+ const observed = opts.unreadable
125
+ ? `could not be re-read (${opts.unreadable})`
126
+ : `still ${opts.stateName ?? "unknown state"}${opts.stateType ? ` (${opts.stateType})` : ""}`;
127
+ return (`Linear ${opts.identifier} ${observed} after the merged PR ${opts.prUrl ?? "(no PR URL recorded)"}. ` +
128
+ `Linear's GitHub integration did not advance it, so advance it by hand. ` +
129
+ `The Dealer issue itself is done and stays done — this action is a notice, not a gate.`);
130
+ }
131
+ /**
132
+ * Re-read the Linear source issue after a merged terminal transition and either
133
+ * confirm it advanced, advance it once via the conditional fallback, or raise
134
+ * the one human action. Records an artifact in every direction. Never throws
135
+ * and never touches the Dealer issue's status.
136
+ */
137
+ // NOT-362 round 3: per-issue in-flight guard. The stale fallback records
138
+ // `fallback: "written"` only via the artifact written *after* the Linear state
139
+ // + comment mutations, so two overlapping checks for one issue (finalize's
140
+ // done-already branch re-entered by recovery, or abort/resolve racing
141
+ // finalize) could both observe stale and each write. Concurrent checks for one
142
+ // issue join the in-flight run instead of running twice; sequential re-runs
143
+ // still execute and stay single-write via the artifact guard.
144
+ const verifyInflightByIssue = new Map();
145
+ export async function verifyLinearPostMerge(issueId) {
146
+ const inflight = verifyInflightByIssue.get(issueId);
147
+ if (inflight)
148
+ return inflight;
149
+ const pending = verifyLinearPostMergeInner(issueId).then((result) => result, (err) => ({ checked: false, reason: err instanceof Error ? err.message : String(err) }));
150
+ verifyInflightByIssue.set(issueId, pending);
151
+ try {
152
+ return await pending;
153
+ }
154
+ finally {
155
+ if (verifyInflightByIssue.get(issueId) === pending)
156
+ verifyInflightByIssue.delete(issueId);
157
+ }
158
+ }
159
+ async function verifyLinearPostMergeInner(issueId) {
160
+ const issue = getIssue(issueId);
161
+ if (!issue || issue.source !== "linear" || !issue.externalId) {
162
+ return { checked: false, reason: "not a Linear-sourced issue" };
163
+ }
164
+ const linearId = issue.externalId;
165
+ const prUrl = issue.prUrl;
166
+ let observation = null;
167
+ let readError = null;
168
+ for (let attempt = 0; attempt < readDelaysMs.length; attempt += 1) {
169
+ if (readDelaysMs[attempt] > 0)
170
+ await sleep(readDelaysMs[attempt]);
171
+ try {
172
+ const read = await postMergeReader(linearId);
173
+ const advancedViaState = !isStaleStateType(read.stateType);
174
+ const prAttached = prUrl != null && read.attachmentUrls.includes(prUrl);
175
+ observation = { ...read, prAttached, advancedViaState };
176
+ if (advancedViaState || prAttached)
177
+ break;
178
+ }
179
+ catch (err) {
180
+ readError = err instanceof Error ? err.message : String(err);
181
+ }
182
+ }
183
+ const identifier = observation?.identifier ?? issue.externalLabel ?? linearId;
184
+ const reads = observation
185
+ ? {
186
+ stateName: observation.stateName,
187
+ stateType: observation.stateType,
188
+ prAttached: observation.prAttached,
189
+ }
190
+ : { error: readError };
191
+ if (observation && (observation.advancedViaState || observation.prAttached)) {
192
+ const advancedVia = observation.advancedViaState ? "state" : "attachment";
193
+ createIssueArtifact({
194
+ issueId,
195
+ kind: LINEAR_MERGE_VERIFY_ARTIFACT_KIND,
196
+ author: "system",
197
+ content: {
198
+ check: "post_merge",
199
+ linearId,
200
+ identifier,
201
+ prUrl,
202
+ reads,
203
+ outcome: "advanced",
204
+ advancedVia,
205
+ },
206
+ });
207
+ dismissOpenStaleAction(issueId, `Linear ${identifier} advanced (${advancedVia}); notice cleared.`);
208
+ return { checked: true, advanced: true, advancedVia };
209
+ }
210
+ // Stale (or unreadable): the fallback fires only after the integration has
211
+ // demonstrably not acted — an unreadable issue never reaches the writer.
212
+ const syncEnabled = getLinearIntakeConfig().syncEnabled && Boolean(process.env.LINEAR_API_KEY);
213
+ const teamId = observation?.teamId ?? null;
214
+ let fallback = "skipped";
215
+ if (syncEnabled && teamId && !fallbackAlreadyWritten(issueId)) {
216
+ try {
217
+ const stateId = await resolveCompletedStateId(teamId);
218
+ if (!stateId) {
219
+ fallback = "unavailable:no-completed-state";
220
+ }
221
+ else {
222
+ // State first, comment second: a rejected state write then posts no
223
+ // comment, so re-runs do not stack "marked done" comments that were
224
+ // never true. (A comment failure after a successful state write still
225
+ // records `failed:…` and the re-run rewrites the same state —
226
+ // idempotent — before retrying the comment.)
227
+ const label = issue.externalLabel ?? identifier;
228
+ await setLinearIssueState(linearId, stateId);
229
+ await postLinearIssueComment(linearId, buildDoneComment(label, dealerIssueUrl(issueId), "View issue"));
230
+ fallback = "written";
231
+ dismissOpenStaleAction(issueId, `Dealer fallback advanced Linear ${identifier}; notice cleared.`);
232
+ }
233
+ }
234
+ catch (err) {
235
+ fallback = `failed:${err instanceof Error ? err.message : String(err)}`;
236
+ }
237
+ }
238
+ else if (fallbackAlreadyWritten(issueId)) {
239
+ fallback = "already_recorded";
240
+ }
241
+ else if (!syncEnabled) {
242
+ fallback = "unavailable:sync-disabled";
243
+ }
244
+ else if (!teamId) {
245
+ fallback = "unavailable:no-team";
246
+ }
247
+ let actionId = null;
248
+ if (fallback !== "written" && fallback !== "already_recorded") {
249
+ const reason = staleReasonText({
250
+ identifier,
251
+ prUrl,
252
+ stateName: observation?.stateName ?? null,
253
+ stateType: observation?.stateType ?? null,
254
+ unreadable: observation ? null : readError,
255
+ });
256
+ const requestId = linearMergeStaleRequestId(issueId);
257
+ const open = findOpenHumanActionByRequestId(issueId, "policy_escalation", requestId);
258
+ if (open) {
259
+ actionId = open.id;
260
+ }
261
+ else {
262
+ const action = createHumanAction({
263
+ issueId,
264
+ workflowInstanceId: null,
265
+ actionType: "policy_escalation",
266
+ reason,
267
+ question: reason,
268
+ evidence: {
269
+ linearMergeStale: true,
270
+ linearId,
271
+ identifier,
272
+ linearUrl: observation?.linearUrl ?? null,
273
+ prUrl,
274
+ observedStateName: observation?.stateName ?? null,
275
+ observedStateType: observation?.stateType ?? null,
276
+ fallback,
277
+ },
278
+ // Acknowledge/re-check only: the Dealer issue is already `done`, so
279
+ // there is no workflow to resume — resolve through
280
+ // resolveLinearMergeStaleAction, never the workflow state machine.
281
+ responseOptions: [
282
+ { choice: "acknowledge", label: "Acknowledge" },
283
+ { choice: "recheck", label: "Re-check Linear" },
284
+ ],
285
+ requestId,
286
+ });
287
+ actionId = action.id;
288
+ }
289
+ }
290
+ createIssueArtifact({
291
+ issueId,
292
+ kind: LINEAR_MERGE_VERIFY_ARTIFACT_KIND,
293
+ author: "system",
294
+ content: {
295
+ check: "post_merge",
296
+ linearId,
297
+ identifier,
298
+ prUrl,
299
+ reads,
300
+ outcome: "stale",
301
+ fallback,
302
+ actionId,
303
+ },
304
+ });
305
+ return { checked: true, advanced: false, fallback, actionId };
306
+ }
307
+ /**
308
+ * Fire-and-forget entry for every `done` landing (merge finalize, external-merge
309
+ * close, abort resolutions) — never throws, never holds the caller. The check's
310
+ * bounded retry window (tens of seconds plus read timeouts) must not hold the
311
+ * merge caller: finalizeAutoMerge is awaited while holding the reviewer session
312
+ * and slot, and the final_review:merge HTTP path must not wait on it either.
313
+ */
314
+ const postMergeInflight = new Set();
315
+ export function triggerLinearPostMerge(issueId) {
316
+ const pending = verifyLinearPostMerge(issueId).catch((err) => {
317
+ console.error(`[linear-merge-verify] post-merge check for ${issueId} failed:`, err);
318
+ return { checked: false, reason: "trigger caught" };
319
+ });
320
+ postMergeInflight.add(pending);
321
+ void pending.finally(() => {
322
+ postMergeInflight.delete(pending);
323
+ });
324
+ }
325
+ /**
326
+ * Test hook — await every post-merge check a test triggered through the
327
+ * fire-and-forget entry, so assertions on artifacts/actions do not race it.
328
+ * Loops until none remain (a re-check resolution can trigger another).
329
+ */
330
+ export async function drainLinearPostMergeForTests() {
331
+ for (let guard = 0; guard < 25 && postMergeInflight.size > 0; guard += 1) {
332
+ await Promise.all([...postMergeInflight]);
333
+ }
334
+ }
335
+ /** True when the action is this module's stale-source notice (any status). */
336
+ export function isLinearMergeStaleAction(action) {
337
+ if (action.actionType !== "policy_escalation" || !action.evidenceJson)
338
+ return false;
339
+ try {
340
+ return JSON.parse(action.evidenceJson).linearMergeStale === true;
341
+ }
342
+ catch {
343
+ return false;
344
+ }
345
+ }
346
+ /**
347
+ * Dedicated resolution for the stale-source notice. The Dealer issue is already
348
+ * `done` — there is no active workflow for the generic resolver to advance, so
349
+ * this resolves the notice directly (the reflection_interaction_required
350
+ * precedent in routes/human-actions.ts):
351
+ * - `acknowledge` (plus legacy `close`/`dismiss` labels): close the notice.
352
+ * - `recheck`: close the notice and re-run the post-merge verification
353
+ * fire-and-forget — a human who advanced the Linear issue by hand gets a
354
+ * fresh verdict (and a verification artifact) instead of a stuck notice. A
355
+ * still-stale issue raises one new action only when the fallback did not
356
+ * already write; an already-recorded fallback stays quiet.
357
+ */
358
+ export function resolveLinearMergeStaleAction(actionId, resolvedBy, choice) {
359
+ const action = getHumanAction(actionId);
360
+ if (!action)
361
+ return { ok: false, code: 404, error: "Human action not found" };
362
+ if (!isLinearMergeStaleAction(action)) {
363
+ return { ok: false, code: 400, error: `Action ${actionId} is not a Linear post-merge notice` };
364
+ }
365
+ if (!action.issueId)
366
+ return { ok: false, code: 500, error: "Human action has no issue" };
367
+ if (action.status !== "open")
368
+ return { ok: false, code: 409, error: "Human action already resolved" };
369
+ if (choice !== "acknowledge" && choice !== "dismiss" && choice !== "close" && choice !== "recheck") {
370
+ return {
371
+ ok: false,
372
+ code: 400,
373
+ error: `Invalid choice "${choice}" for Linear post-merge notice (acknowledge or recheck)`,
374
+ };
375
+ }
376
+ resolveHumanAction(actionId, resolvedBy, { choice });
377
+ const rechecked = choice === "recheck";
378
+ if (rechecked)
379
+ triggerLinearPostMerge(action.issueId);
380
+ const issue = getIssue(action.issueId);
381
+ return { ok: true, issueStatus: issue?.status ?? "done", rechecked };
382
+ }