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.
- package/docs/architecture/storage-model.md +13 -3
- package/docs/architecture.md +5 -21
- package/docs/cli.md +3 -2
- package/docs/container.md +1 -1
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/project-structure-overview.md +13 -13
- package/docs/task-process/README.md +1 -1
- package/docs/task-process/implementation-planning.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/bin/lib/okstra/globals.sh +1 -1
- package/runtime/bin/okstra-provider-exec.py +29 -12
- package/runtime/bin/okstra-trace-cleanup.sh +58 -129
- package/runtime/prompts/lead/adapters/cmux.md +2 -0
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/lead/plan-body-verification.md +3 -3
- package/runtime/prompts/lead/report-writer.md +6 -6
- package/runtime/prompts/profiles/_common-contract.md +2 -2
- package/runtime/prompts/profiles/_implementation-executor.md +2 -0
- package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
- package/runtime/prompts/profiles/error-analysis.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +12 -9
- package/runtime/prompts/profiles/implementation.md +2 -1
- package/runtime/prompts/profiles/release-handoff.md +1 -1
- package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +1 -6
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +4 -4
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -0
- package/runtime/python/okstra_ctl/conformance.py +68 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +89 -39
- package/runtime/python/okstra_ctl/dispatch_state.py +142 -14
- package/runtime/python/okstra_ctl/doctor.py +2 -2
- package/runtime/python/okstra_ctl/domain/worker_exec.py +5 -0
- package/runtime/python/okstra_ctl/final_report_schema.py +5 -4
- package/runtime/python/okstra_ctl/pane_reclaim.py +13 -22
- package/runtime/python/okstra_ctl/render_final_report.py +15 -19
- package/runtime/python/okstra_ctl/report_contract.py +0 -1
- package/runtime/python/okstra_ctl/report_finalize.py +68 -9
- package/runtime/python/okstra_ctl/run.py +43 -2
- package/runtime/python/okstra_ctl/schema_excerpt.py +1 -1
- package/runtime/python/okstra_ctl/scope_provenance.py +1 -1
- package/runtime/python/okstra_ctl/session.py +69 -12
- package/runtime/python/okstra_ctl/team.py +51 -25
- package/runtime/python/okstra_ctl/tmux.py +19 -149
- package/runtime/python/okstra_ctl/worker_request.py +2 -0
- package/runtime/python/okstra_ctl/worktree.py +69 -3
- package/runtime/python/okstra_token_usage/cli.py +1 -1
- package/runtime/python/okstra_token_usage/collect.py +66 -6
- package/runtime/skills/okstra-setup/references/project-config.md +11 -0
- package/runtime/templates/reports/settings.template.json +0 -24
- package/runtime/validators/lib/fixtures.sh +49 -17
- package/runtime/validators/validate-implementation-plan-stages.py +63 -3
- package/runtime/validators/validate-run.py +14 -473
- package/runtime/validators/validate_session_conformance.py +1 -1
- package/src/cli-registry.mjs +8 -1
- package/src/commands/execute/team.mjs +3 -3
- package/src/commands/execute/worktree-status.mjs +109 -0
- package/src/commands/lifecycle/install.mjs +0 -2
- package/src/commands/report/finalize.mjs +13 -6
- package/runtime/bin/okstra-subagent-reclaim.sh +0 -26
- package/runtime/schemas/final-report-v1.0.schema.json +0 -6366
- 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
|
|
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` | `
|
|
15
|
-
| `initialPromptDeliveryMode` | `
|
|
14
|
+
| `workerDispatchBackend` | `cli-wrapper` |
|
|
15
|
+
| `initialPromptDeliveryMode` | `eager-include` |
|
|
16
16
|
| `sessionAccounting` | `artifact-only` |
|
|
17
17
|
| `resumeMode` | `artifact-checkpoint` |
|
|
18
|
-
| `teardownMode` | `
|
|
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
|
|
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=
|
|
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
|
-
|
|
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=
|
|
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":
|
|
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,
|
|
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
|
|
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
|
|
55
|
+
"""Which backend this run's workers get. Called once, by prepare.
|
|
56
56
|
|
|
57
|
-
cmux
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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)
|