okstra 0.173.0 → 0.174.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 (62) hide show
  1. package/docs/architecture/storage-model.md +13 -3
  2. package/docs/architecture.md +5 -21
  3. package/docs/cli.md +3 -2
  4. package/docs/container.md +1 -1
  5. package/docs/contributor-change-matrix.md +1 -1
  6. package/docs/project-structure-overview.md +13 -13
  7. package/docs/task-process/README.md +1 -1
  8. package/docs/task-process/implementation-planning.md +1 -1
  9. package/package.json +1 -1
  10. package/runtime/BUILD.json +2 -2
  11. package/runtime/agents/workers/claude-worker.md +1 -1
  12. package/runtime/bin/lib/okstra/globals.sh +1 -1
  13. package/runtime/bin/okstra-provider-exec.py +29 -12
  14. package/runtime/bin/okstra-trace-cleanup.sh +58 -129
  15. package/runtime/prompts/lead/adapters/cmux.md +2 -0
  16. package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
  17. package/runtime/prompts/lead/plan-body-verification.md +3 -3
  18. package/runtime/prompts/lead/report-writer.md +6 -6
  19. package/runtime/prompts/profiles/_common-contract.md +2 -2
  20. package/runtime/prompts/profiles/_implementation-executor.md +2 -0
  21. package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
  22. package/runtime/prompts/profiles/error-analysis.md +1 -1
  23. package/runtime/prompts/profiles/implementation-planning.md +12 -9
  24. package/runtime/prompts/profiles/implementation.md +2 -1
  25. package/runtime/prompts/profiles/release-handoff.md +1 -1
  26. package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +1 -6
  27. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +4 -4
  28. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -0
  29. package/runtime/python/okstra_ctl/conformance.py +68 -0
  30. package/runtime/python/okstra_ctl/dispatch_core.py +89 -39
  31. package/runtime/python/okstra_ctl/dispatch_state.py +142 -14
  32. package/runtime/python/okstra_ctl/doctor.py +2 -2
  33. package/runtime/python/okstra_ctl/domain/worker_exec.py +5 -0
  34. package/runtime/python/okstra_ctl/final_report_schema.py +5 -4
  35. package/runtime/python/okstra_ctl/pane_reclaim.py +13 -22
  36. package/runtime/python/okstra_ctl/render_final_report.py +15 -19
  37. package/runtime/python/okstra_ctl/report_contract.py +0 -1
  38. package/runtime/python/okstra_ctl/report_finalize.py +68 -9
  39. package/runtime/python/okstra_ctl/run.py +43 -2
  40. package/runtime/python/okstra_ctl/schema_excerpt.py +1 -1
  41. package/runtime/python/okstra_ctl/scope_provenance.py +1 -1
  42. package/runtime/python/okstra_ctl/session.py +69 -12
  43. package/runtime/python/okstra_ctl/team.py +51 -25
  44. package/runtime/python/okstra_ctl/tmux.py +19 -149
  45. package/runtime/python/okstra_ctl/worker_request.py +2 -0
  46. package/runtime/python/okstra_ctl/worktree.py +69 -3
  47. package/runtime/python/okstra_token_usage/cli.py +1 -1
  48. package/runtime/python/okstra_token_usage/collect.py +66 -6
  49. package/runtime/skills/okstra-setup/references/project-config.md +11 -0
  50. package/runtime/templates/reports/settings.template.json +0 -24
  51. package/runtime/validators/lib/fixtures.sh +49 -17
  52. package/runtime/validators/validate-implementation-plan-stages.py +63 -3
  53. package/runtime/validators/validate-run.py +14 -473
  54. package/runtime/validators/validate_session_conformance.py +1 -1
  55. package/src/cli-registry.mjs +8 -1
  56. package/src/commands/execute/team.mjs +3 -3
  57. package/src/commands/execute/worktree-status.mjs +109 -0
  58. package/src/commands/lifecycle/install.mjs +0 -2
  59. package/src/commands/report/finalize.mjs +13 -6
  60. package/runtime/bin/okstra-subagent-reclaim.sh +0 -26
  61. package/runtime/schemas/final-report-v1.0.schema.json +0 -6366
  62. package/runtime/templates/reports/final-report.template.md +0 -1258
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Scope
4
4
 
5
- This adapter maps the neutral Okstra lead operations to a generic host using Okstra-owned tmux panes. Read it only when the rendered launch prompt selects `leadRuntime=external`.
5
+ This adapter maps the neutral Okstra lead operations to a generic host. Workers run as Okstra-owned CLI wrapper subprocesses; the host is not asked to provide a pane for them. Read it only when the rendered launch prompt selects `leadRuntime=external`.
6
6
 
7
7
  ## Capability declaration
8
8
 
@@ -11,11 +11,11 @@ This adapter maps the neutral Okstra lead operations to a generic host using Oks
11
11
  | `runtime` | `external` |
12
12
  | `leadRoleLabel` | `Okstra lead` |
13
13
  | `userPromptMode` | `host-text` |
14
- | `workerDispatchBackend` | `tmux-pane` |
15
- | `initialPromptDeliveryMode` | `lazy-path-reference` |
14
+ | `workerDispatchBackend` | `cli-wrapper` |
15
+ | `initialPromptDeliveryMode` | `eager-include` |
16
16
  | `sessionAccounting` | `artifact-only` |
17
17
  | `resumeMode` | `artifact-checkpoint` |
18
- | `teardownMode` | `pane-teardown` |
18
+ | `teardownMode` | `process-cleanup` |
19
19
  | `leadEventSource` | `lead-events-jsonl` |
20
20
 
21
21
  ## Wizard interaction relay
@@ -54,6 +54,11 @@ class ClaudeExecution:
54
54
 
55
55
  def build_command(self, request: WorkerExecRequest) -> ExecCommand:
56
56
  argv = ["claude", "-p", "--model", request.model]
57
+ # The dispatcher's id, not the CLI's own: it is what team-state records,
58
+ # so token collection reads this worker's jsonl instead of guessing at
59
+ # `agentName` — which a pane-dispatched CLI never writes.
60
+ if request.session_id:
61
+ argv += ["--session-id", request.session_id]
57
62
  for directory in request.policy.write_scope:
58
63
  if directory != request.project_root:
59
64
  argv += ["--add-dir", str(directory)]
@@ -25,6 +25,74 @@ EXTERNAL_ADVISORY_CAPABILITIES: frozenset[str] = frozenset(
25
25
  )
26
26
 
27
27
 
28
+ # --- 계획 산출물의 stage conformance 선언 파싱 ---
29
+ #
30
+ # 정본 형식은 prompts/profiles/implementation-planning.md 의
31
+ # `Conformance tests: stage-<N> — <script> (requires=[db|io|http|external,...])`
32
+ # 이고, data.json 의 `conformanceTests` 는 그 줄에서 `stage-<N> — ` 접두사를 뗀
33
+ # 나머지다. 승인 경계(run.py `_validate_approved_plan`)와 구현 런 끝
34
+ # (validators/validate-run.py)이 이 한 규칙을 같이 읽는다 — 규칙을 두 곳에
35
+ # 복제하면 승인은 통과하는데 구현 런은 막히는 상태가 다시 만들어진다.
36
+ _CONFORMANCE_TESTS_RE = re.compile(
37
+ r"^(?P<script>\S+)\s+\(requires=\[(?P<requires>[^\]]+)\]\)$"
38
+ )
39
+
40
+
41
+ def normalize_conformance_script(script: str) -> str:
42
+ """`<task_root>/` 접두사를 뗀다. 선언과 매니페스트 entry 가 같은 스크립트를
43
+ 서로 다른 접두사로 적을 수 있어, 대조 전에 양쪽을 이 형태로 맞춘다."""
44
+ prefix = "<task_root>/"
45
+ return script[len(prefix):] if script.startswith(prefix) else script
46
+
47
+
48
+ def parse_conformance_tests(value: object) -> tuple[str, frozenset[str]] | None:
49
+ """stage 선언의 `<script> (requires=[cap,...])` 를 파싱. 형식이 아니면 None."""
50
+ if not isinstance(value, str):
51
+ return None
52
+ match = _CONFORMANCE_TESTS_RE.fullmatch(value.strip())
53
+ if match is None:
54
+ return None
55
+ script = normalize_conformance_script(match.group("script"))
56
+ capabilities = [part.strip() for part in match.group("requires").split(",")]
57
+ if (
58
+ not script
59
+ or any(not capability for capability in capabilities)
60
+ or len(set(capabilities)) != len(capabilities)
61
+ or any(capability not in CAPABILITY_WHITELIST for capability in capabilities)
62
+ ):
63
+ return None
64
+ return script, frozenset(capabilities)
65
+
66
+
67
+ def malformed_conformance_stages(data: object) -> list[int]:
68
+ """`conformanceTests` 를 선언했으나 파싱되지 않는 stage 번호들.
69
+
70
+ 선언이 아예 없는 stage 는 대상이 아니다 — 둘 중 하나를 요구하는 XOR 은
71
+ validators/validate-implementation-plan-stages.py 의 check S11 이 계획
72
+ 단계에서 이미 판정한다. `stage` 가 양의 정수가 아닌 경우도 여기 몫이 아니다:
73
+ 스키마(`stage: integer, minimum 1`)가 계획 런의 validate-run 에서 먼저 막는다.
74
+ `conformanceTests` 가 문자열이 아닌 경우도 같은 이유로 뺀다 — 스키마가
75
+ `type: string, minLength: 1` 로 못박아 그 값은 계획 런의 validate-run 을
76
+ 통과하지 못한다. 여기서 그것까지 malformed 로 세면 스키마가 이미 낸 판정을
77
+ 이 게이트가 다른 문구로 되풀이한다.
78
+ """
79
+ planning = data.get("implementationPlanning") if isinstance(data, dict) else None
80
+ stages = planning.get("stages") if isinstance(planning, dict) else None
81
+ bad: list[int] = []
82
+ for stage in stages if isinstance(stages, list) else []:
83
+ if not isinstance(stage, dict):
84
+ continue
85
+ declared = stage.get("conformanceTests")
86
+ if not isinstance(declared, str) or not declared.strip():
87
+ continue
88
+ number = stage.get("stage")
89
+ if not isinstance(number, int) or isinstance(number, bool) or number < 1:
90
+ continue
91
+ if parse_conformance_tests(declared) is None:
92
+ bad.append(number)
93
+ return bad
94
+
95
+
28
96
  def is_advisory_conformance_entry(entry: object) -> bool:
29
97
  """Return whether one entry depends on user-owned external QA."""
30
98
  if not isinstance(entry, dict):
@@ -10,18 +10,18 @@ from pathlib import Path
10
10
  from typing import Any, Mapping, Sequence
11
11
 
12
12
  from . import cmux
13
- from . import tmux
14
13
  from .dispatch_state import (
15
14
  BACKEND_CLI_WRAPPER,
16
15
  BACKEND_CMUX_PANE,
17
16
  BACKEND_MIXED,
18
- BACKEND_TMUX_PANE,
19
17
  append_worker_dispatch as _append_worker_dispatch,
18
+ build_dispatch_id as _build_dispatch_id,
20
19
  DispatchError,
21
20
  WorkerJob,
22
21
  dispatch_completion_paths as _completion_paths,
23
22
  dispatch_mode as _dispatch_mode,
24
23
  dispatch_result_path as _result_path_for_worker,
24
+ dispatch_session_id as _dispatch_session_id,
25
25
  LIVENESS_AUDIT_HEARTBEAT,
26
26
  LIVENESS_WRAPPER_STATUS,
27
27
  load_json_object as _load_json_object,
@@ -203,6 +203,9 @@ def build_dispatch_plan(
203
203
  requested_workers,
204
204
  options,
205
205
  )
206
+ # Ahead of the per-prompt checks: a batch the lead must split is a plan-shape
207
+ # problem, and reporting a prompt-header error for a doomed batch buries it.
208
+ _validate_implementation_phase_order(jobs)
206
209
  _validate_dispatch_prompts(manifest, active_context, jobs)
207
210
  _reject_stale_schema_excerpt(project_root, manifest, jobs)
208
211
  return DispatchPlan(
@@ -219,12 +222,13 @@ def build_dispatch_plan(
219
222
 
220
223
  def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
221
224
  _validate_report_writer_isolation(plan.jobs)
225
+ _validate_implementation_phase_order(plan.jobs)
222
226
  if wait:
223
227
  pane_backends = sorted(
224
228
  {
225
229
  job.backend
226
230
  for job in plan.jobs
227
- if job.backend in (BACKEND_TMUX_PANE, BACKEND_CMUX_PANE)
231
+ if job.backend == BACKEND_CMUX_PANE
228
232
  }
229
233
  )
230
234
  if pane_backends:
@@ -250,6 +254,7 @@ def dispatch_cli_wrapper_plan(plan: DispatchPlan) -> int:
250
254
  if any(job.backend != BACKEND_CLI_WRAPPER for job in plan.jobs):
251
255
  raise DispatchError("concurrent CLI dispatch requires cli-wrapper jobs only")
252
256
  _validate_report_writer_isolation(plan.jobs)
257
+ _validate_implementation_phase_order(plan.jobs)
253
258
  _record_dispatch_facts(plan.team_state_path, _dispatch_mode(plan.jobs))
254
259
  final_codes = _dispatch_cli_wrapper_batch(plan, plan.jobs)
255
260
  return next((final_codes[job.worker_id] for job in plan.jobs
@@ -267,6 +272,55 @@ def _validate_report_writer_isolation(jobs: Sequence[WorkerJob]) -> None:
267
272
  )
268
273
 
269
274
 
275
+ def _validate_implementation_phase_order(jobs: Sequence[WorkerJob]) -> None:
276
+ """Keep the executor and the verifiers in two dispatches, never one batch.
277
+
278
+ A verifier reads the diff the executor wrote, so `implementation.md` binds
279
+ its contract to the window "between Executor stage completion and the first
280
+ verifier dispatch". Nothing enforced that ordering: one
281
+ `okstra team dispatch --workers claude,codex` started both at once and the
282
+ verifier observed base HEAD instead of the stage diff, which is what its
283
+ result then reported on.
284
+
285
+ The refusal names the worker IDs on both sides because the second dispatch
286
+ has to drop the executor's ID: `worker_prompt_policy` re-materializes that
287
+ ID as the executor every time, so a `--workers` list that keeps it is
288
+ refused again and a message saying only "split the batch" would loop.
289
+
290
+ `build_dispatch_plan` is the only call every production dispatch takes, and
291
+ the one `--dry-run` stops at, so it is what makes the preview refuse. The
292
+ two dispatch entry points are public and take a caller-assembled plan, so
293
+ they check it again there rather than trust their input.
294
+ """
295
+ executors = [
296
+ job.worker_id for job in jobs if job.audience == "implementation-executor"
297
+ ]
298
+ verifiers = [
299
+ job.worker_id for job in jobs if job.audience == "implementation-verifier"
300
+ ]
301
+ if executors and verifiers:
302
+ executor_ids = ", ".join(executors)
303
+ raise DispatchError(
304
+ "an implementation batch may not hold the executor and a verifier: "
305
+ f"{_named_role(executors, 'executor')}, "
306
+ f"{_named_role(verifiers, 'verifier')}. "
307
+ "Dispatch the executor alone, settle it with `okstra team await`, "
308
+ f"then dispatch the verifiers with `--workers` omitting {executor_ids} "
309
+ "— that worker ID materializes as the executor again, so a second "
310
+ "batch holding it is refused too. The split is what lets a verifier "
311
+ "observe the stage diff instead of base HEAD"
312
+ )
313
+
314
+
315
+ def _named_role(worker_ids: Sequence[str], role: str) -> str:
316
+ """`verifier codex` for one, `verifiers codex, antigravity` for several.
317
+
318
+ A roster that adds antigravity puts two verifiers in the same refusal, and
319
+ a singular label there reads as one worker named "codex, antigravity".
320
+ """
321
+ return f"{role}{'s' if len(worker_ids) > 1 else ''} {', '.join(worker_ids)}"
322
+
323
+
270
324
  def _dispatch_cli_wrapper_batch(
271
325
  plan: DispatchPlan,
272
326
  jobs: Sequence[WorkerJob],
@@ -317,7 +371,7 @@ def _dispatch_cli_wrapper_batch(
317
371
  "worker-retry-scheduled",
318
372
  _retry_details(job, attempt, outcome),
319
373
  )
320
- next_round.append((job, attempt + 1))
374
+ next_round.append((_job_for_next_attempt(job), attempt + 1))
321
375
  continue
322
376
  final_codes[job.worker_id] = outcome.returncode or 1
323
377
  pending = next_round
@@ -608,6 +662,7 @@ def _job_from_roster_worker(
608
662
  role=fact.role,
609
663
  idle_timeout_seconds=options.idle_timeout_seconds,
610
664
  dispatch_kind=options.dispatch_kind,
665
+ session_id=_dispatch_session_id(fact.provider),
611
666
  **invocation,
612
667
  )
613
668
 
@@ -668,7 +723,7 @@ def _materialize_roster_prompts(
668
723
  project_root=project_root,
669
724
  run_manifest_path=manifest_path,
670
725
  runtime_root=workspace_root,
671
- delivery_mode=_delivery_mode(options.default_backend),
726
+ delivery_mode=PromptDeliveryMode.EAGER_INCLUDE,
672
727
  workers=tuple(
673
728
  InitialPromptWorkerRequest(fact.worker_id, fact.model)
674
729
  for fact in worker_facts
@@ -696,12 +751,6 @@ def _roster_worker_facts(
696
751
  )
697
752
 
698
753
 
699
- def _delivery_mode(default_backend: str) -> PromptDeliveryMode:
700
- if default_backend == BACKEND_TMUX_PANE:
701
- return PromptDeliveryMode.LAZY_PATH_REFERENCE
702
- return PromptDeliveryMode.EAGER_INCLUDE
703
-
704
-
705
754
  def _jobs_from_file(
706
755
  project_root: Path,
707
756
  workspace_root: Path,
@@ -772,6 +821,7 @@ def _dispatch_job_with_retry(plan: DispatchPlan, job: WorkerJob) -> int:
772
821
  return 0
773
822
  if _should_retry(outcome, attempt):
774
823
  _append_event(plan, "worker-retry-scheduled", _retry_details(job, attempt, outcome))
824
+ job = _job_for_next_attempt(job)
775
825
  continue
776
826
  return outcome.returncode or 1
777
827
  return 1
@@ -780,8 +830,6 @@ def _dispatch_job_with_retry(plan: DispatchPlan, job: WorkerJob) -> int:
780
830
  def _start_job(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
781
831
  if job.backend == BACKEND_CLI_WRAPPER:
782
832
  return _run_cli_wrapper(plan, job, "")
783
- if job.backend == BACKEND_TMUX_PANE:
784
- return _start_tmux_or_degrade(plan, job)
785
833
  if job.backend == BACKEND_CMUX_PANE:
786
834
  return _start_cmux_or_degrade(plan, job)
787
835
  raise DispatchError(f"unsupported worker backend: {job.backend}")
@@ -829,23 +877,6 @@ def _opened_cmux_surfaces(team_state_path: Path) -> tuple[str, ...]:
829
877
  return tuple(opened)
830
878
 
831
879
 
832
- def _start_tmux_or_degrade(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
833
- lead_pane = tmux.resolve_caller_pane()
834
- if not lead_pane:
835
- return _run_cli_wrapper(plan, job, BACKEND_TMUX_PANE)
836
- try:
837
- pane_id = tmux.split_worker_pane(
838
- target_pane=lead_pane,
839
- cwd=plan.project_root,
840
- command=job.command,
841
- title=f"{job.worker_id}-worker",
842
- run_dir=_run_dir(plan),
843
- )
844
- except RuntimeError:
845
- return _run_cli_wrapper(plan, job, BACKEND_TMUX_PANE)
846
- return WorkerHandle(job, pane_id, None, status_path_for_prompt(job.prompt_path), "")
847
-
848
-
849
880
  def _refuse_when_cmux_is_walled_off() -> None:
850
881
  """Stop rather than degrade when a sandbox stands between okstra and cmux.
851
882
 
@@ -955,7 +986,9 @@ def _retry_from_record(
955
986
  plan, job, attempt, outcome, reason
956
987
  )
957
988
  _append_event(plan, "worker-retry-scheduled", details)
958
- _spawn_job(plan, job, attempt + 1)
989
+ # The restored job settles attempt 1 above and must keep its session; only
990
+ # the attempt being opened here gets a new one.
991
+ _spawn_job(plan, _job_for_next_attempt(job), attempt + 1)
959
992
 
960
993
 
961
994
  def _finish_attempt(plan: DispatchPlan, job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> None:
@@ -1038,7 +1071,7 @@ def _link_result(
1038
1071
  _link_agent_dispatch_result(
1039
1072
  project_root=plan.project_root,
1040
1073
  run_manifest_path=plan.manifest_path,
1041
- dispatch_id=f"{job.invocation_id}:attempt-{attempt}",
1074
+ dispatch_id=_build_dispatch_id(job.invocation_id, attempt),
1042
1075
  result_path=job.worker_result_path,
1043
1076
  )
1044
1077
  except (DispatchError, OSError) as exc:
@@ -1300,6 +1333,10 @@ def _dispatch_record(
1300
1333
  "provider": job.provider,
1301
1334
  "status": status,
1302
1335
  "paneId": pane_id,
1336
+ # The only handle on a pane worker's transcript: it runs as its own
1337
+ # `claude -p` process and leaves no `agentName` in its session jsonl, so
1338
+ # token collection resolves its usage by this value or not at all.
1339
+ "sessionId": job.session_id,
1303
1340
  "promptPath": str(job.prompt_path),
1304
1341
  "resultPath": str(job.result_path),
1305
1342
  "workerResultPath": str(job.worker_result_path),
@@ -1317,7 +1354,7 @@ def _dispatch_record(
1317
1354
  }
1318
1355
  if job.invocation_id:
1319
1356
  record.update({
1320
- "dispatchId": f"{job.invocation_id}:attempt-{attempt}",
1357
+ "dispatchId": _build_dispatch_id(job.invocation_id, attempt),
1321
1358
  "invocationId": job.invocation_id,
1322
1359
  "audience": job.audience,
1323
1360
  "assignmentRef": job.assignment_ref,
@@ -1330,7 +1367,7 @@ def _dispatch_record(
1330
1367
 
1331
1368
 
1332
1369
  def _liveness_mode(backend: str) -> str:
1333
- if backend in (BACKEND_CLI_WRAPPER, BACKEND_TMUX_PANE, BACKEND_CMUX_PANE):
1370
+ if backend in (BACKEND_CLI_WRAPPER, BACKEND_CMUX_PANE):
1334
1371
  return LIVENESS_WRAPPER_STATUS
1335
1372
  return LIVENESS_AUDIT_HEARTBEAT
1336
1373
 
@@ -1585,6 +1622,10 @@ def _job_from_record(project_root: Path, record: Mapping[str, Any]) -> WorkerJob
1585
1622
  role=_require_string(record, "role"),
1586
1623
  idle_timeout_seconds=600,
1587
1624
  dispatch_kind=_require_string(record, "kind"),
1625
+ # Restored, never re-issued: both callers rebuild a job from its record
1626
+ # to settle a dispatch that already ran, and a fresh id would settle it
1627
+ # against a session that never existed.
1628
+ session_id=_string_value(record.get("sessionId")),
1588
1629
  invocation_id=_string_value(record.get("invocationId")),
1589
1630
  audience=_string_value(record.get("audience")),
1590
1631
  assignment_ref=_string_value(record.get("assignmentRef")),
@@ -1613,6 +1654,20 @@ def _should_retry(outcome: WorkerOutcome, attempt: int) -> bool:
1613
1654
  return outcome.returncode == 0 and bool(outcome.missing_completion_paths) and attempt < MAX_WORKER_ATTEMPTS
1614
1655
 
1615
1656
 
1657
+ def _job_for_next_attempt(job: WorkerJob) -> WorkerJob:
1658
+ """The same work as a new attempt, which means a new session.
1659
+
1660
+ The first attempt's process is dead; a retry starts another one. Carrying
1661
+ the id over would merge two processes into one attribution unit, and it
1662
+ would hand `claude --session-id` a value that is already taken — behaviour
1663
+ the CLI does not document. Every re-dispatch goes through here so no path
1664
+ can quietly keep the old session.
1665
+
1666
+ A provider with no session flag gets '' back, so this is a no-op for it.
1667
+ """
1668
+ return replace(job, session_id=_dispatch_session_id(job.provider))
1669
+
1670
+
1616
1671
  def _failure_reason(outcome: WorkerOutcome) -> str:
1617
1672
  if outcome.timeout:
1618
1673
  return "worker wrapper timed out"
@@ -1709,11 +1764,6 @@ def _reject_stale_schema_excerpt(
1709
1764
  )
1710
1765
 
1711
1766
 
1712
- def _run_dir(plan: DispatchPlan) -> Path:
1713
- value = _string_value(plan.manifest.get("runDirectoryPath"))
1714
- return _resolve_project_path(plan.project_root, value) if value else plan.team_state_path.parent.parent
1715
-
1716
-
1717
1767
  def _load_optional_json(project_root: Path, value: Any) -> dict[str, Any]:
1718
1768
  if not isinstance(value, str) or not value.strip():
1719
1769
  return {}
@@ -1,8 +1,8 @@
1
1
  """Worker dispatch plumbing shared by both lead dispatchers.
2
2
 
3
3
  `dispatch_core` (claude / external lead) and `codex_dispatch` (codex lead) differ
4
- in capability — only the former has tmux panes, blocking waits, and retry from a
5
- persisted record. What they do *not* differ in is the job value object and the
4
+ in capability — only the former has cmux surfaces, blocking waits, and retry from
5
+ a persisted record. What they do *not* differ in is the job value object and the
6
6
  run's state files: the same run-manifest keys, the same team-state document, the
7
7
  same wrapper argv.
8
8
 
@@ -22,6 +22,7 @@ import fcntl
22
22
  import json
23
23
  import os
24
24
  import tempfile
25
+ import uuid
25
26
  from dataclasses import dataclass
26
27
  from datetime import datetime, timezone
27
28
  from pathlib import Path
@@ -46,23 +47,22 @@ from .worker_prompt_contract import (
46
47
  from .worker_runner import LIVE, QUIET
47
48
 
48
49
  BACKEND_CLI_WRAPPER = "cli-wrapper"
49
- BACKEND_TMUX_PANE = "tmux-pane"
50
50
  BACKEND_CMUX_PANE = "cmux-pane"
51
51
  BACKEND_MIXED = "mixed"
52
52
 
53
53
 
54
54
  def detect_terminal_backend() -> str:
55
- """Which pane backend this run gets. Called once, by prepare.
55
+ """Which backend this run's workers get. Called once, by prepare.
56
56
 
57
- cmux wins wherever it is usable: the only thing a tmux session buys a lead
58
- is Claude Code's AgentTeam, and the cmux path deliberately does not use it.
59
- Everywhere else this answers `tmux-pane`, which is what every run got before
60
- cmux existed. The answer is written to the run manifest and read back from
61
- there — consumers must not re-detect, or two phases of one run can disagree.
57
+ cmux is the only backend that gives a worker a surface of its own. Without
58
+ it a worker runs as a cli-wrapper subprocess, which owns no pane and is read
59
+ through its status sidecar instead. The answer is written to the run manifest
60
+ and read back from there — consumers must not re-detect, or two phases of one
61
+ run can disagree.
62
62
  """
63
63
  if cmux.cmux_available():
64
64
  return BACKEND_CMUX_PANE
65
- return BACKEND_TMUX_PANE
65
+ return BACKEND_CLI_WRAPPER
66
66
 
67
67
  # `livenessMode` picks which artifact answers "is this worker still alive": the
68
68
  # in-process worker's audit sidecar heartbeat, or the CLI wrapper's status
@@ -88,6 +88,18 @@ WORKER_STATUSES = frozenset(
88
88
  REASON_REQUIRED_STATUSES = frozenset({"timeout", "error", "not-run"})
89
89
 
90
90
 
91
+ def generate_claude_session_id() -> str:
92
+ """A UUIDv4 for `claude --session-id`.
93
+
94
+ The lead mints one for its own session (`run`) and dispatch mints one per
95
+ worker (`dispatch_session_id`); they are the same kind of value, so one
96
+ generator serves both. It sits here rather than in `session` because
97
+ `session` imports this module, so a worker-side call the other way would
98
+ close an import cycle.
99
+ """
100
+ return str(uuid.uuid4())
101
+
102
+
91
103
  class DispatchError(Exception):
92
104
  """Raised when a worker dispatch request cannot be executed."""
93
105
 
@@ -119,11 +131,17 @@ class WorkerJob:
119
131
  prompt_digest: str = ""
120
132
  host_model_value: str | None = None
121
133
  enforcement_mode: str = ""
134
+ # The session id dispatch mints and carries through to the provider CLI, so
135
+ # token collection can find the worker's jsonl by this value rather than by
136
+ # guessing at `agentName`. A worker started in a cmux pane is a separate CLI
137
+ # process and leaves no `agentName` behind, so without this its usage cannot
138
+ # be attributed at all.
139
+ session_id: str = ""
122
140
 
123
141
  @property
124
142
  def command(self) -> list[str]:
125
143
  # The flag trails the positional contract the entrypoint reads by index.
126
- return [
144
+ argv = [
127
145
  str(self.wrapper_path),
128
146
  str(self.project_root),
129
147
  self.model_execution_value,
@@ -134,6 +152,9 @@ class WorkerJob:
134
152
  "--presentation",
135
153
  self._presentation(),
136
154
  ]
155
+ if self.session_id:
156
+ argv += ["--session-id", self.session_id]
157
+ return argv
137
158
 
138
159
  def _presentation(self) -> str:
139
160
  # A pane is a screen a person watches; a cli-wrapper dispatch's stdout is
@@ -298,7 +319,23 @@ def append_worker_dispatch(
298
319
  team_state_path: Path,
299
320
  record: Mapping[str, Any],
300
321
  ) -> None:
301
- """Append one worker dispatch without losing another state writer's update."""
322
+ """Record one worker dispatch without losing another state writer's update.
323
+
324
+ A row carrying a dispatch id replaces the row that already holds it rather
325
+ than stacking beside it: an observed lead re-sent `okstra team dispatch` for
326
+ a prompt and attempt it had already sent, team-state kept both copies, and
327
+ `validate-run` failed that run with `agent dispatch ID is duplicated`.
328
+
329
+ The replaced row takes its `paneId` with it, and recorded ids are the only
330
+ reclaim candidates `team._reclaimable_panes` has, so that pane is left on
331
+ screen for the session. That is the price of the trade: before this, the
332
+ same re-send cost the whole run its report.
333
+
334
+ A job with no invocation carries no dispatch id, so it has no key to
335
+ collapse on and folding those rows would merge two unrelated dispatches into
336
+ one. They still stack, and `update_worker_dispatch_status` settles the
337
+ newest of them.
338
+ """
302
339
  with _team_state_lock(team_state_path):
303
340
  payload = load_json_object(team_state_path, "team-state")
304
341
  dispatches = payload.setdefault("workerDispatches", [])
@@ -306,7 +343,20 @@ def append_worker_dispatch(
306
343
  raise DispatchError(
307
344
  f"team-state workerDispatches must be an array: {team_state_path}"
308
345
  )
309
- dispatches.append(dict(record))
346
+ new_record = dict(record)
347
+ new_id = str(new_record.get("dispatchId") or "")
348
+ replaced = False
349
+ if new_id:
350
+ for index, existing in enumerate(dispatches):
351
+ if (
352
+ isinstance(existing, Mapping)
353
+ and existing.get("dispatchId") == new_id
354
+ ):
355
+ dispatches[index] = new_record
356
+ replaced = True
357
+ break
358
+ if not replaced:
359
+ dispatches.append(new_record)
310
360
  write_json(team_state_path, payload)
311
361
 
312
362
 
@@ -428,7 +478,9 @@ def record_verified_agent_dispatch(
428
478
  if not isinstance(prompt, Mapping):
429
479
  raise DispatchError("agent invocation prompt is missing")
430
480
  prompt_path = require_string(prompt, "path")
431
- dispatch_id = f"{invocation_id}:attempt-1"
481
+ # One verified record per invocation: the conflict guard below refuses a
482
+ # second write under this id, so this path never numbers a retry.
483
+ dispatch_id = build_dispatch_id(invocation_id, 1)
432
484
  record = {
433
485
  "dispatchId": dispatch_id,
434
486
  "workerId": worker_id,
@@ -712,6 +764,25 @@ def _has_recorded_team_create(team_state: Mapping[str, Any]) -> bool:
712
764
 
713
765
  # --- job facts ----------------------------------------------------------------
714
766
 
767
+ def build_dispatch_id(invocation_id: str, attempt: int) -> str:
768
+ """The only place Python assembles a dispatch id.
769
+
770
+ Two generators drifted: this module pinned the attempt to 1 while
771
+ `dispatch_core` used the real one, so a re-dispatch took the first attempt's
772
+ id, team-state kept the same row twice, and `validate-run` failed that run
773
+ with `agent dispatch ID is duplicated`.
774
+
775
+ Prose mirrors of this shape live outside Python, where no guard reaches
776
+ them: `prompts/lead/convergence.md`, `prompts/lead/report-writer.md`,
777
+ `prompts/lead/plan-body-verification.md` and the five
778
+ `adapters/hosts/*/relay.md` each spell an `--dispatch-id` argument out for
779
+ the lead. They agree with this function only because
780
+ `record_verified_agent_dispatch` records the first attempt and no other;
781
+ changing that convention has to change them too.
782
+ """
783
+ return f"{invocation_id}:attempt-{attempt}"
784
+
785
+
715
786
  def dispatch_mode(jobs: Sequence[WorkerJob]) -> str:
716
787
  backends = {job.backend for job in jobs}
717
788
  if len(backends) == 1:
@@ -723,6 +794,59 @@ def missing_completion_paths(job: WorkerJob) -> tuple[Path, ...]:
723
794
  return tuple(path for path in job.completion_paths if not path.is_file())
724
795
 
725
796
 
797
+ def dispatch_session_id(provider: str) -> str:
798
+ """The session this dispatch mints for *provider*, or '' for one that has no
799
+ such flag.
800
+
801
+ Only the claude CLI reads `--session-id`; handing the value to another
802
+ wrapper would start it with an argument it does not know. An empty value is
803
+ the same as not issuing one — `WorkerJob.command` appends the flag only when
804
+ it is set.
805
+
806
+ This lives beside `worker_jobs_from_file` for the same reason
807
+ `dispatch_result_path` does: both job constructors apply it, and a rule
808
+ written twice is a rule that drifts.
809
+ """
810
+ return generate_claude_session_id() if provider == "claude" else ""
811
+
812
+
813
+ def worker_session_ids(
814
+ team_state: Mapping[str, Any], worker_id: str | None = None
815
+ ) -> list[str]:
816
+ """The claude sessions this run's dispatches issued, in the order recorded.
817
+
818
+ A worker started in a cmux pane is its own `claude -p` process and tags its
819
+ session jsonl with neither `agentName` nor `teamName`, so nothing in the
820
+ transcript says which worker wrote it. The id `dispatch_session_id` minted
821
+ and `_dispatch_record` wrote is the only handle on it.
822
+
823
+ Every id, not one: a retry opens a new session, so one worker legitimately
824
+ owns several rows with different `sessionId` values, and taking only the
825
+ first or last bills a single attempt of a retried worker.
826
+
827
+ Pass ``worker_id`` for one worker's sessions, or omit it for the run's.
828
+ This reads a `workerDispatches[]` row shape, so it belongs beside the code
829
+ that writes that shape rather than in each of the two modules that read it.
830
+
831
+ A non-list log yields nothing rather than raising: `session.observe_lead_session`
832
+ calls this for its exclusion set and catches only OSError, so a `TypeError`
833
+ from iterating a number here would take down `team dispatch` / `team await` /
834
+ `report-finalize`. Observation not breaking its caller is a contract kept
835
+ here, the same way `add_observed_session` keeps it for the keys it appends to.
836
+ """
837
+ session_ids: list[str] = []
838
+ dispatches = team_state.get("workerDispatches")
839
+ for record in dispatches if isinstance(dispatches, list) else []:
840
+ if not isinstance(record, Mapping):
841
+ continue
842
+ if worker_id is not None and record.get("workerId") != worker_id:
843
+ continue
844
+ session_id = str(record.get("sessionId") or "").strip()
845
+ if session_id and session_id not in session_ids:
846
+ session_ids.append(session_id)
847
+ return session_ids
848
+
849
+
726
850
  def dispatch_result_path(
727
851
  worker_id: str,
728
852
  worker_result_path: Path,
@@ -1006,6 +1130,10 @@ def _worker_job_from_file(
1006
1130
  role=require_string(item, "role"),
1007
1131
  idle_timeout_seconds=idle_timeout_seconds,
1008
1132
  dispatch_kind=dispatch_kind,
1133
+ # Minted here, not read from the file: the lead writes this file by
1134
+ # hand, and a session id it typed would be unfalsifiable — nothing can
1135
+ # tell an id that names no session from one that names the wrong one.
1136
+ session_id=dispatch_session_id(provider),
1009
1137
  invocation_id=string_value(item.get("invocationId")),
1010
1138
  audience=string_value(item.get("audience")),
1011
1139
  assignment_ref=string_value(item.get("assignmentRef")),
@@ -236,11 +236,11 @@ def _final_verification_checks(workspace: Path) -> list[DoctorCheck]:
236
236
  _file_check("validation command", workspace / "validators" / "validate-run.py"),
237
237
  _file_check(
238
238
  "final report template",
239
- workspace / "templates" / "reports" / "final-report.template.md",
239
+ workspace / "templates" / "reports" / "final-report-v2.template.md",
240
240
  ),
241
241
  _file_check(
242
242
  "final report schema",
243
- workspace / "schemas" / "final-report-v1.0.schema.json",
243
+ workspace / "schemas" / "final-report-v2.0.schema.json",
244
244
  ),
245
245
  ]
246
246
 
@@ -53,6 +53,11 @@ class WorkerExecRequest:
53
53
  worktree_path: Path | None
54
54
  policy: ExecutionPolicy
55
55
  idle_timeout_seconds: int
56
+ # The id the dispatcher chose, empty when it chose none. Only a CLI that
57
+ # accepts a session id reads it. Handing it over means the CLI does not
58
+ # issue its own, so the recorded id and the session's own jsonl are
59
+ # guaranteed to name the same session.
60
+ session_id: str = ""
56
61
 
57
62
 
58
63
  @dataclass(frozen=True)