patchrelay 0.75.1 → 0.75.2
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/args.js +4 -1
- package/dist/cli/commands/maintenance.js +55 -0
- package/dist/cli/help.js +28 -0
- package/dist/cli/index.js +26 -2
- package/dist/codex-app-server.js +22 -2
- package/dist/config.js +6 -0
- package/dist/db/migrations.js +3 -0
- package/dist/db/run-store.js +13 -3
- package/dist/db/webhook-event-store.js +40 -0
- package/dist/db.js +12 -4
- package/dist/event-retention.js +100 -0
- package/dist/issue-session-projection-invalidator.js +56 -0
- package/dist/run-launcher.js +52 -46
- package/dist/service-queue.js +1 -1
- package/dist/service-runtime.js +58 -17
- package/dist/service.js +38 -0
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
package/dist/cli/args.js
CHANGED
|
@@ -11,6 +11,7 @@ export const KNOWN_COMMANDS = new Set([
|
|
|
11
11
|
"repos",
|
|
12
12
|
"linear",
|
|
13
13
|
"repo",
|
|
14
|
+
"maintenance",
|
|
14
15
|
"dashboard",
|
|
15
16
|
"dash",
|
|
16
17
|
"d",
|
|
@@ -120,7 +121,9 @@ export function assertKnownFlags(parsed, command, allowedFlags) {
|
|
|
120
121
|
? "issue"
|
|
121
122
|
: command === "service"
|
|
122
123
|
? "service"
|
|
123
|
-
: "
|
|
124
|
+
: command === "maintenance"
|
|
125
|
+
? "maintenance"
|
|
126
|
+
: "root");
|
|
124
127
|
}
|
|
125
128
|
export function parsePositiveIntegerFlag(value, flagName) {
|
|
126
129
|
if (typeof value !== "string") {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { runWebhookEventRetention } from "../../event-retention.js";
|
|
2
|
+
import { CliUsageError } from "../errors.js";
|
|
3
|
+
import { formatJson } from "../formatters/json.js";
|
|
4
|
+
import { writeOutput } from "../output.js";
|
|
5
|
+
export async function handleMaintenanceCommand(params) {
|
|
6
|
+
const [subcommand] = params.commandArgs;
|
|
7
|
+
if (subcommand !== "prune-events") {
|
|
8
|
+
throw new CliUsageError(`Unknown maintenance command: ${subcommand ?? ""}`.trim(), "maintenance");
|
|
9
|
+
}
|
|
10
|
+
const retentionDays = readPositiveIntegerFlag(params.parsed, "retention-days");
|
|
11
|
+
const batchSize = readPositiveIntegerFlag(params.parsed, "batch-size");
|
|
12
|
+
const archive = params.parsed.flags.get("archive") === true;
|
|
13
|
+
const discard = params.parsed.flags.get("discard") === true;
|
|
14
|
+
if (archive && discard) {
|
|
15
|
+
throw new CliUsageError("Use either --archive or --discard, not both", "maintenance");
|
|
16
|
+
}
|
|
17
|
+
const result = await runWebhookEventRetention({
|
|
18
|
+
db: params.data.db,
|
|
19
|
+
config: params.config,
|
|
20
|
+
options: {
|
|
21
|
+
dryRun: params.parsed.flags.get("dry-run") === true,
|
|
22
|
+
...(retentionDays !== undefined ? { retentionDays } : {}),
|
|
23
|
+
...(batchSize !== undefined ? { batchSize } : {}),
|
|
24
|
+
...(archive ? { archiveOldEvents: true } : {}),
|
|
25
|
+
...(discard ? { archiveOldEvents: false } : {}),
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
if (params.json) {
|
|
29
|
+
writeOutput(params.stdout, formatJson(result));
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
writeOutput(params.stdout, [
|
|
33
|
+
`Cutoff: ${result.cutoffIso}`,
|
|
34
|
+
`Scanned: ${result.scanned}`,
|
|
35
|
+
`Archived: ${result.archived}`,
|
|
36
|
+
`Deleted: ${result.deleted}`,
|
|
37
|
+
`Remaining: ${result.remaining}`,
|
|
38
|
+
result.archiveFile ? `Archive: ${result.archiveFile}` : undefined,
|
|
39
|
+
result.dryRun ? "Dry run: yes" : undefined,
|
|
40
|
+
].filter(Boolean).join("\n") + "\n");
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
function readPositiveIntegerFlag(parsed, flag) {
|
|
44
|
+
const value = parsed.flags.get(flag);
|
|
45
|
+
if (value === undefined || value === false)
|
|
46
|
+
return undefined;
|
|
47
|
+
if (value === true) {
|
|
48
|
+
throw new CliUsageError(`--${flag} requires a value`, "maintenance");
|
|
49
|
+
}
|
|
50
|
+
const parsedValue = Number(value);
|
|
51
|
+
if (!Number.isInteger(parsedValue) || parsedValue <= 0) {
|
|
52
|
+
throw new CliUsageError(`--${flag} must be a positive integer`, "maintenance");
|
|
53
|
+
}
|
|
54
|
+
return parsedValue;
|
|
55
|
+
}
|
package/dist/cli/help.js
CHANGED
|
@@ -45,6 +45,8 @@ export function rootHelpText() {
|
|
|
45
45
|
" service status [--json] Show systemd state and local health",
|
|
46
46
|
" service codex-status [--json] Show Codex account and usage snapshot from this service",
|
|
47
47
|
" cluster [--json] Check service + workflow health across all tracked issues",
|
|
48
|
+
" maintenance prune-events [--dry-run] [--archive|--discard] [--retention-days <days>] [--json]",
|
|
49
|
+
" Prune or archive old processed webhook events",
|
|
48
50
|
" service logs [--lines <count>] [--json] Show recent service logs",
|
|
49
51
|
" serve Run the local PatchRelay service",
|
|
50
52
|
"",
|
|
@@ -79,6 +81,7 @@ export function rootHelpText() {
|
|
|
79
81
|
" patchrelay help issue",
|
|
80
82
|
" patchrelay help service",
|
|
81
83
|
" patchrelay help cluster",
|
|
84
|
+
" patchrelay help maintenance",
|
|
82
85
|
].join("\n");
|
|
83
86
|
}
|
|
84
87
|
export function linearHelpText() {
|
|
@@ -204,10 +207,35 @@ export function clusterHelpText() {
|
|
|
204
207
|
" patchrelay cluster --json",
|
|
205
208
|
].join("\n");
|
|
206
209
|
}
|
|
210
|
+
export function maintenanceHelpText() {
|
|
211
|
+
return [
|
|
212
|
+
"Usage:",
|
|
213
|
+
" patchrelay maintenance prune-events [options]",
|
|
214
|
+
"",
|
|
215
|
+
"Options:",
|
|
216
|
+
" --dry-run Count archiveable events without deleting",
|
|
217
|
+
" --archive Write old events to cold JSONL gzip storage before deleting",
|
|
218
|
+
" --discard Delete old events without archiving",
|
|
219
|
+
" --retention-days <days> Override configured retention window (default 7)",
|
|
220
|
+
" --batch-size <count> Override maintenance batch size",
|
|
221
|
+
" --json Emit structured JSON output",
|
|
222
|
+
" --help, -h Show this help",
|
|
223
|
+
"",
|
|
224
|
+
"Behavior:",
|
|
225
|
+
" Only processed webhook events older than the retention cutoff are touched.",
|
|
226
|
+
" Pending/unprocessed webhook events are never pruned.",
|
|
227
|
+
"",
|
|
228
|
+
"Examples:",
|
|
229
|
+
" patchrelay maintenance prune-events --dry-run",
|
|
230
|
+
" patchrelay maintenance prune-events --archive",
|
|
231
|
+
].join("\n");
|
|
232
|
+
}
|
|
207
233
|
export function helpTextFor(topic) {
|
|
208
234
|
switch (topic) {
|
|
209
235
|
case "cluster":
|
|
210
236
|
return clusterHelpText();
|
|
237
|
+
case "maintenance":
|
|
238
|
+
return maintenanceHelpText();
|
|
211
239
|
case "linear":
|
|
212
240
|
return linearHelpText();
|
|
213
241
|
case "repo":
|
package/dist/cli/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { handleClusterCommand } from "./commands/cluster.js";
|
|
|
6
6
|
import { handleLinearCommand } from "./commands/linear.js";
|
|
7
7
|
import { handleSequenceCheckCommand } from "./commands/sequence-check.js";
|
|
8
8
|
import { handleRepoCommand } from "./commands/repo.js";
|
|
9
|
+
import { handleMaintenanceCommand } from "./commands/maintenance.js";
|
|
9
10
|
import { handleInitCommand, handleServiceCommand } from "./commands/setup.js";
|
|
10
11
|
import { CliUsageError } from "./errors.js";
|
|
11
12
|
import { formatJson } from "./formatters/json.js";
|
|
@@ -25,6 +26,7 @@ function getCommandConfigProfile(command) {
|
|
|
25
26
|
case "cluster":
|
|
26
27
|
case "repo":
|
|
27
28
|
case "issue":
|
|
29
|
+
case "maintenance":
|
|
28
30
|
case "sequence-check":
|
|
29
31
|
return "cli";
|
|
30
32
|
default:
|
|
@@ -89,6 +91,13 @@ function validateFlags(command, commandArgs, parsed) {
|
|
|
89
91
|
case "cluster":
|
|
90
92
|
assertKnownFlags(parsed, command, ["json"]);
|
|
91
93
|
return;
|
|
94
|
+
case "maintenance":
|
|
95
|
+
if (commandArgs[0] === "prune-events") {
|
|
96
|
+
assertKnownFlags(parsed, command, ["json", "dry-run", "archive", "discard", "retention-days", "batch-size"]);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
assertKnownFlags(parsed, command, []);
|
|
100
|
+
return;
|
|
92
101
|
case "sequence-check":
|
|
93
102
|
assertKnownFlags(parsed, command, ["json", "base"]);
|
|
94
103
|
return;
|
|
@@ -185,7 +194,7 @@ export async function runCli(argv, options) {
|
|
|
185
194
|
const json = parsed.flags.get("json") === true;
|
|
186
195
|
if (command === "help") {
|
|
187
196
|
const topic = commandArgs[0];
|
|
188
|
-
if (topic === "linear" || topic === "repo" || topic === "issue" || topic === "service" || topic === "cluster") {
|
|
197
|
+
if (topic === "linear" || topic === "repo" || topic === "issue" || topic === "service" || topic === "cluster" || topic === "maintenance") {
|
|
189
198
|
writeOutput(stdout, `${helpTextFor(topic)}\n`);
|
|
190
199
|
return 0;
|
|
191
200
|
}
|
|
@@ -206,7 +215,7 @@ export async function runCli(argv, options) {
|
|
|
206
215
|
? "linear"
|
|
207
216
|
: command === "repo"
|
|
208
217
|
? "repo"
|
|
209
|
-
: command === "issue" || command === "service" || command === "cluster"
|
|
218
|
+
: command === "issue" || command === "service" || command === "cluster" || command === "maintenance"
|
|
210
219
|
? command
|
|
211
220
|
: "root";
|
|
212
221
|
writeOutput(stdout, `${helpTextFor(helpTopic)}\n`);
|
|
@@ -354,6 +363,21 @@ export async function runCli(argv, options) {
|
|
|
354
363
|
runCommand,
|
|
355
364
|
});
|
|
356
365
|
}
|
|
366
|
+
if (command === "maintenance") {
|
|
367
|
+
const issueData = await ensureIssueDataAccess(data, config);
|
|
368
|
+
if (!data) {
|
|
369
|
+
data = issueData;
|
|
370
|
+
ownsData = true;
|
|
371
|
+
}
|
|
372
|
+
return await handleMaintenanceCommand({
|
|
373
|
+
commandArgs,
|
|
374
|
+
parsed,
|
|
375
|
+
json,
|
|
376
|
+
stdout,
|
|
377
|
+
data: issueData,
|
|
378
|
+
config,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
357
381
|
if (command === "dashboard") {
|
|
358
382
|
const { handleWatchCommand } = await import("./commands/watch.js");
|
|
359
383
|
return await handleWatchCommand({ config, parsed });
|
package/dist/codex-app-server.js
CHANGED
|
@@ -23,6 +23,7 @@ const FOLLOWUP_INTENT_DEVELOPER_INSTRUCTIONS = [
|
|
|
23
23
|
"Use only the text and state facts in the current prompt.",
|
|
24
24
|
"Return only the requested JSON object.",
|
|
25
25
|
].join("\n");
|
|
26
|
+
const MAX_STDOUT_MESSAGES_PER_DRAIN = 100;
|
|
26
27
|
export function resolveCodexAppServerLaunch(config) {
|
|
27
28
|
if (!config.sourceBashrc) {
|
|
28
29
|
return {
|
|
@@ -50,6 +51,7 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
50
51
|
nextRequestId = 1;
|
|
51
52
|
pending = new Map();
|
|
52
53
|
stdoutBuffer = "";
|
|
54
|
+
drainScheduled = false;
|
|
53
55
|
started = false;
|
|
54
56
|
stopping = false;
|
|
55
57
|
startPromise;
|
|
@@ -360,6 +362,7 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
360
362
|
this.started = false;
|
|
361
363
|
this.child = undefined;
|
|
362
364
|
this.stdoutBuffer = "";
|
|
365
|
+
this.drainScheduled = false;
|
|
363
366
|
const log = this.stopping ? this.logger.info.bind(this.logger) : this.logger.warn.bind(this.logger);
|
|
364
367
|
log({
|
|
365
368
|
code: code ?? 1,
|
|
@@ -374,11 +377,12 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
374
377
|
if (this.stdoutBuffer.length > 50 * 1024 * 1024) {
|
|
375
378
|
this.logger.error({ bufferSize: this.stdoutBuffer.length }, "Codex app-server stdout buffer exceeded 50 MB — killing process");
|
|
376
379
|
this.stdoutBuffer = "";
|
|
380
|
+
this.drainScheduled = false;
|
|
377
381
|
this.rejectAllPending(new Error("Codex app-server stdout buffer overflow"));
|
|
378
382
|
this.child?.kill("SIGTERM");
|
|
379
383
|
return;
|
|
380
384
|
}
|
|
381
|
-
this.
|
|
385
|
+
this.scheduleDrainMessages();
|
|
382
386
|
});
|
|
383
387
|
const initializeResponse = await this.sendRequest("initialize", {
|
|
384
388
|
clientInfo: {
|
|
@@ -438,6 +442,7 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
438
442
|
this.started = false;
|
|
439
443
|
this.child = undefined;
|
|
440
444
|
this.stdoutBuffer = "";
|
|
445
|
+
this.drainScheduled = false;
|
|
441
446
|
this.logger.error({
|
|
442
447
|
error: sanitizeDiagnosticText(error.message),
|
|
443
448
|
pendingRequestCount: this.pending.size,
|
|
@@ -446,7 +451,8 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
446
451
|
child?.kill("SIGTERM");
|
|
447
452
|
}
|
|
448
453
|
drainMessages() {
|
|
449
|
-
|
|
454
|
+
let processed = 0;
|
|
455
|
+
while (processed < MAX_STDOUT_MESSAGES_PER_DRAIN) {
|
|
450
456
|
const newlineIndex = this.stdoutBuffer.indexOf("\n");
|
|
451
457
|
if (newlineIndex === -1) {
|
|
452
458
|
return;
|
|
@@ -458,6 +464,7 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
458
464
|
}
|
|
459
465
|
try {
|
|
460
466
|
this.handleMessage(JSON.parse(line));
|
|
467
|
+
processed += 1;
|
|
461
468
|
}
|
|
462
469
|
catch (error) {
|
|
463
470
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
@@ -471,6 +478,19 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
471
478
|
return;
|
|
472
479
|
}
|
|
473
480
|
}
|
|
481
|
+
if (this.stdoutBuffer.includes("\n")) {
|
|
482
|
+
this.scheduleDrainMessages();
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
scheduleDrainMessages() {
|
|
486
|
+
if (this.drainScheduled) {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
this.drainScheduled = true;
|
|
490
|
+
setImmediate(() => {
|
|
491
|
+
this.drainScheduled = false;
|
|
492
|
+
this.drainMessages();
|
|
493
|
+
});
|
|
474
494
|
}
|
|
475
495
|
handleMessage(message) {
|
|
476
496
|
if ("method" in message && "id" in message) {
|
package/dist/config.js
CHANGED
|
@@ -131,6 +131,9 @@ const configSchema = z.object({
|
|
|
131
131
|
database: z.object({
|
|
132
132
|
path: z.string().min(1).default(getDefaultDatabasePath()),
|
|
133
133
|
wal: z.boolean().default(true),
|
|
134
|
+
event_retention_days: z.number().int().positive().default(7),
|
|
135
|
+
archive_old_events: z.boolean().default(false),
|
|
136
|
+
archive_path: z.string().min(1).optional(),
|
|
134
137
|
}),
|
|
135
138
|
linear: z.object({
|
|
136
139
|
webhook_secret_env: z.string().default("LINEAR_WEBHOOK_SECRET"),
|
|
@@ -555,6 +558,9 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
|
|
|
555
558
|
database: {
|
|
556
559
|
path: ensureAbsolutePath(env.PATCHRELAY_DB_PATH ?? parsed.database.path),
|
|
557
560
|
wal: parsed.database.wal,
|
|
561
|
+
eventRetentionDays: parsed.database.event_retention_days,
|
|
562
|
+
archiveOldEvents: parsed.database.archive_old_events,
|
|
563
|
+
archivePath: ensureAbsolutePath(parsed.database.archive_path ?? path.join(path.dirname(env.PATCHRELAY_DB_PATH ?? parsed.database.path), "archive")),
|
|
558
564
|
},
|
|
559
565
|
linear: {
|
|
560
566
|
webhookSecret: webhookSecret ?? "",
|
package/dist/db/migrations.js
CHANGED
|
@@ -49,6 +49,7 @@ CREATE TABLE IF NOT EXISTS runs (
|
|
|
49
49
|
linear_issue_id TEXT NOT NULL,
|
|
50
50
|
run_type TEXT NOT NULL DEFAULT 'implementation',
|
|
51
51
|
status TEXT NOT NULL,
|
|
52
|
+
launch_phase TEXT,
|
|
52
53
|
source_head_sha TEXT,
|
|
53
54
|
prompt_text TEXT,
|
|
54
55
|
thread_id TEXT,
|
|
@@ -248,6 +249,7 @@ CREATE INDEX IF NOT EXISTS idx_issue_sessions_key ON issue_sessions(issue_key);
|
|
|
248
249
|
CREATE INDEX IF NOT EXISTS idx_issue_sessions_lease ON issue_sessions(leased_until, session_state);
|
|
249
250
|
CREATE INDEX IF NOT EXISTS idx_issue_session_events_issue ON issue_session_events(project_id, linear_issue_id, id);
|
|
250
251
|
CREATE INDEX IF NOT EXISTS idx_issue_session_events_pending ON issue_session_events(processed_at, project_id, linear_issue_id, id);
|
|
252
|
+
CREATE INDEX IF NOT EXISTS idx_webhook_events_retention ON webhook_events(processing_status, received_at, id);
|
|
251
253
|
CREATE INDEX IF NOT EXISTS idx_run_thread_events_run ON run_thread_events(run_id, id);
|
|
252
254
|
CREATE INDEX IF NOT EXISTS idx_operator_feed_events_issue ON operator_feed_events(issue_key, id);
|
|
253
255
|
CREATE INDEX IF NOT EXISTS idx_operator_feed_events_project ON operator_feed_events(project_id, id);
|
|
@@ -295,6 +297,7 @@ export function runPatchRelayMigrations(connection) {
|
|
|
295
297
|
WHERE display_updated_at IS NULL
|
|
296
298
|
`).run();
|
|
297
299
|
addColumnIfMissing(connection, "runs", "source_head_sha", "TEXT");
|
|
300
|
+
addColumnIfMissing(connection, "runs", "launch_phase", "TEXT");
|
|
298
301
|
addColumnIfMissing(connection, "runs", "completion_check_thread_id", "TEXT");
|
|
299
302
|
addColumnIfMissing(connection, "runs", "completion_check_turn_id", "TEXT");
|
|
300
303
|
addColumnIfMissing(connection, "runs", "completion_check_outcome", "TEXT");
|
package/dist/db/run-store.js
CHANGED
|
@@ -20,8 +20,8 @@ export class RunStore {
|
|
|
20
20
|
createRun(params) {
|
|
21
21
|
const now = isoNow();
|
|
22
22
|
const result = this.connection.prepare(`
|
|
23
|
-
INSERT INTO runs (issue_id, project_id, linear_issue_id, run_type, status, source_head_sha, prompt_text, started_at)
|
|
24
|
-
VALUES (?, ?, ?, ?, 'queued', ?, ?, ?)
|
|
23
|
+
INSERT INTO runs (issue_id, project_id, linear_issue_id, run_type, status, launch_phase, source_head_sha, prompt_text, started_at)
|
|
24
|
+
VALUES (?, ?, ?, ?, 'queued', 'claimed', ?, ?, ?)
|
|
25
25
|
`).run(params.issueId, params.projectId, params.linearIssueId, params.runType, params.sourceHeadSha ?? null, params.promptText ?? null, now);
|
|
26
26
|
const run = this.getRunById(Number(result.lastInsertRowid));
|
|
27
27
|
const issue = this.issues.getIssue(params.projectId, params.linearIssueId);
|
|
@@ -76,7 +76,8 @@ export class RunStore {
|
|
|
76
76
|
thread_id = ?,
|
|
77
77
|
parent_thread_id = COALESCE(?, parent_thread_id),
|
|
78
78
|
turn_id = COALESCE(?, turn_id),
|
|
79
|
-
status = 'running'
|
|
79
|
+
status = 'running',
|
|
80
|
+
launch_phase = 'running'
|
|
80
81
|
WHERE id = ?
|
|
81
82
|
AND ended_at IS NULL
|
|
82
83
|
AND status IN ('queued', 'running')
|
|
@@ -100,6 +101,15 @@ export class RunStore {
|
|
|
100
101
|
updateRunTurnId(runId, turnId) {
|
|
101
102
|
this.connection.prepare("UPDATE runs SET turn_id = ? WHERE id = ?").run(turnId, runId);
|
|
102
103
|
}
|
|
104
|
+
updateLaunchPhase(runId, launchPhase) {
|
|
105
|
+
this.connection.prepare(`
|
|
106
|
+
UPDATE runs
|
|
107
|
+
SET launch_phase = ?
|
|
108
|
+
WHERE id = ?
|
|
109
|
+
AND ended_at IS NULL
|
|
110
|
+
AND status IN ('queued', 'running')
|
|
111
|
+
`).run(launchPhase, runId);
|
|
112
|
+
}
|
|
103
113
|
finishRun(runId, params) {
|
|
104
114
|
const now = isoNow();
|
|
105
115
|
this.connection.prepare(`
|
|
@@ -42,6 +42,46 @@ export class WebhookEventStore {
|
|
|
42
42
|
assignWebhookProject(id, projectId) {
|
|
43
43
|
this.connection.prepare("UPDATE webhook_events SET project_id = ? WHERE id = ?").run(projectId, id);
|
|
44
44
|
}
|
|
45
|
+
listArchiveableEventsBefore(cutoffIso, limit) {
|
|
46
|
+
const rows = this.connection.prepare(`
|
|
47
|
+
SELECT id, webhook_id, received_at, project_id, payload_json, processing_status
|
|
48
|
+
FROM webhook_events
|
|
49
|
+
WHERE received_at < ?
|
|
50
|
+
AND processing_status != 'pending'
|
|
51
|
+
ORDER BY received_at ASC, id ASC
|
|
52
|
+
LIMIT ?
|
|
53
|
+
`).all(cutoffIso, limit);
|
|
54
|
+
return rows.map((row) => ({
|
|
55
|
+
id: Number(row.id),
|
|
56
|
+
webhookId: String(row.webhook_id),
|
|
57
|
+
receivedAt: String(row.received_at),
|
|
58
|
+
...(row.project_id != null ? { projectId: String(row.project_id) } : {}),
|
|
59
|
+
...(row.payload_json != null ? { payloadJson: String(row.payload_json) } : {}),
|
|
60
|
+
processingStatus: String(row.processing_status),
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
countArchiveableEventsBefore(cutoffIso) {
|
|
64
|
+
const row = this.connection.prepare(`
|
|
65
|
+
SELECT COUNT(*) AS count
|
|
66
|
+
FROM webhook_events
|
|
67
|
+
WHERE received_at < ?
|
|
68
|
+
AND processing_status != 'pending'
|
|
69
|
+
`).get(cutoffIso);
|
|
70
|
+
return Number(row?.count ?? 0);
|
|
71
|
+
}
|
|
72
|
+
deleteWebhookEventsByIds(ids) {
|
|
73
|
+
if (ids.length === 0)
|
|
74
|
+
return 0;
|
|
75
|
+
return this.connection.transaction(() => {
|
|
76
|
+
let deleted = 0;
|
|
77
|
+
const statement = this.connection.prepare("DELETE FROM webhook_events WHERE id = ?");
|
|
78
|
+
for (const id of ids) {
|
|
79
|
+
const result = statement.run(id);
|
|
80
|
+
deleted += Number(result.changes ?? 0);
|
|
81
|
+
}
|
|
82
|
+
return deleted;
|
|
83
|
+
})();
|
|
84
|
+
}
|
|
45
85
|
findLatestAgentSessionIdForIssue(linearIssueId) {
|
|
46
86
|
const row = this.connection.prepare(`
|
|
47
87
|
SELECT COALESCE(
|
package/dist/db.js
CHANGED
|
@@ -16,6 +16,7 @@ import { TrackedIssueQuery } from "./tracked-issue-query.js";
|
|
|
16
16
|
import { WorkflowWakeResolver } from "./workflow-wake-resolver.js";
|
|
17
17
|
export class PatchRelayDatabase {
|
|
18
18
|
connection;
|
|
19
|
+
issueSessionProjection;
|
|
19
20
|
telemetry = noopTelemetry;
|
|
20
21
|
telemetryProxy = {
|
|
21
22
|
emit: (event) => this.telemetry.emit(event),
|
|
@@ -44,7 +45,7 @@ export class PatchRelayDatabase {
|
|
|
44
45
|
this.operatorFeed = new OperatorFeedStore(this.connection);
|
|
45
46
|
this.repositories = new RepositoryLinkStore(this.connection);
|
|
46
47
|
this.webhookEvents = new WebhookEventStore(this.connection);
|
|
47
|
-
|
|
48
|
+
this.issueSessionProjection = new ImmediateIssueSessionProjectionInvalidator({
|
|
48
49
|
getIssue: (projectId, linearIssueId) => this.issues.getIssue(projectId, linearIssueId),
|
|
49
50
|
listDependents: (projectId, blockerLinearIssueId) => this.issues.listDependents(projectId, blockerLinearIssueId),
|
|
50
51
|
countUnresolvedBlockers: (projectId, linearIssueId) => this.issues.countUnresolvedBlockers(projectId, linearIssueId),
|
|
@@ -59,9 +60,9 @@ export class PatchRelayDatabase {
|
|
|
59
60
|
}),
|
|
60
61
|
telemetry: this.telemetryProxy,
|
|
61
62
|
});
|
|
62
|
-
this.issues = new IssueStore(this.connection, issueSessionProjection);
|
|
63
|
-
this.runs = new RunStore(this.connection, mapRunRow, this.issues, issueSessionProjection, this.telemetryProxy);
|
|
64
|
-
this.issueSessions = new IssueSessionStore(this.connection, mapIssueSessionRow, mapIssueSessionEventRow, this.issues, this.runs, issueSessionProjection, this.telemetryProxy);
|
|
63
|
+
this.issues = new IssueStore(this.connection, this.issueSessionProjection);
|
|
64
|
+
this.runs = new RunStore(this.connection, mapRunRow, this.issues, this.issueSessionProjection, this.telemetryProxy);
|
|
65
|
+
this.issueSessions = new IssueSessionStore(this.connection, mapIssueSessionRow, mapIssueSessionEventRow, this.issues, this.runs, this.issueSessionProjection, this.telemetryProxy);
|
|
65
66
|
this.workflowWakes = new WorkflowWakeResolver(this.issues, this.issueSessions);
|
|
66
67
|
this.trackedIssues = new TrackedIssueQuery(this.issues, this.issueSessions, this.workflowWakes, this.runs);
|
|
67
68
|
}
|
|
@@ -79,6 +80,12 @@ export class PatchRelayDatabase {
|
|
|
79
80
|
transaction(fn) {
|
|
80
81
|
return this.connection.transaction(fn)();
|
|
81
82
|
}
|
|
83
|
+
batchIssueSessionProjections(fn) {
|
|
84
|
+
return this.issueSessionProjection.batch(fn);
|
|
85
|
+
}
|
|
86
|
+
runWalCheckpoint(mode = "PASSIVE") {
|
|
87
|
+
return this.connection.prepare(`PRAGMA wal_checkpoint(${mode})`).all();
|
|
88
|
+
}
|
|
82
89
|
close() {
|
|
83
90
|
this.connection.close();
|
|
84
91
|
}
|
|
@@ -249,6 +256,7 @@ function mapRunRow(row) {
|
|
|
249
256
|
linearIssueId: String(row.linear_issue_id),
|
|
250
257
|
runType: String(row.run_type ?? "implementation"),
|
|
251
258
|
status: String(row.status),
|
|
259
|
+
...(row.launch_phase !== null && row.launch_phase !== undefined ? { launchPhase: String(row.launch_phase) } : {}),
|
|
252
260
|
...(row.source_head_sha !== null ? { sourceHeadSha: String(row.source_head_sha) } : {}),
|
|
253
261
|
...(row.prompt_text !== null ? { promptText: String(row.prompt_text) } : {}),
|
|
254
262
|
...(row.thread_id !== null ? { threadId: String(row.thread_id) } : {}),
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { once } from "node:events";
|
|
5
|
+
import { createGzip } from "node:zlib";
|
|
6
|
+
export const DEFAULT_EVENT_RETENTION_DAYS = 7;
|
|
7
|
+
const DEFAULT_BATCH_SIZE = 1_000;
|
|
8
|
+
export async function runWebhookEventRetention(params) {
|
|
9
|
+
const retentionDays = params.options?.retentionDays
|
|
10
|
+
?? params.config.database.eventRetentionDays
|
|
11
|
+
?? DEFAULT_EVENT_RETENTION_DAYS;
|
|
12
|
+
const cutoffIso = computeRetentionCutoffIso(params.options?.now ?? new Date(), retentionDays);
|
|
13
|
+
const batchSize = Math.max(1, Math.floor(params.options?.batchSize ?? DEFAULT_BATCH_SIZE));
|
|
14
|
+
const archiveOldEvents = params.options?.archiveOldEvents ?? params.config.database.archiveOldEvents === true;
|
|
15
|
+
const archivePath = params.options?.archivePath ?? params.config.database.archivePath;
|
|
16
|
+
const dryRun = params.options?.dryRun === true;
|
|
17
|
+
let scanned = 0;
|
|
18
|
+
let archived = 0;
|
|
19
|
+
let deleted = 0;
|
|
20
|
+
let writer;
|
|
21
|
+
if (dryRun) {
|
|
22
|
+
const remaining = params.db.webhookEvents.countArchiveableEventsBefore(cutoffIso);
|
|
23
|
+
return {
|
|
24
|
+
cutoffIso,
|
|
25
|
+
scanned: remaining,
|
|
26
|
+
archived: 0,
|
|
27
|
+
deleted: 0,
|
|
28
|
+
remaining,
|
|
29
|
+
dryRun,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
if (archiveOldEvents) {
|
|
34
|
+
writer = await JsonlGzipArchiveWriter.create(resolveArchiveFilePath(archivePath, params.options?.now ?? new Date()));
|
|
35
|
+
}
|
|
36
|
+
while (true) {
|
|
37
|
+
const records = params.db.webhookEvents.listArchiveableEventsBefore(cutoffIso, batchSize);
|
|
38
|
+
if (records.length === 0)
|
|
39
|
+
break;
|
|
40
|
+
scanned += records.length;
|
|
41
|
+
if (writer) {
|
|
42
|
+
await writer.writeRecords(records);
|
|
43
|
+
archived += records.length;
|
|
44
|
+
}
|
|
45
|
+
deleted += params.db.webhookEvents.deleteWebhookEventsByIds(records.map((record) => record.id));
|
|
46
|
+
if (records.length < batchSize)
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
await writer?.close();
|
|
52
|
+
}
|
|
53
|
+
const remaining = params.db.webhookEvents.countArchiveableEventsBefore(cutoffIso);
|
|
54
|
+
return {
|
|
55
|
+
cutoffIso,
|
|
56
|
+
scanned,
|
|
57
|
+
archived,
|
|
58
|
+
deleted,
|
|
59
|
+
remaining,
|
|
60
|
+
...(writer?.filePath ? { archiveFile: writer.filePath } : {}),
|
|
61
|
+
dryRun,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function computeRetentionCutoffIso(now, retentionDays) {
|
|
65
|
+
return new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
|
|
66
|
+
}
|
|
67
|
+
function resolveArchiveFilePath(archivePath, now) {
|
|
68
|
+
const root = archivePath ?? path.join(process.cwd(), "archive");
|
|
69
|
+
const stamp = now.toISOString().replaceAll(":", "-").replaceAll(".", "-");
|
|
70
|
+
return path.join(root, "webhook-events", `${stamp}.jsonl.gz`);
|
|
71
|
+
}
|
|
72
|
+
class JsonlGzipArchiveWriter {
|
|
73
|
+
filePath;
|
|
74
|
+
gzip;
|
|
75
|
+
output;
|
|
76
|
+
constructor(filePath, gzip, output) {
|
|
77
|
+
this.filePath = filePath;
|
|
78
|
+
this.gzip = gzip;
|
|
79
|
+
this.output = output;
|
|
80
|
+
}
|
|
81
|
+
static async create(filePath) {
|
|
82
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
83
|
+
const gzip = createGzip();
|
|
84
|
+
const output = createWriteStream(filePath, { flags: "wx" });
|
|
85
|
+
gzip.pipe(output);
|
|
86
|
+
return new JsonlGzipArchiveWriter(filePath, gzip, output);
|
|
87
|
+
}
|
|
88
|
+
async writeRecords(records) {
|
|
89
|
+
for (const record of records) {
|
|
90
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
91
|
+
if (!this.gzip.write(line)) {
|
|
92
|
+
await once(this.gzip, "drain");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async close() {
|
|
97
|
+
this.gzip.end();
|
|
98
|
+
await once(this.output, "close");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import { emitTelemetry, noopTelemetry } from "./telemetry.js";
|
|
2
2
|
export class ImmediateIssueSessionProjectionInvalidator {
|
|
3
3
|
deps;
|
|
4
|
+
batchDepth = 0;
|
|
5
|
+
pendingProjections = new Map();
|
|
4
6
|
constructor(deps) {
|
|
5
7
|
this.deps = deps;
|
|
6
8
|
}
|
|
9
|
+
batch(fn) {
|
|
10
|
+
this.batchDepth += 1;
|
|
11
|
+
try {
|
|
12
|
+
return fn();
|
|
13
|
+
}
|
|
14
|
+
finally {
|
|
15
|
+
this.batchDepth -= 1;
|
|
16
|
+
if (this.batchDepth === 0) {
|
|
17
|
+
this.flushPendingProjections();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
7
21
|
issueChanged(issue, options) {
|
|
8
22
|
const dependents = this.deps.listDependents(issue.projectId, issue.linearIssueId);
|
|
9
23
|
this.emitInvalidated("issue_changed", issue.projectId, issue.linearIssueId, issue.issueKey, 1 + dependents.length);
|
|
@@ -32,12 +46,26 @@ export class ImmediateIssueSessionProjectionInvalidator {
|
|
|
32
46
|
this.projectIssueById(projectId, linearIssueId, "issue_session_events_changed");
|
|
33
47
|
}
|
|
34
48
|
projectIssueById(projectId, linearIssueId, reason) {
|
|
49
|
+
if (this.batchDepth > 0) {
|
|
50
|
+
this.queueProjection({ projectId, linearIssueId, reason });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
35
53
|
const issue = this.deps.getIssue(projectId, linearIssueId);
|
|
36
54
|
if (issue) {
|
|
37
55
|
this.projectIssue(issue, reason);
|
|
38
56
|
}
|
|
39
57
|
}
|
|
40
58
|
projectIssue(issue, reason, options) {
|
|
59
|
+
if (this.batchDepth > 0) {
|
|
60
|
+
this.queueProjection({
|
|
61
|
+
projectId: issue.projectId,
|
|
62
|
+
linearIssueId: issue.linearIssueId,
|
|
63
|
+
issue,
|
|
64
|
+
reason,
|
|
65
|
+
...(options ? { options } : {}),
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
41
69
|
const beforeWaitingReason = this.deps.getIssueSessionWaitingReason?.(issue.projectId, issue.linearIssueId);
|
|
42
70
|
this.deps.projectIssue(issue, options);
|
|
43
71
|
this.emitReprojected(reason, issue);
|
|
@@ -87,4 +115,32 @@ export class ImmediateIssueSessionProjectionInvalidator {
|
|
|
87
115
|
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
88
116
|
});
|
|
89
117
|
}
|
|
118
|
+
queueProjection(projection) {
|
|
119
|
+
const key = `${projection.projectId}::${projection.linearIssueId}`;
|
|
120
|
+
const current = this.pendingProjections.get(key);
|
|
121
|
+
this.pendingProjections.set(key, {
|
|
122
|
+
projectId: projection.projectId,
|
|
123
|
+
linearIssueId: projection.linearIssueId,
|
|
124
|
+
issue: projection.issue ?? current?.issue,
|
|
125
|
+
reason: projection.reason,
|
|
126
|
+
options: mergeProjectionOptions(current?.options, projection.options),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
flushPendingProjections() {
|
|
130
|
+
const pending = Array.from(this.pendingProjections.values());
|
|
131
|
+
this.pendingProjections.clear();
|
|
132
|
+
for (const projection of pending) {
|
|
133
|
+
const issue = projection.issue ?? this.deps.getIssue(projection.projectId, projection.linearIssueId);
|
|
134
|
+
if (issue) {
|
|
135
|
+
this.projectIssue(issue, projection.reason, projection.options);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function mergeProjectionOptions(current, next) {
|
|
141
|
+
if (!current)
|
|
142
|
+
return next;
|
|
143
|
+
if (!next)
|
|
144
|
+
return current;
|
|
145
|
+
return { ...current, ...next };
|
|
90
146
|
}
|
package/dist/run-launcher.js
CHANGED
|
@@ -104,53 +104,55 @@ export class RunLauncher {
|
|
|
104
104
|
}
|
|
105
105
|
claimRun(params) {
|
|
106
106
|
return this.db.issueSessions.withIssueSessionLease(params.item.projectId, params.item.issueId, params.leaseId, () => {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
: params.runType === "
|
|
140
|
-
: params.runType === "
|
|
141
|
-
: params.runType === "
|
|
142
|
-
: "
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
107
|
+
return this.db.batchIssueSessionProjections(() => {
|
|
108
|
+
const fresh = this.db.issues.getIssue(params.item.projectId, params.item.issueId);
|
|
109
|
+
if (!fresh || fresh.activeRunId !== undefined)
|
|
110
|
+
return undefined;
|
|
111
|
+
const wakeIssue = params.materializeLegacyPendingWake(fresh, {
|
|
112
|
+
projectId: params.item.projectId,
|
|
113
|
+
linearIssueId: params.item.issueId,
|
|
114
|
+
leaseId: params.leaseId,
|
|
115
|
+
});
|
|
116
|
+
const freshWake = params.resolveRunWake(wakeIssue);
|
|
117
|
+
if (!freshWake || freshWake.runType !== params.runType)
|
|
118
|
+
return undefined;
|
|
119
|
+
const created = this.db.runs.createRun({
|
|
120
|
+
issueId: fresh.id,
|
|
121
|
+
projectId: params.item.projectId,
|
|
122
|
+
linearIssueId: params.item.issueId,
|
|
123
|
+
runType: params.runType,
|
|
124
|
+
...(params.sourceHeadSha ? { sourceHeadSha: params.sourceHeadSha } : {}),
|
|
125
|
+
promptText: params.prompt,
|
|
126
|
+
});
|
|
127
|
+
const failureHeadSha = typeof params.effectiveContext?.failureHeadSha === "string"
|
|
128
|
+
? params.effectiveContext.failureHeadSha
|
|
129
|
+
: typeof params.effectiveContext?.headSha === "string" ? params.effectiveContext.headSha : undefined;
|
|
130
|
+
const failureSignature = typeof params.effectiveContext?.failureSignature === "string" ? params.effectiveContext.failureSignature : undefined;
|
|
131
|
+
this.db.issues.upsertIssue({
|
|
132
|
+
projectId: params.item.projectId,
|
|
133
|
+
linearIssueId: params.item.issueId,
|
|
134
|
+
pendingRunType: null,
|
|
135
|
+
pendingRunContextJson: null,
|
|
136
|
+
activeRunId: created.id,
|
|
137
|
+
branchName: params.branchName,
|
|
138
|
+
worktreePath: params.worktreePath,
|
|
139
|
+
factoryState: params.runType === "implementation" ? "implementing"
|
|
140
|
+
: params.runType === "ci_repair" ? "repairing_ci"
|
|
141
|
+
: params.runType === "review_fix" || params.runType === "branch_upkeep" ? "changes_requested"
|
|
142
|
+
: params.runType === "queue_repair" ? "repairing_queue"
|
|
143
|
+
: "implementing",
|
|
144
|
+
...((params.runType === "ci_repair" || params.runType === "queue_repair") && failureSignature
|
|
145
|
+
? {
|
|
146
|
+
lastAttemptedFailureSignature: failureSignature,
|
|
147
|
+
lastAttemptedFailureHeadSha: failureHeadSha ?? null,
|
|
148
|
+
lastAttemptedFailureAt: new Date().toISOString(),
|
|
149
|
+
}
|
|
150
|
+
: {}),
|
|
151
|
+
});
|
|
152
|
+
this.db.issueSessions.consumeIssueSessionEvents(params.item.projectId, params.item.issueId, freshWake.eventIds, created.id);
|
|
153
|
+
this.db.issueSessions.setIssueSessionLastWakeReason(params.item.projectId, params.item.issueId, freshWake.wakeReason ?? null);
|
|
154
|
+
return created;
|
|
150
155
|
});
|
|
151
|
-
this.db.issueSessions.consumeIssueSessionEvents(params.item.projectId, params.item.issueId, freshWake.eventIds, created.id);
|
|
152
|
-
this.db.issueSessions.setIssueSessionLastWakeReason(params.item.projectId, params.item.issueId, freshWake.wakeReason ?? null);
|
|
153
|
-
return created;
|
|
154
156
|
});
|
|
155
157
|
}
|
|
156
158
|
async launchTurn(params) {
|
|
@@ -186,6 +188,7 @@ export class RunLauncher {
|
|
|
186
188
|
if (prepareResult.ran && prepareResult.exitCode !== 0) {
|
|
187
189
|
throw new Error(`prepare-worktree hook failed (exit ${prepareResult.exitCode}): ${prepareResult.stderr?.slice(0, 500) ?? ""}`);
|
|
188
190
|
}
|
|
191
|
+
this.db.runs.updateLaunchPhase(params.run.id, "worktree_prepared");
|
|
189
192
|
params.assertLaunchLease(params.run, "before starting the Codex turn");
|
|
190
193
|
const compactThread = shouldCompactThread(params.issue, params.issueSession?.threadGeneration, params.effectiveContext);
|
|
191
194
|
if (compactThread && params.issue.threadId) {
|
|
@@ -200,9 +203,11 @@ export class RunLauncher {
|
|
|
200
203
|
createdThreadForRun = true;
|
|
201
204
|
this.db.issueSessions.upsertIssueWithLease({ projectId: params.project.id, linearIssueId: params.issue.linearIssueId, leaseId: params.leaseId }, { projectId: params.project.id, linearIssueId: params.issue.linearIssueId, threadId });
|
|
202
205
|
}
|
|
206
|
+
this.db.runs.updateLaunchPhase(params.run.id, "thread_started");
|
|
203
207
|
try {
|
|
204
208
|
const turn = await this.codex.startTurn({ threadId, cwd: params.worktreePath, input: params.prompt });
|
|
205
209
|
turnId = turn.turnId;
|
|
210
|
+
this.db.runs.updateLaunchPhase(params.run.id, "turn_started");
|
|
206
211
|
}
|
|
207
212
|
catch (turnError) {
|
|
208
213
|
const msg = turnError instanceof Error ? turnError.message : String(turnError);
|
|
@@ -214,6 +219,7 @@ export class RunLauncher {
|
|
|
214
219
|
this.db.issueSessions.upsertIssueWithLease({ projectId: params.project.id, linearIssueId: params.issue.linearIssueId, leaseId: params.leaseId }, { projectId: params.project.id, linearIssueId: params.issue.linearIssueId, threadId });
|
|
215
220
|
const turn = await this.codex.startTurn({ threadId, cwd: params.worktreePath, input: params.prompt });
|
|
216
221
|
turnId = turn.turnId;
|
|
222
|
+
this.db.runs.updateLaunchPhase(params.run.id, "turn_started");
|
|
217
223
|
}
|
|
218
224
|
else {
|
|
219
225
|
throw turnError;
|
package/dist/service-queue.js
CHANGED
|
@@ -74,7 +74,7 @@ export class SerialWorkQueue {
|
|
|
74
74
|
const nextAttempt = entry.attempt + 1;
|
|
75
75
|
const retry = this.options.retryOnError?.(err, entry.item, nextAttempt);
|
|
76
76
|
if (retry) {
|
|
77
|
-
this.logger.warn({ item: entry.item, error: err.message, attempt: nextAttempt, retryDelayMs: retry.delayMs }, "Queue item processing failed; retrying");
|
|
77
|
+
this.logger[retry.logLevel ?? "warn"]({ item: entry.item, error: err.message, attempt: nextAttempt, retryDelayMs: retry.delayMs }, retry.message ?? "Queue item processing failed; retrying");
|
|
78
78
|
this.scheduleRetry({ item: entry.item, attempt: nextAttempt }, retry.delayMs);
|
|
79
79
|
continue;
|
|
80
80
|
}
|
package/dist/service-runtime.js
CHANGED
|
@@ -4,6 +4,8 @@ const ISSUE_KEY_DELIMITER = "::";
|
|
|
4
4
|
const DEFAULT_RECONCILE_INTERVAL_MS = 5_000;
|
|
5
5
|
const DEFAULT_RECONCILE_TIMEOUT_MS = 60_000;
|
|
6
6
|
const DEFAULT_MAX_ACTIVE_ISSUE_RUNS = 4;
|
|
7
|
+
const DEFAULT_ISSUE_RUN_CAPACITY_RETRY_DELAY_MS = 5_000;
|
|
8
|
+
const EVENT_LOOP_MONITOR_INTERVAL_MS = 1_000;
|
|
7
9
|
function makeIssueQueueKey(item) {
|
|
8
10
|
return `${item.projectId}${ISSUE_KEY_DELIMITER}${item.issueId}`;
|
|
9
11
|
}
|
|
@@ -21,6 +23,9 @@ export class ServiceRuntime {
|
|
|
21
23
|
githubAppAuthError;
|
|
22
24
|
startupError;
|
|
23
25
|
reconcileTimer;
|
|
26
|
+
eventLoopMonitorTimer;
|
|
27
|
+
eventLoopMonitorExpectedAt = 0;
|
|
28
|
+
eventLoopLagMs = 0;
|
|
24
29
|
reconcileInProgress = false;
|
|
25
30
|
constructor(codex, logger, runReconciler, readyIssueSource, webhookProcessor, issueProcessor, options = {}) {
|
|
26
31
|
this.codex = codex;
|
|
@@ -29,13 +34,23 @@ export class ServiceRuntime {
|
|
|
29
34
|
this.readyIssueSource = readyIssueSource;
|
|
30
35
|
this.options = options;
|
|
31
36
|
this.webhookQueue = new SerialWorkQueue((eventId) => webhookProcessor.processWebhookEvent(eventId), logger, (eventId) => String(eventId));
|
|
32
|
-
this.issueQueue = new SerialWorkQueue((item) =>
|
|
33
|
-
retryOnError: (error, _item, attempt) =>
|
|
37
|
+
this.issueQueue = new SerialWorkQueue((item) => this.processIssueWithCapacity(item, issueProcessor), logger, makeIssueQueueKey, {
|
|
38
|
+
retryOnError: (error, _item, attempt) => {
|
|
39
|
+
if (error instanceof IssueRunCapacityFullError) {
|
|
40
|
+
return {
|
|
41
|
+
delayMs: this.getIssueRunCapacityRetryDelayMs(),
|
|
42
|
+
logLevel: "debug",
|
|
43
|
+
message: "Issue run capacity is full; keeping item queued for retry",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return retrySqliteLockedQueueFailure(error, attempt);
|
|
47
|
+
},
|
|
34
48
|
});
|
|
35
49
|
}
|
|
36
50
|
async start() {
|
|
37
51
|
try {
|
|
38
52
|
await this.codex.start();
|
|
53
|
+
this.startEventLoopMonitor();
|
|
39
54
|
for (const issue of this.readyIssueSource.listIssuesReadyForExecution()) {
|
|
40
55
|
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
41
56
|
}
|
|
@@ -52,22 +67,13 @@ export class ServiceRuntime {
|
|
|
52
67
|
async stop() {
|
|
53
68
|
this.ready = false;
|
|
54
69
|
this.clearBackgroundReconcile();
|
|
70
|
+
this.clearEventLoopMonitor();
|
|
55
71
|
await this.codex.stop();
|
|
56
72
|
}
|
|
57
73
|
enqueueWebhookEvent(eventId, options) {
|
|
58
74
|
this.webhookQueue.enqueue(eventId, options);
|
|
59
75
|
}
|
|
60
76
|
enqueueIssue(projectId, issueId) {
|
|
61
|
-
if (!this.hasIssueRunCapacity()) {
|
|
62
|
-
this.logger.warn({
|
|
63
|
-
projectId,
|
|
64
|
-
issueId,
|
|
65
|
-
activeIssueRuns: this.getActiveIssueRunCount(),
|
|
66
|
-
queuedIssueRuns: this.issueQueue.size(),
|
|
67
|
-
maxActiveIssueRuns: this.getMaxActiveIssueRuns(),
|
|
68
|
-
}, "Skipped issue enqueue: active run capacity is full");
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
77
|
this.issueQueue.enqueue({ projectId, issueId });
|
|
72
78
|
}
|
|
73
79
|
setLinearConnected(connected) {
|
|
@@ -83,10 +89,29 @@ export class ServiceRuntime {
|
|
|
83
89
|
codexStarted: this.codex.isStarted(),
|
|
84
90
|
linearConnected: this.linearConnected,
|
|
85
91
|
githubAppAuthHealthy: this.githubAppAuthHealthy,
|
|
92
|
+
eventLoopLagMs: this.eventLoopLagMs,
|
|
86
93
|
...(this.githubAppAuthError ? { githubAppAuthError: this.githubAppAuthError } : {}),
|
|
87
94
|
...(this.startupError ? { startupError: this.startupError } : {}),
|
|
88
95
|
};
|
|
89
96
|
}
|
|
97
|
+
startEventLoopMonitor() {
|
|
98
|
+
this.clearEventLoopMonitor();
|
|
99
|
+
this.eventLoopMonitorExpectedAt = Date.now() + EVENT_LOOP_MONITOR_INTERVAL_MS;
|
|
100
|
+
const timer = setInterval(() => {
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
this.eventLoopLagMs = Math.max(0, now - this.eventLoopMonitorExpectedAt);
|
|
103
|
+
this.eventLoopMonitorExpectedAt = now + EVENT_LOOP_MONITOR_INTERVAL_MS;
|
|
104
|
+
}, EVENT_LOOP_MONITOR_INTERVAL_MS);
|
|
105
|
+
timer.unref?.();
|
|
106
|
+
this.eventLoopMonitorTimer = timer;
|
|
107
|
+
}
|
|
108
|
+
clearEventLoopMonitor() {
|
|
109
|
+
if (this.eventLoopMonitorTimer !== undefined) {
|
|
110
|
+
clearInterval(this.eventLoopMonitorTimer);
|
|
111
|
+
this.eventLoopMonitorTimer = undefined;
|
|
112
|
+
}
|
|
113
|
+
this.eventLoopLagMs = 0;
|
|
114
|
+
}
|
|
90
115
|
scheduleBackgroundReconcile() {
|
|
91
116
|
this.clearBackgroundReconcile();
|
|
92
117
|
const timer = setTimeout(() => {
|
|
@@ -114,9 +139,6 @@ export class ServiceRuntime {
|
|
|
114
139
|
// Pick up issues that became ready outside the webhook path
|
|
115
140
|
// (e.g. CLI retry, manual DB edits) without requiring a restart.
|
|
116
141
|
for (const issue of this.readyIssueSource.listIssuesReadyForExecution()) {
|
|
117
|
-
if (!this.hasIssueRunCapacity()) {
|
|
118
|
-
break;
|
|
119
|
-
}
|
|
120
142
|
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
121
143
|
}
|
|
122
144
|
}
|
|
@@ -134,11 +156,30 @@ export class ServiceRuntime {
|
|
|
134
156
|
const configured = this.options.maxActiveIssueRuns ?? DEFAULT_MAX_ACTIVE_ISSUE_RUNS;
|
|
135
157
|
return Math.max(1, Math.floor(configured));
|
|
136
158
|
}
|
|
159
|
+
getIssueRunCapacityRetryDelayMs() {
|
|
160
|
+
const configured = this.options.issueRunCapacityRetryDelayMs ?? DEFAULT_ISSUE_RUN_CAPACITY_RETRY_DELAY_MS;
|
|
161
|
+
return Math.max(1, Math.floor(configured));
|
|
162
|
+
}
|
|
137
163
|
getActiveIssueRunCount() {
|
|
138
164
|
return Math.max(0, this.readyIssueSource.countActiveIssueRuns?.() ?? 0);
|
|
139
165
|
}
|
|
140
|
-
|
|
141
|
-
|
|
166
|
+
async processIssueWithCapacity(item, processor) {
|
|
167
|
+
const activeIssueRuns = this.getActiveIssueRunCount();
|
|
168
|
+
const maxActiveIssueRuns = this.getMaxActiveIssueRuns();
|
|
169
|
+
if (activeIssueRuns >= maxActiveIssueRuns) {
|
|
170
|
+
throw new IssueRunCapacityFullError(activeIssueRuns, maxActiveIssueRuns);
|
|
171
|
+
}
|
|
172
|
+
await processor.processIssue(item);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
class IssueRunCapacityFullError extends Error {
|
|
176
|
+
activeIssueRuns;
|
|
177
|
+
maxActiveIssueRuns;
|
|
178
|
+
constructor(activeIssueRuns, maxActiveIssueRuns) {
|
|
179
|
+
super(`active issue run capacity is full (${activeIssueRuns}/${maxActiveIssueRuns})`);
|
|
180
|
+
this.activeIssueRuns = activeIssueRuns;
|
|
181
|
+
this.maxActiveIssueRuns = maxActiveIssueRuns;
|
|
182
|
+
this.name = "IssueRunCapacityFullError";
|
|
142
183
|
}
|
|
143
184
|
}
|
|
144
185
|
function promiseWithTimeout(promise, timeoutMs, label) {
|
package/dist/service.js
CHANGED
|
@@ -14,6 +14,7 @@ import { ServiceStartupRecovery } from "./service-startup-recovery.js";
|
|
|
14
14
|
import { WakeDispatcher } from "./wake-dispatcher.js";
|
|
15
15
|
import { WebhookHandler } from "./webhook-handler.js";
|
|
16
16
|
import { acceptIncomingWebhook } from "./service-webhooks.js";
|
|
17
|
+
import { runWebhookEventRetention } from "./event-retention.js";
|
|
17
18
|
import { parseStringArray, TrackedIssueListQuery } from "./tracked-issue-list-query.js";
|
|
18
19
|
import { AgentInputService } from "./agent-input-service.js";
|
|
19
20
|
import { CodexFollowupIntentClassifier } from "./followup-intent.js";
|
|
@@ -36,6 +37,7 @@ export class PatchRelayService {
|
|
|
36
37
|
issueActions;
|
|
37
38
|
startupRecovery;
|
|
38
39
|
trackedIssueListQuery;
|
|
40
|
+
eventRetentionTimer;
|
|
39
41
|
constructor(config, db, codex, linearProvider, logger, configPath) {
|
|
40
42
|
this.config = config;
|
|
41
43
|
this.db = db;
|
|
@@ -181,6 +183,7 @@ export class PatchRelayService {
|
|
|
181
183
|
});
|
|
182
184
|
}
|
|
183
185
|
await this.runtime.start();
|
|
186
|
+
this.scheduleEventRetention(60_000);
|
|
184
187
|
void this.startupRecovery.recoverDelegatedIssueStateFromLinear().catch((error) => {
|
|
185
188
|
const msg = error instanceof Error ? error.message : String(error);
|
|
186
189
|
this.logger.warn({ error: msg }, "Background delegated issue recovery failed");
|
|
@@ -191,6 +194,10 @@ export class PatchRelayService {
|
|
|
191
194
|
});
|
|
192
195
|
}
|
|
193
196
|
async stop() {
|
|
197
|
+
if (this.eventRetentionTimer !== undefined) {
|
|
198
|
+
clearTimeout(this.eventRetentionTimer);
|
|
199
|
+
this.eventRetentionTimer = undefined;
|
|
200
|
+
}
|
|
194
201
|
this.githubAppTokenManager?.stop();
|
|
195
202
|
await this.runtime.stop();
|
|
196
203
|
}
|
|
@@ -280,6 +287,37 @@ export class PatchRelayService {
|
|
|
280
287
|
getReadiness() {
|
|
281
288
|
return this.runtime.getReadiness();
|
|
282
289
|
}
|
|
290
|
+
scheduleEventRetention(delayMs = 24 * 60 * 60 * 1000) {
|
|
291
|
+
if (this.eventRetentionTimer !== undefined) {
|
|
292
|
+
clearTimeout(this.eventRetentionTimer);
|
|
293
|
+
}
|
|
294
|
+
const timer = setTimeout(() => {
|
|
295
|
+
void this.runEventRetentionMaintenance();
|
|
296
|
+
}, delayMs);
|
|
297
|
+
timer.unref?.();
|
|
298
|
+
this.eventRetentionTimer = timer;
|
|
299
|
+
}
|
|
300
|
+
async runEventRetentionMaintenance() {
|
|
301
|
+
try {
|
|
302
|
+
const result = await runWebhookEventRetention({
|
|
303
|
+
db: this.db,
|
|
304
|
+
config: this.config,
|
|
305
|
+
});
|
|
306
|
+
if (result.deleted > 0 || result.archived > 0 || result.remaining > 0) {
|
|
307
|
+
this.logger.info(result, "Webhook event retention maintenance completed");
|
|
308
|
+
}
|
|
309
|
+
if (this.config.database.wal) {
|
|
310
|
+
const checkpoint = this.db.runWalCheckpoint("PASSIVE");
|
|
311
|
+
this.logger.debug({ checkpoint }, "SQLite WAL checkpoint completed");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
this.logger.warn({ error: error instanceof Error ? error.message : String(error) }, "Webhook event retention maintenance failed");
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
this.scheduleEventRetention();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
283
321
|
listTrackedIssues() {
|
|
284
322
|
return this.trackedIssueListQuery.listTrackedIssues();
|
|
285
323
|
}
|