okstra 0.175.1 → 0.176.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/architecture/storage-model.md +2 -2
- package/docs/architecture.md +22 -19
- package/docs/cli.md +18 -14
- package/docs/for-ai/skills/okstra-inspect.md +2 -3
- package/docs/for-ai/skills/okstra-rollup.md +1 -0
- package/docs/project-structure-overview.md +57 -56
- package/docs/task-process/README.md +11 -9
- package/docs/task-process/common-flow.md +13 -16
- package/docs/task-process/error-analysis.md +9 -10
- package/docs/task-process/final-verification.md +7 -7
- package/docs/task-process/implementation-planning.md +9 -9
- package/docs/task-process/implementation.md +6 -6
- package/docs/task-process/release-handoff.md +8 -7
- package/docs/task-process/requirements-discovery.md +8 -8
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/interactive.sh +12 -6
- package/runtime/bin/lib/okstra/usage.sh +3 -2
- package/runtime/bin/okstra-spawn-followups.py +4 -2
- package/runtime/prompts/launch.template.md +2 -2
- package/runtime/prompts/lead/context-loader.md +1 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +3 -4
- package/runtime/prompts/lead/report-writer.md +15 -1
- package/runtime/prompts/lead/team-contract.md +16 -12
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
- package/runtime/prompts/profiles/_implementation-executor.md +0 -3
- package/runtime/prompts/profiles/_implementation-verifier.md +3 -7
- package/runtime/prompts/profiles/change-impact-analysis.md +9 -5
- package/runtime/prompts/profiles/error-analysis.md +14 -8
- package/runtime/prompts/profiles/feature-analysis.md +9 -5
- package/runtime/prompts/profiles/final-verification.md +10 -7
- package/runtime/prompts/profiles/implementation-option-selection.md +9 -5
- package/runtime/prompts/profiles/implementation-planning.md +13 -7
- package/runtime/prompts/profiles/implementation.md +9 -5
- package/runtime/prompts/profiles/improvement-discovery.md +10 -6
- package/runtime/prompts/profiles/project-analysis.md +9 -5
- package/runtime/prompts/profiles/requirements-discovery.md +15 -9
- package/runtime/prompts/wizard/prompts.ko.json +18 -1
- package/runtime/python/okstra_ctl/adapters/runtime/__init__.py +1 -0
- package/runtime/python/okstra_ctl/adapters/runtime/assembly.py +27 -0
- package/runtime/python/okstra_ctl/adapters/runtime/cli_wrapper.py +49 -0
- package/runtime/python/okstra_ctl/adapters/runtime/cmux.py +74 -0
- package/runtime/python/okstra_ctl/application/open_worker.py +29 -0
- package/runtime/python/okstra_ctl/assignment_resolver.py +27 -27
- package/runtime/python/okstra_ctl/dispatch_core.py +120 -100
- package/runtime/python/okstra_ctl/domain/host.py +3 -1
- package/runtime/python/okstra_ctl/domain/wizard/interaction.py +5 -0
- package/runtime/python/okstra_ctl/domain/worker_runtime.py +44 -0
- package/runtime/python/okstra_ctl/implementation_outcome.py +21 -5
- package/runtime/python/okstra_ctl/legacy_model_selection.py +115 -27
- package/runtime/python/okstra_ctl/manager_sync.py +4 -1
- package/runtime/python/okstra_ctl/next_phase.py +236 -0
- package/runtime/python/okstra_ctl/ports/worker_runtime.py +26 -0
- package/runtime/python/okstra_ctl/recap.py +4 -1
- package/runtime/python/okstra_ctl/render.py +46 -47
- package/runtime/python/okstra_ctl/role_requirements.py +28 -35
- package/runtime/python/okstra_ctl/rollup.py +4 -1
- package/runtime/python/okstra_ctl/run.py +8 -5
- package/runtime/python/okstra_ctl/stage_fix_carry.py +17 -1
- package/runtime/python/okstra_ctl/team.py +14 -18
- package/runtime/python/okstra_ctl/wizard.py +248 -56
- package/runtime/python/okstra_ctl/worker_prompt_body.py +18 -2
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +40 -9
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +16 -1
- package/runtime/python/okstra_ctl/workflow.py +18 -32
- package/runtime/python/okstra_ctl/worktree.py +3 -3
- package/runtime/python/okstra_ctl/worktree_registry.py +5 -4
- package/runtime/python/okstra_project/state.py +54 -6
- package/runtime/schemas/final-report-v2.0.schema.json +43 -5
- package/runtime/skills/okstra-inspect/facets/recap.md +2 -0
- package/runtime/skills/okstra-inspect/facets/report.md +1 -1
- package/runtime/skills/okstra-inspect/facets/status.md +15 -13
- package/runtime/skills/okstra-rollup/SKILL.md +1 -0
- package/runtime/skills/okstra-run/SKILL.md +5 -5
- package/runtime/templates/implementation-worker-preamble.md +3 -18
- package/runtime/templates/project-docs/task-index.template.md +0 -1
- package/runtime/templates/reports/html/macros/forms.html +5 -4
- package/runtime/templates/reports/html/tasks/final-verification.template.html +1 -1
- package/runtime/templates/reports/html/tasks/implementation.template.html +1 -1
- package/runtime/templates/worker-prompt-preamble.md +3 -36
- package/runtime/validators/validate-run.py +57 -99
|
@@ -9,7 +9,6 @@ from datetime import datetime, timezone
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import Any, Mapping, Sequence
|
|
11
11
|
|
|
12
|
-
from . import cmux
|
|
13
12
|
from .dispatch_state import (
|
|
14
13
|
BACKEND_CLI_WRAPPER,
|
|
15
14
|
BACKEND_CMUX_PANE,
|
|
@@ -51,9 +50,19 @@ from .assignment_resolver import (
|
|
|
51
50
|
RoleInstance,
|
|
52
51
|
resolve_model_assignment,
|
|
53
52
|
)
|
|
53
|
+
from .application.open_worker import open_worker
|
|
54
54
|
from .domain.host import HostSessionContext
|
|
55
55
|
from .domain.provider import HostModelBinding, ServedModelAttestation
|
|
56
56
|
from .domain.worker_exec import SERVED_MODEL_MISMATCH_EXIT_CODE
|
|
57
|
+
from .domain.worker_runtime import (
|
|
58
|
+
SURFACE_CMUX_PANE,
|
|
59
|
+
EnvironmentBlocked,
|
|
60
|
+
ProgressEvent,
|
|
61
|
+
RuntimeHandle,
|
|
62
|
+
SurfaceUnavailable,
|
|
63
|
+
WorkerSpawnRequest,
|
|
64
|
+
)
|
|
65
|
+
from .ports.worker_runtime import WorkerRuntimePort
|
|
57
66
|
from .execution_identity import Attempt, Invocation, RoleExecution, model_spec_digest
|
|
58
67
|
from .execution_manifest import (
|
|
59
68
|
finish_attempt_mutation,
|
|
@@ -229,6 +238,7 @@ class WorkerHandle:
|
|
|
229
238
|
mutation_retry_allowed: bool = True
|
|
230
239
|
write_policy_digest: str = ""
|
|
231
240
|
write_enforcement: WriteEnforcement | None = None
|
|
241
|
+
runtime_handle: RuntimeHandle | None = None
|
|
232
242
|
|
|
233
243
|
|
|
234
244
|
@dataclass(frozen=True)
|
|
@@ -252,6 +262,7 @@ class DispatchPlan:
|
|
|
252
262
|
manifest: Mapping[str, Any]
|
|
253
263
|
jobs: tuple[WorkerJob, ...]
|
|
254
264
|
default_backend: str
|
|
265
|
+
runtime_chain: tuple[WorkerRuntimePort, ...] = ()
|
|
255
266
|
|
|
256
267
|
def to_payload(self, dry_run: bool) -> dict[str, Any]:
|
|
257
268
|
payload = {
|
|
@@ -358,6 +369,7 @@ def build_dispatch_plan(
|
|
|
358
369
|
|
|
359
370
|
|
|
360
371
|
def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
|
|
372
|
+
plan = _ensure_runtime_chain(plan)
|
|
361
373
|
_validate_report_writer_isolation(plan.jobs)
|
|
362
374
|
_validate_implementation_phase_order(plan.jobs)
|
|
363
375
|
_revalidate_plan_role_executions(plan)
|
|
@@ -391,6 +403,7 @@ def dispatch_cli_wrapper_plan(plan: DispatchPlan) -> int:
|
|
|
391
403
|
"""Start one dependency-free CLI batch before collecting any worker."""
|
|
392
404
|
if any(job.backend != BACKEND_CLI_WRAPPER for job in plan.jobs):
|
|
393
405
|
raise DispatchError("concurrent CLI dispatch requires cli-wrapper jobs only")
|
|
406
|
+
plan = _ensure_runtime_chain(plan)
|
|
394
407
|
_validate_report_writer_isolation(plan.jobs)
|
|
395
408
|
_validate_implementation_phase_order(plan.jobs)
|
|
396
409
|
_revalidate_plan_role_executions(plan)
|
|
@@ -564,14 +577,17 @@ def _dispatch_cli_wrapper_batch(
|
|
|
564
577
|
exc,
|
|
565
578
|
)
|
|
566
579
|
raise
|
|
580
|
+
from .adapters.runtime.assembly import port_for
|
|
581
|
+
|
|
567
582
|
outcomes: list[tuple[WorkerHandle, int, WorkerOutcome]] = []
|
|
568
583
|
for handle, attempt in active:
|
|
569
|
-
|
|
570
|
-
if
|
|
584
|
+
runtime = handle.runtime_handle
|
|
585
|
+
if runtime is None or not runtime.waitable:
|
|
571
586
|
raise DispatchError("concurrent CLI worker process is missing")
|
|
572
|
-
|
|
587
|
+
port = port_for(plan.runtime_chain, runtime.surface)
|
|
588
|
+
returncode = port.wait(runtime)
|
|
573
589
|
completed = subprocess.CompletedProcess(handle.job.command, returncode)
|
|
574
|
-
settled = replace(handle, completed_process=completed
|
|
590
|
+
settled = replace(handle, completed_process=completed)
|
|
575
591
|
outcomes.append((settled, attempt, _outcome_from_completed(settled)))
|
|
576
592
|
|
|
577
593
|
next_round: list[tuple[WorkerJob, int]] = []
|
|
@@ -611,15 +627,18 @@ def _abort_cli_batch_start(
|
|
|
611
627
|
policies: Sequence[WritePolicy],
|
|
612
628
|
error: Exception,
|
|
613
629
|
) -> None:
|
|
630
|
+
from .adapters.runtime.assembly import port_for
|
|
631
|
+
|
|
614
632
|
reason = f"concurrent CLI batch launch failed: {error}"
|
|
615
633
|
for handle, _attempt in active:
|
|
616
|
-
|
|
617
|
-
if
|
|
618
|
-
|
|
634
|
+
runtime = handle.runtime_handle
|
|
635
|
+
if runtime is None:
|
|
636
|
+
continue
|
|
637
|
+
port_for(plan.runtime_chain, runtime.surface).terminate(runtime)
|
|
619
638
|
for handle, attempt in active:
|
|
620
|
-
|
|
621
|
-
if
|
|
622
|
-
|
|
639
|
+
runtime = handle.runtime_handle
|
|
640
|
+
if runtime is not None:
|
|
641
|
+
port_for(plan.runtime_chain, runtime.surface).wait(runtime)
|
|
623
642
|
_transition_job_status(plan, handle.job, "error", reason)
|
|
624
643
|
_update_dispatch_status(
|
|
625
644
|
plan.team_state_path, handle.job, attempt, "error", reason
|
|
@@ -1283,18 +1302,24 @@ def _spawn_job(plan: DispatchPlan, job: WorkerJob, attempt: int) -> WorkerHandle
|
|
|
1283
1302
|
def _spawn_cli_job_nonblocking(
|
|
1284
1303
|
plan: DispatchPlan, job: WorkerJob, attempt: int,
|
|
1285
1304
|
) -> WorkerHandle:
|
|
1286
|
-
|
|
1305
|
+
plan = _ensure_runtime_chain(plan)
|
|
1287
1306
|
try:
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1307
|
+
runtime = open_worker(
|
|
1308
|
+
_chain_starting_at(plan.runtime_chain, job.backend),
|
|
1309
|
+
lambda surface: _spawn_request(plan, job, surface),
|
|
1291
1310
|
)
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1311
|
+
except EnvironmentBlocked as exc:
|
|
1312
|
+
raise DispatchError(str(exc)) from exc
|
|
1313
|
+
except (SurfaceUnavailable, OSError) as exc:
|
|
1314
|
+
raise DispatchError(str(exc)) from exc
|
|
1315
|
+
return WorkerHandle(
|
|
1316
|
+
job,
|
|
1317
|
+
"",
|
|
1318
|
+
None,
|
|
1319
|
+
status_path_for_prompt(job.prompt_path),
|
|
1320
|
+
"",
|
|
1321
|
+
runtime_handle=runtime,
|
|
1322
|
+
)
|
|
1298
1323
|
|
|
1299
1324
|
|
|
1300
1325
|
def _prepare_job_attempt(
|
|
@@ -1515,35 +1540,66 @@ def _dispatch_job_with_retry(plan: DispatchPlan, job: WorkerJob) -> int:
|
|
|
1515
1540
|
return 1
|
|
1516
1541
|
|
|
1517
1542
|
|
|
1543
|
+
def _ensure_runtime_chain(plan: DispatchPlan) -> DispatchPlan:
|
|
1544
|
+
if plan.runtime_chain:
|
|
1545
|
+
return plan
|
|
1546
|
+
from .adapters.runtime.assembly import runtime_chain as build_chain
|
|
1547
|
+
return replace(plan, runtime_chain=build_chain(plan.default_backend))
|
|
1548
|
+
|
|
1549
|
+
|
|
1550
|
+
def _spawn_request(
|
|
1551
|
+
plan: DispatchPlan, job: WorkerJob, surface: str
|
|
1552
|
+
) -> WorkerSpawnRequest:
|
|
1553
|
+
targeted = replace(job, backend=surface)
|
|
1554
|
+
title = (
|
|
1555
|
+
targeted.execution_label
|
|
1556
|
+
if targeted.has_execution_identity
|
|
1557
|
+
else f"{targeted.worker_id}-worker"
|
|
1558
|
+
)
|
|
1559
|
+
return WorkerSpawnRequest(
|
|
1560
|
+
command=tuple(targeted.command),
|
|
1561
|
+
cwd=plan.project_root,
|
|
1562
|
+
title=title,
|
|
1563
|
+
owned_surface_ids=_opened_cmux_surfaces(plan.team_state_path),
|
|
1564
|
+
)
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
def _chain_starting_at(
|
|
1568
|
+
chain: tuple[WorkerRuntimePort, ...], surface: str
|
|
1569
|
+
) -> tuple[WorkerRuntimePort, ...]:
|
|
1570
|
+
# A retry job is already cli-wrapper; do not walk back up to cmux-pane.
|
|
1571
|
+
for index, port in enumerate(chain):
|
|
1572
|
+
if port.surface == surface:
|
|
1573
|
+
return chain[index:]
|
|
1574
|
+
raise DispatchError(f"unsupported worker backend: {surface}")
|
|
1575
|
+
|
|
1576
|
+
|
|
1518
1577
|
def _start_job(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
|
|
1519
|
-
|
|
1520
|
-
return _run_cli_wrapper(plan, job, "")
|
|
1521
|
-
if job.backend == BACKEND_CMUX_PANE:
|
|
1522
|
-
return _start_cmux_or_degrade(plan, job)
|
|
1523
|
-
raise DispatchError(f"unsupported worker backend: {job.backend}")
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
def _start_cmux_or_degrade(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
|
|
1527
|
-
workspace = cmux.resolve_lead_workspace()
|
|
1528
|
-
if not workspace:
|
|
1529
|
-
_refuse_when_cmux_is_walled_off()
|
|
1530
|
-
return _run_cli_wrapper(plan, job, BACKEND_CMUX_PANE)
|
|
1578
|
+
plan = _ensure_runtime_chain(plan)
|
|
1531
1579
|
try:
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
command=job.command,
|
|
1536
|
-
title=(
|
|
1537
|
-
job.execution_label
|
|
1538
|
-
if job.has_execution_identity
|
|
1539
|
-
else f"{job.worker_id}-worker"
|
|
1540
|
-
),
|
|
1541
|
-
owned_surface_ids=_opened_cmux_surfaces(plan.team_state_path),
|
|
1580
|
+
runtime = open_worker(
|
|
1581
|
+
_chain_starting_at(plan.runtime_chain, job.backend),
|
|
1582
|
+
lambda surface: _spawn_request(plan, job, surface),
|
|
1542
1583
|
)
|
|
1543
|
-
except
|
|
1544
|
-
|
|
1584
|
+
except EnvironmentBlocked as exc:
|
|
1585
|
+
raise DispatchError(str(exc)) from exc
|
|
1586
|
+
except SurfaceUnavailable as exc:
|
|
1587
|
+
raise DispatchError(str(exc)) from exc
|
|
1588
|
+
degraded_from = job.backend if runtime.surface != job.backend else ""
|
|
1589
|
+
settled = replace(job, backend=runtime.surface) if degraded_from else job
|
|
1590
|
+
completed = None
|
|
1591
|
+
if runtime.waitable:
|
|
1592
|
+
from .adapters.runtime.assembly import port_for
|
|
1593
|
+
returncode = port_for(plan.runtime_chain, runtime.surface).wait(runtime)
|
|
1594
|
+
completed = subprocess.CompletedProcess(list(settled.command), returncode)
|
|
1595
|
+
pane_id = runtime.identifier if runtime.surface == SURFACE_CMUX_PANE else ""
|
|
1545
1596
|
return WorkerHandle(
|
|
1546
|
-
|
|
1597
|
+
settled,
|
|
1598
|
+
pane_id,
|
|
1599
|
+
completed,
|
|
1600
|
+
status_path_for_prompt(settled.prompt_path),
|
|
1601
|
+
degraded_from,
|
|
1602
|
+
runtime_handle=runtime,
|
|
1547
1603
|
)
|
|
1548
1604
|
|
|
1549
1605
|
|
|
@@ -1569,42 +1625,6 @@ def _opened_cmux_surfaces(team_state_path: Path) -> tuple[str, ...]:
|
|
|
1569
1625
|
return tuple(opened)
|
|
1570
1626
|
|
|
1571
1627
|
|
|
1572
|
-
def _refuse_when_cmux_is_walled_off() -> None:
|
|
1573
|
-
"""Stop rather than degrade when a sandbox stands between okstra and cmux.
|
|
1574
|
-
|
|
1575
|
-
prepare records `cmux-pane` only after reaching cmux, so losing it by
|
|
1576
|
-
dispatch time means something came between. When the socket is present but
|
|
1577
|
-
connecting is denied, that something is a sandbox around the lead — and the
|
|
1578
|
-
same sandbox hides the worker CLIs' own config, so the fallback this would
|
|
1579
|
-
otherwise take is already dead. Degrading here spends every worker's retry
|
|
1580
|
-
budget on the same wall and leaves the user with three failures and no
|
|
1581
|
-
explanation, which is exactly what it looks like when nothing happens.
|
|
1582
|
-
"""
|
|
1583
|
-
reason = cmux.unreachable_reason()
|
|
1584
|
-
if reason not in (cmux.LOST_ENVIRONMENT, cmux.LOST_DENIED):
|
|
1585
|
-
return
|
|
1586
|
-
observed = (
|
|
1587
|
-
"this process has no cmux environment left — a sandbox sanitized it"
|
|
1588
|
-
if reason == cmux.LOST_ENVIRONMENT
|
|
1589
|
-
else "cmux is running but this process may not connect to its socket"
|
|
1590
|
-
)
|
|
1591
|
-
raise DispatchError(
|
|
1592
|
-
f"this run was prepared for cmux panes, but {observed}. The same "
|
|
1593
|
-
"sandbox blocks worker CLIs from their own config, so falling back to "
|
|
1594
|
-
"blocking workers would fail too. Relaunch the lead outside a sandbox "
|
|
1595
|
-
"(codex: `codex -s danger-full-access`)."
|
|
1596
|
-
)
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
def _run_cli_wrapper(plan: DispatchPlan, job: WorkerJob, degraded_from: str) -> WorkerHandle:
|
|
1600
|
-
# Whatever the job was planned as, this is a cli-wrapper dispatch: no pane
|
|
1601
|
-
# took it, so the worker's stdout is read by whoever called the lead. The
|
|
1602
|
-
# record already says so (`_dispatch_record`), and the command must agree.
|
|
1603
|
-
job = replace(job, backend=BACKEND_CLI_WRAPPER)
|
|
1604
|
-
completed = subprocess.run(job.command, cwd=plan.project_root, text=True)
|
|
1605
|
-
return WorkerHandle(job, "", completed, status_path_for_prompt(job.prompt_path), degraded_from)
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
1628
|
def _outcome_from_completed(handle: WorkerHandle) -> WorkerOutcome:
|
|
1609
1629
|
completed = handle.completed_process
|
|
1610
1630
|
if completed is None:
|
|
@@ -2494,33 +2514,33 @@ _SIDEBAR_LEVELS = {
|
|
|
2494
2514
|
|
|
2495
2515
|
|
|
2496
2516
|
def _relay_to_sidebar(plan: DispatchPlan, event_type: str, details: Mapping[str, Any]) -> None:
|
|
2497
|
-
"""Mirror a dispatch event onto the
|
|
2517
|
+
"""Mirror a dispatch event onto the lead surface.
|
|
2498
2518
|
|
|
2499
|
-
A long run is mostly silence, and the
|
|
2519
|
+
A long run is mostly silence, and the lead surface is the one still
|
|
2500
2520
|
visible after the user scrolls away or switches workspaces. A failed worker
|
|
2501
2521
|
additionally raises a notification, because that is the event whose cost
|
|
2502
|
-
grows the longer it goes unnoticed.
|
|
2522
|
+
grows the longer it goes unnoticed. The lead port is `runtime_chain[0]`,
|
|
2523
|
+
so a worker that degraded to the wrapper still lands on a cmux run's
|
|
2524
|
+
sidebar. A cli-wrapper lead is a no-op.
|
|
2503
2525
|
"""
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
# that degradation is exactly what the sidebar should keep showing.
|
|
2507
|
-
if plan.default_backend != BACKEND_CMUX_PANE:
|
|
2508
|
-
return
|
|
2509
|
-
workspace = cmux.resolve_lead_workspace()
|
|
2526
|
+
plan = _ensure_runtime_chain(plan)
|
|
2527
|
+
lead = plan.runtime_chain[0]
|
|
2510
2528
|
worker_id = str(
|
|
2511
2529
|
details.get("executionLabel") or details.get("workerId") or "worker"
|
|
2512
2530
|
)
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
f"{worker_id}: {event_type.removeprefix('worker-')}",
|
|
2516
|
-
level=_SIDEBAR_LEVELS.get(event_type, "info"),
|
|
2517
|
-
)
|
|
2531
|
+
notify_title = None
|
|
2532
|
+
notify_body = None
|
|
2518
2533
|
if event_type == "worker-failed":
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2534
|
+
notify_title = f"okstra — {_require_string(plan.manifest, 'taskType')}"
|
|
2535
|
+
notify_body = f"{worker_id} failed: {details.get('reason', 'no reason recorded')}"
|
|
2536
|
+
lead.notify(
|
|
2537
|
+
ProgressEvent(
|
|
2538
|
+
message=f"{worker_id}: {event_type.removeprefix('worker-')}",
|
|
2539
|
+
level=_SIDEBAR_LEVELS.get(event_type, "info"),
|
|
2540
|
+
notify_title=notify_title,
|
|
2541
|
+
notify_body=notify_body,
|
|
2523
2542
|
)
|
|
2543
|
+
)
|
|
2524
2544
|
|
|
2525
2545
|
|
|
2526
2546
|
def _append_event(plan: DispatchPlan, event_type: str, details: Mapping[str, Any]) -> None:
|
|
@@ -20,6 +20,8 @@ class CurrentSessionModelAttestation:
|
|
|
20
20
|
"cli-handoff",
|
|
21
21
|
"unavailable",
|
|
22
22
|
]
|
|
23
|
+
# 호스트가 알려 줄 때만 채운다. 스크레이핑하지 않는다.
|
|
24
|
+
effort: str | None = None
|
|
23
25
|
|
|
24
26
|
@classmethod
|
|
25
27
|
def unknown(
|
|
@@ -27,7 +29,7 @@ class CurrentSessionModelAttestation:
|
|
|
27
29
|
provider_id: str,
|
|
28
30
|
source: Literal["unavailable"] = "unavailable",
|
|
29
31
|
) -> CurrentSessionModelAttestation:
|
|
30
|
-
return cls(provider_id, None, None, "unknown", source)
|
|
32
|
+
return cls(provider_id, None, None, "unknown", source, None)
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
def _unavailable_current_model() -> CurrentSessionModelAttestation:
|
|
@@ -71,7 +71,12 @@ def _normalize_numbered_item(prompt: WizardPrompt, answer: str) -> str:
|
|
|
71
71
|
number = int(candidate)
|
|
72
72
|
if 1 <= number <= len(prompt.options):
|
|
73
73
|
return prompt.options[number - 1].value
|
|
74
|
+
# 옵션 값 자체가 숫자인 role-count 는 목록 밖 번호를 값으로도 허용한다
|
|
75
|
+
# (예: 옵션 [3,4,5] 에서 "4" → 수량 4). 목록에 없으면 범위 오류.
|
|
74
76
|
if prompt.step.startswith("role-count:"):
|
|
77
|
+
exact_count = _exact_value_match(prompt, candidate)
|
|
78
|
+
if exact_count is not None:
|
|
79
|
+
return exact_count
|
|
75
80
|
role = prompt.step.split(":", 1)[1]
|
|
76
81
|
minimum = prompt.options[0].value if prompt.options else "?"
|
|
77
82
|
maximum = prompt.options[-1].value if prompt.options else "?"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Execution-surface value objects.
|
|
2
|
+
|
|
3
|
+
The surface is where a worker process starts and where its output lands.
|
|
4
|
+
It is not a host, a provider, or a role.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SURFACE_CMUX_PANE = "cmux-pane"
|
|
13
|
+
SURFACE_CLI_WRAPPER = "cli-wrapper"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SurfaceUnavailable(Exception):
|
|
17
|
+
"""The surface could not be opened. The application may try the next one."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EnvironmentBlocked(Exception):
|
|
21
|
+
"""A sandbox hid the surface and the worker CLI config. Do not degrade."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class WorkerSpawnRequest:
|
|
26
|
+
command: tuple[str, ...]
|
|
27
|
+
cwd: Path
|
|
28
|
+
title: str
|
|
29
|
+
owned_surface_ids: tuple[str, ...] = ()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class RuntimeHandle:
|
|
34
|
+
surface: str
|
|
35
|
+
identifier: str
|
|
36
|
+
waitable: bool
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ProgressEvent:
|
|
41
|
+
message: str
|
|
42
|
+
level: str = "info"
|
|
43
|
+
notify_title: str | None = None
|
|
44
|
+
notify_body: str | None = None
|
|
@@ -11,9 +11,10 @@ from .consumers import (
|
|
|
11
11
|
carry_head_commit,
|
|
12
12
|
read_stage_consumer_state,
|
|
13
13
|
)
|
|
14
|
+
from . import next_phase
|
|
14
15
|
from .paths import RunRef, task_manifest_file
|
|
15
16
|
from .stage_map import StageMapError, load_task_stage_map
|
|
16
|
-
from .workflow import
|
|
17
|
+
from .workflow import PHASE_SEQUENCE
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
@dataclass(frozen=True)
|
|
@@ -22,7 +23,6 @@ class ImplementationOutcome:
|
|
|
22
23
|
stages: list[int] = field(default_factory=list)
|
|
23
24
|
head_commit: str = ""
|
|
24
25
|
report_path: str = ""
|
|
25
|
-
next_recommended_phase: str = ""
|
|
26
26
|
reason: str = ""
|
|
27
27
|
|
|
28
28
|
|
|
@@ -66,7 +66,6 @@ def derive_implementation_outcome(task_root: Path) -> ImplementationOutcome:
|
|
|
66
66
|
stages=sorted(required_stages),
|
|
67
67
|
head_commit=str(latest_done.get("head_commit") or "") or carry_head_commit(latest_carry),
|
|
68
68
|
report_path=_latest_implementation_report(task_root),
|
|
69
|
-
next_recommended_phase=DEFAULT_NEXT_PHASE["implementation"],
|
|
70
69
|
reason="all implementation stages have pass-grade carry and done rows",
|
|
71
70
|
)
|
|
72
71
|
|
|
@@ -174,7 +173,22 @@ def _promote_workflow(workflow: dict[str, Any], outcome: ImplementationOutcome)
|
|
|
174
173
|
workflow["currentPhaseState"] = "completed"
|
|
175
174
|
phase_states["implementation"] = "completed"
|
|
176
175
|
workflow["lastCompletedPhase"] = "implementation"
|
|
177
|
-
|
|
176
|
+
# 재조정기의 사실은 "모든 stage 가 끝났다"는 상태다. 대상 phase 는 마지막
|
|
177
|
+
# stage 검증이 이미 정했으므로 건드리지 않는다 (ADR-0004).
|
|
178
|
+
#
|
|
179
|
+
# 대상 phase 가 비어 있으면 status 도 올리지 않는다. `ready` 는 "지금 이
|
|
180
|
+
# phase 를 시작할 수 있다"는 뜻인데 가리킬 phase 가 없으면 시작할 대상이
|
|
181
|
+
# 없다. 마지막 stage 리포트에 라우팅 대상이 없으면 이 자리에 빈 phase 의
|
|
182
|
+
# pending 포인터가 들어오므로, 그때는 그대로 둔다.
|
|
183
|
+
pointer = next_phase.promote(workflow.get("nextRecommendedPhase"))
|
|
184
|
+
if pointer["phase"]:
|
|
185
|
+
workflow["nextRecommendedPhase"] = next_phase.make(
|
|
186
|
+
phase=pointer["phase"],
|
|
187
|
+
status=next_phase.STATUS_READY,
|
|
188
|
+
rationale=pointer["rationale"],
|
|
189
|
+
)
|
|
190
|
+
else:
|
|
191
|
+
workflow["nextRecommendedPhase"] = pointer
|
|
178
192
|
after = json.dumps(workflow, sort_keys=True, ensure_ascii=False)
|
|
179
193
|
return before != after
|
|
180
194
|
|
|
@@ -195,7 +209,9 @@ def _update_task_catalog(project_root: Path, manifest: dict[str, Any]) -> bool:
|
|
|
195
209
|
entry["currentPhase"] = workflow.get("currentPhase", "")
|
|
196
210
|
entry["currentPhaseState"] = workflow.get("currentPhaseState", "")
|
|
197
211
|
entry["lastCompletedPhase"] = workflow.get("lastCompletedPhase", "")
|
|
198
|
-
entry["nextRecommendedPhase"] =
|
|
212
|
+
entry["nextRecommendedPhase"] = next_phase.promote(
|
|
213
|
+
workflow.get("nextRecommendedPhase")
|
|
214
|
+
)
|
|
199
215
|
entry["latestReportPath"] = manifest.get("latestReportPath", "")
|
|
200
216
|
entry["latestRunStatus"] = manifest.get("latestRunStatus", entry.get("latestRunStatus", ""))
|
|
201
217
|
entry["currentStatus"] = manifest.get("currentStatus", entry.get("currentStatus", ""))
|