Support precise Codex session forking#447
Conversation
9cc0f2f to
204f3fd
Compare
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>
ce4cb59 to
081b68c
Compare
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
[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', () => { |
There was a problem hiding this comment.
[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)There was a problem hiding this comment.
Findings
- [Major] Avoid deleting the fork-history parent directory — the new hidden
--fork-history-filepath callsrm(dirname(file), { recursive: true }), so a manually supplied path can recursively delete unrelated user data. Evidencecli/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
| 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) |
There was a problem hiding this comment.
[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)There was a problem hiding this comment.
Findings
- [Major] Clone raw Codex history with the visible fork history —
cloneSessionMessagescopies the transcript into the new forked session, but the newcodex_history_itemsrows stay on the source session only. Later, precise forking from the forked session's inherited messages callsgetPrefixThroughReplyForUserMessageSeqfor the forked session and returnsnull, so the feature regresses after one fork even though the visible transcript is present. Evidencehub/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 |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
Findings
- [Major] Move raw Codex history when duplicate sessions are merged —
mergeSessionDatastill only moves rows frommessages, while this PR addscodex_history_itemsas 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; ifdeleteOldSessionis 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 callsgetPrefixThroughReplyForUserMessageSeqon the canonical session and fails with missing history. Evidencehub/src/store/codexHistoryStore.ts:131; related merge pathhub/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 pluscodex_history_itemsto the old session, rundeduplicateByAgentSessionId, then assert the canonical session can still resolvegetPrefixThroughReplyForUserMessageSeqfor the moved user message.
HAPI Bot
| return parsePrefixRows(rows, sessionId) | ||
| } | ||
|
|
||
| cloneSessionHistory(fromSessionId: string, toSessionId: string, messageSeqOffset: number): number { |
There was a problem hiding this comment.
[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
)There was a problem hiding this comment.
Findings
-
[Major] Do not drop moved raw history on item-id collisions —
moveSessionHistorymoves source rows withUPDATE OR IGNORE, but HAPI-generated Codex user items are named from per-session message seqs (hapi-user-${messageSeq}). Two duplicate sessions can both containhapi-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. Evidencehub/src/store/codexHistoryStore.ts:208; unique indexhub/src/store/index.ts:243; generated idcli/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 —
mergeSessionMessagesshifts target message seqs whenever both sessions have visible messages, butmoveSessionHistoryonly shifts targetcodex_history_items.message_seqinsideif (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 seq1tooldMaxSeq + 1while their raw history still points at1, so a later precise fork from the shifted target turn cannot find the raw user item. Evidencehub/src/store/codexHistoryStore.ts:195; offset callhub/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-1history rows, and merge a source with visible messages/no raw history into a target with raw history; assertgetPrefixThroughReplyForUserMessageSeqworks for the merged source and shifted target turns.
HAPI Bot
| } | ||
|
|
||
| const result = this.db.prepare(` | ||
| UPDATE OR IGNORE codex_history_items |
There was a problem hiding this comment.
[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)| try { | ||
| this.db.exec('BEGIN') | ||
|
|
||
| if (sourceMaxSeq > 0) { |
There was a problem hiding this comment.
[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)
}There was a problem hiding this comment.
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
codexHistoryStorechanges and tests. Residual risk: the historical fork path still depends on Codex app-serverthread/resume(history)behavior.
Testing
- Not run (automation).
HAPI Bot
Summary
Verification
bun typecheckcd 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 --checkNotes
mainand PR conflicts were resolved.bun testfor the web/cli Vitest files is not the right runner here because those tests use Vitest APIs such asvi.hoisted; the targeted web/cli checks above were run with Vitest.