okstra 0.133.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/python/okstra_ctl/clarification_items.py +34 -12
- package/runtime/python/okstra_ctl/run.py +18 -10
- package/runtime/python/okstra_ctl/session.py +36 -7
- 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/worktree.py +39 -9
- package/runtime/python/okstra_token_usage/collect.py +57 -1
- package/runtime/skills/okstra-user-response/SKILL.md +2 -2
- package/runtime/validators/validate-run.py +15 -6
- package/runtime/validators/validate_session_conformance.py +16 -6
|
@@ -34,7 +34,7 @@ okstra preflight --runtime claude-code --json
|
|
|
34
34
|
|
|
35
35
|
## Flow
|
|
36
36
|
|
|
37
|
-
1. **list**: `okstra user-response list --home <home> --project <projectId> --limit 3` → an array of `{taskKey, taskType, seq, reportPath, reportMtime, openApprovalCount, unreadable}
|
|
37
|
+
1. **list**: `okstra user-response list --home <home> --project <projectId> --limit 3` → an array of `{taskKey, taskType, seq, reportPath, reportMtime, openBlockerCount, openApprovalCount, unreadable}` (`openBlockerCount` = open rows with `Blocks` in `{approval, next-phase}`; `openApprovalCount` = the `approval`-only subset). If the array is empty, stop with "no open clarification". A 3-option picker (top recommendations + the final option always "Enter directly" for pasting a `reportPath`/`task-key` directly). `unreadable:true` is a §1 format drift — flag it with `⚠` and do not proceed (do not fabricate rows).
|
|
38
38
|
2. **show**: `okstra user-response show --report <reportPath>` → `rows[]` with `resolvedRefs` (the `definition` of internal tokens such as `RB-002`/`§4.7`). **Do not paste the raw `statement` as the question.** For each open row present: (a) a **self-contained question** with internal tokens expanded inline first, (b) `recommended` (only a recommendation) in plain language, (c) `alternatives[]`, (d) the raw `id`+`statement` as a secondary traceability aid. If `definition` is `null`, resolve the report `§`/`path:line` yourself **using Read**.
|
|
39
39
|
3. **decision (4 branches)**: the user picks one per item — Answer (accept the recommendation / own answer, verbatim), Ask-to-explain (explain only and hand the decision back to the user), Free-form (narrative verbatim), Hold+reframe (`disposition:"reframe"`, does not satisfy the approval gate). Each item's JSON: `{id, kind, value, rationale?, disposition}`.
|
|
40
40
|
4. **echo → confirmed gate**: before `write`, echo the whole collection (each `id`·`disposition`·`value`·`rationale`·approval) as-is and get explicit confirmation. Never `write` before `confirmed`. On any change, re-echo and re-confirm.
|
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -41,7 +41,9 @@ Do NOT duplicate the data.json contents here — the data.json is the canonical
|
|
|
41
41
|
|
|
42
42
|
## Heartbeat (BLOCKING)
|
|
43
43
|
|
|
44
|
-
Write the audit sidecar at `**Audit sidecar path:**` before required reading, then append `- PROGRESS: <stage> <ISO-8601-UTC>` at a cadence no longer than five minutes. Use only: `started`, `required-reading-complete`, `synthesis-start`, `data-json-write-start`, `render-start`, and `write-result-start`. The Phase 7 validator enforces the first stage, timestamps, cadence, and this allowlist.
|
|
44
|
+
Write the audit sidecar at `**Audit sidecar path:**` before required reading, then append `- PROGRESS: <stage> <ISO-8601-UTC>` at a cadence no longer than five minutes. Use only: `started`, `required-reading-complete`, `synthesis-start`, `data-json-write-start`, `render-start`, and `write-result-start`. When a stage runs longer than that, append `- PROGRESS: in-stage:<stage> <ISO-8601-UTC>` with the same stage names. The Phase 7 validator enforces the first stage, timestamps, cadence, and this allowlist.
|
|
45
|
+
|
|
46
|
+
`data-json-write-start`, `render-start` and `write-result-start` open a stretch whose work is one uninterruptible tool call, so no `in-stage:` line can be appended while it runs. Those three carry a 20-minute budget instead of five; every other stage keeps the five-minute cadence.
|
|
45
47
|
|
|
46
48
|
## Execution Rules
|
|
47
49
|
|
|
@@ -258,23 +258,44 @@ def parse_clarification_rows(report_text: str) -> list[dict]:
|
|
|
258
258
|
|
|
259
259
|
UNRESOLVED_STATUSES = {"open", "answered"}
|
|
260
260
|
|
|
261
|
+
# `Blocks` values that gate the user's `approved: true` flip.
|
|
262
|
+
APPROVAL_BLOCKS = frozenset({"approval"})
|
|
263
|
+
# `Blocks` values that owe the user an answer at all. `next-phase` rows never
|
|
264
|
+
# gate approval, but the next phase cannot start with them open either, so the
|
|
265
|
+
# user-response skill must list them — counting only `approval` made a report
|
|
266
|
+
# whose open items were all `next-phase` look like it had none.
|
|
267
|
+
USER_INPUT_BLOCKS = frozenset({"approval", "next-phase"})
|
|
268
|
+
|
|
261
269
|
|
|
262
270
|
@dataclass(frozen=True)
|
|
263
|
-
class
|
|
264
|
-
"""Fail-closed read of
|
|
271
|
+
class ClarificationScan:
|
|
272
|
+
"""Fail-closed read of §1 for rows blocking on one `Blocks` value set.
|
|
265
273
|
|
|
266
274
|
``unreadable_reason`` is ``None`` only when the scan is confident: §1
|
|
267
275
|
parsed cleanly (or is the legitimate table-less placeholder) and
|
|
268
276
|
``blockers`` is therefore authoritative. A non-None reason means the
|
|
269
|
-
|
|
277
|
+
caller must refuse to act — never soft-pass.
|
|
270
278
|
"""
|
|
271
279
|
blockers: list[ClarificationItem]
|
|
272
280
|
unreadable_reason: Optional[str]
|
|
273
281
|
|
|
274
282
|
|
|
275
|
-
def scan_approval_gate(report_text: str) ->
|
|
283
|
+
def scan_approval_gate(report_text: str) -> ClarificationScan:
|
|
276
284
|
"""Scan §1 for unresolved ``Blocks=approval`` rows (``Status`` in
|
|
277
285
|
``{open, answered}``), refusing to guess whenever the schema drifted."""
|
|
286
|
+
return scan_clarification_blockers(report_text, APPROVAL_BLOCKS)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def scan_open_user_input(report_text: str) -> ClarificationScan:
|
|
290
|
+
"""Scan §1 for every unresolved row that still owes the user an answer
|
|
291
|
+
(``Blocks`` in ``{approval, next-phase}``)."""
|
|
292
|
+
return scan_clarification_blockers(report_text, USER_INPUT_BLOCKS)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def scan_clarification_blockers(
|
|
296
|
+
report_text: str, blocking_values: frozenset[str]
|
|
297
|
+
) -> ClarificationScan:
|
|
298
|
+
"""Shared fail-closed §1 walk for both gates above."""
|
|
278
299
|
section = _section_1_slice(report_text)
|
|
279
300
|
if section is None:
|
|
280
301
|
if _LOOSE_SECTION_1_RE.search(report_text):
|
|
@@ -283,32 +304,33 @@ def scan_approval_gate(report_text: str) -> ApprovalGateScan:
|
|
|
283
304
|
"the schema heading format (anchor/format drift)"
|
|
284
305
|
)
|
|
285
306
|
else:
|
|
307
|
+
expected = "/".join(sorted(blocking_values))
|
|
286
308
|
reason = (
|
|
287
309
|
"report has no `## 1. Clarification Items` section — the gate "
|
|
288
|
-
"cannot confirm there are no unresolved `Blocks=
|
|
310
|
+
f"cannot confirm there are no unresolved `Blocks={expected}` rows"
|
|
289
311
|
)
|
|
290
|
-
return
|
|
312
|
+
return ClarificationScan([], reason)
|
|
291
313
|
table = _walk_section_1_table(section)
|
|
292
314
|
if table.items is None:
|
|
293
315
|
if table.has_pipe_lines:
|
|
294
|
-
return
|
|
316
|
+
return ClarificationScan([], (
|
|
295
317
|
"§1 contains a table but its header row is not the schema "
|
|
296
318
|
"header (`| ... | Statement | Expected form | User input |`)"
|
|
297
319
|
))
|
|
298
320
|
# Renderer's emptyState placeholder: heading is intact and no table
|
|
299
|
-
# was emitted — confidently "no
|
|
300
|
-
return
|
|
321
|
+
# was emitted — confidently "no blocking items".
|
|
322
|
+
return ClarificationScan([], None)
|
|
301
323
|
if table.unparsed_row_count:
|
|
302
|
-
return
|
|
324
|
+
return ClarificationScan([], (
|
|
303
325
|
f"§1 table has {table.unparsed_row_count} row(s) whose metadata "
|
|
304
326
|
"cell could not be parsed (Blocks/Status markers missing or "
|
|
305
327
|
"malformed)"
|
|
306
328
|
))
|
|
307
329
|
blockers = [
|
|
308
330
|
it for it in table.items
|
|
309
|
-
if it.blocks
|
|
331
|
+
if it.blocks in blocking_values and it.status in UNRESOLVED_STATUSES
|
|
310
332
|
]
|
|
311
|
-
return
|
|
333
|
+
return ClarificationScan(blockers, None)
|
|
312
334
|
|
|
313
335
|
|
|
314
336
|
# 느슨한 §1 헤딩 탐지: 엄격한 SECTION_HEADING_PATTERN 이 실패해도 이게 매칭되면
|
|
@@ -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
|
|
|
@@ -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):
|
|
@@ -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
|
),
|
|
@@ -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)
|
|
@@ -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
|
|
|
@@ -55,10 +55,10 @@ okstra user-response list --home <resolved-home> --project <projectId> --limit 3
|
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
Returns a JSON array (latest report mtime first); each entry:
|
|
58
|
-
`{taskKey, taskType, seq, reportPath, reportMtime, openApprovalCount, unreadable}`.
|
|
58
|
+
`{taskKey, taskType, seq, reportPath, reportMtime, openBlockerCount, openApprovalCount, unreadable}`. `openBlockerCount` counts every open row that still owes the user an answer (`Blocks` = `approval` or `next-phase`); `openApprovalCount` is the `approval`-only subset that gates the frontmatter `approved` flip.
|
|
59
59
|
|
|
60
60
|
- Empty array → answer `No task has open clarification items.` and stop.
|
|
61
|
-
- Otherwise present a **3-option picker**: the top recommendations from the list (each shown as `taskKey (taskType,
|
|
61
|
+
- Otherwise present a **3-option picker**: the top recommendations from the list (each shown as `taskKey (taskType, <openBlockerCount> open items)`), and the **final option is always "Enter directly"** — where the user pastes a `reportPath` or a `task-key` directly (for a task not in the top-3 window).
|
|
62
62
|
- An entry with `unreadable: true` means its `## 1. Clarification Items` heading exists but drifted from the expected format. Flag it as `⚠ §1 format drift — the CLI could not parse its items` and do not proceed on it until the report is regenerated; do not fabricate rows for it.
|
|
63
63
|
|
|
64
64
|
Carry the chosen entry's `reportPath` and `taskKey` forward.
|
|
@@ -96,19 +96,28 @@ def utc_now() -> str:
|
|
|
96
96
|
_CLI_WRAPPER_AGENTS = frozenset({"codex", "antigravity"})
|
|
97
97
|
|
|
98
98
|
|
|
99
|
-
def
|
|
99
|
+
def _unavailable_usage_worker_labels(team_state: dict) -> frozenset[str]:
|
|
100
100
|
"""Token-table row labels whose `--` cells are honest.
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
Two ways a row legitimately renders `--`: a CLI-wrapper provider (tokens
|
|
103
|
+
live inside its own CLI) and any worker whose collected
|
|
104
|
+
`usage.source` is `unavailable`. The latter is not a value a worker typed —
|
|
105
|
+
the renderer (`okstra_token_usage/report.py` `_worker_detail_row`) emits
|
|
106
|
+
null cells for it, and the collector records *why* in the same block. Gating
|
|
107
|
+
it made an in-process worker the collector could not attribute fail the whole
|
|
108
|
+
run (observed dev-10172: no subagent jsonl carried the report-writer's
|
|
109
|
+
agentName, so a completed run was reported as contract-violated).
|
|
110
|
+
|
|
111
|
+
Labels mirror `_worker_detail_label` (`"{role} ({agent}, {status})"`) so the
|
|
112
|
+
match is on the rendered label the scanner actually sees.
|
|
105
113
|
"""
|
|
106
114
|
labels = set()
|
|
107
115
|
for worker in team_state.get("workers") or []:
|
|
108
116
|
if not isinstance(worker, dict):
|
|
109
117
|
continue
|
|
110
118
|
agent = str(worker.get("agent") or "").strip()
|
|
111
|
-
if
|
|
119
|
+
usage = worker.get("usage") if isinstance(worker.get("usage"), dict) else {}
|
|
120
|
+
if agent not in _CLI_WRAPPER_AGENTS and usage.get("source") != "unavailable":
|
|
112
121
|
continue
|
|
113
122
|
role = str(worker.get("role") or worker.get("workerId") or "Worker").strip()
|
|
114
123
|
status = str(worker.get("status") or "").strip()
|
|
@@ -5778,7 +5787,7 @@ def main() -> int:
|
|
|
5778
5787
|
contract["required_agent_status_entries"],
|
|
5779
5788
|
failures,
|
|
5780
5789
|
allow_unavailable_token_usage=_session_accounting(team_state) == "artifact-only",
|
|
5781
|
-
unavailable_ok_labels=
|
|
5790
|
+
unavailable_ok_labels=_unavailable_usage_worker_labels(team_state),
|
|
5782
5791
|
)
|
|
5783
5792
|
validate_team_state_usage(team_state, failures)
|
|
5784
5793
|
|
|
@@ -36,7 +36,7 @@ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "
|
|
|
36
36
|
|
|
37
37
|
from okstra_ctl.worker_heartbeat import ( # noqa: E402
|
|
38
38
|
HEARTBEAT_LINE_RE,
|
|
39
|
-
|
|
39
|
+
max_gap_seconds_after,
|
|
40
40
|
)
|
|
41
41
|
from okstra_ctl.wrapper_status import read_wrapper_status # noqa: E402
|
|
42
42
|
|
|
@@ -66,7 +66,9 @@ _PROGRESS_LINE_RE = re.compile(
|
|
|
66
66
|
# heartbeat 라인 shape 과 cadence 예산은 okstra_ctl.worker_heartbeat 정본을 쓴다 —
|
|
67
67
|
# `okstra worker-liveness` 가 run 도중 같은 판정을 내리므로 정의가 갈리면 안 된다.
|
|
68
68
|
_HEARTBEAT_LINE_RE = HEARTBEAT_LINE_RE
|
|
69
|
-
|
|
69
|
+
|
|
70
|
+
# 장기 단계 중 append 되는 진행 라인 (claude-worker.md "Heartbeat").
|
|
71
|
+
_IN_STAGE_PREFIX = "in-stage:"
|
|
70
72
|
|
|
71
73
|
# Phase 5/6 진입 전 lead 가 Read 해야 하는 implementation 프로파일 sidecar.
|
|
72
74
|
# 절대 경로는 레이어(repo / runtime / 설치본)마다 다르지만 basename 은 동일하다.
|
|
@@ -546,7 +548,10 @@ def _check_heartbeat_sidecar(path: Path, worker_id: str, errors: list[str]) -> N
|
|
|
546
548
|
)
|
|
547
549
|
if worker_id == "report-writer":
|
|
548
550
|
for stage, _raw_ts in entries:
|
|
549
|
-
|
|
551
|
+
# `in-stage:<stage>` 는 계약이 장기 단계에 요구하는 진행 라인이다 —
|
|
552
|
+
# allowlist 가 이를 배제하면 워커는 cadence 를 지킬 방법이 없다.
|
|
553
|
+
base = stage[len(_IN_STAGE_PREFIX):] if stage.startswith(_IN_STAGE_PREFIX) else stage
|
|
554
|
+
if base not in _REPORT_WRITER_HEARTBEAT_STAGES:
|
|
550
555
|
errors.append(
|
|
551
556
|
f"`{rel}`: heartbeat stage `{stage}` is not allowed for report-writer."
|
|
552
557
|
)
|
|
@@ -560,6 +565,7 @@ def _check_heartbeat_sidecar(path: Path, worker_id: str, errors: list[str]) -> N
|
|
|
560
565
|
"stage must append its own line (agents/workers/claude-worker.md 'Heartbeat')."
|
|
561
566
|
)
|
|
562
567
|
prev: datetime | None = None
|
|
568
|
+
prev_stage = ""
|
|
563
569
|
for stage, raw_ts in entries:
|
|
564
570
|
ts = _parse_iso(raw_ts)
|
|
565
571
|
if ts is None:
|
|
@@ -569,18 +575,22 @@ def _check_heartbeat_sidecar(path: Path, worker_id: str, errors: list[str]) -> N
|
|
|
569
575
|
)
|
|
570
576
|
continue
|
|
571
577
|
if prev is not None:
|
|
578
|
+
# 예산은 구간을 여는 단계에서 고른다 — 이 공백이 재는 것은 직전에
|
|
579
|
+
# 선언된 단계의 작업 시간이다.
|
|
580
|
+
budget = max_gap_seconds_after(prev_stage)
|
|
572
581
|
gap = (ts - prev).total_seconds()
|
|
573
582
|
if gap < 0:
|
|
574
583
|
errors.append(
|
|
575
584
|
f"`{rel}`: heartbeat timestamps regress at stage `{stage}` ({raw_ts})."
|
|
576
585
|
)
|
|
577
|
-
elif gap >
|
|
586
|
+
elif gap > budget:
|
|
578
587
|
errors.append(
|
|
579
|
-
f"`{rel}`: heartbeat gap
|
|
580
|
-
"
|
|
588
|
+
f"`{rel}`: heartbeat gap after stage `{prev_stage}` is {int(gap)}s "
|
|
589
|
+
f"(budget {budget}s) — emit "
|
|
581
590
|
"`- PROGRESS: in-stage:<stage> <ISO>` during long stages."
|
|
582
591
|
)
|
|
583
592
|
prev = ts
|
|
593
|
+
prev_stage = stage
|
|
584
594
|
|
|
585
595
|
|
|
586
596
|
def _check_worker_liveness(
|