From 84e28178cb551c2612fe65659e252e6b5c7dee9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20S=C3=A1nchez-Mariscal?= Date: Wed, 13 May 2026 21:49:08 +0200 Subject: [PATCH 1/2] Clear stale monitor execution state --- src/worker.ts | 37 +++++++++++++++++++++++++++++-- tests/plugin.spec.ts | 52 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/src/worker.ts b/src/worker.ts index 01d7f32..e0fcc5b 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -8133,8 +8133,30 @@ function isHealthyMaintainerWaitTransition(params: { return nextStatus === 'in_review' && (currentStatus === 'done' || currentStatus === 'in_review') - && syncContext.executionState === null - && syncContext.executionPolicy !== null; + && isClearableMaintainerWaitExecutionState(syncContext.executionState); +} + +function isClearableMaintainerWaitExecutionState( + executionState: PaperclipIssueExecutionState | null +): boolean { + if (executionState === null) { + return true; + } + + if ( + executionState.currentParticipant !== null || + executionState.returnAssignee !== null || + executionState.currentStageId !== null || + executionState.currentStageIndex !== null || + executionState.currentStageType !== null || + executionState.completedStageIds.length > 0 || + executionState.lastDecisionId || + executionState.lastDecisionOutcome + ) { + return false; + } + + return !executionState.status || executionState.status === 'idle' || executionState.status === 'completed'; } function shouldClearCompletedSyncExecutionPolicy(params: { @@ -12967,6 +12989,17 @@ async function updatePaperclipIssueState( if (!payloadResult.failure) { issueUpdated = true; + if ( + issuePatch.executionState === null && + ctx.issues && + typeof ctx.issues.update === 'function' + ) { + await ctx.issues.update( + issueId, + { executionState: null } as PaperclipIssueUpdatePatchWithLabels, + companyId + ); + } } if (payloadResult.failure && (payloadResult.failure.status ?? response.status) !== 404 && (payloadResult.failure.status ?? response.status) !== 405) { diff --git a/tests/plugin.spec.ts b/tests/plugin.spec.ts index 7b004f1..c69e23e 100644 --- a/tests/plugin.spec.ts +++ b/tests/plugin.spec.ts @@ -18834,6 +18834,41 @@ test('worker routes non-review-ready GitHub merge state statuses back to active mergeStateStatus: 'UNKNOWN', expectedStatus: 'in_review' as const, expectedReason: /unknown mergeability/ + }, + { + githubIssueId: 5001, + githubIssueNumber: 50, + pullRequestNumber: 500, + title: 'Triggered monitor wait', + initialStatus: 'in_review' as const, + initialExecutionState: { + status: 'idle', + currentStageId: null, + currentStageIndex: null, + currentStageType: null, + currentParticipant: null, + returnAssignee: null, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: null, + monitor: { + status: 'triggered', + nextCheckAt: null, + lastTriggeredAt: '2026-04-09T09:10:00.000Z', + attemptCount: 1, + notes: 'Check GitHub pull request status.', + scheduledBy: 'board', + kind: 'external_service', + serviceName: 'GitHub', + externalRef: null, + timeoutAt: null, + maxAttempts: null, + recoveryPolicy: null, + clearedAt: null, + clearReason: null + } + }, + expectedStatus: 'in_review' as const } ]; @@ -18846,9 +18881,17 @@ test('worker routes non-review-ready GitHub merge state statuses back to active }); const initialStatus = scenario.initialStatus ?? 'in_review'; - return created.status === initialStatus - ? created - : harness.ctx.issues.update(created.id, { status: initialStatus }, 'company-1'); + return harness.ctx.issues.update( + created.id, + { + ...(created.status === initialStatus ? {} : { status: initialStatus }), + ...(scenario.initialExecutionState ? { + assigneeAgentId: 'agent-1', + executionState: scenario.initialExecutionState + } : {}) + } as never, + 'company-1' + ); }) ); @@ -19010,6 +19053,9 @@ test('worker routes non-review-ready GitHub merge state statuses back to active ); } else { assert.equal(issue?.assigneeAgentId ?? null, null); + if (scenario.initialExecutionState) { + assert.equal(issue?.executionState ?? null, null); + } const transitionComment = statusTransitionComments.find((comment) => comment.issueId === issue?.id); if (scenario.initialStatus === 'done') { assert.match(transitionComment?.body ?? '', /from `done` to `in review`/); From 1510c7375e923160fe2abad78e9382f7e6613ac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20S=C3=A1nchez-Mariscal?= Date: Wed, 13 May 2026 22:06:35 +0200 Subject: [PATCH 2/2] Handle execution state clear fallback --- src/worker.ts | 21 ++++++++++++++++----- tests/plugin.spec.ts | 21 ++++++++++----------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/worker.ts b/src/worker.ts index e0fcc5b..15c6437 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -12994,11 +12994,22 @@ async function updatePaperclipIssueState( ctx.issues && typeof ctx.issues.update === 'function' ) { - await ctx.issues.update( - issueId, - { executionState: null } as PaperclipIssueUpdatePatchWithLabels, - companyId - ); + try { + await ctx.issues.update( + issueId, + { executionState: null } as PaperclipIssueUpdatePatchWithLabels, + companyId + ); + } catch (error) { + issueUpdated = false; + ctx.logger.warn('GitHub sync could not clear Paperclip issue execution state after the local API update. Falling back to direct issue mutation.', { + companyId, + issueId, + paperclipApiBaseUrl, + nextStatus, + error: getErrorMessage(error) + }); + } } } diff --git a/tests/plugin.spec.ts b/tests/plugin.spec.ts index c69e23e..d9ac674 100644 --- a/tests/plugin.spec.ts +++ b/tests/plugin.spec.ts @@ -18881,17 +18881,16 @@ test('worker routes non-review-ready GitHub merge state statuses back to active }); const initialStatus = scenario.initialStatus ?? 'in_review'; - return harness.ctx.issues.update( - created.id, - { - ...(created.status === initialStatus ? {} : { status: initialStatus }), - ...(scenario.initialExecutionState ? { - assigneeAgentId: 'agent-1', - executionState: scenario.initialExecutionState - } : {}) - } as never, - 'company-1' - ); + const patch: Record = { + ...(created.status === initialStatus ? {} : { status: initialStatus }), + ...(scenario.initialExecutionState ? { + assigneeAgentId: 'agent-1', + executionState: scenario.initialExecutionState + } : {}) + }; + return Object.keys(patch).length === 0 + ? created + : harness.ctx.issues.update(created.id, patch as never, 'company-1'); }) );