Gitwami

Fork agent run

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.


What ships

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.


Independence from Stage 1 foundations

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) or Web/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.hsnot touched by this feature. The fork reuses the existing launch path; the agent runner is unchanged.

Naming collision warning

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:

  • ForkPromptPullRequestRunAction { sourceJobId :: !(Id PromptPullRequestJob) }
  • forkPromptPullRequestJob (the builder)

Avoid: ForkRunAction, NewForkAction, anything bare. The word "fork" in this codebase already means repo-fork.


1. Schema change

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.


2. Migration file

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 ….

Create Application/Migration/<unixtime>-add-prompt-forked-from-job.sql:

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.


3. Regenerate types

After the schema edit:

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).


4. Builder

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.

Builder signature

-- | 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

Body sketch

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.

What the fork gate is

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.


5. Routing

Add to Web/Types.hs next to the existing CreatePromptPullRequestAction (:514):

| ForkPromptPullRequestRunAction {sourceJobId :: !(Id PromptPullRequestJob)}

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.


6. Controller handler

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}

Things to verify before writing this verbatim

  1. 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.
  2. 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).
  3. syncRepositoryRevision is the same helper CreatePromptPullRequestAction uses at :212.

Imports

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.


7. View — the Fork button

The Tasks-page rendering chain (all in Web/View/Repositories/Show.hs):

SymbolLineRole
data RepositoryTasksView:144view record; do not need to add a field for fork
html RepositoryTasksView{...}:330tab dispatch
renderRepositoryTasksPageContent:765page chrome
renderRepositoryAiTasksPanel:875the AI-task form + jobs list mount
renderPromptPullRequestJobsPanelMount:1872autorefresh wrapper around the list
renderPromptPullRequestJobsPanelBody:1889the 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.


8. Optional: "Forked from #N" badge

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.


9. Tests / verification

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:

direnv exec . ./scripts/with-compile-db.sh cabal build exe:App

(timeout ~7 min the first time; incremental after that). Then a manual smoke test:

  1. Create a prompt-PR task via the Tasks page (existing flow). Wait for it to produce a compare branch and a draft PR.
  2. Return to the Tasks page → confirm the Fork button is enabled on that row.
  3. Click Fork → expect redirect to a new draft PR with title Fork: <orig title>, opened off the original's compare branch.
  4. In psql, verify:
    SELECT id, title, compare_branch, forked_from_job_id
    FROM prompt_pull_request_jobs
    ORDER BY created_at DESC
    LIMIT 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.
  5. 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.

10. CLAUDE.md compliance

/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:

gitnexus_impact({ target: "<symbolName>", direction: "upstream" })

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.


11. Out of scope for this PR

  • 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".

12. Smallest-possible PR boundary

If reviewing scope feels heavy, the minimum viable PR is:

  1. Schema column + migration.
  2. forkPromptPullRequestJob builder.
  3. ForkPromptPullRequestRunAction route + handler.
  4. The Fork button in renderPromptPullRequestJobsPanelBody.

Everything else (the badge, the "fork & edit" flow, the API variant) is a follow-up. Four files, ~120 lines net. That's the PR.


Quick reference — file map

FileChange
Application/Schema.sql:799Append forked_from_job_id UUID DEFAULT NULL + index
Application/Migration/<ts>-add-prompt-forked-from-job.sqlNew migration
Application/PromptPullRequests.hs:8-28Export forkPromptPullRequestJob
Application/PromptPullRequests.hs (end of module)Add forkPromptPullRequestJob body
Web/Types.hs:514 (after CreatePromptPullRequestAction)Add ForkPromptPullRequestRunAction
Web/Controller/PullRequests.hs:210 (after CreatePromptPullRequestAction)Add handler
Web/View/Repositories/Show.hs:1889 (renderPromptPullRequestJobsPanelBody)Add Fork button per row
build/Generated/ActualTypes/PromptPullRequestJob.hsRegenerated automatically

Total: 5 source files + 1 generated. No changes to runner code, isolated execution, or the graph layer.