Files
kandev/docs/specs/session-delete-resource-cleanup/spec.md
T

411 lines
22 KiB
Markdown

---
status: draft
created: 2026-08-08
owner: cfl
---
# Session Delete Preserves Task Workspaces
Decision: [Task-owned worktree lifetime](../../decisions/2026-08-08-task-owned-worktree-lifetime.md)
Runtime contract: [Task runtime cleanup](../tasks/runtime-cleanup.md)
## Why
A session is a conversation and execution reference inside a task; it is not the
owner of the task's materialized workspace. A task may have multiple sessions
sharing one `TaskEnvironment`, may intentionally have zero sessions, and may
create a future session that reuses its existing files and Git state.
PR #2421 proposed reclaiming a worktree when the final active session reference
disappeared. That rule confuses reference count with ownership. Deleting the last
session would destroy uncommitted work and remove a workspace that the still-live
task owns. The persistence model must instead keep task ownership durable until a
task lifecycle operation takes responsibility for cleanup.
## What
- Deleting a session removes its session row and conversation history. Its
`task_environment_id` reference disappears with the row; the task-owned
environment is unchanged.
- Deleting a session never removes a physical directory, runs `git worktree
remove` or `git worktree prune`, or deletes a branch.
- The rule applies when the deleted session is the task's only session and when
it is one of several sessions sharing the workspace.
- A task with zero sessions retains its `TaskEnvironment`, worktree directory,
Git registration, branch, and uncommitted files.
- A later session may reuse the retained workspace.
- Task archive/delete, cascade archive/delete, workspace delete, quick-chat task
expiry, and explicit task-environment reset remain the owners of physical
cleanup.
- Task lifecycle cleanup discovers task-owned resources without joining through a
session row and executes asynchronously through the durable cleanup worker.
- Resources borrowed by another task or referenced through a shared environment
are preserved or transferred according to existing ownership rules.
- Session deletion does not create or activate a `session_delete` cleanup job and
has no `ReclaimSessionWorktree` physical cleanup path.
- Session-delete confirmations describe the conversation deletion and explicitly
say that the task workspace and files remain. They do not warn that
uncommitted/unpushed workspace state will be removed.
## Data Model
### `task_environments`
`task_environments` is the task-owned workspace root:
```text
id string environment identity
task_id string owning task and inventory key
executor fields ... environment runtime configuration
workspace_path string agent-visible workspace root
container/sandbox ... non-worktree resource handles
status/timestamps ... environment lifecycle
```
The deprecated flat `repository_id`, `worktree_id`, `worktree_path`, and
`worktree_branch` columns are removed. Repository and physical worktree state
lives only in the environment's repository rows.
### `task_environment_repos`
`task_environment_repos` is the canonical physical-worktree table and also
represents repository preparation failures where no worktree was created:
```text
id string environment-repository identity
task_environment_id string owning environment
repository_id string source repository
branch_slug string task repository/branch slot
worktree_id string|null physical worktree identity
worktree_path string|null absolute materialized path
worktree_branch string|null registered Git branch
position integer workspace ordering
error_message string preparation failure, when any
status enum active | merged | deleted
created_at timestamp
updated_at timestamp
merged_at/deleted_at timestamp|null
```
Task-level inventory queries these rows through
`task_environments.task_id`. Session projections query them through
`task_sessions.task_environment_id`. Neither query depends on a
session-to-worktree association.
The canonical environment and repository rows are persisted after physical
materialization and before the workspace is exposed for reuse. A worktree that
was physically created but cannot be persisted because task cleanup won a race
is compensated before it becomes task state.
### Schema normalization
SQLite and PostgreSQL migrations backfill canonical rows from
`task_environment_repos`, legacy flat `task_environments` worktree fields, and
`task_session_worktrees`. Sessions with a valid `task_environment_id` retain it.
Legacy sessions are assigned to the matching existing environment or to a
normalized task-owned environment created for their connected worktree group.
Canonical `task_environment_repos` rows take precedence when the legacy flat
environment fields or session references repeat the same physical worktree.
When no canonical repository row exists, the surviving task environment's flat
worktree fields take precedence over a legacy session reference to the same
physical `worktree_id`. This source precedence applies regardless of session
lifecycle state: path or branch drift on a lower-precedence row does not create
a second owner when the physical identity is unchanged.
A legacy session's worktree is normalized onto the environment the session
resolves to, which is not always an environment owned by the session's own
task: workspace-group members, subtasks inheriting a parent workspace, and
tasks that received an environment through ownership handoff share one
environment across tasks. Such a borrowing task gains no environment of its
own, and the shared physical worktree keeps exactly one owner.
Legacy session rows marked `deleted` (or carrying `deleted_at`) and stale
references from terminal sessions are historical evidence, not additional
owners. A higher-precedence task-owned source for the same repository and
branch slot, either a canonical repository row or the surviving flat
environment row, remains the owner even when a terminal reference carries a
different physical `worktree_id`.
Legacy session-worktree rows can also outlive their referenced session in
databases written by older releases. An orphaned row is historical evidence
when it is marked deleted or when its exact non-empty physical `worktree_id` is
already represented by a non-deleted normalized target built from a canonical
repository row or surviving flat environment. The higher-precedence task-owned
source remains authoritative and the orphan does not prevent cutover. A raw
flat row absorbed into a deleted canonical target is not an active owner. An
active orphan with no matching non-deleted normalized target remains
irreconcilable because the cutover cannot recover its task or environment
owner, so startup fails closed.
A non-deleted canonical repository row also takes precedence over deprecated
flat fields for the same normalized task-owned repository slot: the repository
and empty branch slot within the surviving task environment. This rule applies
when the two sources contain different physical `worktree_id` values, including
when the canonical row was re-homed from a collapsed legacy environment. The
original environment ID is not an ownership condition after re-homing. The
upgrade records the flat worktree as historical evidence and logs its identity,
path, and branch after the transaction commits. The upgrade does not remove its
directory, Git registration, or branch.
The task-owned model holds exactly one physical worktree per (repository,
branch slug) slot, while the legacy per-session model let one task accumulate
several for the same slot through handoffs, re-materialized workspaces, and
additional sessions. When no higher-precedence task-owned source exists, the
upgrade elects one owner per contested slot instead of failing: a live
session's worktree wins over a terminal session's, then the most recently
updated row wins. Every other worktree for that slot becomes historical
evidence and is logged with its identity, repository, path, and branch. No
directory, registration, or branch is removed, so a demoted workspace remains
on disk.
Irreconcilable data, including incompatible path or branch metadata for the
same physical worktree identity with no higher-precedence owner or a worktree
whose task has no resolvable environment, still fails closed with a diagnostic.
After backfill validation, the same upgrade drops `task_session_worktrees` and
the deprecated flat worktree columns from `task_environments`. It also removes
the matching Go models, repository methods, duplicate schema initializer, API
fields, and dual-read/dual-write code. Fresh databases contain only the final
schema. There is no indefinite compatibility phase.
If a database was opened by a preview build of PR #2421, the migration removes
its `session_delete` cleanup jobs before the cleanup worker can claim them. The
trigger constant and session-specific cleanup implementation are removed from
the codebase. Task-lifecycle cleanup jobs remain unchanged.
The migration runs under an exclusive writer/migration lock in one transaction.
It builds normalized shadow tables, compares the full worktree identity, path,
and branch inventory before and after, validates all ownership and foreign-key
constraints, and swaps schemas only after every check passes. It returns errors
directly; it does not use the best-effort migration logger.
SQLite takes the repository's existing fatal pre-upgrade snapshot before the
transaction. PostgreSQL uses transactional DDL under an advisory migration lock
and exclusive locks on the affected tables; lock timeout aborts the upgrade.
Mixed-version PostgreSQL writers must be stopped during cutover. The migration
performs no filesystem or Git operation, and no resource cleanup worker starts
until schema initialization commits successfully.
### `task_resource_cleanup_jobs`
The existing durable job is reserved in `prepared` state before task lifecycle
inventory is captured. Its snapshot contains canonical worktree handles before
archive/delete cascades can remove database rows. The job remains independent of
the task/session foreign-key lifetime and retains the existing retry schedule.
There is no session-delete trigger value.
## API Surface
The WebSocket action is unchanged:
```text
session.delete request { "session_id": string }
session.delete response { "success": true }
```
Success means that the session was deleted. It does not mean that task resources
were reclaimed. Existing validation, permission, running-
session refusal, quiescence, and primary-session promotion behavior remains.
No new HTTP or WebSocket action is required for task cleanup.
## State and Concurrency
Session deletion follows:
```text
stopped session -> quiesced runtime -> session/reference deletion -> task retained
```
Task lifecycle cleanup follows:
```text
active task -> prepared cleanup barrier -> complete inventory snapshot
-> archive/delete mutation -> pending durable worker
-> running -> succeeded | retry_wait | failed
```
Session creation and canonical environment-repository persistence serialize with
the owning task row and check for an active prepared task cleanup barrier.
PostgreSQL uses row locking and SQLite uses the serialized writer transaction.
Either creation commits before the barrier and is included in inventory, or the
barrier commits first and creation is rejected/compensated.
The database barrier transaction completes before any repository, target-path,
filesystem, or Git lock is acquired. Existing shared-worktree reference checks
run again during destructive cleanup so an ownership transfer or active borrower
cannot be destroyed by a stale decision.
## Permissions
Session deletion keeps its existing task/session authorization requirements.
This feature does not grant a session deleter permission to archive/delete the
task or remove task resources.
Task archive/delete and cascade operations keep their existing permissions and
remain the authorization boundary for physical cleanup.
## Failure Modes
- If session deletion fails before commit, no filesystem or Git cleanup was
attempted and canonical task ownership is unchanged.
- If session deletion commits and the backend crashes, the canonical owner row
remains queryable by `task_id`; no recovery job is needed for session deletion.
- If migration cannot determine a single compatible owner/path, startup fails
closed, the transaction rolls back, and the pre-upgrade database remains
authoritative instead of authorizing future deletion from ambiguous data.
- Historical deleted session-worktree rows and stale references from terminal
sessions do not block migration when a higher-precedence task-owned source
exists for the same repository and branch slot. The higher-precedence source
remains authoritative.
- An orphaned legacy session-worktree row does not block migration when it is
deleted history or its exact non-empty physical `worktree_id` is already
represented by a non-deleted normalized target built from a canonical
repository row or the surviving flat environment. The orphan contributes no
ownership or metadata.
- An active orphaned session-worktree row with no higher-precedence source for
the same physical `worktree_id` remains irreconcilable and fails closed.
- A raw surviving flat row does not suppress an active orphan when source
merging leaves that physical identity only on a deleted normalized target.
Startup fails closed rather than dropping the only active legacy evidence.
- A legacy session row that repeats a higher-precedence physical `worktree_id`
cannot block migration solely because its path or branch metadata is stale,
including when the session is resumable. The higher-precedence repository or
flat environment metadata remains authoritative.
- Deprecated flat fields cannot block migration when a non-deleted canonical
row owns the same normalized task, repository, and empty branch slot. The
canonical row remains authoritative even when it was re-homed from a
collapsed environment, and the upgrade records the flat worktree as
historical evidence.
- A session bound to another task's environment does not block migration and
does not create a second owner for the shared worktree.
- If migration fails after shadow tables are populated or after legacy DDL has
begun, the database transaction restores the complete legacy schema and data.
- If SQLite cannot create its pre-upgrade snapshot, startup stops before the
migration transaction begins.
- If PostgreSQL cannot acquire its migration/table locks, startup stops without
changing the schema or data.
- An older binary cannot open the normalized schema. Downgrade restores the
SQLite automatic snapshot or the PostgreSQL operator backup.
- If a new session/worktree races task cleanup, the prepared barrier decides the
ordering; cleanup cannot snapshot a partial inventory.
- If physical compensation is required, it removes only the resource created by
the rejected materialization attempt.
- If archive/delete filesystem or Git cleanup fails, the durable task cleanup job
retries after failure and backend restart using its persisted snapshot.
- If another task/session still borrows the environment, cleanup preserves or
transfers the resource rather than deleting it.
## Persistence Guarantees
- Task worktree ownership in `task_environment_repos` survives deletion of any
or all session rows.
- Zero-session workspaces remain protected from storage inventory and orphan GC.
- Backend restart does not change the ownership boundary.
- A failed upgrade leaves no partially normalized schema. SQLite also retains a
directly restorable pre-upgrade snapshot.
- Archive/delete cleanup can query canonical resources by `task_id` before
mutation and can continue from the durable snapshot after task-row deletion.
- Historical branch metadata required for unarchive recovery remains preserved
according to the task runtime cleanup contract.
- No session-delete path invokes filesystem removal, Git worktree removal/prune,
or branch deletion.
## Scenarios
- **GIVEN** a task with one stopped session, a registered worktree, and an
uncommitted marker, **WHEN** the session is deleted, **THEN** the task has zero
sessions and the directory, registration, branch, and marker are unchanged.
- **GIVEN** that zero-session task, **WHEN** a replacement session starts,
**THEN** it reuses the task workspace and observes the marker.
- **GIVEN** two sessions sharing a task workspace, **WHEN** one is deleted,
**THEN** the other continues using the same directory without interruption.
- **GIVEN** the final session was deleted and the backend restarted, **WHEN** the
task is archived, **THEN** the task becomes archived and its canonical
worktree is scheduled for asynchronous cleanup unless shared.
- **GIVEN** the final session was deleted and the backend restarted, **WHEN** the
task is deleted, **THEN** the task row is removed and its worktree/Git
registration are removed by a retryable durable cleanup job.
- **GIVEN** a transient Git/filesystem failure during task cleanup, **WHEN** the
backend restarts and the retry becomes due, **THEN** cleanup resumes from the
snapshot and completes.
- **GIVEN** session/worktree creation and task deletion begin concurrently,
**WHEN** one reserves the task row first, **THEN** the resource is either fully
included in cleanup or rejected and compensated; it cannot become untracked.
- **GIVEN** a legacy database contains deleted session-worktree history and a
canonical task-owned repository row for the same workspace, **WHEN** the new
binary starts, **THEN** the cutover completes, preserves the canonical row,
and removes the legacy schema without requiring manual database edits.
- **GIVEN** a legacy flat environment path differs from the canonical repository
row for the same physical worktree, **WHEN** the new binary starts, **THEN**
the canonical repository path and branch are retained and the cutover
completes.
- **GIVEN** a non-deleted canonical repository row and deprecated flat fields
contain different worktree IDs for the same empty branch slot, **WHEN** the
new binary starts, **THEN** the canonical row remains the owner, the flat
worktree is logged as history, and the cutover completes.
- **GIVEN** a surviving flat environment and a canonical row only in a legacy
environment that collapses into it, **WHEN** the new binary starts, **THEN**
the re-homed canonical row remains the owner of the normalized slot and the
flat worktree is logged as history.
- **GIVEN** a resumable legacy session and a canonical task-owned repository row
carry the same `worktree_id` but different path or branch metadata, **WHEN**
the new binary starts, **THEN** the canonical metadata is retained and the
cutover completes without changing the session lifecycle state.
- **GIVEN** no canonical repository row exists and a legacy session reference
repeats the surviving task environment's `worktree_id` with stale path or
branch metadata, **WHEN** the new binary starts, **THEN** the flat environment
metadata is retained and the cutover completes.
- **GIVEN** a terminal legacy session references an older physical worktree for
the surviving flat environment's repository slot, **WHEN** the new binary
starts, **THEN** the flat environment remains the owner and the cutover
completes.
- **GIVEN** a legacy session-worktree row references a missing session and the
same non-empty `worktree_id` remains on a non-deleted normalized target built
from a surviving flat environment or canonical repository row, **WHEN** the
new binary starts, **THEN** the task-owned source remains authoritative, the
orphan contributes no metadata, and the cutover completes without manual
database edits.
- **GIVEN** a deleted legacy session-worktree row references a missing session,
**WHEN** the new binary starts, **THEN** the row is treated as historical
evidence and does not block the cutover.
- **GIVEN** an active legacy session-worktree row references a missing session
and no higher-precedence source carries its physical `worktree_id`, **WHEN**
the new binary starts, **THEN** startup fails closed and the complete legacy
schema and data remain unchanged.
- **GIVEN** a surviving flat row, a deleted canonical row, and an active orphan
share one physical `worktree_id`, but source merging leaves only a deleted
normalized target, **WHEN** the new binary starts, **THEN** startup fails
closed and preserves the legacy schema rather than dropping the active
orphan evidence.
- **GIVEN** `task_environments` already has the normalized schema but a stale
`task_session_worktrees` table remains from an intermediate build, **WHEN**
the new binary starts, **THEN** the cutover preserves normalized environment
and repository data, imports any remaining session-worktree inventory, and
removes the stale legacy table without requiring manual database edits.
- **GIVEN** a legacy session of one task is bound to another task's environment
and carries a session-worktree row for that shared workspace, **WHEN** the new
binary starts, **THEN** the worktree is normalized onto the owning task's
environment, both sessions keep that environment, the borrowing task gains no
environment of its own, and the cutover completes.
- **GIVEN** several legacy sessions of one task registered different physical
worktrees for the same repository and branch slot, **WHEN** the new binary
starts, **THEN** the canonical owner — or the newest live session worktree
when there is none — keeps the slot, the other worktrees are recorded as
history and logged with their paths, no directory or branch is removed, and
the cutover completes.
- **GIVEN** a non-terminal session references a worktree whose path or branch
metadata contradicts the same physical worktree's owner and cannot be
reconciled, **WHEN** the new binary starts, **THEN** startup fails closed, the
transaction rolls back, and the verified pre-upgrade backup remains the
recovery source.
## Out of Scope
- Reclaiming workspaces merely because their session reference count reaches
zero.
- A session-delete cleanup job, retry state, or physical reclaimer.
- Per-session uncommitted/unpushed warning fetches.
- New archive/delete UI controls or cleanup-failure UI.
- Changing Quick Chat closure, which deletes its hidden backing task and therefore
remains a task-lifecycle cleanup.