okstra 0.136.0 → 0.138.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 +1 -1
- package/docs/architecture.md +3 -2
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/project-structure-overview.md +32 -5
- package/package.json +2 -3
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/antigravity-worker.md +1 -1
- package/runtime/agents/workers/codex-worker.md +1 -1
- package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
- package/runtime/bin/okstra-spawn-followups.py +4 -9
- package/runtime/prompts/lead/adapters/claude-code.md +1 -0
- package/runtime/prompts/lead/adapters/codex.md +1 -0
- package/runtime/prompts/lead/adapters/external.md +1 -0
- package/runtime/prompts/lead/plan-body-verification.md +2 -2
- package/runtime/prompts/lead/report-writer.md +11 -10
- package/runtime/prompts/lead/team-contract.md +4 -2
- package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
- package/runtime/prompts/profiles/_implementation-executor.md +2 -2
- package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
- package/runtime/python/okstra_ctl/codex_dispatch.py +199 -681
- package/runtime/python/okstra_ctl/consumers.py +7 -5
- package/runtime/python/okstra_ctl/context_cost.py +4 -4
- package/runtime/python/okstra_ctl/dispatch_core.py +146 -287
- package/runtime/python/okstra_ctl/dispatch_state.py +231 -0
- package/runtime/python/okstra_ctl/error_report.py +2 -7
- package/runtime/python/okstra_ctl/implementation_outcome.py +12 -23
- package/runtime/python/okstra_ctl/implementation_stage.py +7 -2
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +1053 -0
- package/runtime/python/okstra_ctl/path_hints.py +3 -3
- package/runtime/python/okstra_ctl/paths.py +17 -2
- package/runtime/python/okstra_ctl/recap.py +5 -12
- package/runtime/python/okstra_ctl/render.py +18 -19
- package/runtime/python/okstra_ctl/report_finalize.py +8 -4
- package/runtime/python/okstra_ctl/report_language.py +83 -0
- package/runtime/python/okstra_ctl/run.py +262 -328
- package/runtime/python/okstra_ctl/set_work_status.py +2 -1
- package/runtime/python/okstra_ctl/stage_targets.py +330 -101
- package/runtime/python/okstra_ctl/time_report.py +4 -9
- package/runtime/python/okstra_ctl/wizard.py +47 -49
- package/runtime/python/okstra_ctl/work_categories.py +2 -2
- package/runtime/python/okstra_ctl/worker_prompt_body.py +82 -0
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +73 -13
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +7 -0
- package/runtime/python/okstra_ctl/worktree.py +37 -29
- package/runtime/python/okstra_project/__init__.py +4 -0
- package/runtime/python/okstra_project/dirs.py +7 -0
- package/runtime/python/okstra_project/state.py +78 -8
- package/runtime/validators/lib/fixtures.sh +2 -0
- package/runtime/validators/validate-run.py +3 -21
- package/src/commands/inspect/stage-map.mjs +12 -19
|
@@ -202,7 +202,7 @@ def pr_covered_stages(rows: List[Dict[str, Any]]) -> set:
|
|
|
202
202
|
# `runs/implementation/carry/stage-<N>.json`. The `done` row in consumers.jsonl
|
|
203
203
|
# is a derived index that the lead appends by hand (per the implementation
|
|
204
204
|
# profile) — so it can be missing even when the stage actually finished. The
|
|
205
|
-
# dependency gate (`
|
|
205
|
+
# dependency gate (`stage_targets.resolve_stage_base_commit`) reads `done.head_commit`, so a
|
|
206
206
|
# missing `done` row wrongly blocks downstream stages. We treat the carry file
|
|
207
207
|
# as the source of truth and backfill the missing `done` rows from it before
|
|
208
208
|
# the gate runs. A stage with no carry, or an unfinished carry, is left blocked
|
|
@@ -218,7 +218,9 @@ def _carry_stage_number(carry: Dict[str, Any], filename: str) -> Optional[int]:
|
|
|
218
218
|
return int(m.group(1)) if m else None
|
|
219
219
|
|
|
220
220
|
|
|
221
|
-
|
|
221
|
+
FAILED_CARRY_STATUSES = ("fail", "failed", "blocked", "error", "aborted")
|
|
222
|
+
"""carry 가 실패를 명시하는 status 값. 이 목록은 하나의 business fact 다 —
|
|
223
|
+
implementation_outcome 의 pass-grade 판정도 같은 목록을 본다."""
|
|
222
224
|
|
|
223
225
|
|
|
224
226
|
def _carry_is_complete(carry: Dict[str, Any]) -> bool:
|
|
@@ -227,12 +229,12 @@ def _carry_is_complete(carry: Dict[str, Any]) -> bool:
|
|
|
227
229
|
# Treat it as complete unless it explicitly records a failure status. The
|
|
228
230
|
# real backfill guard is whether a head commit can be extracted.
|
|
229
231
|
status = carry.get("status")
|
|
230
|
-
if status is not None and str(status).lower() in
|
|
232
|
+
if status is not None and str(status).lower() in FAILED_CARRY_STATUSES:
|
|
231
233
|
return False
|
|
232
234
|
return True
|
|
233
235
|
|
|
234
236
|
|
|
235
|
-
def
|
|
237
|
+
def carry_head_commit(carry: Dict[str, Any]) -> str:
|
|
236
238
|
rng = carry.get("stageCommitRange")
|
|
237
239
|
if isinstance(rng, dict) and rng.get("head"):
|
|
238
240
|
return str(rng["head"])
|
|
@@ -288,7 +290,7 @@ def backfill_done_from_carry(plan_run_root: Path) -> int:
|
|
|
288
290
|
continue
|
|
289
291
|
if not _carry_is_complete(carry):
|
|
290
292
|
continue
|
|
291
|
-
head =
|
|
293
|
+
head = carry_head_commit(carry)
|
|
292
294
|
if not head:
|
|
293
295
|
continue
|
|
294
296
|
impl_key = key_by_stage.get(stage) or carry.get("impl_task_key") or fallback_key
|
|
@@ -13,7 +13,7 @@ import sys
|
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import Iterable
|
|
15
15
|
|
|
16
|
-
from okstra_ctl.paths import RunRef, runs_dir_of
|
|
16
|
+
from okstra_ctl.paths import RunRef, runs_dir_of, task_manifest_file
|
|
17
17
|
from okstra_ctl.task_target import resolve_task_root, project_rel
|
|
18
18
|
|
|
19
19
|
|
|
@@ -245,7 +245,7 @@ def _lead_phase1_metric(
|
|
|
245
245
|
instruction_set = task_root / "instruction-set"
|
|
246
246
|
packet = instruction_set / "analysis-packet.md"
|
|
247
247
|
files = [
|
|
248
|
-
task_root
|
|
248
|
+
task_manifest_file(task_root),
|
|
249
249
|
active_path,
|
|
250
250
|
instruction_set / "analysis-profile.md",
|
|
251
251
|
packet if packet.is_file() else instruction_set / "task-brief.md",
|
|
@@ -265,7 +265,7 @@ def _lead_phase1_metric(
|
|
|
265
265
|
state_dir, f"team-state-{current_phase}-*.json"
|
|
266
266
|
)
|
|
267
267
|
files = [
|
|
268
|
-
task_root
|
|
268
|
+
task_manifest_file(task_root),
|
|
269
269
|
task_root / "instruction-set" / "task-brief.md",
|
|
270
270
|
task_root / "instruction-set" / "analysis-profile.md",
|
|
271
271
|
]
|
|
@@ -402,7 +402,7 @@ def _report_writer_metric(run_dir: Path | None, task_root: Path, project_root: P
|
|
|
402
402
|
|
|
403
403
|
|
|
404
404
|
def analyze_task_bundle(task_root: Path, project_root: Path) -> dict:
|
|
405
|
-
manifest = _load_json(task_root
|
|
405
|
+
manifest = _load_json(task_manifest_file(task_root))
|
|
406
406
|
run_dir = _find_current_run_dir(task_root, manifest, project_root)
|
|
407
407
|
all_task_files = _all_files(task_root)
|
|
408
408
|
task_file_count, task_bytes = _count_files(all_task_files)
|
|
@@ -11,81 +11,52 @@ from pathlib import Path
|
|
|
11
11
|
from typing import Any, Mapping, Sequence
|
|
12
12
|
|
|
13
13
|
from . import tmux
|
|
14
|
+
from .dispatch_state import (
|
|
15
|
+
BACKEND_CLI_WRAPPER,
|
|
16
|
+
BACKEND_MIXED,
|
|
17
|
+
BACKEND_TMUX_PANE,
|
|
18
|
+
DispatchError,
|
|
19
|
+
WorkerJob,
|
|
20
|
+
dispatch_mode as _dispatch_mode,
|
|
21
|
+
load_json_object as _load_json_object,
|
|
22
|
+
missing_completion_paths as _missing_completion_paths,
|
|
23
|
+
require_string as _require_string,
|
|
24
|
+
resolve_project_path as _resolve_project_path,
|
|
25
|
+
resolve_required_path as _resolve_required_path,
|
|
26
|
+
set_dispatch_mode as _set_dispatch_mode,
|
|
27
|
+
set_worker_status as _set_worker_status,
|
|
28
|
+
string_list as _string_list,
|
|
29
|
+
string_value as _string_value,
|
|
30
|
+
utc_now as _utc_now,
|
|
31
|
+
validate_initial_prompts as _validate_initial_prompts,
|
|
32
|
+
worker_state as _worker_state,
|
|
33
|
+
worktree_path as _worktree_path,
|
|
34
|
+
write_json as _write_json,
|
|
35
|
+
)
|
|
14
36
|
from .final_report_paths import (
|
|
15
37
|
final_report_data_path as _final_report_data_path,
|
|
16
38
|
final_report_markdown_path as _final_report_markdown_path,
|
|
17
39
|
)
|
|
18
40
|
from .lead_events import LeadEvent, append_lead_event
|
|
41
|
+
from .initial_prompt_materialization import (
|
|
42
|
+
InitialPromptMaterializationError,
|
|
43
|
+
InitialPromptMaterializationRequest,
|
|
44
|
+
InitialPromptWorkerRequest,
|
|
45
|
+
PromptDeliveryMode,
|
|
46
|
+
materialize_initial_prompts,
|
|
47
|
+
)
|
|
19
48
|
from .path_hints import hydrate_active_run_context
|
|
20
|
-
from .
|
|
21
|
-
from .worker_prompt_body import analysis_prompt_body
|
|
22
|
-
from .worker_prompt_contract import PromptRecord, validate_initial_prompt_records
|
|
23
|
-
from .worker_prompt_policy import resolve_prompt_plan_for_manifest
|
|
49
|
+
from .worker_prompt_body import REPORT_WRITER_WORKER_ID
|
|
24
50
|
from .worker_artifact_paths import audit_sidecar_rel
|
|
25
51
|
from .wrapper_status import read_wrapper_status, status_path_for_prompt
|
|
26
52
|
|
|
27
53
|
|
|
28
|
-
BACKEND_CLI_WRAPPER = "cli-wrapper"
|
|
29
|
-
BACKEND_TMUX_PANE = "tmux-pane"
|
|
30
|
-
BACKEND_MIXED = "mixed"
|
|
31
54
|
LIVENESS_AUDIT_HEARTBEAT = "audit-heartbeat"
|
|
32
55
|
LIVENESS_WRAPPER_STATUS = "wrapper-status"
|
|
33
56
|
MAX_WORKER_ATTEMPTS = 2
|
|
34
|
-
REPORT_WRITER_WORKER_ID = "report-writer"
|
|
35
57
|
TERMINAL_DISPATCH_STATUSES = {"completed", "timeout", "error", "not-run"}
|
|
36
58
|
|
|
37
59
|
|
|
38
|
-
class DispatchError(Exception):
|
|
39
|
-
"""Raised when a worker dispatch request cannot be executed."""
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
@dataclass(frozen=True)
|
|
43
|
-
class WorkerJob:
|
|
44
|
-
worker_id: str
|
|
45
|
-
provider: str
|
|
46
|
-
backend: str
|
|
47
|
-
project_root: Path
|
|
48
|
-
model_execution_value: str
|
|
49
|
-
wrapper_path: Path
|
|
50
|
-
prompt_path: Path
|
|
51
|
-
result_path: Path
|
|
52
|
-
worker_result_path: Path
|
|
53
|
-
completion_paths: tuple[Path, ...]
|
|
54
|
-
worktree_path: str
|
|
55
|
-
role: str
|
|
56
|
-
idle_timeout_seconds: int
|
|
57
|
-
dispatch_kind: str
|
|
58
|
-
|
|
59
|
-
@property
|
|
60
|
-
def command(self) -> list[str]:
|
|
61
|
-
return [
|
|
62
|
-
str(self.wrapper_path),
|
|
63
|
-
str(self.project_root),
|
|
64
|
-
self.model_execution_value,
|
|
65
|
-
str(self.prompt_path),
|
|
66
|
-
self.worktree_path,
|
|
67
|
-
self.role,
|
|
68
|
-
str(self.idle_timeout_seconds),
|
|
69
|
-
]
|
|
70
|
-
|
|
71
|
-
def to_payload(self) -> dict[str, Any]:
|
|
72
|
-
return {
|
|
73
|
-
"workerId": self.worker_id,
|
|
74
|
-
"provider": self.provider,
|
|
75
|
-
"backend": self.backend,
|
|
76
|
-
"modelExecutionValue": self.model_execution_value,
|
|
77
|
-
"wrapperPath": str(self.wrapper_path),
|
|
78
|
-
"promptPath": str(self.prompt_path),
|
|
79
|
-
"resultPath": str(self.result_path),
|
|
80
|
-
"workerResultPath": str(self.worker_result_path),
|
|
81
|
-
"completionPaths": [str(path) for path in self.completion_paths],
|
|
82
|
-
"worktreePath": self.worktree_path,
|
|
83
|
-
"role": self.role,
|
|
84
|
-
"dispatchKind": self.dispatch_kind,
|
|
85
|
-
"command": self.command,
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
60
|
@dataclass(frozen=True)
|
|
90
61
|
class WorkerHandle:
|
|
91
62
|
job: WorkerJob
|
|
@@ -146,6 +117,15 @@ class _BuildOptions:
|
|
|
146
117
|
dispatch_kind: str
|
|
147
118
|
|
|
148
119
|
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class _RosterWorkerFacts:
|
|
122
|
+
worker_id: str
|
|
123
|
+
model: str
|
|
124
|
+
provider: str
|
|
125
|
+
result_path: str
|
|
126
|
+
role: str
|
|
127
|
+
|
|
128
|
+
|
|
149
129
|
def build_dispatch_plan(
|
|
150
130
|
*,
|
|
151
131
|
project_root: Path,
|
|
@@ -178,18 +158,22 @@ def build_dispatch_plan(
|
|
|
178
158
|
unsupported_worker_label=unsupported_worker_label,
|
|
179
159
|
dispatch_kind=dispatch_kind,
|
|
180
160
|
)
|
|
181
|
-
|
|
182
|
-
project_root,
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
161
|
+
if jobs_file:
|
|
162
|
+
jobs = _jobs_from_file(project_root, workspace_root, jobs_file, options)
|
|
163
|
+
_validate_initial_prompts(manifest, jobs)
|
|
164
|
+
else:
|
|
165
|
+
jobs = _jobs_from_roster(
|
|
166
|
+
project_root,
|
|
167
|
+
workspace_root,
|
|
168
|
+
manifest_path,
|
|
169
|
+
manifest,
|
|
170
|
+
team_state,
|
|
171
|
+
active_context,
|
|
172
|
+
requested_workers,
|
|
173
|
+
enable_report_writer,
|
|
174
|
+
report_writer_model,
|
|
175
|
+
options,
|
|
176
|
+
)
|
|
193
177
|
return DispatchPlan(
|
|
194
178
|
project_root=project_root,
|
|
195
179
|
workspace_root=workspace_root.resolve(),
|
|
@@ -202,20 +186,6 @@ def build_dispatch_plan(
|
|
|
202
186
|
)
|
|
203
187
|
|
|
204
188
|
|
|
205
|
-
def _validate_initial_prompts(
|
|
206
|
-
manifest: Mapping[str, Any],
|
|
207
|
-
jobs: Sequence[WorkerJob],
|
|
208
|
-
) -> None:
|
|
209
|
-
records = [
|
|
210
|
-
PromptRecord(job.worker_id, job.dispatch_kind, job.prompt_path)
|
|
211
|
-
for job in jobs
|
|
212
|
-
]
|
|
213
|
-
errors = validate_initial_prompt_records(manifest=manifest, records=records)
|
|
214
|
-
if errors:
|
|
215
|
-
task_type = _require_string(manifest, "taskType")
|
|
216
|
-
raise DispatchError(f"{task_type} prompt contract: " + "; ".join(errors))
|
|
217
|
-
|
|
218
|
-
|
|
219
189
|
def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
|
|
220
190
|
if wait:
|
|
221
191
|
if any(job.backend == BACKEND_TMUX_PANE for job in plan.jobs):
|
|
@@ -264,6 +234,7 @@ def await_dispatches(
|
|
|
264
234
|
def _jobs_from_roster(
|
|
265
235
|
project_root: Path,
|
|
266
236
|
workspace_root: Path,
|
|
237
|
+
manifest_path: Path,
|
|
267
238
|
manifest: Mapping[str, Any],
|
|
268
239
|
team_state: Mapping[str, Any],
|
|
269
240
|
active_context: Mapping[str, Any],
|
|
@@ -274,12 +245,27 @@ def _jobs_from_roster(
|
|
|
274
245
|
) -> list[WorkerJob]:
|
|
275
246
|
selected = _select_workers(manifest, requested_workers, enable_report_writer, options)
|
|
276
247
|
_mark_roster_skips(project_root, manifest, team_state, selected, requested_workers, options)
|
|
248
|
+
worker_facts, prompt_paths = _materialize_roster_prompts(
|
|
249
|
+
project_root,
|
|
250
|
+
workspace_root,
|
|
251
|
+
manifest_path,
|
|
252
|
+
manifest,
|
|
253
|
+
team_state,
|
|
254
|
+
selected,
|
|
255
|
+
report_writer_model,
|
|
256
|
+
options,
|
|
257
|
+
)
|
|
277
258
|
return [
|
|
278
259
|
_job_from_roster_worker(
|
|
279
|
-
project_root,
|
|
280
|
-
|
|
260
|
+
project_root,
|
|
261
|
+
workspace_root,
|
|
262
|
+
manifest,
|
|
263
|
+
active_context,
|
|
264
|
+
fact,
|
|
265
|
+
prompt_path,
|
|
266
|
+
options,
|
|
281
267
|
)
|
|
282
|
-
for
|
|
268
|
+
for fact, prompt_path in zip(worker_facts, prompt_paths, strict=True)
|
|
283
269
|
]
|
|
284
270
|
|
|
285
271
|
|
|
@@ -314,49 +300,87 @@ def _job_from_roster_worker(
|
|
|
314
300
|
project_root: Path,
|
|
315
301
|
workspace_root: Path,
|
|
316
302
|
manifest: Mapping[str, Any],
|
|
317
|
-
team_state: Mapping[str, Any],
|
|
318
303
|
active_context: Mapping[str, Any],
|
|
319
|
-
|
|
320
|
-
|
|
304
|
+
fact: _RosterWorkerFacts,
|
|
305
|
+
prompt_path: Path,
|
|
321
306
|
options: _BuildOptions,
|
|
322
307
|
) -> WorkerJob:
|
|
323
|
-
|
|
324
|
-
provider = _provider_for_worker(worker_id)
|
|
325
|
-
model = _model_for_worker(worker_id, state, report_writer_model)
|
|
326
|
-
prompt_rel = _worker_prompt_path(manifest, worker_id)
|
|
327
|
-
result_rel = _require_string(state, "resultPath")
|
|
328
|
-
prompt_path = _resolve_project_path(project_root, prompt_rel)
|
|
329
|
-
result_path = _resolve_project_path(project_root, result_rel)
|
|
330
|
-
_materialize_prompt_if_missing(
|
|
331
|
-
prompt_path=prompt_path,
|
|
332
|
-
prompt_rel=prompt_rel,
|
|
333
|
-
worker_id=worker_id,
|
|
334
|
-
result_rel=result_rel,
|
|
335
|
-
project_root=project_root,
|
|
336
|
-
manifest=manifest,
|
|
337
|
-
active_context=active_context,
|
|
338
|
-
dispatch_kind=options.dispatch_kind,
|
|
339
|
-
model=model,
|
|
340
|
-
role=_string_value(state.get("role")) or "worker",
|
|
341
|
-
)
|
|
308
|
+
result_path = _resolve_project_path(project_root, fact.result_path)
|
|
342
309
|
return WorkerJob(
|
|
343
|
-
worker_id=worker_id,
|
|
344
|
-
provider=provider,
|
|
310
|
+
worker_id=fact.worker_id,
|
|
311
|
+
provider=fact.provider,
|
|
345
312
|
backend=options.default_backend,
|
|
346
313
|
project_root=project_root,
|
|
347
|
-
model_execution_value=model,
|
|
348
|
-
wrapper_path=_resolve_wrapper(provider, workspace_root, options),
|
|
314
|
+
model_execution_value=fact.model,
|
|
315
|
+
wrapper_path=_resolve_wrapper(fact.provider, workspace_root, options),
|
|
349
316
|
prompt_path=prompt_path,
|
|
350
|
-
result_path=_result_path_for_worker(
|
|
317
|
+
result_path=_result_path_for_worker(
|
|
318
|
+
fact.worker_id, result_path, manifest, project_root
|
|
319
|
+
),
|
|
351
320
|
worker_result_path=result_path,
|
|
352
|
-
completion_paths=_completion_paths(
|
|
353
|
-
|
|
354
|
-
|
|
321
|
+
completion_paths=_completion_paths(
|
|
322
|
+
fact.worker_id, result_path, manifest, project_root
|
|
323
|
+
),
|
|
324
|
+
worktree_path=_worktree_path(manifest, active_context),
|
|
325
|
+
role=fact.role,
|
|
355
326
|
idle_timeout_seconds=options.idle_timeout_seconds,
|
|
356
327
|
dispatch_kind=options.dispatch_kind,
|
|
357
328
|
)
|
|
358
329
|
|
|
359
330
|
|
|
331
|
+
def _materialize_roster_prompts(
|
|
332
|
+
project_root: Path,
|
|
333
|
+
workspace_root: Path,
|
|
334
|
+
manifest_path: Path,
|
|
335
|
+
manifest: Mapping[str, Any],
|
|
336
|
+
team_state: Mapping[str, Any],
|
|
337
|
+
selected: Sequence[str],
|
|
338
|
+
report_writer_model: str,
|
|
339
|
+
options: _BuildOptions,
|
|
340
|
+
) -> tuple[tuple[_RosterWorkerFacts, ...], tuple[Path, ...]]:
|
|
341
|
+
worker_facts = tuple(
|
|
342
|
+
_roster_worker_facts(team_state, worker_id, report_writer_model)
|
|
343
|
+
for worker_id in selected
|
|
344
|
+
)
|
|
345
|
+
try:
|
|
346
|
+
prompt_paths = materialize_initial_prompts(
|
|
347
|
+
InitialPromptMaterializationRequest(
|
|
348
|
+
project_root=project_root,
|
|
349
|
+
run_manifest_path=manifest_path,
|
|
350
|
+
runtime_root=workspace_root,
|
|
351
|
+
delivery_mode=_delivery_mode(options.default_backend),
|
|
352
|
+
workers=tuple(
|
|
353
|
+
InitialPromptWorkerRequest(fact.worker_id, fact.model)
|
|
354
|
+
for fact in worker_facts
|
|
355
|
+
),
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
except InitialPromptMaterializationError as exc:
|
|
359
|
+
raise DispatchError(f"{exc.reason}: {exc}") from exc
|
|
360
|
+
return worker_facts, prompt_paths
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _roster_worker_facts(
|
|
364
|
+
team_state: Mapping[str, Any],
|
|
365
|
+
worker_id: str,
|
|
366
|
+
report_writer_model: str,
|
|
367
|
+
) -> _RosterWorkerFacts:
|
|
368
|
+
state = _worker_state(team_state, worker_id)
|
|
369
|
+
return _RosterWorkerFacts(
|
|
370
|
+
worker_id=worker_id,
|
|
371
|
+
model=_model_for_worker(worker_id, state, report_writer_model),
|
|
372
|
+
provider=_provider_for_worker(worker_id),
|
|
373
|
+
result_path=_require_string(state, "resultPath"),
|
|
374
|
+
role=_string_value(state.get("role")) or "worker",
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _delivery_mode(default_backend: str) -> PromptDeliveryMode:
|
|
379
|
+
if default_backend == BACKEND_TMUX_PANE:
|
|
380
|
+
return PromptDeliveryMode.LAZY_PATH_REFERENCE
|
|
381
|
+
return PromptDeliveryMode.EAGER_INCLUDE
|
|
382
|
+
|
|
383
|
+
|
|
360
384
|
def _jobs_from_file(
|
|
361
385
|
project_root: Path,
|
|
362
386
|
workspace_root: Path,
|
|
@@ -412,7 +436,10 @@ def _job_from_file_worker(
|
|
|
412
436
|
|
|
413
437
|
|
|
414
438
|
def _spawn_job(plan: DispatchPlan, job: WorkerJob, attempt: int) -> WorkerHandle:
|
|
415
|
-
_set_worker_status(
|
|
439
|
+
_set_worker_status(
|
|
440
|
+
plan.team_state_path, job.worker_id, "running", "",
|
|
441
|
+
model_execution_value=job.model_execution_value,
|
|
442
|
+
)
|
|
416
443
|
handle = _start_job(plan, job)
|
|
417
444
|
_record_dispatch(plan.team_state_path, handle, attempt, "running", "")
|
|
418
445
|
_append_event(plan, "worker-dispatched", _attempt_details(job, attempt, handle))
|
|
@@ -595,30 +622,6 @@ def _record_matches(record: object, worker_id: str, kind: str, attempt: int) ->
|
|
|
595
622
|
return isinstance(record, dict) and record.get("workerId") == worker_id and record.get("kind") == kind and record.get("attempt") == attempt
|
|
596
623
|
|
|
597
624
|
|
|
598
|
-
def _set_worker_status(
|
|
599
|
-
team_state_path: Path, worker_id: str, status: str, reason: str, model: str = ""
|
|
600
|
-
) -> None:
|
|
601
|
-
payload = _load_json_object(team_state_path, "team-state")
|
|
602
|
-
workers = payload.get("workers")
|
|
603
|
-
if not isinstance(workers, list):
|
|
604
|
-
raise DispatchError(f"team-state workers must be an array: {team_state_path}")
|
|
605
|
-
for worker in workers:
|
|
606
|
-
if isinstance(worker, dict) and worker.get("workerId") == worker_id:
|
|
607
|
-
worker["status"] = status
|
|
608
|
-
worker["reason"] = reason
|
|
609
|
-
if model:
|
|
610
|
-
worker["model"] = model
|
|
611
|
-
worker["modelExecutionValue"] = model
|
|
612
|
-
_write_json(team_state_path, payload)
|
|
613
|
-
return
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
def _set_dispatch_mode(team_state_path: Path, dispatch_mode: str) -> None:
|
|
617
|
-
payload = _load_json_object(team_state_path, "team-state")
|
|
618
|
-
payload["dispatchMode"] = dispatch_mode
|
|
619
|
-
_write_json(team_state_path, payload)
|
|
620
|
-
|
|
621
|
-
|
|
622
625
|
def _running_dispatches(team_state_path: Path) -> list[Mapping[str, Any]]:
|
|
623
626
|
payload = _load_json_object(team_state_path, "team-state")
|
|
624
627
|
dispatches = payload.get("workerDispatches")
|
|
@@ -764,10 +767,6 @@ def _failure_reason(outcome: WorkerOutcome) -> str:
|
|
|
764
767
|
return "worker dispatch failed"
|
|
765
768
|
|
|
766
769
|
|
|
767
|
-
def _missing_completion_paths(job: WorkerJob) -> tuple[Path, ...]:
|
|
768
|
-
return tuple(path for path in job.completion_paths if not path.is_file())
|
|
769
|
-
|
|
770
|
-
|
|
771
770
|
def _mode_from_handles(handles: Sequence[WorkerHandle]) -> str:
|
|
772
771
|
modes = {BACKEND_CLI_WRAPPER if h.degraded_from else h.job.backend for h in handles}
|
|
773
772
|
if len(modes) == 1:
|
|
@@ -775,13 +774,6 @@ def _mode_from_handles(handles: Sequence[WorkerHandle]) -> str:
|
|
|
775
774
|
return BACKEND_MIXED
|
|
776
775
|
|
|
777
776
|
|
|
778
|
-
def _dispatch_mode(jobs: Sequence[WorkerJob]) -> str:
|
|
779
|
-
backends = {job.backend for job in jobs}
|
|
780
|
-
if len(backends) == 1:
|
|
781
|
-
return next(iter(backends))
|
|
782
|
-
return BACKEND_MIXED
|
|
783
|
-
|
|
784
|
-
|
|
785
777
|
def _validate_manifest(manifest: Mapping[str, Any], path: Path, required: str | None) -> None:
|
|
786
778
|
if required is not None and manifest.get("leadRuntime") != required:
|
|
787
779
|
raise DispatchError(f"run manifest is not a {required} lead run: {path}")
|
|
@@ -803,71 +795,6 @@ def _resolve_wrapper(provider: str, workspace_root: Path, options: _BuildOptions
|
|
|
803
795
|
raise DispatchError(f"{script} not found (searched: {', '.join(str(c) for c in candidates)})")
|
|
804
796
|
|
|
805
797
|
|
|
806
|
-
def _materialize_prompt_if_missing(
|
|
807
|
-
*,
|
|
808
|
-
prompt_path: Path,
|
|
809
|
-
prompt_rel: str,
|
|
810
|
-
worker_id: str,
|
|
811
|
-
result_rel: str,
|
|
812
|
-
project_root: Path,
|
|
813
|
-
manifest: Mapping[str, Any],
|
|
814
|
-
active_context: Mapping[str, Any],
|
|
815
|
-
dispatch_kind: str,
|
|
816
|
-
model: str,
|
|
817
|
-
role: str,
|
|
818
|
-
) -> None:
|
|
819
|
-
if prompt_path.is_file():
|
|
820
|
-
return
|
|
821
|
-
prompt_path.parent.mkdir(parents=True, exist_ok=True)
|
|
822
|
-
try:
|
|
823
|
-
headers = worker_prompt_headers(
|
|
824
|
-
project_root=project_root,
|
|
825
|
-
prompt_rel=prompt_rel,
|
|
826
|
-
result_rel=result_rel,
|
|
827
|
-
worker_id=worker_id,
|
|
828
|
-
dispatch_kind=dispatch_kind,
|
|
829
|
-
manifest=manifest,
|
|
830
|
-
active_context=active_context,
|
|
831
|
-
)
|
|
832
|
-
except WorkerPromptHeaderError as exc:
|
|
833
|
-
raise DispatchError(str(exc)) from exc
|
|
834
|
-
try:
|
|
835
|
-
plan = resolve_prompt_plan_for_manifest(
|
|
836
|
-
manifest=manifest,
|
|
837
|
-
worker_id=worker_id,
|
|
838
|
-
dispatch_kind=dispatch_kind,
|
|
839
|
-
)
|
|
840
|
-
except ValueError as exc:
|
|
841
|
-
raise DispatchError(str(exc)) from exc
|
|
842
|
-
lines = list(headers)
|
|
843
|
-
if plan.audience in {
|
|
844
|
-
"analysis",
|
|
845
|
-
"implementation-executor",
|
|
846
|
-
"implementation-verifier",
|
|
847
|
-
}:
|
|
848
|
-
if _string_value(manifest.get("taskType")) == "implementation":
|
|
849
|
-
worktree = _worktree_path(active_context)
|
|
850
|
-
if not worktree:
|
|
851
|
-
raise DispatchError(
|
|
852
|
-
"implementation prompt generation requires a worktree path"
|
|
853
|
-
)
|
|
854
|
-
lines.append(f"**Worktree:** {worktree}")
|
|
855
|
-
try:
|
|
856
|
-
lines.extend(
|
|
857
|
-
analysis_prompt_body(
|
|
858
|
-
manifest,
|
|
859
|
-
active_context,
|
|
860
|
-
worker_id,
|
|
861
|
-
model,
|
|
862
|
-
role,
|
|
863
|
-
plan,
|
|
864
|
-
)
|
|
865
|
-
)
|
|
866
|
-
except ValueError as exc:
|
|
867
|
-
raise DispatchError(str(exc)) from exc
|
|
868
|
-
prompt_path.write_text("\n".join([*lines, ""]), encoding="utf-8")
|
|
869
|
-
|
|
870
|
-
|
|
871
798
|
def _provider_for_worker(worker_id: str) -> str:
|
|
872
799
|
if worker_id == REPORT_WRITER_WORKER_ID:
|
|
873
800
|
return "claude"
|
|
@@ -905,38 +832,6 @@ def _run_dir(plan: DispatchPlan) -> Path:
|
|
|
905
832
|
return _resolve_project_path(plan.project_root, value) if value else plan.team_state_path.parent.parent
|
|
906
833
|
|
|
907
834
|
|
|
908
|
-
def _worktree_path(active_context: Mapping[str, Any]) -> str:
|
|
909
|
-
worktree = active_context.get("executorWorktree")
|
|
910
|
-
if isinstance(worktree, dict):
|
|
911
|
-
return _string_value(worktree.get("path"))
|
|
912
|
-
return ""
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
def _worker_state(team_state: Mapping[str, Any], worker_id: str) -> Mapping[str, Any]:
|
|
916
|
-
workers = team_state.get("workers")
|
|
917
|
-
if isinstance(workers, list):
|
|
918
|
-
for worker in workers:
|
|
919
|
-
if isinstance(worker, dict) and worker.get("workerId") == worker_id:
|
|
920
|
-
return worker
|
|
921
|
-
raise DispatchError(f"team-state has no workerId={worker_id}")
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
def _worker_prompt_path(manifest: Mapping[str, Any], worker_id: str) -> str:
|
|
925
|
-
paths = manifest.get("workerPromptPathByWorkerId")
|
|
926
|
-
if not isinstance(paths, Mapping):
|
|
927
|
-
raise DispatchError("run manifest has no workerPromptPathByWorkerId")
|
|
928
|
-
return _require_string(paths, worker_id)
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
def _load_json_object(path: Path, label: str) -> dict[str, Any]:
|
|
932
|
-
if not path.is_file():
|
|
933
|
-
raise DispatchError(f"{label} not found: {path}")
|
|
934
|
-
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
935
|
-
if not isinstance(payload, dict):
|
|
936
|
-
raise DispatchError(f"{label} must be a JSON object: {path}")
|
|
937
|
-
return payload
|
|
938
|
-
|
|
939
|
-
|
|
940
835
|
def _load_optional_json(project_root: Path, value: Any) -> dict[str, Any]:
|
|
941
836
|
if not isinstance(value, str) or not value.strip():
|
|
942
837
|
return {}
|
|
@@ -946,50 +841,14 @@ def _load_optional_json(project_root: Path, value: Any) -> dict[str, Any]:
|
|
|
946
841
|
return hydrate_active_run_context(_load_json_object(path, "active-run-context"))
|
|
947
842
|
|
|
948
843
|
|
|
949
|
-
def _resolve_required_path(project_root: Path, manifest: Mapping[str, Any], key: str) -> Path:
|
|
950
|
-
return _resolve_project_path(project_root, _require_string(manifest, key))
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
def _resolve_project_path(project_root: Path, value: str) -> Path:
|
|
954
|
-
path = Path(value)
|
|
955
|
-
return path if path.is_absolute() else project_root / path
|
|
956
|
-
|
|
957
|
-
|
|
958
844
|
def _optional_path(value: object) -> Path | None:
|
|
959
845
|
if not isinstance(value, str) or not value.strip():
|
|
960
846
|
return None
|
|
961
847
|
return Path(value)
|
|
962
848
|
|
|
963
849
|
|
|
964
|
-
def _require_string(payload: Mapping[str, Any], key: str) -> str:
|
|
965
|
-
value = payload.get(key)
|
|
966
|
-
if not isinstance(value, str) or not value.strip():
|
|
967
|
-
raise DispatchError(f"required string missing: {key}")
|
|
968
|
-
return value.strip()
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
def _string_value(value: Any) -> str:
|
|
972
|
-
return value.strip() if isinstance(value, str) else ""
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
def _string_list(value: Any) -> list[str]:
|
|
976
|
-
if not isinstance(value, list):
|
|
977
|
-
return []
|
|
978
|
-
return [str(item).strip() for item in value if str(item).strip()]
|
|
979
|
-
|
|
980
|
-
|
|
981
850
|
def _run_seq(manifest: Mapping[str, Any]) -> str:
|
|
982
851
|
seqs = manifest.get("runSequencesByCategory")
|
|
983
852
|
if isinstance(seqs, Mapping):
|
|
984
853
|
return _string_value(seqs.get("manifests")) or "001"
|
|
985
854
|
return "001"
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
def _utc_now() -> str:
|
|
989
|
-
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
def _write_json(path: Path, payload: Mapping[str, Any]) -> None:
|
|
993
|
-
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
994
|
-
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
995
|
-
os.replace(tmp, path)
|