patchrelay 0.38.0 → 0.38.2
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.
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +4 -0
- package/dist/cli/commands/issues.js +20 -1
- package/dist/cli/data.js +54 -7
- package/dist/cli/formatters/text.js +10 -0
- package/dist/cli/help.js +4 -0
- package/dist/cli/index.js +3 -0
- package/dist/config.js +26 -0
- package/dist/db/issue-store.js +10 -2
- package/dist/db/migrations.js +5 -0
- package/dist/factory-state.js +1 -0
- package/dist/github-linear-session-sync.js +57 -0
- package/dist/github-pr-comment-handler.js +74 -0
- package/dist/github-webhook-failure-context.js +70 -0
- package/dist/github-webhook-handler.js +52 -975
- package/dist/github-webhook-issue-resolution.js +46 -0
- package/dist/github-webhook-late-publication-guard.js +94 -0
- package/dist/github-webhook-policy.js +105 -0
- package/dist/github-webhook-reactive-run.js +302 -0
- package/dist/github-webhook-state-projector.js +245 -0
- package/dist/github-webhook-terminal-handler.js +111 -0
- package/dist/github-webhooks.js +39 -4
- package/dist/http.js +17 -0
- package/dist/idle-reconciliation.js +4 -2
- package/dist/issue-overview-query.js +8 -57
- package/dist/issue-session-events.js +1 -0
- package/dist/legacy-issue-overview.js +58 -0
- package/dist/linear-activity-key.js +11 -0
- package/dist/linear-agent-session-client.js +14 -1
- package/dist/linear-progress-reporter.js +7 -181
- package/dist/linear-status-comment-sync.js +3 -19
- package/dist/manual-issue-actions.js +37 -0
- package/dist/presentation-text.js +11 -1
- package/dist/prompting/patchrelay.js +8 -6
- package/dist/reactive-pr-state.js +65 -0
- package/dist/reactive-run-policy.js +35 -118
- package/dist/remote-pr-state.js +11 -0
- package/dist/run-budgets.js +12 -0
- package/dist/run-notification-handler.js +4 -0
- package/dist/run-orchestrator.js +28 -8
- package/dist/run-wake-planner.js +11 -10
- package/dist/service-issue-actions.js +80 -27
- package/dist/service.js +3 -0
- package/dist/webhooks/desired-stage-recorder.js +34 -10
- package/package.json +1 -1
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { execCommand } from "./utils.js";
|
|
2
|
+
export async function readRemotePrState(repoFullName, prNumber) {
|
|
3
|
+
const { stdout, exitCode } = await execCommand("gh", [
|
|
4
|
+
"pr", "view", String(prNumber),
|
|
5
|
+
"--repo", repoFullName,
|
|
6
|
+
"--json", "headRefOid,state,reviewDecision,mergeStateStatus",
|
|
7
|
+
], { timeoutMs: 10_000 });
|
|
8
|
+
if (exitCode !== 0)
|
|
9
|
+
return undefined;
|
|
10
|
+
return JSON.parse(stdout);
|
|
11
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const DEFAULT_CI_REPAIR_BUDGET = 3;
|
|
2
|
+
export const DEFAULT_QUEUE_REPAIR_BUDGET = 3;
|
|
3
|
+
export const DEFAULT_REVIEW_FIX_BUDGET = 3;
|
|
4
|
+
export function getCiRepairBudget(project) {
|
|
5
|
+
return project?.repairBudgets?.ciRepair ?? DEFAULT_CI_REPAIR_BUDGET;
|
|
6
|
+
}
|
|
7
|
+
export function getQueueRepairBudget(project) {
|
|
8
|
+
return project?.repairBudgets?.queueRepair ?? DEFAULT_QUEUE_REPAIR_BUDGET;
|
|
9
|
+
}
|
|
10
|
+
export function getReviewFixBudget(project) {
|
|
11
|
+
return project?.repairBudgets?.reviewFix ?? DEFAULT_REVIEW_FIX_BUDGET;
|
|
12
|
+
}
|
|
@@ -41,6 +41,10 @@ export class RunNotificationHandler {
|
|
|
41
41
|
const run = this.db.runs.getRunByThreadId(threadId);
|
|
42
42
|
if (!run)
|
|
43
43
|
return;
|
|
44
|
+
if (run.status !== "running") {
|
|
45
|
+
this.logger.info({ runId: run.id, status: run.status, issueId: run.linearIssueId }, "Ignoring Codex notification for inactive run");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
44
48
|
if (!this.heartbeatIssueSessionLease(run.projectId, run.linearIssueId)) {
|
|
45
49
|
this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Ignoring Codex notification after losing issue-session lease");
|
|
46
50
|
return;
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -54,6 +54,20 @@ export class RunOrchestrator {
|
|
|
54
54
|
runNotificationHandler;
|
|
55
55
|
runReconciler;
|
|
56
56
|
mergedLinearCompletionReconciler;
|
|
57
|
+
threadPorts = {
|
|
58
|
+
readThreadWithRetry: (threadId, maxRetries) => this.readThreadWithRetry(threadId, maxRetries),
|
|
59
|
+
};
|
|
60
|
+
leasePorts = {
|
|
61
|
+
withHeldLease: (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn),
|
|
62
|
+
releaseLease: (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId),
|
|
63
|
+
heartbeatLease: (projectId, linearIssueId) => this.heartbeatIssueSessionLease(projectId, linearIssueId),
|
|
64
|
+
getHeldLease: (projectId, linearIssueId) => this.getHeldIssueSessionLease(projectId, linearIssueId),
|
|
65
|
+
};
|
|
66
|
+
recoveryPorts = {
|
|
67
|
+
failRunAndClear: (run, message, nextState) => this.failRunAndClear(run, message, nextState),
|
|
68
|
+
restoreIdleWorktree: (issue) => this.restoreIdleWorktree(issue),
|
|
69
|
+
recoverOrEscalate: (issue, runType, reason) => this.recoverOrEscalate(issue, runType, reason),
|
|
70
|
+
};
|
|
57
71
|
activeSessionLeases;
|
|
58
72
|
botIdentity;
|
|
59
73
|
constructor(config, db, codex, linearProvider, enqueueIssue, logger, feed) {
|
|
@@ -66,16 +80,16 @@ export class RunOrchestrator {
|
|
|
66
80
|
this.feed = feed;
|
|
67
81
|
this.worktreeManager = new WorktreeManager(config);
|
|
68
82
|
this.linearSync = new LinearSessionSync(config, db, linearProvider, logger, feed);
|
|
69
|
-
this.leaseService = new IssueSessionLeaseService(db, logger, this.workerId,
|
|
83
|
+
this.leaseService = new IssueSessionLeaseService(db, logger, this.workerId, this.threadPorts.readThreadWithRetry);
|
|
70
84
|
this.activeSessionLeases = this.leaseService.activeSessionLeases;
|
|
71
|
-
this.runCompletionPolicy = new RunCompletionPolicy(config, db, logger,
|
|
85
|
+
this.runCompletionPolicy = new RunCompletionPolicy(config, db, logger, this.leasePorts.withHeldLease);
|
|
72
86
|
this.completionCheck = new CompletionCheckService(codex, logger);
|
|
73
|
-
this.runFinalizer = new RunFinalizer(db, logger, this.linearSync, this.enqueueIssue,
|
|
87
|
+
this.runFinalizer = new RunFinalizer(db, logger, this.linearSync, this.enqueueIssue, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), this.recoveryPorts.failRunAndClear, this.runCompletionPolicy, this.completionCheck, feed);
|
|
74
88
|
this.runLauncher = new RunLauncher(config, db, codex, logger, this.worktreeManager);
|
|
75
|
-
this.runNotificationHandler = new RunNotificationHandler(config, db, logger, this.linearSync, this.runFinalizer,
|
|
76
|
-
this.runRecovery = new RunRecoveryService(db, logger, this.linearSync,
|
|
77
|
-
this.interruptedRunRecovery = new InterruptedRunRecovery(db, logger, this.linearSync,
|
|
78
|
-
this.runReconciler = new RunReconciler(db, logger, linearProvider, this.linearSync, this.interruptedRunRecovery, this.runFinalizer,
|
|
89
|
+
this.runNotificationHandler = new RunNotificationHandler(config, db, logger, this.linearSync, this.runFinalizer, this.threadPorts.readThreadWithRetry, this.leasePorts.withHeldLease, this.leasePorts.heartbeatLease, this.leasePorts.releaseLease, feed);
|
|
90
|
+
this.runRecovery = new RunRecoveryService(db, logger, this.linearSync, this.leasePorts.withHeldLease, this.leasePorts.getHeldLease, (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), this.leasePorts.releaseLease, (projectId, issueId) => this.enqueueIssue(projectId, issueId), feed);
|
|
91
|
+
this.interruptedRunRecovery = new InterruptedRunRecovery(db, logger, this.linearSync, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, this.recoveryPorts.failRunAndClear, this.recoveryPorts.restoreIdleWorktree, this.runCompletionPolicy, (projectId, issueId) => this.enqueueIssue(projectId, issueId), feed);
|
|
92
|
+
this.runReconciler = new RunReconciler(db, logger, linearProvider, this.linearSync, this.interruptedRunRecovery, this.runFinalizer, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, this.threadPorts.readThreadWithRetry, this.recoveryPorts.recoverOrEscalate, feed);
|
|
79
93
|
this.runWakePlanner = new RunWakePlanner(db);
|
|
80
94
|
this.idleReconciler = new IdleIssueReconciler(db, config, {
|
|
81
95
|
enqueueIssue: (projectId, issueId) => this.enqueueIssue(projectId, issueId),
|
|
@@ -124,6 +138,12 @@ export class RunOrchestrator {
|
|
|
124
138
|
return;
|
|
125
139
|
}
|
|
126
140
|
const { runType, context, resumeThread } = wake;
|
|
141
|
+
if (runType === "implementation" && this.db.issues.countUnresolvedBlockers(item.projectId, item.issueId) > 0) {
|
|
142
|
+
this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(item.projectId, item.issueId);
|
|
143
|
+
this.releaseIssueSessionLease(item.projectId, item.issueId);
|
|
144
|
+
this.logger.info({ issueKey: issue.issueKey }, "Skipped implementation launch because the issue is blocked");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
127
147
|
const remainingZombieDelayMs = shouldDelayZombieRecoveryLaunch(issue, issueSession, runType);
|
|
128
148
|
if (remainingZombieDelayMs > 0) {
|
|
129
149
|
this.logger.debug({ issueKey: issue.issueKey, runType, remainingZombieDelayMs }, "Deferring recovered run launch until zombie backoff elapses");
|
|
@@ -138,7 +158,7 @@ export class RunOrchestrator {
|
|
|
138
158
|
: typeof effectiveContext?.headSha === "string"
|
|
139
159
|
? effectiveContext.headSha
|
|
140
160
|
: issue.prHeadSha;
|
|
141
|
-
const budgetExceeded = this.runWakePlanner.budgetExceeded(issue, runType, isRequestedChangesRunType);
|
|
161
|
+
const budgetExceeded = this.runWakePlanner.budgetExceeded(issue, project, runType, isRequestedChangesRunType);
|
|
142
162
|
if (budgetExceeded) {
|
|
143
163
|
this.escalate(issue, runType, budgetExceeded);
|
|
144
164
|
return;
|
package/dist/run-wake-planner.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
const DEFAULT_QUEUE_REPAIR_BUDGET = 3;
|
|
3
|
-
const DEFAULT_REVIEW_FIX_BUDGET = 12;
|
|
1
|
+
import { getCiRepairBudget, getQueueRepairBudget, getReviewFixBudget, } from "./run-budgets.js";
|
|
4
2
|
export class RunWakePlanner {
|
|
5
3
|
db;
|
|
6
4
|
constructor(db) {
|
|
@@ -62,15 +60,18 @@ export class RunWakePlanner {
|
|
|
62
60
|
return issue;
|
|
63
61
|
return this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
64
62
|
}
|
|
65
|
-
budgetExceeded(issue, runType, isRequestedChangesRunType) {
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
budgetExceeded(issue, project, runType, isRequestedChangesRunType) {
|
|
64
|
+
const ciRepairBudget = getCiRepairBudget(project);
|
|
65
|
+
if (runType === "ci_repair" && issue.ciRepairAttempts >= ciRepairBudget) {
|
|
66
|
+
return `CI repair budget exhausted (${ciRepairBudget} attempts)`;
|
|
68
67
|
}
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
const queueRepairBudget = getQueueRepairBudget(project);
|
|
69
|
+
if (runType === "queue_repair" && issue.queueRepairAttempts >= queueRepairBudget) {
|
|
70
|
+
return `Queue repair budget exhausted (${queueRepairBudget} attempts)`;
|
|
71
71
|
}
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
const reviewFixBudget = getReviewFixBudget(project);
|
|
73
|
+
if (isRequestedChangesRunType(runType) && issue.reviewFixAttempts >= reviewFixBudget) {
|
|
74
|
+
return `Requested-changes budget exhausted (${reviewFixBudget} attempts)`;
|
|
74
75
|
}
|
|
75
76
|
return undefined;
|
|
76
77
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { buildOperatorRetryEvent } from "./operator-retry-event.js";
|
|
2
|
-
import {
|
|
2
|
+
import { buildManualRetryAttemptReset, resolveRetryTarget } from "./manual-issue-actions.js";
|
|
3
3
|
export class ServiceIssueActions {
|
|
4
4
|
db;
|
|
5
5
|
codex;
|
|
@@ -103,7 +103,16 @@ export class ServiceIssueActions {
|
|
|
103
103
|
if (issue.activeRunId)
|
|
104
104
|
return { error: "Issue already has an active run" };
|
|
105
105
|
const issueSession = this.db.issueSessions.getIssueSession(issue.projectId, issue.linearIssueId);
|
|
106
|
-
|
|
106
|
+
const retryTarget = resolveRetryTarget({
|
|
107
|
+
prNumber: issue.prNumber,
|
|
108
|
+
prState: issue.prState,
|
|
109
|
+
prReviewState: issue.prReviewState,
|
|
110
|
+
prCheckStatus: issue.prCheckStatus,
|
|
111
|
+
pendingRunType: issue.pendingRunType,
|
|
112
|
+
lastRunType: issueSession?.lastRunType,
|
|
113
|
+
lastGitHubFailureSource: issue.lastGitHubFailureSource,
|
|
114
|
+
});
|
|
115
|
+
if (retryTarget.runType === "none") {
|
|
107
116
|
this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
108
117
|
projectId: issue.projectId,
|
|
109
118
|
linearIssueId: issue.linearIssueId,
|
|
@@ -111,45 +120,89 @@ export class ServiceIssueActions {
|
|
|
111
120
|
});
|
|
112
121
|
return { issueKey, runType: "none" };
|
|
113
122
|
}
|
|
114
|
-
|
|
115
|
-
let factoryState = "delegated";
|
|
116
|
-
if (hasOpenPr(issue.prNumber, issue.prState) && issue.lastGitHubFailureSource === "queue_eviction") {
|
|
117
|
-
runType = "queue_repair";
|
|
118
|
-
factoryState = "repairing_queue";
|
|
119
|
-
}
|
|
120
|
-
else if (hasOpenPr(issue.prNumber, issue.prState) && (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure" || issue.lastGitHubFailureSource === "branch_ci")) {
|
|
121
|
-
runType = "ci_repair";
|
|
122
|
-
factoryState = "repairing_ci";
|
|
123
|
-
}
|
|
124
|
-
else if (hasOpenPr(issue.prNumber, issue.prState) && issue.prReviewState === "changes_requested") {
|
|
125
|
-
runType = issue.pendingRunType === "branch_upkeep" || issueSession?.lastRunType === "branch_upkeep"
|
|
126
|
-
? "branch_upkeep"
|
|
127
|
-
: "review_fix";
|
|
128
|
-
factoryState = "changes_requested";
|
|
129
|
-
}
|
|
130
|
-
else if (hasOpenPr(issue.prNumber, issue.prState)) {
|
|
131
|
-
runType = "implementation";
|
|
132
|
-
factoryState = "implementing";
|
|
133
|
-
}
|
|
134
|
-
this.appendOperatorRetryEvent(issue, runType);
|
|
123
|
+
this.appendOperatorRetryEvent(issue, retryTarget.runType);
|
|
135
124
|
this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
136
125
|
projectId: issue.projectId,
|
|
137
126
|
linearIssueId: issue.linearIssueId,
|
|
138
|
-
factoryState: factoryState,
|
|
127
|
+
factoryState: retryTarget.factoryState,
|
|
128
|
+
...buildManualRetryAttemptReset(retryTarget.runType),
|
|
139
129
|
});
|
|
140
130
|
this.feed.publish({
|
|
141
131
|
level: "info",
|
|
142
132
|
kind: "stage",
|
|
143
133
|
issueKey: issue.issueKey,
|
|
144
134
|
projectId: issue.projectId,
|
|
145
|
-
stage: factoryState,
|
|
135
|
+
stage: retryTarget.factoryState,
|
|
146
136
|
status: "retry",
|
|
147
|
-
summary: `Retry queued: ${runType}`,
|
|
137
|
+
summary: `Retry queued: ${retryTarget.runType}`,
|
|
148
138
|
});
|
|
149
139
|
if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
|
|
150
140
|
this.runtime.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
151
141
|
}
|
|
152
|
-
return { issueKey, runType };
|
|
142
|
+
return { issueKey, runType: retryTarget.runType };
|
|
143
|
+
}
|
|
144
|
+
async closeIssue(issueKey, options) {
|
|
145
|
+
const issue = this.db.issues.getIssueByKey(issueKey);
|
|
146
|
+
if (!issue)
|
|
147
|
+
return undefined;
|
|
148
|
+
const terminalState = options?.failed ? "failed" : "done";
|
|
149
|
+
const run = issue.activeRunId ? this.db.runs.getRunById(issue.activeRunId) : undefined;
|
|
150
|
+
if (run?.threadId && run.turnId) {
|
|
151
|
+
try {
|
|
152
|
+
await this.codex.steerTurn({
|
|
153
|
+
threadId: run.threadId,
|
|
154
|
+
turnId: run.turnId,
|
|
155
|
+
input: `STOP: The operator manually closed this issue in PatchRelay as ${terminalState}. Stop working immediately and exit without making further changes.`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// The turn may already be settled.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
163
|
+
projectId: issue.projectId,
|
|
164
|
+
linearIssueId: issue.linearIssueId,
|
|
165
|
+
eventType: "operator_closed",
|
|
166
|
+
eventJson: JSON.stringify({
|
|
167
|
+
terminalState,
|
|
168
|
+
...(options?.reason ? { reason: options.reason } : {}),
|
|
169
|
+
}),
|
|
170
|
+
dedupeKey: `operator_closed:${issue.linearIssueId}:${terminalState}:${issue.activeRunId ?? "no-run"}`,
|
|
171
|
+
});
|
|
172
|
+
this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
173
|
+
if (run) {
|
|
174
|
+
this.db.issueSessions.finishRunRespectingActiveLease(issue.projectId, issue.linearIssueId, run.id, {
|
|
175
|
+
status: "released",
|
|
176
|
+
failureReason: options?.reason
|
|
177
|
+
? `Operator closed issue as ${terminalState}: ${options.reason}`
|
|
178
|
+
: `Operator closed issue as ${terminalState}`,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
182
|
+
projectId: issue.projectId,
|
|
183
|
+
linearIssueId: issue.linearIssueId,
|
|
184
|
+
factoryState: terminalState,
|
|
185
|
+
activeRunId: null,
|
|
186
|
+
pendingRunType: null,
|
|
187
|
+
pendingRunContextJson: null,
|
|
188
|
+
});
|
|
189
|
+
this.db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
190
|
+
this.feed.publish({
|
|
191
|
+
level: terminalState === "failed" ? "warn" : "info",
|
|
192
|
+
kind: "workflow",
|
|
193
|
+
issueKey: issue.issueKey,
|
|
194
|
+
projectId: issue.projectId,
|
|
195
|
+
stage: terminalState,
|
|
196
|
+
status: "operator_closed",
|
|
197
|
+
summary: options?.reason
|
|
198
|
+
? `Operator closed issue as ${terminalState}: ${options.reason}`
|
|
199
|
+
: `Operator closed issue as ${terminalState}`,
|
|
200
|
+
});
|
|
201
|
+
return {
|
|
202
|
+
issueKey,
|
|
203
|
+
factoryState: terminalState,
|
|
204
|
+
...(run ? { releasedRunId: run.id } : {}),
|
|
205
|
+
};
|
|
153
206
|
}
|
|
154
207
|
queueOperatorPrompt(issue, text, source) {
|
|
155
208
|
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
package/dist/service.js
CHANGED
|
@@ -215,6 +215,9 @@ export class PatchRelayService {
|
|
|
215
215
|
retryIssue(issueKey) {
|
|
216
216
|
return this.issueActions.retryIssue(issueKey);
|
|
217
217
|
}
|
|
218
|
+
async closeIssue(issueKey, options) {
|
|
219
|
+
return await this.issueActions.closeIssue(issueKey, options);
|
|
220
|
+
}
|
|
218
221
|
async acceptWebhook(params) {
|
|
219
222
|
const result = await acceptIncomingWebhook({
|
|
220
223
|
config: this.config,
|
|
@@ -19,16 +19,22 @@ export class DesiredStageRecorder {
|
|
|
19
19
|
const existingIssue = this.db.issues.getIssue(params.project.id, normalizedIssue.id);
|
|
20
20
|
const activeRun = existingIssue?.activeRunId ? this.db.runs.getRunById(existingIssue.activeRunId) : undefined;
|
|
21
21
|
const latestRun = existingIssue ? this.db.runs.getLatestRunForIssue(params.project.id, normalizedIssue.id) : undefined;
|
|
22
|
-
const delegated = this.isDelegatedToPatchRelay(params.project, params.normalized);
|
|
23
22
|
const triggerAllowed = triggerEventAllowed(params.project, params.normalized.triggerEvent);
|
|
24
23
|
const incomingAgentSessionId = params.normalized.agentSession?.id;
|
|
25
24
|
const hasPendingWake = this.db.issueSessions.peekIssueSessionWake(params.project.id, normalizedIssue.id) !== undefined;
|
|
26
|
-
if (!existingIssue && !
|
|
27
|
-
return { issue: undefined, wakeRunType: undefined, delegated };
|
|
25
|
+
if (!existingIssue && !this.isDelegatedToPatchRelay(params.project, normalizedIssue) && !incomingAgentSessionId) {
|
|
26
|
+
return { issue: undefined, wakeRunType: undefined, delegated: false };
|
|
28
27
|
}
|
|
29
28
|
const hydratedIssue = await this.syncIssueDependencies(params.project.id, normalizedIssue);
|
|
29
|
+
const delegated = this.isDelegatedToPatchRelay(params.project, hydratedIssue);
|
|
30
30
|
const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(params.project.id, normalizedIssue.id);
|
|
31
31
|
const terminal = isTerminalDelegationState(existingIssue, hydratedIssue);
|
|
32
|
+
const openPrExists = existingIssue?.prNumber !== undefined
|
|
33
|
+
&& existingIssue.prState !== "closed"
|
|
34
|
+
&& existingIssue.prState !== "merged";
|
|
35
|
+
const blockerPausedImplementation = unresolvedBlockers > 0
|
|
36
|
+
&& activeRun?.runType === "implementation"
|
|
37
|
+
&& !openPrExists;
|
|
32
38
|
const desiredStage = decideRunIntent({
|
|
33
39
|
delegated,
|
|
34
40
|
triggerAllowed,
|
|
@@ -45,6 +51,9 @@ export class DesiredStageRecorder {
|
|
|
45
51
|
triggerEvent: params.normalized.triggerEvent,
|
|
46
52
|
delegated,
|
|
47
53
|
});
|
|
54
|
+
const effectiveRunRelease = blockerPausedImplementation
|
|
55
|
+
? { release: true, reason: "Issue became blocked during implementation" }
|
|
56
|
+
: runRelease;
|
|
48
57
|
const undelegation = decideUnDelegation({
|
|
49
58
|
triggerEvent: params.normalized.triggerEvent,
|
|
50
59
|
delegated,
|
|
@@ -96,11 +105,12 @@ export class DesiredStageRecorder {
|
|
|
96
105
|
...(!reDelegationResume.factoryState && desiredStage ? { pendingRunType: null, pendingRunContextJson: null, factoryState: "delegated" } : {}),
|
|
97
106
|
...(clearPending ? { pendingRunType: null, pendingRunContextJson: null } : {}),
|
|
98
107
|
...(agentSessionId !== undefined ? { agentSessionId } : {}),
|
|
99
|
-
...(
|
|
108
|
+
...(effectiveRunRelease.release ? { activeRunId: null } : {}),
|
|
109
|
+
...(blockerPausedImplementation ? { factoryState: "delegated" } : {}),
|
|
100
110
|
...(undelegation.factoryState ? { factoryState: undelegation.factoryState } : {}),
|
|
101
111
|
});
|
|
102
|
-
if (
|
|
103
|
-
this.db.runs.finishRun(activeRun.id, { status: "released", failureReason:
|
|
112
|
+
if (effectiveRunRelease.release && activeRun && effectiveRunRelease.reason) {
|
|
113
|
+
this.db.runs.finishRun(activeRun.id, { status: "released", failureReason: effectiveRunRelease.reason });
|
|
104
114
|
}
|
|
105
115
|
return record;
|
|
106
116
|
};
|
|
@@ -136,6 +146,22 @@ export class DesiredStageRecorder {
|
|
|
136
146
|
: `Issue un-delegated from PatchRelay; ${issue.factoryState} is now paused`,
|
|
137
147
|
});
|
|
138
148
|
}
|
|
149
|
+
else if (blockerPausedImplementation) {
|
|
150
|
+
if (activeRun?.threadId && activeRun.turnId) {
|
|
151
|
+
await params.stopActiveRun(activeRun, "STOP: The issue is now blocked by another task. Stop working immediately and exit without publishing.");
|
|
152
|
+
}
|
|
153
|
+
this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(params.project.id, normalizedIssue.id);
|
|
154
|
+
this.db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(params.project.id, normalizedIssue.id);
|
|
155
|
+
this.feed?.publish({
|
|
156
|
+
level: "warn",
|
|
157
|
+
kind: "stage",
|
|
158
|
+
issueKey: issue.issueKey,
|
|
159
|
+
projectId: params.project.id,
|
|
160
|
+
stage: issue.factoryState,
|
|
161
|
+
status: "blocked",
|
|
162
|
+
summary: `Implementation paused because ${issue.issueKey ?? normalizedIssue.id} is now blocked`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
139
165
|
else if (reDelegationResume.pendingRunType) {
|
|
140
166
|
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(params.project.id, normalizedIssue.id, {
|
|
141
167
|
projectId: params.project.id,
|
|
@@ -168,13 +194,11 @@ export class DesiredStageRecorder {
|
|
|
168
194
|
delegated,
|
|
169
195
|
};
|
|
170
196
|
}
|
|
171
|
-
isDelegatedToPatchRelay(project,
|
|
172
|
-
if (!normalized.issue)
|
|
173
|
-
return false;
|
|
197
|
+
isDelegatedToPatchRelay(project, issue) {
|
|
174
198
|
const installation = this.db.linearInstallations.getLinearInstallationForProject(project.id);
|
|
175
199
|
if (!installation?.actorId)
|
|
176
200
|
return false;
|
|
177
|
-
return
|
|
201
|
+
return issue.delegateId === installation.actorId;
|
|
178
202
|
}
|
|
179
203
|
async syncIssueDependencies(projectId, issue) {
|
|
180
204
|
let source = issue;
|