Skip to content

fix: reuse prepared session on explicit start#1432

Open
jcfs wants to merge 3 commits into
mainfrom
feature/error-with-todo-task-x5j
Open

fix: reuse prepared session on explicit start#1432
jcfs wants to merge 3 commits into
mainfrom
feature/error-with-todo-task-x5j

Conversation

@jcfs

@jcfs jcfs commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Creating a task in a workflow step without auto_start_agent and then clicking Start Agent from the edit-task dialog left the task with two session tabs. session.ensure already prepared a CREATED session when the preview opened, but launchStart unconditionally called StartTaskPrepareSession, creating a second session row.

Important Changes

  • launchStart now adopts an existing CREATED session via launchStartCreated when no session_id is supplied. The per-task ensureLocks mutex is shared with EnsureSession so the prepare and start paths can't race and both decide to create.
  • New helper findReusableCreatedSession picks the primary CREATED session (newest-updated CREATED as fallback); non-CREATED states are skipped to avoid implicitly reviving terminal or in-flight sessions.

Validation

  • make -C apps/backend test lint — green.
  • New backend regression tests in session_launch_test.go:
    • TestFindReusableCreatedSession — state filter + primary/newest preference.
    • TestLaunchStart_ReusesPreparedSession — confirmed it fails (got 2) with the reuse block removed and passes with it in place.
  • pnpm --filter @kandev/web lint — green after the worktree pnpm install --frozen-lockfile.

Checklist

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

Review in cubic

Preview Environment

URL https://kandev-pr-1432-bwo7.sprites.app
Commit 3b5e4a0
Agent Mock agent

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

session.ensure creates a CREATED session via prepare when a task's step
lacks auto_start_agent. The subsequent edit-dialog "Start Agent" click
called LaunchSession(IntentStart) without a session_id, so launchStart
fell through to StartTask which unconditionally wrote a second session
row and surfaced two tabs on a fresh task.

launchStart now acquires the per-task ensureLocks mutex (shared with
EnsureSession to close the race), looks for a CREATED session on the
task, and promotes it via launchStartCreated instead of creating a
duplicate. Primary wins; newest-updated CREATED is the fallback.

Regression-tested: the integration test fails (got 2) with the reuse
block removed and passes with it in place.
@claude

claude Bot commented Jun 18, 2026

Copy link
Copy Markdown

Claude finished @jcfs's task in 1s —— View job


I'll analyze this and get back to you.

@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

launchStart now reuses an existing CREATED session instead of always creating a new one when req.SessionID is absent and the caller has not set executor, profile, or priority fields. A context flag prevents deadlock when re-entering EnsureSession through the reuse path. The change adds canReusePreparedSession to gate reuse eligibility and findReusableCreatedSession to locate a primary or most-recently-updated CREATED session. Tests validate selection logic, end-to-end reuse behavior, and deadlock-free re-entrance.

Changes

Session reuse and deadlock prevention

Layer / File(s) Summary
Deadlock prevention via ensure lock context flag
apps/backend/internal/orchestrator/session_ensure.go
Introduces ensureLockHeld context marker with getter and setter to track when the per-task ensure lock is held. EnsureSession now sets this flag after acquiring the lock so re-entrant callers can detect lock ownership and skip recursive acquisition of the non-reentrant sync.Mutex.
Session reuse selection and launchStart integration
apps/backend/internal/orchestrator/session_launch.go
Adds canReusePreparedSession to gate reuse based on absence of caller-specified executor, profile, and priority fields. Adds findReusableCreatedSession to list task sessions, filter strictly to CREATED state, prefer the primary session, fall back to the most recently updated CREATED session, and gracefully return nil on listing errors. launchStart conditionally acquires the ensure lock (unless already held via context flag), calls these helpers, backfills req.AgentProfileID when empty, and routes to launchStartCreated; otherwise falls through to the existing StartTask path.
Unit tests for reuse selection and gating
apps/backend/internal/orchestrator/session_launch_test.go
TestFindReusableCreatedSession validates nil returns for missing or non-CREATED sessions, primary session preference even if a newer non-primary CREATED exists, and fallback to newest CREATED by UpdatedAt. TestProfilesCompatible validates agent-profile matching rules allowing reuse when profiles are identical or either side is empty, disallowing when both are non-empty and different. TestCanReusePreparedSession table-drives request field combinations to assert reuse is disabled when SessionID, ExecutorID, ExecutorProfileID, or Priority are set.
Integration and regression tests for reuse behavior
apps/backend/internal/orchestrator/session_launch_test.go
TestLaunchStart_ReusesPreparedSession seeds a primary CREATED session with matching AgentProfileID, invokes LaunchSession with IntentStart in a recovered goroutine, and asserts exactly one session exists with the pre-seeded ID. TestLaunchStart_NoReuseOnProfileMismatch seeds a primary CREATED session, calls LaunchSession with a different AgentProfileID, and asserts reuse is not performed (expecting two sessions total). TestEnsureSession_AutoStartDoesNotDeadlock configures auto_start_agent, deletes the initial session, runs EnsureSession in a goroutine with timeout, and fails if the call hangs, detecting re-entrant deadlock. Timeout in TestLaunchPrepare_PassthroughDoesNotRecurse reduced from 2s to 150ms for tighter deadlock detection.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • kdlbs/kandev#929: Modifies the orchestrator session launch flow in session_launch.go, threading req.AutoStart through the launchStart/StartCreatedSession path which intersects with the session reuse checkpoint.

Poem

🐇 A session once crafted now serves once again,
The rabbit found CREATED and saved itself pain.
With lock-held context to dodge the deadlock,
One session, one truth—no duplicate mock.
The warren runs tidy, no sessions in shock! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. 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 'fix: reuse prepared session on explicit start' clearly and concisely summarizes the main change—reusing an existing prepared session instead of creating a duplicate when launching.
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.
Description check ✅ Passed The pull request description comprehensively covers the problem, solution, validation steps, and includes all required sections from the template.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/error-with-todo-task-x5j

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

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

@greptile-apps

greptile-apps Bot commented Jun 18, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a duplicate-session bug where opening a task whose workflow step lacks auto_start_agent caused session preview to create a CREATED session, then the "Start Agent" button click created a second one via StartTask. launchStart now looks for an existing CREATED session and adopts it via launchStartCreated instead. It also threads an ensureLockHeld context marker through EnsureSession so the shared ensureLocks mutex is not double-acquired when EnsureSession re-enters via LaunchSession.

  • Duplicate-session prevention: findReusableCreatedSession picks the primary (or newest-updated) CREATED session; canReusePreparedSession gates reuse to requests without an explicit executor/priority, and a profile-mismatch check prevents silently overriding the prepared session's agent choice.
  • Deadlock fix: EnsureSession stamps the context with withEnsureLockHeld before calling LaunchSession; launchStart consults ensureLockHeld(ctx) and skips the inner acquireEnsureLock when the marker is present, avoiding a reentrant lock on the non-reentrant sync.Mutex.
  • Regression tests: TestFindReusableCreatedSession, TestLaunchStart_ReusesPreparedSession, TestLaunchStart_NoReuseOnProfileMismatch, and TestEnsureSession_AutoStartDoesNotDeadlock all cover the new paths and previously reported failure modes.

Confidence Score: 5/5

Safe to merge — the fix is well-scoped, both the deadlock and the duplicate-session race are directly tested, and the lock is correctly shared across all entry paths.

The two previously flagged issues (deadlock via reentrant mutex and silent error swallowing in findReusableCreatedSession) are both resolved in this revision. The context marker approach correctly threads through all re-entry paths without introducing new synchronization complexity. The fallthrough to StartTask under the mutex when reuse is considered but not taken is sound — it preserves the check-then-create invariant. No correctness issues identified in the current code.

No files require special attention — all three changed files are in good shape.

Important Files Changed

Filename Overview
apps/backend/internal/orchestrator/session_ensure.go Adds ensureLockHeldCtxKey + helpers to stamp/read a context marker; stamps the context immediately after acquiring the mutex in EnsureSession so downstream launchStart can skip a recursive lock acquisition — clean, minimal change.
apps/backend/internal/orchestrator/session_launch.go Adds canReusePreparedSession gate, findReusableCreatedSession helper (with warn-level error logging), and the reuse block in launchStart; lock acquisition is correctly skipped when the context marker is set, and StartTask still runs under the same mutex when reuse is considered but not taken.
apps/backend/internal/orchestrator/session_launch_test.go Four new tests cover the reuse logic, profile-mismatch fallthrough, the UpdatedAt vs SQL-order tie-break, and the EnsureSession deadlock scenario; all tests rely on recover() for expected scheduler panics, which is clearly documented in comments.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant UI as Frontend (edit dialog)
    participant ES as EnsureSession
    participant LS as launchStart
    participant FR as findReusableCreatedSession
    participant SC as launchStartCreated
    participant ST as StartTask

    Note over ES: Task preview opens
    ES->>ES: acquireEnsureLock(taskID)
    ES->>ES: "ctx = withEnsureLockHeld(ctx)"
    ES->>ES: findExistingSession → nil (no sessions)
    ES->>LS: LaunchSession(IntentPrepare)
    LS->>LS: PrepareTaskSession → CREATED row

    Note over UI: User clicks Start Agent
    UI->>LS: LaunchSession(IntentStart, no session_id)
    LS->>LS: canReusePreparedSession → true
    alt "ensureLockHeld(ctx) == false (direct call)"
        LS->>LS: acquireEnsureLock(taskID)
    end
    LS->>FR: findReusableCreatedSession(taskID)
    FR->>FR: ListTaskSessions → [CREATED session]
    FR-->>LS: session (primary or newest-updated)
    alt profile matches or caller has no preference
        LS->>SC: launchStartCreated(req with session.ID)
        SC-->>UI: success, same session ID
    else profile mismatch
        LS->>ST: StartTask → new session row
        ST-->>UI: success, new session ID
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant UI as Frontend (edit dialog)
    participant ES as EnsureSession
    participant LS as launchStart
    participant FR as findReusableCreatedSession
    participant SC as launchStartCreated
    participant ST as StartTask

    Note over ES: Task preview opens
    ES->>ES: acquireEnsureLock(taskID)
    ES->>ES: "ctx = withEnsureLockHeld(ctx)"
    ES->>ES: findExistingSession → nil (no sessions)
    ES->>LS: LaunchSession(IntentPrepare)
    LS->>LS: PrepareTaskSession → CREATED row

    Note over UI: User clicks Start Agent
    UI->>LS: LaunchSession(IntentStart, no session_id)
    LS->>LS: canReusePreparedSession → true
    alt "ensureLockHeld(ctx) == false (direct call)"
        LS->>LS: acquireEnsureLock(taskID)
    end
    LS->>FR: findReusableCreatedSession(taskID)
    FR->>FR: ListTaskSessions → [CREATED session]
    FR-->>LS: session (primary or newest-updated)
    alt profile matches or caller has no preference
        LS->>SC: launchStartCreated(req with session.ID)
        SC-->>UI: success, same session ID
    else profile mismatch
        LS->>ST: StartTask → new session row
        ST-->>UI: success, new session ID
    end
Loading

Reviews (2): Last reviewed commit: "fix: address review feedback on prepared..." | Re-trigger Greptile

Comment thread apps/backend/internal/orchestrator/session_launch.go Outdated
Comment thread apps/backend/internal/orchestrator/session_launch.go

@cubic-dev-ai cubic-dev-ai 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.

2 issues found across 2 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread apps/backend/internal/orchestrator/session_launch.go Outdated
Comment thread apps/backend/internal/orchestrator/session_launch.go Outdated
Comment thread apps/backend/internal/orchestrator/session_launch_test.go Outdated
Greptile P1: launchStart's acquireEnsureLock(req.TaskID) deadlocked when
EnsureSession (which already holds that mutex via defer release()) re-
entered through LaunchSession on auto_start_agent steps. sync.Mutex isn't
reentrant. Thread an ensureLockHeld context marker so launchStart skips
the inner acquire when EnsureSession owns the lock.

cubic P2 (x2): tighten the reuse gate via canReusePreparedSession so an
explicit ExecutorID, ExecutorProfileID, or Priority bypasses reuse
(start_created carries none of those), and refuse to adopt a prepared
session whose AgentProfileID disagrees with the caller's.

Greptile P2: warn-log instead of silently swallowing ListTaskSessions
errors in findReusableCreatedSession; a transient DB error would
otherwise route the caller back into StartTask and write the duplicate
row this PR exists to prevent.

OpenCode: rewrite the "falls back to newest CREATED when no primary"
subtest so the SQL started_at ordering disagrees with UpdatedAt — the
new fixture writes both timestamps at CreateTaskSession time so only
the UpdatedAt comparison can pick the right session.

New regression tests: TestCanReusePreparedSession,
TestLaunchStart_NoReuseOnProfileMismatch,
TestEnsureSession_AutoStartDoesNotDeadlock (verified: hangs without
the marker, passes with it).
@claude

claude Bot commented Jun 18, 2026

Copy link
Copy Markdown

Claude finished @jcfs's task in 0s —— View job


I'll analyze this and get back to you.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/backend/internal/orchestrator/session_launch_test.go`:
- Around line 695-697: The timeout duration in the time.After calls within the
LaunchSession test is too long for in-process negative assertions. Replace the 2
* time.Second timeout values with a shorter duration of approximately 100 *
time.Millisecond in both locations: the case statement at line 697 and the
similar case statement around line 749-752. This aligns with the coding
guidelines that specify short in-process timeouts (~100ms) for negative
assertions to provide faster failure feedback during testing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: aa41b80a-3a9b-48e4-ba33-614f5942de69

📥 Commits

Reviewing files that changed from the base of the PR and between 7f5a061 and 2443d4f.

📒 Files selected for processing (3)
  • apps/backend/internal/orchestrator/session_ensure.go
  • apps/backend/internal/orchestrator/session_launch.go
  • apps/backend/internal/orchestrator/session_launch_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/backend/internal/orchestrator/session_launch.go

Comment thread apps/backend/internal/orchestrator/session_launch_test.go
Comment thread apps/backend/internal/orchestrator/session_launch.go

@cubic-dev-ai cubic-dev-ai 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.

2 issues found across 3 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread apps/backend/internal/orchestrator/session_launch.go
Comment thread apps/backend/internal/orchestrator/session_launch.go Outdated
CodeRabbit: drop the 2s in-process timeouts in the deadlock/hang tests
to 150ms per the repo's testing guideline ("short timeouts (~100ms)
for in-process negative assertions; multi-second waits only for
subprocess/integration tests"). Failure feedback is now near-instant.

OpenCode: the warn-log comment on findReusableCreatedSession implied
the new code prevents the duplicate row, but launchStart still falls
through to StartTask on a list error — same behavior as pre-PR. Rewrote
the comment to describe what the warn actually buys (visibility into a
transient DB failure), not a behavior change.

cubic P2 (profile compat): extract profilesCompatible — caller and
prepared-session profiles match when identical OR either side is empty.
Previously a non-empty caller profile blocked reuse against a session
with an empty profile, even though start_created could have adopted the
caller's profile. Differing non-empty profiles still skip reuse and
fall through to a fresh launch. TestProfilesCompatible pins the matrix.

cubic P2 (fail-closed on list error): rejected. Returning an error from
launchStart on a transient ListTaskSessions failure would be a UX
regression — pre-PR launchStart never depended on that lookup at all
and would have silently created a session. Warn+continue matches that
prior behavior; the warn now makes the failure visible. The reuse path
is an optimization, not a correctness gate, so failing closed would
trade a silent duplicate row for a surfaced launch error on the same
DB hiccup.
@claude

claude Bot commented Jun 18, 2026

Copy link
Copy Markdown

Claude finished @jcfs's task in 1s —— View job


I'll analyze this and get back to you.

@github-actions

Copy link
Copy Markdown
Contributor

OpenCode review complete

OpenCode model: opencode-go/minimax-m3

No suggestions found for commit 3b5e4a0642e4.

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