patchrelay 0.12.3 → 0.12.5
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.js +1 -1
- package/dist/factory-state.js +93 -49
- package/dist/github-webhook-handler.js +5 -27
- package/dist/run-orchestrator.js +39 -14
- package/dist/service.js +14 -0
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
package/dist/db.js
CHANGED
|
@@ -260,7 +260,7 @@ export class PatchRelayDatabase {
|
|
|
260
260
|
}
|
|
261
261
|
listRunningRuns() {
|
|
262
262
|
const rows = this.connection
|
|
263
|
-
.prepare("SELECT * FROM runs WHERE status
|
|
263
|
+
.prepare("SELECT * FROM runs WHERE status IN ('running', 'queued')")
|
|
264
264
|
.all();
|
|
265
265
|
return rows.map(mapRunRow);
|
|
266
266
|
}
|
package/dist/factory-state.js
CHANGED
|
@@ -10,54 +10,98 @@ export const TERMINAL_STATES = new Set([
|
|
|
10
10
|
"done",
|
|
11
11
|
"escalated",
|
|
12
12
|
]);
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
13
|
+
// ─── Semantic guards ─────────────────────────────────────────────
|
|
14
|
+
//
|
|
15
|
+
// Guards express INTENT rather than enumerating states. Adding a new
|
|
16
|
+
// state automatically participates in transitions whose guard it
|
|
17
|
+
// satisfies — no per-event maintenance required.
|
|
18
|
+
const isOpen = (s) => !TERMINAL_STATES.has(s);
|
|
19
|
+
const TRANSITION_RULES = [
|
|
20
|
+
// ── Terminal events ────────────────────────────────────────────
|
|
21
|
+
// pr_merged is unconditional — PR is merged, we're done.
|
|
22
|
+
{ event: "pr_merged",
|
|
23
|
+
to: "done" },
|
|
24
|
+
// pr_closed during an active run is suppressed — Codex may reopen.
|
|
25
|
+
// Without a guard match, the event produces no transition (undefined).
|
|
26
|
+
{ event: "pr_closed",
|
|
27
|
+
guard: (_, ctx) => ctx.activeRunId === undefined,
|
|
28
|
+
to: "failed" },
|
|
29
|
+
// ── PR lifecycle ───────────────────────────────────────────────
|
|
30
|
+
{ event: "pr_opened",
|
|
31
|
+
guard: (s) => s === "implementing",
|
|
32
|
+
to: "pr_open" },
|
|
33
|
+
// ── Review events — apply when no Codex run is actively executing ──
|
|
34
|
+
// Uses activeRunId (runtime state) rather than the state name, because
|
|
35
|
+
// states like changes_requested are "run states" only while the run is
|
|
36
|
+
// active — once the run completes, reviews should be accepted.
|
|
37
|
+
{ event: "review_approved",
|
|
38
|
+
guard: (s, ctx) => isOpen(s) && ctx.activeRunId === undefined,
|
|
39
|
+
to: "awaiting_queue" },
|
|
40
|
+
{ event: "review_changes_requested",
|
|
41
|
+
guard: (s, ctx) => isOpen(s) && ctx.activeRunId === undefined,
|
|
42
|
+
to: "changes_requested" },
|
|
43
|
+
// review_commented: no rule → no transition (informational only)
|
|
44
|
+
// ── CI check events ────────────────────────────────────────────
|
|
45
|
+
// After queue repair, return to the merge queue.
|
|
46
|
+
{ event: "check_passed",
|
|
47
|
+
guard: (s) => s === "repairing_queue",
|
|
48
|
+
to: "awaiting_queue" },
|
|
49
|
+
// After CI repair, return to merge queue if already approved,
|
|
50
|
+
// otherwise to pr_open for review.
|
|
51
|
+
{ event: "check_passed",
|
|
52
|
+
guard: (s) => s === "repairing_ci",
|
|
53
|
+
to: (_, ctx) => ctx.prReviewState === "approved" ? "awaiting_queue" : "pr_open" },
|
|
54
|
+
// CI failure when no run is active triggers repair.
|
|
55
|
+
{ event: "check_failed",
|
|
56
|
+
guard: (s, ctx) => isOpen(s) && ctx.activeRunId === undefined,
|
|
57
|
+
to: "repairing_ci" },
|
|
58
|
+
// ── Merge queue events ─────────────────────────────────────────
|
|
59
|
+
{ event: "merge_group_failed",
|
|
60
|
+
guard: (s) => s === "awaiting_queue",
|
|
61
|
+
to: "repairing_queue" },
|
|
62
|
+
// merge_group_passed: no rule → no transition (merge event follows)
|
|
63
|
+
// pr_synchronize: no rule → no transition (resets counters only)
|
|
64
|
+
];
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the next factory state from a GitHub webhook event.
|
|
67
|
+
*
|
|
68
|
+
* Returns `undefined` when no rule matches — the event is a no-op
|
|
69
|
+
* for the current state (e.g. check_passed while implementing).
|
|
70
|
+
*/
|
|
71
|
+
export function resolveFactoryStateFromGitHub(triggerEvent, current, ctx = {}) {
|
|
72
|
+
for (const rule of TRANSITION_RULES) {
|
|
73
|
+
if (rule.event !== triggerEvent)
|
|
74
|
+
continue;
|
|
75
|
+
if (rule.guard && !rule.guard(current, ctx))
|
|
76
|
+
continue;
|
|
77
|
+
return typeof rule.to === "function" ? rule.to(current, ctx) : rule.to;
|
|
62
78
|
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Derive the allowed transitions table from the rules for documentation
|
|
83
|
+
* and test validation. Not used at runtime.
|
|
84
|
+
*/
|
|
85
|
+
export function deriveAllowedTransitions(states, events) {
|
|
86
|
+
const result = {};
|
|
87
|
+
for (const state of states) {
|
|
88
|
+
result[state] = new Set();
|
|
89
|
+
}
|
|
90
|
+
// Sample with common review states to catch dynamic targets
|
|
91
|
+
const contexts = [
|
|
92
|
+
{},
|
|
93
|
+
{ prReviewState: "approved" },
|
|
94
|
+
{ prReviewState: "changes_requested" },
|
|
95
|
+
];
|
|
96
|
+
for (const state of states) {
|
|
97
|
+
for (const event of events) {
|
|
98
|
+
for (const ctx of contexts) {
|
|
99
|
+
const target = resolveFactoryStateFromGitHub(event, state, ctx);
|
|
100
|
+
if (target !== undefined && target !== state) {
|
|
101
|
+
result[state].add(target);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
63
107
|
}
|
|
@@ -15,25 +15,6 @@ function isMetadataOnlyCheckEvent(event) {
|
|
|
15
15
|
return event.eventSource === "check_run"
|
|
16
16
|
&& (event.triggerEvent === "check_passed" || event.triggerEvent === "check_failed");
|
|
17
17
|
}
|
|
18
|
-
/**
|
|
19
|
-
* Codex sometimes closes and immediately reopens a PR (e.g. to change the
|
|
20
|
-
* base branch or fix the title). A pr_closed event during an active run
|
|
21
|
-
* should not transition to "failed" — the reopened event will follow.
|
|
22
|
-
* Without this guard, the state gets stuck at "failed" because
|
|
23
|
-
* failed → pr_open is not an allowed transition.
|
|
24
|
-
*/
|
|
25
|
-
function shouldSuppressCloseTransition(newState, event, issue) {
|
|
26
|
-
return newState === "failed" && event.triggerEvent === "pr_closed" && issue.activeRunId !== undefined;
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* After a CI repair succeeds and CI passes, the resolver returns pr_open.
|
|
30
|
-
* If the PR is already approved, fast-track to awaiting_queue so the merge
|
|
31
|
-
* queue picks it up again. This avoids a dead state where the PR is approved
|
|
32
|
-
* and CI-green but nobody advances the merge queue.
|
|
33
|
-
*/
|
|
34
|
-
function shouldFastTrackToQueue(newState, issue) {
|
|
35
|
-
return newState === "pr_open" && issue.prReviewState === "approved";
|
|
36
|
-
}
|
|
37
18
|
export class GitHubWebhookHandler {
|
|
38
19
|
config;
|
|
39
20
|
db;
|
|
@@ -141,15 +122,12 @@ export class GitHubWebhookHandler {
|
|
|
141
122
|
...(event.checkStatus !== undefined ? { prCheckStatus: event.checkStatus } : {}),
|
|
142
123
|
});
|
|
143
124
|
if (!isMetadataOnlyCheckEvent(event)) {
|
|
144
|
-
// Re-read issue after PR metadata upsert so
|
|
125
|
+
// Re-read issue after PR metadata upsert so guards see fresh prReviewState
|
|
145
126
|
const afterMetadata = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
if (shouldFastTrackToQueue(newState, afterMetadata)) {
|
|
151
|
-
newState = "awaiting_queue";
|
|
152
|
-
}
|
|
127
|
+
const newState = resolveFactoryStateFromGitHub(event.triggerEvent, afterMetadata.factoryState, {
|
|
128
|
+
prReviewState: afterMetadata.prReviewState,
|
|
129
|
+
activeRunId: afterMetadata.activeRunId,
|
|
130
|
+
});
|
|
153
131
|
// Only transition and notify when the state actually changes.
|
|
154
132
|
// Multiple check_suite events can arrive for the same outcome.
|
|
155
133
|
if (newState && newState !== afterMetadata.factoryState) {
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -328,24 +328,49 @@ export class RunOrchestrator {
|
|
|
328
328
|
}
|
|
329
329
|
}
|
|
330
330
|
async reconcileRun(run) {
|
|
331
|
-
if (!run.threadId) {
|
|
332
|
-
this.failRunAndClear(run, "Run has no thread ID during reconciliation");
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
331
|
const issue = this.db.getIssue(run.projectId, run.linearIssueId);
|
|
336
332
|
if (!issue)
|
|
337
333
|
return;
|
|
338
|
-
//
|
|
334
|
+
// Zombie run: claimed in DB but Codex never started (no thread).
|
|
335
|
+
// This happens when the service crashes between claiming the run
|
|
336
|
+
// and starting the Codex turn. Re-enqueue instead of failing.
|
|
337
|
+
if (!run.threadId) {
|
|
338
|
+
this.logger.warn({ issueKey: issue.issueKey, runId: run.id, runType: run.runType }, "Zombie run detected (no thread) — clearing and re-enqueueing");
|
|
339
|
+
this.db.transaction(() => {
|
|
340
|
+
this.db.finishRun(run.id, { status: "failed", failureReason: "Zombie: never started (no thread after restart)" });
|
|
341
|
+
this.db.upsertIssue({
|
|
342
|
+
projectId: run.projectId,
|
|
343
|
+
linearIssueId: run.linearIssueId,
|
|
344
|
+
activeRunId: null,
|
|
345
|
+
pendingRunType: run.runType,
|
|
346
|
+
pendingRunContextJson: null,
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
this.enqueueIssue(run.projectId, run.linearIssueId);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
// Read Codex state — thread may not exist after app-server restart.
|
|
339
353
|
let thread;
|
|
340
354
|
try {
|
|
341
355
|
thread = await this.readThreadWithRetry(run.threadId);
|
|
342
356
|
}
|
|
343
357
|
catch {
|
|
344
|
-
this.
|
|
358
|
+
this.logger.warn({ issueKey: issue.issueKey, runId: run.id, runType: run.runType, threadId: run.threadId }, "Stale thread during reconciliation — clearing and re-enqueueing");
|
|
359
|
+
this.db.transaction(() => {
|
|
360
|
+
this.db.finishRun(run.id, { status: "failed", failureReason: "Stale thread after restart" });
|
|
361
|
+
this.db.upsertIssue({
|
|
362
|
+
projectId: run.projectId,
|
|
363
|
+
linearIssueId: run.linearIssueId,
|
|
364
|
+
activeRunId: null,
|
|
365
|
+
pendingRunType: run.runType,
|
|
366
|
+
pendingRunContextJson: null,
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
this.enqueueIssue(run.projectId, run.linearIssueId);
|
|
345
370
|
return;
|
|
346
371
|
}
|
|
347
|
-
// Check Linear state
|
|
348
|
-
const linear = await this.linearProvider.forProject(run.projectId);
|
|
372
|
+
// Check Linear state (non-fatal — token refresh may fail)
|
|
373
|
+
const linear = await this.linearProvider.forProject(run.projectId).catch(() => undefined);
|
|
349
374
|
if (linear) {
|
|
350
375
|
const linearIssue = await linear.getIssue(run.linearIssueId).catch(() => undefined);
|
|
351
376
|
if (linearIssue) {
|
|
@@ -429,10 +454,10 @@ export class RunOrchestrator {
|
|
|
429
454
|
async emitLinearActivity(issue, type, body, options) {
|
|
430
455
|
if (!issue.agentSessionId)
|
|
431
456
|
return;
|
|
432
|
-
const linear = await this.linearProvider.forProject(issue.projectId);
|
|
433
|
-
if (!linear)
|
|
434
|
-
return;
|
|
435
457
|
try {
|
|
458
|
+
const linear = await this.linearProvider.forProject(issue.projectId);
|
|
459
|
+
if (!linear)
|
|
460
|
+
return;
|
|
436
461
|
await linear.createAgentActivity({
|
|
437
462
|
agentSessionId: issue.agentSessionId,
|
|
438
463
|
content: { type, body },
|
|
@@ -446,10 +471,10 @@ export class RunOrchestrator {
|
|
|
446
471
|
async updateLinearPlan(issue, plan) {
|
|
447
472
|
if (!issue.agentSessionId)
|
|
448
473
|
return;
|
|
449
|
-
const linear = await this.linearProvider.forProject(issue.projectId);
|
|
450
|
-
if (!linear?.updateAgentSession)
|
|
451
|
-
return;
|
|
452
474
|
try {
|
|
475
|
+
const linear = await this.linearProvider.forProject(issue.projectId);
|
|
476
|
+
if (!linear?.updateAgentSession)
|
|
477
|
+
return;
|
|
453
478
|
await linear.updateAgentSession({ agentSessionId: issue.agentSessionId, plan });
|
|
454
479
|
}
|
|
455
480
|
catch (error) {
|
package/dist/service.js
CHANGED
|
@@ -77,6 +77,20 @@ export class PatchRelayService {
|
|
|
77
77
|
});
|
|
78
78
|
}
|
|
79
79
|
async start() {
|
|
80
|
+
// Verify Linear connectivity for all configured projects before starting.
|
|
81
|
+
// Fail fast on auth errors rather than crashing mid-run.
|
|
82
|
+
for (const project of this.config.projects) {
|
|
83
|
+
try {
|
|
84
|
+
const client = await this.linearProvider.forProject(project.id);
|
|
85
|
+
if (!client) {
|
|
86
|
+
this.logger.warn({ projectId: project.id }, "No Linear installation linked — project will not receive agent session events");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
91
|
+
throw new Error(`Linear auth failed for project ${project.id}: ${msg}. Re-run "patchrelay connect" to refresh the token.`, { cause: error });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
80
94
|
if (this.githubAppTokenManager) {
|
|
81
95
|
await ensureGhWrapper(this.logger);
|
|
82
96
|
await this.githubAppTokenManager.start();
|