diff --git a/.github/scripts/fork-conflict-notify.cjs b/.github/scripts/fork-conflict-notify.cjs new file mode 100644 index 0000000000000..995436a4284df --- /dev/null +++ b/.github/scripts/fork-conflict-notify.cjs @@ -0,0 +1,220 @@ +const { execFileSync } = require("node:child_process"); +const { existsSync } = require("node:fs"); +const { selectLatestNightly } = require("./fork-nightly.cjs"); + +const repository = "amv146/t3code"; +const repo = { owner: "amv146", repo: "t3code" }; +const workflow = ".github/workflows/fork-nightly.yml"; +const mergeStep = "Merge the latest published upstream nightly"; +const shaPattern = /^[a-f0-9]{40}$/; + +function conflictMetadata(log) { + if (typeof log !== "string" || Buffer.byteLength(log) > 1024 * 1024) { + throw new Error("Missing or oversized sync log."); + } + const markers = [...log.matchAll(/FORK_NIGHTLY_CONFLICT=(\{[^\r\n]+\})/g)]; + if (markers.length === 0) return null; + if (markers.length !== 1) throw new Error("Ambiguous sync conflict metadata."); + const data = JSON.parse(markers[0][1]); + if ( + Object.keys(data).sort().join(",") !== "fork_base,upstream_sha,upstream_tag" || + !shaPattern.test(data.fork_base) || + !shaPattern.test(data.upstream_sha) || + typeof data.upstream_tag !== "string" + ) { + throw new Error("Invalid sync conflict metadata."); + } + return data; +} + +function repairBranch(forkBase, upstreamSha) { + if (!shaPattern.test(forkBase) || !shaPattern.test(upstreamSha)) { + throw new Error("Invalid repair inputs."); + } + return `fork/nightly-repair/${forkBase}-${upstreamSha}`; +} + +const runGit = (args) => + execFileSync("git", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + +async function probeForkConflict({ github, context, git = runGit }) { + const event = context.payload; + if ( + `${context.repo.owner}/${context.repo.repo}` !== repository || + context.eventName !== "workflow_run" || + event.action !== "completed" || + event.repository?.full_name !== repository || + !Number.isSafeInteger(event.workflow_run?.id) || + event.workflow_run.id <= 0 + ) { + throw new Error("Expected a completed fork nightly workflow event."); + } + const { data: run } = await github.rest.actions.getWorkflowRun({ + ...repo, + run_id: event.workflow_run.id, + }); + if ( + run.repository.full_name !== repository || + run.head_repository.full_name !== repository || + run.path !== workflow || + run.head_branch !== "main" || + !["push", "schedule", "workflow_dispatch"].includes(run.event) || + run.status !== "completed" || + run.conclusion !== "failure" || + run.run_attempt !== event.workflow_run.run_attempt + ) { + return { reason: "Not a current, failed fork nightly attempt." }; + } + const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({ + ...repo, + run_id: run.id, + filter: "latest", + per_page: 100, + }); + if (jobs.total_count > jobs.jobs.length) throw new Error("Incomplete nightly job evidence."); + const sync = jobs.jobs.filter( + (job) => + job.name === "sync" && + job.conclusion === "failure" && + job.steps.some((step) => step.name === mergeStep && step.conclusion === "failure"), + ); + if (sync.length !== 1) return { reason: "No failed nightly merge step." }; + const { data: log } = await github.rest.actions.downloadJobLogsForWorkflowRun({ + ...repo, + job_id: sync[0].id, + }); + const metadata = conflictMetadata(log); + if (metadata === null) return { reason: "Merge step failed without verified conflict metadata." }; + const { data: main } = await github.rest.repos.getBranch({ ...repo, branch: "main" }); + if (main.commit.sha !== metadata.fork_base || run.head_sha !== metadata.fork_base) { + return { reason: "Fork main moved since this conflict." }; + } + const { data: releases } = await github.rest.repos.listReleases({ + owner: "pingdotgg", + repo: "t3code", + per_page: 100, + }); + const upstream = selectLatestNightly(releases); + if (upstream.tag_name !== metadata.upstream_tag) { + return { reason: "A newer published upstream nightly supersedes this conflict." }; + } + if (git(["rev-parse", "HEAD"]) !== metadata.fork_base || git(["status", "--porcelain"])) { + throw new Error("Conflict probe requires a clean checkout of the verified fork base."); + } + git([ + "fetch", + "--no-tags", + "https://github.com/pingdotgg/t3code.git", + `refs/tags/${upstream.tag_name}`, + ]); + if (git(["rev-parse", "FETCH_HEAD^{commit}"]) !== metadata.upstream_sha) { + return { reason: "Published nightly tag no longer matches the failed merge." }; + } + const branch = repairBranch(metadata.fork_base, metadata.upstream_sha); + try { + await github.rest.git.getRef({ ...repo, ref: `heads/${branch}` }); + return { + reason: `Repair branch ${branch} is already claimed. Merge its repair PR when ready to unblock nightly sync; an interrupted claim needs human attention.`, + }; + } catch (error) { + if (error.status !== 404) throw error; + } + git(["config", "user.name", "github-actions[bot]"]); + git(["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"]); + let conflicted = false; + try { + git(["merge", "--no-commit", "--no-ff", metadata.upstream_sha]); + } catch (error) { + if (!git(["ls-files", "--unmerged"])) throw error; + conflicted = true; + } finally { + if (existsSync(git(["rev-parse", "--path-format=absolute", "--git-path", "MERGE_HEAD"]))) { + git(["merge", "--abort"]); + } + } + if (!conflicted) return { reason: "The verified nightly now merges without conflicts." }; + const { data: currentMain } = await github.rest.repos.getBranch({ ...repo, branch: "main" }); + if (currentMain.commit.sha !== metadata.fork_base) { + return { reason: "Fork main changed during the conflict probe." }; + } + return { + payload: { + event: "fork-nightly-conflict", + repository, + workflow, + conclusion: "failure", + ref: "refs/heads/main", + run_id: String(run.id), + ...metadata, + }, + }; +} + +async function notifyForkConflict({ + github, + context, + core, + webhookUrl, + git, + fetchImpl = fetch, + dryRun = false, +}) { + const result = await probeForkConflict({ github, context, git }); + if (!result.payload) { + core.info(result.reason); + await core.summary.addRaw(result.reason).write(); + return result; + } + if (dryRun) return result; + let endpoint; + try { + endpoint = new URL(webhookUrl); + } catch { + throw new Error("Set FORK_CONFLICT_WEBHOOK_URL to the private T3 webhook endpoint."); + } + if ( + endpoint.origin !== "https://relay.t3.codes" || + !endpoint.pathname.startsWith("/v1/hooks/") || + endpoint.username || + endpoint.password || + endpoint.search || + endpoint.hash + ) { + throw new Error("Conflict delivery requires a T3 Connect webhook endpoint."); + } + let response; + try { + response = await fetchImpl(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(result.payload), + redirect: "error", + signal: AbortSignal.timeout(20_000), + }); + } catch { + // Fetch errors can include the credential-bearing URL. + throw new Error("T3 conflict webhook delivery failed. Inspect the task's delivery log."); + } + const outcome = response.headers.get("x-t3-hook-outcome"); + if ( + response.status !== 202 || + (outcome !== null && !["accepted", "duplicate", "coalesced"].includes(outcome)) + ) { + throw new Error(`T3 did not accept the conflict delivery (HTTP ${response.status}).`); + } + const receipt = await response.json(); + const held = receipt.queued === true; + if (!held && (typeof receipt.deliveryId !== "string" || !receipt.deliveryId)) { + throw new Error("T3 returned no conflict delivery receipt."); + } + // The public relay strips the outcome header; its receipt does not prove agent dispatch. + const message = `${held ? "T3 relay queued" : "T3 received"} the conflict notification. Confirm dispatch in its delivery log. A ready repair PR needs user merge approval to unblock nightly sync.`; + core.info(message); + await core.summary.addRaw(message).write(); + return result; +} + +module.exports = { conflictMetadata, notifyForkConflict, probeForkConflict, repairBranch }; diff --git a/.github/scripts/fork-conflict-notify.test.cjs b/.github/scripts/fork-conflict-notify.test.cjs new file mode 100644 index 0000000000000..3821e8e4cfccd --- /dev/null +++ b/.github/scripts/fork-conflict-notify.test.cjs @@ -0,0 +1,305 @@ +const assert = require("node:assert/strict"); +const { execFileSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { + conflictMetadata, + notifyForkConflict, + probeForkConflict, + repairBranch, +} = require("./fork-conflict-notify.cjs"); + +const tag = "v0.1.8-nightly.20261108.4353"; +const core = { + info() {}, + summary: { + addRaw() { + return this; + }, + async write() {}, + }, +}; + +function fixture(t, conflict = true) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "t3-conflict-notify-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const upstream = path.join(root, "upstream"); + const fork = path.join(root, "fork"); + const gitAt = (cwd, args) => + execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + fs.mkdirSync(upstream); + gitAt(upstream, ["init", "--initial-branch=main"]); + gitAt(upstream, ["config", "user.name", "Fixture"]); + gitAt(upstream, ["config", "user.email", "fixture@example.invalid"]); + gitAt(upstream, ["config", "core.autocrlf", "false"]); + fs.writeFileSync(path.join(upstream, "feature.txt"), "base\n"); + gitAt(upstream, ["add", "."]); + gitAt(upstream, ["commit", "-m", "Base"]); + gitAt(root, ["clone", "--config", "core.autocrlf=false", upstream, fork]); + const git = (args) => gitAt(fork, args); + git(["config", "user.name", "Fixture"]); + git(["config", "user.email", "fixture@example.invalid"]); + git([ + "config", + `url.${upstream.replaceAll("\\", "/")}.insteadOf`, + "https://github.com/pingdotgg/t3code.git", + ]); + fs.writeFileSync(path.join(fork, "feature.txt"), "personal behavior\n"); + git(["add", "."]); + git(["commit", "-m", "Personal feature"]); + fs.writeFileSync(path.join(upstream, conflict ? "feature.txt" : "new.txt"), "upstream update\n"); + gitAt(upstream, ["add", "."]); + gitAt(upstream, ["commit", "-m", "Nightly"]); + gitAt(upstream, ["tag", tag]); + const metadata = { + fork_base: git(["rev-parse", "HEAD"]), + upstream_sha: gitAt(upstream, ["rev-parse", "HEAD"]), + upstream_tag: tag, + }; + const run = { + id: 123, + run_attempt: 1, + repository: { full_name: "amv146/t3code" }, + head_repository: { full_name: "amv146/t3code" }, + path: ".github/workflows/fork-nightly.yml", + head_branch: "main", + head_sha: metadata.fork_base, + event: "schedule", + status: "completed", + conclusion: "failure", + }; + const sync = { + id: 456, + name: "sync", + conclusion: "failure", + steps: [{ name: "Merge the latest published upstream nightly", conclusion: "failure" }], + }; + const state = { + run, + sync, + log: `2026-11-08T05:00:00Z FORK_NIGHTLY_CONFLICT=${JSON.stringify(metadata)}\n`, + main: metadata.fork_base, + release: { + tag_name: tag, + prerelease: true, + draft: false, + published_at: "2026-11-08T05:00:00Z", + }, + claimed: false, + }; + const github = { + rest: { + actions: { + async getWorkflowRun() { + return { data: state.run }; + }, + async listJobsForWorkflowRun() { + return { data: { total_count: 1, jobs: [state.sync] } }; + }, + async downloadJobLogsForWorkflowRun() { + return { data: state.log }; + }, + }, + repos: { + async getBranch() { + return { data: { commit: { sha: state.main } } }; + }, + async listReleases() { + return { data: [state.release] }; + }, + }, + git: { + async getRef() { + if (state.claimed) return { data: {} }; + throw Object.assign(new Error("Not found"), { status: 404 }); + }, + }, + }, + }; + const context = { + repo: { owner: "amv146", repo: "t3code" }, + eventName: "workflow_run", + payload: { + action: "completed", + repository: { full_name: "amv146/t3code" }, + workflow_run: { id: 123, run_attempt: 1 }, + }, + }; + return { github, context, git, state, metadata, fork }; +} + +test("conflict markers and persistent repair keys reject ambiguity and executable input", () => { + const metadata = { fork_base: "a".repeat(40), upstream_sha: "b".repeat(40), upstream_tag: tag }; + const marker = `FORK_NIGHTLY_CONFLICT=${JSON.stringify(metadata)}\n`; + assert.deepEqual(conflictMetadata(marker), metadata); + assert.equal(conflictMetadata("push permission denied"), null); + assert.throws(() => conflictMetadata(marker + marker), /Ambiguous/); + assert.throws(() => conflictMetadata("x".repeat(1024 * 1024 + 1)), /oversized/); + assert.throws( + () => + conflictMetadata(`FORK_NIGHTLY_CONFLICT=${JSON.stringify({ ...metadata, command: "bad" })}`), + /Invalid/, + ); + assert.equal( + repairBranch(metadata.fork_base, metadata.upstream_sha), + `fork/nightly-repair/${metadata.fork_base}-${metadata.upstream_sha}`, + ); + assert.throws(() => repairBranch("main; bad", metadata.upstream_sha), /Invalid/); +}); + +test("only the exact failed nightly conflict routes a bounded event, and dry-run never delivers", async (t) => { + const f = fixture(t); + const result = await notifyForkConflict({ + ...f, + core, + dryRun: true, + async fetchImpl() { + assert.fail("dry-run must not invoke T3"); + }, + }); + assert.deepEqual(result.payload, { + event: "fork-nightly-conflict", + repository: "amv146/t3code", + workflow: ".github/workflows/fork-nightly.yml", + conclusion: "failure", + ref: "refs/heads/main", + run_id: "123", + ...f.metadata, + }); + assert.equal(f.git(["status", "--porcelain"]), ""); + assert.equal(f.git(["rev-parse", "HEAD"]), f.metadata.fork_base); + assert.equal(fs.readFileSync(path.join(f.fork, "feature.txt"), "utf8"), "personal behavior\n"); + for (const [target, name, value] of [ + [f.state.run, "conclusion", "success"], + [f.state.run, "path", ".github/workflows/ci.yml"], + [f.state.run, "event", "pull_request"], + [f.state.run, "head_branch", "feature"], + [f.state.run, "run_attempt", 2], + [f.state.sync.steps[0], "conclusion", "success"], + [f.state, "log", "merge failed: permission denied"], + [f.state, "main", "c".repeat(40)], + [f.state.release, "tag_name", "v0.1.8-nightly.20261109.4354"], + [f.state, "claimed", true], + ]) { + const old = target[name]; + target[name] = value; + const skipped = await notifyForkConflict({ + ...f, + core, + async fetchImpl() { + assert.fail(`must not invoke T3 for ${name}=${value}`); + }, + }); + assert.ok(skipped.reason); + target[name] = old; + } + const original = f.context.repo.owner; + f.context.repo.owner = "pingdotgg"; + await assert.rejects(probeForkConflict(f), /Expected a completed fork/); + f.context.repo.owner = original; +}); + +test("a clean merge or changed tag SHA does not deliver and the probe restores the fixture", async (t) => { + const f = fixture(t, false); + assert.match((await probeForkConflict(f)).reason, /without conflicts/); + assert.equal(f.git(["status", "--porcelain"]), ""); + assert.equal(fs.existsSync(path.join(f.fork, "new.txt")), false); + f.state.log = f.state.log.replace(f.metadata.upstream_sha, "c".repeat(40)); + assert.match((await probeForkConflict(f)).reason, /no longer matches/); +}); + +test("delivery requires the private relay endpoint and a real accepted outcome, without leaking tokens", async (t) => { + const f = fixture(t); + const endpoint = "https://relay.t3.codes/v1/hooks/test/private-fixture-token"; + let deliveries = 0; + for (const url of [ + undefined, + "http://localhost:9224/hook", + "https://example.com/hook", + `${endpoint}?q=1`, + ]) { + await assert.rejects( + notifyForkConflict({ + ...f, + core, + webhookUrl: url, + async fetchImpl() { + assert.fail("invalid endpoint must not receive metadata"); + }, + }), + /webhook endpoint/, + ); + } + for (const outcome of [ + "accepted", + "duplicate", + "coalesced", + null, + "filtered", + "prompt_too_long", + ]) { + const delivery = notifyForkConflict({ + ...f, + core, + webhookUrl: endpoint, + async fetchImpl(url, options) { + deliveries++; + assert.equal(String(url), endpoint); + assert.equal(options.redirect, "error"); + assert.equal(JSON.parse(options.body).fork_base, f.metadata.fork_base); + return new Response('{"deliveryId":"fixture-delivery"}', { + status: 202, + headers: outcome === null ? {} : { "x-t3-hook-outcome": outcome }, + }); + }, + }); + if (["filtered", "prompt_too_long"].includes(outcome)) { + await assert.rejects(delivery, /did not accept/); + } else { + await delivery; + } + } + assert.equal(deliveries, 6); + await notifyForkConflict({ + ...f, + core, + webhookUrl: endpoint, + async fetchImpl() { + return new Response('{"queued":true}', { status: 202 }); + }, + }); + for (const [status, body] of [ + [409, '{"error":"hook_disabled"}'], + [202, "{}"], + ]) { + await assert.rejects( + notifyForkConflict({ + ...f, + core, + webhookUrl: endpoint, + async fetchImpl() { + return new Response(body, { status }); + }, + }), + /did not accept|no conflict delivery receipt/, + ); + } + await assert.rejects( + notifyForkConflict({ + ...f, + core, + webhookUrl: endpoint, + async fetchImpl() { + throw new Error(endpoint); + }, + }), + (error) => + !error.message.includes("private-fixture-token") && /delivery failed/.test(error.message), + ); +}); diff --git a/.github/scripts/fork-nightly.cjs b/.github/scripts/fork-nightly.cjs index 6f2abc1a0d532..266e0531da43d 100644 --- a/.github/scripts/fork-nightly.cjs +++ b/.github/scripts/fork-nightly.cjs @@ -78,11 +78,25 @@ async function prepareForkNightly({ github, context, core }) { `refs/tags/${upstream.tag_name}`, ]); const upstreamSha = git(["rev-parse", "FETCH_HEAD^{commit}"]); - execFileSync( - "git", - ["merge", "--no-edit", "-m", `Merge upstream nightly ${upstream.tag_name}`, upstreamSha], - { stdio: "inherit" }, - ); + const forkBase = git(["rev-parse", "HEAD"]); + try { + execFileSync( + "git", + ["merge", "--no-edit", "-m", `Merge upstream nightly ${upstream.tag_name}`, upstreamSha], + { stdio: "inherit" }, + ); + } catch (error) { + if (git(["ls-files", "--unmerged"])) { + core.info( + `FORK_NIGHTLY_CONFLICT=${JSON.stringify({ + fork_base: forkBase, + upstream_sha: upstreamSha, + upstream_tag: upstream.tag_name, + })}`, + ); + } + throw error; + } const sha = git(["rev-parse", "HEAD"]); const { data: forkReleases } = await github.rest.repos.listReleases({ ...context.repo, diff --git a/.github/scripts/fork-nightly.test.cjs b/.github/scripts/fork-nightly.test.cjs index 8427447064d36..87e977917781c 100644 --- a/.github/scripts/fork-nightly.test.cjs +++ b/.github/scripts/fork-nightly.test.cjs @@ -136,7 +136,7 @@ function prepareFixture(fork, releases = []) { prepareForkNightly({ github, context: { repo: { owner: "amv146", repo: "t3code" }, ref: "refs/heads/main" }, - core: { setOutput(name, value) { outputs[name] = value; }, info() {} }, + core: { setOutput(name, value) { outputs[name] = value; }, info(message) { console.log(message); } }, }).then(() => console.log("FORK_OUTPUTS=" + JSON.stringify(outputs))); `, ], @@ -168,8 +168,20 @@ test("merges and pushes the nightly without losing personal commits, then skips }); test("a conflicting upstream update fails without pushing over the fork branch", (t) => { - const { fork, remote, before, git } = gitFixture(t, true); - assert.throws(() => prepareFixture(fork)); + const { fork, remote, upstream, before, git } = gitFixture(t, true); + assert.throws( + () => prepareFixture(fork), + (error) => { + const marker = error.stdout.match(/FORK_NIGHTLY_CONFLICT=(\{[^\r\n]+\})/); + assert.ok(marker, "a real conflict must emit bounded metadata for the repair trigger"); + assert.deepEqual(JSON.parse(marker[1]), { + fork_base: before, + upstream_sha: git(upstream, "rev-parse", "HEAD"), + upstream_tag: upstreamTag, + }); + return true; + }, + ); assert.equal(git(fork, "--git-dir", remote, "rev-parse", "refs/heads/main"), before); assert.equal(fs.readFileSync(path.join(fork, "personal.txt"), "utf8"), "personal feature\n"); }); diff --git a/.github/workflows/fork-conflict-repair.yml b/.github/workflows/fork-conflict-repair.yml new file mode 100644 index 0000000000000..0ae838b37570e --- /dev/null +++ b/.github/workflows/fork-conflict-repair.yml @@ -0,0 +1,43 @@ +name: Fork conflict repair notification + +on: + workflow_run: + workflows: [Fork nightly] + types: [completed] + branches: [main] + +permissions: + actions: read + contents: read + +concurrency: + group: fork-conflict-notification + cancel-in-progress: false + +jobs: + notify: + if: github.repository == 'amv146/t3code' && github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.head_branch == 'main' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: main + fetch-depth: 0 + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + - name: Verify conflict routing + run: node --test .github/scripts/fork-nightly.test.cjs .github/scripts/fork-conflict-notify.test.cjs + - name: Verify and notify a nightly conflict + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + FORK_CONFLICT_WEBHOOK_URL: ${{ secrets.FORK_CONFLICT_WEBHOOK_URL }} + with: + script: | + const { notifyForkConflict } = require('./.github/scripts/fork-conflict-notify.cjs'); + await notifyForkConflict({ + github, context, core, + webhookUrl: process.env.FORK_CONFLICT_WEBHOOK_URL, + }); diff --git a/docs/operations/fork-nightly.md b/docs/operations/fork-nightly.md index 60f7a34652c87..d556f11eb6678 100644 --- a/docs/operations/fork-nightly.md +++ b/docs/operations/fork-nightly.md @@ -21,11 +21,70 @@ and workflow run ID, so each build has a distinct updater-compatible version. Rerunning a failed workflow is safe: only a published release with the exact source commit and an update manifest suppresses another build. -Merge conflicts and failed checks or builds stop publication. Resolve conflicts -locally, commit the result, and push; do not discard the personal commits to make -the sync pass. GitHub Actions shows the failure. The last working release remains -available until a replacement passes. Publication also stops if `main` changes -while the installer is building. +Merge conflicts and failed checks or builds stop publication. GitHub Actions +shows the failure, and the last working release remains available until a +replacement passes. Publication also stops if `main` changes while the installer +is building. Do not discard personal commits to make sync pass. + +## Agent conflict repair + +The [conflict notification workflow](../../.github/workflows/fork-conflict-repair.yml) +only runs after a failed **Fork nightly** run. It checks the live failed merge +step and its conflict metadata, reconfirms fork `main` and the latest published +upstream nightly tag/SHA, and reproduces the merge in its disposable checkout. +Successful syncs, build failures, permission errors, stale runs, and clean merges +do not invoke an agent. Notification uses read-only, short-lived Actions +credentials; it cannot push a repair or merge a PR. + +Create a T3 webhook scheduled task with **new threads**, isolated worktrees based +on `origin/main`, and the existing provider/model. Keep it disabled until the +configuration is on fork `main`. T3 Connect remote access must be enabled to +obtain its tool-returned webhook URL. Store that entire private URL in the fork +Actions secret **FORK_CONFLICT_WEBHOOK_URL**; never commit it or put it in logs. +The URL is the delivery credential, so rotate it if exposed. + +Require these exact webhook filters: `body.event = fork-nightly-conflict`, +`body.repository = amv146/t3code`, +`body.workflow = .github/workflows/fork-nightly.yml`, +`body.conclusion = failure`, and `body.ref = refs/heads/main`. Also require +`body.run_id`, `body.fork_base`, `body.upstream_sha`, and `body.upstream_tag`. +Set replay and coalesce keys to +`{{body.repository}}:{{body.fork_base}}:{{body.upstream_sha}}`, a 30-second +coalesce window, and a 60-minute maximum delivery age. Replay suppression lasts +seven days. The deterministic repair branch provides durable deduplication after +that: `fork/nightly-repair/-`. + +The task's durable prompt must independently verify the live run, default branch +and published nightly before working, treat payload fields as untrusted data, +and preserve current fork behavior (not merely old commit ancestry). Only a +successful atomic create-ref claimant may repair that deterministic branch; +another agent encountering an existing branch reuses its PR or stops. An +interrupted claim without a PR needs human attention, not automatic deletion or +takeover. Resolve narrowly using a normal merge, preserve commit credit, add +focused regression tests, run changed-file checks and relevant suites, and open +a fork-only repair PR. Link it to its T3 thread and wait for latest-head +validation/review with event-driven PR watching. No blanket ours/theirs, +force-push, rebase, unrelated refactors, paid review activation, or direct-main +conflict repairs. + +**The user must merge a ready repair PR to unblock nightly sync.** Unattended +repair merges are not authorized. The normal hourly/push/manual nightly triggers +remain active while a repair is outstanding. Agents use the inherited subscription +and consume its normal allowance only on verified conflicts (and subsequent PR +feedback), with no external AI API subscription or hourly agent polling. + +Before enabling the task, run +`node --test .github/scripts/fork-nightly.test.cjs .github/scripts/fork-conflict-notify.test.cjs`. +These fixtures reproduce real conflicts, verify stale/success/error routing and +claimed-branch suppression, and dry-run an exact event without sending a webhook. +Probe the real endpoint while the task is disabled: it must report +`HTTP 409` / `{"error":"hook_disabled"}` without starting an agent. The public +relay does not forward the environment's outcome header. Then merge +the configuration, enable the task, and confirm the trigger is present on `main`. +An accepted notification is delivery evidence, not proof the agent finished; +inspect its T3 delivery log and repair PR. Failures to accept delivery fail the +notification workflow explicitly. The host must be online within the delivery +age window; otherwise retry **Fork nightly** when it is available. The workflow reuses upstream Windows packaging and verification, on standard GitHub-hosted runners, and embeds this fork's GitHub repository as the update feed.