* perf(backend): cap cumulative diff bytes and file count during large rebases
* perf(web): coalesce cumulative-diff refetches and skip redundant status compares
* feat(web): warn when the changes panel hides files from a capped diff
* fix(backend): enforce cumulative diff total byte budget strictly
* fix(web): keep coalesced refetch timer shared across envKey subscribers
* fix(web): drop unsound git-status SHA fast-path that staled live diffs
* fix(web): cancel coalesced timer on mid-flight drain to avoid duplicate refetch
* refactor(web): move changes-panel banner and helper to a sibling file
* docs(backend): clarify cumulative diff truncated-files-count semantics
* fix(web): tighten useSelectedFileKey mode param to the all|file union
* fix(backend): prefer local copilot CLI over npx at launch
IsInstalled probed the copilot binary for the UI badge but launch always
ran `npx -y @github/copilot`, paying an npm registry round-trip every
start — minutes behind a private registry. Reuse the env-correct binary
check (standalone LookPath, SSH preflight probe) so when copilot is on
PATH the lifecycle runs it directly; containerized runtimes keep npx.
* fix(backend): make SSH native-binary probe non-fatal on transport error
The native-binary probe is an optimisation, not a requirement. A transient
SSH transport error (MaxSessions, reset) made "Verifying agent binary"
abort the launch, blaming copilot before npx was tried. Treat probe
transport errors as a miss and fall through to the required default-command
probe, which reports failures with proper context. Also call
NativeBinaryName() once.
* fix(backend): clear stale native-binary metadata before SSH probe
A pre-existing or stale MetadataKeyNativeBinary in req.Metadata could make
the command builder prefer copilot --acp on an npx-only host even when this
probe misses. Clear it up front so only a positive probe re-sets it.
* refactor(backend): use resolved RuntimeName for native-binary check
buildExecutionFromInstance re-derived the runtime from req.ExecutorType
while promoteWorkspaceExecution read execution.RuntimeName. Use the already
resolved execution.RuntimeName (set from rt.Name()) in both, so the two call
sites are consistent.
* fix(backend): reflect local copilot CLI in profile command preview
The profile setup command preview always rendered `npx -y @github/copilot`
because PreviewAgentCommand built the command without the native-binary
probe. The preview is not bound to an executor, so assume the default
standalone host and probe PATH (mirroring the standalone launch path): show
`copilot --acp` when the CLI is installed, npx otherwise.
* feat(diff): use stored base_branch for diff stats + per-task compare picker
Task-card additions/deletions and the Changes panel were computed against a
hardcoded origin/main → master priority list inside agentctl. For tasks
branched off a release/staging branch this inflated the counts by every
commit since the actual fork point, and the Changes panel grouped unrelated
files alongside the task's real work.
This wires the per-repo task_repositories.base_branch end-to-end into the
agentctl WorkspaceTracker so each repo's diff stats resolve against the
recorded base instead of the integration-branch fallback. A picker on the
Changes panel branch hover card lets the user change the base mid-task; the
new value is persisted via PATCH /tasks/:id/repositories/:repo_id, pushed
live to the running agentctl instance, and the session's cached
base_commit_sha is cleared so the commits panel + cumulative diff refetch
against the new base without a session restart.
Falls back to the existing origin/main → master list for legacy tasks
(empty base_branch) and to the branch tip when no merge-base exists with
HEAD (unrelated histories — local rebase backups, etc.) so the stats and
commits panels stay anchored on the same SHA in every failure mode.
* fix(agentctl): sanitize user-controlled base_branch refs before git exec
CodeQL flagged the picker's user-controlled base_branch payload reaching
exec.Command("git", ..., baseBranch) inside workspace_git_cmd.go as a
command-built-from-user-controlled-source vulnerability. A malicious caller
could smuggle a leading-"-" argument like "-upload-pack=..." that git
itself reinterprets as a flag, even though exec.Command keeps args
separate from the shell.
Adds an IsSafeGitRef sanitizer at every boundary the value crosses on its
way to git: the service write path, the agentctl HTTP handlers
(/workspace/base-branches, /git/log, /git/cumulative-diff), and
WorkspaceTracker.SetBaseBranch itself. Unsafe values are rejected at the
service tier and silently downgraded to the no-override fallback inside
agentctl so a misbehaving caller can't half-break the tracker.
* fix(diff): address PR review blockers and CI lint
- Split UpdateRepositoryBaseBranch into validate/load/apply helpers to drop
cyclomatic complexity below the cyclop limit that gated Run Backend Tests.
- Replace direct m.cfg.BaseBranches reads/writes with getBaseBranches /
setBaseBranches under a dedicated RWMutex. UpdateBaseBranches used to
write under repoTrackersMu while rescan + lazy-subpath callers read under
workspaceTrackersMu, leaving an unsynchronized data race on the map.
- Drop the false 'PR target is auto-wired' claim from the MCP tool
description — GitOperator.CreatePR takes baseBranch as an explicit arg
and never reads the tracker's value.
- Picker no-op guard now compares against currentBase instead of storedBase
so selecting the displayed fallback (legacy task before any picker
change) is treated as no-op instead of triggering a spurious save.
- HTTP handler now maps non-validation service errors to 500 instead of
400, classifying via the existing isValidationError helper plus the new
unsafe-git-ref message.
* fix(diff): regex-based ref sanitiser at every git subprocess sink
CodeQL kept flagging workspace_git_cmd.go:43 even after SetBaseBranch
rejected unsafe values — the taint tracker doesn't follow sanitised stores
through struct fields, so it still saw user input flowing into
exec.Command("git", ..., baseBranch).
Switches IsSafeGitRef to a single regex check (a pattern CodeQL recognises
as a sanitiser barrier) and adds SanitizeGitRef calls inline at every
agentctl call site that splices a user-controlled ref into a git arg:
- workspace_git_status.resolveStoredRef (before the rev-parse probes)
- workspace_git_status.computeBaseCommit (before merge-base/rev-parse)
- workspace_git_status.getAheadBehindCounts (before rev-list)
- agentctl/server/api/git.go runGitLogForRepo + handleGitCumulativeDiff
(before computeMergeBase/GetRevParse on the HTTP TargetBranch)
Behaviour is unchanged for valid refs — only narrows the allowlist (now
[A-Za-z0-9_./-]) so the sanitiser barrier is recognisable to static
analysis. SetBaseBranch's field-store sanitiser stays as defence in depth.
* fix(diff): transform sanitiser at every map read for CodeQL taint flow
CodeQL still flagged workspace_git_cmd.go:43 because:
- handleSetBaseBranches used an IsSafeGitRef bool guard before a verbatim
map[k] = v copy. CodeQL recognises regex-transforming sanitisers but not
bool-guarded passthroughs.
- lookupBaseBranch returned m.cfg.BaseBranches[k] verbatim. Even though
the map was sanitised on write, taint flow across map stores + field
stores loses the sanitised state in the analyser.
Switches both sites to the transforming SanitizeGitRef helper so every
read produces a freshly-sanitised value before it flows to a downstream
git argument. Behaviour is unchanged for valid refs.
* fix(diff): regex transformer sanitiser + guard live push from empty map
CodeQL still flagged the previous SanitizeGitRef because it returned the
input string unchanged on success — an identity passthrough that the taint
tracker doesn't recognise as a sanitiser barrier.
Switches to safeGitRefPattern.FindString so the output comes from the
regex match (not the parameter). IsSafeGitRef now wraps SanitizeGitRef so
the predicate and the transformer share one allowlist.
Also addresses two Claude review blockers:
- collectTaskBaseBranches returns a nil/empty map when the task has no
recorded base_branches; pushing that to agentctl wipes every tracker's
override. Now skipped — the DB row is the source of truth for the next
session launch.
- applyBaseBranchSideEffects now runs on context.WithoutCancel so an HTTP
/ WS request cancel after the task_repositories UPDATE commits can't
leave session.base_commit_sha stale + the live agentctl tracker map
un-pushed, drifting from the persisted row.
* fix(diff): share securityutil.IsValidBranchName + harden error classification
CodeQL still flagged the picker's regex sanitiser as taint passthrough.
Refactors SanitizeGitRef / isSafeBaseBranchRef to delegate to the existing
securityutil.IsValidBranchName helper that agentctl's Rebase / Merge /
RenameBranch already use without being flagged. The shared allowlist also
fixes a Claude review blocker: 'feature/@2024' used to pass the service
validator but get dropped by the agentctl-side check at push time,
silently wiping every tracker's override.
Plus the two error-classification blockers Claude raised on the previous
HEAD:
- WS handler (task_ws_handlers.go wsUpdateTaskRepository) now distinguishes
validation errors (surfaced verbatim) from internal faults (opaque
500-style message). HTTP path already did this via the existing
isValidationError helper.
- MCP handler (handlers.handleUpdateRepositoryBaseBranch) gets the same
treatment via a new isValidationError local to the mcp/handlers
package so raw service error strings stop leaking across the MCP
boundary.
* fix(diff): inline securityutil.IsValidBranchName at every git subprocess sink
CodeQL's interprocedural taint tracking wasn't recognising SanitizeGitRef
as a sanitiser even after it delegated to securityutil.IsValidBranchName
— the wrapper helper + map/field hops between source and sink kept the
flow tainted in the analyser.
Inlines the IsValidBranchName allowlist check (already used inline by
Rebase/Merge in git.go without being flagged) at every call site that
splices a user-controlled ref into a git subprocess argument:
- workspace_git_status.resolveStoredRef (before rev-parse --verify)
- workspace_git_status.computeBaseCommit (before merge-base / rev-parse)
- workspace_git_status.getAheadBehindCounts (before rev-list)
- agentctl/server/api/git.go runGitLogForRepo + handleGitCumulativeDiff
- agentctl/server/api/workspace_base_branches.go handleSetBaseBranches
Behaviour unchanged: same allowlist as Rebase / Merge / RenameBranch
already use. SanitizeGitRef / IsSafeGitRef survive as defence-in-depth
helpers for callers that want a one-line check; they delegate to the same
securityutil helper so the rules stay aligned.
* fix(diff): inline regexp.MatchString at every git subprocess sink
CodeQL still flagged workspace_git_cmd.go:43 even with the inline
securityutil.IsValidBranchName helper — the taint tracker apparently does
not follow into helper functions for the go/command-injection rule on
this path.
Hoists the regex to package-scope vars (safeBranchRefPattern in both
process and api packages) and calls regexp.MatchString directly at each
sink site. Behaviour is identical to securityutil.IsValidBranchName; only
the call form changes.
* fix(diff): SHA-shape allowlist on BaseCommit before downstream git diff
CodeQL still flagged workspace_git_cmd.go:43 because update.BaseCommit
is the OUTPUT of an earlier merge-base / rev-parse call whose argument
list contained a user-controlled branch ref — the analyser treats that
command's output as still tainted even when the input ref was sanitised.
Adds an inline 40-hex SHA-shape allowlist check in enrichWithBranchDiff
right before `git diff --numstat <update.BaseCommit>` so the sanitiser
barrier sits in the same function as the next subprocess call. Behaviour
is unchanged: merge-base / rev-parse already produce SHA-shaped output.
* fix(diff): async tracker refresh + tighten init spread order
Two CodeRabbit/Claude review fixes:
- UpdateBaseBranches stamps each tracker's new baseBranch synchronously
then runs RefreshGitStatus on a background goroutine instead of inside
the picker-save HTTP handler. Per-tracker git subprocesses can each
take ~3-5 calls; serialising them on the request goroutine made the
PATCH response wait for every refresh to finish. The picker UI re-fetches
via the existing WS stream once Refresh emits, so the handler doesn't
need to block.
- updateTaskRepositoryBaseBranch now spreads options.init BEFORE the
required method/body fields so an upstream caller's init overrides
can't replace the PATCH method or the base_branch payload. Matches
the convention deleteTask / moveTask already follow.
* fix(diff): narrow mcp isValidationError + opaque 500 + post-sanitize log count
Three final Claude review threads on the latest commit:
- mcp/handlers isValidationError drops the broad 'invalid' substring so
DB / IO errors whose messages happen to contain 'invalid' don't
misclassify as ErrorCodeValidation.
- task_http_handlers httpUpdateTaskRepository now logs the raw service
error server-side and returns an opaque 'failed to update task
repository' on the 500 path. Stops potential DB / IO detail leakage
through the HTTP boundary.
- workspace_base_branches debug log reports accepted + rejected counts
(post-sanitisation) instead of the pre-filter total so the metric
actually reflects what hit UpdateBaseBranches.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* fix(agentctl): order workspace stream wg.Add before publish to fix Close race
Client.StreamWorkspace published the new WorkspaceStream pointer to
c.workspaceStream under the lock, then released the lock and called
stream.wg.Add(2) afterwards. A concurrent Client.Close captured
c.workspaceStream under the same lock and then called ws.Wait() outside
of it, racing the subsequent Add(2) on a WaitGroup whose counter was
still 0. The race detector flagged this as Add-after-Wait on the same
counter address — reproduced in CI in
TestInitializeAndPrompt_StreamBeforeInitialize and
TestManager_RestartAgentProcess_StopErrorIsNonFatal, both of which use a
deferred client.Close after the StreamManager spawns the workspace
stream goroutine.
Move stream.wg.Add(2) under the same lock that publishes the pointer.
The unlock/lock pair now carries the Add into Close's view, so any
caller that observes c.workspaceStream is guaranteed to see the counter
already bumped before invoking Wait.
Adds TestClientClose_StreamWorkspaceAddWaitRace as a deterministic
regression: it waits for the server to observe the WS upgrade, then
fires Close in parallel with StreamWorkspace — pre-fix this triggers
the race within a handful of iterations under -race.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(agentctl): defer ctx cancel in StreamWorkspaceAddWaitRace iter
Wrap the per-iteration body in a runIter closure so `defer cancel()`
covers every exit path (timeout fatals as well as the normal flow),
replacing the explicit cancel() calls. Pure test hygiene — addresses
Claude review comment on PR #1272.
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(lifecycle): drain StreamManager goroutines in tests
Goroutines spawned by `agentctl.Client.StreamUpdates` /
`StreamWorkspace` and by `lifecycle.StreamManager.connectWorkspaceStream`
could outlive the tests that created them on slow CI runners, causing
intermittent `goleak.VerifyTestMain` failures in
`internal/agent/runtime/lifecycle` even though every individual test
passed. The leak required the test to race: `client.Close()` could
return while a workspace dial was still in flight, leaving the
just-spawned WS read/write loops with nobody to drain them, and
`StreamManager.Wait()` only fired the drain when the external `stopCh`
had been closed first.
Make the close paths absolute drain barriers:
- `Client.Close()` now tracks every stream goroutine it spawns
(`streamWG` + a per-stream `WorkspaceStream` reference) and blocks
until all of them have exited. A `closed` flag flipped under the
client mutex makes subsequent `StreamUpdates` / `StreamWorkspace`
calls reject the dial, so a Close that races a dial in flight cannot
leave behind a stranded WS connection. `manager_interaction.go`
switches the restart path to per-stream Close helpers so it can keep
reusing the client after teardown.
- `StreamManager.Wait()` closes a new internal `waitCh` that the retry
backoff (`sleepOrStop`) and the connected `<-ws.Done() / <-stop>`
select also observe. This makes `Wait` an absolute drain barrier
regardless of whether the caller ever closes the external `stopCh`.
`streamContext` carries both stops through `stopChannelContext` so
in-flight WebSocket dials cancel on either signal.
Local stress (`make test-lifecycle-goleak LIFECYCLE_GOLEAK_COUNT=20`)
passes 20/20 with `-race` after the fix. The new
`TestClientClose_DrainsStreamGoroutines` covers the drain barrier and
the post-Close rejection path. Added a `test-lifecycle-goleak`
Makefile target as the repro hook for the CI-only flake.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(lifecycle): track mergeStops goroutine and guard workspace stream races
Address PR review feedback:
- stopChannelContext now registers the mergeStops goroutine on an optional
*sync.WaitGroup so sm.wg.Wait stays a true drain barrier even for the
connectUpdatesStream path (outer goroutine returns immediately after dial).
- StreamWorkspace re-checks workspaceStreamConn after dial to seal the
concurrent-callers race that could orphan a conn.
- readWorkspaceStream's defer now guards the workspaceStreamConn reset by
identity so an old read goroutine cannot zero out a newer conn pointer.
- Clarify comments on streamWG, Client.Close, and the mock.connected wait
in the close-barrier test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(lifecycle): drop agent-stream drain barrier to unblock workflow cascade
The previous Close drain barrier for the agent (updates) stream blocked
the on_turn_complete cascade flow. completeAndStopSession calls Close
synchronously on the current session's client, and Close was waiting on
streamWG (which tracked the read goroutine). The wait stalled the
cascade long enough that Step2's auto-started session never got its
profile name into the tab — surfacing as a flaky "Profile B tab not
visible" failure in workflow-agent-switch E2E shard 7.
Drain barrier is still in place for the workspace stream (where the
original goleak flake lived). Agent stream is closed but not awaited:
the cascade legitimately stops + restarts the agent stream on the same
client, and the workspace stream's existing barrier is sufficient for
the leak coverage.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Kandev Agent <agent@kandev.dev>
* fix(agents): emit --dangerously-skip-permissions for Claude CLI passthrough (#1261)
Claude ACP declared empty PermSettings in passthrough mode, so the launch path's
PermissionValues map (which carries the profile's DangerouslySkipPermissions
toggle) had no matching CLI-flag mapping to emit. Bypass / auto-approve was
silently ignored. Wire the permission setting to --dangerously-skip-permissions
and skip the key in seedCLIFlags so the toggle does not double-surface in the
curated cli_flags list.
* test(agents): use PermissionKeyDangerouslySkipPermissions constant in claude_acp_test
* test(agents): use constant in claude_acp_test failure message too
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* fix(agentctl): reap disconnected ACP sessions + MCP child trees (#1247)
Two independent leaks let opencode acp sessions accumulate indefinitely:
1. instance.Manager never reaped disconnected sessions. StopInstance
only ran via the kandev DELETE route, so any session whose backend
went away (UI closed mid-run, WS dropped, opencode failed to emit
complete) was orphaned forever.
2. Even when DELETE fired, the ACP adapter unconditionally returned
RequiresProcessKill=false. opencode acp keeps an HTTP server + MCP
child tree alive after stdin EOF, so closing stdin was a no-op and
the children re-parented to init.
Fix both:
- Plumb RuntimeConfig.RequiresProcessKill from agents.OpenCodeACP
through the ExecutorCreateRequest -> CreateInstanceRequest ->
InstanceConfig -> adapter.Config -> shared.Config chain. acp.Adapter
now reads cfg.RequiresProcessKill instead of hardcoding false.
- Belt-and-braces: process.Manager.waitForProcessExit timeout fallback
now killProcessGroup first (only falls back to single-process kill
if that errors), so any future ACP CLI that ignores stdin EOF still
gets reaped.
- Add an idle-instance reaper to instance.Manager. Per-instance
activity middleware tracks inflight HTTP requests + lastActivity;
a background goroutine sweeps every minute and StopInstance's any
instance idle past KANDEV_ACP_IDLE_TIMEOUT (default 1h, set to 0
to disable). Long-lived /agent/stream WS connections keep the
inflight counter above zero so active sessions are never reaped.
- Validate stdio MCP commands at CreateInstance time via
exec.LookPath; missing entries are dropped with a warn instead of
spawning a permanently broken child every session (fixes the
stale /snap/bin/brave MCP repro in the issue).
Regression tests: process-group kill (parent+child fixture), idle
reaper end-to-end, MCP validation, opencode-acp opt-in vs other
ACP agents' default.
* fix: address PR review feedback on #1247 fix
- idle_reaper: extract reapSingleIdleInstance helper with defer cancel(),
add reaperStop polling between sweep iterations so Shutdown drains in
one StopInstance window instead of N * timeout (cubic, claude, greptile).
- instance.Manager: use sync.Once instead of m.mu to gate reaper-stop
channel close (claude).
- mcpStdioValidationError: tolerate compound Command strings by
resolving only the first whitespace-delimited token; reject entries
with neither URL nor Command outright; document agentctl-vs-agent
PATH skew in Docker/SSH executor modes (claude, greptile, cubic).
- manager_killgroup_test: treat zombie state as dead via /proc/<pid>/stat
on Linux + bump Eventually window to 15s — CI containers' PID 1 can
delay harvesting orphans long enough to flake signal(0) probes.
- instance package: add goleak TestMain for the new reaper goroutine
(coderabbit).
- AGENTS.md: correct SIGKILL vs SIGTERM wording; clarify the timeout
fallback only fires on ctx.Done (coderabbit, cubic).
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Three areas were broken in the MCP add_branch_to_task flow:
1. Predictable branch names. autoBranchName counted up from existing
branch-N rows. Concurrent add_branch calls raced onto the same name
and the worktree directory leaked ordering. Now uses a random
branch-<3 random chars> suffix via worktree.SmallSuffix.
2. Sibling materialize did not flow to the UI. The materializer
published AgentctlReady with the NEW sibling worktree's path, which
the frontend handler wrote onto the session row — clobbering the
primary's worktree_id/branch and pointing the file browser at the
sibling instead of the task root. Added TaskWorkspacePath to the
payload, detected the sibling case on the frontend (existing
worktree_id != payload's), kept the primary's id/branch, repointed
worktree_path to the task root, bumped commits refetch, and dropped
the pre-multi-repo legacy git-status entry so the Changes panel
stops surfacing the frozen single-repo snapshot. File browser's
resetKey now includes the worktree count so the tree reloads when
the sibling lands.
3. workspace_path promotion + agentctl rescan refused to act on
historical envs. A few envs had workspace_path pointing at the
source repo's local_path (orchestrator computeWorkspacePath fallback
to req.RepositoryPath when resp.WorktreePath is empty) instead of
the primary worktree. The materializer's promotion gate compared
against env.WorkspacePath's parent, so those envs were never
promoted, and agentctl's resolveRescanPath only accepted
parent-of-current so the rescan to the real task root was rejected.
Trackers stayed on the wrong directory and the sibling's commits
never appeared in the Commits panel even after a full reload.
Anchored promotion on env.TaskDirName (basename cross-check) and
added a recovery branch to resolveRescanPath that accepts any
absolute directory which actually contains git-repo subdirs — the
bearer-token middleware already gates the endpoint and
scanRepositorySubdirs validates the children, so the relaxed check
stays safe.
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(tasks): multi-branch tasks — N (repo, branch) pairs per task
Extends multi-repo into multi-branch so a single task can hold multiple
worktrees on the same repo (one per branch). Sibling worktrees layout at
<task>/<repo>/ + <task>/<repo>-<slug>/, dedicated PRWatch per branch,
live agentctl tracker rescan + frontend store updates so the UI splits
PRs / files / commits per branch without a session restart. MCP
add_branch_to_task_kandev tool is shielded to the worktree executor.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(web): unify multi-branch repo label as <repo> · <branch> across panels
PR Changes and Commits panels rendered repo labels differently for multi-branch
tasks — Commits showed "kandev · feature/x" while PR Changes leaked the raw
"kandev-feature-x" agentctl tracker tag. Reformat tracker-tagged repo names via
formatRepoLabel using the longest known-repo prefix so kandev-cli-* doesn't
collapse to kandev · cli-*.
* fix(tasks): address multi-branch PR review — data races, slug collisions, MCP error codes
Backend
- agentctl process.Manager: add repoTrackersMu RWMutex guarding repoTrackers
+ workspaceTracker. Reads snapshot under RLock; RescanRepositories builds
trackers first, takes Lock for swap/append. Validates newWorkDir via
os.Stat before publishing cfg.WorkDir.
- worktree.Manager: reject invalid explicit BranchSlug up-front; cache key
uses sanitized wt.BranchSlug so read/write paths are symmetric.
- worktree.store: narrow isDuplicateColumnError to "duplicate column name".
- task service AddBranchToTask: slug-collision guard rejects feat/a vs feat-a.
- orchestrator resolvePushRepo: two-pass exact-name match before subdir
prefix fallback so "backend" doesn't swallow pushes for "backend-admin".
- executor buildRepoSpecs: duplicate repos always get a non-empty BranchSlug
(falls back to branch-N when both branch fields sanitize to empty).
- MCP handler: classifyAddBranchError maps service errors to NOT_FOUND /
CONFLICT / VALIDATION_ERROR; add ErrorCodeConflict constant.
- MCP server: extract registerAddBranchToTaskTool, register in task mode
only (external mode contract: config tools + create_task_kandev).
- workspace_rescan handler: reject malformed JSON with 400 instead of
silently rescanning the current root.
- branch_materializer: split MaterializeBranch into prepare + finalize
helpers to satisfy the 80-line cap.
Frontend
- existingPrUrl returns undefined when a task has >1 PR so per-repo View PR
buttons take over (no silent linking to a sibling).
- formatRepoLabel checks exact known-repo match before prefix splitting so
"kandev-cli" isn't mislabeled as "kandev · cli".
- Repo chip key now includes base_branch so siblings differing only on
base render as distinct chips.
Docs
- multi-branch spec uniqueness wording aligned with the canonical 4-column
key (task_id, repository_id, base_branch, checkout_branch).
- worktree.CreateRequest.BranchSlug doc describes the sibling layout
(<repo>-<slug>) instead of the obsolete nested form.
* fix(tasks): scope slug-collision check to repo only + use errors.Is for ErrTaskNotFound
- service_branches: drop base_branch from the slug-collision check. Worktree
path is (repo, slug); two rows on the same repo cut from different bases
still collide on disk.
- service_branches: prepareBranchAdd wraps taskrepo.ErrTaskNotFound via
fmt.Errorf with %w so classifiers don't have to substring-match the
formatted task UUID.
- mcp handlers: classifyAddBranchError uses errors.Is(err, ErrTaskNotFound).
* fix(agentctl): sanitize rescan work_dir before os.Stat to clear CodeQL path-injection
CodeQL flagged the rescan endpoint as a path-injection sink (the
HTTP-supplied work_dir flowed straight into os.Stat and downstream
scanRepositorySubdirs). Add validateRescanPath which:
- requires the input to be a clean absolute path with no ".." segments
- rejects anything that isn't equal to or an ancestor of the current
cfg.WorkDir — the only legitimate use case is the branch materializer
promoting <task>/<repo>/ to <task>/
Defense in depth: agentctl binds to localhost, but any colocated process
could otherwise direct it at /etc or the user's home by POSTing
/api/v1/workspace/rescan.
* fix(agentctl): tighten validateRescanPath, close TOCTOU on existingTrackers, add tests
- Drop the strings.Contains(clean, "..") guard. filepath.Clean already
resolves traversal; the substring check rejects legitimate names like
/home/u/..hidden without adding defense (the ancestor check via
filepath.Rel is the real boundary).
- Drop the rel == "." branch in the ancestor check — early return for
clean == currentClean already covers identity.
- Read existingTrackers inside the write-lock section (not under RLock
then re-Lock) so two concurrent rescans can't both see 0 trackers and
double-bootstrap the multi-repo set.
- Add table-driven TestValidateRescanPath covering rejection paths
(relative, descendant, sibling, traversal-cleaned, empty input),
legitimate ancestor promotion, exact match, no-anchor first-launch,
and a /home/u/..hidden dotted-segment case.
* fix(agentctl): break taint flow into os.Stat — derive rescan path from cfg, not HTTP body
CodeQL's go/path-injection trace still flagged manager_rescan.go after the
prior sanitizer because the user-supplied newWorkDir flowed (after
validation) into os.Stat and then into cfg.WorkDir → tracker paths →
workspace_monitor os.Stat sinks.
Replace validateRescanPath with resolveRescanPath: it returns a path
DERIVED from currentWorkDir (no-op = currentClean, promotion =
filepath.Dir(currentClean)) and uses newPath only for comparison, never
as a source for the returned string. os.Stat now receives a path whose
provenance is trusted manager config.
Functionally the legitimate caller (branch materializer promoting
<task>/<repo>/ to <task>/) is unchanged.
* test(agentctl): make TestResolveRescanPath windows-portable
The Linux Backend Tests job passed but the Backend (windows) job rejected
every "absolute" case because filepath.IsAbs("/task/repo") is false on
Windows (no volume letter).
Build inputs via filepath.Join against absRoot() (filepath.Abs of "/" /
"\\") so the same table covers POSIX (`/...`) and Windows (`C:\...`)
without per-OS branches. Keep the traversal-after-Clean case POSIX-only
since filepath.Clean is the shared primitive and that input form needs a
volume to be considered absolute on Windows anyway.
* fix(agentctl): serialize RescanRepositories + re-check trackers under write lock
Two concurrent RescanRepositories calls could both observe an empty
tracker set (between the snapshot read and the bootstrap branch) and both
enter transitionToMultiRepoMode, leaking a duplicate bare-root tracker
and duplicate per-repo trackers. Same race in appendNewRepoTrackers would
double-append the same new child across both callers.
Fix:
- Add rescanMu sync.Mutex on Manager. Acquired for the full body of
RescanRepositories so the snapshot → branch → mutate sequence is
serialized. repoTrackersMu still guards individual field reads from
unrelated goroutines (subscribe/unsubscribe/poll-mode).
- appendNewRepoTrackers re-checks membership inside the write-lock
before appending and drops already-present trackers (Stop + detach
outside the lock). Defense in depth.
- Test: add `dotted segment is legal as promotion` case so the
..hidden-segment promotion path stays exercised.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(backend): make repository setup script failures non-fatal
A failing repository setup script aborted environment preparation and
stopped the agent ("Environment setup failed"), and Resume / Start-fresh
couldn't recover because each relaunch re-ran the same failing script.
runWorktreeSetupScript (run inside worktree creation) treated a non-zero
exit as fatal: it deleted the worktree and returned an error, which
cascaded to EnvPrepareResult{Success: false} and aborted the launch. This
contradicted the already-non-fatal task/profile-level setup script path.
The repo-level script is now non-fatal too: on failure it logs a warning,
records SetupScriptWarning on the worktree, keeps the worktree, and lets
the launch proceed. The env preparer surfaces it on the "Create worktree"
step so the panel shows completed_with_warnings instead of failed. The
full script output still streams to the script_execution chat message.
Adds backend regression tests and an e2e test (verified red before / green
after) asserting the agent still launches when the repo setup script fails.
* fix(backend): preserve base-branch warning when setup script also fails
Addresses review feedback: the post-Create block surfacing SetupScriptWarning
on the "Create worktree" step unconditionally overwrote BaseBranchFallbackWarning
when both occurred, silently dropping the "wrong branch" notice. Append instead
of replace so both survive.
Also updates TestWorktreePreparer_SetupScriptFailure_KeepsCreateWorktreeStepCompleted
to the non-fatal contract: a failed setup script now yields a successful prepare
with a warning on the step, not a failed prepare.
* chore(agentctl): delete duplicate procattr files in adapter package
setProcGroup and killProcessGroup are defined in both
agentctl/server/adapter/ and agentctl/server/process/. The process/
copies are the live ones (called by process/runner.go, manager.go,
vscode.go); the adapter/ copies have zero callers and are removed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(agentctl): remove orphan helpers from transport/shared
Left over from the ACP refactor. All had zero callers:
- DebugEnabled(): internal debugMode var is checked directly by
LogRawEvent/LogNormalizedEvent — the exported wrapper was unused.
- GetBool, GetMap, GetSlice: only GetString/GetInt are called in acp/normalize.go.
- NormalizeShellResult, NormalizeReadResult, NormalizeCodeSearchResult,
NormalizeModifyResult: normalization moved to acp/normalize.go.
- TruncateIfNeeded, MaxContentLength, MaxFileCount: only used by the
four Normalize* helpers above; removed transitively.
- TraceControlMessage: zero callers in the adapter transport layer.
GenerateUnifiedDiff, SplitLines, DetectLanguage, Tracer, ShutdownTracing,
TraceProtocolEvent, TraceProtocolRequest are alive and kept.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(agentctl): remove unused tool_payload constructors
NewHttpRequest, NewCreateTask, NewSubagentTask, NewShowPlan,
NewManageTodos, and NewMisc have zero callers — ACP normalization
builds NormalizedPayload via struct literals and JSON unmarshaling,
not these factory functions. NewReadFile, NewModifyFile, NewShellExec,
NewCodeSearch, and NewGeneric are kept (live callers in acp/normalize.go).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(agents/detect): remove dead fluent options and OSPaths type
WithFileExists, WithCommandOutput, WithEnvVar, expandHomePath (their
transitive helper), and OSPaths (struct + Resolve + Expanded) have
zero callers. Only WithCommand and WithNpxRunnable are used by agent
IsInstalled implementations. Imports trimmed to context and os/exec.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(office): remove dead channel methods superseded by service/
ListComments, DeleteComment, and UpdateChannelStatus on ChannelService
have zero callers — the live implementations live in
internal/office/service/comments.go and service/channels.go.
CreateComment is kept: it is called by HandleChannelInbound.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: remove scattered orphan helpers
Remove dead single-symbol exports verified to have zero callers:
- ParseCronExpression (office/shared/cron.go): NextCronTime covers
all live usages; the validator wrapper was unused.
- IsValidPathComponent, MustJSON (office/shared/helpers.go): zero
callers; GenerateSlug is kept.
- DiscoverUserSkillsAt (office/skills/userhome_discovery.go): exported
wrapper over the private discoverUserSkillsAt; only the internal
DiscoverUserSkills method is wired up.
- Manager.Get (agent/loginpty/manager.go): zero callers; GetByID and
Stop are kept (Stop is tested in manager_test.go).
- DetachedWithValues (common/appctx/appctx.go): zero callers; Detached
is kept.
- Repository.Close no-ops (task/share, terminal/repository): both are
documented no-ops with zero callers.
- TaskBranchName (worktree/config.go): zero callers; the deterministic
TaskBranchNameWithSuffix is kept.
Note: dialect.Now was initially targeted but is tested in
dialect_test.go (same-package call — missed by package-qualified grep).
It remains unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(share): remove stale Close reference from provider.go comment
Repository.Close() was removed as a no-op in the round-4 cleanup.
The comment in provider.go still referenced it by name; updated to
reflect that the repo simply doesn't own its DB connections.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(office/shared): delete helpers.go — GenerateSlug had zero callers
The triage step mistakenly identified skills/service.go as a caller,
but that package defines its own local GenerateSlug (different regex,
adds TrimSpace and a 'skill' fallback default). shared.GenerateSlug
was never called anywhere. helpers.go is now empty; delete the file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(worktree): fix stale TaskBranchName reference in TaskBranchNameWithSuffix doc
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(backend): add go.uber.org/goleak test dependency
Promotes the previously-indirect goleak v1.3.0 to a direct require so
per-package TestMain calls can use it for goroutine leak detection.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(backend/lifecycle): drain workspace-stream backoff on Manager.Stop
StreamManager.connectWorkspaceStream slept the full retry backoff in
time.Sleep between failed attempts. SessionTraceContext is deliberately
uncancellable (it carries a long-lived span), so if the Manager began
shutting down mid-backoff the goroutine outlived Stop() and stranded
until the timer fired.
Thread Manager.stopCh through the StreamManager constructor and add a
sleepOrStop helper that selects between the timer and the shutdown
signal. ReconnectAll's startup sleep gets the same treatment.
TestConnectWorkspaceStream_BackoffDrainsOnStop closes stopCh while a
failing-port retry is in flight and asserts the goroutine exits within
500ms — fails without the fix, passes with it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(backend/github): close sqlite DB in newTestStore to drain connectionOpener
newTestStore opened *sqlx.DB via db.OpenSQLite but never registered a
Close on cleanup, leaving database/sql.(*DB).connectionOpener goroutines
parked across test boundaries. Add t.Cleanup(sqlxDB.Close); goleak's
per-package TestMain (added in a follow-up commit) is the regression
check.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(backend): wire goleak.VerifyTestMain across leak-prone packages
Per-package TestMain in:
- internal/integrations/healthpoll (auth-health ticker loop)
- internal/jira, internal/linear (auth + issue-watch loops)
- internal/github (PR / review / issue poller loops)
- internal/gateway/websocket (per-conn pumps + terminal bridge)
- internal/orchestrator (detached event-handler goroutines)
- internal/agent/runtime/lifecycle (remote-status loop, StreamManager
helpers; ignores OTel batch-span processor and OTLP retry helper —
initialised early in the process and only exit on shutdown)
- internal/agentctl/server/process (extends the existing fixture-binary
TestMain so subprocess + PTY pump leaks surface there)
Catches regressions where a Start/Stop forgets to drain on WaitGroup,
or a detached goroutine forgets to honour ctx/stopCh cancellation.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs(backend): document goroutine ownership and leak-testing convention
New "Goroutine ownership and leak testing" subsection under Conventions
in apps/backend/AGENTS.md (CLAUDE.md symlinks to it). Covers:
- Lifecycle: Start/Stop + WaitGroup, both idempotent.
- Cancellation: every long wait selects on ctx.Done() or stopCh; never
time.Sleep in retry loops (reference lifecycle.StreamManager.sleepOrStop).
- Detached helpers: must respect the owning type's shutdown signal.
- Leak testing: new packages spawning goroutines add goleak.VerifyTestMain;
IgnoreTopFunction is reserved for genuine third-party background
goroutines and must carry a comment explaining the suppression.
Lists the packages currently instrumented so future contributors can
mirror the pattern.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(backend/lifecycle): drop dead OTel IgnoreTopFunction from goleak setup
Review follow-up. agentctl/tracing.initTracing only spawns the OTel SDK
provider when OTEL_EXPORTER_OTLP_ENDPOINT is set; in tests it isn't, so
the noop provider is used and no batchSpanProcessor / OTLP retry
goroutines ever exist. The IgnoreTopFunction calls were dead
suppressions and the comment ("called by NewManager via the agent
execution trace path") misdescribed when the SDK init runs. Drop the
ignores and correct AGENTS.md to stop citing OTel as the canonical
suppression example.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(backend/lifecycle): drop unneeded 50ms sleep from backoff-drain test
QA follow-up. The 50ms time.Sleep before close(stopCh) in
TestConnectWorkspaceStream_BackoffDrainsOnStop was meant to ensure the
goroutine had entered the backoff sleep before shutdown — but that
ordering doesn't affect the test outcome:
- With the fix: sleepOrStop sees stopCh already closed and returns
false on the first call, so the goroutine drains within one attempt
regardless of when stopCh closes.
- Without the fix: time.Sleep ignores stopCh, the goroutine burns
its full 5-retry backoff (~31s), and the 500ms timeout fires.
Removing the sleep makes the test ~12x faster (15ms vs 200ms total) and
removes the only time.Sleep-based wait from the new tests, aligning
with the codebase's "channel-based sync, no sleep waits" convention.
Verified by 50x -race and 5x revert-and-fail.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(backend/github): drain HTTP idle conns before goleak inspects
CI surfaced a leaked goroutine on the Linux runner that local runs
missed: a single net/http.(*http2ClientConn).readLoop parked on a
TLS read, created by net/http.(*http2Transport).newClientConn. The
package's anonymousHTTPClient and PATClient's rewriteTransport both
route through http.DefaultTransport's connection pool, so an idle
HTTP/2 connection in that pool keeps the reader goroutine alive past
m.Run() — invisible on sandboxed local runs where outbound network
is blocked.
Replace github/goleak_test.go's plain VerifyTestMain with an
expanded TestMain that drains http.DefaultTransport's idle
connections before goleak.Find runs. This is the stdlib's
documented teardown for the connection pool, keeps the assertion
strict (no IgnoreTopFunction), and doesn't leak into production
code. Other in-scope packages don't need this — they don't use
http.DefaultTransport for HTTPS calls.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(backend): drain ws.Done on stopCh; always run goleak in github tests
- lifecycle/streams.go: replace bare <-ws.Done() with a select that also
watches sm.stopCh so a connected goroutine drains when Manager.Stop()
fires before the remote WS closes. A nil stopCh (isolated tests) blocks
the nil channel case forever, degrading to plain <-ws.Done() semantics.
- github/goleak_test.go: remove the exitCode==0 guard around goleak.Find
so leaks are always surfaced even when a test has already failed; original
exit code is preserved (leaks only promote it to 1).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(backend/lifecycle): close ws on stopCh so agentctl WS loops drain
cubic flagged that the previous shutdown path observed stopCh but never
called ws.Close(), so the underlying WebSocket and its agentctl-side
read/write goroutines (workspace_stream.go:67-68) kept running past
Manager.Stop(). ClearWorkspaceStream only removes the WS from the
execution's tracking — it doesn't close the connection.
Call ws.Close() on the stopCh path. ws.Close is idempotent via
closeOnce, so it's safe even if the remote end already closed.
TestConnectWorkspaceStream_ClosesWSOnStop connects to a mock workspace
server that stays open until the client closes its side; after stopCh
fires, the test asserts ws.Done() closes within 500ms — which is only
true if connectWorkspaceStream invoked ws.Close(). Verified to fail
without the fix and pass with it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(backend/lifecycle): use channel sync instead of sleep polling in ws-close test
cubic flagged that TestConnectWorkspaceStream_ClosesWSOnStop polled
execution.GetWorkspaceStream() in a 500ms sleep loop, which is brittle
under CI load. connectWorkspaceStream already accepts a ready
channel that closes after SetWorkspaceStream — pass it in and wait on
the channel instead.
synctest isn't viable here (real network I/O), so channel-based sync is
the testing convention per AGENTS.md.
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>
* chore: remove orphan executor tracing file and deprecated ResolveScript wrappers
- Delete internal/agentctl/tracing/executor.go (7 TraceExecutor* funcs)
- Delete internal/orchestrator/executor/script_template.go (deprecated wrapper)
- Delete scriptengine.ResolveScript convenience function
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: remove orphan setter methods across healthpoll, jira, linear, slack, updates
- Delete Poller.SetInterval from integrations/healthpoll (no callers)
- Delete Poller.SetIssueTickHook from jira and linear (no callers)
- Delete Trigger.SetInterval and Trigger.SetMatchHook from slack (no callers)
- Delete Service.SetPollInterval from system/updates (no callers)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: remove orphan wrapper functions and accessor method
- Delete updates.FetchLatestRelease shim (wraps FetchLatestReleaseFrom, no callers)
- Delete usage.NewClaudeUsageClient and NewCodexUsageClient shims (no callers)
- Delete database.Service.Path() accessor (no callers)
- Update ErrGitHubRateLimited godoc to reference FetchLatestReleaseFrom
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: remove unwired handleListExecutors MCP handler
The sibling profile handlers (list/create/update/delete) are registered
via RegisterFunc in handlers.go. handleListExecutors never was — dead flow.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: remove stale hook-field comments after setter deletion
SetIssueTickHook (jira, linear) and SetMatchHook (slack) were removed
in the deadcode cleanup. Their struct fields still claimed "tests use
this" — no test or caller can set these fields now.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: remove orphan hook fields and fire methods after setter deletion
issueTickHook (jira/linear) and matchHook (slack) are unexported and
never set after their public setters were removed; fireIssueTickHook /
fireHook were permanent no-ops.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>