Skip to content

fix(backend): don't flag healthy sessions as orphaned after upgrading - #3524

Open
nova28 wants to merge 4 commits into
kdlbs:mainfrom
nova28:feature/preserve-pre-upgrade-i2k
Open

fix(backend): don't flag healthy sessions as orphaned after upgrading#3524
nova28 wants to merge 4 commits into
kdlbs:mainfrom
nova28:feature/preserve-pre-upgrade-i2k

Conversation

@nova28

@nova28 nova28 commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Tip

PR walkthrough: Open the visual walkthrough

Before PR #3362, a successful dynamic launch never durably advanced past the starting route state (there was no MarkActive), so on the first backend restart after upgrading to that PR, reconcileOrphanedDynamicStartingRoutes misread every pre-existing healthy IDLE dynamic route as orphaned and flipped it to action_required — a false-positive Retry banner on otherwise-healthy Office sessions. This adds a one-time backfill so those legacy routes are marked active before the sweep ever sees them.

Today: On the first restart after upgrading past PR #3362, every pre-existing healthy IDLE dynamic-routed Office session gets a spurious action_required Retry banner, because the orphan sweep can't distinguish "never marked active because the marking mechanism didn't exist yet" from "genuinely orphaned mid-launch."
After this: A one-time migration backfills active onto legacy starting+IDLE routes before the orphan sweep runs, so the upgrade is silent for healthy sessions; the sweep still catches genuine post-upgrade orphans.
Who hits this: Any existing install with in-flight Office dynamic-routed sessions at the moment it upgrades past PR #3362 — a one-time event on first restart, not a recurring issue.
Scope: Standalone follow-up from PR #3362 review (thread r3931855722, dynamic_policy_recovery.go:106), deliberately deferred out of that PR's scope. No sibling PRs.
Not here: The sweep's IDLE-is-orphanable classification itself is unchanged and correct for real post-upgrade orphans — only pre-existing legacy routes are backfilled. Also unchanged: Engine.MarkActive/MarkActionRequired semantics, the Retry/Try-next recovery UI, and the feature flag default.

Also fixes a second, independent defect found while investigating: reconcileOrphanedDynamicStartingRoutes only guarded on profileExecutionResolver == nil, but that resolver is always constructed regardless of the feature flag — so the sweep ran (and could write action_required) even with dynamic routing disabled, which is every shipped profile's default. The guard now also checks !resolver.Enabled(), matching the sibling startDynamicPolicyRecovery guard.

Validation

  • go build ./...
  • go run ./cmd/sqlguard ./internal
  • go test ./internal/task/repository/sqlite/... -run TestBackfillLegacyActiveDynamicRoutes -count=1 -v — 4/4 PASS
  • go test ./internal/orchestrator/... -run TestReconcileOrphanedDynamicStartingRoutes -count=1 -v — all PASS (in-flight STARTING/IDLE sweep, disabled-flag skip, ordering before general startup reconciliation, 6 non-orphan-state skips)
  • go test ./internal/persistence/storeconformance/... -run TestPreviousStableUpgrade_BackfillsLegacyActiveDynamicRoutes -count=1 -v — sqlite PASS, pgx self-skips locally (no KANDEV_TEST_POSTGRES_DSN); the pgx variant runs in CI's PG16 persistence job (.github/workflows/backend-tests.yml, full-package run, no -run filter). Note: the PG18 job's -run anchor does not match this test name, so PG18 does not additionally exercise it — PG16 does, and that job is unmodified by this PR.
  • go test ./internal/orchestrator/... ./internal/task/repository/sqlite/... ./internal/persistence/storeconformance/... (full packages, post-rebase) — all green
  • make typecheck, make lint (golangci-lint run ./... → 0 issues), make lint-format, cd apps/web && pnpm run i18n:ratchet — all green
  • E2E: not required — diff is 7 files, all under apps/backend/internal/, none under apps/web/
  • Rebased onto current main (401947f); no conflicts, diff scope unchanged (7 files, 506 insertions / 2 deletions)

Possible Improvements

Low risk: the backfill runs once (gated on a schema marker column, following the existing worktree_ownership_migration.go / git_snapshot_environment_migration.go pattern), is scoped to IDLE routes still in starting state, and cannot make anything worse than the pre-#3362 status quo it preserves. It genuinely cannot distinguish a healthy legacy route from a true pre-upgrade orphan (the two are byte-identical on a legacy DB) — this was raised and rejected during review because the alternative (no backfill) false-positives on the healthy majority instead of the stranded minority, and the session is IDLE so the next prompt claims a new generation regardless.

Checklist

  • If I do not have repository write access and this is a large architectural change, I discussed the direction in a linked issue before opening this PR.
  • This PR contains one logical change; unrelated work is split into separate PRs.
  • I have performed a self-review of my code.
  • I have manually tested my changes and they work as expected.
  • My changes have tests that cover the new functionality and edge cases.
  • If my change touches UI files (apps/web/), I have added or updated Playwright e2e tests in apps/web/e2e/ and verified them with make test-e2e.
  • I checked whether this affects public docs in docs/public/** and updated them or noted why no docs change is needed.

Related: #3362 (comment)

Review in cubic

Preview Environment

URL https://kandev-pr-3524-bwo7.sprites.app
Commit 6bd3978
Agent Mock agent

Updates automatically on each push. Destroyed when the PR is closed.

…liation

Startup reconciliation flipped every pre-kdlbs#3362 dynamic route to
action_required on first restart after upgrade: those routes are durably
"starting" only because the "active" status didn't exist yet, not because
anything is stuck. A one-time migration backfills legacy starting+IDLE
routes to active behind a self-detecting schema marker, so the sweep's
IDLE branch still catches genuine post-upgrade orphans.

Also close a second guard gap: reconcileOrphanedDynamicStartingRoutes only
checked profileExecutionResolver == nil, but the resolver is always
constructed regardless of the feature flag, so the sweep ran (and wrote
action_required) even with dynamic routing disabled - the default in
every shipped profile. It now also checks Enabled(), matching
startDynamicPolicyRecovery.

Follow-up from PR kdlbs#3362 review (thread r3931855722).

Pre-commit hook skipped with explicit user authorization: golangci-lint,
gofmt, and sqlguard were all independently verified clean (0 issues)
outside the hook; the hook itself was repeatedly timing out at its
internal 5-minute budget due to extreme host-wide CPU contention
(load average 220-310+ from many concurrent agent sessions), unrelated
to this change.
…ts projection write

PR kdlbs#3362 review round 1 (F1, F2): the one-time backfill migration lacked the
Postgres advisory-lock serialization its sibling one-time migrations take,
so two racing initializers could abort the required task store on
duplicate-column. It also flipped task_sessions.route_state for any IDLE
session reading "starting", not just the rows it had just backfilled to
"active" — capable of erasing a live action_required Retry banner on a
genuinely broken route. Scope the projection write to the backfilled rows
and add the Postgres lock + in-tx marker re-probe, matching
git_snapshot_environment_migration.go and worktree_ownership_migration.go.
The already-exists branch returned success without committing, so a
failed backfill could leave the marker column present with the
deferred rollback discarding the two backfill UPDATEs, permanently
skipping the repair on every future boot.
@nova28
nova28 temporarily deployed to opencode-review-trusted September 9, 2026 00:07 — with GitHub Actions Inactive
@coderabbitai

coderabbitai Bot commented Sep 9, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: QUIET

Plan: Advanced

Run ID: 518f7473-690e-4a7f-adf7-54884369f0dd

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: QUIET

Plan: Advanced

Run ID: d43a9263-2d8a-493e-9713-4b4339bc584d

📥 Commits

Reviewing files that changed from the base of the PR and between 401947f and 745da64.

📒 Files selected for processing (7)
  • apps/backend/internal/orchestrator/dynamic_policy_recovery.go
  • apps/backend/internal/orchestrator/dynamic_route_recovery_test.go
  • apps/backend/internal/persistence/storeconformance/upgrade_test.go
  • apps/backend/internal/task/repository/sqlite/base_migrations.go
  • apps/backend/internal/task/repository/sqlite/base_schema.go
  • apps/backend/internal/task/repository/sqlite/dynamic_route_legacy_active_migration.go
  • apps/backend/internal/task/repository/sqlite/dynamic_route_legacy_active_migration_test.go

Included review availability: Your plan provides up to 4 included reviews per hour; 2 remain after this review.


📝 Summary

Summary by CodeRabbit

  • Bug Fixes
    • Prevented dynamic route recovery from changing route states when dynamic routing is disabled.
    • Corrected legacy route states during database upgrades, preserving valid active routes for idle sessions.
    • Ensured active-state backfills do not affect genuinely starting sessions or routes requiring action.
  • Tests
    • Added regression coverage for disabled routing, legacy database upgrades, and migration replay behavior across SQLite and PostgreSQL.

Walkthrough

The change prevents orphan recovery from modifying routes when dynamic routing is disabled. It adds a marker-gated migration that converts eligible legacy starting routes to active and updates session projections for SQLite and PostgreSQL.

Changes

Dynamic route recovery

Layer / File(s) Summary
Disabled-routing recovery guard
apps/backend/internal/orchestrator/dynamic_policy_recovery.go, apps/backend/internal/orchestrator/dynamic_route_recovery_test.go
The orphaned-route sweep now skips disabled dynamic routing. The regression test verifies that durable and projected route states remain starting.
Legacy active-route backfill
apps/backend/internal/task/repository/sqlite/base_schema.go, apps/backend/internal/task/repository/sqlite/dynamic_route_legacy_active_migration.go, apps/backend/internal/task/repository/sqlite/base_migrations.go
Initialization detects legacy schemas, serializes PostgreSQL execution, converts eligible starting routes for IDLE sessions to active, updates matching projections, adds a marker column, and aborts on migration errors.
Migration regression coverage
apps/backend/internal/task/repository/sqlite/dynamic_route_legacy_active_migration_test.go, apps/backend/internal/persistence/storeconformance/upgrade_test.go
Tests cover SQLite and PostgreSQL upgrades, projection updates, excluded route states, missing route rows, marker replay, and fresh installations.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~45 minutes

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 745da

The migration and disabled-routing recovery behavior are covered without an identified current-head regression.

Sequence Diagram(s)

sequenceDiagram
  participant Repository
  participant Database
  participant PostgreSQL
  Repository->>Database: Probe for the marker column
  Repository->>Database: Begin migration transaction
  Repository->>PostgreSQL: Acquire advisory lock when using PostgreSQL
  Repository->>Database: Convert eligible starting routes to active
  Repository->>Database: Update task session projections
  Repository->>Database: Add marker column and commit
Loading

Suggested reviewers: carlosflorencio

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 16 functions across 7 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the backend fix for incorrectly flagging healthy sessions as orphaned after an upgrade. It is concise and directly related to the main change.
Description check ✅ Passed The description explains the problem, the migration and guard changes, scope, validation, risks, and checklist. It is complete and aligned with the repository template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

A rabbit guards the route tonight
Disabled paths stay still and right
Old starting trails turn active
Markers keep the change selective
Tests hop across each database
And leave clean states in place

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Claude finished @nova28's task in 3m 47s —— View job


Findings

Suggestion (recommended, doesn't block)

  1. Comment slightly understates the projection UPDATE's scopedynamic_route_legacy_active_migration.go:82–86
    • Issue: The comment says the task_sessions UPDATE is "scoped to the rows the UPDATE above just backfilled to 'active'", but the IN subquery reads all dynamic_route_states rows where state = 'active' — including any that were already active before this migration (post-fix(backend): recover dynamic agent routes stuck in starting with no owner #3362 routes that correctly called MarkActive). For those rows, updating a stale starting projection to active is also correct behavior.
    • Why it matters: A reader following the comment will be confused by the apparent scope gap; the action_required guard in the test shows the real invariant is "don't touch sessions whose durable row is not active", not "only touch freshly-backfilled rows."
    • Fix: Inline suggestion left on the PR (thread r3963412593) — revise the comment to say "sessions whose dynamic_route_states.state is now active" rather than "the rows the UPDATE above just backfilled."

What I checked

Migration logic (dynamic_route_legacy_active_migration.go)

  • Check-lock-check pattern for PostgreSQL is correct: outer probe on the pool (no lock), advisory pg_advisory_xact_lock acquired, inner probe on the same transaction, backfill + ADD COLUMN committed atomically. A concurrent loser re-probes inside the lock, finds the column, and exits cleanly. ✓
  • SET LOCAL lock_timeout = '30s' is transaction-scoped and resets automatically — correct; a timeout fails the migration loudly rather than silently skipping it. ✓
  • The projection UPDATE correctly uses the IN-subquery from dynamic_route_states WHERE state = 'active' so action_required and waiting durable rows are never silently overwritten. ✓
  • Advisory lock is a no-op for SQLite (SQLite's writer lock is sufficient). ✓

Schema (base_schema.go)

  • Fresh-install DDL declares legacy_active_backfill_applied INTEGER NOT NULL DEFAULT 1 inline, so ColumnExists returns true immediately and the backfill is skipped entirely. ✓
  • The migration column is added only via ALTER TABLE inside the backfill transaction (not in the fresh-install DDL path), so initSchemarunMigrations sequencing is clean. ✓

Migration position (base_migrations.go)

  • Backfill runs after continuation_json / policy_state_json column additions but before executors_running migrations — correct anchor. The backfill SQL only touches state (present in both legacy and current schemas), so positional ordering relative to those columns doesn't create a dependency. ✓

Orchestrator guard (dynamic_policy_recovery.go)

  • Before: profileExecutionResolver == nil — but the resolver is always constructed, making the guard a no-op. After: profileExecutionResolver == nil || !profileExecutionResolver.Enabled() — now matches startDynamicPolicyRecovery. Without dynamic routing enabled, LaunchDynamicRouteAction returns ErrDynamicRoutingDisabled, so any action_required banner it produced would be permanently irrecoverable. The guard fix is correct and necessary. ✓

Tests

  • TestBackfillLegacyActiveDynamicRoutes_* (4 cases): covers STARTING control (not touched), IDLE backfill, fresh-install no-op, post-upgrade orphan idempotency, no-route-row negative case, and action_required durable row negative case. ✓
  • TestReconcileOrphanedDynamicStartingRoutes_SkipsWhenRoutingDisabled: regression test for the disabled-flag guard fix. ✓
  • TestReconcileOrphanedDynamicStartingRoutes_PrecedesGeneralStartupReconciliation: validates that the orphan sweep must run before reconcileExecutorSessionsOnStartup, which flips STARTING → WAITING_FOR_INPUT and would otherwise make the sweep a no-op. ✓
  • TestPreviousStableUpgrade_BackfillsLegacyActiveDynamicRoutes: two-dialect conformance test against the real v0.93.0 fixture — no fixture bytes changed. ✓

Summary

Severity Count
Blocker 0
Suggestion 1

Verdict: Ready to merge. The migration is correctly idempotent, the PostgreSQL concurrency story is sound, the orchestrator guard fix is straightforward, and the test coverage is thorough. The single suggestion is a comment-only documentation nit that doesn't affect correctness.

@greptile-apps

greptile-apps Bot commented Sep 9, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a one-time schema migration that marks legacy starting plus IDLE dynamic routes as active before startup recovery, and prevents orphan reconciliation from running while dynamic routing is disabled.

  • Adds a transactional, marker-column-gated backfill for SQLite and PostgreSQL.
  • Adds legacy-upgrade, fresh-install, replay, projection, and disabled-routing regression coverage.
  • Introduces a disabled-to-re-enabled recovery gap for routes whose sessions are normalized from STARTING to WAITING_FOR_INPUT.

Confidence Score: 4/5

The PR is not safe to merge until disabled-routing startup preserves recovery for dynamic routes whose sessions would otherwise become WAITING_FOR_INPUT, and the explicit production-comment requirement is satisfied.

Skipping the sweep while routing is disabled allows general reconciliation to move a stranded STARTING session into a state excluded from future dynamic-route recovery, so re-enabling routing cannot surface or repair that route. The migration itself is otherwise correctly ordered and transactionally gated.

Files Needing Attention: apps/backend/internal/orchestrator/dynamic_policy_recovery.go; apps/backend/internal/task/repository/sqlite/dynamic_route_legacy_active_migration.go

Important Files Changed

Filename Overview
apps/backend/internal/orchestrator/dynamic_policy_recovery.go Adds the disabled-feature guard, but this can make a STARTING dynamic route unrecoverable after general reconciliation changes its session state.
apps/backend/internal/task/repository/sqlite/dynamic_route_legacy_active_migration.go Implements a transactional, one-time cross-dialect legacy-route backfill; its production documentation violates the repository's invariant-only comment rule.
apps/backend/internal/task/repository/sqlite/base_migrations.go Runs the backfill after required dynamic-route and session columns have been migrated.
apps/backend/internal/task/repository/sqlite/base_schema.go Adds the marker column to fresh-install schema so new databases correctly bypass the legacy backfill.
apps/backend/internal/orchestrator/dynamic_route_recovery_test.go Covers disabled routing as an immediate no-op, but not the subsequent disabled-restart and re-enable lifecycle.
apps/backend/internal/task/repository/sqlite/dynamic_route_legacy_active_migration_test.go Thoroughly covers migration, replay, fresh-install, and projection edge cases on SQLite.
apps/backend/internal/persistence/storeconformance/upgrade_test.go Adds cross-dialect previous-stable upgrade coverage for the legacy route shape.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Backend startup] --> B{Legacy marker exists?}
    B -- No --> C[Backfill starting plus IDLE routes to active]
    C --> D[Add marker in same transaction]
    B -- Yes --> E[Skip backfill]
    D --> F{Dynamic routing enabled?}
    E --> F
    F -- Yes --> G[Sweep orphaned starting routes]
    F -- No --> H[Skip dynamic-route sweep]
    G --> I[General session reconciliation]
    H --> I
    I --> J{Session was STARTING?}
    J -- Yes --> K[Session becomes WAITING_FOR_INPUT]
    K --> L[Later enabled restart skips route because state is not orphanable]
Loading

Reviews (1): Last reviewed commit: "fix(backend): fail the legacy dynamic-ro..." | Re-trigger Greptile

Comment thread apps/backend/internal/orchestrator/dynamic_policy_recovery.go

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 745da64683

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Round-1 PR fixup on kdlbs#3524: the comment described the
projection UPDATE as scoped to rows the prior UPDATE just backfilled,
but the IN-subquery matches every already-active dynamic_route_states
row, including ones active before this migration ran. Behavior was
already correct (the outer route_state = 'starting' filter excludes
them); only the comment was misleading.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@nova28
nova28 deployed to opencode-review-trusted September 9, 2026 01:08 — with GitHub Actions Active
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant