mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-21 10:22:18 +00:00
fix(kanban): materialize and reuse linked worktrees for worktree tasks
The dispatcher treated workspace_kind=worktree as metadata only and never ran 'git worktree add', so every worktree task ran in the main repo checkout instead of an isolated worktree — concurrent tasks silently shared one tree and contaminated each other. This materializes a real linked worktree at <repo>/.worktrees/<task_id> on branch wt/<task_id> when resolve_workspace() handles a worktree task, treats a repo-root workspace_path as shorthand for that location, persists the derived workspace/branch back onto the task row, and — on rerun/redispatch — detects an already-materialized linked worktree (via git-common-dir) and reuses it instead of nesting a second .worktrees/<id> inside it.
This commit is contained in:
parent
37fa3c58b4
commit
d79f67fda6
2 changed files with 405 additions and 19 deletions
|
|
@ -4702,6 +4702,196 @@ def delete_task(conn: sqlite3.Connection, task_id: str) -> bool:
|
|||
# Workspace resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _git_toplevel(path: Path) -> Optional[Path]:
|
||||
"""Return the git toplevel containing ``path``, or ``None`` if not in a repo."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(path), "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
out = (result.stdout or "").strip()
|
||||
if not out:
|
||||
return None
|
||||
try:
|
||||
return Path(out).expanduser().resolve()
|
||||
except Exception:
|
||||
return Path(out).expanduser()
|
||||
|
||||
|
||||
def _git_branch_exists(repo_root: Path, branch_name: str) -> bool:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repo_root), "show-ref", "--verify", f"refs/heads/{branch_name}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def _git_common_dir(path: Path) -> Optional[Path]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(path), "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
out = (result.stdout or "").strip()
|
||||
if not out:
|
||||
return None
|
||||
return Path(out).expanduser().resolve(strict=False)
|
||||
|
||||
|
||||
def _git_dir(path: Path) -> Optional[Path]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(path), "rev-parse", "--path-format=absolute", "--git-dir"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
out = (result.stdout or "").strip()
|
||||
if not out:
|
||||
return None
|
||||
return Path(out).expanduser().resolve(strict=False)
|
||||
|
||||
|
||||
def _git_current_branch(path: Path) -> Optional[str]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(path), "branch", "--show-current"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
branch = (result.stdout or "").strip()
|
||||
return branch or None
|
||||
|
||||
|
||||
def _is_linked_worktree_checkout(path: Path) -> bool:
|
||||
git_dir = _git_dir(path)
|
||||
common_dir = _git_common_dir(path)
|
||||
if git_dir is None or common_dir is None:
|
||||
return False
|
||||
return git_dir != common_dir
|
||||
|
||||
|
||||
def _nearest_existing_path(path: Path) -> Path:
|
||||
current = path
|
||||
while not current.exists() and current != current.parent:
|
||||
current = current.parent
|
||||
return current
|
||||
|
||||
|
||||
def _repo_root_for_worktree_target(path: Path) -> Optional[Path]:
|
||||
current = _nearest_existing_path(path).resolve(strict=False)
|
||||
while True:
|
||||
repo_root = _git_toplevel(current)
|
||||
if repo_root is not None:
|
||||
return repo_root
|
||||
if current == current.parent:
|
||||
return None
|
||||
current = current.parent
|
||||
|
||||
|
||||
def _ensure_git_worktree(repo_root: Path, target: Path, branch_name: str) -> None:
|
||||
"""Materialize ``target`` as a linked git worktree under ``repo_root``."""
|
||||
target = target.expanduser()
|
||||
repo_common = _git_common_dir(repo_root)
|
||||
if target.exists() and repo_common is not None:
|
||||
target_common = _git_common_dir(target)
|
||||
if target_common == repo_common:
|
||||
return
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if _git_branch_exists(repo_root, branch_name):
|
||||
cmd = ["git", "-C", str(repo_root), "worktree", "add", str(target), branch_name]
|
||||
else:
|
||||
cmd = [
|
||||
"git", "-C", str(repo_root), "worktree", "add", "-b", branch_name,
|
||||
str(target), "HEAD",
|
||||
]
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = (result.stderr or result.stdout or "").strip()
|
||||
raise RuntimeError(
|
||||
f"git worktree add failed for {target} on branch {branch_name}: {stderr}"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_worktree_workspace(task: Task) -> tuple[Path, str]:
|
||||
"""Resolve + materialize a linked git worktree for ``task``."""
|
||||
branch_name = (task.branch_name or "").strip() or f"wt/{task.id}"
|
||||
if not task.workspace_path:
|
||||
repo_root = _git_toplevel(Path.cwd())
|
||||
if repo_root is None:
|
||||
raise ValueError(
|
||||
f"task {task.id} has workspace_kind=worktree but no workspace_path, "
|
||||
"and the dispatcher's current working directory is not inside a git repo"
|
||||
)
|
||||
target = repo_root / ".worktrees" / task.id
|
||||
_ensure_git_worktree(repo_root, target, branch_name)
|
||||
return target, branch_name
|
||||
|
||||
requested = Path(task.workspace_path).expanduser()
|
||||
if not requested.is_absolute():
|
||||
raise ValueError(
|
||||
f"task {task.id} has non-absolute worktree path "
|
||||
f"{task.workspace_path!r}; use an absolute path"
|
||||
)
|
||||
requested_resolved = requested.resolve(strict=False)
|
||||
|
||||
if requested.exists() and _is_linked_worktree_checkout(requested):
|
||||
actual_branch = _git_current_branch(requested)
|
||||
return requested_resolved, actual_branch or branch_name
|
||||
|
||||
repo_root = _git_toplevel(requested)
|
||||
if repo_root is not None and requested_resolved == repo_root:
|
||||
target = repo_root / ".worktrees" / task.id
|
||||
_ensure_git_worktree(repo_root, target, branch_name)
|
||||
return target, branch_name
|
||||
|
||||
repo_root = _repo_root_for_worktree_target(requested.parent)
|
||||
if repo_root is None:
|
||||
raise ValueError(
|
||||
f"task {task.id} worktree path {task.workspace_path!r} is not inside a git repo "
|
||||
"and does not point at a git repo root"
|
||||
)
|
||||
_ensure_git_worktree(repo_root, requested, branch_name)
|
||||
return requested, branch_name
|
||||
|
||||
|
||||
def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path:
|
||||
"""Resolve (and create if needed) the workspace for a task.
|
||||
|
||||
|
|
@ -4715,9 +4905,11 @@ def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path:
|
|||
resolves against the dispatcher's CWD instead of a meaningful
|
||||
root. Users who want a kanban-root-relative workspace should
|
||||
compute the absolute path themselves.
|
||||
- ``worktree``: a git worktree at ``workspace_path``. Not created
|
||||
automatically in v1 -- the kanban-worker skill documents
|
||||
``git worktree add`` as a worker-side step. Returns the intended path.
|
||||
- ``worktree``: a real linked git worktree. If ``workspace_path`` names
|
||||
a repo root, Hermes treats it as an anchor and materializes a linked
|
||||
worktree at ``<repo>/.worktrees/<task-id>``. If ``workspace_path`` names
|
||||
a concrete target path, Hermes creates/reuses that linked worktree. When
|
||||
``branch_name`` is empty, Hermes uses ``wt/<task-id>``.
|
||||
|
||||
Persist the resolved path back to the task row via ``set_workspace_path``
|
||||
so subsequent runs reuse the same directory.
|
||||
|
|
@ -4753,15 +4945,7 @@ def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path:
|
|||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
if kind == "worktree":
|
||||
if not task.workspace_path:
|
||||
# Default: .worktrees/<id>/ under CWD. Worker skill creates it.
|
||||
return Path.cwd() / ".worktrees" / task.id
|
||||
p = Path(task.workspace_path).expanduser()
|
||||
if not p.is_absolute():
|
||||
raise ValueError(
|
||||
f"task {task.id} has non-absolute worktree path "
|
||||
f"{task.workspace_path!r}; use an absolute path"
|
||||
)
|
||||
p, _branch_name = _resolve_worktree_workspace(task)
|
||||
return p
|
||||
raise ValueError(f"unknown workspace_kind: {kind}")
|
||||
|
||||
|
|
@ -4776,6 +4960,16 @@ def set_workspace_path(
|
|||
)
|
||||
|
||||
|
||||
def set_branch_name(
|
||||
conn: sqlite3.Connection, task_id: str, branch_name: str
|
||||
) -> None:
|
||||
with write_txn(conn):
|
||||
conn.execute(
|
||||
"UPDATE tasks SET branch_name = ? WHERE id = ?",
|
||||
(str(branch_name), task_id),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
def schedule_task(
|
||||
conn: sqlite3.Connection,
|
||||
|
|
@ -6373,7 +6567,11 @@ def dispatch_once(
|
|||
if claimed is None:
|
||||
continue
|
||||
try:
|
||||
workspace = resolve_workspace(claimed, board=board)
|
||||
resolved_branch_name = None
|
||||
if claimed.workspace_kind == "worktree":
|
||||
workspace, resolved_branch_name = _resolve_worktree_workspace(claimed)
|
||||
else:
|
||||
workspace = resolve_workspace(claimed, board=board)
|
||||
except Exception as exc:
|
||||
auto = _record_spawn_failure(
|
||||
conn, claimed.id, f"workspace: {exc}",
|
||||
|
|
@ -6384,6 +6582,8 @@ def dispatch_once(
|
|||
continue
|
||||
# Persist the resolved workspace path so the worker can cd there.
|
||||
set_workspace_path(conn, claimed.id, str(workspace))
|
||||
if claimed.workspace_kind == "worktree":
|
||||
set_branch_name(conn, claimed.id, resolved_branch_name or (claimed.branch_name or "").strip() or f"wt/{claimed.id}")
|
||||
_maybe_emit_scratch_tip(conn, claimed.id, claimed.workspace_kind)
|
||||
_spawn = spawn_fn if spawn_fn is not None else _default_spawn
|
||||
try:
|
||||
|
|
@ -6459,7 +6659,11 @@ def dispatch_once(
|
|||
if claimed is None:
|
||||
continue
|
||||
try:
|
||||
workspace = resolve_workspace(claimed, board=board)
|
||||
resolved_branch_name = None
|
||||
if claimed.workspace_kind == "worktree":
|
||||
workspace, resolved_branch_name = _resolve_worktree_workspace(claimed)
|
||||
else:
|
||||
workspace = resolve_workspace(claimed, board=board)
|
||||
except Exception as exc:
|
||||
auto = _record_spawn_failure(
|
||||
conn, claimed.id, f"workspace: {exc}",
|
||||
|
|
@ -6470,6 +6674,8 @@ def dispatch_once(
|
|||
continue
|
||||
# Persist the resolved workspace path so the worker can cd there.
|
||||
set_workspace_path(conn, claimed.id, str(workspace))
|
||||
if claimed.workspace_kind == "worktree":
|
||||
set_branch_name(conn, claimed.id, resolved_branch_name or (claimed.branch_name or "").strip() or f"wt/{claimed.id}")
|
||||
_maybe_emit_scratch_tip(conn, claimed.id, claimed.workspace_kind)
|
||||
# Force-load sdlc-review skill for review agents. The
|
||||
# _default_spawn function already auto-loads kanban-worker, and
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||
import concurrent.futures
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import types
|
||||
|
|
@ -27,6 +28,16 @@ def kanban_home(tmp_path, monkeypatch):
|
|||
return home
|
||||
|
||||
|
||||
def _init_git_repo(repo: Path) -> None:
|
||||
repo.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(["git", "init", "-b", "main", str(repo)], check=True, capture_output=True, text=True)
|
||||
subprocess.run(["git", "-C", str(repo), "config", "user.email", "kanban@example.com"], check=True, capture_output=True, text=True)
|
||||
subprocess.run(["git", "-C", str(repo), "config", "user.name", "Kanban Test"], check=True, capture_output=True, text=True)
|
||||
(repo / "README.md").write_text("hello\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(repo), "add", "README.md"], check=True, capture_output=True, text=True)
|
||||
subprocess.run(["git", "-C", str(repo), "commit", "-m", "init"], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema / init
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -2064,6 +2075,7 @@ def test_scratch_workspace_created_under_hermes_home(kanban_home):
|
|||
with kb.connect() as conn:
|
||||
t = kb.create_task(conn, title="x")
|
||||
task = kb.get_task(conn, t)
|
||||
assert task is not None
|
||||
ws = kb.resolve_workspace(task)
|
||||
assert ws.exists()
|
||||
assert ws.is_dir()
|
||||
|
|
@ -2077,21 +2089,188 @@ def test_dir_workspace_honors_given_path(kanban_home, tmp_path):
|
|||
conn, title="biz", workspace_kind="dir", workspace_path=str(target)
|
||||
)
|
||||
task = kb.get_task(conn, t)
|
||||
assert task is not None
|
||||
ws = kb.resolve_workspace(task)
|
||||
assert ws == target
|
||||
assert ws.exists()
|
||||
|
||||
|
||||
def test_worktree_workspace_returns_intended_path(kanban_home, tmp_path):
|
||||
target = str(tmp_path / ".worktrees" / "my-task")
|
||||
def test_worktree_workspace_repo_root_anchor_materializes_linked_worktree(kanban_home, tmp_path):
|
||||
repo = tmp_path / "repo"
|
||||
_init_git_repo(repo)
|
||||
with kb.connect() as conn:
|
||||
t = kb.create_task(
|
||||
conn, title="ship", workspace_kind="worktree", workspace_path=target
|
||||
conn, title="ship", workspace_kind="worktree", workspace_path=str(repo)
|
||||
)
|
||||
task = kb.get_task(conn, t)
|
||||
assert task is not None
|
||||
ws = kb.resolve_workspace(task)
|
||||
# We do NOT auto-create worktrees; the worker's skill handles that.
|
||||
assert str(ws) == target
|
||||
|
||||
expected = repo / ".worktrees" / t
|
||||
assert ws == expected
|
||||
assert ws.exists()
|
||||
repo_common = subprocess.run(
|
||||
["git", "-C", str(repo), "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
ws_common = subprocess.run(
|
||||
["git", "-C", str(ws), "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
assert ws_common == repo_common
|
||||
listed = subprocess.run(
|
||||
["git", "-C", str(repo), "worktree", "list", "--porcelain"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
assert f"worktree {expected}" in listed
|
||||
assert f"branch refs/heads/wt/{t}" in listed
|
||||
|
||||
|
||||
def test_worktree_workspace_explicit_target_materializes_linked_worktree(kanban_home, tmp_path):
|
||||
repo = tmp_path / "repo"
|
||||
_init_git_repo(repo)
|
||||
target = repo / ".worktrees" / "custom-task"
|
||||
branch = "wt/custom-task"
|
||||
with kb.connect() as conn:
|
||||
t = kb.create_task(
|
||||
conn,
|
||||
title="ship",
|
||||
workspace_kind="worktree",
|
||||
workspace_path=str(target),
|
||||
branch_name=branch,
|
||||
)
|
||||
task = kb.get_task(conn, t)
|
||||
assert task is not None
|
||||
ws = kb.resolve_workspace(task)
|
||||
|
||||
assert ws == target
|
||||
assert ws.exists()
|
||||
repo_common = subprocess.run(
|
||||
["git", "-C", str(repo), "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
ws_common = subprocess.run(
|
||||
["git", "-C", str(ws), "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
assert ws_common == repo_common
|
||||
listed = subprocess.run(
|
||||
["git", "-C", str(repo), "worktree", "list", "--porcelain"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
assert f"worktree {target}" in listed
|
||||
assert f"branch refs/heads/{branch}" in listed
|
||||
|
||||
|
||||
def test_dispatch_worktree_task_persists_materialized_workspace_and_branch(kanban_home, tmp_path, monkeypatch):
|
||||
repo = tmp_path / "repo"
|
||||
_init_git_repo(repo)
|
||||
kb.create_board("worktree-board", default_workdir=str(repo))
|
||||
import hermes_cli.profiles as profiles
|
||||
monkeypatch.setattr(profiles, "profile_exists", lambda _name: True)
|
||||
spawns: list[tuple[str, str]] = []
|
||||
|
||||
def fake_spawn(task, workspace, board=None):
|
||||
spawns.append((task.id, workspace))
|
||||
return None
|
||||
|
||||
with kb.connect(board="worktree-board") as conn:
|
||||
tid = kb.create_task(
|
||||
conn,
|
||||
title="ship",
|
||||
assignee="sentinel",
|
||||
workspace_kind="worktree",
|
||||
board="worktree-board",
|
||||
)
|
||||
result = kb.dispatch_once(conn, spawn_fn=fake_spawn, board="worktree-board")
|
||||
task = kb.get_task(conn, tid)
|
||||
|
||||
expected = repo / ".worktrees" / tid
|
||||
assert result.spawned == [(tid, "sentinel", str(expected))]
|
||||
assert spawns == [(tid, str(expected))]
|
||||
assert task is not None
|
||||
assert task.workspace_path == str(expected)
|
||||
assert task.branch_name == f"wt/{tid}"
|
||||
listed = subprocess.run(
|
||||
["git", "-C", str(repo), "worktree", "list", "--porcelain"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
assert f"worktree {expected}" in listed
|
||||
assert f"branch refs/heads/wt/{tid}" in listed
|
||||
|
||||
|
||||
def test_dispatch_worktree_task_rerun_reuses_existing_linked_worktree_and_branch(kanban_home, tmp_path, monkeypatch):
|
||||
repo = tmp_path / "repo"
|
||||
_init_git_repo(repo)
|
||||
kb.create_board("worktree-rerun-board", default_workdir=str(repo))
|
||||
import hermes_cli.profiles as profiles
|
||||
monkeypatch.setattr(profiles, "profile_exists", lambda _name: True)
|
||||
spawns: list[tuple[str, str]] = []
|
||||
|
||||
def fake_spawn(task, workspace, board=None):
|
||||
spawns.append((task.id, workspace))
|
||||
return None
|
||||
|
||||
with kb.connect(board="worktree-rerun-board") as conn:
|
||||
tid = kb.create_task(
|
||||
conn,
|
||||
title="ship",
|
||||
assignee="sentinel",
|
||||
workspace_kind="worktree",
|
||||
board="worktree-rerun-board",
|
||||
)
|
||||
first = kb.dispatch_once(conn, spawn_fn=fake_spawn, board="worktree-rerun-board")
|
||||
first_task = kb.get_task(conn, tid)
|
||||
assert first_task is not None
|
||||
expected = repo / ".worktrees" / tid
|
||||
assert first_task.workspace_path == str(expected)
|
||||
assert first_task.branch_name == f"wt/{tid}"
|
||||
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status='ready', claim_lock=NULL, claim_expires=NULL, worker_pid=NULL WHERE id=?",
|
||||
(tid,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
second = kb.dispatch_once(conn, spawn_fn=fake_spawn, board="worktree-rerun-board")
|
||||
second_task = kb.get_task(conn, tid)
|
||||
|
||||
assert first.spawned == [(tid, "sentinel", str(expected))]
|
||||
assert second.spawned == [(tid, "sentinel", str(expected))]
|
||||
assert spawns == [(tid, str(expected)), (tid, str(expected))]
|
||||
assert second_task is not None
|
||||
assert second_task.workspace_path == str(expected)
|
||||
actual_branch = subprocess.run(
|
||||
["git", "-C", str(expected), "branch", "--show-current"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
assert actual_branch == f"wt/{tid}"
|
||||
assert second_task.branch_name == actual_branch
|
||||
listed = subprocess.run(
|
||||
["git", "-C", str(repo), "worktree", "list", "--porcelain"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
assert listed.count(f"worktree {expected}\n") == 1
|
||||
assert f"worktree {expected}/.worktrees/{tid}" not in listed
|
||||
assert f"branch refs/heads/{actual_branch}" in listed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -2103,6 +2282,7 @@ def test_cleanup_workspace_removes_managed_scratch_dir(kanban_home):
|
|||
with kb.connect() as conn:
|
||||
t = kb.create_task(conn, title="scratchy")
|
||||
task = kb.get_task(conn, t)
|
||||
assert task is not None
|
||||
ws = kb.resolve_workspace(task)
|
||||
kb.set_workspace_path(conn, t, ws)
|
||||
assert ws.is_dir()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue