patchrelay 0.8.5 → 0.8.7

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.5",
4
- "commit": "1a8f1cf74d1f",
5
- "builtAt": "2026-03-19T10:46:11.106Z"
3
+ "version": "0.8.7",
4
+ "commit": "d054181e45d3",
5
+ "builtAt": "2026-03-19T11:51:02.960Z"
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,
@@ -541,10 +552,27 @@ export class ServiceStageFinalizer {
541
552
  }
542
553
  async restartInterruptedRun(snapshot) {
543
554
  const liveCodex = snapshot.input.live?.codex;
555
+ const liveLinear = snapshot.input.live?.linear;
544
556
  const latestTurn = liveCodex?.status === "found" ? liveCodex.thread?.turns.at(-1) : undefined;
545
557
  if (latestTurn?.status !== "interrupted") {
546
558
  return false;
547
559
  }
560
+ if (liveLinear?.status === "known") {
561
+ const authoritativeStopState = resolveAuthoritativeLinearStopState({
562
+ ...(liveLinear.issue?.stateName ? { stateName: liveLinear.issue.stateName } : {}),
563
+ workflowStates: liveLinear.issue?.stateName
564
+ ? [
565
+ {
566
+ name: liveLinear.issue.stateName,
567
+ ...(liveLinear.issue.stateType ? { type: liveLinear.issue.stateType } : {}),
568
+ },
569
+ ]
570
+ : [],
571
+ });
572
+ if (authoritativeStopState) {
573
+ return false;
574
+ }
575
+ }
548
576
  if (snapshot.runLease.turnId && latestTurn.id !== snapshot.runLease.turnId) {
549
577
  return true;
550
578
  }
@@ -623,6 +651,44 @@ export class ServiceStageFinalizer {
623
651
  }
624
652
  await this.failStageRunDuringReconciliation(stageRun, threadId, message, options);
625
653
  }
654
+ async releaseRunDuringReconciliation(projectId, linearIssueId, params) {
655
+ const runId = typeof params.runId === "number" ? params.runId : Number(params.runId);
656
+ if (!Number.isFinite(runId)) {
657
+ return;
658
+ }
659
+ this.runAtomically(() => {
660
+ this.finishLedgerRun(projectId, linearIssueId, "released", {
661
+ stageRunId: runId,
662
+ ...(params.threadId ? { threadId: params.threadId } : {}),
663
+ ...(params.turnId ? { turnId: params.turnId } : {}),
664
+ nextLifecycleStatus: params.nextLifecycleStatus ?? "completed",
665
+ });
666
+ const existingIssue = this.stores.issueWorkflows.getTrackedIssue(projectId, linearIssueId);
667
+ this.stores.workflowCoordinator.upsertTrackedIssue({
668
+ projectId,
669
+ linearIssueId,
670
+ ...(params.currentLinearState
671
+ ? { currentLinearState: params.currentLinearState }
672
+ : existingIssue?.currentLinearState
673
+ ? { currentLinearState: existingIssue.currentLinearState }
674
+ : {}),
675
+ lifecycleStatus: params.nextLifecycleStatus ?? "completed",
676
+ });
677
+ });
678
+ const issue = this.stores.issueWorkflows.getTrackedIssue(projectId, linearIssueId);
679
+ this.feed?.publish({
680
+ level: "info",
681
+ kind: "workflow",
682
+ issueKey: issue?.issueKey,
683
+ projectId,
684
+ ...(issue?.selectedWorkflowId ? { workflowId: issue.selectedWorkflowId } : {}),
685
+ status: params.nextLifecycleStatus === "paused" ? "transition_suppressed" : "completed",
686
+ summary: params.nextLifecycleStatus === "paused"
687
+ ? "Released stale run after terminal Linear pause"
688
+ : "Released stale run after terminal Linear completion",
689
+ detail: params.currentLinearState ? `Live Linear state is already ${params.currentLinearState}.` : undefined,
690
+ });
691
+ }
626
692
  findStageRunForIssue(projectId, linearIssueId, threadId) {
627
693
  return (threadId ? this.stores.issueWorkflows.getStageRunByThreadId(threadId) : undefined) ??
628
694
  this.stores.issueWorkflows.getLatestStageRunForIssue(projectId, linearIssueId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {