Skip to content

[factory] publishPullRequest never creates the head ref on the cloud path, then 422-loops forever and pins the only batch slot #430

Description

@khaliqgant

Summary

On the deployed cloud writeback path, publishPullRequest never creates the head
ref
, then opens a PR against it. GitHub answers 422 Validation Failed, the retry
is unbounded, the dispatch lifecycle never reaches a terminal phase, and with
batchSize: 1 the pinned slot stops the entire factory.

This is the live cause of the current outage: candidates: 20, dispatched: 0, skipped: 20 while /healthz reports ok: true.

The code

src/mount/relayfile-github-connection-write.ts (confirmed identical in the
deployed @agent-relay/factory@0.1.83 dist/):

const headSha = input.headSha ?? (input.clonePath && !input.headRef
  ? await this.#gitValue(['-C', input.clonePath, 'rev-parse', 'HEAD'], 'HEAD commit')
  : undefined)
const draftName = githubDraftName(headRef, headSha)
// ...
// A remote implementer already pushed its branch. For the legacy local-clone
// path, create the branch before opening the PR.
if (headSha) {
  await this.#writeAndConfirm(createRefPath, { ref: fullHeadRef, sha: headSha })
  // ...
}
const pullRequestPath = `${repoRoot}/pull-requests/${draftName}.json`
await this.#writeAndConfirm(pullRequestPath, { head: headRef, base: input.baseRef, ... })

A remote implementer supplies headRef and no headSha. So !input.headRef is
false, the ternary yields undefined, headSha is undefined, and if (headSha)
does not run. Ref creation is skipped in exactly the case it is needed, and the PR is
then opened against a branch GitHub has never seen.

The ?? 'pushed' sentinel in githubDraftName means the failure signs its own
artifact — every wedged writeback path ends in -pushed.json, which decodes as
"no sha was available".

Measured, from /evidence at 2026-09-02T09:15Z

[factory] durable dispatch lifecycle retry failed {"issue":"186","error":
  "Writeback operation failed for /github/repos/AgentWorkforce/factory/pull-requests/
   factory-factory-186-agentworkforce-factory-808843c4-pushed.json:
   GitHub writeback failed with status 422: Validation Failed"}

git ls-remote confirms factory/186-agentworkforce-factory-808843c4 does not
exist
on origin. Six work units were starved behind that one row, all naming it:

{"issue":"427","attempts":275,"waitedMs":8227919,"batchSize":1,"occupiedBy":["186"]}
{"issue":"417","attempts":274,"waitedMs":8228052,"batchSize":1,"occupiedBy":["186"]}
{"issue":"416","attempts":274,"waitedMs":8228329,"batchSize":1,"occupiedBy":["186"]}
{"issue":"394","attempts":274,"waitedMs":8228788,"batchSize":1,"occupiedBy":["186"]}
{"issue":"413","attempts":274,"waitedMs":8228632,"batchSize":1,"occupiedBy":["186"]}
{"issue":"412","attempts":83, "waitedMs":2339996,"batchSize":1,"occupiedBy":["186"]}

2.3 h on that boot alone; the row survived container replacement, so the true age is
longer. The newest factory/* branch in this repo is factory/273, dated
2026-08-16 — no dispatch has produced a branch in over two weeks.

Contrast: the sibling path does not have this hole

src/writeback/github.ts (the gh-CLI writeback) does both of the things the
relayfile path omits:

const headSha = input.headSha ?? (input.clonePath          // no `&& !input.headRef`
  ? await this.#gitValue([...'rev-parse', 'HEAD'], 'HEAD commit')
  : undefined)

if (input.clonePath && !input.headRef) {
  await this.#git(['-C', input.clonePath, 'push', 'origin', `HEAD:refs/heads/${headRef}`])
}

The relayfile path has no push at all — the gated createRefPath writeback was
the only mechanism that could put the branch on GitHub, and it is gated on the one
value that is never present when it is needed.

Two defects, please treat separately

  1. The ref is never created (this issue). Whatever the upstream cause, the
    publish step must not open a PR against a head it has not confirmed exists.
    It should confirm the ref and fail loudly and terminally if it cannot,
    rather than 422-looping.

  2. A deterministic writeback failure is retried forever. A 422 Validation Failed is not transient — no number of retries fixes an absent head ref — yet
    the lifecycle retry has no dead-letter, so one poisoned row pins the batch slot
    indefinitely. #419/#423/#429 address the reaper; this is upstream of the
    reaper, because the row is running and legitimately owned the whole time. A
    non-retryable writeback status should terminalise the lifecycle and release the
    slot.

Open question for whoever picks this up

Whether the implementer should have pushed and could not (no authenticated
GitHub actor in the sandbox — the "C5 gap"), or whether it is never asked to push
and the orchestrator was always meant to create the ref. The 422 proves the ref is
absent; it does not by itself say which half is wrong. Both readings still require
fix (1): the publish step must verify rather than assume.

Repro

Dispatch any issue through the deployed cloud path with github.identity: "app".
The lifecycle reaches running, the implementer reports a headRef, and the
publish writeback 422s on .../pull-requests/factory-<branch>-pushed.json forever.


Found while driving one issue end-to-end from lane/cloud-ensure-fix-r1.
Slot unpinned in production by clearing factory:186 via reset-stuck-dispatches
(run 33614083863) and removing its factory-ready label; see #186 for that note.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    factoryFactory automation trigger

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions