Commit Graph

20 Commits

Author SHA1 Message Date
Carlos Florêncio 0b2085688b feat(office): autonomous agent management layer (#914) 2026-05-16 20:07:57 +01:00
Carlos Florêncio a5108de926 fix(windows): make conpty Close idempotent and kill orphan agentctl (#900) 2026-05-15 16:04:35 +01:00
José Almeida f3397b4ad7 feat: docker and sprites executor improvements (#738)
* feat(orchestrator): generalize task environment reuse across executor types

Worktree, container, and sandbox IDs now flow uniformly from a persisted
TaskEnvironment back into LaunchAgentRequest through a single helper. This
gives Sprites session-to-session reuse the same first-class treatment that
Docker already gained from the GitHub-token-injection PR, and lets a fallback
launch refresh the environment row instead of leaving stale IDs in the DB.

- applyExistingEnvironment propagates WorktreeID, ContainerID (via metadata),
  and SandboxID (as sprite_name metadata) onto the launch request
- persistTaskEnvironment refreshes those IDs when the executor ends up
  creating new resources (e.g. the previous container was pruned)
- extractSandboxID lifts the sprite identifier out of executor response
  metadata so it can be persisted on the TaskEnvironment row

* feat(backend): add task-level reset environment action

Users sometimes need a clean slate — broken packages, stale caches, a
container that drifted into a bad state. Reset Environment is the explicit
escape hatch: tear down the recorded container, sandbox, and/or worktree
for a task and delete the TaskEnvironment row so the next session starts
fresh.

- service.ResetTaskEnvironment(taskID, ResetOptions) is the entry point.
  ResetOptions.PushBranch optionally git-pushes the workspace branch
  before teardown so committed work isn't dropped on the floor; a failed
  push aborts the reset and leaves the environment intact.
- A new EnvironmentDestroyer interface in the task service hides the
  per-resource teardown behind a single seam, implemented in cmd/kandev
  by delegating to lifecycle.Manager (containers, sprites) and
  worktree.Manager (worktrees, branch preserved).
- lifecycle.Manager.DestroyContainer / DestroySandbox dispatch to the
  Docker and Sprites executor backends without leaking model types into
  the lifecycle package.
- Reset is blocked while any session on the task still has an
  ExecutorRunning row (returns ErrSessionRunning → HTTP 409). Per-resource
  teardown is best-effort but the DB row is only removed once everything
  is confirmed gone or already missing, so a partial failure is retryable
  by clicking Reset again.
- Endpoint: POST /api/v1/tasks/:id/environment/reset.

* feat(web): add executor settings popover with reset environment action

A new button on the task top bar opens a popover showing the active
TaskEnvironment — executor type, status, container/sprite/worktree
identifiers, and a copy-ready 'docker exec' command for local Docker.
The popover hosts the destructive Reset Environment action so users have
one obvious place to inspect and manage the environment without
cluttering the New Session dialog.

The reset confirmation dialog requires explicit acknowledgement that
uncommitted changes will be lost. When the environment has a host-side
worktree it offers an opt-in 'push branch before resetting' checkbox so
committed work can be pushed first; the backend aborts the reset on
push failure.

- task-environment-api.ts: fetchTaskEnvironment + resetTaskEnvironment
- executor-settings-button.tsx: popover trigger + identity panel
- task-reset-env-confirm-dialog.tsx: destructive confirm with push opt-in
- task-top-bar.tsx: wire the new button into the right-side controls
- e2e: replace fresh-env-toggle test with reset-from-popover flow

* fix(docker): expand {workspace} placeholder in container WorkingDir

The agent runtime config carries `WorkingDir: "{workspace}"` for most ACP
agents. Mount sources were already expanded via expandMountSource, but
WorkingDir was passed through verbatim, so Docker rejected new containers
with:

  Error response from daemon: the working directory '{workspace}' is
  invalid, it needs to be an absolute path

Run the same expansion on WorkingDir, defaulting to /workspace when no
host WorkspacePath is set (the clone-inside-container case introduced by
PR #654).

* fix(docker-profile): persist Dockerfile/image_tag and apply tag override at runtime

Two bugs:

1. The Dockerfile editor and Image Tag input on the executor profile page
   stored their values in component-local useState inside DockerSections,
   not in the form state. Saving the profile never wrote them back, so
   "Use defaults" / typing in the editor / changing the tag never
   persisted — and the Build button was the only thing that consumed
   them. The same gap existed in the new-executor flow's
   buildProfileConfig.

   Lift `dockerfile` and `image_tag` into useProfileFormState (edit) and
   useCreateProfileFormState (new), and emit them in buildSaveConfig /
   buildProfileConfig so they round-trip through profile.config.

2. Even when an image_tag is set on the profile, the container builder
   ignored it. imageName was always derived from the agent runtime's
   hardcoded `kandev/multi-agent`, so a user who built a custom image
   like `kandev/agent:latest` would still see Docker fail with
   "No such image: kandev/multi-agent:latest".

   Propagate `profile.config.image_tag` → metadata
   (`image_tag_override`) → ExecutorCreateRequest → ContainerConfig,
   and let it override `rt.Image` in buildContainerConfig when set.

Also align the frontend default image tag (kandev/multi-agent:latest)
with the backend's hardcoded runtime default so users who don't override
end up with a working build.

* test(docker): cover container config build + Docker profile persistence

Adds two regression tests for the recent Docker fixes:

- container_config_test.go (Go unit): asserts buildContainerConfig
  expands `{workspace}` in WorkingDir (defaulting to /workspace when no
  host WorkspacePath is set) and that ContainerConfig.ImageTagOverride
  takes precedence over the agent runtime's hardcoded `rt.Image`. No
  Docker daemon required.

- docker-profile-persistence.spec.ts (Playwright E2E): creates a
  local_docker executor + profile, opens the profile edit page, clicks
  "Use defaults" to populate the Image Tag input + Dockerfile editor,
  hits Save, then refetches the profile via API and reloads the page to
  verify both fields round-trip through `profile.config`. Catches the
  prior regression where DockerSections owned the form state locally
  and never wrote it back. Runs against the existing E2E backend that
  has KANDEV_DOCKER_ENABLED=false — we only test the persistence path,
  not container creation.

Adds an api-client helper (`getExecutorProfile`) for the round-trip
assertion.

* feat(docker): self-contained Dockerfile + mount agentctl + capabilities adapter

Make the Docker executor work with a Dockerfile users can actually build,
without depending on a maintainer-shipped image:

- The container now launches a small bootstrap (`sh -c "$KANDEV_PREPARE_SCRIPT
  ; exec /usr/local/bin/agentctl"`) regardless of the image's ENTRYPOINT.
  The agent CLI is no longer wired through container Cmd; agentctl receives
  it later via its CreateInstance HTTP API.
- Host agentctl-linux binary is mounted into the container at
  /usr/local/bin/agentctl via the existing AgentctlResolver. User-built
  images don't have to bake it in.
- Default Dockerfile in the executor profile UI replaced with a working
  node:22-slim + git + curl image — runs npx-based ACP agents (Claude,
  Codex, OpenCode, Auggie) out of the box.
- lifecycleAdapter now exposes RequiresCloneURL / ShouldApplyPreferredShell
  so the orchestrator can correctly identify containerized executors and
  resolve the clone URL. Without this, applyRepositoryConfig short-circuits
  on `e.capabilities == nil`, the cloneURL is never resolved, and the
  prepare script fails with `repository '{{repository.clone_url}}' does
  not exist`.
- applyRepositoryConfig also surfaces the resolved clone URL to script
  metadata so the prepare script can interpolate {{repository.clone_url}}.
- docker.ContainerConfig gains an Entrypoint field, plumbed to the docker
  client.

Verified end-to-end on a local stack with the new minimal image: the
container starts, bootstrap clones the GitHub repo, agentctl launches and
hands off to claude-acp inside the container.

* fix(docker): patient health check + resilient bootstrap

Two follow-ups to the Docker bootstrap work:

- The 30 retries × 500ms = 15s budget for agentctl health was too tight.
  In the previous flow agentctl was a separate exec from the prepare
  script, so it came up immediately. Now it runs after the prepare
  script (which does git clone, optional installs), so first launch can
  easily exceed 15s and produce a misleading "agentctl not healthy after
  30 retries" error. Bumped to 240 retries (120s) and surfaced the last
  underlying error in the message so failures point to the real cause.

- Prepare scripts commonly start with `set -e`/`set -eu`; running them
  via `eval` propagated the option to the outer bootstrap shell, so any
  failure killed the bootstrap before `exec /usr/local/bin/agentctl`.
  Run prepare in a subshell so its `set -e` is contained, and start
  agentctl regardless. If prepare fails, agentctl still comes up — the
  host can connect, surface the failure, and let the user debug from
  the Executor Settings popover.

* fix(executor-profile): persist remote auth secrets/credentials for Docker profiles

The Remote Credentials card (per-agent secret picker, plus the GitHub
credential checkbox) is rendered for any remote executor — Sprites,
local_docker, or remote_docker — but the save path in both the create
and edit pages only emitted `remote_auth_secrets` and `remote_credentials`
when `isSprites` was true. Selecting a secret for the Claude / Codex /
Auggie ANTHROPIC_API_KEY-style entry on a Docker profile updated form
state, the dropdown showed it as picked, but Save Changes silently
dropped the value before posting to the API. On reload the agent showed
as "Not configured", and any session start failed with
`{"code":-32000,"message":"Authentication required"}`.

Switch the guards to `isRemote` so the same data persists for all remote
profile types. The backend orchestrator already propagates
`profile.config.remote_auth_secrets` and `remote_credentials` through to
metadata for any containerized executor and resolves them via
`applyContainerCredentials` → `resolveAuthSecrets`, so no backend change
is needed once the values actually land in the saved profile.

* fix(executor-profile): live Configured badge + session/load resilience + live container status

Three independent fixes that all surface together while iterating on the
Docker executor flow:

- Remote Credentials card: the per-agent "Configured" / "Not Configured"
  badge was driven by useState that only seeded once on mount. Picking a
  secret in the dropdown updated the parent's `agentEnvVars` map but the
  local `choice` never moved to `"env"`, so the badge stayed grey until
  the page was reloaded. Derive `choice` straight from props every render
  so the badge tracks live state.

- ACP session/load: when the agent process restarted between turns
  (common with claude-agent-acp because its session map is in-memory),
  the host's session/load call failed with JSON-RPC -32002 "Resource not
  found" and the entire launch errored out. Treat that as the same kind
  of "session is gone" signal as -32601 / capability=false and fall back
  to session/new. Worst case the agent loses prior context; best case
  the user just keeps typing.

- Executor Settings popover: dropped Execution ID and Environment ID
  rows (debug-only noise users don't need), kept Container ID + the
  copyable `docker exec -it … bash` line, added a live status badge
  with tones for running / exited / paused / dead / error / missing,
  and a 3-second polling interval so the badge follows the real
  container state. New `GET /api/v1/tasks/:id/environment/live`
  endpoint plumbs Docker `inspect` data through the lifecycle manager
  → task service → handler. ContainerLiveStatus mirror type at the
  service layer keeps lifecycle types out of the public surface.

* feat(web): gate agent profiles by executor profile credentials

Filter the agent profile dropdown in both the Create Task dialog and the
New Session dialog to only show agents that are actually configured for
the selected executor profile.

For local/worktree executors, no gating (agents need no extra creds).
For Docker/Sprites profiles, an agent is "configured" iff one of its
remote-auth methods has a value in `remote_credentials` or a non-null
secret in `remote_auth_secrets`. Agents without an entry in the remote
auth catalog (Copilot, Amp, OpenCode, TUI, mock) cannot carry creds on
remote executors and are blocked.

Spec IDs match `AgentProfileOption.agent_name` (the registry-type string
like "claude-acp"); `agent_id` is a DB UUID and unrelated to the catalog.

The Create Task footer surfaces a "No agent profile is configured for X"
tooltip and disables submit when the selected executor profile has no
compatible agents. The New Session dialog auto-swaps to the first
compatible agent and shows the same banner inline.

`useRemoteAuthSpecs` returns a `loaded` flag so callers don't filter
during the initial fetch (would otherwise hide everything).

* feat(docker): make mock-agent runnable inside Docker for E2E

Lay the backend groundwork so the existing mock-agent can run inside
local_docker containers — needed for real-Docker E2E tests that don't
require Anthropic/OpenAI API keys.

Container builder bind-mounts a host mock-agent binary at
/usr/local/bin/mock-agent when KANDEV_MOCK_AGENT_LINUX_BINARY is set
(or build/mock-agent-linux-amd64 sits next to the kandev binary), the
same pattern already used for agentctl. MockAgent.BuildCommand always
resolves via PATH so the same command works on host (E2E PATH includes
bin/) and in containers (mounted at /usr/local/bin/mock-agent).

Mock now declares non-nil empty RemoteAuth so it counts as a
remote-capable agent. The remoteauth catalog includes specs even with
zero methods, and the frontend gating helper treats an empty-method
spec as "no credentials needed" — letting mock-agent pass the
configure-on-executor check that hides uncatalogued agents on remote
executors.

repositoryCloneURL falls back to the local origin remote URL when the
repo has no provider info, so local-only repos with a real remote
(including file:// remotes used in E2E) work on remote executors that
clone inside the container.

Makefile: build-mock-agent-linux target mirrors build-agentctl-linux.

* test(docker-e2e): add real-Docker Playwright project + first spec

A new Playwright project — \`docker\` — runs container-bound E2E specs
against a backend with KANDEV_E2E_DOCKER=1. The fixture probes for a
Docker daemon and skips the worker entirely when none is reachable, so
contributors without Docker can still run the rest of the suite.

The fixture builds a tiny kandev-agent:e2e image once per machine
(node:22-slim + git/curl), pre-seeds a local_docker executor profile,
and seeds a local repo with a file:// origin so the executor's
clone-inside-container path resolves without network access. A small
WS-based session.launch helper is added to the api-client for tests
that need to launch additional sessions on an existing task.

First spec — docker-launch.spec.ts — covers the smoke path: a session
launches in a real container, the task environment records its
container_id, and \`docker inspect\` confirms the container exists.
Two follow-up specs (multi-session env reuse + recovery from external
container removal) are wired up but marked test.fixme with comments
pinpointing the backend gaps that block them: persistTaskEnvironment
isn't called for workflow-prepared sessions (so task_environment_id
stays empty on those rows), and DockerExecutor.reconnectToContainer
loses the agentctl handshake auth token across launches (so reconnect
401s and falls back to a fresh container every time).

Run locally with: \`pnpm --filter @kandev/web exec playwright test
--config e2e/playwright.config.ts --project=docker\`.

* ci(e2e): add dedicated Docker E2E job

The new \`docker\` Playwright project needs a Docker daemon and the
linux/amd64 agentctl + mock-agent binaries bind-mounted into containers.
Pin the existing sharded job to chromium + mobile-chrome so the docker
project no longer leaks into every shard (where it would fail seeding
without KANDEV_DOCKER_ENABLED), and add an \`e2e-docker\` job that:

- Verifies the Docker daemon is reachable on the runner.
- Downloads the same backend artifact (now including the linux helper
  binaries, built via \`make build-backend-linux-helpers\`).
- Runs \`playwright test --project=docker\` with KANDEV_E2E_DOCKER=1.
- Cleans up any kandev-managed containers at teardown so leaks don't
  poison the next run.

* ci(e2e): include docker job in merged HTML report

Upload the docker job's blob report under the \`blob-report-*\` glob the
merge job already consumes, and add e2e-docker to e2e-report's needs so
the final HTML report covers both the sharded chromium runs and the
docker run.

* ci(e2e): add workflow_dispatch for ad-hoc runs and draft-PR verification

* fix(build): always produce linux/amd64 agentctl as part of \`make build\`

Remote executors (local Docker, remote Docker, Sprites) bind-mount the
linux/amd64 agentctl binary into the container. On non-Linux hosts (e.g.
macOS dev installs) running plain \`make build\` produced only the host
binary, so the first Docker session would fail with:

  failed to build container config: agentctl linux binary not found

The top-level \`make dev\` already special-cased non-Linux hosts to chain
\`build-agentctl-linux\`, but \`make build\` and \`make start\` skipped it.
Add it as a dependency of the standard backend build so every install
ships a usable linux variant. Cross-compiling agentctl is fast (~1-2s)
and the binary is ~20 MB, so the cost is negligible.

Users who hit the error can run \`make build-backend\` (or \`make build\`
in apps/backend) to regenerate; setting KANDEV_AGENTCTL_LINUX_BINARY
remains a manual override.

* fix(release): ship linux/amd64 agentctl in every release artifact

Remote executors (local Docker, remote Docker, Sprites) bind-mount the
linux/amd64 agentctl into the container. The release pipeline only
shipped a host-arch agentctl, so users who installed via the bundled
binaries (npm/Homebrew/etc.) hit the same "agentctl linux binary not
found" error the first time they tried a Docker executor — even on
Linux, since the resolver expects the -linux-amd64 suffix.

Changes:

- .github/workflows/release.yml: cross-compile agentctl-linux-amd64 in
  the build-bundles matrix step (CGO off so it works from every host).
  Bundle ships kandev + host agentctl + agentctl-linux-amd64 in
  dist/kandev/bin/. The AgentctlResolver finds it next to the kandev
  binary automatically.
- Dockerfile: same idea for the container image — produce both variants
  in the Go builder stage and copy the linux-amd64 helper into
  /app/apps/backend/bin/ so the resolver picks it up inside the image.

A previous commit added the same dependency to \`make build\` for local
dev installs; this commit closes the gap for end users.

* fix: address PR review feedback (CI, CodeRabbit, Greptile, Claude)

Backend:
- service_task_environments: fail closed when checkAnySessionRunning
  errors so a transient DB hiccup can't bypass the running-session
  guard and tear down a live container/sandbox; teardown is now truly
  best-effort with errors.Join across container/sandbox/worktree, so a
  failed Docker call doesn't permanently orphan the worktree on retry.
  Adds tests for the running-check error path and the multi-resource
  best-effort path.
- executor_execute: propagate GetTaskEnvironmentByTaskID errors instead
  of silently launching a fresh environment that orphans existing
  containers/worktrees; gate environment reuse on matching ExecutorType
  so a profile switch doesn't pass stale PreviousExecutionID/container
  IDs to the wrong backend.
- task_environment_handlers: reject malformed JSON on POST
  /tasks/:id/environment/reset (still allow empty body) so a destructive
  endpoint doesn't silently proceed with a bad payload.
- worktree (PushEnvironmentBranch): bound git push with a 30s context
  timeout and disable interactive prompts via GIT_TERMINAL_PROMPT=0
  so a stalled remote can't hang the request thread.
- manager_environment: explicitly call ensureClient() before
  ContainerMgr() in DestroyContainer/GetContainerLiveStatus so a missing
  Docker daemon surfaces as "docker unavailable" rather than the
  generic "container manager not initialized".
- container: WorkingDir always uses the in-container /workspace path
  rather than the host workspace path (Docker would happily start in an
  unrelated directory in host bind-mount mode); bootstrap now logs the
  prepare-script exit code so failures show up in container logs.
- docker/client: CreateContainerInteractive honors ContainerConfig.Entrypoint
  so the same config struct produces consistent behavior on both creation
  paths.

Frontend:
- executor-settings-button: extract useTaskEnvironment hook (component
  shrinks below the 100-line limit); only treat 404 as empty env (other
  errors no longer wipe a valid environment); track "loaded once" via a
  ref so loadEnv doesn't depend on env state — that dep was creating a
  new function identity on every successful poll, cancelling the
  3-second interval and turning the popover into a tight loop hammering
  /environment/live; copy-shell command uses sh instead of bash so it
  works on user-built minimal images.
- task-reset-env-confirm-dialog: AlertDialogAction onClick calls
  e.preventDefault() so Radix's auto-close doesn't dismiss the dialog
  before the async reset completes (loading spinner now visible; close
  is parent-controlled via onOpenChange).
- remote-credentials-card: track env method in selectedIds the same way
  files/gh_cli_token are tracked, and let initialChoice match either
  selectedIds OR envSecretId. First-time env-credential setup for an
  agent that exposes both files+env was broken: clicking the env radio
  never updated state, choice re-derived to "none", and the secret
  picker disappeared before the user could pick anything.
- e2e/executor-reuse: select the acknowledge checkbox by label, not by
  positional last(), so adding another checkbox to the dialog can't
  silently target the wrong control.

Format pass on prettier-flagged files; gofmt fixed two whitespace
nits in lifecycle files.

* test(executor): cover applyExistingEnvironment executor-type mismatch guard

* fix(docker): connect agentctl through published ports

* fix(web): keep remote auth selection stable

* fix(docker): reuse task containers across sessions

* fix(docker): resume task environment after refresh

* fix(docker): track executor containers for cleanup

* fix(docker): explain missing agent credentials

* fix(docker): surface container launch progress

* fix(docker): stabilize container reuse and feedback

* fix(docker): show task titles in container lists

* fix(docker): use container icon for docker tasks

* fix(orchestrator): align environment reuse after rebase

* fix(sprites): reconnect sessions on existing sandboxes

* fix(docker): use container icon for local docker executor too

Local docker tasks were rendering the Docker brand "chip" mark while
remote docker uses the container glyph. Map both variants to
IconContainer so the executor surfaces look consistent.

* feat(sprites): provision a fresh sandbox when the persisted one is gone

Resuming a Sprites task whose sandbox was destroyed upstream returned
"sprite not found" and dead-ended the user. Detect the upstream 404 via
a typed ErrSpriteNotFound sentinel, mark the reconnect step skipped with
a user-facing warning explaining what happened, swap the prepare plan
in place to the fresh-provision shape, and continue the launch under a
new sandbox name on the same branch.

The progress plan is now mutable so the reporter can stream the fresh
steps live after the reconnect-then-fresh transition. Only the explicit
not-found case triggers fallback; other reconnect failures still
surface as before, and the fallback fires at most once per launch.

* feat(web): friendlier header for sandbox-recovered prepare runs

When the only warning on a completed prepare is the missing-sandbox
fallback notice, render "Environment prepared on a fresh sandbox" in
the header instead of the generic "Environment prepared with warnings".
The fallback row still carries its own amber warning callout so the
user understands why a fresh sandbox was provisioned.

* feat(web): unify executor surfaces in the right-side popover

The desktop task top bar carried a small cloud icon (RemoteCloudTooltip)
in the breadcrumb cluster while the right-side Executor popover showed
the same kind of info — two surfaces for one concept. Remove the cloud
icon from the desktop top bar; kanban cards, task list rows, and the
mobile top bar keep theirs since they have no popover.

The popover button now picks its icon by executor type (cloud for
sprites, container for docker, server for local PC) using a shared
getExecutorStatusIcon helper. While the prepare progress is preparing
the icon swaps to a spinner so the user can see at a glance that work
is in flight.

The popover content gains a Preparing-environment section that streams
"Step N of M: <step>", an amber inline note when the missing-sandbox
fallback is in progress, an Environment-ready row with duration on
success, and a failed-step summary on failure. Polling promotes to the
active 3s cadence while preparing so the icon flips to ready without
the user opening the popover.

* fix(docker): swap container glyph for a plain box icon

IconContainer's vertical ribbing reads as a microchip on small sizes.
IconBox is an unambiguous outlined cube. Use it for both docker
variants so the executor surfaces look like a box, not a chip.

* fix(orchestrator): preserve base branch on resume for clone-based executors

Sprites and Docker resumes were sending the launch request with an empty
BaseBranch, so the in-sandbox prepare script's `git clone --branch <X>`
shifted positional args and failed with "repository '/workspace' does
not exist". applyResumeRepoConfig only set BaseBranch inside the
worktree-specific block; widen that to all executors that require a
clone URL.

Local executors are intentionally left untouched: LocalPreparer reads
BaseBranch and forces a `git fetch && git checkout` against the user's
own workspace, which would clobber the "use current state" path.

* feat(sprites,docker): generate a kandev feature branch for clone-based executors

Sprites tasks committed straight onto the chosen base branch because
SpritesPreparer didn't populate WorktreeBranch and the orchestrator's
"forward env.WorktreeBranch on reuse" check only fired for Docker.

Mirror the Docker preparer's TaskBranchName generation in Sprites,
extract a shared `nonWorktreeTaskBranch` helper that uses a
deterministic suffix derived from the task ID (so legacy env rows that
predate this change resolve to the same branch on every resume), and
let the orchestrator forward env.WorktreeBranch for any clone-based
executor — Docker or Sprites.

* fix(sprites,docker): prefer existing remote feature branch on resume

When a sandbox is destroyed and we provision a fresh one for the same
task, the agent may have previously committed and pushed onto the
feature branch. The previous prepare scripts only ran `git checkout -b
{{worktree.branch}}` which would overwrite that progress with a fresh
branch off the chosen base.

Try `git fetch origin <feature>` first; on success, `git checkout -B`
against the remote tip so the agent picks up where it left off. Falls
back to a fresh local branch when origin has nothing.

Also add the same checkout block to the Sprites prepare script — it was
missing entirely, so every Sprites session was committing onto main.

* test(sprites): lock in informative branch name in fallback warning

Now that BaseBranch is preserved on resume for clone-based executors,
the missing-sandbox fallback warning detail names the actual branch
("Cloning … on the same branch main") instead of the "(unknown)"
placeholder. Add a regression test so a future change to the resume
metadata propagation can't silently regress that copy.

* fix(sprites): preserve sandbox on plain agent stop so resume is fast

StopInstance was calling sprite.Destroy() unconditionally, so every
session stop tore down the cloud sandbox. Resume then always fell into
the missing-sandbox fallback, re-cloned the repo, re-ran the prepare
script, and lost the agent's working tree. That's a 30s+ round-trip
for what should be "stop the process, then re-attach later".

Limit Destroy to terminal lifecycle reasons (task/session
deleted/archived), the same set runTerminalCleanupScript already uses.
Plain stops just close the proxy and leave the sandbox alive.

Also update the existing Docker e2e regex: the feature-branch suffix is
now a deterministic 6-hex-char prefix of the task UUID rather than a
random 3-char tail, so resume on legacy env rows lands on the same
branch.

* test(docker): cover stop + resume → same container, branch preserved

Adds a Docker e2e test that exercises the user-visible stop-then-resume
flow: launch session, stop it via WS session.stop, launch a fresh
session on the same task, and assert the same task environment +
container ID are reused and the kandev feature branch is preserved.

Also nudge the executor popover padding: when the prepare-status
section is rendered above the Sprites/Docker info block, the cumulative
top+bottom paddings looked too generous around "Sprites sandbox" /
"Local Docker". Tighten both sections vertically without affecting the
horizontal rhythm or the bottom action row.

* feat(web): show "Resuming session" feedback in popover during reconnect

The bottom Resume button on the chat panel was leaving users staring at
nothing for the 5-30s reconnect window: prepare-progress events are
intentionally suppressed on resume (the workspace is already there), so
the popover sat in the idle phase even while session.state was STARTING.

Synthesize a "resuming" phase from session state when no prepare events
are flowing, rendered as a spinner row in the popover with copy
"Resuming session — Reconnecting to the existing environment…". The
button icon also flips to the spinner so the toolbar advertises the
in-flight work without the user having to open the popover.

Extract the prepare-status reducer + section component into their own
file so the main popover module stays under the 600-line ESLint cap.

* fix(codex): align with the standard SessionDirTemplate pattern

Codex was the only agent that bind-mounted its host home into the
container via an explicit `Mounts: [{home}/.codex → /root/.codex]`
entry, while every other ACP agent uses `SessionConfig.SessionDirTemplate`
+ `SessionDirTarget`. Move codex onto the same surface so the
host-state isolation work in the next commit covers it uniformly.

This change alone does not stop the host-state leak — the bind mount
still resolves to the host's ~/.codex for now — but it removes the
last agent-specific branch in the runtime config so the docker
container manager can apply one rule for all agents.

* fix(docker): isolate per-container agent state from the host home

Every ACP agent's session dir was bind-mounted from ~/.<agent> on the
host into /root/.<agent> in the container. That exposed host state DBs,
session caches, and lock files to the container — codex's rollout DB
caches absolute /Users/... paths, which don't resolve inside Linux and
the agent silently loses prior thread context on resume.

Replace the host bind mount with a kandev-managed per-container session
dir under <kandev-home>/agent-sessions/<instance_id>/. Before the
container starts, selectively copy the auth-relevant files (the same
files Sprites already copies via RemoteAuth.SourceFiles) into that
dir; everything else — state DBs, sessions/, lock files — stays on the
host and the agent rebuilds them inside the container with
container-local Linux paths.

The change is agent-agnostic: any agent that has SessionDirTemplate
+ SessionDirTarget gets the new behavior automatically. Cleanup runs
on destructive stops only (task/session deleted/archived), mirroring
the Sprites preserve-on-stop rule so plain stops let resume re-attach
to the same agent state.

* fix(executor): make the kandev feature-branch checkout an invariant postlude

Profiles created in the UI snapshot the *then-current* DefaultPrepareScript
into their stored prepare_script field. Updates to the default — like
shipping the worktree-branch checkout block — never reach those older
profiles, so users keep landing the agent on the chosen base branch
(main) even though the env row carries a feature branch name.

Append a kandev-managed postlude to whatever prepare_script ran (custom
or default), wrapped in a subshell + `|| true` so it's idempotent and
never blocks agentctl from starting. The postlude prefers the remote tip
when origin already has the branch (resume after a destroyed sandbox),
falls back to checking out the existing local branch, and finally
creates one off the current HEAD.

Sprites needed the WorktreeProvider in resolvePrepareScript so the
postlude's `{{worktree.branch}}` actually expands; Docker already had
it.

The Docker e2e gains a regression case that explicitly stores an older
default-style script (clone, no checkout) and asserts the agent still
ends up on the feature branch — locking the contract in regardless of
which default the user happens to have stored.

* fix(web): tighten executor popover padding under the env header

The Sprites/Docker info block under the Environment-ready row had too
much vertical breathing room — the header-to-fields gap and the
inter-row spacing piled up and the popover felt airy. Trim the section
to match the snug rhythm of the rest of the popover (px-3 py-2,
1.5-unit gap before the field list, 1-unit gap between rows). Also
unify the Environment-ready row from p-3 to px-3 py-2 so the dividers
between sections sit at the same vertical cadence.

* fix(web): tighten executor popover bottom padding too

The previous tighten only fixed the top half — pb under the field rows
plus the Reset-row's full p-2 still left a generous gap below the last
"Sprite ..." line. Same fix applies whether the popover is showing
Docker or Sprites.

EnvironmentInfo: pt-2 pb-1.5 (was py-2) so the bottom field hugs the
divider, and the Reset-row goes from p-2 to px-2 py-1.5 so the button
sits closer to the text above.

* fix(web): drop the redundant Environment-ready row + tighten popover top

The READY badge next to the executor name already conveys ready-state;
the dedicated "Environment ready · 12s" row above the executor info
just added vertical noise. Skip rendering for the ready phase (other
phases that surface non-redundant info — preparing, resuming, failed —
still render). Drop top padding on the executor info section now that
nothing sits above it on the happy path.

* fix(web): move executor info to the left top bar, hover-to-open, no border

The executor section is informational, not a button — readiness state +
sandbox/container/branch fields. Move it to the left top bar (next to
the integrations menu) where it sits alongside the other contextual
info, drop the outline-button styling, and switch from click-to-open to
hover-to-open via HoverCard with 150ms open / 250ms close so the cursor
can travel to the destructive Reset button without flickering.

The trigger is a borderless icon-with-status-dot span; tabIndex keeps
it keyboard-accessible. Reset still works — it opens the existing
confirmation dialog independently of the hover card.

Tests: pointerEnter instead of click for the hover-to-open path.

* refactor(cleanup): drop dead code, dedupe predicates, unify token injection

Tier 1 of the post-review cleanup. Each item is independently safe; bundling
because they're all small, mechanical, and lint-passing.

- fix(clone): unify injectTokenIntoURL across Docker and Sprites — Sprites
  used to ignore GH_TOKEN entirely (a user with only GH_TOKEN set got 401
  on Sprites clone but worked on Docker). Promote one
  `injectGitHubTokenIntoCloneURL` helper, accept both env vars
  (GITHUB_TOKEN wins, matching Docker), and use the documented
  `https://x-access-token:T@github.com/` form on both surfaces.
- fix(executor-popover): swap the <span tabIndex role="button"> trigger
  for <Button variant="ghost"> so Enter/Space activation, focus rings on
  Safari, and disabled semantics come for free.
- test(docker): rewrite TestDockerStopInstance_PreservesSessionDirOnPlainStop
  to drive the real DockerExecutor.StopInstance path with ContainerID=""
  (skips the docker daemon, exercises the kandev-side cleanup branch).
  Previous version only iterated shouldRunExecutorCleanup strings — would
  pass even if StopInstance always cleaned up. Now table-driven over
  preserve+destructive reasons with on-disk asserts.
- refactor(orchestrator): drop dead `_ = arg` parameters from persist*
  helpers (persistResumeState, persistModelSwitchState, persistLaunchState).
  Brings signatures from 9→6 args, under the funlen lint cap.
- refactor(orchestrator): collapse byte-identical isContainerizedExecutor
  ≡ isCloneBasedExecutorType predicate into one.
- refactor(lifecycle): drop dead getMetadataBool, FileUploader.RunCommand,
  expandMountSource {home} arm (no production agent ships {home} in
  Mounts; codex_acp_test asserts it), dockerTaskBranch one-line wrapper.

* refactor(scripts): postlude owns worktree-branch checkout

Strip the inline `git checkout -B` block from the Docker and Sprites
default prepare scripts. The kandev-managed postlude
(KandevBranchCheckoutPostlude) is appended unconditionally after every
user prepare script, so keeping the same logic inline made the default
and the postlude two sources of truth that could drift.

* refactor(lifecycle): drop dead instance_port persistence entry

`instance_port` is only used as a zap log field — it's never written to
the executor metadata map, so carrying it across resume in
`persistentMetadataKeys` was a no-op.

* refactor(lifecycle): drop legacy {home} session-dir fallback

ExpandSessionDir kept a `{home}/<dotdir>` fallback for the case where
kandevHomeDir was unresolved. In production, ResolvedHomeDir() always
returns a non-empty path, so the fallback only ever fired in tests
that hadn't been updated to pass an instance ID. Returning the empty
string in that case makes container.go skip the bind mount — the
correct behaviour, since without a kandev home there's no session dir
to mount.

* refactor(orchestrator): collapse env-reuse helpers into reuseExistingEnvironment

The two env-reuse helpers (`applyExistingEnvironment` and
`applyExistingEnvironmentRuntimeMetadata`) were always called together
and shared the same intent: carry the previous TaskEnvironment forward
into the launch request. Merge them into a single
`reuseExistingEnvironment(ctx, req, env)` so the executor-type gate
runs once for both env-level fields and runtime metadata, and callers
have a single entry point.

Document the layered metadata rule (running wins for container_id;
env wins for sprite_name and other stable IDs) inline so the next
reader doesn't have to reverse-engineer it from two functions.

* refactor(web): lift prepare-summary reducer + isFallbackNoticeStep into lib/prepare

Move the popover's prepare-summary reducer (and the
`isFallbackNoticeStep` helper duplicated word-for-word in
`prepare-progress.tsx`) into `apps/web/lib/prepare/summarize.ts`. The
hook moves to `hooks/domains/session/use-prepare-summary.ts` to match
the project's domain-organized hook layout.

The popover's PrepareSummary type and the chat panel's EffectiveStatus
serve different UIs, so the panel keeps its own `deriveStatus` — only
the literal duplicate (`isFallbackNoticeStep`) is shared.

* refactor(web): split executor-settings-button by concern

Slice the 471-line button file into three modules with single
concerns: the popover shell stays in `executor-settings-button.tsx`
(168 lines, just trigger + popover layout + icon/status dot),
`executor-environment-info.tsx` owns the env detail rows + status
badge, and `hooks/domains/session/use-task-environment.ts` owns the
fetch/poll/reset lifecycle alongside other session hooks.

* refactor(e2e): extract docker + session helpers from docker-launch.spec

Pull the inline docker CLI helpers (`dockerInspectExists`, `dockerStop`,
`dockerCurrentBranch`, etc.) and the generic session-state pollers
(`waitForLatestSessionDone`, `waitForSessionDone`,
`waitForSessionEnvironment`) into `e2e/helpers/docker.ts` and
`e2e/helpers/session.ts`. The spec drops from 763 to 647 lines and the
helpers can be reused by future docker / session tests.

* test(scripts): execute postlude under bash -e against a real git repo

Pair the static-content assertion with a behaviour test that runs the
postlude as a real shell script. Three scenarios exercise the three
branches inside the snippet: remote tip exists (track origin), no
remote (fresh local branch), and already on the target branch
(idempotency). This catches regressions where the snippet still parses
as bash but no longer does the right thing — wrong refspec, missing
-B, swallowed exit code, etc.

* test(sprites): integration check for stop preserve + resume reconnect path

Each piece of the resume-fast path is unit-tested separately
(StopInstance keeps the token cache, spritesShouldReconnect reads
sprite_name, the reconnect plan has 3 steps). This new test wires them
together end to end so a future change that accidentally strips
sprite_name on stop or forgets to look at metadata on launch fails
loudly here, not at runtime.

* test(sprites): assert plan ordering instead of absolute indexes

Replace `plan.index(spriteStepWaitHealthy) == 1` with an
`assertPlanOrder` helper that checks "X runs before Y". The contract
the test guards is the order of create-sprite → wait-healthy →
agent-instance, not their absolute slot numbers — adding a new step
shouldn't break the test if the order is preserved.

* test(web): hoist test constants above vi.mock factories

Move SESSION_ID and friends above the vi.mock factories so the mock
closures don't depend on vitest hoisting magic capturing TDZ-undefined
locals. The previous layout worked because factories aren't invoked
until the module under test is imported (after const initialization),
but a refactor that imports earlier would break the closure silently.

* fix(seeder): contain localFileUploader writes inside the session root

CodeQL flagged go/path-injection on the localFileUploader.WriteFile
sink: instanceID flows into the write path, and a malformed value
would let writes escape the kandev session root and clobber arbitrary
host files. Bind the uploader to its session root and reject any path
that filepath.Rel resolves to a "../" escape. Production callers
always pass an internally generated instanceID; the check is defense
in depth against a future caller plumbing in user input.

* refactor(docker): split reconnectToContainer + cover reconnect helpers with unit tests

Address recurring review feedback on the docker reconnect path:

- reconnectToContainer was 98 lines (over the 80-line repo limit). Split
  into ensureContainerRunning (inspect + start + re-inspect) and
  bringupAgentctl (health-check, find-or-create instance, transparent
  re-handshake). The orchestration body now reads top-down at 49 lines.
- resolveDockerEndpoint and waitForAgentctlHealth grew real branches in
  this PR (host-port lookup with container-IP fallback; 240×500ms
  retry loop) but had no direct tests. Introduce narrow hostPortLookup
  / healthChecker interfaces, satisfied by *docker.Client and
  *agentctl.ControlClient, so both functions are now driven by stubs
  that exercise happy-path, fallback, retry-until-healthy, exhaustion,
  and context-cancellation branches.
- Add direct unit tests for the docker package's pure helpers
  (normalizeDockerHostIP, buildDockerPortBindings) and for
  dockerAgentctlPortBindings — a regression in the published port
  range or host IP would silently break container reconnect.

* test(handlers): cover ensureShellExecution stale-execution recovery

The recovery branch (Failed execution → CleanupStaleExecutionBySessionID
→ relaunch) is the load-bearing piece of shell session resilience and
had no direct test. Extract the body into a free function that takes a
narrow shellLifecycle interface so the four branches are testable
without standing up a real lifecycle.Manager:
  - happy path (running execution, no cleanup)
  - stale recovery (Failed → cleanup → fresh)
  - initial GetOrEnsureExecution failure
  - cleanup failure must NOT trigger a re-launch loop
  - relaunch failure surfaces a clear "recover execution" error

* fix(backend): clear lint blockers from CI lint job

CI's only-new-issues lint surfaced three real items left over from
recent additions:

- container.go bootstrap snippet had two `fi` shell tokens flagged by
  dupword. Tagged with the same nolint directive used by the
  KandevBranchCheckoutPostlude (two distinct shell blocks closing).
- DockerExecutor.CreateInstance was 89 lines (over funlen 80). Split
  the body into helpers: reportCreateInstanceProgress (deferred
  progress reporter), tryReconnect (gates the resume path),
  seedSessionDir (per-container auth seed), buildContainerLaunchConfig,
  and buildCreatedInstance. CreateInstance is now a 30-line top-down
  orchestrator.
- executor_execute.go was 887 effective lines (over revive 800). Move
  reuseExistingEnvironment + its helpers (latestExecutorRunningForEnv,
  executorRunningMatchesEnvironment, applyExecutorRunningMetadata,
  ensureLaunchMetadata) into a sibling executor_environment_reuse.go.

* fix(web): only block submit during real env prepare, not every STARTING

The "agent still being set up" submit gate was triggering on every
session that briefly transitions through STARTING — including local
quick-chat, which has no prepare phase and has its agent live within
~100ms. The eager-init quick-chat e2e tests (chat/quick-chat.spec.ts
and chat/message-queue.spec.ts) wait for the editor to become
editable, then press Cmd+Enter. The race made wrappedSubmit a no-op
exactly when the test was firing — message dropped, agent never saw
it, downstream assertions failed.

Split the merged isStarting flag in useSessionState: keep the broad
flag (still used for the workspace-prep loading UI), and expose
isPreparingEnvironment as a separate signal that's only true while
prepareProgress.status === "preparing". Thread it through chat-input-
area → chat-input-container → useChatInputContainer and gate the
submit-disabled tooltip on it instead of the broad isStarting.

Net: Docker / Sprites bootstrap still surfaces the "agent still being
set up" message and disables submit; quick-chat / local sessions stop
silently dropping in-flight messages.

* fix(web): keep editor disabled during STARTING so quick-chat sends don't race

My previous fix made submit no longer block on isStarting, with the
goal of keeping local quick-chat sessions from silently dropping
messages. The trace from CI showed it traded one failure for another:
the editor became contenteditable while the session was still
STARTING, the e2e suite pressed Cmd+Enter immediately, and the
backend rejected with "Failed to send message to agent" because the
agent process wasn't ready yet.

Restore the original semantics — STARTING blocks both editor and
submit — but keep `isPreparingEnvironment` as the narrower trigger for
the "agent still being set up" tooltip. Net behaviour:
  - STARTING transition: editor disabled, no tooltip (signal is the
    disabled state itself)
  - container/sandbox prepare: editor disabled + tooltip explaining why
  - quick-chat sessions: editor stays disabled until RUNNING, no race

* docs(web): correct isPreparingEnvironment JSDoc after STARTING re-block

The 9389ac9 fix restored isStarting as the editor disabled gate.
Update the JSDoc on isPreparingEnvironment to match: it now drives
the "agent still being set up" submit-disabled tooltip only — the
disabled state itself is gated on the broader isStarting so e2e
Cmd+Enter doesn't race the not-yet-ready agent.

* fix(lifecycle): make agentctl health retry delay cancelable

Replace `time.Sleep(retryDelay)` in `waitForHealth` (container.go) and
`waitForAgentctlHealthWith` (executor_docker.go) with a cancelable
`select { <-ctx.Done() | <-time.After(retryDelay) }`. Also skip the
final retry's sleep — the loop only re-enters when `i+1 < maxRetries`,
so the extra delay was just added latency on aborted launches.

Cancellation now propagates within ≤500 ms instead of waiting for the
next probe iteration to observe `ctx.Err()`.

* fix(scripts): non-destructive postlude branch checkout

The previous postlude tried `git fetch + git checkout -B feature
origin/feature` first, which is force-reset semantics: any unpushed
local commits on the resumed workspace would be orphaned without
warning.

Reorder to prefer the existing local branch (preserving in-flight
work after a sandbox/container resume), fall through to a fresh
tracking branch off origin, and only as a last resort create the
branch off HEAD:

  1. local feature branch exists  → checkout (preserves)
  2. else fetch + checkout -b      → tracks origin, no force-reset
  3. else                          → create new local branch

Behaviour tests still pass: fresh sandbox with remote tip → falls
through to step 2 (clean track). The static-content test now also
verifies the destructive `-B "feature" "origin/feature"` form is
absent.

* fix(lifecycle): reject non-regular files for KANDEV_MOCK_AGENT_LINUX_BINARY

`os.Stat` succeeds on a directory, so the previous resolver happily
accepted a directory path and forwarded it to the Docker bind-mount +
exec path, where it surfaced as a confusing "permission denied" /
"exec format error" deep in container bootstrap.

Validate `info.Mode().IsRegular()` up front and return a clear error
naming the env var.

* docs(test): update stale comment after retry-delay became cancelable

aaec9e87 swapped the retry sleep for a select on ctx.Done. The test
comment in TestWaitForAgentctlHealth_HonoursContextCancellation still
described the old "subsequent time.Sleep is short" reasoning, which is
no longer how cancellation propagates — the select exits immediately
via ctx.Done() within the same iteration.

* fix(web): extract buildDialog{FormBody,Footer}Props to satisfy lint cap

After the main merge, prettier expanded my hand-compacted destructuring
in TaskCreateDialog onto multiple lines, putting the function at 115
lines (over the 100 cap). Extract the two prop-assembly blocks into
buildDialogFormBodyProps + buildDialogFooterProps helper functions so
the orchestrator becomes a thin JSX wrapper. No behaviour change — the
helpers project the same shape, 1:1.

Also fixes the upstream CI failure: prettier --check was rejecting the
hand-compacted destructuring lines.

* test(executor): cover repositoryCloneURL LocalPath fallback

The function gained a second code path to support Docker E2E tests with
file:// remotes — when ProviderOwner/ProviderName are both empty and
LocalPath is set, it shells out to git remote get-url origin. The
original table-driven test had eight cases, none of which exercised
the new branch.

Add a dedicated TestRepositoryCloneURL_LocalPathFallback with four
sub-cases:
  - local-only repo with a valid origin remote → returns the URL
  - local-only repo without an origin remote → returns empty
  - local-only non-git directory → returns empty
  - no provider and no local path → returns empty (regression for the
    early-return branch the old table already covered)

* refactor(web): move task-create-dialog prop builders to sibling file

3a0c45a0 introduced buildDialogFormBodyProps + buildDialogFooterProps
to slim TaskCreateDialog under the function-line cap, but adding them
in-file pushed task-create-dialog.tsx to 612 raw lines. Local eslint
still passes (skipBlankLines + skipComments brings the effective count
under 600), but Claude's review flagged the raw-line growth as a
blocker — and the prop builders are pure projections from setup state
to plain JS objects, no JSX, so they belong outside the component
module anyway.

Move computeHasAllBranches and both builders to a new
task-create-dialog-prop-builders.ts. Export TaskCreateDialogProps and
useTaskCreateDialogSetup from dialog.tsx so the builders can derive
their parameter types via ReturnType<typeof useTaskCreateDialogSetup>.

task-create-dialog.tsx: 612 → 522 raw lines.

* chore(web): apply prettier to task-create-dialog-prop-builders.ts

The new file from 08aa04c5 wasn't run through prettier before push,
so CI's --check rejected the long import line. Mechanical reformat
(import collapsed to one line, no semantic change).

* fix(web): import-type the prop-builders circular dep + add computeHasAllBranches test

Two findings from Claude on 08aa04c5 + fa5ba9d6:

  - The mutual import between task-create-dialog-prop-builders.ts and
    task-create-dialog.tsx is purely type-level (TaskCreateDialogProps
    is an interface; useTaskCreateDialogSetup is consumed only through
    `ReturnType<typeof ...>`). Switch to `import type` so bundlers and
    analysis tools don't flag this as a runtime cycle.

  - computeHasAllBranches was a private function in
    task-create-dialog.tsx and didn't need its own test. Moving it to
    a sibling .ts utility makes it a public helper, and the SKILL.md
    convention is that public utilities carry their own tests. Add a
    table-driven unit test covering the three branches: noRepository
    short-circuit, useGitHubUrl branch validation, and the
    every-repo-has-a-branch case.

* fix(web): pass executorType to kanban card RemoteCloudTooltip

The kanban-card call site was missing the optional executorType prop,
so getExecutorStatusIcon(undefined, ...) fell through to the default
arm and rendered IconServer for every remote-executor task —
including Docker, which should be IconBox. Other call sites
(task-item.tsx, session-mobile-top-bar.tsx) already pass it.

This was a pre-existing bug from c64e8356 that became visible because
this PR makes Docker tasks first-class on the home view.

* fix(web): give executor popover env header breathing room above the title

The EnvironmentInfo block was `px-3 pb-1.5` (no top padding), so the
"Local Docker" heading hugged the popover top edge when no prepare-
status row was rendered. Add `pt-2.5` to match the visual rhythm of
the prepare-status row that sometimes sits above it.

* fix(sprites): DestroySandbox passes terminal StopReason so cleanup actually runs

shouldRunExecutorCleanup gates Sprites teardown on a small set of
terminal reasons (task/session archived/deleted). DestroySandbox was
calling StopInstance with an empty StopReason, which never matched
that set — so the sandbox was never actually destroyed, only the
local execution state was cleaned up. The sprite kept running
upstream until it idled out.

Pass StopReasonTaskDeleted explicitly. While here, lift the four
terminal-reason literals into named constants so callers don't have
to keep them in sync with shouldRunExecutorCleanup's switch.

* fix(web): block submit when selected agent is incompatible with executor

useExecutorProfileCompat returned noCompatibleAgent=true only when
NO agent profile was compatible with the chosen executor. The user
could still pick an agent that happened to be incompatible with the
currently-selected executor (e.g. by switching executor after the
agent was chosen) and the submit gate didn't fire.

Two fixes:
  - Hook now takes the selected agent profile ID and returns
    noCompatibleAgent=true when the *selected* agent isn't in the
    compatible set (covers both the old "no agents at all" case and
    the new "wrong agent picked" case).
  - Footer's session-mode disable was gating only on missing agent;
    add noCompatibleAgent so the disable matches the reason text
    sessionDefaultReason already surfaces.

* fix(task-env): sanitize generic 500 response on reset failure

The reset-environment endpoint was returning the raw err.Error() in the
500 response body, leaking internal details (paths, wrapped chains,
infra error strings). Keep the structured log at error level for
operators and return a generic "failed to reset task environment"
to clients.

* fix(worktree): push to the branch's configured upstream remote, not hard-coded origin

PushEnvironmentBranch was running `git push origin <branch>`, which
breaks for repos whose primary remote isn't called "origin" (fork
workflows that use "upstream", "github", etc.). Read the branch's
configured upstream remote via `git config branch.<branch>.remote`
and fall back to "origin" only when no upstream is set.

* refactor(web): type workflows/snapshots in DialogFormBodyProps properly

DialogFormBodyProps had `workflows: unknown[]` and `snapshots: unknown`
which dropped type safety and forced unsafe casts at the call site.
Replace with the actual store-derived types (WorkflowsState["items"]
and KanbanMultiState["snapshots"]) that the call sites already
produce.

* fix(turns): align info.TaskEnvironmentID with the resolved env after fallback

GetWorkspaceInfoForSession initializes info.TaskEnvironmentID from
session.TaskEnvironmentID, then resolves the env — first by that ID,
falling back to GetTaskEnvironmentByTaskID when the session's ID
points to a stale/missing row. When the fallback kicks in, the
resolved env has a different ID, but applyTaskEnvironmentToWorkspaceInfo
was only updating info.TaskEnvironmentID when it was empty. So
downstream reconcilers received metadata + path from one env keyed by
a different env's ID.

Always overwrite info.TaskEnvironmentID with env.ID inside the apply
function — once we've picked an env to spread, the info should
identify that env consistently.

* fix(web): gate compatibility on effectiveAgentProfileId, not the raw form value

The previous fix passed fs.agentProfileId to useExecutorProfileCompat,
so workflow-locked sessions (where fs.agentProfileId is empty and the
real value comes from workflowAgentProfileId) bypassed the
noCompatibleAgent check entirely — selectedAgentProfileId was "" so
the new "selected agent isn't in compatible set" branch silently
returned false.

Thread `effectiveAgentProfileId` (which already coalesces the form
value with the workflow override) through to the compat hook so
locked agent selections are gated the same way as user-picked ones.

* test: cover detectBranchRemote + applyTaskEnvironmentToWorkspaceInfo stale-ID fallback

Two test additions for the Round 29/30 Claude blockers — both are
recent backend fixes (a07b8f1f, e6195f3f) that landed without
regression coverage.

  - worktree_test.go: TestDetectBranchRemote covers the three
    branches of the new helper — configured upstream returned,
    no upstream → "origin" fallback, non-git dir → "origin"
    fallback. GIT_CONFIG_NOSYSTEM=1 keeps host config from
    interfering.
  - service_turns_test.go: a regression test that drives
    GetWorkspaceInfoForSession down the fallback path
    (session.TaskEnvironmentID points at a missing row; real env
    exists under a different ID for the same task) and asserts
    info.TaskEnvironmentID is aligned with the resolved env, plus
    WorkspacePath/container_id come from the same env.

* fix(backend): extract defaultGitRemote constant to satisfy goconst

The new detectBranchRemote helper plus its tests pushed "origin"
literal occurrences past goconst's min-occurrences=3 threshold.

* fix(backend): inject git identity in worktree tests for CI

CI runners have no user.email/user.name configured, so
`git commit --allow-empty` aborts with "Author identity unknown".
GIT_AUTHOR_*/GIT_COMMITTER_* env vars override the config check.

* fix(backend): base mustGit env on os.Environ to keep HOME/PATH

Appending to a nil cmd.Env produced a single-element slice that
replaced the inherited environment, leaving the git subprocess
without HOME, PATH, etc. Base on os.Environ() instead.
2026-05-14 21:26:09 +01:00
Carlos Florêncio 5644b0ad18 feat(preview): add PR preview environments via Sprites (#707) 2026-04-26 18:14:41 +01:00
Carlos Florêncio 204fac1cca feat: acp-first profiles, models, and modes (#566) 2026-04-15 13:22:32 +01:00
su f757e07460 fix(backend): always enable cgo on build (#586) 2026-04-15 10:11:27 +01:00
Carlos Florêncio b07356b29b feat(debug): enable pprof memory profiling in dev/debug mode (#518) 2026-04-01 23:21:29 +01:00
Carlos Florêncio a23ab5be3a feat: remote executors + secrets (#257) 2026-02-21 22:14:56 +00:00
Carlos Florêncio 122094fa5d feat: e2e tests 2026-02-21 22:05:24 +00:00
Carlos Florêncio cad2d783b3 feat: improve windows support (#225)
release / build-web (push) Has been cancelled
release / build-bundles (aarch64-linux-gnu-gcc, arm64, linux, ubuntu-latest, linux-arm64) (push) Has been cancelled
release / build-bundles (amd64, darwin, macos-13, macos-x64) (push) Has been cancelled
release / build-bundles (amd64, linux, ubuntu-latest, linux-x64) (push) Has been cancelled
release / build-bundles (amd64, windows, windows-latest, windows-x64) (push) Has been cancelled
release / build-bundles (arm64, darwin, macos-14, macos-arm64) (push) Has been cancelled
feat: windows support
2026-02-15 18:16:21 +00:00
Carlos Florêncio 4d41155158 feat: mock agent (#208) 2026-02-07 17:48:04 +00:00
Carlos Florêncio ff4e0779d2 feat: improve make dev shutdown (#133) 2026-01-31 18:06:22 +00:00
João Salavisa f229c72393 feat: migrate MCP server from backend to agentctl (#124) 2026-01-30 15:15:19 +00:00
João Salavisa c57a82f81d feat(mcp): add embedded MCP server with dual transport support (#67) 2026-01-21 12:03:46 +00:00
Carlos Florêncio cdeb32af18 feat: remove dev db not used (#45) 2026-01-19 20:15:12 +00:00
Carlos Florêncio aebcf5283f feat: display settings in kanban page 2026-01-13 22:37:36 +00:00
João Salavisa 6d9f78b7b7 fix(agentctl): standalone mode workspace path and worktree lookup (#10) 2026-01-13 02:05:33 +00:00
João Salavisa 3701d9057b feat(backend): build agentctl to bin/ and use pre-built binary in Dockerfile (#9) 2026-01-12 23:56:36 +00:00
jcfs 7b3e94d6dc feat(backend): add persistent agent session tracking
- Add AgentSession database entity with SQLite persistence
- Implement AgentSession CRUD operations in repository layer
- Update executor to use hybrid cache + database approach
- Add session restoration on startup (LoadActiveSessionsFromDB)
- Link comments to agent sessions via agent_session_id
- Fix SQLite NULL handling for nullable task fields
- Add dev database setup with seed data (make dev, make dev-db-reset)
- Update Makefile with dev targets for development mode

This enables agent sessions to persist across backend restarts,
solving the 'execution not found' error for resumed conversations.
2026-01-12 02:02:47 +00:00
jcfs 20f5f43fb3 feat: complete acp-go-sdk integration with task state updates
- Use acp-go-sdk for all ACP communication in agentctl
- Mark agent as completed when Prompt() returns successfully
- Update task state to COMPLETED when agent completes
- Update task state to FAILED when agent fails and retries exhausted
- Enhanced E2E test to verify agent actually creates files
- E2E test now validates full flow: task creation -> agent work -> file creation -> task completion
2026-01-10 16:46:35 +00:00