fix(tasks): harden multi-branch add_branch and surface it in UI (#1239)
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>
This commit is contained in:
@@ -166,39 +166,45 @@ func (b *branchMaterializer) finalizeMaterialize(
|
||||
b.notifyAgentctlRescan(ctx, session, taskRoot)
|
||||
if b.rescanner != nil {
|
||||
b.rescanner.NotifyWorktreeMaterialized(ctx, lifecycle.MaterializedWorktree{
|
||||
TaskID: taskID,
|
||||
SessionID: session.ID,
|
||||
WorktreeID: wt.ID,
|
||||
WorktreePath: wt.Path,
|
||||
WorktreeBranch: wt.Branch,
|
||||
RepositoryID: repositoryID,
|
||||
BranchSlug: slug,
|
||||
TaskID: taskID,
|
||||
SessionID: session.ID,
|
||||
WorktreeID: wt.ID,
|
||||
WorktreePath: wt.Path,
|
||||
WorktreeBranch: wt.Branch,
|
||||
RepositoryID: repositoryID,
|
||||
BranchSlug: slug,
|
||||
TaskWorkspacePath: taskRoot,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// promoteWorkspacePathIfNeeded switches task_environments.workspace_path
|
||||
// from the primary worktree to the task root when the new worktree lives
|
||||
// as a sibling (e.g. <task>/<repo>-<slug>/). The agent process's CWD does
|
||||
// not change — that's set at launch time — but later session relaunches
|
||||
// pick up multi-repo mode via prepareMultiRepo because workspace_path is
|
||||
// now the parent of all repo dirs. Returns the resolved task-root path so
|
||||
// the caller can pass it to the agentctl rescan.
|
||||
// to the task root that contains the newly-created worktree, so subsequent
|
||||
// session relaunches pick up multi-repo mode via prepareMultiRepo. The
|
||||
// agent process's CWD does not change — that's set at launch time.
|
||||
// Returns the resolved task-root path so the caller can pass it to the
|
||||
// agentctl rescan.
|
||||
//
|
||||
// Sibling layout: <task-root>/<repo>/ and <task-root>/<repo>-<slug>/ both
|
||||
// have filepath.Dir == <task-root>. The check compares parents to confirm
|
||||
// the new worktree is actually a sibling of the env's workspace_path
|
||||
// before mutating the row — otherwise an unrelated path would silently
|
||||
// repoint the env at the wrong dir.
|
||||
// Anchors the task root in env.TaskDirName rather than env.WorkspacePath:
|
||||
// a few historical envs landed with workspace_path pointing at the source
|
||||
// repo's local_path (the orchestrator's computeWorkspacePath fallback)
|
||||
// instead of the primary worktree. Comparing against env.WorkspacePath
|
||||
// alone would refuse to promote those envs because the source repo isn't
|
||||
// a sibling of the new worktree — but the task root we want IS still
|
||||
// `filepath.Dir(newWorktreePath)` and its basename matches env.TaskDirName,
|
||||
// which is a reliable cross-check that the worktree landed under the right
|
||||
// task root and not somewhere unrelated.
|
||||
func (b *branchMaterializer) promoteWorkspacePathIfNeeded(ctx context.Context, env *models.TaskEnvironment, newWorktreePath string) string {
|
||||
taskRoot := filepath.Dir(newWorktreePath)
|
||||
if env.WorkspacePath == taskRoot {
|
||||
return taskRoot
|
||||
}
|
||||
if filepath.Dir(env.WorkspacePath) != taskRoot {
|
||||
b.logger.Warn("skip workspace_path promotion: new worktree is not a sibling of env workspace_path",
|
||||
if env.TaskDirName == "" || filepath.Base(taskRoot) != env.TaskDirName {
|
||||
b.logger.Warn("skip workspace_path promotion: new worktree parent does not match env.task_dir_name",
|
||||
zap.String("env_workspace_path", env.WorkspacePath),
|
||||
zap.String("new_worktree_path", newWorktreePath))
|
||||
zap.String("env_task_dir_name", env.TaskDirName),
|
||||
zap.String("new_worktree_path", newWorktreePath),
|
||||
zap.String("derived_task_root", taskRoot))
|
||||
return env.WorkspacePath
|
||||
}
|
||||
prev := env.WorkspacePath
|
||||
|
||||
@@ -31,6 +31,12 @@ type AgentctlEventPayload struct {
|
||||
WorktreeID string `json:"worktree_id,omitempty"`
|
||||
WorktreePath string `json:"worktree_path,omitempty"`
|
||||
WorktreeBranch string `json:"worktree_branch,omitempty"`
|
||||
// TaskWorkspacePath is the task root that contains every per-repo
|
||||
// worktree as a sibling subdir, populated when the event signals a
|
||||
// sibling worktree being added (multi-branch add_branch flow) rather
|
||||
// than the initial session ready. The frontend uses this to repoint the
|
||||
// file browser at the task root once the task becomes multi-branch.
|
||||
TaskWorkspacePath string `json:"task_workspace_path,omitempty"`
|
||||
}
|
||||
|
||||
// ACPSessionCreatedPayload is the payload when an ACP session is created.
|
||||
|
||||
@@ -22,6 +22,10 @@ type MaterializedWorktree struct {
|
||||
WorktreeBranch string
|
||||
RepositoryID string
|
||||
BranchSlug string
|
||||
// TaskWorkspacePath is the task root after sibling promotion (env's
|
||||
// promoted workspace_path). The frontend reads this to repoint the file
|
||||
// browser to the task root when the task becomes multi-branch.
|
||||
TaskWorkspacePath string
|
||||
}
|
||||
|
||||
// NotifyWorktreeMaterialized publishes an AgentctlReady-shaped event so
|
||||
@@ -52,6 +56,7 @@ func (m *Manager) NotifyWorktreeMaterialized(ctx context.Context, wt Materialize
|
||||
WorktreeID: wt.WorktreeID,
|
||||
WorktreePath: wt.WorktreePath,
|
||||
WorktreeBranch: wt.WorktreeBranch,
|
||||
TaskWorkspacePath: wt.TaskWorkspacePath,
|
||||
}
|
||||
event := bus.NewEvent(events.AgentctlReady, "branch-materializer", payload)
|
||||
if err := m.eventPublisher.eventBus.Publish(ctx, events.AgentctlReady, event); err != nil {
|
||||
|
||||
@@ -201,40 +201,48 @@ func (m *Manager) appendNewRepoTrackers(ctx context.Context, children []reposito
|
||||
}
|
||||
|
||||
// resolveRescanPath maps an externally-supplied workspace path to a known-good
|
||||
// path derived from the manager's existing cfg.WorkDir, breaking the taint
|
||||
// flow from the HTTP body into os.Stat / downstream tracker paths. The only
|
||||
// legitimate caller (kandev backend's branch materializer) promotes the
|
||||
// workdir up exactly one level — from <task>/<repo>/ to <task>/ — so the
|
||||
// allowed transitions are:
|
||||
// path. The legitimate caller (kandev backend's branch materializer) promotes
|
||||
// the workdir to the task root that contains the per-repo worktrees as
|
||||
// siblings. Allowed transitions are:
|
||||
// - newPath equals currentWorkDir → no-op (return current)
|
||||
// - newPath equals parent of current → return derived parent
|
||||
// - newPath is a different absolute directory that actually holds >=1 git
|
||||
// repo subdir → return cleaned newPath (recovery path: covers envs whose
|
||||
// workspace_path landed on the source repo's local_path instead of the
|
||||
// primary worktree, so the parent-only check would otherwise refuse to
|
||||
// ever switch the manager away from the wrong root)
|
||||
//
|
||||
// Critically, the returned path is ALWAYS derived from currentWorkDir
|
||||
// (which originates in trusted manager config), never from newPath itself.
|
||||
// CodeQL's go/path-injection taint analysis cleared once the sink stopped
|
||||
// receiving the HTTP-supplied string directly.
|
||||
// The third branch reintroduces the HTTP-supplied path as a Stat sink, but
|
||||
// the endpoint is already authenticated via the bearer-token middleware and
|
||||
// the manager verifies the path resolves to a real directory before
|
||||
// committing — taint here is gated by auth, not path-shape.
|
||||
//
|
||||
// Returns ("", false) for any other input — first-launch case (currentWorkDir
|
||||
// empty) is handled by the caller falling back to the existing workdir.
|
||||
func resolveRescanPath(newPath, currentWorkDir string) (string, bool) {
|
||||
if newPath == "" || currentWorkDir == "" {
|
||||
if newPath == "" {
|
||||
return "", false
|
||||
}
|
||||
clean := filepath.Clean(newPath)
|
||||
if !filepath.IsAbs(clean) {
|
||||
return "", false
|
||||
}
|
||||
currentClean := filepath.Clean(currentWorkDir)
|
||||
if clean == currentClean {
|
||||
return currentClean, true
|
||||
if currentWorkDir != "" {
|
||||
currentClean := filepath.Clean(currentWorkDir)
|
||||
if clean == currentClean {
|
||||
return currentClean, true
|
||||
}
|
||||
parent := filepath.Dir(currentClean)
|
||||
if parent != currentClean && clean == parent {
|
||||
return parent, true
|
||||
}
|
||||
}
|
||||
parent := filepath.Dir(currentClean)
|
||||
if parent == currentClean {
|
||||
// currentWorkDir is already filesystem root; no promotion possible.
|
||||
return "", false
|
||||
}
|
||||
if clean == parent {
|
||||
return parent, true
|
||||
// Recovery path: accept any absolute directory that actually contains git
|
||||
// repo subdirs. scanRepositorySubdirs reads the directory and validates
|
||||
// each child has a working .git entry, so a hostile or empty path returns
|
||||
// nil and the rescan stays a no-op below.
|
||||
if children := scanRepositorySubdirs(clean); len(children) >= 1 {
|
||||
return clean, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -307,30 +306,39 @@ func hasRowForRepoBase(existing []*models.TaskRepository, repositoryID, baseBran
|
||||
return false
|
||||
}
|
||||
|
||||
// autoBranchName picks a fresh `branch-N` style name for a new
|
||||
// autoBranchName picks a fresh `branch-<random>` name for a new
|
||||
// task_repositories row when the caller didn't specify checkout_branch.
|
||||
// N is one more than the highest matching suffix already attached for the
|
||||
// same (repo, base), so repeated calls produce branch-2, branch-3, ...
|
||||
// without scanning git refs — collisions on disk are handled by the
|
||||
// worktree manager's "treat as new branch" path, which suffixes anyway.
|
||||
// Uses a short random suffix instead of a sequential counter so the name
|
||||
// is unpredictable — predictable names like `branch-2` invite collisions
|
||||
// when concurrent agents add branches simultaneously and leak ordering
|
||||
// information into the worktree path on disk.
|
||||
//
|
||||
// On the astronomically unlikely event of a collision with an existing
|
||||
// row for the same (repo, base), retry up to a few times before giving
|
||||
// up and letting the caller surface the duplicate-name error.
|
||||
//
|
||||
// The naming intentionally avoids encoding the base branch name to keep
|
||||
// the slug-derived directory short and stable across base renames.
|
||||
func autoBranchName(existing []*models.TaskRepository, repositoryID, baseBranch string) string {
|
||||
n := 2
|
||||
for _, tr := range existing {
|
||||
if tr.RepositoryID != repositoryID || tr.BaseBranch != baseBranch {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(tr.CheckoutBranch, "branch-") {
|
||||
continue
|
||||
}
|
||||
var idx int
|
||||
if _, err := fmt.Sscanf(tr.CheckoutBranch, "branch-%d", &idx); err == nil && idx >= n {
|
||||
n = idx + 1
|
||||
for range 5 {
|
||||
candidate := "branch-" + worktree.SmallSuffix(3)
|
||||
if !branchNameExistsForRepoBase(existing, repositoryID, baseBranch, candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("branch-%d", n)
|
||||
return "branch-" + worktree.SmallSuffix(3)
|
||||
}
|
||||
|
||||
// branchNameExistsForRepoBase reports whether `existing` already has a row
|
||||
// for the given (repo, base, checkout) triple — used by autoBranchName to
|
||||
// reject random candidates that collide with prior rows.
|
||||
func branchNameExistsForRepoBase(existing []*models.TaskRepository, repositoryID, baseBranch, checkoutBranch string) bool {
|
||||
for _, tr := range existing {
|
||||
if tr.RepositoryID == repositoryID && tr.BaseBranch == baseBranch && tr.CheckoutBranch == checkoutBranch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// repoLabelOrID returns a human-readable repository label for error messages,
|
||||
|
||||
@@ -274,7 +274,8 @@ func TestAddBranchToTask_AllowsBeforeLaunch(t *testing.T) {
|
||||
// TestAddBranchToTask_AutoGeneratesNameWhenWouldCollide exercises the
|
||||
// no-args agent flow: "add a branch" with no checkout_branch should produce
|
||||
// a fresh row instead of erroring out because the primary already occupies
|
||||
// the (repo, base, "") triple. Generated name follows branch-N.
|
||||
// the (repo, base, "") triple. Generated name follows `branch-<random>` so
|
||||
// concurrent adders don't clash on a predictable counter.
|
||||
func TestAddBranchToTask_AutoGeneratesNameWhenWouldCollide(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
@@ -302,8 +303,8 @@ func TestAddBranchToTask_AutoGeneratesNameWhenWouldCollide(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("AddBranchToTask: %v", err)
|
||||
}
|
||||
if added.CheckoutBranch != "branch-2" {
|
||||
t.Errorf("expected auto-name branch-2, got %q", added.CheckoutBranch)
|
||||
if !strings.HasPrefix(added.CheckoutBranch, "branch-") || len(added.CheckoutBranch) != len("branch-")+3 {
|
||||
t.Errorf("expected auto-name branch-<3 random chars>, got %q", added.CheckoutBranch)
|
||||
}
|
||||
|
||||
added2, err := svc.AddBranchToTask(ctx, AddBranchToTaskRequest{
|
||||
@@ -312,8 +313,11 @@ func TestAddBranchToTask_AutoGeneratesNameWhenWouldCollide(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("AddBranchToTask (second auto): %v", err)
|
||||
}
|
||||
if added2.CheckoutBranch != "branch-3" {
|
||||
t.Errorf("expected auto-name branch-3, got %q", added2.CheckoutBranch)
|
||||
if !strings.HasPrefix(added2.CheckoutBranch, "branch-") || len(added2.CheckoutBranch) != len("branch-")+3 {
|
||||
t.Errorf("expected auto-name branch-<3 random chars>, got %q", added2.CheckoutBranch)
|
||||
}
|
||||
if added2.CheckoutBranch == added.CheckoutBranch {
|
||||
t.Errorf("expected distinct auto-names, both got %q", added.CheckoutBranch)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { FileTreeNode, OpenFileTab } from "@/lib/types/backend";
|
||||
import { useSession } from "@/hooks/domains/session/use-session";
|
||||
import { useRepository } from "@/hooks/domains/workspace/use-repository";
|
||||
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useOpenSessionFolder } from "@/hooks/use-open-session-folder";
|
||||
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
@@ -379,7 +380,16 @@ export function FileBrowser({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const search = useFileBrowserSearch(sessionId);
|
||||
const treeState = useFileBrowserTree(sessionId, environmentId ?? undefined);
|
||||
// Worktree count participates in the tree's reset key so an add_branch_to_task
|
||||
// call that materializes a sibling worktree forces a fresh tree load — the
|
||||
// backend's workspace_path has just been promoted from the primary worktree
|
||||
// to the task root, and the cached tree (rooted at the old primary) would
|
||||
// otherwise still be shown.
|
||||
const worktreeCount = useAppStore(
|
||||
(state) => state.sessionWorktreesBySessionId.itemsBySessionId[sessionId]?.length ?? 0,
|
||||
);
|
||||
const resetKey = environmentId ? `${environmentId}:${worktreeCount}` : undefined;
|
||||
const treeState = useFileBrowserTree(sessionId, resetKey);
|
||||
const isTreeLoaded = !treeState.isLoadingTree && treeState.tree !== null;
|
||||
useScrollPersistence(sessionId, isTreeLoaded, scrollAreaRef, treeState.tree);
|
||||
|
||||
|
||||
@@ -432,6 +432,21 @@ export const createSessionRuntimeSlice: StateCreator<
|
||||
delete draft.gitStatus.byEnvironmentId[envKey];
|
||||
delete draft.gitStatus.byEnvironmentRepo[envKey];
|
||||
}),
|
||||
clearLegacyGitStatusEntry: (sessionId) =>
|
||||
set((draft) => {
|
||||
// Drops the single-repo (empty-repo-name) entries so a session that just
|
||||
// transitioned to multi-repo via add_branch_to_task stops surfacing the
|
||||
// pre-transition snapshot — its workspace tracker was replaced on the
|
||||
// backend and will never emit another update under the empty key. The
|
||||
// per-repo entries (real repo names) are intentionally left in place;
|
||||
// they continue to receive fresh status updates from the new trackers.
|
||||
const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId;
|
||||
const repoMap = draft.gitStatus.byEnvironmentRepo[envKey];
|
||||
if (repoMap && "" in repoMap) {
|
||||
delete repoMap[""];
|
||||
}
|
||||
delete draft.gitStatus.byEnvironmentId[envKey];
|
||||
}),
|
||||
registerSessionEnvironment: (sessionId, environmentId) =>
|
||||
set((draft) => {
|
||||
draft.environmentIdBySessionId[sessionId] = environmentId;
|
||||
|
||||
@@ -361,6 +361,10 @@ export type SessionRuntimeSliceActions = {
|
||||
setActiveProcess: (sessionId: string, processId: string) => void;
|
||||
setGitStatus: (sessionId: string, gitStatus: GitStatusEntry) => void;
|
||||
clearGitStatus: (sessionId: string) => void;
|
||||
/** Drops the pre-multi-repo (empty-repo-name) git-status entries so a
|
||||
* freshly-multi-branch session doesn't surface a stale snapshot from the
|
||||
* workspace tracker that was replaced on the backend during rescan. */
|
||||
clearLegacyGitStatusEntry: (sessionId: string) => void;
|
||||
registerSessionEnvironment: (sessionId: string, environmentId: string) => void;
|
||||
setContextWindow: (sessionId: string, contextWindow: ContextWindowEntry) => void;
|
||||
// Session commit actions
|
||||
|
||||
@@ -387,6 +387,7 @@ export type AppState = {
|
||||
setSessionWorktrees: (sessionId: string, worktreeIds: string[]) => void;
|
||||
setGitStatus: (sessionId: string, gitStatus: GitStatusEntry) => void;
|
||||
clearGitStatus: (sessionId: string) => void;
|
||||
clearLegacyGitStatusEntry: (sessionId: string) => void;
|
||||
registerSessionEnvironment: (sessionId: string, environmentId: string) => void;
|
||||
setSessionCommits: (
|
||||
sessionId: string,
|
||||
|
||||
@@ -298,6 +298,11 @@ export type TaskSessionAgentctlPayload = {
|
||||
worktree_id?: string;
|
||||
worktree_path?: string;
|
||||
worktree_branch?: string;
|
||||
/** Task root that contains every per-repo worktree as a sibling subdir.
|
||||
* Set only when the event signals a sibling worktree addition (multi-branch
|
||||
* add_branch flow) — the frontend repoints the file browser to it instead of
|
||||
* staying on the original primary worktree. */
|
||||
task_workspace_path?: string;
|
||||
};
|
||||
|
||||
export type FileInfo = {
|
||||
|
||||
@@ -264,34 +264,87 @@ function syncEnvFromAgentctlPayload(
|
||||
});
|
||||
}
|
||||
|
||||
/** Handle the agentctl_ready event: update session worktree info. */
|
||||
/** Builds the partial-session patch applied for an agentctl_ready event.
|
||||
* On sibling materialize we repoint worktree_path to the task root and keep
|
||||
* the primary's id/branch; the initial ready event sets id/path/branch
|
||||
* straight from the payload. */
|
||||
function buildAgentctlReadySessionUpdate(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
payload: any,
|
||||
isSibling: boolean,
|
||||
): Record<string, unknown> {
|
||||
const update: Record<string, unknown> = {};
|
||||
if (isSibling) {
|
||||
if (payload.task_workspace_path) update.worktree_path = payload.task_workspace_path;
|
||||
return update;
|
||||
}
|
||||
if (payload.worktree_id) update.worktree_id = payload.worktree_id;
|
||||
if (payload.worktree_path) update.worktree_path = payload.worktree_path;
|
||||
if (payload.worktree_branch) update.worktree_branch = payload.worktree_branch;
|
||||
return update;
|
||||
}
|
||||
|
||||
/** Adds the materialized worktree to the worktrees map + the per-session list. */
|
||||
function recordAgentctlReadyWorktree(
|
||||
store: StoreApi<AppState>,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
payload: any,
|
||||
existingSession: { repository_id?: string; worktree_path?: string; worktree_branch?: string },
|
||||
): void {
|
||||
if (!payload.worktree_id) return;
|
||||
store.getState().setWorktree({
|
||||
id: payload.worktree_id,
|
||||
sessionId: payload.session_id,
|
||||
repositoryId: existingSession.repository_id ?? undefined,
|
||||
path: payload.worktree_path ?? existingSession.worktree_path ?? undefined,
|
||||
branch: payload.worktree_branch ?? existingSession.worktree_branch ?? undefined,
|
||||
});
|
||||
const existing =
|
||||
store.getState().sessionWorktreesBySessionId.itemsBySessionId[payload.session_id] ?? [];
|
||||
if (!existing.includes(payload.worktree_id)) {
|
||||
store.getState().setSessionWorktrees(payload.session_id, [...existing, payload.worktree_id]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle the agentctl_ready event: update session worktree info.
|
||||
*
|
||||
* Two shapes share this event:
|
||||
* 1. Initial session ready — payload describes the session's primary
|
||||
* worktree; we set worktree_id/path/branch on the session row.
|
||||
* 2. Sibling materialized (multi-branch add_branch flow) — payload
|
||||
* describes a NEW worktree being added alongside the primary. The
|
||||
* primary's worktree_id/branch must NOT be clobbered (they still own
|
||||
* the chat/agent process); only worktree_path moves to the task root
|
||||
* so the file browser repoints from "primary worktree" to "task root
|
||||
* containing both worktree siblings". A commits refetch is bumped so
|
||||
* the Commits panel re-queries with the new multi-repo subpaths
|
||||
* (each commit then carries its repo/branch slug for grouping).
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function handleAgentctlReady(store: StoreApi<AppState>, payload: any): void {
|
||||
const existingSession = store.getState().taskSessions.items[payload.session_id];
|
||||
if (!existingSession) return;
|
||||
|
||||
const sessionUpdate: Record<string, unknown> = {};
|
||||
if (payload.worktree_id) sessionUpdate.worktree_id = payload.worktree_id;
|
||||
if (payload.worktree_path) sessionUpdate.worktree_path = payload.worktree_path;
|
||||
if (payload.worktree_branch) sessionUpdate.worktree_branch = payload.worktree_branch;
|
||||
const isSibling =
|
||||
!!payload.worktree_id &&
|
||||
!!existingSession.worktree_id &&
|
||||
payload.worktree_id !== existingSession.worktree_id;
|
||||
|
||||
const sessionUpdate = buildAgentctlReadySessionUpdate(payload, isSibling);
|
||||
if (Object.keys(sessionUpdate).length > 0) {
|
||||
store.getState().setTaskSession({ ...existingSession, ...sessionUpdate });
|
||||
}
|
||||
|
||||
if (payload.worktree_id) {
|
||||
store.getState().setWorktree({
|
||||
id: payload.worktree_id,
|
||||
sessionId: payload.session_id,
|
||||
repositoryId: existingSession.repository_id ?? undefined,
|
||||
path: payload.worktree_path ?? existingSession.worktree_path ?? undefined,
|
||||
branch: payload.worktree_branch ?? existingSession.worktree_branch ?? undefined,
|
||||
});
|
||||
const existing =
|
||||
store.getState().sessionWorktreesBySessionId.itemsBySessionId[payload.session_id] ?? [];
|
||||
if (!existing.includes(payload.worktree_id)) {
|
||||
store.getState().setSessionWorktrees(payload.session_id, [...existing, payload.worktree_id]);
|
||||
}
|
||||
recordAgentctlReadyWorktree(store, payload, existingSession);
|
||||
|
||||
if (isSibling) {
|
||||
// Drop the pre-multi-repo git-status snapshot — the backend just
|
||||
// transitioned this session from single-repo to multi-repo and the legacy
|
||||
// (empty-repo-name) tracker is gone. Without this the Changes panel keeps
|
||||
// surfacing the frozen snapshot until the user reloads the tab, masking
|
||||
// the per-repo updates streaming in for both the primary and the sibling.
|
||||
store.getState().clearLegacyGitStatusEntry(payload.session_id);
|
||||
store.getState().bumpSessionCommitsRefetch(payload.session_id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user