-
Notifications
You must be signed in to change notification settings - Fork 294
fix: recover interrupted command dispatches #407
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
228f9b3
4bb104c
6203048
2e5d648
deaf276
1c7e05b
eab3622
ca335bc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| #!/usr/bin/env bash | ||
| set -euo pipefail | ||
|
|
||
| workflow="${1:?workflow file is required}" | ||
| expected_title="${2:?expected run title is required}" | ||
| current_run_id="${3:?current run id is required}" | ||
| required_job_name="${4:?required worker job name is required}" | ||
|
|
||
| runs_json="$(gh api --method GET \ | ||
| "repos/${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}/actions/workflows/${workflow}/runs?per_page=100")" | ||
|
|
||
| active_owner_id="$( | ||
| jq -r --arg title "$expected_title" --arg current "$current_run_id" ' | ||
| first( | ||
| .workflow_runs[] | ||
| | select(.display_title == $title and .id < ($current | tonumber)) | ||
| | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested") | ||
| | .id | ||
| ) // empty | ||
| ' <<<"$runs_json" | ||
| )" | ||
| if [ -n "$active_owner_id" ]; then | ||
| printf 'owner\n' | ||
| exit 0 | ||
| fi | ||
|
|
||
| successful_run_ids="$( | ||
| jq -r --arg title "$expected_title" --arg current "$current_run_id" ' | ||
| .workflow_runs[] | ||
| | select(.display_title == $title and .id < ($current | tonumber) and .conclusion == "success") | ||
| | .id | ||
| ' <<<"$runs_json" | ||
| )" | ||
| while IFS= read -r run_id; do | ||
| if [ -z "$run_id" ]; then | ||
| continue | ||
| fi | ||
| jobs_json="$(gh api --method GET \ | ||
| "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100")" | ||
| if jq -e --arg required "$required_job_name" \ | ||
| 'any(.jobs[]; .name == $required and .conclusion == "success")' \ | ||
| <<<"$jobs_json" >/dev/null; then | ||
| printf 'owner\n' | ||
| exit 0 | ||
| fi | ||
| done <<<"$successful_run_ids" | ||
|
|
||
| printf 'none\n' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,33 @@ const DEFAULT_IGNORED_CHECKS = [ | |
| ]; | ||
| const TRANSIENT_CANCELLED_CHECKS = new Set(["real behavior proof"]); | ||
|
|
||
| export function dispatchClaimLookupKeys(entry: LooseRecord) { | ||
| const keys: string[] = []; | ||
| const commentId = String(entry.comment_id ?? "").trim(); | ||
| const commentUpdatedAt = String(entry.comment_updated_at ?? "").trim(); | ||
| if (commentId && commentUpdatedAt) keys.push(`comment:${commentId}:${commentUpdatedAt}`); | ||
| const idempotencyKey = String(entry.idempotency_key ?? "").trim(); | ||
| if (idempotencyKey) keys.push(`idempotency:${idempotencyKey}`); | ||
| return keys; | ||
| } | ||
|
|
||
| export function dispatchReceiptKeyMaterial(entry: LooseRecord, claim: LooseRecord | null) { | ||
| const idempotencyKey = String(entry.idempotency_key ?? entry.comment_version_key ?? "unknown"); | ||
| if (entry.automation_source !== "repair_loop_label_sweep") return idempotencyKey; | ||
| const attempt = String( | ||
| claim?.processed_at ?? entry.processed_at ?? entry.comment_updated_at ?? "unknown-attempt", | ||
| ); | ||
| return `${idempotencyKey}:${attempt}`; | ||
| } | ||
|
|
||
| export function hasSuccessfulDispatchExecutionJob(jobs: LooseRecord[], requiredJobName: string) { | ||
| return jobs.some( | ||
| (job) => | ||
| String(job.name ?? "") === requiredJobName && | ||
| String(job.conclusion ?? "").toLowerCase() === "success", | ||
| ); | ||
| } | ||
|
|
||
| export function summarizeChecks(checks: LooseRecord[]) { | ||
| const ignored = ignoredCheckNames(); | ||
| const latestChecks = latestCheckRuns(checks); | ||
|
|
@@ -115,6 +142,49 @@ export function shouldSuppressProcessedCommentVersion(entry: LooseRecord) { | |
| return true; | ||
| } | ||
|
|
||
| export function dispatchClaimDecision({ | ||
| claim, | ||
| runs, | ||
| expectedTitle, | ||
| nowMs = Date.now(), | ||
| graceMs = 300_000, | ||
| }: { | ||
| claim: LooseRecord | null; | ||
| runs: LooseRecord[]; | ||
| expectedTitle: string; | ||
| nowMs?: number; | ||
| graceMs?: number; | ||
| }) { | ||
| if (!claim) return { action: "dispatch", run: null }; | ||
| const normalizedGraceMs = Number.isFinite(graceMs) ? Math.max(0, graceMs) : 300_000; | ||
| const claimedAtMs = Date.parse(String(claim.processed_at ?? "")); | ||
| const matchingRuns = runs.filter((run) => { | ||
| if (String(run.display_title ?? run.displayTitle ?? "") !== expectedTitle) return false; | ||
| const createdAtMs = Date.parse(String(run.created_at ?? run.createdAt ?? "")); | ||
| return ( | ||
| Number.isFinite(claimedAtMs) && | ||
| Number.isFinite(createdAtMs) && | ||
| createdAtMs >= claimedAtMs - 5_000 | ||
| ); | ||
| }); | ||
| const successfulRun = matchingRuns.find( | ||
| (run) => | ||
| String(run.conclusion ?? "").toLowerCase() === "success" && | ||
| run.dispatch_execution_verified !== false, | ||
| ); | ||
| if (successfulRun) return { action: "recover", run: successfulRun }; | ||
| const activeRun = matchingRuns.find((run) => | ||
| ["queued", "in_progress", "waiting", "pending", "requested"].includes( | ||
| String(run.status ?? "").toLowerCase(), | ||
| ), | ||
| ); | ||
| if (activeRun) return { action: "wait", run: null }; | ||
| if (Number.isFinite(claimedAtMs) && nowMs - claimedAtMs >= normalizedGraceMs) { | ||
| return { action: "dispatch", run: null }; | ||
| } | ||
| return { action: "wait", run: null }; | ||
| } | ||
|
|
||
| export function sortCommentsForRouting(comments: LooseRecord[]) { | ||
| return [...comments].sort((left: LooseRecord, right: LooseRecord) => { | ||
| const leftTime = commentRoutingTime(left); | ||
|
|
@@ -244,7 +314,9 @@ export function readLedger(file: JsonValue) { | |
|
|
||
| export function appendLedger(current: LooseRecord, entries: LooseRecord[]) { | ||
| const compact = entries | ||
| .filter((entry: JsonValue) => ["executed", "skipped", "waiting"].includes(entry.status)) | ||
| .filter((entry: JsonValue) => | ||
| ["claimed", "executed", "skipped", "waiting"].includes(entry.status), | ||
| ) | ||
| .filter((entry: JsonValue) => !isNoopSkip(entry)) | ||
| .map((entry: JsonValue) => { | ||
| const actions = compactLedgerActions(entry.actions); | ||
|
|
@@ -271,7 +343,7 @@ export function appendLedger(current: LooseRecord, entries: LooseRecord[]) { | |
| expected_head_sha: entry.expected_head_sha ?? null, | ||
| finding_id: entry.finding_id ?? null, | ||
| status: entry.status, | ||
| processed_at: new Date().toISOString(), | ||
| processed_at: entry.processed_at ?? new Date().toISOString(), | ||
| target: entry.target | ||
| ? { | ||
| kind: entry.target.kind, | ||
|
|
@@ -313,10 +385,20 @@ function isNoopSkip(entry: LooseRecord) { | |
| } | ||
|
|
||
| function stableLedgerEntry(entry: LooseRecord) { | ||
| return JSON.stringify({ ...entry, processed_at: null }); | ||
| return JSON.stringify({ | ||
| ...entry, | ||
| processed_at: entry.status === "claimed" ? entry.processed_at : null, | ||
| }); | ||
| } | ||
|
|
||
| function ledgerEntryKey(entry: LooseRecord) { | ||
| if ( | ||
| !entry.comment_version_key && | ||
| entry.automation_source === "repair_loop_label_sweep" && | ||
| entry.idempotency_key | ||
| ) { | ||
| return `idempotency:${entry.idempotency_key}`; | ||
|
Comment on lines
+396
to
+400
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For repair-loop label sweeps, Useful? React with 👍 / 👎. |
||
| } | ||
| return ( | ||
| entry.comment_version_key ?? | ||
| `${entry.comment_id ?? "unknown"}:${entry.comment_updated_at ?? "unknown"}` | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the new receipt jobs in
assist.ymlandrepair-cluster-worker.yml, a duplicate run that sees an older active run writesproceed=falseand exits successfully, so that workflow run can have conclusionsuccesseven though the assist/cluster worker never ran. Because this branch recovers on any matching successful run, if the older run later fails, a subsequent router retry will recover the skipped duplicate and mark the dispatch executed instead of redispatching.Useful? React with 👍 / 👎.