Summary
When an execution is re-created after the agent stops (rate-limit kill, crash, lazy recovery), the new agentctl instance is built without the per-repo base-branch map. Its workspace trackers fall back to the hardcoded {origin/main, origin/master} list, so for any repository whose integration branch is named something else the commits panel reports the branch's divergence from main rather than from its real base. On a task with zero commits of its own, the panel showed COMMITS (100) — the request cap, not a count — and the entries were unrelated history from three repositories.
The value corrects itself on a later refetch, so the panel silently alternates between right and wrong with no visible difference between the two.
Affected area
agentctl
Kandev version
0.94.0 (desktop app; source read at tag v0.94.0, behaviour also present on main)
Install mode
Other — packaged desktop app
Environment
macOS, Darwin 25.6.0, arm64. Multi-repo task, three repositories, all with base branch origin/develop. Worktree executor.
Kandev configuration
Three repositories attached to one task, each with base_branch = origin/develop and repository default_branch = develop. Claude ACP agent profile. No comparison targets set.
Task/session context
A multi-repo task started from a Jira ticket via the full launch path, whose agent was later terminated by a provider rate limit and whose execution was then re-created on demand.
Steps to reproduce
- Create a multi-repo task whose repositories use an integration branch not named
main or master (e.g. develop), started from a Jira ticket or any full-launch path.
- Let the agent run, then let its process die — rate-limit kill, crash, or stop.
- Touch anything that needs the workspace (open the Changes panel, a terminal, a file). This re-creates the execution through
GetOrEnsureExecution.
- Read the COMMITS count in the Changes panel while the new instance is coming up.
Reproducibility
Intermittent
Clean-state check
Not tried
Expected behavior
The commits panel counts commits on the task's branch relative to its configured base branch — zero when the branch has no commits of its own — on every execution, however it was created.
Actual behavior
The panel shows up to defaultLogLimit (100) commits of unrelated repository history. Observed on a task whose three repositories were each 0 commits ahead of origin/develop but 315 / 178 / 871 ahead of origin/main — 1,364 commits merged and truncated to 100. A later refetch (or a page reload) returns the correct 0.
Intermittency note: the bug depends on a fetch landing in the unseeded window, but it is guaranteed reachable on any task whose agent dies and is re-created. Clean-state note: the behaviour follows from execution-creation ordering, not stored workspace state.
Root cause
Two independent gaps, either of which produces it:
1. The recovery path never carries the map. prepareExecutionCreateRequest (manager_execution.go) builds the create request for every on-demand execution. It hydrates comparison targets:
comparisonTargets, err := comparisonTargetsFromMetadata(metadata)
…
ComparisonTargets: comparisonTargets,
but sets no BaseBranches — there is no baseBranchesFromMetadata equivalent and the field is never populated. The full launch path does carry it (buildMetadata → MetadataKeyBaseBranches → getMetadataStringMap in each executor), so a launched instance is born seeded and a recovered one is not.
2. Seeding happens after the instance is serving. waitForAgentctlReady (manager_startup.go):
err := client.WaitForReady(ctx, 60*time.Second) // HTTP server proven up
if err != nil { … return }
execution.MarkAgentctlReady() // ← requests can be served from here
…
m.pushTaskBaseBranches(ctx, …) // ← map arrives only now
m.pushTaskComparisonTargets(ctx, …)
Anything that fetches between those two points gets trackers with no stored base branch.
Downstream, resolveBaseBranchWithReason falls through to branchDiffCandidates = integrationBranchRefs(true) over integrationBranchNames = {main, master}. develop isn't a candidate, and the repository's own DefaultBranch — which kandev stores and already reads in collectTaskBaseBranches — is never consulted. With no anchor at all, GetLog takes its open-ended path (-n<limit>) and mergeGitLogResults truncates to the same limit, producing a plausible-looking "100".
Prior art: #2270 added the post-ready push precisely because "LaunchRequest metadata only carries it on the full launch path". That fix is incomplete on both counts — the push runs after the gate, and the create-request path it backstops still omits the field.
Timeline from one occurrence
17:06:33 launching agent for prepared session ← full launch, instance seeded
17:06:41 agent launched for prepared session
17:07:25–29 three branch switches ← worktrees checked out
17:32:47 handling agent stopped ← agent killed
17:33:14 creating execution for task session ← ensure path, no BaseBranches
17:52:45 creating execution for task session ← again
Suggested fix
In priority order:
- Populate
BaseBranches in prepareExecutionCreateRequest, mirroring the ComparisonTargets hydration directly above it. Read it from info.Metadata when the environment persisted it, otherwise hydrate synchronously from m.baseBranchProvider(ctx, taskID) — the manager already holds that provider and already calls it in pushTaskBaseBranches. This makes a recovered execution born seeded, like a launched one. Without it, the other two only narrow the window.
- Move
pushTaskBaseBranches / pushTaskComparisonTargets above execution.MarkAgentctlReady(). A pure reorder: client.WaitForReady has already proven the HTTP server answers, and the client is acquired identically in both places.
- Refuse to answer range queries before the map is known.
config.BaseBranches already exists; add a seeded flag set at instance creation and by POST /workspace/base-branches, and have git log / status / cumulative-diff return the existing ready:false envelope until it is set. The frontend already retries that envelope (NOT_READY_RETRY_MS = 2000), so no UI change is needed. This makes the failure impossible to reintroduce silently.
Optional hardening, independent of the above: consult the repository's stored DefaultBranch before {main, master} in integrationBranchNames, so the fallback is correct for non-main shops when it genuinely is the right path.
Logs and artifacts
Timeline above is from the backend log of one occurrence. No errors are logged on this path — base-branch seeding only logs on failure, and none occurred; the wrong answer is produced by a successful request.
Last known good version
Unknown. The post-ready push dates to #2270; the create-request omission appears to predate it.
Extra context
Related: #2270 (added the post-ready push this extends). The truncation itself is also invisible: mergeGitLogResults slices to the limit with no flag, so a capped list renders identically to a real count — CumulativeDiffResult already carries TruncatedFilesCount for the analogous case.
Summary
When an execution is re-created after the agent stops (rate-limit kill, crash, lazy recovery), the new agentctl instance is built without the per-repo base-branch map. Its workspace trackers fall back to the hardcoded
{origin/main, origin/master}list, so for any repository whose integration branch is named something else the commits panel reports the branch's divergence frommainrather than from its real base. On a task with zero commits of its own, the panel showedCOMMITS (100)— the request cap, not a count — and the entries were unrelated history from three repositories.The value corrects itself on a later refetch, so the panel silently alternates between right and wrong with no visible difference between the two.
Affected area
agentctl
Kandev version
0.94.0 (desktop app; source read at tag
v0.94.0, behaviour also present onmain)Install mode
Other — packaged desktop app
Environment
macOS, Darwin 25.6.0, arm64. Multi-repo task, three repositories, all with base branch
origin/develop. Worktree executor.Kandev configuration
Three repositories attached to one task, each with
base_branch = origin/developand repositorydefault_branch = develop. Claude ACP agent profile. No comparison targets set.Task/session context
A multi-repo task started from a Jira ticket via the full launch path, whose agent was later terminated by a provider rate limit and whose execution was then re-created on demand.
Steps to reproduce
mainormaster(e.g.develop), started from a Jira ticket or any full-launch path.GetOrEnsureExecution.Reproducibility
Intermittent
Clean-state check
Not tried
Expected behavior
The commits panel counts commits on the task's branch relative to its configured base branch — zero when the branch has no commits of its own — on every execution, however it was created.
Actual behavior
The panel shows up to
defaultLogLimit(100) commits of unrelated repository history. Observed on a task whose three repositories were each 0 commits ahead oforigin/developbut 315 / 178 / 871 ahead oforigin/main— 1,364 commits merged and truncated to 100. A later refetch (or a page reload) returns the correct 0.Intermittency note: the bug depends on a fetch landing in the unseeded window, but it is guaranteed reachable on any task whose agent dies and is re-created. Clean-state note: the behaviour follows from execution-creation ordering, not stored workspace state.
Root cause
Two independent gaps, either of which produces it:
1. The recovery path never carries the map.
prepareExecutionCreateRequest(manager_execution.go) builds the create request for every on-demand execution. It hydrates comparison targets:but sets no
BaseBranches— there is nobaseBranchesFromMetadataequivalent and the field is never populated. The full launch path does carry it (buildMetadata→MetadataKeyBaseBranches→getMetadataStringMapin each executor), so a launched instance is born seeded and a recovered one is not.2. Seeding happens after the instance is serving.
waitForAgentctlReady(manager_startup.go):Anything that fetches between those two points gets trackers with no stored base branch.
Downstream,
resolveBaseBranchWithReasonfalls through tobranchDiffCandidates=integrationBranchRefs(true)overintegrationBranchNames = {main, master}.developisn't a candidate, and the repository's ownDefaultBranch— which kandev stores and already reads incollectTaskBaseBranches— is never consulted. With no anchor at all,GetLogtakes its open-ended path (-n<limit>) andmergeGitLogResultstruncates to the same limit, producing a plausible-looking "100".Prior art: #2270 added the post-ready push precisely because "LaunchRequest metadata only carries it on the full launch path". That fix is incomplete on both counts — the push runs after the gate, and the create-request path it backstops still omits the field.
Timeline from one occurrence
Suggested fix
In priority order:
BaseBranchesinprepareExecutionCreateRequest, mirroring theComparisonTargetshydration directly above it. Read it frominfo.Metadatawhen the environment persisted it, otherwise hydrate synchronously fromm.baseBranchProvider(ctx, taskID)— the manager already holds that provider and already calls it inpushTaskBaseBranches. This makes a recovered execution born seeded, like a launched one. Without it, the other two only narrow the window.pushTaskBaseBranches/pushTaskComparisonTargetsaboveexecution.MarkAgentctlReady(). A pure reorder:client.WaitForReadyhas already proven the HTTP server answers, and the client is acquired identically in both places.config.BaseBranchesalready exists; add a seeded flag set at instance creation and byPOST /workspace/base-branches, and have git log / status / cumulative-diff return the existingready:falseenvelope until it is set. The frontend already retries that envelope (NOT_READY_RETRY_MS = 2000), so no UI change is needed. This makes the failure impossible to reintroduce silently.Optional hardening, independent of the above: consult the repository's stored
DefaultBranchbefore{main, master}inintegrationBranchNames, so the fallback is correct for non-main shops when it genuinely is the right path.Logs and artifacts
Timeline above is from the backend log of one occurrence. No errors are logged on this path — base-branch seeding only logs on failure, and none occurred; the wrong answer is produced by a successful request.
Last known good version
Unknown. The post-ready push dates to #2270; the create-request omission appears to predate it.
Extra context
Related: #2270 (added the post-ready push this extends). The truncation itself is also invisible:
mergeGitLogResultsslices to the limit with no flag, so a capped list renders identically to a real count —CumulativeDiffResultalready carriesTruncatedFilesCountfor the analogous case.