Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 0 additions & 48 deletions src/conflict-repair-ownership.ts

This file was deleted.

28 changes: 10 additions & 18 deletions src/idle-reconciliation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ import type { WorkflowTaskDispatcher } from "./workflow-task-dispatcher.ts";
import { LinearIssueProjectionService } from "./linear-issue-projection.ts";
import type { LinearClientProvider } from "./types.ts";
import { TerminalInboxReconciler } from "./terminal-inbox-reconciler.ts";
import { resolveConflictRepairOwnership } from "./conflict-repair-ownership.ts";

const BLOCKED_DEPENDENCY_REFRESH_SUCCESS_BACKOFF_MS = 60_000;
const BLOCKED_DEPENDENCY_REFRESH_FAILURE_BACKOFF_MS = 5 * 60_000;
Expand Down Expand Up @@ -953,24 +952,17 @@ export class IdleIssueReconciler {
});
return;
}
if (issue.delegatedToPatchRelay && reactiveIntent?.runType === "integration_repair" && mergeConflictDetected) {
this.logger.info(
{
issueKey: issue.issueKey,
prNumber: issue.prNumber,
},
"Reconciliation: dirty approved PR remains frozen; candidate reconciliation owns integration repair",
);
return;
}
if (issue.delegatedToPatchRelay && reactiveIntent?.runType === "queue_repair" && mergeConflictDetected) {
const ownership = await resolveConflictRepairOwnership({
project,
prNumber,
...(pr.headRefOid ? { approvedHeadSha: pr.headRefOid } : {}),
});
if (ownership.owner === "integration_candidate") {
this.logger.info(
{
issueKey: issue.issueKey,
prNumber: issue.prNumber,
candidateKind: ownership.candidateKind,
candidateSha: ownership.candidateSha,
},
"Reconciliation: integration candidate owns the dirty PR; feature head remains frozen",
);
return;
}
this.logger.info(
{ issueKey: issue.issueKey, prNumber: issue.prNumber, mergeable: pr.mergeable },
"Reconciliation: PR needs queue repair from fresh GitHub truth",
Expand Down
7 changes: 6 additions & 1 deletion src/issue-session-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,12 @@ export function deriveSessionInputPlan(
if (!typed) continue;
switch (typed.eventType) {
case "merge_steward_incident":
runType = typed.payload?.candidateBranch ? "integration_repair" : "queue_repair";
// Once a PR is approved, every merge-steward incident belongs to the
// integration candidate even while its ref is still materializing.
// Legacy feature-branch queue repair is only valid before approval.
runType = typed.payload?.candidateBranch || issue.prReviewState === "approved"
? "integration_repair"
: "queue_repair";
workflowReason = "merge_steward_incident";
eventIds = [event.id];
Object.assign(context, typed.payload ?? {});
Expand Down
14 changes: 5 additions & 9 deletions src/manual-issue-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,7 @@ export function resolveRetryTarget(params: {
}

if (hasOpenPr(params.prNumber, params.prState) && params.lastGitHubFailureSource === "queue_eviction") {
return { runType: "queue_repair" };
}
if (
hasOpenPr(params.prNumber, params.prState)
&& params.prReviewState === "approved"
&& params.lastRunType === "queue_repair"
) {
return { runType: "queue_repair" };
return { runType: params.prReviewState === "approved" ? "integration_repair" : "queue_repair" };
}
if (
hasOpenPr(params.prNumber, params.prState)
Expand All @@ -42,6 +35,9 @@ export function resolveRetryTarget(params: {
: "review_fix",
};
}
if (hasOpenPr(params.prNumber, params.prState) && params.prReviewState === "approved") {
return { runType: "integration_repair" };
}
if (hasOpenPr(params.prNumber, params.prState)) {
return { runType: "implementation" };
}
Expand All @@ -52,7 +48,7 @@ export function buildManualRetryAttemptReset(runType: RunType): Partial<Pick<Ups
if (runType === "ci_repair") {
return { ciRepairAttempts: 0 };
}
if (runType === "queue_repair") {
if (runType === "queue_repair" || runType === "integration_repair") {
return { queueRepairAttempts: 0 };
}
if (runType === "review_fix" || runType === "branch_upkeep") {
Expand Down
14 changes: 14 additions & 0 deletions src/operator-retry-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ function parseRunContextColumn(value: string | undefined): RunContext | undefine
}

export function buildOperatorRetryEvent(issue: IssueRecord, runType: string, source: string = "operator_retry") {
if (runType === "integration_repair") {
const queueIncident = parseRunContextColumn(issue.lastQueueIncidentJson);
const failureContext = parseRunContextColumn(issue.lastGitHubFailureContextJson);
return {
eventType: "merge_steward_incident" as const,
eventJson: JSON.stringify({
...queueIncident,
...failureContext,
source,
} satisfies RunContext),
dedupeKey: `${source}:integration_repair:${issue.linearIssueId}:${failureContext?.candidateSha ?? issue.lastGitHubFailureSignature ?? issue.lastGitHubFailureHeadSha ?? "unknown-sha"}`,
};
}

if (runType === "queue_repair") {
const queueIncident = parseRunContextColumn(issue.lastQueueIncidentJson);
const failureContext = parseRunContextColumn(issue.lastGitHubFailureContextJson);
Expand Down
2 changes: 1 addition & 1 deletion src/reactive-workflow-intent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function deriveReactiveWorkflowIntent(

if (params.latestFailureSource === "queue_eviction" || (params.mergeConflictDetected && params.downstreamOwned)) {
return {
runType: "queue_repair",
runType: params.prReviewState === "approved" ? "integration_repair" : "queue_repair",
workflowReason: "merge_steward_incident",
};
}
Expand Down
60 changes: 60 additions & 0 deletions src/run-attempt-accounting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { IssueRecord } from "./db-types.ts";
import type { UpsertIssueParams } from "./db/issue-store.ts";
import { isRequestedChangesRunType } from "./reactive-pr-state.ts";
import type { RunContext } from "./run-context.ts";
import type { RunType } from "./run-type.ts";

export type AttemptAccountingFields = Partial<Pick<
UpsertIssueParams,
| "ciRepairAttempts"
| "queueRepairAttempts"
| "reviewFixAttempts"
| "lastAttemptedFailureHeadSha"
| "lastAttemptedFailureSignature"
| "lastAttemptedFailureAt"
>>;

export function buildAttemptStartFields(
runType: RunType,
issue: Pick<IssueRecord, "ciRepairAttempts" | "queueRepairAttempts" | "reviewFixAttempts">,
context?: RunContext,
): AttemptAccountingFields {
const counter = runType === "ci_repair"
? { ciRepairAttempts: issue.ciRepairAttempts + 1 }
: runType === "integration_repair" || runType === "queue_repair"
? { queueRepairAttempts: issue.queueRepairAttempts + 1 }
: isRequestedChangesRunType(runType)
? { reviewFixAttempts: issue.reviewFixAttempts + 1 }
: {};
const failureSignature = context?.failureSignature;
const provenance = (runType === "ci_repair" || runType === "integration_repair" || runType === "queue_repair")
&& failureSignature
? {
lastAttemptedFailureSignature: failureSignature,
lastAttemptedFailureHeadSha: context?.failureHeadSha ?? null,
lastAttemptedFailureAt: new Date().toISOString(),
}
: {};
return { ...counter, ...provenance };
}

export function buildAttemptRefundFields(
runType: RunType,
issue: Pick<IssueRecord, "ciRepairAttempts" | "queueRepairAttempts" | "reviewFixAttempts">,
): AttemptAccountingFields {
const counter = runType === "ci_repair" && issue.ciRepairAttempts > 0
? { ciRepairAttempts: issue.ciRepairAttempts - 1 }
: (runType === "integration_repair" || runType === "queue_repair") && issue.queueRepairAttempts > 0
? { queueRepairAttempts: issue.queueRepairAttempts - 1 }
: isRequestedChangesRunType(runType) && issue.reviewFixAttempts > 0
? { reviewFixAttempts: issue.reviewFixAttempts - 1 }
: {};
const provenance = runType === "ci_repair" || runType === "integration_repair" || runType === "queue_repair"
? {
lastAttemptedFailureHeadSha: null,
lastAttemptedFailureSignature: null,
lastAttemptedFailureAt: null,
}
: {};
return { ...counter, ...provenance };
}
43 changes: 6 additions & 37 deletions src/run-failure-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,53 +22,18 @@ import { appendBranchUpkeepObservation } from "./branch-upkeep-signal.ts";
import { reconcileWorkflowTasksForIssue } from "./workflow-task-reconciler.ts";
import { settleRun } from "./run-settlement.ts";
import type { ProjectConfig } from "./workflow-types.ts";
import { buildAttemptRefundFields } from "./run-attempt-accounting.ts";

const WRITER = "run-failure-policy";

type AttemptRefundFields = Partial<Pick<
UpsertIssueParams,
| "ciRepairAttempts"
| "queueRepairAttempts"
| "reviewFixAttempts"
| "lastAttemptedFailureHeadSha"
| "lastAttemptedFailureSignature"
| "lastAttemptedFailureAt"
>>;

// Roll back the attempt counter consumed at launch and clear the
// attempted-failure provenance for repair runs, so a run that died without
// evidence about the work (interrupted turn, capacity outage) neither burns
// a budget unit nor blocks the same failure from re-deriving a workflow task.
function buildAttemptRefundFields(
runType: RunType,
issue: Pick<IssueRecord, "ciRepairAttempts" | "queueRepairAttempts" | "reviewFixAttempts">,
): AttemptRefundFields | undefined {
const counter = runType === "ci_repair" && issue.ciRepairAttempts > 0
? { ciRepairAttempts: issue.ciRepairAttempts - 1 }
: (runType === "integration_repair" || runType === "queue_repair") && issue.queueRepairAttempts > 0
? { queueRepairAttempts: issue.queueRepairAttempts - 1 }
: isRequestedChangesRunType(runType) && issue.reviewFixAttempts > 0
? { reviewFixAttempts: issue.reviewFixAttempts - 1 }
: undefined;
const provenance = runType === "ci_repair" || runType === "integration_repair" || runType === "queue_repair"
? {
lastAttemptedFailureHeadSha: null,
lastAttemptedFailureSignature: null,
lastAttemptedFailureAt: null,
}
: undefined;
if (!counter && !provenance) return undefined;
return { ...counter, ...provenance };
}

// The interrupted-run variant: same refund, committed as a single issue
// update so the whole repair commits (and conflict-recomputes) atomically.
function buildInterruptedAttemptRepairUpdate(
runType: RunType,
issue: Pick<IssueRecord, "projectId" | "linearIssueId" | "ciRepairAttempts" | "queueRepairAttempts" | "reviewFixAttempts">,
): UpsertIssueParams | undefined {
const fields = buildAttemptRefundFields(runType, issue);
if (!fields) return undefined;
if (Object.keys(fields).length === 0) return undefined;
return {
projectId: issue.projectId,
linearIssueId: issue.linearIssueId,
Expand Down Expand Up @@ -129,12 +94,16 @@ export class RunFailurePolicy {
failureReason: string;
}): void {
const { run, issue } = params;
const turnNeverStarted = run.launchPhase !== "turn_started" && run.launchPhase !== "running";
this.withHeldLease(run.projectId, run.linearIssueId, (lease) =>
settleRun({
db: this.db,
run,
finish: { status: "failed", failureReason: params.failureReason },
lease,
...(turnNeverStarted
? { buildIssueUpdate: (current) => buildAttemptRefundFields(run.runType, current) }
: {}),
}));
this.recoverOrEscalate({ issue, runType: run.runType, reason: params.reason });
}
Expand Down
39 changes: 27 additions & 12 deletions src/run-launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { CodexAppServerClient } from "./codex-app-server.ts";
import type { PatchRelayDatabase } from "./db.ts";
import type { IssueRecord, RunRecord } from "./db-types.ts";
import type { RunType } from "./run-type.ts";
import { buildAttemptRefundFields, buildAttemptStartFields } from "./run-attempt-accounting.ts";
import { resolveFailureOutcome } from "./reactive-pr-state.ts";
import { buildHookEnv, type HookEnv, type HookResult, runProjectHook } from "./hook-runner.ts";
import { buildRunFailureActivity } from "./linear-session-reporting.ts";
Expand Down Expand Up @@ -320,9 +321,7 @@ export class RunLauncher {
...(params.authorityEpoch !== undefined ? { authorityEpoch: params.authorityEpoch } : {}),
promptText: params.prompt,
});
const failureHeadSha = params.effectiveContext?.failureHeadSha;
const failureSignature = params.effectiveContext?.failureSignature;
const claimUpdate = {
const buildClaimUpdate = (record: IssueRecord) => ({
projectId: params.item.projectId,
linearIssueId: params.item.issueId,
activeRunId: created.id,
Expand All @@ -334,20 +333,15 @@ export class RunLauncher {
workflowOutcomeReason: null,
inputRequestKind: null,
}),
...((params.runType === "ci_repair" || params.runType === "integration_repair" || params.runType === "queue_repair") && failureSignature
? {
lastAttemptedFailureSignature: failureSignature,
lastAttemptedFailureHeadSha: failureHeadSha ?? null,
lastAttemptedFailureAt: new Date().toISOString(),
}
: {}),
};
...buildAttemptStartFields(params.runType, record, params.effectiveContext),
});
const claimUpdate = buildClaimUpdate(fresh);
const claimCommit = this.db.issueSessions.commitIssueState({
writer: WRITER,
expectedVersion: fresh.version,
update: claimUpdate,
// Never steal a slot another writer claimed concurrently.
onConflict: (current) => (current.activeRunId == null ? claimUpdate : undefined),
onConflict: (current) => (current.activeRunId == null ? buildClaimUpdate(current) : undefined),
});
if (claimCommit.outcome !== "applied") return undefined;
// Session events are consumed for session-history coherence; the
Expand Down Expand Up @@ -554,20 +548,41 @@ export class RunLauncher {
// Issue clear + run-terminal write ride in one transaction; the run
// finish is gated on the issue commit so a lost lease skips both.
this.db.transaction(() => {
const current = this.db.issues.getIssue(params.project.id, params.issue.linearIssueId);
// A completion notification may have settled this run while the
// start request was rejecting. Never clear or refund a slot that no
// longer belongs to this launch.
if (!current || current.activeRunId !== params.run.id) return;
const commit = this.db.issueSessions.commitIssueState({
writer: WRITER,
lease: { projectId: params.project.id, linearIssueId: params.issue.linearIssueId, leaseId: params.leaseId },
expectedVersion: current.version,
update: {
projectId: params.project.id,
linearIssueId: params.issue.linearIssueId,
activeRunId: null,
...buildAttemptRefundFields(params.runType, current),
...(workflowOutcome
? {
workflowOutcome,
workflowOutcomeReason: `run_launch_failed:${params.runType}`,
}
: {}),
},
onConflict: (fresh) => fresh.activeRunId === params.run.id
? {
projectId: params.project.id,
linearIssueId: params.issue.linearIssueId,
activeRunId: null,
...buildAttemptRefundFields(params.runType, fresh),
...(workflowOutcome
? {
workflowOutcome,
workflowOutcomeReason: `run_launch_failed:${params.runType}`,
}
: {}),
}
: undefined,
});
if (commit.outcome !== "applied") return;
this.db.runs.finishRun(params.run.id, {
Expand Down
Loading
Loading