okstra 0.149.0 → 0.150.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.md +1 -1
- package/docs/cli.md +1 -1
- package/docs/project-structure-overview.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/translator-worker.md +67 -0
- package/runtime/bin/okstra-render-final-report.py +0 -11
- package/runtime/bin/okstra-report-translate.py +158 -0
- package/runtime/prompts/lead/adapters/claude-code.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -0
- package/runtime/prompts/lead/report-writer.md +14 -13
- package/runtime/prompts/lead/team-contract.md +2 -2
- package/runtime/prompts/wizard/prompts.ko.json +17 -1
- package/runtime/python/okstra_ctl/analysis_inputs.py +24 -9
- package/runtime/python/okstra_ctl/analysis_packet.py +23 -1
- package/runtime/python/okstra_ctl/clarification_items.py +241 -44
- package/runtime/python/okstra_ctl/codex_dispatch.py +2 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +2 -2
- package/runtime/python/okstra_ctl/dispatch_state.py +12 -1
- package/runtime/python/okstra_ctl/final_report_paths.py +22 -1
- package/runtime/python/okstra_ctl/i18n.py +12 -7
- package/runtime/python/okstra_ctl/render_final_report.py +18 -17
- package/runtime/python/okstra_ctl/report_html/filters.py +15 -77
- package/runtime/python/okstra_ctl/report_html/render.py +44 -2
- package/runtime/python/okstra_ctl/report_translation.py +440 -0
- package/runtime/python/okstra_ctl/report_views.py +23 -9
- package/runtime/python/okstra_ctl/run.py +1 -1
- package/runtime/python/okstra_ctl/user_response.py +11 -6
- package/runtime/python/okstra_ctl/wizard.py +100 -25
- package/runtime/python/okstra_ctl/worker_liveness.py +130 -36
- package/runtime/templates/reports/html/base.template.html +12 -12
- package/runtime/templates/reports/html/i18n/en.json +395 -0
- package/runtime/templates/reports/html/i18n/ko.json +395 -0
- package/runtime/templates/reports/html/macros/forms.html +16 -16
- package/runtime/templates/reports/html/macros/visualizations.html +2 -2
- package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +17 -17
- package/runtime/templates/reports/html/tasks/error-analysis.template.html +12 -12
- package/runtime/templates/reports/html/tasks/feature-analysis.template.html +16 -16
- package/runtime/templates/reports/html/tasks/final-verification.template.html +12 -12
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +37 -37
- package/runtime/templates/reports/html/tasks/implementation.template.html +18 -18
- package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +7 -7
- package/runtime/templates/reports/html/tasks/project-analysis.template.html +29 -29
- package/runtime/templates/reports/html/tasks/release-handoff.template.html +13 -13
- package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +14 -14
- package/runtime/templates/reports/report.js +8 -5
- package/runtime/validators/validate-report-views.py +1 -1
- package/runtime/validators/validate-run.py +28 -31
- package/src/cli-registry.mjs +11 -0
- package/src/commands/inspect/worker-liveness.mjs +9 -7
- package/src/commands/report/translate.mjs +31 -0
- package/src/lib/helper-scripts.mjs +1 -0
- package/runtime/templates/reports/i18n/ko.json +0 -273
|
@@ -132,12 +132,23 @@ def _infer_run_meta_from_path(path: Path) -> dict:
|
|
|
132
132
|
return {"task_type": m.group("task_type"), "seq": m.group("seq")} if m else {}
|
|
133
133
|
|
|
134
134
|
|
|
135
|
+
# 리포트 헤더는 이 값들을 인라인 코드로 렌더한다 (`- Task Type: \`x\``). 마커를
|
|
136
|
+
# 벗기지 않으면 백틱이 sidecar 파일명과 frontmatter 로 새고, 다음 run 의
|
|
137
|
+
# clarification 캐리인이 `user-response-<task-type>-<seq>.md` 매칭에 실패한다.
|
|
138
|
+
_INLINE_CODE_VALUE_RE = re.compile(r"^`(?P<value>.+)`$")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _strip_inline_code(value: str) -> str:
|
|
142
|
+
m = _INLINE_CODE_VALUE_RE.match(value)
|
|
143
|
+
return m.group("value").strip() if m else value
|
|
144
|
+
|
|
145
|
+
|
|
135
146
|
def _infer_run_meta_from_body(text: str) -> dict:
|
|
136
147
|
found: dict[str, str] = {}
|
|
137
148
|
for label, key in (("Task Key", "task_key"), ("Task Type", "task_type")):
|
|
138
149
|
m = re.search(rf"^- {label}:\s*(\S.*?)\s*$", text, re.MULTILINE)
|
|
139
150
|
if m:
|
|
140
|
-
found[key] = m.group(1)
|
|
151
|
+
found[key] = _strip_inline_code(m.group(1))
|
|
141
152
|
return found
|
|
142
153
|
|
|
143
154
|
|
|
@@ -145,9 +156,14 @@ def infer_run_meta(report_path: Path, *, task_key: Optional[str] = None,
|
|
|
145
156
|
task_type: Optional[str] = None, seq: Optional[str] = None,
|
|
146
157
|
source_report: Optional[str] = None) -> RunMeta:
|
|
147
158
|
"""Derive a ``RunMeta`` from a final-report path/body, honouring any
|
|
148
|
-
explicit override.
|
|
149
|
-
|
|
150
|
-
|
|
159
|
+
explicit override.
|
|
160
|
+
|
|
161
|
+
Used by the schema-v1 HTML view render script and by the in-session
|
|
162
|
+
user-response writer. Schema-v2 resolves its own ``HtmlRunMeta`` from the
|
|
163
|
+
data.json header instead, so the sidecar match keys (task_type/seq/
|
|
164
|
+
source-report) this produces must agree with that header — the sidecar
|
|
165
|
+
name the v2 HTML advertises is the one the next run's carry-in looks for.
|
|
166
|
+
"""
|
|
151
167
|
text = report_path.read_text(encoding="utf-8")
|
|
152
168
|
inferred = {**_infer_run_meta_from_path(report_path), **_infer_run_meta_from_body(text)}
|
|
153
169
|
return RunMeta(
|
|
@@ -1194,9 +1210,7 @@ def analysis_review_context(src_md_path: Path) -> AnalysisReviewContext | None:
|
|
|
1194
1210
|
return AnalysisReviewContext(selector_ids=tuple(sorted(found)))
|
|
1195
1211
|
|
|
1196
1212
|
|
|
1197
|
-
def plan_approval_context(
|
|
1198
|
-
src_md_path: Path, src_text: str
|
|
1199
|
-
) -> PlanApprovalContext | None:
|
|
1213
|
+
def plan_approval_context(src_md_path: Path) -> PlanApprovalContext | None:
|
|
1200
1214
|
"""implementation-planning 보고서 + sibling data.json 의 optionCandidates 가
|
|
1201
1215
|
있을 때만 컨텍스트를 만든다. planning 여부는 task-type 문자열이 아니라
|
|
1202
1216
|
data.json 의 ``implementationPlanning`` 키(SSOT)로 판정한다 — renderer 와
|
|
@@ -1209,7 +1223,7 @@ def plan_approval_context(
|
|
|
1209
1223
|
state = plan_approval_state(data)
|
|
1210
1224
|
if state is None:
|
|
1211
1225
|
return None
|
|
1212
|
-
scan = scan_approval_gate(
|
|
1226
|
+
scan = scan_approval_gate(src_md_path)
|
|
1213
1227
|
blocker_ids = state.blocker_ids
|
|
1214
1228
|
reason = state.disabled_reason
|
|
1215
1229
|
if scan.unreadable_reason:
|
|
@@ -1418,7 +1432,7 @@ def render_html_view(
|
|
|
1418
1432
|
"re-render the report so §1 matches the schema before generating "
|
|
1419
1433
|
"the HTML view."
|
|
1420
1434
|
)
|
|
1421
|
-
approval_ctx = plan_approval_context(src_md_path
|
|
1435
|
+
approval_ctx = plan_approval_context(src_md_path)
|
|
1422
1436
|
analysis_review_ctx = analysis_review_context(src_md_path)
|
|
1423
1437
|
reader_ctx = reader_dashboard_context(src_md_path, src_text, approval_ctx)
|
|
1424
1438
|
has_clarifications = report_has_clarification_items(src_text)
|
|
@@ -434,7 +434,7 @@ def _validate_approved_plan(path: str) -> None:
|
|
|
434
434
|
_validate_data_json_approval_consistency(p, markdown_approved=True)
|
|
435
435
|
# frontmatter approved == true 상태. §1 Clarification Items 의
|
|
436
436
|
# Blocks=approval 행이 아직 open/answered 면 승인을 무효화한다.
|
|
437
|
-
scan = scan_approval_gate(
|
|
437
|
+
scan = scan_approval_gate(p)
|
|
438
438
|
if scan.unreadable_reason:
|
|
439
439
|
raise PrepareError(
|
|
440
440
|
f"approved plan §1 approval gate could not be read: {path}\n"
|
|
@@ -24,7 +24,7 @@ from okstra_ctl.report_views import (
|
|
|
24
24
|
from okstra_ctl.report_view_artifacts import user_responses_dir_for_report
|
|
25
25
|
from okstra_ctl.listing import list_runs, absolute_final_report_path
|
|
26
26
|
from okstra_ctl.clarification_items import (
|
|
27
|
-
|
|
27
|
+
read_clarification_rows,
|
|
28
28
|
scan_open_user_input,
|
|
29
29
|
section_1_present_but_unparsed,
|
|
30
30
|
_section_1_slice,
|
|
@@ -320,9 +320,14 @@ def load_authoritative_analysis_review(
|
|
|
320
320
|
if _analysis_review_matches_with_valid_created_at(text):
|
|
321
321
|
attached.append(f"\n## {sidecar.name}\n\n{text.strip()}\n")
|
|
322
322
|
if not attached:
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
323
|
+
# `sidecar_name` matches every user-response sidecar for this run, not
|
|
324
|
+
# just review ones — recording a clarification answer produces the same
|
|
325
|
+
# filename. No `## ANALYSIS REVIEW` block in any of them means no review
|
|
326
|
+
# was attached, which is what `None` says; raising here made the routine
|
|
327
|
+
# act of answering a clarification disqualify the report as a carry-in
|
|
328
|
+
# candidate. A block that *is* present but malformed still raises, in
|
|
329
|
+
# `_analysis_review_matches_with_valid_created_at` above.
|
|
330
|
+
return None
|
|
326
331
|
review = parse_analysis_review("".join(attached))
|
|
327
332
|
if review is None:
|
|
328
333
|
raise UserResponseError("analysis review sidecar is unreadable")
|
|
@@ -443,7 +448,7 @@ def list_awaiting_tasks(home: Path, project_id: str, limit: int) -> list[dict]:
|
|
|
443
448
|
if report is None or not report.is_file():
|
|
444
449
|
continue
|
|
445
450
|
text = report.read_text(encoding="utf-8")
|
|
446
|
-
scan = scan_open_user_input(
|
|
451
|
+
scan = scan_open_user_input(report)
|
|
447
452
|
base = {"taskKey": key, "taskType": row.get("taskType", ""),
|
|
448
453
|
"seq": _seq_from_report(report), "reportPath": str(report),
|
|
449
454
|
"reportMtime": report.stat().st_mtime}
|
|
@@ -541,7 +546,7 @@ def resolve_refs(report_text: str, refs: list[str]) -> list[dict]:
|
|
|
541
546
|
def show_open_rows(report_path: Path) -> dict:
|
|
542
547
|
text = report_path.read_text(encoding="utf-8")
|
|
543
548
|
rows = []
|
|
544
|
-
for r in
|
|
549
|
+
for r in read_clarification_rows(report_path):
|
|
545
550
|
it = r["item"]
|
|
546
551
|
if it.status not in ("open", "answered"):
|
|
547
552
|
continue
|
|
@@ -185,6 +185,8 @@ _RECOMMENDATION_CAP = 3
|
|
|
185
185
|
# Pick-vs-free-text tokens shared by suggestion-aware prompts.
|
|
186
186
|
PICK_USE_SUGGESTED = "__use_suggested__"
|
|
187
187
|
PICK_TYPE_CUSTOM = "__free_input__"
|
|
188
|
+
# workers_override 에서 "옵션 워커를 추가하지 않음" 을 뜻하는 sentinel.
|
|
189
|
+
_DEFAULT_ROSTER_TOKEN = "__default_roster__"
|
|
188
190
|
_RECENT_PREFIX = "__recent:"
|
|
189
191
|
_REPORT_PREFIX = "__report:"
|
|
190
192
|
_BRIEF_PREFIX = "__brief:"
|
|
@@ -318,6 +320,7 @@ S_CRITIC_TEXT = "critic_text"
|
|
|
318
320
|
S_REUSE_PREVIOUS = "reuse_previous"
|
|
319
321
|
S_DEFAULTS_OR_CUSTOM = "defaults_or_custom"
|
|
320
322
|
S_WORKERS_OVERRIDE = "workers_override"
|
|
323
|
+
S_WORKERS_CUSTOM = "workers_custom"
|
|
321
324
|
S_LEAD_MODEL = "lead_model"
|
|
322
325
|
S_EXECUTOR_MODEL = "executor_model"
|
|
323
326
|
S_CLAUDE_MODEL = "claude_model"
|
|
@@ -444,6 +447,7 @@ class WizardState:
|
|
|
444
447
|
# customize
|
|
445
448
|
use_defaults: Optional[bool] = None
|
|
446
449
|
workers_override: str = ""
|
|
450
|
+
workers_custom_pending: bool = False
|
|
447
451
|
lead_provider: str = ""
|
|
448
452
|
lead_model: str = ""
|
|
449
453
|
claude_model: str = ""
|
|
@@ -632,7 +636,7 @@ def _classify_approved_plan(path_str: str, project_root: Path) -> tuple[Path, bo
|
|
|
632
636
|
# A blocking gate or an open Blocks=approval row makes the plan UN-approvable
|
|
633
637
|
# — these raise regardless of the current flag value.
|
|
634
638
|
_reject_blocking_plan_body_gate(p, body, action="approved plan validation")
|
|
635
|
-
scan = scan_approval_gate(
|
|
639
|
+
scan = scan_approval_gate(p)
|
|
636
640
|
if scan.unreadable_reason:
|
|
637
641
|
raise WizardError(
|
|
638
642
|
f"approved plan §1 approval gate could not be read: {p}\n"
|
|
@@ -3367,35 +3371,29 @@ def _submit_defaults_or_custom(state: WizardState, value: str) -> Optional[str]:
|
|
|
3367
3371
|
return f"model-mode: {mode}"
|
|
3368
3372
|
|
|
3369
3373
|
|
|
3370
|
-
def
|
|
3371
|
-
"""분석
|
|
3372
|
-
|
|
3373
|
-
t = _p(state.workspace_root, "workers_override")
|
|
3374
|
-
optional_suffix = t["options"].get("_OPTIONAL_SUFFIX", "")
|
|
3375
|
-
analyser_choices = [
|
|
3374
|
+
def _analyser_choices(state: WizardState) -> list[str]:
|
|
3375
|
+
"""프로필이 분석 워커로 허용하는 전체 후보 (report-writer 제외)."""
|
|
3376
|
+
return [
|
|
3376
3377
|
w for w in (state.profile_workers + state.profile_optional_workers)
|
|
3377
3378
|
if w != "report-writer"
|
|
3378
3379
|
]
|
|
3379
|
-
options: list[Option] = []
|
|
3380
|
-
for w in analyser_choices:
|
|
3381
|
-
is_optional = w in state.profile_optional_workers
|
|
3382
|
-
label = f"{w}{optional_suffix}" if is_optional else w
|
|
3383
|
-
options.append(_opt(value=w, label=label))
|
|
3384
|
-
return Prompt(
|
|
3385
|
-
step=S_WORKERS_OVERRIDE, kind="pick", multi=True,
|
|
3386
|
-
label=t["label"],
|
|
3387
|
-
options=options,
|
|
3388
|
-
echo_template=t["echo_template"],
|
|
3389
|
-
)
|
|
3390
3380
|
|
|
3391
3381
|
|
|
3392
|
-
def
|
|
3393
|
-
|
|
3382
|
+
def _default_analysers(state: WizardState) -> list[str]:
|
|
3383
|
+
"""옵션 워커를 하나도 고르지 않았을 때 쓰는 기본 분석 로스터."""
|
|
3384
|
+
return [w for w in state.profile_workers if w != "report-writer"]
|
|
3385
|
+
|
|
3386
|
+
|
|
3387
|
+
def _analyser_option(state: WizardState, worker: str, suffix: str) -> Option:
|
|
3388
|
+
label = f"{worker}{suffix}" if worker in state.profile_optional_workers else worker
|
|
3389
|
+
return _opt(value=worker, label=label)
|
|
3390
|
+
|
|
3391
|
+
|
|
3392
|
+
def _finalize_workers(state: WizardState, workers: list[str]) -> str:
|
|
3393
|
+
"""정규화 → 프로필 allowlist 검증 → report-writer 강제 포함까지의 확정 경로.
|
|
3394
|
+
두 워커 단계가 공유한다."""
|
|
3394
3395
|
try:
|
|
3395
|
-
chosen = normalize_workers(
|
|
3396
|
-
if not chosen:
|
|
3397
|
-
t = _p(state.workspace_root, "workers_override")
|
|
3398
|
-
raise WizardError(t["errors"]["min_one_required"])
|
|
3396
|
+
chosen = normalize_workers(",".join(workers))
|
|
3399
3397
|
validate_workers_against_profile(
|
|
3400
3398
|
chosen,
|
|
3401
3399
|
state.profile_workers,
|
|
@@ -3411,6 +3409,72 @@ def _submit_workers_override(state: WizardState, value: str) -> Optional[str]:
|
|
|
3411
3409
|
return f"workers: {state.workers_override}"
|
|
3412
3410
|
|
|
3413
3411
|
|
|
3412
|
+
def _build_workers_override(state: WizardState) -> Prompt:
|
|
3413
|
+
"""분석 워커 멀티픽. 기본 로스터(예: claude·codex)는 매 run 사실상 고정이라
|
|
3414
|
+
옵션에서 빼고 결과에 항상 포함시킨다 — 화면에는 '기본 그대로' + 옵션 워커 +
|
|
3415
|
+
'직접 선택'만 남는다. report-writer 도 같은 이유로 빠진다. 기본 로스터에서
|
|
3416
|
+
워커를 빼는 축소는 '직접 선택'(`workers_custom`)에서만 가능하다."""
|
|
3417
|
+
t = _p(state.workspace_root, "workers_override")
|
|
3418
|
+
labels = t["labels"]
|
|
3419
|
+
options = [_opt(
|
|
3420
|
+
_DEFAULT_ROSTER_TOKEN,
|
|
3421
|
+
labels["default_roster"].format(
|
|
3422
|
+
workers=" + ".join(_default_analysers(state))),
|
|
3423
|
+
)]
|
|
3424
|
+
for w in state.profile_optional_workers:
|
|
3425
|
+
options.append(_opt(w, labels["add_optional"].format(worker=w)))
|
|
3426
|
+
options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
|
|
3427
|
+
return Prompt(
|
|
3428
|
+
step=S_WORKERS_OVERRIDE, kind="pick", multi=True,
|
|
3429
|
+
label=t["label"],
|
|
3430
|
+
options=options,
|
|
3431
|
+
echo_template=t["echo_template"],
|
|
3432
|
+
)
|
|
3433
|
+
|
|
3434
|
+
|
|
3435
|
+
def _submit_workers_override(state: WizardState, value: str) -> Optional[str]:
|
|
3436
|
+
t = _p(state.workspace_root, "workers_override")
|
|
3437
|
+
picked = [v.strip() for v in (value or "").split(",") if v.strip()]
|
|
3438
|
+
if not picked:
|
|
3439
|
+
raise WizardError(t["errors"]["min_one_required"])
|
|
3440
|
+
if PICK_TYPE_CUSTOM in picked:
|
|
3441
|
+
if len(picked) > 1:
|
|
3442
|
+
raise WizardError(t["errors"]["custom_must_be_alone"])
|
|
3443
|
+
state.workers_custom_pending = True
|
|
3444
|
+
return None
|
|
3445
|
+
allowed = {_DEFAULT_ROSTER_TOKEN, *state.profile_optional_workers}
|
|
3446
|
+
unknown = [w for w in picked if w not in allowed]
|
|
3447
|
+
if unknown:
|
|
3448
|
+
raise WizardError(
|
|
3449
|
+
t["errors"]["unknown_option"].format(values=",".join(unknown)))
|
|
3450
|
+
state.workers_custom_pending = False
|
|
3451
|
+
added = [w for w in picked if w != _DEFAULT_ROSTER_TOKEN]
|
|
3452
|
+
return _finalize_workers(state, _default_analysers(state) + added)
|
|
3453
|
+
|
|
3454
|
+
|
|
3455
|
+
def _build_workers_custom(state: WizardState) -> Prompt:
|
|
3456
|
+
"""'직접 선택' 화면 — 기본 로스터까지 포함한 전체 분석 워커 후보.
|
|
3457
|
+
기본 워커를 빼는 축소는 이 화면에서만 가능하다."""
|
|
3458
|
+
t = _p(state.workspace_root, "workers_custom")
|
|
3459
|
+
suffix = t["options"].get("_OPTIONAL_SUFFIX", "")
|
|
3460
|
+
return Prompt(
|
|
3461
|
+
step=S_WORKERS_CUSTOM, kind="pick", multi=True,
|
|
3462
|
+
label=t["label"],
|
|
3463
|
+
options=[_analyser_option(state, w, suffix)
|
|
3464
|
+
for w in _analyser_choices(state)],
|
|
3465
|
+
echo_template=t["echo_template"],
|
|
3466
|
+
)
|
|
3467
|
+
|
|
3468
|
+
|
|
3469
|
+
def _submit_workers_custom(state: WizardState, value: str) -> Optional[str]:
|
|
3470
|
+
picked = [v.strip() for v in (value or "").split(",") if v.strip()]
|
|
3471
|
+
if not picked:
|
|
3472
|
+
t = _p(state.workspace_root, "workers_custom")
|
|
3473
|
+
raise WizardError(t["errors"]["min_one_required"])
|
|
3474
|
+
state.workers_custom_pending = False
|
|
3475
|
+
return _finalize_workers(state, picked)
|
|
3476
|
+
|
|
3477
|
+
|
|
3414
3478
|
def _model_pick(step: str, label: str, options: list[str], echo: str) -> Prompt:
|
|
3415
3479
|
# "default" picks the role's recommended model — leaving it here yields
|
|
3416
3480
|
# the SAME result as the 'Use defaults' branch. Spell that out on the
|
|
@@ -4065,7 +4129,14 @@ STEPS: list[Step] = [
|
|
|
4065
4129
|
)
|
|
4066
4130
|
and S_WORKERS_OVERRIDE not in s.answered),
|
|
4067
4131
|
build=_build_workers_override, submit=_submit_workers_override,
|
|
4068
|
-
owns=("workers_override",)),
|
|
4132
|
+
owns=("workers_override", "workers_custom_pending")),
|
|
4133
|
+
# "직접 선택" 을 고른 run 에서만 등장한다 — 기본 로스터에서 워커를 빼는
|
|
4134
|
+
# 축소가 가능한 유일한 화면.
|
|
4135
|
+
Step(S_WORKERS_CUSTOM,
|
|
4136
|
+
applies=lambda s: (s.workers_custom_pending
|
|
4137
|
+
and S_WORKERS_CUSTOM not in s.answered),
|
|
4138
|
+
build=_build_workers_custom, submit=_submit_workers_custom,
|
|
4139
|
+
owns=("workers_override", "workers_custom_pending")),
|
|
4069
4140
|
Step(S_LEAD_MODEL,
|
|
4070
4141
|
applies=lambda s: (s.use_defaults is False
|
|
4071
4142
|
and S_LEAD_MODEL not in s.answered),
|
|
@@ -4234,6 +4305,8 @@ def _ready_for_confirm(s: WizardState) -> bool:
|
|
|
4234
4305
|
workers_step = STEP_BY_ID[S_WORKERS_OVERRIDE]
|
|
4235
4306
|
if workers_step.applies(s):
|
|
4236
4307
|
return False
|
|
4308
|
+
if STEP_BY_ID[S_WORKERS_CUSTOM].applies(s):
|
|
4309
|
+
return False
|
|
4237
4310
|
if s.use_defaults:
|
|
4238
4311
|
return True
|
|
4239
4312
|
# customize: every customize-branch step must be answered or not-applicable.
|
|
@@ -4264,6 +4337,7 @@ def _reset_from(state: WizardState, target_step: str) -> None:
|
|
|
4264
4337
|
S_FEATURE_EVIDENCE: "feature_evidence_pending_text",
|
|
4265
4338
|
S_PROJECT_EVIDENCE: "project_evidence_pending_text",
|
|
4266
4339
|
S_ANALYSIS_TARGET: "analysis_target_pending_text",
|
|
4340
|
+
S_WORKERS_CUSTOM: "workers_custom_pending",
|
|
4267
4341
|
}
|
|
4268
4342
|
pending_field = direct_input_pending.get(target_step)
|
|
4269
4343
|
if pending_field is not None:
|
|
@@ -4294,6 +4368,7 @@ _FIELD_DEFAULTS: dict[str, Any] = {
|
|
|
4294
4368
|
"executor": "", "critic": "", "critic_pending_text": False,
|
|
4295
4369
|
"reuse_previous": None,
|
|
4296
4370
|
"use_defaults": None, "workers_override": "",
|
|
4371
|
+
"workers_custom_pending": False,
|
|
4297
4372
|
"lead_provider": "", "lead_model": "", "claude_model": "", "codex_model": "",
|
|
4298
4373
|
"antigravity_model": "", "grok_model": "", "kimi_model": "",
|
|
4299
4374
|
"report_writer_provider": "", "report_writer_model": "", "directive": "",
|
|
@@ -10,27 +10,40 @@ one-retry budget") and the absence of any `did-not-launch` status at all.
|
|
|
10
10
|
This module is that mechanism. It reports, not decides: the lead reads the
|
|
11
11
|
verdict and spends its existing one-retry budget.
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
Every probe is selected the same way — ``--team-state`` + ``--worker`` — and the
|
|
14
|
+
worker row's ``livenessMode`` decides which of the two artifacts answers:
|
|
15
|
+
|
|
16
|
+
* ``audit-heartbeat`` — an in-process worker's audit sidecar. Stale past the
|
|
17
|
+
heartbeat cadence (or present with no heartbeat at all) means the worker
|
|
18
|
+
hung. Uses the same line shape and budget the Phase 7 validator applies.
|
|
19
|
+
* ``wrapper-status`` — a CLI-wrapper assignment. Neither the worker's
|
|
20
|
+
`<prompt>.log` nor `<prompt>.status.json` past the launch grace means the
|
|
21
|
+
wrapper never ran.
|
|
22
|
+
|
|
23
|
+
Both graces start at the persisted dispatch timestamp (``workers[].startedAt``),
|
|
24
|
+
never at an artifact's mtime. That anchor is why the selector needs team-state:
|
|
25
|
+
the audit sidecar is reused when a worker is re-dispatched, so without knowing
|
|
26
|
+
when *this* dispatch started, the previous dispatch's last heartbeat reads as
|
|
27
|
+
this worker's newest signal and a freshly launched worker probes `stalled`.
|
|
22
28
|
"""
|
|
23
29
|
from __future__ import annotations
|
|
24
30
|
|
|
25
31
|
import argparse
|
|
26
32
|
import json
|
|
27
33
|
import sys
|
|
34
|
+
from dataclasses import dataclass
|
|
28
35
|
from datetime import datetime, timezone
|
|
29
36
|
from pathlib import Path
|
|
30
37
|
|
|
31
|
-
from okstra_ctl.dispatch_state import
|
|
38
|
+
from okstra_ctl.dispatch_state import (
|
|
39
|
+
DispatchError,
|
|
40
|
+
LIVENESS_AUDIT_HEARTBEAT,
|
|
41
|
+
LIVENESS_WRAPPER_STATUS,
|
|
42
|
+
load_json_object,
|
|
43
|
+
)
|
|
32
44
|
from okstra_ctl.worker_heartbeat import (
|
|
33
45
|
HEARTBEAT_MAX_GAP_SECONDS,
|
|
46
|
+
Heartbeat,
|
|
34
47
|
latest_heartbeat,
|
|
35
48
|
max_gap_seconds_after,
|
|
36
49
|
)
|
|
@@ -43,7 +56,13 @@ def _log_path(prompt: Path) -> Path:
|
|
|
43
56
|
return prompt.with_suffix(".log") if prompt.suffix == ".md" else Path(f"{prompt}.log")
|
|
44
57
|
|
|
45
58
|
|
|
46
|
-
def probe_heartbeat(
|
|
59
|
+
def probe_heartbeat(
|
|
60
|
+
sidecar: Path,
|
|
61
|
+
dispatched_at: datetime,
|
|
62
|
+
now: datetime,
|
|
63
|
+
max_idle: float,
|
|
64
|
+
grace: float,
|
|
65
|
+
) -> dict:
|
|
47
66
|
"""Liveness of one in-process worker, read from its audit sidecar.
|
|
48
67
|
|
|
49
68
|
``max_idle`` is the floor. A stage whose work is one uninterruptible tool
|
|
@@ -62,6 +81,8 @@ def probe_heartbeat(sidecar: Path, now: datetime, max_idle: float) -> dict:
|
|
|
62
81
|
"state": "stalled",
|
|
63
82
|
"reason": "audit sidecar carries no `- PROGRESS:` heartbeat",
|
|
64
83
|
}
|
|
84
|
+
if beat.at < dispatched_at:
|
|
85
|
+
return _probe_before_first_beat(probe, beat, dispatched_at, now, grace)
|
|
65
86
|
budget = max(max_idle, max_gap_seconds_after(beat.stage))
|
|
66
87
|
idle = (now - beat.at).total_seconds()
|
|
67
88
|
state = "stalled" if idle > budget else "live"
|
|
@@ -81,6 +102,41 @@ def probe_heartbeat(sidecar: Path, now: datetime, max_idle: float) -> dict:
|
|
|
81
102
|
}
|
|
82
103
|
|
|
83
104
|
|
|
105
|
+
def _probe_before_first_beat(
|
|
106
|
+
probe: dict,
|
|
107
|
+
beat: Heartbeat,
|
|
108
|
+
dispatched_at: datetime,
|
|
109
|
+
now: datetime,
|
|
110
|
+
grace: float,
|
|
111
|
+
) -> dict:
|
|
112
|
+
"""Verdict when the newest heartbeat predates this dispatch.
|
|
113
|
+
|
|
114
|
+
The audit sidecar is reused on re-dispatch, so that beat belongs to the
|
|
115
|
+
previous attempt and this one has produced no signal yet — the launch
|
|
116
|
+
grace decides, and the idle measure runs from the dispatch, never from a
|
|
117
|
+
heartbeat this worker never wrote.
|
|
118
|
+
"""
|
|
119
|
+
waited = (now - dispatched_at).total_seconds()
|
|
120
|
+
if waited <= grace:
|
|
121
|
+
return {
|
|
122
|
+
**probe,
|
|
123
|
+
"state": "pending",
|
|
124
|
+
"waitedSeconds": int(waited),
|
|
125
|
+
"lastStage": beat.stage,
|
|
126
|
+
"reason": "within launch grace",
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
**probe,
|
|
130
|
+
"state": "stalled",
|
|
131
|
+
"waitedSeconds": int(waited),
|
|
132
|
+
"lastStage": beat.stage,
|
|
133
|
+
"reason": (
|
|
134
|
+
f"no heartbeat for this dispatch {int(waited)}s after it started "
|
|
135
|
+
f"(grace {int(grace)}s); newest beat `{beat.stage}` predates it"
|
|
136
|
+
),
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
84
140
|
def probe_launch(
|
|
85
141
|
prompt: Path, dispatched_at: datetime, now: datetime, grace: float
|
|
86
142
|
) -> dict:
|
|
@@ -108,18 +164,34 @@ def probe_launch(
|
|
|
108
164
|
}
|
|
109
165
|
|
|
110
166
|
|
|
167
|
+
@dataclass(frozen=True)
|
|
168
|
+
class ProbeTarget:
|
|
169
|
+
"""One pending worker resolved from team-state: which artifact answers for
|
|
170
|
+
it, where that artifact is, and when this dispatch started."""
|
|
171
|
+
liveness_mode: str
|
|
172
|
+
artifact: Path
|
|
173
|
+
dispatched_at: datetime
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def probe_one(target: ProbeTarget, *, now: datetime, max_idle: float,
|
|
177
|
+
launch_grace: float) -> dict:
|
|
178
|
+
if target.liveness_mode == LIVENESS_AUDIT_HEARTBEAT:
|
|
179
|
+
return probe_heartbeat(
|
|
180
|
+
target.artifact, target.dispatched_at, now, max_idle, launch_grace
|
|
181
|
+
)
|
|
182
|
+
return probe_launch(target.artifact, target.dispatched_at, now, launch_grace)
|
|
183
|
+
|
|
184
|
+
|
|
111
185
|
def probe_all(
|
|
112
|
-
|
|
113
|
-
launches: list[tuple[str, datetime]],
|
|
186
|
+
targets: list[ProbeTarget],
|
|
114
187
|
*,
|
|
115
188
|
now: datetime,
|
|
116
189
|
max_idle: float,
|
|
117
190
|
launch_grace: float,
|
|
118
191
|
) -> dict:
|
|
119
|
-
probes = [
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
for prompt, dispatched_at in launches
|
|
192
|
+
probes = [
|
|
193
|
+
probe_one(t, now=now, max_idle=max_idle, launch_grace=launch_grace)
|
|
194
|
+
for t in targets
|
|
123
195
|
]
|
|
124
196
|
unhealthy = [p for p in probes if p["state"] in ("stalled", "did-not-launch")]
|
|
125
197
|
return {"ok": not unhealthy, "checkedAt": now.isoformat(), "probes": probes,
|
|
@@ -147,8 +219,13 @@ def _project_root_for_team_state(team_state_path: Path) -> Path:
|
|
|
147
219
|
)
|
|
148
220
|
|
|
149
221
|
|
|
150
|
-
|
|
151
|
-
|
|
222
|
+
_ARTIFACT_FIELD_BY_MODE = {
|
|
223
|
+
LIVENESS_AUDIT_HEARTBEAT: "auditSidecarPath",
|
|
224
|
+
LIVENESS_WRAPPER_STATUS: "promptPath",
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _worker_row(team_state_path: Path, worker_id: str) -> dict:
|
|
152
229
|
state = load_json_object(team_state_path, "team-state")
|
|
153
230
|
workers = state.get("workers")
|
|
154
231
|
if not isinstance(workers, list):
|
|
@@ -162,14 +239,34 @@ def _launch_target(team_state_value: str, worker_id: str) -> tuple[str, datetime
|
|
|
162
239
|
)
|
|
163
240
|
if worker is None:
|
|
164
241
|
raise DispatchError(f"team-state has no workerId={worker_id}: {team_state_path}")
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
242
|
+
return worker
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def probe_target(team_state_value: str, worker_id: str) -> ProbeTarget:
|
|
246
|
+
"""Resolve one worker's probe target from team-state.
|
|
247
|
+
|
|
248
|
+
``livenessMode`` is authoritative — never infer the transport from the
|
|
249
|
+
provider or a filename, which is how a lead ends up probing an in-process
|
|
250
|
+
worker for a wrapper log that will never exist.
|
|
251
|
+
"""
|
|
252
|
+
team_state_path = Path(team_state_value).resolve()
|
|
253
|
+
worker = _worker_row(team_state_path, worker_id)
|
|
254
|
+
mode = worker.get("livenessMode")
|
|
255
|
+
field = _ARTIFACT_FIELD_BY_MODE.get(mode) if isinstance(mode, str) else None
|
|
256
|
+
if field is None:
|
|
257
|
+
allowed = ", ".join(sorted(_ARTIFACT_FIELD_BY_MODE))
|
|
258
|
+
raise DispatchError(
|
|
259
|
+
f"worker {worker_id} has missing or unknown livenessMode {mode!r}; "
|
|
260
|
+
f"expected one of: {allowed}"
|
|
261
|
+
)
|
|
262
|
+
artifact_value = worker.get(field)
|
|
263
|
+
if not isinstance(artifact_value, str) or not artifact_value.strip():
|
|
264
|
+
raise DispatchError(f"worker {worker_id} has no {field}")
|
|
265
|
+
artifact = Path(artifact_value)
|
|
266
|
+
if not artifact.is_absolute():
|
|
267
|
+
artifact = _project_root_for_team_state(team_state_path) / artifact
|
|
171
268
|
dispatched_at = _parse_utc(worker.get("startedAt"), f"worker {worker_id} startedAt")
|
|
172
|
-
return
|
|
269
|
+
return ProbeTarget(mode, artifact, dispatched_at)
|
|
173
270
|
|
|
174
271
|
|
|
175
272
|
def main(argv: list[str] | None = None) -> int:
|
|
@@ -177,35 +274,32 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
177
274
|
prog="okstra worker-liveness",
|
|
178
275
|
description="Report whether pending workers are still alive (read-only).",
|
|
179
276
|
)
|
|
180
|
-
parser.add_argument("--audit", action="append", default=[],
|
|
181
|
-
help="in-process worker audit sidecar path (repeatable)")
|
|
182
277
|
parser.add_argument("--team-state", action="append", default=[],
|
|
183
|
-
help="team-state path for a
|
|
278
|
+
help="team-state path for a pending worker (repeatable)")
|
|
184
279
|
parser.add_argument("--worker", action="append", default=[],
|
|
185
280
|
help="worker id paired with --team-state (repeatable)")
|
|
186
281
|
parser.add_argument("--max-idle", type=float, default=HEARTBEAT_MAX_GAP_SECONDS,
|
|
187
282
|
help="heartbeat staleness budget in seconds")
|
|
188
283
|
parser.add_argument("--launch-grace", type=float, default=DEFAULT_LAUNCH_GRACE_SECONDS,
|
|
189
|
-
help="seconds a
|
|
284
|
+
help="seconds a worker may take to write its first artifact")
|
|
190
285
|
parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
|
|
191
286
|
args = parser.parse_args(argv)
|
|
192
287
|
|
|
193
288
|
if len(args.team_state) != len(args.worker):
|
|
194
289
|
parser.error("each --team-state must have one paired --worker")
|
|
195
|
-
if not args.
|
|
196
|
-
parser.error("pass at least one --
|
|
290
|
+
if not args.team_state:
|
|
291
|
+
parser.error("pass at least one --team-state/--worker pair")
|
|
197
292
|
|
|
198
293
|
try:
|
|
199
|
-
|
|
200
|
-
|
|
294
|
+
targets = [
|
|
295
|
+
probe_target(team_state, worker)
|
|
201
296
|
for team_state, worker in zip(args.team_state, args.worker, strict=True)
|
|
202
297
|
]
|
|
203
298
|
except DispatchError as exc:
|
|
204
299
|
parser.error(str(exc))
|
|
205
300
|
|
|
206
301
|
result = probe_all(
|
|
207
|
-
|
|
208
|
-
launches,
|
|
302
|
+
targets,
|
|
209
303
|
now=datetime.now(timezone.utc),
|
|
210
304
|
max_idle=args.max_idle,
|
|
211
305
|
launch_grace=args.launch_grace,
|
|
@@ -8,15 +8,15 @@
|
|
|
8
8
|
<style>{{ css | safe }}</style>
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
|
-
<a class="skip-link" href="#main-content">
|
|
11
|
+
<a class="skip-link" href="#main-content">{{ t('base.skip-to-report-content') }}</a>
|
|
12
12
|
<header class="human-report-header">
|
|
13
13
|
<p class="eyebrow">{{ taskType }}</p>
|
|
14
14
|
<h1>{{ taskType }} #{{ runMeta.seq }}</h1>
|
|
15
15
|
<dl class="report-meta">
|
|
16
|
-
<div><dt>
|
|
17
|
-
<div><dt>
|
|
18
|
-
<div><dt>
|
|
19
|
-
{% if reportMeta.elapsed %}<div><dt>
|
|
16
|
+
<div><dt>{{ t('base.task') }}</dt><dd data-report-meta="taskTitle">{{ reportMeta.taskTitle | inline_code }}</dd></div>
|
|
17
|
+
<div><dt>{{ t('base.task-key') }}</dt><dd data-report-meta="taskKey"><code>{{ reportMeta.taskKey }}</code></dd></div>
|
|
18
|
+
<div><dt>{{ t('base.written') }}</dt><dd data-report-meta="createdAt">{{ reportMeta.createdAt }}</dd></div>
|
|
19
|
+
{% if reportMeta.elapsed %}<div><dt>{{ t('base.elapsed') }}</dt><dd data-report-meta="elapsed">{{ reportMeta.elapsed }}</dd></div>{% endif %}
|
|
20
20
|
</dl>
|
|
21
21
|
<p class="lede">{{ humanSummary.headline | inline_code }}</p>
|
|
22
22
|
<p>{{ humanSummary.outcome | inline_code }}</p>
|
|
@@ -25,14 +25,14 @@
|
|
|
25
25
|
{% block human_content %}{% endblock %}
|
|
26
26
|
{{ clarification_responses(clarificationItems) }}
|
|
27
27
|
{% if evidenceIndex %}
|
|
28
|
-
<section data-report-section="evidence-ledger">
|
|
29
|
-
<h2>
|
|
28
|
+
<section data-report-section="evidence-ledger" data-reader-kind="audit">
|
|
29
|
+
<h2>{{ t('base.evidence-ledger') }}</h2>
|
|
30
30
|
<ol class="ledger">
|
|
31
31
|
{% for row_id, row in evidenceIndex.items() %}
|
|
32
32
|
<li class="ledger-item" id="id-{{ row_id }}">
|
|
33
33
|
<p class="ledger-key"><span class="ledger-id">{{ row_id }}</span><span class="ledger-kind">{{ row.kind }}</span></p>
|
|
34
34
|
<p class="ledger-text">{{ row.text | inline_code }}</p>
|
|
35
|
-
<p class="ledger-source"><span>
|
|
35
|
+
<p class="ledger-source"><span>{{ t('base.source-confidence') }}</span> {% if row.codeEvidence %}{{ row.codeEvidence | code_evidence }}{% else %}{{ row.source | inline_code }}{% endif %}</p>
|
|
36
36
|
</li>
|
|
37
37
|
{% endfor %}
|
|
38
38
|
</ol>
|
|
@@ -40,10 +40,10 @@
|
|
|
40
40
|
{% endif %}
|
|
41
41
|
</main>
|
|
42
42
|
<footer class="human-report-footer">
|
|
43
|
-
<button type="button" data-action="export-user-response">
|
|
44
|
-
<button type="button" data-action="copy-user-response">
|
|
45
|
-
<button type="button" data-action="dismiss-user-response" hidden>
|
|
46
|
-
<p class="user-response-hint">
|
|
43
|
+
<button type="button" data-action="export-user-response">{{ t('base.export-my-answers') }}</button>
|
|
44
|
+
<button type="button" data-action="copy-user-response">{{ t('base.copy') }}</button>
|
|
45
|
+
<button type="button" data-action="dismiss-user-response" hidden>{{ t('base.dismiss') }}</button>
|
|
46
|
+
<p class="user-response-hint">{{ t('base.export-downloads') }} <code>user-response-{{ runMeta.task_type }}-{{ runMeta.seq }}.md</code>{{ t('base.drop-that-file-into') }} <code>runs/{{ runMeta.task_type }}/user-responses/</code> {{ t('base.and-the-next-run-picks-your-answers-up-on-it') }}</p>
|
|
47
47
|
<pre id="user-response-output" aria-live="polite"></pre>
|
|
48
48
|
</footer>
|
|
49
49
|
<script id="run-meta" type="application/json">{{ {
|