agent-dealer 1.2.13 → 1.2.14

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 (39) 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/coordinator/auto-merge.js +10 -0
  7. package/bundle/server/dist/coordinator/branch-progress.js +5 -4
  8. package/bundle/server/dist/coordinator/commands.js +37 -3
  9. package/bundle/server/dist/coordinator/developer-effect.js +33 -6
  10. package/bundle/server/dist/coordinator/linear-merge-verify.js +382 -0
  11. package/bundle/server/dist/coordinator/linear-merge-verify.test.js +376 -0
  12. package/bundle/server/dist/coordinator/prompts.js +9 -1
  13. package/bundle/server/dist/coordinator/prompts.test.js +93 -0
  14. package/bundle/server/dist/coordinator/source-attachments.js +473 -0
  15. package/bundle/server/dist/coordinator/source-attachments.test.js +284 -0
  16. package/bundle/server/dist/coordinator/source-reload.js +178 -0
  17. package/bundle/server/dist/db/schema.sql +28 -0
  18. package/bundle/server/dist/docs-linear-attachments.test.js +36 -0
  19. package/bundle/server/dist/paths.js +12 -0
  20. package/bundle/server/dist/repository/source-attachments.js +56 -0
  21. package/bundle/server/dist/routes/human-actions.js +17 -0
  22. package/bundle/server/dist/routes/issues-execution-contract.test.js +296 -0
  23. package/bundle/server/dist/routes/issues-source-attachments.test.js +411 -0
  24. package/bundle/server/dist/routes/issues.js +89 -1
  25. package/bundle/server/package.json +2 -2
  26. package/bundle/server/static-ui/assets/index-DDbJ9ZU7.js +64 -0
  27. package/bundle/server/static-ui/index.html +1 -1
  28. package/bundle/shared/dist/index.d.ts +108 -2
  29. package/bundle/shared/dist/index.js +12 -1
  30. package/bundle/shared/dist/issues.d.ts +43 -2
  31. package/bundle/shared/dist/issues.js +10 -0
  32. package/bundle/shared/dist/linear-intake.d.ts +85 -0
  33. package/bundle/shared/dist/linear-intake.js +56 -0
  34. package/bundle/shared/dist/linear-intake.test.js +55 -1
  35. package/bundle/shared/dist/workflow.d.ts +4 -4
  36. package/bundle/shared/dist/workflow.js +7 -0
  37. package/bundle/shared/package.json +1 -1
  38. package/package.json +1 -1
  39. package/bundle/server/static-ui/assets/index-BzLjMBI9.js +0 -64
@@ -20,6 +20,7 @@ import { getAgent } from "../repository/agents.js";
20
20
  import { githubIssuesSync, invalidateMuseHealthCache } from "../adapters/agent-health.js";
21
21
  import { recordMuseCapabilityOverride } from "../adapters/muse-capability.js";
22
22
  import { createIssueArtifact, latestIssueArtifact } from "../repository/artifacts.js";
23
+ import { listSourceAttachments } from "../repository/source-attachments.js";
23
24
  import { cancelWorkItem, enqueueWorkItem, finishWorkItem, getWorkItem, getWorkItemByIdempotencyKey, listWorkItemsForIssue, } from "../repository/work-items.js";
24
25
  import { completeSession, getActiveWorkerSessionForIssue, getWorkerSession, listWorkerSessionsForIssue } from "../repository/worker-sessions.js";
25
26
  import { killRunProcess } from "../runners/spawn-cli.js";
@@ -31,6 +32,7 @@ import { routeDeveloperOutcome, routeReviewerOutcome, } from "./routing.js";
31
32
  import { projectDeveloperRoute, projectReviewerRoute } from "./projection.js";
32
33
  import { MERGE_CONFLICT_FILES_EVIDENCE_KEY, MERGE_FAILURE_EVIDENCE_KEY, MERGE_FAILURE_RESPONSE_OPTIONS, PUSH_DIVERGENCE_EVIDENCE_KEY, PUSH_DIVERGENCE_RESPONSE_OPTIONS, WORKTREE_BLOCKER_EVIDENCE_KEY, normalizeResolutionNote, parseHumanResolution, resolveHumanActionOutcome, } from "./human-resolution.js";
33
34
  import { AUTO_MERGE_INTENT, finalizeAutoMerge } from "./auto-merge.js";
35
+ import { triggerLinearPostMerge } from "./linear-merge-verify.js";
34
36
  import { conflictRepairSpent, queueConflictRepairRound } from "./merge-conflict-sync.js";
35
37
  import { OPERATOR_VERIFICATION_ARTIFACT_KIND, OPERATOR_VERIFICATION_RESPONSE_OPTIONS, } from "./operator-criteria.js";
36
38
  import { externalMergeStateForIssue } from "./external-merge.js";
@@ -60,7 +62,17 @@ export function getTaskSnapshot(issue) {
60
62
  // Snapshots frozen before NOT-306 carry no contract — derive it from the
61
63
  // frozen source description so old workflows still render what they ran.
62
64
  if (parsed.executionContract === undefined) {
63
- return { ...parsed, executionContract: tryCompileContract(parsed.description) };
65
+ return {
66
+ ...parsed,
67
+ executionContract: tryCompileContract(parsed.description),
68
+ // NOT-364: pre-attachment snapshots carry no manifest — empty, not live
69
+ // rows, so a running workflow never gains inputs mid-flight.
70
+ sourceAttachments: parsed.sourceAttachments ?? [],
71
+ };
72
+ }
73
+ // NOT-364: pre-attachment snapshots carry no manifest — same rule.
74
+ if (parsed.sourceAttachments === undefined) {
75
+ return { ...parsed, sourceAttachments: [] };
64
76
  }
65
77
  return parsed;
66
78
  }
@@ -76,6 +88,9 @@ export function getTaskSnapshot(issue) {
76
88
  baseBranch: issue.baseBranch,
77
89
  workflowVersion: WORKFLOW_VERSION,
78
90
  executionContract: tryCompileContract(issue.description),
91
+ // Live-field fallback is pre-freeze by definition (no workflow owns a
92
+ // snapshot yet) — current rows are exactly what the freeze would capture.
93
+ sourceAttachments: listSourceAttachments(issue.id),
79
94
  };
80
95
  }
81
96
  /**
@@ -243,6 +258,9 @@ function freezeTaskSnapshot(issue) {
243
258
  baseBranch: issue.baseBranch,
244
259
  workflowVersion: WORKFLOW_VERSION,
245
260
  executionContract: tryCompileContract(issue.description),
261
+ // NOT-364: freeze the attachment manifest with the text — later Linear
262
+ // edits cannot silently change the inputs repair rounds see.
263
+ sourceAttachments: listSourceAttachments(issue.id),
246
264
  },
247
265
  });
248
266
  }
@@ -2043,8 +2061,19 @@ export async function resolveHumanActionAndAdvanceAsync(actionId, resolvedBy, ch
2043
2061
  externalMergeState,
2044
2062
  note: opts?.note,
2045
2063
  });
2046
- if (!result.ok || !result.pendingMerge)
2064
+ if (!result.ok || !result.pendingMerge) {
2065
+ // NOT-362: the sync core may have landed a terminal merge without going
2066
+ // through finalize (external-merge close-as-done) — confirm the Linear
2067
+ // source post-check. Fire-and-forget: never blocks the response, and the
2068
+ // check itself never touches the issue's status. Non-Linear issues return
2069
+ // unchecked with no network call.
2070
+ if (result.ok && result.issueStatus === "done") {
2071
+ const doneAction = getHumanAction(actionId);
2072
+ if (doneAction?.issueId)
2073
+ triggerLinearPostMerge(doneAction.issueId);
2074
+ }
2047
2075
  return result;
2076
+ }
2048
2077
  const action = getHumanAction(actionId);
2049
2078
  if (!action?.issueId) {
2050
2079
  return { ok: false, code: 500, error: "Human action missing issue after merge park" };
@@ -2540,7 +2569,12 @@ export async function abortIssueAsync(issueId, resolvedBy, deps = defaultAbortDe
2540
2569
  catch {
2541
2570
  externalMergeState = undefined;
2542
2571
  }
2543
- return abortIssue(issueId, resolvedBy, deps, { externalMergeState });
2572
+ const aborted = abortIssue(issueId, resolvedBy, deps, { externalMergeState });
2573
+ // NOT-362: an abort that landed as `done` means the PR was merged outside
2574
+ // Dealer — same post-check as every other terminal merged transition.
2575
+ if (aborted.ok && aborted.issueStatus === "done")
2576
+ triggerLinearPostMerge(issueId);
2577
+ return aborted;
2544
2578
  }
2545
2579
  /**
2546
2580
  * NOT-196 pre-read for the async close entry: returns a PR merge state only when this
@@ -19,6 +19,7 @@ import path from "node:path";
19
19
  import { parsePhaseBudget, parseProfileSnapshot, roleCeiling } from "@agent-dealer/shared";
20
20
  import { CONFLICTING_FILES_MAX, runMergeConflictSync } from "./merge-conflict-sync.js";
21
21
  import { getTaskSnapshot } from "./commands.js";
22
+ import { materializeSourceAttachments, SourceAttachmentError, } from "./source-attachments.js";
22
23
  import { extractOperatorCriteria } from "./operator-criteria.js";
23
24
  import { buildDeveloperPrompt } from "./prompts.js";
24
25
  import { guidanceForNextSession } from "./guidance.js";
@@ -912,13 +913,16 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
912
913
  intent,
913
914
  payload,
914
915
  });
915
- // issue.branch is only ever written on a verified clean_handoff (design: it's ground
916
- // truth, not agent self-report), so a first-round attempt after an earlier retryable
916
+ // issue.branch is ground truth for the branch NAME (written on a verified
917
+ // clean_handoff, or seeded from Linear's branchName at intake — NOT-362), but
918
+ // not for the branch's EXISTENCE: a seeded branch has no local ref and the
919
+ // remote never saw it. A first-round attempt after an earlier retryable
917
920
  // failure in the SAME round (no_pr/session_failed/timed_out/checks_failed all remove
918
921
  // their worktree but leave the local branch ref behind) would otherwise re-run
919
922
  // `git worktree add -b <same name>` and fail with "branch already exists" — a review
920
923
  // round reproduced this directly. Check the branch itself, not just issue.branch, and
921
- // reuse it (preserving whatever local commits it already carries) when it's there.
924
+ // reuse it (preserving whatever local commits it already carries) when it's there
925
+ // (resolveDeveloperWorktree cuts a nonexistent reuse-path branch fresh from base).
922
926
  const branchName = issue.branch ?? `issue-${issue.id}`;
923
927
  let repoPath;
924
928
  let desiredWorktreePath;
@@ -942,7 +946,11 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
942
946
  catch (err) {
943
947
  return { kind: "adapter_failure", reason: `repository checkout failed: ${String(err)}` };
944
948
  }
945
- const reuseBranch = issue.branch != null || (await branchExists(repoPath, branchName));
949
+ // NOT-362: a seeded issue.branch names a branch that exists nowhere yet — the
950
+ // local ref is the only existence signal honest enough for reuse accounting
951
+ // below (a seeded name must not count as "prior commits to reuse").
952
+ const localBranchPreexists = await branchExists(repoPath, branchName);
953
+ const reuseBranch = issue.branch != null || localBranchPreexists;
946
954
  let worktreePath;
947
955
  // NOT-172: whether the retry-resolution reused an existing checkout (source for
948
956
  // the retry-reuse record emitted once the retry begins).
@@ -1066,6 +1074,25 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
1066
1074
  milestone("worktree.ready", `Developer · worktree ready (round ${round})`, {
1067
1075
  worktreePath: shortWorktreePath(worktreePath),
1068
1076
  });
1077
+ // NOT-364: re-materialize the FROZEN attachment manifest's durable files
1078
+ // under `.agent-dealer-inputs/linear/` for every attempt — first, repair,
1079
+ // and retry rounds all see the same bytes at the same prompt-listed,
1080
+ // git-ignored path. A missing or corrupt blob fails closed (no spawn on
1081
+ // partial inputs) instead of running the session without its ticket files.
1082
+ try {
1083
+ const materialized = materializeSourceAttachments(worktreePath, taskSnapshot.sourceAttachments);
1084
+ if (materialized.length > 0) {
1085
+ milestone("inputs.materialized", `Developer · source attachments ready (round ${round})`, {
1086
+ files: materialized.map((m) => m.relativePath),
1087
+ });
1088
+ }
1089
+ }
1090
+ catch (err) {
1091
+ const reason = err instanceof SourceAttachmentError
1092
+ ? err.message
1093
+ : `source attachment materialization failed: ${String(err)}`;
1094
+ return { kind: "adapter_failure", reason };
1095
+ }
1069
1096
  // NOT-172: capture the worktree HEAD as this session's input-SHA baseline before
1070
1097
  // the agent spawns — developer work items are never enqueued with an input SHA,
1071
1098
  // so without this the sampler's HEAD diff (and the session_end fallback) have
@@ -1264,7 +1291,7 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
1264
1291
  worktreePath,
1265
1292
  baseRef: `origin/${baseBranch}`,
1266
1293
  }).catch(() => null);
1267
- if ((ahead !== null && ahead > 0) || (ahead === null && issue.branch != null)) {
1294
+ if ((ahead !== null && ahead > 0) || (ahead === null && localBranchPreexists)) {
1268
1295
  reuseKinds.push("commit");
1269
1296
  }
1270
1297
  }
@@ -1498,7 +1525,7 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
1498
1525
  worktreePath,
1499
1526
  baseRef: `origin/${baseBranch}`,
1500
1527
  }).catch(() => null);
1501
- if ((endAhead !== null && endAhead > 0) || (endAhead === null && issue.branch != null)) {
1528
+ if ((endAhead !== null && endAhead > 0) || (endAhead === null && localBranchPreexists)) {
1502
1529
  emitCheckpointObserved({
1503
1530
  issueId: issue.id,
1504
1531
  workflowInstanceId: instance.id,
@@ -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
+ }