patchrelay 0.67.2 → 0.68.1
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/codex-app-server.js +26 -0
- package/dist/db/issue-store.js +19 -0
- package/dist/db.js +8 -0
- package/dist/github-pr-comment-handler.js +4 -9
- package/dist/github-webhook-handler.js +12 -7
- package/dist/github-webhook-reactive-run.js +10 -38
- package/dist/github-webhook-stack-coordination.js +5 -2
- package/dist/github-webhook-terminal-handler.js +3 -8
- package/dist/idle-reconciliation.js +31 -18
- package/dist/main-branch-health-monitor.js +5 -15
- package/dist/no-pr-completion-check.js +1 -3
- package/dist/orchestration-parent-wake.js +3 -14
- package/dist/queue-health-monitor.js +1 -6
- package/dist/run-finalizer.js +29 -35
- package/dist/run-launcher.js +48 -0
- package/dist/run-orchestrator.js +58 -14
- package/dist/service.js +15 -3
- package/dist/wake-dispatcher.js +121 -0
- package/dist/webhook-handler.js +19 -17
- package/dist/webhooks/agent-session-handler.js +4 -8
- package/dist/webhooks/comment-wake-handler.js +8 -24
- package/dist/webhooks/dependency-readiness-handler.js +4 -4
- package/dist/webhooks/desired-stage-recorder.js +5 -1
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
package/dist/codex-app-server.js
CHANGED
|
@@ -206,6 +206,20 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
206
206
|
status: String(response.turn.status),
|
|
207
207
|
};
|
|
208
208
|
}
|
|
209
|
+
async setThreadGoal(options) {
|
|
210
|
+
const params = {
|
|
211
|
+
threadId: options.threadId,
|
|
212
|
+
objective: options.objective,
|
|
213
|
+
};
|
|
214
|
+
if (options.status !== undefined) {
|
|
215
|
+
params.status = options.status;
|
|
216
|
+
}
|
|
217
|
+
if (options.tokenBudget !== undefined) {
|
|
218
|
+
params.tokenBudget = options.tokenBudget;
|
|
219
|
+
}
|
|
220
|
+
const response = (await this.sendRequest("thread/goal/set", params));
|
|
221
|
+
return this.mapThreadGoal(response.goal);
|
|
222
|
+
}
|
|
209
223
|
async readThread(threadId, includeTurns = true) {
|
|
210
224
|
const response = (await this.sendRequest("thread/read", {
|
|
211
225
|
threadId,
|
|
@@ -230,6 +244,18 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
230
244
|
],
|
|
231
245
|
});
|
|
232
246
|
}
|
|
247
|
+
mapThreadGoal(goal) {
|
|
248
|
+
return {
|
|
249
|
+
threadId: String(goal.threadId),
|
|
250
|
+
objective: String(goal.objective ?? ""),
|
|
251
|
+
status: String(goal.status ?? "active"),
|
|
252
|
+
...(goal.tokenBudget === null || goal.tokenBudget === undefined ? { tokenBudget: null } : { tokenBudget: Number(goal.tokenBudget) }),
|
|
253
|
+
tokensUsed: Number(goal.tokensUsed ?? 0),
|
|
254
|
+
timeUsedSeconds: Number(goal.timeUsedSeconds ?? 0),
|
|
255
|
+
createdAt: Number(goal.createdAt ?? 0),
|
|
256
|
+
updatedAt: Number(goal.updatedAt ?? 0),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
233
259
|
sendNotification(method, params) {
|
|
234
260
|
this.writeMessage({
|
|
235
261
|
jsonrpc: "2.0",
|
package/dist/db/issue-store.js
CHANGED
|
@@ -416,6 +416,25 @@ export class IssueStore {
|
|
|
416
416
|
.all();
|
|
417
417
|
return rows.map(mapIssueRow);
|
|
418
418
|
}
|
|
419
|
+
// Safety net for orphaned wakes: any delegated, non-terminal issue
|
|
420
|
+
// with at least one unprocessed session event but no active run.
|
|
421
|
+
// The orchestrator's enqueueIssue is the only path that drains these
|
|
422
|
+
// events, and a prior enqueueIssue call can be silently lost (worker
|
|
423
|
+
// race, lease contention, in-memory queue cleared by service restart).
|
|
424
|
+
// The idle reconciler iterates this set and re-enqueues each one.
|
|
425
|
+
listIdleIssuesWithPendingWake() {
|
|
426
|
+
const rows = this.connection
|
|
427
|
+
.prepare(`SELECT DISTINCT i.* FROM issues i
|
|
428
|
+
INNER JOIN issue_session_events e
|
|
429
|
+
ON e.project_id = i.project_id
|
|
430
|
+
AND e.linear_issue_id = i.linear_issue_id
|
|
431
|
+
WHERE e.processed_at IS NULL
|
|
432
|
+
AND i.active_run_id IS NULL
|
|
433
|
+
AND i.delegated_to_patchrelay = 1
|
|
434
|
+
AND i.factory_state NOT IN ('done', 'escalated', 'failed', 'awaiting_input')`)
|
|
435
|
+
.all();
|
|
436
|
+
return rows.map(mapIssueRow);
|
|
437
|
+
}
|
|
419
438
|
listBlockedDelegatedIssues() {
|
|
420
439
|
const rows = this.connection
|
|
421
440
|
.prepare(`SELECT DISTINCT i.* FROM issues i
|
package/dist/db.js
CHANGED
|
@@ -182,6 +182,14 @@ export class PatchRelayDatabase {
|
|
|
182
182
|
listIdleNonTerminalIssues() {
|
|
183
183
|
return this.issues.listIdleNonTerminalIssues();
|
|
184
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Idle delegated issues that still have unprocessed session events.
|
|
187
|
+
* The idle reconciler re-enqueues these to recover from a silently
|
|
188
|
+
* dropped enqueueIssue (lease race, in-memory queue cleared at restart).
|
|
189
|
+
*/
|
|
190
|
+
listIdleIssuesWithPendingWake() {
|
|
191
|
+
return this.issues.listIdleIssuesWithPendingWake();
|
|
192
|
+
}
|
|
185
193
|
/**
|
|
186
194
|
* Issues in delegated state with dependencies but no pending/active run.
|
|
187
195
|
* Candidates for unblocking when their blockers complete.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
export class GitHubPrCommentHandler {
|
|
2
2
|
db;
|
|
3
|
-
|
|
3
|
+
wakeDispatcher;
|
|
4
4
|
logger;
|
|
5
5
|
codex;
|
|
6
6
|
feed;
|
|
7
|
-
constructor(db,
|
|
7
|
+
constructor(db, wakeDispatcher, logger, codex, feed) {
|
|
8
8
|
this.db = db;
|
|
9
|
-
this.
|
|
9
|
+
this.wakeDispatcher = wakeDispatcher;
|
|
10
10
|
this.logger = logger;
|
|
11
11
|
this.codex = codex;
|
|
12
12
|
this.feed = feed;
|
|
@@ -61,14 +61,9 @@ export class GitHubPrCommentHandler {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
-
this.
|
|
65
|
-
projectId: issue.projectId,
|
|
66
|
-
linearIssueId: issue.linearIssueId,
|
|
64
|
+
this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
67
65
|
eventType: "followup_comment",
|
|
68
66
|
eventJson: JSON.stringify({ body, author }),
|
|
69
67
|
});
|
|
70
|
-
if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
|
|
71
|
-
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
72
|
-
}
|
|
73
68
|
}
|
|
74
69
|
}
|
|
@@ -10,11 +10,11 @@ import { maybeEnqueueGitHubReactiveRun } from "./github-webhook-reactive-run.js"
|
|
|
10
10
|
import { maybeRunSequenceBackstop } from "./github-webhook-sequence-backstop.js";
|
|
11
11
|
import { maybeFanChildRebaseWakes } from "./github-webhook-stack-coordination.js";
|
|
12
12
|
import { handleGitHubTerminalPrEvent } from "./github-webhook-terminal-handler.js";
|
|
13
|
+
import { WakeDispatcher } from "./wake-dispatcher.js";
|
|
13
14
|
export class GitHubWebhookHandler {
|
|
14
15
|
config;
|
|
15
16
|
db;
|
|
16
17
|
linearProvider;
|
|
17
|
-
enqueueIssue;
|
|
18
18
|
logger;
|
|
19
19
|
codex;
|
|
20
20
|
feed;
|
|
@@ -22,18 +22,23 @@ export class GitHubWebhookHandler {
|
|
|
22
22
|
ciSnapshotResolver;
|
|
23
23
|
fetchImpl;
|
|
24
24
|
prCommentHandler;
|
|
25
|
-
|
|
25
|
+
wakeDispatcher;
|
|
26
|
+
constructor(config, db, linearProvider, wakeDispatcherOrEnqueueIssue, logger, codex, feed, failureContextResolver = createGitHubFailureContextResolver(), ciSnapshotResolver = createGitHubCiSnapshotResolver(), fetchImpl = fetch) {
|
|
26
27
|
this.config = config;
|
|
27
28
|
this.db = db;
|
|
28
29
|
this.linearProvider = linearProvider;
|
|
29
|
-
this.enqueueIssue = enqueueIssue;
|
|
30
30
|
this.logger = logger;
|
|
31
31
|
this.codex = codex;
|
|
32
32
|
this.feed = feed;
|
|
33
33
|
this.failureContextResolver = failureContextResolver;
|
|
34
34
|
this.ciSnapshotResolver = ciSnapshotResolver;
|
|
35
35
|
this.fetchImpl = fetchImpl;
|
|
36
|
-
|
|
36
|
+
// GitHub webhook handlers never release leases either — see
|
|
37
|
+
// WebhookHandler for the same rationale.
|
|
38
|
+
this.wakeDispatcher = wakeDispatcherOrEnqueueIssue instanceof WakeDispatcher
|
|
39
|
+
? wakeDispatcherOrEnqueueIssue
|
|
40
|
+
: new WakeDispatcher(db, wakeDispatcherOrEnqueueIssue, () => undefined, logger, feed);
|
|
41
|
+
this.prCommentHandler = new GitHubPrCommentHandler(db, this.wakeDispatcher, logger, codex, feed);
|
|
37
42
|
}
|
|
38
43
|
async acceptGitHubWebhook(params) {
|
|
39
44
|
if (this.db.webhookEvents.isWebhookDuplicate(params.deliveryId)) {
|
|
@@ -125,7 +130,7 @@ export class GitHubWebhookHandler {
|
|
|
125
130
|
db: this.db,
|
|
126
131
|
logger: this.logger,
|
|
127
132
|
feed: this.feed,
|
|
128
|
-
|
|
133
|
+
wakeDispatcher: this.wakeDispatcher,
|
|
129
134
|
issue: freshIssue,
|
|
130
135
|
event,
|
|
131
136
|
project,
|
|
@@ -152,7 +157,7 @@ export class GitHubWebhookHandler {
|
|
|
152
157
|
db: this.db,
|
|
153
158
|
logger: this.logger,
|
|
154
159
|
...(this.feed ? { feed: this.feed } : {}),
|
|
155
|
-
|
|
160
|
+
wakeDispatcher: this.wakeDispatcher,
|
|
156
161
|
event,
|
|
157
162
|
});
|
|
158
163
|
}
|
|
@@ -161,7 +166,7 @@ export class GitHubWebhookHandler {
|
|
|
161
166
|
config: this.config,
|
|
162
167
|
db: this.db,
|
|
163
168
|
linearProvider: this.linearProvider,
|
|
164
|
-
|
|
169
|
+
wakeDispatcher: this.wakeDispatcher,
|
|
165
170
|
logger: this.logger,
|
|
166
171
|
codex: this.codex,
|
|
167
172
|
feed: this.feed,
|
|
@@ -4,7 +4,7 @@ import { isIssueTerminal } from "./pr-state.js";
|
|
|
4
4
|
import { buildGitHubQueueFailureContext, getRelevantGitHubCiSnapshot, resolveGitHubBranchFailureContext, resolveGitHubCheckClass, } from "./github-webhook-failure-context.js";
|
|
5
5
|
import { isQueueEvictionFailure, isSettledBranchFailure } from "./github-webhook-policy.js";
|
|
6
6
|
export async function maybeEnqueueGitHubReactiveRun(params) {
|
|
7
|
-
const { issue, event, project, logger, feed,
|
|
7
|
+
const { issue, event, project, logger, feed, wakeDispatcher, db, fetchImpl, failureContextResolver } = params;
|
|
8
8
|
if (isIssueTerminal(issue))
|
|
9
9
|
return;
|
|
10
10
|
if (!issue.delegatedToPatchRelay) {
|
|
@@ -27,7 +27,7 @@ export async function maybeEnqueueGitHubReactiveRun(params) {
|
|
|
27
27
|
db,
|
|
28
28
|
logger,
|
|
29
29
|
feed,
|
|
30
|
-
|
|
30
|
+
wakeDispatcher,
|
|
31
31
|
issue,
|
|
32
32
|
event,
|
|
33
33
|
project,
|
|
@@ -40,7 +40,7 @@ export async function maybeEnqueueGitHubReactiveRun(params) {
|
|
|
40
40
|
db,
|
|
41
41
|
logger,
|
|
42
42
|
feed,
|
|
43
|
-
|
|
43
|
+
wakeDispatcher,
|
|
44
44
|
issue,
|
|
45
45
|
event,
|
|
46
46
|
fetchImpl,
|
|
@@ -48,7 +48,7 @@ export async function maybeEnqueueGitHubReactiveRun(params) {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
async function handleCheckFailedEvent(params) {
|
|
51
|
-
const { db, logger, feed,
|
|
51
|
+
const { db, logger, feed, wakeDispatcher, issue, event, project, failureContextResolver } = params;
|
|
52
52
|
// Plan §4.3: while In Deploy (`awaiting_queue`), branch CI is metadata
|
|
53
53
|
// only — the lander owns admission, and its spec CI on the integration
|
|
54
54
|
// tree is the gate. Queue eviction failures still flow through (they're
|
|
@@ -71,20 +71,14 @@ async function handleCheckFailedEvent(params) {
|
|
|
71
71
|
if (hasDuplicatePendingReactiveRun(db, feed, issue, "queue_repair", failureContext)) {
|
|
72
72
|
return;
|
|
73
73
|
}
|
|
74
|
-
const
|
|
75
|
-
db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
76
|
-
projectId: issue.projectId,
|
|
77
|
-
linearIssueId: issue.linearIssueId,
|
|
74
|
+
const queuedRunType = wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
78
75
|
eventType: "merge_steward_incident",
|
|
79
76
|
eventJson: JSON.stringify({
|
|
80
77
|
...queueRepairContext,
|
|
81
78
|
...failureContext,
|
|
82
79
|
}),
|
|
83
|
-
dedupeKey: failureContext.failureSignature,
|
|
80
|
+
...(failureContext.failureSignature ? { dedupeKey: failureContext.failureSignature } : {}),
|
|
84
81
|
});
|
|
85
|
-
const queuedRunType = hadPendingWake
|
|
86
|
-
? db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)?.runType
|
|
87
|
-
: enqueuePendingSessionWake(db, enqueueIssue, issue.projectId, issue.linearIssueId);
|
|
88
82
|
logger.info({ issueKey: issue.issueKey, checkName: event.checkName }, "Queue eviction detected, enqueued queue repair");
|
|
89
83
|
feed?.publish({
|
|
90
84
|
level: "warn",
|
|
@@ -120,7 +114,6 @@ async function handleCheckFailedEvent(params) {
|
|
|
120
114
|
if (hasDuplicatePendingReactiveRun(db, feed, issue, "ci_repair", failureContext)) {
|
|
121
115
|
return;
|
|
122
116
|
}
|
|
123
|
-
const hadPendingWake = db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
|
|
124
117
|
const snapshot = getRelevantGitHubCiSnapshot(db, issue, event);
|
|
125
118
|
db.issues.upsertIssue({
|
|
126
119
|
projectId: issue.projectId,
|
|
@@ -134,20 +127,15 @@ async function handleCheckFailedEvent(params) {
|
|
|
134
127
|
lastGitHubFailureAt: new Date().toISOString(),
|
|
135
128
|
lastQueueIncidentJson: null,
|
|
136
129
|
});
|
|
137
|
-
|
|
138
|
-
projectId: issue.projectId,
|
|
139
|
-
linearIssueId: issue.linearIssueId,
|
|
130
|
+
const queuedRunType = wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
140
131
|
eventType: "settled_red_ci",
|
|
141
132
|
eventJson: JSON.stringify({
|
|
142
133
|
...failureContext,
|
|
143
134
|
checkClass: resolveGitHubCheckClass(failureContext.checkName ?? event.checkName, project),
|
|
144
135
|
...(snapshot ? { ciSnapshot: snapshot } : {}),
|
|
145
136
|
}),
|
|
146
|
-
dedupeKey: failureContext.failureSignature,
|
|
137
|
+
...(failureContext.failureSignature ? { dedupeKey: failureContext.failureSignature } : {}),
|
|
147
138
|
});
|
|
148
|
-
const queuedRunType = hadPendingWake
|
|
149
|
-
? db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)?.runType
|
|
150
|
-
: enqueuePendingSessionWake(db, enqueueIssue, issue.projectId, issue.linearIssueId);
|
|
151
139
|
logger.info({ issueKey: issue.issueKey, checkName: failureContext.checkName ?? event.checkName }, "Enqueued CI repair run");
|
|
152
140
|
feed?.publish({
|
|
153
141
|
level: "warn",
|
|
@@ -161,8 +149,7 @@ async function handleCheckFailedEvent(params) {
|
|
|
161
149
|
});
|
|
162
150
|
}
|
|
163
151
|
async function handleRequestedChangesEvent(params) {
|
|
164
|
-
const {
|
|
165
|
-
const hadPendingWake = db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
|
|
152
|
+
const { logger, feed, wakeDispatcher, issue, event, fetchImpl } = params;
|
|
166
153
|
const reviewComments = await fetchReviewCommentsForEvent(event, fetchImpl).catch((error) => {
|
|
167
154
|
logger.warn({
|
|
168
155
|
issueKey: issue.issueKey,
|
|
@@ -172,9 +159,7 @@ async function handleRequestedChangesEvent(params) {
|
|
|
172
159
|
}, "Failed to fetch inline review comments for requested-changes event");
|
|
173
160
|
return undefined;
|
|
174
161
|
});
|
|
175
|
-
|
|
176
|
-
projectId: issue.projectId,
|
|
177
|
-
linearIssueId: issue.linearIssueId,
|
|
162
|
+
const queuedRunType = wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
178
163
|
eventType: "review_changes_requested",
|
|
179
164
|
eventJson: JSON.stringify({
|
|
180
165
|
reviewBody: event.reviewBody,
|
|
@@ -190,11 +175,6 @@ async function handleRequestedChangesEvent(params) {
|
|
|
190
175
|
event.reviewerName ?? "unknown-reviewer",
|
|
191
176
|
].join("::"),
|
|
192
177
|
});
|
|
193
|
-
const queuedRunType = hadPendingWake
|
|
194
|
-
? db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)?.runType
|
|
195
|
-
: issue.activeRunId === undefined
|
|
196
|
-
? enqueuePendingSessionWake(db, enqueueIssue, issue.projectId, issue.linearIssueId)
|
|
197
|
-
: db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)?.runType;
|
|
198
178
|
logger.info({
|
|
199
179
|
issueKey: issue.issueKey,
|
|
200
180
|
reviewerName: event.reviewerName,
|
|
@@ -254,14 +234,6 @@ function hasDuplicatePendingReactiveRun(db, feed, issue, runType, failureContext
|
|
|
254
234
|
}
|
|
255
235
|
return false;
|
|
256
236
|
}
|
|
257
|
-
function enqueuePendingSessionWake(db, enqueueIssue, projectId, issueId) {
|
|
258
|
-
const wake = db.issueSessions.peekIssueSessionWake(projectId, issueId);
|
|
259
|
-
if (!wake) {
|
|
260
|
-
return undefined;
|
|
261
|
-
}
|
|
262
|
-
enqueueIssue(projectId, issueId);
|
|
263
|
-
return wake.runType;
|
|
264
|
-
}
|
|
265
237
|
async function fetchReviewCommentsForEvent(event, fetchImpl) {
|
|
266
238
|
if (event.triggerEvent !== "review_changes_requested") {
|
|
267
239
|
return undefined;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// matching child and enqueues a `branch_upkeep` run to rebase the
|
|
5
5
|
// child onto the new parent head.
|
|
6
6
|
export function maybeFanChildRebaseWakes(params) {
|
|
7
|
-
const { db, logger, feed,
|
|
7
|
+
const { db, logger, feed, wakeDispatcher, event } = params;
|
|
8
8
|
if (event.triggerEvent !== "pr_synchronize")
|
|
9
9
|
return;
|
|
10
10
|
if (!event.branchName)
|
|
@@ -24,7 +24,10 @@ export function maybeFanChildRebaseWakes(params) {
|
|
|
24
24
|
linearIssueId: child.linearIssueId,
|
|
25
25
|
pendingRunType: "branch_upkeep",
|
|
26
26
|
});
|
|
27
|
-
|
|
27
|
+
// The pending_run_type field above isn't an event, so we still need
|
|
28
|
+
// an explicit dispatch call. dispatchIfWakePending will pick up the
|
|
29
|
+
// wake derived from the legacy pendingRunType column.
|
|
30
|
+
wakeDispatcher.dispatchIfWakePending(child.projectId, child.linearIssueId);
|
|
28
31
|
logger.info({
|
|
29
32
|
parentBranch: event.branchName,
|
|
30
33
|
parentHeadSha: event.headSha,
|
|
@@ -3,7 +3,7 @@ import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
|
|
|
3
3
|
import { syncGitHubLinearSession } from "./github-linear-session-sync.js";
|
|
4
4
|
import { wakeOrchestrationParentsForChildEvent } from "./orchestration-parent-wake.js";
|
|
5
5
|
export async function handleGitHubTerminalPrEvent(params) {
|
|
6
|
-
const { db, linearProvider,
|
|
6
|
+
const { db, linearProvider, wakeDispatcher, logger, codex, issue, event, config } = params;
|
|
7
7
|
const eventType = event.triggerEvent === "pr_merged" ? "pr_merged" : "pr_closed";
|
|
8
8
|
db.issueSessions.appendIssueSessionEvent({
|
|
9
9
|
projectId: issue.projectId,
|
|
@@ -56,22 +56,17 @@ export async function handleGitHubTerminalPrEvent(params) {
|
|
|
56
56
|
db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
57
57
|
const updatedIssue = db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
58
58
|
if (event.triggerEvent === "pr_closed" && resolveClosedPrDisposition(issue) === "redelegate") {
|
|
59
|
-
|
|
60
|
-
projectId: issue.projectId,
|
|
61
|
-
linearIssueId: issue.linearIssueId,
|
|
59
|
+
wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
62
60
|
eventType: "delegated",
|
|
63
61
|
dedupeKey: `github_pr_closed:implementation:${issue.linearIssueId}`,
|
|
64
62
|
});
|
|
65
|
-
if (db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
|
|
66
|
-
enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
67
|
-
}
|
|
68
63
|
}
|
|
69
64
|
if (event.triggerEvent === "pr_merged") {
|
|
70
65
|
wakeOrchestrationParentsForChildEvent({
|
|
71
66
|
db,
|
|
72
67
|
child: updatedIssue,
|
|
73
68
|
eventType: "child_delivered",
|
|
74
|
-
|
|
69
|
+
wakeDispatcher,
|
|
75
70
|
});
|
|
76
71
|
await completeLinearIssueAfterMerge(params, updatedIssue);
|
|
77
72
|
}
|
|
@@ -105,17 +105,27 @@ function hasFailureProvenance(issue) {
|
|
|
105
105
|
export class IdleIssueReconciler {
|
|
106
106
|
db;
|
|
107
107
|
config;
|
|
108
|
-
|
|
108
|
+
wakeDispatcher;
|
|
109
109
|
logger;
|
|
110
110
|
feed;
|
|
111
|
-
constructor(db, config,
|
|
111
|
+
constructor(db, config, wakeDispatcher, logger, feed) {
|
|
112
112
|
this.db = db;
|
|
113
113
|
this.config = config;
|
|
114
|
-
this.
|
|
114
|
+
this.wakeDispatcher = wakeDispatcher;
|
|
115
115
|
this.logger = logger;
|
|
116
116
|
this.feed = feed;
|
|
117
117
|
}
|
|
118
118
|
async reconcile() {
|
|
119
|
+
// Wrap the entire reconcile pass in a dispatcher tick. Every
|
|
120
|
+
// dispatchIfWakePending / recordEventAndDispatch call inside the
|
|
121
|
+
// callback automatically shares one dedupe Set, so a single pass
|
|
122
|
+
// produces at most one enqueue per issue even when several sub-
|
|
123
|
+
// passes detect the same wake. SerialWorkQueue would dedupe anyway,
|
|
124
|
+
// but keeping the call log clean makes orchestrator behaviour
|
|
125
|
+
// easier to inspect from tests and the operator feed.
|
|
126
|
+
return this.wakeDispatcher.withTick(() => this.reconcileBody());
|
|
127
|
+
}
|
|
128
|
+
async reconcileBody() {
|
|
119
129
|
for (const issue of this.db.issues.listIdleNonTerminalIssues()) {
|
|
120
130
|
if (issue.prState === "merged") {
|
|
121
131
|
this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
|
|
@@ -161,15 +171,10 @@ export class IdleIssueReconciler {
|
|
|
161
171
|
continue;
|
|
162
172
|
const unresolved = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
|
|
163
173
|
if (unresolved === 0) {
|
|
164
|
-
this.
|
|
165
|
-
projectId: issue.projectId,
|
|
166
|
-
linearIssueId: issue.linearIssueId,
|
|
174
|
+
this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
167
175
|
eventType: "delegated",
|
|
168
176
|
dedupeKey: `delegated:${issue.linearIssueId}`,
|
|
169
177
|
});
|
|
170
|
-
if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
|
|
171
|
-
this.deps.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
172
|
-
}
|
|
173
178
|
}
|
|
174
179
|
}
|
|
175
180
|
const now = Date.now();
|
|
@@ -187,9 +192,19 @@ export class IdleIssueReconciler {
|
|
|
187
192
|
queueSettledOrchestrationIssue({
|
|
188
193
|
db: this.db,
|
|
189
194
|
issue,
|
|
190
|
-
|
|
195
|
+
wakeDispatcher: this.wakeDispatcher,
|
|
191
196
|
});
|
|
192
197
|
}
|
|
198
|
+
// Safety net: re-enqueue any idle delegated issue that still has
|
|
199
|
+
// unprocessed session events. Until this pass existed, a single
|
|
200
|
+
// dropped enqueueIssue (lease race, in-memory queue lost across
|
|
201
|
+
// restart) left review_fix / ci_repair / queue_repair wakes stuck
|
|
202
|
+
// for hours until an external event re-poked the issue. The
|
|
203
|
+
// surrounding withTick scope ensures the call log shows at most one
|
|
204
|
+
// enqueue per issue per pass even when earlier passes also queued.
|
|
205
|
+
for (const issue of this.db.issues.listIdleIssuesWithPendingWake()) {
|
|
206
|
+
this.wakeDispatcher.dispatchIfWakePending(issue.projectId, issue.linearIssueId);
|
|
207
|
+
}
|
|
193
208
|
}
|
|
194
209
|
shouldProbeTerminalIssueFromGitHub(issue) {
|
|
195
210
|
if (issue.prNumber === undefined)
|
|
@@ -232,7 +247,7 @@ export class IdleIssueReconciler {
|
|
|
232
247
|
: {}),
|
|
233
248
|
});
|
|
234
249
|
if (options?.pendingRunType) {
|
|
235
|
-
this.
|
|
250
|
+
this.recordWakeEvent(issue, options.pendingRunType, options.pendingRunContext, "idle_reconciliation");
|
|
236
251
|
}
|
|
237
252
|
this.feed?.publish({
|
|
238
253
|
level: "info",
|
|
@@ -243,11 +258,11 @@ export class IdleIssueReconciler {
|
|
|
243
258
|
status: "reconciled",
|
|
244
259
|
summary: `Reconciliation: ${issue.factoryState} \u2192 ${newState}`,
|
|
245
260
|
});
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
261
|
+
// The dispatcher's recordEventAndDispatch in recordWakeEvent already
|
|
262
|
+
// handles the enqueue when no run is in flight, so no extra poke
|
|
263
|
+
// is needed here.
|
|
249
264
|
}
|
|
250
|
-
|
|
265
|
+
recordWakeEvent(issue, runType, context, dedupeScope = "idle_reconciliation") {
|
|
251
266
|
let eventType;
|
|
252
267
|
let dedupeKey;
|
|
253
268
|
if (runType === "queue_repair") {
|
|
@@ -266,9 +281,7 @@ export class IdleIssueReconciler {
|
|
|
266
281
|
eventType = "delegated";
|
|
267
282
|
dedupeKey = `${dedupeScope}:implementation:${issue.linearIssueId}`;
|
|
268
283
|
}
|
|
269
|
-
this.
|
|
270
|
-
projectId: issue.projectId,
|
|
271
|
-
linearIssueId: issue.linearIssueId,
|
|
284
|
+
this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
272
285
|
eventType,
|
|
273
286
|
...(context ? { eventJson: JSON.stringify(context) } : {}),
|
|
274
287
|
dedupeKey,
|
|
@@ -14,14 +14,14 @@ export class MainBranchHealthMonitor {
|
|
|
14
14
|
db;
|
|
15
15
|
config;
|
|
16
16
|
linearProvider;
|
|
17
|
-
|
|
17
|
+
wakeDispatcher;
|
|
18
18
|
logger;
|
|
19
19
|
feed;
|
|
20
|
-
constructor(db, config, linearProvider,
|
|
20
|
+
constructor(db, config, linearProvider, wakeDispatcher, logger, feed) {
|
|
21
21
|
this.db = db;
|
|
22
22
|
this.config = config;
|
|
23
23
|
this.linearProvider = linearProvider;
|
|
24
|
-
this.
|
|
24
|
+
this.wakeDispatcher = wakeDispatcher;
|
|
25
25
|
this.logger = logger;
|
|
26
26
|
this.feed = feed;
|
|
27
27
|
}
|
|
@@ -82,9 +82,7 @@ export class MainBranchHealthMonitor {
|
|
|
82
82
|
branchName,
|
|
83
83
|
factoryState: "delegated",
|
|
84
84
|
});
|
|
85
|
-
this.
|
|
86
|
-
projectId,
|
|
87
|
-
linearIssueId: issue.linearIssueId,
|
|
85
|
+
this.wakeDispatcher.recordEventAndDispatch(projectId, issue.linearIssueId, {
|
|
88
86
|
eventType: "delegated",
|
|
89
87
|
eventJson: JSON.stringify({
|
|
90
88
|
runType: "main_repair",
|
|
@@ -96,9 +94,6 @@ export class MainBranchHealthMonitor {
|
|
|
96
94
|
}),
|
|
97
95
|
dedupeKey: `main_repair:${projectId}:${summary.baseSha}:${summary.failingChecks.map((check) => check.name).join("|")}`,
|
|
98
96
|
});
|
|
99
|
-
if (this.db.issueSessions.peekIssueSessionWake(projectId, issue.linearIssueId)) {
|
|
100
|
-
this.enqueueIssue(projectId, issue.linearIssueId);
|
|
101
|
-
}
|
|
102
97
|
this.feed?.publish({
|
|
103
98
|
level: "warn",
|
|
104
99
|
kind: "github",
|
|
@@ -153,9 +148,7 @@ export class MainBranchHealthMonitor {
|
|
|
153
148
|
pendingRunContextJson: null,
|
|
154
149
|
activeRunId: null,
|
|
155
150
|
});
|
|
156
|
-
this.
|
|
157
|
-
projectId: issue.projectId,
|
|
158
|
-
linearIssueId: issue.linearIssueId,
|
|
151
|
+
this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
159
152
|
eventType: "delegated",
|
|
160
153
|
eventJson: JSON.stringify({
|
|
161
154
|
runType: "main_repair",
|
|
@@ -167,9 +160,6 @@ export class MainBranchHealthMonitor {
|
|
|
167
160
|
}),
|
|
168
161
|
dedupeKey: `main_repair:${issue.projectId}:${summary.baseSha}:${summary.failingChecks.map((check) => check.name).join("|")}`,
|
|
169
162
|
});
|
|
170
|
-
if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
|
|
171
|
-
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
172
|
-
}
|
|
173
163
|
}
|
|
174
164
|
async resolveRecoveredMainRepair(issue) {
|
|
175
165
|
if (issue.activeRunId !== undefined)
|
|
@@ -89,7 +89,6 @@ export async function handleNoPrCompletionCheck(params) {
|
|
|
89
89
|
summary: "No PR found; continuing automatically",
|
|
90
90
|
detail: completionCheck.summary,
|
|
91
91
|
activity: buildCompletionCheckActivity("continue"),
|
|
92
|
-
enqueue: true,
|
|
93
92
|
});
|
|
94
93
|
return;
|
|
95
94
|
}
|
|
@@ -166,7 +165,6 @@ export async function handleNoPrCompletionCheck(params) {
|
|
|
166
165
|
summary: "No PR found; continuing automatically to finish publication",
|
|
167
166
|
detail: params.publishedOutcomeError,
|
|
168
167
|
activity: buildCompletionCheckActivity("continue"),
|
|
169
|
-
enqueue: true,
|
|
170
168
|
});
|
|
171
169
|
return;
|
|
172
170
|
}
|
|
@@ -211,7 +209,6 @@ export async function handleNoPrCompletionCheck(params) {
|
|
|
211
209
|
summary: "No repair PR found; continuing automatically",
|
|
212
210
|
detail: "Main repair cannot close until PatchRelay publishes a repair PR or main recovers externally.",
|
|
213
211
|
activity: buildCompletionCheckActivity("continue"),
|
|
214
|
-
enqueue: true,
|
|
215
212
|
});
|
|
216
213
|
return;
|
|
217
214
|
}
|
|
@@ -267,6 +264,7 @@ export async function handleNoPrCompletionCheck(params) {
|
|
|
267
264
|
db: params.db,
|
|
268
265
|
child: doneIssue,
|
|
269
266
|
eventType: "child_delivered",
|
|
267
|
+
wakeDispatcher: params.wakeDispatcher,
|
|
270
268
|
});
|
|
271
269
|
return;
|
|
272
270
|
}
|
|
@@ -37,9 +37,7 @@ export function queueSettledOrchestrationIssue(params) {
|
|
|
37
37
|
linearIssueId: params.issue.linearIssueId,
|
|
38
38
|
orchestrationSettleUntil: null,
|
|
39
39
|
});
|
|
40
|
-
params.
|
|
41
|
-
projectId: params.issue.projectId,
|
|
42
|
-
linearIssueId: params.issue.linearIssueId,
|
|
40
|
+
const dispatched = params.wakeDispatcher.recordEventAndDispatch(params.issue.projectId, params.issue.linearIssueId, {
|
|
43
41
|
eventType: "delegated",
|
|
44
42
|
eventJson: JSON.stringify({
|
|
45
43
|
...(params.promptContext
|
|
@@ -48,11 +46,7 @@ export function queueSettledOrchestrationIssue(params) {
|
|
|
48
46
|
}),
|
|
49
47
|
dedupeKey: `delegated:orchestration_settle:${params.issue.linearIssueId}`,
|
|
50
48
|
});
|
|
51
|
-
|
|
52
|
-
params.enqueueIssue?.(params.issue.projectId, params.issue.linearIssueId);
|
|
53
|
-
return true;
|
|
54
|
-
}
|
|
55
|
-
return false;
|
|
49
|
+
return dispatched !== undefined;
|
|
56
50
|
}
|
|
57
51
|
export function wakeOrchestrationParentsForChildEvent(params) {
|
|
58
52
|
const parentIds = [];
|
|
@@ -71,9 +65,7 @@ export function wakeOrchestrationParentsForChildEvent(params) {
|
|
|
71
65
|
parentIds.push(parent.linearIssueId);
|
|
72
66
|
continue;
|
|
73
67
|
}
|
|
74
|
-
params.
|
|
75
|
-
projectId: parent.projectId,
|
|
76
|
-
linearIssueId: parent.linearIssueId,
|
|
68
|
+
params.wakeDispatcher.recordEventAndDispatch(parent.projectId, parent.linearIssueId, {
|
|
77
69
|
eventType: params.eventType,
|
|
78
70
|
eventJson: JSON.stringify({
|
|
79
71
|
childIssueId: params.child.linearIssueId,
|
|
@@ -87,9 +79,6 @@ export function wakeOrchestrationParentsForChildEvent(params) {
|
|
|
87
79
|
}),
|
|
88
80
|
dedupeKey: `${params.eventType}:${parent.linearIssueId}:${params.child.linearIssueId}:${params.child.factoryState}:${params.changeKind ?? params.child.prState ?? "no-pr"}`,
|
|
89
81
|
});
|
|
90
|
-
if (params.db.issueSessions.peekIssueSessionWake(parent.projectId, parent.linearIssueId)) {
|
|
91
|
-
params.enqueueIssue?.(parent.projectId, parent.linearIssueId);
|
|
92
|
-
}
|
|
93
82
|
parentIds.push(parent.linearIssueId);
|
|
94
83
|
}
|
|
95
84
|
return unique(parentIds);
|
|
@@ -149,17 +149,12 @@ export class QueueHealthMonitor {
|
|
|
149
149
|
lastAttemptedFailureHeadSha: headRefOid,
|
|
150
150
|
lastAttemptedFailureSignature: signature,
|
|
151
151
|
});
|
|
152
|
-
this.
|
|
153
|
-
projectId: issue.projectId,
|
|
154
|
-
linearIssueId: issue.linearIssueId,
|
|
152
|
+
this.advancer.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
155
153
|
eventType: "merge_steward_incident",
|
|
156
154
|
eventJson: JSON.stringify(pendingRunContext),
|
|
157
155
|
dedupeKey: `queue_health:queue_repair:${issue.linearIssueId}:${signature}`,
|
|
158
156
|
});
|
|
159
157
|
this.advancer.advanceIdleIssue(issue, "repairing_queue");
|
|
160
|
-
if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
|
|
161
|
-
this.advancer.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
162
|
-
}
|
|
163
158
|
this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, headRefOid, reason }, "Queue health: queue issue detected, dispatching repair");
|
|
164
159
|
this.feed?.publish({
|
|
165
160
|
level: "warn",
|