okstra 0.135.0 → 0.137.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/docs/architecture/storage-model.md +1 -1
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/project-structure-overview.md +8 -3
- package/package.json +2 -3
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/interactive.sh +24 -67
- package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
- package/runtime/bin/okstra-antigravity-exec.sh +10 -8
- package/runtime/bin/okstra-claude-exec.sh +10 -8
- package/runtime/bin/okstra-codex-exec.sh +10 -8
- package/runtime/bin/okstra-spawn-followups.py +4 -9
- package/runtime/prompts/lead/plan-body-verification.md +2 -2
- package/runtime/python/okstra_ctl/backfill.py +2 -1
- package/runtime/python/okstra_ctl/codex_dispatch.py +56 -331
- package/runtime/python/okstra_ctl/consumers.py +7 -5
- package/runtime/python/okstra_ctl/context_cost.py +6 -5
- package/runtime/python/okstra_ctl/dispatch_core.py +54 -175
- package/runtime/python/okstra_ctl/dispatch_state.py +231 -0
- package/runtime/python/okstra_ctl/error_log_core.py +3 -1
- package/runtime/python/okstra_ctl/error_report.py +2 -7
- package/runtime/python/okstra_ctl/handoff.py +2 -1
- package/runtime/python/okstra_ctl/implementation_outcome.py +17 -27
- package/runtime/python/okstra_ctl/implementation_stage.py +7 -2
- package/runtime/python/okstra_ctl/path_hints.py +5 -3
- package/runtime/python/okstra_ctl/paths.py +278 -4
- package/runtime/python/okstra_ctl/plan_run_root.py +4 -4
- package/runtime/python/okstra_ctl/recap.py +5 -12
- package/runtime/python/okstra_ctl/render.py +9 -15
- package/runtime/python/okstra_ctl/report_finalize.py +8 -4
- package/runtime/python/okstra_ctl/report_language.py +83 -0
- package/runtime/python/okstra_ctl/run.py +222 -239
- package/runtime/python/okstra_ctl/set_work_status.py +2 -1
- package/runtime/python/okstra_ctl/stage_targets.py +98 -55
- package/runtime/python/okstra_ctl/time_report.py +4 -9
- package/runtime/python/okstra_ctl/wizard.py +50 -50
- package/runtime/python/okstra_ctl/work_categories.py +2 -2
- package/runtime/python/okstra_ctl/worker_prompt_body.py +82 -0
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +0 -13
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +7 -0
- package/runtime/python/okstra_project/__init__.py +4 -0
- package/runtime/python/okstra_project/dirs.py +7 -0
- package/runtime/python/okstra_project/state.py +78 -8
- package/runtime/validators/validate-run.py +23 -52
- package/src/commands/inspect/stage-map.mjs +12 -19
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Report-language resolution for the report-writer prompt.
|
|
2
|
+
|
|
3
|
+
Both dispatchers stamp `**Report Language:**` into the report-writer prompt, so
|
|
4
|
+
the precedence (project config → global config → inferred from the task brief)
|
|
5
|
+
lives here rather than in whichever dispatcher happened to need it first.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Mapping
|
|
13
|
+
|
|
14
|
+
from .worker_prompt_body import instruction_path
|
|
15
|
+
|
|
16
|
+
REPORT_LANGUAGE_VALUES = {"en", "ko", "auto"}
|
|
17
|
+
|
|
18
|
+
# Below this many Hangul syllables a brief reads as incidental Korean in an
|
|
19
|
+
# otherwise English document.
|
|
20
|
+
_HANGUL_THRESHOLD = 10
|
|
21
|
+
_BRIEF_SCAN_CHARS = 16000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ReportLanguageError(ValueError):
|
|
25
|
+
"""A config file declares a reportLanguage outside the allowed values."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def resolve_report_language(
|
|
29
|
+
project_root: Path,
|
|
30
|
+
manifest: Mapping[str, Any],
|
|
31
|
+
active_context: Mapping[str, Any],
|
|
32
|
+
) -> str:
|
|
33
|
+
for value in _report_language_candidates(project_root):
|
|
34
|
+
if value == "auto":
|
|
35
|
+
return _infer_report_language(project_root, manifest, active_context)
|
|
36
|
+
if value:
|
|
37
|
+
return value
|
|
38
|
+
return _infer_report_language(project_root, manifest, active_context)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _report_language_candidates(project_root: Path) -> list[str]:
|
|
42
|
+
okstra_home = Path(os.environ.get("OKSTRA_HOME") or Path.home() / ".okstra")
|
|
43
|
+
return [
|
|
44
|
+
_read_report_language(project_root / ".okstra" / "project.json", "project"),
|
|
45
|
+
_read_report_language(okstra_home / "config.json", "global"),
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _read_report_language(path: Path, label: str) -> str:
|
|
50
|
+
if not path.is_file():
|
|
51
|
+
return ""
|
|
52
|
+
try:
|
|
53
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
54
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
55
|
+
raise ReportLanguageError(f"{label} config is unreadable: {path}") from exc
|
|
56
|
+
if not isinstance(payload, Mapping):
|
|
57
|
+
raise ReportLanguageError(f"{label} config must be a JSON object: {path}")
|
|
58
|
+
value = payload.get("reportLanguage")
|
|
59
|
+
if value in (None, ""):
|
|
60
|
+
return ""
|
|
61
|
+
if isinstance(value, str) and value in REPORT_LANGUAGE_VALUES:
|
|
62
|
+
return value
|
|
63
|
+
raise ReportLanguageError(
|
|
64
|
+
f"{label} config reportLanguage must be en, ko, or auto: {path}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _infer_report_language(
|
|
69
|
+
project_root: Path,
|
|
70
|
+
manifest: Mapping[str, Any],
|
|
71
|
+
active_context: Mapping[str, Any],
|
|
72
|
+
) -> str:
|
|
73
|
+
task_brief = instruction_path(manifest, active_context, "taskBriefPath")
|
|
74
|
+
if not task_brief:
|
|
75
|
+
return "en"
|
|
76
|
+
path = Path(task_brief)
|
|
77
|
+
if not path.is_absolute():
|
|
78
|
+
path = project_root / path
|
|
79
|
+
if not path.is_file():
|
|
80
|
+
return "en"
|
|
81
|
+
text = path.read_text(encoding="utf-8", errors="replace")[:_BRIEF_SCAN_CHARS]
|
|
82
|
+
hangul = sum(1 for char in text if "가" <= char <= "힣")
|
|
83
|
+
return "ko" if hangul >= _HANGUL_THRESHOLD else "en"
|
|
@@ -444,85 +444,8 @@ def _stage_target_prepare_error(exc: _stage_targets.StageTargetError) -> Prepare
|
|
|
444
444
|
return PrepareError(str(exc))
|
|
445
445
|
|
|
446
446
|
|
|
447
|
-
def _resolve_effective_stages(
|
|
448
|
-
stages: list,
|
|
449
|
-
done_stages: set,
|
|
450
|
-
requested: str,
|
|
451
|
-
budget: int = RUN_STEP_BUDGET,
|
|
452
|
-
started_stages: set = None,
|
|
453
|
-
reserved_stages: set = None,
|
|
454
|
-
) -> list:
|
|
455
|
-
"""Compatibility wrapper for the public stage-target policy module."""
|
|
456
|
-
try:
|
|
457
|
-
return _stage_targets.resolve_effective_stages(
|
|
458
|
-
stages,
|
|
459
|
-
done_stages,
|
|
460
|
-
requested,
|
|
461
|
-
budget=budget,
|
|
462
|
-
started_stages=started_stages,
|
|
463
|
-
reserved_stages=reserved_stages,
|
|
464
|
-
)
|
|
465
|
-
except _stage_targets.StageTargetError as exc:
|
|
466
|
-
raise _stage_target_prepare_error(exc) from exc
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
def _check_multi_dep_merged(project_root, plan_run_root, latest,
|
|
470
|
-
pred_commits: dict, candidate_base: str,
|
|
471
|
-
stage_n: int) -> None:
|
|
472
|
-
"""Compatibility wrapper for the stage-target dependency gate."""
|
|
473
|
-
try:
|
|
474
|
-
_stage_targets.check_multi_dep_merged(
|
|
475
|
-
project_root,
|
|
476
|
-
plan_run_root,
|
|
477
|
-
latest,
|
|
478
|
-
pred_commits,
|
|
479
|
-
candidate_base,
|
|
480
|
-
stage_n,
|
|
481
|
-
)
|
|
482
|
-
except _stage_targets.StageTargetError as exc:
|
|
483
|
-
raise _stage_target_prepare_error(exc) from exc
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
def _resolve_stage_base_commit(
|
|
487
|
-
stage: dict,
|
|
488
|
-
consumer_done_rows: list,
|
|
489
|
-
anchor_base_commit: str,
|
|
490
|
-
candidate_base: str = "",
|
|
491
|
-
project_root=None,
|
|
492
|
-
plan_run_root=None,
|
|
493
|
-
) -> str:
|
|
494
|
-
"""Compatibility wrapper for stage worktree base selection."""
|
|
495
|
-
try:
|
|
496
|
-
return _stage_targets.resolve_stage_base_commit(
|
|
497
|
-
stage,
|
|
498
|
-
consumer_done_rows,
|
|
499
|
-
anchor_base_commit,
|
|
500
|
-
candidate_base=candidate_base,
|
|
501
|
-
project_root=project_root,
|
|
502
|
-
plan_run_root=plan_run_root,
|
|
503
|
-
)
|
|
504
|
-
except _stage_targets.StageTargetError as exc:
|
|
505
|
-
raise _stage_target_prepare_error(exc) from exc
|
|
506
447
|
|
|
507
448
|
|
|
508
|
-
def _resolve_whole_task_target(
|
|
509
|
-
*, stage_map: list, done_rows: list, anchor_base: str,
|
|
510
|
-
task_worktree_path: str, task_head: str, task_dirty: bool,
|
|
511
|
-
merged: dict,
|
|
512
|
-
) -> "_FVTarget":
|
|
513
|
-
"""Compatibility wrapper for whole-task verification target selection."""
|
|
514
|
-
try:
|
|
515
|
-
return _stage_targets.resolve_whole_task_target(
|
|
516
|
-
stage_map=stage_map,
|
|
517
|
-
done_rows=done_rows,
|
|
518
|
-
anchor_base=anchor_base,
|
|
519
|
-
task_worktree_path=task_worktree_path,
|
|
520
|
-
task_head=task_head,
|
|
521
|
-
task_dirty=task_dirty,
|
|
522
|
-
merged=merged,
|
|
523
|
-
)
|
|
524
|
-
except _stage_targets.StageTargetError as exc:
|
|
525
|
-
raise _stage_target_prepare_error(exc) from exc
|
|
526
449
|
|
|
527
450
|
|
|
528
451
|
def _resolve_single_stage_target(
|
|
@@ -1513,10 +1436,6 @@ def _auto_reconcile_best_effort(inp: "PrepareInputs", plan_run_root: Path) -> No
|
|
|
1513
1436
|
_stage_auto_reconcile_best_effort(inp, plan_run_root)
|
|
1514
1437
|
|
|
1515
1438
|
|
|
1516
|
-
def _is_ancestor(cwd, commit, head) -> bool:
|
|
1517
|
-
from .worktree import is_ancestor
|
|
1518
|
-
return is_ancestor(cwd, commit, head)
|
|
1519
|
-
|
|
1520
1439
|
|
|
1521
1440
|
def _is_dirty_excluding_okstra(cwd) -> bool:
|
|
1522
1441
|
from .worktree import is_dirty_excluding_okstra
|
|
@@ -1969,6 +1888,215 @@ def _provision_settings_symlink(inp: PrepareInputs) -> None:
|
|
|
1969
1888
|
)
|
|
1970
1889
|
|
|
1971
1890
|
|
|
1891
|
+
@dataclass
|
|
1892
|
+
class _ProvisionedTask:
|
|
1893
|
+
"""What one task-key's provisioning critical section hands to run-path compute."""
|
|
1894
|
+
|
|
1895
|
+
worktree: WorktreeProvision
|
|
1896
|
+
stage_run_claim: object | None
|
|
1897
|
+
stage_arg: int | None
|
|
1898
|
+
|
|
1899
|
+
|
|
1900
|
+
def _write_bundle_artifacts(
|
|
1901
|
+
inp: PrepareInputs,
|
|
1902
|
+
ctx: dict,
|
|
1903
|
+
project_root: Path,
|
|
1904
|
+
lead_runtime: str,
|
|
1905
|
+
claude_session_id: str,
|
|
1906
|
+
) -> None:
|
|
1907
|
+
"""Create the task/run directories and the artifacts the lead reads at launch.
|
|
1908
|
+
|
|
1909
|
+
Order is contractual. The fix-cycle ledger runs LAST here but still *before*
|
|
1910
|
+
the instruction-set build in the caller, so a cycle opened by this run shows
|
|
1911
|
+
up in the analysis packet's Fix History; finalize rewrites the manifest after
|
|
1912
|
+
that, which keeps the lazy-close invariant that the on-disk manifest holds
|
|
1913
|
+
pre-rewrite values when the cycle is judged.
|
|
1914
|
+
"""
|
|
1915
|
+
_ensure_task_directories(ctx)
|
|
1916
|
+
migrate_legacy_run_artifacts(ctx)
|
|
1917
|
+
cleanup_obsolete_generated_docs(
|
|
1918
|
+
project_root=project_root, instruction_set_dir=Path(ctx["INSTRUCTION_SET_PATH"]),
|
|
1919
|
+
)
|
|
1920
|
+
if lead_runtime == "claude-code":
|
|
1921
|
+
# Always materialise the resume command script for Claude Code. Even in
|
|
1922
|
+
# --render-only preparation flows the user (or a later non-interactive
|
|
1923
|
+
# runner) may invoke it manually; deferring its creation until
|
|
1924
|
+
# interactive launch leaves runs/<phase>/sessions/ empty and the
|
|
1925
|
+
# manifest pointing at a path that does not exist. Codex runs resume via
|
|
1926
|
+
# artifacts/checkpoints instead, so they intentionally leave this empty.
|
|
1927
|
+
write_claude_resume_command_file(
|
|
1928
|
+
resume_command_path=Path(ctx["CLAUDE_RESUME_COMMAND_PATH"]),
|
|
1929
|
+
project_root=project_root,
|
|
1930
|
+
claude_session_id=claude_session_id,
|
|
1931
|
+
task_key=ctx["TASK_KEY"],
|
|
1932
|
+
task_type=ctx["TASK_TYPE"],
|
|
1933
|
+
phase_state=ctx["CURRENT_RUN_STATUS"],
|
|
1934
|
+
worker_prompts_dir_relative=ctx["RUN_PROMPTS_RELATIVE_PATH"],
|
|
1935
|
+
prompt_seq=ctx["RUN_PROMPTS_SEQ"],
|
|
1936
|
+
)
|
|
1937
|
+
ctx["FIX_CYCLE_ID"] = _record_fix_cycle_events(inp, ctx)
|
|
1938
|
+
|
|
1939
|
+
|
|
1940
|
+
def _record_run_in_central_index(
|
|
1941
|
+
inp: PrepareInputs,
|
|
1942
|
+
ctx: dict,
|
|
1943
|
+
workspace_root: Path,
|
|
1944
|
+
run_seq_override: int | None,
|
|
1945
|
+
) -> None:
|
|
1946
|
+
"""Publish this run to ~/.okstra/{recent,active}.jsonl. Never fatal.
|
|
1947
|
+
|
|
1948
|
+
A failure here leaves the central index incomplete but the bundle on disk is
|
|
1949
|
+
already usable, so prepare reports and continues. The rerun path pre-stamps a
|
|
1950
|
+
'reserving' row before spawning (OKSTRA_RUN_SEQ_OVERRIDE forces that seq); if
|
|
1951
|
+
the promotion to 'running' fails, that row would sit in active.jsonl forever
|
|
1952
|
+
and inflate activeCount, so it is cleaned up whenever the reserved seq is known.
|
|
1953
|
+
"""
|
|
1954
|
+
try:
|
|
1955
|
+
_record_start(
|
|
1956
|
+
workspace_root=workspace_root,
|
|
1957
|
+
ctx=ctx,
|
|
1958
|
+
initial_status="prepared" if inp.render_only else "running",
|
|
1959
|
+
canonical_argv=_canonical_argv(inp, ctx),
|
|
1960
|
+
cwd=os.getcwd(),
|
|
1961
|
+
brief_sha256=_brief_sha256(inp.brief_path),
|
|
1962
|
+
)
|
|
1963
|
+
except Exception as exc:
|
|
1964
|
+
print(
|
|
1965
|
+
f"okstra-central: record_start failed; central index will be incomplete ({exc})",
|
|
1966
|
+
file=sys.stderr,
|
|
1967
|
+
)
|
|
1968
|
+
if run_seq_override is not None:
|
|
1969
|
+
_remove_leaked_reservation(ctx, run_seq_override)
|
|
1970
|
+
|
|
1971
|
+
|
|
1972
|
+
def _initial_workflow_ctx(
|
|
1973
|
+
inp: PrepareInputs, forbidden_by_phase: dict
|
|
1974
|
+
) -> dict[str, str]:
|
|
1975
|
+
"""Workflow tokens for a run that has been prepared but not started.
|
|
1976
|
+
|
|
1977
|
+
The run status is not known yet, so both statuses are the pre-launch
|
|
1978
|
+
constants; `_finalize_status_and_render_manifests` recomputes the block once
|
|
1979
|
+
the real status is settled.
|
|
1980
|
+
"""
|
|
1981
|
+
task_status, run_status = "ready-for-claude", "not-run"
|
|
1982
|
+
return {
|
|
1983
|
+
"CURRENT_TASK_STATUS": task_status,
|
|
1984
|
+
"CURRENT_RUN_STATUS": run_status,
|
|
1985
|
+
**compute_workflow_state(
|
|
1986
|
+
task_type=inp.task_type,
|
|
1987
|
+
current_run_status=run_status,
|
|
1988
|
+
current_task_status=task_status,
|
|
1989
|
+
render_only=inp.render_only,
|
|
1990
|
+
forbidden_by_phase=forbidden_by_phase,
|
|
1991
|
+
),
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
|
|
1995
|
+
def _related_tasks_ctx(ctx: dict, inp: PrepareInputs) -> dict[str, str]:
|
|
1996
|
+
"""Render tokens for the tasks this run declares a relation to."""
|
|
1997
|
+
items = resolve_related_tasks(
|
|
1998
|
+
task_manifest_path=Path(ctx["TASK_MANIFEST_PATH"]),
|
|
1999
|
+
raw_related=inp.related_tasks_raw,
|
|
2000
|
+
)
|
|
2001
|
+
return {
|
|
2002
|
+
"RELATED_TASKS_JSON": json.dumps(items, ensure_ascii=False),
|
|
2003
|
+
"RELATED_TASKS_BULLETS": related_tasks_bullets(items),
|
|
2004
|
+
"RELATED_TASKS_INLINE": related_tasks_inline(items),
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
|
|
2008
|
+
def _model_ctx(models: "_ModelBindings") -> dict[str, str]:
|
|
2009
|
+
"""Render tokens for every model binding this run resolved."""
|
|
2010
|
+
return {
|
|
2011
|
+
"LEAD_MODEL": models.lead.display,
|
|
2012
|
+
"LEAD_MODEL_EXECUTION_VALUE": models.lead.execution,
|
|
2013
|
+
"CLAUDE_WORKER_MODEL": models.cw.display,
|
|
2014
|
+
"CLAUDE_WORKER_MODEL_EXECUTION_VALUE": models.cw.execution,
|
|
2015
|
+
"CODEX_WORKER_MODEL": models.co.display,
|
|
2016
|
+
"CODEX_WORKER_MODEL_EXECUTION_VALUE": models.codex_worker_execution,
|
|
2017
|
+
"ANTIGRAVITY_WORKER_MODEL": models.ge.display,
|
|
2018
|
+
"ANTIGRAVITY_WORKER_MODEL_EXECUTION_VALUE": models.antigravity_worker_execution,
|
|
2019
|
+
"REPORT_WRITER_MODEL": models.rw.display,
|
|
2020
|
+
"REPORT_WRITER_MODEL_EXECUTION_VALUE": models.rw.execution,
|
|
2021
|
+
"EXECUTOR_PROVIDER": models.executor_provider,
|
|
2022
|
+
"EXECUTOR_DISPLAY_NAME": models.executor_display_name,
|
|
2023
|
+
"EXECUTOR_WORKER_AGENT": models.executor_worker_agent,
|
|
2024
|
+
"EXECUTOR_MODEL_DISPLAY": models.executor_model_meta.display,
|
|
2025
|
+
"EXECUTOR_MODEL_EXECUTION_VALUE": models.executor_execution,
|
|
2026
|
+
"CRITIC_CHOICE": models.critic_choice,
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
|
|
2030
|
+
def _provision_task_and_stage(
|
|
2031
|
+
inp: PrepareInputs,
|
|
2032
|
+
project_root: Path,
|
|
2033
|
+
ctx_stage_map: list,
|
|
2034
|
+
task_group_segment: str,
|
|
2035
|
+
task_id_segment: str,
|
|
2036
|
+
task_key: str,
|
|
2037
|
+
) -> _ProvisionedTask:
|
|
2038
|
+
"""Reserve this run's worktree and, for implementation, its Stage Map stage.
|
|
2039
|
+
|
|
2040
|
+
One worktree per task-key: requirements-discovery, error-analysis,
|
|
2041
|
+
implementation-planning and implementation phases of the same task all share
|
|
2042
|
+
this directory and branch. Runs BEFORE run-path compute: the worktree's
|
|
2043
|
+
degrade status (skipped-*) feeds the implementation Stage Run Claim, and the
|
|
2044
|
+
resolved stage namespaces the run path.
|
|
2045
|
+
|
|
2046
|
+
The whole check→select→`git worktree add`→reserve sequence (task worktree AND
|
|
2047
|
+
implementation stage) is one critical section per task-key: the registry lock
|
|
2048
|
+
alone only covers the reserve row, so without this mutex two concurrent runs
|
|
2049
|
+
can pick the same stage or race the same path/branch at the git level (TOCTOU).
|
|
2050
|
+
"""
|
|
2051
|
+
with worktree_provision_mutex(
|
|
2052
|
+
okstra_home(), inp.project_id, task_group_segment, task_id_segment,
|
|
2053
|
+
):
|
|
2054
|
+
if inp.task_type == "final-verification" and inp.stage and inp.stage != "auto":
|
|
2055
|
+
worktree = _single_stage_final_verification_worktree(inp)
|
|
2056
|
+
# Single-stage final-verification namespaces its run path under
|
|
2057
|
+
# runs/final-verification/stage-<N> (same isolation as
|
|
2058
|
+
# implementation) so concurrent per-stage verifications never
|
|
2059
|
+
# share state/reports/worker-results.
|
|
2060
|
+
fv_stage_arg = int(inp.stage)
|
|
2061
|
+
else:
|
|
2062
|
+
fv_stage_arg = None
|
|
2063
|
+
try:
|
|
2064
|
+
worktree = provision_task_worktree(
|
|
2065
|
+
task_type=inp.task_type,
|
|
2066
|
+
project_root=project_root,
|
|
2067
|
+
project_id=inp.project_id,
|
|
2068
|
+
task_group_segment=task_group_segment,
|
|
2069
|
+
task_id_segment=task_id_segment,
|
|
2070
|
+
work_category=inp.work_category,
|
|
2071
|
+
base_ref=inp.base_ref,
|
|
2072
|
+
require_base_ref=True,
|
|
2073
|
+
)
|
|
2074
|
+
except RuntimeError as exc:
|
|
2075
|
+
raise PrepareError(
|
|
2076
|
+
f"task worktree provisioning failed: {exc}"
|
|
2077
|
+
) from exc
|
|
2078
|
+
|
|
2079
|
+
# ---- implementation Stage Run Claim (path-independent) ----
|
|
2080
|
+
# Resolve + provision the stage BEFORE run-path compute so RUN_DIR
|
|
2081
|
+
# lands in runs/implementation/stage-<N>. The registry stage-key is
|
|
2082
|
+
# reserved exactly once here (inside provision_stage_worktree), and
|
|
2083
|
+
# the surrounding mutex makes the registry read in the claim
|
|
2084
|
+
# and that reserve atomic. Other task-types skip this claim;
|
|
2085
|
+
# single-stage final-verification threads its explicit stage via
|
|
2086
|
+
# fv_stage_arg, everything else keeps stage_arg=None (flat paths).
|
|
2087
|
+
if inp.task_type == "implementation":
|
|
2088
|
+
stage_run_claim = _claim_implementation_stage_run(
|
|
2089
|
+
inp, ctx_stage_map, task_group_segment, task_id_segment,
|
|
2090
|
+
task_key, worktree.status,
|
|
2091
|
+
)
|
|
2092
|
+
# Drop any stale waiver on this stage so the run actually verifies
|
|
2093
|
+
# conformance (kept inside the per-task-key mutex so concurrent
|
|
2094
|
+
# same-task runs don't race the manifest write).
|
|
2095
|
+
_clear_stale_stage_waiver(inp, project_root, stage_run_claim.stage)
|
|
2096
|
+
return _ProvisionedTask(worktree, stage_run_claim, stage_run_claim.stage)
|
|
2097
|
+
return _ProvisionedTask(worktree, None, fv_stage_arg)
|
|
2098
|
+
|
|
2099
|
+
|
|
1972
2100
|
def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
1973
2101
|
"""Produce a complete okstra task bundle on disk. See module docstring."""
|
|
1974
2102
|
workspace_root = Path(inp.workspace_root)
|
|
@@ -2011,12 +2139,6 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2011
2139
|
pr_template_path_str, pr_template_source = _resolve_pr_template(inp)
|
|
2012
2140
|
|
|
2013
2141
|
models = _resolve_model_bindings(inp, workers)
|
|
2014
|
-
lead, cw, co, ge, rw = models.lead, models.cw, models.co, models.ge, models.rw
|
|
2015
|
-
critic_choice = models.critic_choice
|
|
2016
|
-
executor_provider = models.executor_provider
|
|
2017
|
-
executor_display_name = models.executor_display_name
|
|
2018
|
-
executor_worker_agent = models.executor_worker_agent
|
|
2019
|
-
executor_model_meta = models.executor_model_meta
|
|
2020
2142
|
|
|
2021
2143
|
# ---- paths under per-task mutex (writes run-context-*.json) ----
|
|
2022
2144
|
# OKSTRA_RUN_SEQ_OVERRIDE: okstra-ctl rerun / 테스트 hook 이 미리 reserve
|
|
@@ -2043,67 +2165,13 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2043
2165
|
task_id=inp.task_id,
|
|
2044
2166
|
)
|
|
2045
2167
|
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
# The whole check→select→`git worktree add`→reserve sequence (task
|
|
2054
|
-
# worktree AND implementation stage) is one critical section per
|
|
2055
|
-
# task-key: the registry lock alone only covers the reserve row, so
|
|
2056
|
-
# without this mutex two concurrent runs can pick the same stage or
|
|
2057
|
-
# race the same path/branch at the git level (TOCTOU).
|
|
2058
|
-
with worktree_provision_mutex(
|
|
2059
|
-
okstra_home(), inp.project_id, task_group_segment, task_id_segment,
|
|
2060
|
-
):
|
|
2061
|
-
if inp.task_type == "final-verification" and inp.stage and inp.stage != "auto":
|
|
2062
|
-
worktree = _single_stage_final_verification_worktree(inp)
|
|
2063
|
-
# Single-stage final-verification namespaces its run path under
|
|
2064
|
-
# runs/final-verification/stage-<N> (same isolation as
|
|
2065
|
-
# implementation) so concurrent per-stage verifications never
|
|
2066
|
-
# share state/reports/worker-results.
|
|
2067
|
-
fv_stage_arg = int(inp.stage)
|
|
2068
|
-
else:
|
|
2069
|
-
fv_stage_arg = None
|
|
2070
|
-
try:
|
|
2071
|
-
worktree = provision_task_worktree(
|
|
2072
|
-
task_type=inp.task_type,
|
|
2073
|
-
project_root=project_root,
|
|
2074
|
-
project_id=inp.project_id,
|
|
2075
|
-
task_group_segment=task_group_segment,
|
|
2076
|
-
task_id_segment=task_id_segment,
|
|
2077
|
-
work_category=inp.work_category,
|
|
2078
|
-
base_ref=inp.base_ref,
|
|
2079
|
-
require_base_ref=True,
|
|
2080
|
-
)
|
|
2081
|
-
except RuntimeError as exc:
|
|
2082
|
-
raise PrepareError(
|
|
2083
|
-
f"task worktree provisioning failed: {exc}"
|
|
2084
|
-
) from exc
|
|
2085
|
-
|
|
2086
|
-
# ---- implementation Stage Run Claim (path-independent) ----
|
|
2087
|
-
# Resolve + provision the stage BEFORE run-path compute so RUN_DIR
|
|
2088
|
-
# lands in runs/implementation/stage-<N>. The registry stage-key is
|
|
2089
|
-
# reserved exactly once here (inside provision_stage_worktree), and
|
|
2090
|
-
# the surrounding mutex makes the registry read in the claim
|
|
2091
|
-
# and that reserve atomic. Other task-types skip this claim;
|
|
2092
|
-
# single-stage final-verification threads its explicit stage via
|
|
2093
|
-
# fv_stage_arg, everything else keeps stage_arg=None (flat paths).
|
|
2094
|
-
if inp.task_type == "implementation":
|
|
2095
|
-
stage_run_claim = _claim_implementation_stage_run(
|
|
2096
|
-
inp, ctx_stage_map, task_group_segment, task_id_segment,
|
|
2097
|
-
task_key, worktree.status,
|
|
2098
|
-
)
|
|
2099
|
-
stage_arg = stage_run_claim.stage
|
|
2100
|
-
# Drop any stale waiver on this stage so the run actually verifies
|
|
2101
|
-
# conformance (kept inside the per-task-key mutex so concurrent
|
|
2102
|
-
# same-task runs don't race the manifest write).
|
|
2103
|
-
_clear_stale_stage_waiver(inp, project_root, stage_run_claim.stage)
|
|
2104
|
-
else:
|
|
2105
|
-
stage_run_claim = None
|
|
2106
|
-
stage_arg = fv_stage_arg
|
|
2168
|
+
provisioned = _provision_task_and_stage(
|
|
2169
|
+
inp, project_root, ctx_stage_map,
|
|
2170
|
+
task_group_segment, task_id_segment, task_key,
|
|
2171
|
+
)
|
|
2172
|
+
worktree = provisioned.worktree
|
|
2173
|
+
stage_run_claim = provisioned.stage_run_claim
|
|
2174
|
+
stage_arg = provisioned.stage_arg
|
|
2107
2175
|
|
|
2108
2176
|
ctx = compute_and_write_run_context(
|
|
2109
2177
|
workspace_root=workspace_root, project_root=project_root,
|
|
@@ -2154,13 +2222,6 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2154
2222
|
# ---- material + related-tasks ----
|
|
2155
2223
|
profile_content = _expand_profile_includes(profile_file)
|
|
2156
2224
|
review_material = build_analysis_material(inp.brief_path, inp.directive)
|
|
2157
|
-
related_items = resolve_related_tasks(
|
|
2158
|
-
task_manifest_path=Path(ctx["TASK_MANIFEST_PATH"]),
|
|
2159
|
-
raw_related=inp.related_tasks_raw,
|
|
2160
|
-
)
|
|
2161
|
-
related_tasks_json_str = json.dumps(related_items, ensure_ascii=False)
|
|
2162
|
-
bullets = related_tasks_bullets(related_items)
|
|
2163
|
-
inline = related_tasks_inline(related_items)
|
|
2164
2225
|
|
|
2165
2226
|
# ---- relative paths for brief + clarification ----
|
|
2166
2227
|
brief_relative = relative_to_project_root(inp.brief_path, project_root)
|
|
@@ -2169,17 +2230,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2169
2230
|
if inp.clarification_response_path else ""
|
|
2170
2231
|
)
|
|
2171
2232
|
|
|
2172
|
-
# ---- initial workflow state (current_run_status not yet known) ----
|
|
2173
|
-
initial_task_status = "ready-for-claude"
|
|
2174
|
-
initial_run_status = "not-run"
|
|
2175
2233
|
forbidden_by_phase = load_phase_forbidden(workspace_root)
|
|
2176
|
-
workflow_state = compute_workflow_state(
|
|
2177
|
-
task_type=inp.task_type,
|
|
2178
|
-
current_run_status=initial_run_status,
|
|
2179
|
-
current_task_status=initial_task_status,
|
|
2180
|
-
render_only=inp.render_only,
|
|
2181
|
-
forbidden_by_phase=forbidden_by_phase,
|
|
2182
|
-
)
|
|
2183
2234
|
|
|
2184
2235
|
# ---- assemble full ctx (the values render functions expect) ----
|
|
2185
2236
|
ctx.update({
|
|
@@ -2196,27 +2247,9 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2196
2247
|
"CLARIFICATION_RESPONSE_RELATIVE_PATH": clarification_relative,
|
|
2197
2248
|
"BRIEF_FILE_PATH": str(inp.brief_path),
|
|
2198
2249
|
"BRIEF_RELATIVE_PATH": brief_relative,
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
"CLAUDE_WORKER_MODEL_EXECUTION_VALUE": cw.execution,
|
|
2203
|
-
"CODEX_WORKER_MODEL": co.display,
|
|
2204
|
-
"CODEX_WORKER_MODEL_EXECUTION_VALUE": models.codex_worker_execution,
|
|
2205
|
-
"ANTIGRAVITY_WORKER_MODEL": ge.display,
|
|
2206
|
-
"ANTIGRAVITY_WORKER_MODEL_EXECUTION_VALUE": models.antigravity_worker_execution,
|
|
2207
|
-
"REPORT_WRITER_MODEL": rw.display,
|
|
2208
|
-
"REPORT_WRITER_MODEL_EXECUTION_VALUE": rw.execution,
|
|
2209
|
-
"EXECUTOR_PROVIDER": executor_provider,
|
|
2210
|
-
"EXECUTOR_DISPLAY_NAME": executor_display_name,
|
|
2211
|
-
"EXECUTOR_WORKER_AGENT": executor_worker_agent,
|
|
2212
|
-
"EXECUTOR_MODEL_DISPLAY": executor_model_meta.display,
|
|
2213
|
-
"EXECUTOR_MODEL_EXECUTION_VALUE": models.executor_execution,
|
|
2214
|
-
"CRITIC_CHOICE": critic_choice,
|
|
2215
|
-
"RELATED_TASKS_JSON": related_tasks_json_str,
|
|
2216
|
-
"RELATED_TASKS_BULLETS": bullets,
|
|
2217
|
-
"RELATED_TASKS_INLINE": inline,
|
|
2218
|
-
"CURRENT_TASK_STATUS": initial_task_status,
|
|
2219
|
-
"CURRENT_RUN_STATUS": initial_run_status,
|
|
2250
|
+
**_model_ctx(models),
|
|
2251
|
+
**_related_tasks_ctx(ctx, inp),
|
|
2252
|
+
**_initial_workflow_ctx(inp, forbidden_by_phase),
|
|
2220
2253
|
"VALIDATION_STATUS": "not-run",
|
|
2221
2254
|
"VALIDATION_UPDATED_AT": "",
|
|
2222
2255
|
"VALIDATION_FAILURES_JSON": "[]",
|
|
@@ -2224,7 +2257,6 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2224
2257
|
"LATEST_REPORT_RELATIVE_PATH": "",
|
|
2225
2258
|
"RENDER_ONLY": "true" if inp.render_only else "false",
|
|
2226
2259
|
"OKSTRA_VERSION": installed_version(),
|
|
2227
|
-
**workflow_state,
|
|
2228
2260
|
})
|
|
2229
2261
|
if lead_runtime == "codex":
|
|
2230
2262
|
ctx["CLAUDE_RESUME_COMMAND_PATH"] = ""
|
|
@@ -2233,36 +2265,9 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2233
2265
|
if inp.task_type == "final-verification":
|
|
2234
2266
|
_reserve_final_verification_target(inp, ctx, ctx_stage_map)
|
|
2235
2267
|
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
migrate_legacy_run_artifacts(ctx)
|
|
2239
|
-
cleanup_obsolete_generated_docs(
|
|
2240
|
-
project_root=project_root, instruction_set_dir=Path(ctx["INSTRUCTION_SET_PATH"]),
|
|
2268
|
+
_write_bundle_artifacts(
|
|
2269
|
+
inp, ctx, project_root, lead_runtime, claude_session_id,
|
|
2241
2270
|
)
|
|
2242
|
-
if lead_runtime == "claude-code":
|
|
2243
|
-
# Always materialise the resume command script for Claude Code. Even in
|
|
2244
|
-
# --render-only preparation flows the user (or a later non-interactive
|
|
2245
|
-
# runner) may invoke it manually; deferring its creation until
|
|
2246
|
-
# interactive launch leaves runs/<phase>/sessions/ empty and the
|
|
2247
|
-
# manifest pointing at a path that does not exist. Codex runs resume via
|
|
2248
|
-
# artifacts/checkpoints instead, so they intentionally leave this empty.
|
|
2249
|
-
write_claude_resume_command_file(
|
|
2250
|
-
resume_command_path=Path(ctx["CLAUDE_RESUME_COMMAND_PATH"]),
|
|
2251
|
-
project_root=project_root,
|
|
2252
|
-
claude_session_id=claude_session_id,
|
|
2253
|
-
task_key=ctx["TASK_KEY"],
|
|
2254
|
-
task_type=ctx["TASK_TYPE"],
|
|
2255
|
-
phase_state=ctx["CURRENT_RUN_STATUS"],
|
|
2256
|
-
worker_prompts_dir_relative=ctx["RUN_PROMPTS_RELATIVE_PATH"],
|
|
2257
|
-
prompt_seq=ctx["RUN_PROMPTS_SEQ"],
|
|
2258
|
-
)
|
|
2259
|
-
|
|
2260
|
-
# ---- fix-cycle 기록 (manifest 재작성 전 디스크 값으로 lazy-close 판정) ----
|
|
2261
|
-
# instruction-set 빌드보다 *앞*에서 호출해 이번 run 에서 새로 open 되는
|
|
2262
|
-
# cycle 도 analysis-packet 의 Fix History 에 보이도록 한다. finalize 가
|
|
2263
|
-
# 여전히 이후에 manifest 를 재작성하므로 lazy-close 의 "디스크 manifest 는
|
|
2264
|
-
# prepare 재작성 전 값" 불변식은 유지된다.
|
|
2265
|
-
ctx["FIX_CYCLE_ID"] = _record_fix_cycle_events(inp, ctx)
|
|
2266
2271
|
|
|
2267
2272
|
# ---- write instruction-set scaffolding + lead prompt ----
|
|
2268
2273
|
instruction_set = _write_instruction_set_sources(
|
|
@@ -2281,29 +2286,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2281
2286
|
)
|
|
2282
2287
|
_record_artifact_runtime_render_only_event(inp, ctx)
|
|
2283
2288
|
|
|
2284
|
-
|
|
2285
|
-
initial_status = "prepared" if inp.render_only else "running"
|
|
2286
|
-
canonical_argv = _canonical_argv(inp, ctx)
|
|
2287
|
-
try:
|
|
2288
|
-
_record_start(
|
|
2289
|
-
workspace_root=workspace_root,
|
|
2290
|
-
ctx=ctx,
|
|
2291
|
-
initial_status=initial_status,
|
|
2292
|
-
canonical_argv=canonical_argv,
|
|
2293
|
-
cwd=os.getcwd(),
|
|
2294
|
-
brief_sha256=_brief_sha256(inp.brief_path),
|
|
2295
|
-
)
|
|
2296
|
-
except Exception as exc:
|
|
2297
|
-
print(
|
|
2298
|
-
f"okstra-central: record_start failed; central index will be incomplete ({exc})",
|
|
2299
|
-
file=sys.stderr,
|
|
2300
|
-
)
|
|
2301
|
-
# rerun 경로는 spawn 전에 'reserving' row 를 미리 박아 둔다
|
|
2302
|
-
# (OKSTRA_RUN_SEQ_OVERRIDE 가 그 seq 를 강제한다). record_start 가
|
|
2303
|
-
# 'reserving' → 'running' 승격에 실패하면 그 row 가 active.jsonl 에
|
|
2304
|
-
# 영구히 남아 activeCount 를 부풀린다. 예약 seq 를 알 때만 정리한다.
|
|
2305
|
-
if run_seq_override is not None:
|
|
2306
|
-
_remove_leaked_reservation(ctx, run_seq_override)
|
|
2289
|
+
_record_run_in_central_index(inp, ctx, workspace_root, run_seq_override)
|
|
2307
2290
|
|
|
2308
2291
|
if not inp.render_only:
|
|
2309
2292
|
_provision_settings_symlink(inp)
|
|
@@ -15,6 +15,7 @@ from datetime import datetime, timezone
|
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
17
|
from okstra_ctl.ids import slugify_task_segment
|
|
18
|
+
from okstra_ctl.paths import task_dir, task_manifest_file
|
|
18
19
|
from okstra_project import (
|
|
19
20
|
ResolverError,
|
|
20
21
|
StateError,
|
|
@@ -40,7 +41,7 @@ def _manifest_path(project_root: Path, entry: dict) -> Path:
|
|
|
40
41
|
id_seg = entry.get("taskIdPathSegment") or slugify_task_segment(
|
|
41
42
|
entry.get("taskId", "")
|
|
42
43
|
)
|
|
43
|
-
return project_root
|
|
44
|
+
return task_manifest_file(task_dir(project_root, group_seg, id_seg))
|
|
44
45
|
|
|
45
46
|
|
|
46
47
|
def main(argv: list[str] | None = None) -> int:
|