patchrelay 0.38.1 → 0.39.0

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 (40) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/cli/args.js +4 -0
  3. package/dist/cli/commands/issues.js +20 -1
  4. package/dist/cli/data.js +54 -7
  5. package/dist/cli/formatters/text.js +10 -0
  6. package/dist/cli/help.js +4 -0
  7. package/dist/cli/index.js +3 -0
  8. package/dist/config.js +26 -0
  9. package/dist/db/issue-store.js +10 -2
  10. package/dist/db/migrations.js +5 -0
  11. package/dist/factory-state.js +1 -0
  12. package/dist/github-webhook-handler.js +12 -0
  13. package/dist/github-webhook-late-publication-guard.js +94 -0
  14. package/dist/github-webhook-state-projector.js +15 -1
  15. package/dist/github-webhooks.js +39 -4
  16. package/dist/github-worktree-auth.js +18 -0
  17. package/dist/http.js +17 -0
  18. package/dist/idle-reconciliation.js +4 -2
  19. package/dist/issue-session-events.js +1 -0
  20. package/dist/linear-activity-key.js +11 -0
  21. package/dist/linear-agent-session-client.js +14 -1
  22. package/dist/linear-progress-facts.js +170 -0
  23. package/dist/linear-progress-reporter.js +21 -168
  24. package/dist/linear-status-comment-sync.js +3 -19
  25. package/dist/linear-workflow-state-sync.js +37 -18
  26. package/dist/manual-issue-actions.js +37 -0
  27. package/dist/merged-linear-completion-reconciler.js +102 -22
  28. package/dist/no-pr-completion-check.js +52 -0
  29. package/dist/presentation-text.js +11 -1
  30. package/dist/prompting/patchrelay.js +8 -6
  31. package/dist/run-budgets.js +12 -0
  32. package/dist/run-launcher.js +6 -6
  33. package/dist/run-notification-handler.js +4 -0
  34. package/dist/run-orchestrator.js +7 -1
  35. package/dist/run-wake-planner.js +11 -10
  36. package/dist/service-issue-actions.js +80 -27
  37. package/dist/service.js +3 -0
  38. package/dist/trusted-no-pr-completion.js +7 -0
  39. package/dist/webhooks/desired-stage-recorder.js +34 -10
  40. package/package.json +1 -1
@@ -4,8 +4,8 @@ import { deriveGateCheckStatusFromRollup } from "./github-rollup.js";
4
4
  import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
5
5
  import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
6
6
  import { buildClosedPrCleanupFields, resolveClosedPrDisposition } from "./pr-state.js";
7
+ import { getReviewFixBudget } from "./run-budgets.js";
7
8
  import { execCommand } from "./utils.js";
8
- const DEFAULT_REVIEW_FIX_BUDGET = 12;
9
9
  function isFailingCheckStatus(status) {
10
10
  return status === "failed" || status === "failure";
11
11
  }
@@ -510,13 +510,15 @@ export class IdleIssueReconciler {
510
510
  if (issue.delegatedToPatchRelay
511
511
  && (issue.factoryState === "escalated" || issue.factoryState === "failed")
512
512
  && (reactiveIntent?.runType === "review_fix" || reactiveIntent?.runType === "branch_upkeep")) {
513
- if (issue.reviewFixAttempts >= DEFAULT_REVIEW_FIX_BUDGET) {
513
+ const reviewFixBudget = getReviewFixBudget(project);
514
+ if (issue.reviewFixAttempts >= reviewFixBudget) {
514
515
  this.logger.debug({
515
516
  issueKey: issue.issueKey,
516
517
  prNumber: issue.prNumber,
517
518
  from: issue.factoryState,
518
519
  runType: reactiveIntent.runType,
519
520
  reviewFixAttempts: issue.reviewFixAttempts,
521
+ reviewFixBudget,
520
522
  }, "Reconciliation: leaving terminal requested-changes issue escalated because the repair budget is exhausted");
521
523
  return;
522
524
  }
@@ -1,6 +1,7 @@
1
1
  import { sanitizeOperatorFacingText } from "./presentation-text.js";
2
2
  const TERMINAL_SESSION_EVENTS = new Set([
3
3
  "stop_requested",
4
+ "operator_closed",
4
5
  "undelegated",
5
6
  "issue_removed",
6
7
  "pr_closed",
@@ -0,0 +1,11 @@
1
+ import { sanitizeOperatorFacingText } from "./presentation-text.js";
2
+ export function computeLinearActivityKey(content) {
3
+ if (content.type === "action") {
4
+ const action = sanitizeOperatorFacingText(content.action) ?? content.action;
5
+ const parameter = sanitizeOperatorFacingText(content.parameter) ?? content.parameter;
6
+ const result = sanitizeOperatorFacingText(content.result);
7
+ return `action:${action}:${parameter}:${result ?? ""}`;
8
+ }
9
+ const body = sanitizeOperatorFacingText(content.body) ?? content.body;
10
+ return `${content.type}:${body}`;
11
+ }
@@ -1,5 +1,6 @@
1
1
  import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
2
2
  import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
3
+ import { computeLinearActivityKey } from "./linear-activity-key.js";
3
4
  export class LinearAgentSessionClient {
4
5
  config;
5
6
  db;
@@ -36,11 +37,23 @@ export class LinearAgentSessionClient {
36
37
  if (!linear)
37
38
  return;
38
39
  const allowEphemeral = content.type === "thought" || content.type === "action";
40
+ const ephemeral = options?.ephemeral && allowEphemeral;
41
+ const activityKey = ephemeral ? undefined : computeLinearActivityKey(content);
42
+ if (activityKey && syncedIssue.lastLinearActivityKey === activityKey) {
43
+ return;
44
+ }
39
45
  await linear.createAgentActivity({
40
46
  agentSessionId: syncedIssue.agentSessionId,
41
47
  content,
42
- ...(options?.ephemeral && allowEphemeral ? { ephemeral: true } : {}),
48
+ ...(ephemeral ? { ephemeral: true } : {}),
43
49
  });
50
+ if (activityKey) {
51
+ this.db.issues.upsertIssue({
52
+ projectId: syncedIssue.projectId,
53
+ linearIssueId: syncedIssue.linearIssueId,
54
+ lastLinearActivityKey: activityKey,
55
+ });
56
+ }
44
57
  }
45
58
  catch (error) {
46
59
  const msg = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,170 @@
1
+ import { sanitizeOperatorFacingText } from "./presentation-text.js";
2
+ export function deriveLinearProgressFact(notification, issue) {
3
+ switch (notification.method) {
4
+ case "item/completed":
5
+ return deriveProgressFactFromCompletedItem(notification.params.item, issue);
6
+ case "turn/plan/updated":
7
+ return deriveProgressFactFromPlan(notification.params.plan, issue);
8
+ default:
9
+ return undefined;
10
+ }
11
+ }
12
+ function deriveProgressFactFromCompletedItem(rawItem, issue) {
13
+ void issue;
14
+ if (!rawItem || typeof rawItem !== "object") {
15
+ return undefined;
16
+ }
17
+ const item = rawItem;
18
+ if (item.type !== "agentMessage" || typeof item.text !== "string") {
19
+ return undefined;
20
+ }
21
+ const body = compactOperatorSentence(item.text);
22
+ if (!body) {
23
+ return undefined;
24
+ }
25
+ if (looksLikeVerification(body)) {
26
+ return {
27
+ kind: "verification_started",
28
+ meaningKey: `verification:${normalizeMeaningKey(body)}`,
29
+ content: { type: "thought", body },
30
+ };
31
+ }
32
+ if (looksLikePublishing(body)) {
33
+ return {
34
+ kind: "publishing_started",
35
+ meaningKey: `publishing:${normalizeMeaningKey(body)}`,
36
+ content: { type: "thought", body },
37
+ };
38
+ }
39
+ if (looksLikeRootCause(body)) {
40
+ return {
41
+ kind: "root_cause_found",
42
+ meaningKey: `finding:${normalizeMeaningKey(body)}`,
43
+ content: { type: "thought", body },
44
+ };
45
+ }
46
+ return undefined;
47
+ }
48
+ function deriveProgressFactFromPlan(rawPlan, issue) {
49
+ if (!Array.isArray(rawPlan)) {
50
+ return undefined;
51
+ }
52
+ const activeStep = rawPlan
53
+ .map((entry) => normalizePlanEntry(entry))
54
+ .find((entry) => entry && entry.status === "in_progress");
55
+ if (!activeStep) {
56
+ return undefined;
57
+ }
58
+ if (looksLikeVerification(activeStep.step)) {
59
+ return {
60
+ kind: "verification_started",
61
+ meaningKey: `verification:${normalizeMeaningKey(activeStep.step)}`,
62
+ content: {
63
+ type: "action",
64
+ action: "Verifying",
65
+ parameter: summarizePlanStep(activeStep.step, "latest changes before publishing"),
66
+ },
67
+ };
68
+ }
69
+ if (looksLikePublishing(activeStep.step)) {
70
+ const parameter = summarizePlanStep(activeStep.step, issue?.prNumber !== undefined ? `changes to PR #${issue.prNumber}` : "latest changes");
71
+ return {
72
+ kind: "publishing_started",
73
+ meaningKey: `publishing:${normalizeMeaningKey(activeStep.step)}`,
74
+ content: {
75
+ type: "action",
76
+ action: "Publishing",
77
+ parameter,
78
+ },
79
+ };
80
+ }
81
+ return undefined;
82
+ }
83
+ function normalizePlanEntry(rawEntry) {
84
+ if (!rawEntry || typeof rawEntry !== "object") {
85
+ return undefined;
86
+ }
87
+ const entry = rawEntry;
88
+ const rawStep = entry.step;
89
+ if (typeof rawStep !== "string" || !rawStep.trim()) {
90
+ return undefined;
91
+ }
92
+ const rawStatus = typeof entry.status === "string" ? entry.status : "pending";
93
+ return {
94
+ step: rawStep.trim(),
95
+ status: rawStatus === "inProgress" ? "in_progress"
96
+ : rawStatus === "completed" ? "completed"
97
+ : rawStatus === "pending" ? "pending"
98
+ : rawStatus === "in_progress" ? "in_progress"
99
+ : "pending",
100
+ };
101
+ }
102
+ function looksLikeRootCause(text) {
103
+ const normalized = text.toLowerCase();
104
+ return /\b(narrowed|isolated|root cause)\b/.test(normalized)
105
+ || normalized.startsWith("found that ")
106
+ || normalized.startsWith("the failure is isolated")
107
+ || normalized.startsWith("the issue is isolated");
108
+ }
109
+ function looksLikeVerification(text) {
110
+ const normalized = text.toLowerCase();
111
+ return /\b(verifying|verification|targeted verification|smoke)\b/.test(normalized);
112
+ }
113
+ function looksLikePublishing(text) {
114
+ const normalized = text.toLowerCase();
115
+ return /\b(publish|publishing|push|pushing)\b/.test(normalized)
116
+ || normalized.includes("opening pr")
117
+ || normalized.includes("opening the pr")
118
+ || normalized.includes("opening pull request");
119
+ }
120
+ function compactOperatorSentence(text, maxLength = 160) {
121
+ const sanitized = sanitizeOperatorFacingText(text)?.replace(/\s+/g, " ").trim();
122
+ if (!sanitized) {
123
+ return undefined;
124
+ }
125
+ if (sanitized.length <= maxLength) {
126
+ return sanitized;
127
+ }
128
+ const punctuated = lastBoundaryWithinLimit(sanitized, maxLength, /[.;!?]/g);
129
+ if (punctuated !== undefined) {
130
+ return sanitized.slice(0, punctuated + 1).trim();
131
+ }
132
+ const spaced = sanitized.lastIndexOf(" ", maxLength);
133
+ if (spaced > 0) {
134
+ return `${sanitized.slice(0, spaced).trimEnd()}...`;
135
+ }
136
+ return `${sanitized.slice(0, maxLength).trimEnd()}...`;
137
+ }
138
+ function summarizePlanStep(step, fallback) {
139
+ const sanitized = sanitizeOperatorFacingText(step)?.replace(/\s+/g, " ").trim();
140
+ if (!sanitized) {
141
+ return fallback;
142
+ }
143
+ const stripped = sanitized
144
+ .replace(/^(run|running|start|starting)\s+/i, "")
145
+ .replace(/^(verify|verifying|verification of)\s+/i, "")
146
+ .replace(/^(publish|publishing|push|pushing|open|opening)\s+/i, "")
147
+ .trim()
148
+ .replace(/[.]+$/, "");
149
+ return stripped || fallback;
150
+ }
151
+ function normalizeMeaningKey(text) {
152
+ return text
153
+ .toLowerCase()
154
+ .replace(/\s+/g, " ")
155
+ .trim();
156
+ }
157
+ function lastBoundaryWithinLimit(text, maxLength, pattern) {
158
+ let last = -1;
159
+ for (;;) {
160
+ const match = pattern.exec(text);
161
+ if (!match) {
162
+ break;
163
+ }
164
+ if (match.index >= maxLength) {
165
+ break;
166
+ }
167
+ last = match.index;
168
+ }
169
+ return last >= 0 ? last : undefined;
170
+ }
@@ -1,185 +1,38 @@
1
- import { sanitizeOperatorFacingCommand, sanitizeOperatorFacingText } from "./presentation-text.js";
2
- const PROGRESS_THROTTLE_MS = 5_000;
3
- const MAX_PROGRESS_TEXT_LENGTH = 220;
1
+ import { deriveLinearProgressFact } from "./linear-progress-facts.js";
4
2
  export class LinearProgressReporter {
5
3
  db;
6
4
  emitActivity;
7
- progressThrottle = new Map();
8
- workingOnPublishedRuns = new Set();
9
- agentMessageBuffers = new Map();
10
- agentMessageProgressPublished = new Set();
5
+ publicationsByRun = new Map();
11
6
  constructor(db, emitActivity) {
12
7
  this.db = db;
13
8
  this.emitActivity = emitActivity;
14
9
  }
15
10
  maybeEmitProgress(notification, run) {
16
- const issue = this.db.issues.getIssue(run.projectId, run.linearIssueId);
17
- if (!issue)
11
+ const issue = this.db.getIssue(run.projectId, run.linearIssueId);
12
+ if (!issue) {
18
13
  return;
19
- const agentSentence = this.consumeAgentMessageSentence(notification, run);
20
- const workingOn = resolveWorkingOnActivity(notification, agentSentence?.sentence);
21
- if (workingOn && !this.workingOnPublishedRuns.has(run.id)) {
22
- this.workingOnPublishedRuns.add(run.id);
23
- void this.emitActivity(issue, workingOn);
24
14
  }
25
- const progress = resolveEphemeralProgressActivity(notification, agentSentence?.sentence);
26
- if (!progress)
15
+ const fact = deriveLinearProgressFact(notification, issue);
16
+ if (!fact) {
27
17
  return;
28
- if (!progress.bypassThrottle) {
29
- const now = Date.now();
30
- const lastEmit = this.progressThrottle.get(run.id) ?? 0;
31
- if (now - lastEmit < PROGRESS_THROTTLE_MS)
32
- return;
33
- this.progressThrottle.set(run.id, now);
34
18
  }
35
- void this.emitActivity(issue, progress.activity, { ephemeral: true });
36
- }
37
- clearProgress(runId) {
38
- this.progressThrottle.delete(runId);
39
- this.workingOnPublishedRuns.delete(runId);
40
- for (const key of this.agentMessageBuffers.keys()) {
41
- if (key.startsWith(`${runId}:`)) {
42
- this.agentMessageBuffers.delete(key);
43
- }
44
- }
45
- for (const key of this.agentMessageProgressPublished) {
46
- if (key.startsWith(`${runId}:`)) {
47
- this.agentMessageProgressPublished.delete(key);
48
- }
49
- }
50
- }
51
- consumeAgentMessageSentence(notification, run) {
52
- const messageKey = resolveAgentMessageKey(notification, run);
53
- if (!messageKey)
54
- return undefined;
55
- if (this.agentMessageProgressPublished.has(messageKey))
56
- return undefined;
57
- const delta = resolveAgentMessageDelta(notification);
58
- if (delta) {
59
- const previous = this.agentMessageBuffers.get(messageKey) ?? "";
60
- const next = `${previous}${delta}`;
61
- this.agentMessageBuffers.set(messageKey, next);
62
- const sentence = extractFirstCompletedSentence(next);
63
- if (!sentence)
64
- return undefined;
65
- this.agentMessageProgressPublished.add(messageKey);
66
- return { sentence };
67
- }
68
- const completedText = resolveCompletedAgentMessageText(notification);
69
- if (!completedText)
70
- return undefined;
71
- const sentence = extractFirstSentence(completedText);
72
- if (!sentence)
73
- return undefined;
74
- this.agentMessageProgressPublished.add(messageKey);
75
- return { sentence };
76
- }
77
- }
78
- function resolveWorkingOnActivity(notification, agentSentence) {
79
- const summary = resolveWorkingOnSummary(notification) ?? agentSentence;
80
- if (!summary)
81
- return undefined;
82
- return { type: "response", body: `Working on: ${summary}` };
83
- }
84
- function resolveEphemeralProgressActivity(notification, agentSentence) {
85
- if (notification.method === "item/started") {
86
- const item = notification.params.item;
87
- if (!item)
88
- return undefined;
89
- const type = typeof item.type === "string" ? item.type : undefined;
90
- if (type === "commandExecution") {
91
- const cmd = item.command;
92
- const cmdStr = Array.isArray(cmd)
93
- ? sanitizeOperatorFacingCommand(cmd.map((part) => String(part)).join(" "))
94
- : sanitizeOperatorFacingCommand(typeof cmd === "string" ? cmd : undefined);
95
- return { activity: { type: "action", action: "Running", parameter: truncateProgressText(cmdStr ?? "command", 120) } };
96
- }
97
- if (type === "mcpToolCall") {
98
- const server = typeof item.server === "string" ? item.server : "";
99
- const tool = typeof item.tool === "string" ? item.tool : "";
100
- return { activity: { type: "action", action: "Using", parameter: `${server}/${tool}` } };
101
- }
102
- if (type === "dynamicToolCall") {
103
- const tool = typeof item.tool === "string" ? item.tool : "tool";
104
- return { activity: { type: "action", action: "Using", parameter: tool } };
19
+ const previous = this.publicationsByRun.get(run.id);
20
+ if (previous?.meaningKey === fact.meaningKey) {
21
+ return;
105
22
  }
106
- }
107
- if (agentSentence) {
108
- return {
109
- activity: { type: "thought", body: agentSentence },
110
- bypassThrottle: true,
23
+ const publication = {
24
+ meaningKey: fact.meaningKey,
25
+ publishedAtMs: Date.now(),
111
26
  };
27
+ this.publicationsByRun.set(run.id, publication);
28
+ void this.emitActivity(issue, fact.content, { ephemeral: true }).catch(() => {
29
+ const current = this.publicationsByRun.get(run.id);
30
+ if (current?.publishedAtMs === publication.publishedAtMs && current.meaningKey === publication.meaningKey) {
31
+ this.publicationsByRun.delete(run.id);
32
+ }
33
+ });
112
34
  }
113
- return undefined;
114
- }
115
- function resolveWorkingOnSummary(notification) {
116
- if (notification.method !== "turn/plan/updated") {
117
- return undefined;
118
- }
119
- const plan = notification.params.plan;
120
- if (!Array.isArray(plan))
121
- return undefined;
122
- const ranked = plan
123
- .map((entry) => entry)
124
- .filter((entry) => typeof entry.step === "string" && entry.step.trim().length > 0)
125
- .sort((a, b) => rankPlanStatus(a.status) - rankPlanStatus(b.status));
126
- const first = ranked[0];
127
- return summarizeProgressSentence(typeof first?.step === "string" ? first.step : undefined);
128
- }
129
- function rankPlanStatus(status) {
130
- return status === "inProgress" ? 0
131
- : status === "pending" ? 1
132
- : status === "completed" ? 2
133
- : 3;
134
- }
135
- function resolveAgentMessageKey(notification, run) {
136
- if (notification.method === "item/agentMessage/delta") {
137
- const itemId = typeof notification.params.itemId === "string" ? notification.params.itemId : undefined;
138
- return itemId ? `${run.id}:${itemId}` : undefined;
139
- }
140
- if (notification.method === "item/completed") {
141
- const item = notification.params.item;
142
- const itemId = typeof item?.id === "string" ? item.id : undefined;
143
- const itemType = typeof item?.type === "string" ? item.type : undefined;
144
- return itemId && itemType === "agentMessage" ? `${run.id}:${itemId}` : undefined;
145
- }
146
- return undefined;
147
- }
148
- function resolveAgentMessageDelta(notification) {
149
- if (notification.method !== "item/agentMessage/delta") {
150
- return undefined;
151
- }
152
- return typeof notification.params.delta === "string" ? notification.params.delta : undefined;
153
- }
154
- function resolveCompletedAgentMessageText(notification) {
155
- if (notification.method !== "item/completed") {
156
- return undefined;
35
+ clearProgress(runId) {
36
+ this.publicationsByRun.delete(runId);
157
37
  }
158
- const item = notification.params.item;
159
- if (!item || item.type !== "agentMessage")
160
- return undefined;
161
- return typeof item.text === "string" ? item.text : undefined;
162
- }
163
- function extractFirstSentence(text) {
164
- const sanitized = sanitizeOperatorFacingText(text)?.replace(/\s+/g, " ").trim();
165
- if (!sanitized)
166
- return undefined;
167
- const match = sanitized.match(/^(.+?[.!?])(?:\s|$)/);
168
- return truncateProgressText((match?.[1] ?? sanitized).trim(), MAX_PROGRESS_TEXT_LENGTH);
169
- }
170
- function extractFirstCompletedSentence(text) {
171
- const sanitized = sanitizeOperatorFacingText(text)?.replace(/\s+/g, " ").trim();
172
- if (!sanitized)
173
- return undefined;
174
- const match = sanitized.match(/^(.+?[.!?])(?:\s|$)/);
175
- return match?.[1] ? truncateProgressText(match[1].trim(), MAX_PROGRESS_TEXT_LENGTH) : undefined;
176
- }
177
- function summarizeProgressSentence(text) {
178
- const summary = extractFirstSentence(text);
179
- if (!summary)
180
- return undefined;
181
- return summary.endsWith(".") || summary.endsWith("!") || summary.endsWith("?") ? summary : `${summary}.`;
182
- }
183
- function truncateProgressText(text, maxLength) {
184
- return text.length <= maxLength ? text : `${text.slice(0, maxLength - 3).trimEnd()}...`;
185
38
  }
@@ -1,8 +1,7 @@
1
1
  import { extractCompletionCheck } from "./completion-check.js";
2
+ import { isClosedPrState } from "./pr-state.js";
2
3
  import { deriveIssueStatusNote } from "./status-note.js";
3
4
  import { derivePatchRelayWaitingReason } from "./waiting-reason.js";
4
- import { isClosedPrState } from "./pr-state.js";
5
- import { isUndelegatedPausedIssue } from "./paused-issue-state.js";
6
5
  export async function syncVisibleStatusComment(params) {
7
6
  const { db, issue, linear, logger, trackedIssue, options } = params;
8
7
  try {
@@ -25,23 +24,8 @@ export async function syncVisibleStatusComment(params) {
25
24
  logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to sync Linear status comment");
26
25
  }
27
26
  }
28
- export function shouldSyncVisibleIssueComment(issue, hasAgentSession) {
29
- if (!hasAgentSession) {
30
- return true;
31
- }
32
- if (issue.sessionState === "waiting_input" || issue.sessionState === "failed"
33
- || issue.factoryState === "awaiting_input" || issue.factoryState === "failed" || issue.factoryState === "escalated") {
34
- return true;
35
- }
36
- if (isUndelegatedPausedIssue(issue)) {
37
- return true;
38
- }
39
- if ((issue.sessionState === "done" || issue.factoryState === "done")
40
- && ((issue.prNumber === undefined && !issue.prUrl)
41
- || isClosedPrState(issue.prState))) {
42
- return true;
43
- }
44
- return false;
27
+ export function shouldSyncVisibleIssueComment(_issue, _hasAgentSession) {
28
+ return true;
45
29
  }
46
30
  function renderStatusComment(db, issue, trackedIssue, options) {
47
31
  const activeRun = issue.activeRunId ? db.runs.getRunById(issue.activeRunId) : undefined;
@@ -1,22 +1,24 @@
1
- import { resolvePreferredDeployingLinearState, resolvePreferredHumanNeededLinearState, resolvePreferredImplementingLinearState, resolvePreferredReviewLinearState, resolvePreferredReviewingLinearState, } from "./linear-workflow.js";
1
+ import { resolvePreferredCompletedLinearState, resolvePreferredDeployingLinearState, resolvePreferredHumanNeededLinearState, resolvePreferredImplementingLinearState, resolvePreferredReviewLinearState, resolvePreferredReviewingLinearState, } from "./linear-workflow.js";
2
+ import { isCompletedLinearState } from "./pr-state.js";
3
+ import { hasTrustedNoPrCompletion } from "./trusted-no-pr-completion.js";
2
4
  export async function syncActiveWorkflowState(params) {
3
5
  const { db, issue, linear, trackedIssue, options } = params;
4
- if (!shouldAutoAdvanceLinearState(issue)) {
5
- return;
6
- }
7
6
  const liveIssue = await linear.getIssue(issue.linearIssueId).catch(() => undefined);
8
7
  if (!liveIssue)
9
8
  return;
9
+ const latestRun = db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
10
+ if (hasTrustedNoPrCompletion(issue, latestRun)) {
11
+ await syncCompletedLinearState({ db, issue, linear, liveIssue });
12
+ return;
13
+ }
14
+ if (!shouldAutoAdvanceLinearState(issue)) {
15
+ return;
16
+ }
10
17
  if (!shouldAutoAdvanceLinearState({
11
18
  currentLinearState: liveIssue.stateName,
12
19
  currentLinearStateType: liveIssue.stateType,
13
20
  })) {
14
- db.issues.upsertIssue({
15
- projectId: issue.projectId,
16
- linearIssueId: issue.linearIssueId,
17
- ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
18
- ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
19
- });
21
+ refreshCachedLinearState(db, issue, liveIssue.stateName, liveIssue.stateType);
20
22
  return;
21
23
  }
22
24
  const targetState = resolveDesiredActiveWorkflowState(issue, trackedIssue, options, liveIssue);
@@ -24,20 +26,37 @@ export async function syncActiveWorkflowState(params) {
24
26
  return;
25
27
  const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
26
28
  if (normalizedCurrent === targetState.trim().toLowerCase()) {
27
- db.issues.upsertIssue({
28
- projectId: issue.projectId,
29
- linearIssueId: issue.linearIssueId,
30
- ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
31
- ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
32
- });
29
+ refreshCachedLinearState(db, issue, liveIssue.stateName, liveIssue.stateType);
33
30
  return;
34
31
  }
35
32
  const updated = await linear.setIssueState(issue.linearIssueId, targetState);
33
+ refreshCachedLinearState(db, issue, updated.stateName, updated.stateType);
34
+ }
35
+ async function syncCompletedLinearState(params) {
36
+ const { db, issue, linear, liveIssue } = params;
37
+ if (isCompletedLinearState(liveIssue.stateType, liveIssue.stateName)) {
38
+ refreshCachedLinearState(db, issue, liveIssue.stateName, liveIssue.stateType);
39
+ return;
40
+ }
41
+ const targetState = resolvePreferredCompletedLinearState(liveIssue);
42
+ if (!targetState) {
43
+ refreshCachedLinearState(db, issue, liveIssue.stateName, liveIssue.stateType);
44
+ return;
45
+ }
46
+ const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
47
+ if (normalizedCurrent === targetState.trim().toLowerCase()) {
48
+ refreshCachedLinearState(db, issue, liveIssue.stateName, liveIssue.stateType);
49
+ return;
50
+ }
51
+ const updated = await linear.setIssueState(issue.linearIssueId, targetState);
52
+ refreshCachedLinearState(db, issue, updated.stateName, updated.stateType);
53
+ }
54
+ function refreshCachedLinearState(db, issue, stateName, stateType) {
36
55
  db.issues.upsertIssue({
37
56
  projectId: issue.projectId,
38
57
  linearIssueId: issue.linearIssueId,
39
- ...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
40
- ...(updated.stateType ? { currentLinearStateType: updated.stateType } : {}),
58
+ ...(stateName ? { currentLinearState: stateName } : {}),
59
+ ...(stateType ? { currentLinearStateType: stateType } : {}),
41
60
  });
42
61
  }
43
62
  function shouldAutoAdvanceLinearState(issue) {
@@ -0,0 +1,37 @@
1
+ import { hasOpenPr } from "./pr-state.js";
2
+ export function resolveRetryTarget(params) {
3
+ if (params.prState === "merged") {
4
+ return { runType: "none", factoryState: "done" };
5
+ }
6
+ if (hasOpenPr(params.prNumber, params.prState) && params.lastGitHubFailureSource === "queue_eviction") {
7
+ return { runType: "queue_repair", factoryState: "repairing_queue" };
8
+ }
9
+ if (hasOpenPr(params.prNumber, params.prState)
10
+ && (params.prCheckStatus === "failed" || params.prCheckStatus === "failure" || params.lastGitHubFailureSource === "branch_ci")) {
11
+ return { runType: "ci_repair", factoryState: "repairing_ci" };
12
+ }
13
+ if (hasOpenPr(params.prNumber, params.prState) && params.prReviewState === "changes_requested") {
14
+ return {
15
+ runType: params.pendingRunType === "branch_upkeep" || params.lastRunType === "branch_upkeep"
16
+ ? "branch_upkeep"
17
+ : "review_fix",
18
+ factoryState: "changes_requested",
19
+ };
20
+ }
21
+ if (hasOpenPr(params.prNumber, params.prState)) {
22
+ return { runType: "implementation", factoryState: "implementing" };
23
+ }
24
+ return { runType: "implementation", factoryState: "delegated" };
25
+ }
26
+ export function buildManualRetryAttemptReset(runType) {
27
+ if (runType === "ci_repair") {
28
+ return { ciRepairAttempts: 0 };
29
+ }
30
+ if (runType === "queue_repair") {
31
+ return { queueRepairAttempts: 0 };
32
+ }
33
+ if (runType === "review_fix" || runType === "branch_upkeep") {
34
+ return { reviewFixAttempts: 0 };
35
+ }
36
+ return {};
37
+ }