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.
@@ -1,7 +1,11 @@
1
1
  import { triggerEventAllowed } from "../project-resolution.js";
2
2
  import { resolveAwaitingInputReason } from "../awaiting-input-reason.js";
3
+ import { appendDelegationObservedEvent } from "../delegation-audit.js";
3
4
  import { decideActiveRunRelease, decideAgentSession, decideRunIntent, decideUnDelegation, isTerminalDelegationState, mergeIssueMetadata, resolveReDelegationResume, } from "./decision-helpers.js";
4
5
  import { buildOperatorRetryEvent } from "../operator-retry-event.js";
6
+ import { resolveLinkedPullRequest } from "../linear-linked-pr-reconciliation.js";
7
+ import { readRemotePrState } from "../remote-pr-state.js";
8
+ import { deriveLinkedPrAdoptionOutcome } from "../delegation-linked-pr.js";
5
9
  export class DesiredStageRecorder {
6
10
  db;
7
11
  linearProvider;
@@ -25,8 +29,27 @@ export class DesiredStageRecorder {
25
29
  if (!existingIssue && !this.isDelegatedToPatchRelay(params.project, normalizedIssue) && !incomingAgentSessionId) {
26
30
  return { issue: undefined, wakeRunType: undefined, delegated: false };
27
31
  }
28
- const hydratedIssue = await this.syncIssueDependencies(params.project.id, normalizedIssue);
29
- const delegated = this.isDelegatedToPatchRelay(params.project, hydratedIssue);
32
+ const syncResult = await this.syncIssueDependencies(params.project.id, normalizedIssue);
33
+ const hydratedIssue = syncResult.issue;
34
+ const delegation = this.resolveDelegationTruth({
35
+ project: params.project,
36
+ normalizedIssue,
37
+ hydratedIssue,
38
+ existingIssue,
39
+ triggerEvent: params.normalized.triggerEvent,
40
+ webhookId: params.normalized.webhookId,
41
+ actorId: params.normalized.actor?.id,
42
+ hydration: syncResult.hydration,
43
+ activeRunId: activeRun?.id,
44
+ });
45
+ const delegated = delegation.delegated;
46
+ const linkedPrAdoption = await this.resolveLinkedPrAdoption({
47
+ project: params.project,
48
+ issue: hydratedIssue,
49
+ existingIssue,
50
+ delegated,
51
+ triggerEvent: params.normalized.triggerEvent,
52
+ });
30
53
  const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(params.project.id, normalizedIssue.id);
31
54
  const terminal = isTerminalDelegationState(existingIssue, hydratedIssue);
32
55
  const openPrExists = existingIssue?.prNumber !== undefined
@@ -35,16 +58,18 @@ export class DesiredStageRecorder {
35
58
  const blockerPausedImplementation = unresolvedBlockers > 0
36
59
  && activeRun?.runType === "implementation"
37
60
  && !openPrExists;
38
- const desiredStage = decideRunIntent({
39
- delegated,
40
- triggerAllowed,
41
- triggerEvent: params.normalized.triggerEvent,
42
- unresolvedBlockers,
43
- hasActiveRun: Boolean(activeRun),
44
- hasPendingWake,
45
- terminal,
46
- currentState: existingIssue?.factoryState,
47
- });
61
+ const desiredStage = linkedPrAdoption
62
+ ? undefined
63
+ : decideRunIntent({
64
+ delegated,
65
+ triggerAllowed,
66
+ triggerEvent: params.normalized.triggerEvent,
67
+ unresolvedBlockers,
68
+ hasActiveRun: Boolean(activeRun),
69
+ hasPendingWake,
70
+ terminal,
71
+ currentState: existingIssue?.factoryState,
72
+ });
48
73
  const runRelease = decideActiveRunRelease({
49
74
  hasActiveRun: Boolean(activeRun),
50
75
  terminal,
@@ -60,20 +85,31 @@ export class DesiredStageRecorder {
60
85
  currentState: existingIssue?.factoryState,
61
86
  hasPr: existingIssue?.prNumber !== undefined && existingIssue?.prState !== "merged",
62
87
  });
63
- const reDelegationResume = resolveReDelegationResume({
64
- delegated,
65
- previouslyDelegated: existingIssue?.delegatedToPatchRelay,
66
- currentState: existingIssue?.factoryState,
67
- awaitingInputReason: existingIssue
68
- ? resolveAwaitingInputReason({ issue: existingIssue, latestRun })
69
- : undefined,
70
- unresolvedBlockers,
71
- prNumber: existingIssue?.prNumber,
72
- prState: existingIssue?.prState,
73
- prReviewState: existingIssue?.prReviewState,
74
- prCheckStatus: existingIssue?.prCheckStatus,
75
- latestFailureSource: existingIssue?.lastGitHubFailureSource,
76
- });
88
+ const startupResume = linkedPrAdoption
89
+ ? {
90
+ factoryState: linkedPrAdoption.factoryState,
91
+ pendingRunType: linkedPrAdoption.pendingRunType,
92
+ pendingRunContext: linkedPrAdoption.pendingRunContext,
93
+ source: "linked_pr_adoption",
94
+ }
95
+ : {
96
+ ...resolveReDelegationResume({
97
+ delegated,
98
+ previouslyDelegated: existingIssue?.delegatedToPatchRelay,
99
+ currentState: existingIssue?.factoryState,
100
+ awaitingInputReason: existingIssue
101
+ ? resolveAwaitingInputReason({ issue: existingIssue, latestRun })
102
+ : undefined,
103
+ unresolvedBlockers,
104
+ prNumber: existingIssue?.prNumber,
105
+ prState: existingIssue?.prState,
106
+ prIsDraft: existingIssue?.prIsDraft,
107
+ prReviewState: existingIssue?.prReviewState,
108
+ prCheckStatus: existingIssue?.prCheckStatus,
109
+ latestFailureSource: existingIssue?.lastGitHubFailureSource,
110
+ }),
111
+ source: "re_delegated",
112
+ };
77
113
  const existingWakeRunType = existingIssue
78
114
  ? params.peekPendingSessionWakeRunType(params.project.id, normalizedIssue.id)
79
115
  : undefined;
@@ -96,13 +132,19 @@ export class DesiredStageRecorder {
96
132
  ...(hydratedIssue.estimate != null ? { estimate: hydratedIssue.estimate } : {}),
97
133
  ...(hydratedIssue.stateName ? { currentLinearState: hydratedIssue.stateName } : {}),
98
134
  ...(hydratedIssue.stateType ? { currentLinearStateType: hydratedIssue.stateType } : {}),
135
+ ...(linkedPrAdoption?.issueUpdates ?? {}),
99
136
  delegatedToPatchRelay: delegated,
100
137
  ...(!existingIssue && !delegated && incomingAgentSessionId ? { factoryState: "awaiting_input" } : {}),
101
- ...(reDelegationResume.factoryState ? { factoryState: reDelegationResume.factoryState } : {}),
102
- ...(reDelegationResume.pendingRunType !== undefined
103
- ? { pendingRunType: null, pendingRunContextJson: null }
138
+ ...(startupResume.factoryState ? { factoryState: startupResume.factoryState } : {}),
139
+ ...(startupResume.pendingRunType !== undefined
140
+ ? {
141
+ pendingRunType: null,
142
+ pendingRunContextJson: startupResume.pendingRunContext
143
+ ? JSON.stringify(startupResume.pendingRunContext)
144
+ : null,
145
+ }
104
146
  : {}),
105
- ...(!reDelegationResume.factoryState && desiredStage ? { pendingRunType: null, pendingRunContextJson: null, factoryState: "delegated" } : {}),
147
+ ...(!startupResume.factoryState && desiredStage ? { pendingRunType: null, pendingRunContextJson: null, factoryState: "delegated" } : {}),
106
148
  ...(clearPending ? { pendingRunType: null, pendingRunContextJson: null } : {}),
107
149
  ...(agentSessionId !== undefined ? { agentSessionId } : {}),
108
150
  ...(effectiveRunRelease.release ? { activeRunId: null } : {}),
@@ -162,15 +204,15 @@ export class DesiredStageRecorder {
162
204
  summary: `Implementation paused because ${issue.issueKey ?? normalizedIssue.id} is now blocked`,
163
205
  });
164
206
  }
165
- else if (reDelegationResume.pendingRunType) {
207
+ else if (startupResume.pendingRunType) {
166
208
  this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(params.project.id, normalizedIssue.id, {
167
209
  projectId: params.project.id,
168
210
  linearIssueId: normalizedIssue.id,
169
- ...buildOperatorRetryEvent(issue, reDelegationResume.pendingRunType, "re_delegated"),
211
+ ...buildOperatorRetryEvent(issue, startupResume.pendingRunType, startupResume.source),
170
212
  });
171
213
  }
172
- else if (!reDelegationResume.factoryState
173
- && !reDelegationResume.pendingRunType
214
+ else if (!startupResume.factoryState
215
+ && !startupResume.pendingRunType
174
216
  &&
175
217
  desiredStage === "implementation"
176
218
  && params.normalized.triggerEvent !== "commentCreated"
@@ -200,16 +242,56 @@ export class DesiredStageRecorder {
200
242
  return false;
201
243
  return issue.delegateId === installation.actorId;
202
244
  }
245
+ resolveDelegationTruth(params) {
246
+ const previousDelegated = params.existingIssue?.delegatedToPatchRelay;
247
+ const observedDelegated = this.isDelegatedToPatchRelay(params.project, params.hydratedIssue);
248
+ const explicitDelegateSignal = params.triggerEvent === "delegateChanged";
249
+ const hasObservedDelegate = params.hydratedIssue.delegateId !== undefined;
250
+ let delegated = observedDelegated;
251
+ let reason = hasObservedDelegate
252
+ ? "delegate_id_present"
253
+ : `missing_delegate_identity_after_${params.hydration}`;
254
+ if (!hasObservedDelegate && !explicitDelegateSignal && previousDelegated !== undefined) {
255
+ delegated = previousDelegated;
256
+ reason = `preserved_previous_delegation_after_${params.hydration}`;
257
+ }
258
+ if (previousDelegated !== delegated
259
+ || params.hydration === "live_linear_failed"
260
+ || (!hasObservedDelegate && previousDelegated !== undefined)) {
261
+ appendDelegationObservedEvent(this.db, {
262
+ projectId: params.project.id,
263
+ linearIssueId: params.normalizedIssue.id,
264
+ payload: {
265
+ source: "linear_webhook",
266
+ webhookId: params.webhookId,
267
+ triggerEvent: params.triggerEvent,
268
+ ...(params.actorId ? { actorId: params.actorId } : {}),
269
+ ...(params.hydratedIssue.delegateId ? { observedDelegateId: params.hydratedIssue.delegateId } : {}),
270
+ ...(previousDelegated !== undefined ? { previousDelegatedToPatchRelay: previousDelegated } : {}),
271
+ observedDelegatedToPatchRelay: observedDelegated,
272
+ appliedDelegatedToPatchRelay: delegated,
273
+ hydration: params.hydration,
274
+ ...(params.activeRunId !== undefined ? { activeRunId: params.activeRunId } : {}),
275
+ decision: "none",
276
+ reason,
277
+ },
278
+ });
279
+ }
280
+ return { delegated };
281
+ }
203
282
  async syncIssueDependencies(projectId, issue) {
204
283
  let source = issue;
284
+ let hydration = "webhook_only";
205
285
  if (!source.relationsKnown) {
206
286
  const linear = await this.linearProvider.forProject(projectId);
207
287
  if (linear) {
208
288
  try {
209
289
  source = mergeIssueMetadata(source, await linear.getIssue(issue.id));
290
+ hydration = "live_linear";
210
291
  }
211
292
  catch {
212
293
  // Preserve existing dependency rows when webhook relation data is incomplete.
294
+ hydration = "live_linear_failed";
213
295
  }
214
296
  }
215
297
  }
@@ -226,6 +308,38 @@ export class DesiredStageRecorder {
226
308
  })),
227
309
  });
228
310
  }
229
- return source;
311
+ return { issue: source, hydration };
312
+ }
313
+ async resolveLinkedPrAdoption(params) {
314
+ if (!params.delegated)
315
+ return undefined;
316
+ if (params.triggerEvent !== "delegateChanged")
317
+ return undefined;
318
+ if (params.existingIssue?.prNumber !== undefined)
319
+ return undefined;
320
+ const resolution = resolveLinkedPullRequest(params.issue.attachments, params.project.github?.repoFullName);
321
+ if (resolution.kind === "none")
322
+ return undefined;
323
+ if (resolution.kind === "ambiguous") {
324
+ return {
325
+ factoryState: "awaiting_input",
326
+ pendingRunType: null,
327
+ pendingRunContext: undefined,
328
+ issueUpdates: {},
329
+ };
330
+ }
331
+ const remote = await readRemotePrState(resolution.reference.repoFullName, resolution.reference.prNumber);
332
+ if (!remote) {
333
+ return {
334
+ factoryState: "awaiting_input",
335
+ pendingRunType: null,
336
+ pendingRunContext: undefined,
337
+ issueUpdates: {
338
+ prNumber: resolution.reference.prNumber,
339
+ prUrl: resolution.reference.url,
340
+ },
341
+ };
342
+ }
343
+ return deriveLinkedPrAdoptionOutcome(params.project, resolution.reference.prNumber, remote);
230
344
  }
231
345
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {