okstra 0.154.2 → 0.156.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.
@@ -870,3 +870,32 @@ def render_to_file(
870
870
  tmp.write_text(rendered, encoding="utf-8")
871
871
  tmp.replace(output_path)
872
872
  return len(rendered.encode("utf-8"))
873
+
874
+
875
+ def snapshot_last_valid(data_path: Path) -> Path | None:
876
+ """Keep the data.json that just rendered, as `<name>.last-valid`.
877
+
878
+ The gate blocks in this file are hand-edited between self-fix rounds, and a
879
+ write in the wrong shape destroys the previous round's verdicts. `.okstra`
880
+ is conventionally gitignored, so there is no version history to fall back
881
+ on — recovery has meant scraping the JSON back out of the last rendered
882
+ markdown. This snapshot is that fallback, replaced only after a render that
883
+ passed schema enforcement, so it is always a document that renders.
884
+
885
+ Called by the CLI entry point rather than `render_to_file`, so rendering a
886
+ data.json in place (a fixture, a dry run) never writes beside its input.
887
+ Failing to write it must never fail the render: the report is the
888
+ deliverable, the snapshot is a convenience.
889
+ """
890
+ snapshot = data_path.with_name(data_path.name + ".last-valid")
891
+ try:
892
+ tmp = snapshot.with_suffix(snapshot.suffix + f".tmp.{os.getpid()}")
893
+ tmp.write_bytes(data_path.read_bytes())
894
+ tmp.replace(snapshot)
895
+ except OSError as exc:
896
+ print(
897
+ f"render-final-report: could not write {snapshot.name} ({exc})",
898
+ file=sys.stderr,
899
+ )
900
+ return None
901
+ return snapshot
@@ -110,11 +110,30 @@ def task_id(manifest: Mapping[str, Any]) -> str:
110
110
  raise FinalizeError(f"cannot infer task id from taskKey: {task_key}")
111
111
 
112
112
 
113
- def run_seq(manifest: Mapping[str, Any]) -> str:
113
+ def _category_seq(manifest: Mapping[str, Any], category: str) -> str:
114
114
  seqs = manifest.get("runSequencesByCategory")
115
115
  if not isinstance(seqs, Mapping):
116
116
  raise FinalizeError("run manifest has no runSequencesByCategory object")
117
- return require_string(seqs, "manifests")
117
+ return require_string(seqs, category)
118
+
119
+
120
+ def run_seq(manifest: Mapping[str, Any]) -> str:
121
+ """How many times this task type has run — what a lead event records."""
122
+ return _category_seq(manifest, "manifests")
123
+
124
+
125
+ def report_seq(manifest: Mapping[str, Any]) -> str:
126
+ """The `reports` sequence — the one the final-report filename carries.
127
+
128
+ Categories advance independently (`paths.compute_run_paths`), so a run whose
129
+ predecessor produced no report reuses the free report slot and `run_seq`
130
+ outruns this one. This value reaches `render-report-views --seq`, which
131
+ stamps it into the HTML `runMeta` naming the exported
132
+ `user-response-<task-type>-<seq>.md`; `user_response.write_sidecar` derives
133
+ that same name from the report path, so anything but `reports` makes the two
134
+ answer paths write different files for one report.
135
+ """
136
+ return _category_seq(manifest, "reports")
118
137
 
119
138
 
120
139
  def task_manifest_path(project_root: Path, manifest: Mapping[str, Any]) -> Path:
@@ -204,7 +223,7 @@ class FinalizeContext:
204
223
  task_key=require_string(manifest, "taskKey"),
205
224
  task_type=require_string(manifest, "taskType"),
206
225
  task_group=task_group(manifest),
207
- seq=run_seq(manifest),
226
+ seq=report_seq(manifest),
208
227
  final_status_path=resolve_optional_path(
209
228
  project_root, manifest.get("finalStatusPath")
210
229
  ),
@@ -218,6 +218,103 @@ def parse_stage_map_file(path: Path) -> list[StageMapStage]:
218
218
  return parse_stage_map_text(text, source_plan_path=str(resolved))
219
219
 
220
220
 
221
+ def _require_data_positive_int(
222
+ value: Any, field: str, row_number: int, source_plan_path: str,
223
+ ) -> int:
224
+ if not isinstance(value, int) or isinstance(value, bool) or value < 1:
225
+ raise StageMapError(
226
+ "stage_map",
227
+ f"structured Stage Map row {row_number} has invalid {field} {value!r}",
228
+ source_plan_path,
229
+ )
230
+ return value
231
+
232
+
233
+ def _require_data_text(
234
+ value: Any, field: str, row_number: int, source_plan_path: str,
235
+ ) -> str:
236
+ if not isinstance(value, str) or not value.strip():
237
+ raise StageMapError(
238
+ "stage_map",
239
+ f"structured Stage Map row {row_number} has invalid {field} {value!r}",
240
+ source_plan_path,
241
+ )
242
+ return value
243
+
244
+
245
+ def _parse_data_stage_map_row(
246
+ value: Any, row_number: int, source_plan_path: str,
247
+ ) -> StageMapStage:
248
+ if not isinstance(value, dict):
249
+ raise StageMapError(
250
+ "stage_map",
251
+ f"structured Stage Map row {row_number} must be an object",
252
+ source_plan_path,
253
+ )
254
+ stage_number = _require_data_positive_int(
255
+ value.get("stage"), "stage", row_number, source_plan_path
256
+ )
257
+ step_count = _require_data_positive_int(
258
+ value.get("stepCount"), "stepCount", row_number, source_plan_path
259
+ )
260
+ title = _require_data_text(
261
+ value.get("title"), "title", row_number, source_plan_path
262
+ )
263
+ depends_on = _require_data_text(
264
+ value.get("dependsOn"), "dependsOn", row_number, source_plan_path
265
+ )
266
+ exit_summary = _require_data_text(
267
+ value.get("exitContractSummary"),
268
+ "exitContractSummary",
269
+ row_number,
270
+ source_plan_path,
271
+ )
272
+ return StageMapStage(
273
+ stage_number,
274
+ title,
275
+ _parse_depends_on(depends_on.strip(), row_number, source_plan_path),
276
+ step_count,
277
+ exit_summary,
278
+ )
279
+
280
+
281
+ def _parse_schema_v2_stage_map(
282
+ data: dict[str, Any], source_plan_path: str,
283
+ ) -> list[StageMapStage]:
284
+ planning = data.get("implementationPlanning")
285
+ stage_map = planning.get("stageMap") if isinstance(planning, dict) else None
286
+ if not isinstance(stage_map, list) or not stage_map:
287
+ raise StageMapError(
288
+ "stage_map",
289
+ "structured report requires a non-empty implementationPlanning.stageMap",
290
+ source_plan_path,
291
+ )
292
+ stages = [
293
+ _parse_data_stage_map_row(value, row_number, source_plan_path)
294
+ for row_number, value in enumerate(stage_map, start=1)
295
+ ]
296
+ _validate_stage_numbers(stages, source_plan_path)
297
+ return stages
298
+
299
+
300
+ def _parse_stage_map_source(markdown_path: Path) -> list[StageMapStage]:
301
+ resolved = markdown_path.resolve()
302
+ data_path = resolved.with_suffix(".data.json")
303
+ if not data_path.exists():
304
+ return parse_stage_map_file(resolved)
305
+ try:
306
+ data = json.loads(data_path.read_text(encoding="utf-8"))
307
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
308
+ raise StageMapError("stage_map", str(exc), str(data_path)) from exc
309
+ if not isinstance(data, dict):
310
+ raise StageMapError(
311
+ "stage_map", "structured report must be an object", str(data_path)
312
+ )
313
+ if data.get("schemaVersion") != "2.0":
314
+ return parse_stage_map_file(resolved)
315
+ return _parse_schema_v2_stage_map(data, str(data_path))
316
+
317
+
221
318
  def stage_map_records(stages: Iterable[StageMapStage]) -> list[dict[str, Any]]:
222
319
  return [
223
320
  {
@@ -256,7 +353,7 @@ def load_task_stage_map(
256
353
  return StageMapSnapshot(
257
354
  "ready",
258
355
  str(resolved),
259
- stage_map_records(parse_stage_map_file(resolved)),
356
+ stage_map_records(_parse_stage_map_source(resolved)),
260
357
  )
261
358
 
262
359
 
@@ -31,6 +31,8 @@ from __future__ import annotations
31
31
  import argparse
32
32
  import json
33
33
  import sys
34
+ import time
35
+ from collections.abc import Callable
34
36
  from dataclasses import dataclass
35
37
  from datetime import datetime, timezone
36
38
  from pathlib import Path
@@ -49,6 +51,12 @@ from okstra_ctl.worker_heartbeat import (
49
51
  )
50
52
 
51
53
  DEFAULT_LAUNCH_GRACE_SECONDS = 60
54
+ DEFAULT_POLL_INTERVAL_SECONDS = 20.0
55
+ DEFAULT_WAIT_TIMEOUT_SECONDS = 2400.0
56
+
57
+
58
+ def _utc_now() -> datetime:
59
+ return datetime.now(timezone.utc)
52
60
 
53
61
 
54
62
  def _log_path(prompt: Path) -> Path:
@@ -167,10 +175,12 @@ def probe_launch(
167
175
  @dataclass(frozen=True)
168
176
  class ProbeTarget:
169
177
  """One pending worker resolved from team-state: which artifact answers for
170
- it, where that artifact is, and when this dispatch started."""
178
+ it, where that artifact is, when this dispatch started, and the result file
179
+ whose arrival means this worker is done."""
171
180
  liveness_mode: str
172
181
  artifact: Path
173
182
  dispatched_at: datetime
183
+ result_path: Path | None = None
174
184
 
175
185
 
176
186
  def probe_one(target: ProbeTarget, *, now: datetime, max_idle: float,
@@ -198,6 +208,61 @@ def probe_all(
198
208
  "unhealthy": unhealthy}
199
209
 
200
210
 
211
+ def result_ready(target: ProbeTarget) -> bool:
212
+ """Whether this worker's result file has landed with content in it."""
213
+ path = target.result_path
214
+ return bool(path and path.is_file() and path.stat().st_size > 0)
215
+
216
+
217
+ def wait_for_results(
218
+ targets: list[ProbeTarget],
219
+ *,
220
+ max_idle: float,
221
+ launch_grace: float,
222
+ interval: float,
223
+ timeout: float,
224
+ clock: Callable[[], datetime] = _utc_now,
225
+ sleep: Callable[[float], None] = time.sleep,
226
+ ) -> dict:
227
+ """Poll until every result file lands, a worker dies, or the deadline passes.
228
+
229
+ This is the loop a lead would otherwise hand-write in Bash at each dispatch,
230
+ and every hand-written one has to re-derive the same two facts: what "done"
231
+ means (the persisted ``resultPath``, not a guessed filename) and what
232
+ "dead" means (`probe_all`, whose graces run from ``startedAt`` — never from
233
+ an artifact's mtime, which a re-dispatched worker inherits from its previous
234
+ attempt and reads as instantly stale).
235
+
236
+ ``outcome`` is ``completed`` / ``unhealthy`` / ``timeout``.
237
+ """
238
+ started = clock()
239
+ while True:
240
+ now = clock()
241
+ result = probe_all(
242
+ targets, now=now, max_idle=max_idle, launch_grace=launch_grace
243
+ )
244
+ pending = [
245
+ str(t.result_path) for t in targets if not result_ready(t)
246
+ ]
247
+ waited = (now - started).total_seconds()
248
+ if not pending:
249
+ outcome = "completed"
250
+ elif result["unhealthy"]:
251
+ outcome = "unhealthy"
252
+ elif waited >= timeout:
253
+ outcome = "timeout"
254
+ else:
255
+ sleep(interval)
256
+ continue
257
+ return {
258
+ **result,
259
+ "ok": outcome == "completed",
260
+ "outcome": outcome,
261
+ "pending": pending,
262
+ "waitedSeconds": int(waited),
263
+ }
264
+
265
+
201
266
  def _parse_utc(value: object, label: str) -> datetime:
202
267
  if not isinstance(value, str) or not value:
203
268
  raise DispatchError(f"{label} must be a UTC ISO timestamp")
@@ -262,11 +327,18 @@ def probe_target(team_state_value: str, worker_id: str) -> ProbeTarget:
262
327
  artifact_value = worker.get(field)
263
328
  if not isinstance(artifact_value, str) or not artifact_value.strip():
264
329
  raise DispatchError(f"worker {worker_id} has no {field}")
330
+ project_root = _project_root_for_team_state(team_state_path)
265
331
  artifact = Path(artifact_value)
266
332
  if not artifact.is_absolute():
267
- artifact = _project_root_for_team_state(team_state_path) / artifact
333
+ artifact = project_root / artifact
268
334
  dispatched_at = _parse_utc(worker.get("startedAt"), f"worker {worker_id} startedAt")
269
- return ProbeTarget(mode, artifact, dispatched_at)
335
+ result_value = worker.get("resultPath")
336
+ result_path = None
337
+ if isinstance(result_value, str) and result_value.strip():
338
+ result_path = Path(result_value)
339
+ if not result_path.is_absolute():
340
+ result_path = project_root / result_path
341
+ return ProbeTarget(mode, artifact, dispatched_at, result_path)
270
342
 
271
343
 
272
344
  def main(argv: list[str] | None = None) -> int:
@@ -283,6 +355,18 @@ def main(argv: list[str] | None = None) -> int:
283
355
  parser.add_argument("--launch-grace", type=float, default=DEFAULT_LAUNCH_GRACE_SECONDS,
284
356
  help="seconds a worker may take to write its first artifact")
285
357
  parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
358
+ parser.add_argument(
359
+ "--wait", action="store_true",
360
+ help=(
361
+ "poll until every worker's persisted resultPath lands (exit 0), one "
362
+ "worker probes unhealthy (exit 1), or --timeout passes (exit 2). "
363
+ "Use this instead of hand-writing a Bash poll loop."
364
+ ),
365
+ )
366
+ parser.add_argument("--interval", type=float, default=DEFAULT_POLL_INTERVAL_SECONDS,
367
+ help="--wait poll interval in seconds")
368
+ parser.add_argument("--timeout", type=float, default=DEFAULT_WAIT_TIMEOUT_SECONDS,
369
+ help="--wait deadline in seconds")
286
370
  args = parser.parse_args(argv)
287
371
 
288
372
  if len(args.team_state) != len(args.worker):
@@ -298,16 +382,37 @@ def main(argv: list[str] | None = None) -> int:
298
382
  except DispatchError as exc:
299
383
  parser.error(str(exc))
300
384
 
301
- result = probe_all(
385
+ if not args.wait:
386
+ result = probe_all(
387
+ targets,
388
+ now=_utc_now(),
389
+ max_idle=args.max_idle,
390
+ launch_grace=args.launch_grace,
391
+ )
392
+ print(json.dumps(result, ensure_ascii=False, indent=2))
393
+ # Non-zero on an unhealthy worker so a poll loop can branch on the exit
394
+ # code without parsing the JSON.
395
+ return 0 if result["ok"] else 1
396
+
397
+ unwaitable = [
398
+ worker for target, worker in zip(targets, args.worker, strict=True)
399
+ if target.result_path is None
400
+ ]
401
+ if unwaitable:
402
+ parser.error(
403
+ f"--wait needs a resultPath in team-state for: {', '.join(unwaitable)}. "
404
+ "Waiting on a guessed filename is what --wait exists to prevent."
405
+ )
406
+
407
+ result = wait_for_results(
302
408
  targets,
303
- now=datetime.now(timezone.utc),
304
409
  max_idle=args.max_idle,
305
410
  launch_grace=args.launch_grace,
411
+ interval=args.interval,
412
+ timeout=args.timeout,
306
413
  )
307
414
  print(json.dumps(result, ensure_ascii=False, indent=2))
308
- # Non-zero on an unhealthy worker so a poll loop can branch on the exit code
309
- # without parsing the JSON.
310
- return 0 if result["ok"] else 1
415
+ return {"completed": 0, "unhealthy": 1}.get(result["outcome"], 2)
311
416
 
312
417
 
313
418
  if __name__ == "__main__":
@@ -187,10 +187,18 @@ def validate_reverify_prompt(
187
187
  *,
188
188
  task_type: str,
189
189
  forbidden_actions: str,
190
+ expected_model: str | None = None,
190
191
  ) -> list[str]:
191
- """Require the active phase boundary in a lightweight reverify prompt."""
192
+ """Require the active phase boundary in a lightweight reverify prompt.
193
+
194
+ ``expected_model`` is the value this dispatch will actually run. A reverify
195
+ prompt's `**Model:**` header is hand-written per round, and a header naming
196
+ a model the runtime does not serve does not fail here — it fails as a
197
+ provider 400 once the worker launches, where it reads as a worker fault.
198
+ Pass the dispatch's model so the mismatch is caught before launch.
199
+ """
192
200
  normalized = text.replace("\r\n", "\n").replace("\r", "\n")
193
- errors: list[str] = []
201
+ errors: list[str] = _validate_model_header(normalized, expected_model)
194
202
  task_values = _header_values(normalized, TASK_TYPE_HEADER)
195
203
  if task_values != [task_type]:
196
204
  errors.append(
@@ -287,23 +295,30 @@ def validate_initial_prompt_records(
287
295
  return errors
288
296
 
289
297
 
290
- def _validate_record_metadata(text: str, record: PromptRecord) -> list[str]:
291
- errors = _validate_delivery_mode(
292
- _header_values(text, PROMPT_DELIVERY_MODE_HEADER),
293
- record.expected_delivery_mode,
294
- )
295
- if record.expected_model is None:
296
- return errors
298
+ def _validate_model_header(text: str, expected_model: str | None) -> list[str]:
299
+ """The `**Model:** <label>, <model>` header must name the requested model.
300
+
301
+ A caller with no resolved model passes ``None`` and the header is not
302
+ judged — there is nothing to compare it against.
303
+ """
304
+ if expected_model is None:
305
+ return []
297
306
  model = _model_value(_header_values(text, MODEL_HEADER))
298
307
  if model is None:
299
- errors.append(
300
- "exactly one non-empty **Model:** <label>, <model> header is required"
301
- )
302
- elif model != record.expected_model:
303
- errors.append(
304
- f"prompt model does not match requested model: {record.expected_model}"
305
- )
306
- return errors
308
+ return ["exactly one non-empty **Model:** <label>, <model> header is required"]
309
+ if model != expected_model:
310
+ return [f"prompt model does not match requested model: {expected_model}"]
311
+ return []
312
+
313
+
314
+ def _validate_record_metadata(text: str, record: PromptRecord) -> list[str]:
315
+ return [
316
+ *_validate_delivery_mode(
317
+ _header_values(text, PROMPT_DELIVERY_MODE_HEADER),
318
+ record.expected_delivery_mode,
319
+ ),
320
+ *_validate_model_header(text, record.expected_model),
321
+ ]
307
322
 
308
323
 
309
324
  def _validate_evidence_ledger_header(
@@ -193,6 +193,8 @@ If an action has an unknown `command`, `key`, or `scope`, stop and report the wi
193
193
 
194
194
  Before rendering the next phase's bundle — and between worker rounds within a phase (reverify/critic/gapverify batches), after you have collected that round's results and token usage and before you dispatch the next round — reclaim the prior round's completed teammate panes so they do not accumulate, in two passes and adding `--keep report-writer-worker` to **both** whenever the report writer is still in flight. First source the count: `$HOME/.okstra/bin/okstra-trace-cleanup.sh --list --run-dir "<RUN_DIR>" [--keep report-writer-worker]` never kills and prints one `<pane_id>\t<pane_title>` line per pane it would reclaim — count those lines as `<n>`. Then run the same command **without** `--list` to perform the reclaim, and emit `PROGRESS: phase-batch-cleanup panes=<n>` with that count at the batch boundary. Call both passes after collecting results and before the next dispatch so no in-flight worker pane is caught. This `tmux kill-pane`s the harness teammate panes; `shutdown_request` alone only idles the agent and never frees the pane, so it stays part of the run-end sequence for roster/token hygiene. `<RUN_DIR>` is the current (or just-finished) run's directory; its recorded `state/lead-pane.id` scopes the lead's session and the lead pane is never killed. In a non-tmux session there are no panes and the script is a silent no-op.
195
195
 
196
+ Before you ask the user for any approval, clarification, or decision after workers have been dispatched, run the same reclaim first: `$HOME/.okstra/bin/okstra-trace-cleanup.sh --list --run-dir "<RUN_DIR>" [--keep report-writer-worker]` to count the panes, then the same command without `--list` to close them, emit `PROGRESS: phase-gate-cleanup panes=<n>`, and `TaskStop` each completed worker. A `TaskStop` by itself idles the task but leaves the pane open — the `trace-cleanup` call is what actually closes it. This keeps a user gate from being shown while finished worker panes remain; in-flight workers and an in-flight report writer are preserved.
197
+
196
198
  Build the `okstra render-bundle` invocation from `outcome.renderArgs`, passing each key as `--<key>` and the value verbatim (including empty strings — they are intentional `use phase default` markers).
197
199
 
198
200
  Analysis sidetracks therefore forward wizard-owned entries such as `--analysis-target "<args.analysis-target>"` and `--evidence-inputs "<args.evidence-inputs>"` when those keys are present. These are examples of the generic mapping rule, not a separate hard-coded argument list.
@@ -38,6 +38,17 @@
38
38
  "filesystem": { "allowWrite": ["~/.gemini", "~/.codex"] }
39
39
  },
40
40
  "hooks": {
41
+ "SessionStart": [
42
+ {
43
+ "matcher": "compact",
44
+ "hooks": [
45
+ {
46
+ "type": "command",
47
+ "command": "$HOME/.okstra/bin/okstra-compact-reminder.sh"
48
+ }
49
+ ]
50
+ }
51
+ ],
41
52
  "PreToolUse": [
42
53
  {
43
54
  "matcher": "Write|Edit|MultiEdit|NotebookEdit",