diff --git a/docs/system-specs/modules/config.md b/docs/system-specs/modules/config.md index 82b7254895a..93d9c9fd56c 100644 --- a/docs/system-specs/modules/config.md +++ b/docs/system-specs/modules/config.md @@ -3366,6 +3366,17 @@ pass `--profile`. on the session cwd and on every key, then compares them component by component. The deepest containing key wins. `/a/proj` never matches `/a/project2`, and a symlink is judged by its target. +- **Crewmate worktrees.** A cwd in a crewmate's worktree is matched as the same + path in the repository's own checkout (`crewmate_worktree.source_path`): + `/worktrees//app-/src` matches as `/repos/app/src`, so + a worker keeps the binding on `/repos/app` and never takes one on its + workspace. It is asked of git each time, through the sandboxed runner, with + nothing remembered, so it holds after a restart. It applies only when git's + work-tree top sits at `worktrees//`, carries the name `path_for` gives + that repository, and is registered by that repository as a worktree. Anything + else, a repository cloned inside the worktree included, matches as itself. A + path with no `worktrees` folder in it runs no git, and a git failure leaves the + path as it is. - **Injection.** `SessionAllocation._get_or_create_impl` resolves the binding against the final `effective_cwd`. This happens after capability preparation and before the warm-pool decision. `bound_profile_env` sets both `AWS_PROFILE` and diff --git a/docs/system-specs/modules/crew-mode.md b/docs/system-specs/modules/crew-mode.md index ea4b17114d6..2b7be72f261 100644 --- a/docs/system-specs/modules/crew-mode.md +++ b/docs/system-specs/modules/crew-mode.md @@ -3123,14 +3123,19 @@ the Kiro Crew docs search (`search_docs`, `find_ui` on `kirocrew-guide`, which both the conductor's fixed servers and the worker's base config mount); `work_ledger_record` / `work_ledger_read`, `save_goal_report`, `save_notes`; the worker's kit, the capability card that adds to it, the Profile's -Permissions reaching its sessions. `test_every_crewmate_only_tool_is_named_in_the_assembled_conductor_prompt` +Permissions reaching its sessions, and its own worktree under the crew/ +prefix. `test_every_crewmate_only_tool_is_named_in_the_assembled_conductor_prompt` reads the tool names from the `kirocrew-mate`, `kirocrew-panel`, `kirocrew-crew-log` and `kirocrew-work` server sources plus the guide and session tools, and fails when the assembled prompt omits one, with an explicit exempt list (`panel_templates`, `work_brief`, `work_report`, `work_ledger_rebuild`). The worker's principles add one line: facts the crewmate's Dashboard depends on are recorded as crew-log events, through the -tools that record them. +tools that record them. It also says its project directory is the crewmate's +own worktree on `crew//`, to commit there and name further +branches under that prefix, to rebase onto the default branch itself when it +needs that branch's changes, and to check a branch with `git merge-tree` before +it pushes. `scripts/crewmate_prompt_preview.py --fixture [--piece

] [--diff | --write]` runs the real assembly over a fixture in a throwaway data home and prints @@ -3728,6 +3733,62 @@ conductor's `session_send` to it revives it first (session-control.md). The conductor reads `process: live | released` on `session_status`, and the worker tab shows a released one with a small marker (`session-row-released`). +**Each crewmate works in a git worktree of its own** (`kiro_crew.crewmate_worktree`, +`dashboard/crewmate_worktree_scope.py`), so two crewmates handed one repository +never share a working tree or a checked-out branch. The gateway does three +things here and leaves every other git step to the worker's prompt +(`worker.principles`): + +1. **One worktree per crewmate per repository.** `crewmate_worktree.ensure` + makes it at `/worktrees//-` + (`path_for`: the slug keeps two crewmates sharing a workspace apart, the + hash two repositories sharing a name), on the branch + `crew//` (`branch_for`), cut the first time from the + repository's default branch (`refs/remotes/origin/HEAD`, else `refs/heads/main` + or `master`, else `HEAD`; full ref names, so a tag never stands in for the + branch). After that a worktree git registers at that place is used as it is, + on whatever branch the worker has checked out; one whose folder was deleted + by hand is pruned and made again. A name segment the cleaning changed (or + one over 40 characters) keeps a digest of the whole and is a legal git ref + component. A repository that contains the crewmate's own workspace is not + one it was handed and gets none. The repository is read from git's common + directory, so a path inside another crewmate's worktree maps to the same + repository. +2. **The worker runs there.** At the start of a worker's turn (a `template` + record on `kirocrew-mate-worker` with the crewmate's `member_id`; the + conductor is left alone), after its Permissions are seeded and before the + provider is claimed, turn admission (`chat_turn/turn_admission._turn_admit`) + calls `scope_worker_turn`. It reads the repository from the paths the + turn's message names (absolute or `~` paths, and drive paths on Windows; a + quoted one is read whole; one not made yet is read from its nearest existing + folder; `crewmate_worktree.paths_in`), then from the slot's project, and sets + `slot.project` to the crewmate's worktree of the first one in a git + repository (the same subfolder when the path named one, unless that folder + is a link out of the worktree or holds a control character or bracket). A + path in the worktree the worker already runs in, or in that repository's own + checkout, moves nothing. The binding is re-captured under the dispatch lock, + and a session already running takes the deferred reset `set_project` uses. + Git failing (or no sandbox) leaves the turn on its project. The + repository's `aws.project_profiles` binding still applies in the worktree: + the cwd is matched as the same path in the checkout, asked of git each time + (`crewmate_worktree.source_path`; config.md, "Project AWS profiles"). +3. **It goes with the crewmate.** Deleting the crewmate + (`crew_removal.api_kirocrew_agent_delete`, `crewmate_worktree.remove_for_member`) + lists `/worktrees/` and removes each folder git registers as + a worktree of its repository with `git worktree remove` (never `--force`). + One with uncommitted changes, ignored files or a repository that configures a + checkout filter is kept and logged; so is anything git does not register. + Branches stay, and the repositories' own checkouts are never touched. + +Git runs through the follow-up card's sandboxed runner +(`handlers/worktree._run_git`): no hooks, no fsmonitor, and a repository +declaring a checkout filter is refused. The folders sit in the agent-writable +workspace, so the gateway makes a new worktree's folder through no-follow +descriptors (`pinned_fs.open_pinned_descendant_dir`) and, after a failed +`worktree add`, removes it only by descriptor and only while it is still the +folder it made (`remove_tree_pinned`, by `(dev, ino)`), then drops the branch +it cut (`test/test_crewmate_worktree.py`). + **A worker reports back on its own** (`dashboard/mate_thread_wakes.py`), so the conductor dispatches and ends its turn. Every top-level turn end calls `note_turn_end`. When the slot's `_created_by` names a live crewmate thread and diff --git a/src/kiro_crew/aws_project_profiles.py b/src/kiro_crew/aws_project_profiles.py index 42a2da1d66b..2f7da38b381 100644 --- a/src/kiro_crew/aws_project_profiles.py +++ b/src/kiro_crew/aws_project_profiles.py @@ -8,7 +8,9 @@ The map lives in the operator's own configuration and nowhere else. Nothing here reads a file inside the project checkout, so a cloned repository cannot choose -which credentials an agent uses. Nothing here reads credential material either: +which credentials an agent uses. The one thing asked of git is which checkout a +crewmate's worktree was made from, and only for a path under a ``worktrees`` +folder: that names a directory to match, never a profile. Nothing here reads credential material either: the value is a profile NAME, and the AWS SDK or CLI the agent runs resolves it through the operator's own ``~/.aws`` files exactly as it would for a gateway started with ``AWS_PROFILE`` set. @@ -136,10 +138,17 @@ def resolve_project_binding(mapping: Mapping[str, str], cwd: str) -> tuple[str, on both sides: ``/a/proj`` contains ``/a/proj/src`` but not ``/a/project2``. The deepest containing directory wins. Touches the filesystem (``realpath``), so an async caller runs it off the event loop. + + A cwd in a crewmate's worktree is matched as the same path in the + repository's own checkout (``crewmate_worktree.source_path``): the worktree + sits in the crewmate's workspace, so ``/worktrees//app-`` + would otherwise miss the binding on ``/repos/app``. """ if not mapping or not cwd: return None - target = _dir_parts(cwd) + from kiro_crew.crewmate_worktree import source_path # git helpers import config + + target = _dir_parts(source_path(cwd)) best: tuple[str, str] | None = None best_depth = -1 for directory, profile in mapping.items(): diff --git a/src/kiro_crew/crewmate_prompts.json b/src/kiro_crew/crewmate_prompts.json index 5145545f7f9..240ff989957 100644 --- a/src/kiro_crew/crewmate_prompts.json +++ b/src/kiro_crew/crewmate_prompts.json @@ -98,7 +98,7 @@ "- Dashboard tab: a page you own and build; the dashboard skill and dashboard_types have the detail.", "- Kiro Crew docs: search_docs and find_ui (kirocrew-guide) search all of Kiro Crew's documentation; look there before guessing how a feature works (crew log types, dashboard fields, folds, settings, where a control is).", "- Tracking and reports: work_ledger_record and work_ledger_read keep the items you track; save_goal_report files the weekly or monthly report; save_notes keeps your briefing.", - "- Your worker: its kit (the tools, MCP servers and skills on your Built From tabs) is what it runs with, and a capability card from request_capabilities adds to it; the Permissions on your Profile apply to its sessions too." + "- Your worker: its kit (the tools, MCP servers and skills on your Built From tabs) is what it runs with, and a capability card from request_capabilities adds to it; the Permissions on your Profile apply to its sessions too; it works in your own git worktree, on branches under your crew/ prefix." ], "conductor.crew_log_section_manual": [ "", @@ -233,7 +233,7 @@ "welcome.offer_tools": "When they answer with a name for you, call update_self with display_name set to that name in the same reply, say plainly in that reply that you go by the new name from now on, and use it from then on.", "welcome.one_more_of_crew": "They already have other mates, so introduce yourself as one more teammate on their crew, one who remembers what you work on together and keeps at it over days. ", "welcome.preamble": "[First welcome] The user just opened your chat for the first time and it has no messages yet. They have not typed anything: this note comes from Kiro Crew, not from them, and they cannot see it, so never quote or mention it. This is your first conversation with them, so greet them as someone you are meeting for the first time. Use a name for the user only when their preferences or your memory state it; a username, path, email or host name is not their name. Write in the language of the dashboard's [UI LANGUAGE] when it is shown, else English. Call no tools in this reply. ", - "worker.principles": "You are the worker a crewmate hands its work to, and your report goes back to that crewmate. When a task needs a tool, an MCP server or a skill you do not have, say so rather than working around it or acting as if it were done: open your report with one line, `{missing_prefix} -- `, then report what you could do without it. Your crewmate can ask the user to add it; you have no tool for that yourself. The facts your crewmate's Dashboard depends on are recorded as crew-log events, through the tools that record them (a work-ledger or report tool when you have one), not only in the prose of your report.", + "worker.principles": "You are the worker a crewmate hands its work to, and your report goes back to that crewmate. When a task needs a tool, an MCP server or a skill you do not have, say so rather than working around it or acting as if it were done: open your report with one line, `{missing_prefix} -- `, then report what you could do without it. Your crewmate can ask the user to add it; you have no tool for that yourself. The facts your crewmate's Dashboard depends on are recorded as crew-log events, through the tools that record them (a work-ledger or report tool when you have one), not only in the prose of your report. Your project directory is your crewmate's own git worktree of the repository, on the branch `crew//`: edit and commit there, never in the repository's own checkout, and name any further branch you need under the same `crew//` prefix. Rebase onto the default branch yourself when you need its changes. Before you push a branch, run `git merge-tree --write-tree HEAD`; rebase only when it reports a conflict, and say so in your report.", "worker_report.cron_title": "your scheduled job {name}", "worker_report.ended_many": "ended {turns} turns since you last heard", "worker_report.ended_one": "just ended a turn", diff --git a/src/kiro_crew/crewmate_worktree.py b/src/kiro_crew/crewmate_worktree.py new file mode 100644 index 00000000000..8db4ad6ddf3 --- /dev/null +++ b/src/kiro_crew/crewmate_worktree.py @@ -0,0 +1,503 @@ +"""Each crewmate works in a git worktree of its own. + +Two crewmates handed the same repository must not share a working tree or a +checked-out branch: one would commit the other's edits, or land a commit on the +branch the other has checked out. The gateway does only the three things a +model cannot do for itself here: + +* give each crewmate one worktree per repository (:func:`ensure`), at + ``/worktrees//-`` + (:func:`path_for`), on its own branch ``crew//`` + (:func:`branch_for`), cut from the repository's default branch the first time + and reused, as it is, after that; +* point the worker's ``slot.project`` at it at the start of each turn + (:mod:`kiro_crew.dashboard.crewmate_worktree_scope`), with the repository's + ``aws.project_profiles`` binding still applying there (:func:`source_path`); +* remove a crewmate's worktrees when the crewmate is removed + (:func:`remove_crewmate_worktrees`): one with uncommitted or ignored files is + kept and reported, the branch always stays, and the repository's own checkout + is never touched. + +Everything else is the worker's own git work, asked for in its prompt +(``worker.principles``): further branches under its prefix, rebasing onto the +default branch when it needs that branch's changes, checking before it pushes. + +Every git call goes through the follow-up card's sandboxed runner +(:mod:`kiro_crew.dashboard.handlers.worktree`) with its repo-code guards: no +hooks, no fsmonitor, and a repository declaring a checkout filter is refused. +The folder a worktree goes in sits in the agent-writable workspace, so the +gateway makes (and, after a failed add, removes) it only through no-follow +descriptors. All functions here block; callers run them off the event loop. +""" + +from __future__ import annotations + +import contextlib +import hashlib +import logging +import os +import re +import subprocess +import threading +from dataclasses import dataclass +from pathlib import PurePath + +logger = logging.getLogger(__name__) + +#: The folder under a crewmate's workspace that holds its worktrees. +WORKTREES_DIR = "worktrees" +#: Every crewmate branch starts with this, then the crewmate's slug. +BRANCH_PREFIX = "crew/" + +# An absolute or home-relative path inside free text: a worker's brief. +_PATH_RE = re.compile(r"(?|;,()\[\]{}]+)") +# A Windows drive path (``D:\projects\tasklog`` or ``D:/projects``), unquoted. +_WIN_PATH_RE = re.compile(r"(?|;,()\[\]{}]*)") +# Any of those paths inside matching quotes, read whole so a space does not cut it. +_QUOTED_PATH_RE = re.compile(r"([\"'`])((?:~|/|[A-Za-z]:[\\/])[^\"'`\n]{0,400}?)\1") +_PATH_TRAILING = ".:!?*" +_MAX_PATHS = 20 +_NAME_STRIP_RE = re.compile(r"[^A-Za-z0-9._-]+") +_DOTS_RE = re.compile(r"\.{2,}") +#: Longest name segment kept as it is; a longer one is cut and keeps a digest of the whole. +_NAME_MAX = 40 +#: Local branches tried, in order, when the repository names no ``origin/HEAD``. +_LOCAL_BASES = ("main", "master") # wokeignore:rule=master + + +@dataclass(frozen=True) +class CrewmateWorktree: + """A crewmate's worktree of one repository.""" + + repo: str # the repository's own checkout (the main worktree root) + path: str # this crewmate's worktree root + branch: str + cwd: str # where the worker starts: the same subdirectory inside the worktree + + +class WorktreeRefused(RuntimeError): + """The repository cannot be given a crewmate worktree (the reason says why).""" + + +def _wt(): + from kiro_crew.dashboard.handlers import worktree as wt # circular: handlers import config + + return wt + + +def _name(text: str) -> str: + """*text* as one safe path segment and legal git ref component, distinct per name. + + A name already safe and short is kept as it is. One the cleaning changed + (``app+tools``, ``app.lock``, ``app..tools``) or cut keeps an 8-character + digest of the whole, so two names never share a segment. + """ + clean = _DOTS_RE.sub("-", _NAME_STRIP_RE.sub("-", text)).strip("-.") + while clean.lower().endswith(".lock"): + clean = clean[: -len(".lock")].rstrip("-.") + clean = clean or "repo" + if clean == text and len(clean) <= _NAME_MAX: + return clean + digest = hashlib.sha256(text.encode("utf-8", "surrogateescape")).hexdigest()[:8] + return f"{clean[: _NAME_MAX - 9].rstrip('-.') or 'repo'}-{digest}" + + +def branch_for(slug: str, repo: str) -> str: + """The branch a crewmate of *slug* works on in *repo*.""" + return f"{BRANCH_PREFIX}{_name(slug)}/{_name(os.path.basename(repo))}" + + +def _root(workspace_dir: str, slug: str) -> str: + """The folder that holds crewmate *slug*'s worktrees.""" + return os.path.join(os.path.realpath(workspace_dir), WORKTREES_DIR, _name(slug)) + + +def path_for(workspace_dir: str, slug: str, repo: str) -> str: + """The worktree crewmate *slug* gets for *repo*, one per repository. + + Under the crewmate's workspace, in a folder of its own slug (two crewmates may + share a workspace), named for the repository and a digest of its path (two + repositories may share a name). + """ + return os.path.join(_root(workspace_dir, slug), _folder_name(repo)) + + +def _folder_name(repo: str) -> str: + """The last segment of every crewmate worktree of *repo*: its name and path digest.""" + real = os.path.realpath(repo) + digest = hashlib.sha256(real.encode("utf-8", "surrogateescape")).hexdigest()[:8] + return f"{_name(os.path.basename(real))}-{digest}" + + +def _absolute_here(path: str) -> bool: + """Whether *path*, ``~`` expanded, is absolute on the platform the gateway runs on.""" + try: + return os.path.isabs(os.path.expanduser(path)) + except (TypeError, ValueError): + return False + + +def paths_in(text: str) -> list[str]: + """Absolute, ``~`` or Windows drive paths named in *text*, in order, at most a bounded few. + + A quoted path is read whole, spaces included (``"/projects/My Repo"``); an + unquoted one ends at the first space. Only a path absolute on THIS platform is + kept: ``C:\\tools\\x`` on a POSIX gateway would resolve against its own cwd. + """ + text = text or "" + found: list[tuple[int, str]] = [] + taken: list[tuple[int, int]] = [] + for match in _QUOTED_PATH_RE.finditer(text): + taken.append(match.span()) + found.append((match.start(), match.group(2).strip())) + # Windows paths first, so the ``/tools/x`` inside ``C:/tools/x`` is not a path of its own. + for regex in (_WIN_PATH_RE, _PATH_RE): + for match in regex.finditer(text): + if any(a <= match.start() < b for a, b in taken): + continue + taken.append(match.span()) + found.append((match.start(), match.group(1).rstrip(_PATH_TRAILING))) + out: list[str] = [] + for _start, candidate in sorted(found): + if len(candidate) > 1 and candidate not in out and _absolute_here(candidate): + out.append(candidate) + if len(out) >= _MAX_PATHS: + break + return out + + +def _nearest_dir(real: str) -> str: + """*real*, or its nearest existing ancestor: a brief may name a file not made yet.""" + current = real + while not os.path.isdir(current): + parent = os.path.dirname(current) + if parent == current: + return current + current = parent + return current + + +def repo_root(path: str) -> str | None: + """The repository checkout *path* belongs to, else ``None``. + + Read from git's common directory, so a path inside any worktree of a + repository (another crewmate's included) maps to that repository's own + checkout. A bare repository, or a path not absolute here, answers ``None``. + """ + if not _absolute_here(path): + return None + start = _nearest_dir(os.path.realpath(os.path.expanduser(path))) + if not os.path.isdir(start): + return None + proc = _wt()._run_git(["rev-parse", "--path-format=absolute", "--git-common-dir"], start) + common = proc.stdout.strip() if proc.returncode == 0 else "" + if not common or os.path.basename(os.path.normpath(common)) != ".git": + return None + return os.path.realpath(os.path.dirname(os.path.normpath(common))) + + +def _contains(parent: str, child: str) -> bool: + """Whether *child* is *parent* or inside it; paths on different drives are not.""" + try: + return os.path.commonpath([parent, child]) == parent + except ValueError: + return False + + +def source_path(cwd: str) -> str: + """*cwd* as the same path in the repository's own checkout, when it is in a crewmate worktree. + + ``/worktrees//-/src`` answers ``/src``; + any other path answers itself, so ``aws.project_profiles`` binds a worker in + its worktree exactly as it would bind it in the checkout. It is read from git + each time and nothing is remembered, so it holds after a restart: the folder + git names as the work tree's top must sit at ``worktrees//``, carry the + name :func:`path_for` gives that repository, and be registered by that + repository as one of its worktrees. A repository cloned inside the worktree + is its own top, so it answers itself. A path with no ``worktrees`` folder in + it runs no git. Never raises; blocks, so an async caller runs it off the loop. + """ + if not cwd or not _absolute_here(cwd): + return cwd + try: + real = os.path.realpath(os.path.expanduser(cwd)) + except (OSError, ValueError): + return cwd + if WORKTREES_DIR not in PurePath(real).parts[:-2]: + return cwd + try: + return _source_of(real) or cwd + except Exception: # noqa: BLE001 - no sandbox, no git: the path matches as itself + logger.debug("crewmate worktree source of %s unknown", cwd, exc_info=True) + return cwd + + +def _source_of(real: str) -> str | None: + """The checkout path *real* mirrors, else ``None`` (see :func:`source_path`).""" + wt = _wt() + top = wt._git_toplevel(_nearest_dir(real)) + if not top or not _contains(top, real): + return None + parts = PurePath(top).parts + if len(parts) < 3 or parts[-3] != WORKTREES_DIR: + return None + repo = repo_root(top) + if repo is None or repo == top or os.path.basename(top) != _folder_name(repo): + return None + trees = wt._worktree_branches(repo) + if not trees or wt._norm_path(top) not in trees: + return None + rel = os.path.relpath(real, top) + return repo if rel == "." else os.path.join(repo, rel) + + +def _plain(text: str) -> bool: + """Whether *text* (a subfolder that becomes the printed project) has no control character or bracket.""" + return not any(ord(c) < 0x20 or c in "\x7f[]" for c in text) + + +_LOCKS: dict[str, threading.Lock] = {} +_LOCKS_GUARD = threading.Lock() + + +def _lock(dest: str) -> threading.Lock: + """One lookup at a time per worktree: two worker turns of one crewmate may race.""" + with _LOCKS_GUARD: + return _LOCKS.setdefault(dest, threading.Lock()) + + +def ensure(slug: str, workspace_dir: str, path: str) -> CrewmateWorktree | None: + """The crewmate's own worktree for the repository *path* is in, made if missing. + + ``None`` when *path* is in no git repository, or in one that contains the + crewmate's own workspace (a home folder under git is not a repository it was + handed). Raises :class:`WorktreeRefused` when the gateway will not make it (a + checkout filter, an unlistable worktree table, something else at the place). + One git already registers at the place is used as it is, whatever branch the + worker has checked out there: that is the worker's own work. + """ + wt = _wt() + repo = repo_root(path) + if repo is None or _contains(repo, os.path.realpath(workspace_dir)): + return None + dest = path_for(workspace_dir, slug, repo) + with _lock(dest): + trees = wt._worktree_branches(repo) + if trees is None: + raise WorktreeRefused("git could not list this repository's worktrees") + registered = trees.get(wt._norm_path(dest)) + if registered is None or not os.path.isdir(dest): + if os.path.lexists(dest): + raise WorktreeRefused(f"{dest} exists and is not a worktree of {repo}") + if registered is not None: + # Its folder was deleted by hand: drop the stale registration. + wt._run_git(["worktree", "prune"], repo) + registered = branch_for(slug, repo) + _create(wt, repo, dest, registered, workspace_dir, slug) + return CrewmateWorktree( + repo=repo, path=dest, branch=registered, cwd=_mapped_cwd(wt, repo, dest, path) + ) + + +def _mapped_cwd(wt, repo: str, dest: str, path: str) -> str: + """The folder in *dest* matching the subfolder *path* names; the worktree root otherwise.""" + start = _nearest_dir(os.path.realpath(os.path.expanduser(path))) + top = wt._git_toplevel(start) or repo + rel = os.path.relpath(start, top) + if rel == "." or rel.startswith("..") or not _plain(rel): + return dest + cwd = os.path.join(dest, rel) + # A link inside the worktree (``docs -> ../../repo``) would lead back out of it. + if not os.path.isdir(cwd) or not _contains(os.path.realpath(dest), os.path.realpath(cwd)): + return dest + return cwd + + +def _default_branch(wt, repo: str) -> str: + """The ref the first cut comes from: ``origin/HEAD``, else a usual default branch name, else ``HEAD``. + + Fully qualified, so a tag named like the branch is never taken for it. + """ + for ref in ("refs/remotes/origin/HEAD", *(f"refs/heads/{n}" for n in _LOCAL_BASES)): + if wt._resolve_commit(repo, ref): + return ref + return "HEAD" + + +def _create(wt, repo: str, dest: str, branch: str, workspace_dir: str, slug: str) -> None: + """Add *dest* on *branch*, cutting the branch from the default branch when it is new.""" + offending = wt._checkout_filter(repo) + if offending: + raise WorktreeRefused(f"the repository configures a checkout filter ({offending})") + claimed = False + base_sha = "" + if not wt._resolve_commit(repo, f"refs/heads/{branch}"): + base_sha = wt._resolve_commit(repo, _default_branch(wt, repo)) + if not base_sha: + raise WorktreeRefused("the repository has no commit to branch from") + if not wt._claim_branch(repo, branch, base_sha): + raise WorktreeRefused(f"could not create {branch}") + claimed = True + try: + made = _make_dest(workspace_dir, slug, dest) + except (OSError, WorktreeRefused): + if claimed: + wt._cleanup_partial(repo, dest, branch, claimed=True, created=False, base_sha=base_sha) + raise + try: + proc = wt._run_git(["worktree", "add", dest, branch], repo) + except (subprocess.TimeoutExpired, OSError): + _unwind_add(wt, repo, dest, branch, made, claimed=claimed, base_sha=base_sha) + raise + if proc.returncode != 0 or not os.path.exists(os.path.join(dest, ".git")): + _unwind_add(wt, repo, dest, branch, made, claimed=claimed, base_sha=base_sha) + raise WorktreeRefused(wt._git_error(proc) if proc.returncode else "worktree add failed") + logger.info("crewmate worktree %s on %s from %s", dest, branch, repo) + + +def _make_dest(workspace_dir: str, slug: str, dest: str) -> tuple[int, int]: + """Make the empty folder *dest* without following a link; its ``(dev, ino)``. + + ``worktrees/`` sits in the agent-writable workspace and this runs in the + gateway: each part is made and opened with a no-follow descriptor from the + workspace down (``pinned_fs.open_pinned_descendant_dir``) and *dest* is made + relative to the last one. A link raises :class:`WorktreeRefused`. + """ + from kiro_crew.pinned_fs import PinnedPathRefusal, open_pinned_descendant_dir + + workspace = os.path.realpath(workspace_dir) + os.makedirs(workspace, exist_ok=True) # configured, not the agent's + name = os.path.basename(dest) + try: + with open_pinned_descendant_dir( + workspace, (WORKTREES_DIR, _name(slug)), what="crewmate worktree folder", create=True + ) as dir_fd: + if dir_fd is None: # Windows: the walk lstat-refused every link + os.mkdir(dest) + made = os.lstat(dest) + else: + os.mkdir(name, dir_fd=dir_fd) + made = os.stat(name, dir_fd=dir_fd, follow_symlinks=False) + except FileExistsError: + raise WorktreeRefused(f"{dest} already exists") from None + except PinnedPathRefusal as exc: + raise WorktreeRefused(f"{dest} is not a plain folder: {exc}") from None + return made.st_dev, made.st_ino + + +def _unwind_add( + wt, repo: str, dest: str, branch: str, made: tuple[int, int], *, claimed: bool, base_sha: str +) -> None: + """Undo a failed ``worktree add``: the folder this lookup made, then git's records. + + The folder goes by descriptor, and only when it is still the one made (its + ``(dev, ino)``), so a parent swapped for a link in between leads to nothing + being removed; never by a by-name ``rmtree``. Then ``_cleanup_partial`` with + ``created=False`` prunes the registration and drops the branch this lookup cut. + """ + from kiro_crew import pinned_fs + + def _approve(root_fd: int, _tree: object) -> str | None: + opened = os.fstat(root_fd) + return None if (opened.st_dev, opened.st_ino) == made else "not the folder made" + + try: + pinned_fs.remove_tree_pinned( + os.path.realpath(dest), + what="half-made crewmate worktree", + approve=_approve, + refusal=OSError, + ) + except OSError: + logger.warning("crewmate worktree %s left in place after a failed add", dest, exc_info=True) + wt._cleanup_partial(repo, dest, branch, claimed=claimed, created=False, base_sha=base_sha) + + +def _ignored_files(wt, path: str) -> str: + """The first ignored paths in the worktree at *path*, ``""`` when none; ``"unknown"`` if git cannot say.""" + proc = wt._run_git( + ["ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--directory"], path + ) + if proc.returncode != 0: + return "unknown" + found = [p for p in proc.stdout.split("\0") if p] + return ", ".join(found[:5]) + (f" and {len(found) - 5} more" if len(found) > 5 else "") + + +def remove_crewmate_worktrees(workspace_dir: str, slug: str) -> tuple[list[str], list[str]]: + """Remove crewmate *slug*'s worktrees; ``(removed, kept)``. + + Found by listing ``/worktrees/`` and asking git which + repository each belongs to and whether it registers it there; anything git + does not register as a worktree of that repository is left untouched. Each + goes with ``git worktree remove`` (never ``--force``): one with uncommitted + changes, ignored files (results, a data file) or a repository that configures + a checkout filter is kept and listed in ``kept``. Branches stay, and the + repository's own checkout is never touched. A link where the folder should be + is not followed. + """ + wt = _wt() + root = _root(workspace_dir, slug) + if os.path.islink(os.path.dirname(root)) or os.path.islink(root) or not os.path.isdir(root): + return [], [] + removed: list[str] = [] + kept: list[str] = [] + for item in sorted(os.scandir(root), key=lambda e: e.name): + if item.is_symlink() or not item.is_dir(follow_symlinks=False): + continue + dest = item.path + try: + repo = repo_root(dest) + trees = wt._worktree_branches(repo) if repo else None + if ( + not repo + or repo == os.path.realpath(dest) + or not trees + or (wt._norm_path(dest) not in trees) + ): + logger.warning("crewmate worktree %s kept: not a registered worktree", dest) + kept.append(dest) + continue + why = "" + if wt._checkout_filter(dest): + why = "the repository configures a checkout filter" + elif ignored := _ignored_files(wt, dest): + why = f"ignored files ({ignored})" + if why: + logger.warning("crewmate worktree %s kept: %s", dest, why) + kept.append(dest) + continue + proc = wt._run_git(["worktree", "remove", dest], repo) + except Exception: # noqa: BLE001 - no sandbox, no git: keep it + logger.warning("crewmate worktree %s could not be removed", dest, exc_info=True) + kept.append(dest) + continue + if proc.returncode == 0: + removed.append(dest) + else: + logger.warning("crewmate worktree %s kept: %s", dest, wt._git_error(proc)) + kept.append(dest) + with contextlib.suppress(OSError): + os.rmdir(root) # only once empty; rmdir never follows a link + return removed, kept + + +def remove_for_member(name: str, config) -> None: + """Remove the worktrees of crewmate *name* (its record read from *config*). Best-effort. + + Called when the crewmate is deleted; never raises, so it cannot block that. + """ + from kiro_crew.config import loader + from kiro_crew.members import member_slug + + record = getattr(config, "agents", {}).get(name) + if record is None: + return + try: + workspace = str(loader.workspace_dir_for(getattr(record, "workspace", "") or None)) + _removed, kept = remove_crewmate_worktrees(workspace, member_slug(name, config)) + except Exception: # noqa: BLE001 - never blocks the crewmate's removal + logger.warning("worktrees of %s were not removed", name, exc_info=True) + return + if kept: + logger.warning("kept %d worktree(s) of %s: %s", len(kept), name, kept) diff --git a/src/kiro_crew/dashboard/agent_admin/crew_removal.py b/src/kiro_crew/dashboard/agent_admin/crew_removal.py index 25000108b43..3ebf78de713 100644 --- a/src/kiro_crew/dashboard/agent_admin/crew_removal.py +++ b/src/kiro_crew/dashboard/agent_admin/crew_removal.py @@ -447,6 +447,12 @@ def _drop_from_team() -> None: # picture under the same digest stem. await _drained_to_thread(_remove_avatar_files, name) await _remove_goal_check_ins(request, name, cfg) + # The git worktrees the crewmate's workers ran in go with it; one with + # uncommitted changes is kept, and its branches and the repositories' + # own checkouts are never touched. + from kiro_crew.crewmate_worktree import remove_for_member + + await _drained_to_thread(remove_for_member, name, cfg) # Likewise the crew's private template copy: a copy the sidecar marks # private to THIS crew, and that the crew was bound to, has no reader # left once the record is gone — kept, it lists in the Agent Templates diff --git a/src/kiro_crew/dashboard/chat_turn/turn_admission.py b/src/kiro_crew/dashboard/chat_turn/turn_admission.py index a36d9caf153..5bd2a42e0d2 100644 --- a/src/kiro_crew/dashboard/chat_turn/turn_admission.py +++ b/src/kiro_crew/dashboard/chat_turn/turn_admission.py @@ -656,6 +656,15 @@ async def _turn_admit(turn: _ChatTurn) -> _Flow | None: from kiro_crew.dashboard.crewmate_approval import seed_for_turn seed_for_turn(state, slot, loaded_cfg, execution_context) + # A crewmate's worker runs in the crewmate's own git worktree of the + # repository this turn is about, never in the shared checkout. + from kiro_crew.dashboard.crewmate_worktree_scope import scope_worker_turn + + if await scope_worker_turn(state, slot, loaded_cfg, execution_context, message): + # The project is part of the captured binding; this turn now + # runs on the worktree, under the dispatch lock it still holds. + selected_binding = _current_binding(turn) + turn.selected_binding = selected_binding # FAIL-LOUD: an app-owned slot whose agent STILL did not resolve after the # self-heal must NOT run the default agent — that generic-substitution is diff --git a/src/kiro_crew/dashboard/crewmate_worktree_scope.py b/src/kiro_crew/dashboard/crewmate_worktree_scope.py new file mode 100644 index 00000000000..29189652973 --- /dev/null +++ b/src/kiro_crew/dashboard/crewmate_worktree_scope.py @@ -0,0 +1,104 @@ +"""Point a crewmate's worker at the crewmate's own worktree, at the start of its turn. + +A worker session a crewmate opened (a ``template`` record on +``kirocrew-mate-worker`` carrying the crewmate's ``member_id``) gets its +``slot.project`` -- its cwd and project scope -- set to the crewmate's own git +worktree of the repository the turn is about (:mod:`kiro_crew.crewmate_worktree`). +The repository is read from the paths this turn's message names, then from the +slot's current project; the first one inside a git repository wins. A path in +the worktree the worker already runs in (or in that repository's own checkout) +leaves it where it is, so its session is not reset for nothing. + +Called by turn admission (``chat_turn.turn_admission``) right after the +crewmate's Profile Permissions are seeded and before the provider is claimed, so +a first turn, a restored slot and a resumed one alike start in the worktree. A +change of project on a session that already runs arms the same deferred reset +``set_project`` arms, so the next spawn takes the new cwd. Git failing leaves the +turn on its current project. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from typing import Any + +logger = logging.getLogger(__name__) + + +def _worker_crewmate(config: Any, execution: Any) -> str: + """The crewmate a WORKER session runs for, else ``""`` (a conductor answers ``""``).""" + from kiro_crew.dashboard.crewmate_approval import crewmate_name_for + from kiro_crew.mate_worker import MATE_WORKER_AGENT + + if ( + getattr(execution, "selection_kind", "") != "template" + or getattr(execution, "template_id", "") != MATE_WORKER_AGENT + ): + return "" + return crewmate_name_for(config, execution) + + +def resolve_worker_project(config: Any, name: str, project: str, message: str) -> "Any | None": + """The crewmate worktree this worker turn should run in, else ``None``. Blocking.""" + from kiro_crew import crewmate_worktree as cw + from kiro_crew.config.loader import workspace_dir_for + from kiro_crew.members import member_slug + + record = config.agents.get(name) + if record is None: + return None + slug = member_slug(name, config) + ws_dir = str(workspace_dir_for(getattr(record, "workspace", "") or None)) + candidates = [*cw.paths_in(message), *([project] if project else [])] + for candidate in candidates: + try: + found = cw.ensure(slug, ws_dir, candidate) + except cw.WorktreeRefused as exc: + logger.warning("crewmate %s: no worktree for %s: %s", name, candidate, exc) + continue + except Exception as exc: # noqa: BLE001 - this candidate only; try the next + if type(exc).__name__ == "SandboxUnavailable": + # No sandbox for git at all: no candidate can have a worktree. + logger.warning("crewmate %s: worktree lookup failed", name, exc_info=True) + return None + logger.warning("crewmate %s: no worktree for %s", name, candidate, exc_info=True) + continue + if found is None: + continue + real_project = os.path.realpath(project) if project else "" + if real_project and cw._contains(found.path, real_project): + # Already in this worktree: a path of the same repository moves nothing. + return cw.CrewmateWorktree(found.repo, found.path, found.branch, project) + return found + return None + + +async def scope_worker_turn( + state: Any, slot: Any, config: Any, execution: Any, message: str +) -> str: + """Move *slot*'s project to its crewmate's worktree; return the new project or ``""``.""" + name = _worker_crewmate(config, execution) + if not name: + return "" + project = getattr(slot, "project", "") or "" + found = await asyncio.to_thread(resolve_worker_project, config, name, project, message or "") + if found is None or found.cwd == project: + return "" + from kiro_crew.dashboard.chat_utils import effective_session_key + + slot.project = found.cwd + key = effective_session_key(slot) + sessions = getattr(state, "sessions", None) + if sessions is not None and sessions.get_provider(key) is not None: + slot._pending_reset_history_key = key + slot._dirty = True + push = getattr(state, "push_slots_update", None) + if push is not None: + try: + push() + except Exception: # noqa: BLE001 - a missed push is repaired by the next one + logger.debug("push_slots_update failed", exc_info=True) + logger.info("crewmate %s worker %s runs in %s (%s)", name, slot.key, found.cwd, found.branch) + return found.cwd diff --git a/test/fixtures/crewmate_prompts/all-met.golden.txt b/test/fixtures/crewmate_prompts/all-met.golden.txt index ac5d52b89df..edcd59b4f38 100644 --- a/test/fixtures/crewmate_prompts/all-met.golden.txt +++ b/test/fixtures/crewmate_prompts/all-met.golden.txt @@ -67,7 +67,7 @@ Your name, your description and your ghost face are yours: when the user asks yo - Dashboard tab: a page you own and build; the dashboard skill and dashboard_types have the detail. - Kiro Crew docs: search_docs and find_ui (kirocrew-guide) search all of Kiro Crew's documentation; look there before guessing how a feature works (crew log types, dashboard fields, folds, settings, where a control is). - Tracking and reports: work_ledger_record and work_ledger_read keep the items you track; save_goal_report files the weekly or monthly report; save_notes keeps your briefing. -- Your worker: its kit (the tools, MCP servers and skills on your Built From tabs) is what it runs with, and a capability card from request_capabilities adds to it; the Permissions on your Profile apply to its sessions too. +- Your worker: its kit (the tools, MCP servers and skills on your Built From tabs) is what it runs with, and a capability card from request_capabilities adds to it; the Permissions on your Profile apply to its sessions too; it works in your own git worktree, on branches under your crew/ prefix. [YOUR GOALS — set by the user; the key results measure how far you are from them. Their current readings are in goal_read] Goals: @@ -94,7 +94,7 @@ Report outcomes back in this thread when work completes or needs a decision only === worker === -You are the worker a crewmate hands its work to, and your report goes back to that crewmate. When a task needs a tool, an MCP server or a skill you do not have, say so rather than working around it or acting as if it were done: open your report with one line, `MISSING CAPABILITY: -- `, then report what you could do without it. Your crewmate can ask the user to add it; you have no tool for that yourself. The facts your crewmate's Dashboard depends on are recorded as crew-log events, through the tools that record them (a work-ledger or report tool when you have one), not only in the prose of your report. +You are the worker a crewmate hands its work to, and your report goes back to that crewmate. When a task needs a tool, an MCP server or a skill you do not have, say so rather than working around it or acting as if it were done: open your report with one line, `MISSING CAPABILITY: -- `, then report what you could do without it. Your crewmate can ask the user to add it; you have no tool for that yourself. The facts your crewmate's Dashboard depends on are recorded as crew-log events, through the tools that record them (a work-ledger or report tool when you have one), not only in the prose of your report. Your project directory is your crewmate's own git worktree of the repository, on the branch `crew//`: edit and commit there, never in the repository's own checkout, and name any further branch you need under the same `crew//` prefix. Rebase onto the default branch yourself when you need its changes. Before you push a branch, run `git merge-tree --write-tree HEAD`; rebase only when it reports a conflict, and say so in your report. === heartbeat === [Heartbeat] Kiro Crew sends this; the user did not type it and sees only your reply, so keep this note itself out of what you write. Write in the language of the dashboard's [UI LANGUAGE] when it is shown, else English. diff --git a/test/test_agent_refactor_spec_bytes.py b/test/test_agent_refactor_spec_bytes.py index 008063d76db..fd684cbb030 100644 --- a/test/test_agent_refactor_spec_bytes.py +++ b/test/test_agent_refactor_spec_bytes.py @@ -441,7 +441,7 @@ def materialize(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, scenario: str) "kirocrew-ledger-conductor.json": "cc38d843938887b90ab24e6389d56233b8557cc5e42acad08c7c290738765987", "kirocrew-lite.json": "c24c577f0102ce20ccdd15f28e045888356e3a36db3e5646d86f02f62ca38612", "kirocrew-mate-conductor.json": "c856a684626094b433797d91d8e8ad2cbbe4c4d2d5624ed42e787dec8128feec", - "kirocrew-mate-worker.json": "47370eeb5c9bf2d5d8059e87142f52fab93cf226760ea17aaebbca619354bb61", + "kirocrew-mate-worker.json": "f4fd62a82630dc38ed7ccd0c7abaee8cc7d5ac3e961ca477535add842f490bf3", "kirocrew-pipeline-conductor.json": "f964776a8d3ca68d5d17192597d88fad77d17b3c8903e3c52d9d92aab9a18948", "kirocrew-research.json": "e6f3ae6eaa577c909b7d28a9d3637efb8e75548312641b497d4c50418c9130b1", "kirocrew-security-conductor.json": "e92405dd1e9e8f1386e97b74d4b1f3f3d608b344351520dbbe43283a23bd0e01", @@ -465,7 +465,7 @@ def materialize(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, scenario: str) "kirocrew-ledger-conductor.json": "cc38d843938887b90ab24e6389d56233b8557cc5e42acad08c7c290738765987", "kirocrew-lite.json": "c24c577f0102ce20ccdd15f28e045888356e3a36db3e5646d86f02f62ca38612", "kirocrew-mate-conductor.json": "c856a684626094b433797d91d8e8ad2cbbe4c4d2d5624ed42e787dec8128feec", - "kirocrew-mate-worker.json": "47370eeb5c9bf2d5d8059e87142f52fab93cf226760ea17aaebbca619354bb61", + "kirocrew-mate-worker.json": "f4fd62a82630dc38ed7ccd0c7abaee8cc7d5ac3e961ca477535add842f490bf3", "kirocrew-pipeline-conductor.json": "f964776a8d3ca68d5d17192597d88fad77d17b3c8903e3c52d9d92aab9a18948", "kirocrew-research.json": "e6f3ae6eaa577c909b7d28a9d3637efb8e75548312641b497d4c50418c9130b1", "kirocrew-security-conductor.json": "e92405dd1e9e8f1386e97b74d4b1f3f3d608b344351520dbbe43283a23bd0e01", @@ -489,7 +489,7 @@ def materialize(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, scenario: str) "kirocrew-ledger-conductor.json": "2e3667734b05b5c3d7ef0aa7da3ed1246e1d5fd2ce73e77d339aa479c22cea83", "kirocrew-lite.json": "c24c577f0102ce20ccdd15f28e045888356e3a36db3e5646d86f02f62ca38612", "kirocrew-mate-conductor.json": "95e8638222f4f9425ff86f86cfb0dcae2f16d3f8b1b69c9de56b3e7b9d949b6c", - "kirocrew-mate-worker.json": "c870cb07e117b072dcfe488c01fcbe5f17baaef1f72b169d9387dadc5035c362", + "kirocrew-mate-worker.json": "9a09d1359752de18344ccc380d58ae3e54f9ec19299e7da1150ef0a9f9cf6fca", "kirocrew-pipeline-conductor.json": "e6391f999ae7f12d8de6ef7e0a76fe1a9cb7d508d215e6a25d4b09980f1cffe4", "kirocrew-research.json": "226891438f47950133f9fde98d5d7877321a2af2072ea47a4183337dc15d471b", "kirocrew-security-conductor.json": "07cd286733e8ed187b95de3f95e86320c04f5887932847403d231f3a6866a154", @@ -515,7 +515,7 @@ def materialize(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, scenario: str) "kirocrew-ledger-conductor.json": "2e3667734b05b5c3d7ef0aa7da3ed1246e1d5fd2ce73e77d339aa479c22cea83", "kirocrew-lite.json": "c24c577f0102ce20ccdd15f28e045888356e3a36db3e5646d86f02f62ca38612", "kirocrew-mate-conductor.json": "8cc9503f890d6f1ac23255fe210d7702cf0872e615bab328b00d01da0cb828e8", - "kirocrew-mate-worker.json": "4b00ae096e6b064d1c2feab3723e1fc7806fb343129f22987ccf6004761b4340", + "kirocrew-mate-worker.json": "ba9c310dbdd66824c76e37c53a8a12f2fd305fbcdcb1e15758e00477577fb51f", "kirocrew-pipeline-conductor.json": "e6391f999ae7f12d8de6ef7e0a76fe1a9cb7d508d215e6a25d4b09980f1cffe4", "kirocrew-research.json": "f4ad1ea7fce492420f5e93905ff38ee0ef97cf362089e56aa8a51862c6266921", "kirocrew-security-conductor.json": "07cd286733e8ed187b95de3f95e86320c04f5887932847403d231f3a6866a154", @@ -539,7 +539,7 @@ def materialize(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, scenario: str) "kirocrew-ledger-conductor.json": "7dd42b5e5b91938be88ecd69d1244b8f1504c2522ef754bbc5dd2c53752501ff", "kirocrew-lite.json": "c24c577f0102ce20ccdd15f28e045888356e3a36db3e5646d86f02f62ca38612", "kirocrew-mate-conductor.json": "326a30b153e685825b9fe756e6e28351bd97063c18199b780a8dddad68c62a1b", - "kirocrew-mate-worker.json": "5e4a059d1f2e69b64f7b937e18470337236879f3e9d37cb37b5d5863a58e6cf8", + "kirocrew-mate-worker.json": "e281fff6303adf2a14864e92944e86be9326d90af60b7d0d74827fee4f9988c5", "kirocrew-pipeline-conductor.json": "f964776a8d3ca68d5d17192597d88fad77d17b3c8903e3c52d9d92aab9a18948", "kirocrew-research.json": "0cb7038ce95c848b4fd6625a85cf9eccd0ef87224ce2f8ad65c0389990b5c7bb", "kirocrew-security-conductor.json": "e92405dd1e9e8f1386e97b74d4b1f3f3d608b344351520dbbe43283a23bd0e01", @@ -563,7 +563,7 @@ def materialize(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, scenario: str) "kirocrew-ledger-conductor.json": "4d9ee797f418dac63c2a0c82ecff1634ab673dbb83c1dc21d4786c6b3d1559f8", "kirocrew-lite.json": "c24c577f0102ce20ccdd15f28e045888356e3a36db3e5646d86f02f62ca38612", "kirocrew-mate-conductor.json": "e55265899de33ed4d9103bb01fc907b361bb7d7b4a5d5d475032282c2f3c2bb5", - "kirocrew-mate-worker.json": "80fa55c75a41d1eb6c6d3e1d855e9ffa7153510a7b5f6fd670a35f458294c2a6", + "kirocrew-mate-worker.json": "8bf83f30652a2483ba2a4819d4ed5d52304a53e5fc08b906338cfd63e5c83c3d", "kirocrew-pipeline-conductor.json": "e724508ee90474abe4e4939d3a79b197d22996dea982829748f398ff3602385f", "kirocrew-research.json": "7666a438bbdfc5d547687d5b473d04471aaddf9757194e6227669d9e86182980", "kirocrew-security-conductor.json": "5d18d21cd5aecc8b725ddc27077db94df3033121a06734360af6d087e8c8c83f", @@ -587,7 +587,7 @@ def materialize(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, scenario: str) "kirocrew-ledger-conductor.json": "34477ce92ec0ed21cb4df0d2e84c8b3d2d089e2167ee93411e962ecdbdeb1d2b", "kirocrew-lite.json": "c24c577f0102ce20ccdd15f28e045888356e3a36db3e5646d86f02f62ca38612", "kirocrew-mate-conductor.json": "22145c045280241ea42252cb5b87dd19a5285a4b3cb5356a24b4a4e37583e956", - "kirocrew-mate-worker.json": "3f4fa9e7d7c2358699994d809a9cc82289a95ddb31cc38db0f39140c26b17f57", + "kirocrew-mate-worker.json": "5a6961c829ffa9e9c5788acdf7569099108f9b2d3361787a304b908a985eaad2", "kirocrew-pipeline-conductor.json": "2afbd27cb485888dc61f088f30f2b96dba535522b2213f46018a166570007efe", "kirocrew-research.json": "4f6418d8353ac284af564ffe464e7e4852f0af510ba05c424387a7f0f6b9d961", "kirocrew-security-conductor.json": "01bcf43a6f2e008cd898ef2dad5abd07f833ace6b79fb75f07c1aa1f56b678a0", @@ -611,7 +611,7 @@ def materialize(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, scenario: str) "kirocrew-ledger-conductor.json": "b5570908c78885bdc78995a1c4eb81c591688382365e526a48f7e32960568455", "kirocrew-lite.json": "c24c577f0102ce20ccdd15f28e045888356e3a36db3e5646d86f02f62ca38612", "kirocrew-mate-conductor.json": "38a3763ea1929f24e8e53294aeb9a6adeacb976dcf62a15f1b02c341a73efbc2", - "kirocrew-mate-worker.json": "34afc93a6bdb110e0fb17e66d259dd68fd02ca3cecbd8a24e3efe79593a97aad", + "kirocrew-mate-worker.json": "798e12f4bc1d5f982c875ea88eb9605940ca851fe13a75d21f3723e46afb44d6", "kirocrew-pipeline-conductor.json": "332243fd3e8cc25eb33407ac8ef3be6b9af729a2e7328e45aa67a50e5a8fbc7e", "kirocrew-research.json": "7db3afde79a24d50e572046bc40531b845cfa17972dc2e0c408dc3be648f769a", "kirocrew-security-conductor.json": "abcdf7d1fa1e308c06302fd9c6b1d3d8c73698efde0a521f37f01ef4db642a6e", diff --git a/test/test_crewmate_worktree.py b/test/test_crewmate_worktree.py new file mode 100644 index 00000000000..bb91cc1d212 --- /dev/null +++ b/test/test_crewmate_worktree.py @@ -0,0 +1,820 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Each crewmate's workers run in that crewmate's own git worktree. + +Three crewmates shared one checkout in the soak: one committed the others' edits +and landed a commit on the branch another had checked out. The gateway gives each +crewmate one worktree per repository, at +``/worktrees//-``, on a branch of its own, +points its workers' cwd at it, and removes it with the crewmate. The rest of the +git work is the worker's own. +""" + +from __future__ import annotations + +import asyncio +import os +import shutil +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest +from test_chat_agent_selection import _finished +from test_crewmate_worker_approval import home # noqa: F401 +from test_mate_worker_dispatch import _create, _fresh_create_budget, crewmate # noqa: F401 +from test_worktree_create import ( + _passthrough_spawn, + _sandbox_exec_reason, +) + +from kiro_crew import crewmate_worktree as cw +from kiro_crew.dashboard import chat_runner + + +def _git(cwd, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=cwd, check=True, capture_output=True, text=True, encoding="utf-8" + ).stdout.strip() + + +@pytest.fixture +def git_here(monkeypatch): + """Git for these tests: sandboxed where the host has a backend, plain otherwise. + + The Linux CI runners have no sandbox backend, and a skip there would leave + the worktree logic untested; the sandbox wrapper itself is tested in + ``test_worktree_create``. Windows is skipped up front, before any probe: the + sandbox fails closed there ("not Linux"), and these cases are written for + POSIX paths, permissions and links. + """ + if sys.platform == "win32": + pytest.skip("sandboxed git does not run on Windows (no backend)") + if _sandbox_exec_reason(): + from kiro_crew.dashboard.handlers import worktree as wt + + monkeypatch.setattr(wt, "sandboxed_spawn_argv", _passthrough_spawn) + + +def _init(root, branch: str = "main") -> str: + root.mkdir(parents=True, exist_ok=True) + _git(root.parent, "init", "-q", "-b", branch, str(root)) + _git(root, "config", "user.email", "t@example.com") + _git(root, "config", "user.name", "t") + (root / "README.md").write_text("hi\n", encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "init") + return str(root) + + +@pytest.fixture +def repo(tmp_path, git_here): + root = tmp_path / "shared" / "tasklog" + (root / "docs").mkdir(parents=True) + (root / "docs" / "a.md").write_text("a\n", encoding="utf-8") + return _init(root) + + +def _commit_in(path: str, name: str, message: str) -> None: + with open(os.path.join(path, name), "w", encoding="utf-8") as fh: + fh.write(message + "\n") + _git(path, "add", name) + _git(path, "commit", "-q", "-m", message) + + +# ── job 1: one worktree per crewmate and repository, on its own branch ── + + +def test_two_crewmates_on_one_repo_get_their_own_worktree_and_branch(repo, tmp_path): + atlas_ws, briar_ws = str(tmp_path / "ws-atlas"), str(tmp_path / "ws-briar") + atlas = cw.ensure("atlas", atlas_ws, repo) + briar = cw.ensure("briar", briar_ws, os.path.join(repo, "docs")) + + assert atlas.path == cw.path_for(atlas_ws, "atlas", repo) + assert briar.path == cw.path_for(briar_ws, "briar", repo) + assert atlas.path.startswith(os.path.join(os.path.realpath(atlas_ws), "worktrees", "atlas")) + assert atlas.branch == "crew/atlas/tasklog" and briar.branch == "crew/briar/tasklog" + assert briar.cwd == os.path.join(briar.path, "docs") + assert _git(atlas.path, "branch", "--show-current") == atlas.branch + assert _git(briar.path, "branch", "--show-current") == briar.branch + # The repository's own checkout stays on its own branch, untouched. + assert _git(repo, "branch", "--show-current") == "main" + assert _git(repo, "status", "--porcelain") == "" + + +def test_two_crewmates_sharing_a_workspace_get_separate_folders(repo, tmp_path): + ws = str(tmp_path / "ws") + atlas = cw.ensure("atlas", ws, repo) + briar = cw.ensure("briar", ws, repo) + assert os.path.dirname(atlas.path) != os.path.dirname(briar.path) + assert atlas.branch != briar.branch + + +def test_the_worktree_is_reused_as_the_worker_left_it(repo, tmp_path): + ws = str(tmp_path / "ws-atlas") + atlas = cw.ensure("atlas", ws, repo) + with open(os.path.join(atlas.path, "work.txt"), "w", encoding="utf-8") as fh: + fh.write("wip\n") + assert cw.ensure("atlas", ws, repo) == atlas # uncommitted work does not stop the reuse + # A topic branch the worker cut there is its own work: used as it is. + _git(atlas.path, "checkout", "-q", "-b", "crew/atlas/fix-login") + again = cw.ensure("atlas", ws, repo) + assert again.path == atlas.path and again.branch == "crew/atlas/fix-login" + # A brief naming another crewmate's worktree lands in this crewmate's own. + briar = cw.ensure("briar", str(tmp_path / "ws-briar"), os.path.join(atlas.path, "docs")) + assert briar.repo == os.path.realpath(repo) + assert briar.path == cw.path_for(str(tmp_path / "ws-briar"), "briar", repo) + assert briar.cwd == os.path.join(briar.path, "docs") + + +def test_a_path_outside_any_repository_has_no_worktree(tmp_path, git_here): + plain = tmp_path / "plain" + plain.mkdir() + assert cw.ensure("atlas", str(tmp_path / "ws"), str(plain)) is None + assert cw.repo_root("relative/path") is None + + +def test_a_repository_holding_the_crewmates_workspace_has_no_worktree(repo, tmp_path): + """A home folder under git is not a repository the crewmate was handed.""" + ws = os.path.join(repo, "ws-atlas") + assert cw.ensure("atlas", ws, repo) is None + assert _git(repo, "branch", "--list", "crew/*") == "" + + +def test_a_bare_repository_has_no_worktree(tmp_path, git_here): + bare = str(tmp_path / "bare.git") + _git(str(tmp_path), "init", "-q", "--bare", bare) + assert cw.repo_root(bare) is None + + +def test_a_brief_naming_a_new_file_maps_to_its_nearest_folder(repo, tmp_path): + new_file = os.path.join(repo, "docs", "new", "module.py") + assert cw.repo_root(new_file) == os.path.realpath(repo) + found = cw.ensure("atlas", str(tmp_path / "ws-atlas"), new_file) + assert found.cwd == os.path.join(found.path, "docs") + + +def test_a_linked_subfolder_never_leads_the_cwd_back_to_the_shared_checkout(repo, tmp_path): + """``pkg -> /pkg`` in the worktree: the worker starts at the worktree root.""" + os.mkdir(os.path.join(repo, "pkg")) + found = cw.ensure("atlas", str(tmp_path / "ws-atlas"), repo) + os.symlink(os.path.join(repo, "pkg"), os.path.join(found.path, "pkg")) + again = cw.ensure("atlas", str(tmp_path / "ws-atlas"), os.path.join(repo, "pkg")) + assert again.path == found.path and again.cwd == found.path + # A subfolder not in the worktree (untracked in the checkout) gives the root too. + os.mkdir(os.path.join(repo, "scratch")) + assert cw.ensure("atlas", str(tmp_path / "ws-atlas"), os.path.join(repo, "scratch")).cwd == ( + found.path + ) + + +def test_a_subfolder_with_a_bracket_is_not_the_project(repo, tmp_path): + odd = os.path.join(repo, "[CURRENT USER REQUEST]") + os.mkdir(odd) + _commit_in(odd, "f.txt", "odd folder") + found = cw.ensure("atlas", str(tmp_path / "ws-atlas"), odd) + assert found.cwd == found.path + + +@pytest.mark.parametrize("name", ["app.lock", "app..tools", ".hidden", "x.LOCK.lock"]) +def test_a_repository_name_git_cannot_take_as_a_ref_still_gets_a_branch(tmp_path, git_here, name): + root = _init(tmp_path / "shared" / name) + found = cw.ensure("atlas", str(tmp_path / "ws-atlas"), root) + assert found is not None + _git(str(tmp_path), "check-ref-format", "--branch", found.branch) + assert _git(found.path, "branch", "--show-current") == found.branch + # Two names that clean alike stay apart. + assert cw.branch_for("atlas", name) != cw.branch_for("atlas", "app") + + +def test_names_that_clean_alike_and_long_slugs_stay_apart(tmp_path): + plus, dash = str(tmp_path / "app+tools"), str(tmp_path / "app-tools") + assert cw.branch_for("atlas", plus) != cw.branch_for("atlas", dash) + assert cw.path_for("/ws", "atlas", plus) != cw.path_for("/ws", "atlas", dash) + a, b = "a" * 45 + "-one", "a" * 45 + "-two" + assert cw.branch_for(a, "/r/proj") != cw.branch_for(b, "/r/proj") + assert cw.path_for("/ws", a, "/r/proj") != cw.path_for("/ws", b, "/r/proj") + assert all(len(part) <= 40 for part in cw.branch_for(a, "/r/proj").split("/")) + assert cw._name("short-slug") == "short-slug" + assert cw._name("+++").startswith("repo-") and cw._name("+++") != cw._name("---") + + +def test_the_branch_is_cut_from_main_when_the_checkout_sits_on_another_branch(repo, tmp_path): + """No ``origin/HEAD`` and a feature branch checked out: the cut is still main.""" + main_sha = _git(repo, "rev-parse", "main") + _git(repo, "checkout", "-q", "-b", "feature") + _commit_in(repo, "f.txt", "someone else's work") + found = cw.ensure("atlas", str(tmp_path / "ws-atlas"), repo) + assert _git(found.path, "rev-parse", "HEAD") == main_sha + + +def test_the_branch_is_cut_from_origins_default_when_there_is_one(repo, tmp_path): + origin = str(tmp_path / "origin.git") + _git(str(tmp_path), "init", "-q", "--bare", origin) + _git(repo, "remote", "add", "origin", origin) + _git(repo, "push", "-q", "origin", "main") + _git(repo, "remote", "set-head", "origin", "main") + pushed = _git(repo, "rev-parse", "HEAD") + _commit_in(repo, "local.txt", "local only") + found = cw.ensure("atlas", str(tmp_path / "ws-atlas"), repo) + assert _git(found.path, "rev-parse", "HEAD") == pushed + + +def test_no_default_branch_cuts_from_head(tmp_path, git_here): + root = _init(tmp_path / "shared" / "trunkrepo", branch="trunk") + found = cw.ensure("atlas", str(tmp_path / "ws-atlas"), root) + assert _git(found.path, "rev-parse", "HEAD") == _git(root, "rev-parse", "HEAD") + + +def test_a_tag_named_like_the_default_branch_is_never_the_base(repo, tmp_path): + old = _git(repo, "rev-parse", "HEAD") + _git(repo, "tag", "main", old) + _commit_in(repo, "issues.md", "ISSUE-17") + tip = _git(repo, "rev-parse", "refs/heads/main") + found = cw.ensure("atlas", str(tmp_path / "ws-atlas"), repo) + assert _git(found.path, "rev-parse", "HEAD") == tip + + +def test_a_worktree_folder_deleted_by_hand_is_rebuilt_on_its_branch(repo, tmp_path): + ws = str(tmp_path / "ws-atlas") + found = cw.ensure("atlas", ws, repo) + _commit_in(found.path, "work.txt", "atlas's work") + kept = _git(found.path, "rev-parse", "HEAD") + shutil.rmtree(found.path) + again = cw.ensure("atlas", ws, repo) + assert again.path == found.path and again.branch == "crew/atlas/tasklog" + assert _git(again.path, "rev-parse", "HEAD") == kept + + +def test_a_folder_git_does_not_register_is_refused(repo, tmp_path): + ws = str(tmp_path / "ws-atlas") + dest = cw.path_for(ws, "atlas", repo) + os.makedirs(dest) + (open(os.path.join(dest, "theirs.txt"), "w", encoding="utf-8")).close() + with pytest.raises(cw.WorktreeRefused, match="is not a worktree"): + cw.ensure("atlas", ws, repo) + assert os.listdir(dest) == ["theirs.txt"] + + +def test_an_unlistable_worktree_table_is_refused(repo, tmp_path, monkeypatch): + from kiro_crew.dashboard.handlers import worktree as wt + + monkeypatch.setattr(wt, "_worktree_branches", lambda _r: None) + with pytest.raises(cw.WorktreeRefused, match="could not list"): + cw.ensure("atlas", str(tmp_path / "ws-atlas"), repo) + + +def test_a_repository_with_a_checkout_filter_is_refused(repo, tmp_path): + marker = tmp_path / "filter-ran" + _git(repo, "config", "filter.evil.smudge", f"touch {marker}; cat") + with pytest.raises(cw.WorktreeRefused, match="checkout filter"): + cw.ensure("atlas", str(tmp_path / "ws-atlas"), repo) + assert not marker.exists() + assert _git(repo, "branch", "--list", "crew/*") == "" + + +def test_a_repository_with_no_commit_is_refused(tmp_path, git_here): + root = tmp_path / "shared" / "empty" + root.mkdir(parents=True) + _git(root.parent, "init", "-q", "-b", "main", str(root)) + with pytest.raises(cw.WorktreeRefused, match="no commit"): + cw.ensure("atlas", str(tmp_path / "ws-atlas"), str(root)) + + +def test_a_worktree_add_that_times_out_leaves_no_branch_and_no_folder(repo, tmp_path, monkeypatch): + """A half-made worktree and its claimed branch would be reused by the next turn.""" + from kiro_crew.dashboard.handlers import worktree as wt + + real = wt._run_git + + def slow(args, cwd, **kw): + if args[:2] == ["worktree", "add"]: + real(args, cwd, **kw) # git got as far as registering it + raise subprocess.TimeoutExpired(args, 120) + return real(args, cwd, **kw) + + ws = str(tmp_path / "ws-atlas") + monkeypatch.setattr(wt, "_run_git", slow) + with pytest.raises(subprocess.TimeoutExpired): + cw.ensure("atlas", ws, repo) + dest = cw.path_for(ws, "atlas", repo) + assert not os.path.exists(dest) + assert _git(repo, "branch", "--list", "crew/atlas/tasklog") == "" + assert dest not in _git(repo, "worktree", "list", "--porcelain") + # The next turn builds it whole. + monkeypatch.setattr(wt, "_run_git", real) + again = cw.ensure("atlas", ws, repo) + assert again.path == dest and os.path.isfile(os.path.join(dest, ".git")) + + +def test_a_failed_add_keeps_a_branch_it_did_not_cut(repo, tmp_path, monkeypatch): + from kiro_crew.dashboard.handlers import worktree as wt + + ws = str(tmp_path / "ws-atlas") + found = cw.ensure("atlas", ws, repo) + _commit_in(found.path, "work.txt", "atlas's work") + kept = _git(found.path, "rev-parse", "HEAD") + shutil.rmtree(found.path) + real = wt._run_git + + def fail(args, cwd, **kw): + if args[:2] == ["worktree", "add"]: + return subprocess.CompletedProcess(args, 128, "", "fatal: add failed") + return real(args, cwd, **kw) + + monkeypatch.setattr(wt, "_run_git", fail) + with pytest.raises(cw.WorktreeRefused, match="add failed"): + cw.ensure("atlas", ws, repo) + assert not os.path.exists(found.path) + assert _git(repo, "rev-parse", "crew/atlas/tasklog") == kept + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX links and no-follow descriptors") +def test_a_failed_add_never_removes_what_a_swapped_parent_points_at(repo, tmp_path, monkeypatch): + """``worktrees/`` swapped for a link to the crew home before the cleanup: it stays.""" + from kiro_crew.dashboard.handlers import worktree as wt + + crew_home = tmp_path / "crew-home" + ws = str(tmp_path / "ws-atlas") + dest = cw.path_for(ws, "atlas", repo) + (crew_home / os.path.basename(dest)).mkdir(parents=True) + (crew_home / os.path.basename(dest) / "ceiling.json").write_text("{}", encoding="utf-8") + parent = os.path.dirname(dest) + real = wt._run_git + + def swap_then_fail(args, cwd, **kw): + if args[:2] == ["worktree", "add"]: + os.rename(parent, parent + ".moved") + os.symlink(crew_home, parent) + return subprocess.CompletedProcess(args, 128, "", "fatal: add failed") + return real(args, cwd, **kw) + + monkeypatch.setattr(wt, "_run_git", swap_then_fail) + with pytest.raises(cw.WorktreeRefused, match="add failed"): + cw.ensure("atlas", ws, repo) + assert (crew_home / os.path.basename(dest) / "ceiling.json").read_text(encoding="utf-8") == "{}" + # The folder this lookup made moved with the swap and was left, not chased. + assert os.path.isdir(os.path.join(parent + ".moved", os.path.basename(dest))) + assert _git(repo, "branch", "--list", "crew/atlas/tasklog") == "" + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX links and no-follow descriptors") +def test_a_worktree_folder_is_made_through_no_follow_descriptors(repo, tmp_path): + """``/worktrees/`` linked elsewhere: nothing is made through it.""" + ws = tmp_path / "ws-atlas" + elsewhere = tmp_path / "governance" / "profiles" + elsewhere.mkdir(parents=True) + (ws / "worktrees").mkdir(parents=True) + (ws / "worktrees" / "atlas").symlink_to(elsewhere, target_is_directory=True) + with pytest.raises(cw.WorktreeRefused, match="not a plain folder"): + cw.ensure("atlas", str(ws), repo) + assert os.listdir(elsewhere) == [] + assert _git(repo, "branch", "--list", "crew/atlas/tasklog") == "" + # Removal does not follow it either. + assert cw.remove_crewmate_worktrees(str(ws), "atlas") == ([], []) + + +def test_quoted_paths_with_spaces_and_windows_paths_are_found(): + """A drive path counts only where it is absolute: on a Windows gateway.""" + text = 'Fix "/projects/My Repo/src" then D:\\work\\tasklog and /srv/app, plus `~/a b`.' + drive = ["D:\\work\\tasklog"] if os.name == "nt" else [] + assert cw.paths_in(text) == ["/projects/My Repo/src", *drive, "/srv/app", "~/a b"] + # Never the ``/tools/x`` inside a drive path either. + assert cw.paths_in("work in C:/tools/x") == (["C:/tools/x"] if os.name == "nt" else []) + assert cw.paths_in(" ".join(f"/p/{i}" for i in range(30)))[-1] == "/p/19" + assert cw.paths_in(None) == [] and cw._absolute_here("\0") is False + + +def test_paths_on_different_drives_are_not_contained(monkeypatch): + def other_drive(_paths): + raise ValueError("Paths don't have the same drive") + + monkeypatch.setattr(cw.os.path, "commonpath", other_drive) + assert cw._contains("C:\\repo", "D:\\ws") is False + + +# ── job 3: removal with the crewmate ── + + +def test_removal_removes_clean_worktrees_and_keeps_everything_else(repo, tmp_path): + ws = str(tmp_path / "ws-atlas") + other = _init(tmp_path / "shared" / "other") + clean = cw.ensure("atlas", ws, repo) + dirty = cw.ensure("atlas", ws, other) + with open(os.path.join(dirty.path, "README.md"), "a", encoding="utf-8") as fh: + fh.write("unsaved\n") + stray = os.path.join(os.path.dirname(clean.path), "not-a-worktree") + os.mkdir(stray) + os.symlink(repo, os.path.join(os.path.dirname(clean.path), "link")) + with open(os.path.join(repo, "user-notes.txt"), "w", encoding="utf-8") as fh: + fh.write("the user's own work\n") + + removed, kept = cw.remove_crewmate_worktrees(ws, "atlas") + assert removed == [clean.path] and sorted(kept) == sorted([dirty.path, stray]) + assert not os.path.exists(clean.path) + assert os.path.exists(os.path.join(dirty.path, "README.md")) + # Branches stay, and the repository's own checkout keeps the user's file. + assert _git(repo, "branch", "--list", "crew/atlas/tasklog") + assert os.path.exists(os.path.join(repo, "user-notes.txt")) + assert cw.remove_crewmate_worktrees(str(tmp_path / "nobody"), "atlas") == ([], []) + + +def test_removal_keeps_a_worktree_holding_only_ignored_files(repo, tmp_path): + """``worktree remove`` would take ignored worker output with it.""" + with open(os.path.join(repo, ".gitignore"), "w", encoding="utf-8") as fh: + fh.write("results/\n") + _git(repo, "add", ".gitignore") + _git(repo, "commit", "-q", "-m", "ignore results") + ws = str(tmp_path / "ws-atlas") + found = cw.ensure("atlas", ws, repo) + os.mkdir(os.path.join(found.path, "results")) + with open(os.path.join(found.path, "results", "run.csv"), "w", encoding="utf-8") as fh: + fh.write("a,b\n") + assert _git(found.path, "status", "--porcelain") == "" + assert cw.remove_crewmate_worktrees(ws, "atlas") == ([], [found.path]) + assert os.path.exists(os.path.join(found.path, "results", "run.csv")) + + +def test_removal_keeps_a_worktree_whose_repository_names_a_filter(repo, tmp_path): + """``worktree remove`` checks the files for changes, which can run a clean filter.""" + ws = str(tmp_path / "ws-atlas") + found = cw.ensure("atlas", ws, repo) + _git(repo, "config", "filter.evil.clean", f"touch {tmp_path / 'filter-ran'}; cat") + assert cw.remove_crewmate_worktrees(ws, "atlas") == ([], [found.path]) + assert os.path.isdir(found.path) + assert not (tmp_path / "filter-ran").exists() + + +def test_removal_keeps_a_worktree_git_will_not_remove(repo, tmp_path, monkeypatch): + from kiro_crew.dashboard.handlers import worktree as wt + + ws = str(tmp_path / "ws-atlas") + found = cw.ensure("atlas", ws, repo) + _git(repo, "worktree", "lock", found.path) + assert cw.remove_crewmate_worktrees(ws, "atlas") == ([], [found.path]) + + def gone(*_a, **_k): + raise OSError("git gone") + + monkeypatch.setattr(wt, "_run_git", gone) + assert cw.remove_crewmate_worktrees(ws, "atlas") == ([], [found.path]) + monkeypatch.setattr( + wt, "_run_git", lambda *_a, **_k: subprocess.CompletedProcess([], 1, "", "") + ) + assert cw._ignored_files(wt, found.path) == "unknown" + + +def test_the_ignored_list_is_bounded(monkeypatch): + many = "\0".join(f"f{i}" for i in range(8)) + "\0" + wt = SimpleNamespace(_run_git=lambda *_a, **_k: subprocess.CompletedProcess([], 0, many, "")) + assert cw._ignored_files(wt, "/x") == "f0, f1, f2, f3, f4 and 3 more" + + +def test_remove_for_member_never_raises(monkeypatch, caplog): + import kiro_crew.config.loader as loader + import kiro_crew.members as members + + config = SimpleNamespace(agents={"Atlas": SimpleNamespace(workspace="")}) + cw.remove_for_member("Nobody", config) # no record: nothing to do + monkeypatch.setattr(members, "member_slug", lambda _n, _c: "atlas") + monkeypatch.setattr(loader, "workspace_dir_for", lambda _w: "/ws") + monkeypatch.setattr(cw, "remove_crewmate_worktrees", lambda *_a: ([], ["/ws/kept"])) + cw.remove_for_member("Atlas", config) + assert "/ws/kept" in caplog.text + + def boom(*_a): + raise RuntimeError("no sandbox") + + monkeypatch.setattr(cw, "remove_crewmate_worktrees", boom) + cw.remove_for_member("Atlas", config) + assert "were not removed" in caplog.text + + +# ── job 2: the worker turn's cwd ── + + +def _scope_env(monkeypatch, ws): + import kiro_crew.config.loader as loader + import kiro_crew.members as members + from kiro_crew.dashboard import crewmate_worktree_scope as scope + + monkeypatch.setattr(members, "member_slug", lambda _n, _c: "atlas") + monkeypatch.setattr(loader, "workspace_dir_for", lambda _w: ws) + return scope, SimpleNamespace(agents={"Atlas": SimpleNamespace(workspace="")}) + + +def test_only_a_crewmates_worker_is_scoped(monkeypatch): + from kiro_crew.dashboard import crewmate_approval + from kiro_crew.dashboard import crewmate_worktree_scope as scope + from kiro_crew.mate_worker import MATE_WORKER_AGENT + + conductor = SimpleNamespace(selection_kind="agent", template_id="") + assert scope._worker_crewmate(None, conductor) == "" + worker = SimpleNamespace(selection_kind="template", template_id=MATE_WORKER_AGENT) + monkeypatch.setattr(crewmate_approval, "crewmate_name_for", lambda _c, _e: "Atlas") + assert scope._worker_crewmate(None, worker) == "Atlas" + slot = SimpleNamespace(project="/p", key="s") + assert asyncio.run(scope.scope_worker_turn(None, slot, None, conductor, "")) == "" + assert slot.project == "/p" + + +def test_each_candidate_is_tried_in_turn(monkeypatch, tmp_path): + scope, config = _scope_env(monkeypatch, str(tmp_path / "ws")) + assert scope.resolve_worker_project(config, "Nobody", "/r/project", "") is None + hit = cw.CrewmateWorktree(repo="/r", path="/ws/wt/atlas/r", branch="crew/atlas/r", cwd="/ws/a") + tried: list[str] = [] + + def ensure(_slug, _ws, candidate): + tried.append(candidate) + if candidate == "/r/named": + raise cw.WorktreeRefused("checkout filter") + if candidate == "/r/bad": + raise PermissionError("unsearchable") + return hit if candidate == "/r/project" else None + + monkeypatch.setattr(cw, "ensure", ensure) + # The message's paths first, then the slot's project. + got = scope.resolve_worker_project(config, "Atlas", "/r/project", "see /r/named, /r/bad, /r/x") + assert got is hit and tried == ["/r/named", "/r/bad", "/r/x", "/r/project"] + assert scope.resolve_worker_project(config, "Atlas", "", "see /r/named") is None + + from kiro_crew.dashboard.handlers import worktree as wt + + def no_sandbox(*_a): + raise wt.SandboxUnavailable("no backend") + + tried.clear() + monkeypatch.setattr(cw, "ensure", no_sandbox) + assert scope.resolve_worker_project(config, "Atlas", "/r/project", "see /r/named") is None + + +def test_a_path_of_the_same_repository_leaves_a_worker_in_its_worktree(repo, tmp_path, monkeypatch): + """``~/...`` in its own worktree, or the repository's checkout: no move, no reset.""" + ws = str(tmp_path / "ws-atlas") + found = cw.ensure("atlas", ws, os.path.join(repo, "docs")) + scope, config = _scope_env(monkeypatch, ws) + monkeypatch.setattr(scope, "_worker_crewmate", lambda _c, _e: "Atlas") + monkeypatch.setenv("HOME", str(tmp_path)) + spelled = "~/" + os.path.relpath(os.path.join(found.path, "README.md"), str(tmp_path)) + state = SimpleNamespace(sessions=SimpleNamespace(get_provider=lambda _k: object())) + for named in (spelled, repo): + slot = SimpleNamespace(project=found.cwd, key="w1", _dirty=False) + out = asyncio.run(scope.scope_worker_turn(state, slot, config, None, f"edit {named}")) + assert out == "" and slot.project == found.cwd, named + assert not hasattr(slot, "_pending_reset_history_key") and slot._dirty is False + + +def test_a_brief_naming_another_repository_moves_the_turn_there(tmp_path, git_here, monkeypatch): + alpha = _init(tmp_path / "shared" / "alpha") + beta = _init(tmp_path / "shared" / "beta") + ws = str(tmp_path / "ws-atlas") + first = cw.ensure("atlas", ws, alpha) + scope, config = _scope_env(monkeypatch, ws) + new_file = os.path.join(beta, "new_package", "module.py") + got = scope.resolve_worker_project(config, "Atlas", first.cwd, f"Create {new_file}") + assert got is not None and got.repo == os.path.realpath(beta) + assert got.path == cw.path_for(ws, "atlas", beta) and got.cwd == got.path + + +def test_a_new_worktree_moves_the_project_and_resets_a_running_session(monkeypatch): + from kiro_crew.dashboard import chat_utils + from kiro_crew.dashboard import crewmate_worktree_scope as scope + + hit = cw.CrewmateWorktree(repo="/r", path="/ws/wt/atlas/r", branch="crew/atlas/r", cwd="/ws/a") + pushes: list[int] = [] + + def push(): + pushes.append(1) + raise RuntimeError("socket closed") + + monkeypatch.setattr(scope, "_worker_crewmate", lambda *_a: "Atlas") + monkeypatch.setattr(scope, "resolve_worker_project", lambda *_a: hit) + monkeypatch.setattr(chat_utils, "effective_session_key", lambda slot: "dashboard:" + slot.key) + state = SimpleNamespace( + sessions=SimpleNamespace(get_provider=lambda _key: object()), push_slots_update=push + ) + slot = SimpleNamespace(project="/r/src", key="w1", _dirty=False) + # A failed push is repaired by the next one. + assert asyncio.run(scope.scope_worker_turn(state, slot, None, None, "")) == hit.cwd + assert (slot.project, slot._dirty) == (hit.cwd, True) + assert slot._pending_reset_history_key == "dashboard:w1" + assert pushes == [1] + # Already there: nothing moves. + assert asyncio.run(scope.scope_worker_turn(state, slot, None, None, "")) == "" + monkeypatch.setattr(scope, "resolve_worker_project", lambda *_a: None) + assert asyncio.run(scope.scope_worker_turn(state, slot, None, None, "")) == "" + # No running session: the move arms no reset. + idle = SimpleNamespace(project="", key="w2", _dirty=False) + monkeypatch.setattr(scope, "resolve_worker_project", lambda *_a: hit) + assert asyncio.run(scope.scope_worker_turn(None, idle, None, None, "")) == hit.cwd + assert not hasattr(idle, "_pending_reset_history_key") + + +@pytest.mark.skipif(os.name == "nt", reason="a drive path is absolute on Windows") +def test_a_drive_path_on_a_posix_gateway_never_reaches_the_gateways_own_checkout( + tmp_path, git_here, monkeypatch +): + """The gateway's cwd is a checkout: ``C:\\tools\\x`` must not resolve into it.""" + own = _init(tmp_path / "gateway-checkout") + monkeypatch.chdir(own) + assert cw.repo_root("C:/tools/x") is None + ws = str(tmp_path / "ws-atlas") + scope, config = _scope_env(monkeypatch, ws) + monkeypatch.setattr(scope, "_worker_crewmate", lambda _c, _e: "Atlas") + slot = SimpleNamespace(project="", key="w1", _dirty=False) + out = asyncio.run(scope.scope_worker_turn(None, slot, config, None, "fix C:\\tools\\x now")) + assert out == "" and slot.project == "" + assert _git(own, "branch", "--list", "crew/*") == "" + + +# ── job 2: the repository's aws.project_profiles binding holds in its worktree ── + + +def _profile(name: str) -> dict[str, str]: + return {"AWS_PROFILE": name, "AWS_DEFAULT_PROFILE": name} + + +def test_a_worker_in_its_worktree_keeps_the_repositorys_aws_profile(repo, tmp_path): + """``/repos/app -> sandbox``: the worktree sits in the workspace, not under ``/repos/app``.""" + from kiro_crew.aws_project_profiles import bound_profile_env + + ws = str(tmp_path / "ws-atlas") + real = os.path.realpath(repo) + # The workspace's own binding never stands in for the repository's. + mapping = {os.path.dirname(real): "production", real: "sandbox", ws: "personal"} + found = cw.ensure("atlas", ws, os.path.join(repo, "docs")) + assert found.cwd == os.path.join(found.path, "docs") + assert cw.source_path(found.path) == real + assert cw.source_path(found.cwd) == os.path.join(real, "docs") + for cwd in (found.path, found.cwd, os.path.join(found.cwd, "not-made-yet")): + assert bound_profile_env(mapping, cwd, None) == _profile("sandbox"), cwd + # An unmapped repository's worktree is bound as its checkout is: not at all. + caller = {"FOO": "bar"} + assert bound_profile_env({str(tmp_path / "elsewhere"): "dev"}, found.cwd, caller) is caller + + +def test_the_binding_is_read_from_git_so_it_holds_after_a_restart(repo, tmp_path): + """A worktree an earlier gateway made (plain git here): nothing in this process knew it.""" + from kiro_crew.aws_project_profiles import resolve_project_profile + + ws = str(tmp_path / "ws-atlas") + dest = cw.path_for(ws, "atlas", repo) + os.makedirs(os.path.dirname(dest)) + _git(repo, "worktree", "add", "-q", "-b", "crew/atlas/tasklog", dest, "main") + real = os.path.realpath(repo) + assert resolve_project_profile({real: "sandbox"}, os.path.join(dest, "docs")) == "sandbox" + # And on every later start in the reused worktree. + assert cw.ensure("atlas", ws, repo).path == dest + assert resolve_project_profile({real: "sandbox"}, dest) == "sandbox" + + +def test_only_a_registered_worktree_at_the_crewmate_layout_is_mapped(repo, tmp_path): + ws = str(tmp_path / "ws-atlas") + found = cw.ensure("atlas", ws, repo) + # A repository cloned inside the worktree is its own checkout. + nested = _init(Path(found.path) / "vendor" / "lib") + assert cw.source_path(nested) == nested + # A folder named for the repository that git does not register as its worktree. + other_ws = str(tmp_path / "ws-briar") + look_alike = cw.path_for(other_ws, "briar", repo) + os.makedirs(os.path.join(look_alike, "docs")) + assert cw.source_path(os.path.join(look_alike, "docs")) == os.path.join(look_alike, "docs") + _init(Path(look_alike)) + assert cw.source_path(look_alike) == look_alike + # A ``.git`` copied in from a real worktree: git does not register this folder. + forged = cw.path_for(str(tmp_path / "ws-pixel"), "pixel", repo) + os.makedirs(os.path.join(forged, "docs")) + shutil.copy(os.path.join(found.path, ".git"), os.path.join(forged, ".git")) + assert cw.source_path(os.path.join(forged, "docs")) == os.path.join(forged, "docs") + # A worktree named for the repository, outside a ``worktrees//`` folder. + shallow = str(tmp_path / "worktrees" / os.path.basename(found.path)) + _git(repo, "worktree", "add", "-q", "-b", "user/shallow", shallow, "main") + assert cw.source_path(os.path.join(shallow, "docs")) == os.path.join(shallow, "docs") + # A worktree of the repository moved to a folder not named for it. + moved = os.path.join(os.path.dirname(found.path), "other-12345678") + _git(repo, "worktree", "move", found.path, moved) + assert cw.source_path(moved) == moved + # A worktree outside any ``worktrees//`` folder. + beside = str(tmp_path / "beside") + _git(repo, "worktree", "add", "-q", "-b", "user/beside", beside, "main") + assert cw.source_path(beside) == beside + + +def test_a_path_with_no_worktrees_folder_runs_no_git(monkeypatch, tmp_path): + from kiro_crew.dashboard.handlers import worktree as wt + + ran: list[list[str]] = [] + monkeypatch.setattr(wt, "_run_git", lambda args, *_a, **_k: ran.append(list(args))) + for path in (str(tmp_path / "repos" / "app" / "src"), "", "relative/worktrees/a/b"): + assert cw.source_path(path) == path + assert ran == [], "no git for an ordinary path" + + def no_sandbox(*_a, **_k): + raise wt.SandboxUnavailable("no backend") + + monkeypatch.setattr(wt, "_run_git", no_sandbox) + inside = str(tmp_path / "ws" / "worktrees" / "atlas" / "app-12345678" / "src") + assert cw.source_path(inside) == inside + + +@pytest.mark.asyncio +async def test_a_session_started_in_the_worktree_gets_the_repositorys_profile(repo, tmp_path): + from test_aws_project_profiles import _cfg, _recording_factory + + from kiro_crew.session import SessionManager + + found = await asyncio.to_thread(cw.ensure, "atlas", str(tmp_path / "ws-atlas"), repo) + calls: list[dict] = [] + cfg = _cfg({os.path.realpath(repo): "sandbox"}) + mgr = SessionManager(cfg, provider_factory=_recording_factory(calls)) + try: + await mgr.get_or_create("chat-23563", cwd=found.cwd, extra_env=None) + finally: + await mgr.close_all() + assert calls[0]["extra_env"] == _profile("sandbox") + + +# ── end to end: a worker turn, and deleting the crewmate ── + + +@pytest.mark.asyncio +async def test_a_workers_cwd_is_its_crewmates_worktree(home, repo, monkeypatch): # noqa: F811 + from test_chat_agent_selection import _turn_state + + from kiro_crew.config.loader import KiroCrewConfig, workspace_dir_for + from kiro_crew.history import ConversationLog + from kiro_crew.members import member_slug + + state, caller, _execution, tmp_path = home + created = state.get_slot((await asyncio.to_thread(_create, state, caller))["target"]) + shared = created.project + turn_state = _turn_state(tmp_path, monkeypatch) + turn_state.conversation_log = ConversationLog() + worker = turn_state.get_or_create_slot(created.key) + for field in ("agent", "agent_kind", "workspace", "memory_store", "project"): + setattr(worker, field, getattr(created, field)) + seen = {} + claim = turn_state.sessions.get_or_create + + async def _claim(*args, **kwargs): + seen["cwd"] = kwargs.get("cwd") + return await claim(*args, **kwargs) + + turn_state.sessions.get_or_create = _claim + await _finished( + chat_runner._run_chat(turn_state, worker, f"Fix the failing tests in {repo}."), + "worker turn", + ) + + cfg = KiroCrewConfig.load() + ws = str(workspace_dir_for(cfg.agents["pixel"].workspace)) + expected = cw.path_for(ws, member_slug("pixel", cfg), repo) + assert seen.get("cwd") == expected, f"worker ran in {seen.get('cwd')}, not its worktree" + assert worker.project == expected and worker.project != shared + assert _git(expected, "branch", "--show-current").startswith("crew/") + assert _git(repo, "branch", "--show-current") == "main" + + +@pytest.mark.asyncio +async def test_removing_the_crewmate_removes_its_worktrees_and_keeps_dirty_ones( + repo, tmp_path, monkeypatch +): + from test_agent_delete_prunes_private_copy import _delete_request + + from kiro_crew.config.loader import KiroCrewAgentConfig, KiroCrewConfig, WorkspaceConfig + from kiro_crew.dashboard.handlers.agents import api_kirocrew_agent_delete + + monkeypatch.setattr( + "kiro_crew.dashboard.handlers.source_providers.is_owner_dashboard_request", + lambda request: True, + ) + clean_ws, dirty_ws = tmp_path / "ws-clean", tmp_path / "ws-dirty" + cfg = KiroCrewConfig() + cfg.workspaces = { + "m-clean": WorkspaceConfig(dir=str(clean_ws)), + "m-dirty": WorkspaceConfig(dir=str(dirty_ws)), + } + cfg.agents = { + "kirocrew": KiroCrewAgentConfig(kiro_agent="kirocrew"), + "Atlas": KiroCrewAgentConfig(kiro_agent="kirocrew", workspace="m-clean"), + "Briar": KiroCrewAgentConfig(kiro_agent="kirocrew", workspace="m-dirty"), + } + cfg.default_agent = "kirocrew" + await asyncio.to_thread(cfg.save) + clean = await asyncio.to_thread(cw.ensure, "atlas", str(clean_ws), repo) + dirty = await asyncio.to_thread(cw.ensure, "briar", str(dirty_ws), repo) + with open(os.path.join(dirty.path, "README.md"), "a", encoding="utf-8") as fh: + fh.write("briar's unsaved edit\n") + with open(os.path.join(repo, "user-notes.txt"), "w", encoding="utf-8") as fh: + fh.write("the user's own work\n") + + for name in ("Atlas", "Briar"): + resp = await api_kirocrew_agent_delete(_delete_request(name)) + assert resp.status == 200 + + assert not os.path.exists(clean.path) + assert clean.path not in _git(repo, "worktree", "list") + assert os.path.exists(os.path.join(dirty.path, "README.md")), "uncommitted work was deleted" + # Branches stay, and the shared checkout keeps the user's uncommitted file. + assert _git(repo, "branch", "--list", "crew/atlas/tasklog") + assert open(os.path.join(repo, "user-notes.txt"), encoding="utf-8").read()