patchrelay 0.12.6 → 0.12.8
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 +29 -3
- package/dist/db.js +10 -0
- package/dist/http.js +8 -5
- package/dist/index.js +8 -7
- package/dist/run-orchestrator.js +51 -0
- package/dist/service-runtime.js +7 -2
- package/dist/service.js +2 -2
- package/dist/webhook-handler.js +4 -2
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
package/dist/codex-app-server.js
CHANGED
|
@@ -48,6 +48,15 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
48
48
|
this.child = this.spawnProcess(launch.command, launch.args, {
|
|
49
49
|
stdio: ["pipe", "pipe", "pipe"],
|
|
50
50
|
});
|
|
51
|
+
this.child.stdin.on("error", (error) => {
|
|
52
|
+
this.logger.error({ error: sanitizeDiagnosticText(error.message) }, "Codex app-server stdin error");
|
|
53
|
+
});
|
|
54
|
+
this.child.stdout.on("error", (error) => {
|
|
55
|
+
this.logger.error({ error: sanitizeDiagnosticText(error.message) }, "Codex app-server stdout error");
|
|
56
|
+
});
|
|
57
|
+
this.child.stderr.on("error", (error) => {
|
|
58
|
+
this.logger.error({ error: sanitizeDiagnosticText(error.message) }, "Codex app-server stderr error");
|
|
59
|
+
});
|
|
51
60
|
this.child.stderr.on("data", (chunk) => {
|
|
52
61
|
const line = chunk.toString().trim();
|
|
53
62
|
if (line) {
|
|
@@ -75,6 +84,13 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
75
84
|
});
|
|
76
85
|
this.child.stdout.on("data", (chunk) => {
|
|
77
86
|
this.stdoutBuffer += chunk.toString("utf8");
|
|
87
|
+
if (this.stdoutBuffer.length > 50 * 1024 * 1024) {
|
|
88
|
+
this.logger.error({ bufferSize: this.stdoutBuffer.length }, "Codex app-server stdout buffer exceeded 50 MB — killing process");
|
|
89
|
+
this.stdoutBuffer = "";
|
|
90
|
+
this.rejectAllPending(new Error("Codex app-server stdout buffer overflow"));
|
|
91
|
+
this.child?.kill("SIGTERM");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
78
94
|
this.drainMessages();
|
|
79
95
|
});
|
|
80
96
|
const initializeResponse = await this.sendRequest("initialize", {
|
|
@@ -96,14 +112,24 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
96
112
|
this.started = true;
|
|
97
113
|
}
|
|
98
114
|
async stop() {
|
|
99
|
-
|
|
115
|
+
const child = this.child;
|
|
116
|
+
if (!child) {
|
|
100
117
|
return;
|
|
101
118
|
}
|
|
102
119
|
this.logger.info("Stopping Codex app-server");
|
|
103
120
|
this.stopping = true;
|
|
104
|
-
this.child.kill("SIGTERM");
|
|
105
|
-
this.child = undefined;
|
|
106
121
|
this.started = false;
|
|
122
|
+
const exited = new Promise((resolve) => {
|
|
123
|
+
child.on("close", () => resolve());
|
|
124
|
+
});
|
|
125
|
+
child.kill("SIGTERM");
|
|
126
|
+
this.child = undefined;
|
|
127
|
+
// Wait for the child to exit, but don't block shutdown forever.
|
|
128
|
+
const timeout = new Promise((resolve) => {
|
|
129
|
+
const timer = setTimeout(resolve, 10_000);
|
|
130
|
+
timer.unref?.();
|
|
131
|
+
});
|
|
132
|
+
await Promise.race([exited, timeout]);
|
|
107
133
|
}
|
|
108
134
|
async startThread(options) {
|
|
109
135
|
const params = {
|
package/dist/db.js
CHANGED
|
@@ -217,6 +217,16 @@ export class PatchRelayDatabase {
|
|
|
217
217
|
linearIssueId: String(row.linear_issue_id),
|
|
218
218
|
}));
|
|
219
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Issues idle in pr_open with no active run — candidates for state
|
|
222
|
+
* advancement based on stored PR metadata (missed GitHub webhooks).
|
|
223
|
+
*/
|
|
224
|
+
listIdlePrOpenIssues() {
|
|
225
|
+
const rows = this.connection
|
|
226
|
+
.prepare("SELECT * FROM issues WHERE factory_state = 'pr_open' AND active_run_id IS NULL AND pending_run_type IS NULL AND pr_number IS NOT NULL")
|
|
227
|
+
.all();
|
|
228
|
+
return rows.map(mapIssueRow);
|
|
229
|
+
}
|
|
220
230
|
listIssuesByState(projectId, state) {
|
|
221
231
|
const rows = this.connection
|
|
222
232
|
.prepare("SELECT * FROM issues WHERE project_id = ? AND factory_state = ? ORDER BY pr_number ASC")
|
package/dist/http.js
CHANGED
|
@@ -312,6 +312,12 @@ export async function buildHttpServer(config, service, logger) {
|
|
|
312
312
|
for (const event of service.listOperatorFeed(feedQuery)) {
|
|
313
313
|
writeEvent(event);
|
|
314
314
|
}
|
|
315
|
+
const cleanup = () => {
|
|
316
|
+
clearInterval(keepAlive);
|
|
317
|
+
unsubscribe();
|
|
318
|
+
if (!reply.raw.destroyed)
|
|
319
|
+
reply.raw.end();
|
|
320
|
+
};
|
|
315
321
|
const unsubscribe = service.subscribeOperatorFeed((event) => {
|
|
316
322
|
if (!matchesOperatorFeedEvent(event, feedQuery)) {
|
|
317
323
|
return;
|
|
@@ -321,11 +327,8 @@ export async function buildHttpServer(config, service, logger) {
|
|
|
321
327
|
const keepAlive = setInterval(() => {
|
|
322
328
|
reply.raw.write(": keepalive\n\n");
|
|
323
329
|
}, 15000);
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
unsubscribe();
|
|
327
|
-
reply.raw.end();
|
|
328
|
-
});
|
|
330
|
+
reply.raw.on("error", cleanup);
|
|
331
|
+
request.raw.on("close", cleanup);
|
|
329
332
|
});
|
|
330
333
|
app.get("/api/installations", async (_request, reply) => {
|
|
331
334
|
return reply.send({ ok: true, installations: service.listLinearInstallations() });
|
package/dist/index.js
CHANGED
|
@@ -62,15 +62,16 @@ async function main() {
|
|
|
62
62
|
secrets: config.secretSources,
|
|
63
63
|
}, "PatchRelay started");
|
|
64
64
|
const shutdown = async () => {
|
|
65
|
-
service.stop();
|
|
65
|
+
await service.stop();
|
|
66
66
|
await app.close();
|
|
67
67
|
};
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
const onSignal = (signal) => {
|
|
69
|
+
shutdown().catch((error) => {
|
|
70
|
+
logger.error({ signal, error: error instanceof Error ? error.message : String(error) }, "Shutdown error");
|
|
71
|
+
}).finally(() => { process.exitCode ??= 0; });
|
|
72
|
+
};
|
|
73
|
+
process.once("SIGINT", () => onSignal("SIGINT"));
|
|
74
|
+
process.once("SIGTERM", () => onSignal("SIGTERM"));
|
|
74
75
|
}
|
|
75
76
|
main().catch((error) => {
|
|
76
77
|
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -349,6 +349,57 @@ export class RunOrchestrator {
|
|
|
349
349
|
for (const run of this.db.listRunningRuns()) {
|
|
350
350
|
await this.reconcileRun(run);
|
|
351
351
|
}
|
|
352
|
+
// Advance issues stuck in pr_open whose stored PR metadata already
|
|
353
|
+
// shows they should transition (e.g. approved PR, missed webhook).
|
|
354
|
+
await this.reconcileIdlePrOpenIssues();
|
|
355
|
+
}
|
|
356
|
+
async reconcileIdlePrOpenIssues() {
|
|
357
|
+
for (const issue of this.db.listIdlePrOpenIssues()) {
|
|
358
|
+
if (issue.prState === "merged") {
|
|
359
|
+
this.advanceIdleIssue(issue, "done");
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (issue.prReviewState === "approved") {
|
|
363
|
+
this.advanceIdleIssue(issue, "awaiting_queue");
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
// Stored metadata may be stale (missed webhooks during downtime).
|
|
367
|
+
// Query GitHub for the actual PR review state.
|
|
368
|
+
const project = this.config.projects.find((p) => p.id === issue.projectId);
|
|
369
|
+
if (!project?.github?.repoFullName || !issue.prNumber)
|
|
370
|
+
continue;
|
|
371
|
+
try {
|
|
372
|
+
const { stdout } = await execCommand("gh", [
|
|
373
|
+
"pr", "view", String(issue.prNumber),
|
|
374
|
+
"--repo", project.github.repoFullName,
|
|
375
|
+
"--json", "state,reviewDecision",
|
|
376
|
+
], { timeoutMs: 10_000 });
|
|
377
|
+
const pr = JSON.parse(stdout);
|
|
378
|
+
if (pr.state === "MERGED") {
|
|
379
|
+
this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
|
|
380
|
+
this.advanceIdleIssue(issue, "done");
|
|
381
|
+
}
|
|
382
|
+
else if (pr.reviewDecision === "APPROVED") {
|
|
383
|
+
this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prReviewState: "approved" });
|
|
384
|
+
this.advanceIdleIssue(issue, "awaiting_queue");
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
this.logger.debug({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to query GitHub PR state during reconciliation");
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
advanceIdleIssue(issue, newState) {
|
|
393
|
+
this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState }, "Reconciliation: advancing idle issue from stored PR metadata");
|
|
394
|
+
this.db.upsertIssue({
|
|
395
|
+
projectId: issue.projectId,
|
|
396
|
+
linearIssueId: issue.linearIssueId,
|
|
397
|
+
factoryState: newState,
|
|
398
|
+
...(newState === "awaiting_queue" ? { pendingMergePrep: true } : {}),
|
|
399
|
+
});
|
|
400
|
+
if (newState === "awaiting_queue") {
|
|
401
|
+
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
402
|
+
}
|
|
352
403
|
}
|
|
353
404
|
async reconcileRun(run) {
|
|
354
405
|
const issue = this.db.getIssue(run.projectId, run.linearIssueId);
|
package/dist/service-runtime.js
CHANGED
|
@@ -44,10 +44,10 @@ export class ServiceRuntime {
|
|
|
44
44
|
throw error;
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
-
stop() {
|
|
47
|
+
async stop() {
|
|
48
48
|
this.ready = false;
|
|
49
49
|
this.clearBackgroundReconcile();
|
|
50
|
-
|
|
50
|
+
await this.codex.stop();
|
|
51
51
|
}
|
|
52
52
|
enqueueWebhookEvent(eventId, options) {
|
|
53
53
|
this.webhookQueue.enqueue(eventId, options);
|
|
@@ -90,6 +90,11 @@ export class ServiceRuntime {
|
|
|
90
90
|
this.reconcileInProgress = true;
|
|
91
91
|
try {
|
|
92
92
|
await promiseWithTimeout(this.runReconciler.reconcileActiveRuns(), this.options.reconcileTimeoutMs ?? DEFAULT_RECONCILE_TIMEOUT_MS, "Background active-run reconciliation");
|
|
93
|
+
// Pick up issues that became ready outside the webhook path
|
|
94
|
+
// (e.g. CLI retry, manual DB edits) without requiring a restart.
|
|
95
|
+
for (const issue of this.readyIssueSource.listIssuesReadyForExecution()) {
|
|
96
|
+
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
97
|
+
}
|
|
93
98
|
}
|
|
94
99
|
catch (error) {
|
|
95
100
|
this.logger.warn({ error: error instanceof Error ? error.message : String(error) }, "Background active-run reconciliation failed");
|
package/dist/service.js
CHANGED
|
@@ -115,9 +115,9 @@ export class PatchRelayService {
|
|
|
115
115
|
await this.runtime.start();
|
|
116
116
|
this.mergeQueue.seedOnStartup();
|
|
117
117
|
}
|
|
118
|
-
stop() {
|
|
118
|
+
async stop() {
|
|
119
119
|
this.githubAppTokenManager?.stop();
|
|
120
|
-
this.runtime.stop();
|
|
120
|
+
await this.runtime.stop();
|
|
121
121
|
}
|
|
122
122
|
async createLinearOAuthStart(params) {
|
|
123
123
|
return await this.oauthService.createStart(params);
|
package/dist/webhook-handler.js
CHANGED
|
@@ -230,7 +230,8 @@ export class WebhookHandler {
|
|
|
230
230
|
summary: `Delivered follow-up prompt to active ${activeRun.runType} workflow`,
|
|
231
231
|
});
|
|
232
232
|
}
|
|
233
|
-
catch {
|
|
233
|
+
catch (error) {
|
|
234
|
+
this.logger.warn({ issueKey: trackedIssue?.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to deliver follow-up prompt");
|
|
234
235
|
this.feed?.publish({
|
|
235
236
|
level: "warn",
|
|
236
237
|
kind: "agent",
|
|
@@ -288,7 +289,8 @@ export class WebhookHandler {
|
|
|
288
289
|
summary: `Delivered follow-up comment to active ${run.runType} workflow`,
|
|
289
290
|
});
|
|
290
291
|
}
|
|
291
|
-
catch {
|
|
292
|
+
catch (error) {
|
|
293
|
+
this.logger.warn({ issueKey: trackedIssue?.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to deliver follow-up comment");
|
|
292
294
|
this.feed?.publish({
|
|
293
295
|
level: "warn",
|
|
294
296
|
kind: "comment",
|