patchrelay 0.36.7 → 0.36.9
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/cluster-health.js +2 -2
- package/dist/cli/data.js +20 -20
- package/dist/db/issue-session-store.js +284 -0
- package/dist/db/issue-store.js +559 -0
- package/dist/db/run-store.js +125 -0
- package/dist/db/webhook-event-store.js +71 -0
- package/dist/db.js +52 -1138
- package/dist/github-webhook-handler.js +44 -44
- package/dist/idle-reconciliation.js +20 -20
- package/dist/interrupted-run-recovery.js +176 -0
- package/dist/issue-query-service.js +13 -13
- package/dist/issue-session-lease-service.js +143 -0
- package/dist/issue-session-projector.js +114 -0
- package/dist/linear-session-sync.js +10 -10
- package/dist/queue-health-monitor.js +5 -5
- package/dist/run-completion-policy.js +412 -0
- package/dist/run-finalizer.js +172 -0
- package/dist/run-launcher.js +193 -0
- package/dist/run-orchestrator.js +145 -1505
- package/dist/run-recovery-service.js +209 -0
- package/dist/run-wake-planner.js +101 -0
- package/dist/service.js +33 -33
- package/dist/tracked-issue-projector.js +69 -0
- package/dist/webhook-handler.js +64 -693
- package/dist/webhooks/agent-session-handler.js +212 -0
- package/dist/webhooks/comment-policy.js +41 -0
- package/dist/webhooks/comment-wake-handler.js +133 -0
- package/dist/webhooks/decision-helpers.js +74 -0
- package/dist/webhooks/desired-stage-recorder.js +177 -0
- package/dist/webhooks/issue-removal-handler.js +68 -0
- package/dist/worktree-manager.js +69 -0
- package/package.json +1 -1
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { buildRunFailureActivity } from "./linear-session-reporting.js";
|
|
2
|
+
const DEFAULT_ZOMBIE_RECOVERY_BUDGET = 5;
|
|
3
|
+
const ZOMBIE_RECOVERY_BASE_DELAY_MS = 15_000;
|
|
4
|
+
export class RunRecoveryService {
|
|
5
|
+
db;
|
|
6
|
+
logger;
|
|
7
|
+
linearSync;
|
|
8
|
+
withHeldLease;
|
|
9
|
+
getHeldLease;
|
|
10
|
+
appendWakeEventWithLease;
|
|
11
|
+
releaseLease;
|
|
12
|
+
enqueueIssue;
|
|
13
|
+
resolveBranchOwnerForStateTransition;
|
|
14
|
+
feed;
|
|
15
|
+
constructor(db, logger, linearSync, withHeldLease, getHeldLease, appendWakeEventWithLease, releaseLease, enqueueIssue, resolveBranchOwnerForStateTransition, feed) {
|
|
16
|
+
this.db = db;
|
|
17
|
+
this.logger = logger;
|
|
18
|
+
this.linearSync = linearSync;
|
|
19
|
+
this.withHeldLease = withHeldLease;
|
|
20
|
+
this.getHeldLease = getHeldLease;
|
|
21
|
+
this.appendWakeEventWithLease = appendWakeEventWithLease;
|
|
22
|
+
this.releaseLease = releaseLease;
|
|
23
|
+
this.enqueueIssue = enqueueIssue;
|
|
24
|
+
this.resolveBranchOwnerForStateTransition = resolveBranchOwnerForStateTransition;
|
|
25
|
+
this.feed = feed;
|
|
26
|
+
}
|
|
27
|
+
recoverOrEscalate(params) {
|
|
28
|
+
const { issue, runType, reason } = params;
|
|
29
|
+
const fresh = this.db.issues.getIssue(issue.projectId, issue.linearIssueId);
|
|
30
|
+
if (!fresh)
|
|
31
|
+
return;
|
|
32
|
+
if (params.isRequestedChangesRunType(runType)) {
|
|
33
|
+
const updated = this.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
|
|
34
|
+
this.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
|
|
35
|
+
this.db.issueSessions.upsertIssueWithLease(lease, {
|
|
36
|
+
projectId: fresh.projectId,
|
|
37
|
+
linearIssueId: fresh.linearIssueId,
|
|
38
|
+
pendingRunType: null,
|
|
39
|
+
pendingRunContextJson: null,
|
|
40
|
+
factoryState: "escalated",
|
|
41
|
+
});
|
|
42
|
+
return true;
|
|
43
|
+
});
|
|
44
|
+
if (!updated) {
|
|
45
|
+
this.logger.warn({ issueKey: fresh.issueKey, reason }, "Skipping review-fix recovery escalation after losing issue-session lease");
|
|
46
|
+
this.releaseLease(fresh.projectId, fresh.linearIssueId);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
this.logger.warn({ issueKey: fresh.issueKey, reason }, "Requested-changes run failed before a new head was published - escalating");
|
|
50
|
+
this.feed?.publish({
|
|
51
|
+
level: "error",
|
|
52
|
+
kind: "workflow",
|
|
53
|
+
issueKey: fresh.issueKey,
|
|
54
|
+
projectId: fresh.projectId,
|
|
55
|
+
stage: runType,
|
|
56
|
+
status: "escalated",
|
|
57
|
+
summary: `Requested-changes run failed before publishing a new head (${reason})`,
|
|
58
|
+
});
|
|
59
|
+
this.releaseLease(fresh.projectId, fresh.linearIssueId);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (fresh.prState === "merged") {
|
|
63
|
+
const updated = this.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
|
|
64
|
+
this.db.issueSessions.upsertIssueWithLease(lease, {
|
|
65
|
+
projectId: fresh.projectId,
|
|
66
|
+
linearIssueId: fresh.linearIssueId,
|
|
67
|
+
factoryState: "done",
|
|
68
|
+
zombieRecoveryAttempts: 0,
|
|
69
|
+
lastZombieRecoveryAt: null,
|
|
70
|
+
});
|
|
71
|
+
return true;
|
|
72
|
+
});
|
|
73
|
+
if (!updated) {
|
|
74
|
+
this.logger.warn({ issueKey: fresh.issueKey, reason }, "Skipping merged recovery completion after losing issue-session lease");
|
|
75
|
+
this.releaseLease(fresh.projectId, fresh.linearIssueId);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
this.logger.info({ issueKey: fresh.issueKey, reason }, "Recovery: PR already merged - transitioning to done");
|
|
79
|
+
this.releaseLease(fresh.projectId, fresh.linearIssueId);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const attempts = fresh.zombieRecoveryAttempts + 1;
|
|
83
|
+
if (attempts > DEFAULT_ZOMBIE_RECOVERY_BUDGET) {
|
|
84
|
+
const updated = this.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
|
|
85
|
+
this.db.issueSessions.upsertIssueWithLease(lease, {
|
|
86
|
+
projectId: fresh.projectId,
|
|
87
|
+
linearIssueId: fresh.linearIssueId,
|
|
88
|
+
factoryState: "escalated",
|
|
89
|
+
});
|
|
90
|
+
return true;
|
|
91
|
+
});
|
|
92
|
+
if (!updated) {
|
|
93
|
+
this.logger.warn({ issueKey: fresh.issueKey, attempts, reason }, "Skipping recovery escalation after losing issue-session lease");
|
|
94
|
+
this.releaseLease(fresh.projectId, fresh.linearIssueId);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
this.logger.warn({ issueKey: fresh.issueKey, attempts, reason }, "Recovery: budget exhausted - escalating");
|
|
98
|
+
this.feed?.publish({
|
|
99
|
+
level: "error",
|
|
100
|
+
kind: "workflow",
|
|
101
|
+
issueKey: fresh.issueKey,
|
|
102
|
+
projectId: fresh.projectId,
|
|
103
|
+
stage: "escalated",
|
|
104
|
+
status: "budget_exhausted",
|
|
105
|
+
summary: `${reason} recovery failed after ${DEFAULT_ZOMBIE_RECOVERY_BUDGET} attempts`,
|
|
106
|
+
});
|
|
107
|
+
this.releaseLease(fresh.projectId, fresh.linearIssueId);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (fresh.lastZombieRecoveryAt) {
|
|
111
|
+
const elapsed = Date.now() - new Date(fresh.lastZombieRecoveryAt).getTime();
|
|
112
|
+
const delay = ZOMBIE_RECOVERY_BASE_DELAY_MS * Math.pow(2, fresh.zombieRecoveryAttempts);
|
|
113
|
+
if (elapsed < delay) {
|
|
114
|
+
this.logger.debug({ issueKey: fresh.issueKey, attempts: fresh.zombieRecoveryAttempts, delay, elapsed }, "Recovery: backoff not elapsed, skipping");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const requeued = this.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
|
|
119
|
+
this.db.issueSessions.upsertIssueWithLease(lease, {
|
|
120
|
+
projectId: fresh.projectId,
|
|
121
|
+
linearIssueId: fresh.linearIssueId,
|
|
122
|
+
pendingRunType: null,
|
|
123
|
+
pendingRunContextJson: null,
|
|
124
|
+
zombieRecoveryAttempts: attempts,
|
|
125
|
+
lastZombieRecoveryAt: new Date().toISOString(),
|
|
126
|
+
});
|
|
127
|
+
return this.appendWakeEventWithLease(lease, fresh, runType, undefined, `recovery:${attempts}`);
|
|
128
|
+
});
|
|
129
|
+
if (!requeued) {
|
|
130
|
+
this.logger.warn({ issueKey: fresh.issueKey, attempts, reason }, "Skipping recovery re-enqueue after losing issue-session lease");
|
|
131
|
+
this.releaseLease(fresh.projectId, fresh.linearIssueId);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
this.enqueueIssue(fresh.projectId, fresh.linearIssueId);
|
|
135
|
+
this.logger.info({ issueKey: fresh.issueKey, attempts, reason }, "Recovery: re-enqueued with backoff");
|
|
136
|
+
}
|
|
137
|
+
escalate(params) {
|
|
138
|
+
const { issue, runType, reason } = params;
|
|
139
|
+
this.logger.warn({ issueKey: issue.issueKey, runType, reason }, "Escalating to human");
|
|
140
|
+
const escalated = this.withHeldLease(issue.projectId, issue.linearIssueId, (lease) => {
|
|
141
|
+
if (issue.activeRunId) {
|
|
142
|
+
this.db.issueSessions.finishRunWithLease(lease, issue.activeRunId, { status: "released" });
|
|
143
|
+
}
|
|
144
|
+
this.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
|
|
145
|
+
this.db.issueSessions.upsertIssueWithLease(lease, {
|
|
146
|
+
projectId: issue.projectId,
|
|
147
|
+
linearIssueId: issue.linearIssueId,
|
|
148
|
+
pendingRunType: null,
|
|
149
|
+
pendingRunContextJson: null,
|
|
150
|
+
activeRunId: null,
|
|
151
|
+
factoryState: "escalated",
|
|
152
|
+
});
|
|
153
|
+
return true;
|
|
154
|
+
});
|
|
155
|
+
if (!escalated) {
|
|
156
|
+
this.logger.warn({ issueKey: issue.issueKey, runType }, "Skipping escalation write after losing issue-session lease");
|
|
157
|
+
this.releaseLease(issue.projectId, issue.linearIssueId);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
this.feed?.publish({
|
|
161
|
+
level: "error",
|
|
162
|
+
kind: "workflow",
|
|
163
|
+
issueKey: issue.issueKey,
|
|
164
|
+
projectId: issue.projectId,
|
|
165
|
+
stage: runType,
|
|
166
|
+
status: "escalated",
|
|
167
|
+
summary: `Escalated: ${reason}`,
|
|
168
|
+
});
|
|
169
|
+
const escalatedIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
170
|
+
void this.linearSync.emitActivity(escalatedIssue, {
|
|
171
|
+
type: "error",
|
|
172
|
+
body: `PatchRelay needs human help to continue.\n\n${reason}`,
|
|
173
|
+
});
|
|
174
|
+
void this.linearSync.syncSession(escalatedIssue);
|
|
175
|
+
this.releaseLease(issue.projectId, issue.linearIssueId);
|
|
176
|
+
}
|
|
177
|
+
failRunAndClear(params) {
|
|
178
|
+
const { run, message, nextState } = params;
|
|
179
|
+
const updated = this.withHeldLease(run.projectId, run.linearIssueId, (lease) => {
|
|
180
|
+
this.db.runs.finishRun(run.id, { status: "failed", failureReason: message });
|
|
181
|
+
if (nextState === "failed" || nextState === "escalated" || nextState === "awaiting_input" || nextState === "done") {
|
|
182
|
+
this.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
|
|
183
|
+
}
|
|
184
|
+
this.db.issues.upsertIssue({
|
|
185
|
+
projectId: run.projectId,
|
|
186
|
+
linearIssueId: run.linearIssueId,
|
|
187
|
+
activeRunId: null,
|
|
188
|
+
factoryState: nextState,
|
|
189
|
+
});
|
|
190
|
+
const branchOwner = this.resolveBranchOwnerForStateTransition(nextState);
|
|
191
|
+
if (branchOwner) {
|
|
192
|
+
const heldLease = this.getHeldLease(run.projectId, run.linearIssueId);
|
|
193
|
+
if (heldLease) {
|
|
194
|
+
this.db.issueSessions.setBranchOwnerWithLease(heldLease, branchOwner);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return true;
|
|
198
|
+
});
|
|
199
|
+
if (!updated) {
|
|
200
|
+
this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Skipping failure cleanup after losing issue-session lease");
|
|
201
|
+
}
|
|
202
|
+
this.releaseLease(run.projectId, run.linearIssueId);
|
|
203
|
+
}
|
|
204
|
+
emitInterruptedFailure(runType, issue, message) {
|
|
205
|
+
const latest = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
206
|
+
void this.linearSync.emitActivity(latest, buildRunFailureActivity(runType, message));
|
|
207
|
+
void this.linearSync.syncSession(latest, { activeRunType: runType });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const DEFAULT_CI_REPAIR_BUDGET = 3;
|
|
2
|
+
const DEFAULT_QUEUE_REPAIR_BUDGET = 3;
|
|
3
|
+
const DEFAULT_REVIEW_FIX_BUDGET = 12;
|
|
4
|
+
export class RunWakePlanner {
|
|
5
|
+
db;
|
|
6
|
+
constructor(db) {
|
|
7
|
+
this.db = db;
|
|
8
|
+
}
|
|
9
|
+
resolveRunWake(issue) {
|
|
10
|
+
const sessionWake = this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId);
|
|
11
|
+
if (!sessionWake)
|
|
12
|
+
return undefined;
|
|
13
|
+
return {
|
|
14
|
+
runType: sessionWake.runType,
|
|
15
|
+
context: sessionWake.context,
|
|
16
|
+
wakeReason: sessionWake.wakeReason,
|
|
17
|
+
resumeThread: sessionWake.resumeThread,
|
|
18
|
+
eventIds: sessionWake.eventIds,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
appendWakeEventWithLease(lease, issue, runType, context, dedupeScope) {
|
|
22
|
+
let eventType;
|
|
23
|
+
let dedupeKey;
|
|
24
|
+
if (runType === "queue_repair") {
|
|
25
|
+
eventType = "merge_steward_incident";
|
|
26
|
+
dedupeKey = `${dedupeScope ?? "wake"}:queue_repair:${issue.linearIssueId}:${issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown-sha"}`;
|
|
27
|
+
}
|
|
28
|
+
else if (runType === "ci_repair") {
|
|
29
|
+
eventType = "settled_red_ci";
|
|
30
|
+
dedupeKey = `${dedupeScope ?? "wake"}:ci_repair:${issue.linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? "unknown-sha"}`;
|
|
31
|
+
}
|
|
32
|
+
else if (runType === "review_fix" || runType === "branch_upkeep") {
|
|
33
|
+
eventType = "review_changes_requested";
|
|
34
|
+
dedupeKey = `${dedupeScope ?? "wake"}:${runType}:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown-sha"}`;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
eventType = "delegated";
|
|
38
|
+
dedupeKey = `${dedupeScope ?? "wake"}:implementation:${issue.linearIssueId}`;
|
|
39
|
+
}
|
|
40
|
+
return Boolean(this.db.issueSessions.appendIssueSessionEventWithLease(lease, {
|
|
41
|
+
projectId: issue.projectId,
|
|
42
|
+
linearIssueId: issue.linearIssueId,
|
|
43
|
+
eventType,
|
|
44
|
+
...(context ? { eventJson: JSON.stringify(context) } : {}),
|
|
45
|
+
dedupeKey,
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
materializeLegacyPendingWake(issue, lease) {
|
|
49
|
+
if (!issue.pendingRunType)
|
|
50
|
+
return issue;
|
|
51
|
+
const context = issue.pendingRunContextJson
|
|
52
|
+
? JSON.parse(issue.pendingRunContextJson)
|
|
53
|
+
: undefined;
|
|
54
|
+
this.appendWakeEventWithLease(lease, issue, issue.pendingRunType, context, "legacy_pending");
|
|
55
|
+
const updated = this.db.issueSessions.upsertIssueWithLease(lease, {
|
|
56
|
+
projectId: issue.projectId,
|
|
57
|
+
linearIssueId: issue.linearIssueId,
|
|
58
|
+
pendingRunType: null,
|
|
59
|
+
pendingRunContextJson: null,
|
|
60
|
+
});
|
|
61
|
+
if (!updated)
|
|
62
|
+
return issue;
|
|
63
|
+
return this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
64
|
+
}
|
|
65
|
+
budgetExceeded(issue, runType, isRequestedChangesRunType) {
|
|
66
|
+
if (runType === "ci_repair" && issue.ciRepairAttempts >= DEFAULT_CI_REPAIR_BUDGET) {
|
|
67
|
+
return `CI repair budget exhausted (${DEFAULT_CI_REPAIR_BUDGET} attempts)`;
|
|
68
|
+
}
|
|
69
|
+
if (runType === "queue_repair" && issue.queueRepairAttempts >= DEFAULT_QUEUE_REPAIR_BUDGET) {
|
|
70
|
+
return `Queue repair budget exhausted (${DEFAULT_QUEUE_REPAIR_BUDGET} attempts)`;
|
|
71
|
+
}
|
|
72
|
+
if (isRequestedChangesRunType(runType) && issue.reviewFixAttempts >= DEFAULT_REVIEW_FIX_BUDGET) {
|
|
73
|
+
return `Requested-changes budget exhausted (${DEFAULT_REVIEW_FIX_BUDGET} attempts)`;
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
incrementAttemptCounters(issue, lease, runType, isRequestedChangesRunType) {
|
|
78
|
+
if (runType === "ci_repair") {
|
|
79
|
+
return Boolean(this.db.issueSessions.upsertIssueWithLease(lease, {
|
|
80
|
+
projectId: issue.projectId,
|
|
81
|
+
linearIssueId: issue.linearIssueId,
|
|
82
|
+
ciRepairAttempts: issue.ciRepairAttempts + 1,
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
if (runType === "queue_repair") {
|
|
86
|
+
return Boolean(this.db.issueSessions.upsertIssueWithLease(lease, {
|
|
87
|
+
projectId: issue.projectId,
|
|
88
|
+
linearIssueId: issue.linearIssueId,
|
|
89
|
+
queueRepairAttempts: issue.queueRepairAttempts + 1,
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
if (isRequestedChangesRunType(runType)) {
|
|
93
|
+
return Boolean(this.db.issueSessions.upsertIssueWithLease(lease, {
|
|
94
|
+
projectId: issue.projectId,
|
|
95
|
+
linearIssueId: issue.linearIssueId,
|
|
96
|
+
reviewFixAttempts: issue.reviewFixAttempts + 1,
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
}
|
package/dist/service.js
CHANGED
|
@@ -132,7 +132,7 @@ export class PatchRelayService {
|
|
|
132
132
|
});
|
|
133
133
|
}
|
|
134
134
|
async start() {
|
|
135
|
-
this.db.releaseExpiredIssueSessionLeases();
|
|
135
|
+
this.db.issueSessions.releaseExpiredIssueSessionLeases();
|
|
136
136
|
const repairedInstallations = this.db.linearInstallations.repairProjectInstallations(this.config.projects.map((project) => project.id));
|
|
137
137
|
for (const repair of repairedInstallations) {
|
|
138
138
|
this.logger.info({ projectId: repair.projectId, installationId: repair.installationId, reason: repair.reason }, "Repaired Linear project installation link");
|
|
@@ -182,16 +182,16 @@ export class PatchRelayService {
|
|
|
182
182
|
await this.runtime.stop();
|
|
183
183
|
}
|
|
184
184
|
async syncKnownAgentSessions() {
|
|
185
|
-
for (const issue of this.db.listIssues()) {
|
|
185
|
+
for (const issue of this.db.issues.listIssues()) {
|
|
186
186
|
if (issue.factoryState === "done") {
|
|
187
187
|
continue;
|
|
188
188
|
}
|
|
189
189
|
const syncedIssue = issue.agentSessionId
|
|
190
190
|
? issue
|
|
191
191
|
: (() => {
|
|
192
|
-
const recoveredAgentSessionId = this.db.findLatestAgentSessionIdForIssue(issue.linearIssueId);
|
|
192
|
+
const recoveredAgentSessionId = this.db.webhookEvents.findLatestAgentSessionIdForIssue(issue.linearIssueId);
|
|
193
193
|
return recoveredAgentSessionId
|
|
194
|
-
? this.db.upsertIssue({
|
|
194
|
+
? this.db.issues.upsertIssue({
|
|
195
195
|
projectId: issue.projectId,
|
|
196
196
|
linearIssueId: issue.linearIssueId,
|
|
197
197
|
agentSessionId: recoveredAgentSessionId,
|
|
@@ -201,12 +201,12 @@ export class PatchRelayService {
|
|
|
201
201
|
if (!syncedIssue.agentSessionId) {
|
|
202
202
|
continue;
|
|
203
203
|
}
|
|
204
|
-
const activeRun = syncedIssue.activeRunId ? this.db.
|
|
204
|
+
const activeRun = syncedIssue.activeRunId ? this.db.runs.getRunById(syncedIssue.activeRunId) : undefined;
|
|
205
205
|
await this.orchestrator.linearSync.syncSession(syncedIssue, activeRun ? { activeRunType: activeRun.runType } : undefined);
|
|
206
206
|
}
|
|
207
207
|
}
|
|
208
208
|
async recoverDelegatedIssueStateFromLinear() {
|
|
209
|
-
for (const issue of this.db.listIssuesWithAgentSessions()) {
|
|
209
|
+
for (const issue of this.db.issues.listIssuesWithAgentSessions()) {
|
|
210
210
|
if (issue.factoryState === "done" || issue.activeRunId !== undefined) {
|
|
211
211
|
continue;
|
|
212
212
|
}
|
|
@@ -222,7 +222,7 @@ export class PatchRelayService {
|
|
|
222
222
|
if (!liveIssue) {
|
|
223
223
|
continue;
|
|
224
224
|
}
|
|
225
|
-
this.db.replaceIssueDependencies({
|
|
225
|
+
this.db.issues.replaceIssueDependencies({
|
|
226
226
|
projectId: issue.projectId,
|
|
227
227
|
linearIssueId: issue.linearIssueId,
|
|
228
228
|
blockers: liveIssue.blockedBy.map((blocker) => ({
|
|
@@ -234,11 +234,11 @@ export class PatchRelayService {
|
|
|
234
234
|
})),
|
|
235
235
|
});
|
|
236
236
|
const delegated = liveIssue.delegateId === installation.actorId;
|
|
237
|
-
const unresolvedBlockers = this.db.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
|
|
237
|
+
const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
|
|
238
238
|
const shouldRecoverAwaitingInput = delegated
|
|
239
239
|
&& issue.factoryState === "awaiting_input"
|
|
240
|
-
&& this.db.peekIssueSessionWake(issue.projectId, issue.linearIssueId) === undefined;
|
|
241
|
-
const updated = this.db.upsertIssue({
|
|
240
|
+
&& this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId) === undefined;
|
|
241
|
+
const updated = this.db.issues.upsertIssue({
|
|
242
242
|
projectId: issue.projectId,
|
|
243
243
|
linearIssueId: issue.linearIssueId,
|
|
244
244
|
...(liveIssue.identifier ? { issueKey: liveIssue.identifier } : {}),
|
|
@@ -255,13 +255,13 @@ export class PatchRelayService {
|
|
|
255
255
|
continue;
|
|
256
256
|
}
|
|
257
257
|
if (unresolvedBlockers === 0) {
|
|
258
|
-
this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
258
|
+
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
259
259
|
projectId: issue.projectId,
|
|
260
260
|
linearIssueId: issue.linearIssueId,
|
|
261
261
|
eventType: "delegated",
|
|
262
262
|
dedupeKey: `delegated:${issue.linearIssueId}`,
|
|
263
263
|
});
|
|
264
|
-
if (this.db.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
|
|
264
|
+
if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
|
|
265
265
|
this.runtime.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
266
266
|
}
|
|
267
267
|
this.logger.info({ issueKey: updated.issueKey }, "Recovered delegated issue from stale awaiting_input state and re-queued implementation");
|
|
@@ -427,7 +427,7 @@ export class PatchRelayService {
|
|
|
427
427
|
const blockedByCount = Number(row.blocked_by_count ?? 0);
|
|
428
428
|
const hasPendingSessionEvents = Number(row.pending_session_event_count ?? 0) > 0;
|
|
429
429
|
const hasPendingWake = hasPendingSessionEvents
|
|
430
|
-
|| this.db.peekIssueSessionWake(String(row.project_id), String(row.linear_issue_id)) !== undefined;
|
|
430
|
+
|| this.db.issueSessions.peekIssueSessionWake(String(row.project_id), String(row.linear_issue_id)) !== undefined;
|
|
431
431
|
const readyForExecution = isIssueSessionReadyForExecution({
|
|
432
432
|
...(typeof row.session_state === "string" ? { sessionState: String(row.session_state) } : {}),
|
|
433
433
|
factoryState: String(row.factory_state ?? "delegated"),
|
|
@@ -473,7 +473,7 @@ export class PatchRelayService {
|
|
|
473
473
|
startedAt: String(row.updated_at),
|
|
474
474
|
}
|
|
475
475
|
: undefined;
|
|
476
|
-
const latestEvent = this.db.listIssueSessionEvents(String(row.project_id), String(row.linear_issue_id), { limit: 1 }).at(-1);
|
|
476
|
+
const latestEvent = this.db.issueSessions.listIssueSessionEvents(String(row.project_id), String(row.linear_issue_id), { limit: 1 }).at(-1);
|
|
477
477
|
const statusNoteCandidate = deriveIssueStatusNote({
|
|
478
478
|
issue: { factoryState: String(row.factory_state ?? "delegated") },
|
|
479
479
|
sessionSummary,
|
|
@@ -520,7 +520,7 @@ export class PatchRelayService {
|
|
|
520
520
|
});
|
|
521
521
|
}
|
|
522
522
|
async promptIssue(issueKey, text, source = "watch") {
|
|
523
|
-
const issue = this.db.getIssueByKey(issueKey);
|
|
523
|
+
const issue = this.db.issues.getIssueByKey(issueKey);
|
|
524
524
|
if (!issue)
|
|
525
525
|
return undefined;
|
|
526
526
|
// Publish to operator feed so all clients see the prompt
|
|
@@ -536,7 +536,7 @@ export class PatchRelayService {
|
|
|
536
536
|
});
|
|
537
537
|
// If no active run, queue as pending context for the next run
|
|
538
538
|
if (!issue.activeRunId) {
|
|
539
|
-
this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
539
|
+
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
540
540
|
projectId: issue.projectId,
|
|
541
541
|
linearIssueId: issue.linearIssueId,
|
|
542
542
|
eventType: "operator_prompt",
|
|
@@ -545,7 +545,7 @@ export class PatchRelayService {
|
|
|
545
545
|
this.runtime.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
546
546
|
return { delivered: false, queued: true };
|
|
547
547
|
}
|
|
548
|
-
const run = this.db.
|
|
548
|
+
const run = this.db.runs.getRunById(issue.activeRunId);
|
|
549
549
|
if (!run?.threadId || !run.turnId) {
|
|
550
550
|
return { error: "Active run has no thread or turn yet" };
|
|
551
551
|
}
|
|
@@ -561,7 +561,7 @@ export class PatchRelayService {
|
|
|
561
561
|
// Turn may have completed between check and steer — queue for next run
|
|
562
562
|
const msg = error instanceof Error ? error.message : String(error);
|
|
563
563
|
this.logger.warn({ issueKey, error: msg }, "steerTurn failed, queuing prompt for next run");
|
|
564
|
-
this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
564
|
+
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
565
565
|
projectId: issue.projectId,
|
|
566
566
|
linearIssueId: issue.linearIssueId,
|
|
567
567
|
eventType: "operator_prompt",
|
|
@@ -572,12 +572,12 @@ export class PatchRelayService {
|
|
|
572
572
|
}
|
|
573
573
|
}
|
|
574
574
|
async stopIssue(issueKey) {
|
|
575
|
-
const issue = this.db.getIssueByKey(issueKey);
|
|
575
|
+
const issue = this.db.issues.getIssueByKey(issueKey);
|
|
576
576
|
if (!issue)
|
|
577
577
|
return undefined;
|
|
578
578
|
if (!issue.activeRunId)
|
|
579
579
|
return { error: "No active run to stop" };
|
|
580
|
-
const run = this.db.
|
|
580
|
+
const run = this.db.runs.getRunById(issue.activeRunId);
|
|
581
581
|
if (run?.threadId && run.turnId) {
|
|
582
582
|
try {
|
|
583
583
|
await this.codex.steerTurn({
|
|
@@ -590,14 +590,14 @@ export class PatchRelayService {
|
|
|
590
590
|
// Turn may already be done
|
|
591
591
|
}
|
|
592
592
|
}
|
|
593
|
-
this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
593
|
+
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
594
594
|
projectId: issue.projectId,
|
|
595
595
|
linearIssueId: issue.linearIssueId,
|
|
596
596
|
eventType: "stop_requested",
|
|
597
597
|
dedupeKey: `operator_stop:${issue.linearIssueId}`,
|
|
598
598
|
});
|
|
599
|
-
this.db.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
600
|
-
this.db.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
599
|
+
this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
600
|
+
this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
601
601
|
projectId: issue.projectId,
|
|
602
602
|
linearIssueId: issue.linearIssueId,
|
|
603
603
|
factoryState: "awaiting_input",
|
|
@@ -613,14 +613,14 @@ export class PatchRelayService {
|
|
|
613
613
|
return { stopped: true };
|
|
614
614
|
}
|
|
615
615
|
retryIssue(issueKey) {
|
|
616
|
-
const issue = this.db.getIssueByKey(issueKey);
|
|
616
|
+
const issue = this.db.issues.getIssueByKey(issueKey);
|
|
617
617
|
if (!issue)
|
|
618
618
|
return undefined;
|
|
619
619
|
if (issue.activeRunId)
|
|
620
620
|
return { error: "Issue already has an active run" };
|
|
621
|
-
const issueSession = this.db.getIssueSession(issue.projectId, issue.linearIssueId);
|
|
621
|
+
const issueSession = this.db.issueSessions.getIssueSession(issue.projectId, issue.linearIssueId);
|
|
622
622
|
if (issue.prState === "merged") {
|
|
623
|
-
this.db.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
623
|
+
this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
624
624
|
projectId: issue.projectId,
|
|
625
625
|
linearIssueId: issue.linearIssueId,
|
|
626
626
|
factoryState: "done",
|
|
@@ -650,7 +650,7 @@ export class PatchRelayService {
|
|
|
650
650
|
factoryState = "implementing";
|
|
651
651
|
}
|
|
652
652
|
this.appendOperatorRetryEvent(issue, runType);
|
|
653
|
-
this.db.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
653
|
+
this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
654
654
|
projectId: issue.projectId,
|
|
655
655
|
linearIssueId: issue.linearIssueId,
|
|
656
656
|
factoryState: factoryState,
|
|
@@ -664,7 +664,7 @@ export class PatchRelayService {
|
|
|
664
664
|
status: "retry",
|
|
665
665
|
summary: `Retry queued: ${runType}`,
|
|
666
666
|
});
|
|
667
|
-
if (this.db.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
|
|
667
|
+
if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
|
|
668
668
|
this.runtime.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
669
669
|
}
|
|
670
670
|
return { issueKey, runType };
|
|
@@ -673,7 +673,7 @@ export class PatchRelayService {
|
|
|
673
673
|
if (runType === "queue_repair") {
|
|
674
674
|
const queueIncident = parseObjectJson(issue.lastQueueIncidentJson);
|
|
675
675
|
const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson);
|
|
676
|
-
this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
676
|
+
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
677
677
|
projectId: issue.projectId,
|
|
678
678
|
linearIssueId: issue.linearIssueId,
|
|
679
679
|
eventType: "merge_steward_incident",
|
|
@@ -688,7 +688,7 @@ export class PatchRelayService {
|
|
|
688
688
|
}
|
|
689
689
|
if (runType === "ci_repair") {
|
|
690
690
|
const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson);
|
|
691
|
-
this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
691
|
+
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
692
692
|
projectId: issue.projectId,
|
|
693
693
|
linearIssueId: issue.linearIssueId,
|
|
694
694
|
eventType: "settled_red_ci",
|
|
@@ -701,7 +701,7 @@ export class PatchRelayService {
|
|
|
701
701
|
return;
|
|
702
702
|
}
|
|
703
703
|
if (runType === "review_fix" || runType === "branch_upkeep") {
|
|
704
|
-
this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
704
|
+
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
705
705
|
projectId: issue.projectId,
|
|
706
706
|
linearIssueId: issue.linearIssueId,
|
|
707
707
|
eventType: "review_changes_requested",
|
|
@@ -716,7 +716,7 @@ export class PatchRelayService {
|
|
|
716
716
|
});
|
|
717
717
|
return;
|
|
718
718
|
}
|
|
719
|
-
this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
719
|
+
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
720
720
|
projectId: issue.projectId,
|
|
721
721
|
linearIssueId: issue.linearIssueId,
|
|
722
722
|
eventType: "delegated",
|
|
@@ -733,7 +733,7 @@ export class PatchRelayService {
|
|
|
733
733
|
stores: {
|
|
734
734
|
webhookEvents: {
|
|
735
735
|
insertWebhookEvent: (p) => {
|
|
736
|
-
const r = this.db.insertFullWebhookEvent(p);
|
|
736
|
+
const r = this.db.webhookEvents.insertFullWebhookEvent(p);
|
|
737
737
|
return { id: r.id, dedupeStatus: r.dedupeStatus };
|
|
738
738
|
},
|
|
739
739
|
},
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { parseGitHubFailureContext } from "./github-failure-context.js";
|
|
2
|
+
import { isIssueSessionReadyForExecution } from "./issue-session.js";
|
|
3
|
+
import { deriveIssueStatusNote } from "./status-note.js";
|
|
4
|
+
import { derivePatchRelayWaitingReason } from "./waiting-reason.js";
|
|
5
|
+
export function isResolvedLinearState(stateType, stateName) {
|
|
6
|
+
return stateType === "completed" || stateName?.trim().toLowerCase() === "done";
|
|
7
|
+
}
|
|
8
|
+
export function buildTrackedIssueRecord(params) {
|
|
9
|
+
const unresolvedBlockedBy = params.blockedBy.filter((entry) => !isResolvedLinearState(entry.blockerCurrentLinearStateType, entry.blockerCurrentLinearState));
|
|
10
|
+
const failureContext = parseGitHubFailureContext(params.issue.lastGitHubFailureContextJson);
|
|
11
|
+
const blockedByKeys = unresolvedBlockedBy.map((entry) => entry.blockerIssueKey ?? entry.blockerLinearIssueId);
|
|
12
|
+
const waitingReason = derivePatchRelayWaitingReason({
|
|
13
|
+
...(params.issue.activeRunId !== undefined ? { activeRunId: params.issue.activeRunId } : {}),
|
|
14
|
+
blockedByKeys,
|
|
15
|
+
factoryState: params.issue.factoryState,
|
|
16
|
+
pendingRunType: params.issue.pendingRunType,
|
|
17
|
+
prNumber: params.issue.prNumber,
|
|
18
|
+
prHeadSha: params.issue.prHeadSha,
|
|
19
|
+
prReviewState: params.issue.prReviewState,
|
|
20
|
+
prCheckStatus: params.issue.prCheckStatus,
|
|
21
|
+
lastBlockingReviewHeadSha: params.issue.lastBlockingReviewHeadSha,
|
|
22
|
+
latestFailureCheckName: params.issue.lastGitHubFailureCheckName,
|
|
23
|
+
});
|
|
24
|
+
const statusNote = deriveIssueStatusNote({
|
|
25
|
+
issue: params.issue,
|
|
26
|
+
sessionSummary: params.session?.summaryText,
|
|
27
|
+
latestRun: params.latestRun,
|
|
28
|
+
latestEvent: params.latestEvent,
|
|
29
|
+
failureSummary: failureContext?.summary,
|
|
30
|
+
blockedByKeys,
|
|
31
|
+
waitingReason,
|
|
32
|
+
});
|
|
33
|
+
return {
|
|
34
|
+
id: params.issue.id,
|
|
35
|
+
projectId: params.issue.projectId,
|
|
36
|
+
linearIssueId: params.issue.linearIssueId,
|
|
37
|
+
...(params.issue.issueKey ? { issueKey: params.issue.issueKey } : {}),
|
|
38
|
+
...(params.issue.title ? { title: params.issue.title } : {}),
|
|
39
|
+
...(params.issue.url ? { issueUrl: params.issue.url } : {}),
|
|
40
|
+
...(statusNote ? { statusNote } : {}),
|
|
41
|
+
...(params.issue.currentLinearState ? { currentLinearState: params.issue.currentLinearState } : {}),
|
|
42
|
+
...(params.session?.sessionState ? { sessionState: params.session.sessionState } : {}),
|
|
43
|
+
factoryState: params.issue.factoryState,
|
|
44
|
+
blockedByCount: unresolvedBlockedBy.length,
|
|
45
|
+
blockedByKeys,
|
|
46
|
+
readyForExecution: isIssueSessionReadyForExecution({
|
|
47
|
+
sessionState: params.session?.sessionState,
|
|
48
|
+
factoryState: params.issue.factoryState,
|
|
49
|
+
activeRunId: params.issue.activeRunId,
|
|
50
|
+
blockedByCount: unresolvedBlockedBy.length,
|
|
51
|
+
hasPendingWake: params.hasPendingWake,
|
|
52
|
+
hasLegacyPendingRun: params.issue.pendingRunType !== undefined,
|
|
53
|
+
...(params.issue.prNumber !== undefined ? { prNumber: params.issue.prNumber } : {}),
|
|
54
|
+
...(params.issue.prState ? { prState: params.issue.prState } : {}),
|
|
55
|
+
...(params.issue.prReviewState ? { prReviewState: params.issue.prReviewState } : {}),
|
|
56
|
+
...(params.issue.prCheckStatus ? { prCheckStatus: params.issue.prCheckStatus } : {}),
|
|
57
|
+
...(params.issue.lastGitHubFailureSource ? { latestFailureSource: params.issue.lastGitHubFailureSource } : {}),
|
|
58
|
+
}),
|
|
59
|
+
...(params.issue.lastGitHubFailureSource ? { latestFailureSource: params.issue.lastGitHubFailureSource } : {}),
|
|
60
|
+
...(params.issue.lastGitHubFailureHeadSha ? { latestFailureHeadSha: params.issue.lastGitHubFailureHeadSha } : {}),
|
|
61
|
+
...(params.issue.lastGitHubFailureCheckName ? { latestFailureCheckName: params.issue.lastGitHubFailureCheckName } : {}),
|
|
62
|
+
...(failureContext?.stepName ? { latestFailureStepName: failureContext.stepName } : {}),
|
|
63
|
+
...(failureContext?.summary ? { latestFailureSummary: failureContext.summary } : {}),
|
|
64
|
+
...(waitingReason ? { waitingReason } : {}),
|
|
65
|
+
...(params.issue.activeRunId !== undefined ? { activeRunId: params.issue.activeRunId } : {}),
|
|
66
|
+
...(params.issue.agentSessionId ? { activeAgentSessionId: params.issue.agentSessionId } : {}),
|
|
67
|
+
updatedAt: params.issue.updatedAt,
|
|
68
|
+
};
|
|
69
|
+
}
|