patchrelay 0.38.0 → 0.38.2

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 (45) 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-linear-session-sync.js +57 -0
  13. package/dist/github-pr-comment-handler.js +74 -0
  14. package/dist/github-webhook-failure-context.js +70 -0
  15. package/dist/github-webhook-handler.js +52 -975
  16. package/dist/github-webhook-issue-resolution.js +46 -0
  17. package/dist/github-webhook-late-publication-guard.js +94 -0
  18. package/dist/github-webhook-policy.js +105 -0
  19. package/dist/github-webhook-reactive-run.js +302 -0
  20. package/dist/github-webhook-state-projector.js +245 -0
  21. package/dist/github-webhook-terminal-handler.js +111 -0
  22. package/dist/github-webhooks.js +39 -4
  23. package/dist/http.js +17 -0
  24. package/dist/idle-reconciliation.js +4 -2
  25. package/dist/issue-overview-query.js +8 -57
  26. package/dist/issue-session-events.js +1 -0
  27. package/dist/legacy-issue-overview.js +58 -0
  28. package/dist/linear-activity-key.js +11 -0
  29. package/dist/linear-agent-session-client.js +14 -1
  30. package/dist/linear-progress-reporter.js +7 -181
  31. package/dist/linear-status-comment-sync.js +3 -19
  32. package/dist/manual-issue-actions.js +37 -0
  33. package/dist/presentation-text.js +11 -1
  34. package/dist/prompting/patchrelay.js +8 -6
  35. package/dist/reactive-pr-state.js +65 -0
  36. package/dist/reactive-run-policy.js +35 -118
  37. package/dist/remote-pr-state.js +11 -0
  38. package/dist/run-budgets.js +12 -0
  39. package/dist/run-notification-handler.js +4 -0
  40. package/dist/run-orchestrator.js +28 -8
  41. package/dist/run-wake-planner.js +11 -10
  42. package/dist/service-issue-actions.js +80 -27
  43. package/dist/service.js +3 -0
  44. package/dist/webhooks/desired-stage-recorder.js +34 -10
  45. package/package.json +1 -1
@@ -0,0 +1,11 @@
1
+ import { execCommand } from "./utils.js";
2
+ export async function readRemotePrState(repoFullName, prNumber) {
3
+ const { stdout, exitCode } = await execCommand("gh", [
4
+ "pr", "view", String(prNumber),
5
+ "--repo", repoFullName,
6
+ "--json", "headRefOid,state,reviewDecision,mergeStateStatus",
7
+ ], { timeoutMs: 10_000 });
8
+ if (exitCode !== 0)
9
+ return undefined;
10
+ return JSON.parse(stdout);
11
+ }
@@ -0,0 +1,12 @@
1
+ export const DEFAULT_CI_REPAIR_BUDGET = 3;
2
+ export const DEFAULT_QUEUE_REPAIR_BUDGET = 3;
3
+ export const DEFAULT_REVIEW_FIX_BUDGET = 3;
4
+ export function getCiRepairBudget(project) {
5
+ return project?.repairBudgets?.ciRepair ?? DEFAULT_CI_REPAIR_BUDGET;
6
+ }
7
+ export function getQueueRepairBudget(project) {
8
+ return project?.repairBudgets?.queueRepair ?? DEFAULT_QUEUE_REPAIR_BUDGET;
9
+ }
10
+ export function getReviewFixBudget(project) {
11
+ return project?.repairBudgets?.reviewFix ?? DEFAULT_REVIEW_FIX_BUDGET;
12
+ }
@@ -41,6 +41,10 @@ export class RunNotificationHandler {
41
41
  const run = this.db.runs.getRunByThreadId(threadId);
42
42
  if (!run)
43
43
  return;
44
+ if (run.status !== "running") {
45
+ this.logger.info({ runId: run.id, status: run.status, issueId: run.linearIssueId }, "Ignoring Codex notification for inactive run");
46
+ return;
47
+ }
44
48
  if (!this.heartbeatIssueSessionLease(run.projectId, run.linearIssueId)) {
45
49
  this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Ignoring Codex notification after losing issue-session lease");
46
50
  return;
@@ -54,6 +54,20 @@ export class RunOrchestrator {
54
54
  runNotificationHandler;
55
55
  runReconciler;
56
56
  mergedLinearCompletionReconciler;
57
+ threadPorts = {
58
+ readThreadWithRetry: (threadId, maxRetries) => this.readThreadWithRetry(threadId, maxRetries),
59
+ };
60
+ leasePorts = {
61
+ withHeldLease: (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn),
62
+ releaseLease: (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId),
63
+ heartbeatLease: (projectId, linearIssueId) => this.heartbeatIssueSessionLease(projectId, linearIssueId),
64
+ getHeldLease: (projectId, linearIssueId) => this.getHeldIssueSessionLease(projectId, linearIssueId),
65
+ };
66
+ recoveryPorts = {
67
+ failRunAndClear: (run, message, nextState) => this.failRunAndClear(run, message, nextState),
68
+ restoreIdleWorktree: (issue) => this.restoreIdleWorktree(issue),
69
+ recoverOrEscalate: (issue, runType, reason) => this.recoverOrEscalate(issue, runType, reason),
70
+ };
57
71
  activeSessionLeases;
58
72
  botIdentity;
59
73
  constructor(config, db, codex, linearProvider, enqueueIssue, logger, feed) {
@@ -66,16 +80,16 @@ export class RunOrchestrator {
66
80
  this.feed = feed;
67
81
  this.worktreeManager = new WorktreeManager(config);
68
82
  this.linearSync = new LinearSessionSync(config, db, linearProvider, logger, feed);
69
- this.leaseService = new IssueSessionLeaseService(db, logger, this.workerId, (threadId, maxRetries) => this.readThreadWithRetry(threadId, maxRetries));
83
+ this.leaseService = new IssueSessionLeaseService(db, logger, this.workerId, this.threadPorts.readThreadWithRetry);
70
84
  this.activeSessionLeases = this.leaseService.activeSessionLeases;
71
- this.runCompletionPolicy = new RunCompletionPolicy(config, db, logger, (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn));
85
+ this.runCompletionPolicy = new RunCompletionPolicy(config, db, logger, this.leasePorts.withHeldLease);
72
86
  this.completionCheck = new CompletionCheckService(codex, logger);
73
- this.runFinalizer = new RunFinalizer(db, logger, this.linearSync, this.enqueueIssue, (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn), (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId), (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), (run, message, nextState) => this.failRunAndClear(run, message, nextState), this.runCompletionPolicy, this.completionCheck, feed);
87
+ this.runFinalizer = new RunFinalizer(db, logger, this.linearSync, this.enqueueIssue, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), this.recoveryPorts.failRunAndClear, this.runCompletionPolicy, this.completionCheck, feed);
74
88
  this.runLauncher = new RunLauncher(config, db, codex, logger, this.worktreeManager);
75
- this.runNotificationHandler = new RunNotificationHandler(config, db, logger, this.linearSync, this.runFinalizer, (threadId, maxRetries) => this.readThreadWithRetry(threadId, maxRetries), (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn), (projectId, linearIssueId) => this.heartbeatIssueSessionLease(projectId, linearIssueId), (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId), feed);
76
- this.runRecovery = new RunRecoveryService(db, logger, this.linearSync, (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn), (projectId, linearIssueId) => this.getHeldIssueSessionLease(projectId, linearIssueId), (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId), (projectId, issueId) => this.enqueueIssue(projectId, issueId), feed);
77
- this.interruptedRunRecovery = new InterruptedRunRecovery(db, logger, this.linearSync, (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn), (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId), (run, message, nextState) => this.failRunAndClear(run, message, nextState), (issue) => this.restoreIdleWorktree(issue), this.runCompletionPolicy, (projectId, issueId) => this.enqueueIssue(projectId, issueId), feed);
78
- this.runReconciler = new RunReconciler(db, logger, linearProvider, this.linearSync, this.interruptedRunRecovery, this.runFinalizer, (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn), (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId), (threadId, maxRetries) => this.readThreadWithRetry(threadId, maxRetries), (issue, runType, reason) => this.recoverOrEscalate(issue, runType, reason), feed);
89
+ this.runNotificationHandler = new RunNotificationHandler(config, db, logger, this.linearSync, this.runFinalizer, this.threadPorts.readThreadWithRetry, this.leasePorts.withHeldLease, this.leasePorts.heartbeatLease, this.leasePorts.releaseLease, feed);
90
+ this.runRecovery = new RunRecoveryService(db, logger, this.linearSync, this.leasePorts.withHeldLease, this.leasePorts.getHeldLease, (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), this.leasePorts.releaseLease, (projectId, issueId) => this.enqueueIssue(projectId, issueId), feed);
91
+ this.interruptedRunRecovery = new InterruptedRunRecovery(db, logger, this.linearSync, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, this.recoveryPorts.failRunAndClear, this.recoveryPorts.restoreIdleWorktree, this.runCompletionPolicy, (projectId, issueId) => this.enqueueIssue(projectId, issueId), feed);
92
+ this.runReconciler = new RunReconciler(db, logger, linearProvider, this.linearSync, this.interruptedRunRecovery, this.runFinalizer, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, this.threadPorts.readThreadWithRetry, this.recoveryPorts.recoverOrEscalate, feed);
79
93
  this.runWakePlanner = new RunWakePlanner(db);
80
94
  this.idleReconciler = new IdleIssueReconciler(db, config, {
81
95
  enqueueIssue: (projectId, issueId) => this.enqueueIssue(projectId, issueId),
@@ -124,6 +138,12 @@ export class RunOrchestrator {
124
138
  return;
125
139
  }
126
140
  const { runType, context, resumeThread } = wake;
141
+ if (runType === "implementation" && this.db.issues.countUnresolvedBlockers(item.projectId, item.issueId) > 0) {
142
+ this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(item.projectId, item.issueId);
143
+ this.releaseIssueSessionLease(item.projectId, item.issueId);
144
+ this.logger.info({ issueKey: issue.issueKey }, "Skipped implementation launch because the issue is blocked");
145
+ return;
146
+ }
127
147
  const remainingZombieDelayMs = shouldDelayZombieRecoveryLaunch(issue, issueSession, runType);
128
148
  if (remainingZombieDelayMs > 0) {
129
149
  this.logger.debug({ issueKey: issue.issueKey, runType, remainingZombieDelayMs }, "Deferring recovered run launch until zombie backoff elapses");
@@ -138,7 +158,7 @@ export class RunOrchestrator {
138
158
  : typeof effectiveContext?.headSha === "string"
139
159
  ? effectiveContext.headSha
140
160
  : issue.prHeadSha;
141
- const budgetExceeded = this.runWakePlanner.budgetExceeded(issue, runType, isRequestedChangesRunType);
161
+ const budgetExceeded = this.runWakePlanner.budgetExceeded(issue, project, runType, isRequestedChangesRunType);
142
162
  if (budgetExceeded) {
143
163
  this.escalate(issue, runType, budgetExceeded);
144
164
  return;
@@ -1,6 +1,4 @@
1
- const DEFAULT_CI_REPAIR_BUDGET = 3;
2
- const DEFAULT_QUEUE_REPAIR_BUDGET = 3;
3
- const DEFAULT_REVIEW_FIX_BUDGET = 12;
1
+ import { getCiRepairBudget, getQueueRepairBudget, getReviewFixBudget, } from "./run-budgets.js";
4
2
  export class RunWakePlanner {
5
3
  db;
6
4
  constructor(db) {
@@ -62,15 +60,18 @@ export class RunWakePlanner {
62
60
  return issue;
63
61
  return this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
64
62
  }
65
- budgetExceeded(issue, runType, isRequestedChangesRunType) {
66
- if (runType === "ci_repair" && issue.ciRepairAttempts >= DEFAULT_CI_REPAIR_BUDGET) {
67
- return `CI repair budget exhausted (${DEFAULT_CI_REPAIR_BUDGET} attempts)`;
63
+ budgetExceeded(issue, project, runType, isRequestedChangesRunType) {
64
+ const ciRepairBudget = getCiRepairBudget(project);
65
+ if (runType === "ci_repair" && issue.ciRepairAttempts >= ciRepairBudget) {
66
+ return `CI repair budget exhausted (${ciRepairBudget} attempts)`;
68
67
  }
69
- if (runType === "queue_repair" && issue.queueRepairAttempts >= DEFAULT_QUEUE_REPAIR_BUDGET) {
70
- return `Queue repair budget exhausted (${DEFAULT_QUEUE_REPAIR_BUDGET} attempts)`;
68
+ const queueRepairBudget = getQueueRepairBudget(project);
69
+ if (runType === "queue_repair" && issue.queueRepairAttempts >= queueRepairBudget) {
70
+ return `Queue repair budget exhausted (${queueRepairBudget} attempts)`;
71
71
  }
72
- if (isRequestedChangesRunType(runType) && issue.reviewFixAttempts >= DEFAULT_REVIEW_FIX_BUDGET) {
73
- return `Requested-changes budget exhausted (${DEFAULT_REVIEW_FIX_BUDGET} attempts)`;
72
+ const reviewFixBudget = getReviewFixBudget(project);
73
+ if (isRequestedChangesRunType(runType) && issue.reviewFixAttempts >= reviewFixBudget) {
74
+ return `Requested-changes budget exhausted (${reviewFixBudget} attempts)`;
74
75
  }
75
76
  return undefined;
76
77
  }
@@ -1,5 +1,5 @@
1
1
  import { buildOperatorRetryEvent } from "./operator-retry-event.js";
2
- import { hasOpenPr } from "./pr-state.js";
2
+ import { buildManualRetryAttemptReset, resolveRetryTarget } from "./manual-issue-actions.js";
3
3
  export class ServiceIssueActions {
4
4
  db;
5
5
  codex;
@@ -103,7 +103,16 @@ export class ServiceIssueActions {
103
103
  if (issue.activeRunId)
104
104
  return { error: "Issue already has an active run" };
105
105
  const issueSession = this.db.issueSessions.getIssueSession(issue.projectId, issue.linearIssueId);
106
- if (issue.prState === "merged") {
106
+ const retryTarget = resolveRetryTarget({
107
+ prNumber: issue.prNumber,
108
+ prState: issue.prState,
109
+ prReviewState: issue.prReviewState,
110
+ prCheckStatus: issue.prCheckStatus,
111
+ pendingRunType: issue.pendingRunType,
112
+ lastRunType: issueSession?.lastRunType,
113
+ lastGitHubFailureSource: issue.lastGitHubFailureSource,
114
+ });
115
+ if (retryTarget.runType === "none") {
107
116
  this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
108
117
  projectId: issue.projectId,
109
118
  linearIssueId: issue.linearIssueId,
@@ -111,45 +120,89 @@ export class ServiceIssueActions {
111
120
  });
112
121
  return { issueKey, runType: "none" };
113
122
  }
114
- let runType = "implementation";
115
- let factoryState = "delegated";
116
- if (hasOpenPr(issue.prNumber, issue.prState) && issue.lastGitHubFailureSource === "queue_eviction") {
117
- runType = "queue_repair";
118
- factoryState = "repairing_queue";
119
- }
120
- else if (hasOpenPr(issue.prNumber, issue.prState) && (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure" || issue.lastGitHubFailureSource === "branch_ci")) {
121
- runType = "ci_repair";
122
- factoryState = "repairing_ci";
123
- }
124
- else if (hasOpenPr(issue.prNumber, issue.prState) && issue.prReviewState === "changes_requested") {
125
- runType = issue.pendingRunType === "branch_upkeep" || issueSession?.lastRunType === "branch_upkeep"
126
- ? "branch_upkeep"
127
- : "review_fix";
128
- factoryState = "changes_requested";
129
- }
130
- else if (hasOpenPr(issue.prNumber, issue.prState)) {
131
- runType = "implementation";
132
- factoryState = "implementing";
133
- }
134
- this.appendOperatorRetryEvent(issue, runType);
123
+ this.appendOperatorRetryEvent(issue, retryTarget.runType);
135
124
  this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
136
125
  projectId: issue.projectId,
137
126
  linearIssueId: issue.linearIssueId,
138
- factoryState: factoryState,
127
+ factoryState: retryTarget.factoryState,
128
+ ...buildManualRetryAttemptReset(retryTarget.runType),
139
129
  });
140
130
  this.feed.publish({
141
131
  level: "info",
142
132
  kind: "stage",
143
133
  issueKey: issue.issueKey,
144
134
  projectId: issue.projectId,
145
- stage: factoryState,
135
+ stage: retryTarget.factoryState,
146
136
  status: "retry",
147
- summary: `Retry queued: ${runType}`,
137
+ summary: `Retry queued: ${retryTarget.runType}`,
148
138
  });
149
139
  if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
150
140
  this.runtime.enqueueIssue(issue.projectId, issue.linearIssueId);
151
141
  }
152
- return { issueKey, runType };
142
+ return { issueKey, runType: retryTarget.runType };
143
+ }
144
+ async closeIssue(issueKey, options) {
145
+ const issue = this.db.issues.getIssueByKey(issueKey);
146
+ if (!issue)
147
+ return undefined;
148
+ const terminalState = options?.failed ? "failed" : "done";
149
+ const run = issue.activeRunId ? this.db.runs.getRunById(issue.activeRunId) : undefined;
150
+ if (run?.threadId && run.turnId) {
151
+ try {
152
+ await this.codex.steerTurn({
153
+ threadId: run.threadId,
154
+ turnId: run.turnId,
155
+ input: `STOP: The operator manually closed this issue in PatchRelay as ${terminalState}. Stop working immediately and exit without making further changes.`,
156
+ });
157
+ }
158
+ catch {
159
+ // The turn may already be settled.
160
+ }
161
+ }
162
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
163
+ projectId: issue.projectId,
164
+ linearIssueId: issue.linearIssueId,
165
+ eventType: "operator_closed",
166
+ eventJson: JSON.stringify({
167
+ terminalState,
168
+ ...(options?.reason ? { reason: options.reason } : {}),
169
+ }),
170
+ dedupeKey: `operator_closed:${issue.linearIssueId}:${terminalState}:${issue.activeRunId ?? "no-run"}`,
171
+ });
172
+ this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
173
+ if (run) {
174
+ this.db.issueSessions.finishRunRespectingActiveLease(issue.projectId, issue.linearIssueId, run.id, {
175
+ status: "released",
176
+ failureReason: options?.reason
177
+ ? `Operator closed issue as ${terminalState}: ${options.reason}`
178
+ : `Operator closed issue as ${terminalState}`,
179
+ });
180
+ }
181
+ this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
182
+ projectId: issue.projectId,
183
+ linearIssueId: issue.linearIssueId,
184
+ factoryState: terminalState,
185
+ activeRunId: null,
186
+ pendingRunType: null,
187
+ pendingRunContextJson: null,
188
+ });
189
+ this.db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
190
+ this.feed.publish({
191
+ level: terminalState === "failed" ? "warn" : "info",
192
+ kind: "workflow",
193
+ issueKey: issue.issueKey,
194
+ projectId: issue.projectId,
195
+ stage: terminalState,
196
+ status: "operator_closed",
197
+ summary: options?.reason
198
+ ? `Operator closed issue as ${terminalState}: ${options.reason}`
199
+ : `Operator closed issue as ${terminalState}`,
200
+ });
201
+ return {
202
+ issueKey,
203
+ factoryState: terminalState,
204
+ ...(run ? { releasedRunId: run.id } : {}),
205
+ };
153
206
  }
154
207
  queueOperatorPrompt(issue, text, source) {
155
208
  this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
package/dist/service.js CHANGED
@@ -215,6 +215,9 @@ export class PatchRelayService {
215
215
  retryIssue(issueKey) {
216
216
  return this.issueActions.retryIssue(issueKey);
217
217
  }
218
+ async closeIssue(issueKey, options) {
219
+ return await this.issueActions.closeIssue(issueKey, options);
220
+ }
218
221
  async acceptWebhook(params) {
219
222
  const result = await acceptIncomingWebhook({
220
223
  config: this.config,
@@ -19,16 +19,22 @@ export class DesiredStageRecorder {
19
19
  const existingIssue = this.db.issues.getIssue(params.project.id, normalizedIssue.id);
20
20
  const activeRun = existingIssue?.activeRunId ? this.db.runs.getRunById(existingIssue.activeRunId) : undefined;
21
21
  const latestRun = existingIssue ? this.db.runs.getLatestRunForIssue(params.project.id, normalizedIssue.id) : undefined;
22
- const delegated = this.isDelegatedToPatchRelay(params.project, params.normalized);
23
22
  const triggerAllowed = triggerEventAllowed(params.project, params.normalized.triggerEvent);
24
23
  const incomingAgentSessionId = params.normalized.agentSession?.id;
25
24
  const hasPendingWake = this.db.issueSessions.peekIssueSessionWake(params.project.id, normalizedIssue.id) !== undefined;
26
- if (!existingIssue && !delegated && !incomingAgentSessionId) {
27
- return { issue: undefined, wakeRunType: undefined, delegated };
25
+ if (!existingIssue && !this.isDelegatedToPatchRelay(params.project, normalizedIssue) && !incomingAgentSessionId) {
26
+ return { issue: undefined, wakeRunType: undefined, delegated: false };
28
27
  }
29
28
  const hydratedIssue = await this.syncIssueDependencies(params.project.id, normalizedIssue);
29
+ const delegated = this.isDelegatedToPatchRelay(params.project, hydratedIssue);
30
30
  const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(params.project.id, normalizedIssue.id);
31
31
  const terminal = isTerminalDelegationState(existingIssue, hydratedIssue);
32
+ const openPrExists = existingIssue?.prNumber !== undefined
33
+ && existingIssue.prState !== "closed"
34
+ && existingIssue.prState !== "merged";
35
+ const blockerPausedImplementation = unresolvedBlockers > 0
36
+ && activeRun?.runType === "implementation"
37
+ && !openPrExists;
32
38
  const desiredStage = decideRunIntent({
33
39
  delegated,
34
40
  triggerAllowed,
@@ -45,6 +51,9 @@ export class DesiredStageRecorder {
45
51
  triggerEvent: params.normalized.triggerEvent,
46
52
  delegated,
47
53
  });
54
+ const effectiveRunRelease = blockerPausedImplementation
55
+ ? { release: true, reason: "Issue became blocked during implementation" }
56
+ : runRelease;
48
57
  const undelegation = decideUnDelegation({
49
58
  triggerEvent: params.normalized.triggerEvent,
50
59
  delegated,
@@ -96,11 +105,12 @@ export class DesiredStageRecorder {
96
105
  ...(!reDelegationResume.factoryState && desiredStage ? { pendingRunType: null, pendingRunContextJson: null, factoryState: "delegated" } : {}),
97
106
  ...(clearPending ? { pendingRunType: null, pendingRunContextJson: null } : {}),
98
107
  ...(agentSessionId !== undefined ? { agentSessionId } : {}),
99
- ...(runRelease.release ? { activeRunId: null } : {}),
108
+ ...(effectiveRunRelease.release ? { activeRunId: null } : {}),
109
+ ...(blockerPausedImplementation ? { factoryState: "delegated" } : {}),
100
110
  ...(undelegation.factoryState ? { factoryState: undelegation.factoryState } : {}),
101
111
  });
102
- if (runRelease.release && activeRun && runRelease.reason) {
103
- this.db.runs.finishRun(activeRun.id, { status: "released", failureReason: runRelease.reason });
112
+ if (effectiveRunRelease.release && activeRun && effectiveRunRelease.reason) {
113
+ this.db.runs.finishRun(activeRun.id, { status: "released", failureReason: effectiveRunRelease.reason });
104
114
  }
105
115
  return record;
106
116
  };
@@ -136,6 +146,22 @@ export class DesiredStageRecorder {
136
146
  : `Issue un-delegated from PatchRelay; ${issue.factoryState} is now paused`,
137
147
  });
138
148
  }
149
+ else if (blockerPausedImplementation) {
150
+ if (activeRun?.threadId && activeRun.turnId) {
151
+ await params.stopActiveRun(activeRun, "STOP: The issue is now blocked by another task. Stop working immediately and exit without publishing.");
152
+ }
153
+ this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(params.project.id, normalizedIssue.id);
154
+ this.db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(params.project.id, normalizedIssue.id);
155
+ this.feed?.publish({
156
+ level: "warn",
157
+ kind: "stage",
158
+ issueKey: issue.issueKey,
159
+ projectId: params.project.id,
160
+ stage: issue.factoryState,
161
+ status: "blocked",
162
+ summary: `Implementation paused because ${issue.issueKey ?? normalizedIssue.id} is now blocked`,
163
+ });
164
+ }
139
165
  else if (reDelegationResume.pendingRunType) {
140
166
  this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(params.project.id, normalizedIssue.id, {
141
167
  projectId: params.project.id,
@@ -168,13 +194,11 @@ export class DesiredStageRecorder {
168
194
  delegated,
169
195
  };
170
196
  }
171
- isDelegatedToPatchRelay(project, normalized) {
172
- if (!normalized.issue)
173
- return false;
197
+ isDelegatedToPatchRelay(project, issue) {
174
198
  const installation = this.db.linearInstallations.getLinearInstallationForProject(project.id);
175
199
  if (!installation?.actorId)
176
200
  return false;
177
- return normalized.issue.delegateId === installation.actorId;
201
+ return issue.delegateId === installation.actorId;
178
202
  }
179
203
  async syncIssueDependencies(projectId, issue) {
180
204
  let source = issue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.38.0",
3
+ "version": "0.38.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {