okstra 0.98.2 → 0.99.1
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/kr/architecture.md +5 -5
- package/docs/superpowers/plans/2026-06-23-phase-batch-cleanup-enforcement.md +409 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/bin/okstra-team-reconcile.sh +7 -7
- package/runtime/prompts/coding-preflight/overview.md +1 -1
- package/runtime/prompts/lead/context-loader.md +1 -1
- package/runtime/prompts/lead/convergence.md +3 -4
- package/runtime/prompts/lead/okstra-lead-contract.md +26 -24
- package/runtime/prompts/lead/report-writer.md +6 -7
- package/runtime/prompts/lead/team-contract.md +4 -4
- package/runtime/prompts/profiles/_common-contract.md +16 -16
- package/runtime/prompts/profiles/forbidden-actions.json +1 -1
- package/runtime/prompts/profiles/release-handoff.md +2 -2
- package/runtime/python/okstra_ctl/paths.py +1 -5
- package/runtime/python/okstra_ctl/render.py +38 -49
- package/runtime/python/okstra_ctl/run.py +1 -1
- package/runtime/python/okstra_ctl/session.py +1 -1
- package/runtime/python/okstra_ctl/team_reconcile.py +10 -12
- package/runtime/python/okstra_ctl/worktree.py +2 -3
- package/runtime/python/okstra_ctl/worktree_registry.py +3 -3
- package/runtime/python/okstra_project/dirs.py +31 -4
- package/runtime/python/okstra_token_usage/collect.py +7 -5
- package/runtime/skills/okstra-run/SKILL.md +3 -3
- package/runtime/validators/lib/fixtures.sh +8 -7
- package/runtime/validators/validate-run.py +18 -15
- package/runtime/validators/validate_session_conformance.py +62 -10
- package/src/commands/lifecycle/config.mjs +2 -4
- package/src/commands/memory/memory.mjs +2 -3
- package/src/lib/paths.mjs +28 -2
|
@@ -2,22 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
A Claude Code team member clears its own ``isActive`` flag in
|
|
4
4
|
``~/.claude/teams/<team>/config.json`` when its ``Agent()`` dispatch returns, so
|
|
5
|
-
by Phase 7 every worker is normally already inactive
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
that re-sending ``shutdown_request`` cannot clear — the addressee is already
|
|
10
|
-
gone.
|
|
5
|
+
by Phase 7 every worker is normally already inactive. The one failure mode is a
|
|
6
|
+
member whose tmux pane died WITHOUT clearing the flag (killed mid-turn): it stays
|
|
7
|
+
``isActive: true`` forever and keeps showing as a live member that re-sending
|
|
8
|
+
``shutdown_request`` cannot clear — the addressee is already gone.
|
|
11
9
|
|
|
12
10
|
This module reconciles exactly that case: a member with ``isActive`` truthy
|
|
13
11
|
whose recorded tmux pane is no longer live is flipped to inactive. It NEVER
|
|
14
12
|
touches a member whose pane is still live, the lead, or a member with no
|
|
15
13
|
recorded pane — those are left for graceful shutdown.
|
|
16
14
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
the
|
|
20
|
-
|
|
15
|
+
CC v2.1.178 removed ``TeamDelete``: the implicit team ends with the session and
|
|
16
|
+
run-end teardown only dismisses teammates via ``shutdown_request``. Reconcile
|
|
17
|
+
keeps the roster honest so a dismissed-but-dead member does not linger as a
|
|
18
|
+
live pill — see _common-contract.md "Run-end teammate teardown".
|
|
21
19
|
"""
|
|
22
20
|
from __future__ import annotations
|
|
23
21
|
|
|
@@ -79,8 +77,8 @@ def _config_path(team_arg: str) -> Path:
|
|
|
79
77
|
|
|
80
78
|
|
|
81
79
|
def _atomic_write(path: Path, text: str) -> None:
|
|
82
|
-
"""Replace ``path`` atomically — config.json is
|
|
83
|
-
|
|
80
|
+
"""Replace ``path`` atomically — config.json is the harness's roster source
|
|
81
|
+
of truth, so a partial write on crash must never be observable."""
|
|
84
82
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".reconcile-")
|
|
85
83
|
try:
|
|
86
84
|
with os.fdopen(fd, "w") as handle:
|
|
@@ -41,7 +41,7 @@ from dataclasses import dataclass
|
|
|
41
41
|
from pathlib import Path
|
|
42
42
|
from typing import Optional
|
|
43
43
|
|
|
44
|
-
from okstra_project.dirs import project_json_path
|
|
44
|
+
from okstra_project.dirs import okstra_home, project_json_path
|
|
45
45
|
|
|
46
46
|
from .ids import _safe_fs_segment
|
|
47
47
|
from . import worktree_registry
|
|
@@ -642,8 +642,7 @@ def compute_worktree_path(
|
|
|
642
642
|
`~/.okstra`."""
|
|
643
643
|
if stage_number is not None and group_id is not None:
|
|
644
644
|
raise ValueError("stage_number and group_id are mutually exclusive")
|
|
645
|
-
|
|
646
|
-
base = Path(okstra_home) if okstra_home else (Path.home() / ".okstra")
|
|
645
|
+
base = okstra_home()
|
|
647
646
|
path = (
|
|
648
647
|
base / "worktrees"
|
|
649
648
|
/ _safe_segment(project_id)
|
|
@@ -32,15 +32,15 @@ from dataclasses import dataclass
|
|
|
32
32
|
from pathlib import Path
|
|
33
33
|
from typing import Optional
|
|
34
34
|
|
|
35
|
+
from okstra_project.dirs import okstra_home
|
|
36
|
+
|
|
35
37
|
|
|
36
38
|
REGISTRY_FILENAME = "registry.json"
|
|
37
39
|
LOCK_FILENAME = "registry.lock"
|
|
38
40
|
|
|
39
41
|
|
|
40
42
|
def _okstra_worktrees_dir() -> Path:
|
|
41
|
-
|
|
42
|
-
base = Path(home_env) if home_env else (Path.home() / ".okstra")
|
|
43
|
-
return base / "worktrees"
|
|
43
|
+
return okstra_home() / "worktrees"
|
|
44
44
|
|
|
45
45
|
|
|
46
46
|
def task_key(
|
|
@@ -15,6 +15,8 @@ DRY 위반의 비용: 이전에는 동일 path 문자열이 50+ Python·Shell·m
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
17
|
import os
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
18
20
|
from pathlib import Path
|
|
19
21
|
|
|
20
22
|
OKSTRA_DIR_NAME = ".okstra"
|
|
@@ -40,12 +42,37 @@ TASK_CATALOG_RELATIVE = DISCOVERY_RELATIVE / "task-catalog.json"
|
|
|
40
42
|
LATEST_TASK_RELATIVE = DISCOVERY_RELATIVE / "latest-task.json"
|
|
41
43
|
|
|
42
44
|
|
|
45
|
+
def _is_stale_tmp_home(path: str) -> bool:
|
|
46
|
+
"""삭제된 pytest 격리 OKSTRA_HOME 누수 판별. tests/conftest.py 가 격리 홈을
|
|
47
|
+
``<tmp_path>/.okstra-isolated``(시스템 임시 하위)로 만드는데, 그 디렉터리가
|
|
48
|
+
사라진 채 env 만 남은 경우만 폴백 대상이다. 실제/커스텀 홈이나 곧 생성될
|
|
49
|
+
임시 홈(``.okstra`` 등)은 이름이 달라 걸리지 않는다(테스트 오폴백 방지)."""
|
|
50
|
+
if Path(path).name != ".okstra-isolated":
|
|
51
|
+
return False
|
|
52
|
+
roots = [
|
|
53
|
+
tempfile.gettempdir(),
|
|
54
|
+
"/tmp", "/private/tmp", "/var/folders", "/private/var/folders",
|
|
55
|
+
]
|
|
56
|
+
return any(path == r or path.startswith(r.rstrip("/") + "/")
|
|
57
|
+
for r in roots if r)
|
|
58
|
+
|
|
59
|
+
|
|
43
60
|
def okstra_home() -> Path:
|
|
44
|
-
"""`~/.okstra` 절대 path. 테스트/설치 환경에서 `OKSTRA_HOME` env 로 override.
|
|
61
|
+
"""`~/.okstra` 절대 path. 테스트/설치 환경에서 `OKSTRA_HOME` env 로 override.
|
|
62
|
+
|
|
63
|
+
OKSTRA_HOME 이 존재하지 않는 시스템 임시 경로(삭제된 pytest 격리 홈 등
|
|
64
|
+
누수된 값)를 가리키면 stderr 경고 후 기본 `~/.okstra` 로 폴백한다."""
|
|
45
65
|
override = os.environ.get("OKSTRA_HOME", "").strip()
|
|
46
|
-
if override:
|
|
47
|
-
return Path(
|
|
48
|
-
|
|
66
|
+
if not override:
|
|
67
|
+
return Path.home() / ".okstra"
|
|
68
|
+
home = Path(override).expanduser()
|
|
69
|
+
if not home.exists() and _is_stale_tmp_home(str(home)):
|
|
70
|
+
sys.stderr.write(
|
|
71
|
+
f"⚠ OKSTRA_HOME={override} 가 존재하지 않는 임시 경로입니다 "
|
|
72
|
+
"(누수된 테스트 격리 값으로 보임) → ~/.okstra 로 폴백합니다.\n"
|
|
73
|
+
)
|
|
74
|
+
return Path.home() / ".okstra"
|
|
75
|
+
return home
|
|
49
76
|
|
|
50
77
|
|
|
51
78
|
def okstra_root(project_root: Path) -> Path:
|
|
@@ -517,12 +517,14 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
|
|
|
517
517
|
unattributed_sessions.append(sid)
|
|
518
518
|
unattributed_totals.append(totals)
|
|
519
519
|
|
|
520
|
-
#
|
|
521
|
-
#
|
|
522
|
-
#
|
|
523
|
-
#
|
|
520
|
+
# implicit-team run (CC v2.1.178+: no TeamCreate, status "implicit") or
|
|
521
|
+
# legacy no-team run (skipped/concurrent-run, error fallback) — the implicit
|
|
522
|
+
# team tags worker jsonls with the harness team name (`session-<leadSid>`),
|
|
523
|
+
# NOT okstra's `teamName` label, so the needle scan above only finds the lead.
|
|
524
|
+
# agentName 기반 발견으로 worker 세션을 보강한다. run 윈도우 밖 세션(in-window
|
|
525
|
+
# 이벤트 없음 = startedAt 부재)은 같은 agentName 의 타 run 세션이므로 버린다.
|
|
524
526
|
team_create_status = str((state.get("teamCreate") or {}).get("status", "")).strip()
|
|
525
|
-
if team_create_status in ("skipped", "error"):
|
|
527
|
+
if team_create_status in ("implicit", "skipped", "error"):
|
|
526
528
|
worker_prefix_pool = [
|
|
527
529
|
prefix
|
|
528
530
|
for w in state.get("workers", [])
|
|
@@ -209,14 +209,14 @@ When the user picks a waiver, append `--qa-waiver "<stageKey>:<reason>"` to the
|
|
|
209
209
|
|
|
210
210
|
`render-bundle` stdout 에 `okstra concurrent-run stages: <stages>` 라벨 라인이
|
|
211
211
|
있으면(같은 task-key 의 다른 implementation run 이 `<stages>` 를 점유 중), launch
|
|
212
|
-
프롬프트는 이미 "Concurrent-run
|
|
212
|
+
프롬프트는 이미 "Concurrent-run marker" 게이트로 렌더돼 있다. 이 라인이
|
|
213
213
|
없으면 동시-run 이 아니므로 이 분기를 건너뛴다. 라인이 있으면 dispatch 전에
|
|
214
214
|
사용자에게 3-옵션 recommendation picker 를 제시한다 (run-prompt recommendation 규칙:
|
|
215
215
|
1–2 추천 + 직접 입력; 이 picker 는 스킬이 author 하는 것이라 wizard `options[]`
|
|
216
216
|
제약과 무관):
|
|
217
217
|
|
|
218
|
-
1. (추천) 이대로
|
|
219
|
-
team 을
|
|
218
|
+
1. (추천) 이대로 진행 — 이미 렌더된 bundle 을 그대로 사용한다. 각 세션은 자기
|
|
219
|
+
implicit team 을 쓰므로 동시 run 끼리 team 충돌이 없고, split-pane 도 정상 동작한다.
|
|
220
220
|
2. 대기 — 지금 dispatch 를 보류한다. stage worktree·run-context 는 보존되므로,
|
|
221
221
|
점유 중인 다른 run 종료 후 같은 stage 를 resume 으로 재개하면 그때는 정상 team
|
|
222
222
|
경로다. resume 명령(`okstra-inspect` history → resume)을 사용자에게 출력한다.
|
|
@@ -288,13 +288,13 @@ if isinstance(lead, dict):
|
|
|
288
288
|
lead["sessionId"] = "fixture-lead-session-0001"
|
|
289
289
|
team_state["workflowState"] = "worker-results-collected"
|
|
290
290
|
|
|
291
|
-
# validate-run.py requires team-state.teamCreate.
|
|
292
|
-
#
|
|
293
|
-
#
|
|
294
|
-
# represents a valid post-Phase-3 state.
|
|
291
|
+
# validate-run.py requires team-state.teamCreate.status == "implicit" once any
|
|
292
|
+
# worker has been dispatched (CC v2.1.178 removed TeamCreate; every session has
|
|
293
|
+
# an implicit team). Mirror that here so the fixture is a valid post-Phase-3 state.
|
|
295
294
|
team_state["teamCreate"] = {
|
|
296
|
-
"attempted":
|
|
297
|
-
"status": "
|
|
295
|
+
"attempted": False,
|
|
296
|
+
"status": "implicit",
|
|
297
|
+
"splitPane": False,
|
|
298
298
|
}
|
|
299
299
|
|
|
300
300
|
# Phase 7 token-usage collection is normally produced by okstra-token-usage.py.
|
|
@@ -461,7 +461,7 @@ if lead_sid:
|
|
|
461
461
|
"PROGRESS: phase-1-intake reading task bundle",
|
|
462
462
|
"PROGRESS: phase-1-intake complete",
|
|
463
463
|
f"PROGRESS: phase-2-prompts preparing {len(team_state.get('workers') or [])} worker prompts",
|
|
464
|
-
"PROGRESS: phase-3-team-create
|
|
464
|
+
"PROGRESS: phase-3-team-create using implicit team (split-pane=off)",
|
|
465
465
|
]
|
|
466
466
|
for worker in team_state.get("workers", []):
|
|
467
467
|
if not isinstance(worker, dict):
|
|
@@ -478,6 +478,7 @@ if lead_sid:
|
|
|
478
478
|
progress_lines.append(
|
|
479
479
|
f"PROGRESS: phase-5-collect worker={worker_id}-worker status=completed"
|
|
480
480
|
)
|
|
481
|
+
progress_lines.append("PROGRESS: phase-batch-cleanup panes=1 teammates=1")
|
|
481
482
|
progress_lines.append("PROGRESS: phase-6-synthesis dispatching report-writer-worker")
|
|
482
483
|
progress_lines.append("PROGRESS: phase-7-persist updating manifests")
|
|
483
484
|
records = [
|
|
@@ -357,9 +357,9 @@ def _is_legal_concurrent_run_skip(
|
|
|
357
357
|
team_create: object, concurrent_run_authorized: bool
|
|
358
358
|
) -> bool:
|
|
359
359
|
"""prepare 가 run-manifest 에 동시-run 을 기록한 run 에서만, 렌더 게이트
|
|
360
|
-
("Concurrent-run
|
|
361
|
-
|
|
362
|
-
|
|
360
|
+
("Concurrent-run marker")가 지시한 teamCreate skipped 형태를 legal 터미널
|
|
361
|
+
상태로 인정한다. lead 의 team-state 자기 선언만으로는(앵커 없이) 열리지
|
|
362
|
+
않는다 — 선언이 아닌 prepare-측 사실이 강제 근거다."""
|
|
363
363
|
if not concurrent_run_authorized or not isinstance(team_create, dict):
|
|
364
364
|
return False
|
|
365
365
|
return (
|
|
@@ -431,23 +431,26 @@ def validate_team_state(
|
|
|
431
431
|
team_create = team_state.get("teamCreate")
|
|
432
432
|
if _is_legal_concurrent_run_skip(team_create, concurrent_run_authorized):
|
|
433
433
|
pass
|
|
434
|
-
elif not isinstance(team_create, dict) or not team_create.get("
|
|
434
|
+
elif not isinstance(team_create, dict) or not str(team_create.get("status", "")).strip():
|
|
435
435
|
failures.append(
|
|
436
|
-
"team-state.teamCreate
|
|
437
|
-
"been dispatched (status in completed/timeout/error/in-progress). "
|
|
438
|
-
"
|
|
439
|
-
|
|
440
|
-
"
|
|
441
|
-
"
|
|
442
|
-
|
|
443
|
-
'
|
|
436
|
+
"team-state.teamCreate must be recorded with a status once any worker "
|
|
437
|
+
"has been dispatched (status in completed/timeout/error/in-progress). "
|
|
438
|
+
"CC v2.1.178 removed TeamCreate; every session has an implicit team, so "
|
|
439
|
+
'Phase 3 records `teamCreate: { attempted: false, status: "implicit" }` — '
|
|
440
|
+
"the audit marker that drives Phase 7 token attribution. See "
|
|
441
|
+
"prompts/lead/okstra-lead-contract.md Phase 3. (The concurrent-run path "
|
|
442
|
+
"is legal ONLY when the run-manifest carries prepare-recorded "
|
|
443
|
+
'`concurrentRun.detected: true` AND team-state records '
|
|
444
|
+
'`teamCreate: { attempted: false, status: "skipped", reason: "concurrent-run" }`.)'
|
|
444
445
|
)
|
|
445
446
|
else:
|
|
446
447
|
tc_status = str(team_create.get("status", "")).strip()
|
|
447
|
-
if tc_status
|
|
448
|
+
if tc_status != "implicit":
|
|
448
449
|
failures.append(
|
|
449
|
-
"team-state.teamCreate.status must be `
|
|
450
|
-
|
|
450
|
+
"team-state.teamCreate.status must be `implicit` once workers have "
|
|
451
|
+
"been dispatched (the only other legal value is `skipped` with "
|
|
452
|
+
"reason `concurrent-run` in a prepare-authorized concurrent run). "
|
|
453
|
+
f"Found: `{tc_status}`."
|
|
451
454
|
)
|
|
452
455
|
|
|
453
456
|
by_role: dict[str, dict] = {}
|
|
@@ -348,6 +348,38 @@ def _check_worker_checkpoint_lines(
|
|
|
348
348
|
)
|
|
349
349
|
|
|
350
350
|
|
|
351
|
+
def _check_batch_cleanup_checkpoints(
|
|
352
|
+
by_phase: dict[str, list[tuple[str, str]]],
|
|
353
|
+
convergence_ran: bool,
|
|
354
|
+
report_writer_dispatched: bool,
|
|
355
|
+
errors: list[str],
|
|
356
|
+
) -> None:
|
|
357
|
+
"""phase-batch-cleanup: 각 배치 경계 직전에 이전 배치 pane/teammate 정리가
|
|
358
|
+
실제로 일어났는지 (prompts/profiles/_common-contract.md 'Phase-start cleanup').
|
|
359
|
+
R1=convergence round 1 직전, R2=report-writer dispatch 직전(수렴이 있었으면
|
|
360
|
+
마지막 라운드 이후 별도 1회). ISO-8601 ts 는 lexicographic 비교가 곧 시간순."""
|
|
361
|
+
detail = "prompts/profiles/_common-contract.md 'Phase-start cleanup'"
|
|
362
|
+
cleanup_ts = sorted(ts for ts, _line in by_phase.get("phase-batch-cleanup", []))
|
|
363
|
+
conv_ts = sorted(ts for ts, _line in by_phase.get("phase-5.5-convergence", []))
|
|
364
|
+
synth_ts = sorted(ts for ts, _line in by_phase.get("phase-6-synthesis", []))
|
|
365
|
+
|
|
366
|
+
if convergence_ran and conv_ts and not any(ts <= conv_ts[0] for ts in cleanup_ts):
|
|
367
|
+
errors.append(
|
|
368
|
+
"PROGRESS checkpoint missing: no `phase-batch-cleanup` line before the "
|
|
369
|
+
f"first `phase-5.5-convergence` round ({conv_ts[0]}) — the prior analysis "
|
|
370
|
+
f"batch's panes/teammates must be cleared first ({detail})."
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
if report_writer_dispatched and synth_ts:
|
|
374
|
+
lower = conv_ts[-1] if (convergence_ran and conv_ts) else ""
|
|
375
|
+
if not any(lower <= ts <= synth_ts[0] for ts in cleanup_ts):
|
|
376
|
+
errors.append(
|
|
377
|
+
"PROGRESS checkpoint missing: no `phase-batch-cleanup` line before "
|
|
378
|
+
f"`phase-6-synthesis` ({synth_ts[0]}) — the prior batch must be cleared "
|
|
379
|
+
f"before dispatching report-writer ({detail})."
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
351
383
|
def _check_progress_checkpoints(
|
|
352
384
|
evidence: _LeadEvidence,
|
|
353
385
|
team_state: dict,
|
|
@@ -387,24 +419,31 @@ def _check_progress_checkpoints(
|
|
|
387
419
|
require(
|
|
388
420
|
"phase-3-team-create",
|
|
389
421
|
any_dispatched,
|
|
390
|
-
"
|
|
391
|
-
"
|
|
392
|
-
"
|
|
422
|
+
"in Phase 3 after recording teamCreate in team-state — the "
|
|
423
|
+
"`using implicit team (split-pane=<on|off>)` line, or the "
|
|
424
|
+
"`skipped (concurrent-run)` variant in the concurrent-run path",
|
|
393
425
|
)
|
|
394
426
|
_check_worker_checkpoint_lines(by_phase, analysis_workers, errors)
|
|
427
|
+
convergence_ran = _convergence_rounds_ran(run_dir, suffix)
|
|
395
428
|
require(
|
|
396
429
|
"phase-5.5-convergence",
|
|
397
|
-
|
|
430
|
+
convergence_ran,
|
|
398
431
|
"at the start of each convergence round (state artifact records totalRounds >= 1)",
|
|
399
432
|
)
|
|
400
433
|
report_writer = next((w for w in workers if _is_report_writer(w)), None)
|
|
434
|
+
report_writer_dispatched = (
|
|
435
|
+
report_writer is not None
|
|
436
|
+
and str(report_writer.get("status", "")).strip() in _DISPATCHED_STATUSES
|
|
437
|
+
)
|
|
401
438
|
require(
|
|
402
439
|
"phase-6-synthesis",
|
|
403
|
-
|
|
404
|
-
and str(report_writer.get("status", "")).strip() in _DISPATCHED_STATUSES,
|
|
440
|
+
report_writer_dispatched,
|
|
405
441
|
"at the start of Phase 6 (report-writer dispatch)",
|
|
406
442
|
)
|
|
407
443
|
require("phase-7-persist", True, "at the start of Phase 7")
|
|
444
|
+
_check_batch_cleanup_checkpoints(
|
|
445
|
+
by_phase, convergence_ran, report_writer_dispatched, errors
|
|
446
|
+
)
|
|
408
447
|
|
|
409
448
|
|
|
410
449
|
def _parse_iso(ts: str) -> datetime | None:
|
|
@@ -469,13 +508,26 @@ def _check_heartbeat_sidecar(path: Path, errors: list[str]) -> None:
|
|
|
469
508
|
prev = ts
|
|
470
509
|
|
|
471
510
|
|
|
472
|
-
def _check_heartbeat_sidecars(
|
|
511
|
+
def _check_heartbeat_sidecars(
|
|
512
|
+
run_dir: Path, task_type: str, suffix: str | None, errors: list[str]
|
|
513
|
+
) -> None:
|
|
473
514
|
"""검사 2 — 사이드카 **존재** 는 validate-run.py validate_worker_results_audit 가
|
|
474
|
-
이미 강제하므로, 여기서는 존재하는 사이드카의 heartbeat 내용만 본다.
|
|
515
|
+
이미 강제하므로, 여기서는 존재하는 사이드카의 heartbeat 내용만 본다.
|
|
516
|
+
|
|
517
|
+
비-stage 격리 task-type(requirements-discovery / error-analysis /
|
|
518
|
+
implementation-planning)은 `runs/<task-type>/worker-results/` 를 run 끼리
|
|
519
|
+
공유한다 — seq 는 파일명에만 있다. seq 를 무시하고 glob 하면 이미 폐기된
|
|
520
|
+
직전 run 의 audit(heartbeat 위반)까지 검사해 현재 run 을 오탐 실패시킨다.
|
|
521
|
+
다른 검사(progress / sidecar-read)와 동일하게 현재 run seq(suffix)로 스코핑한다."""
|
|
475
522
|
worker_results_dir = run_dir / "worker-results"
|
|
476
523
|
if not worker_results_dir.is_dir():
|
|
477
524
|
return
|
|
478
|
-
|
|
525
|
+
pattern = (
|
|
526
|
+
f"claude-worker-audit-{suffix}.md"
|
|
527
|
+
if suffix
|
|
528
|
+
else f"claude-worker-audit-{task_type}-*.md"
|
|
529
|
+
)
|
|
530
|
+
for path in sorted(worker_results_dir.glob(pattern)):
|
|
479
531
|
_check_heartbeat_sidecar(path, errors)
|
|
480
532
|
|
|
481
533
|
|
|
@@ -534,8 +586,8 @@ def validate_session_conformance(
|
|
|
534
586
|
return result
|
|
535
587
|
|
|
536
588
|
run_dir = report_path.parent.parent
|
|
537
|
-
_check_heartbeat_sidecars(run_dir, task_type, result.errors)
|
|
538
589
|
suffix = run_artifact_suffix(team_state_path)
|
|
590
|
+
_check_heartbeat_sidecars(run_dir, task_type, suffix, result.errors)
|
|
539
591
|
|
|
540
592
|
lead_runtime = team_state.get("leadRuntime", "") or "claude-code"
|
|
541
593
|
dispatch_mode = str(team_state.get("dispatchMode", "")).strip()
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
2
|
import { dirname, join, resolve, isAbsolute } from "node:path";
|
|
4
|
-
import { buildPythonpath, resolvePaths } from "../../lib/paths.mjs";
|
|
3
|
+
import { buildPythonpath, resolvePaths, resolveOkstraHome } from "../../lib/paths.mjs";
|
|
5
4
|
import { OKSTRA_DIR, projectJsonPath } from "../../lib/okstra-dirs.mjs";
|
|
6
5
|
import { fileExists, runProcess } from "../../lib/proc.mjs";
|
|
7
6
|
|
|
@@ -124,8 +123,7 @@ function parseArgs(args) {
|
|
|
124
123
|
}
|
|
125
124
|
|
|
126
125
|
function okstraHome() {
|
|
127
|
-
|
|
128
|
-
return override !== "" ? override : join(homedir(), ".okstra");
|
|
126
|
+
return resolveOkstraHome();
|
|
129
127
|
}
|
|
130
128
|
|
|
131
129
|
function globalConfigPath() {
|
|
@@ -2,8 +2,8 @@ import { createInterface } from "node:readline/promises";
|
|
|
2
2
|
import { stdin as input, stdout as output } from "node:process";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { promises as fs } from "node:fs";
|
|
5
|
-
import { homedir } from "node:os";
|
|
6
5
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
6
|
+
import { resolveOkstraHome } from "../../lib/paths.mjs";
|
|
7
7
|
|
|
8
8
|
const MEMORY_TYPES = new Set([
|
|
9
9
|
"context",
|
|
@@ -68,8 +68,7 @@ Add options:
|
|
|
68
68
|
`;
|
|
69
69
|
|
|
70
70
|
function okstraHome() {
|
|
71
|
-
|
|
72
|
-
return override !== "" ? override : join(homedir(), ".okstra");
|
|
71
|
+
return resolveOkstraHome();
|
|
73
72
|
}
|
|
74
73
|
|
|
75
74
|
function memoryRoot() {
|
package/src/lib/paths.mjs
CHANGED
|
@@ -1,10 +1,36 @@
|
|
|
1
|
-
import { homedir } from "node:os";
|
|
1
|
+
import { homedir, tmpdir } from "node:os";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { getPackageRoot, getPackageVersion } from "./version.mjs";
|
|
5
5
|
|
|
6
|
+
function isStaleTmpHome(p) {
|
|
7
|
+
// tests/conftest.py creates the isolated home as `<tmp_path>/.okstra-isolated`
|
|
8
|
+
// under the system temp dir. Only a deleted one (env leaked, dir gone) heals;
|
|
9
|
+
// a soon-to-be-created `.okstra` temp home or a custom home is left untouched.
|
|
10
|
+
if (!p.endsWith("/.okstra-isolated")) return false;
|
|
11
|
+
const roots = [tmpdir(), "/tmp", "/private/tmp", "/var/folders", "/private/var/folders"];
|
|
12
|
+
return roots.some(
|
|
13
|
+
(r) => r && (p === r || p.startsWith(r.replace(/\/+$/, "") + "/")),
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Resolve OKSTRA_HOME, healing a leaked value: when it points at a missing
|
|
18
|
+
// system-temp path (e.g. a deleted pytest-isolated home), warn and fall back
|
|
19
|
+
// to ~/.okstra instead of operating on the dead directory.
|
|
20
|
+
export function resolveOkstraHome() {
|
|
21
|
+
const raw = process.env.OKSTRA_HOME?.trim();
|
|
22
|
+
if (!raw) return join(homedir(), ".okstra");
|
|
23
|
+
if (!existsSync(raw) && isStaleTmpHome(raw)) {
|
|
24
|
+
process.stderr.write(
|
|
25
|
+
`⚠ OKSTRA_HOME=${raw} points to a missing temp dir (leaked test value); falling back to ~/.okstra\n`,
|
|
26
|
+
);
|
|
27
|
+
return join(homedir(), ".okstra");
|
|
28
|
+
}
|
|
29
|
+
return raw;
|
|
30
|
+
}
|
|
31
|
+
|
|
6
32
|
export async function resolvePaths() {
|
|
7
|
-
const home =
|
|
33
|
+
const home = resolveOkstraHome();
|
|
8
34
|
const pkgRoot = getPackageRoot();
|
|
9
35
|
const devLink = await readSimpleFile(join(home, "dev-link"));
|
|
10
36
|
const runtimeRoot = devLink || join(pkgRoot, "runtime");
|