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.
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.
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.
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.
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.
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.
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.
`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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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(orchestrator): carry sender_task metadata through user message paths
Add WithSenderTask builder to UserMessageMeta and a metadata-aware
QueueMessageWithMetadata variant on the message queue. The drain path
in event_handlers_agent merges queued metadata into the recorded user
message so attribution survives the queue → DB transition.
* feat(mcp): attribute message_task_kandev calls to the sending agent
The MCP server inside agentctl now injects sender_task_id and
sender_session_id from its own server context. The backend handler
validates the sender (rejecting missing IDs, self-messages, and unknown
tasks), looks up the sender title, wraps the prompt in a <kandev-system>
attribution block, and propagates structured metadata across all three
delivery paths (queue, created-session, record+prompt). The wrapped
content lets the receiving agent identify the peer both live and on ACP
session resume; the existing sysprompt strip pipeline hides the wrapper
from the UI.
* feat(web): render sender-task badge on cross-task agent messages
User messages whose metadata carries sender_task_id now render a
clickable purple badge above the bubble, linking to the source task.
The badge live-resolves the sender's title from the kanban store and
falls back to a greyed snapshot when the task isn't loaded
(cross-workspace, deleted, etc.). Adds a non-subscribing useTaskById
selector so badges don't fan out WS subscriptions per message.
* test(e2e): cover cross-task agent message attribution
Eight scenarios driven through the mock-agent's MCP script directive:
queue path, prompt path, self-message rejection, unknown-target error,
badge link target, live title re-resolution after rename, kandev-system
wrapper preservation in raw_content, and prompt bodies containing their
own kandev-system tags. Adds an updateTaskTitle helper to ApiClient.
* fix: address CodeRabbit + Greptile P1 findings on PR #819
- requeueMessage now forwards queuedMsg.Metadata via QueueMessageWithMetadata
so sender attribution survives transient delivery failures and retries.
- CREATED-session branch in handleMessageTask records the user message AFTER
StartCreatedSession succeeds, so a launch failure no longer leaves an
orphaned attributed chat row.
- SenderTaskBadge: collapse identical displayTitle/fullTitle locals.
- E2E: extract waitForTargetIdle helper; replace the 1.5s raw sleep in the
collision-check test with the same poll the rest of the suite uses.
* fix: address Claude review suggestions on PR #819
- wrapAgentMessage strips </kandev-system> from the embedded sender title
before formatting. Without this, a title containing the closing tag
would short-circuit the non-greedy strip regex and leak the attribution
tail into the visible UI bubble. Metadata snapshot keeps the original
title so the badge still displays it.
- Add a regression test covering the malicious-title case.
- Document the deliberate cross-workspace messaging policy on the sender
lookup so future readers don't add a workspace-scope check (Q13 of the
feature plan locked this as intentional).
* fix(changes-panel): scope commits/diff to live branch divergence; flatten single-repo
Two bugs in the COMMITS panel made it surface commits and file changes
that were already on main:
1. The commit list and the cumulative diff both anchored to a stale base.
Commit list used `git merge-base HEAD <local-branch>` and the file diff
used the session's stored base SHA captured once at session start. On
long-lived worktrees the local main ref lags far behind origin/main, so
the merge-base lands on an old SHA; once the agent merges main back in,
the stored base is also stale. Both end up sweeping in dozens of main
commits as if they were the branch's work.
2. `git log <base>..HEAD` follows both parents of a merge commit by
default, surfacing main commits brought in via merges from main.
Fixes:
- agentctl computes merge-base against `origin/<target_branch>` (with a
local-ref fallback for unfetched/local-only branches), and uses that
for both the commit log and the cumulative diff. Mirrors GitHub's
three-dot PR diff semantics — anchor to live divergence with the
upstream, not a session-start snapshot.
- `git log` now passes `--first-parent` so only the branch's own line of
work plus its merge commits show up; commits brought in via merges from
main are excluded.
Frontend: drop the per-repo "REPOSITORY" sub-header in the COMMITS panel
for single-repo workspaces. CommitsRepoGroup gains a `showHeader` prop;
when the workspace has one untagged group the section renders flat with
the per-repo Push / PR (and Stage all / Commit / Unstage all) buttons
lifted into the section header. Multi-repo rendering unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: address PR review feedback
- Scope --first-parent to the divergence-range path only (Greptile P1).
The flag is wrong for open-ended history calls — future activity
widgets etc. need the full graph.
- Log empty merge-base case in cumulative diff (Greptile P2). Silent
fallback to stored base was invisible during diagnostics.
- Rename TestRunGitCumulativeDiffForRepo_PrefersOriginRef →
TestComputeMergeBase_CorrectAnchorForCumulativeDiff (Claude). Test
exercises computeMergeBase, not the runGitCumulativeDiffForRepo
conditional; new name reflects what it actually proves.
- Add TestGetLog_NoBaseReturnsFullGraph to lock in the --first-parent
scoping.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: address PR review round 2
- Drop dead ActionButtonsSection from changes-panel-timeline.tsx (unused
export, no consumers anywhere). Brings the file from 640 → 516 lines,
comfortably under the 600-line TS limit Claude flagged.
- Extract isSingleRepoGroup helper into lib/group-by-repo.ts and use it
in all three sites (CommitsSection, FileListBody, FileListSection) so
single-repo detection has one source of truth.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix: sanitize multi-repo worktree dirs and polish task-create chip dropdown
Backend: org-prefixed Repository.Name like "owner/repo" was used verbatim
as the worktree subdirectory under the task root, nesting one level too
deep and breaking agentctl's sibling-based multi-repo detection in
scanRepositorySubdirs. Sanitize to a single path-safe segment so all
worktrees land as siblings of the task root.
Web: the multi-repo chip dropdown built its own option list and dropped
the path Badge plus search filtering that the legacy header dropdown
shipped. Restore parity (name + path), widen the popover (288 → 360 px),
stack name and full path on two lines so neither has to truncate
aggressively, hide the Badge for workspace repos without a local clone,
and use a path-aware scorer that floors weak fuzzy matches so unrelated
paths stop surfacing for short queries.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: address PR review feedback
- Add ErrInvalidRepoName sentinel and use it in createInTaskDir when
RepoName sanitizes to empty, replacing the misleading reuse of
ErrTaskDirRequired (Claude/Greptile).
- Reuse repoDirHyphenRun in SanitizeForBranch instead of recompiling the
regexp per call (CodeRabbit nitpick).
- Document the SanitizeRepoDirName collision edge case in the doc
comment (Greptile).
- Extract scoreRepo to lib/utils/repo-filter.ts and add unit tests
pinning the 0.4 fuzzy-match threshold (Claude).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(backend): return mcp message_task once dispatched, not after target turn ends
The MCP message_task_kandev tool was synchronously waiting for the target
session's entire turn to complete before returning, even though its
description advertises a fast dispatch status. For idle target sessions,
the call would hang for the full duration of the agent's response (with
no upper bound — waitForPromptDone has no timeout).
Threads a dispatchOnly flag from the MCP handler down through
PromptTask → executor.Prompt → PromptAgent → SendPrompt. When set,
SendPrompt returns once agentctl.Prompt accepts the prompt instead of
blocking on the agent's complete event. Stale completion signals from
prior dispatch-only prompts are drained at the start of SendPrompt so
they don't poison the next waiting call.
* fix: address PR review feedback
- Replace time.Sleep with channel-based wait for WS connection in new
SendPrompt tests, per CLAUDE.md (channel sync over time.Sleep for
non-subprocess tests).
- Drop redundant Warn that logged the remapped sentinel after the
original error was already logged by the preceding Warn.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* fix(jira): use shared prompt template with placeholder autocomplete
Default prompt for the JIRA watcher now lives in
config/prompts/jira-issue-watch-default.md (loaded via promptcfg.Get),
matching how the GitHub issue/PR-review watchers source their defaults.
The watch dialog swaps the plain Textarea for the ScriptEditor used by
the GitHub dialog so users get {{...}} autocomplete, and the inline
hardcoded default string is gone from both backend and frontend.
* fix(jira): treat whitespace-only stored prompt as empty on hydrate
CodeRabbit caught that w.prompt || DEFAULT keeps a stored " " value,
which canSave then rejects — dialog reopens in an unsavable state. Fall
back to the default whenever the trimmed value is empty.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>