patchrelay 0.8.2 → 0.8.4

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.2",
4
- "commit": "bae4a3fbeb97",
5
- "builtAt": "2026-03-18T21:53:19.259Z"
3
+ "version": "0.8.4",
4
+ "commit": "57164b7aed73",
5
+ "builtAt": "2026-03-19T08:00:15.798Z"
6
6
  }
@@ -200,7 +200,7 @@ export class IssueWorkflowCoordinator {
200
200
  });
201
201
  }
202
202
  const workspace = this.authoritativeLedger.getWorkspaceOwnership(stageRun.workspaceId);
203
- if (workspace) {
203
+ if (workspace && workspace.currentRunLeaseId === params.stageRunId) {
204
204
  this.authoritativeLedger.upsertWorkspaceOwnership({
205
205
  projectId: stageRun.projectId,
206
206
  linearIssueId: stageRun.linearIssueId,
@@ -1,11 +1,53 @@
1
1
  import { resolveWorkflowStageConfig } from "./workflow-policy.js";
2
2
  const STATUS_MARKER = "<!-- patchrelay:status-comment -->";
3
+ function normalizeLinearState(value) {
4
+ const trimmed = value?.trim();
5
+ return trimmed ? trimmed.toLowerCase() : undefined;
6
+ }
3
7
  export function resolveActiveLinearState(project, stage, workflowDefinitionId) {
4
8
  return resolveWorkflowStageConfig(project, stage, workflowDefinitionId)?.activeState;
5
9
  }
6
10
  export function resolveFallbackLinearState(project, stage, workflowDefinitionId) {
7
11
  return resolveWorkflowStageConfig(project, stage, workflowDefinitionId)?.fallbackState;
8
12
  }
13
+ export function resolveDoneLinearState(issue) {
14
+ const typedMatch = issue.workflowStates.find((state) => normalizeLinearState(state.type) === "completed");
15
+ if (typedMatch?.name) {
16
+ return typedMatch.name;
17
+ }
18
+ const nameMatch = issue.workflowStates.find((state) => {
19
+ const normalized = normalizeLinearState(state.name);
20
+ return normalized === "done" || normalized === "completed" || normalized === "complete";
21
+ });
22
+ return nameMatch?.name;
23
+ }
24
+ export function resolveAuthoritativeLinearStopState(issue) {
25
+ const currentStateName = issue.stateName?.trim();
26
+ const normalizedCurrentState = normalizeLinearState(currentStateName);
27
+ if (!currentStateName || !normalizedCurrentState) {
28
+ return undefined;
29
+ }
30
+ const currentWorkflowState = issue.workflowStates.find((state) => normalizeLinearState(state.name) === normalizedCurrentState);
31
+ if (normalizeLinearState(currentWorkflowState?.type) === "completed") {
32
+ return {
33
+ stateName: currentWorkflowState?.name ?? currentStateName,
34
+ lifecycleStatus: "completed",
35
+ };
36
+ }
37
+ if (normalizedCurrentState === "human needed") {
38
+ return {
39
+ stateName: currentStateName,
40
+ lifecycleStatus: "paused",
41
+ };
42
+ }
43
+ if (normalizedCurrentState === "done" || normalizedCurrentState === "completed" || normalizedCurrentState === "complete") {
44
+ return {
45
+ stateName: currentStateName,
46
+ lifecycleStatus: "completed",
47
+ };
48
+ }
49
+ return undefined;
50
+ }
9
51
  export function buildRunningStatusComment(params) {
10
52
  return [
11
53
  STATUS_MARKER,
@@ -3,12 +3,11 @@ import { reconcileIssue } from "./reconciliation-engine.js";
3
3
  import { buildReconciliationSnapshot } from "./reconciliation-snapshot-builder.js";
4
4
  import { syncFailedStageToLinear } from "./stage-failure.js";
5
5
  import { parseStageHandoff } from "./stage-handoff.js";
6
- import { resolveFallbackLinearState } from "./linear-workflow.js";
6
+ import { resolveAuthoritativeLinearStopState, resolveDoneLinearState, resolveFallbackLinearState } from "./linear-workflow.js";
7
7
  import { resolveDefaultTransitionTarget, transitionTargetAllowed } from "./workflow-policy.js";
8
8
  import { buildFailedStageReport, buildPendingMaterializationThread, buildStageReport, countEventMethods, extractStageSummary, extractTurnId, resolveStageRunStatus, summarizeCurrentThread, } from "./stage-reporting.js";
9
9
  import { StageLifecyclePublisher } from "./stage-lifecycle-publisher.js";
10
10
  import { StageTurnInputDispatcher } from "./stage-turn-input-dispatcher.js";
11
- const MAX_AUTOMATIC_TRANSITION_ATTEMPTS = 3;
12
11
  export class ServiceStageFinalizer {
13
12
  config;
14
13
  stores;
@@ -153,6 +152,7 @@ export class ServiceStageFinalizer {
153
152
  const report = buildStageReport(finalizedStageRun, issue, thread, countEventMethods(this.stores.stageEvents.listThreadEvents(stageRun.id)));
154
153
  this.runAtomically(() => {
155
154
  this.finishLedgerRun(stageRun.projectId, stageRun.linearIssueId, "completed", {
155
+ stageRunId: stageRun.id,
156
156
  threadId: params.threadId,
157
157
  ...(params.turnId ? { turnId: params.turnId } : {}),
158
158
  nextLifecycleStatus: params.nextLifecycleStatus ?? (issue.desiredStage ? "queued" : "completed"),
@@ -171,6 +171,7 @@ export class ServiceStageFinalizer {
171
171
  failStageRun(stageRun, threadId, message, options) {
172
172
  this.runAtomically(() => {
173
173
  this.finishLedgerRun(stageRun.projectId, stageRun.linearIssueId, "failed", {
174
+ stageRunId: stageRun.id,
174
175
  threadId,
175
176
  ...(options?.turnId ? { turnId: options.turnId } : {}),
176
177
  failureReason: message,
@@ -240,7 +241,7 @@ export class ServiceStageFinalizer {
240
241
  }
241
242
  async maybeQueueAutomaticTransition(stageRun, report) {
242
243
  const refreshedIssue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
243
- if (!refreshedIssue || refreshedIssue.desiredStage) {
244
+ if (!refreshedIssue) {
244
245
  return;
245
246
  }
246
247
  const project = this.config.projects.find((candidate) => candidate.id === stageRun.projectId);
@@ -259,6 +260,14 @@ export class ServiceStageFinalizer {
259
260
  if (!linearIssue) {
260
261
  return;
261
262
  }
263
+ const authoritativeStopState = resolveAuthoritativeLinearStopState(linearIssue);
264
+ if (authoritativeStopState) {
265
+ this.syncIssueToAuthoritativeLinearStopState(stageRun, refreshedIssue, authoritativeStopState);
266
+ return;
267
+ }
268
+ if (refreshedIssue.desiredStage) {
269
+ return;
270
+ }
262
271
  const continuationPrecondition = await this.checkAutomaticContinuationPreconditions(stageRun, refreshedIssue, linear, linearIssue);
263
272
  if (!continuationPrecondition.allowed) {
264
273
  this.feed?.publish({
@@ -315,9 +324,19 @@ export class ServiceStageFinalizer {
315
324
  await this.routeStageToHumanNeeded(project, stageRun, linearIssue, `PatchRelay received ${nextTarget} as the next stage again and needs a human to confirm the intended loop.`);
316
325
  return;
317
326
  }
318
- const priorAttempts = this.countPriorTransitionAttempts(stageRun.projectId, stageRun.linearIssueId, stageRun.stage, nextTarget);
319
- if (priorAttempts >= MAX_AUTOMATIC_TRANSITION_ATTEMPTS) {
320
- await this.routeStageToHumanNeeded(project, stageRun, linearIssue, `PatchRelay hit the automatic continuation limit for ${stageRun.stage} -> ${nextTarget}.`);
327
+ if (this.isTransitionAlreadyInFlight(stageRun, nextTarget)) {
328
+ this.feed?.publish({
329
+ level: "info",
330
+ kind: "workflow",
331
+ issueKey: refreshedIssue.issueKey,
332
+ projectId: stageRun.projectId,
333
+ stage: stageRun.stage,
334
+ ...(refreshedIssue.selectedWorkflowId ? { workflowId: refreshedIssue.selectedWorkflowId } : {}),
335
+ nextStage: nextTarget,
336
+ status: "transition_in_progress",
337
+ summary: `${nextTarget} is already queued or running`,
338
+ detail: `PatchRelay kept ${stageRun.stage} completion from re-queueing ${nextTarget}.`,
339
+ });
321
340
  return;
322
341
  }
323
342
  this.feed?.publish({
@@ -337,6 +356,32 @@ export class ServiceStageFinalizer {
337
356
  lifecycleStatus: "queued",
338
357
  });
339
358
  }
359
+ syncIssueToAuthoritativeLinearStopState(stageRun, issue, stopState) {
360
+ this.stores.workflowCoordinator.setIssueDesiredStage(stageRun.projectId, stageRun.linearIssueId, undefined, {
361
+ lifecycleStatus: stopState.lifecycleStatus,
362
+ });
363
+ this.stores.workflowCoordinator.upsertTrackedIssue({
364
+ projectId: stageRun.projectId,
365
+ linearIssueId: stageRun.linearIssueId,
366
+ currentLinearState: stopState.stateName,
367
+ lifecycleStatus: stopState.lifecycleStatus,
368
+ });
369
+ this.feed?.publish({
370
+ level: "info",
371
+ kind: "workflow",
372
+ issueKey: issue.issueKey,
373
+ projectId: stageRun.projectId,
374
+ stage: stageRun.stage,
375
+ ...(issue.selectedWorkflowId ? { workflowId: issue.selectedWorkflowId } : {}),
376
+ status: stopState.lifecycleStatus === "completed" ? "completed" : "transition_suppressed",
377
+ summary: stopState.lifecycleStatus === "completed"
378
+ ? `Kept workflow completed after ${stageRun.stage}`
379
+ : `Ignored stale ${stageRun.stage} completion`,
380
+ detail: stopState.lifecycleStatus === "completed"
381
+ ? `Live Linear state is already ${stopState.stateName}, so PatchRelay kept the issue finished.`
382
+ : `Live Linear state is already ${stopState.stateName}, so PatchRelay kept the issue paused.`,
383
+ });
384
+ }
340
385
  async checkAutomaticContinuationPreconditions(stageRun, issue, linear, linearIssue) {
341
386
  const actorProfile = await linear.getActorProfile().catch(() => undefined);
342
387
  if (actorProfile?.actorId && linearIssue.delegateId && linearIssue.delegateId !== actorProfile.actorId) {
@@ -360,19 +405,16 @@ export class ServiceStageFinalizer {
360
405
  }
361
406
  return resolveDefaultTransitionTarget(project, stageRun.stage, workflowDefinitionId) ?? "human_needed";
362
407
  }
363
- countPriorTransitionAttempts(projectId, linearIssueId, currentStage, nextTarget) {
364
- const stageHistory = this.stores.issueWorkflows
365
- .listStageRunsForIssue(projectId, linearIssueId)
366
- .filter((stageRun) => stageRun.status === "completed");
367
- let count = 0;
368
- for (let index = 0; index < stageHistory.length - 1; index += 1) {
369
- const current = stageHistory[index];
370
- const next = stageHistory[index + 1];
371
- if (current?.stage === currentStage && next?.stage === nextTarget) {
372
- count += 1;
373
- }
408
+ isTransitionAlreadyInFlight(stageRun, nextTarget) {
409
+ const refreshedIssue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
410
+ if (!refreshedIssue) {
411
+ return false;
374
412
  }
375
- return count;
413
+ if (refreshedIssue.desiredStage === nextTarget) {
414
+ return true;
415
+ }
416
+ const activeStageRun = this.resolveActiveStageRun(refreshedIssue);
417
+ return activeStageRun !== undefined && activeStageRun.id !== stageRun.id && activeStageRun.stage === nextTarget;
376
418
  }
377
419
  async routeStageToHumanNeeded(project, stageRun, linearIssue, reason) {
378
420
  const linear = await this.linearProvider.forProject(stageRun.projectId);
@@ -408,29 +450,41 @@ export class ServiceStageFinalizer {
408
450
  }
409
451
  finishLedgerRun(projectId, linearIssueId, status, params) {
410
452
  const issueControl = this.stores.issueControl.getIssueControl(projectId, linearIssueId);
411
- if (!issueControl?.activeRunLeaseId) {
453
+ const targetRunLeaseId = params.stageRunId ?? issueControl?.activeRunLeaseId;
454
+ if (!targetRunLeaseId) {
455
+ return;
456
+ }
457
+ const targetRunLease = this.stores.runLeases.getRunLease(targetRunLeaseId);
458
+ if (!targetRunLease) {
412
459
  return;
413
460
  }
414
461
  this.stores.runLeases.finishRunLease({
415
- runLeaseId: issueControl.activeRunLeaseId,
462
+ runLeaseId: targetRunLeaseId,
416
463
  status,
417
464
  ...(params.threadId ? { threadId: params.threadId } : {}),
418
465
  ...(params.turnId ? { turnId: params.turnId } : {}),
419
466
  ...(params.failureReason ? { failureReason: params.failureReason } : {}),
420
467
  });
421
- if (issueControl.activeWorkspaceOwnershipId !== undefined) {
422
- const workspace = this.stores.workspaceOwnership.getWorkspaceOwnership(issueControl.activeWorkspaceOwnershipId);
468
+ if (targetRunLease.workspaceOwnershipId !== undefined) {
469
+ const workspace = this.stores.workspaceOwnership.getWorkspaceOwnership(targetRunLease.workspaceOwnershipId);
423
470
  if (workspace) {
424
471
  this.stores.workspaceOwnership.upsertWorkspaceOwnership({
425
472
  projectId,
426
473
  linearIssueId,
427
474
  branchName: workspace.branchName,
428
475
  worktreePath: workspace.worktreePath,
429
- status: status === "completed" ? "active" : "paused",
430
- currentRunLeaseId: null,
476
+ status: workspace.currentRunLeaseId === targetRunLeaseId ? (status === "completed" ? "active" : "paused") : workspace.status,
477
+ ...(workspace.currentRunLeaseId === targetRunLeaseId
478
+ ? { currentRunLeaseId: null }
479
+ : workspace.currentRunLeaseId !== undefined
480
+ ? { currentRunLeaseId: workspace.currentRunLeaseId }
481
+ : {}),
431
482
  });
432
483
  }
433
484
  }
485
+ if (!issueControl?.activeRunLeaseId || issueControl.activeRunLeaseId !== targetRunLeaseId) {
486
+ return;
487
+ }
434
488
  this.stores.issueControl.upsertIssueControl({
435
489
  projectId,
436
490
  linearIssueId,
@@ -631,14 +685,3 @@ function normalizeLinearState(value) {
631
685
  const trimmed = value?.trim();
632
686
  return trimmed ? trimmed.toLowerCase() : undefined;
633
687
  }
634
- function resolveDoneLinearState(issue) {
635
- const typedMatch = issue.workflowStates.find((state) => normalizeLinearState(state.type) === "completed");
636
- if (typedMatch?.name) {
637
- return typedMatch.name;
638
- }
639
- const nameMatch = issue.workflowStates.find((state) => {
640
- const normalized = normalizeLinearState(state.name);
641
- return normalized === "done" || normalized === "completed" || normalized === "complete";
642
- });
643
- return nameMatch?.name;
644
- }
@@ -1,3 +1,4 @@
1
+ import { resolveAuthoritativeLinearStopState } from "./linear-workflow.js";
1
2
  import { buildStageLaunchPlan, isCodexThreadId } from "./stage-launch.js";
2
3
  import { syncFailedStageToLinear } from "./stage-failure.js";
3
4
  import { buildFailedStageReport } from "./stage-reporting.js";
@@ -47,6 +48,22 @@ export class ServiceStageRunner {
47
48
  if (!issue) {
48
49
  return;
49
50
  }
51
+ if (issue.lifecycleStatus === "completed" || issue.lifecycleStatus === "paused") {
52
+ this.feed?.publish({
53
+ level: "info",
54
+ kind: "workflow",
55
+ issueKey: issue.issueKey,
56
+ projectId: item.projectId,
57
+ stage: desiredStage,
58
+ ...(issue.selectedWorkflowId ? { workflowId: issue.selectedWorkflowId } : {}),
59
+ status: issue.lifecycleStatus === "completed" ? "completed" : "transition_suppressed",
60
+ summary: issue.lifecycleStatus === "completed"
61
+ ? `Skipped ${desiredStage} because the issue is already complete`
62
+ : `Skipped ${desiredStage} because the issue is already paused`,
63
+ detail: issue.currentLinearState ? `Live Linear state is ${issue.currentLinearState}.` : undefined,
64
+ });
65
+ return;
66
+ }
50
67
  const existingWorkspace = this.stores.workspaceOwnership.getWorkspaceOwnershipForIssue(item.projectId, item.issueId);
51
68
  const stageHistory = this.stores.issueWorkflows.listStageRunsForIssue(item.projectId, item.issueId);
52
69
  const previousStageRun = stageHistory.at(-1);
@@ -201,13 +218,28 @@ export class ServiceStageRunner {
201
218
  }
202
219
  async ensureLaunchIssueMirror(project, linearIssueId, _desiredStage, _desiredWebhookId) {
203
220
  const existing = this.stores.issueWorkflows.getTrackedIssue(project.id, linearIssueId);
204
- if (existing?.issueKey && existing.title && existing.issueUrl && existing.currentLinearState) {
205
- return existing;
206
- }
207
221
  const liveIssue = await this.linearProvider
208
222
  .forProject(project.id)
209
223
  .then((linear) => linear?.getIssue(linearIssueId))
210
224
  .catch(() => undefined);
225
+ const authoritativeStopState = liveIssue ? resolveAuthoritativeLinearStopState(liveIssue) : undefined;
226
+ if (authoritativeStopState) {
227
+ this.stores.workflowCoordinator.setIssueDesiredStage(project.id, linearIssueId, undefined, {
228
+ lifecycleStatus: authoritativeStopState.lifecycleStatus,
229
+ });
230
+ return this.stores.workflowCoordinator.upsertTrackedIssue({
231
+ projectId: project.id,
232
+ linearIssueId,
233
+ ...(liveIssue?.identifier ? { issueKey: liveIssue.identifier } : existing?.issueKey ? { issueKey: existing.issueKey } : {}),
234
+ ...(liveIssue?.title ? { title: liveIssue.title } : existing?.title ? { title: existing.title } : {}),
235
+ ...(liveIssue?.url ? { issueUrl: liveIssue.url } : existing?.issueUrl ? { issueUrl: existing.issueUrl } : {}),
236
+ currentLinearState: authoritativeStopState.stateName,
237
+ lifecycleStatus: authoritativeStopState.lifecycleStatus,
238
+ });
239
+ }
240
+ if (!liveIssue && existing?.issueKey && existing.title && existing.issueUrl && existing.currentLinearState) {
241
+ return existing;
242
+ }
211
243
  return this.stores.workflowCoordinator.recordDesiredStage({
212
244
  projectId: project.id,
213
245
  linearIssueId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {