okstra 0.145.0 → 0.146.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 +4 -2
- package/docs/cli.md +15 -5
- package/docs/project-structure-overview.md +3 -5
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +5 -6
- package/runtime/bin/okstra-trace-cleanup.sh +28 -2
- package/runtime/prompts/lead/adapters/claude-code.md +3 -3
- package/runtime/prompts/lead/convergence.md +20 -3
- package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
- package/runtime/prompts/lead/report-writer.md +20 -14
- package/runtime/prompts/lead/team-contract.md +3 -3
- package/runtime/python/okstra_ctl/analysis_packet.py +4 -10
- package/runtime/python/okstra_ctl/codex_dispatch.py +117 -58
- package/runtime/python/okstra_ctl/convergence_engine.py +3 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +19 -56
- package/runtime/python/okstra_ctl/dispatch_state.py +167 -3
- package/runtime/python/okstra_ctl/path_hints.py +6 -0
- package/runtime/python/okstra_ctl/paths.py +7 -44
- package/runtime/python/okstra_ctl/render.py +2 -0
- package/runtime/python/okstra_ctl/wizard.py +34 -0
- package/runtime/python/okstra_ctl/worker_liveness.py +84 -21
- package/runtime/python/okstra_ctl/worker_prompt_body.py +24 -4
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +57 -0
- package/runtime/python/okstra_ctl/worker_state.py +65 -0
- package/runtime/python/okstra_token_usage/antigravity.py +3 -0
- package/runtime/python/okstra_token_usage/codex.py +54 -23
- package/runtime/python/okstra_token_usage/collect.py +141 -33
- package/runtime/python/okstra_token_usage/paths.py +27 -0
- package/runtime/python/okstra_vendor/__init__.py +15 -2
- package/runtime/schemas/convergence-groups-v1.0.schema.json +0 -1
- package/runtime/skills/okstra-run/SKILL.md +14 -4
- package/runtime/skills/okstra-setup/references/project-config.md +13 -4
- package/runtime/validators/lib/fixtures.sh +1 -1
- package/runtime/validators/validate-run.py +52 -1
- package/runtime/validators/validate_analysis_report.py +34 -3
- package/src/cli-registry.mjs +7 -10
- package/src/commands/execute/worker-state.mjs +29 -0
- package/src/commands/inspect/worker-liveness.mjs +5 -3
- package/src/commands/lifecycle/preflight.mjs +13 -3
- package/src/lib/runtime-readiness.mjs +90 -0
- package/runtime/python/okstra_ctl/phase_cleanup.py +0 -235
- package/src/commands/execute/phase-cleanup.mjs +0 -38
|
@@ -22,9 +22,13 @@ import os
|
|
|
22
22
|
from dataclasses import dataclass
|
|
23
23
|
from datetime import datetime, timezone
|
|
24
24
|
from pathlib import Path
|
|
25
|
-
from typing import Any, Mapping, Sequence
|
|
25
|
+
from typing import Any, Callable, Mapping, Sequence
|
|
26
26
|
|
|
27
|
-
from .worker_prompt_contract import
|
|
27
|
+
from .worker_prompt_contract import (
|
|
28
|
+
PromptRecord,
|
|
29
|
+
validate_initial_prompt_records,
|
|
30
|
+
validate_reverify_prompt,
|
|
31
|
+
)
|
|
28
32
|
|
|
29
33
|
BACKEND_CLI_WRAPPER = "cli-wrapper"
|
|
30
34
|
BACKEND_TMUX_PANE = "tmux-pane"
|
|
@@ -35,6 +39,10 @@ BACKEND_MIXED = "mixed"
|
|
|
35
39
|
# analysis phases write their artifacts under project-root (see the contract in
|
|
36
40
|
# scripts/okstra-codex-exec.sh).
|
|
37
41
|
WORKTREE_TASK_TYPES = frozenset({"implementation", "final-verification"})
|
|
42
|
+
WORKER_STATUSES = frozenset(
|
|
43
|
+
{"in-progress", "completed", "timeout", "error", "not-run"}
|
|
44
|
+
)
|
|
45
|
+
REASON_REQUIRED_STATUSES = frozenset({"timeout", "error", "not-run"})
|
|
38
46
|
|
|
39
47
|
|
|
40
48
|
class DispatchError(Exception):
|
|
@@ -171,14 +179,24 @@ def worker_state(team_state: Mapping[str, Any], worker_id: str) -> Mapping[str,
|
|
|
171
179
|
raise DispatchError(f"team-state has no workerId={worker_id}")
|
|
172
180
|
|
|
173
181
|
|
|
174
|
-
def
|
|
182
|
+
def transition_worker_status(
|
|
175
183
|
team_state_path: Path,
|
|
176
184
|
worker_id: str,
|
|
177
185
|
status: str,
|
|
178
186
|
reason: str,
|
|
179
187
|
*,
|
|
180
188
|
model_execution_value: str = "",
|
|
189
|
+
at: str | datetime | None = None,
|
|
181
190
|
) -> None:
|
|
191
|
+
if status not in WORKER_STATUSES:
|
|
192
|
+
allowed = ", ".join(sorted(WORKER_STATUSES))
|
|
193
|
+
raise DispatchError(
|
|
194
|
+
f"unsupported worker status `{status}`; expected one of: {allowed}"
|
|
195
|
+
)
|
|
196
|
+
reason = reason.strip()
|
|
197
|
+
if status in REASON_REQUIRED_STATUSES and not reason:
|
|
198
|
+
raise DispatchError(f"worker status `{status}` requires a non-empty reason")
|
|
199
|
+
timestamp = _utc_timestamp(at)
|
|
182
200
|
payload = load_json_object(team_state_path, "team-state")
|
|
183
201
|
workers = payload.get("workers")
|
|
184
202
|
if not isinstance(workers, list):
|
|
@@ -187,6 +205,14 @@ def set_worker_status(
|
|
|
187
205
|
if isinstance(worker, dict) and worker.get("workerId") == worker_id:
|
|
188
206
|
worker["status"] = status
|
|
189
207
|
worker["reason"] = reason
|
|
208
|
+
if status == "in-progress":
|
|
209
|
+
worker["startedAt"] = timestamp
|
|
210
|
+
worker.pop("endedAt", None)
|
|
211
|
+
elif status == "not-run":
|
|
212
|
+
worker.pop("startedAt", None)
|
|
213
|
+
worker.pop("endedAt", None)
|
|
214
|
+
else:
|
|
215
|
+
worker["endedAt"] = timestamp
|
|
190
216
|
if model_execution_value:
|
|
191
217
|
worker["model"] = model_execution_value
|
|
192
218
|
worker["modelExecutionValue"] = model_execution_value
|
|
@@ -195,6 +221,23 @@ def set_worker_status(
|
|
|
195
221
|
raise DispatchError(f"team-state has no workerId={worker_id}: {team_state_path}")
|
|
196
222
|
|
|
197
223
|
|
|
224
|
+
def _utc_timestamp(value: str | datetime | None) -> str:
|
|
225
|
+
if value is None:
|
|
226
|
+
instant = datetime.now(timezone.utc)
|
|
227
|
+
elif isinstance(value, datetime):
|
|
228
|
+
instant = value
|
|
229
|
+
elif isinstance(value, str):
|
|
230
|
+
try:
|
|
231
|
+
instant = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
232
|
+
except ValueError as exc:
|
|
233
|
+
raise DispatchError(f"invalid UTC timestamp `{value}`") from exc
|
|
234
|
+
else:
|
|
235
|
+
raise DispatchError(f"invalid UTC timestamp `{value}`")
|
|
236
|
+
if instant.tzinfo is None or instant.utcoffset() != timezone.utc.utcoffset(instant):
|
|
237
|
+
raise DispatchError(f"invalid UTC timestamp `{value}`")
|
|
238
|
+
return instant.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
239
|
+
|
|
240
|
+
|
|
198
241
|
def set_dispatch_mode(team_state_path: Path, dispatch_mode: str) -> None:
|
|
199
242
|
payload = load_json_object(team_state_path, "team-state")
|
|
200
243
|
payload["dispatchMode"] = dispatch_mode
|
|
@@ -227,5 +270,126 @@ def validate_initial_prompts(
|
|
|
227
270
|
raise DispatchError(f"{task_type} prompt contract: " + "; ".join(errors))
|
|
228
271
|
|
|
229
272
|
|
|
273
|
+
def validate_dispatch_prompts(
|
|
274
|
+
manifest: Mapping[str, Any],
|
|
275
|
+
active_context: Mapping[str, Any],
|
|
276
|
+
jobs: Sequence[WorkerJob],
|
|
277
|
+
) -> None:
|
|
278
|
+
initial_jobs = [
|
|
279
|
+
job for job in jobs if not job.dispatch_kind.startswith("reverify-r")
|
|
280
|
+
]
|
|
281
|
+
if initial_jobs:
|
|
282
|
+
validate_initial_prompts(manifest, initial_jobs)
|
|
283
|
+
|
|
284
|
+
reverify_jobs = [
|
|
285
|
+
job for job in jobs if job.dispatch_kind.startswith("reverify-r")
|
|
286
|
+
]
|
|
287
|
+
if not reverify_jobs:
|
|
288
|
+
return
|
|
289
|
+
task_type = require_string(manifest, "taskType")
|
|
290
|
+
workflow = active_context.get("workflow")
|
|
291
|
+
if not isinstance(workflow, Mapping):
|
|
292
|
+
raise DispatchError("reverify prompt contract: active run workflow is missing")
|
|
293
|
+
forbidden_actions = string_value(workflow.get("forbiddenActions"))
|
|
294
|
+
if not forbidden_actions:
|
|
295
|
+
raise DispatchError(
|
|
296
|
+
"reverify prompt contract: workflow.forbiddenActions is missing"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
errors: list[str] = []
|
|
300
|
+
for job in reverify_jobs:
|
|
301
|
+
try:
|
|
302
|
+
text = job.prompt_path.read_text(encoding="utf-8")
|
|
303
|
+
except OSError as exc:
|
|
304
|
+
errors.append(f"{job.worker_id}: cannot read prompt {job.prompt_path}: {exc}")
|
|
305
|
+
continue
|
|
306
|
+
errors.extend(
|
|
307
|
+
f"{job.worker_id}: {error}"
|
|
308
|
+
for error in validate_reverify_prompt(
|
|
309
|
+
text,
|
|
310
|
+
task_type=task_type,
|
|
311
|
+
forbidden_actions=forbidden_actions,
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
if errors:
|
|
315
|
+
raise DispatchError("reverify prompt contract: " + "; ".join(errors))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def worker_jobs_from_file(
|
|
319
|
+
project_root: Path,
|
|
320
|
+
jobs_file: Path,
|
|
321
|
+
*,
|
|
322
|
+
backend: str,
|
|
323
|
+
idle_timeout_seconds: int,
|
|
324
|
+
default_dispatch_kind: str,
|
|
325
|
+
resolve_wrapper: Callable[[str], Path],
|
|
326
|
+
default_provider: Callable[[str], str],
|
|
327
|
+
) -> list[WorkerJob]:
|
|
328
|
+
payload = load_json_object(
|
|
329
|
+
resolve_project_path(project_root, str(jobs_file)),
|
|
330
|
+
"jobs file",
|
|
331
|
+
)
|
|
332
|
+
dispatch_kind = string_value(payload.get("dispatchKind")) or default_dispatch_kind
|
|
333
|
+
workers = payload.get("workers")
|
|
334
|
+
if not isinstance(workers, list):
|
|
335
|
+
raise DispatchError("jobs file workers must be an array")
|
|
336
|
+
return [
|
|
337
|
+
_worker_job_from_file(
|
|
338
|
+
project_root,
|
|
339
|
+
item,
|
|
340
|
+
backend=backend,
|
|
341
|
+
idle_timeout_seconds=idle_timeout_seconds,
|
|
342
|
+
dispatch_kind=dispatch_kind,
|
|
343
|
+
resolve_wrapper=resolve_wrapper,
|
|
344
|
+
default_provider=default_provider,
|
|
345
|
+
)
|
|
346
|
+
for item in workers
|
|
347
|
+
if isinstance(item, Mapping)
|
|
348
|
+
]
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _worker_job_from_file(
|
|
352
|
+
project_root: Path,
|
|
353
|
+
item: Mapping[str, Any],
|
|
354
|
+
*,
|
|
355
|
+
backend: str,
|
|
356
|
+
idle_timeout_seconds: int,
|
|
357
|
+
dispatch_kind: str,
|
|
358
|
+
resolve_wrapper: Callable[[str], Path],
|
|
359
|
+
default_provider: Callable[[str], str],
|
|
360
|
+
) -> WorkerJob:
|
|
361
|
+
worker_id = require_string(item, "workerId")
|
|
362
|
+
provider = string_value(item.get("provider")) or default_provider(worker_id)
|
|
363
|
+
prompt_path = resolve_project_path(
|
|
364
|
+
project_root, require_string(item, "promptPath")
|
|
365
|
+
)
|
|
366
|
+
result_path = resolve_project_path(
|
|
367
|
+
project_root, require_string(item, "resultPath")
|
|
368
|
+
)
|
|
369
|
+
worker_result_path = resolve_project_path(
|
|
370
|
+
project_root, require_string(item, "workerResultPath")
|
|
371
|
+
)
|
|
372
|
+
completion_paths = tuple(
|
|
373
|
+
resolve_project_path(project_root, path)
|
|
374
|
+
for path in string_list(item.get("completionPaths"))
|
|
375
|
+
) or (result_path,)
|
|
376
|
+
return WorkerJob(
|
|
377
|
+
worker_id=worker_id,
|
|
378
|
+
provider=provider,
|
|
379
|
+
backend=backend,
|
|
380
|
+
project_root=project_root,
|
|
381
|
+
model_execution_value=require_string(item, "modelExecutionValue"),
|
|
382
|
+
wrapper_path=resolve_wrapper(provider),
|
|
383
|
+
prompt_path=prompt_path,
|
|
384
|
+
result_path=result_path,
|
|
385
|
+
worker_result_path=worker_result_path,
|
|
386
|
+
completion_paths=completion_paths,
|
|
387
|
+
worktree_path=string_value(item.get("worktreePath")),
|
|
388
|
+
role=require_string(item, "role"),
|
|
389
|
+
idle_timeout_seconds=idle_timeout_seconds,
|
|
390
|
+
dispatch_kind=dispatch_kind,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
230
394
|
def utc_now() -> str:
|
|
231
395
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
@@ -188,6 +188,7 @@ def _hydrate_active_run(ctx: Mapping[str, str]) -> dict[str, str]:
|
|
|
188
188
|
"teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
|
|
189
189
|
"promptSnapshotPath": ctx.get("RUN_PROMPT_SNAPSHOT_RELATIVE_PATH", ""),
|
|
190
190
|
"finalReportPath": ctx.get("FINAL_REPORT_RELATIVE_PATH", ""),
|
|
191
|
+
"convergenceStatePath": ctx.get("CONVERGENCE_STATE_RELATIVE_PATH", ""),
|
|
191
192
|
"finalStatusPath": ctx.get("FINAL_STATUS_RELATIVE_PATH", ""),
|
|
192
193
|
"validatorScriptPath": ctx.get("RUN_VALIDATOR_RELATIVE_PATH", ""),
|
|
193
194
|
"resumeCommandPath": ctx.get("CLAUDE_RESUME_COMMAND_RELATIVE_PATH", ""),
|
|
@@ -430,6 +431,7 @@ def _run_files(
|
|
|
430
431
|
"team_state": run_state / f"team-state{suffixes['state']}.json",
|
|
431
432
|
"active_run_context": run_state / f"active-run-context{suffixes['state']}.json",
|
|
432
433
|
"lead_events": run_state / f"lead-events-{task_type_segment}-{sequences['state']}.jsonl",
|
|
434
|
+
"convergence_state": run_state / f"convergence-{task_type_segment}-{sequences['state']}.json",
|
|
433
435
|
"claude_resume_command": run_sessions / f"claude-resume{suffixes['sessions']}.sh",
|
|
434
436
|
"claude_worker_result": worker_results / f"claude-worker{suffixes['worker_results']}.md",
|
|
435
437
|
"codex_worker_result": worker_results / f"codex-worker{suffixes['worker_results']}.md",
|
|
@@ -540,6 +542,7 @@ def _absolute_run_fields(paths: Mapping[str, Any]) -> dict[str, str]:
|
|
|
540
542
|
"RUN_CONTEXT_FILE": str(paths["run_context_file"]),
|
|
541
543
|
"RUN_PROMPT_SNAPSHOT_FILE": str(paths["run_prompt_snapshot"]),
|
|
542
544
|
"FINAL_REPORT_PATH": str(paths["final_report"]),
|
|
545
|
+
"CONVERGENCE_STATE_PATH": str(paths["convergence_state"]),
|
|
543
546
|
"FINAL_STATUS_PATH": str(paths["final_status"]),
|
|
544
547
|
"TEAM_STATE_PATH": str(paths["team_state"]),
|
|
545
548
|
"ACTIVE_RUN_CONTEXT_PATH": str(paths["active_run_context"]),
|
|
@@ -629,6 +632,9 @@ def _relative_file_fields(project_root: Path, paths: Mapping[str, Any]) -> dict[
|
|
|
629
632
|
"ANTIGRAVITY_WORKER_PROMPT_RELATIVE_PATH": _rel(project_root, paths["antigravity_worker_prompt"]),
|
|
630
633
|
"REPORT_WRITER_WORKER_PROMPT_RELATIVE_PATH": _rel(project_root, paths["report_writer_worker_prompt"]),
|
|
631
634
|
"FINAL_REPORT_RELATIVE_PATH": _rel(project_root, paths["final_report"]),
|
|
635
|
+
"CONVERGENCE_STATE_RELATIVE_PATH": _rel(
|
|
636
|
+
project_root, paths["convergence_state"]
|
|
637
|
+
),
|
|
632
638
|
"FINAL_STATUS_RELATIVE_PATH": _rel(project_root, paths["final_status"]),
|
|
633
639
|
"TEAM_STATE_RELATIVE_PATH": _rel(project_root, paths["team_state"]),
|
|
634
640
|
"ACTIVE_RUN_CONTEXT_RELATIVE_PATH": _rel(project_root, paths["active_run_context"]),
|
|
@@ -66,19 +66,6 @@ def _newest_report(reports_dir: Path) -> Optional[Path]:
|
|
|
66
66
|
return max(found, key=lambda p: (p.stat().st_mtime, p.name))
|
|
67
67
|
|
|
68
68
|
|
|
69
|
-
def _report_dirs(type_dir: Path, include_stages: bool) -> list[Path]:
|
|
70
|
-
"""한 task-type 의 report 디렉터리들. flat 은 항상, stage-<N> 은 opt-in.
|
|
71
|
-
|
|
72
|
-
stage-isolated task-type(implementation / final-verification)만 stage 하위를
|
|
73
|
-
갖는다. `include_stages` 가 꺼져 있으면 flat `reports/` 만 돌려준다 —
|
|
74
|
-
`latest_under` 의 원래 범위이자 resume-clarification 이 의존하는 계약이다.
|
|
75
|
-
"""
|
|
76
|
-
dirs = [type_dir / "reports"]
|
|
77
|
-
if include_stages and type_dir.name in _STAGED_TASK_TYPES:
|
|
78
|
-
dirs += sorted(type_dir.glob("stage-*/reports"))
|
|
79
|
-
return dirs
|
|
80
|
-
|
|
81
|
-
|
|
82
69
|
def _project_root_of(task_root: Path) -> Optional[Path]:
|
|
83
70
|
"""canonical `<project>/.okstra/tasks/<g>/<t>` 일 때만 project root.
|
|
84
71
|
|
|
@@ -264,42 +251,19 @@ class RunRef:
|
|
|
264
251
|
return None if best is None else cls.from_report_path(best)
|
|
265
252
|
|
|
266
253
|
@classmethod
|
|
267
|
-
def
|
|
254
|
+
def latest_under(
|
|
268
255
|
cls,
|
|
269
|
-
|
|
270
|
-
task_group: str,
|
|
271
|
-
task_id: str,
|
|
256
|
+
task_root: Path,
|
|
272
257
|
task_types: Optional[tuple[str, ...]] = None,
|
|
273
|
-
*,
|
|
274
|
-
include_stages: bool = False,
|
|
275
258
|
) -> Optional["RunRef"]:
|
|
276
|
-
"""여러 task-type 을 가로질러 최신 final-report 를
|
|
259
|
+
"""task_root 아래 여러 task-type 을 가로질러 최신 final-report 를 찾는다.
|
|
277
260
|
|
|
278
261
|
`task_types` 를 주지 않으면 runs/ 에 실제로 존재하는 모든 task-type 을
|
|
279
262
|
훑는다. 비교 규칙은 `latest` 와 같다(mtime, 동률이면 basename).
|
|
280
263
|
|
|
281
|
-
`include_stages` 를 켜면 stage-isolated task-type 의 `stage-<N>/reports/`
|
|
282
|
-
도 함께 훑는다 — phase-cleanup 이 staged run 을 자동발견할 때 쓴다.
|
|
283
|
-
"""
|
|
284
|
-
return cls.latest_under(
|
|
285
|
-
task_dir(project_root, task_group, task_id),
|
|
286
|
-
task_types,
|
|
287
|
-
include_stages=include_stages,
|
|
288
|
-
)
|
|
289
|
-
|
|
290
|
-
@classmethod
|
|
291
|
-
def latest_under(
|
|
292
|
-
cls,
|
|
293
|
-
task_root: Path,
|
|
294
|
-
task_types: Optional[tuple[str, ...]] = None,
|
|
295
|
-
*,
|
|
296
|
-
include_stages: bool = False,
|
|
297
|
-
) -> Optional["RunRef"]:
|
|
298
|
-
"""`latest_across` 의 task_root 진입점.
|
|
299
|
-
|
|
300
264
|
task-key 단축키/오타 진단을 이미 거쳐 task_root 를 손에 쥔 호출자
|
|
301
265
|
(bash resume-clarification)가 쓴다. 그 호출자는 언제나 flat 분석 phase 만
|
|
302
|
-
넘기므로
|
|
266
|
+
넘기므로 stage 하위는 훑지 않는다 — flat `reports/` 고정이 그 계약이다.
|
|
303
267
|
"""
|
|
304
268
|
runs_dir = runs_dir_of(task_root)
|
|
305
269
|
if task_types is None:
|
|
@@ -308,10 +272,9 @@ class RunRef:
|
|
|
308
272
|
task_types = tuple(sorted(p.name for p in runs_dir.iterdir() if p.is_dir()))
|
|
309
273
|
candidates: list[Path] = []
|
|
310
274
|
for task_type in task_types:
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
candidates.append(found)
|
|
275
|
+
found = _newest_report(runs_dir / task_type / "reports")
|
|
276
|
+
if found is not None:
|
|
277
|
+
candidates.append(found)
|
|
315
278
|
if not candidates:
|
|
316
279
|
return None
|
|
317
280
|
return cls.from_report_path(
|
|
@@ -347,6 +347,7 @@ def _active_run(ctx: dict) -> dict:
|
|
|
347
347
|
"teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
|
|
348
348
|
"promptSnapshotPath": ctx.get("RUN_PROMPT_SNAPSHOT_RELATIVE_PATH", ""),
|
|
349
349
|
"finalReportPath": ctx.get("FINAL_REPORT_RELATIVE_PATH", ""),
|
|
350
|
+
"convergenceStatePath": ctx.get("CONVERGENCE_STATE_RELATIVE_PATH", ""),
|
|
350
351
|
"finalStatusPath": ctx.get("FINAL_STATUS_RELATIVE_PATH", ""),
|
|
351
352
|
"validatorScriptPath": ctx.get("RUN_VALIDATOR_RELATIVE_PATH", ""),
|
|
352
353
|
"resumeCommandPath": ctx.get("CLAUDE_RESUME_COMMAND_RELATIVE_PATH", ""),
|
|
@@ -1389,6 +1390,7 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
|
|
|
1389
1390
|
"expectedStatusPath": ctx.get("FINAL_STATUS_RELATIVE_PATH", ""),
|
|
1390
1391
|
"teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
|
|
1391
1392
|
"activeRunContextPath": ctx.get("ACTIVE_RUN_CONTEXT_RELATIVE_PATH", ""),
|
|
1393
|
+
"convergenceStatePath": ctx.get("CONVERGENCE_STATE_RELATIVE_PATH", ""),
|
|
1392
1394
|
"analysisPacketPath": ctx.get("ANALYSIS_PACKET_RELATIVE_PATH", ""),
|
|
1393
1395
|
"analysisEvidencePath": (
|
|
1394
1396
|
ctx.get("INSTRUCTION_SET_RELATIVE_PATH", "") + "/analysis-evidence.md"
|
|
@@ -160,6 +160,7 @@ _BRIEF_ENTRY_TASK_TYPES = (
|
|
|
160
160
|
|
|
161
161
|
CANONICAL_BASE_REFS = ["main", "dev", "staging", "preprod", "prod"]
|
|
162
162
|
BASE_REF_FREE_INPUT_TOKEN = "__free_input__"
|
|
163
|
+
HOST_PICK_OPTION_CAP = 4
|
|
163
164
|
|
|
164
165
|
CLAUDE_MODEL_OPTIONS = ["default", *picker_options("claude")]
|
|
165
166
|
CODEX_MODEL_OPTIONS = ["default", *picker_options("codex")]
|
|
@@ -498,6 +499,8 @@ class Prompt:
|
|
|
498
499
|
"echoTemplate": self.echo_template,
|
|
499
500
|
"multi": self.multi,
|
|
500
501
|
}
|
|
502
|
+
if self.kind == "pick" and len(self.options) > HOST_PICK_OPTION_CAP:
|
|
503
|
+
out["presentation"] = "numbered-text"
|
|
501
504
|
if self.kind == "pick_group":
|
|
502
505
|
out["questions"] = [
|
|
503
506
|
{"step": q.step, "label": q.label,
|
|
@@ -512,6 +515,35 @@ class WizardError(Exception):
|
|
|
512
515
|
"""validation failure surfaced to user."""
|
|
513
516
|
|
|
514
517
|
|
|
518
|
+
def _normalize_numbered_pick_item(prompt: Prompt, answer: str) -> str:
|
|
519
|
+
candidate = (answer or "").strip()
|
|
520
|
+
exact_values = [option.value for option in prompt.options if option.value == candidate]
|
|
521
|
+
if exact_values:
|
|
522
|
+
return exact_values[0]
|
|
523
|
+
|
|
524
|
+
if candidate.isdecimal():
|
|
525
|
+
number = int(candidate)
|
|
526
|
+
if 1 <= number <= len(prompt.options):
|
|
527
|
+
return prompt.options[number - 1].value
|
|
528
|
+
raise WizardError(f"numbered-text answer is out of range: {candidate}")
|
|
529
|
+
|
|
530
|
+
label_values = [option.value for option in prompt.options if option.label == candidate]
|
|
531
|
+
if len(label_values) == 1:
|
|
532
|
+
return label_values[0]
|
|
533
|
+
if len(label_values) > 1:
|
|
534
|
+
raise WizardError(f"numbered-text answer label is ambiguous: {candidate}")
|
|
535
|
+
raise WizardError(f"numbered-text answer is not an option: {candidate}")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _normalize_numbered_pick_answer(prompt: Prompt, answer: str) -> str:
|
|
539
|
+
if prompt.multi:
|
|
540
|
+
return ",".join(
|
|
541
|
+
_normalize_numbered_pick_item(prompt, item)
|
|
542
|
+
for item in (answer or "").split(",")
|
|
543
|
+
)
|
|
544
|
+
return _normalize_numbered_pick_item(prompt, answer)
|
|
545
|
+
|
|
546
|
+
|
|
515
547
|
# ---- Validation helpers --------------------------------------------------
|
|
516
548
|
|
|
517
549
|
_SLUG_OK = re.compile(r"[a-z0-9]")
|
|
@@ -4328,6 +4360,8 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
|
|
|
4328
4360
|
return {"echo": "", "next": prompt_payload(state, prompt)}
|
|
4329
4361
|
if prompt.kind == "pick_group":
|
|
4330
4362
|
return _submit_group(state, prompt, value)
|
|
4363
|
+
if prompt.kind == "pick" and len(prompt.options) > HOST_PICK_OPTION_CAP:
|
|
4364
|
+
value = _normalize_numbered_pick_answer(prompt, value)
|
|
4331
4365
|
step = STEP_BY_ID[prompt.step]
|
|
4332
4366
|
echo = step.submit(state, value or "")
|
|
4333
4367
|
if prompt.step not in state.answered:
|
|
@@ -15,10 +15,10 @@ Two probes, matching the two ways a pending worker goes quiet:
|
|
|
15
15
|
* ``--audit`` — an in-process worker audit sidecar. Stale past the heartbeat
|
|
16
16
|
cadence (or present with no heartbeat at all) means the worker hung. Uses
|
|
17
17
|
the same line shape and budget the Phase 7 validator applies.
|
|
18
|
-
* ``--
|
|
19
|
-
`<prompt>.status.json` past the launch grace means
|
|
20
|
-
the
|
|
21
|
-
|
|
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.
|
|
22
22
|
"""
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
@@ -28,6 +28,7 @@ import sys
|
|
|
28
28
|
from datetime import datetime, timezone
|
|
29
29
|
from pathlib import Path
|
|
30
30
|
|
|
31
|
+
from okstra_ctl.dispatch_state import DispatchError, load_json_object
|
|
31
32
|
from okstra_ctl.worker_heartbeat import (
|
|
32
33
|
HEARTBEAT_MAX_GAP_SECONDS,
|
|
33
34
|
latest_heartbeat,
|
|
@@ -80,43 +81,97 @@ def probe_heartbeat(sidecar: Path, now: datetime, max_idle: float) -> dict:
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
|
|
83
|
-
def probe_launch(
|
|
84
|
+
def probe_launch(
|
|
85
|
+
prompt: Path, dispatched_at: datetime, now: datetime, grace: float
|
|
86
|
+
) -> dict:
|
|
84
87
|
"""Whether the wrapper behind *prompt* ever started."""
|
|
85
88
|
log, status = _log_path(prompt), Path(f"{prompt}.status.json")
|
|
86
89
|
probe = {"kind": "launch", "path": str(prompt)}
|
|
87
90
|
if log.exists() or status.exists():
|
|
88
91
|
return {**probe, "state": "live", "reason": ""}
|
|
89
|
-
|
|
90
|
-
return {**probe, "state": "pending", "reason": "prompt history not written yet"}
|
|
91
|
-
waited = now.timestamp() - prompt.stat().st_mtime
|
|
92
|
+
waited = (now - dispatched_at).total_seconds()
|
|
92
93
|
if waited <= grace:
|
|
93
|
-
return {
|
|
94
|
+
return {
|
|
95
|
+
**probe,
|
|
96
|
+
"state": "pending",
|
|
97
|
+
"waitedSeconds": int(waited),
|
|
98
|
+
"reason": "within launch grace",
|
|
99
|
+
}
|
|
94
100
|
return {
|
|
95
101
|
**probe,
|
|
96
102
|
"state": "did-not-launch",
|
|
97
103
|
"waitedSeconds": int(waited),
|
|
98
104
|
"reason": (
|
|
99
105
|
f"neither {log.name} nor {status.name} exists {int(waited)}s after the "
|
|
100
|
-
f"
|
|
106
|
+
f"dispatch started (grace {int(grace)}s)"
|
|
101
107
|
),
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
|
|
105
111
|
def probe_all(
|
|
106
112
|
audits: list[str],
|
|
107
|
-
|
|
113
|
+
launches: list[tuple[str, datetime]],
|
|
108
114
|
*,
|
|
109
115
|
now: datetime,
|
|
110
116
|
max_idle: float,
|
|
111
117
|
launch_grace: float,
|
|
112
118
|
) -> dict:
|
|
113
119
|
probes = [probe_heartbeat(Path(p), now, max_idle) for p in audits]
|
|
114
|
-
probes += [
|
|
120
|
+
probes += [
|
|
121
|
+
probe_launch(Path(prompt), dispatched_at, now, launch_grace)
|
|
122
|
+
for prompt, dispatched_at in launches
|
|
123
|
+
]
|
|
115
124
|
unhealthy = [p for p in probes if p["state"] in ("stalled", "did-not-launch")]
|
|
116
125
|
return {"ok": not unhealthy, "checkedAt": now.isoformat(), "probes": probes,
|
|
117
126
|
"unhealthy": unhealthy}
|
|
118
127
|
|
|
119
128
|
|
|
129
|
+
def _parse_utc(value: object, label: str) -> datetime:
|
|
130
|
+
if not isinstance(value, str) or not value:
|
|
131
|
+
raise DispatchError(f"{label} must be a UTC ISO timestamp")
|
|
132
|
+
try:
|
|
133
|
+
instant = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
134
|
+
except ValueError as exc:
|
|
135
|
+
raise DispatchError(f"{label} must be a UTC ISO timestamp") from exc
|
|
136
|
+
if instant.tzinfo is None or instant.utcoffset() != timezone.utc.utcoffset(instant):
|
|
137
|
+
raise DispatchError(f"{label} must be a UTC ISO timestamp")
|
|
138
|
+
return instant.astimezone(timezone.utc)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _project_root_for_team_state(team_state_path: Path) -> Path:
|
|
142
|
+
for parent in team_state_path.resolve().parents:
|
|
143
|
+
if parent.name == ".okstra":
|
|
144
|
+
return parent.parent
|
|
145
|
+
raise DispatchError(
|
|
146
|
+
f"team-state is outside a project .okstra directory: {team_state_path}"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _launch_target(team_state_value: str, worker_id: str) -> tuple[str, datetime]:
|
|
151
|
+
team_state_path = Path(team_state_value).resolve()
|
|
152
|
+
state = load_json_object(team_state_path, "team-state")
|
|
153
|
+
workers = state.get("workers")
|
|
154
|
+
if not isinstance(workers, list):
|
|
155
|
+
raise DispatchError(f"team-state workers must be an array: {team_state_path}")
|
|
156
|
+
worker = next(
|
|
157
|
+
(
|
|
158
|
+
row for row in workers
|
|
159
|
+
if isinstance(row, dict) and row.get("workerId") == worker_id
|
|
160
|
+
),
|
|
161
|
+
None,
|
|
162
|
+
)
|
|
163
|
+
if worker is None:
|
|
164
|
+
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
|
|
171
|
+
dispatched_at = _parse_utc(worker.get("startedAt"), f"worker {worker_id} startedAt")
|
|
172
|
+
return str(prompt), dispatched_at
|
|
173
|
+
|
|
174
|
+
|
|
120
175
|
def main(argv: list[str] | None = None) -> int:
|
|
121
176
|
parser = argparse.ArgumentParser(
|
|
122
177
|
prog="okstra worker-liveness",
|
|
@@ -124,12 +179,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
124
179
|
)
|
|
125
180
|
parser.add_argument("--audit", action="append", default=[],
|
|
126
181
|
help="in-process worker audit sidecar path (repeatable)")
|
|
127
|
-
parser.add_argument("--
|
|
128
|
-
help="CLI-wrapper (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
"claude-worker path here always reports "
|
|
132
|
-
"did-not-launch — probe those with --audit")
|
|
182
|
+
parser.add_argument("--team-state", action="append", default=[],
|
|
183
|
+
help="team-state path for a CLI-wrapper assignment (repeatable)")
|
|
184
|
+
parser.add_argument("--worker", action="append", default=[],
|
|
185
|
+
help="worker id paired with --team-state (repeatable)")
|
|
133
186
|
parser.add_argument("--max-idle", type=float, default=HEARTBEAT_MAX_GAP_SECONDS,
|
|
134
187
|
help="heartbeat staleness budget in seconds")
|
|
135
188
|
parser.add_argument("--launch-grace", type=float, default=DEFAULT_LAUNCH_GRACE_SECONDS,
|
|
@@ -137,12 +190,22 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
137
190
|
parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
|
|
138
191
|
args = parser.parse_args(argv)
|
|
139
192
|
|
|
140
|
-
if
|
|
141
|
-
parser.error("
|
|
193
|
+
if len(args.team_state) != len(args.worker):
|
|
194
|
+
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")
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
launches = [
|
|
200
|
+
_launch_target(team_state, worker)
|
|
201
|
+
for team_state, worker in zip(args.team_state, args.worker, strict=True)
|
|
202
|
+
]
|
|
203
|
+
except DispatchError as exc:
|
|
204
|
+
parser.error(str(exc))
|
|
142
205
|
|
|
143
206
|
result = probe_all(
|
|
144
207
|
args.audit,
|
|
145
|
-
|
|
208
|
+
launches,
|
|
146
209
|
now=datetime.now(timezone.utc),
|
|
147
210
|
max_idle=args.max_idle,
|
|
148
211
|
launch_grace=args.launch_grace,
|
|
@@ -90,8 +90,8 @@ def report_writer_prompt_body(
|
|
|
90
90
|
"",
|
|
91
91
|
"## Role",
|
|
92
92
|
(
|
|
93
|
-
"You are the Report writer worker. Author the final-report data.json "
|
|
94
|
-
"and the worker-
|
|
93
|
+
"You are the Report writer worker. Author the final-report data.json, "
|
|
94
|
+
"its rendered Markdown sibling, and the worker-result pointer."
|
|
95
95
|
),
|
|
96
96
|
"",
|
|
97
97
|
"## Task",
|
|
@@ -104,9 +104,15 @@ def report_writer_prompt_body(
|
|
|
104
104
|
mcp_pointer_line(),
|
|
105
105
|
"",
|
|
106
106
|
"## Output Contract",
|
|
107
|
-
"You are the author of
|
|
107
|
+
"You are the author of THREE files:",
|
|
108
108
|
"- The final-report data.json at Result Path.",
|
|
109
|
-
"- The
|
|
109
|
+
"- The rendered Markdown sibling produced through okstra render-final-report.",
|
|
110
|
+
"- The worker-result pointer at Worker Result Path.",
|
|
111
|
+
(
|
|
112
|
+
"Keep the pointer to three entries: the data.json path, rendered "
|
|
113
|
+
"Markdown path, and Convergence state input path."
|
|
114
|
+
),
|
|
115
|
+
"Maintain the separate audit sidecar at Audit sidecar path.",
|
|
110
116
|
(
|
|
111
117
|
'After writing the data.json, invoke "okstra render-final-report '
|
|
112
118
|
'<Result Path>" so the markdown sibling is rendered before you return.'
|
|
@@ -129,6 +135,7 @@ def report_writer_input_lines(
|
|
|
129
135
|
("Clarification response", instruction_path(manifest, active_context, "clarificationResponsePath")),
|
|
130
136
|
("Final report template", instruction_path(manifest, active_context, "finalReportTemplatePath")),
|
|
131
137
|
("Final report schema", instruction_path(manifest, active_context, "finalReportSchemaPath")),
|
|
138
|
+
("Convergence state", run_path(manifest, active_context, "convergenceStatePath")),
|
|
132
139
|
("Worker results directory", _string_value(manifest.get("workerResultsDirectoryPath"))),
|
|
133
140
|
]
|
|
134
141
|
lines = existing_input_lines(inputs)
|
|
@@ -171,6 +178,19 @@ def instruction_path(
|
|
|
171
178
|
return _string_value(manifest.get(key))
|
|
172
179
|
|
|
173
180
|
|
|
181
|
+
def run_path(
|
|
182
|
+
manifest: Mapping[str, Any],
|
|
183
|
+
active_context: Mapping[str, Any],
|
|
184
|
+
key: str,
|
|
185
|
+
) -> str:
|
|
186
|
+
run = active_context.get("run")
|
|
187
|
+
if isinstance(run, Mapping):
|
|
188
|
+
value = _string_value(run.get(key))
|
|
189
|
+
if value:
|
|
190
|
+
return value
|
|
191
|
+
return _string_value(manifest.get(key))
|
|
192
|
+
|
|
193
|
+
|
|
174
194
|
def mcp_pointer_line() -> str:
|
|
175
195
|
return (
|
|
176
196
|
'**MCP servers:** follow the task brief\'s "## Available MCP Servers" '
|