patchrelay 0.75.3 → 0.77.0
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/agent-input-service.js +40 -26
- package/dist/build-info.json +3 -3
- package/dist/cli/data.js +3 -1
- package/dist/db/issue-session-store.js +44 -9
- package/dist/db/issue-store.js +11 -2
- package/dist/db/migrations.js +3 -0
- package/dist/factory-state.js +23 -0
- package/dist/github-webhook-reactive-run.js +15 -11
- package/dist/github-webhook-stack-coordination.js +8 -4
- package/dist/github-webhook-state-projector.js +204 -139
- package/dist/github-webhook-terminal-handler.js +37 -27
- package/dist/idle-reconciliation.js +122 -66
- package/dist/implementation-outcome-policy.js +5 -1
- package/dist/issue-session-projection-invalidator.js +9 -0
- package/dist/linear-agent-session-client.js +16 -8
- package/dist/linear-issue-projection.js +15 -11
- package/dist/linear-status-comment-sync.js +8 -4
- package/dist/linear-workflow-state-sync.js +9 -5
- package/dist/merged-linear-completion-reconciler.js +39 -17
- package/dist/no-pr-completion-check.js +51 -29
- package/dist/orchestration-parent-wake.js +15 -8
- package/dist/queue-health-monitor.js +17 -8
- package/dist/reactive-run-policy.js +5 -1
- package/dist/run-budgets.js +40 -6
- package/dist/run-completion-policy.js +50 -9
- package/dist/run-failure-policy.js +463 -0
- package/dist/run-finalizer.js +68 -35
- package/dist/run-launcher.js +63 -12
- package/dist/run-notification-handler.js +19 -9
- package/dist/run-orchestrator.js +70 -78
- package/dist/run-reconciler.js +137 -64
- package/dist/run-settlement.js +57 -0
- package/dist/run-wake-planner.js +39 -29
- package/dist/service-issue-actions.js +45 -28
- package/dist/service-startup-recovery.js +61 -35
- package/dist/telemetry.js +9 -0
- package/dist/terminal-wake-reconciler.js +20 -3
- package/dist/webhooks/agent-session-handler.js +22 -12
- package/dist/webhooks/dependency-readiness-handler.js +17 -10
- package/dist/webhooks/desired-stage-recorder.js +32 -13
- package/dist/webhooks/issue-removal-handler.js +24 -13
- package/package.json +1 -1
- package/dist/interrupted-run-recovery.js +0 -227
- package/dist/run-recovery-service.js +0 -202
- package/dist/zombie-recovery.js +0 -13
|
@@ -2,6 +2,7 @@ import { buildFollowupStatusActivity, buildNonActionableFollowupActivity, buildP
|
|
|
2
2
|
import { deriveIssueStatusNote } from "./status-note.js";
|
|
3
3
|
import { extractLatestAssistantSummary } from "./issue-session-events.js";
|
|
4
4
|
import { dirtyWorktreeEventPayload, inspectGitWorktreeStatus } from "./git-worktree-status.js";
|
|
5
|
+
const WRITER = "agent-input-service";
|
|
5
6
|
export class AgentInputService {
|
|
6
7
|
db;
|
|
7
8
|
codex;
|
|
@@ -195,21 +196,25 @@ export class AgentInputService {
|
|
|
195
196
|
prepareReplacementWork(project, issue) {
|
|
196
197
|
const issueRef = (issue.issueKey ?? issue.linearIssueId).replace(/[^a-zA-Z0-9._-]+/g, "-");
|
|
197
198
|
const suffix = Date.now().toString(36);
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
199
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
200
|
+
writer: WRITER,
|
|
201
|
+
update: {
|
|
202
|
+
projectId: issue.projectId,
|
|
203
|
+
linearIssueId: issue.linearIssueId,
|
|
204
|
+
factoryState: "delegated",
|
|
205
|
+
branchName: `${project.branchPrefix}/${issueRef}-replacement-${suffix}`,
|
|
206
|
+
prNumber: null,
|
|
207
|
+
prUrl: null,
|
|
208
|
+
prState: null,
|
|
209
|
+
prIsDraft: null,
|
|
210
|
+
prHeadSha: null,
|
|
211
|
+
prAuthorLogin: null,
|
|
212
|
+
prReviewState: null,
|
|
213
|
+
prCheckStatus: null,
|
|
214
|
+
lastBlockingReviewHeadSha: null,
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
return commit.outcome === "applied" ? commit.issue : issue;
|
|
213
218
|
}
|
|
214
219
|
async stopActiveRun(issue, run, body, source) {
|
|
215
220
|
const worktreeStatus = issue.worktreePath ? inspectGitWorktreeStatus(issue.worktreePath) : undefined;
|
|
@@ -226,18 +231,27 @@ export class AgentInputService {
|
|
|
226
231
|
catch (error) {
|
|
227
232
|
this.logger.warn({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to steer Codex turn for stop request");
|
|
228
233
|
}
|
|
229
|
-
this.db.runs.finishRun(run.id, {
|
|
230
|
-
status: "released",
|
|
231
|
-
threadId: run.threadId,
|
|
232
|
-
turnId: run.turnId,
|
|
233
|
-
failureReason: dirtySummary ? `Operator stopped run; ${dirtySummary}` : "Operator stopped run",
|
|
234
|
-
});
|
|
235
234
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
235
|
+
// The stop is an operator fact: the issue slot clear and the run release
|
|
236
|
+
// ride in one transaction, with the run gated on the issue commit.
|
|
237
|
+
this.db.transaction(() => {
|
|
238
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
239
|
+
writer: WRITER,
|
|
240
|
+
update: {
|
|
241
|
+
projectId: issue.projectId,
|
|
242
|
+
linearIssueId: issue.linearIssueId,
|
|
243
|
+
activeRunId: null,
|
|
244
|
+
factoryState: "awaiting_input",
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
if (commit.outcome === "applied" && run.threadId && run.turnId) {
|
|
248
|
+
this.db.runs.finishRun(run.id, {
|
|
249
|
+
status: "released",
|
|
250
|
+
threadId: run.threadId,
|
|
251
|
+
turnId: run.turnId,
|
|
252
|
+
failureReason: dirtySummary ? `Operator stopped run; ${dirtySummary}` : "Operator stopped run",
|
|
253
|
+
});
|
|
254
|
+
}
|
|
241
255
|
});
|
|
242
256
|
this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
243
257
|
eventType: "stop_requested",
|
package/dist/build-info.json
CHANGED
package/dist/cli/data.js
CHANGED
|
@@ -276,7 +276,9 @@ export class CliDataAccess extends CliOperatorApiClient {
|
|
|
276
276
|
: `Operator closed issue as ${terminalState}`,
|
|
277
277
|
});
|
|
278
278
|
}
|
|
279
|
-
|
|
279
|
+
// Operator CLI manual close — interactive, single-writer by construction
|
|
280
|
+
// (see the issue-write-door guard allowlist), so a raw upsert is fine.
|
|
281
|
+
this.db.upsertIssue({
|
|
280
282
|
projectId: issue.projectId,
|
|
281
283
|
linearIssueId: issue.linearIssueId,
|
|
282
284
|
delegatedToPatchRelay: false,
|
|
@@ -20,6 +20,7 @@ export class IssueSessionStore {
|
|
|
20
20
|
this.telemetry = telemetry;
|
|
21
21
|
}
|
|
22
22
|
getIssueSession(projectId, linearIssueId) {
|
|
23
|
+
this.issueSessionProjection.assertNotMidBatch?.("getIssueSession");
|
|
23
24
|
const row = this.connection
|
|
24
25
|
.prepare("SELECT * FROM issue_sessions WHERE project_id = ? AND linear_issue_id = ?")
|
|
25
26
|
.get(projectId, linearIssueId);
|
|
@@ -261,15 +262,49 @@ export class IssueSessionStore {
|
|
|
261
262
|
return fn();
|
|
262
263
|
})();
|
|
263
264
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
265
|
+
/**
|
|
266
|
+
* The single door for issue-state writes (core simplification plan, phase
|
|
267
|
+
* A): one transaction wrapping lease validity, an optimistic version check
|
|
268
|
+
* against the row the update was derived from, and the write itself. A
|
|
269
|
+
* version mismatch means another writer landed between the caller's read
|
|
270
|
+
* and this commit — emitted as `state.write_conflict` telemetry and either
|
|
271
|
+
* recomputed via `onConflict`, skipped, or applied anyway (see params).
|
|
272
|
+
*/
|
|
273
|
+
commitIssueState(params) {
|
|
274
|
+
return this.connection.transaction(() => {
|
|
275
|
+
const { projectId, linearIssueId } = params.update;
|
|
276
|
+
if (params.lease && !this.hasActiveIssueSessionLease(projectId, linearIssueId, params.lease.leaseId)) {
|
|
277
|
+
return { outcome: "lease_denied" };
|
|
278
|
+
}
|
|
279
|
+
const current = this.issues.getIssue(projectId, linearIssueId);
|
|
280
|
+
const actualVersion = current?.version ?? null;
|
|
281
|
+
if (params.expectedVersion === undefined || actualVersion === params.expectedVersion) {
|
|
282
|
+
return { outcome: "applied", issue: this.issues.upsertIssue(params.update), conflicted: false };
|
|
283
|
+
}
|
|
284
|
+
const emitConflict = (resolution) => {
|
|
285
|
+
emitTelemetry(this.telemetry, {
|
|
286
|
+
type: "state.write_conflict",
|
|
287
|
+
projectId,
|
|
288
|
+
linearIssueId,
|
|
289
|
+
...(current?.issueKey ? { issueKey: current.issueKey } : {}),
|
|
290
|
+
writer: params.writer,
|
|
291
|
+
expectedVersion: params.expectedVersion ?? null,
|
|
292
|
+
actualVersion,
|
|
293
|
+
resolution,
|
|
294
|
+
});
|
|
295
|
+
};
|
|
296
|
+
if (params.onConflict && current) {
|
|
297
|
+
const recomputed = params.onConflict(current);
|
|
298
|
+
if (!recomputed) {
|
|
299
|
+
emitConflict("skipped");
|
|
300
|
+
return { outcome: "conflict_skipped", issue: current };
|
|
301
|
+
}
|
|
302
|
+
emitConflict("recomputed");
|
|
303
|
+
return { outcome: "applied", issue: this.issues.upsertIssue(recomputed), conflicted: true };
|
|
304
|
+
}
|
|
305
|
+
emitConflict("applied_anyway");
|
|
306
|
+
return { outcome: "applied", issue: this.issues.upsertIssue(params.update), conflicted: true };
|
|
307
|
+
})();
|
|
273
308
|
}
|
|
274
309
|
finishRunWithLease(lease, runId, params) {
|
|
275
310
|
return this.withIssueSessionLease(lease.projectId, lease.linearIssueId, lease.leaseId, () => {
|
package/dist/db/issue-store.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FACTORY_STATES, isFactoryState } from "../factory-state.js";
|
|
1
2
|
import { buildInsertBindings, buildUpdateAssignments } from "./issue-upsert-columns.js";
|
|
2
3
|
import { isoNow } from "./shared.js";
|
|
3
4
|
const CANCELED_OR_DUPLICATE_CHILD_PREDICATE = `
|
|
@@ -20,7 +21,7 @@ export class IssueStore {
|
|
|
20
21
|
const existing = this.getIssue(params.projectId, params.linearIssueId);
|
|
21
22
|
if (existing) {
|
|
22
23
|
const { assignments, values } = buildUpdateAssignments(params);
|
|
23
|
-
const sql = `UPDATE issues SET ${["updated_at = @now", ...assignments].join(", ")} WHERE project_id = @projectId AND linear_issue_id = @linearIssueId`;
|
|
24
|
+
const sql = `UPDATE issues SET ${["updated_at = @now", "version = version + 1", ...assignments].join(", ")} WHERE project_id = @projectId AND linear_issue_id = @linearIssueId`;
|
|
24
25
|
this.connection.prepare(sql).run({
|
|
25
26
|
...values,
|
|
26
27
|
now,
|
|
@@ -419,6 +420,13 @@ export class IssueStore {
|
|
|
419
420
|
}
|
|
420
421
|
}
|
|
421
422
|
export function mapIssueRow(row) {
|
|
423
|
+
const factoryState = String(row.factory_state ?? "delegated");
|
|
424
|
+
if (!isFactoryState(factoryState)) {
|
|
425
|
+
throw new Error(`Invalid factory_state '${factoryState}' on issue row `
|
|
426
|
+
+ `${String(row.project_id)}/${String(row.linear_issue_id)}`
|
|
427
|
+
+ `${row.issue_key != null ? ` (${String(row.issue_key)})` : ""}; `
|
|
428
|
+
+ "expected one of: " + [...FACTORY_STATES].join(", "));
|
|
429
|
+
}
|
|
422
430
|
return {
|
|
423
431
|
id: Number(row.id),
|
|
424
432
|
projectId: String(row.project_id),
|
|
@@ -448,7 +456,7 @@ export function mapIssueRow(row) {
|
|
|
448
456
|
...(row.current_linear_state_type !== null && row.current_linear_state_type !== undefined
|
|
449
457
|
? { currentLinearStateType: String(row.current_linear_state_type) }
|
|
450
458
|
: {}),
|
|
451
|
-
factoryState
|
|
459
|
+
factoryState,
|
|
452
460
|
...(row.pending_run_type !== null && row.pending_run_type !== undefined ? { pendingRunType: String(row.pending_run_type) } : {}),
|
|
453
461
|
...(row.pending_run_context_json !== null && row.pending_run_context_json !== undefined ? { pendingRunContextJson: String(row.pending_run_context_json) } : {}),
|
|
454
462
|
...(row.branch_name !== null ? { branchName: String(row.branch_name) } : {}),
|
|
@@ -546,5 +554,6 @@ export function mapIssueRow(row) {
|
|
|
546
554
|
...(row.deploy_started_at !== null && row.deploy_started_at !== undefined
|
|
547
555
|
? { deployStartedAt: String(row.deploy_started_at) }
|
|
548
556
|
: {}),
|
|
557
|
+
version: Number(row.version ?? 0),
|
|
549
558
|
};
|
|
550
559
|
}
|
package/dist/db/migrations.js
CHANGED
|
@@ -366,6 +366,9 @@ export function runPatchRelayMigrations(connection) {
|
|
|
366
366
|
// `deploying` state, so the deploy watcher only considers deploy runs
|
|
367
367
|
// created at/after the merge (and can time out a never-arriving deploy).
|
|
368
368
|
addColumnIfMissing(connection, "issues", "deploy_started_at", "TEXT");
|
|
369
|
+
// Optimistic-concurrency counter for issue-state writes (core
|
|
370
|
+
// simplification plan, phase A). Bumped on every UPDATE by upsertIssue.
|
|
371
|
+
addColumnIfMissing(connection, "issues", "version", "INTEGER NOT NULL DEFAULT 0");
|
|
369
372
|
}
|
|
370
373
|
function addColumnIfMissing(connection, table, column, definition) {
|
|
371
374
|
const cols = connection.prepare(`PRAGMA table_info(${table})`).all();
|
package/dist/factory-state.js
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical value set for {@link FactoryState}. Used for read-side validation
|
|
3
|
+
* (row mappers throw on unknown values instead of lying via cast) — keep in
|
|
4
|
+
* sync with the union above; `satisfies` rejects values outside the union.
|
|
5
|
+
*/
|
|
6
|
+
const FACTORY_STATE_VALUES = [
|
|
7
|
+
"delegated",
|
|
8
|
+
"implementing",
|
|
9
|
+
"pr_open",
|
|
10
|
+
"changes_requested",
|
|
11
|
+
"repairing_ci",
|
|
12
|
+
"awaiting_queue",
|
|
13
|
+
"repairing_queue",
|
|
14
|
+
"deploying",
|
|
15
|
+
"awaiting_input",
|
|
16
|
+
"escalated",
|
|
17
|
+
"done",
|
|
18
|
+
"failed",
|
|
19
|
+
];
|
|
20
|
+
export const FACTORY_STATES = new Set(FACTORY_STATE_VALUES);
|
|
21
|
+
export function isFactoryState(value) {
|
|
22
|
+
return FACTORY_STATES.has(value);
|
|
23
|
+
}
|
|
1
24
|
/** Which factory states involve an active Codex run. */
|
|
2
25
|
export const ACTIVE_RUN_STATES = new Set([
|
|
3
26
|
"implementing",
|
|
@@ -4,6 +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
|
import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
|
|
7
|
+
const WRITER = "github-webhook-reactive-run";
|
|
7
8
|
export async function maybeEnqueueGitHubReactiveRun(params) {
|
|
8
9
|
const { issue, event, project, logger, feed, wakeDispatcher, db, fetchImpl, failureContextResolver } = params;
|
|
9
10
|
if (isIssueTerminal(issue))
|
|
@@ -116,17 +117,20 @@ async function handleCheckFailedEvent(params) {
|
|
|
116
117
|
return;
|
|
117
118
|
}
|
|
118
119
|
const snapshot = getRelevantGitHubCiSnapshot(db, issue, event);
|
|
119
|
-
db.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
120
|
+
db.issueSessions.commitIssueState({
|
|
121
|
+
writer: WRITER,
|
|
122
|
+
update: {
|
|
123
|
+
projectId: issue.projectId,
|
|
124
|
+
linearIssueId: issue.linearIssueId,
|
|
125
|
+
lastGitHubFailureSource: "branch_ci",
|
|
126
|
+
lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? null,
|
|
127
|
+
lastGitHubFailureSignature: failureContext.failureSignature ?? null,
|
|
128
|
+
lastGitHubFailureCheckName: failureContext.checkName ?? event.checkName ?? null,
|
|
129
|
+
lastGitHubFailureCheckUrl: failureContext.checkUrl ?? event.checkUrl ?? null,
|
|
130
|
+
lastGitHubFailureContextJson: JSON.stringify(failureContext),
|
|
131
|
+
lastGitHubFailureAt: new Date().toISOString(),
|
|
132
|
+
lastQueueIncidentJson: null,
|
|
133
|
+
},
|
|
130
134
|
});
|
|
131
135
|
const queuedRunType = wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
132
136
|
eventType: "settled_red_ci",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const WRITER = "github-webhook-stack-coordination";
|
|
1
2
|
// Plan §8.3-8.4: when a parent PR's head moves (review-fix push,
|
|
2
3
|
// eviction repair, base-branch update), child PRs stacked on it
|
|
3
4
|
// become stale. Patchrelay treats this as a wake event for each
|
|
@@ -19,10 +20,13 @@ export function maybeFanChildRebaseWakes(params) {
|
|
|
19
20
|
logger.debug({ parentBranch: event.branchName, childIssue: child.issueKey, childRunId: child.activeRunId }, "Skipping child-rebase wake — child has an active run");
|
|
20
21
|
continue;
|
|
21
22
|
}
|
|
22
|
-
db.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
db.issueSessions.commitIssueState({
|
|
24
|
+
writer: WRITER,
|
|
25
|
+
update: {
|
|
26
|
+
projectId: child.projectId,
|
|
27
|
+
linearIssueId: child.linearIssueId,
|
|
28
|
+
pendingRunType: "branch_upkeep",
|
|
29
|
+
},
|
|
26
30
|
});
|
|
27
31
|
// The pending_run_type field above isn't an event, so we still need
|
|
28
32
|
// an explicit dispatch call. dispatchIfWakePending will pick up the
|