okstra 0.208.0 → 0.209.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 +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 +5 -5
- 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 +114 -41
- package/runtime/python/okstra_ctl/convergence.py +29 -10
- package/runtime/python/okstra_ctl/coverage_census.py +8 -1
- package/runtime/python/okstra_ctl/dispatch_checkpoints.py +93 -3
- package/runtime/python/okstra_ctl/dispatch_core.py +6 -1
- 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/phases/requirements_discovery/profile.md +4 -0
- 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 +22 -0
- 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_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.
|
|
@@ -379,8 +431,8 @@ def plan_worker_height_resize(
|
|
|
379
431
|
return None
|
|
380
432
|
|
|
381
433
|
|
|
382
|
-
def
|
|
383
|
-
"""The
|
|
434
|
+
def shim_free_worker_path() -> str:
|
|
435
|
+
"""The lead's inherited PATH with cmux's per-surface CLI shims removed.
|
|
384
436
|
|
|
385
437
|
A cmux pane execs its command through `login … bash --noprofile --norc`, so
|
|
386
438
|
none of the user's shell rc runs and PATH is cmux's own short list. What that
|
|
@@ -389,14 +441,23 @@ def shim_free_login_path() -> str:
|
|
|
389
441
|
CLIs — and where a provider cmux does not know has no entry at all, which is
|
|
390
442
|
a plain exit 127 inside the worker wrapper.
|
|
391
443
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
444
|
+
The base is the PATH the lead process inherited, because the cli-wrapper
|
|
445
|
+
runtime (and every automatic retry, which always runs there) spawns with it.
|
|
446
|
+
A pane that resolved PATH from the login shell instead could pick another
|
|
447
|
+
install of the same CLI: on 2026-10-04 (fontsninja-v3-site dev-11118) the
|
|
448
|
+
login shell put Homebrew's codex 0.153.4 first, which rejects `gpt-6.1-sol`,
|
|
449
|
+
while the lead's PATH found nvm's 0.160.0, so every first attempt failed and
|
|
450
|
+
every retry passed. The login shell's PATH is the fallback when the inherited
|
|
451
|
+
one is empty after filtering.
|
|
396
452
|
"""
|
|
397
|
-
|
|
453
|
+
path = _without_shims(os.environ.get("PATH", ""))
|
|
454
|
+
return path or _without_shims(_login_shell_path())
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _without_shims(path_value: str) -> str:
|
|
398
458
|
return os.pathsep.join(
|
|
399
|
-
entry for entry in
|
|
459
|
+
entry for entry in path_value.split(os.pathsep)
|
|
460
|
+
if entry and SHIM_DIR_MARKER not in entry
|
|
400
461
|
)
|
|
401
462
|
|
|
402
463
|
|
|
@@ -441,6 +502,7 @@ def spawn_worker_surface(
|
|
|
441
502
|
command: Sequence[str],
|
|
442
503
|
title: str,
|
|
443
504
|
owned_surface_ids: Collection[str],
|
|
505
|
+
recorded: LeadLocation | None,
|
|
444
506
|
) -> str:
|
|
445
507
|
"""Start one worker beside the lead and return its surface UUID.
|
|
446
508
|
|
|
@@ -453,7 +515,7 @@ def spawn_worker_surface(
|
|
|
453
515
|
user and may hold anything.
|
|
454
516
|
"""
|
|
455
517
|
panes = list_panes(workspace)
|
|
456
|
-
lead = _lead_pane(panes)
|
|
518
|
+
lead = _lead_pane(panes, recorded)
|
|
457
519
|
placement = plan_worker_placement(
|
|
458
520
|
panes,
|
|
459
521
|
lead_pane_id=lead.pane_id,
|
|
@@ -463,7 +525,7 @@ def spawn_worker_surface(
|
|
|
463
525
|
try:
|
|
464
526
|
surface_uuid = _create_surface(workspace, placement, target)
|
|
465
527
|
except (RuntimeError, OSError, subprocess.SubprocessError):
|
|
466
|
-
_restore_lead_after_failed_spawn(workspace, owned_surface_ids)
|
|
528
|
+
_restore_lead_after_failed_spawn(workspace, owned_surface_ids, recorded)
|
|
467
529
|
raise
|
|
468
530
|
try:
|
|
469
531
|
renamed = rename_surface(surface_uuid, title)
|
|
@@ -471,8 +533,8 @@ def spawn_worker_surface(
|
|
|
471
533
|
raise RuntimeError(renamed.stderr.strip() or "cmux could not name the worker")
|
|
472
534
|
_exec_worker(surface_uuid, cwd=cwd, command=command)
|
|
473
535
|
owned = (*owned_surface_ids, surface_uuid)
|
|
474
|
-
_size_lead_pane(workspace, owned)
|
|
475
|
-
_equalize_worker_heights(workspace, owned)
|
|
536
|
+
_size_lead_pane(workspace, owned, recorded)
|
|
537
|
+
_equalize_worker_heights(workspace, owned, recorded)
|
|
476
538
|
except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
|
|
477
539
|
# 호출이 반환되기 전에는 명부에 창 식별자가 없다. 여기서 정리하지
|
|
478
540
|
# 않으면 대체 실행으로 전환된 뒤에도 회수할 수 없는 창이 남는다.
|
|
@@ -485,13 +547,15 @@ def spawn_worker_surface(
|
|
|
485
547
|
f"{exc}; cleanup failed for cmux surface {surface_uuid}: {cleanup_error}"
|
|
486
548
|
) from exc
|
|
487
549
|
finally:
|
|
488
|
-
_restore_lead_after_failed_spawn(workspace, owned_surface_ids)
|
|
550
|
+
_restore_lead_after_failed_spawn(workspace, owned_surface_ids, recorded)
|
|
489
551
|
raise
|
|
490
552
|
return surface_uuid
|
|
491
553
|
|
|
492
554
|
|
|
493
555
|
def _restore_lead_after_failed_spawn(
|
|
494
|
-
workspace: str,
|
|
556
|
+
workspace: str,
|
|
557
|
+
owned_surface_ids: Collection[str],
|
|
558
|
+
recorded: LeadLocation | None,
|
|
495
559
|
) -> None:
|
|
496
560
|
"""실패한 분할이 리드에서 떼어 간 폭을 돌려준다.
|
|
497
561
|
|
|
@@ -508,7 +572,7 @@ def _restore_lead_after_failed_spawn(
|
|
|
508
572
|
중이고, 원장에 남아야 할 문장은 그쪽이다.
|
|
509
573
|
"""
|
|
510
574
|
try:
|
|
511
|
-
_size_lead_pane(workspace, owned_surface_ids)
|
|
575
|
+
_size_lead_pane(workspace, owned_surface_ids, recorded)
|
|
512
576
|
except (RuntimeError, OSError, subprocess.SubprocessError):
|
|
513
577
|
return
|
|
514
578
|
|
|
@@ -552,7 +616,7 @@ def close_surface(surface_uuid: str) -> None:
|
|
|
552
616
|
return
|
|
553
617
|
|
|
554
618
|
|
|
555
|
-
def restore_lead_width() -> None:
|
|
619
|
+
def restore_lead_width(recorded: LeadLocation | None) -> None:
|
|
556
620
|
"""Put the lead back on its target width once its workers are reclaimed.
|
|
557
621
|
|
|
558
622
|
Closing a pane hands its width to whichever neighbour cmux picks, and that
|
|
@@ -573,10 +637,10 @@ def restore_lead_width() -> None:
|
|
|
573
637
|
failed to close counts as a stranger's and keeps its width, which is the
|
|
574
638
|
safe way to be wrong here.
|
|
575
639
|
"""
|
|
576
|
-
workspace = resolve_lead_workspace()
|
|
640
|
+
workspace = resolve_lead_workspace(recorded)
|
|
577
641
|
if not workspace:
|
|
578
642
|
return
|
|
579
|
-
_size_lead_pane(workspace, ())
|
|
643
|
+
_size_lead_pane(workspace, (), recorded)
|
|
580
644
|
|
|
581
645
|
|
|
582
646
|
def socket_reachability() -> str:
|
|
@@ -605,7 +669,7 @@ def socket_reachability() -> str:
|
|
|
605
669
|
probe.close()
|
|
606
670
|
|
|
607
671
|
|
|
608
|
-
def unreachable_reason() -> str:
|
|
672
|
+
def unreachable_reason(recorded: LeadLocation | None) -> str:
|
|
609
673
|
"""Why cmux cannot be reached from here, or "" when it can.
|
|
610
674
|
|
|
611
675
|
`environment` is the case a socket probe alone cannot see: a sandbox that
|
|
@@ -620,7 +684,7 @@ def unreachable_reason() -> str:
|
|
|
620
684
|
return LOST_DENIED
|
|
621
685
|
if reachability != SOCKET_OK:
|
|
622
686
|
return LOST_GONE
|
|
623
|
-
return LOST_NOTHING if resolve_lead_workspace() else LOST_GONE
|
|
687
|
+
return LOST_NOTHING if resolve_lead_workspace(recorded) else LOST_GONE
|
|
624
688
|
|
|
625
689
|
|
|
626
690
|
def unreachable_advice(reason: str) -> tuple[str, str]:
|
|
@@ -783,14 +847,18 @@ def _surface_from_split_echo(stdout: str) -> str:
|
|
|
783
847
|
|
|
784
848
|
def _exec_worker(surface_uuid: str, *, cwd: Path, command: Sequence[str]) -> None:
|
|
785
849
|
line = worker_command_line(
|
|
786
|
-
cwd=cwd, argv=command, path_value=
|
|
850
|
+
cwd=cwd, argv=command, path_value=shim_free_worker_path()
|
|
787
851
|
)
|
|
788
852
|
started = run_cmux(["respawn-pane", "--surface", surface_uuid, "--command", line])
|
|
789
853
|
if started.returncode != 0:
|
|
790
854
|
raise RuntimeError(started.stderr.strip() or "cmux could not start the worker")
|
|
791
855
|
|
|
792
856
|
|
|
793
|
-
def _size_lead_pane(
|
|
857
|
+
def _size_lead_pane(
|
|
858
|
+
workspace: str,
|
|
859
|
+
owned_surface_ids: Collection[str],
|
|
860
|
+
recorded: LeadLocation | None,
|
|
861
|
+
) -> None:
|
|
794
862
|
"""Move the lead's border to its share of the workspace.
|
|
795
863
|
|
|
796
864
|
This is the horizontal border okstra places. The first worker column
|
|
@@ -817,7 +885,7 @@ def _size_lead_pane(workspace: str, owned_surface_ids: Collection[str]) -> None:
|
|
|
817
885
|
# rather than leave it where the user last saw it.
|
|
818
886
|
return
|
|
819
887
|
panes = _panes_from(payload)
|
|
820
|
-
lead = _lead_pane(panes)
|
|
888
|
+
lead = _lead_pane(panes, recorded)
|
|
821
889
|
target = lead_target_width(
|
|
822
890
|
panes,
|
|
823
891
|
lead_pane_id=lead.pane_id,
|
|
@@ -854,7 +922,9 @@ def _size_lead_pane(workspace: str, owned_surface_ids: Collection[str]) -> None:
|
|
|
854
922
|
|
|
855
923
|
|
|
856
924
|
def _equalize_worker_heights(
|
|
857
|
-
workspace: str,
|
|
925
|
+
workspace: str,
|
|
926
|
+
owned_surface_ids: Collection[str],
|
|
927
|
+
recorded: LeadLocation | None,
|
|
858
928
|
) -> None:
|
|
859
929
|
"""Give every owned worker the same height in the first column.
|
|
860
930
|
|
|
@@ -866,7 +936,7 @@ def _equalize_worker_heights(
|
|
|
866
936
|
"""
|
|
867
937
|
for _ in range(MAX_WORKER_HEIGHT_MOVES):
|
|
868
938
|
panes = list_panes(workspace)
|
|
869
|
-
lead = _lead_pane(panes)
|
|
939
|
+
lead = _lead_pane(panes, recorded)
|
|
870
940
|
workers = [
|
|
871
941
|
pane
|
|
872
942
|
for pane in panes
|
|
@@ -887,7 +957,14 @@ def _equalize_worker_heights(
|
|
|
887
957
|
)
|
|
888
958
|
|
|
889
959
|
|
|
890
|
-
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")
|
|
891
968
|
caller_pane_ref = identify_caller().get("pane_ref", "")
|
|
892
969
|
for pane in panes:
|
|
893
970
|
if caller_pane_ref and pane.ref == caller_pane_ref:
|
|
@@ -939,11 +1016,7 @@ def _pane_geometry(entry: dict[str, Any]) -> PaneGeometry:
|
|
|
939
1016
|
|
|
940
1017
|
|
|
941
1018
|
def _login_shell_path() -> str:
|
|
942
|
-
"""PATH as the user's own login shell resolves it.
|
|
943
|
-
|
|
944
|
-
Falls back to the inherited PATH: a worker started with a shim-filtered
|
|
945
|
-
inherited PATH is still better than one started with cmux's bare list.
|
|
946
|
-
"""
|
|
1019
|
+
"""PATH as the user's own login shell resolves it, or '' when it cannot run."""
|
|
947
1020
|
shell = os.environ.get("SHELL", "") or "/bin/sh"
|
|
948
1021
|
try:
|
|
949
1022
|
result = subprocess.run(
|
|
@@ -954,9 +1027,9 @@ def _login_shell_path() -> str:
|
|
|
954
1027
|
check=False,
|
|
955
1028
|
)
|
|
956
1029
|
except (OSError, subprocess.SubprocessError):
|
|
957
|
-
return
|
|
1030
|
+
return ""
|
|
958
1031
|
if result.returncode != 0 or not result.stdout.strip():
|
|
959
|
-
return
|
|
1032
|
+
return ""
|
|
960
1033
|
return result.stdout.strip()
|
|
961
1034
|
|
|
962
1035
|
|
|
@@ -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
|
|
|
@@ -242,8 +242,15 @@ def _split_verdict_section(text: str) -> tuple[list[str], str]:
|
|
|
242
242
|
)
|
|
243
243
|
if start is None:
|
|
244
244
|
return [], text
|
|
245
|
+
level = len(lines[start]) - len(lines[start].lstrip("#"))
|
|
246
|
+
# Subheadings (`### EB-001`) group the verdict lines; only a heading at the
|
|
247
|
+
# section's own level or above closes it.
|
|
245
248
|
end = next(
|
|
246
|
-
(
|
|
249
|
+
(
|
|
250
|
+
index for index in range(start + 1, len(lines))
|
|
251
|
+
if _HEADING_RE.match(lines[index])
|
|
252
|
+
and len(lines[index]) - len(lines[index].lstrip("#")) <= level
|
|
253
|
+
),
|
|
247
254
|
len(lines),
|
|
248
255
|
)
|
|
249
256
|
return lines[start + 1:end], "\n".join(lines[:start] + lines[end:])
|
|
@@ -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
|
|
@@ -1992,7 +1993,10 @@ def _ensure_runtime_chain(plan: DispatchPlan) -> DispatchPlan:
|
|
|
1992
1993
|
if plan.runtime_chain:
|
|
1993
1994
|
return plan
|
|
1994
1995
|
from .adapters.runtime.assembly import runtime_chain as build_chain
|
|
1995
|
-
return replace(
|
|
1996
|
+
return replace(
|
|
1997
|
+
plan,
|
|
1998
|
+
runtime_chain=build_chain(plan.default_backend, manifest=plan.manifest),
|
|
1999
|
+
)
|
|
1996
2000
|
|
|
1997
2001
|
|
|
1998
2002
|
def _spawn_request(
|
|
@@ -2040,6 +2044,7 @@ def _start_job(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
|
|
|
2040
2044
|
print(
|
|
2041
2045
|
f"DEGRADED {job.role}: {degraded_from} -> {runtime.surface} "
|
|
2042
2046
|
f"({runtime.fallback_reason or 'no reason reported'})",
|
|
2047
|
+
file=sys.stderr,
|
|
2043
2048
|
flush=True,
|
|
2044
2049
|
)
|
|
2045
2050
|
completed = None
|
|
@@ -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:
|