okstra 0.132.0 → 0.134.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/for-ai/skills/okstra-user-response.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +3 -1
- package/runtime/prompts/lead/report-writer.md +4 -2
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation.md +1 -1
- package/runtime/prompts/profiles/release-handoff.md +8 -5
- package/runtime/python/okstra_ctl/clarification_items.py +34 -12
- package/runtime/python/okstra_ctl/handoff.py +72 -32
- package/runtime/python/okstra_ctl/model_discovery.py +16 -9
- package/runtime/python/okstra_ctl/models.py +6 -5
- package/runtime/python/okstra_ctl/render_final_report.py +73 -26
- package/runtime/python/okstra_ctl/run.py +19 -11
- package/runtime/python/okstra_ctl/session.py +36 -7
- package/runtime/python/okstra_ctl/stage_integrate.py +38 -12
- package/runtime/python/okstra_ctl/user_response.py +14 -5
- package/runtime/python/okstra_ctl/worker_heartbeat.py +35 -4
- package/runtime/python/okstra_ctl/worker_liveness.py +19 -6
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +4 -0
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +20 -0
- package/runtime/python/okstra_ctl/worktree.py +39 -9
- package/runtime/python/okstra_ctl/worktree_registry.py +11 -9
- package/runtime/python/okstra_token_usage/collect.py +57 -1
- package/runtime/python/okstra_token_usage/report.py +44 -14
- package/runtime/schemas/final-report-v1.0.schema.json +1 -0
- package/runtime/skills/okstra-pr-gen/SKILL.md +18 -0
- package/runtime/skills/okstra-user-response/SKILL.md +2 -2
- package/runtime/templates/reports/final-report.template.md +3 -0
- package/runtime/validators/validate-run.py +15 -6
- package/runtime/validators/validate_session_conformance.py +16 -6
- package/src/commands/execute/handoff.mjs +1 -1
|
@@ -1326,7 +1326,12 @@ def _resolve_pr_template(inp: PrepareInputs) -> tuple[str, str]:
|
|
|
1326
1326
|
|
|
1327
1327
|
@dataclass
|
|
1328
1328
|
class _ModelBindings:
|
|
1329
|
-
"""이 run 의 lead/worker 모델 + critic + executor 바인딩.
|
|
1329
|
+
"""이 run 의 lead/worker 모델 + critic + executor 바인딩.
|
|
1330
|
+
|
|
1331
|
+
`*_execution` 은 dispatch 에 쓰이는 CLI 신원 철자다. 카탈로그 값과 달리
|
|
1332
|
+
provider CLI 조회를 거치므로 실패할 수 있고, 그래서 이 묶음에서 함께
|
|
1333
|
+
해소한다 — prepare 가 run seq 를 할당하거나 worktree 를 만들기 전에.
|
|
1334
|
+
"""
|
|
1330
1335
|
|
|
1331
1336
|
lead: ModelAssignment
|
|
1332
1337
|
cw: ModelAssignment
|
|
@@ -1338,6 +1343,9 @@ class _ModelBindings:
|
|
|
1338
1343
|
executor_display_name: str
|
|
1339
1344
|
executor_worker_agent: str
|
|
1340
1345
|
executor_model_meta: ModelAssignment
|
|
1346
|
+
codex_worker_execution: str
|
|
1347
|
+
antigravity_worker_execution: str
|
|
1348
|
+
executor_execution: str
|
|
1341
1349
|
|
|
1342
1350
|
|
|
1343
1351
|
def recommended_role_models() -> dict[str, str]:
|
|
@@ -1413,6 +1421,12 @@ def _resolve_model_bindings(inp: PrepareInputs, workers: list[str]) -> _ModelBin
|
|
|
1413
1421
|
executor_display_name=display_name,
|
|
1414
1422
|
executor_worker_agent=worker_agent,
|
|
1415
1423
|
executor_model_meta=model_meta,
|
|
1424
|
+
codex_worker_execution=_worker_execution_value(
|
|
1425
|
+
m["co"], "codex", "worker", workers),
|
|
1426
|
+
antigravity_worker_execution=_worker_execution_value(
|
|
1427
|
+
m["ge"], "antigravity", "worker", workers),
|
|
1428
|
+
executor_execution=_normalized_execution(
|
|
1429
|
+
model_meta, provider=executor_provider, role="executor"),
|
|
1416
1430
|
)
|
|
1417
1431
|
|
|
1418
1432
|
|
|
@@ -1421,7 +1435,7 @@ def _normalized_execution(meta: ModelAssignment, provider: str, role: str) -> st
|
|
|
1421
1435
|
so the manifest carries the dispatch-correct spelling. SSOT for the mapping
|
|
1422
1436
|
lives in model_discovery; this is only the run.py call seam."""
|
|
1423
1437
|
return normalize_execution_for_dispatch(
|
|
1424
|
-
provider=provider, execution=meta.execution,
|
|
1438
|
+
provider=provider, execution=meta.execution, role=role,
|
|
1425
1439
|
)
|
|
1426
1440
|
|
|
1427
1441
|
|
|
@@ -2176,22 +2190,16 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2176
2190
|
"CLAUDE_WORKER_MODEL": cw.display,
|
|
2177
2191
|
"CLAUDE_WORKER_MODEL_EXECUTION_VALUE": cw.execution,
|
|
2178
2192
|
"CODEX_WORKER_MODEL": co.display,
|
|
2179
|
-
"CODEX_WORKER_MODEL_EXECUTION_VALUE":
|
|
2180
|
-
co, "codex", "worker", workers,
|
|
2181
|
-
),
|
|
2193
|
+
"CODEX_WORKER_MODEL_EXECUTION_VALUE": models.codex_worker_execution,
|
|
2182
2194
|
"ANTIGRAVITY_WORKER_MODEL": ge.display,
|
|
2183
|
-
"ANTIGRAVITY_WORKER_MODEL_EXECUTION_VALUE":
|
|
2184
|
-
ge, "antigravity", "worker", workers,
|
|
2185
|
-
),
|
|
2195
|
+
"ANTIGRAVITY_WORKER_MODEL_EXECUTION_VALUE": models.antigravity_worker_execution,
|
|
2186
2196
|
"REPORT_WRITER_MODEL": rw.display,
|
|
2187
2197
|
"REPORT_WRITER_MODEL_EXECUTION_VALUE": rw.execution,
|
|
2188
2198
|
"EXECUTOR_PROVIDER": executor_provider,
|
|
2189
2199
|
"EXECUTOR_DISPLAY_NAME": executor_display_name,
|
|
2190
2200
|
"EXECUTOR_WORKER_AGENT": executor_worker_agent,
|
|
2191
2201
|
"EXECUTOR_MODEL_DISPLAY": executor_model_meta.display,
|
|
2192
|
-
"EXECUTOR_MODEL_EXECUTION_VALUE":
|
|
2193
|
-
executor_model_meta, provider=executor_provider, role="executor",
|
|
2194
|
-
),
|
|
2202
|
+
"EXECUTOR_MODEL_EXECUTION_VALUE": models.executor_execution,
|
|
2195
2203
|
"CRITIC_CHOICE": critic_choice,
|
|
2196
2204
|
"RELATED_TASKS_JSON": related_tasks_json_str,
|
|
2197
2205
|
"RELATED_TASKS_BULLETS": bullets,
|
|
@@ -65,17 +65,46 @@ def _session_has_agent_name(jsonl_path: Path) -> bool:
|
|
|
65
65
|
return False
|
|
66
66
|
|
|
67
67
|
|
|
68
|
+
def _session_mentions(jsonl_path: Path, needle: str) -> bool:
|
|
69
|
+
"""세션 transcript 어딘가에 *needle* 문자열이 등장하는가."""
|
|
70
|
+
try:
|
|
71
|
+
with jsonl_path.open(encoding="utf-8", errors="ignore") as fh:
|
|
72
|
+
return any(needle in line for line in fh)
|
|
73
|
+
except OSError:
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_lead_session_id_for_run(project_root: Path, run_dir: Path) -> str:
|
|
78
|
+
"""`run_dir` 를 다룬 lead 세션 중 가장 최근 수정된 것의 id, 없으면 ''.
|
|
79
|
+
|
|
80
|
+
프로젝트 디렉토리의 최신 jsonl 을 그대로 집으면(``resolve_inproc_lead_session_id``)
|
|
81
|
+
같은 프로젝트에서 동시에 도는 **다른 태스크의 세션**이 잡힌다 — 실측
|
|
82
|
+
(dev-10172): 남의 세션이 `leadSessionIds` 에 들어가 run 비용이 $80 → $207 로
|
|
83
|
+
부풀고, 그 세션의 PROGRESS 라인이 conformance 스캔에 섞여 오탐을 냈다.
|
|
84
|
+
자기 run 의 산출물 경로를 언급한 세션만 후보로 인정해 그 오염을 막는다.
|
|
85
|
+
"""
|
|
86
|
+
proj_dir = _claude_projects_dir_for(project_root)
|
|
87
|
+
try:
|
|
88
|
+
candidates = [p for p in proj_dir.iterdir() if p.suffix == ".jsonl"]
|
|
89
|
+
except OSError:
|
|
90
|
+
return ""
|
|
91
|
+
needle = str(run_dir)
|
|
92
|
+
for path in sorted(candidates, key=lambda p: p.stat().st_mtime, reverse=True):
|
|
93
|
+
if _session_has_agent_name(path):
|
|
94
|
+
continue
|
|
95
|
+
if _session_mentions(path, needle):
|
|
96
|
+
return path.stem
|
|
97
|
+
return ""
|
|
98
|
+
|
|
99
|
+
|
|
68
100
|
def record_observed_lead_session(project_root: Path, team_state_path: Path) -> str:
|
|
69
|
-
"""
|
|
70
|
-
|
|
101
|
+
"""이 run 의 live lead 세션을 관측해 team-state 에 append(멱등). 이 run 을
|
|
102
|
+
다룬 lead 세션을 못 찾거나 중복이면 '' 반환. 재발급으로 갈린 세대를 축1 이
|
|
103
|
+
여기에 모은다.
|
|
71
104
|
"""
|
|
72
|
-
sid =
|
|
105
|
+
sid = resolve_lead_session_id_for_run(project_root, team_state_path.parent.parent)
|
|
73
106
|
if not sid:
|
|
74
107
|
return ""
|
|
75
|
-
proj_dir = _claude_projects_dir_for(project_root)
|
|
76
|
-
jsonl = proj_dir / f"{sid}.jsonl"
|
|
77
|
-
if _session_has_agent_name(jsonl):
|
|
78
|
-
return ""
|
|
79
108
|
try:
|
|
80
109
|
state = json.loads(team_state_path.read_text(encoding="utf-8"))
|
|
81
110
|
except (OSError, json.JSONDecodeError):
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Phase A: stage done commit 을 task 브랜치에 위상순 --no-ff 머지(이미 머지면
|
|
4
4
|
skip, 충돌이면 해당 머지만 abort 후 IntegrateError). Phase B: 머지/skip 된
|
|
5
|
-
stage 의 worktree
|
|
5
|
+
stage 의 worktree 디렉터리와 registry 키를 정리(dirty 면 skip). stage 브랜치는
|
|
6
|
+
스택 보존을 위해 삭제하지 않는다.
|
|
6
7
|
설계: docs/superpowers/specs/2026-06-20-stage-auto-integrate-teardown-design.md
|
|
7
8
|
"""
|
|
8
9
|
from __future__ import annotations
|
|
@@ -64,7 +65,7 @@ def integrate_stages(
|
|
|
64
65
|
) -> IntegrateResult:
|
|
65
66
|
"""whole-task 통합: Phase A 머지 → Phase B teardown.
|
|
66
67
|
|
|
67
|
-
teardown=False 면 Phase B(stage worktree
|
|
68
|
+
teardown=False 면 Phase B(stage worktree 디렉터리·registry 키 정리)를 건너뛴다 —
|
|
68
69
|
container 는 머지만 하고 stage 트리를 보존한다(스펙 §10). final-verification
|
|
69
70
|
호출은 기본값(True)으로 기존 행위를 유지한다."""
|
|
70
71
|
from .consumers import latest_done_by_stage
|
|
@@ -137,11 +138,39 @@ def _remove_clean_worktree(task_wt: str, wt: str, stage_n: int) -> tuple[str, st
|
|
|
137
138
|
return None
|
|
138
139
|
|
|
139
140
|
|
|
141
|
+
def _unmerged_branch_tip_warning(task_wt: str, stage_n: int, branch: str) -> str:
|
|
142
|
+
"""Warn text when the stage branch tip is not an ancestor of the task
|
|
143
|
+
worktree HEAD, or "" when it is (or cannot be resolved).
|
|
144
|
+
|
|
145
|
+
Phase A merges `done.head_commit`, never the branch ref, so a stage branch
|
|
146
|
+
can carry commits that whole-task verification never saw. `local-checkout
|
|
147
|
+
--stage <N>` hands that branch to the user as a verified branch, so the
|
|
148
|
+
divergence has to be reported."""
|
|
149
|
+
from .worktree import is_ancestor
|
|
150
|
+
|
|
151
|
+
tip = _git(task_wt, "rev-parse", "--verify", "--quiet", branch)
|
|
152
|
+
if tip.returncode != 0:
|
|
153
|
+
return ""
|
|
154
|
+
sha = tip.stdout.strip()
|
|
155
|
+
head = _git(task_wt, "rev-parse", "HEAD").stdout.strip()
|
|
156
|
+
if is_ancestor(task_wt, sha, head):
|
|
157
|
+
return ""
|
|
158
|
+
return (f"stage {stage_n} 브랜치 {branch} 의 tip {sha[:12]} 이 검증된 task "
|
|
159
|
+
f"HEAD 에 포함되지 않습니다 — 검증에 포함되지 않은 커밋이 있습니다")
|
|
160
|
+
|
|
161
|
+
|
|
140
162
|
def _teardown_stage(project_id, task_group, task_id, task_wt, stage_n, row):
|
|
141
|
-
"""clean stage worktree
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
163
|
+
"""Remove the clean stage worktree directory and mark the registry row
|
|
164
|
+
released. The stage branch is deliberately kept: the per-stage stack
|
|
165
|
+
branches must survive whole-task verification so that
|
|
166
|
+
`okstra handoff local-checkout --stage <N>` still has a target. The
|
|
167
|
+
branch's registry slot is not touched here — `consumers`'
|
|
168
|
+
`_release_stage_reservation` already freed it at stage-done time.
|
|
169
|
+
|
|
170
|
+
Warns when the surviving branch tip carries commits the verified merge
|
|
171
|
+
never included. Teardown itself is already complete at that point, so the
|
|
172
|
+
warning reports the divergence rather than undoing the cleanup. Returns
|
|
173
|
+
('torn_down'|'skipped'|'warn', detail)."""
|
|
145
174
|
from . import worktree_registry
|
|
146
175
|
|
|
147
176
|
wt = (row or {}).get("worktree_path") or ""
|
|
@@ -152,12 +181,9 @@ def _teardown_stage(project_id, task_group, task_id, task_wt, stage_n, row):
|
|
|
152
181
|
return outcome
|
|
153
182
|
worktree_registry.release_status(project_id, task_group, task_id, stage_number=stage_n)
|
|
154
183
|
if branch:
|
|
155
|
-
|
|
156
|
-
if
|
|
157
|
-
return ("warn",
|
|
158
|
-
f"{bd.stderr.strip()}")
|
|
159
|
-
worktree_registry.free_branch_slot(project_id, task_group, task_id,
|
|
160
|
-
stage_number=stage_n)
|
|
184
|
+
warning = _unmerged_branch_tip_warning(task_wt, stage_n, branch)
|
|
185
|
+
if warning:
|
|
186
|
+
return ("warn", warning)
|
|
161
187
|
return ("torn_down", "")
|
|
162
188
|
|
|
163
189
|
|
|
@@ -24,7 +24,7 @@ from okstra_ctl.report_view_artifacts import user_responses_dir_for_report
|
|
|
24
24
|
from okstra_ctl.listing import list_runs, absolute_final_report_path
|
|
25
25
|
from okstra_ctl.clarification_items import (
|
|
26
26
|
parse_clarification_rows,
|
|
27
|
-
|
|
27
|
+
scan_open_user_input,
|
|
28
28
|
section_1_present_but_unparsed,
|
|
29
29
|
_section_1_slice,
|
|
30
30
|
)
|
|
@@ -118,7 +118,13 @@ def _seq_from_report(report: Path) -> str:
|
|
|
118
118
|
|
|
119
119
|
|
|
120
120
|
def list_awaiting_tasks(home: Path, project_id: str, limit: int) -> list[dict]:
|
|
121
|
-
"""
|
|
121
|
+
"""사용자 답변을 기다리는 clarification 이 있는 태스크를 최신 report mtime 순으로.
|
|
122
|
+
|
|
123
|
+
`Blocks=approval` 만 세면 열린 항목이 전부 `Blocks=next-phase` 인 리포트가
|
|
124
|
+
빈 목록으로 보이고, 스킬의 picker 자체에 진입할 수 없다(실측: dev-10172 —
|
|
125
|
+
열린 4건이 모두 next-phase 라 `list` 가 `[]` 를 냈다). 승인 게이트 판정은
|
|
126
|
+
`scan_approval_gate` 쪽 소비자들의 몫이고, 여기서는 답변 대기 전체를 센다.
|
|
127
|
+
"""
|
|
122
128
|
seen: set[str] = set()
|
|
123
129
|
out: list[dict] = []
|
|
124
130
|
for row in list_runs(home, project=project_id, limit=0): # startedAt desc
|
|
@@ -131,18 +137,21 @@ def list_awaiting_tasks(home: Path, project_id: str, limit: int) -> list[dict]:
|
|
|
131
137
|
if report is None or not report.is_file():
|
|
132
138
|
continue
|
|
133
139
|
text = report.read_text(encoding="utf-8")
|
|
134
|
-
scan =
|
|
140
|
+
scan = scan_open_user_input(text)
|
|
135
141
|
base = {"taskKey": key, "taskType": row.get("taskType", ""),
|
|
136
142
|
"seq": _seq_from_report(report), "reportPath": str(report),
|
|
137
143
|
"reportMtime": report.stat().st_mtime}
|
|
138
144
|
if scan.unreadable_reason is not None:
|
|
139
145
|
# §1 heading exists but drifted → real warning; §1 simply absent → skip.
|
|
140
146
|
if section_1_present_but_unparsed(text):
|
|
141
|
-
out.append({**base, "
|
|
147
|
+
out.append({**base, "openBlockerCount": 0, "openApprovalCount": 0,
|
|
148
|
+
"unreadable": True})
|
|
142
149
|
continue
|
|
143
150
|
if not scan.blockers:
|
|
144
151
|
continue
|
|
145
|
-
|
|
152
|
+
approval_count = sum(1 for it in scan.blockers if it.blocks == "approval")
|
|
153
|
+
out.append({**base, "openBlockerCount": len(scan.blockers),
|
|
154
|
+
"openApprovalCount": approval_count, "unreadable": False})
|
|
146
155
|
out.sort(key=lambda t: t["reportMtime"], reverse=True)
|
|
147
156
|
return out[:limit] if limit > 0 else out
|
|
148
157
|
|
|
@@ -9,6 +9,7 @@ the cadence budget live here rather than in either consumer.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
12
13
|
from datetime import datetime, timezone
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
|
|
@@ -21,6 +22,36 @@ HEARTBEAT_LINE_RE = re.compile(
|
|
|
21
22
|
# 고정 grace 60초를 더한다.
|
|
22
23
|
HEARTBEAT_MAX_GAP_SECONDS = 5 * 60 + 60
|
|
23
24
|
|
|
25
|
+
# 단일 도구 호출이 구간 전체를 차지해 워커가 하트비트를 끼워 넣을 수 없는 단계.
|
|
26
|
+
# 한 번의 Write 가 진행되는 동안에는 `in-stage:` 라인도 append 할 수 없으므로
|
|
27
|
+
# 5분 예산으로는 살아 있는 워커가 stalled 로 판정된다 (실측 dev-10172:
|
|
28
|
+
# report-writer 가 74KB data.json 한 개를 쓰는 11분 동안 무하트비트).
|
|
29
|
+
SINGLE_WRITE_STAGES = frozenset({
|
|
30
|
+
"data-json-write-start",
|
|
31
|
+
"render-start",
|
|
32
|
+
"write-result-start",
|
|
33
|
+
})
|
|
34
|
+
SINGLE_WRITE_MAX_GAP_SECONDS = 20 * 60 + 60
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def max_gap_seconds_after(stage: str) -> int:
|
|
38
|
+
"""*stage* 를 알린 뒤 다음 하트비트까지 허용되는 최대 공백(초).
|
|
39
|
+
|
|
40
|
+
간격이 재는 것은 직전에 선언된 단계의 작업 시간이므로, 예산은 언제나
|
|
41
|
+
구간을 *여는* 단계에서 고른다."""
|
|
42
|
+
return (
|
|
43
|
+
SINGLE_WRITE_MAX_GAP_SECONDS
|
|
44
|
+
if stage in SINGLE_WRITE_STAGES
|
|
45
|
+
else HEARTBEAT_MAX_GAP_SECONDS
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Heartbeat:
|
|
51
|
+
"""사이드카에 기록된 하나의 heartbeat."""
|
|
52
|
+
stage: str
|
|
53
|
+
at: datetime
|
|
54
|
+
|
|
24
55
|
|
|
25
56
|
def parse_timestamp(raw: str) -> datetime | None:
|
|
26
57
|
"""A heartbeat's ISO-8601 timestamp as an aware datetime, or None."""
|
|
@@ -31,7 +62,7 @@ def parse_timestamp(raw: str) -> datetime | None:
|
|
|
31
62
|
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
32
63
|
|
|
33
64
|
|
|
34
|
-
def latest_heartbeat(sidecar: Path) ->
|
|
65
|
+
def latest_heartbeat(sidecar: Path) -> Heartbeat | None:
|
|
35
66
|
"""The newest parseable heartbeat in *sidecar*, or None when it has none.
|
|
36
67
|
|
|
37
68
|
Takes the maximum rather than the last line: a regressing timestamp is the
|
|
@@ -42,9 +73,9 @@ def latest_heartbeat(sidecar: Path) -> datetime | None:
|
|
|
42
73
|
text = sidecar.read_text(encoding="utf-8")
|
|
43
74
|
except OSError:
|
|
44
75
|
return None
|
|
45
|
-
|
|
46
|
-
parsed
|
|
76
|
+
beats = [
|
|
77
|
+
Heartbeat(match.group("stage"), parsed)
|
|
47
78
|
for match in HEARTBEAT_LINE_RE.finditer(text)
|
|
48
79
|
if (parsed := parse_timestamp(match.group("ts"))) is not None
|
|
49
80
|
]
|
|
50
|
-
return max(
|
|
81
|
+
return max(beats, key=lambda beat: beat.at) if beats else None
|
|
@@ -28,7 +28,11 @@ import sys
|
|
|
28
28
|
from datetime import datetime, timezone
|
|
29
29
|
from pathlib import Path
|
|
30
30
|
|
|
31
|
-
from okstra_ctl.worker_heartbeat import
|
|
31
|
+
from okstra_ctl.worker_heartbeat import (
|
|
32
|
+
HEARTBEAT_MAX_GAP_SECONDS,
|
|
33
|
+
latest_heartbeat,
|
|
34
|
+
max_gap_seconds_after,
|
|
35
|
+
)
|
|
32
36
|
|
|
33
37
|
DEFAULT_LAUNCH_GRACE_SECONDS = 60
|
|
34
38
|
|
|
@@ -39,7 +43,12 @@ def _log_path(prompt: Path) -> Path:
|
|
|
39
43
|
|
|
40
44
|
|
|
41
45
|
def probe_heartbeat(sidecar: Path, now: datetime, max_idle: float) -> dict:
|
|
42
|
-
"""Liveness of one in-process worker, read from its audit sidecar.
|
|
46
|
+
"""Liveness of one in-process worker, read from its audit sidecar.
|
|
47
|
+
|
|
48
|
+
``max_idle`` is the floor. A stage whose work is one uninterruptible tool
|
|
49
|
+
call carries its own, longer budget (``max_gap_seconds_after``) — during a
|
|
50
|
+
single large Write the worker cannot append a heartbeat at all.
|
|
51
|
+
"""
|
|
43
52
|
probe = {"kind": "heartbeat", "path": str(sidecar)}
|
|
44
53
|
if not sidecar.is_file():
|
|
45
54
|
# The worker writes the sidecar early but not instantly; absence is the
|
|
@@ -52,15 +61,19 @@ def probe_heartbeat(sidecar: Path, now: datetime, max_idle: float) -> dict:
|
|
|
52
61
|
"state": "stalled",
|
|
53
62
|
"reason": "audit sidecar carries no `- PROGRESS:` heartbeat",
|
|
54
63
|
}
|
|
55
|
-
|
|
56
|
-
|
|
64
|
+
budget = max(max_idle, max_gap_seconds_after(beat.stage))
|
|
65
|
+
idle = (now - beat.at).total_seconds()
|
|
66
|
+
state = "stalled" if idle > budget else "live"
|
|
57
67
|
return {
|
|
58
68
|
**probe,
|
|
59
69
|
"state": state,
|
|
60
70
|
"idleSeconds": int(idle),
|
|
61
|
-
"lastHeartbeat": beat.isoformat(),
|
|
71
|
+
"lastHeartbeat": beat.at.isoformat(),
|
|
72
|
+
"lastStage": beat.stage,
|
|
73
|
+
"budgetSeconds": int(budget),
|
|
62
74
|
"reason": (
|
|
63
|
-
f"no heartbeat for {int(idle)}s
|
|
75
|
+
f"no heartbeat for {int(idle)}s after stage `{beat.stage}` "
|
|
76
|
+
f"(budget {int(budget)}s)"
|
|
64
77
|
if state == "stalled"
|
|
65
78
|
else ""
|
|
66
79
|
),
|
|
@@ -41,6 +41,7 @@ _NON_BODY_PREFIXES = (
|
|
|
41
41
|
"**Worker Preamble Path:**",
|
|
42
42
|
*ERRORS_PATH_HEADERS,
|
|
43
43
|
"**Read scope:**",
|
|
44
|
+
"**File write mode:**",
|
|
44
45
|
"**Worktree:**",
|
|
45
46
|
"**Verification scope:**",
|
|
46
47
|
"**Verification base ref:**",
|
|
@@ -63,6 +64,9 @@ _WORKER_SPECIFIC_PREFIXES = (
|
|
|
63
64
|
"Assigned worker prompt history path:",
|
|
64
65
|
"**Errors sidecar path:**",
|
|
65
66
|
"**Worker Result Path:**",
|
|
67
|
+
# Only antigravity carries this (worker_prompt_headers.PLAIN_FILE_WRITE_HEADER)
|
|
68
|
+
# because only agy has an artifact store; unstripped it reads as divergence.
|
|
69
|
+
"**File write mode:**",
|
|
66
70
|
"**Model:**",
|
|
67
71
|
"**Pane role:**",
|
|
68
72
|
)
|
|
@@ -37,6 +37,24 @@ READ_SCOPE_HEADER = (
|
|
|
37
37
|
"or Assumptions* instead of reading it."
|
|
38
38
|
)
|
|
39
39
|
|
|
40
|
+
# `agy`'s write tool validates the target against the Gemini artifact store
|
|
41
|
+
# whenever the model attaches ArtifactMetadata, and rejects every path outside
|
|
42
|
+
# `~/.gemini/antigravity-cli/brain/<uuid>/`. All okstra worker outputs live under
|
|
43
|
+
# `<PROJECT_ROOT>/.okstra/`, so an attached ArtifactMetadata fails the write —
|
|
44
|
+
# usually on the audit sidecar, after which the CLI exits 0 having persisted no
|
|
45
|
+
# result file. Only antigravity carries this: codex and claude have no artifact
|
|
46
|
+
# store, and a shared header would break the cross-worker prompt-body equality
|
|
47
|
+
# that `worker_prompt_contract` enforces.
|
|
48
|
+
PLAIN_FILE_WRITE_HEADER = (
|
|
49
|
+
"**File write mode:** Write every okstra output (result file, audit "
|
|
50
|
+
"sidecar, errors sidecar) as a plain project file at the absolute path this "
|
|
51
|
+
"prompt gives. Do NOT attach ArtifactMetadata and do NOT route these writes "
|
|
52
|
+
"through the Gemini artifact store — artifact paths are restricted to the "
|
|
53
|
+
"brain folder, so an artifact write under `<PROJECT_ROOT>/.okstra/` is "
|
|
54
|
+
"rejected and the run ends with no result file."
|
|
55
|
+
)
|
|
56
|
+
ANTIGRAVITY_WORKER_ID = "antigravity"
|
|
57
|
+
|
|
40
58
|
|
|
41
59
|
def worker_prompt_headers(
|
|
42
60
|
*,
|
|
@@ -81,6 +99,8 @@ def worker_prompt_headers(
|
|
|
81
99
|
f"**Errors sidecar path:** {errors_sidecar_path}",
|
|
82
100
|
READ_SCOPE_HEADER,
|
|
83
101
|
])
|
|
102
|
+
if worker_id == ANTIGRAVITY_WORKER_ID:
|
|
103
|
+
headers.append(PLAIN_FILE_WRITE_HEADER)
|
|
84
104
|
if _string_value(manifest.get("taskType")) == "improvement-discovery":
|
|
85
105
|
headers.append(
|
|
86
106
|
f"{GRILLING_LOG_HEADER} "
|
|
@@ -284,6 +284,43 @@ def _branch_exists(project_root: Path, branch: str) -> bool:
|
|
|
284
284
|
return res.returncode == 0
|
|
285
285
|
|
|
286
286
|
|
|
287
|
+
def _branch_checkout_path(project_root: Path, branch: str) -> str:
|
|
288
|
+
"""The worktree that currently has *branch* checked out, or "" when none does.
|
|
289
|
+
|
|
290
|
+
`git branch -D` refuses to delete a checked-out branch, so an existing-branch
|
|
291
|
+
error that only recommends `-D` is unfollowable whenever the branch is the
|
|
292
|
+
main checkout's HEAD. Callers use this to name the extra step instead.
|
|
293
|
+
"""
|
|
294
|
+
res = _git(project_root, "worktree", "list", "--porcelain")
|
|
295
|
+
if res.returncode != 0:
|
|
296
|
+
return ""
|
|
297
|
+
current = ""
|
|
298
|
+
for line in res.stdout.splitlines():
|
|
299
|
+
if line.startswith("worktree "):
|
|
300
|
+
current = line[len("worktree "):].strip()
|
|
301
|
+
elif line.strip() == f"branch refs/heads/{branch}":
|
|
302
|
+
return current
|
|
303
|
+
return ""
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _branch_exists_message(project_root: Path, branch: str, kind: str) -> str:
|
|
307
|
+
"""Existing-branch error text whose remedy is actually runnable."""
|
|
308
|
+
checkout = _branch_checkout_path(project_root, branch)
|
|
309
|
+
if checkout:
|
|
310
|
+
return (
|
|
311
|
+
f"{kind} worktree branch already exists: {branch}, and it is checked "
|
|
312
|
+
f"out at {checkout}. `git branch -D {branch}` will refuse while it is "
|
|
313
|
+
f"checked out — switch that checkout to another branch first "
|
|
314
|
+
f"(`git -C {checkout} switch <other-branch>`), then delete it, or "
|
|
315
|
+
"choose a different work-category before retrying."
|
|
316
|
+
)
|
|
317
|
+
return (
|
|
318
|
+
f"{kind} worktree branch already exists: {branch}. "
|
|
319
|
+
f"Delete it (`git branch -D {branch}`) or choose a different "
|
|
320
|
+
"work-category before retrying."
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
287
324
|
def _head_sha(cwd: Path) -> str:
|
|
288
325
|
res = _git(cwd, "rev-parse", "HEAD")
|
|
289
326
|
if res.returncode != 0:
|
|
@@ -768,11 +805,7 @@ def provision_task_worktree(
|
|
|
768
805
|
"(or `rm -rf` if it is not a registered worktree) before retrying."
|
|
769
806
|
)
|
|
770
807
|
if _branch_exists(project_root, branch):
|
|
771
|
-
raise RuntimeError(
|
|
772
|
-
f"task worktree branch already exists: {branch}. "
|
|
773
|
-
"Delete it (`git branch -D <branch>`) or choose a different "
|
|
774
|
-
"work-category before retrying."
|
|
775
|
-
)
|
|
808
|
+
raise RuntimeError(_branch_exists_message(project_root, branch, "task"))
|
|
776
809
|
|
|
777
810
|
main_root = main_worktree_path(project_root)
|
|
778
811
|
requested_base = (base_ref or "").strip()
|
|
@@ -921,10 +954,7 @@ def provision_stage_worktree(
|
|
|
921
954
|
f"{worktree_path}. Remove it before retrying."
|
|
922
955
|
)
|
|
923
956
|
if _branch_exists(project_root, branch):
|
|
924
|
-
raise RuntimeError(
|
|
925
|
-
f"stage worktree branch already exists: {branch}. "
|
|
926
|
-
"Delete it (`git branch -D <branch>`) before retrying."
|
|
927
|
-
)
|
|
957
|
+
raise RuntimeError(_branch_exists_message(project_root, branch, "stage"))
|
|
928
958
|
|
|
929
959
|
main_root = main_worktree_path(project_root)
|
|
930
960
|
resolved_sha = _resolve_commit_sha(main_root, base_commit)
|
|
@@ -275,10 +275,11 @@ def release_status(
|
|
|
275
275
|
) -> Optional[WorktreeEntry]:
|
|
276
276
|
"""Mark the entry as `released` WITHOUT freeing its branch index slot.
|
|
277
277
|
|
|
278
|
-
Use when the
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
278
|
+
Use when the caller must not touch the branch index — either the slot is
|
|
279
|
+
still needed, or another caller owns its lifetime. Stage teardown is the
|
|
280
|
+
latter case: `consumers._release_stage_reservation` already ran `release()`
|
|
281
|
+
at stage-done time, so teardown only flips the status. Returns the prior
|
|
282
|
+
entry, or None.
|
|
282
283
|
"""
|
|
283
284
|
key = task_key(project_id, task_group, task_id, stage_number, group_id)
|
|
284
285
|
with _registry_lock():
|
|
@@ -323,11 +324,12 @@ def release(
|
|
|
323
324
|
group_id: Optional[str] = None,
|
|
324
325
|
) -> Optional[WorktreeEntry]:
|
|
325
326
|
"""Mark the entry as `released` and free its branch slot (worktree dir
|
|
326
|
-
intact — preservation is the project's policy). Used
|
|
327
|
-
|
|
328
|
-
(
|
|
329
|
-
|
|
330
|
-
|
|
327
|
+
intact — preservation is the project's policy). Used at the points that
|
|
328
|
+
end a reservation's life: done-time stage release
|
|
329
|
+
(`consumers._release_stage_reservation`) and group cleanup. Both free the
|
|
330
|
+
slot even when the physical branch survives — stage stack branches are
|
|
331
|
+
deliberately kept, and their names are task-scoped, so a freed slot cannot
|
|
332
|
+
collide with a live branch from another task.
|
|
331
333
|
Returns the prior entry, or None when not found.
|
|
332
334
|
"""
|
|
333
335
|
entry = release_status(project_id, task_group, task_id, stage_number, group_id)
|
|
@@ -178,6 +178,55 @@ def _earliest_lead_session_ts(state: dict, team_state_path: Path) -> str | None:
|
|
|
178
178
|
return earliest
|
|
179
179
|
|
|
180
180
|
|
|
181
|
+
def _previous_run_suffix(run_dir: Path, suffix: str) -> str | None:
|
|
182
|
+
"""같은 task-type 에서 이 run 직전에 실행된 run 의 접미사, 없으면 None.
|
|
183
|
+
|
|
184
|
+
seq 는 연속이 아닐 수 있으므로(실패한 prep 이 번호를 소모한다) manifest 를
|
|
185
|
+
실제로 스캔해 현재 seq 미만 중 최대를 고른다."""
|
|
186
|
+
task_type, _, raw_seq = suffix.rpartition("-")
|
|
187
|
+
if not task_type or not raw_seq.isdigit():
|
|
188
|
+
return None
|
|
189
|
+
current = int(raw_seq)
|
|
190
|
+
prefix, width = f"run-manifest-{task_type}-", len(raw_seq)
|
|
191
|
+
earlier = []
|
|
192
|
+
try:
|
|
193
|
+
names = [p.name for p in (run_dir / "manifests").iterdir()]
|
|
194
|
+
except OSError:
|
|
195
|
+
return None
|
|
196
|
+
for name in names:
|
|
197
|
+
if not (name.startswith(prefix) and name.endswith(".json")):
|
|
198
|
+
continue
|
|
199
|
+
candidate = name[len(prefix):-len(".json")]
|
|
200
|
+
if candidate.isdigit() and int(candidate) < current:
|
|
201
|
+
earlier.append(int(candidate))
|
|
202
|
+
if not earlier:
|
|
203
|
+
return None
|
|
204
|
+
return f"{task_type}-{max(earlier):0{width}d}"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _previous_run_end(run_dir: Path, suffix: str) -> str | None:
|
|
208
|
+
"""직전 run 의 종료 시각 — 종료 산출물이 없으면 그 run 의 시작 시각.
|
|
209
|
+
|
|
210
|
+
`since` 완화의 하한이다. 한 세션에서 같은 task-type 을 두 번 돌리면
|
|
211
|
+
`leadSessionIds` 의 세션 jsonl 첫 ts 는 *첫 run* 의 시작을 가리키므로,
|
|
212
|
+
완화를 그대로 두면 seq002 의 윈도우가 seq001 구간을 통째로 삼킨다 —
|
|
213
|
+
seq001 의 PROGRESS 체크포인트가 seq002 검증에 섞여 순서 오탐을 낸다
|
|
214
|
+
(관측: dev-10172 — seq002 검증이 seq001 의 phase-6 타임스탬프를 인용)."""
|
|
215
|
+
prev = _previous_run_suffix(run_dir, suffix)
|
|
216
|
+
if prev is None:
|
|
217
|
+
return None
|
|
218
|
+
try:
|
|
219
|
+
prev_state = json.loads(
|
|
220
|
+
(run_dir / "state" / f"team-state-{prev}.json").read_text()
|
|
221
|
+
)
|
|
222
|
+
ended_at = prev_state.get("runEndedAt")
|
|
223
|
+
if ended_at:
|
|
224
|
+
return str(ended_at)
|
|
225
|
+
except (OSError, json.JSONDecodeError):
|
|
226
|
+
pass
|
|
227
|
+
return _run_end_estimate(run_dir, prev) or _run_manifest_created_at(run_dir, prev)
|
|
228
|
+
|
|
229
|
+
|
|
181
230
|
def resolve_run_window(team_state_path: Path, state: dict) -> tuple[str | None, str | None]:
|
|
182
231
|
"""이 run 의 [시작, 종료] ISO 윈도우.
|
|
183
232
|
|
|
@@ -193,7 +242,11 @@ def resolve_run_window(team_state_path: Path, state: dict) -> tuple[str | None,
|
|
|
193
242
|
늦게 찍히면(관측: dev-9902 — createdAt 11:43Z 가 05:34Z 시작 세션보다 늦음)
|
|
194
243
|
since 가 앞선 세션을 잘라낸다. Task 3 이 기록한 `leadSessionIds[]` 세션의
|
|
195
244
|
첫 ts 최소값이 createdAt 보다 앞서면 그 값으로 since 를 앞당긴다.
|
|
196
|
-
`leadSessionIds` 부재(legacy)면 createdAt 그대로 — 기존 동작 불변.
|
|
245
|
+
`leadSessionIds` 부재(legacy)면 createdAt 그대로 — 기존 동작 불변.
|
|
246
|
+
|
|
247
|
+
완화 하한: 직전 run 의 종료 시각 이전으로는 내려가지 않는다
|
|
248
|
+
(`_previous_run_end`). 세션 jsonl 은 run 마다 새로 생기지 않으므로 하한이
|
|
249
|
+
없으면 같은 세션의 이전 run 이 이 윈도우에 통째로 들어온다."""
|
|
197
250
|
suffix = run_artifact_suffix(team_state_path)
|
|
198
251
|
if not suffix:
|
|
199
252
|
return None, None
|
|
@@ -202,6 +255,9 @@ def resolve_run_window(team_state_path: Path, state: dict) -> tuple[str | None,
|
|
|
202
255
|
earliest = _earliest_lead_session_ts(state, team_state_path)
|
|
203
256
|
if earliest and (since is None or earliest < since):
|
|
204
257
|
since = earliest
|
|
258
|
+
floor = _previous_run_end(run_dir, suffix)
|
|
259
|
+
if floor and since and since < floor:
|
|
260
|
+
since = floor
|
|
205
261
|
until = state.get("runEndedAt") or _run_end_estimate(run_dir, suffix) or utc_now()
|
|
206
262
|
return since, until
|
|
207
263
|
|