* 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(agentctl): read models/modes from configOptions fallback
claude-agent-acp v0.42 dropped the unstable `models` field from
session/new and publishes models only via `configOptions[]` with
category=model. The probe still relied on the legacy field, so
the inference-agents endpoint reported zero models and the UI
showed "Claude advertised no models."
Read the legacy fields first, then fall back to configOptions
when they are absent. Same fallback applied to modes for parity
with future agents.
* fix(agentctl): gate configOptions fallback on nil legacy field
Switch the fallback guard in `applySessionProbeFields` from
`len(out.Models) == 0` to `sessionResp.Models == nil` (same for
modes). A non-nil `Models` struct with an empty `AvailableModels`
slice is schema-valid: the legacy branch already stamps
`CurrentModelID`, and the previous guard would then fire the
configOptions fallback and overwrite it with a different source.
Adds `TestApplySessionProbeFields_LegacyEmptyModelsBlocksFallback`
covering the edge case.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* 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): 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>
* fix(acp): handle claude-acp async_launched subagents end-to-end
Claude Agent SDK's Task tool with run_in_background=true returns
session/prompt with stop_reason=end_turn while subagents stay alive
in the background, reporting `_meta.claudeCode.toolResponse.status =
"async_launched"` instead of a terminal top-level status. kandev
previously treated stop_reason as authoritative, cancelled the
subagent card on prompt return, and fired an "agent finished without
producing any output" notice — even though the dispatch did produce
work and the subagent's child events would arrive seconds later.
Backend:
- `SubagentTaskPayload` gains `IsAsync` / `OutputFile` /
`CanReadOutputFile` so the async-launched envelope is preserved.
- `convertToolCallResultUpdate` recognizes async_launched as terminal
for the Task tool itself (dispatch is the completion).
- `cancelActiveToolCalls` preserves subagent_task tool calls through
the prompt-return sweep so a late terminal tool_call_update from
the SDK still lands correctly (covers the in-turn-complete case).
Frontend:
- `subagentMetaChips` emits a "background" chip when `is_async` or
`status == "async_launched"` so the card is visually distinct.
- `computeEmptyTurnNotice` suppresses the notice when any subagent
task message exists for the turn (the dispatch IS output).
Reproduced via `acpdbg prompt` against `claude-acp` 0.31.1; captured
frames in acp-debug/claude-acp-prompt-20260602-110119.jsonl drive the
backend test fixtures verbatim.
* fix(acp): harden async_launched override against future SDK frames
Address PR review feedback (greptile #1244, claude review):
- Move the async_launched override inside the mutex lock so it can
inspect the stored payload kind before flipping the status.
- Drop the `status == ""` precondition. The earlier in_progress
synthesis (Title/RawInput/Content/Locations) was implicitly trumping
the override; a future SDK frame that combines async_launched with
any of those fields would have left the card stuck in_progress
forever. The override is now unconditional for async_launched
subagent_task frames.
- Gate the override on `payload.Kind() == ToolKindSubagentTask`. A
hypothetical future claude-acp tool that reuses the `async_launched`
literal in its own `_meta.claudeCode.toolResponse.status` would have
been incorrectly marked complete by the prior unscoped check.
Pinned by two new tests:
- TestConvertToolCallResultUpdate_AsyncLaunched_OverridesInProgress
- TestConvertToolCallResultUpdate_AsyncLaunched_NonSubagentNotTerminated
---------
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>
* 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>
* refactor(acp): split adapter.go into per-concern files
Pure file split of the 2,083-line transport/acp/adapter.go into sibling
files inside the same package — no behavior change, no new types, no
signature changes. The struct, constructor, lifecycle, and locking-
sensitive helpers (sendUpdate, trace ctx, Close) stay co-located in
adapter.go (422 lines); session/mcp/auth, prompt/cancel/wakeup, update
dispatch, tool-call conversion, permission flow, and cross-file helpers
each move to their own file, all under the 800-line lint cap.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(acp): restore struct doc wording on availableAuthMethods
independent-review fix: the file split should have been byte-identical for
comments. Restore "surface a picker" (matches origin/main) — accidentally
rewritten as "drive a picker" during the adapter.go rewrite in ccfad56d.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* ci(acp): extract toolStatusInProgress / toolStatusCancelled constants
CI golangci-lint flagged 'in_progress' (9 occurrences) and 'cancelled'
(4 occurrences) as goconst violations after the file split surfaced them
as "new" lines. Extend the existing toolStatus* constant set in
normalize.go and use the new constants at every production call site
(adapter_tools.go, adapter_prompt.go, monitor.go). Behavior unchanged.
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): force fresh git status on session subscribe
Every new WS subscriber to a session now triggers a fresh `git status
--porcelain` in agentctl instead of replaying the cached snapshot.
This makes the cache an optimization for already-subscribed observers
only — a fresh observer revalidates ground truth on every connect, so
a stale cache (e.g. left over when the poll loop missed a HEAD change
in paused mode or dropped a tick) self-heals on the next subscribe.
- `?fresh=true` on `/api/v1/git/status/multi` propagates through to
`WorkspaceTracker.GetGitStatus(ctx, true)`.
- New `GetGitStatusMultiFresh` client method; `tryGetLiveGitStatus`
(the session-subscribe hydration path) uses it.
- Regression test reproduces the bug: dirty file → cache primed →
commit without refresh → cached read still shows the file as
modified; fresh=true returns the clean truth.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: address PR review feedback
- Reframe regression-test precondition comment so it reads as scenario
setup (Claude review).
- Document GetGitStatusMultiFresh's read-only-vs-cache contract in its
godoc so a future reader doesn't try to write the result back into
the tracker's currentStatus (Claude review).
- Trim multi-line block comment in helpers.go to a single line per the
backend AGENTS.md convention (Claude review).
- Parallelize handleGitStatusMulti's per-repo fan-out via sync.WaitGroup
to keep the fresh-path wall-clock close to the slowest single repo
regardless of repo count, so multi-repo workspaces don't blow past
the 2s subscribe-path timeout (Greptile review). Pre-sized result
slice means each goroutine writes its own slot — no shared-slice
contention. collectStatusForRepo now takes context.Context directly
instead of *gin.Context.
- Add a third assertion in the regression test that locks in the
documented cache-unchanged contract — after fresh=true, a subsequent
GetGitStatus(ctx, false) call must still return the stale cached
value, so any future change that silently writes through fresh would
fail the test (Greptile review).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: address round 2 review feedback
- Trim the parallelization rationale in handleGitStatusMulti to a
single line per AGENTS.md convention (Claude review).
- Drop unused GetGitStatusMulti client method (cached variant) and its
internal getGitStatusMulti helper — only GetGitStatusMultiFresh has
a caller after this PR, and AGENTS.md says to delete unused code
outright (Claude review).
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>