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.
- package/bundle/server/dist/adapters/git-worktree.js +15 -0
- package/bundle/server/dist/adapters/git-worktree.test.js +30 -0
- package/bundle/server/dist/adapters/linear-inbox.js +97 -0
- package/bundle/server/dist/adapters/linear-inbox.test.js +299 -1
- package/bundle/server/dist/adapters/linear-sync.js +33 -10
- package/bundle/server/dist/coordinator/auto-merge.js +10 -0
- package/bundle/server/dist/coordinator/branch-progress.js +5 -4
- package/bundle/server/dist/coordinator/commands.js +37 -3
- package/bundle/server/dist/coordinator/developer-effect.js +33 -6
- package/bundle/server/dist/coordinator/linear-merge-verify.js +382 -0
- package/bundle/server/dist/coordinator/linear-merge-verify.test.js +376 -0
- package/bundle/server/dist/coordinator/prompts.js +9 -1
- package/bundle/server/dist/coordinator/prompts.test.js +93 -0
- package/bundle/server/dist/coordinator/source-attachments.js +473 -0
- package/bundle/server/dist/coordinator/source-attachments.test.js +284 -0
- package/bundle/server/dist/coordinator/source-reload.js +178 -0
- package/bundle/server/dist/db/schema.sql +28 -0
- package/bundle/server/dist/docs-linear-attachments.test.js +36 -0
- package/bundle/server/dist/paths.js +12 -0
- package/bundle/server/dist/repository/source-attachments.js +56 -0
- package/bundle/server/dist/routes/human-actions.js +17 -0
- package/bundle/server/dist/routes/issues-execution-contract.test.js +296 -0
- package/bundle/server/dist/routes/issues-source-attachments.test.js +411 -0
- package/bundle/server/dist/routes/issues.js +89 -1
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-DDbJ9ZU7.js +64 -0
- package/bundle/server/static-ui/index.html +1 -1
- package/bundle/shared/dist/index.d.ts +108 -2
- package/bundle/shared/dist/index.js +12 -1
- package/bundle/shared/dist/issues.d.ts +43 -2
- package/bundle/shared/dist/issues.js +10 -0
- package/bundle/shared/dist/linear-intake.d.ts +85 -0
- package/bundle/shared/dist/linear-intake.js +56 -0
- package/bundle/shared/dist/linear-intake.test.js +55 -1
- package/bundle/shared/dist/workflow.d.ts +4 -4
- package/bundle/shared/dist/workflow.js +7 -0
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- 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 {
|
|
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
|
-
|
|
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
|
|
916
|
-
//
|
|
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
|
-
|
|
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 &&
|
|
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 &&
|
|
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
|
+
}
|