feat(explore): plan-reviewen kan besvares over DAGER (U12 + asynkron U13, rad 3)

F4 gjorde "be om svar, BRUKE svarene" naabar, men bare SYNKRONT: terminal_plan_reviewer
blokkerer loekka paa et menneske ved en terminal, saa svaret maa komme mens prosessen lever.
Maalbilde §3s tidsskala er den andre - eksperten svarer dager senere, i en prosess som aldri
saa kjoeringen.

--checkpoint-dir PARKERER reviewen (FileCheckpointStorage + {run_id}-plan-review.json) og
avslutter; --resume <run_id> leser svaret fra --review-inbox i en fersk interpreter. Det
eneste som krysser prosessgrensen er disk.

MAALT FELLE (Verifiseringsloven ansikt 4): list_checkpoints (_checkpoint.py:386-388) svelger
en blokkert deserialisering til en logger.warning og returnerer TOM liste. Uten BEGGE
MagenticPlanReviewRequest/Response i allowed_checkpoint_types feiler en resume som et FRAVAER,
ikke som en feil. _ALLOWED_CHECKPOINT_TYPES har derfor EN kopi, checkpoint_storage er eneste
konstruksjonssted, og en tom listing ved park raiser CheckpointUnreadable i stedet for aa
skrive et spoersmaal ingen kan besvare.

Budsjettet og revisjons-capen spenner over suspensjonen (meter.charge(parked.tokens_spent) +
trace.ledger.extend), ellers faar hver park et helt budsjett paa nytt. Fail-closed paa
ekspertens egen fil: request_id-mismatch, ord utenfor vokabularet og revise uten innhold
refuseres alle ved navn. hitl.pending_plan_reviews er registeret over hvem som venter.

Load-bearing MAALT: 17 tester, TRETTEN mutasjoner alle roede mot HELE suiten, groenn kontroll
1059 passed / 5 skipped, golden demo-transcript.stdout byte-uendret
(ea8c534773acdbe41ae68f2c55724d69aaf8be4f).

EN MUTASJON FALSIFISERTE SUITEN (vakuoes-gate-klassen, tiende gang): detach av
trace.plan_reviews.extend(parked.plan_reviews) lot HELE suiten staa groenn - capen leser
parked.plan_reviews DIREKTE, saa den binder uansett, og de to foerste legene er identiske
under begge implementasjoner. Gaten maatte bli det TREDJE leget, der artefaktet ellers taper
dag 1s revisjon og to ulike planer deler indeks 1. Ny test skrevet mot mutasjonen foerst.

Aerlighets-grenser: hostet flate NEKTER fortsatt (synkron review ville blokkert baade
requesten og event-loekka som svarer /readiness); en park midt i loepet etter en stall har
ingen naabar sti under det skriptede manuset, saa carry-overen som betjener den drives gjennom
en CRAFTED parkert tilstand.

Ordre 20260825T114645Z-6622513622-from-portfolio-optimiser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-08-26 12:26:04 +02:00
commit c08ae91809
7 changed files with 1754 additions and 91 deletions

View file

@ -61,9 +61,16 @@ from portfolio_optimiser.explore import (
ExplorationContract,
ExplorationResult,
ExplorationTrace,
ParkedStateError,
PlanReviewDecision,
PlanReviewParked,
explore,
exploration_notice,
load_exploration_contract,
load_parked,
parked_notice,
parked_payload,
resume_exploration,
terminal_plan_reviewer,
trace_payload,
)
@ -92,7 +99,7 @@ from portfolio_optimiser.provenance import ProvenanceStamp
from portfolio_optimiser.reference_domain import Project, load_reference_projects
from portfolio_optimiser.tracing import TracingConfigError, configure_tracing, tracing_notice
from portfolio_optimiser.validator import Rejection, ValidatedProposal, baseline_from_project
from portfolio_optimiser import okf, outbox
from portfolio_optimiser import hitl, okf, outbox
from portfolio_optimiser.semretrieval import (
SEMANTIC_WEIGHT_DEFAULT,
Embedder,
@ -1647,6 +1654,37 @@ def main(argv: list[str] | None = None) -> int:
"round trip is recorded in {run_id}-exploration.json, feedback verbatim. Input that ends "
"without an answer is an error, NEVER a sign-off",
)
parser.add_argument(
"--checkpoint-dir",
default=None,
metavar="DIR",
help="U12 ASYNCHRONOUS HITL door (REQUIRES --explore, --explore-config with "
"enable_plan_review, --run-id and --outbox-dir; refused together with --plan-review): "
"instead of blocking on a human at this terminal, park the exploration's plan review to "
"disk. The workflow's checkpoints go here and the question goes to "
"{run_id}-plan-review.json in the outbox; an expert answers days later by dropping "
"{run_id}-plan-review-answer.json into a review inbox, and --resume picks it up",
)
parser.add_argument(
"--review-inbox",
default=None,
metavar="DIR",
help="where the expert drops their answer to a parked plan review (READ-only, and never "
"the same folder as --outbox-dir: a run that read its own output as input would be "
"answering itself). Required by --resume",
)
parser.add_argument(
"--resume",
default=None,
metavar="RUN_ID",
help="resume the exploration parked under RUN_ID (REQUIRES --checkpoint-dir and "
"--review-inbox): read the open question from the outbox, the answer from the review "
"inbox, and drive the exploration onward in THIS process. The prompt, the bounds and the "
"knowledge bases are read from the parked state, not from argv — the workflow has to be "
"rebuilt exactly as it was for the checkpoint to be accepted at all. A revision makes the "
"manager replan and park a NEW question; an approval lets the run continue into the "
"pipeline as usual",
)
parser.add_argument(
"--mcp-config",
default=None,
@ -1793,6 +1831,11 @@ def main(argv: list[str] | None = None) -> int:
# this list is silently dropped rather than refused — which is the whole reason the
# list enumerates every distinguishable flag instead of the ones that would misbehave.
"--plan-review": args.plan_review,
# The three U12 flags, listed for exactly that reason: report mode returns before the
# resume dispatch, so an omission here is a silent drop, not a refusal.
"--checkpoint-dir": args.checkpoint_dir is not None,
"--review-inbox": args.review_inbox is not None,
"--resume": args.resume is not None,
}
if any(report_forbidden.values()):
print(
@ -1843,6 +1886,11 @@ def main(argv: list[str] | None = None) -> int:
# --explore" would tell an operator who wrote --portfolio --plan-review to add the one
# flag this mode also refuses.
"--plan-review": args.plan_review,
# And the asynchronous half of the same door, on the same side of the partition and by
# NAME for the same reason.
"--checkpoint-dir": args.checkpoint_dir,
"--review-inbox": args.review_inbox,
"--resume": args.resume,
}
offending = [name for name, value in single_only.items() if value]
if offending:
@ -1952,6 +2000,108 @@ def main(argv: list[str] | None = None) -> int:
file=sys.stderr,
)
return 1
# --- U12, the asynchronous half. Every refusal names its flags, and every one of them fires
# BEFORE the first model call: a resume that is going to be refused must be refused while it
# is still free (the økt-57 hoist), and a park that cannot write its question must not run at
# all — the whole point of the door is that somebody can answer it afterwards.
if args.checkpoint_dir is not None and args.plan_review:
print(
"run refused: --plan-review and --checkpoint-dir are two doors onto one review — the "
"first answers it at this terminal, the second parks it for another process. Refused "
"rather than ranked: silently preferring either would block an operator who asked for "
"the other",
file=sys.stderr,
)
return 1
if args.resume is not None:
if args.explore is not None:
print(
"run refused: --resume and --explore are two sources of one exploration. --resume "
"continues the one recorded in the parked state (its own prompt, bounds and "
"bases); --explore starts a new one. Merging would silently drop a prompt",
file=sys.stderr,
)
return 1
if args.mandate is not None:
print(
"run refused: --resume and --mandate are two sources of one mandate — the resumed "
"exploration SHAPES one (the --explore + --mandate refusal, one time-scale later)",
file=sys.stderr,
)
return 1
if args.run_id is not None:
print(
"run refused: --resume and --run-id are two sources of one run id. --resume names "
"the parked run, and the resumed leg keeps writing under that same id",
file=sys.stderr,
)
return 1
if args.live_dry_run:
print(
"run refused: --resume and --live-dry-run contradict each other (the drill stops "
"before the first model call; resuming an exploration is model calls) — pick one",
file=sys.stderr,
)
return 1
if args.checkpoint_dir is None:
print(
"run refused: --resume requires --checkpoint-dir (the workflow state a resume "
"restores from lives there; without it there is nothing to resume)",
file=sys.stderr,
)
return 1
if args.review_inbox is None:
print(
"run refused: --resume requires --review-inbox (the expert's answer lives there, "
"and a resume with no answer would have to invent one)",
file=sys.stderr,
)
return 1
if not args.outbox_dir:
print(
"run refused: --resume requires --outbox-dir (the open question was written "
"there as {run_id}-plan-review.json, and it is what names the review to answer)",
file=sys.stderr,
)
return 1
if not args.bundle_dir:
print(
"run refused: --resume requires --bundle-dir (the resumed exploration navigates "
"knowledge bases, exactly as the parked one did)",
file=sys.stderr,
)
return 1
# ONE run id across the suspension. --run-id was refused above precisely so this
# assignment is the only source, and the resumed leg keeps writing under the id the parked
# leg used — an artefact set split across two ids would describe two runs that never were.
args.run_id = args.resume
elif args.checkpoint_dir is not None and args.explore is None:
print(
"run refused: --checkpoint-dir requires --explore (to park a plan review) or --resume "
"(to lift one); on its own it names a folder nothing would ever be written to",
file=sys.stderr,
)
return 1
if args.review_inbox is not None and args.resume is None:
print(
"run refused: --review-inbox requires --resume (the answers there are read by a "
"resume and by nothing else, so the folder would be named and never opened)",
file=sys.stderr,
)
return 1
if args.checkpoint_dir is not None and args.explore is not None:
# The HOIST again, and it is the one that matters most here: the question artefact IS the
# asynchronous door. Without somewhere to write it the exploration would spend its whole
# budget and then have no way to say what it stopped to ask — a park indistinguishable
# from a crash, days before anybody noticed.
if not args.outbox_dir or not args.run_id:
print(
"run refused: --checkpoint-dir requires --outbox-dir and --run-id, settled BEFORE "
"the exploration runs: the parked question is written as "
"{run_id}-plan-review.json, and without it the review could never be answered",
file=sys.stderr,
)
return 1
if args.explore_config is not None and args.explore is None:
print(
"run refused: --explore-config requires --explore (the bounds describe an exploration "
@ -2022,7 +2172,19 @@ def main(argv: list[str] | None = None) -> int:
# The two messages share the token ``enable_plan_review`` and must NOT share their
# distinguishing wording: a test asserting on the shared substring passes against a
# surface missing one of the branches entirely (measured in økt 57 on --explore).
if exploration_contract.enable_plan_review and not args.plan_review:
if args.checkpoint_dir is not None and not exploration_contract.enable_plan_review:
print(
"run refused: --checkpoint-dir was given but --explore-config sets "
"enable_plan_review false, so nothing would ever park and the checkpoints would "
"be written and never read (refused, never silently ignored)",
file=sys.stderr,
)
return 1
if (
exploration_contract.enable_plan_review
and not args.plan_review
and args.checkpoint_dir is None
):
# The refusal SURVIVES F4 — a run must never stop at a review nobody can answer — but
# its old wording ("the synchronous door is the library API") stopped being true the
# moment this CLI grew one, so it names the flag instead. A claim a surface makes about
@ -2030,7 +2192,8 @@ def main(argv: list[str] | None = None) -> int:
print(
"run refused: --explore-config sets enable_plan_review but no reviewer was "
"offered, so the run would stop at a review nobody can answer. Add --plan-review "
"to answer it at this terminal, or set enable_plan_review to false",
"to answer it at this terminal, or --checkpoint-dir to park it for an expert to "
"answer later, or set enable_plan_review to false",
file=sys.stderr,
)
return 1
@ -2093,7 +2256,13 @@ def main(argv: list[str] | None = None) -> int:
# door must know about them BEFORE loading the file, or a missing one crashes deep inside
# ``explore()`` instead of being refused here, at the door, by name.
required_scripted_roles: Sequence[str] = _SCRIPTED_ROLES
if args.explore is not None:
if args.explore is not None or args.resume is not None:
# ``--resume`` rebuilds the SAME workflow with the SAME three participants, so it needs
# the same three replies. Measured, not reasoned: without ``--resume`` here the child
# process died on ``KeyError: 'navigator'`` deep inside ``fresh_exploration_workflow``
# — the identical defect MAJOR-2 closed for ``--explore`` in økt 62, reappearing on the
# second surface that builds an exploration. A gate that names one door and not the
# other is the drift this comment exists to stop happening a third time.
required_scripted_roles = _SCRIPTED_ROLES + _EXPLORATION_SCRIPTED_ROLES
try:
replies = _load_scripted_replies(args.scripted_replies, required_scripted_roles)
@ -2120,27 +2289,86 @@ def main(argv: list[str] | None = None) -> int:
# marked hypothesis, an exhausted budget, and (since F4) a plan review the operator left
# unanswered — is the RUN failing, not the caller erring, and leaves as it does for the debate
# today.
if args.explore is not None:
assert (
exploration_contract is not None
) # guarded above: --explore requires --explore-config
# The resume's two loads happen HERE, before the trace block below: they are refusals, and a
# refusal must not first overwrite {run_id}-exploration.json with an empty trace — the record
# of what the PARKED leg did is the only evidence of the run so far. This is also the økt-57
# hoist in its purest form: not answered yet is the NORMAL state of this door, so it has to be
# free. Both errors are ``ValueError``s (``PlanReviewAnswerError``) or ``ExplorationError``
# (``ParkedStateError``), and both are caught by NAME rather than left to escape as tracebacks.
resumed: tuple[Any, PlanReviewDecision] | None = None
if args.resume is not None:
question = hitl.read_plan_review_question(args.outbox_dir, args.resume)
if question is None:
print(
f"run refused: no parked plan review for run {args.resume!r} in "
f"{args.outbox_dir!r} (expected {args.resume}-plan-review.json) — there is "
f"nothing to resume",
file=sys.stderr,
)
return 1
try:
parked_state = load_parked(question)
answer = hitl.load_plan_review_answer(
args.review_inbox, args.resume, request_id=parked_state.request_id
)
except (hitl.PlanReviewAnswerError, ParkedStateError) as exc:
print(f"run refused: {exc}", file=sys.stderr)
return 1
resumed = (
parked_state,
PlanReviewDecision.approve()
if answer.decision == "approve"
else PlanReviewDecision.revise(answer.feedback),
)
if args.explore is not None or resumed is not None:
exploration_trace = ExplorationTrace()
exploration: ExplorationResult | None = None
parked_now: PlanReviewParked | None = None
try:
exploration = asyncio.run(
explore(
args.explore,
contract=exploration_contract,
bundle_dirs=(args.bundle_dir,),
profile=args.profile,
client_factory=scripted_client_factory,
trace=exploration_trace,
# The F4 door. Built here and never inside ``explore()``: the loop owns the
# seam, the CLI owns which reviewer fills it, and a library that reached for
# stdin on its own would answer for a caller that never offered to.
plan_reviewer=terminal_plan_reviewer() if args.plan_review else None,
if resumed is not None:
# The parked state, not argv, is what rebuilds the workflow: the graph has to match
# the checkpoint's signature for the restore to be accepted at all, so an operator
# who had to re-supply the prompt and the bounds could get one wrong and find out
# as a restore failure days later.
parked_state, decision = resumed
exploration = asyncio.run(
resume_exploration(
parked_state,
decision,
checkpoint_dir=args.checkpoint_dir,
profile=args.profile,
client_factory=scripted_client_factory,
trace=exploration_trace,
)
)
)
else:
assert (
exploration_contract is not None
) # guarded above: --explore requires --explore-config
exploration = asyncio.run(
explore(
args.explore,
contract=exploration_contract,
bundle_dirs=(args.bundle_dir,),
profile=args.profile,
client_factory=scripted_client_factory,
trace=exploration_trace,
# The F4 door. Built here and never inside ``explore()``: the loop owns the
# seam, the CLI owns which reviewer fills it, and a library that reached
# for stdin on its own would answer for a caller that never offered to.
plan_reviewer=terminal_plan_reviewer() if args.plan_review else None,
# The U12 door. Mutually exclusive with the one above, refused at the top.
checkpoint_dir=args.checkpoint_dir,
)
)
except PlanReviewParked as parked_exc:
# NOT an error, and not a completed run either — the third channel, for the reason
# ``BudgetExceeded`` has its own: the exploration produced no mandate, so returning one
# would let a caller book "explored" for a loop suspended mid-plan. Caught here rather
# than left to escape, because parking is what the operator ASKED for by giving
# --checkpoint-dir; the artefact is where a machine reads that it happened.
parked_now = parked_exc
finally:
# From a ``finally``, exactly as ``write_parse_failures`` is (Fase 1b, funn 1): the run
# that most needs this evidence is the one a cap cut short, and that run returns
@ -2156,6 +2384,13 @@ def main(argv: list[str] | None = None) -> int:
completed=exploration is not None,
),
)
if parked_now is not None:
outbox.write_plan_review(
args.outbox_dir, args.run_id, payload=parked_payload(parked_now.parked)
)
print(parked_notice(parked_now.parked, run_id=args.run_id))
return 0
assert exploration is not None # the only other way out of the block above is an exception
print(exploration_notice(exploration))
mandate = exploration.mandate