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

@ -165,6 +165,138 @@ def pending(outbox_dir: str, verdict_dir: str) -> list[PendingProposal]:
return sorted(unjudged, key=lambda p: (p.run_id, p.approach_id, p.verdict_id))
# --- U12: the pending PLAN REVIEWS of parked explorations, and the expert's answer ---------------
# The same registry shape as ``pending`` above, one time-scale earlier: there the outbox holds a
# PROPOSAL awaiting a verdict, here it holds a QUESTION awaiting a decision. Both live in this
# MAF-free module because both are read by an operator tool that must not drag the framework in.
#: The closed answer vocabulary, identical to the terminal door's (``explore.terminal_plan_reviewer``).
#: Two words, matched structurally — a file cannot be re-asked, so anything else is a refusal.
_PLAN_REVIEW_ANSWERS = frozenset({"approve", "revise"})
@dataclass(frozen=True)
class PendingPlanReview:
"""One parked exploration still waiting on a human. ``plan`` is carried because a registry that
only counted questions could not be used to answer one."""
run_id: str
request_id: str
index: int
plan: str
is_stalled: bool
@dataclass(frozen=True)
class PlanReviewAnswer:
"""The expert's decision, as read off a file. ``feedback`` is empty exactly when approving —
the same encoding ``explore.PlanReviewDecision`` uses, kept plain so this module stays
MAF-free and the adapter between them lives at ONE call site."""
run_id: str
request_id: str
decision: str
feedback: str
class PlanReviewAnswerError(ValueError):
"""An answer file that cannot be read as a decision.
Fail-closed, and deliberately NOT the tolerant rule the verdict inbox uses. A dropped verdict
that will not parse is one opinion missing from a fold; an unreadable plan-review answer is the
one thing standing between a suspended run and a plan nobody signed. A ``ValueError`` so the
CLI's existing structured-refusal arm surfaces it as ``rc 1`` rather than a traceback."""
def _answer_path(review_dir: str, run_id: str) -> Path:
return Path(review_dir) / f"{run_id}-plan-review-answer.json"
def read_plan_review_question(outbox_dir: str, run_id: str) -> dict[str, Any] | None:
"""The open question of ``run_id``, or ``None`` when there is none. Tolerant: an outbox with no
such file simply has no parked review."""
return _load_json_dict(Path(outbox_dir) / f"{run_id}-plan-review.json")
def load_plan_review_answer(review_dir: str, run_id: str, *, request_id: str) -> PlanReviewAnswer:
"""Read the expert's answer to ONE named review, fail-closed at every step.
``request_id`` is a required argument rather than something read off the file and trusted: two
reviews of one run share a file name, so an answer left over from the previous round would
otherwise be applied to a plan the expert never saw. A mismatch is REFUSED by name it is a
stale answer, not an absent one, and the two need different words.
A missing file raises rather than returning ``None``: "not answered yet" is the normal state of
this door, and the caller asking to resume has already said it believes otherwise."""
path = _answer_path(review_dir, run_id)
data = _load_json_dict(path)
if data is None:
raise PlanReviewAnswerError(
f"no answer for plan review {request_id} of run {run_id!r} in {review_dir!r} "
f"(expected {path.name}): the review is still waiting on a human"
)
found = str(data.get("request_id", ""))
if found != request_id:
raise PlanReviewAnswerError(
f"the answer in {path.name} answers plan review {found!r}, but the open review of run "
f"{run_id!r} is {request_id!r}. Refused: an answer to another question is not an "
f"answer to this one"
)
decision = str(data.get("decision", ""))
if decision not in _PLAN_REVIEW_ANSWERS:
raise PlanReviewAnswerError(
f"{path.name} answers {decision!r}, which is outside the vocabulary "
f"{sorted(_PLAN_REVIEW_ANSWERS)}. Refused, never read as a sign-off"
)
feedback = str(data.get("feedback", ""))
if decision == "revise" and not feedback.strip():
raise PlanReviewAnswerError(
f"{path.name} answers 'revise' with nothing to revise: the manager would be asked to "
f"replan against an empty instruction. Say what to change, or answer 'approve'"
)
return PlanReviewAnswer(
run_id=run_id, request_id=request_id, decision=decision, feedback=feedback
)
def pending_plan_reviews(outbox_dir: str, review_dir: str) -> list[PendingPlanReview]:
"""Every parked plan review whose OWN answer has not landed, sorted by ``run_id``.
Mirrors ``pending``: an outbox artefact joined against an inbox, with the join on the key each
side names. The key here is ``request_id`` an answer to a different review leaves this one
pending rather than quietly clearing it, which is the same fail-closed rule the resume path
applies and for the same reason.
Tolerant on the READ side (an unreadable file in either folder is not a question and not an
answer), fail-closed on the DECIDE side (``load_plan_review_answer``). The registry says who is
waiting; it never decides what they said."""
waiting: list[PendingPlanReview] = []
directory = Path(outbox_dir)
if not directory.is_dir():
return waiting
for file in sorted(directory.glob("*-plan-review.json")):
data = _load_json_dict(file)
if data is None:
continue
run_id = str(data.get("run_id", ""))
request_id = str(data.get("request_id", ""))
if not run_id or not request_id:
continue
answer = _load_json_dict(_answer_path(review_dir, run_id))
if answer is not None and str(answer.get("request_id", "")) == request_id:
continue
waiting.append(
PendingPlanReview(
run_id=run_id,
request_id=request_id,
index=int(data.get("index", 0)),
plan=str(data.get("plan", "")),
is_stalled=bool(data.get("is_stalled", False)),
)
)
return sorted(waiting, key=lambda p: (p.run_id, p.index))
# --- Routing config: self-contained dimension→expert table (fail-fast) ----------------------------
# A minimal MVP stand-in for the S3.5 dimension catalog (kept DISTINCT — see the plan's Non-Goals).
# Field names mirror ``dimension.Dimension`` so the two reconcile cleanly when S3.5 lands. No ``label``