okstra 0.189.3 → 0.190.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/README.md +1 -1
- package/dist/cli-registry.mjs +9 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/chat/chat.mjs +54 -4
- package/dist/commands/chat/chat.mjs.map +1 -1
- package/dist/commands/lifecycle/install.mjs +12 -5
- package/dist/commands/lifecycle/install.mjs.map +1 -1
- package/dist/commands/lifecycle/uninstall.mjs +2 -1
- package/dist/commands/lifecycle/uninstall.mjs.map +1 -1
- package/dist/lib/host-config.d.mts +5 -3
- package/dist/lib/host-config.mjs +89 -31
- package/dist/lib/host-config.mjs.map +1 -1
- package/docs/architecture/storage-model.md +4 -1
- package/docs/architecture.md +3 -3
- package/docs/cli.md +11 -9
- package/docs/for-ai/skills/okstra-brief-gen.md +1 -1
- package/docs/for-ai/skills/okstra-chat.md +3 -3
- package/docs/for-ai/skills/okstra-inspect.md +11 -1
- package/docs/for-ai/skills/okstra-rollup.md +1 -0
- package/docs/for-ai/skills/okstra-run.md +5 -4
- package/docs/for-ai/skills/okstra-user-response.md +1 -1
- package/docs/project-structure-overview.md +17 -5
- package/docs/task-process/README.md +1 -1
- package/docs/task-process/error-analysis.md +2 -2
- package/docs/task-process/final-verification.md +1 -1
- package/docs/task-process/implementation.md +1 -1
- package/docs/task-process/release-handoff.md +8 -6
- package/docs/task-process/requirements-discovery.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +4 -0
- package/runtime/bin/okstra-report-translate.py +10 -4
- package/runtime/prompts/launch.template.md +11 -5
- package/runtime/prompts/lead/adapters/cmux.md +4 -7
- package/runtime/prompts/lead/context-loader.md +2 -0
- package/runtime/prompts/lead/convergence.md +20 -83
- package/runtime/prompts/lead/okstra-lead-contract.md +24 -6
- package/runtime/prompts/lead/plan-body-verification.md +10 -3
- package/runtime/prompts/lead/report-writer.md +15 -5
- package/runtime/prompts/lead/team-contract.md +15 -9
- package/runtime/prompts/profiles/_common-contract.md +2 -1
- package/runtime/prompts/profiles/_implementation-deliverable.md +2 -0
- package/runtime/prompts/profiles/_implementation-diff-review.md +4 -0
- package/runtime/prompts/profiles/_implementation-executor.md +9 -5
- package/runtime/prompts/profiles/_implementation-self-check.md +2 -0
- package/runtime/prompts/profiles/_implementation-verifier.md +3 -4
- package/runtime/prompts/profiles/_stage-discipline.md +12 -5
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +1 -1
- package/runtime/prompts/profiles/implementation.md +2 -0
- package/runtime/prompts/profiles/release-handoff.md +4 -3
- package/runtime/prompts/wizard/prompts.ko.json +2 -1
- package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +6 -0
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +9 -0
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +3 -3
- package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +17 -3
- package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +3 -3
- package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +2 -2
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +3 -3
- package/runtime/python/okstra_ctl/agent/invocation.py +107 -5
- package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +16 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +3 -2
- package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +87 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +72 -3
- package/runtime/python/okstra_ctl/analysis_packet.py +47 -6
- package/runtime/python/okstra_ctl/cmux.py +34 -13
- package/runtime/python/okstra_ctl/consumers.py +30 -7
- package/runtime/python/okstra_ctl/convergence.py +110 -21
- package/runtime/python/okstra_ctl/convergence_provenance.py +83 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +127 -5
- package/runtime/python/okstra_ctl/dispatch_state.py +17 -9
- package/runtime/python/okstra_ctl/domain/host.py +5 -0
- package/runtime/python/okstra_ctl/domain/wizard/interaction.py +1 -0
- package/runtime/python/okstra_ctl/error_log_write.py +44 -4
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +84 -7
- package/runtime/python/okstra_ctl/fixed_text.py +25 -7
- package/runtime/python/okstra_ctl/group_context.py +475 -10
- package/runtime/python/okstra_ctl/handoff.py +109 -32
- package/runtime/python/okstra_ctl/handoff_verification.py +80 -0
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +52 -10
- package/runtime/python/okstra_ctl/lead_progress.py +291 -0
- package/runtime/python/okstra_ctl/model_io/renderers.py +60 -1
- package/runtime/python/okstra_ctl/model_io_cli.py +6 -1
- package/runtime/python/okstra_ctl/ports/worker_dispatch.py +4 -0
- package/runtime/python/okstra_ctl/recap.py +179 -17
- package/runtime/python/okstra_ctl/reconcile.py +55 -4
- package/runtime/python/okstra_ctl/render.py +34 -8
- package/runtime/python/okstra_ctl/report_assembly.py +17 -10
- package/runtime/python/okstra_ctl/report_finalize.py +204 -11
- package/runtime/python/okstra_ctl/report_html/common.py +241 -113
- package/runtime/python/okstra_ctl/report_html/context_links.py +121 -0
- package/runtime/python/okstra_ctl/report_html/models.py +11 -5
- package/runtime/python/okstra_ctl/report_html/render.py +69 -17
- package/runtime/python/okstra_ctl/report_html/view_models/change_impact_analysis.py +8 -1
- package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +8 -1
- package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +11 -1
- package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +13 -1
- package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +9 -1
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +29 -2
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +14 -9
- package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +8 -1
- package/runtime/python/okstra_ctl/report_html/view_models/project_analysis.py +15 -1
- package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +7 -1
- package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +9 -1
- package/runtime/python/okstra_ctl/report_translation.py +58 -1
- package/runtime/python/okstra_ctl/run.py +54 -14
- package/runtime/python/okstra_ctl/stage_targets.py +66 -0
- package/runtime/python/okstra_ctl/verdict_blocks.py +39 -12
- package/runtime/python/okstra_ctl/wizard/__init__.py +148 -0
- package/runtime/python/okstra_ctl/wizard/__main__.py +13 -0
- package/runtime/python/okstra_ctl/wizard/cli.py +176 -0
- package/runtime/python/okstra_ctl/wizard/confirmation.py +331 -0
- package/runtime/python/okstra_ctl/wizard/engine.py +363 -0
- package/runtime/python/okstra_ctl/wizard/ids.py +411 -0
- package/runtime/python/okstra_ctl/wizard/prompts.py +202 -0
- package/runtime/python/okstra_ctl/wizard/registry.py +836 -0
- package/runtime/python/okstra_ctl/wizard/render.py +189 -0
- package/runtime/python/okstra_ctl/wizard/roles.py +737 -0
- package/runtime/python/okstra_ctl/wizard/sources.py +703 -0
- package/runtime/python/okstra_ctl/wizard/state.py +689 -0
- package/runtime/python/okstra_ctl/wizard/statefile.py +145 -0
- package/runtime/python/okstra_ctl/wizard/steps_analysis.py +558 -0
- package/runtime/python/okstra_ctl/wizard/steps_identity.py +828 -0
- package/runtime/python/okstra_ctl/wizard/steps_options.py +340 -0
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +959 -0
- package/runtime/python/okstra_ctl/wizard/steps_roles.py +672 -0
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +43 -3
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +14 -0
- package/runtime/python/okstra_ctl/workflow.py +2 -2
- package/runtime/python/okstra_ctl/worktree/__init__.py +2 -0
- package/runtime/python/okstra_ctl/worktree/cleanliness.py +7 -4
- package/runtime/python/okstra_ctl/worktree/git_ops.py +7 -0
- package/runtime/python/okstra_ctl/worktree/naming.py +15 -6
- package/runtime/python/okstra_ctl/worktree/provision.py +2 -1
- package/runtime/python/okstra_ctl/worktree_registry.py +27 -0
- package/runtime/python/okstra_ctl/wrapper_status.py +4 -0
- package/runtime/schemas/final-report-v2.0.schema.json +4 -0
- package/runtime/schemas/final-report-v3.0.schema.json +4 -0
- package/runtime/skills/okstra-brief-gen/SKILL.md +22 -3
- package/runtime/skills/okstra-chat/SKILL.md +8 -6
- package/runtime/skills/okstra-inspect/SKILL.md +2 -2
- package/runtime/skills/okstra-inspect/facets/recap.md +52 -4
- package/runtime/skills/okstra-rollup/SKILL.md +1 -1
- package/runtime/skills/okstra-run/SKILL.md +17 -7
- package/runtime/skills/okstra-schedule-gen/SKILL.md +2 -0
- package/runtime/skills/okstra-user-response/SKILL.md +1 -1
- package/runtime/templates/reports/brief.template.md +15 -7
- package/runtime/templates/reports/final-verification-input.template.md +2 -0
- package/runtime/templates/reports/group-context.template.md +7 -0
- package/runtime/templates/reports/html/base.template.html +13 -3
- package/runtime/templates/reports/html/i18n/en.json +43 -5
- package/runtime/templates/reports/html/i18n/ko.json +43 -5
- package/runtime/templates/reports/html/macros/forms.html +4 -4
- package/runtime/templates/reports/html/tasks/final-verification.template.html +11 -1
- package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +16 -15
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +5 -5
- package/runtime/templates/reports/html/tasks/release-handoff.template.html +2 -2
- package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +1 -1
- package/runtime/templates/reports/implementation-planning-input.template.md +3 -0
- package/runtime/templates/reports/improvement-discovery-input.template.md +3 -0
- package/runtime/templates/reports/release-handoff-input.template.md +5 -3
- package/runtime/templates/reports/schedule.template.md +13 -4
- package/runtime/templates/reports/task-brief.template.md +1 -1
- package/runtime/templates/reverify-output-contract.md +5 -0
- package/runtime/templates/worker-error-contract.md +2 -0
- package/runtime/templates/worker-prompt-preamble.md +1 -1
- package/runtime/validators/validate-report-views.py +30 -1
- package/runtime/validators/validate-run.py +194 -25
- package/runtime/validators/validate_session_conformance.py +102 -2
- package/runtime/python/okstra_ctl/wizard.py +0 -7168
|
@@ -19,6 +19,7 @@ from typing import Any, Dict, List, Optional
|
|
|
19
19
|
from .json_boundary import JsonBoundaryError, load_owned_object
|
|
20
20
|
from .jsonl import append_jsonl
|
|
21
21
|
from .run_context import consumers_mutex
|
|
22
|
+
from .release_gate import release_handoff_allowed
|
|
22
23
|
|
|
23
24
|
CONSUMERS_FILENAME = "consumers.jsonl"
|
|
24
25
|
|
|
@@ -271,11 +272,18 @@ def _append_row(plan_run_root: Path, record: Dict[str, Any]) -> None:
|
|
|
271
272
|
|
|
272
273
|
|
|
273
274
|
def append_verified(plan_run_root: Path, *, impl_task_key: str, stage: int,
|
|
274
|
-
verdict: str, report_path: str
|
|
275
|
+
verdict: str, report_path: str, head_commit: str = "",
|
|
276
|
+
final_verdict: dict | None = None) -> None:
|
|
275
277
|
"""단독-stage final-verification 결과 기록. 같은 report_path 재기록은 멱등,
|
|
276
278
|
다른 report_path 는 재검증으로 append 한다 (읽기는 last-wins)."""
|
|
277
279
|
with consumers_mutex(plan_run_root):
|
|
278
|
-
|
|
280
|
+
rows = read_consumers(plan_run_root)
|
|
281
|
+
done = latest_done_by_stage(rows).get(stage, {})
|
|
282
|
+
if head_commit and (done.get("head_commit") != head_commit
|
|
283
|
+
or done.get("impl_task_key") != impl_task_key
|
|
284
|
+
or last_lifecycle_status_by_stage(rows).get(stage) != "done"):
|
|
285
|
+
raise ValueError("verification commit no longer matches completed implementation")
|
|
286
|
+
for row in rows:
|
|
279
287
|
if (row.get("impl_task_key") == impl_task_key
|
|
280
288
|
and row.get("stage") == stage
|
|
281
289
|
and row.get("status") == "verified"
|
|
@@ -285,6 +293,8 @@ def append_verified(plan_run_root: Path, *, impl_task_key: str, stage: int,
|
|
|
285
293
|
"impl_task_key": impl_task_key, "stage": stage,
|
|
286
294
|
"status": "verified", "verdict": verdict,
|
|
287
295
|
"report_path": report_path,
|
|
296
|
+
"head_commit": head_commit,
|
|
297
|
+
"final_verdict": final_verdict or {},
|
|
288
298
|
})
|
|
289
299
|
|
|
290
300
|
|
|
@@ -302,12 +312,25 @@ def append_pr(plan_run_root: Path, *, impl_task_key: str, stages: List[int],
|
|
|
302
312
|
|
|
303
313
|
|
|
304
314
|
def verified_accepted_stages(rows: List[Dict[str, Any]]) -> set:
|
|
305
|
-
"""
|
|
306
|
-
last: Dict[int,
|
|
315
|
+
"""마지막 구현 이후 동일 커밋을 검증한 단계만 승인 후보로 남긴다."""
|
|
316
|
+
last: Dict[int, dict] = {}
|
|
317
|
+
done = latest_done_by_stage(rows)
|
|
307
318
|
for r in rows:
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
319
|
+
stage = r.get("stage")
|
|
320
|
+
if not isinstance(stage, int):
|
|
321
|
+
continue
|
|
322
|
+
if r.get("status") in STAGE_LIFECYCLE_STATUSES:
|
|
323
|
+
last.pop(stage, None)
|
|
324
|
+
elif r.get("status") == "verified":
|
|
325
|
+
last[stage] = r
|
|
326
|
+
return {
|
|
327
|
+
n for n, row in last.items()
|
|
328
|
+
if row.get("head_commit")
|
|
329
|
+
and row["head_commit"] == done.get(n, {}).get("head_commit")
|
|
330
|
+
and row.get("impl_task_key") == done.get(n, {}).get("impl_task_key")
|
|
331
|
+
and row.get("verdict") == (row.get("final_verdict") or {}).get("verdictToken")
|
|
332
|
+
and release_handoff_allowed({"finalVerdict": row.get("final_verdict") or {}})
|
|
333
|
+
}
|
|
311
334
|
|
|
312
335
|
|
|
313
336
|
def pr_covered_stages(rows: List[Dict[str, Any]]) -> set:
|
|
@@ -35,6 +35,7 @@ from .convergence_migration import (
|
|
|
35
35
|
)
|
|
36
36
|
from .convergence_provenance import (
|
|
37
37
|
GROUPS_BASENAME_RE,
|
|
38
|
+
discarded_worker_errors,
|
|
38
39
|
provenance_errors,
|
|
39
40
|
worker_result_suffix,
|
|
40
41
|
)
|
|
@@ -360,9 +361,11 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
360
361
|
collect.add_argument("--result", action="append", default=[],
|
|
361
362
|
metavar="<worker>=<path>",
|
|
362
363
|
help="one worker's reverify result file (repeatable)")
|
|
363
|
-
collect.add_argument("--
|
|
364
|
-
|
|
365
|
-
|
|
364
|
+
collect.add_argument("--run-manifest", type=Path, required=True,
|
|
365
|
+
help="the current run manifest; each planned worker's "
|
|
366
|
+
"terminal outcome and duration are read from its "
|
|
367
|
+
"attempts, and a still-`started` attempt refuses "
|
|
368
|
+
"the collect until `okstra team await` closes it")
|
|
366
369
|
collect.add_argument("--output", type=Path, required=True)
|
|
367
370
|
|
|
368
371
|
apply = subparsers.add_parser("apply-round", help="apply structured round results")
|
|
@@ -877,6 +880,11 @@ def _seed(args: argparse.Namespace) -> tuple[str, Path]:
|
|
|
877
880
|
raise ConvergenceContractError(
|
|
878
881
|
"grouping cites source items no worker reported: " + "; ".join(provenance)
|
|
879
882
|
)
|
|
883
|
+
discarded = discarded_worker_errors(grouped_input, execution_manifest)
|
|
884
|
+
if discarded:
|
|
885
|
+
raise ConvergenceContractError(
|
|
886
|
+
"grouping cites workers whose result was discarded: " + "; ".join(discarded)
|
|
887
|
+
)
|
|
880
888
|
state = seed_working_state(
|
|
881
889
|
grouped_input,
|
|
882
890
|
execution_manifest=execution_manifest,
|
|
@@ -981,27 +989,98 @@ def _split_pair(raw: str, label: str) -> tuple[str, str]:
|
|
|
981
989
|
return key.strip(), value.strip()
|
|
982
990
|
|
|
983
991
|
|
|
984
|
-
|
|
985
|
-
"""
|
|
992
|
+
_ATTEMPT_DISPATCH_STATUS = {
|
|
993
|
+
"ok": "completed",
|
|
994
|
+
"failed-no-mutation": "error",
|
|
995
|
+
"contract-failed-unattributed": "error",
|
|
996
|
+
"mutation-present-unresolved": "error",
|
|
997
|
+
}
|
|
986
998
|
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
999
|
+
|
|
1000
|
+
def _dispatch_rows_from_manifest(
|
|
1001
|
+
plan: Mapping[str, Any],
|
|
1002
|
+
manifest: Any,
|
|
1003
|
+
run_manifest_path: Path,
|
|
1004
|
+
) -> list[dict[str, Any]]:
|
|
1005
|
+
"""`dispatches[]` 를 execution manifest 의 attempt 원장에서 도출한다.
|
|
1006
|
+
|
|
1007
|
+
종전에는 리드가 `--dispatch <worker>=<status>:<durationMs>` 로 상태와
|
|
1008
|
+
소요시간을 손으로 넘겼다. 실측(2026-09-06, fontsninja-v3-site
|
|
1009
|
+
final-verification 001): 재검증 결과 3건이 라운드 결과에 `completed`,
|
|
1010
|
+
`durationMs: 0` 으로 기록됐는데 같은 attempt 들은 매니페스트에서 `started`
|
|
1011
|
+
로 남아 있었다 — 결과 파일이 있으니 리드가 상태를 적었고, attempt 를 닫는
|
|
1012
|
+
유일한 경로(`team await`)는 돌지 않았다. 원장에서 읽으면 두 기록이 갈릴
|
|
1013
|
+
수 없고, 미종결 attempt 가 있으면 여기서 멈춰 `team await` 를 강제한다.
|
|
1014
|
+
|
|
1015
|
+
상태 대응: `ok` → completed, 그 밖의 terminal 은 error (wrapper 가 timeout
|
|
1016
|
+
으로 닫은 attempt 는 `changeSummary.timeout` 이 참이면 timeout). 이 라운드의
|
|
1017
|
+
invocation 이 없는 워커는 not-run.
|
|
990
1018
|
"""
|
|
1019
|
+
round_number = plan.get("round")
|
|
1020
|
+
if not isinstance(round_number, int) or round_number < 1:
|
|
1021
|
+
raise ConvergenceContractError("round plan `round` must be a positive integer")
|
|
1022
|
+
kind = f"reverify-r{round_number}"
|
|
1023
|
+
source_roles = {
|
|
1024
|
+
role.role_execution_ref: role.source_role_execution_ref or role.role_execution_ref
|
|
1025
|
+
for role in getattr(manifest, "role_executions", ())
|
|
1026
|
+
}
|
|
1027
|
+
latest: dict[str, Any] = {}
|
|
1028
|
+
for attempt in getattr(manifest, "attempts", ()):
|
|
1029
|
+
current = latest.get(attempt.invocation_ref)
|
|
1030
|
+
if current is None or attempt.attempt > current.attempt:
|
|
1031
|
+
latest[attempt.invocation_ref] = attempt
|
|
991
1032
|
rows: list[dict[str, Any]] = []
|
|
992
|
-
for
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1033
|
+
for row in plan.get("dispatches") or []:
|
|
1034
|
+
if not isinstance(row, Mapping):
|
|
1035
|
+
continue
|
|
1036
|
+
worker = str(row.get("worker") or "")
|
|
1037
|
+
role_ref = row.get("sourceRoleExecutionRef")
|
|
1038
|
+
matches = [
|
|
1039
|
+
invocation for invocation in getattr(manifest, "invocations", ())
|
|
1040
|
+
if invocation.dispatch_kind == kind
|
|
1041
|
+
and (
|
|
1042
|
+
source_roles.get(invocation.role_execution_ref) == role_ref
|
|
1043
|
+
if isinstance(role_ref, str) and role_ref
|
|
1044
|
+
else invocation.invocation_ref.endswith(f"-{worker}")
|
|
1045
|
+
)
|
|
1046
|
+
]
|
|
1047
|
+
attempts = [latest[inv.invocation_ref] for inv in matches if inv.invocation_ref in latest]
|
|
1048
|
+
if not attempts:
|
|
1049
|
+
rows.append({"worker": worker, "status": "not-run", "durationMs": 0})
|
|
1050
|
+
continue
|
|
1051
|
+
attempt = max(attempts, key=lambda item: (item.started_at, item.attempt))
|
|
1052
|
+
if attempt.finished_at is None or attempt.status == "started":
|
|
996
1053
|
raise ConvergenceContractError(
|
|
997
|
-
f"
|
|
1054
|
+
f"attempt `{attempt.invocation_ref}#{attempt.attempt}` for `{worker}` "
|
|
1055
|
+
f"is still `started`; run `okstra team await --run-manifest "
|
|
1056
|
+
f"{run_manifest_path}` so the attempt closes before collecting — a "
|
|
1057
|
+
"round result written over an open attempt is a status the ledger "
|
|
1058
|
+
"never recorded"
|
|
998
1059
|
)
|
|
1060
|
+
status = _ATTEMPT_DISPATCH_STATUS.get(attempt.status, "error")
|
|
1061
|
+
if status == "error" and bool((attempt.change_summary or {}).get("timeout")):
|
|
1062
|
+
status = "timeout"
|
|
999
1063
|
rows.append({
|
|
1000
|
-
"worker": worker,
|
|
1064
|
+
"worker": worker,
|
|
1065
|
+
"status": status,
|
|
1066
|
+
"durationMs": _duration_ms(attempt.started_at, attempt.finished_at),
|
|
1001
1067
|
})
|
|
1002
1068
|
return rows
|
|
1003
1069
|
|
|
1004
1070
|
|
|
1071
|
+
def _duration_ms(started_at: str, finished_at: str) -> int:
|
|
1072
|
+
from datetime import datetime
|
|
1073
|
+
|
|
1074
|
+
def _parse(value: str) -> datetime:
|
|
1075
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
1076
|
+
|
|
1077
|
+
try:
|
|
1078
|
+
delta = _parse(finished_at) - _parse(started_at)
|
|
1079
|
+
except ValueError:
|
|
1080
|
+
return 0
|
|
1081
|
+
return max(0, int(delta.total_seconds() * 1000))
|
|
1082
|
+
|
|
1083
|
+
|
|
1005
1084
|
def _planned_finding_ids(plan: dict[str, Any]) -> dict[str, list[str]]:
|
|
1006
1085
|
dispatches = plan.get("dispatches")
|
|
1007
1086
|
if not isinstance(dispatches, list):
|
|
@@ -1049,15 +1128,25 @@ def _worker_votes(
|
|
|
1049
1128
|
def _collect_results(args: argparse.Namespace) -> tuple[str, Path]:
|
|
1050
1129
|
plan = load_owned_json_object(args.plan)
|
|
1051
1130
|
planned = _planned_finding_ids(plan)
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
if undispatched:
|
|
1131
|
+
manifest, _authority = read_execution_manifest_view(args.run_manifest)
|
|
1132
|
+
if getattr(manifest, "legacy", False):
|
|
1055
1133
|
raise ConvergenceContractError(
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
f"indistinguishable from one that was never asked"
|
|
1134
|
+
"collect-results reads dispatch outcomes from a v2 execution manifest; "
|
|
1135
|
+
"a legacy v1 roster has no attempt ledger to read"
|
|
1059
1136
|
)
|
|
1137
|
+
dispatches = _dispatch_rows_from_manifest(plan, manifest, args.run_manifest)
|
|
1060
1138
|
votes = _worker_votes(args.result, planned, args.mode == "adversarial")
|
|
1139
|
+
returned_without_completion = sorted(
|
|
1140
|
+
worker for worker in votes
|
|
1141
|
+
if next((row["status"] for row in dispatches if row["worker"] == worker), None)
|
|
1142
|
+
!= "completed"
|
|
1143
|
+
)
|
|
1144
|
+
if returned_without_completion:
|
|
1145
|
+
raise ConvergenceContractError(
|
|
1146
|
+
f"{returned_without_completion} returned a result but the ledger did not "
|
|
1147
|
+
"close their attempt as ok — a discarded or failed attempt's file is not "
|
|
1148
|
+
"a vote"
|
|
1149
|
+
)
|
|
1061
1150
|
by_finding: dict[str, dict[str, Any]] = {}
|
|
1062
1151
|
for worker, parsed in votes.items():
|
|
1063
1152
|
for finding_id, vote in parsed.items():
|
|
@@ -1297,7 +1386,7 @@ def _prepare_groups(args: argparse.Namespace) -> tuple[str, Path]:
|
|
|
1297
1386
|
"config": {"enabled": bool(config.get("enabled", True)),
|
|
1298
1387
|
"adversarial": bool(config.get("adversarial", False)),
|
|
1299
1388
|
"maxRounds": int(config.get("maxRounds", 2)),
|
|
1300
|
-
"effectiveMaxRounds": int(config.get("effectiveMaxRounds", 2)),
|
|
1389
|
+
"effectiveMaxRounds": int(config.get("effectiveMaxRounds", config.get("maxRounds", 2))),
|
|
1301
1390
|
"verificationMode": str(config.get("verificationMode", "lightweight"))},
|
|
1302
1391
|
"workers": workers, "groups": groups}
|
|
1303
1392
|
schema_errors = validate_schema(
|
|
@@ -233,3 +233,86 @@ def run_dir_provenance_errors(run_dir: Path) -> list[str]:
|
|
|
233
233
|
)
|
|
234
234
|
)
|
|
235
235
|
return errors
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# 변이 감사가 결과를 폐기하고 재시도까지 막는 terminal 상태. 이 상태의 결과 파일은
|
|
239
|
+
# 디스크에 남아 있어 파일 존재만 보는 provenance 검사는 통과시킨다.
|
|
240
|
+
DISCARDED_ATTEMPT_STATUSES = frozenset(
|
|
241
|
+
{"contract-failed-unattributed", "mutation-present-unresolved"}
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def discarded_workers(document: object, execution_manifest: object) -> dict[str, str]:
|
|
246
|
+
"""``{workerId: "<invocationRef>#<attempt> <status>"}`` — 초기 attempt 가 전부
|
|
247
|
+
폐기돼 채택할 결과가 없는 분석 워커.
|
|
248
|
+
|
|
249
|
+
한 워커의 초기 invocation 이 둘 이상이면(교정 디스패치는 새 invocation 이다)
|
|
250
|
+
그중 하나라도 마지막 attempt 가 ``ok`` 면 채택 가능한 결과가 있는 것이므로
|
|
251
|
+
폐기로 보지 않는다. legacy 매니페스트나 v1 roster 는 판정하지 않는다.
|
|
252
|
+
"""
|
|
253
|
+
if not isinstance(document, dict) or not isinstance(document.get("workers"), list):
|
|
254
|
+
return {}
|
|
255
|
+
if execution_manifest is None or getattr(execution_manifest, "legacy", False):
|
|
256
|
+
return {}
|
|
257
|
+
initial_refs: dict[str, set[str]] = {}
|
|
258
|
+
for invocation in getattr(execution_manifest, "invocations", ()):
|
|
259
|
+
if invocation.dispatch_kind == "initial":
|
|
260
|
+
initial_refs.setdefault(invocation.role_execution_ref, set()).add(
|
|
261
|
+
invocation.invocation_ref
|
|
262
|
+
)
|
|
263
|
+
latest: dict[str, object] = {}
|
|
264
|
+
for attempt in getattr(execution_manifest, "attempts", ()):
|
|
265
|
+
current = latest.get(attempt.invocation_ref)
|
|
266
|
+
if current is None or attempt.attempt > current.attempt:
|
|
267
|
+
latest[attempt.invocation_ref] = attempt
|
|
268
|
+
result: dict[str, str] = {}
|
|
269
|
+
for row in document["workers"]:
|
|
270
|
+
if not isinstance(row, dict):
|
|
271
|
+
continue
|
|
272
|
+
worker = row.get("workerId")
|
|
273
|
+
role_ref = row.get("sourceRoleExecutionRef")
|
|
274
|
+
if not _nonempty_string(worker) or not _nonempty_string(role_ref):
|
|
275
|
+
continue
|
|
276
|
+
rows = [latest[ref] for ref in sorted(initial_refs.get(role_ref, ())) if ref in latest]
|
|
277
|
+
if not rows or any(item.status == "ok" for item in rows):
|
|
278
|
+
continue
|
|
279
|
+
discarded = [item for item in rows if item.status in DISCARDED_ATTEMPT_STATUSES]
|
|
280
|
+
if discarded:
|
|
281
|
+
item = discarded[-1]
|
|
282
|
+
result[worker] = f"{item.invocation_ref}#{item.attempt} {item.status}"
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def discarded_worker_errors(document: object, execution_manifest: object) -> list[str]:
|
|
287
|
+
"""Groups that cite a worker whose only initial attempts were discarded.
|
|
288
|
+
|
|
289
|
+
The mutation audit discards a result by setting the attempt terminal status
|
|
290
|
+
and clearing ``resultPath``; the file itself stays in ``worker-results/``, so
|
|
291
|
+
``provenance_errors`` (which reads the file) still passes. Observed 2026-09-06
|
|
292
|
+
(`fontsninja-v3-site` final-verification 001): the claude verifier's initial
|
|
293
|
+
attempt was ``contract-failed-unattributed`` yet 9 of 10 groups cited it and
|
|
294
|
+
3 named it ``originWorker`` — the ledger said "discarded" while convergence
|
|
295
|
+
said "adopted".
|
|
296
|
+
"""
|
|
297
|
+
discarded = discarded_workers(document, execution_manifest)
|
|
298
|
+
if not discarded or not isinstance(document, dict):
|
|
299
|
+
return []
|
|
300
|
+
errors: list[str] = []
|
|
301
|
+
for group in document.get("groups") or []:
|
|
302
|
+
if not isinstance(group, dict):
|
|
303
|
+
continue
|
|
304
|
+
cited: set[str] = set()
|
|
305
|
+
origin = group.get("originWorker")
|
|
306
|
+
if isinstance(origin, str) and origin in discarded:
|
|
307
|
+
cited.add(origin)
|
|
308
|
+
discovered_by = group.get("discoveredBy")
|
|
309
|
+
if isinstance(discovered_by, dict):
|
|
310
|
+
cited.update(worker for worker in discovered_by if worker in discarded)
|
|
311
|
+
for worker in sorted(cited):
|
|
312
|
+
errors.append(
|
|
313
|
+
f"group {group.get('findingId')} cites `{worker}`, whose initial "
|
|
314
|
+
f"attempt {discarded[worker]} was discarded by the mutation audit — "
|
|
315
|
+
"a discarded result may not seed convergence; re-dispatch the worker "
|
|
316
|
+
"as a new invocation and cite that result"
|
|
317
|
+
)
|
|
318
|
+
return errors
|
|
@@ -11,6 +11,8 @@ import os
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import Any, Mapping, Sequence
|
|
13
13
|
|
|
14
|
+
from .dispatch_state import abandon_agent_dispatch_attempt
|
|
15
|
+
from .agent.invocation import invocation_input_digest
|
|
14
16
|
from .dispatch_state import (
|
|
15
17
|
CompletedWithoutResultError,
|
|
16
18
|
BACKEND_CLI_WRAPPER,
|
|
@@ -896,9 +898,17 @@ def _select_workers(
|
|
|
896
898
|
*,
|
|
897
899
|
team_state: Mapping[str, Any] | None = None,
|
|
898
900
|
) -> list[str]:
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
901
|
+
# 번역자 디스패치는 roster 를 읽지 않는다 — 근거는 prepare 가 심는 canonical
|
|
902
|
+
# translator role execution 이다. 그래서 빈 roster 검사보다 앞에 둔다.
|
|
903
|
+
# 종전에는 뒤에 있어서, roster 가 비는 phase 에서 이 분기에 닿을 수 없었다:
|
|
904
|
+
# `release-handoff` 는 의도적으로 single-lead 라 `_resolve_roster` 가 빈
|
|
905
|
+
# 목록을 강제하는데(`run.py`), `reportLanguage` 가 en 이 아니면 render-views 는
|
|
906
|
+
# 번역 사이드카를 요구한다. 두 요구가 겹치면 예약 경로는 "run manifest has no
|
|
907
|
+
# recommendedWorkers entries" 로, jobs-file 경로는 "jobs file translator must
|
|
908
|
+
# use canonical reservation dispatch" 로 막혀 번역자를 띄울 방법이 없었다
|
|
909
|
+
# (실측 2026-09-07, fontsninja-v3-site dev-10626 release-handoff 001 — 리드가
|
|
910
|
+
# `okstra report-translate write` 로 우회했고, 그 run 의 매니페스트에는
|
|
911
|
+
# translator role execution 과 invocationAssignments.translator 가 둘 다 있다).
|
|
902
912
|
if tuple(requested_workers) == ("translator",):
|
|
903
913
|
provider = _canonical_translator_provider(manifest)
|
|
904
914
|
if provider is None:
|
|
@@ -911,6 +921,9 @@ def _select_workers(
|
|
|
911
921
|
f"{provider}"
|
|
912
922
|
)
|
|
913
923
|
return ["translator"]
|
|
924
|
+
recommended = _string_list(manifest.get("recommendedWorkers"))
|
|
925
|
+
if not recommended:
|
|
926
|
+
raise DispatchError("run manifest has no recommendedWorkers entries")
|
|
914
927
|
if options.cli_wrapper_assignments_only:
|
|
915
928
|
return _select_cli_wrapper_assignments(
|
|
916
929
|
recommended,
|
|
@@ -1365,6 +1378,36 @@ def _invocation_job_fields(
|
|
|
1365
1378
|
return fields
|
|
1366
1379
|
|
|
1367
1380
|
|
|
1381
|
+
def _initial_prompt_delivery_mode(
|
|
1382
|
+
manifest: Mapping[str, Any],
|
|
1383
|
+
) -> PromptDeliveryMode:
|
|
1384
|
+
"""이 run 의 어댑터가 선언한 초기 프롬프트 배송 방식.
|
|
1385
|
+
|
|
1386
|
+
선언은 `leadAdapter.initialPromptDeliveryMode` 에 있고, 그 값은 render 가
|
|
1387
|
+
호스트 descriptor(어댑터가 워커 디스패치를 가져갔으면 그 어댑터)에서
|
|
1388
|
+
옮겨 적은 것이다. 선언이 없는 매니페스트는 이 필드가 생기기 전에 준비된
|
|
1389
|
+
run 이므로 그때의 동작인 `eager-include` 로 간다. 아는 값이 아니면 멈춘다 —
|
|
1390
|
+
프롬프트를 발행하고 나면 그 프롬프트는 불변이라, 잘못 고른 배송 방식은
|
|
1391
|
+
나중에 고칠 수 없다.
|
|
1392
|
+
"""
|
|
1393
|
+
adapter = manifest.get("leadAdapter")
|
|
1394
|
+
declared = (
|
|
1395
|
+
_string_value(adapter.get("initialPromptDeliveryMode"))
|
|
1396
|
+
if isinstance(adapter, Mapping)
|
|
1397
|
+
else ""
|
|
1398
|
+
)
|
|
1399
|
+
if not declared:
|
|
1400
|
+
return PromptDeliveryMode.EAGER_INCLUDE
|
|
1401
|
+
try:
|
|
1402
|
+
return PromptDeliveryMode(declared)
|
|
1403
|
+
except ValueError:
|
|
1404
|
+
allowed = ", ".join(mode.value for mode in PromptDeliveryMode)
|
|
1405
|
+
raise DispatchError(
|
|
1406
|
+
"leadAdapter.initialPromptDeliveryMode is not a delivery mode: "
|
|
1407
|
+
f"{declared!r} (allowed: {allowed})"
|
|
1408
|
+
) from None
|
|
1409
|
+
|
|
1410
|
+
|
|
1368
1411
|
def _materialize_roster_prompts(
|
|
1369
1412
|
project_root: Path,
|
|
1370
1413
|
workspace_root: Path,
|
|
@@ -1384,7 +1427,7 @@ def _materialize_roster_prompts(
|
|
|
1384
1427
|
project_root=project_root,
|
|
1385
1428
|
run_manifest_path=manifest_path,
|
|
1386
1429
|
runtime_root=workspace_root,
|
|
1387
|
-
delivery_mode=
|
|
1430
|
+
delivery_mode=_initial_prompt_delivery_mode(manifest),
|
|
1388
1431
|
workers=tuple(
|
|
1389
1432
|
InitialPromptWorkerRequest(fact.worker_id, fact.model)
|
|
1390
1433
|
for fact in worker_facts
|
|
@@ -1640,7 +1683,9 @@ def _record_execution_attempt(plan: DispatchPlan, job: WorkerJob) -> None:
|
|
|
1640
1683
|
duty_id=job.duty_id,
|
|
1641
1684
|
dispatch_kind=job.dispatch_kind,
|
|
1642
1685
|
round=_dispatch_round(job.dispatch_kind),
|
|
1643
|
-
input_digest=
|
|
1686
|
+
input_digest=invocation_input_digest(
|
|
1687
|
+
_load_json_object(job.prompt_metadata_path, "agent metadata"), plan.project_root,
|
|
1688
|
+
),
|
|
1644
1689
|
),
|
|
1645
1690
|
Attempt(
|
|
1646
1691
|
invocation_ref=job.invocation_ref,
|
|
@@ -1763,12 +1808,15 @@ def _job_for_attempt(job: WorkerJob, attempt: int) -> WorkerJob:
|
|
|
1763
1808
|
raise DispatchError(f"cannot materialize retry invocation: {exc}") from exc
|
|
1764
1809
|
if retry.invocation_ref != job.invocation_ref:
|
|
1765
1810
|
raise DispatchError("retry invocation reference changed")
|
|
1811
|
+
metadata = _load_json_object(retry.metadata_path, "retry agent metadata")
|
|
1766
1812
|
return replace(
|
|
1767
1813
|
job,
|
|
1768
1814
|
prompt_path=retry.prompt_path,
|
|
1769
1815
|
prompt_metadata_path=retry.metadata_path,
|
|
1770
1816
|
invocation_id=retry.invocation_id,
|
|
1771
1817
|
attempt=retry.attempt,
|
|
1818
|
+
instruction_digest=metadata["digests"]["instructionDigest"],
|
|
1819
|
+
prompt_digest=metadata["digests"]["promptDigest"],
|
|
1772
1820
|
)
|
|
1773
1821
|
|
|
1774
1822
|
|
|
@@ -1932,6 +1980,8 @@ def _advance_running_dispatches(plan: DispatchPlan, records: Sequence[Mapping[st
|
|
|
1932
1980
|
status_path = _optional_path(record.get("statusSidecarPath"))
|
|
1933
1981
|
status = read_wrapper_status(status_path) if status_path else None
|
|
1934
1982
|
if status is None or not status.is_terminal:
|
|
1983
|
+
if status is not None and _wrapper_process_is_gone(status):
|
|
1984
|
+
_settle_dead_dispatch(plan, record, status)
|
|
1935
1985
|
continue
|
|
1936
1986
|
outcome = _outcome_from_status(record, status)
|
|
1937
1987
|
retry_allowed = _finish_record(plan, record, outcome)
|
|
@@ -1942,6 +1992,78 @@ def _advance_running_dispatches(plan: DispatchPlan, records: Sequence[Mapping[st
|
|
|
1942
1992
|
_retry_from_record(plan, record, outcome)
|
|
1943
1993
|
|
|
1944
1994
|
|
|
1995
|
+
def _wrapper_process_is_gone(status) -> bool:
|
|
1996
|
+
"""wrapper 가 terminal 상태를 쓰기 전에 죽었는가.
|
|
1997
|
+
|
|
1998
|
+
cmux-pane·cli-wrapper 의 liveness 는 status 사이드카 하나다(`_liveness_mode`).
|
|
1999
|
+
wrapper 는 종료 트랩에서 `stage: exited` 를 쓰므로, 프로세스는 없는데 상태가
|
|
2000
|
+
`started` 면 트랩이 돌지 못한 죽음이다 — pane 이 닫혔거나 호스트가 죽였거나
|
|
2001
|
+
import 오류로 로그 한 줄 못 남긴 경우. 실측(2026-09-06, fontsninja-v3-site
|
|
2002
|
+
final-verification 001): critic wrapper 로그가 시작 110초 뒤 끊겼는데 attempt
|
|
2003
|
+
는 32분 뒤 리드가 손으로 abandon 할 때까지 `started` 였다.
|
|
2004
|
+
|
|
2005
|
+
pid 가 사라진 뒤 상태를 한 번 더 읽는다 — 트랩이 상태를 쓰고 프로세스가
|
|
2006
|
+
빠져나가는 그 사이에 첫 읽기가 끼었을 수 있다. 그때는 다음 폴링이 terminal
|
|
2007
|
+
을 읽으므로 여기서 죽음으로 보지 않는다.
|
|
2008
|
+
"""
|
|
2009
|
+
pid = status.pid
|
|
2010
|
+
if pid is None or pid <= 0:
|
|
2011
|
+
return False
|
|
2012
|
+
try:
|
|
2013
|
+
os.kill(pid, 0)
|
|
2014
|
+
except ProcessLookupError:
|
|
2015
|
+
# expected-miss: 프로세스 부재가 이 함수가 묻는 답이다 — 예외가 곧 "죽었다"
|
|
2016
|
+
# 는 결과이고, 그 판정을 아래 재읽기가 확인한다.
|
|
2017
|
+
process_gone = True
|
|
2018
|
+
except PermissionError:
|
|
2019
|
+
# 다른 사용자의 살아 있는 프로세스. 시그널 0 은 거부돼도 존재는 확인된다.
|
|
2020
|
+
process_gone = False
|
|
2021
|
+
else:
|
|
2022
|
+
process_gone = False
|
|
2023
|
+
if not process_gone:
|
|
2024
|
+
return False
|
|
2025
|
+
refreshed = read_wrapper_status(status.path)
|
|
2026
|
+
return refreshed is not None and not refreshed.is_terminal
|
|
2027
|
+
|
|
2028
|
+
|
|
2029
|
+
def _settle_dead_dispatch(plan: DispatchPlan, record: Mapping[str, Any], status) -> None:
|
|
2030
|
+
"""죽은 wrapper 의 dispatch 를 원장에 맞게 닫는다.
|
|
2031
|
+
|
|
2032
|
+
`source-readonly` attempt 는 `abandon_agent_dispatch_attempt` 로 닫는다 — 그
|
|
2033
|
+
정책은 아무것도 쓰지 않았다는 단언이 정책상 참이라 추측이 없다. `project-mutation`
|
|
2034
|
+
attempt 는 열어 둔 채 dispatch 행만 `error` 로 바꾼다 — 반쯤 바뀐 트리 위에서
|
|
2035
|
+
다시 돌지 않게 하려면 변이 감사가 그 attempt 를 판정해야 한다. 어느 쪽도 자동
|
|
2036
|
+
재시도는 하지 않는다: pane 이 죽은 뒤의 재실행은 아무도 보고 있지 않은 작업이다.
|
|
2037
|
+
"""
|
|
2038
|
+
attempt = int(record.get("attempt", 1))
|
|
2039
|
+
job = _job_from_record(plan.project_root, record)
|
|
2040
|
+
reason = (
|
|
2041
|
+
f"wrapper pid {status.pid} exited without writing a terminal status "
|
|
2042
|
+
f"(stage `{status.stage}`)"
|
|
2043
|
+
)
|
|
2044
|
+
contract = _persisted_write_contract(plan, job)
|
|
2045
|
+
mode = contract[0].source_mode if contract is not None else "unknown"
|
|
2046
|
+
closed = None
|
|
2047
|
+
if mode == "source-readonly" and job.invocation_id:
|
|
2048
|
+
closed = abandon_agent_dispatch_attempt(
|
|
2049
|
+
project_root=plan.project_root,
|
|
2050
|
+
run_manifest_path=plan.manifest_path,
|
|
2051
|
+
invocation_ref=job.invocation_id,
|
|
2052
|
+
reason=reason,
|
|
2053
|
+
)
|
|
2054
|
+
else:
|
|
2055
|
+
reason += "; attempt left open for the mutation audit"
|
|
2056
|
+
_update_dispatch_status(plan.team_state_path, job, attempt, "error", reason)
|
|
2057
|
+
details = _event_execution_identity(job, attempt)
|
|
2058
|
+
details.update({
|
|
2059
|
+
"pid": status.pid,
|
|
2060
|
+
"sourceMode": mode,
|
|
2061
|
+
"attemptClosed": closed is not None,
|
|
2062
|
+
"reason": reason,
|
|
2063
|
+
})
|
|
2064
|
+
_append_event(plan, "worker-dead-detected", details)
|
|
2065
|
+
|
|
2066
|
+
|
|
1945
2067
|
def _retry_from_record(
|
|
1946
2068
|
plan: DispatchPlan, record: Mapping[str, Any], outcome: WorkerOutcome
|
|
1947
2069
|
) -> None:
|
|
@@ -35,6 +35,7 @@ from .agent.invocation import (
|
|
|
35
35
|
InvocationMetadataIdentity,
|
|
36
36
|
agent_model_assignment_from_payload,
|
|
37
37
|
invocation_metadata_identity,
|
|
38
|
+
invocation_input_digest,
|
|
38
39
|
v2_role_assignment_authority_errors,
|
|
39
40
|
verify_agent_invocation,
|
|
40
41
|
)
|
|
@@ -113,11 +114,6 @@ LIVENESS_WRAPPER_STATUS = "wrapper-status"
|
|
|
113
114
|
# so the string lives here rather than in whichever module wrote it first.
|
|
114
115
|
TEARDOWN_BEFORE_TERMINAL_REASON = "teardown before terminal status"
|
|
115
116
|
|
|
116
|
-
# The worktree argument grants the wrapper `--add-dir` write access outside
|
|
117
|
-
# project-root. Only the phases whose workers mutate a stage worktree get it;
|
|
118
|
-
# analysis phases write their artifacts under project-root (see the contract in
|
|
119
|
-
# scripts/okstra-codex-exec.sh).
|
|
120
|
-
WORKTREE_TASK_TYPES = frozenset({"implementation", "final-verification"})
|
|
121
117
|
WORKER_STATUSES = frozenset(
|
|
122
118
|
{"in-progress", "completed", "timeout", "error", "not-run"}
|
|
123
119
|
)
|
|
@@ -362,8 +358,6 @@ def resolve_required_path(
|
|
|
362
358
|
def worktree_path(
|
|
363
359
|
manifest: Mapping[str, Any], active_context: Mapping[str, Any]
|
|
364
360
|
) -> str:
|
|
365
|
-
if manifest.get("taskType") not in WORKTREE_TASK_TYPES:
|
|
366
|
-
return ""
|
|
367
361
|
worktree = active_context.get("executorWorktree")
|
|
368
362
|
if not isinstance(worktree, Mapping):
|
|
369
363
|
return ""
|
|
@@ -706,7 +700,6 @@ def record_verified_agent_dispatch(
|
|
|
706
700
|
return record
|
|
707
701
|
if execution_identity is not None:
|
|
708
702
|
dispatch_kind = require_string(metadata, "dispatchKind")
|
|
709
|
-
prompt_digest = require_string(digests, "promptDigest")
|
|
710
703
|
if write_policy is None or write_enforcement is None:
|
|
711
704
|
raise DispatchError("agent write contract was not prepared")
|
|
712
705
|
record_invocation_attempt(
|
|
@@ -720,7 +713,7 @@ def record_verified_agent_dispatch(
|
|
|
720
713
|
duty_id=execution_identity.duty_id,
|
|
721
714
|
dispatch_kind=dispatch_kind,
|
|
722
715
|
round=_execution_dispatch_round(dispatch_kind),
|
|
723
|
-
input_digest=
|
|
716
|
+
input_digest=invocation_input_digest(metadata, project_root),
|
|
724
717
|
),
|
|
725
718
|
Attempt(
|
|
726
719
|
invocation_ref=execution_identity.invocation_ref,
|
|
@@ -2149,6 +2142,21 @@ def worker_jobs_from_file(
|
|
|
2149
2142
|
resolve_project_path(project_root, str(jobs_file)),
|
|
2150
2143
|
"jobs file",
|
|
2151
2144
|
)
|
|
2145
|
+
return worker_jobs_from_payload(
|
|
2146
|
+
project_root, payload, manifest=manifest, active_context=active_context,
|
|
2147
|
+
backend=backend, idle_timeout_seconds=idle_timeout_seconds,
|
|
2148
|
+
default_dispatch_kind=default_dispatch_kind, resolve_wrapper=resolve_wrapper,
|
|
2149
|
+
default_provider=default_provider,
|
|
2150
|
+
)
|
|
2151
|
+
|
|
2152
|
+
|
|
2153
|
+
def worker_jobs_from_payload(
|
|
2154
|
+
project_root: Path, payload: Mapping[str, Any], *,
|
|
2155
|
+
manifest: Mapping[str, Any], active_context: Mapping[str, Any] | None = None,
|
|
2156
|
+
backend: str, idle_timeout_seconds: int | None, default_dispatch_kind: str,
|
|
2157
|
+
resolve_wrapper: Callable[[str], Path], default_provider: Callable[[str], str],
|
|
2158
|
+
) -> list[WorkerJob]:
|
|
2159
|
+
"""파일 소비자와 생성기가 동일한 배치 계약을 검사한다."""
|
|
2152
2160
|
dispatch_kind = string_value(payload.get("dispatchKind")) or default_dispatch_kind
|
|
2153
2161
|
workers = payload.get("workers")
|
|
2154
2162
|
if not isinstance(workers, list):
|
|
@@ -51,6 +51,11 @@ class HostDescriptor:
|
|
|
51
51
|
session_accounting: str
|
|
52
52
|
has_claude_session: bool
|
|
53
53
|
relay_contract: str
|
|
54
|
+
# 이 호스트가 초기 워커 프롬프트를 어떻게 싣겠다고 선언하는가. 값은
|
|
55
|
+
# `relay.md` 의 `initialPromptDeliveryMode` 행과 같아야 한다 — 그 표를
|
|
56
|
+
# 읽고 dispatch 를 판단하는 것은 리드이고, 실제로 프롬프트를 만드는 것은
|
|
57
|
+
# 이 값을 받는 `initial_prompt_materialization` 이다.
|
|
58
|
+
initial_prompt_delivery_mode: str = "eager-include"
|
|
54
59
|
|
|
55
60
|
|
|
56
61
|
@dataclass(frozen=True)
|