okstra 0.209.0 → 0.209.2
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 +1 -1
- package/docs/cli.md +9 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/duties/business-flow-investigator.json +1 -1
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/lead/adapters/cmux.md +1 -1
- package/runtime/prompts/lead/convergence.md +2 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +4 -4
- package/runtime/prompts/wizard/prompts.ko.json +15 -14
- package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +15 -4
- package/runtime/python/okstra_ctl/adapters/runtime/assembly.py +7 -3
- package/runtime/python/okstra_ctl/adapters/runtime/cmux.py +8 -4
- package/runtime/python/okstra_ctl/agent/activity.py +2 -2
- package/runtime/python/okstra_ctl/analysis_packet.py +7 -0
- package/runtime/python/okstra_ctl/business_flow/engine.py +2 -2
- package/runtime/python/okstra_ctl/business_flow/source.py +35 -2
- package/runtime/python/okstra_ctl/cmux.py +93 -25
- package/runtime/python/okstra_ctl/convergence.py +29 -10
- package/runtime/python/okstra_ctl/dispatch_checkpoints.py +93 -3
- package/runtime/python/okstra_ctl/dispatch_core.py +18 -7
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +16 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +70 -2
- package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +21 -16
- package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +11 -9
- package/runtime/python/okstra_ctl/phases/implementation_planning/wizard.py +79 -17
- package/runtime/python/okstra_ctl/plan_items.py +4 -2
- package/runtime/python/okstra_ctl/render.py +4 -0
- package/runtime/python/okstra_ctl/report_finalize.py +19 -7
- package/runtime/python/okstra_ctl/run.py +15 -1
- package/runtime/python/okstra_ctl/team.py +6 -4
- package/runtime/python/okstra_ctl/user_response.py +22 -0
- package/runtime/python/okstra_ctl/wizard/cli.py +0 -2
- package/runtime/python/okstra_ctl/wizard/engine.py +33 -46
- package/runtime/python/okstra_ctl/wizard/registry.py +2 -2
- package/runtime/python/okstra_ctl/wizard/roles.py +2 -8
- package/runtime/python/okstra_ctl/wizard/steps_identity.py +7 -5
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +1 -1
- package/runtime/python/okstra_ctl/worker_audit_ledger.py +35 -24
- package/runtime/python/okstra_ctl/worker_prompt_body.py +6 -1
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +13 -0
- package/runtime/python/okstra_ctl/write_policy.py +16 -0
- package/runtime/skills/okstra-run/SKILL.md +3 -3
- package/runtime/validators/validate-run.py +9 -2
|
@@ -14,7 +14,7 @@ import socket
|
|
|
14
14
|
import subprocess
|
|
15
15
|
from dataclasses import dataclass
|
|
16
16
|
from pathlib import Path
|
|
17
|
-
from typing import Any, Collection, Sequence
|
|
17
|
+
from typing import Any, Collection, Mapping, Sequence
|
|
18
18
|
|
|
19
19
|
PING_OK = "PONG"
|
|
20
20
|
|
|
@@ -124,18 +124,63 @@ def cmux_available() -> bool:
|
|
|
124
124
|
return False
|
|
125
125
|
if not _ping_answers():
|
|
126
126
|
return False
|
|
127
|
-
return bool(resolve_lead_workspace())
|
|
127
|
+
return bool(resolve_lead_workspace(None))
|
|
128
128
|
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class LeadLocation:
|
|
132
|
+
"""prepare 가 본 리드의 cmux 위치. run manifest 에 기록된다."""
|
|
133
|
+
|
|
134
|
+
workspace_id: str
|
|
135
|
+
surface_id: str
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# run manifest 키. 리드 프로세스의 CMUX_* 는 리드를 띄운 터미널의 것이 아닐 수
|
|
139
|
+
# 있다 — Claude Code 가 미리 띄워 둔 bg-spare 를 잡으면 그 프로세스가 만들어질
|
|
140
|
+
# 때의 workspace 를 물려받는다(실측 2026-10-04). prepare 는 사용자가 실행한
|
|
141
|
+
# 터미널에서 돌므로 그 시점 값을 기록하고, 이후 해석은 기록을 먼저 쓴다.
|
|
142
|
+
LEAD_LOCATION_KEY = "cmuxLead"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def lead_location_from_env() -> LeadLocation | None:
|
|
146
|
+
"""이 프로세스의 cmux 위치. CMUX_* 두 값 중 하나라도 없으면 None."""
|
|
147
|
+
workspace = _lead_workspace_env()
|
|
148
|
+
surface = os.environ.get("CMUX_SURFACE_ID", "").strip()
|
|
149
|
+
if not workspace or not surface:
|
|
150
|
+
return None
|
|
151
|
+
return LeadLocation(workspace_id=workspace, surface_id=surface)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def lead_location_payload(location: LeadLocation) -> dict[str, str]:
|
|
155
|
+
return {"workspaceId": location.workspace_id, "surfaceId": location.surface_id}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def recorded_lead(manifest: Mapping[str, Any]) -> LeadLocation | None:
|
|
159
|
+
"""prepare 가 기록한 리드 위치. 기록이 없는 run(이전 버전 manifest 포함)은 None."""
|
|
160
|
+
block = manifest.get(LEAD_LOCATION_KEY)
|
|
161
|
+
if not isinstance(block, Mapping):
|
|
162
|
+
return None
|
|
163
|
+
workspace = str(block.get("workspaceId") or "").strip()
|
|
164
|
+
surface = str(block.get("surfaceId") or "").strip()
|
|
165
|
+
if not workspace or not surface:
|
|
166
|
+
return None
|
|
167
|
+
return LeadLocation(workspace_id=workspace, surface_id=surface)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def resolve_lead_workspace(recorded: LeadLocation | None) -> str:
|
|
131
171
|
"""The lead's workspace UUID, or "" when it cannot be resolved.
|
|
132
172
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
173
|
+
기록된 위치가 있으면 환경보다 우선하고, 그 surface 가 아직 열려 있을 때만
|
|
174
|
+
유효하다 — 닫힌 리드 옆에 워커를 열 이유가 없다.
|
|
175
|
+
|
|
176
|
+
Without one, the UUID is read from the environment rather than from
|
|
177
|
+
`identify`, whose refs are positional and shift as surfaces open and close.
|
|
178
|
+
`identify` is consulted only to prove the environment is current: a process
|
|
179
|
+
that inherits a CMUX_* block keeps it verbatim, so those variables can
|
|
180
|
+
outlive the surface they name.
|
|
138
181
|
"""
|
|
182
|
+
if recorded is not None:
|
|
183
|
+
return recorded.workspace_id if _recorded_surface_is_open(recorded) else ""
|
|
139
184
|
workspace = _lead_workspace_env()
|
|
140
185
|
if not workspace:
|
|
141
186
|
return ""
|
|
@@ -148,6 +193,13 @@ def _lead_workspace_env() -> str:
|
|
|
148
193
|
return os.environ.get("CMUX_WORKSPACE_ID", "").strip()
|
|
149
194
|
|
|
150
195
|
|
|
196
|
+
def _recorded_surface_is_open(recorded: LeadLocation) -> bool:
|
|
197
|
+
try:
|
|
198
|
+
return recorded.surface_id in open_surface_ids(recorded.workspace_id)
|
|
199
|
+
except (RuntimeError, OSError, subprocess.SubprocessError):
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
|
|
151
203
|
@dataclass(frozen=True)
|
|
152
204
|
class PaneGeometry:
|
|
153
205
|
"""One pane as `pane.list` reports it.
|
|
@@ -450,6 +502,7 @@ def spawn_worker_surface(
|
|
|
450
502
|
command: Sequence[str],
|
|
451
503
|
title: str,
|
|
452
504
|
owned_surface_ids: Collection[str],
|
|
505
|
+
recorded: LeadLocation | None,
|
|
453
506
|
) -> str:
|
|
454
507
|
"""Start one worker beside the lead and return its surface UUID.
|
|
455
508
|
|
|
@@ -462,7 +515,7 @@ def spawn_worker_surface(
|
|
|
462
515
|
user and may hold anything.
|
|
463
516
|
"""
|
|
464
517
|
panes = list_panes(workspace)
|
|
465
|
-
lead = _lead_pane(panes)
|
|
518
|
+
lead = _lead_pane(panes, recorded)
|
|
466
519
|
placement = plan_worker_placement(
|
|
467
520
|
panes,
|
|
468
521
|
lead_pane_id=lead.pane_id,
|
|
@@ -472,7 +525,7 @@ def spawn_worker_surface(
|
|
|
472
525
|
try:
|
|
473
526
|
surface_uuid = _create_surface(workspace, placement, target)
|
|
474
527
|
except (RuntimeError, OSError, subprocess.SubprocessError):
|
|
475
|
-
_restore_lead_after_failed_spawn(workspace, owned_surface_ids)
|
|
528
|
+
_restore_lead_after_failed_spawn(workspace, owned_surface_ids, recorded)
|
|
476
529
|
raise
|
|
477
530
|
try:
|
|
478
531
|
renamed = rename_surface(surface_uuid, title)
|
|
@@ -480,8 +533,8 @@ def spawn_worker_surface(
|
|
|
480
533
|
raise RuntimeError(renamed.stderr.strip() or "cmux could not name the worker")
|
|
481
534
|
_exec_worker(surface_uuid, cwd=cwd, command=command)
|
|
482
535
|
owned = (*owned_surface_ids, surface_uuid)
|
|
483
|
-
_size_lead_pane(workspace, owned)
|
|
484
|
-
_equalize_worker_heights(workspace, owned)
|
|
536
|
+
_size_lead_pane(workspace, owned, recorded)
|
|
537
|
+
_equalize_worker_heights(workspace, owned, recorded)
|
|
485
538
|
except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
|
|
486
539
|
# 호출이 반환되기 전에는 명부에 창 식별자가 없다. 여기서 정리하지
|
|
487
540
|
# 않으면 대체 실행으로 전환된 뒤에도 회수할 수 없는 창이 남는다.
|
|
@@ -494,13 +547,15 @@ def spawn_worker_surface(
|
|
|
494
547
|
f"{exc}; cleanup failed for cmux surface {surface_uuid}: {cleanup_error}"
|
|
495
548
|
) from exc
|
|
496
549
|
finally:
|
|
497
|
-
_restore_lead_after_failed_spawn(workspace, owned_surface_ids)
|
|
550
|
+
_restore_lead_after_failed_spawn(workspace, owned_surface_ids, recorded)
|
|
498
551
|
raise
|
|
499
552
|
return surface_uuid
|
|
500
553
|
|
|
501
554
|
|
|
502
555
|
def _restore_lead_after_failed_spawn(
|
|
503
|
-
workspace: str,
|
|
556
|
+
workspace: str,
|
|
557
|
+
owned_surface_ids: Collection[str],
|
|
558
|
+
recorded: LeadLocation | None,
|
|
504
559
|
) -> None:
|
|
505
560
|
"""실패한 분할이 리드에서 떼어 간 폭을 돌려준다.
|
|
506
561
|
|
|
@@ -517,7 +572,7 @@ def _restore_lead_after_failed_spawn(
|
|
|
517
572
|
중이고, 원장에 남아야 할 문장은 그쪽이다.
|
|
518
573
|
"""
|
|
519
574
|
try:
|
|
520
|
-
_size_lead_pane(workspace, owned_surface_ids)
|
|
575
|
+
_size_lead_pane(workspace, owned_surface_ids, recorded)
|
|
521
576
|
except (RuntimeError, OSError, subprocess.SubprocessError):
|
|
522
577
|
return
|
|
523
578
|
|
|
@@ -561,7 +616,7 @@ def close_surface(surface_uuid: str) -> None:
|
|
|
561
616
|
return
|
|
562
617
|
|
|
563
618
|
|
|
564
|
-
def restore_lead_width() -> None:
|
|
619
|
+
def restore_lead_width(recorded: LeadLocation | None) -> None:
|
|
565
620
|
"""Put the lead back on its target width once its workers are reclaimed.
|
|
566
621
|
|
|
567
622
|
Closing a pane hands its width to whichever neighbour cmux picks, and that
|
|
@@ -582,10 +637,10 @@ def restore_lead_width() -> None:
|
|
|
582
637
|
failed to close counts as a stranger's and keeps its width, which is the
|
|
583
638
|
safe way to be wrong here.
|
|
584
639
|
"""
|
|
585
|
-
workspace = resolve_lead_workspace()
|
|
640
|
+
workspace = resolve_lead_workspace(recorded)
|
|
586
641
|
if not workspace:
|
|
587
642
|
return
|
|
588
|
-
_size_lead_pane(workspace, ())
|
|
643
|
+
_size_lead_pane(workspace, (), recorded)
|
|
589
644
|
|
|
590
645
|
|
|
591
646
|
def socket_reachability() -> str:
|
|
@@ -614,7 +669,7 @@ def socket_reachability() -> str:
|
|
|
614
669
|
probe.close()
|
|
615
670
|
|
|
616
671
|
|
|
617
|
-
def unreachable_reason() -> str:
|
|
672
|
+
def unreachable_reason(recorded: LeadLocation | None) -> str:
|
|
618
673
|
"""Why cmux cannot be reached from here, or "" when it can.
|
|
619
674
|
|
|
620
675
|
`environment` is the case a socket probe alone cannot see: a sandbox that
|
|
@@ -629,7 +684,7 @@ def unreachable_reason() -> str:
|
|
|
629
684
|
return LOST_DENIED
|
|
630
685
|
if reachability != SOCKET_OK:
|
|
631
686
|
return LOST_GONE
|
|
632
|
-
return LOST_NOTHING if resolve_lead_workspace() else LOST_GONE
|
|
687
|
+
return LOST_NOTHING if resolve_lead_workspace(recorded) else LOST_GONE
|
|
633
688
|
|
|
634
689
|
|
|
635
690
|
def unreachable_advice(reason: str) -> tuple[str, str]:
|
|
@@ -799,7 +854,11 @@ def _exec_worker(surface_uuid: str, *, cwd: Path, command: Sequence[str]) -> Non
|
|
|
799
854
|
raise RuntimeError(started.stderr.strip() or "cmux could not start the worker")
|
|
800
855
|
|
|
801
856
|
|
|
802
|
-
def _size_lead_pane(
|
|
857
|
+
def _size_lead_pane(
|
|
858
|
+
workspace: str,
|
|
859
|
+
owned_surface_ids: Collection[str],
|
|
860
|
+
recorded: LeadLocation | None,
|
|
861
|
+
) -> None:
|
|
803
862
|
"""Move the lead's border to its share of the workspace.
|
|
804
863
|
|
|
805
864
|
This is the horizontal border okstra places. The first worker column
|
|
@@ -826,7 +885,7 @@ def _size_lead_pane(workspace: str, owned_surface_ids: Collection[str]) -> None:
|
|
|
826
885
|
# rather than leave it where the user last saw it.
|
|
827
886
|
return
|
|
828
887
|
panes = _panes_from(payload)
|
|
829
|
-
lead = _lead_pane(panes)
|
|
888
|
+
lead = _lead_pane(panes, recorded)
|
|
830
889
|
target = lead_target_width(
|
|
831
890
|
panes,
|
|
832
891
|
lead_pane_id=lead.pane_id,
|
|
@@ -863,7 +922,9 @@ def _size_lead_pane(workspace: str, owned_surface_ids: Collection[str]) -> None:
|
|
|
863
922
|
|
|
864
923
|
|
|
865
924
|
def _equalize_worker_heights(
|
|
866
|
-
workspace: str,
|
|
925
|
+
workspace: str,
|
|
926
|
+
owned_surface_ids: Collection[str],
|
|
927
|
+
recorded: LeadLocation | None,
|
|
867
928
|
) -> None:
|
|
868
929
|
"""Give every owned worker the same height in the first column.
|
|
869
930
|
|
|
@@ -875,7 +936,7 @@ def _equalize_worker_heights(
|
|
|
875
936
|
"""
|
|
876
937
|
for _ in range(MAX_WORKER_HEIGHT_MOVES):
|
|
877
938
|
panes = list_panes(workspace)
|
|
878
|
-
lead = _lead_pane(panes)
|
|
939
|
+
lead = _lead_pane(panes, recorded)
|
|
879
940
|
workers = [
|
|
880
941
|
pane
|
|
881
942
|
for pane in panes
|
|
@@ -896,7 +957,14 @@ def _equalize_worker_heights(
|
|
|
896
957
|
)
|
|
897
958
|
|
|
898
959
|
|
|
899
|
-
def _lead_pane(
|
|
960
|
+
def _lead_pane(
|
|
961
|
+
panes: Sequence[PaneGeometry], recorded: LeadLocation | None
|
|
962
|
+
) -> PaneGeometry:
|
|
963
|
+
if recorded is not None:
|
|
964
|
+
for pane in panes:
|
|
965
|
+
if recorded.surface_id in pane.surface_ids:
|
|
966
|
+
return pane
|
|
967
|
+
raise RuntimeError("cmux could not locate the lead's pane in its workspace")
|
|
900
968
|
caller_pane_ref = identify_caller().get("pane_ref", "")
|
|
901
969
|
for pane in panes:
|
|
902
970
|
if caller_pane_ref and pane.ref == caller_pane_ref:
|
|
@@ -564,7 +564,9 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
564
564
|
return parser
|
|
565
565
|
|
|
566
566
|
|
|
567
|
-
def _emit(
|
|
567
|
+
def _emit(
|
|
568
|
+
operation: str, action: str, path: Path, extra: Mapping[str, Any] | None = None,
|
|
569
|
+
) -> None:
|
|
568
570
|
print(
|
|
569
571
|
json.dumps(
|
|
570
572
|
{
|
|
@@ -572,6 +574,7 @@ def _emit(operation: str, action: str, path: Path) -> None:
|
|
|
572
574
|
"operation": operation,
|
|
573
575
|
"action": action,
|
|
574
576
|
"path": str(path),
|
|
577
|
+
**(extra or {}),
|
|
575
578
|
},
|
|
576
579
|
ensure_ascii=False,
|
|
577
580
|
)
|
|
@@ -1078,14 +1081,21 @@ def _seed(args: argparse.Namespace) -> tuple[str, Path]:
|
|
|
1078
1081
|
return decision.action, args.work_state
|
|
1079
1082
|
|
|
1080
1083
|
|
|
1081
|
-
def _plan_round(
|
|
1084
|
+
def _plan_round(
|
|
1085
|
+
args: argparse.Namespace,
|
|
1086
|
+
) -> tuple[str, Path, dict[str, Any]]:
|
|
1082
1087
|
state = load_owned_json_object(args.work_state)
|
|
1083
1088
|
errors = validate_working_state(state)
|
|
1084
1089
|
if errors:
|
|
1085
1090
|
raise ConvergenceContractError("invalid working state: " + "; ".join(errors))
|
|
1086
1091
|
plan = plan_next_round(state)
|
|
1087
1092
|
write_json_atomic(args.plan, plan)
|
|
1088
|
-
|
|
1093
|
+
# 리드의 `phase-5.5-convergence round=<N> queue=<count>` 줄이 이 두 값을 그대로
|
|
1094
|
+
# 옮긴다. 리드가 계획 파일을 세면 `queueFindingIds` 와 혼동해 queue=0 을 적었다.
|
|
1095
|
+
return str(plan["action"]), args.plan, {
|
|
1096
|
+
"round": plan["round"],
|
|
1097
|
+
"inputQueueSize": plan["inputQueueSize"],
|
|
1098
|
+
}
|
|
1089
1099
|
|
|
1090
1100
|
|
|
1091
1101
|
def _apply_round(args: argparse.Namespace) -> tuple[str, Path]:
|
|
@@ -1378,7 +1388,7 @@ def _group_from_fields(
|
|
|
1378
1388
|
if not colon or not worker or not item_id:
|
|
1379
1389
|
raise ConvergenceContractError("Sources must use worker:item-id entries")
|
|
1380
1390
|
parsed = [(worker, item_id, scalar["evidence"])]
|
|
1381
|
-
discovered, source_items = _group_source_provenance(parsed)
|
|
1391
|
+
discovered, source_items = _group_source_provenance(finding_id, parsed)
|
|
1382
1392
|
origin_worker, separator, origin_item = scalar.get("origin", "").partition(":")
|
|
1383
1393
|
if not separator or discovered.get(origin_worker, {}).get("itemId") != origin_item:
|
|
1384
1394
|
raise ConvergenceContractError("Origin must identify one Source item")
|
|
@@ -1398,11 +1408,20 @@ def _group_from_fields(
|
|
|
1398
1408
|
|
|
1399
1409
|
|
|
1400
1410
|
def _group_source_provenance(
|
|
1411
|
+
finding_id: str,
|
|
1401
1412
|
parsed: list[tuple[str, str, str]],
|
|
1402
1413
|
) -> tuple[dict[str, dict[str, str]], list[dict[str, str]]]:
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1414
|
+
items_by_worker: dict[str, list[str]] = {}
|
|
1415
|
+
for worker, item, _evidence in parsed:
|
|
1416
|
+
items_by_worker.setdefault(worker, []).append(item)
|
|
1417
|
+
for worker, items in items_by_worker.items():
|
|
1418
|
+
if len(items) > 1:
|
|
1419
|
+
raise ConvergenceContractError(
|
|
1420
|
+
f"duplicate Source worker in one finding: {finding_id} has "
|
|
1421
|
+
f"{len(items)} Source lines for `{worker}` ({', '.join(items)}); "
|
|
1422
|
+
"a group takes one Source line per worker, so split the extra "
|
|
1423
|
+
"item(s) into their own group(s) instead of dropping them"
|
|
1424
|
+
)
|
|
1406
1425
|
discovered = {
|
|
1407
1426
|
worker: {"itemId": item, "evidence": evidence}
|
|
1408
1427
|
for worker, item, evidence in parsed
|
|
@@ -1754,7 +1773,7 @@ def _settled_critic_result(manifest: Mapping[str, Any]) -> str:
|
|
|
1754
1773
|
return latest["resultPath"]
|
|
1755
1774
|
|
|
1756
1775
|
|
|
1757
|
-
def _execute(args: argparse.Namespace) -> tuple[
|
|
1776
|
+
def _execute(args: argparse.Namespace) -> tuple[Any, ...]:
|
|
1758
1777
|
operations: dict[str, Any] = {
|
|
1759
1778
|
"prepare-groups": _prepare_groups,
|
|
1760
1779
|
"seed": _seed,
|
|
@@ -1797,7 +1816,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1797
1816
|
if args.operation == "census-gapfill-prompt":
|
|
1798
1817
|
print(_census_gapfill_prompt(args), end="")
|
|
1799
1818
|
return 0
|
|
1800
|
-
action, path = _execute(args)
|
|
1819
|
+
action, path, *extra = _execute(args)
|
|
1801
1820
|
except (ConvergenceContractError, VerdictBlockError, ReverifyPromptError,
|
|
1802
1821
|
CriticVerifyPromptError, CensusError,
|
|
1803
1822
|
json.JSONDecodeError, ValueError) as exc:
|
|
@@ -1809,7 +1828,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1809
1828
|
if args.operation == "prepare-groups":
|
|
1810
1829
|
print("Convergence groups\n" + line("Status", "ready") + line("Action", action), end="")
|
|
1811
1830
|
else:
|
|
1812
|
-
_emit(args.operation, action, path)
|
|
1831
|
+
_emit(args.operation, action, path, *extra)
|
|
1813
1832
|
return 0
|
|
1814
1833
|
|
|
1815
1834
|
|
|
@@ -9,18 +9,30 @@ dispatch 를 정하고 결과를 확정하는 곳은 `team dispatch` / `team awa
|
|
|
9
9
|
워커별 체크포인트는 `initial` dispatch 만 센다. reverify·critic·plan-verify 는
|
|
10
10
|
리드가 따로 알리는 이후 라운드이고, 여기서 기록하면 activity-contract run 이
|
|
11
11
|
존재하지 않는 `worker-dispatched` / `worker-completed` 행을 요구받게 된다.
|
|
12
|
+
|
|
13
|
+
activity-contract v1 run 에서는 검증기가 워커별 `phase-4-dispatch` /
|
|
14
|
+
`phase-5-collect` 마다 같은 워커의 `worker-dispatched` / `worker-completed`
|
|
15
|
+
activity 를 요구한다. 체크포인트를 명령이 쓰면 리드는 "activity 먼저, PROGRESS
|
|
16
|
+
다음" 순서를 지킬 수 없으므로 그 activity 도 여기서 쓴다. summary 는 고정 영어다.
|
|
12
17
|
"""
|
|
13
18
|
from __future__ import annotations
|
|
14
19
|
|
|
20
|
+
import sys
|
|
15
21
|
from pathlib import Path
|
|
16
22
|
from typing import Any, Mapping, Sequence
|
|
17
23
|
|
|
24
|
+
from .agent.activity import (
|
|
25
|
+
ActivityProjectionError,
|
|
26
|
+
conversation_activity_line,
|
|
27
|
+
record_activity,
|
|
28
|
+
)
|
|
18
29
|
from .dispatch_state import (
|
|
19
30
|
TERMINAL_WORKER_STATUSES,
|
|
20
31
|
dispatch_worker_key,
|
|
21
32
|
has_recorded_team_create,
|
|
22
33
|
worker_dispatch_records,
|
|
23
34
|
)
|
|
35
|
+
from .json_boundary import JsonBoundaryError, load_owned_object
|
|
24
36
|
from .lead_progress import record_checkpoint
|
|
25
37
|
from .worker_prompt_body import REPORT_WRITER_WORKER_ID
|
|
26
38
|
|
|
@@ -44,6 +56,7 @@ def record_dispatch_checkpoints(
|
|
|
44
56
|
"""
|
|
45
57
|
if not jobs:
|
|
46
58
|
return []
|
|
59
|
+
records_activity = _declares_activity_contract(project_root, run_manifest, source)
|
|
47
60
|
lines: list[str | None] = []
|
|
48
61
|
if not has_recorded_team_create(team_state):
|
|
49
62
|
lines.append(record_checkpoint(
|
|
@@ -56,6 +69,13 @@ def record_dispatch_checkpoints(
|
|
|
56
69
|
worker=job.worker_id,
|
|
57
70
|
fields=(("model", job.model_execution_value),),
|
|
58
71
|
))
|
|
72
|
+
if records_activity:
|
|
73
|
+
lines.append(_record_worker_activity(
|
|
74
|
+
project_root, run_manifest, source=source,
|
|
75
|
+
kind="worker-dispatched", agent=job.worker_id,
|
|
76
|
+
summary=f"Dispatched {job.worker_id} as an initial worker",
|
|
77
|
+
result_path=None, outcome="pending",
|
|
78
|
+
))
|
|
59
79
|
dispatches_writer = any(job.worker_id == REPORT_WRITER_WORKER_ID for job in jobs)
|
|
60
80
|
writer_dispatched_before = bool(
|
|
61
81
|
worker_dispatch_records(team_state, REPORT_WRITER_WORKER_ID)
|
|
@@ -104,18 +124,88 @@ def record_collect_checkpoints(
|
|
|
104
124
|
통과했고, 결과가 dispatch 에 연결됐거나(`dispatch_core._finish_attempt`),
|
|
105
125
|
attempt 가 `error` / `timeout` 으로 닫혔다.
|
|
106
126
|
"""
|
|
107
|
-
lines = []
|
|
127
|
+
lines: list[str | None] = []
|
|
128
|
+
records_activity: bool | None = None
|
|
108
129
|
for key, record in after.items():
|
|
109
130
|
previous = before.get(key)
|
|
110
131
|
if previous is not None and _attempt(previous) == _attempt(record):
|
|
111
132
|
continue
|
|
133
|
+
worker = dispatch_worker_key(record)
|
|
134
|
+
status = str(record.get("status"))
|
|
112
135
|
lines.append(record_checkpoint(
|
|
113
136
|
project_root, run_manifest, "phase-5-collect", source=source,
|
|
114
|
-
worker=
|
|
115
|
-
fields=(("status", str(record.get("status"))),),
|
|
137
|
+
worker=worker, fields=(("status", status),),
|
|
116
138
|
))
|
|
139
|
+
if records_activity is None:
|
|
140
|
+
records_activity = _declares_activity_contract(
|
|
141
|
+
project_root, run_manifest, source
|
|
142
|
+
)
|
|
143
|
+
if records_activity:
|
|
144
|
+
lines.append(_record_worker_activity(
|
|
145
|
+
project_root, run_manifest, source=source,
|
|
146
|
+
kind="worker-completed", agent=worker,
|
|
147
|
+
summary=f"Collected the {worker} result: status={status}",
|
|
148
|
+
result_path=_project_relative(project_root, record.get("resultPath")),
|
|
149
|
+
outcome="completed" if status == "completed" else "failed",
|
|
150
|
+
))
|
|
117
151
|
return [line for line in lines if line]
|
|
118
152
|
|
|
119
153
|
|
|
154
|
+
def _declares_activity_contract(
|
|
155
|
+
project_root: Path, run_manifest: str | Path, source: str,
|
|
156
|
+
) -> bool:
|
|
157
|
+
path = Path(run_manifest)
|
|
158
|
+
if not path.is_absolute():
|
|
159
|
+
path = project_root.resolve() / path
|
|
160
|
+
try:
|
|
161
|
+
manifest = load_owned_object(path, artifact="run manifest")
|
|
162
|
+
except JsonBoundaryError as exc:
|
|
163
|
+
print(f"{source}: could not read activity contract: {exc}", file=sys.stderr)
|
|
164
|
+
return False
|
|
165
|
+
return manifest.get("activityContractVersion") == 1
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _project_relative(project_root: Path, value: Any) -> str | None:
|
|
169
|
+
if not isinstance(value, str) or not value:
|
|
170
|
+
return None
|
|
171
|
+
path = Path(value)
|
|
172
|
+
root = project_root.resolve()
|
|
173
|
+
return str(path.relative_to(root)) if path.is_relative_to(root) else value
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _record_worker_activity(
|
|
177
|
+
project_root: Path,
|
|
178
|
+
run_manifest: str | Path,
|
|
179
|
+
*,
|
|
180
|
+
source: str,
|
|
181
|
+
kind: str,
|
|
182
|
+
agent: str,
|
|
183
|
+
summary: str,
|
|
184
|
+
result_path: str | None,
|
|
185
|
+
outcome: str,
|
|
186
|
+
) -> str | None:
|
|
187
|
+
"""실패 처리는 `record_checkpoint` 와 같다: stderr 에만 남기고, 누락은 검증기가 보고한다."""
|
|
188
|
+
root = project_root.resolve()
|
|
189
|
+
path = Path(run_manifest)
|
|
190
|
+
try:
|
|
191
|
+
event = record_activity(
|
|
192
|
+
root, path if path.is_absolute() else root / path,
|
|
193
|
+
{
|
|
194
|
+
"kind": kind,
|
|
195
|
+
"agent": agent,
|
|
196
|
+
"summary": summary,
|
|
197
|
+
"planItemIds": [],
|
|
198
|
+
"resultPath": result_path,
|
|
199
|
+
"commands": [],
|
|
200
|
+
"evidenceRefs": [],
|
|
201
|
+
"outcome": outcome,
|
|
202
|
+
},
|
|
203
|
+
)
|
|
204
|
+
except (ActivityProjectionError, OSError) as exc:
|
|
205
|
+
print(f"{source}: could not record `{kind}` for `{agent}`: {exc}", file=sys.stderr)
|
|
206
|
+
return None
|
|
207
|
+
return conversation_activity_line(event.details)
|
|
208
|
+
|
|
209
|
+
|
|
120
210
|
def _attempt(record: Mapping[str, Any]) -> int:
|
|
121
211
|
return int(record.get("attempt", 1))
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
import json
|
|
5
5
|
import re
|
|
6
6
|
import subprocess
|
|
7
|
+
import sys
|
|
7
8
|
import time
|
|
8
9
|
from dataclasses import dataclass, field, replace
|
|
9
10
|
from datetime import datetime, timezone
|
|
@@ -114,6 +115,7 @@ from .seeding import installed_version
|
|
|
114
115
|
from .worker_audit_ledger import (
|
|
115
116
|
check_worker_results_audit,
|
|
116
117
|
parse_worker_result_name,
|
|
118
|
+
result_file_audit_failures,
|
|
117
119
|
)
|
|
118
120
|
from .worker_artifacts import worker_provider_id
|
|
119
121
|
from .worker_prompt_body import REPORT_WRITER_WORKER_ID
|
|
@@ -121,7 +123,7 @@ from .worker_prompt_headers import (
|
|
|
121
123
|
WorkerPromptHeaderError,
|
|
122
124
|
resolve_errors_log_path,
|
|
123
125
|
)
|
|
124
|
-
from .worker_artifact_paths import audit_sidecar_rel
|
|
126
|
+
from .worker_artifact_paths import WorkerArtifactPathError, audit_sidecar_rel
|
|
125
127
|
from .wrapper_status import (
|
|
126
128
|
log_path_for_prompt,
|
|
127
129
|
mutation_snapshot_path_for_prompt,
|
|
@@ -1992,7 +1994,10 @@ def _ensure_runtime_chain(plan: DispatchPlan) -> DispatchPlan:
|
|
|
1992
1994
|
if plan.runtime_chain:
|
|
1993
1995
|
return plan
|
|
1994
1996
|
from .adapters.runtime.assembly import runtime_chain as build_chain
|
|
1995
|
-
return replace(
|
|
1997
|
+
return replace(
|
|
1998
|
+
plan,
|
|
1999
|
+
runtime_chain=build_chain(plan.default_backend, manifest=plan.manifest),
|
|
2000
|
+
)
|
|
1996
2001
|
|
|
1997
2002
|
|
|
1998
2003
|
def _spawn_request(
|
|
@@ -2040,6 +2045,7 @@ def _start_job(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
|
|
|
2040
2045
|
print(
|
|
2041
2046
|
f"DEGRADED {job.role}: {degraded_from} -> {runtime.surface} "
|
|
2042
2047
|
f"({runtime.fallback_reason or 'no reason reported'})",
|
|
2048
|
+
file=sys.stderr,
|
|
2043
2049
|
flush=True,
|
|
2044
2050
|
)
|
|
2045
2051
|
completed = None
|
|
@@ -2727,11 +2733,16 @@ def _audit_sidecar_failures(job: WorkerJob) -> tuple[str, ...]:
|
|
|
2727
2733
|
"""
|
|
2728
2734
|
parsed = parse_worker_result_name(job.worker_result_path.name)
|
|
2729
2735
|
if parsed is None:
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2736
|
+
# reverify·critic 결과는 `<role>-worker-reverify-r<N>-…` 처럼 run 단위
|
|
2737
|
+
# 스캔이 못 읽는 이름이다. 사이드카 이름은 같은 규칙으로 나오므로 이 파일만 본다.
|
|
2738
|
+
try:
|
|
2739
|
+
audit_path = Path(audit_sidecar_rel(str(job.worker_result_path)))
|
|
2740
|
+
except WorkerArtifactPathError:
|
|
2741
|
+
return (
|
|
2742
|
+
f"worker result `{job.worker_result_path.name}` has no "
|
|
2743
|
+
f"`-worker-` token, so the audit-sidecar contract could not be checked",
|
|
2744
|
+
)
|
|
2745
|
+
return tuple(result_file_audit_failures(job.worker_result_path, audit_path))
|
|
2735
2746
|
return tuple(
|
|
2736
2747
|
check_worker_results_audit(
|
|
2737
2748
|
job.worker_result_path.parent.parent,
|
|
@@ -722,6 +722,7 @@ def _prompt_body(
|
|
|
722
722
|
worker.model,
|
|
723
723
|
plan,
|
|
724
724
|
_optional_approved_plan_path(context) if plan.include_approved_plan else "",
|
|
725
|
+
_selected_direction_path(context) if plan.include_selected_direction else "",
|
|
725
726
|
)
|
|
726
727
|
|
|
727
728
|
|
|
@@ -1543,6 +1544,21 @@ def _optional_approved_plan_path(context: _MaterializationContext) -> str:
|
|
|
1543
1544
|
return ""
|
|
1544
1545
|
|
|
1545
1546
|
|
|
1547
|
+
def _selected_direction_path(context: _MaterializationContext) -> str:
|
|
1548
|
+
"""The instruction-set snapshot path when prepare wrote one, otherwise empty.
|
|
1549
|
+
|
|
1550
|
+
Only a plan opened from `--selected-direction` has the snapshot; a legacy
|
|
1551
|
+
rerun has none and its prompt drops the line.
|
|
1552
|
+
"""
|
|
1553
|
+
packet = instruction_path(context.manifest, context.active_context, "analysisPacketPath")
|
|
1554
|
+
if not packet:
|
|
1555
|
+
return ""
|
|
1556
|
+
snapshot = Path(packet).parent / "selected-direction.json"
|
|
1557
|
+
if not _resolve_input_path(context.project_root, snapshot).is_file():
|
|
1558
|
+
return ""
|
|
1559
|
+
return str(snapshot)
|
|
1560
|
+
|
|
1561
|
+
|
|
1546
1562
|
def _required_stage_number(context: _MaterializationContext) -> str:
|
|
1547
1563
|
value = _string_value(_active_run_field(context, "stage"))
|
|
1548
1564
|
if not value:
|