Repository navigation
feat(worker): publish agent work through the GitHub bot - #145
Conversation
Runners publish a running attempt's file changes at POST /api/runner/publish. The Worker validates the attempt fence, each workflow's permitted paths, the requester's live maintainer authority, and the branch's expected head, then commits the changes as the App with the Git Data API on the job's one branch: the service fork for plan/start/bootstrap, or an iterated PR's own head. Refused changes and blocked updates pause the job with a publication decision. A pending commit is recorded under the fence just before a fast-forward-only ref update, so a lost outcome is adopted on the next publication instead of replayed. Delivery finds the branch's open PR before creating one, never replaces a closed one, keeps planning PRs draft, and marks finished work ready. Merges pick squash, then merge commit, then rebase from what the repository allows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Outcome: the publication path is implemented and passes (Written by Claude Opus 5.5) |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 076790fea9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| else if (input.head !== head) | ||
| throw new JobError(`The job's branch is at ${head ?? "no commit yet"}; continue from it`); |
There was a problem hiding this comment.
Revalidate the branch before pull-only delivery
When a runner publishes commits first and later sends a pull-only request, this branch compares input.head only with the cached jobs.head and skips branch.head(). If the service-fork branch was pushed or deleted in between, deliver() can open the PR from that unexpected live head while returning the stale cached SHA, bypassing the branch-change decision used for publications containing changes. Resolve the branch and verify its live head before opening or updating the pull request.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed. Fixed in 39efeb0: a pull-only publication now runs the same branch verification as a commit (verify in publish.ts, inside contribute), so a branch moved or deleted outside the job pauses on the publication decision and opens no PR. Regression test: "pauses a delivery alone when the branch moved outside the job". A window between that check and the PR create remains, since GitHub cannot pin a PR to a head SHA at creation.
(Written by Claude Opus 5.5)
| const open = async () => | ||
| (await this.#api<PullHead[]>(token, `${pulls}?state=open&head=${encodeURIComponent(label)}`))[0] ?? | ||
| null; |
There was a problem hiding this comment.
Preserve closed PRs during interrupted-delivery recovery
If GitHub creates the PR but the response or subsequent store.delivered() write is lost, job.pull remains null. Should the maintainer close that PR before the retry, this recovery lookup excludes it with state=open, so the retry proceeds to create a replacement instead of honoring the stated closed-PR behavior. Reconciliation needs to find the prior closed PR for the same job branch as well.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed. Fixed in 39efeb0: when the job has no recorded PR, recovery lists state=all PRs for the branch and takes one created since the job began, so a PR closed after an unrecorded delivery is refused rather than replaced. The creation-time bound keeps a closed PR from an earlier job on the same branch name from blocking a new job. Regression tests cover both cases.
(Written by Claude Opus 5.5)
…und on recovery A pull-only publication now checks the job's branch through the same verification as a commit, so a branch changed outside the job pauses on a decision instead of opening a PR from it. Recovering an unrecorded delivery also finds pull requests the maintainer closed since, so none is replaced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # cloudflare/README.md
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 39efeb0ad2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| return "Changing GitHub Actions workflows needs the workflows permission, which the App does not request"; | ||
| if (workflow === "bootstrap" && path !== "flake.nix" && path !== "flake.lock") | ||
| return "A devShell bootstrap publishes only flake.nix and flake.lock"; | ||
| if (workflow === "plan" && !path.startsWith("quest/")) return "Planning publishes only quest files"; |
There was a problem hiding this comment.
Restrict planning publications to actual quest documents
For a planning request containing paths such as quest/tool.sh or quest/AGENTS.md, this prefix check allows the files to be committed even though isQuest() defines quest documents as Markdown excluding agent-instruction files. This bypasses the stated plan-only policy and lets a planning job publish unrelated code or modify instructions; validate the path with the shared quest predicate in addition to requiring the quest/ directory.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed. Fixed in 519cbac: planning now requires quest/ plus the shared isQuest predicate, so quest/tool.sh and quest/AGENTS.md are refused with a decision. GITHUB.md says so too.
(Written by Claude Opus 5.5)
| const label = `${fork.owner.login}:${job.branch}`; | ||
| // An unrecorded earlier delivery may have opened one, which the maintainer may since have closed. | ||
| // GitHub reports whole seconds, so compare against the second the job was created in. | ||
| const since = Math.floor(job.createdAt / 1000) * 1000; |
There was a problem hiding this comment.
Distinguish earlier PRs created in the same second
When an earlier job's PR was closed and a replacement job for the same deterministic branch is created within the same GitHub timestamp second, flooring createdAt makes that older PR pass the recovery cutoff. opened() then treats it as the new job's PR and rejects delivery because it is closed, rather than opening the intended replacement. The reconciliation key needs to distinguish jobs without relying solely on second-resolution creation time.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Disagree; leaving this as is. For an earlier PR to fall in the same second as this job's creation, all of these would have to happen inside one second: the earlier job delivers its PR, the PR is closed, the fork branch is deleted (otherwise the new job pauses on the leftover-branch decision before it can deliver), and the replacement job is created. Rounding the other way would instead miss this job's own PR if it were created in the second the job was, which is equally impossible because a job must be dispatched and commit first. A stronger key would mean recording a delivery marker before the create, which adds state for a case that cannot happen in practice.
(Written by Claude Opus 5.5)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 122594b296
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const draft = input.pull.draft || job.workflow === "plan" || job.cancel === 1; | ||
| const pull = await github.deliver(upstream, job.actor, job, { ...input.pull, draft }); |
There was a problem hiding this comment.
Revalidate the job before delivering its pull request
If the runner finishes or loses the attempt, or a maintainer cancels the job while contribute() is awaiting GitHub, this cached job remains valid-looking and deliver() still opens or updates the PR without another store fence. In the cancellation case, a request with draft: false can also mark the PR ready using the stale cancel === 0, and later deliveries intentionally cannot return it to draft. Re-read the current attempt and cancellation state immediately before delivery.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed. Fixed in 519cbac: publish rereads the attempt under the fence after the branch write and delivers from that row, so an attempt that ended meanwhile opens no PR and a cancellation keeps it a draft. Regression test: "rechecks the attempt and its cancellation after writing the branch, before delivering". A cancel landing during deliver itself can still slip through, but that window is now one GitHub round trip rather than the whole branch write.
(Written by Claude Opus 5.5)
…ivery Planning publications now accept only quest documents under quest/, using the shared isQuest predicate, so scripts and agent instructions there are refused. Delivery rereads the attempt after the branch write, so an attempt that ended meanwhile opens no PR and a cancellation keeps it a draft. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 519cbac561
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| -- The commit the job's branch holds, as far as kix has published or observed | ||
| -- it. NULL until the first publication creates the branch; an iteration starts | ||
| -- at the pull request's head. | ||
| ALTER TABLE jobs ADD COLUMN head TEXT; |
There was a problem hiding this comment.
Backfill heads for existing iteration jobs
When this migration runs with an existing queued, running, or waiting iterate job, the new head remains null even though that job's branch already starts at its recorded revision. On its first publication, verify() sees the live PR branch at revision but the stored null head and repeatedly pauses it as an externally created branch. Backfill head = revision for existing iteration jobs as part of the migration.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Disagree; leaving the migration without a backfill. Nothing that uses jobs is deployed yet (the merged 0008_output.sql drops job_output for the same reason), so no queued or running iteration job can exist when this runs. New iteration jobs start with head set to the PR head at creation.
(Written by Claude Opus 5.5)
| const base = await this.#api<{ tree: { sha: string } }>(token, `${path}/git/commits/${sha(parent)}`); | ||
| const tree = []; | ||
| for (const change of changes) { | ||
| const mode = change.executable ? "100755" : "100644"; |
There was a problem hiding this comment.
Preserve or reject non-regular Git file modes
When a job changes a symlink or submodule path, the publication format provides only an executable flag and this code always writes a regular blob (100644 or 100755). Publishing a symlink therefore silently replaces it with a regular file containing its target text, while a submodule cannot be represented correctly at all. Carry the Git object type and mode through the request, or explicitly refuse unsupported entries rather than corrupting them.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed that silently writing a regular file was wrong. Fixed in 22103b2: a change may set symlink: true, whose content is the link target, and is written with mode 120000; executable and symlink together are refused. Submodule pointers have no representation in a content-based change, so GITHUB.md documents them as unsupported. The runner adapter must set the flag; the service does not read the base tree to infer modes.
(Written by Claude Opus 5.5)
A change may set symlink, whose content is the link target, so publishing a symlink no longer replaces it with a regular file. Submodule changes have no representation and are documented as unsupported. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge summaryThe maintainer chose to merge. Auto-merge (squash) is enabled at b5a58cf. What this PR does: jobs can now publish through the GitHub bot via Reviews: Codex reviewed three times, which used up the three fix rounds.
Since the last push: merged main (#147, #148, #149) in b5a58cf with no conflicts. These commits only add skills and quest plans; none of them refers to the deleted Coordination: #144 still has Follow-ups: listed in the description and not yet planned. They are: a per-project fork repository ID, a service-rendered decision paper trail, a (Written by Claude Opus 5.5) |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b5a58cf1e5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (workflow === "plan" && !(path.startsWith("quest/") && isQuest(path))) | ||
| return "Planning publishes only quest files"; |
There was a problem hiding this comment.
Reject symlinks from planning publications
A planning request for quest/foo.md with symlink: true passes this path-only check, after which parsePublication emits mode 120000; the job therefore publishes a symlink rather than a quest document. If that PR is merged, GitHubService.#snapshot rejects the matching quest entry because it is not a regular file, breaking subsequent project refreshes. Include the change mode in the planning policy and refuse symlinks for plan jobs.
Useful? React with 👍 / 👎.
Brings in bot publication (#145). The inbox migration becomes 0010_inbox.sql after 0009_publication.sql. merge.md and planning.md drop the deleted publication and inbox quests from Required, and merge.md's emptied section goes away. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…inbox Codex review findings on #144: - A publication decision from bot publication (#145) fell through to "could not be read" with no way to answer. The inbox now lists the refused paths or why the branch is blocked and takes a text reply, which the answer route accepts from the form. - A failed PR job whose pull request is no longer observed crashed the inbox: the retry action dereferenced missing activity before the refresh reason was checked. The action is built only when unblocked. - The inbox read only the 50 newest jobs, so an older waiting job vanished. It now also reads every queued, running, and waiting job. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(quest): claim quest/m0/kix/prompts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(worker): make the decision inbox the signed-in home Add /inbox: one active decision thread across a maintainer's enrolled projects, coalesced per job target, with agent questions, account-limit choices, failed jobs, new pull requests, new issues, and ready quests. Define the agent prompt contract (choices, recommendation, free text, permission vs decision) and validate prompts and answers. Job routes accept inbox form posts, bind decisions to the revision on screen, and refuse stale or repeated answers. Per-maintainer read state marks changed items unread and keeps skips until an item changes. Sign-in now lands on the inbox. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(worker): keep the decision thread in one column on wide screens The PR meta line reused .entry-meta, whose grid-column: 2 split the thread into two columns once there was room. Also show GitHub's check and review enums as plain words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(quest): drop the deleted inbox quest from paper-trail #150 added paper-trail.md requiring /quest/m0/kix/prompts.md, which this PR deletes. That was its only blocker, so the Required section goes away. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(worker): answer publication decisions and keep older jobs in the inbox Codex review findings on #144: - A publication decision from bot publication (#145) fell through to "could not be read" with no way to answer. The inbox now lists the refused paths or why the branch is blocked and takes a text reply, which the answer route accepts from the form. - A failed PR job whose pull request is no longer observed crashed the inbox: the retry action dereferenced missing activity before the refresh reason was checked. The action is built only when unblocked. - The inbox read only the 50 newest jobs, so an older waiting job vanished. It now also reads every queued, running, and waiting job. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(worker): retry failures only where the retry can work Codex review findings on #144, round 2: - Retrying a failed planning interview started an unrelated one under a new plans/<id>. The retry now keeps the target, which the jobs route accepts, so the new job replaces the failure. - A failed quest job offered "Try again" after the quest stopped being ready, which the route then refused. The inbox explains instead. - An older failure beyond the 50-job history showed as fresh work or vanished. The inbox also reads each target's latest failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(worker): read each target's latest job and encode quest links Codex review findings on #144, round 3: - A finished job beyond the 50-job window let its still-open issue, PR, or quest show as fresh work. The inbox now reads each target's latest job however old: every one awaiting a decision, plus any other whose target is still observed. An index on (repository, target, createdAt) backs the lookup, replacing the 50-job window. - Quest links interpolated raw paths, so a `#` or `?` in a filename broke the board link. They now reuse the board's path encoding. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(worker): limit publication replies by characters, not JSON length The inbox's reply box allows 1,000 characters, but the answer route checked the JSON-serialized length against 1024, so quotes, backslashes, and line breaks could get a valid reply refused as too large. The route now checks the text itself against the same 1,000-character limit the form uses, after normalizing the CRLF line breaks forms submit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(quest): plan batched inbox reads for m1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(quest): plan retries that continue from the published branch Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Implements Bot publication and deletes the quest.
Problem
Jobs could run, but their output had no path to GitHub.
publishQuestandcreatePullRequestwere unused primitives limited to a single quest file.mergePullRequestalways used a merge commit, which squash-only and rebase-only repositories refuse.Behavior
POST /api/runner/publish(runner bearer token, HTTPS rather than the hub socket so slow GitHub writes don't stall other messages). The request carriesjob,attempt,head(the commit the changes build on, ornull),message, base64changes(at most 500 files and 16 MiB in total, each optionallyexecutableor asymlink; submodules are unsupported), an optionalpull: { title, body, draft }, andsession. The reply is{ head, pull? }, or202 { decision, cancel }.(attempt, runner), still running.quest/(Markdown other than AGENTS.md/CLAUDE.md), bootstrap →flake.nix/flake.lock, start/iterate → their changes. No workflow may touch.github/workflows/.publicationquestion kind. Answering resumes the agent's session.GITHUB_FORK_OWNER/<repo>, which is verified by its parent ID.pendingunder the fence just before a fast-forward-only ref update. If the outcome is lost, the next publication adopts it.draftdefaults to true, anddraft: falsemarks the PR ready. Nothing ever converts a PR back to draft.0009_publication.sqladdsjobs.head,pending,pendingAtandpull, and rebuildsjob_questionsfor the new kind. Iterate jobs start withheadset to the PR head.cloudflare/GITHUB.mdgains a Publication section covering the contract, token scopes and broader installation grants, and the untrusted fork-PR CI warning.Validation
just checkandjust testpass innix developafter merging main: 231 Worker tests and 225 CLI tests.test/publish.test.tsruns against a stateful fake of GitHub's Git, ref and pull request APIs. It covers:Limitations: not verified against real GitHub
These need real App installations. They are listed under operator setup and were not exercised here:
Decisions (settled by the maintainer, 2026-10-19)
GITHUB_FORK_OWNER/<upstream repo name>for the m0 pilots. A per-project fork repository ID, recorded at enrollment, comes before opening to other owners.Co-authored-bylines; the agent writes them per the repository's instructions.Merge coordination
publication.mdfromquest/m0/kix/merge.md's Required (the decision inbox entry remains) and pointed its Plan atmergePullRequestincloudflare/src/github/service.ts.runner.mdkeeps both main's runner-settings bullet and this PR's publication bullet.cloudflare/README.mdkeeps both the publication paragraph and the raw output section, andpublish.test.tsimports the Honoappnow that the default export is{ fetch, scheduled }.0008_output.sql, so this migration is now0009_publication.sql. feat(worker): make the decision inbox the signed-in home #144 (0008_inbox.sql) is still open and already collides with0008_output.sql; it needs the next free number (0010if it merges after this PR).Suggested follow-ups
publishedjob event, so agent activity can show commits and the PR.🤖 Generated with Claude Code
(Written by Claude Opus 5.5)