okstra 0.145.0 → 0.146.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/architecture.md +4 -2
- package/docs/cli.md +15 -5
- package/docs/project-structure-overview.md +3 -5
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +5 -6
- package/runtime/bin/okstra-trace-cleanup.sh +41 -11
- package/runtime/prompts/lead/adapters/claude-code.md +3 -3
- package/runtime/prompts/lead/convergence.md +20 -3
- package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
- package/runtime/prompts/lead/report-writer.md +20 -14
- package/runtime/prompts/lead/team-contract.md +3 -3
- package/runtime/python/okstra_ctl/analysis_packet.py +4 -10
- package/runtime/python/okstra_ctl/codex_dispatch.py +117 -58
- package/runtime/python/okstra_ctl/convergence_engine.py +3 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +19 -56
- package/runtime/python/okstra_ctl/dispatch_state.py +167 -3
- package/runtime/python/okstra_ctl/path_hints.py +6 -0
- package/runtime/python/okstra_ctl/paths.py +7 -44
- package/runtime/python/okstra_ctl/render.py +2 -0
- package/runtime/python/okstra_ctl/wizard.py +34 -0
- package/runtime/python/okstra_ctl/worker_liveness.py +84 -21
- package/runtime/python/okstra_ctl/worker_prompt_body.py +24 -4
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +57 -0
- package/runtime/python/okstra_ctl/worker_state.py +65 -0
- package/runtime/python/okstra_token_usage/antigravity.py +3 -0
- package/runtime/python/okstra_token_usage/codex.py +54 -23
- package/runtime/python/okstra_token_usage/collect.py +141 -33
- package/runtime/python/okstra_token_usage/paths.py +27 -0
- package/runtime/python/okstra_vendor/__init__.py +15 -2
- package/runtime/schemas/convergence-groups-v1.0.schema.json +0 -1
- package/runtime/skills/okstra-run/SKILL.md +14 -4
- package/runtime/skills/okstra-setup/references/project-config.md +13 -4
- package/runtime/validators/lib/fixtures.sh +1 -1
- package/runtime/validators/validate-run.py +52 -1
- package/runtime/validators/validate_analysis_report.py +34 -3
- package/src/cli-registry.mjs +7 -10
- package/src/commands/execute/worker-state.mjs +29 -0
- package/src/commands/inspect/worker-liveness.mjs +5 -3
- package/src/commands/lifecycle/preflight.mjs +13 -3
- package/src/lib/runtime-readiness.mjs +90 -0
- package/runtime/python/okstra_ctl/phase_cleanup.py +0 -235
- package/src/commands/execute/phase-cleanup.mjs +0 -38
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
The final-report data.json is authored by `Report writer worker` when that role is in the roster. The lead reviews both rendered artifacts but does not write them. Lead-authored fallback is legal only after a real `dispatch_worker` attempt records `error`, `timeout`, or `not-run` with a concrete reason. `release-handoff` remains the intentional single-lead exception.
|
|
6
6
|
|
|
7
|
-
The JSON SSOT path is `runs/<task-type>/reports/final-report-<task-type>-<seq>.data.json`. The user-facing markdown at `runs/<task-type>/reports/final-report-<task-type>-<seq>.md` is produced by `scripts/okstra-render-final-report.py` from the data.json
|
|
7
|
+
The JSON SSOT path is `runs/<task-type>/reports/final-report-<task-type>-<seq>.data.json`. The user-facing markdown at `runs/<task-type>/reports/final-report-<task-type>-<seq>.md` is produced by `scripts/okstra-render-final-report.py` from the data.json. The worker-result pointer at `**Worker Result Path:**` records those two paths and the reconciled convergence input. These three completion artifacts land on disk before the worker returns; the heartbeat audit sidecar remains a separate required audit artifact.
|
|
8
8
|
|
|
9
9
|
The data.json schema is `schemas/final-report-v1.0.schema.json`. The renderer + the run-validator both consume that schema, so a data.json that validates is guaranteed to render into a markdown that passes the contract checks.
|
|
10
10
|
|
|
@@ -12,7 +12,7 @@ Two `frontmatter` approval fields are always emitted with their unset default
|
|
|
12
12
|
|
|
13
13
|
**As the report-writer worker:** YOU write the data.json and invoke the renderer; the files on disk are the canonical record, so do not return either artifact inline.
|
|
14
14
|
|
|
15
|
-
**As the lead:** prepare the report-writer prompt, dispatch the Report writer worker per the Phase 6 dispatch template in okstra-lead-contract.md, and review
|
|
15
|
+
**As the lead:** prepare the report-writer prompt, dispatch the Report writer worker per the Phase 6 dispatch template in okstra-lead-contract.md, and review the three completion artifacts plus the separate audit sidecar in Phase 7. Do not call `write_artifact` against the report paths or worker-result pointer yourself when Report writer worker is in the roster.
|
|
16
16
|
|
|
17
17
|
## When to Use
|
|
18
18
|
|
|
@@ -26,7 +26,7 @@ Two `frontmatter` approval fields are always emitted with their unset default
|
|
|
26
26
|
2. Persist the exact prompt history with the required anchor headers and audience-specific reading list.
|
|
27
27
|
3. Emit the Phase 6 checkpoint.
|
|
28
28
|
4. Call `dispatch_worker(report_writer_assignment, prompt)` through the selected adapter.
|
|
29
|
-
5. Call `await_workers([handle])` and verify
|
|
29
|
+
5. Call `await_workers([handle])` and verify the data.json Result Path, rendered Markdown sibling, and worker-result pointer at Worker Result Path. Verify the separate heartbeat audit sidecar before accepting the run. **Enforced:** both dispatch adapters keep the three completion paths in `WorkerJob.completion_paths`, and `validators/validate_session_conformance.py` validates the audit sidecar.
|
|
30
30
|
|
|
31
31
|
The assignment's `modelExecutionValue` feeds both adapter dispatch and the prompt header in item 9 below, so the execution model and recorded `**Model:**` header always agree. Missing or unsupported model resolution is a pre-dispatch contract failure; the common contract does not choose a runtime fallback.
|
|
32
32
|
|
|
@@ -35,8 +35,8 @@ The prompt MUST include, in this order at the top:
|
|
|
35
35
|
1. `**Project Root:** <absolute-path>`
|
|
36
36
|
2. `**Prompt History Path:** <project-relative-path>` (under current run `prompts/`)
|
|
37
37
|
3. `**Result Path:** runs/<task-type>/reports/final-report-<task-type>-<seq>.data.json` — canonical JSON SSOT. The renderer produces the sibling `.md` automatically.
|
|
38
|
-
4. `**
|
|
39
|
-
5. `**
|
|
38
|
+
4. `**Worker Result Path:** runs/<task-type>/worker-results/report-writer-worker-<task-type>-<seq>.md` — canonical three-path worker-result pointer and source for the audit-path derivation.
|
|
39
|
+
5. `**Audit sidecar path:** <absolute-path>` — the generated report-writer heartbeat/read-confirmation destination derived from the Markdown `**Worker Result Path:**`, never from Result Path.
|
|
40
40
|
6. `Assigned worker prompt history path: <absolute-path>`
|
|
41
41
|
7. The four BLOCKING dispatch anchor headers generated from the report-writer audience (the worker cannot synthesize any of these paths):
|
|
42
42
|
- `**Worker Preamble Path:** <absolute-path>` — selects `templates/report-writer-prompt-preamble.md`.
|
|
@@ -49,18 +49,18 @@ The prompt MUST include, in this order at the top:
|
|
|
49
49
|
- `<instruction-set>/final-report-schema.json` — a task-type excerpt of the data.json schema (the other task-types' deliverable blocks and their unreachable `$defs` are stripped; ~38% of the full schema is `$defs` alone). This is your authoring aid for the data.json shape — the installed schema, not the excerpt, is what the run is judged against. Do **NOT** pull the full `schemas/final-report-v1.0.schema.json` — it carries all task-types and its `schemas/...` path is not part of the task bundle. (Validation still runs against the full schema post-hoc via the renderer, so the excerpt never relaxes the contract.)
|
|
50
50
|
- `<instruction-set>/final-report-template.md` — the **phase-stripped** template (every other task-type's §5.x deliverable block removed by `render.py`'s `_strip_phase_blocks`, leaving only your run's §5.x). Do **NOT** also pull the full `templates/reports/final-report.template.md` source (it re-adds ~330 lines of other phases' deliverables and is not in the task bundle).
|
|
51
51
|
11. A one-line MCP pointer instead of the verbatim block (redundant — the brief is already in the report-writer's Required reading, item 10): `**MCP servers:** follow the task brief's "## Available MCP Servers" section (already in your Required reading).`
|
|
52
|
-
12.
|
|
52
|
+
12. `Convergence state: runs/<task-type>/state/convergence-<task-type>-<seq>.json`, followed by pointers to all analysis-worker result files under `worker-results/`. The convergence path is deterministic and is listed even before Phase 5.5 creates the file. Read its classifications (Full/Partial/Contested/Worker-Unique), `roundHistory[]`, `round2SkippedReason`, and `finalClassificationCounts`; populate `crossVerification.roundHistory` in data.json so Section 6 can show which rounds executed, queue sizes, and why Round 2 was (or was not) skipped. The renderer prints the full per-round table only when more than one round ran; single-round or zero-round histories are auto-collapsed to a one-line summary.
|
|
53
53
|
13. `**Report Language:** <en|ko>` — must be either `en` or `ko`; `auto`
|
|
54
54
|
has been resolved by the lead from project.json / global config
|
|
55
55
|
before the dispatch is constructed. The worker copies this verbatim
|
|
56
56
|
into `data.json.meta.reportLanguage`.
|
|
57
57
|
14. For implementation-planning runs: a literal block listing the 12 required English section headings — `Option Candidates`, `Trade-off`, `Recommended Option`, `Stage Map`, `Stepwise Execution Order`, `Dependency`, `Validation Checklist`, `Rollback`, `Requirement Coverage`, `Plan Body Verification`, `Cross-Project Dependencies`, `Decision Drafts`. This list is `PLANNING_REQUIRED_SECTIONS` in `validators/validate-run.py`; that tuple is the SSOT and this block must match it exactly. The writer uses these exact substrings as section headings (Korean translation in parentheses is allowed), and the `Plan Body Verification` section carries its required `Gate result:` line.
|
|
58
|
-
15. An explicit instruction: `You are the author of
|
|
58
|
+
15. An explicit instruction: `You are the author of THREE files: (a) the final-report data.json at <Result Path>, (b) its rendered Markdown sibling produced through "okstra render-final-report <Result Path>", and (c) the worker-result pointer at <Worker Result Path>. Maintain the separate heartbeat audit sidecar at <Audit sidecar path>. Do not return the report inline. The dispatch fails when any of the three completion artifacts is missing, and session conformance fails when the audit sidecar is missing or invalid.`
|
|
59
59
|
16. The prose budget (dedup contract): `verdictCard.finalConclusion` is the conclusion SSOT — at most 3 sentences. `rationale.*` fields stay within 2 sentences each and reference the verdict card / row IDs instead of restating their prose; `readerSummary` fields are one line each; `summary` stays at 3-5 rows unless the run covers multiple tickets. The schema field descriptions carry the same budgets (`tests/contract/test_report_prose_budget.py` guards both surfaces). Generation time scales with output volume, so exceeding the budget is a cost bug, not extra diligence.
|
|
60
60
|
|
|
61
61
|
**Fix-run incremental authoring (applies when the run's profile carries a "Fix-Run Carry" block).** Do not author the data.json from scratch. Start by copying the previous run's data.json (the `Previous report` path in the Fix-Run Carry block) to this run's Result Path, then update ONLY the blocks the fix run changed: `meta`/`header` (run seq, dates), `executionStatus`, `implementation.verifierResults`, `implementation.validationEvidence`, `implementation.commitList` / `diffSummary`, `crossVerification`, `verdictCard`, `finalVerdict`, and any `evidence` rows the fix touched. Deliverable prose for unchanged sections is carried forward verbatim — do not re-generate it. Then invoke the renderer exactly as in a full run. The schema validation and renderer contract are unchanged, so an incrementally-authored data.json passes the same post-hoc gates. The lead's dispatch prompt MUST include the previous data.json path when the carry block is present.
|
|
62
62
|
|
|
63
|
-
**Completion detection after dispatch (BLOCKING).** A dispatch acknowledgement is NOT completion — detect completion via the SSOT protocol in [team-contract](./team-contract.md) "Worker-completion detection", with a
|
|
63
|
+
**Completion detection after dispatch (BLOCKING).** A dispatch acknowledgement is NOT completion — detect completion via the SSOT protocol in [team-contract](./team-contract.md) "Worker-completion detection", with a pending set covering the data.json (Result Path), rendered Markdown sibling, and worker-result pointer (Worker Result Path). Check the separate audit sidecar before accepting conformance. Do NOT end the turn with a prose "waiting for the report" statement. **Enforced:** the adapters reject a completed transition while any `completionPaths` entry is absent; `validators/validate_session_conformance.py` owns the audit check.
|
|
64
64
|
|
|
65
65
|
### Resume-safe dispatch
|
|
66
66
|
|
|
@@ -273,19 +273,25 @@ When the run's `task-type` is `release-handoff`, the final report MUST include S
|
|
|
273
273
|
|
|
274
274
|
The final-report template `templates/reports/final-report.template.md` Section 5.6 already encodes this contract — copy that block verbatim and fill in. For non-`release-handoff` runs, omit Section 5.6 entirely.
|
|
275
275
|
|
|
276
|
-
### Mandatory worker-
|
|
276
|
+
### Mandatory worker-result pointer and audit sidecar (BLOCKING)
|
|
277
277
|
|
|
278
|
-
You (the report-writer worker) MUST
|
|
278
|
+
You (the report-writer worker) MUST write the worker-result pointer at `**Worker Result Path:**`, defaulting to:
|
|
279
279
|
|
|
280
280
|
```
|
|
281
|
-
runs/<task-type>/worker-results/report-writer-worker
|
|
281
|
+
runs/<task-type>/worker-results/report-writer-worker-<task-type>-<seq>.md
|
|
282
282
|
```
|
|
283
283
|
|
|
284
|
-
|
|
284
|
+
Its body contains exactly the project-relative data.json path, rendered Markdown path, and convergence-state input path. Analysis-worker result files stay in `## Inputs`; do not copy their list into the pointer. **Enforced:** both dispatch adapters include this pointer in `WorkerJob.completion_paths` and refuse `completed` while it is absent.
|
|
285
|
+
|
|
286
|
+
The pointer's frontmatter and header follow `team-contract` "Result Frontmatter" and the standard worker-result header sections. Use `workerId: "report-writer"` and copy the remaining canonical values from `analysis-material.md`; do not duplicate the final-report body.
|
|
287
|
+
|
|
288
|
+
You MUST also write the separate heartbeat/read-confirmation audit file at `**Audit sidecar path:**`, derived from Worker Result Path and defaulting to:
|
|
285
289
|
|
|
286
|
-
|
|
290
|
+
```
|
|
291
|
+
runs/<task-type>/worker-results/report-writer-worker-audit-<task-type>-<seq>.md
|
|
292
|
+
```
|
|
287
293
|
|
|
288
|
-
|
|
294
|
+
The selected report-writer preamble defines that audit shape. **Enforced:** `validators/validate_session_conformance.py` checks its reading confirmation, progress stages, timestamps, and cadence whenever the role completes.
|
|
289
295
|
|
|
290
296
|
### Main Body Section
|
|
291
297
|
|
|
@@ -130,9 +130,9 @@ Each probe matches exactly one dispatch backend. The dispatch record's `liveness
|
|
|
130
130
|
| Worker | Backend | Flag | What it reads |
|
|
131
131
|
|---|---|---|---|
|
|
132
132
|
| any in-process worker (including `claude-worker` and `report-writer-worker`) | in-process dispatch | `--audit` | its registered audit sidecar's newest `- PROGRESS:` heartbeat |
|
|
133
|
-
| `codex-worker` / `antigravity-worker` | CLI wrapper | `--
|
|
133
|
+
| `codex-worker` / `antigravity-worker` | CLI wrapper | `--team-state <path> --worker <id>` | the worker's persisted `promptPath` and `startedAt`, then `<prompt>.log` / `<prompt>.status.json` |
|
|
134
134
|
|
|
135
|
-
The mismatch is not intermittent, it is guaranteed: only the `okstra-*-exec.sh` wrappers ever write `<prompt>.log` / `<prompt>.status.json`, so an in-process worker produces neither by construction.
|
|
135
|
+
The mismatch is not intermittent, it is guaranteed: only the `okstra-*-exec.sh` wrappers ever write `<prompt>.log` / `<prompt>.status.json`, so an in-process worker produces neither by construction. Probe in-process workers with `--audit`; use the paired `--team-state` / `--worker` selector only for a wrapper assignment. Its launch grace begins at the atomic `in-progress` transition's `startedAt`, never at prompt-materialization time.
|
|
136
136
|
|
|
137
137
|
This is a transport-adapter liveness choice only. It does not change the reducer's worker identity or its verification responsibility: both remain bound to the registered worker instance and canonical artifacts.
|
|
138
138
|
|
|
@@ -147,7 +147,7 @@ After each worker subagent returns (regardless of role), Lead MUST verify the ca
|
|
|
147
147
|
- The wrapper subagent returned an explicit `*_RESULT_MISSING` sentinel (codex-worker / antigravity-worker step 8c — `CODEX_RESULT_MISSING` / `ANTIGRAVITY_RESULT_MISSING`).
|
|
148
148
|
- The result file is absent at the resolved absolute path even though the worker returned without a `*_RESULT_MISSING` sentinel — for example, claude-worker returned its final assistant message but never persisted the artifact, or the wrapper exited 0 and the codex/antigravity sub-agent forwarded raw stdout despite the contract.
|
|
149
149
|
- The result file exists but cannot be parsed (frontmatter unreadable, sections 1–5 entirely missing). A truncated file in the middle of section 5 is NOT covered here — it goes to the validator's regular `error` path, not the retry path.
|
|
150
|
-
- `okstra worker-liveness --
|
|
150
|
+
- `okstra worker-liveness --team-state <path> --worker <id>` reports a **CLI-wrapper** worker (`codex` / `antigravity`) `did-not-launch` — neither `<prompt-path>.log` nor `<prompt-path>.status.json` exists after the persisted `startedAt` plus the launch grace (default 60s). The wrapper writes its status sidecar before invoking the CLI and hard-fails loudly with a distinct exit code on every argument check before that, so the absence of BOTH artifacts means the dispatch itself never reached the script. Without this trigger the only evidence was a lead noticing two missing files by eye, and the run paid the full polling cap for a worker that never started.
|
|
151
151
|
- `okstra worker-liveness --audit` reports an **in-process** worker `stalled` — its registered audit sidecar's newest `- PROGRESS:` heartbeat is older than the cadence budget, or the sidecar carries no heartbeat at all. This is the in-process equivalent of the CLI wrappers' idle watchdog: the wrapper reaps a silent CLI itself, but nothing reaped a silent in-process worker until its deadline.
|
|
152
152
|
- The result file exists but its audit sidecar does not, at `runs/<task-type>/worker-results/<worker>-audit-<task-type>-<seq>.md`. Workers write both in the same step, so a result without a sidecar means the Reading Confirmation block — the only evidence the worker read its inputs — was never produced. `validate-run.py` fails the run on this at Phase 7 either way (`validate_worker_results_audit`); checking it here spends the existing one-retry budget while the role can still be re-dispatched, instead of surfacing hours later when the worker session is gone.
|
|
153
153
|
|
|
@@ -20,7 +20,7 @@ BRIEF_SECTIONS = (
|
|
|
20
20
|
"Task Continuity Notes",
|
|
21
21
|
"Available MCP Servers",
|
|
22
22
|
)
|
|
23
|
-
|
|
23
|
+
CANONICAL_BRIEF_SECTIONS = (
|
|
24
24
|
"Source Material",
|
|
25
25
|
"Context",
|
|
26
26
|
"Problem / Symptom",
|
|
@@ -38,9 +38,6 @@ ERROR_ANALYSIS_BRIEF_SECTIONS = (
|
|
|
38
38
|
"Reporter Confirmations",
|
|
39
39
|
"Augmentation",
|
|
40
40
|
) + BRIEF_SECTIONS
|
|
41
|
-
BRIEF_SECTIONS_BY_TASK_TYPE = {
|
|
42
|
-
"error-analysis": ERROR_ANALYSIS_BRIEF_SECTIONS,
|
|
43
|
-
}
|
|
44
41
|
PROFILE_SECTIONS = (
|
|
45
42
|
"Primary focus areas",
|
|
46
43
|
"Expected output emphasis",
|
|
@@ -101,7 +98,7 @@ def build_analysis_packet(
|
|
|
101
98
|
bool(clarification_response_path),
|
|
102
99
|
)
|
|
103
100
|
)
|
|
104
|
-
parts.extend(_brief_block(
|
|
101
|
+
parts.extend(_brief_block(brief_text))
|
|
105
102
|
parts.extend(_profile_block(task_type, profile_text))
|
|
106
103
|
parts.extend(_reference_block(reference_text))
|
|
107
104
|
parts.extend(_fix_history_block(fix_history_text))
|
|
@@ -160,15 +157,12 @@ def _intro_block(
|
|
|
160
157
|
return lines
|
|
161
158
|
|
|
162
159
|
|
|
163
|
-
def _brief_block(
|
|
160
|
+
def _brief_block(brief_text: str) -> list[str]:
|
|
164
161
|
return [
|
|
165
162
|
"",
|
|
166
163
|
"## Task-Specific Brief Extract",
|
|
167
164
|
"",
|
|
168
|
-
_extract_sections(
|
|
169
|
-
brief_text,
|
|
170
|
-
BRIEF_SECTIONS_BY_TASK_TYPE.get(task_type, BRIEF_SECTIONS),
|
|
171
|
-
),
|
|
165
|
+
_extract_sections(brief_text, CANONICAL_BRIEF_SECTIONS),
|
|
172
166
|
]
|
|
173
167
|
|
|
174
168
|
|
|
@@ -43,10 +43,12 @@ from .dispatch_state import (
|
|
|
43
43
|
resolve_project_path as _resolve_project_path,
|
|
44
44
|
resolve_required_path as _resolve_required_path,
|
|
45
45
|
set_dispatch_mode as _set_dispatch_mode,
|
|
46
|
-
set_worker_status as _set_worker_status,
|
|
47
46
|
string_list as _string_list,
|
|
48
47
|
string_value as _string_value,
|
|
48
|
+
transition_worker_status as _transition_worker_status,
|
|
49
49
|
utc_now as _utc_now,
|
|
50
|
+
validate_dispatch_prompts as _validate_dispatch_prompts,
|
|
51
|
+
worker_jobs_from_file as _worker_jobs_from_file,
|
|
50
52
|
worker_state as _worker_state,
|
|
51
53
|
worktree_path as _worktree_path,
|
|
52
54
|
write_json as _write_json,
|
|
@@ -120,33 +122,46 @@ def build_dispatch_plan(
|
|
|
120
122
|
idle_timeout_seconds: int = 600,
|
|
121
123
|
enable_codex_report_writer: bool = False,
|
|
122
124
|
report_writer_codex_model: str = "",
|
|
125
|
+
dispatch_kind: str = "initial",
|
|
126
|
+
jobs_file: Path | None = None,
|
|
123
127
|
) -> DispatchPlan:
|
|
124
128
|
inputs = _load_dispatch_inputs(
|
|
125
129
|
project_root,
|
|
126
130
|
run_manifest_path,
|
|
127
131
|
requested_workers,
|
|
128
132
|
enable_codex_report_writer=enable_codex_report_writer,
|
|
133
|
+
select_roster=jobs_file is None,
|
|
129
134
|
)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
135
|
+
if jobs_file is not None:
|
|
136
|
+
workers = _codex_jobs_from_file(
|
|
137
|
+
inputs,
|
|
138
|
+
jobs_file,
|
|
139
|
+
workspace_root=workspace_root,
|
|
140
|
+
okstra_bin=okstra_bin,
|
|
141
|
+
idle_timeout_seconds=idle_timeout_seconds,
|
|
142
|
+
dispatch_kind=dispatch_kind,
|
|
143
|
+
)
|
|
144
|
+
else:
|
|
145
|
+
worker_requests, prompt_paths = _materialize_selected_worker_prompts(
|
|
146
|
+
project_root=inputs.project_root,
|
|
147
|
+
manifest_path=inputs.manifest_path,
|
|
148
|
+
workspace_root=workspace_root,
|
|
149
|
+
team_state=inputs.team_state,
|
|
150
|
+
selected_workers=inputs.selected_workers,
|
|
151
|
+
report_writer_codex_model=report_writer_codex_model,
|
|
152
|
+
)
|
|
153
|
+
workers = _build_selected_worker_dispatches(
|
|
154
|
+
project_root=inputs.project_root,
|
|
155
|
+
manifest=inputs.manifest,
|
|
156
|
+
team_state=inputs.team_state,
|
|
157
|
+
active_context=inputs.active_context,
|
|
158
|
+
workspace_root=workspace_root,
|
|
159
|
+
okstra_bin=okstra_bin,
|
|
160
|
+
idle_timeout_seconds=idle_timeout_seconds,
|
|
161
|
+
selected_workers=inputs.selected_workers,
|
|
162
|
+
worker_requests=worker_requests,
|
|
163
|
+
prompt_paths=prompt_paths,
|
|
164
|
+
)
|
|
150
165
|
return DispatchPlan(
|
|
151
166
|
project_root=inputs.project_root,
|
|
152
167
|
workspace_root=workspace_root,
|
|
@@ -158,36 +173,68 @@ def build_dispatch_plan(
|
|
|
158
173
|
)
|
|
159
174
|
|
|
160
175
|
|
|
176
|
+
def _codex_jobs_from_file(
|
|
177
|
+
inputs: _DispatchInputs,
|
|
178
|
+
jobs_file: Path,
|
|
179
|
+
*,
|
|
180
|
+
workspace_root: Path,
|
|
181
|
+
okstra_bin: Path | None,
|
|
182
|
+
idle_timeout_seconds: int,
|
|
183
|
+
dispatch_kind: str,
|
|
184
|
+
) -> list[WorkerJob]:
|
|
185
|
+
workers = _worker_jobs_from_file(
|
|
186
|
+
inputs.project_root,
|
|
187
|
+
jobs_file,
|
|
188
|
+
backend=BACKEND_CLI_WRAPPER,
|
|
189
|
+
idle_timeout_seconds=idle_timeout_seconds,
|
|
190
|
+
default_dispatch_kind=dispatch_kind,
|
|
191
|
+
resolve_wrapper=lambda provider: _resolve_wrapper(
|
|
192
|
+
provider, workspace_root, okstra_bin
|
|
193
|
+
),
|
|
194
|
+
default_provider=lambda worker_id: (
|
|
195
|
+
"codex" if worker_id == REPORT_WRITER_WORKER_ID else worker_id
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
_validate_dispatch_prompts(inputs.manifest, inputs.active_context, workers)
|
|
199
|
+
return workers
|
|
200
|
+
|
|
201
|
+
|
|
161
202
|
def _load_dispatch_inputs(
|
|
162
203
|
project_root: Path,
|
|
163
204
|
run_manifest_path: Path,
|
|
164
205
|
requested_workers: Sequence[str],
|
|
165
206
|
*,
|
|
166
207
|
enable_codex_report_writer: bool,
|
|
208
|
+
select_roster: bool = True,
|
|
167
209
|
) -> _DispatchInputs:
|
|
168
210
|
project_root = project_root.resolve()
|
|
169
211
|
manifest_path = _resolve_project_path(project_root, str(run_manifest_path))
|
|
170
212
|
manifest = _load_json_object(manifest_path, "run manifest")
|
|
171
213
|
_validate_codex_manifest(manifest, manifest_path)
|
|
172
|
-
selected_workers =
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
214
|
+
selected_workers = (
|
|
215
|
+
tuple(
|
|
216
|
+
_select_workers(
|
|
217
|
+
manifest,
|
|
218
|
+
requested_workers,
|
|
219
|
+
enable_codex_report_writer=enable_codex_report_writer,
|
|
220
|
+
)
|
|
177
221
|
)
|
|
222
|
+
if select_roster
|
|
223
|
+
else ()
|
|
178
224
|
)
|
|
179
225
|
team_state_path = _resolve_required_path(project_root, manifest, "teamStatePath")
|
|
180
226
|
team_state = _load_json_object(team_state_path, "team-state")
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
227
|
+
if select_roster:
|
|
228
|
+
_mark_skipped_workers(
|
|
229
|
+
team_state_path,
|
|
230
|
+
team_state,
|
|
231
|
+
_skipped_worker_reasons(
|
|
232
|
+
manifest,
|
|
233
|
+
selected_workers,
|
|
234
|
+
requested_workers,
|
|
235
|
+
enable_codex_report_writer=enable_codex_report_writer,
|
|
236
|
+
),
|
|
237
|
+
)
|
|
191
238
|
active_context = _load_optional_json_object(
|
|
192
239
|
_resolve_optional_path(project_root, manifest.get("activeRunContextPath"))
|
|
193
240
|
)
|
|
@@ -276,13 +323,6 @@ def _build_selected_worker_dispatches(
|
|
|
276
323
|
def dispatch_plan(plan: DispatchPlan) -> int:
|
|
277
324
|
_set_dispatch_mode(plan.team_state_path, _dispatch_mode(plan.workers))
|
|
278
325
|
for worker in plan.workers:
|
|
279
|
-
_set_worker_status(
|
|
280
|
-
plan.team_state_path,
|
|
281
|
-
worker.worker_id,
|
|
282
|
-
"running",
|
|
283
|
-
"",
|
|
284
|
-
model_execution_value=worker.model_execution_value,
|
|
285
|
-
)
|
|
286
326
|
completed = _dispatch_worker_with_retry(plan, worker)
|
|
287
327
|
if completed == 0:
|
|
288
328
|
continue
|
|
@@ -292,6 +332,13 @@ def dispatch_plan(plan: DispatchPlan) -> int:
|
|
|
292
332
|
|
|
293
333
|
def _dispatch_worker_with_retry(plan: DispatchPlan, worker: WorkerJob) -> int:
|
|
294
334
|
for attempt in range(1, MAX_WORKER_ATTEMPTS + 1):
|
|
335
|
+
_transition_worker_status(
|
|
336
|
+
plan.team_state_path,
|
|
337
|
+
worker.worker_id,
|
|
338
|
+
"in-progress",
|
|
339
|
+
"",
|
|
340
|
+
model_execution_value=worker.model_execution_value,
|
|
341
|
+
)
|
|
295
342
|
_record_worker_attempt(plan, worker, attempt, "running")
|
|
296
343
|
attempt_details = _attempt_event_details(worker, attempt)
|
|
297
344
|
_append_event(plan, "worker-dispatched", attempt_details)
|
|
@@ -301,7 +348,9 @@ def _dispatch_worker_with_retry(plan: DispatchPlan, worker: WorkerJob) -> int:
|
|
|
301
348
|
post_process = _post_process_report_writer_result(plan, worker)
|
|
302
349
|
if not post_process["ok"]:
|
|
303
350
|
reason = _require_string(post_process, "reason")
|
|
304
|
-
|
|
351
|
+
_transition_worker_status(
|
|
352
|
+
plan.team_state_path, worker.worker_id, "error", reason
|
|
353
|
+
)
|
|
305
354
|
_record_worker_attempt(plan, worker, attempt, "error", reason)
|
|
306
355
|
details = {
|
|
307
356
|
**attempt_details,
|
|
@@ -312,7 +361,9 @@ def _dispatch_worker_with_retry(plan: DispatchPlan, worker: WorkerJob) -> int:
|
|
|
312
361
|
}
|
|
313
362
|
_append_event(plan, "worker-failed", details)
|
|
314
363
|
return 1
|
|
315
|
-
|
|
364
|
+
_transition_worker_status(
|
|
365
|
+
plan.team_state_path, worker.worker_id, "completed", ""
|
|
366
|
+
)
|
|
316
367
|
_record_worker_attempt(plan, worker, attempt, "completed")
|
|
317
368
|
details = {
|
|
318
369
|
**attempt_details,
|
|
@@ -338,7 +389,9 @@ def _dispatch_worker_with_retry(plan: DispatchPlan, worker: WorkerJob) -> int:
|
|
|
338
389
|
continue
|
|
339
390
|
|
|
340
391
|
reason = _failure_reason(result.returncode, missing_paths)
|
|
341
|
-
|
|
392
|
+
_transition_worker_status(
|
|
393
|
+
plan.team_state_path, worker.worker_id, "error", reason
|
|
394
|
+
)
|
|
342
395
|
_record_worker_attempt(plan, worker, attempt, "error", reason)
|
|
343
396
|
details = {
|
|
344
397
|
**attempt_details,
|
|
@@ -378,7 +431,7 @@ def _matches_worker_attempt(
|
|
|
378
431
|
return (
|
|
379
432
|
isinstance(record, dict)
|
|
380
433
|
and record.get("workerId") == worker.worker_id
|
|
381
|
-
and record.get("kind") ==
|
|
434
|
+
and record.get("kind") == worker.dispatch_kind
|
|
382
435
|
and record.get("attempt") == attempt
|
|
383
436
|
)
|
|
384
437
|
|
|
@@ -386,14 +439,13 @@ def _matches_worker_attempt(
|
|
|
386
439
|
def _worker_dispatch_record(
|
|
387
440
|
worker: WorkerJob, attempt: int, status: str, reason: str
|
|
388
441
|
) -> dict[str, Any]:
|
|
389
|
-
provider = "codex" if worker.worker_id == REPORT_WRITER_WORKER_ID else worker.worker_id
|
|
390
442
|
return {
|
|
391
443
|
"workerId": worker.worker_id,
|
|
392
444
|
"role": worker.role,
|
|
393
|
-
"kind":
|
|
445
|
+
"kind": worker.dispatch_kind,
|
|
394
446
|
"attempt": attempt,
|
|
395
447
|
"backendType": worker.backend,
|
|
396
|
-
"provider": provider,
|
|
448
|
+
"provider": worker.provider,
|
|
397
449
|
"status": status,
|
|
398
450
|
"paneId": "",
|
|
399
451
|
"promptPath": str(worker.prompt_path),
|
|
@@ -410,6 +462,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
410
462
|
parser = _parser()
|
|
411
463
|
args = parser.parse_args(argv)
|
|
412
464
|
try:
|
|
465
|
+
if args.jobs_file and args.workers:
|
|
466
|
+
raise DispatchError("--jobs-file and --workers cannot be combined")
|
|
413
467
|
idle_timeout_seconds = _parse_idle_timeout(args.idle_timeout_seconds)
|
|
414
468
|
plan = build_dispatch_plan(
|
|
415
469
|
project_root=Path(args.project_root),
|
|
@@ -420,6 +474,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
420
474
|
idle_timeout_seconds=idle_timeout_seconds,
|
|
421
475
|
enable_codex_report_writer=args.enable_codex_report_writer,
|
|
422
476
|
report_writer_codex_model=args.report_writer_codex_model,
|
|
477
|
+
dispatch_kind=args.dispatch_kind,
|
|
478
|
+
jobs_file=Path(args.jobs_file) if args.jobs_file else None,
|
|
423
479
|
)
|
|
424
480
|
if args.dry_run:
|
|
425
481
|
_print_json(plan.to_payload(dry_run=True))
|
|
@@ -446,6 +502,8 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
446
502
|
parser.add_argument("--idle-timeout-seconds", default="600")
|
|
447
503
|
parser.add_argument("--enable-codex-report-writer", action="store_true")
|
|
448
504
|
parser.add_argument("--report-writer-codex-model", default="")
|
|
505
|
+
parser.add_argument("--dispatch-kind", default="initial")
|
|
506
|
+
parser.add_argument("--jobs-file", default="")
|
|
449
507
|
return parser
|
|
450
508
|
|
|
451
509
|
|
|
@@ -672,7 +730,6 @@ def _mark_skipped_workers(
|
|
|
672
730
|
workers = team_state.get("workers")
|
|
673
731
|
if not isinstance(workers, list):
|
|
674
732
|
raise DispatchError(f"team-state workers must be an array: {team_state_path}")
|
|
675
|
-
changed = False
|
|
676
733
|
for worker in workers:
|
|
677
734
|
if not isinstance(worker, dict):
|
|
678
735
|
continue
|
|
@@ -683,11 +740,9 @@ def _mark_skipped_workers(
|
|
|
683
740
|
status = _string_value(worker.get("status")) or "not-run"
|
|
684
741
|
current_reason = _string_value(worker.get("reason"))
|
|
685
742
|
if status == "not-run" and not current_reason:
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
if changed:
|
|
690
|
-
_write_json(team_state_path, dict(team_state))
|
|
743
|
+
_transition_worker_status(
|
|
744
|
+
team_state_path, worker_id, "not-run", reason
|
|
745
|
+
)
|
|
691
746
|
|
|
692
747
|
|
|
693
748
|
def _resolve_wrapper(
|
|
@@ -695,7 +750,9 @@ def _resolve_wrapper(
|
|
|
695
750
|
workspace_root: Path,
|
|
696
751
|
okstra_bin: Path | None,
|
|
697
752
|
) -> Path:
|
|
698
|
-
script = SUPPORTED_CLI_WORKERS
|
|
753
|
+
script = SUPPORTED_CLI_WORKERS.get(worker_id)
|
|
754
|
+
if script is None:
|
|
755
|
+
raise DispatchError(f"unsupported worker provider: {worker_id}")
|
|
699
756
|
candidates = []
|
|
700
757
|
if okstra_bin is not None:
|
|
701
758
|
candidates.append(okstra_bin / script)
|
|
@@ -759,7 +816,9 @@ def _post_process_report_writer_result(
|
|
|
759
816
|
# validate-run reads team-state; the writer is done by the time its
|
|
760
817
|
# artifacts pass the earlier steps.
|
|
761
818
|
if step_name == STEP_VALIDATE_RUN:
|
|
762
|
-
|
|
819
|
+
_transition_worker_status(
|
|
820
|
+
plan.team_state_path, worker.worker_id, "completed", ""
|
|
821
|
+
)
|
|
763
822
|
|
|
764
823
|
return run_finalize(ctx, before_step=settle_before)
|
|
765
824
|
|
|
@@ -1946,7 +1946,9 @@ def _parse_group(
|
|
|
1946
1946
|
"findingId": _required_string(group, "findingId", label),
|
|
1947
1947
|
"summary": _required_string(group, "summary", label),
|
|
1948
1948
|
"category": _required_string(group, "category", label),
|
|
1949
|
-
"ticketIds":
|
|
1949
|
+
"ticketIds": _string_array_allow_empty(
|
|
1950
|
+
group.get("ticketIds"), f"{label}.ticketIds"
|
|
1951
|
+
),
|
|
1950
1952
|
"originWorker": origin,
|
|
1951
1953
|
"originEvidence": origin_evidence,
|
|
1952
1954
|
"discoveredBy": discovered_by,
|
|
@@ -24,11 +24,12 @@ from .dispatch_state import (
|
|
|
24
24
|
resolve_project_path as _resolve_project_path,
|
|
25
25
|
resolve_required_path as _resolve_required_path,
|
|
26
26
|
set_dispatch_mode as _set_dispatch_mode,
|
|
27
|
-
|
|
27
|
+
transition_worker_status as _transition_worker_status,
|
|
28
28
|
string_list as _string_list,
|
|
29
29
|
string_value as _string_value,
|
|
30
30
|
utc_now as _utc_now,
|
|
31
|
-
|
|
31
|
+
validate_dispatch_prompts as _validate_dispatch_prompts,
|
|
32
|
+
worker_jobs_from_file as _worker_jobs_from_file,
|
|
32
33
|
worker_state as _worker_state,
|
|
33
34
|
worktree_path as _worktree_path,
|
|
34
35
|
write_json as _write_json,
|
|
@@ -160,7 +161,7 @@ def build_dispatch_plan(
|
|
|
160
161
|
)
|
|
161
162
|
if jobs_file:
|
|
162
163
|
jobs = _jobs_from_file(project_root, workspace_root, jobs_file, options)
|
|
163
|
-
|
|
164
|
+
_validate_dispatch_prompts(manifest, active_context, jobs)
|
|
164
165
|
else:
|
|
165
166
|
jobs = _jobs_from_roster(
|
|
166
167
|
project_root,
|
|
@@ -389,55 +390,22 @@ def _jobs_from_file(
|
|
|
389
390
|
) -> list[WorkerJob]:
|
|
390
391
|
if jobs_file is None:
|
|
391
392
|
return []
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
if not isinstance(workers, list):
|
|
396
|
-
raise DispatchError("jobs file workers must be an array")
|
|
397
|
-
return [
|
|
398
|
-
_job_from_file_worker(project_root, workspace_root, item, options, dispatch_kind)
|
|
399
|
-
for item in workers
|
|
400
|
-
if isinstance(item, dict)
|
|
401
|
-
]
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
def _job_from_file_worker(
|
|
405
|
-
project_root: Path,
|
|
406
|
-
workspace_root: Path,
|
|
407
|
-
item: Mapping[str, Any],
|
|
408
|
-
options: _BuildOptions,
|
|
409
|
-
dispatch_kind: str,
|
|
410
|
-
) -> WorkerJob:
|
|
411
|
-
worker_id = _require_string(item, "workerId")
|
|
412
|
-
provider = _provider_for_job(item, worker_id)
|
|
413
|
-
prompt_path = _resolve_project_path(project_root, _require_string(item, "promptPath"))
|
|
414
|
-
result_path = _resolve_project_path(project_root, _require_string(item, "resultPath"))
|
|
415
|
-
worker_result_path = _resolve_project_path(project_root, _require_string(item, "workerResultPath"))
|
|
416
|
-
completion_paths = tuple(
|
|
417
|
-
_resolve_project_path(project_root, path)
|
|
418
|
-
for path in _string_list(item.get("completionPaths"))
|
|
419
|
-
) or (result_path,)
|
|
420
|
-
return WorkerJob(
|
|
421
|
-
worker_id=worker_id,
|
|
422
|
-
provider=provider,
|
|
393
|
+
return _worker_jobs_from_file(
|
|
394
|
+
project_root,
|
|
395
|
+
jobs_file,
|
|
423
396
|
backend=options.default_backend,
|
|
424
|
-
project_root=project_root,
|
|
425
|
-
model_execution_value=_require_string(item, "modelExecutionValue"),
|
|
426
|
-
wrapper_path=_resolve_wrapper(provider, workspace_root, options),
|
|
427
|
-
prompt_path=prompt_path,
|
|
428
|
-
result_path=result_path,
|
|
429
|
-
worker_result_path=worker_result_path,
|
|
430
|
-
completion_paths=completion_paths,
|
|
431
|
-
worktree_path=_string_value(item.get("worktreePath")),
|
|
432
|
-
role=_require_string(item, "role"),
|
|
433
397
|
idle_timeout_seconds=options.idle_timeout_seconds,
|
|
434
|
-
|
|
398
|
+
default_dispatch_kind=options.dispatch_kind,
|
|
399
|
+
resolve_wrapper=lambda provider: _resolve_wrapper(
|
|
400
|
+
provider, workspace_root, options
|
|
401
|
+
),
|
|
402
|
+
default_provider=_provider_for_worker,
|
|
435
403
|
)
|
|
436
404
|
|
|
437
405
|
|
|
438
406
|
def _spawn_job(plan: DispatchPlan, job: WorkerJob, attempt: int) -> WorkerHandle:
|
|
439
|
-
|
|
440
|
-
plan.team_state_path, job.worker_id, "
|
|
407
|
+
_transition_worker_status(
|
|
408
|
+
plan.team_state_path, job.worker_id, "in-progress", "",
|
|
441
409
|
model_execution_value=job.model_execution_value,
|
|
442
410
|
)
|
|
443
411
|
handle = _start_job(plan, job)
|
|
@@ -544,13 +512,15 @@ def _retry_from_record(
|
|
|
544
512
|
|
|
545
513
|
def _finish_attempt(plan: DispatchPlan, job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> None:
|
|
546
514
|
if outcome.returncode == 0 and not outcome.missing_completion_paths and not outcome.timeout:
|
|
547
|
-
|
|
515
|
+
_transition_worker_status(
|
|
516
|
+
plan.team_state_path, job.worker_id, "completed", ""
|
|
517
|
+
)
|
|
548
518
|
_update_dispatch_status(plan.team_state_path, job.worker_id, job.dispatch_kind, attempt, "completed", "")
|
|
549
519
|
_append_event(plan, "worker-result-collected", _result_details(job, attempt, outcome))
|
|
550
520
|
return
|
|
551
521
|
reason = _failure_reason(outcome)
|
|
552
522
|
status = "timeout" if outcome.timeout else "error"
|
|
553
|
-
|
|
523
|
+
_transition_worker_status(plan.team_state_path, job.worker_id, status, reason)
|
|
554
524
|
_update_dispatch_status(plan.team_state_path, job.worker_id, job.dispatch_kind, attempt, status, reason)
|
|
555
525
|
_append_event(plan, "worker-failed", _failure_details(job, attempt, outcome, reason))
|
|
556
526
|
|
|
@@ -643,7 +613,7 @@ def _mark_roster_skips(
|
|
|
643
613
|
return
|
|
644
614
|
team_state_path = _resolve_required_path(project_root, manifest, "teamStatePath")
|
|
645
615
|
for worker_id, reason in reasons.items():
|
|
646
|
-
|
|
616
|
+
_transition_worker_status(team_state_path, worker_id, "not-run", reason)
|
|
647
617
|
|
|
648
618
|
|
|
649
619
|
def _skip_reasons(
|
|
@@ -801,13 +771,6 @@ def _provider_for_worker(worker_id: str) -> str:
|
|
|
801
771
|
return worker_id
|
|
802
772
|
|
|
803
773
|
|
|
804
|
-
def _provider_for_job(item: Mapping[str, Any], worker_id: str) -> str:
|
|
805
|
-
provider = _string_value(item.get("provider"))
|
|
806
|
-
if provider:
|
|
807
|
-
return provider
|
|
808
|
-
return _provider_for_worker(worker_id)
|
|
809
|
-
|
|
810
|
-
|
|
811
774
|
def _model_for_worker(worker_id: str, state: Mapping[str, Any], report_writer_model: str) -> str:
|
|
812
775
|
if worker_id == REPORT_WRITER_WORKER_ID and report_writer_model.strip():
|
|
813
776
|
return report_writer_model.strip()
|