fix(goal): harden blocked-goal hold semantics#379
Conversation
|
CI triage disposition: the Windows |
64193b8 to
264d0cd
Compare
Rebased onto
|
|
Worktree triage note (stale-worktree sweep, 2026-07-24) The original worktree for this branch ( The 7 committed subjects on that local branch already exist on this PR under rebased SHAs, but the uncommitted delta does not. To avoid losing it I committed it as
It is not built or tested (cold codex-rs build), and no competing PR was opened. Whoever drives #379 should cherry-pick or discard deliberately. |
|
Follow-up: the two |
264d0cd to
ddcdf33
Compare
Rebased onto
|
The blocked-goal hold work started mentioning lease_id in the update_thread_schedule SET list and switched the claim update to fetch_one. Both interact badly with the thread_schedules_ignore_legacy_live_owner_claim trigger, which is a BEFORE UPDATE OF lease_id trigger and therefore fires on the mere mention of the column, silently dropping the whole statement via RAISE(IGNORE). - Only include the lease columns when transitioning to a non-active status, so active edits no longer get discarded while a legacy lease is held and a local session is live. - Treat a dropped claim update as an unclaimed schedule instead of panicking on a missing row, and roll back the speculative expired-run reap so the live owner keeps its runs.
origin/main landed 0061_background_agent_lifecycle_receipts.sql, so this branch's 0061_thread_schedule_run_goal_id.sql collided on the sqlx migration version. sqlx performs no duplicate-version check, so the clash compiles cleanly and only fails at runtime on every fresh database. Renumber to the next free slot.
Keeps the legacy-schema baseline exactly one migration below the renumbered 0062 goal_id migration.
… drop dead pause API Round-2 review follow-ups on the goal-hold blocked-semantics change. Test coverage: `core/tests/invalid_image_lifecycle.rs` was gated behind `#![cfg(not(debug_assertions))]`, so it compiled to an empty test binary in every CI lane (Bazel builds fastbuild; "Verify release build" only builds). The headline fix in `session/turn.rs` therefore had no executed coverage. Two real causes, both fixed instead of hidden: * A full turn future overflows the 2 MiB default `#[tokio::test]` worker stack in debug builds. The test now runs on an explicitly sized runtime (16 MiB workers), mirroring what the shipped binary already does via `TOKIO_WORKER_STACK_SIZE_BYTES` in codex-arg0. * The `CodexErr::InvalidImageRequest` arm called `error_or_panic`, which aborts the turn task in every debug build and made the recovery branch unreachable outside release builds. An upstream 400 for a bad image is an externally triggered condition, not a broken local invariant, so it is now logged with `error!` (identical to the previous release behaviour). Dead API: `complete_thread_schedule_run_and_pause` and `fail_thread_schedule_run_and_pause` had no production callers, and the `pause_schedule: true` branch of `finish_thread_schedule_run_once` was unreachable. Both wrappers and the `pause_schedule` field are removed; a goal hold is now the only thing that pauses a schedule at finish time. The three tests that used them were rewritten onto the real `*_thread_schedule_run_for_goal` path with a blocked goal, which also proves the hold wins over a caller-requested rearm. Also: * `claim_thread_schedule_once` / `finish_thread_schedule_run_once` open the goals.db probe transaction with a deferred `begin()` instead of `BEGIN IMMEDIATE`; the probe is a read-only `SELECT EXISTS` that is always rolled back, so it no longer holds a goals.db write lock across the state.db commit. * Removed the guaranteed no-op re-insert loop in `observe_active_thread_goal_blocker_once`. * Downgraded the `unreachable!()` in `finish_thread_schedule_run_once` to a returned error so invariant drift cannot panic out of a state-store write. * Documented the flapping-HTTP-status tradeoff on `fingerprint_with_http_status`.
ddcdf33 to
15bb302
Compare
Task: 78b9a1ee-a33b-4924-8c54-4ace858f82a4
Architecture
Regression evidence
git diff --check: pass.codex-state: 358/358 passed.goal_extension_backend: 61/61 passed.thread_schedule_runtime: 54/54 passed, 462 skipped.invalid_image_lifecycle: 1/1 passed.just fmtandjust fmt-check: pass.while_let_loop,byte_char_slices).just bazel-lock-updateandjust bazel-lock-check: pass.aae8f81a: virtual merge clean; only overlapping path wascodex-rs/Cargo.lock, whose effective delta is onesqlxdependency entry.cargo metadata --lockedand Bazel lock update/check passed on the hypothetical merged tree.Accepted risks and toolchain caveats
No merge, publish, install, restart, deploy, migration, or rollout was performed.
Round 2 — review follow-ups (head
15bb302, rebased ontoorigin/main@b71a05e)Blocker 1 — the invalid-image test never executed in CI (fixed)
codex-rs/core/tests/invalid_image_lifecycle.rsopened with#![cfg(not(debug_assertions))], so it compiled to an empty test binary in everylane that exists: Bazel builds
--compilation_mode=fastbuild(debug assertions on) and"Verify release build" only builds, never tests. The headline fix
(
emit_turn_error_lifecycle_with_protocol_errorincore/src/session/turn.rs) thereforehad zero executed coverage, and the previous "Release
invalid_image_lifecycle: 1/1passed" line in this description was a local, unverifiable claim. That line is retracted.
The gate was investigated rather than worked around; there were two real causes, and
both are fixed at the source:
2 MiB default
#[tokio::test]worker stack in a debug build — the worker aborts withfatal runtime error: stack overflow. Reproduced locally at the previous head. Theshipped binary already runs turns on 16 MiB worker stacks
(
TOKIO_WORKER_STACK_SIZE_BYTESincodex-arg0), so the test now builds its ownruntime with the same sizing, matching the existing repo pattern
(
core/tests/suite/rmcp_client.rs,app-server/src/in_process.rs,tui/src/app/tests.rs).This is env-independent: it does not rely on a CI lane exporting
RUST_MIN_STACK.error_or_panicon a non-invariant path. TheCodexErr::InvalidImageRequestarmcalled
crate::util::error_or_panic, which panics in every debug build and abortsthe turn task before the recovery/emit code runs. An upstream 400 for a bad image is an
externally triggered condition, not a broken local invariant, and the branch below it is
the designed handling for exactly that. It now logs with
error!— byte-identical towhat
error_or_panicalready did in release, so release behaviour is unchanged.Proof both were required, at the new head: with the stack fix but
error_or_panicrestored, the test fails
timeout waiting for event(the turn task panicked); with bothfixes it passes in a plain debug build.
core/tests/suite/view_image.rs::replaces_invalid_local_image_after_bad_requestkeeps itsown
cfg(not(debug_assertions))gate: it is network-gated and lives in thecodex-core::allbinary, so ungating it could not be proven in this PR's CI. Left aspre-existing.
Blocker 2 — dead
_and_pauseAPI removedcomplete_thread_schedule_run_and_pauseandfail_thread_schedule_run_and_pausewere newpublic
ScheduleStoremethods with no production callers, and thepause_schedule: truebranch of
finish_thread_schedule_run_oncewas unreachable outsidemod tests. Productionpauses a schedule only through a goal hold, via the
*_thread_schedule_run_for_goalentrypoints and the
pause_for_goal_holdprobe.Both wrappers and the
pause_schedulefield onFinishScheduleRunare removed;pause_scheduleis now simplypause_for_goal_hold. The three tests that used the deadAPI were rewritten onto the real goal-hold path with a
Blockedgoal, which is strictlybetter coverage —
completed_schedule_run_for_held_goal_pauses_without_rearmingnow alsopasses a non-
Nonenext_run_at, proving the hold beats a caller-requested rearm.Quality nits (all three applied)
claim_thread_schedule_onceandfinish_thread_schedule_run_oncenow open the goals.dbprobe with a deferred
begin()instead ofbegin_with("BEGIN IMMEDIATE"). The probe is aread-only
SELECT EXISTSthat is always rolled back; the write lock was held across thestate.db commit for no benefit. Lock order is consistently state -> goals at both sites,
so nothing needed the immediate lock to preserve ordering.
observe_active_thread_goal_blocker_once:it ran exactly in the
preserves_current_streakbranch where theobserved_turnsrowswere never deleted, so every
INSERThitON CONFLICT DO NOTHING. The now-unusedcreated_at_mscolumn was dropped from the probingSELECT.unreachable!()infinish_thread_schedule_run_onceis downgraded to ananyhow::bail!, so drift in thegoal_tx/goal_hold_can_pauseinvariant cannot panicout of a state-store write path.
Documented tradeoff (behaviour intentionally unchanged)
Blocking requires 3 consecutive turns with an identical fingerprint, and fingerprints embed
the HTTP status, so a flapping upstream (502/503/502) resets the streak. This matches the
model-facing contract at
ext/goal/src/spec.rs:298and is left as-is; a doc comment onfingerprint_with_http_statusnow records it as a known tradeoff rather than an accident.Rebase and migration numbering
Rebased onto
origin/main@b71a05e(picks up #393 and #406); clean, no conflicts. Themerged-tree migration-prefix check was re-run: no duplicate numeric prefixes in any of the
four migration dirs,
state/migrationsis contiguous0001..0062(62 files) with0062uniquely claimed,
state/goals_migrationscontiguous0001..0009(9 files), and theruntime.rsbaseline stays at 61.Round-2 local verification (new head)
cargo fmt -- --config imports_granularity=Item --check: clean.cargo clippy --tests --locked -p codex-state -p codex-core -p codex-goal-extension -p codex-app-server -p codex-extension-api -- -D warnings -A clippy::byte_char_slices -A clippy::while_let_loop: clean.cargo nextest run -p codex-state -p codex-goal-extension: 456/456 passed.cargo nextest run -p codex-core -E 'not binary_id(codex-core::all)'withRUST_MIN_STACK=8388608(the value CI exports): 2193/2201 passed. The 8 failures are local-workstation artifacts unrelated to this diff —config_loader/git_info/realtime_contextpick up a stray/tmp/.codewithand the surrounding git layout, andshell_snapshotneeds a real login shell.Round-2 CI evidence (head
15bb302)Bazel / release lanes on Linux — all four green on this head:
Every other required check is SUCCESS too (
CI results (required): SUCCESS). The onlynon-success entries are two conditionally skipped jobs (
Argument comment lint package,Bazel test on windows-latest ... (native main)).Blacksmith testbox — run on the new head, with
-p codex-coreand-p codex-extension-apiadded so the changedcodex-corecode is executed, not merelycompiled by
clippy --tests:15bb302):3897 tests, 3895 passed, 2 failed. The invalid-image test appears by name as a PASS:
PASS [0.312s] (2715/3897) codex-core::invalid_image_lifecycle invalid_image_run_turn_preserves_bad_request_and_stable_fingerprint.The 2 failures were
codex-core shell_snapshot::tests::try_new_creates_and_deletes_snapshot_fileand
try_new_uses_distinct_generation_paths, bothsnapshot should be created: "validation_failed".main@23a664c,same runner image, same two tests):
2 tests run: 0 passed, 2 failed, identicalvalidation_failedpanics. Those two are therefore pre-existing and environmental(the runner's login shell fails snapshot validation) and are not caused by this PR.
15bb302,same command with the two proven-pre-existing tests excluded): conclusion = success,
3895 tests run: 3895 passed (1 slow), 27 skipped, again includingPASS codex-core::invalid_image_lifecycle invalid_image_run_turn_preserves_bad_request_and_stable_fingerprintplus the rewritten schedule tests
(
completed_schedule_run_for_held_goal_pauses_without_rearming,explicit_expiry_terminalizes_active_run_before_late_completion,terminal_schedule_status_rejects_late_finalizers) and the two new fingerprint unit tests.Exclusions kept, and why:
binary_id(codex-core::all)— already red onmainin this runner image(
suite::rmcp_client/suite::shell_snapshot/ sandbox failures, see run 30121948648 onmain).Not this PR's to fix. Note the Bazel
ubuntu-24.04lanes do run that binary and are green there.test(background_agent_live)— live-network suite, unchanged from the previously agreed base command.test(shell_snapshot::tests::try_new_)— added only to the confirmation run, after themainbaseline above proved both cases fail identically without this diff.