patchrelay 0.8.4 → 0.8.6

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,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.8.4",
4
- "commit": "57164b7aed73",
5
- "builtAt": "2026-03-19T08:00:15.798Z"
3
+ "version": "0.8.6",
4
+ "commit": "c79c544a7789",
5
+ "builtAt": "2026-03-19T11:44:33.599Z"
6
6
  }
@@ -45,6 +45,19 @@ export class ReconciliationActionApplier {
45
45
  if (decision.outcome === "fail" || decision.outcome === "release") {
46
46
  const failedAction = decision.actions.find((action) => action.type === "mark_run_failed");
47
47
  if (decision.outcome === "release" && failedAction?.type !== "mark_run_failed") {
48
+ const releasedAction = decision.actions.find((action) => action.type === "release_issue_ownership");
49
+ if (releasedAction?.type !== "release_issue_ownership") {
50
+ return;
51
+ }
52
+ await this.callbacks.releaseRunDuringReconciliation(snapshot.runLease.projectId, snapshot.runLease.linearIssueId, {
53
+ runId: snapshot.runLease.id,
54
+ ...(threadId ? { threadId } : {}),
55
+ ...(turnId ? { turnId } : {}),
56
+ ...(nextLifecycleStatus ? { nextLifecycleStatus } : {}),
57
+ ...(snapshot.input.live?.linear?.status === "known" && snapshot.input.live.linear.issue?.stateName
58
+ ? { currentLinearState: snapshot.input.live.linear.issue.stateName }
59
+ : {}),
60
+ });
48
61
  return;
49
62
  }
50
63
  await this.callbacks.failRunDuringReconciliation(snapshot.runLease.projectId, snapshot.runLease.linearIssueId, failedAction?.type === "mark_run_failed" && failedAction.threadId
@@ -68,6 +68,23 @@ export function reconcileIssue(input) {
68
68
  function reconcileActiveRun(params) {
69
69
  const { issue, liveLinear, liveCodex, obligations, policy } = params;
70
70
  const run = issue.activeRun;
71
+ const authoritativeStopState = resolveAuthoritativeStopState(liveLinear);
72
+ if (authoritativeStopState) {
73
+ return {
74
+ outcome: "release",
75
+ reasons: [`live Linear state is already ${authoritativeStopState.stateName}`],
76
+ actions: [
77
+ {
78
+ type: "release_issue_ownership",
79
+ projectId: issue.projectId,
80
+ linearIssueId: issue.linearIssueId,
81
+ runId: run.id,
82
+ nextLifecycleStatus: authoritativeStopState.lifecycleStatus,
83
+ reason: `live Linear state is already ${authoritativeStopState.stateName}`,
84
+ },
85
+ ],
86
+ };
87
+ }
71
88
  if (run.status === "queued") {
72
89
  return {
73
90
  outcome: "launch",
@@ -295,6 +312,27 @@ function matchesActiveLinearOwnership(liveLinear, policy) {
295
312
  }
296
313
  return liveLinear.issue?.stateName === policy.activeLinearStateName;
297
314
  }
315
+ function resolveAuthoritativeStopState(liveLinear) {
316
+ if (liveLinear.status !== "known" || !liveLinear.issue?.stateName) {
317
+ return undefined;
318
+ }
319
+ const stateName = liveLinear.issue.stateName.trim();
320
+ const normalizedName = stateName.toLowerCase();
321
+ const normalizedType = liveLinear.issue.stateType?.trim().toLowerCase();
322
+ if (normalizedType === "completed" || normalizedName === "done" || normalizedName === "completed" || normalizedName === "complete") {
323
+ return {
324
+ stateName,
325
+ lifecycleStatus: "completed",
326
+ };
327
+ }
328
+ if (normalizedName === "human needed") {
329
+ return {
330
+ stateName,
331
+ lifecycleStatus: "paused",
332
+ };
333
+ }
334
+ return undefined;
335
+ }
298
336
  function relevantObligations(issue, obligations) {
299
337
  const activeRunId = issue.activeRun?.id;
300
338
  return obligations.filter((obligation) => {
@@ -22,6 +22,10 @@ export async function buildReconciliationSnapshot(params) {
22
22
  issue: {
23
23
  id: issue.id,
24
24
  stateName: issue.stateName,
25
+ ...(() => {
26
+ const currentState = issue.workflowStates?.find((state) => state.name === issue.stateName);
27
+ return currentState?.type ? { stateType: currentState.type } : {};
28
+ })(),
25
29
  },
26
30
  }
27
31
  : ({ status: "unknown" }))
@@ -36,6 +36,7 @@ export class ServiceStageFinalizer {
36
36
  deliverPendingObligations: (projectId, linearIssueId, threadId, turnId) => this.deliverPendingObligations(projectId, linearIssueId, threadId, turnId),
37
37
  completeRun: (projectId, linearIssueId, thread, params) => this.completeReconciledRun(projectId, linearIssueId, thread, params),
38
38
  failRunDuringReconciliation: (projectId, linearIssueId, threadId, message, options) => this.failRunLeaseDuringReconciliation(projectId, linearIssueId, threadId, message, options),
39
+ releaseRunDuringReconciliation: (projectId, linearIssueId, params) => this.releaseRunDuringReconciliation(projectId, linearIssueId, params),
39
40
  });
40
41
  }
41
42
  async getActiveStageStatus(issueKey) {
@@ -468,13 +469,21 @@ export class ServiceStageFinalizer {
468
469
  if (targetRunLease.workspaceOwnershipId !== undefined) {
469
470
  const workspace = this.stores.workspaceOwnership.getWorkspaceOwnership(targetRunLease.workspaceOwnershipId);
470
471
  if (workspace) {
472
+ const workspaceOwnedByTargetRun = workspace.currentRunLeaseId === targetRunLeaseId ||
473
+ (issueControl?.activeWorkspaceOwnershipId === workspace.id && issueControl.activeRunLeaseId === targetRunLeaseId);
471
474
  this.stores.workspaceOwnership.upsertWorkspaceOwnership({
472
475
  projectId,
473
476
  linearIssueId,
474
477
  branchName: workspace.branchName,
475
478
  worktreePath: workspace.worktreePath,
476
- status: workspace.currentRunLeaseId === targetRunLeaseId ? (status === "completed" ? "active" : "paused") : workspace.status,
477
- ...(workspace.currentRunLeaseId === targetRunLeaseId
479
+ status: workspaceOwnedByTargetRun
480
+ ? status === "released"
481
+ ? "released"
482
+ : status === "completed"
483
+ ? "active"
484
+ : "paused"
485
+ : workspace.status,
486
+ ...(workspaceOwnedByTargetRun
478
487
  ? { currentRunLeaseId: null }
479
488
  : workspace.currentRunLeaseId !== undefined
480
489
  ? { currentRunLeaseId: workspace.currentRunLeaseId }
@@ -489,9 +498,11 @@ export class ServiceStageFinalizer {
489
498
  projectId,
490
499
  linearIssueId,
491
500
  activeRunLeaseId: null,
492
- ...(issueControl.activeWorkspaceOwnershipId !== undefined
493
- ? { activeWorkspaceOwnershipId: issueControl.activeWorkspaceOwnershipId }
494
- : {}),
501
+ ...(status === "released"
502
+ ? { activeWorkspaceOwnershipId: null }
503
+ : issueControl.activeWorkspaceOwnershipId !== undefined
504
+ ? { activeWorkspaceOwnershipId: issueControl.activeWorkspaceOwnershipId }
505
+ : {}),
495
506
  ...(issueControl.serviceOwnedCommentId ? { serviceOwnedCommentId: issueControl.serviceOwnedCommentId } : {}),
496
507
  ...(issueControl.activeAgentSessionId ? { activeAgentSessionId: issueControl.activeAgentSessionId } : {}),
497
508
  lifecycleStatus: params.nextLifecycleStatus,
@@ -623,6 +634,44 @@ export class ServiceStageFinalizer {
623
634
  }
624
635
  await this.failStageRunDuringReconciliation(stageRun, threadId, message, options);
625
636
  }
637
+ async releaseRunDuringReconciliation(projectId, linearIssueId, params) {
638
+ const runId = typeof params.runId === "number" ? params.runId : Number(params.runId);
639
+ if (!Number.isFinite(runId)) {
640
+ return;
641
+ }
642
+ this.runAtomically(() => {
643
+ this.finishLedgerRun(projectId, linearIssueId, "released", {
644
+ stageRunId: runId,
645
+ ...(params.threadId ? { threadId: params.threadId } : {}),
646
+ ...(params.turnId ? { turnId: params.turnId } : {}),
647
+ nextLifecycleStatus: params.nextLifecycleStatus ?? "completed",
648
+ });
649
+ const existingIssue = this.stores.issueWorkflows.getTrackedIssue(projectId, linearIssueId);
650
+ this.stores.workflowCoordinator.upsertTrackedIssue({
651
+ projectId,
652
+ linearIssueId,
653
+ ...(params.currentLinearState
654
+ ? { currentLinearState: params.currentLinearState }
655
+ : existingIssue?.currentLinearState
656
+ ? { currentLinearState: existingIssue.currentLinearState }
657
+ : {}),
658
+ lifecycleStatus: params.nextLifecycleStatus ?? "completed",
659
+ });
660
+ });
661
+ const issue = this.stores.issueWorkflows.getTrackedIssue(projectId, linearIssueId);
662
+ this.feed?.publish({
663
+ level: "info",
664
+ kind: "workflow",
665
+ issueKey: issue?.issueKey,
666
+ projectId,
667
+ ...(issue?.selectedWorkflowId ? { workflowId: issue.selectedWorkflowId } : {}),
668
+ status: params.nextLifecycleStatus === "paused" ? "transition_suppressed" : "completed",
669
+ summary: params.nextLifecycleStatus === "paused"
670
+ ? "Released stale run after terminal Linear pause"
671
+ : "Released stale run after terminal Linear completion",
672
+ detail: params.currentLinearState ? `Live Linear state is already ${params.currentLinearState}.` : undefined,
673
+ });
674
+ }
626
675
  findStageRunForIssue(projectId, linearIssueId, threadId) {
627
676
  return (threadId ? this.stores.issueWorkflows.getStageRunByThreadId(threadId) : undefined) ??
628
677
  this.stores.issueWorkflows.getLatestStageRunForIssue(projectId, linearIssueId);
@@ -39,7 +39,7 @@ export class WebhookDesiredStageRecorder {
39
39
  ...(normalizedIssue.stateName ? { currentLinearState: normalizedIssue.stateName } : {}),
40
40
  ...(selectedWorkflowId !== undefined ? { selectedWorkflowId } : {}),
41
41
  ...(desiredStage ? { desiredStage } : {}),
42
- ...(options?.eventReceiptId !== undefined ? { desiredReceiptId: options.eventReceiptId } : {}),
42
+ ...(desiredStage && options?.eventReceiptId !== undefined ? { desiredReceiptId: options.eventReceiptId } : {}),
43
43
  ...(activeAgentSessionId !== undefined ? { activeAgentSessionId } : {}),
44
44
  lastWebhookAt: new Date().toISOString(),
45
45
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {