patchrelay 0.42.0 → 0.43.0
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/db/issue-store.js +10 -2
- package/dist/db/migrations.js +4 -0
- package/dist/github-webhook-state-projector.js +2 -0
- package/dist/idle-reconciliation.js +14 -2
- package/dist/interrupted-run-recovery.js +1 -0
- package/dist/issue-session-lease-service.js +14 -3
- package/dist/linear-workflow-state-sync.js +7 -2
- package/dist/linear-workflow.js +10 -0
- package/dist/no-pr-completion-check.js +1 -0
- package/dist/reactive-run-policy.js +8 -0
- package/dist/run-finalizer.js +1 -0
- package/dist/run-launcher.js +1 -0
- package/dist/service-startup-recovery.js +8 -2
- package/dist/webhook-handler.js +20 -0
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
package/dist/db/issue-store.js
CHANGED
|
@@ -192,6 +192,10 @@ export class IssueStore {
|
|
|
192
192
|
sets.push("last_attempted_failure_signature = @lastAttemptedFailureSignature");
|
|
193
193
|
values.lastAttemptedFailureSignature = params.lastAttemptedFailureSignature;
|
|
194
194
|
}
|
|
195
|
+
if (params.lastAttemptedFailureAt !== undefined) {
|
|
196
|
+
sets.push("last_attempted_failure_at = @lastAttemptedFailureAt");
|
|
197
|
+
values.lastAttemptedFailureAt = params.lastAttemptedFailureAt;
|
|
198
|
+
}
|
|
195
199
|
if (params.ciRepairAttempts !== undefined) {
|
|
196
200
|
sets.push("ci_repair_attempts = @ciRepairAttempts");
|
|
197
201
|
values.ciRepairAttempts = params.ciRepairAttempts;
|
|
@@ -226,7 +230,7 @@ export class IssueStore {
|
|
|
226
230
|
last_github_failure_source, last_github_failure_head_sha, last_github_failure_signature, last_github_failure_check_name, last_github_failure_check_url, last_github_failure_context_json, last_github_failure_at,
|
|
227
231
|
last_github_ci_snapshot_head_sha, last_github_ci_snapshot_gate_check_name, last_github_ci_snapshot_gate_check_status, last_github_ci_snapshot_json, last_github_ci_snapshot_settled_at,
|
|
228
232
|
last_queue_signal_at, last_queue_incident_json,
|
|
229
|
-
last_attempted_failure_head_sha, last_attempted_failure_signature,
|
|
233
|
+
last_attempted_failure_head_sha, last_attempted_failure_signature, last_attempted_failure_at,
|
|
230
234
|
ci_repair_attempts, queue_repair_attempts, review_fix_attempts, zombie_recovery_attempts, last_zombie_recovery_at,
|
|
231
235
|
updated_at
|
|
232
236
|
) VALUES (
|
|
@@ -239,7 +243,7 @@ export class IssueStore {
|
|
|
239
243
|
@lastGitHubFailureSource, @lastGitHubFailureHeadSha, @lastGitHubFailureSignature, @lastGitHubFailureCheckName, @lastGitHubFailureCheckUrl, @lastGitHubFailureContextJson, @lastGitHubFailureAt,
|
|
240
244
|
@lastGitHubCiSnapshotHeadSha, @lastGitHubCiSnapshotGateCheckName, @lastGitHubCiSnapshotGateCheckStatus, @lastGitHubCiSnapshotJson, @lastGitHubCiSnapshotSettledAt,
|
|
241
245
|
@lastQueueSignalAt, @lastQueueIncidentJson,
|
|
242
|
-
@lastAttemptedFailureHeadSha, @lastAttemptedFailureSignature,
|
|
246
|
+
@lastAttemptedFailureHeadSha, @lastAttemptedFailureSignature, @lastAttemptedFailureAt,
|
|
243
247
|
@ciRepairAttempts, @queueRepairAttempts, @reviewFixAttempts, @zombieRecoveryAttempts, @lastZombieRecoveryAt,
|
|
244
248
|
@now
|
|
245
249
|
)
|
|
@@ -290,6 +294,7 @@ export class IssueStore {
|
|
|
290
294
|
lastQueueIncidentJson: params.lastQueueIncidentJson ?? null,
|
|
291
295
|
lastAttemptedFailureHeadSha: params.lastAttemptedFailureHeadSha ?? null,
|
|
292
296
|
lastAttemptedFailureSignature: params.lastAttemptedFailureSignature ?? null,
|
|
297
|
+
lastAttemptedFailureAt: params.lastAttemptedFailureAt ?? null,
|
|
293
298
|
ciRepairAttempts: params.ciRepairAttempts ?? 0,
|
|
294
299
|
queueRepairAttempts: params.queueRepairAttempts ?? 0,
|
|
295
300
|
reviewFixAttempts: params.reviewFixAttempts ?? 0,
|
|
@@ -556,6 +561,9 @@ export function mapIssueRow(row) {
|
|
|
556
561
|
...(row.last_attempted_failure_signature !== null && row.last_attempted_failure_signature !== undefined
|
|
557
562
|
? { lastAttemptedFailureSignature: String(row.last_attempted_failure_signature) }
|
|
558
563
|
: {}),
|
|
564
|
+
...(row.last_attempted_failure_at !== null && row.last_attempted_failure_at !== undefined
|
|
565
|
+
? { lastAttemptedFailureAt: String(row.last_attempted_failure_at) }
|
|
566
|
+
: {}),
|
|
559
567
|
ciRepairAttempts: Number(row.ci_repair_attempts ?? 0),
|
|
560
568
|
queueRepairAttempts: Number(row.queue_repair_attempts ?? 0),
|
|
561
569
|
reviewFixAttempts: Number(row.review_fix_attempts ?? 0),
|
package/dist/db/migrations.js
CHANGED
|
@@ -293,6 +293,7 @@ export function runPatchRelayMigrations(connection) {
|
|
|
293
293
|
addColumnIfMissing(connection, "issues", "last_queue_incident_json", "TEXT");
|
|
294
294
|
addColumnIfMissing(connection, "issues", "last_attempted_failure_head_sha", "TEXT");
|
|
295
295
|
addColumnIfMissing(connection, "issues", "last_attempted_failure_signature", "TEXT");
|
|
296
|
+
addColumnIfMissing(connection, "issues", "last_attempted_failure_at", "TEXT");
|
|
296
297
|
removeRetiredIssueColumnsIfPresent(connection);
|
|
297
298
|
}
|
|
298
299
|
function addColumnIfMissing(connection, table, column, definition) {
|
|
@@ -359,6 +360,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
359
360
|
last_queue_incident_json TEXT,
|
|
360
361
|
last_attempted_failure_head_sha TEXT,
|
|
361
362
|
last_attempted_failure_signature TEXT,
|
|
363
|
+
last_attempted_failure_at TEXT,
|
|
362
364
|
ci_repair_attempts INTEGER NOT NULL DEFAULT 0,
|
|
363
365
|
queue_repair_attempts INTEGER NOT NULL DEFAULT 0,
|
|
364
366
|
review_fix_attempts INTEGER NOT NULL DEFAULT 0,
|
|
@@ -416,6 +418,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
416
418
|
last_queue_incident_json,
|
|
417
419
|
last_attempted_failure_head_sha,
|
|
418
420
|
last_attempted_failure_signature,
|
|
421
|
+
last_attempted_failure_at,
|
|
419
422
|
ci_repair_attempts,
|
|
420
423
|
queue_repair_attempts,
|
|
421
424
|
review_fix_attempts,
|
|
@@ -471,6 +474,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
471
474
|
last_queue_incident_json,
|
|
472
475
|
last_attempted_failure_head_sha,
|
|
473
476
|
last_attempted_failure_signature,
|
|
477
|
+
last_attempted_failure_at,
|
|
474
478
|
COALESCE(ci_repair_attempts, 0),
|
|
475
479
|
COALESCE(queue_repair_attempts, 0),
|
|
476
480
|
COALESCE(review_fix_attempts, 0),
|
|
@@ -80,6 +80,7 @@ export async function projectGitHubWebhookState(deps, issue, event, project, lin
|
|
|
80
80
|
lastQueueIncidentJson: null,
|
|
81
81
|
lastAttemptedFailureHeadSha: null,
|
|
82
82
|
lastAttemptedFailureSignature: null,
|
|
83
|
+
lastAttemptedFailureAt: null,
|
|
83
84
|
});
|
|
84
85
|
}
|
|
85
86
|
deps.logger.info({ issueKey: issue.issueKey, branchName: event.branchName, triggerEvent: event.triggerEvent, prNumber: event.prNumber }, "GitHub webhook: updated issue PR state");
|
|
@@ -240,6 +241,7 @@ async function updateGitHubFailureProvenance(deps, issue, event, project, failur
|
|
|
240
241
|
lastQueueIncidentJson: null,
|
|
241
242
|
lastAttemptedFailureHeadSha: null,
|
|
242
243
|
lastAttemptedFailureSignature: null,
|
|
244
|
+
lastAttemptedFailureAt: null,
|
|
243
245
|
});
|
|
244
246
|
}
|
|
245
247
|
}
|
|
@@ -50,8 +50,19 @@ function isDuplicateRepairAttempt(issue, context) {
|
|
|
50
50
|
: typeof context?.headSha === "string" ? context.headSha : undefined;
|
|
51
51
|
if (!signature)
|
|
52
52
|
return false;
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
if (issue.lastAttemptedFailureSignature !== signature)
|
|
54
|
+
return false;
|
|
55
|
+
if (headSha !== undefined && issue.lastAttemptedFailureHeadSha !== headSha)
|
|
56
|
+
return false;
|
|
57
|
+
// A signature+headSha match alone isn't enough: for queue evictions the PR head
|
|
58
|
+
// doesn't advance (we haven't pushed) and the steward's check name is constant,
|
|
59
|
+
// so a fresh incident after main advances looks identical. Treat the attempt as
|
|
60
|
+
// stale if a newer failure has been observed since it was recorded.
|
|
61
|
+
if (issue.lastAttemptedFailureAt && issue.lastGitHubFailureAt
|
|
62
|
+
&& issue.lastGitHubFailureAt > issue.lastAttemptedFailureAt) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
55
66
|
}
|
|
56
67
|
function buildFailureContext(issue) {
|
|
57
68
|
const storedFailureContext = parseGitHubFailureContext(issue.lastGitHubFailureContextJson);
|
|
@@ -196,6 +207,7 @@ export class IdleIssueReconciler {
|
|
|
196
207
|
lastQueueIncidentJson: null,
|
|
197
208
|
lastAttemptedFailureHeadSha: null,
|
|
198
209
|
lastAttemptedFailureSignature: null,
|
|
210
|
+
lastAttemptedFailureAt: null,
|
|
199
211
|
}
|
|
200
212
|
: {}),
|
|
201
213
|
});
|
|
@@ -14,10 +14,10 @@ export class IssueSessionLeaseService {
|
|
|
14
14
|
this.readThreadWithRetry = readThreadWithRetry;
|
|
15
15
|
}
|
|
16
16
|
hasLocalLease(projectId, linearIssueId) {
|
|
17
|
-
return this.
|
|
17
|
+
return this.getValidatedLocalLeaseId(projectId, linearIssueId) !== undefined;
|
|
18
18
|
}
|
|
19
19
|
getHeldLease(projectId, linearIssueId) {
|
|
20
|
-
const leaseId = this.
|
|
20
|
+
const leaseId = this.getValidatedLocalLeaseId(projectId, linearIssueId);
|
|
21
21
|
if (!leaseId)
|
|
22
22
|
return undefined;
|
|
23
23
|
return { projectId, linearIssueId, leaseId };
|
|
@@ -133,10 +133,21 @@ export class IssueSessionLeaseService {
|
|
|
133
133
|
}
|
|
134
134
|
release(projectId, linearIssueId) {
|
|
135
135
|
const key = this.issueSessionLeaseKey(projectId, linearIssueId);
|
|
136
|
-
const leaseId = this.
|
|
136
|
+
const leaseId = this.getValidatedLocalLeaseId(projectId, linearIssueId);
|
|
137
137
|
this.db.issueSessions.releaseIssueSessionLease(projectId, linearIssueId, leaseId);
|
|
138
138
|
this.activeSessionLeases.delete(key);
|
|
139
139
|
}
|
|
140
|
+
getValidatedLocalLeaseId(projectId, linearIssueId) {
|
|
141
|
+
const key = this.issueSessionLeaseKey(projectId, linearIssueId);
|
|
142
|
+
const leaseId = this.activeSessionLeases.get(key);
|
|
143
|
+
if (!leaseId)
|
|
144
|
+
return undefined;
|
|
145
|
+
if (this.db.issueSessions.hasActiveIssueSessionLease(projectId, linearIssueId, leaseId)) {
|
|
146
|
+
return leaseId;
|
|
147
|
+
}
|
|
148
|
+
this.activeSessionLeases.delete(key);
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
140
151
|
issueSessionLeaseKey(projectId, linearIssueId) {
|
|
141
152
|
return `${projectId}:${linearIssueId}`;
|
|
142
153
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolvePreferredCompletedLinearState, resolvePreferredDeployingLinearState, resolvePreferredHumanNeededLinearState, resolvePreferredImplementingLinearState, resolvePreferredReviewLinearState, resolvePreferredReviewingLinearState, } from "./linear-workflow.js";
|
|
1
|
+
import { resolvePreferredQueuedLinearState, resolvePreferredCompletedLinearState, resolvePreferredDeployingLinearState, resolvePreferredHumanNeededLinearState, resolvePreferredImplementingLinearState, resolvePreferredReviewLinearState, resolvePreferredReviewingLinearState, } from "./linear-workflow.js";
|
|
2
2
|
import { isCompletedLinearState } from "./pr-state.js";
|
|
3
3
|
import { hasTrustedNoPrCompletion } from "./trusted-no-pr-completion.js";
|
|
4
4
|
export async function syncActiveWorkflowState(params) {
|
|
@@ -72,10 +72,15 @@ function resolveDesiredActiveWorkflowState(issue, trackedIssue, options, liveIss
|
|
|
72
72
|
|| trackedIssue?.sessionState === "waiting_input" || trackedIssue?.sessionState === "failed") {
|
|
73
73
|
return resolvePreferredHumanNeededLinearState(liveIssue);
|
|
74
74
|
}
|
|
75
|
+
const blocked = (trackedIssue?.blockedByCount ?? 0) > 0;
|
|
76
|
+
const pausedNoPrWork = issue.prNumber === undefined && (!issue.delegatedToPatchRelay || blocked);
|
|
77
|
+
if (pausedNoPrWork) {
|
|
78
|
+
return resolvePreferredQueuedLinearState(liveIssue);
|
|
79
|
+
}
|
|
75
80
|
const activelyWorking = issue.delegatedToPatchRelay !== false && (issue.activeRunId !== undefined
|
|
76
81
|
|| options?.activeRunType !== undefined
|
|
77
82
|
|| trackedIssue?.sessionState === "running"
|
|
78
|
-
|| issue.factoryState === "delegated"
|
|
83
|
+
|| (issue.factoryState === "delegated" && !blocked && trackedIssue?.readyForExecution !== false)
|
|
79
84
|
|| issue.factoryState === "implementing"
|
|
80
85
|
|| issue.factoryState === "changes_requested"
|
|
81
86
|
|| issue.factoryState === "repairing_ci"
|
package/dist/linear-workflow.js
CHANGED
|
@@ -24,6 +24,16 @@ export function resolvePreferredStartedLinearState(issue) {
|
|
|
24
24
|
});
|
|
25
25
|
return preferred?.name ?? startedStates[0]?.name;
|
|
26
26
|
}
|
|
27
|
+
export function resolvePreferredQueuedLinearState(issue) {
|
|
28
|
+
return resolvePreferredLinearState(issue, {
|
|
29
|
+
names: ["backlog", "start", "todo", "to do", "planned", "ready"],
|
|
30
|
+
types: ["backlog", "unstarted"],
|
|
31
|
+
fallback: issue.workflowStates.find((state) => {
|
|
32
|
+
const normalizedType = normalizeLinearState(state.type);
|
|
33
|
+
return normalizedType === "backlog" || normalizedType === "unstarted";
|
|
34
|
+
})?.name,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
27
37
|
export function resolvePreferredImplementingLinearState(issue) {
|
|
28
38
|
return resolvePreferredLinearState(issue, {
|
|
29
39
|
names: ["implementing", "in progress", "in-progress", "started", "doing"],
|
|
@@ -23,6 +23,13 @@ export class ReactiveRunPolicy {
|
|
|
23
23
|
return undefined;
|
|
24
24
|
if (!snapshot.headSha || snapshot.headSha !== issue.lastGitHubFailureHeadSha)
|
|
25
25
|
return undefined;
|
|
26
|
+
// For queue repairs, the agent's no-op is legitimate when the incident has
|
|
27
|
+
// already self-resolved: GitHub reports the PR as mergeable, so there is no
|
|
28
|
+
// conflict left to push. Only flag as failed when the merge state is still
|
|
29
|
+
// DIRTY after the run — then the agent really did miss the fix.
|
|
30
|
+
if (run.runType === "queue_repair" && !isDirtyMergeStateStatus(snapshot.pr.mergeStateStatus)) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
26
33
|
return `Repair finished but PR #${issue.prNumber} is still on failing head ${issue.lastGitHubFailureHeadSha.slice(0, 8)}`;
|
|
27
34
|
}
|
|
28
35
|
catch (error) {
|
|
@@ -99,6 +106,7 @@ export class ReactiveRunPolicy {
|
|
|
99
106
|
lastQueueIncidentJson: null,
|
|
100
107
|
lastAttemptedFailureHeadSha: null,
|
|
101
108
|
lastAttemptedFailureSignature: null,
|
|
109
|
+
lastAttemptedFailureAt: null,
|
|
102
110
|
lastGitHubCiSnapshotHeadSha: snapshot.headSha ?? null,
|
|
103
111
|
lastGitHubCiSnapshotGateCheckName: snapshot.gateCheckName,
|
|
104
112
|
lastGitHubCiSnapshotGateCheckStatus: "pending",
|
package/dist/run-finalizer.js
CHANGED
package/dist/run-launcher.js
CHANGED
|
@@ -38,7 +38,7 @@ export class ServiceStartupRecovery {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
async recoverDelegatedIssueStateFromLinear() {
|
|
41
|
-
for (const issue of this.db.issues.
|
|
41
|
+
for (const issue of this.db.issues.listIssues()) {
|
|
42
42
|
if (issue.factoryState === "done" || issue.activeRunId !== undefined) {
|
|
43
43
|
continue;
|
|
44
44
|
}
|
|
@@ -87,7 +87,13 @@ export class ServiceStartupRecovery {
|
|
|
87
87
|
const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
|
|
88
88
|
const latestRun = this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
|
|
89
89
|
const shouldRecoverPausedLocalWork = delegated
|
|
90
|
-
&& isResumablePausedLocalWork({
|
|
90
|
+
&& isResumablePausedLocalWork({
|
|
91
|
+
issue: {
|
|
92
|
+
...issue,
|
|
93
|
+
delegatedToPatchRelay: delegated,
|
|
94
|
+
},
|
|
95
|
+
latestRun,
|
|
96
|
+
})
|
|
91
97
|
&& this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId) === undefined;
|
|
92
98
|
const updated = this.db.issues.upsertIssue({
|
|
93
99
|
projectId: issue.projectId,
|
package/dist/webhook-handler.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { deriveIssueStatusNote } from "./status-note.js";
|
|
2
|
+
import { LinearSessionSync } from "./linear-session-sync.js";
|
|
2
3
|
import { trustedActorAllowed } from "./project-resolution.js";
|
|
3
4
|
import { normalizeWebhook } from "./webhooks.js";
|
|
4
5
|
import { InstallationWebhookHandler } from "./webhook-installation-handler.js";
|
|
@@ -25,6 +26,7 @@ export class WebhookHandler {
|
|
|
25
26
|
desiredStageRecorder;
|
|
26
27
|
contextLoader;
|
|
27
28
|
dependencyReadinessHandler;
|
|
29
|
+
linearSync;
|
|
28
30
|
constructor(config, db, linearProvider, codex, enqueueIssue, logger, feed) {
|
|
29
31
|
this.config = config;
|
|
30
32
|
this.db = db;
|
|
@@ -39,6 +41,7 @@ export class WebhookHandler {
|
|
|
39
41
|
this.agentSessionHandler = new AgentSessionHandler(config, db, linearProvider, codex, logger, feed);
|
|
40
42
|
this.desiredStageRecorder = new DesiredStageRecorder(db, linearProvider, feed);
|
|
41
43
|
this.contextLoader = new WebhookContextLoader(config, linearProvider);
|
|
44
|
+
this.linearSync = new LinearSessionSync(config, db, linearProvider, logger, feed);
|
|
42
45
|
this.dependencyReadinessHandler = new DependencyReadinessHandler(db, (projectId, issueId) => this.peekPendingSessionWakeRunType(projectId, issueId));
|
|
43
46
|
}
|
|
44
47
|
async processWebhookEvent(webhookEventId) {
|
|
@@ -114,6 +117,9 @@ export class WebhookHandler {
|
|
|
114
117
|
});
|
|
115
118
|
const trackedIssue = result.issue;
|
|
116
119
|
const newlyReadyDependents = this.dependencyReadinessHandler.reconcile(project.id, issue.id);
|
|
120
|
+
const syncTargets = new Set(shouldSyncLinearStateAfterWebhook(hydrated.triggerEvent)
|
|
121
|
+
? [issue.id, ...newlyReadyDependents]
|
|
122
|
+
: newlyReadyDependents);
|
|
117
123
|
// Handle issue removal: release active runs, mark as failed.
|
|
118
124
|
if (hydrated.triggerEvent === "issueRemoved") {
|
|
119
125
|
await this.issueRemovalHandler.handle({
|
|
@@ -172,6 +178,13 @@ export class WebhookHandler {
|
|
|
172
178
|
detail: `All blockers are now done for ${dependent?.issueKey ?? dependentIssueId}.`,
|
|
173
179
|
});
|
|
174
180
|
}
|
|
181
|
+
for (const issueId of syncTargets) {
|
|
182
|
+
const syncIssue = this.db.getIssue(project.id, issueId);
|
|
183
|
+
if (!syncIssue) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
await this.linearSync.syncSession(syncIssue);
|
|
187
|
+
}
|
|
175
188
|
}
|
|
176
189
|
catch (error) {
|
|
177
190
|
this.db.webhookEvents.markWebhookProcessed(webhookEventId, "failed");
|
|
@@ -233,3 +246,10 @@ export class WebhookHandler {
|
|
|
233
246
|
return Boolean(statusNote?.endsWith("?"));
|
|
234
247
|
}
|
|
235
248
|
}
|
|
249
|
+
function shouldSyncLinearStateAfterWebhook(triggerEvent) {
|
|
250
|
+
return triggerEvent !== "agentSessionCreated"
|
|
251
|
+
&& triggerEvent !== "agentPrompted"
|
|
252
|
+
&& triggerEvent !== "commentCreated"
|
|
253
|
+
&& triggerEvent !== "commentUpdated"
|
|
254
|
+
&& triggerEvent !== "commentRemoved";
|
|
255
|
+
}
|