Branch-only rewind/fork of prompt-PR agent runs — re-run off a prior result branch with forked_from_job_id lineage, ahead of warm-fs snapshot forks.
A parallel-track plan for the §1 "Rewind & fork agent runs" feature from
developer-features, in its branch-only form (no
warm-fs snapshot yet — that arrives with the sandbox fleet, see
nixos-sandbox-fleet).
This is written for a fresh agent. Every file:line anchor was verified against the
tree on branch feat/activegraph-stage1-foundations. The plan is deliberately
self-contained: pick it up, follow the steps, ship.
Status: Stage 3 implemented — branch-only run forks ship via a Tasks-page
button, forkPromptPullRequestJob, and forked_from_job_id lineage; warm-fs
snapshot forks remain deferred to the sandbox-fleet stages.
A "Fork" button on each row of the Tasks-page job list. Clicking it:
copies the source job's prompt, mode, baseBranch;
cuts a new compare branch from the source job's compare branch (not its
source branch — the fork starts from the prior result);
opens a new Draft PR via the unchanged launchPromptPullRequestJob;
records forked_from_job_id on the new row so lineage is queryable.
No graph emission, no snapshot, no UI changes beyond the button and an optional
"Forked from #N" badge. This is the smallest seam that delivers a real "rewind a
run" feel.
Stage 1 (in progress on this branch) touches: Application/Schema.sql (append
graph tables), Application/IsolatedExecution.hs, Config/Config.hs,
Jobs/WorkflowRun.hs, Jobs/PromptPullRequest/Run.hs,
Jobs/PullRequestConflictResolution/Run.hs, and adds Application/Graph.hs.
This feature touches: Application/PromptPullRequests.hs, Web/Types.hs,
Web/Controller/PullRequests.hs (handler) orWeb/Controller/Repositories.hs
(if you prefer to colocate with the Tasks page), Web/View/Repositories/Show.hs,
plus the schema append + a new migration.
Overlaps and how to handle them:
Application/Schema.sql — both branches append. Trivial merge; keep the new
column inside the existing prompt_pull_request_jobs definition near
source_branch (Schema.sql:799) and the file's structure stays linear.
Web/Controller/Repositories.hs — Stage 1's only edit here is the
deleteAwsWorkflowVm teardown at :4094. The Tasks-page touch points
(:3232 and buildRepositoryTasksView:3522) are ~570 lines away. Effectively
zero conflict surface.
Jobs/PromptPullRequest/Run.hs — not touched by this feature. The fork
reuses the existing launch path; the agent runner is unchanged.
NewForkRepositoryAction / CreateForkRepositoryAction already exist
(Web/Types.hs:431 and :435) — these are repository-level fork (fork a
repo to a new org), not run-level fork. The maybeForkRepository and
openForkModal parameters threaded through every buildRepository*View
function (Web/Controller/Repositories.hs:3211-3240,
buildRepositoryPageLayout:3296) are for the cross-page fork modal — leave
them alone.
Use new names with PromptPullRequestRun (or Task) in them to keep the
two concepts distinct:
Add one nullable, non-FK self-referential UUID column to
prompt_pull_request_jobs (Application/Schema.sql:774-800):
forked_from_job_id UUID DEFAULT NULL
Place it right after source_branch (:799) so the file stays append-only.
Add an index for lineage queries:
CREATE INDEX prompt_pull_request_jobs_forked_from_job_id_idx ON prompt_pull_request_jobs (forked_from_job_id) WHERE forked_from_job_id IS NOT NULL;
Why no SQL foreign key. A self-FK on prompt_pull_request_jobs makes IHP's
code generator emit a relation accessor on PromptPullRequestJob that points
back at PromptPullRequestJob, which can clash with the table's own field
naming and break type generation. The same constraint is why the new
ActiveGraph tables on this branch deliberately avoid intra-graph FKs (see
Application/Schema.sql:1287-1346 for the rationale). Enforce the reference
in Haskell: in the fork handler, fetch sourceJobId will throw if the row is
gone — that's the integrity check.
If you really want referential integrity at the DB layer, add a deferred
constraint as a follow-up after verifying the generator handles it; do not
block the first PR on it.
Pattern: forward-only, idempotent, timestamped. The most analogous precedent is
Application/Migration/1779963158-add-prompt-source-branch.sql, which is a
single-line ALTER TABLE … ADD COLUMN ….
ALTER TABLE prompt_pull_request_jobs ADD COLUMN IF NOT EXISTS forked_from_job_id UUID DEFAULT NULL;CREATE INDEX IF NOT EXISTS prompt_pull_request_jobs_forked_from_job_id_idx ON prompt_pull_request_jobs (forked_from_job_id) WHERE forked_from_job_id IS NOT NULL;
Get the timestamp with date +%s. Use IF NOT EXISTS so the migration is safe
to re-apply.
direnv exec . ./scripts/with-compile-db.sh make build/Generated/Types.hs
This spins an ephemeral Postgres, applies Application/Schema.sql, and writes
per-table modules under build/Generated/ActualTypes/. Verify that
build/Generated/ActualTypes/PromptPullRequestJob.hs now contains
forkedFromJobId :: Maybe (Id PromptPullRequestJob) (or Maybe UUID — IHP
chooses based on naming conventions; either is fine, the builder below handles
both).
Add a single new exported function in Application/PromptPullRequests.hs. The
module already owns the prompt-job state machine (:1-7 module header,
draftPromptPullRequestJob:108, launchPromptPullRequestJob:151), and the
existing buildPromptPullRequestJob:78 is the right pattern to mirror.
-- | Build an unsaved prompt-PR job that forks off a prior run's compare-- branch. The new job inherits the source's prompt/mode/baseBranch and points-- forkedFromJobId at the source. Pass the result to launchPromptPullRequestJob-- (the launch path is unchanged).forkPromptPullRequestJob :: User -> Repository -> PromptPullRequestJob -> Either PromptPullRequestValidationError PromptPullRequestJob
forkPromptPullRequestJob user repository sourceJob = do normalizedPrompt <- requirePrompt sourceJob.prompt let sourceCompareBranch = sourceJob.compareBranch -- Fork = start a new compare branch from the prior run's compare branch. -- launchPromptPullRequestJob:158 already uses sourceBranch to decide where -- to cut, so this is the entire "rewind" mechanic. when (Text.null (Text.strip sourceCompareBranch)) $ Left PromptPullRequestBaseBranchInvalid pure $ newRecord @PromptPullRequestJob |> set #repositoryId repository.id |> set #authorId user.id |> set #mode sourceJob.mode |> set #prompt normalizedPrompt |> set #title ("Fork: " <> sourceJob.title) |> set #description sourceJob.description |> set #baseBranch sourceJob.baseBranch |> set #sourceBranch sourceCompareBranch |> set #forkedFromJobId (Just sourceJob.id)
Add forkPromptPullRequestJob to the module's export list at :8-28. Do not
touch buildPromptPullRequestJob or launchPromptPullRequestJob — fork
deliberately bypasses request-shape validation (the source job was already
validated when it was created) and reuses the launch path verbatim.
The builder rejects forks of jobs whose compareBranch is empty — i.e., jobs
that never reached the "compare branch cut" stage. That covers:
jobs still queued / running (no branch yet);
jobs that failed before launchPromptPullRequestJob:163 succeeded.
It does not require the source job to have succeeded — a fork from a
failed run is exactly the rewind use case ("the agent broke at step 3, let me
retry with a different prompt"). Allow forks from any job with a non-empty
compareBranch.
Place it inside the same data RepositoriesController (or whichever
sum-controller hosts CreatePromptPullRequestAction — verify by reading
Web/Types.hs:510-520 for the surrounding constructor).
If there is a parallel API surface, also add
CreateApiForkPromptPullRequestRunAction next to
CreateApiPromptPullRequestAction:76. The API variant is optional for
the first PR — skip it unless the user asks.
No URL routing changes needed: IHP's auto-routing derives the path from the
action name.
Add to Web/Controller/PullRequests.hs, immediately after
CreatePromptPullRequestAction (:210-269). The handler is a slimmer cousin of
CreatePromptPullRequestAction:
action ForkPromptPullRequestRunAction{sourceJobId} = do ensureIsUser @User sourceJob <- fetch sourceJobId repository <- fetch sourceJob.repositoryId >>= syncRepositoryRevision accessRole <- fetchRepositoryAccessRole repository (Just currentUser.id) accessDeniedUnless (canWriteRepository accessRole) case forkPromptPullRequestJob currentUser repository sourceJob of Left _ -> do setErrorMessage "This run cannot be forked yet — its compare branch has not been created." redirectToSeeOther $ ShowRepositoryWithTabAction { repositoryId = repository.id , selectedTab = repositoryTasksTabCode } Right forkedJob -> do codexStatus <- fetchUserCodexCredentialStatus currentUser if not (codexCredentialStatusConnected codexStatus) then do setErrorMessage codexConnectionRequiredMessage redirectToSeeOther $ ShowRepositoryWithTabAction { repositoryId = repository.id , selectedTab = repositoryTasksTabCode } else do launchResult <- launchPromptPullRequestJob repository forkedJob case launchResult of Left errorMessage -> do setErrorMessage errorMessage redirectToSeeOther $ ShowRepositoryWithTabAction { repositoryId = repository.id , selectedTab = repositoryTasksTabCode } Right (_, pullRequest) -> do setSuccessMessage "Forked the run — a new draft PR is being prepared." redirectToSeeOther ShowPullRequestAction{pullRequestId = pullRequest.id}
The exact name of the "show repo at tab" action and its tab-code argument.
Search for usages of RepositoryTasksTab at
Web/Controller/Repositories.hs:3232 and follow back to the action that
serves it; the redirect must land back on the Tasks tab. If a direct
ShowRepositoryTasksAction exists, prefer it.
fetchUserCodexCredentialStatus / codexCredentialStatusConnected /
codexConnectionRequiredMessage — grep for these in
Web/Controller/PullRequests.hs to confirm they're in scope (they are used
by CreatePromptPullRequestAction:245-248, so the imports already cover
it).
syncRepositoryRevision is the same helper CreatePromptPullRequestAction
uses at :212.
Extend the Application.PromptPullRequests import line near
Web/Controller/PullRequests.hs top with forkPromptPullRequestJob. The other
helpers (fetch, redirectToSeeOther, setErrorMessage, setSuccessMessage)
are already in scope via IHP.ControllerPrelude.
The Tasks-page rendering chain (all in Web/View/Repositories/Show.hs):
Symbol
Line
Role
data RepositoryTasksView
:144
view record; do not need to add a field for fork
html RepositoryTasksView{...}
:330
tab dispatch
renderRepositoryTasksPageContent
:765
page chrome
renderRepositoryAiTasksPanel
:875
the AI-task form + jobs list mount
renderPromptPullRequestJobsPanelMount
:1872
autorefresh wrapper around the list
renderPromptPullRequestJobsPanelBody
:1889
the per-job rendering — Fork button goes here
Read :1889 and the lines that follow to see the row layout, then add a small
<form> next to each job's existing actions:
<form method="POST" action={pathTo ForkPromptPullRequestRunAction { sourceJobId = job.id }} class="d-inline" > <button type="submit" class="btn btn-sm btn-outline-secondary" disabled={Text.null job.compareBranch} title="Re-run this prompt off the same compare branch" > Fork </button></form>
Gate the button on not (Text.null job.compareBranch) so it greys out for
jobs that never created a branch — matches the builder's gate.
If renderPromptPullRequestJobsPanelBody iterates with a helper (e.g.
renderPromptPullRequestJobRow), add the button there instead. Read 30–50
lines starting at :1889 to confirm the exact insertion point.
Once forked_from_job_id is populated, surface the lineage on each job row.
Inside the per-job render (same place as the Fork button), look up the source
PR number:
case job.forkedFromJobId of Nothing -> mempty Just sourceJobId -> [hsx| <span class="badge bg-light text-dark me-2"> Forked from #{tshow sourceJobId} </span> |]
This is cosmetic — defer to a follow-up PR if the first PR is getting long.
A clean version resolves sourceJobId to a PR number via
fetchPromptPullRequestJobForPullRequest in reverse; build that as a small
batch fetch in buildRepositoryTasksView and stash the resulting Map (Id PromptPullRequestJob) Int on RepositoryTasksView. Skip for v1.
This codebase has no unit-test convention for the prompt-PR flow — the
existing source_branch feature shipped without one (commit 02c50de4).
Verify by running:
(timeout ~7 min the first time; incremental after that). Then a manual smoke
test:
Create a prompt-PR task via the Tasks page (existing flow). Wait for it to
produce a compare branch and a draft PR.
Return to the Tasks page → confirm the Fork button is enabled on that row.
Click Fork → expect redirect to a new draft PR with title Fork: <orig title>, opened off the original's compare branch.
In psql, verify:
SELECT id, title, compare_branch, forked_from_job_idFROM prompt_pull_request_jobsORDER BY created_at DESCLIMIT 2;
The newest row's forked_from_job_id matches the older row's id; the
newest row's compare_branch is a new branch, distinct from the older
row's compare_branch, but cut from it.
Try to fork a job whose compare_branch is empty (e.g., a queued or
freshly-failed run) → the button is disabled; manually POSTing the action
returns the user to the Tasks tab with the expected error toast.
/data/lazrossi/code/gitoku/CLAUDE.md requires running gitnexus_impact
before editing any symbol. Before touching launchPromptPullRequestJob,
draftPromptPullRequestJob, CreatePromptPullRequestAction, or the Tasks
view renders, run:
For this feature, the builder and handler are new symbols, so impact
analysis applies to read-only references only. The only modified existing
surface is Web/Types.hs (adding a constructor) and the Tasks-view panel body
(adding markup). Run gitnexus_detect_changes() before committing.
If the GitNexus index is stale (it will be — Stage 1 work is uncommitted on
this branch), run npx gitnexus analyze first.
Warm-fs snapshot. Booting the sandbox from the prior run's filesystem is
the §1 headline in the feature doc, but it requires the
SandboxBackend/snapshot work from
nixos-sandbox-fleet-plan.md §4–5. Branch
fork ships now; snapshot fork lands after the fleet work.
Graph emission. A future PR (Stage 2 of the foundations) will turn each
prompt-job row into a run node and each forkedFromJobId into a
derived_from edge in Application/Graph.hs. Do not block this PR on the
graph tables — backfill is one query (SELECT id, forked_from_job_id FROM prompt_pull_request_jobs WHERE forked_from_job_id IS NOT NULL) once the
graph layer ships.
API surface.CreateApiPromptPullRequestAction (Web/Types.hs:76,
handler at Web/Controller/Api.hs:993) has no fork sibling yet. Skip unless
the user asks; the web UI is the dogfood path.
Re-rendering the AI tasks form pre-filled with the source prompt. A
"fork & edit" flow (where the user tweaks the prompt before launching) is a
separate UX. v1 is one-click "re-run as-is off the prior branch".