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.
Files changed (53) hide show
  1. package/docs/architecture.md +1 -1
  2. package/docs/cli.md +1 -1
  3. package/docs/project-structure-overview.md +1 -1
  4. package/package.json +1 -1
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/agents/workers/translator-worker.md +67 -0
  7. package/runtime/bin/okstra-render-final-report.py +0 -11
  8. package/runtime/bin/okstra-report-translate.py +158 -0
  9. package/runtime/prompts/lead/adapters/claude-code.md +1 -1
  10. package/runtime/prompts/lead/okstra-lead-contract.md +1 -0
  11. package/runtime/prompts/lead/report-writer.md +14 -13
  12. package/runtime/prompts/lead/team-contract.md +2 -2
  13. package/runtime/prompts/wizard/prompts.ko.json +17 -1
  14. package/runtime/python/okstra_ctl/analysis_inputs.py +24 -9
  15. package/runtime/python/okstra_ctl/analysis_packet.py +23 -1
  16. package/runtime/python/okstra_ctl/clarification_items.py +241 -44
  17. package/runtime/python/okstra_ctl/codex_dispatch.py +2 -1
  18. package/runtime/python/okstra_ctl/dispatch_core.py +2 -2
  19. package/runtime/python/okstra_ctl/dispatch_state.py +12 -1
  20. package/runtime/python/okstra_ctl/final_report_paths.py +22 -1
  21. package/runtime/python/okstra_ctl/i18n.py +12 -7
  22. package/runtime/python/okstra_ctl/render_final_report.py +18 -17
  23. package/runtime/python/okstra_ctl/report_html/filters.py +15 -77
  24. package/runtime/python/okstra_ctl/report_html/render.py +44 -2
  25. package/runtime/python/okstra_ctl/report_translation.py +440 -0
  26. package/runtime/python/okstra_ctl/report_views.py +23 -9
  27. package/runtime/python/okstra_ctl/run.py +1 -1
  28. package/runtime/python/okstra_ctl/user_response.py +11 -6
  29. package/runtime/python/okstra_ctl/wizard.py +100 -25
  30. package/runtime/python/okstra_ctl/worker_liveness.py +130 -36
  31. package/runtime/templates/reports/html/base.template.html +12 -12
  32. package/runtime/templates/reports/html/i18n/en.json +395 -0
  33. package/runtime/templates/reports/html/i18n/ko.json +395 -0
  34. package/runtime/templates/reports/html/macros/forms.html +16 -16
  35. package/runtime/templates/reports/html/macros/visualizations.html +2 -2
  36. package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +17 -17
  37. package/runtime/templates/reports/html/tasks/error-analysis.template.html +12 -12
  38. package/runtime/templates/reports/html/tasks/feature-analysis.template.html +16 -16
  39. package/runtime/templates/reports/html/tasks/final-verification.template.html +12 -12
  40. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +37 -37
  41. package/runtime/templates/reports/html/tasks/implementation.template.html +18 -18
  42. package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +7 -7
  43. package/runtime/templates/reports/html/tasks/project-analysis.template.html +29 -29
  44. package/runtime/templates/reports/html/tasks/release-handoff.template.html +13 -13
  45. package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +14 -14
  46. package/runtime/templates/reports/report.js +8 -5
  47. package/runtime/validators/validate-report-views.py +1 -1
  48. package/runtime/validators/validate-run.py +28 -31
  49. package/src/cli-registry.mjs +11 -0
  50. package/src/commands/inspect/worker-liveness.mjs +9 -7
  51. package/src/commands/report/translate.mjs +31 -0
  52. package/src/lib/helper-scripts.mjs +1 -0
  53. 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. Single reference point for BOTH the HTML view render
149
- script and the in-session user-response writer, so sidecar match keys
150
- (task_type/seq/source-report) never drift between the two paths."""
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(src_text)
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, src_text)
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(body)
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
- parse_clarification_rows,
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
- raise UserResponseError(
324
- "existing review sidecar has no ANALYSIS REVIEW block"
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(text)
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 parse_clarification_rows(text):
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(body)
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 _build_workers_override(state: WizardState) -> Prompt:
3371
- """분석 워커 멀티픽. report-writer 는 옵션에서 빼고 항상 결과에 강제
3372
- 포함시킨다(프로필이 report-writer 를 Required 로 가질 때)."""
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 _submit_workers_override(state: WizardState, value: str) -> Optional[str]:
3393
- raw = (value or "").strip()
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(raw) if raw else []
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
- Two probes, matching the two ways a pending worker goes quiet:
14
-
15
- * ``--audit`` — an in-process worker audit sidecar. Stale past the heartbeat
16
- cadence (or present with no heartbeat at all) means the worker hung. Uses
17
- the same line shape and budget the Phase 7 validator applies.
18
- * ``--team-state`` + ``--worker`` a CLI-wrapper assignment. Neither the
19
- worker's `<prompt>.log` nor `<prompt>.status.json` past the launch grace means
20
- the wrapper never ran. The grace starts at the persisted dispatch timestamp,
21
- not when the prompt was materialized.
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 DispatchError, load_json_object
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(sidecar: Path, now: datetime, max_idle: float) -> dict:
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
- audits: list[str],
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 = [probe_heartbeat(Path(p), now, max_idle) for p in audits]
120
- probes += [
121
- probe_launch(Path(prompt), dispatched_at, now, launch_grace)
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
- def _launch_target(team_state_value: str, worker_id: str) -> tuple[str, datetime]:
151
- team_state_path = Path(team_state_value).resolve()
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
- prompt_value = worker.get("promptPath")
166
- if not isinstance(prompt_value, str) or not prompt_value.strip():
167
- raise DispatchError(f"worker {worker_id} has no promptPath")
168
- prompt = Path(prompt_value)
169
- if not prompt.is_absolute():
170
- prompt = _project_root_for_team_state(team_state_path) / prompt
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 str(prompt), dispatched_at
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 CLI-wrapper assignment (repeatable)")
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 wrapper may take to write its first artifact")
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.audit and not args.team_state:
196
- parser.error("pass at least one --audit or --team-state/--worker pair")
290
+ if not args.team_state:
291
+ parser.error("pass at least one --team-state/--worker pair")
197
292
 
198
293
  try:
199
- launches = [
200
- _launch_target(team_state, worker)
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
- args.audit,
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">Skip to report content</a>
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>Task</dt><dd data-report-meta="taskTitle">{{ reportMeta.taskTitle | inline_code }}</dd></div>
17
- <div><dt>Task key</dt><dd data-report-meta="taskKey"><code>{{ reportMeta.taskKey }}</code></dd></div>
18
- <div><dt>Written</dt><dd data-report-meta="createdAt">{{ reportMeta.createdAt }}</dd></div>
19
- {% if reportMeta.elapsed %}<div><dt>Elapsed</dt><dd data-report-meta="elapsed">{{ reportMeta.elapsed }}</dd></div>{% endif %}
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>Evidence ledger</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>Source · confidence</span> {% if row.codeEvidence %}{{ row.codeEvidence | code_evidence }}{% else %}{{ row.source | inline_code }}{% endif %}</p>
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">Export my answers</button>
44
- <button type="button" data-action="copy-user-response">Copy</button>
45
- <button type="button" data-action="dismiss-user-response" hidden>Dismiss</button>
46
- <p class="user-response-hint">Export downloads <code>user-response-{{ runMeta.task_type }}-{{ runMeta.seq }}.md</code>. Drop that file into <code>runs/{{ runMeta.task_type }}/user-responses/</code> and the next run picks your answers up on its own.</p>
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">{{ {