356 lines
20 KiB
Markdown
356 lines
20 KiB
Markdown
---
|
|
status: draft
|
|
created: 2026-06-22
|
|
owner: cfl
|
|
---
|
|
|
|
# Task Runtime Cleanup
|
|
|
|
## Why
|
|
|
|
Operators archive and delete completed tasks to keep the board usable and the
|
|
workspace tidy. Those actions must also release the task's runtime resources;
|
|
otherwise completed work leaves hidden ACP processes, host utility processes,
|
|
worktrees, or executor rows behind and the machine slowly runs out of memory.
|
|
|
|
## What
|
|
|
|
- Archiving a task stops every runtime execution that is still durably associated
|
|
with that task before the task's worktrees or runtime tracking rows are removed.
|
|
- Deleting a task stops every runtime execution that is still durably associated
|
|
with that task before task records, worktrees, or runtime tracking rows are
|
|
removed.
|
|
- Cleanup ownership is based on `executors_running`, not only on active
|
|
`task_sessions` state. A runtime row for a completed, cancelled, archived, or
|
|
otherwise terminal session is still a cleanup target until the runtime stop has
|
|
been attempted.
|
|
- A cleanup path MUST NOT delete the last durable runtime handle for a task until
|
|
either the matching runtime execution has stopped successfully or the failure is
|
|
preserved for retry/diagnosis.
|
|
- Worktree and task-environment cleanup is ownership-aware. A task cleanup may
|
|
destroy only resources owned by that task's `TaskEnvironment`; borrowed or
|
|
inherited worktrees remain owned by the source task.
|
|
- A `TaskEnvironment` referenced by another active task session is shared for the
|
|
duration of that session. Cleanup can stop the current task's runtime rows, but
|
|
must defer destructive worktree/container/sandbox teardown until no other
|
|
active task session references the environment.
|
|
- Agent subprocess shutdown kills the whole agent process group when graceful
|
|
shutdown does not finish within the configured stop timeout.
|
|
- Agent subprocess shutdown does not treat the command leader exiting as
|
|
sufficient while descendants remain alive in the same process group.
|
|
- Agentctl instance shutdown never leaves an agent subprocess group alive as a
|
|
child of `init`/PID 1.
|
|
- Standalone agentctl is isolated from terminal foreground interrupts so the
|
|
backend remains the owner of Ctrl+C shutdown sequencing.
|
|
- Top-level launchers, including `make start-debug` through `kandev start`, send
|
|
the backend a graceful termination signal before any force kill so backend
|
|
shutdown can stop agents and agentctl instances.
|
|
- Backend shutdown waits long enough for standalone agentctl's instance cleanup
|
|
window before reporting shutdown complete.
|
|
- Backend startup reconciles stale runtime rows for archived/deleted/missing task
|
|
state and attempts safe cleanup instead of treating the rows as live sessions.
|
|
- Cleanup is idempotent: repeating archive/delete cleanup, startup reconciliation,
|
|
or explicit session stop does not fail because the process, task, session, or
|
|
worktree was already removed.
|
|
- A cleanup operation treats a classifiable missing owned resource as already
|
|
complete. This includes a missing worktree and a missing `TaskEnvironment`
|
|
row captured by the durable cleanup snapshot.
|
|
- Stopping a runtime that is already gone is a successful, idempotent stop, not a
|
|
failed stop. When a stop operation is asked to stop an execution or session
|
|
that no longer exists, and the row it owns is a confirmed-dead local runtime,
|
|
the cleanup treats the runtime as already stopped so the runtime row can be
|
|
pruned or repaired under the resume-safety invariant and the durable cleanup
|
|
job does not retry solely because the owned runtime is absent.
|
|
- "Confirmed dead" is judged in a runtime-aware way. A row is confirmed dead only
|
|
when it is a local runtime whose local liveness handle refers to a process that
|
|
no longer exists on this host. An alive local runtime, or a runtime whose
|
|
liveness is Unknown (remote SSH, containerized, or a local row with no liveness
|
|
handle), is never treated as dead on the basis of a not-found stop result; its
|
|
row is preserved and the outcome remains retryable.
|
|
- Missing-runtime handling never blanket-ignores every session-level or
|
|
execution-level not-found error. Only a not-found result for a runtime whose row
|
|
is confirmed dead is reclassified as a successful stop. A not-found result for
|
|
an alive or unknown/remote row, or a session/task lookup error that is not a
|
|
typed not-found sentinel, remains a preserved, retryable outcome so a transient
|
|
store error is never mistaken for an absent runtime.
|
|
- Where a runtime-specific persisted stop handle exists (for example
|
|
`agent_execution_id`, or a remote/container handle), missing-runtime handling
|
|
uses it to decide the stop result rather than inferring absence from a generic
|
|
error.
|
|
- Archive, delete, cascade, workspace-delete, and quick-chat expiration persist a
|
|
cleanup intent and resource snapshot before mutating or deleting task state.
|
|
Cleanup is performed by a durable worker rather than a detached goroutine.
|
|
- A durable cleanup job makes at most eight attempts. Failed attempts back off
|
|
for 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours, and 12 hours
|
|
before the next claim. An eighth failed attempt becomes terminal `failed`
|
|
instead of scheduling another retry.
|
|
- Archive cleanup revalidates that the task remains archived before every
|
|
destructive step. Unarchiving a task cancels its pending archive cleanup so a
|
|
delayed retry cannot remove the newly active task's resources.
|
|
- Cleanup preserves historical archived-task worktree records and branch metadata
|
|
used by unarchive recovery. Filesystem removal does not imply recovery-history
|
|
removal.
|
|
|
|
## Data Model
|
|
|
|
### `executors_running`
|
|
|
|
`executors_running` remains the durable runtime ownership table and the source of
|
|
cleanup handles for task runtime teardown.
|
|
|
|
- `session_id` identifies the task session that originally launched the runtime.
|
|
- `task_id` identifies the owning task and is the primary lookup key for archive
|
|
and delete cleanup.
|
|
- `agent_execution_id` is the preferred stop handle for in-memory runtime
|
|
shutdown through the lifecycle manager.
|
|
- `runtime`, `container_id`, `agentctl_url`, `agentctl_port`, `pid`, and
|
|
`metadata` provide fallback handles for runtime-specific cleanup and
|
|
diagnostics.
|
|
- `status`, `error_message`, and timestamps remain available for cleanup
|
|
diagnostics when stop fails and the row must remain retryable.
|
|
|
|
Rows may temporarily reference archived tasks or terminal sessions while cleanup
|
|
is in progress. Rows must not reference missing task/session state indefinitely.
|
|
|
|
### `task_sessions`
|
|
|
|
`task_sessions.state` remains the user-facing session state. Terminal states do
|
|
not imply runtime resources have been released. Cleanup code must not use terminal
|
|
session state as a reason to skip runtime teardown when an `executors_running`
|
|
row exists.
|
|
|
|
### `task_resource_cleanup_jobs`
|
|
|
|
`task_resource_cleanup_jobs` is the durable task-lifecycle cleanup intent. It has
|
|
no foreign key to `tasks`, so delete cleanup survives deletion of the owning row.
|
|
It stores the trigger, state, retry timing, last error, and a JSON snapshot of the
|
|
runtime, environment, worktree, and path handles captured before task mutation.
|
|
Only one non-terminal row exists for an operation ID; repeated event delivery
|
|
reuses the same cleanup job. `attempts` counts successful worker claims. A
|
|
terminal `failed` row retains its final error and completion timestamp for
|
|
diagnosis but is not selected by the automatic worker.
|
|
|
|
## API Surface
|
|
|
|
No new user-facing HTTP or WebSocket action is required. Existing task archive,
|
|
task delete, session stop, and backend startup behavior gain stronger cleanup
|
|
guarantees.
|
|
|
|
Internal contracts:
|
|
|
|
```go
|
|
type ExecutorRunningRepository interface {
|
|
ListExecutorsRunningByTaskID(ctx context.Context, taskID string) ([]*models.ExecutorRunning, error)
|
|
ListExecutorsRunning(ctx context.Context) ([]*models.ExecutorRunning, error)
|
|
}
|
|
```
|
|
|
|
`TaskExecutionStopper.StopExecution(ctx, executionID, reason, force)` remains the
|
|
primary stop operation when `agent_execution_id` is available. Fallback cleanup is
|
|
runtime-specific and must be bounded by context.
|
|
|
|
## State Machine
|
|
|
|
Runtime cleanup for a task follows this lifecycle:
|
|
|
|
- `tracked`: an `executors_running` row exists for the task.
|
|
- `stop_requested`: archive, delete, explicit stop, terminal-agent cleanup, or
|
|
startup reconciliation has selected the row for cleanup.
|
|
- `stopped`: the runtime instance and its subprocess group have exited or were
|
|
already absent.
|
|
- `tracking_removed`: the `executors_running` row is deleted after the stop
|
|
result is known.
|
|
- `retryable_failure`: stop could not be confirmed before the timeout. The row
|
|
remains durable with enough context to retry and diagnose.
|
|
|
|
Allowed transitions:
|
|
|
|
- `tracked` -> `stop_requested` by archive/delete/session stop/reconciliation.
|
|
- `stop_requested` -> `stopped` when runtime shutdown succeeds or the runtime is
|
|
confirmed absent.
|
|
- `stopped` -> `tracking_removed` after worktree/environment cleanup has been
|
|
attempted and the runtime row is no longer needed as the durable stop handle.
|
|
- `stop_requested` -> `retryable_failure` on timeout or uncertain runtime state.
|
|
- `retryable_failure` -> `stop_requested` on the next cleanup attempt.
|
|
|
|
The durable cleanup job wraps that resource lifecycle:
|
|
|
|
- `pending` -> `running` when the cleanup worker claims the job.
|
|
- `running` -> `succeeded` when runtime and owned resource cleanup finish.
|
|
- `running` -> `retry_wait` when bounded cleanup fails and the resource snapshot
|
|
must be retried and fewer than eight claims have run.
|
|
- `running` -> `failed` when the eighth claimed attempt fails.
|
|
- `retry_wait` -> `running` on the next scheduled retry or manual storage run.
|
|
- `pending|running|retry_wait` -> `cancelled` when an archive-triggered cleanup
|
|
observes that its task has been unarchived.
|
|
|
|
## Failure Modes
|
|
|
|
- If querying `executors_running` for a task fails, archive/delete still updates
|
|
the task only if existing product behavior requires it, but destructive runtime
|
|
cleanup must fail closed: do not remove runtime rows or worktrees based on an
|
|
empty or partial inventory.
|
|
- If stopping a runtime execution times out, the process manager escalates to a
|
|
process-group kill and waits for confirmation. If confirmation still fails, the
|
|
runtime row remains retryable.
|
|
- If a runtime row points at a missing in-memory execution, cleanup attempts the
|
|
runtime-specific persisted handle when available. If no handle can be used, the
|
|
row is preserved with a warning instead of being silently dropped.
|
|
- If a stop operation reports the execution or session is not found and the owned
|
|
row is a confirmed-dead local runtime, cleanup records the stop as successful,
|
|
prunes or repairs the row under the resume-safety invariant, proceeds with any
|
|
now-safe shared-environment/worktree teardown, and does not count the row as a
|
|
failed stop. The durable cleanup job therefore does not re-enter `retry_wait`
|
|
solely because that owned runtime was confirmed absent.
|
|
- If a stop operation reports not found but the owned row is alive, or its
|
|
liveness is Unknown (remote/containerized/no local handle), cleanup preserves
|
|
the row and treats the outcome as retryable rather than pruning it.
|
|
- If a session or task lookup during stop fails with an error that is not a typed
|
|
not-found sentinel, cleanup treats it as a retryable failure and preserves the
|
|
row; it does not reinterpret the error as an absent runtime.
|
|
- If worktree or task environment cleanup fails after runtime shutdown is
|
|
confirmed, the runtime tracking row can still be removed because it no longer
|
|
identifies a live process. The resource cleanup error is logged and handled by
|
|
the resource-specific retry path.
|
|
- If a worktree or captured task-environment row is already absent, cleanup
|
|
treats that deletion step as successful and continues. Other teardown errors
|
|
remain retryable and are not hidden by an accompanying not-found result.
|
|
- If cleanup still fails on its eighth worker claim, the job becomes terminal
|
|
`failed`, preserves `last_error`, clears `next_attempt_at`, stamps
|
|
`completed_at`, and is excluded from automatic due-job selection.
|
|
- If cleanup cannot prove that a session worktree belongs to the task being
|
|
cleaned, destructive worktree deletion fails closed and skips that worktree.
|
|
- If an agentctl process exits unexpectedly, its owned agent subprocess group is
|
|
killed before agentctl shutdown completes.
|
|
- If the user sends Ctrl+C to a standalone Kandev process tree, agentctl does
|
|
not receive the terminal interrupt directly; it is stopped through backend
|
|
lifecycle shutdown, parent liveness, or an explicit backend signal.
|
|
- If the user sends Ctrl+C while running through `make start-debug`, the launcher
|
|
forwards a graceful stop to the backend and waits before escalating, rather
|
|
than immediately killing the backend process group.
|
|
- If startup reconciliation finds rows for archived tasks, deleted tasks, missing
|
|
sessions, or terminal sessions with no live runtime, it removes only rows that
|
|
are positively confirmed safe to remove.
|
|
- If cleanup intent or its resource snapshot cannot be persisted, the lifecycle
|
|
mutation fails before destructive cleanup begins; Kandev does not rely on an
|
|
unrecorded background goroutine.
|
|
- If an archived task is unarchived before cleanup completes, the worker cancels
|
|
remaining archive cleanup. Already completed resource removal remains valid and
|
|
the unarchive branch-recovery flow recreates the environment when possible.
|
|
|
|
## Persistence Guarantees
|
|
|
|
- Runtime cleanup intent survives backend restarts because `executors_running`
|
|
rows remain durable until cleanup succeeds or a retryable failure is recorded.
|
|
- Worktrees and task environment rows are not removed before runtime stop has been
|
|
attempted for every runtime row owned by the task.
|
|
- Startup reconciliation is allowed to recover from a previous backend crash by
|
|
reattempting cleanup for stale runtime rows.
|
|
- Pending and retryable task cleanup jobs survive restart and resume independently
|
|
of whether optional scheduled storage maintenance is enabled.
|
|
- Terminal failed task cleanup jobs survive restart for diagnosis but do not
|
|
resume automatically.
|
|
- Cleanup snapshots needed after task deletion survive without foreign-keyed task,
|
|
session, environment, or worktree rows.
|
|
- Historical worktree rows for archived tasks remain available to unarchive branch
|
|
recovery even after their on-disk directories are removed.
|
|
- Orphaned OS processes without any durable `executors_running` row are outside
|
|
normal cleanup guarantees; they may be handled by an explicit operator recovery
|
|
tool, but automatic task cleanup must not rely on process-name scanning.
|
|
|
|
## Scenarios
|
|
|
|
- **GIVEN** a task has a `WAITING_FOR_INPUT` session and an
|
|
`executors_running` row, **WHEN** the task is archived, **THEN** the runtime is
|
|
stopped by `agent_execution_id` before the row and worktrees are removed.
|
|
- **GIVEN** a task has a `COMPLETED` session but still has an
|
|
`executors_running` row, **WHEN** the task is deleted, **THEN** cleanup still
|
|
selects that row and attempts runtime shutdown.
|
|
- **GIVEN** runtime shutdown succeeds for every row owned by a deleted task,
|
|
**WHEN** cleanup finishes, **THEN** the task's `executors_running` rows and
|
|
worktrees are removed.
|
|
- **GIVEN** runtime shutdown times out for one row owned by an archived task,
|
|
**WHEN** cleanup finishes, **THEN** the row remains retryable with a diagnostic
|
|
error and worktree deletion does not erase the only runtime handle.
|
|
- **GIVEN** the backend restarts with an `executors_running` row for an archived
|
|
task, **WHEN** startup reconciliation runs, **THEN** it attempts cleanup for the
|
|
row instead of treating the archived task as active.
|
|
- **GIVEN** an `executors_running` row whose task session is missing and whose
|
|
local liveness handle refers to a dead host process, **WHEN** startup
|
|
reconciliation stops it and the stop reports the execution is not found, **THEN**
|
|
the row is pruned or repaired under the resume-safety invariant instead of being
|
|
preserved indefinitely.
|
|
- **GIVEN** a resumed durable cleanup job whose only remaining runtime target is a
|
|
confirmed-dead local runtime, **WHEN** the cleanup worker stops it and the stop
|
|
reports not found, **THEN** the job records a successful stop, completes owned
|
|
resource teardown, and does not re-enter `retry_wait` because of that runtime.
|
|
- **GIVEN** an `executors_running` row that is still alive on this host, **WHEN**
|
|
a stop reports not found for it, **THEN** the row is preserved and the outcome is
|
|
treated as retryable rather than pruned.
|
|
- **GIVEN** an `executors_running` row for a remote (SSH) or containerized runtime
|
|
whose local liveness is Unknown, **WHEN** a stop reports not found, **THEN** the
|
|
row is preserved with its remote handle and resume token intact and is not
|
|
pruned on the basis of a host-local not-found.
|
|
- **GIVEN** a session lookup during a stop fails with a non-not-found store error,
|
|
**WHEN** cleanup evaluates the result, **THEN** it preserves the row and retries
|
|
rather than treating the runtime as absent.
|
|
- **GIVEN** a confirmed-dead local row that still carries a resume token, **WHEN**
|
|
a not-found stop is reclassified as successful, **THEN** the row is repaired in
|
|
place (token and worktree preserved) rather than deleted, per
|
|
`RowMustBePreserved`.
|
|
- **GIVEN** agentctl is stopped while an ACP child process ignores stdin EOF,
|
|
**WHEN** the stop timeout expires, **THEN** the ACP process group is killed and
|
|
no ACP child is reparented to PID 1.
|
|
- **GIVEN** an ACP wrapper process exits after spawning a native child in the
|
|
same process group, **WHEN** agentctl completes shutdown, **THEN** the
|
|
remaining process-group descendants are terminated before shutdown is reported
|
|
complete.
|
|
- **GIVEN** standalone Kandev owns an active agentctl instance, **WHEN** the user
|
|
stops Kandev with Ctrl+C, **THEN** backend shutdown supervises agentctl
|
|
cleanup and waits for the instance stop window instead of letting agentctl exit
|
|
directly from the terminal interrupt.
|
|
- **GIVEN** Kandev is running under `make start-debug`, **WHEN** the user stops it
|
|
with Ctrl+C, **THEN** the launcher gives the backend a graceful shutdown window
|
|
before any force kill so active ACP process groups are reaped.
|
|
- **GIVEN** the `executors_running` query fails during archive cleanup, **WHEN**
|
|
cleanup evaluates destructive actions, **THEN** it logs the failure and does not
|
|
delete runtime tracking rows based on incomplete information.
|
|
- **GIVEN** a child task reuses its parent's `TaskEnvironment`, **WHEN** the child
|
|
is archived or deleted, **THEN** cleanup stops the child's runtime rows without
|
|
deleting the inherited parent worktree.
|
|
- **GIVEN** a parent task owns a `TaskEnvironment` that an active child session
|
|
still references, **WHEN** parent cleanup runs, **THEN** destructive
|
|
environment and worktree teardown is deferred until the child no longer holds
|
|
the environment.
|
|
- **GIVEN** the backend exits after a task is deleted but before its worktree is
|
|
removed, **WHEN** the backend restarts, **THEN** the durable cleanup job retries
|
|
using its captured resource snapshot.
|
|
- **GIVEN** a delete cleanup snapshot references a worktree that is already
|
|
absent, **WHEN** the cleanup worker runs, **THEN** the worktree step is treated
|
|
as complete and the job does not retry because of that absence.
|
|
- **GIVEN** a delete cleanup snapshot requests deletion of a task-environment row
|
|
that is already absent, **WHEN** the cleanup worker runs, **THEN** the row step
|
|
is treated as complete and the job does not retry because of that absence.
|
|
- **GIVEN** a cleanup job fails for a genuinely retryable reason, **WHEN** fewer
|
|
than eight attempts have run, **THEN** it enters `retry_wait` using the
|
|
documented backoff schedule.
|
|
- **GIVEN** a cleanup job fails on its eighth attempt, **WHEN** the worker records
|
|
the result, **THEN** it enters terminal `failed` with the final diagnostic and
|
|
is not automatically claimed again.
|
|
- **GIVEN** an archive cleanup job is pending, **WHEN** the task is unarchived,
|
|
**THEN** the job is cancelled and cannot delete resources created after
|
|
unarchive.
|
|
- **GIVEN** archive cleanup removed a local worktree, **WHEN** the task is
|
|
unarchived, **THEN** its historical worktree branch metadata remains available
|
|
for local/remote recovery.
|
|
|
|
## Out of Scope
|
|
|
|
- A general-purpose OS process sweeper that kills every process named
|
|
`codex-acp`, `claude-acp`, or `opencode`.
|
|
- UI changes for showing runtime cleanup failures.
|
|
- A user-facing action to replay a terminal failed cleanup job.
|
|
- New user-facing archive/delete controls.
|
|
- Changing the task/session state model beyond the cleanup guarantees described
|
|
here.
|