patchrelay 0.40.0 → 0.41.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.
@@ -2,24 +2,47 @@ import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
2
2
  import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
3
3
  import { isCompletedLinearState } from "./pr-state.js";
4
4
  import { hasTrustedNoPrCompletion } from "./trusted-no-pr-completion.js";
5
+ const COMPLETION_RECONCILE_WINDOW_MS = 60 * 60 * 1000;
6
+ const COMPLETION_RECONCILE_SUCCESS_BACKOFF_MS = 60 * 60 * 1000;
7
+ const COMPLETION_RECONCILE_FAILURE_BACKOFF_MS = 5 * 60 * 1000;
8
+ const COMPLETION_RECONCILE_RATE_LIMIT_BACKOFF_MS = 30 * 60 * 1000;
9
+ const COMPLETION_RECONCILE_MAX_ISSUES_PER_PASS = 10;
5
10
  export class MergedLinearCompletionReconciler {
6
11
  db;
7
12
  linearProvider;
8
13
  logger;
14
+ retryAfterByIssueKey = new Map();
15
+ globalRetryAfter;
9
16
  constructor(db, linearProvider, logger) {
10
17
  this.db = db;
11
18
  this.linearProvider = linearProvider;
12
19
  this.logger = logger;
13
20
  }
14
21
  async reconcile() {
15
- for (const issue of this.db.issues.listIssues()) {
16
- if (issue.factoryState !== "done" && issue.prState !== "merged") {
22
+ const now = Date.now();
23
+ if (this.globalRetryAfter !== undefined) {
24
+ if (this.globalRetryAfter > now) {
25
+ return;
26
+ }
27
+ this.globalRetryAfter = undefined;
28
+ }
29
+ const candidates = this.db.issues.listIssues()
30
+ .filter((issue) => this.isRecentCompletionCandidate(issue, now))
31
+ .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
32
+ this.pruneRetryBackoff(candidates, now);
33
+ let attemptedIssues = 0;
34
+ for (const issue of candidates) {
35
+ if (attemptedIssues >= COMPLETION_RECONCILE_MAX_ISSUES_PER_PASS) {
36
+ break;
37
+ }
38
+ if (!this.shouldAttemptIssue(issue, now)) {
17
39
  continue;
18
40
  }
19
41
  const linear = await this.linearProvider.forProject(issue.projectId).catch(() => undefined);
20
42
  if (!linear) {
21
43
  continue;
22
44
  }
45
+ attemptedIssues += 1;
23
46
  try {
24
47
  const liveIssue = await linear.getIssue(issue.linearIssueId);
25
48
  this.db.issues.replaceIssueDependencies({
@@ -37,6 +60,7 @@ export class MergedLinearCompletionReconciler {
37
60
  const trustedNoPrDone = hasTrustedNoPrCompletion(issue, latestRun);
38
61
  if (issue.prState === "merged" || trustedNoPrDone) {
39
62
  await this.reconcileCompletedLinearState(issue, liveIssue, linear);
63
+ this.settleIssue(issue, now);
40
64
  continue;
41
65
  }
42
66
  if (issue.factoryState === "done" && !isCompletedLinearState(liveIssue.stateType, liveIssue.stateName)) {
@@ -45,9 +69,15 @@ export class MergedLinearCompletionReconciler {
45
69
  else {
46
70
  this.refreshCachedLinearState(issue, liveIssue);
47
71
  }
72
+ this.settleIssue(issue, now);
48
73
  }
49
74
  catch (error) {
75
+ this.deferIssue(issue, error, now);
50
76
  this.logger.warn({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to reconcile merged or stale completed issue state");
77
+ if (isRateLimitedError(error)) {
78
+ this.globalRetryAfter = now + COMPLETION_RECONCILE_RATE_LIMIT_BACKOFF_MS;
79
+ break;
80
+ }
51
81
  }
52
82
  }
53
83
  }
@@ -92,6 +122,9 @@ export class MergedLinearCompletionReconciler {
92
122
  }, "Reopened stale local done state from live Linear workflow");
93
123
  }
94
124
  refreshCachedLinearState(issue, liveIssue) {
125
+ if (issue.currentLinearState === liveIssue.stateName && issue.currentLinearStateType === liveIssue.stateType) {
126
+ return;
127
+ }
95
128
  this.db.issues.upsertIssue({
96
129
  projectId: issue.projectId,
97
130
  linearIssueId: issue.linearIssueId,
@@ -99,6 +132,55 @@ export class MergedLinearCompletionReconciler {
99
132
  ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
100
133
  });
101
134
  }
135
+ isRecentCompletionCandidate(issue, now) {
136
+ if (issue.factoryState !== "done" && issue.prState !== "merged") {
137
+ return false;
138
+ }
139
+ const updatedAt = Date.parse(issue.updatedAt);
140
+ return Number.isFinite(updatedAt) && now - updatedAt <= COMPLETION_RECONCILE_WINDOW_MS;
141
+ }
142
+ shouldAttemptIssue(issue, now) {
143
+ const retry = this.retryAfterByIssueKey.get(this.issueKey(issue));
144
+ if (!retry) {
145
+ return true;
146
+ }
147
+ if (retry.updatedAt !== issue.updatedAt) {
148
+ this.retryAfterByIssueKey.delete(this.issueKey(issue));
149
+ return true;
150
+ }
151
+ return retry.retryAfter <= now;
152
+ }
153
+ settleIssue(issue, now) {
154
+ this.retryAfterByIssueKey.set(this.issueKey(issue), {
155
+ retryAfter: now + COMPLETION_RECONCILE_SUCCESS_BACKOFF_MS,
156
+ updatedAt: issue.updatedAt,
157
+ });
158
+ }
159
+ deferIssue(issue, error, now) {
160
+ const message = error instanceof Error ? error.message : String(error);
161
+ const backoffMs = /ratelimit|rate limit/i.test(message)
162
+ ? COMPLETION_RECONCILE_RATE_LIMIT_BACKOFF_MS
163
+ : COMPLETION_RECONCILE_FAILURE_BACKOFF_MS;
164
+ this.retryAfterByIssueKey.set(this.issueKey(issue), {
165
+ retryAfter: now + backoffMs,
166
+ updatedAt: issue.updatedAt,
167
+ });
168
+ }
169
+ pruneRetryBackoff(candidates, now) {
170
+ const candidateKeys = new Set(candidates.map((issue) => this.issueKey(issue)));
171
+ for (const [key, retry] of this.retryAfterByIssueKey.entries()) {
172
+ if (!candidateKeys.has(key) || retry.retryAfter <= now) {
173
+ this.retryAfterByIssueKey.delete(key);
174
+ }
175
+ }
176
+ }
177
+ issueKey(issue) {
178
+ return `${issue.projectId}::${issue.linearIssueId}`;
179
+ }
180
+ }
181
+ function isRateLimitedError(error) {
182
+ const message = error instanceof Error ? error.message : String(error);
183
+ return /ratelimit|rate limit/i.test(message);
102
184
  }
103
185
  function resolveOpenWorkflowState(issue) {
104
186
  const reactiveIntent = deriveIssueSessionReactiveIntent({
@@ -3,7 +3,7 @@ export async function readRemotePrState(repoFullName, prNumber) {
3
3
  const { stdout, exitCode } = await execCommand("gh", [
4
4
  "pr", "view", String(prNumber),
5
5
  "--repo", repoFullName,
6
- "--json", "headRefOid,state,reviewDecision,mergeStateStatus",
6
+ "--json", "url,headRefName,headRefOid,isDraft,isCrossRepository,state,author,reviewDecision,mergeable,mergeStateStatus,statusCheckRollup",
7
7
  ], { timeoutMs: 10_000 });
8
8
  if (exitCode !== 0)
9
9
  return undefined;
@@ -1,3 +1,4 @@
1
+ import { appendDelegationObservedEvent, appendRunReleasedAuthorityEvent } from "./delegation-audit.js";
1
2
  import { TERMINAL_STATES } from "./factory-state.js";
2
3
  import { resolveAuthoritativeLinearStopState } from "./linear-workflow.js";
3
4
  import { buildRunFailureActivity } from "./linear-session-reporting.js";
@@ -31,39 +32,40 @@ export class RunReconciler {
31
32
  async reconcile(params) {
32
33
  const { run, issue, recoveryLease } = params;
33
34
  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;
35
+ let effectiveIssue = issue;
36
+ if (!effectiveIssue.delegatedToPatchRelay) {
37
+ const authority = await this.confirmDelegationAuthorityBeforeRelease(run, effectiveIssue);
38
+ effectiveIssue = authority.issue;
39
+ if (authority.released) {
40
+ const pausedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? effectiveIssue;
41
+ void this.linearSync.syncSession(pausedIssue, { activeRunType: run.runType });
42
+ this.releaseLease(run.projectId, run.linearIssueId);
43
+ return;
44
+ }
43
45
  }
44
- if (TERMINAL_STATES.has(issue.factoryState)) {
46
+ if (TERMINAL_STATES.has(effectiveIssue.factoryState)) {
45
47
  this.withHeldLease(run.projectId, run.linearIssueId, () => {
46
48
  this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue reached terminal state during active run" });
47
49
  this.db.issues.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
48
50
  });
49
- this.logger.info({ issueKey: issue.issueKey, runId: run.id, factoryState: issue.factoryState }, "Reconciliation: released run on terminal issue");
50
- const releasedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
51
+ this.logger.info({ issueKey: effectiveIssue.issueKey, runId: run.id, factoryState: effectiveIssue.factoryState }, "Reconciliation: released run on terminal issue");
52
+ const releasedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? effectiveIssue;
51
53
  void this.linearSync.syncSession(releasedIssue, { activeRunType: run.runType });
52
54
  this.releaseLease(run.projectId, run.linearIssueId);
53
55
  return;
54
56
  }
55
57
  if (!run.threadId) {
56
58
  if (recoveryLease === "owned") {
57
- this.logger.debug({ issueKey: issue.issueKey, runId: run.id, runType: run.runType }, "Skipping zombie reconciliation for locally-owned launch that has not created a thread yet");
59
+ this.logger.debug({ issueKey: effectiveIssue.issueKey, runId: run.id, runType: run.runType }, "Skipping zombie reconciliation for locally-owned launch that has not created a thread yet");
58
60
  return;
59
61
  }
60
- this.logger.warn({ issueKey: issue.issueKey, runId: run.id, runType: run.runType }, "Zombie run detected (no thread)");
62
+ this.logger.warn({ issueKey: effectiveIssue.issueKey, runId: run.id, runType: run.runType }, "Zombie run detected (no thread)");
61
63
  this.withHeldLease(run.projectId, run.linearIssueId, () => {
62
64
  this.db.runs.finishRun(run.id, { status: "failed", failureReason: "Zombie: never started (no thread after restart)" });
63
65
  this.db.issues.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
64
66
  });
65
- this.recoverOrEscalate(issue, run.runType, "zombie");
66
- const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
67
+ this.recoverOrEscalate(effectiveIssue, run.runType, "zombie");
68
+ const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? effectiveIssue;
67
69
  void this.linearSync.emitActivity(recoveredIssue, buildRunFailureActivity(run.runType, "The Codex turn never started before PatchRelay restarted."));
68
70
  void this.linearSync.syncSession(recoveredIssue, { activeRunType: run.runType });
69
71
  this.releaseLease(run.projectId, run.linearIssueId);
@@ -74,13 +76,13 @@ export class RunReconciler {
74
76
  thread = await this.readThreadWithRetry(run.threadId);
75
77
  }
76
78
  catch {
77
- this.logger.warn({ issueKey: issue.issueKey, runId: run.id, runType: run.runType, threadId: run.threadId }, "Stale thread during reconciliation");
79
+ this.logger.warn({ issueKey: effectiveIssue.issueKey, runId: run.id, runType: run.runType, threadId: run.threadId }, "Stale thread during reconciliation");
78
80
  this.withHeldLease(run.projectId, run.linearIssueId, () => {
79
81
  this.db.runs.finishRun(run.id, { status: "failed", failureReason: "Stale thread after restart" });
80
82
  this.db.issues.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
81
83
  });
82
- this.recoverOrEscalate(issue, run.runType, "stale_thread");
83
- const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
84
+ this.recoverOrEscalate(effectiveIssue, run.runType, "stale_thread");
85
+ const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? effectiveIssue;
84
86
  void this.linearSync.emitActivity(recoveredIssue, buildRunFailureActivity(run.runType, "PatchRelay lost the active Codex thread after restart and needs to recover."));
85
87
  void this.linearSync.syncSession(recoveredIssue, { activeRunType: run.runType });
86
88
  this.releaseLease(run.projectId, run.linearIssueId);
@@ -111,7 +113,7 @@ export class RunReconciler {
111
113
  status: "reconciled",
112
114
  summary: `Linear state ${stopState.stateName} -> done`,
113
115
  });
114
- const doneIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
116
+ const doneIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? effectiveIssue;
115
117
  void this.linearSync.syncSession(doneIssue, { activeRunType: run.runType });
116
118
  this.releaseLease(run.projectId, run.linearIssueId);
117
119
  return;
@@ -120,14 +122,14 @@ export class RunReconciler {
120
122
  }
121
123
  const latestTurn = getThreadTurns(thread).at(-1);
122
124
  if (latestTurn?.status === "interrupted") {
123
- await this.interruptedRunRecovery.handle(run, issue);
125
+ await this.interruptedRunRecovery.handle(run, effectiveIssue);
124
126
  return;
125
127
  }
126
128
  if (latestTurn?.status === "completed") {
127
129
  await this.runFinalizer.finalizeCompletedRun({
128
130
  source: "reconciliation",
129
131
  run,
130
- issue,
132
+ issue: effectiveIssue,
131
133
  thread,
132
134
  threadId: run.threadId,
133
135
  ...(latestTurn.id ? { completedTurnId: latestTurn.id } : {}),
@@ -139,4 +141,111 @@ export class RunReconciler {
139
141
  this.releaseLease(run.projectId, run.linearIssueId);
140
142
  }
141
143
  }
144
+ async confirmDelegationAuthorityBeforeRelease(run, issue) {
145
+ const installation = this.db.linearInstallations.getLinearInstallationForProject(run.projectId);
146
+ const linear = await this.linearProvider.forProject(run.projectId).catch(() => undefined);
147
+ if (!installation?.actorId || !linear) {
148
+ appendDelegationObservedEvent(this.db, {
149
+ projectId: run.projectId,
150
+ linearIssueId: run.linearIssueId,
151
+ payload: {
152
+ source: "run_reconciler",
153
+ ...(installation?.actorId ? { actorId: installation.actorId } : {}),
154
+ previousDelegatedToPatchRelay: issue.delegatedToPatchRelay,
155
+ observedDelegatedToPatchRelay: issue.delegatedToPatchRelay,
156
+ appliedDelegatedToPatchRelay: issue.delegatedToPatchRelay,
157
+ hydration: "live_linear_failed",
158
+ activeRunId: run.id,
159
+ decision: "none",
160
+ reason: "live_linear_unavailable_before_undelegation_release",
161
+ },
162
+ });
163
+ return { issue, released: false };
164
+ }
165
+ const linearIssue = await linear.getIssue(run.linearIssueId).catch(() => undefined);
166
+ if (!linearIssue) {
167
+ appendDelegationObservedEvent(this.db, {
168
+ projectId: run.projectId,
169
+ linearIssueId: run.linearIssueId,
170
+ payload: {
171
+ source: "run_reconciler",
172
+ actorId: installation.actorId,
173
+ previousDelegatedToPatchRelay: issue.delegatedToPatchRelay,
174
+ observedDelegatedToPatchRelay: issue.delegatedToPatchRelay,
175
+ appliedDelegatedToPatchRelay: issue.delegatedToPatchRelay,
176
+ hydration: "live_linear_failed",
177
+ activeRunId: run.id,
178
+ decision: "none",
179
+ reason: "live_linear_refresh_failed_before_undelegation_release",
180
+ },
181
+ });
182
+ return { issue, released: false };
183
+ }
184
+ const delegated = linearIssue.delegateId === installation.actorId;
185
+ appendDelegationObservedEvent(this.db, {
186
+ projectId: run.projectId,
187
+ linearIssueId: run.linearIssueId,
188
+ payload: {
189
+ source: "run_reconciler",
190
+ actorId: installation.actorId,
191
+ ...(linearIssue.delegateId ? { observedDelegateId: linearIssue.delegateId } : {}),
192
+ previousDelegatedToPatchRelay: issue.delegatedToPatchRelay,
193
+ observedDelegatedToPatchRelay: delegated,
194
+ appliedDelegatedToPatchRelay: delegated,
195
+ hydration: "live_linear",
196
+ activeRunId: run.id,
197
+ decision: delegated ? "resume_issue" : "release_run",
198
+ reason: delegated
199
+ ? "live_linear_confirmed_issue_is_still_delegated"
200
+ : "live_linear_confirmed_issue_is_no_longer_delegated",
201
+ },
202
+ });
203
+ if (delegated) {
204
+ const repairedIssue = this.withHeldLease(run.projectId, run.linearIssueId, () => this.db.issues.upsertIssue({
205
+ projectId: run.projectId,
206
+ linearIssueId: run.linearIssueId,
207
+ delegatedToPatchRelay: true,
208
+ ...(linearIssue.identifier ? { issueKey: linearIssue.identifier } : {}),
209
+ ...(linearIssue.title ? { title: linearIssue.title } : {}),
210
+ ...(linearIssue.description ? { description: linearIssue.description } : {}),
211
+ ...(linearIssue.url ? { url: linearIssue.url } : {}),
212
+ ...(linearIssue.priority != null ? { priority: linearIssue.priority } : {}),
213
+ ...(linearIssue.estimate != null ? { estimate: linearIssue.estimate } : {}),
214
+ ...(linearIssue.stateName ? { currentLinearState: linearIssue.stateName } : {}),
215
+ ...(linearIssue.stateType ? { currentLinearStateType: linearIssue.stateType } : {}),
216
+ })) ?? issue;
217
+ return { issue: repairedIssue, released: false };
218
+ }
219
+ appendRunReleasedAuthorityEvent(this.db, {
220
+ projectId: run.projectId,
221
+ linearIssueId: run.linearIssueId,
222
+ payload: {
223
+ runId: run.id,
224
+ runType: run.runType,
225
+ localDelegatedToPatchRelay: issue.delegatedToPatchRelay,
226
+ liveDelegatedToPatchRelay: delegated,
227
+ source: "run_reconciler",
228
+ reason: "Issue was un-delegated during active run",
229
+ },
230
+ });
231
+ this.withHeldLease(run.projectId, run.linearIssueId, () => {
232
+ this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue was un-delegated during active run" });
233
+ this.db.issues.upsertIssue({
234
+ projectId: run.projectId,
235
+ linearIssueId: run.linearIssueId,
236
+ activeRunId: null,
237
+ factoryState: issue.factoryState,
238
+ delegatedToPatchRelay: false,
239
+ ...(linearIssue.identifier ? { issueKey: linearIssue.identifier } : {}),
240
+ ...(linearIssue.title ? { title: linearIssue.title } : {}),
241
+ ...(linearIssue.description ? { description: linearIssue.description } : {}),
242
+ ...(linearIssue.url ? { url: linearIssue.url } : {}),
243
+ ...(linearIssue.priority != null ? { priority: linearIssue.priority } : {}),
244
+ ...(linearIssue.estimate != null ? { estimate: linearIssue.estimate } : {}),
245
+ ...(linearIssue.stateName ? { currentLinearState: linearIssue.stateName } : {}),
246
+ ...(linearIssue.stateType ? { currentLinearStateType: linearIssue.stateType } : {}),
247
+ });
248
+ });
249
+ return { issue, released: true };
250
+ }
142
251
  }
@@ -30,13 +30,12 @@ export class ServiceRuntime {
30
30
  async start() {
31
31
  try {
32
32
  await this.codex.start();
33
- await this.runReconciler.reconcileActiveRuns();
34
33
  for (const issue of this.readyIssueSource.listIssuesReadyForExecution()) {
35
34
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
36
35
  }
37
36
  this.ready = true;
38
37
  this.startupError = undefined;
39
- this.scheduleBackgroundReconcile();
38
+ void this.runBackgroundReconcile();
40
39
  }
41
40
  catch (error) {
42
41
  this.ready = false;
@@ -1,3 +1,4 @@
1
+ import { appendDelegationObservedEvent } from "./delegation-audit.js";
1
2
  import { isResumablePausedLocalWork } from "./paused-issue-state.js";
2
3
  export class ServiceStartupRecovery {
3
4
  db;
@@ -65,6 +66,24 @@ export class ServiceStartupRecovery {
65
66
  })),
66
67
  });
67
68
  const delegated = liveIssue.delegateId === installation.actorId;
69
+ if (issue.delegatedToPatchRelay !== delegated) {
70
+ appendDelegationObservedEvent(this.db, {
71
+ projectId: issue.projectId,
72
+ linearIssueId: issue.linearIssueId,
73
+ payload: {
74
+ source: "startup_recovery",
75
+ actorId: installation.actorId,
76
+ ...(liveIssue.delegateId ? { observedDelegateId: liveIssue.delegateId } : {}),
77
+ previousDelegatedToPatchRelay: issue.delegatedToPatchRelay,
78
+ observedDelegatedToPatchRelay: delegated,
79
+ appliedDelegatedToPatchRelay: delegated,
80
+ hydration: "live_linear",
81
+ ...(issue.activeRunId !== undefined ? { activeRunId: issue.activeRunId } : {}),
82
+ decision: delegated ? "resume_issue" : "none",
83
+ reason: "startup_recovery_refreshed_linear_delegation",
84
+ },
85
+ });
86
+ }
68
87
  const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
69
88
  const latestRun = this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
70
89
  const shouldRecoverPausedLocalWork = delegated
package/dist/service.js CHANGED
@@ -106,7 +106,10 @@ export class PatchRelayService {
106
106
  }
107
107
  }
108
108
  await this.runtime.start();
109
- await this.startupRecovery.recoverDelegatedIssueStateFromLinear();
109
+ void this.startupRecovery.recoverDelegatedIssueStateFromLinear().catch((error) => {
110
+ const msg = error instanceof Error ? error.message : String(error);
111
+ this.logger.warn({ error: msg }, "Background delegated issue recovery failed");
112
+ });
110
113
  void this.startupRecovery.syncKnownAgentSessions().catch((error) => {
111
114
  const msg = error instanceof Error ? error.message : String(error);
112
115
  this.logger.warn({ error: msg }, "Background agent session sync failed");
@@ -10,7 +10,7 @@ export const PATCHRELAY_WAITING_REASONS = {
10
10
  waitingForReviewOnNewHead: "Waiting on review of a newer pushed head",
11
11
  sameHeadStillBlocked: "Requested changes still block the current head",
12
12
  waitingForMergeStewardRepair: "Waiting to repair a merge-steward incident",
13
- waitingForDownstreamAutomation: "Waiting on downstream review/merge automation",
13
+ waitingForDownstreamAutomation: "PatchRelay work is done; waiting on downstream review/merge automation",
14
14
  workComplete: "PatchRelay work is complete",
15
15
  waitingForOperatorIntervention: "Waiting on operator intervention",
16
16
  waitingForExternalReview: "Waiting on external review",
@@ -38,10 +38,17 @@ export function resolveReDelegationResume(p) {
38
38
  if (p.prState === "merged") {
39
39
  return { factoryState: "done", pendingRunType: null };
40
40
  }
41
+ if (p.prNumber !== undefined && (p.prState === undefined || p.prState === "open") && p.prIsDraft) {
42
+ return {
43
+ factoryState: "delegated",
44
+ pendingRunType: (p.unresolvedBlockers ?? 0) === 0 ? "implementation" : null,
45
+ };
46
+ }
41
47
  const reactiveIntent = deriveIssueSessionReactiveIntent({
42
48
  delegatedToPatchRelay: true,
43
49
  prNumber: p.prNumber,
44
50
  prState: p.prState,
51
+ prIsDraft: p.prIsDraft,
45
52
  prReviewState: p.prReviewState,
46
53
  prCheckStatus: p.prCheckStatus,
47
54
  latestFailureSource: p.latestFailureSource,
@@ -100,6 +107,7 @@ export function mergeIssueMetadata(issue, liveIssue) {
100
107
  ...(issue.identifier ? {} : liveIssue.identifier ? { identifier: liveIssue.identifier } : {}),
101
108
  ...(issue.title ? {} : liveIssue.title ? { title: liveIssue.title } : {}),
102
109
  ...(issue.url ? {} : liveIssue.url ? { url: liveIssue.url } : {}),
110
+ ...(issue.attachments && issue.attachments.length > 0 ? {} : liveIssue.attachments ? { attachments: liveIssue.attachments } : {}),
103
111
  ...(issue.teamId ? {} : liveIssue.teamId ? { teamId: liveIssue.teamId } : {}),
104
112
  ...(issue.teamKey ? {} : liveIssue.teamKey ? { teamKey: liveIssue.teamKey } : {}),
105
113
  ...(issue.stateId ? {} : liveIssue.stateId ? { stateId: liveIssue.stateId } : {}),