Skip to content

Support precise Codex session forking#447

Open
pppobear wants to merge 27 commits intotiann:mainfrom
pppobear:hapi-feature-codex-fork
Open

Support precise Codex session forking#447
pppobear wants to merge 27 commits intotiann:mainfrom
pppobear:hapi-feature-codex-fork

Conversation

@pppobear
Copy link
Copy Markdown
Contributor

@pppobear pppobear commented Apr 13, 2026

Summary

  • Add end-to-end Codex session forking across the CLI runner, hub RPC/REST routes, and web session controls.
  • Persist raw Codex history and message sequence metadata so forks can start from the current session, before a user message, or from an assistant response.
  • Clone and move visible message history plus raw Codex history through fork and session merge/dedup paths, while preserving session metadata/title, model, permission mode, collaboration mode, and model reasoning effort.
  • Harden the fork UX and error handling: expose fork actions only for Codex sessions, return a conflict for unavailable forks, keep feedback on the source session, and suppress bootstrap ready notifications for forked sessions.
  • Fix review feedback so the user-message "Fork before here" action actually cuts before the selected user turn, covers that control in the web test, only removes the consumed fork-history file instead of its parent directory, supports chained precise forks from already-forked sessions, keeps raw history attached after duplicate-session merges, preserves moved/cloned raw rows when item ids collide, and remaps target raw message seqs even when the source has no raw rows.

Verification

  • bun typecheck
  • cd hub && bun test src/sync/sessionModel.test.ts src/web/routes/sessions.test.ts src/store/codexHistoryStore.test.ts src/sync/sessionMetadata.test.ts (67 pass)
  • cd web && bunx vitest run src/components/AssistantChat/messages/AssistantMessage.test.tsx src/components/AssistantChat/messages/UserMessage.test.tsx (4 pass)
  • cd cli && bunx vitest run src/commands/codex.test.ts src/codex/codexRemoteLauncher.test.ts src/codex/codexLocal.test.ts src/codex/utils/appServerConfig.test.ts (41 pass)
  • git diff --check

Notes

  • The branch was rebased on main and PR conflicts were resolved.
  • Direct bun test for the web/cli Vitest files is not the right runner here because those tests use Vitest APIs such as vi.hoisted; the targeted web/cli checks above were run with Vitest.

@pppobear pppobear force-pushed the hapi-feature-codex-fork branch from 9cc0f2f to 204f3fd Compare May 3, 2026 03:51
@pppobear pppobear changed the title [codex] support codex fork across cli hub and web Support Codex session forking May 3, 2026
pppobear and others added 21 commits May 3, 2026 22:54
Address review feedback covering correctness, ordering, and UX gaps in
the fork pipeline:

- syncEngine: clone+inherit run only after waitForSessionActive succeeds,
  removing the partial-success leak; clone/inherit failures surface as
  warnings on the success result rather than fork_failed; defer forkToken
  validation so historical fork (which carries the prefix inline) does
  not require codexSessionId; lower historical-fork payload cap to
  512 KiB; add observability log when target session already has rows
  at clone time.
- sessionCache.inheritSessionMetadata: retry on version-mismatch (3x)
  with small backoff so concurrent CLI metadata writes don't drop
  inheritance.
- codexHistoryStore.addItem: compute seq atomically inside INSERT...SELECT
  so concurrent writes can't read the same MAX(seq); throw on corrupt
  raw_item rows; warn (don't drop silently) when INSERT OR IGNORE
  swallows a row.
- messages.cloneSessionMessages: force non-null invoked_at on cloned
  rows so source-side queued messages aren't re-delivered to the fork.
- codexRemoteLauncher: surface thread/fork failures to the user instead
  of silently falling back; export isAbortError; document the synthetic
  hapi-fork-<uuid> contract on thread/resume(history).
- web: ApiClient.forkSession returns { sessionId, warnings? } end to
  end; useForkWithFeedback drops the dead onSuccess callback and the
  redundant sessionId param; failure toast no longer self-navigates;
  Toast gains an 'error' variant; en/zh-CN gain dialog.fork.partialTitle.
- routes/sessions: validate beforeSeq shape (positive integer) and
  propagate warnings from the engine.
- runner+commands/codex: clean up the fork-history temp file in both
  consumer (CLI) and producer (runner crash fallback).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@pppobear pppobear force-pushed the hapi-feature-codex-fork branch from ce4cb59 to 081b68c Compare May 3, 2026 14:59
@pppobear pppobear changed the title Support Codex session forking Support precise Codex session forking May 3, 2026
@pppobear pppobear marked this pull request as ready for review May 3, 2026 15:53
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review mode: initial
Two issues in the new Codex fork path: the user-message fork button is labeled as a fork-before action, but the server maps user cut points through the selected user turn, so it forks after that turn; and the new user-message test still checks the old assistant-only title, so it does not exercise the new control.
Testing: Not run (automation)

{canForkBefore && (
<button
type="button"
title="Fork before here"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] This button says Fork before here, but forkSession() currently treats a user beforeSeq as the selected user turn and then calls getPrefixThroughReplyForUserMessageSeq(...), which includes that turn and its reply (hub/src/sync/syncEngine.ts:585-594). Clicking a user bubble will therefore fork after the clicked turn, not before it.

Suggested fix:

if (record.role === 'user') {
    const previousUserSeq = this.store.messages.getPreviousUserMessageSeq(sessionId, opts.beforeSeq)
    if (!previousUserSeq) {
        return { type: 'error', message: 'No earlier history to fork from', code: 'fork_unavailable' }
    }
    return previousUserSeq
}

} as any
})

it('does not show fork action for user messages with a seq', () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] This test still queries Fork from this response, so it never exercises the new Fork before here control added in UserMessage.tsx:142-149. Coverage is stale and will not catch regressions in the new user-message fork action.

Suggested fix:

expect(screen.getByTitle('Fork before here')).toBeInTheDocument()
screen.getByTitle('Fork before here').click()
expect(onForkBeforeMessage).toHaveBeenCalledWith(7)

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Avoid deleting the fork-history parent directory — the new hidden --fork-history-file path calls rm(dirname(file), { recursive: true }), so a manually supplied path can recursively delete unrelated user data. Evidence cli/src/commands/codex.ts:103.
    Suggested fix:
    options.forkHistory = parsed as ResponseItem[]
    void rm(file, { force: true }).catch(() => undefined)

Questions

  • None.

Summary

  • Review mode: initial
  • Found one major data-loss risk in the new fork-history transfer flag.

Testing

  • Not run (automation)

HAPI Bot

Comment thread cli/src/commands/codex.ts Outdated
throw new Error('--fork-history-file must contain a JSON array')
}
options.forkHistory = parsed as ResponseItem[]
void rm(dirname(file), { recursive: true, force: true }).catch(() => undefined)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] This deletes the parent directory of whatever --fork-history-file points at. The runner-created path is temporary, but the flag is accepted by the public CLI parser too; a manual invocation like --fork-history-file ~/.hapi/history.json would recursively remove ~/.hapi after a successful parse. The runner already tracks forkHistoryDir for cleanup, so the child should only remove the file it consumed.

Suggested fix:

options.forkHistory = parsed as ResponseItem[]
void rm(file, { force: true }).catch(() => undefined)

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Clone raw Codex history with the visible fork history — cloneSessionMessages copies the transcript into the new forked session, but the new codex_history_items rows stay on the source session only. Later, precise forking from the forked session's inherited messages calls getPrefixThroughReplyForUserMessageSeq for the forked session and returns null, so the feature regresses after one fork even though the visible transcript is present. Evidence hub/src/sync/syncEngine.ts:701.
    Suggested fix:
    let historicalForkUserMessageSeq: number | undefined
    // when resolving beforeSeq:
    historicalForkUserMessageSeq = userMessageSeq
    
    const cloneResult = this.store.messages.cloneSessionMessages(sessionId, spawnResult.sessionId, cloneBeforeSeq)
    if (historicalForkUserMessageSeq !== undefined) {
        this.store.codexHistory.clonePrefixThroughReplyForUserMessageSeq(
            sessionId,
            spawnResult.sessionId,
            historicalForkUserMessageSeq
        )
    } else {
        this.store.codexHistory.cloneSessionHistory(sessionId, spawnResult.sessionId)
    }

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The previous data-loss cleanup issue is fixed, but precise fork metadata is not preserved when a session is forked, so chained precise forks lose access to inherited raw history.

Testing

  • Not run (automation). Suggested coverage: fork a Codex session, then call forkSession(forkedSessionId, 'default', { beforeSeq }) against a cloned message and assert it succeeds with a raw history prefix.

HAPI Bot

// warning back to the caller (and a log line) rather than reporting fork_failed.
const warnings: string[] = []
try {
// Observability for the residual ordering race: codex sessions don't normally write to
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] cloneSessionMessages copies the visible transcript into the forked session, but the new codex_history_items rows remain attached to the source session. A later precise fork from the forked session's inherited transcript reaches getPrefixThroughReplyForUserMessageSeq(forkedSessionId, ...) and gets null, because the raw history prefix was never cloned alongside the messages.

Suggested fix:

let historicalForkUserMessageSeq: number | undefined
// when resolving beforeSeq:
historicalForkUserMessageSeq = userMessageSeq

const cloneResult = this.store.messages.cloneSessionMessages(sessionId, spawnResult.sessionId, cloneBeforeSeq)
if (historicalForkUserMessageSeq !== undefined) {
    this.store.codexHistory.clonePrefixThroughReplyForUserMessageSeq(
        sessionId,
        spawnResult.sessionId,
        historicalForkUserMessageSeq
    )
} else {
    this.store.codexHistory.cloneSessionHistory(sessionId, spawnResult.sessionId)
}

Add the same raw-history move/clone handling to session dedup/merge paths so resumed duplicate sessions keep precise-fork support too.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Move raw Codex history when duplicate sessions are merged — mergeSessionData still only moves rows from messages, while this PR adds codex_history_items as the source of truth for precise forks. When dedup/resume merges a Codex duplicate, the visible transcript moves to the canonical session but the raw history rows stay on the old session; if deleteOldSession is true they are deleted by cascade, and if it is false they remain under the hidden duplicate. A later precise fork from the merged transcript then calls getPrefixThroughReplyForUserMessageSeq on the canonical session and fails with missing history. Evidence hub/src/store/codexHistoryStore.ts:131; related merge path hub/src/sync/sessionCache.ts:561.
    Suggested fix:
    // hub/src/store/codexHistoryStore.ts
    moveSessionHistory(fromSessionId: string, toSessionId: string, messageSeqOffset: number): number {
        const result = this.db.prepare(`
            UPDATE codex_history_items
            SET session_id = ?,
                seq = seq + ?,
                message_seq = CASE
                    WHEN message_seq IS NULL THEN NULL
                    ELSE message_seq + ?
                END
            WHERE session_id = ?
        `).run(toSessionId, messageSeqOffset, messageSeqOffset, fromSessionId)
        return result.changes
    }
    
    // hub/src/sync/sessionCache.ts, immediately after mergeSessionMessages(...)
    this.store.codexHistory.moveSessionHistory(
        oldSessionId,
        newSessionId,
        movedMessages.oldMaxSeq
    )

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The previous fork-to-fork raw-history cloning issue is fixed, but resume/dedup merge paths can still detach or delete the new raw Codex history table from the visible transcript.

Testing

  • Not run (automation). Suggested coverage: create two duplicate Codex sessions with the same codexSessionId, add messages plus codex_history_items to the old session, run deduplicateByAgentSessionId, then assert the canonical session can still resolve getPrefixThroughReplyForUserMessageSeq for the moved user message.

HAPI Bot

return parsePrefixRows(rows, sessionId)
}

cloneSessionHistory(fromSessionId: string, toSessionId: string, messageSeqOffset: number): number {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] mergeSessionData still only moves rows from messages, while this PR adds codex_history_items as the source of truth for precise forks. When dedup/resume merges a Codex duplicate, the visible transcript moves to the canonical session but the raw history rows stay on the old session; if deleteOldSession is true they are deleted by cascade, and if it is false they remain under the hidden duplicate. A later precise fork from the merged transcript then calls getPrefixThroughReplyForUserMessageSeq on the canonical session and fails with missing history.

Suggested fix:

// hub/src/store/codexHistoryStore.ts
moveSessionHistory(fromSessionId: string, toSessionId: string, messageSeqOffset: number): number {
    const result = this.db.prepare(`
        UPDATE codex_history_items
        SET session_id = ?,
            seq = seq + ?,
            message_seq = CASE
                WHEN message_seq IS NULL THEN NULL
                ELSE message_seq + ?
            END
        WHERE session_id = ?
    `).run(toSessionId, messageSeqOffset, messageSeqOffset, fromSessionId)
    return result.changes
}

// hub/src/sync/sessionCache.ts, immediately after mergeSessionMessages(...)
this.store.codexHistory.moveSessionHistory(
    oldSessionId,
    newSessionId,
    movedMessages.oldMaxSeq
)

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Do not drop moved raw history on item-id collisions — moveSessionHistory moves source rows with UPDATE OR IGNORE, but HAPI-generated Codex user items are named from per-session message seqs (hapi-user-${messageSeq}). Two duplicate sessions can both contain hapi-user-1; after the target rows are shifted, the source row is ignored by the (session_id, item_id) unique index while the source visible messages remain in the merged transcript. Precise fork from those moved source turns then has no raw history to resolve. Evidence hub/src/store/codexHistoryStore.ts:208; unique index hub/src/store/index.ts:243; generated id cli/src/codex/codexRemoteLauncher.ts:1044.
    Suggested fix:

    const sourceRows = this.db.prepare(`
        SELECT id, codex_thread_id, turn_id, item_id, item_kind, message_seq, raw_item, seq, created_at
        FROM codex_history_items
        WHERE session_id = ?
        ORDER BY seq ASC
    `).all(fromSessionId) as Array<CodexHistoryRow & { id: string }>
    
    const existingItemIds = new Set(
        (this.db.prepare('SELECT item_id FROM codex_history_items WHERE session_id = ?').all(toSessionId) as Array<{ item_id: string }>)
            .map((row) => row.item_id)
    )
    
    for (const row of sourceRows) {
        const itemId = existingItemIds.has(row.item_id) ? `${row.item_id}:moved:${row.id}` : row.item_id
        insert.run({
            id: randomUUID(),
            session_id: toSessionId,
            codex_thread_id: row.codex_thread_id,
            turn_id: row.turn_id,
            item_id: itemId,
            item_kind: row.item_kind,
            message_seq: row.message_seq,
            raw_item: row.raw_item,
            seq: row.seq,
            created_at: row.created_at
        })
        existingItemIds.add(itemId)
    }
    this.db.prepare('DELETE FROM codex_history_items WHERE session_id = ?').run(fromSessionId)
  • [Major] Shift target raw message seqs even when the source has no raw rows — mergeSessionMessages shifts target message seqs whenever both sessions have visible messages, but moveSessionHistory only shifts target codex_history_items.message_seq inside if (sourceMaxSeq > 0). If the source duplicate has visible messages but no new raw-history rows and the target does have raw history, target messages move from seq 1 to oldMaxSeq + 1 while their raw history still points at 1, so a later precise fork from the shifted target turn cannot find the raw user item. Evidence hub/src/store/codexHistoryStore.ts:195; offset call hub/src/sync/sessionCache.ts:562.
    Suggested fix:

    if (targetMessageSeqOffset !== 0) {
        this.db.prepare(`
            UPDATE codex_history_items
            SET message_seq = CASE
                WHEN message_seq IS NULL THEN NULL
                ELSE message_seq + ?
            END
            WHERE session_id = ?
        `).run(targetMessageSeqOffset, toSessionId)
    }
    
    if (sourceMaxSeq > 0) {
        this.db.prepare(`
            UPDATE codex_history_items
            SET seq = seq + ?
            WHERE session_id = ?
        `).run(sourceMaxSeq, toSessionId)
    }

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The prior bot finding was addressed for the simple source-history move case, but duplicate-session merges can still detach raw Codex history from the visible transcript in collision and old-source/new-target cases.

Testing

  • Not run (automation). Suggested coverage: merge duplicate Codex sessions where both sides contain hapi-user-1 history rows, and merge a source with visible messages/no raw history into a target with raw history; assert getPrefixThroughReplyForUserMessageSeq works for the merged source and shifted target turns.

HAPI Bot

Comment thread hub/src/store/codexHistoryStore.ts Outdated
}

const result = this.db.prepare(`
UPDATE OR IGNORE codex_history_items
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] moveSessionHistory moves source rows with UPDATE OR IGNORE, but HAPI-generated Codex user items are named from per-session message seqs (hapi-user-${messageSeq}). Two duplicate sessions can both contain hapi-user-1; after the target rows are shifted, the source row is ignored by the (session_id, item_id) unique index while the source visible messages remain in the merged transcript. Precise fork from those moved source turns then has no raw history to resolve.

Suggested fix:

const sourceRows = this.db.prepare(`
    SELECT id, codex_thread_id, turn_id, item_id, item_kind, message_seq, raw_item, seq, created_at
    FROM codex_history_items
    WHERE session_id = ?
    ORDER BY seq ASC
`).all(fromSessionId) as Array<CodexHistoryRow & { id: string }>

const existingItemIds = new Set(
    (this.db.prepare('SELECT item_id FROM codex_history_items WHERE session_id = ?').all(toSessionId) as Array<{ item_id: string }>)
        .map((row) => row.item_id)
)

for (const row of sourceRows) {
    const itemId = existingItemIds.has(row.item_id) ? `${row.item_id}:moved:${row.id}` : row.item_id
    insert.run({
        id: randomUUID(),
        session_id: toSessionId,
        codex_thread_id: row.codex_thread_id,
        turn_id: row.turn_id,
        item_id: itemId,
        item_kind: row.item_kind,
        message_seq: row.message_seq,
        raw_item: row.raw_item,
        seq: row.seq,
        created_at: row.created_at
    })
    existingItemIds.add(itemId)
}
this.db.prepare('DELETE FROM codex_history_items WHERE session_id = ?').run(fromSessionId)

Comment thread hub/src/store/codexHistoryStore.ts Outdated
try {
this.db.exec('BEGIN')

if (sourceMaxSeq > 0) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] mergeSessionMessages shifts target message seqs whenever both sessions have visible messages, but this branch only shifts target codex_history_items.message_seq when the source also has raw-history rows. If the source duplicate has visible messages but no new raw-history rows and the target does have raw history, target messages move from seq 1 to oldMaxSeq + 1 while their raw history still points at 1, so a later precise fork from the shifted target turn cannot find the raw user item.

Suggested fix:

if (targetMessageSeqOffset !== 0) {
    this.db.prepare(`
        UPDATE codex_history_items
        SET message_seq = CASE
            WHEN message_seq IS NULL THEN NULL
            ELSE message_seq + ?
        END
        WHERE session_id = ?
    `).run(targetMessageSeqOffset, toSessionId)
}

if (sourceMaxSeq > 0) {
    this.db.prepare(`
        UPDATE codex_history_items
        SET seq = seq + ?
        WHERE session_id = ?
    `).run(sourceMaxSeq, toSessionId)
}

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • No blocking, major, minor, or nit findings.

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • No issues found in the latest diff. Prior bot findings around moved/cloned raw history collisions and target message-seq remapping appear addressed by the latest codexHistoryStore changes and tests. Residual risk: the historical fork path still depends on Codex app-server thread/resume(history) behavior.

Testing

  • Not run (automation).

HAPI Bot

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant