patchrelay 0.37.1 → 0.38.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 (38) hide show
  1. package/README.md +47 -9
  2. package/dist/awaiting-input-reason.js +9 -0
  3. package/dist/build-info.json +3 -3
  4. package/dist/cli/cluster-health.js +59 -3
  5. package/dist/cli/help.js +1 -1
  6. package/dist/cli/output.js +2 -0
  7. package/dist/db/issue-session-store.js +0 -14
  8. package/dist/db/issue-store.js +8 -16
  9. package/dist/db/migrations.js +6 -13
  10. package/dist/db.js +1 -3
  11. package/dist/github-webhook-handler.js +70 -51
  12. package/dist/github-webhooks.js +4 -0
  13. package/dist/idle-reconciliation.js +22 -23
  14. package/dist/issue-overview-query.js +3 -0
  15. package/dist/issue-session-projector.js +1 -0
  16. package/dist/issue-session.js +8 -0
  17. package/dist/linear-session-reporting.js +30 -1
  18. package/dist/linear-session-sync.js +9 -1
  19. package/dist/linear-status-comment-sync.js +34 -1
  20. package/dist/linear-workflow-state-sync.js +2 -2
  21. package/dist/operator-retry-event.js +15 -12
  22. package/dist/paused-issue-state.js +24 -0
  23. package/dist/run-launcher.js +0 -1
  24. package/dist/run-orchestrator.js +2 -5
  25. package/dist/run-reconciler.js +10 -0
  26. package/dist/run-recovery-service.js +1 -10
  27. package/dist/service-issue-actions.js +5 -0
  28. package/dist/service-startup-recovery.js +9 -6
  29. package/dist/service.js +0 -1
  30. package/dist/tracked-issue-list-query.js +3 -1
  31. package/dist/tracked-issue-projector.js +3 -0
  32. package/dist/waiting-reason.js +10 -0
  33. package/dist/webhooks/agent-session-handler.js +9 -1
  34. package/dist/webhooks/comment-wake-handler.js +12 -0
  35. package/dist/webhooks/decision-helpers.js +44 -3
  36. package/dist/webhooks/dependency-readiness-handler.js +1 -0
  37. package/dist/webhooks/desired-stage-recorder.js +40 -10
  38. package/package.json +1 -1
@@ -0,0 +1,24 @@
1
+ import { resolveAwaitingInputReason } from "./awaiting-input-reason.js";
2
+ export function isUndelegatedPausedIssue(issue) {
3
+ return issue.delegatedToPatchRelay === false
4
+ && issue.factoryState !== "done"
5
+ && issue.factoryState !== "failed"
6
+ && issue.factoryState !== "escalated";
7
+ }
8
+ export function isUndelegatedPausedNoPrWork(issue) {
9
+ return isUndelegatedPausedIssue(issue)
10
+ && issue.prNumber === undefined
11
+ && (issue.factoryState === "delegated" || issue.factoryState === "implementing");
12
+ }
13
+ export function isResumablePausedLocalWork(params) {
14
+ if (params.issue.delegatedToPatchRelay === false) {
15
+ return false;
16
+ }
17
+ if (params.issue.prNumber !== undefined) {
18
+ return false;
19
+ }
20
+ if (params.issue.factoryState === "delegated" || params.issue.factoryState === "implementing") {
21
+ return true;
22
+ }
23
+ return resolveAwaitingInputReason(params) === "paused_local_work";
24
+ }
@@ -106,7 +106,6 @@ export class RunLauncher {
106
106
  });
107
107
  this.db.issueSessions.consumeIssueSessionEvents(params.item.projectId, params.item.issueId, freshWake.eventIds, created.id);
108
108
  this.db.issueSessions.setIssueSessionLastWakeReason(params.item.projectId, params.item.issueId, freshWake.wakeReason ?? null);
109
- this.db.issueSessions.setBranchOwnerWithLease({ projectId: params.item.projectId, linearIssueId: params.item.issueId, leaseId: params.leaseId }, "patchrelay");
110
109
  return created;
111
110
  });
112
111
  }
@@ -4,7 +4,7 @@ import { CompletionCheckService } from "./completion-check.js";
4
4
  import { WorktreeManager } from "./worktree-manager.js";
5
5
  import { MergedLinearCompletionReconciler } from "./merged-linear-completion-reconciler.js";
6
6
  import { QueueHealthMonitor } from "./queue-health-monitor.js";
7
- import { IdleIssueReconciler, resolveBranchOwnerForStateTransition } from "./idle-reconciliation.js";
7
+ import { IdleIssueReconciler } from "./idle-reconciliation.js";
8
8
  import { LinearSessionSync } from "./linear-session-sync.js";
9
9
  import { IssueSessionLeaseService } from "./issue-session-lease-service.js";
10
10
  import { InterruptedRunRecovery } from "./interrupted-run-recovery.js";
@@ -73,7 +73,7 @@ export class RunOrchestrator {
73
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);
74
74
  this.runLauncher = new RunLauncher(config, db, codex, logger, this.worktreeManager);
75
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), (newState, pendingRunType) => this.resolveBranchOwnerForStateTransition(newState, pendingRunType), 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
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
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);
79
79
  this.runWakePlanner = new RunWakePlanner(db);
@@ -302,9 +302,6 @@ export class RunOrchestrator {
302
302
  nextState,
303
303
  });
304
304
  }
305
- resolveBranchOwnerForStateTransition(newState, pendingRunType) {
306
- return resolveBranchOwnerForStateTransition(newState, pendingRunType);
307
- }
308
305
  async resolveRequestedChangesWakeContext(issue, runType, context) {
309
306
  return await this.runCompletionPolicy.resolveRequestedChangesWakeContext(issue, runType, context);
310
307
  }
@@ -31,6 +31,16 @@ export class RunReconciler {
31
31
  async reconcile(params) {
32
32
  const { run, issue, recoveryLease } = params;
33
33
  const acquiredRecoveryLease = recoveryLease === true;
34
+ if (!issue.delegatedToPatchRelay) {
35
+ this.withHeldLease(run.projectId, run.linearIssueId, () => {
36
+ this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue was un-delegated during active run" });
37
+ this.db.issues.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null, factoryState: issue.factoryState });
38
+ });
39
+ const pausedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
40
+ void this.linearSync.syncSession(pausedIssue, { activeRunType: run.runType });
41
+ this.releaseLease(run.projectId, run.linearIssueId);
42
+ return;
43
+ }
34
44
  if (TERMINAL_STATES.has(issue.factoryState)) {
35
45
  this.withHeldLease(run.projectId, run.linearIssueId, () => {
36
46
  this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue reached terminal state during active run" });
@@ -10,9 +10,8 @@ export class RunRecoveryService {
10
10
  appendWakeEventWithLease;
11
11
  releaseLease;
12
12
  enqueueIssue;
13
- resolveBranchOwnerForStateTransition;
14
13
  feed;
15
- constructor(db, logger, linearSync, withHeldLease, getHeldLease, appendWakeEventWithLease, releaseLease, enqueueIssue, resolveBranchOwnerForStateTransition, feed) {
14
+ constructor(db, logger, linearSync, withHeldLease, getHeldLease, appendWakeEventWithLease, releaseLease, enqueueIssue, feed) {
16
15
  this.db = db;
17
16
  this.logger = logger;
18
17
  this.linearSync = linearSync;
@@ -21,7 +20,6 @@ export class RunRecoveryService {
21
20
  this.appendWakeEventWithLease = appendWakeEventWithLease;
22
21
  this.releaseLease = releaseLease;
23
22
  this.enqueueIssue = enqueueIssue;
24
- this.resolveBranchOwnerForStateTransition = resolveBranchOwnerForStateTransition;
25
23
  this.feed = feed;
26
24
  }
27
25
  recoverOrEscalate(params) {
@@ -189,13 +187,6 @@ export class RunRecoveryService {
189
187
  activeRunId: null,
190
188
  factoryState: nextState,
191
189
  });
192
- const branchOwner = this.resolveBranchOwnerForStateTransition(nextState);
193
- if (branchOwner) {
194
- const heldLease = this.getHeldLease(run.projectId, run.linearIssueId);
195
- if (heldLease) {
196
- this.db.issueSessions.setBranchOwnerWithLease(heldLease, branchOwner);
197
- }
198
- }
199
190
  return true;
200
191
  });
201
192
  if (!updated) {
@@ -17,6 +17,9 @@ export class ServiceIssueActions {
17
17
  const issue = this.db.issues.getIssueByKey(issueKey);
18
18
  if (!issue)
19
19
  return undefined;
20
+ if (!issue.delegatedToPatchRelay && !issue.activeRunId) {
21
+ return { error: "Issue is undelegated from PatchRelay; delegate it again before prompting work" };
22
+ }
20
23
  this.feed.publish({
21
24
  level: "info",
22
25
  kind: "comment",
@@ -95,6 +98,8 @@ export class ServiceIssueActions {
95
98
  const issue = this.db.issues.getIssueByKey(issueKey);
96
99
  if (!issue)
97
100
  return undefined;
101
+ if (!issue.delegatedToPatchRelay)
102
+ return { error: "Issue is undelegated from PatchRelay; delegate it again before retrying" };
98
103
  if (issue.activeRunId)
99
104
  return { error: "Issue already has an active run" };
100
105
  const issueSession = this.db.issueSessions.getIssueSession(issue.projectId, issue.linearIssueId);
@@ -1,3 +1,4 @@
1
+ import { isResumablePausedLocalWork } from "./paused-issue-state.js";
1
2
  export class ServiceStartupRecovery {
2
3
  db;
3
4
  linearProvider;
@@ -65,12 +66,14 @@ export class ServiceStartupRecovery {
65
66
  });
66
67
  const delegated = liveIssue.delegateId === installation.actorId;
67
68
  const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
68
- const shouldRecoverAwaitingInput = delegated
69
- && issue.factoryState === "awaiting_input"
69
+ const latestRun = this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
70
+ const shouldRecoverPausedLocalWork = delegated
71
+ && isResumablePausedLocalWork({ issue, latestRun })
70
72
  && this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId) === undefined;
71
73
  const updated = this.db.issues.upsertIssue({
72
74
  projectId: issue.projectId,
73
75
  linearIssueId: issue.linearIssueId,
76
+ delegatedToPatchRelay: delegated,
74
77
  ...(liveIssue.identifier ? { issueKey: liveIssue.identifier } : {}),
75
78
  ...(liveIssue.title ? { title: liveIssue.title } : {}),
76
79
  ...(liveIssue.description ? { description: liveIssue.description } : {}),
@@ -79,9 +82,9 @@ export class ServiceStartupRecovery {
79
82
  ...(liveIssue.estimate != null ? { estimate: liveIssue.estimate } : {}),
80
83
  ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
81
84
  ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
82
- ...(shouldRecoverAwaitingInput ? { factoryState: "delegated" } : {}),
85
+ ...(shouldRecoverPausedLocalWork ? { factoryState: "delegated" } : {}),
83
86
  });
84
- if (!shouldRecoverAwaitingInput) {
87
+ if (!shouldRecoverPausedLocalWork) {
85
88
  continue;
86
89
  }
87
90
  if (unresolvedBlockers === 0) {
@@ -94,10 +97,10 @@ export class ServiceStartupRecovery {
94
97
  if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
95
98
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
96
99
  }
97
- this.logger.info({ issueKey: updated.issueKey }, "Recovered delegated issue from stale awaiting_input state and re-queued implementation");
100
+ this.logger.info({ issueKey: updated.issueKey }, "Recovered delegated issue from paused local-work state and re-queued implementation");
98
101
  }
99
102
  else {
100
- this.logger.info({ issueKey: updated.issueKey, unresolvedBlockers }, "Recovered delegated blocked issue from stale awaiting_input state");
103
+ this.logger.info({ issueKey: updated.issueKey, unresolvedBlockers }, "Recovered delegated blocked issue from paused local-work state");
101
104
  }
102
105
  }
103
106
  }
package/dist/service.js CHANGED
@@ -103,7 +103,6 @@ export class PatchRelayService {
103
103
  const identity = this.githubAppTokenManager.botIdentity();
104
104
  if (identity) {
105
105
  this.orchestrator.botIdentity = identity;
106
- this.githubWebhookHandler.setPatchRelayAuthorLogins([identity.name]);
107
106
  }
108
107
  }
109
108
  await this.runtime.start();
@@ -80,7 +80,7 @@ export class TrackedIssueListQuery {
80
80
  const rows = this.db.connection
81
81
  .prepare(`SELECT
82
82
  s.project_id, s.linear_issue_id, s.issue_key, i.title,
83
- i.current_linear_state, i.factory_state, s.session_state, s.waiting_reason, s.summary_text, s.updated_at,
83
+ i.current_linear_state, i.factory_state, i.delegated_to_patchrelay, s.session_state, s.waiting_reason, s.summary_text, s.updated_at,
84
84
  i.pending_run_type,
85
85
  i.pr_number, i.pr_state, i.pr_head_sha, i.pr_review_state, i.pr_check_status, i.last_blocking_review_head_sha,
86
86
  i.last_github_ci_snapshot_json,
@@ -157,6 +157,7 @@ export class TrackedIssueListQuery {
157
157
  const readyForExecution = isIssueSessionReadyForExecution({
158
158
  ...(typeof row.session_state === "string" ? { sessionState: String(row.session_state) } : {}),
159
159
  factoryState: String(row.factory_state ?? "delegated"),
160
+ ...(row.delegated_to_patchrelay !== null ? { delegatedToPatchRelay: Number(row.delegated_to_patchrelay) !== 0 } : {}),
160
161
  ...(row.active_run_type !== null ? { activeRunId: 1 } : {}),
161
162
  blockedByCount,
162
163
  hasPendingWake,
@@ -175,6 +176,7 @@ export class TrackedIssueListQuery {
175
176
  ? row.summary_text
176
177
  : undefined;
177
178
  const waitingReason = sessionWaitingReason ?? derivePatchRelayWaitingReason({
179
+ ...(row.delegated_to_patchrelay !== null ? { delegatedToPatchRelay: Number(row.delegated_to_patchrelay) !== 0 } : {}),
178
180
  ...(row.active_run_type !== null ? { activeRunType: String(row.active_run_type) } : {}),
179
181
  blockedByKeys,
180
182
  factoryState: String(row.factory_state ?? "delegated"),
@@ -10,6 +10,7 @@ export function buildTrackedIssueRecord(params) {
10
10
  const failureContext = parseGitHubFailureContext(params.issue.lastGitHubFailureContextJson);
11
11
  const blockedByKeys = unresolvedBlockedBy.map((entry) => entry.blockerIssueKey ?? entry.blockerLinearIssueId);
12
12
  const waitingReason = derivePatchRelayWaitingReason({
13
+ delegatedToPatchRelay: params.issue.delegatedToPatchRelay,
13
14
  ...(params.issue.activeRunId !== undefined ? { activeRunId: params.issue.activeRunId } : {}),
14
15
  blockedByKeys,
15
16
  factoryState: params.issue.factoryState,
@@ -40,6 +41,7 @@ export function buildTrackedIssueRecord(params) {
40
41
  id: params.issue.id,
41
42
  projectId: params.issue.projectId,
42
43
  linearIssueId: params.issue.linearIssueId,
44
+ delegatedToPatchRelay: params.issue.delegatedToPatchRelay,
43
45
  ...(params.issue.issueKey ? { issueKey: params.issue.issueKey } : {}),
44
46
  ...(params.issue.title ? { title: params.issue.title } : {}),
45
47
  ...(params.issue.url ? { issueUrl: params.issue.url } : {}),
@@ -56,6 +58,7 @@ export function buildTrackedIssueRecord(params) {
56
58
  readyForExecution: isIssueSessionReadyForExecution({
57
59
  sessionState: params.session?.sessionState,
58
60
  factoryState: params.issue.factoryState,
61
+ delegatedToPatchRelay: params.issue.delegatedToPatchRelay,
59
62
  activeRunId: params.issue.activeRunId,
60
63
  blockedByCount: unresolvedBlockedBy.length,
61
64
  hasPendingWake: params.hasPendingWake,
@@ -1,6 +1,8 @@
1
1
  import { hasOpenPr } from "./pr-state.js";
2
2
  export const PATCHRELAY_WAITING_REASONS = {
3
3
  activeWork: "PatchRelay is actively working",
4
+ automationPaused: "PatchRelay automation is paused because the issue is undelegated",
5
+ automationPausedDownstream: "PatchRelay automation is paused; downstream merge may still continue until the PR is closed",
4
6
  finalizingPublishedPr: "PatchRelay is finalizing a published PR",
5
7
  finalizingMergedChange: "PatchRelay is finalizing a merged change",
6
8
  waitingForOperatorInput: "Waiting on operator input",
@@ -14,6 +16,11 @@ export const PATCHRELAY_WAITING_REASONS = {
14
16
  waitingForExternalReview: "Waiting on external review",
15
17
  };
16
18
  export function derivePatchRelayWaitingReason(params) {
19
+ if (params.delegatedToPatchRelay === false && params.factoryState !== "done" && params.factoryState !== "failed" && params.factoryState !== "escalated") {
20
+ return params.factoryState === "awaiting_queue" || (hasLiveOpenPr(params.prNumber, params.prState) && params.prReviewState === "approved")
21
+ ? PATCHRELAY_WAITING_REASONS.automationPausedDownstream
22
+ : PATCHRELAY_WAITING_REASONS.automationPaused;
23
+ }
17
24
  if (params.activeRunType) {
18
25
  if (hasOpenPr(params.prNumber, params.prState) && (params.factoryState === "pr_open" || params.factoryState === "awaiting_queue")) {
19
26
  return PATCHRELAY_WAITING_REASONS.finalizingPublishedPr;
@@ -78,3 +85,6 @@ export function derivePatchRelayWaitingReason(params) {
78
85
  function humanize(value) {
79
86
  return value.replaceAll("_", " ");
80
87
  }
88
+ function hasLiveOpenPr(prNumber, prState) {
89
+ return prNumber !== undefined && (prState === undefined || prState === "open");
90
+ }
@@ -26,6 +26,7 @@ export class AgentSessionHandler {
26
26
  return;
27
27
  const existingIssue = this.db.issues.getIssue(project.id, normalized.issue.id);
28
28
  const activeRun = existingIssue?.activeRunId ? this.db.runs.getRunById(existingIssue.activeRunId) : undefined;
29
+ const automationEnabled = delegated || existingIssue?.delegatedToPatchRelay === true;
29
30
  if (normalized.triggerEvent === "agentSessionCreated") {
30
31
  if (!delegated) {
31
32
  const latestIssue = this.db.issues.getIssue(project.id, normalized.issue.id);
@@ -70,6 +71,13 @@ export class AgentSessionHandler {
70
71
  if (!triggerEventAllowed(project, normalized.triggerEvent))
71
72
  return;
72
73
  const promptBody = normalized.agentSession.promptBody?.trim();
74
+ if (!automationEnabled && promptBody && existingIssue) {
75
+ await this.publishAgentActivity(linear, normalized.agentSession.id, {
76
+ type: "thought",
77
+ body: "PatchRelay is paused because the issue is undelegated.",
78
+ }, { ephemeral: true });
79
+ return;
80
+ }
73
81
  if (activeRun && promptBody && activeRun.threadId && activeRun.turnId) {
74
82
  const input = `New Linear agent prompt received while you are working.\n\n${promptBody}`;
75
83
  try {
@@ -99,7 +107,7 @@ export class AgentSessionHandler {
99
107
  await this.publishAgentActivity(linear, normalized.agentSession.id, buildPromptDeliveredThought(activeRun.runType), { ephemeral: true });
100
108
  return;
101
109
  }
102
- if (promptBody && existingIssue && (delegated || existingIssue.factoryState === "awaiting_input")) {
110
+ if (promptBody && existingIssue && automationEnabled) {
103
111
  const hadPendingWake = this.db.issueSessions.peekIssueSessionWake(project.id, normalized.issue.id) !== undefined;
104
112
  const directReply = params.isDirectReplyToOutstandingQuestion(existingIssue);
105
113
  this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
@@ -40,6 +40,18 @@ export class CommentWakeHandler {
40
40
  });
41
41
  return;
42
42
  }
43
+ if (!issue.delegatedToPatchRelay) {
44
+ this.feed?.publish({
45
+ level: "info",
46
+ kind: "comment",
47
+ projectId: project.id,
48
+ issueKey: trackedIssue?.issueKey,
49
+ status: "ignored_undelegated",
50
+ summary: "Ignored comment because the issue is undelegated",
51
+ detail: trimmedBody.slice(0, 200),
52
+ });
53
+ return;
54
+ }
43
55
  if (!issue.activeRunId) {
44
56
  if (ENQUEUEABLE_STATES.has(issue.factoryState)) {
45
57
  const directReply = params.isDirectReplyToOutstandingQuestion(issue);
@@ -1,4 +1,5 @@
1
1
  import { TERMINAL_STATES } from "../factory-state.js";
2
+ import { deriveIssueSessionReactiveIntent } from "../issue-session.js";
2
3
  export function decideRunIntent(p) {
3
4
  const wakeEligibleState = p.currentState === undefined
4
5
  || p.currentState === "delegated"
@@ -26,10 +27,50 @@ export function decideUnDelegation(p) {
26
27
  return { clearPending: false };
27
28
  if (!p.currentState)
28
29
  return { clearPending: false };
29
- const pastNoReturn = p.currentState === "awaiting_queue" || TERMINAL_STATES.has(p.currentState);
30
- if (pastNoReturn)
30
+ if (TERMINAL_STATES.has(p.currentState))
31
31
  return { clearPending: false };
32
- return { factoryState: "awaiting_input", clearPending: true };
32
+ return { factoryState: p.currentState, clearPending: true };
33
+ }
34
+ export function resolveReDelegationResume(p) {
35
+ if (!p.delegated || p.previouslyDelegated !== false) {
36
+ return {};
37
+ }
38
+ if (p.prState === "merged") {
39
+ return { factoryState: "done", pendingRunType: null };
40
+ }
41
+ const reactiveIntent = deriveIssueSessionReactiveIntent({
42
+ delegatedToPatchRelay: true,
43
+ prNumber: p.prNumber,
44
+ prState: p.prState,
45
+ prReviewState: p.prReviewState,
46
+ prCheckStatus: p.prCheckStatus,
47
+ latestFailureSource: p.latestFailureSource,
48
+ });
49
+ if (reactiveIntent) {
50
+ return {
51
+ factoryState: reactiveIntent.compatibilityFactoryState,
52
+ pendingRunType: reactiveIntent.runType,
53
+ };
54
+ }
55
+ if (p.prNumber !== undefined && (p.prState === undefined || p.prState === "open")) {
56
+ if (p.prReviewState === "approved") {
57
+ return { factoryState: "awaiting_queue", pendingRunType: null };
58
+ }
59
+ return { factoryState: "pr_open", pendingRunType: null };
60
+ }
61
+ if (p.currentState === "awaiting_input" && p.awaitingInputReason === "completion_check_question") {
62
+ return {
63
+ factoryState: "awaiting_input",
64
+ pendingRunType: null,
65
+ };
66
+ }
67
+ if (p.currentState === "awaiting_input" || p.currentState === "delegated" || p.currentState === "implementing") {
68
+ return {
69
+ factoryState: "delegated",
70
+ pendingRunType: (p.unresolvedBlockers ?? 0) === 0 ? "implementation" : null,
71
+ };
72
+ }
73
+ return {};
33
74
  }
34
75
  export function decideAgentSession(p) {
35
76
  if (p.sessionId)
@@ -27,6 +27,7 @@ export class DependencyReadinessHandler {
27
27
  continue;
28
28
  }
29
29
  if (issue.factoryState !== "delegated"
30
+ || !issue.delegatedToPatchRelay
30
31
  || issue.activeRunId !== undefined
31
32
  || this.db.issueSessions.hasPendingIssueSessionEvents(projectId, dependent.linearIssueId)) {
32
33
  continue;
@@ -1,5 +1,7 @@
1
1
  import { triggerEventAllowed } from "../project-resolution.js";
2
- import { decideActiveRunRelease, decideAgentSession, decideRunIntent, decideUnDelegation, isTerminalDelegationState, mergeIssueMetadata, } from "./decision-helpers.js";
2
+ import { resolveAwaitingInputReason } from "../awaiting-input-reason.js";
3
+ import { decideActiveRunRelease, decideAgentSession, decideRunIntent, decideUnDelegation, isTerminalDelegationState, mergeIssueMetadata, resolveReDelegationResume, } from "./decision-helpers.js";
4
+ import { buildOperatorRetryEvent } from "../operator-retry-event.js";
3
5
  export class DesiredStageRecorder {
4
6
  db;
5
7
  linearProvider;
@@ -16,6 +18,7 @@ export class DesiredStageRecorder {
16
18
  }
17
19
  const existingIssue = this.db.issues.getIssue(params.project.id, normalizedIssue.id);
18
20
  const activeRun = existingIssue?.activeRunId ? this.db.runs.getRunById(existingIssue.activeRunId) : undefined;
21
+ const latestRun = existingIssue ? this.db.runs.getLatestRunForIssue(params.project.id, normalizedIssue.id) : undefined;
19
22
  const delegated = this.isDelegatedToPatchRelay(params.project, params.normalized);
20
23
  const triggerAllowed = triggerEventAllowed(params.project, params.normalized.triggerEvent);
21
24
  const incomingAgentSessionId = params.normalized.agentSession?.id;
@@ -46,11 +49,22 @@ export class DesiredStageRecorder {
46
49
  triggerEvent: params.normalized.triggerEvent,
47
50
  delegated,
48
51
  currentState: existingIssue?.factoryState,
52
+ hasPr: existingIssue?.prNumber !== undefined && existingIssue?.prState !== "merged",
53
+ });
54
+ const reDelegationResume = resolveReDelegationResume({
55
+ delegated,
56
+ previouslyDelegated: existingIssue?.delegatedToPatchRelay,
57
+ currentState: existingIssue?.factoryState,
58
+ awaitingInputReason: existingIssue
59
+ ? resolveAwaitingInputReason({ issue: existingIssue, latestRun })
60
+ : undefined,
61
+ unresolvedBlockers,
62
+ prNumber: existingIssue?.prNumber,
63
+ prState: existingIssue?.prState,
64
+ prReviewState: existingIssue?.prReviewState,
65
+ prCheckStatus: existingIssue?.prCheckStatus,
66
+ latestFailureSource: existingIssue?.lastGitHubFailureSource,
49
67
  });
50
- const delegatedStateRecovery = delegated
51
- && !terminal
52
- && existingIssue?.factoryState === "awaiting_input"
53
- && !undelegation.factoryState;
54
68
  const existingWakeRunType = existingIssue
55
69
  ? params.peekPendingSessionWakeRunType(params.project.id, normalizedIssue.id)
56
70
  : undefined;
@@ -73,9 +87,13 @@ export class DesiredStageRecorder {
73
87
  ...(hydratedIssue.estimate != null ? { estimate: hydratedIssue.estimate } : {}),
74
88
  ...(hydratedIssue.stateName ? { currentLinearState: hydratedIssue.stateName } : {}),
75
89
  ...(hydratedIssue.stateType ? { currentLinearStateType: hydratedIssue.stateType } : {}),
90
+ delegatedToPatchRelay: delegated,
76
91
  ...(!existingIssue && !delegated && incomingAgentSessionId ? { factoryState: "awaiting_input" } : {}),
77
- ...(delegatedStateRecovery ? { factoryState: "delegated" } : {}),
78
- ...(desiredStage ? { pendingRunType: null, pendingRunContextJson: null, factoryState: "delegated" } : {}),
92
+ ...(reDelegationResume.factoryState ? { factoryState: reDelegationResume.factoryState } : {}),
93
+ ...(reDelegationResume.pendingRunType !== undefined
94
+ ? { pendingRunType: null, pendingRunContextJson: null }
95
+ : {}),
96
+ ...(!reDelegationResume.factoryState && desiredStage ? { pendingRunType: null, pendingRunContextJson: null, factoryState: "delegated" } : {}),
79
97
  ...(clearPending ? { pendingRunType: null, pendingRunContextJson: null } : {}),
80
98
  ...(agentSessionId !== undefined ? { agentSessionId } : {}),
81
99
  ...(runRelease.release ? { activeRunId: null } : {}),
@@ -111,12 +129,24 @@ export class DesiredStageRecorder {
111
129
  kind: "stage",
112
130
  issueKey: issue.issueKey,
113
131
  projectId: params.project.id,
114
- stage: "awaiting_input",
132
+ stage: issue.factoryState,
115
133
  status: "un_delegated",
116
- summary: "Issue un-delegated from PatchRelay",
134
+ summary: issue.factoryState === "awaiting_input"
135
+ ? "Issue un-delegated from PatchRelay"
136
+ : `Issue un-delegated from PatchRelay; ${issue.factoryState} is now paused`,
137
+ });
138
+ }
139
+ else if (reDelegationResume.pendingRunType) {
140
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(params.project.id, normalizedIssue.id, {
141
+ projectId: params.project.id,
142
+ linearIssueId: normalizedIssue.id,
143
+ ...buildOperatorRetryEvent(issue, reDelegationResume.pendingRunType, "re_delegated"),
117
144
  });
118
145
  }
119
- else if (desiredStage === "implementation"
146
+ else if (!reDelegationResume.factoryState
147
+ && !reDelegationResume.pendingRunType
148
+ &&
149
+ desiredStage === "implementation"
120
150
  && params.normalized.triggerEvent !== "commentCreated"
121
151
  && params.normalized.triggerEvent !== "commentUpdated"
122
152
  && params.normalized.triggerEvent !== "agentPrompted") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.37.1",
3
+ "version": "0.38.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {