patchrelay 0.12.4 → 0.12.6
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/github-webhook-handler.js +19 -19
- package/dist/run-orchestrator.js +62 -14
- package/dist/service-runtime.js +6 -1
- package/dist/service.js +33 -2
- 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
|
}
|
|
@@ -226,32 +226,32 @@ export class GitHubWebhookHandler {
|
|
|
226
226
|
async emitLinearActivity(issue, newState, event) {
|
|
227
227
|
if (!issue.agentSessionId)
|
|
228
228
|
return;
|
|
229
|
-
const linear = await this.linearProvider.forProject(issue.projectId);
|
|
230
|
-
if (!linear?.createAgentActivity)
|
|
231
|
-
return;
|
|
232
|
-
const messages = {
|
|
233
|
-
pr_open: `PR #${event.prNumber ?? ""} opened.${event.prUrl ? ` ${event.prUrl}` : ""}`,
|
|
234
|
-
awaiting_queue: "PR approved. Preparing merge.",
|
|
235
|
-
changes_requested: `Review requested changes.${event.reviewerName ? ` Reviewer: ${event.reviewerName}` : ""}`,
|
|
236
|
-
repairing_ci: `CI check failed${event.checkName ? `: ${event.checkName}` : ""}. Starting repair.`,
|
|
237
|
-
repairing_queue: "Merge conflict with base branch. Starting repair.",
|
|
238
|
-
done: `PR merged and deployed.${event.prNumber ? ` PR #${event.prNumber}` : ""}`,
|
|
239
|
-
failed: "PR was closed without merging.",
|
|
240
|
-
};
|
|
241
|
-
const body = messages[newState];
|
|
242
|
-
if (!body)
|
|
243
|
-
return;
|
|
244
|
-
const type = newState === "failed" || newState === "repairing_ci" || newState === "repairing_queue"
|
|
245
|
-
? "error"
|
|
246
|
-
: "response";
|
|
247
229
|
try {
|
|
230
|
+
const linear = await this.linearProvider.forProject(issue.projectId);
|
|
231
|
+
if (!linear?.createAgentActivity)
|
|
232
|
+
return;
|
|
233
|
+
const messages = {
|
|
234
|
+
pr_open: `PR #${event.prNumber ?? ""} opened.${event.prUrl ? ` ${event.prUrl}` : ""}`,
|
|
235
|
+
awaiting_queue: "PR approved. Preparing merge.",
|
|
236
|
+
changes_requested: `Review requested changes.${event.reviewerName ? ` Reviewer: ${event.reviewerName}` : ""}`,
|
|
237
|
+
repairing_ci: `CI check failed${event.checkName ? `: ${event.checkName}` : ""}. Starting repair.`,
|
|
238
|
+
repairing_queue: "Merge conflict with base branch. Starting repair.",
|
|
239
|
+
done: `PR merged and deployed.${event.prNumber ? ` PR #${event.prNumber}` : ""}`,
|
|
240
|
+
failed: "PR was closed without merging.",
|
|
241
|
+
};
|
|
242
|
+
const body = messages[newState];
|
|
243
|
+
if (!body)
|
|
244
|
+
return;
|
|
245
|
+
const type = newState === "failed" || newState === "repairing_ci" || newState === "repairing_queue"
|
|
246
|
+
? "error"
|
|
247
|
+
: "response";
|
|
248
248
|
await linear.createAgentActivity({
|
|
249
249
|
agentSessionId: issue.agentSessionId,
|
|
250
250
|
content: { type, body },
|
|
251
251
|
});
|
|
252
252
|
}
|
|
253
253
|
catch {
|
|
254
|
-
// Non-blocking — don't
|
|
254
|
+
// Non-blocking — don't crash the webhook handler for a Linear activity error
|
|
255
255
|
}
|
|
256
256
|
}
|
|
257
257
|
}
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { ACTIVE_RUN_STATES } from "./factory-state.js";
|
|
3
4
|
import { buildHookEnv, runProjectHook } from "./hook-runner.js";
|
|
4
5
|
import { buildRunningSessionPlan, buildCompletedSessionPlan, buildFailedSessionPlan, } from "./agent-session-plan.js";
|
|
5
6
|
import { buildStageReport, countEventMethods, extractTurnId, resolveRunCompletionStatus, summarizeCurrentThread, } from "./run-reporting.js";
|
|
@@ -275,6 +276,22 @@ export class RunOrchestrator {
|
|
|
275
276
|
// Complete the run
|
|
276
277
|
const trackedIssue = this.db.issueToTrackedIssue(issue);
|
|
277
278
|
const report = buildStageReport(run, trackedIssue, thread, countEventMethods(this.db.listThreadEvents(run.id)));
|
|
279
|
+
// Determine post-run state. When a re-run finds the PR already exists
|
|
280
|
+
// and makes no changes, no pr_opened webhook arrives — the state would
|
|
281
|
+
// stay in the active-run state forever. Advance based on PR metadata.
|
|
282
|
+
const freshIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
283
|
+
let postRunState;
|
|
284
|
+
if (ACTIVE_RUN_STATES.has(freshIssue.factoryState) && freshIssue.prNumber) {
|
|
285
|
+
if (freshIssue.prReviewState === "approved") {
|
|
286
|
+
postRunState = "awaiting_queue";
|
|
287
|
+
}
|
|
288
|
+
else if (freshIssue.prState === "merged") {
|
|
289
|
+
postRunState = "done";
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
postRunState = "pr_open";
|
|
293
|
+
}
|
|
294
|
+
}
|
|
278
295
|
this.db.transaction(() => {
|
|
279
296
|
this.db.finishRun(run.id, {
|
|
280
297
|
status: "completed",
|
|
@@ -287,8 +304,14 @@ export class RunOrchestrator {
|
|
|
287
304
|
projectId: run.projectId,
|
|
288
305
|
linearIssueId: run.linearIssueId,
|
|
289
306
|
activeRunId: null,
|
|
307
|
+
...(postRunState ? { factoryState: postRunState } : {}),
|
|
308
|
+
...(postRunState === "awaiting_queue" ? { pendingMergePrep: true } : {}),
|
|
290
309
|
});
|
|
291
310
|
});
|
|
311
|
+
// If we advanced to awaiting_queue, enqueue for merge prep
|
|
312
|
+
if (postRunState === "awaiting_queue") {
|
|
313
|
+
this.enqueueIssue(run.projectId, run.linearIssueId);
|
|
314
|
+
}
|
|
292
315
|
this.feed?.publish({
|
|
293
316
|
level: "info",
|
|
294
317
|
kind: "turn",
|
|
@@ -328,24 +351,49 @@ export class RunOrchestrator {
|
|
|
328
351
|
}
|
|
329
352
|
}
|
|
330
353
|
async reconcileRun(run) {
|
|
331
|
-
if (!run.threadId) {
|
|
332
|
-
this.failRunAndClear(run, "Run has no thread ID during reconciliation");
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
354
|
const issue = this.db.getIssue(run.projectId, run.linearIssueId);
|
|
336
355
|
if (!issue)
|
|
337
356
|
return;
|
|
338
|
-
//
|
|
357
|
+
// Zombie run: claimed in DB but Codex never started (no thread).
|
|
358
|
+
// This happens when the service crashes between claiming the run
|
|
359
|
+
// and starting the Codex turn. Re-enqueue instead of failing.
|
|
360
|
+
if (!run.threadId) {
|
|
361
|
+
this.logger.warn({ issueKey: issue.issueKey, runId: run.id, runType: run.runType }, "Zombie run detected (no thread) — clearing and re-enqueueing");
|
|
362
|
+
this.db.transaction(() => {
|
|
363
|
+
this.db.finishRun(run.id, { status: "failed", failureReason: "Zombie: never started (no thread after restart)" });
|
|
364
|
+
this.db.upsertIssue({
|
|
365
|
+
projectId: run.projectId,
|
|
366
|
+
linearIssueId: run.linearIssueId,
|
|
367
|
+
activeRunId: null,
|
|
368
|
+
pendingRunType: run.runType,
|
|
369
|
+
pendingRunContextJson: null,
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
this.enqueueIssue(run.projectId, run.linearIssueId);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
// Read Codex state — thread may not exist after app-server restart.
|
|
339
376
|
let thread;
|
|
340
377
|
try {
|
|
341
378
|
thread = await this.readThreadWithRetry(run.threadId);
|
|
342
379
|
}
|
|
343
380
|
catch {
|
|
344
|
-
this.
|
|
381
|
+
this.logger.warn({ issueKey: issue.issueKey, runId: run.id, runType: run.runType, threadId: run.threadId }, "Stale thread during reconciliation — clearing and re-enqueueing");
|
|
382
|
+
this.db.transaction(() => {
|
|
383
|
+
this.db.finishRun(run.id, { status: "failed", failureReason: "Stale thread after restart" });
|
|
384
|
+
this.db.upsertIssue({
|
|
385
|
+
projectId: run.projectId,
|
|
386
|
+
linearIssueId: run.linearIssueId,
|
|
387
|
+
activeRunId: null,
|
|
388
|
+
pendingRunType: run.runType,
|
|
389
|
+
pendingRunContextJson: null,
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
this.enqueueIssue(run.projectId, run.linearIssueId);
|
|
345
393
|
return;
|
|
346
394
|
}
|
|
347
|
-
// Check Linear state
|
|
348
|
-
const linear = await this.linearProvider.forProject(run.projectId);
|
|
395
|
+
// Check Linear state (non-fatal — token refresh may fail)
|
|
396
|
+
const linear = await this.linearProvider.forProject(run.projectId).catch(() => undefined);
|
|
349
397
|
if (linear) {
|
|
350
398
|
const linearIssue = await linear.getIssue(run.linearIssueId).catch(() => undefined);
|
|
351
399
|
if (linearIssue) {
|
|
@@ -429,10 +477,10 @@ export class RunOrchestrator {
|
|
|
429
477
|
async emitLinearActivity(issue, type, body, options) {
|
|
430
478
|
if (!issue.agentSessionId)
|
|
431
479
|
return;
|
|
432
|
-
const linear = await this.linearProvider.forProject(issue.projectId);
|
|
433
|
-
if (!linear)
|
|
434
|
-
return;
|
|
435
480
|
try {
|
|
481
|
+
const linear = await this.linearProvider.forProject(issue.projectId);
|
|
482
|
+
if (!linear)
|
|
483
|
+
return;
|
|
436
484
|
await linear.createAgentActivity({
|
|
437
485
|
agentSessionId: issue.agentSessionId,
|
|
438
486
|
content: { type, body },
|
|
@@ -446,10 +494,10 @@ export class RunOrchestrator {
|
|
|
446
494
|
async updateLinearPlan(issue, plan) {
|
|
447
495
|
if (!issue.agentSessionId)
|
|
448
496
|
return;
|
|
449
|
-
const linear = await this.linearProvider.forProject(issue.projectId);
|
|
450
|
-
if (!linear?.updateAgentSession)
|
|
451
|
-
return;
|
|
452
497
|
try {
|
|
498
|
+
const linear = await this.linearProvider.forProject(issue.projectId);
|
|
499
|
+
if (!linear?.updateAgentSession)
|
|
500
|
+
return;
|
|
453
501
|
await linear.updateAgentSession({ agentSessionId: issue.agentSessionId, plan });
|
|
454
502
|
}
|
|
455
503
|
catch (error) {
|
package/dist/service-runtime.js
CHANGED
|
@@ -14,6 +14,7 @@ export class ServiceRuntime {
|
|
|
14
14
|
webhookQueue;
|
|
15
15
|
issueQueue;
|
|
16
16
|
ready = false;
|
|
17
|
+
linearConnected = false;
|
|
17
18
|
startupError;
|
|
18
19
|
reconcileTimer;
|
|
19
20
|
reconcileInProgress = false;
|
|
@@ -54,10 +55,14 @@ export class ServiceRuntime {
|
|
|
54
55
|
enqueueIssue(projectId, issueId) {
|
|
55
56
|
this.issueQueue.enqueue({ projectId, issueId });
|
|
56
57
|
}
|
|
58
|
+
setLinearConnected(connected) {
|
|
59
|
+
this.linearConnected = connected;
|
|
60
|
+
}
|
|
57
61
|
getReadiness() {
|
|
58
62
|
return {
|
|
59
|
-
ready: this.ready && this.codex.isStarted(),
|
|
63
|
+
ready: this.ready && this.codex.isStarted() && this.linearConnected,
|
|
60
64
|
codexStarted: this.codex.isStarted(),
|
|
65
|
+
linearConnected: this.linearConnected,
|
|
61
66
|
...(this.startupError ? { startupError: this.startupError } : {}),
|
|
62
67
|
};
|
|
63
68
|
}
|
package/dist/service.js
CHANGED
|
@@ -73,10 +73,37 @@ export class PatchRelayService {
|
|
|
73
73
|
this.githubAppTokenManager = createGitHubAppTokenManager(ghAppCredentials, logger);
|
|
74
74
|
}
|
|
75
75
|
this.codex.on("notification", (notification) => {
|
|
76
|
-
|
|
76
|
+
this.orchestrator.handleCodexNotification(notification).catch((error) => {
|
|
77
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
78
|
+
logger.error({ method: notification.method, error: msg }, "Unhandled error in Codex notification handler");
|
|
79
|
+
});
|
|
77
80
|
});
|
|
78
81
|
}
|
|
79
82
|
async start() {
|
|
83
|
+
// Verify Linear connectivity for all configured projects before starting.
|
|
84
|
+
// Auth errors do not prevent startup (the OAuth callback must be reachable
|
|
85
|
+
// for `patchrelay connect`), but the service reports NOT READY until at
|
|
86
|
+
// least one project has a working Linear token.
|
|
87
|
+
let anyLinearConnected = false;
|
|
88
|
+
for (const project of this.config.projects) {
|
|
89
|
+
try {
|
|
90
|
+
const client = await this.linearProvider.forProject(project.id);
|
|
91
|
+
if (client) {
|
|
92
|
+
anyLinearConnected = true;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
this.logger.warn({ projectId: project.id }, "No Linear installation linked — run 'patchrelay connect' to authorize");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
100
|
+
this.logger.error({ projectId: project.id, error: msg }, "Linear auth failed — run 'patchrelay connect' to refresh the token. Runs for this project will fail until re-authorized.");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
this.runtime.setLinearConnected(anyLinearConnected);
|
|
104
|
+
if (!anyLinearConnected && this.config.projects.length > 0) {
|
|
105
|
+
this.logger.error("No projects have working Linear auth — service is NOT READY. Run 'patchrelay connect' to authorize.");
|
|
106
|
+
}
|
|
80
107
|
if (this.githubAppTokenManager) {
|
|
81
108
|
await ensureGhWrapper(this.logger);
|
|
82
109
|
await this.githubAppTokenManager.start();
|
|
@@ -96,7 +123,11 @@ export class PatchRelayService {
|
|
|
96
123
|
return await this.oauthService.createStart(params);
|
|
97
124
|
}
|
|
98
125
|
async completeLinearOAuth(params) {
|
|
99
|
-
|
|
126
|
+
const result = await this.oauthService.complete(params);
|
|
127
|
+
// A successful OAuth completion means at least one project now has
|
|
128
|
+
// working Linear auth — update readiness.
|
|
129
|
+
this.runtime.setLinearConnected(true);
|
|
130
|
+
return result;
|
|
100
131
|
}
|
|
101
132
|
getLinearOAuthStateStatus(state) {
|
|
102
133
|
return this.oauthService.getStateStatus(state);
|