okstra 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/kr/architecture.md +1 -1
- package/docs/kr/cli.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +11 -8
- package/runtime/prompts/launch.template.md +12 -28
- package/runtime/prompts/profiles/implementation.md +8 -8
- package/runtime/prompts/profiles/release-handoff.md +22 -22
- package/runtime/python/okstra_ctl/render.py +48 -0
- package/runtime/python/okstra_ctl/run.py +19 -11
- package/runtime/python/okstra_ctl/workflow.py +3 -3
- package/runtime/python/okstra_ctl/worktree.py +212 -90
- package/runtime/python/okstra_ctl/worktree_registry.py +211 -0
- package/runtime/python/okstra_project/resolver.py +9 -6
- package/runtime/skills/okstra-report-writer/SKILL.md +3 -1
- package/runtime/skills/okstra-run/SKILL.md +52 -8
- package/runtime/skills/okstra-setup/SKILL.md +26 -0
|
@@ -1,48 +1,73 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Per-task git worktree provisioning.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
The
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
Every okstra task — regardless of task-type/phase — runs inside an
|
|
4
|
+
isolated git worktree rooted at
|
|
5
|
+
`~/.okstra/worktrees/<project_id>/<task_group>/<task_id>/`. The same
|
|
6
|
+
worktree is reused across all phases of one task-key (requirements-
|
|
7
|
+
discovery → error-analysis → implementation-planning → implementation),
|
|
8
|
+
so phase N picks up exactly the working-tree state phase N-1 left
|
|
9
|
+
behind.
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
worktree (the run reuses the caller's working tree).
|
|
13
|
-
- Refuse to clobber an existing path or branch — raise PrepareError.
|
|
11
|
+
A global registry (`worktree_registry.py`) maps task-keys to the
|
|
12
|
+
on-disk path + branch and serialises reservations, so two concurrent
|
|
13
|
+
okstra runs cannot collide on the same path or branch name.
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
Pre-conditions handled here:
|
|
16
|
+
- Skip when `project_root` is not a git repo (degrade gracefully).
|
|
17
|
+
- Skip when `project_root` itself is already a non-main worktree
|
|
18
|
+
(caller's tree is already an isolated workspace; reuse it).
|
|
19
|
+
- Re-entry of the same task-key returns the existing worktree.
|
|
20
|
+
- Branch / path collisions across task-keys raise PrepareError-like
|
|
21
|
+
RuntimeError.
|
|
22
|
+
|
|
23
|
+
Side effects:
|
|
24
|
+
- `git worktree add -b <branch> <path> <base_ref>` invoked in the
|
|
25
|
+
main worktree of `project_root` (NOT `project_root` itself when it
|
|
26
|
+
is also a worktree — base must be the main checkout).
|
|
27
|
+
- Per-task sync dirs (.project-docs, .scratch, graphify-out by
|
|
28
|
+
default) symlinked from the **main worktree** into the new
|
|
29
|
+
worktree so every task sees the same shared state, irrespective of
|
|
30
|
+
which worktree the caller invoked okstra from.
|
|
31
|
+
- The function does NOT chdir.
|
|
17
32
|
"""
|
|
18
33
|
from __future__ import annotations
|
|
19
34
|
|
|
35
|
+
import json
|
|
20
36
|
import os
|
|
21
37
|
import subprocess
|
|
22
38
|
from dataclasses import dataclass
|
|
23
39
|
from pathlib import Path
|
|
24
40
|
from typing import Optional
|
|
25
41
|
|
|
42
|
+
from .ids import _safe_fs_segment
|
|
43
|
+
from . import worktree_registry
|
|
44
|
+
|
|
26
45
|
|
|
27
46
|
OKSTRA_WORKTREES_RELATIVE = Path(".okstra/worktrees")
|
|
28
47
|
|
|
29
48
|
|
|
30
49
|
# Project-root directories that hold okstra task state, ignored by git, or
|
|
31
50
|
# otherwise required for the executor to operate but NOT carried across by
|
|
32
|
-
# `git worktree add`. Each is symlinked from
|
|
33
|
-
# worktree at provision time. Symlinks (not copies) so
|
|
34
|
-
#
|
|
35
|
-
# any write through the link reaches the
|
|
36
|
-
# acceptable because
|
|
51
|
+
# `git worktree add`. Each is symlinked from the MAIN worktree into the new
|
|
52
|
+
# worktree at provision time. Symlinks (not copies) so every task sees the
|
|
53
|
+
# live shared state and disk/CPU cost stays near zero; the trade-off is
|
|
54
|
+
# that any write through the link reaches the main worktree, which is
|
|
55
|
+
# acceptable because okstra only writes inside its own task-scoped
|
|
37
56
|
# subdirectory (e.g. `.project-docs/okstra/tasks/<task-id>/runs/...`).
|
|
38
57
|
#
|
|
39
|
-
# Override
|
|
40
|
-
#
|
|
41
|
-
#
|
|
58
|
+
# Override precedence (most-specific first):
|
|
59
|
+
# 1. `OKSTRA_WORKTREE_SYNC_DIRS` env var — colon-separated list, REPLACES
|
|
60
|
+
# defaults. Empty string disables the feature entirely. One-off
|
|
61
|
+
# operator override.
|
|
62
|
+
# 2. `worktreeSyncDirs` array in `.project-docs/okstra/project.json` —
|
|
63
|
+
# project-level config, persists across runs. Same semantics: array
|
|
64
|
+
# REPLACES defaults, empty array disables.
|
|
65
|
+
# 3. The built-in `DEFAULT_WORKTREE_SYNC_DIRS` below.
|
|
42
66
|
DEFAULT_WORKTREE_SYNC_DIRS: tuple[str, ...] = (
|
|
43
67
|
".project-docs",
|
|
44
68
|
".scratch",
|
|
45
69
|
"graphify-out",
|
|
70
|
+
".claude",
|
|
46
71
|
)
|
|
47
72
|
|
|
48
73
|
|
|
@@ -62,32 +87,45 @@ _WORK_CATEGORY_PREFIX = {
|
|
|
62
87
|
|
|
63
88
|
@dataclass
|
|
64
89
|
class WorktreeProvision:
|
|
65
|
-
"""Result of `
|
|
90
|
+
"""Result of `provision_task_worktree`.
|
|
66
91
|
|
|
67
92
|
status:
|
|
68
93
|
- "created": fresh worktree at `path` on `branch`
|
|
69
|
-
- "
|
|
70
|
-
|
|
94
|
+
- "reused": registry already had this task-key; same path/branch
|
|
95
|
+
returned and no new `git worktree add` was executed
|
|
96
|
+
- "skipped-in-worktree": project_root is itself a non-main
|
|
71
97
|
worktree; the run reuses `project_root` and no new worktree is
|
|
72
|
-
materialised
|
|
98
|
+
materialised (registry NOT updated — that caller is already
|
|
99
|
+
isolated by virtue of its own worktree)
|
|
73
100
|
- "skipped-not-git": project_root has no `.git` (worktree path
|
|
74
101
|
cannot be provisioned; degrade gracefully)
|
|
75
102
|
"""
|
|
76
103
|
status: str
|
|
77
|
-
path: str = "" # absolute path of the
|
|
104
|
+
path: str = "" # absolute path of the task worktree (or project_root when reused)
|
|
78
105
|
branch: str = "" # branch checked out in the worktree (empty when reused / not-git)
|
|
79
106
|
base_ref: str = "" # commit SHA the worktree was branched from (empty when not created)
|
|
80
107
|
note: str = "" # human-readable explanation, surfaced in team-state / manifests
|
|
81
108
|
|
|
82
109
|
|
|
110
|
+
def _safe_segment(value: str) -> str:
|
|
111
|
+
"""Sanitise a single path/branch segment.
|
|
112
|
+
|
|
113
|
+
Forbidden chars (`/`, `:`, spaces, anything outside `[a-z0-9-]`)
|
|
114
|
+
are collapsed to `-`. Empty result becomes `_` so we never create
|
|
115
|
+
an empty path component. Delegates to the canonical slugifier in
|
|
116
|
+
`ids.py` to stay in lock-step with run-id / manifest segmentation.
|
|
117
|
+
"""
|
|
118
|
+
return _safe_fs_segment(value)
|
|
119
|
+
|
|
120
|
+
|
|
83
121
|
def _work_category_prefix(work_category: str) -> str:
|
|
84
122
|
key = (work_category or "").strip().lower()
|
|
85
123
|
return _WORK_CATEGORY_PREFIX.get(key, "task")
|
|
86
124
|
|
|
87
125
|
|
|
88
|
-
def _git(
|
|
126
|
+
def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
|
89
127
|
return subprocess.run(
|
|
90
|
-
["git", "-C", str(
|
|
128
|
+
["git", "-C", str(cwd), *args],
|
|
91
129
|
capture_output=True, text=True, check=False,
|
|
92
130
|
)
|
|
93
131
|
|
|
@@ -101,7 +139,6 @@ def _is_inside_non_main_worktree(project_root: Path) -> bool:
|
|
|
101
139
|
per_tree = _git(project_root, "rev-parse", "--git-dir")
|
|
102
140
|
if common.returncode != 0 or per_tree.returncode != 0:
|
|
103
141
|
return False
|
|
104
|
-
# Both paths can be relative to project_root; resolve before compare.
|
|
105
142
|
common_abs = (project_root / common.stdout.strip()).resolve()
|
|
106
143
|
per_tree_abs = (project_root / per_tree.stdout.strip()).resolve()
|
|
107
144
|
return common_abs != per_tree_abs
|
|
@@ -117,32 +154,83 @@ def _branch_exists(project_root: Path, branch: str) -> bool:
|
|
|
117
154
|
return res.returncode == 0
|
|
118
155
|
|
|
119
156
|
|
|
120
|
-
def _head_sha(
|
|
121
|
-
res = _git(
|
|
157
|
+
def _head_sha(cwd: Path) -> str:
|
|
158
|
+
res = _git(cwd, "rev-parse", "HEAD")
|
|
122
159
|
if res.returncode != 0:
|
|
123
160
|
return ""
|
|
124
161
|
return res.stdout.strip()
|
|
125
162
|
|
|
126
163
|
|
|
127
|
-
def
|
|
128
|
-
"""
|
|
129
|
-
|
|
130
|
-
|
|
164
|
+
def _main_worktree_path(project_root: Path) -> Path:
|
|
165
|
+
"""Locate the repository's MAIN worktree (the original checkout).
|
|
166
|
+
|
|
167
|
+
`git worktree list --porcelain` lists worktrees in a stable order
|
|
168
|
+
where the first `worktree <path>` block is the main checkout.
|
|
169
|
+
Falls back to `project_root` if parsing fails — caller still gets
|
|
170
|
+
a working path, sync-dir links just point at the caller's tree
|
|
171
|
+
(the prior behaviour).
|
|
131
172
|
"""
|
|
132
|
-
|
|
133
|
-
if
|
|
134
|
-
return
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
return
|
|
173
|
+
res = _git(project_root, "worktree", "list", "--porcelain")
|
|
174
|
+
if res.returncode != 0:
|
|
175
|
+
return project_root
|
|
176
|
+
for line in res.stdout.splitlines():
|
|
177
|
+
if line.startswith("worktree "):
|
|
178
|
+
return Path(line[len("worktree "):].strip())
|
|
179
|
+
return project_root
|
|
180
|
+
|
|
139
181
|
|
|
182
|
+
def _read_project_json_sync_dirs(project_root: Path) -> Optional[tuple[str, ...]]:
|
|
183
|
+
"""Read `worktreeSyncDirs` from `.project-docs/okstra/project.json`.
|
|
140
184
|
|
|
141
|
-
|
|
142
|
-
|
|
185
|
+
Returns None if the field is absent or the file cannot be parsed (so
|
|
186
|
+
the caller falls back to defaults). Returns an empty tuple if the
|
|
187
|
+
field is explicitly an empty array (caller treats this as "disable").
|
|
188
|
+
A non-list value is treated as missing — we do not raise here because
|
|
189
|
+
sync-dir resolution must never block worktree provisioning.
|
|
190
|
+
"""
|
|
191
|
+
target = project_root / ".project-docs" / "okstra" / "project.json"
|
|
192
|
+
if not target.is_file():
|
|
193
|
+
return None
|
|
194
|
+
try:
|
|
195
|
+
data = json.loads(target.read_text(encoding="utf-8"))
|
|
196
|
+
except (OSError, json.JSONDecodeError):
|
|
197
|
+
return None
|
|
198
|
+
if not isinstance(data, dict):
|
|
199
|
+
return None
|
|
200
|
+
value = data.get("worktreeSyncDirs")
|
|
201
|
+
if not isinstance(value, list):
|
|
202
|
+
return None
|
|
203
|
+
cleaned = tuple(
|
|
204
|
+
item.strip() for item in value
|
|
205
|
+
if isinstance(item, str) and item.strip()
|
|
206
|
+
)
|
|
207
|
+
return cleaned
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _resolve_sync_dirs(project_root: Optional[Path] = None) -> tuple[str, ...]:
|
|
211
|
+
"""Return the list of project-root-relative dirs to symlink into the
|
|
212
|
+
new worktree. Precedence: env var → project.json → built-in default.
|
|
213
|
+
See the comment above `DEFAULT_WORKTREE_SYNC_DIRS` for full semantics.
|
|
214
|
+
"""
|
|
215
|
+
raw = os.environ.get("OKSTRA_WORKTREE_SYNC_DIRS")
|
|
216
|
+
if raw is not None:
|
|
217
|
+
raw = raw.strip()
|
|
218
|
+
if not raw:
|
|
219
|
+
return ()
|
|
220
|
+
return tuple(part for part in (p.strip() for p in raw.split(":")) if part)
|
|
221
|
+
if project_root is not None:
|
|
222
|
+
from_project = _read_project_json_sync_dirs(project_root)
|
|
223
|
+
if from_project is not None:
|
|
224
|
+
return from_project
|
|
225
|
+
return DEFAULT_WORKTREE_SYNC_DIRS
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _link_sync_dirs(source_root: Path, worktree_path: Path) -> list[str]:
|
|
229
|
+
"""Symlink each configured dir from `source_root` (the MAIN
|
|
230
|
+
worktree) into the new worktree.
|
|
143
231
|
|
|
144
232
|
Skip rules:
|
|
145
|
-
- Source missing in
|
|
233
|
+
- Source missing in `source_root` → silently skipped.
|
|
146
234
|
- Target path already exists in worktree (e.g. tracked content
|
|
147
235
|
checked out by `git worktree add`) → skipped to avoid clobbering
|
|
148
236
|
version-controlled files.
|
|
@@ -152,8 +240,8 @@ def _link_sync_dirs(project_root: Path, worktree_path: Path) -> list[str]:
|
|
|
152
240
|
caller can include them in the provisioning note.
|
|
153
241
|
"""
|
|
154
242
|
notes: list[str] = []
|
|
155
|
-
for rel in _resolve_sync_dirs():
|
|
156
|
-
src = (
|
|
243
|
+
for rel in _resolve_sync_dirs(source_root):
|
|
244
|
+
src = (source_root / rel).resolve()
|
|
157
245
|
if not src.exists():
|
|
158
246
|
continue
|
|
159
247
|
dst = worktree_path / rel
|
|
@@ -170,17 +258,20 @@ def compute_worktree_path(
|
|
|
170
258
|
project_id: str,
|
|
171
259
|
task_group_segment: str,
|
|
172
260
|
task_id_segment: str,
|
|
173
|
-
run_seq: int,
|
|
174
261
|
) -> Path:
|
|
175
|
-
"""Pure path computation.
|
|
262
|
+
"""Pure path computation. One worktree dir per task-key.
|
|
176
263
|
|
|
177
|
-
Uses `OKSTRA_HOME` when set (test hook), else `~/.okstra`.
|
|
264
|
+
Uses `OKSTRA_HOME` when set (test hook), else `~/.okstra`. Note
|
|
265
|
+
there is NO run-seq segment — every phase of the same task-key
|
|
266
|
+
shares this dir.
|
|
178
267
|
"""
|
|
179
268
|
okstra_home = os.environ.get("OKSTRA_HOME", "").strip()
|
|
180
269
|
base = Path(okstra_home) if okstra_home else (Path.home() / ".okstra")
|
|
181
270
|
return (
|
|
182
|
-
base / "worktrees"
|
|
183
|
-
/
|
|
271
|
+
base / "worktrees"
|
|
272
|
+
/ _safe_segment(project_id)
|
|
273
|
+
/ _safe_segment(task_group_segment)
|
|
274
|
+
/ _safe_segment(task_id_segment)
|
|
184
275
|
)
|
|
185
276
|
|
|
186
277
|
|
|
@@ -188,46 +279,40 @@ def compute_branch_name(
|
|
|
188
279
|
*,
|
|
189
280
|
work_category: str,
|
|
190
281
|
task_id_segment: str,
|
|
191
|
-
run_seq: int,
|
|
192
282
|
) -> str:
|
|
193
|
-
|
|
283
|
+
"""One branch per task-key. No run-seq — phases share the branch."""
|
|
284
|
+
return f"{_work_category_prefix(work_category)}-{_safe_segment(task_id_segment)}"
|
|
194
285
|
|
|
195
286
|
|
|
196
|
-
def
|
|
287
|
+
def provision_task_worktree(
|
|
197
288
|
*,
|
|
198
289
|
task_type: str,
|
|
199
290
|
project_root: Path,
|
|
200
291
|
project_id: str,
|
|
201
292
|
task_group_segment: str,
|
|
202
293
|
task_id_segment: str,
|
|
203
|
-
run_seq: int,
|
|
204
294
|
work_category: str,
|
|
205
295
|
) -> WorktreeProvision:
|
|
206
|
-
"""Materialise (or
|
|
296
|
+
"""Materialise (or reuse) the task worktree for this run.
|
|
207
297
|
|
|
208
|
-
|
|
209
|
-
|
|
298
|
+
First phase of a task-key creates the worktree on a new branch.
|
|
299
|
+
Subsequent phases of the same task-key look up the registry and
|
|
300
|
+
return the existing path + branch unchanged.
|
|
210
301
|
|
|
211
302
|
Raises:
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
303
|
+
RuntimeError when worktree creation fails (path clash on disk
|
|
304
|
+
that the registry does not know about, branch clash with a
|
|
305
|
+
different task-key, `git worktree add` non-zero). The caller
|
|
306
|
+
(`run.py`) catches and re-raises as PrepareError to keep a
|
|
307
|
+
single error surface.
|
|
216
308
|
"""
|
|
217
|
-
if task_type != "implementation":
|
|
218
|
-
return WorktreeProvision(
|
|
219
|
-
status="skipped-non-implementation",
|
|
220
|
-
path=str(project_root),
|
|
221
|
-
note="worktree provisioning skipped: task-type is not 'implementation'",
|
|
222
|
-
)
|
|
223
|
-
|
|
224
309
|
if not _is_git_repo(project_root):
|
|
225
310
|
return WorktreeProvision(
|
|
226
311
|
status="skipped-not-git",
|
|
227
312
|
path=str(project_root),
|
|
228
313
|
note=(
|
|
229
314
|
"worktree provisioning skipped: project_root is not inside a git "
|
|
230
|
-
"repository;
|
|
315
|
+
"repository; task will operate directly on project_root"
|
|
231
316
|
),
|
|
232
317
|
)
|
|
233
318
|
|
|
@@ -237,44 +322,62 @@ def provision_implementation_worktree(
|
|
|
237
322
|
path=str(project_root),
|
|
238
323
|
note=(
|
|
239
324
|
"worktree provisioning skipped: project_root is already inside a "
|
|
240
|
-
"non-main git worktree;
|
|
325
|
+
"non-main git worktree; task reuses the caller's worktree"
|
|
326
|
+
),
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
safe_project = _safe_segment(project_id)
|
|
330
|
+
safe_group = _safe_segment(task_group_segment)
|
|
331
|
+
safe_task = _safe_segment(task_id_segment)
|
|
332
|
+
|
|
333
|
+
# Registry lookup first — same task-key across phases must reuse.
|
|
334
|
+
existing = worktree_registry.lookup(safe_project, safe_group, safe_task)
|
|
335
|
+
if existing is not None and existing.status == "active":
|
|
336
|
+
worktree_registry.touch_phase(safe_project, safe_group, safe_task, task_type)
|
|
337
|
+
return WorktreeProvision(
|
|
338
|
+
status="reused",
|
|
339
|
+
path=existing.worktree_path,
|
|
340
|
+
branch=existing.branch,
|
|
341
|
+
base_ref=existing.base_ref,
|
|
342
|
+
note=(
|
|
343
|
+
f"task worktree reused at {existing.worktree_path} on branch "
|
|
344
|
+
f"{existing.branch} (base {existing.base_ref[:12]}); phase {task_type}"
|
|
241
345
|
),
|
|
242
346
|
)
|
|
243
347
|
|
|
244
348
|
worktree_path = compute_worktree_path(
|
|
245
|
-
project_id=
|
|
246
|
-
task_group_segment=
|
|
247
|
-
task_id_segment=
|
|
248
|
-
run_seq=run_seq,
|
|
349
|
+
project_id=safe_project,
|
|
350
|
+
task_group_segment=safe_group,
|
|
351
|
+
task_id_segment=safe_task,
|
|
249
352
|
)
|
|
250
353
|
branch = compute_branch_name(
|
|
251
354
|
work_category=work_category,
|
|
252
|
-
task_id_segment=
|
|
253
|
-
run_seq=run_seq,
|
|
355
|
+
task_id_segment=safe_task,
|
|
254
356
|
)
|
|
255
357
|
|
|
256
358
|
if worktree_path.exists():
|
|
257
359
|
raise RuntimeError(
|
|
258
|
-
f"
|
|
259
|
-
"Remove it with `git worktree remove <path>`
|
|
260
|
-
"not a registered worktree) before retrying
|
|
360
|
+
f"task worktree path already exists but is not in the registry: "
|
|
361
|
+
f"{worktree_path}. Remove it with `git worktree remove <path>` "
|
|
362
|
+
"(or `rm -rf` if it is not a registered worktree) before retrying."
|
|
261
363
|
)
|
|
262
364
|
if _branch_exists(project_root, branch):
|
|
263
365
|
raise RuntimeError(
|
|
264
|
-
f"
|
|
265
|
-
"Delete it (`git branch -D <branch>`) or
|
|
266
|
-
"before retrying."
|
|
366
|
+
f"task worktree branch already exists: {branch}. "
|
|
367
|
+
"Delete it (`git branch -D <branch>`) or choose a different "
|
|
368
|
+
"work-category before retrying."
|
|
267
369
|
)
|
|
268
370
|
|
|
269
|
-
|
|
371
|
+
main_root = _main_worktree_path(project_root)
|
|
372
|
+
base_ref = _head_sha(main_root)
|
|
270
373
|
if not base_ref:
|
|
271
374
|
raise RuntimeError(
|
|
272
|
-
"could not resolve HEAD sha in
|
|
375
|
+
"could not resolve HEAD sha in main worktree; cannot create task worktree"
|
|
273
376
|
)
|
|
274
377
|
|
|
275
378
|
worktree_path.parent.mkdir(parents=True, exist_ok=True)
|
|
276
379
|
res = _git(
|
|
277
|
-
|
|
380
|
+
main_root,
|
|
278
381
|
"worktree", "add", "-b", branch, str(worktree_path), base_ref,
|
|
279
382
|
)
|
|
280
383
|
if res.returncode != 0:
|
|
@@ -283,16 +386,35 @@ def provision_implementation_worktree(
|
|
|
283
386
|
f"{(res.stderr or res.stdout).strip()}"
|
|
284
387
|
)
|
|
285
388
|
|
|
286
|
-
|
|
389
|
+
# Sync dirs sourced from the MAIN worktree so every task sees the
|
|
390
|
+
# same shared state regardless of which checkout invoked okstra.
|
|
391
|
+
linked = _link_sync_dirs(main_root, worktree_path)
|
|
287
392
|
linked_suffix = f"; linked {', '.join(linked)}" if linked else ""
|
|
288
393
|
|
|
394
|
+
try:
|
|
395
|
+
worktree_registry.reserve(
|
|
396
|
+
project_id=safe_project,
|
|
397
|
+
task_group=safe_group,
|
|
398
|
+
task_id=safe_task,
|
|
399
|
+
worktree_path=str(worktree_path),
|
|
400
|
+
branch=branch,
|
|
401
|
+
base_ref=base_ref,
|
|
402
|
+
phase=task_type,
|
|
403
|
+
)
|
|
404
|
+
except RuntimeError:
|
|
405
|
+
# Roll back the on-disk worktree so the next attempt is not
|
|
406
|
+
# blocked by the lingering directory / branch.
|
|
407
|
+
_git(main_root, "worktree", "remove", "--force", str(worktree_path))
|
|
408
|
+
_git(main_root, "branch", "-D", branch)
|
|
409
|
+
raise
|
|
410
|
+
|
|
289
411
|
return WorktreeProvision(
|
|
290
412
|
status="created",
|
|
291
413
|
path=str(worktree_path),
|
|
292
414
|
branch=branch,
|
|
293
415
|
base_ref=base_ref,
|
|
294
416
|
note=(
|
|
295
|
-
f"
|
|
296
|
-
f"(base {base_ref[:12]}){linked_suffix}"
|
|
417
|
+
f"task worktree created at {worktree_path} on branch {branch} "
|
|
418
|
+
f"(base {base_ref[:12]}; phase {task_type}){linked_suffix}"
|
|
297
419
|
),
|
|
298
420
|
)
|