diff --git a/apps/presentation/dashboard/smoke/checkpoint-recovery-browser-smoke.mjs b/apps/presentation/dashboard/smoke/checkpoint-recovery-browser-smoke.mjs new file mode 100644 index 0000000000..c97df97533 --- /dev/null +++ b/apps/presentation/dashboard/smoke/checkpoint-recovery-browser-smoke.mjs @@ -0,0 +1,141 @@ +// Actual packaged checkpoint controls + production owning HTTP/store/controller. +// Workspace navigation is synthetic; target /loopx responses are never fabricated. +import assert from "node:assert/strict"; +import {spawn} from "node:child_process"; +import {once} from "node:events"; +import {createHash} from "node:crypto"; +import {mkdir, readFile, writeFile} from "node:fs/promises"; +import {resolve} from "node:path"; +import {createInterface} from "node:readline"; +import {createServer} from "node:net"; +import {resolveTestPython} from "../../../../scripts/test-python.mjs"; +import {launchBrowser, loadPlaywright, waitForHttp} from "../../../../examples/dashboard-browser-smoke-support.mjs"; + +const at=process.argv.indexOf("--output"); +assert.ok(at>=0 && process.argv[at+1], "Declare a fresh evidence output directory"); +const out=resolve(process.argv[at+1]); +await mkdir(out,{recursive:true}); +const probe=createServer();probe.listen(0,"127.0.0.1");await once(probe,"listening"); +process.env.LOOPX_PERSONAL_WORKSPACE_PORT=String(probe.address().port); +await new Promise((done,reject)=>probe.close(error=>error?reject(error):done())); +process.env.LOOPX_PERSONAL_WORKSPACE_PACKAGED="1"; +const {repoRoot,port,startServer}=await import("../../../../examples/personal-workspace-browser/fixture.mjs"); +const {openWorkspacePage,fetchWorkspaceBackend}=await import("../../../../examples/personal-workspace-browser/scenario-context.mjs"); +const fixture=spawn(resolveTestPython({repoRoot}),["-u","apps/presentation/dashboard/smoke/checkpoint-recovery-http-fixture.py",resolve(out,"backend")], + {cwd:repoRoot,stdio:["pipe","pipe","pipe"],windowsHide:true}); +const exited=once(fixture,"exit"); +const lines=createInterface({input:fixture.stdout});const iterator=lines[Symbol.asyncIterator](); +let diagnostic="";fixture.stderr.on("data",chunk=>{diagnostic+=String(chunk);}); +async function next(){const line=await iterator.next();assert.equal(line.done,false,diagnostic);return JSON.parse(line.value);} +async function command(value){fixture.stdin.write(`${value}\n`);return next();} +async function artifact(path){return {path,sha256:createHash("sha256").update(await readFile(path)).digest("hex")};} +let server,browser,workspace; +const requests=[]; +try { + const {origin,session,original_identity}=await next(); + server=await startServer(); + const url=`http://127.0.0.1:${port}/chat/?statusUrl=/status.json`; + await waitForHttp(url); + browser=await launchBrowser(loadPlaywright().chromium); + workspace=await openWorkspacePage({newPage:options=>browser.newPage({locale:"zh-CN",...options})},url,{ + beforeGoto:async(_api,page)=>{ + page.__loopxRuntime.sessions.set(session.session_id,session); + // Only the unrelated workspace/navigation uses the existing synthetic API. + // Every target operation, including reads across reload, uses its owning server. + await page.route(`**/api/chat/sessions/${session.session_id}/loopx`,async route=>{ + const request=route.request(); + const body=request.method()==="POST"?request.postDataJSON():null; + const response=await fetchWorkspaceBackend(route,origin); + requests.push({method:request.method(),body,status:response.status(),response:await response.json()}); + await route.fulfill({response}); + }); + }, + }); + const {page}=workspace; + await page.locator(".personal-goal-link",{hasText:"Product Release"}).click(); + await page.getByRole("navigation",{name:"Goal 视图"}).getByRole("button",{name:"对话",exact:true}).click(); + const section=page.getByRole("region",{name:"LoopX 运行模式"}); + const retained=page.getByText("LoopX · 结果已保留,待协调员核验",{exact:true}); + await retained.waitFor({timeout:60000}); + const before=await command("inspect"); + assert.deepEqual(before.counts,{result:1,debit:0});assert.equal(before.dispatches.length,0); + await page.reload();await retained.waitFor({timeout:60000}); + const reloaded=await command("inspect"); + assert.deepEqual(reloaded.counts,before.counts);assert.equal(reloaded.dispatches.length,0); + const writes=()=>requests.filter(row=>row.method==="POST" && !["operations","inspect"].includes(row.body?.operation)); + assert.equal(writes().length,0,"Reload must not dispatch or mutate execution"); + const button=page.getByRole("button",{name:"继续核验",exact:true}); + await button.scrollIntoViewIfNeeded(); + const screenshots=[]; + let path=resolve(out,"retained-desktop.png");await page.screenshot({path,animations:"disabled"});screenshots.push(await artifact(path)); + await button.click(); + let after;const deadline=Date.now()+300000; + while(Date.now()setTimeout(done,500)); + } + assert.deepEqual(after.counts,{result:1,debit:1});assert.equal(after.dispatches.length,1); + assert.equal(after.result_sha256,before.result_sha256); + assert.equal(after.upstream_thread_id,before.upstream_thread_id,"Continue retains the original SDK session"); + const posted=writes(); + assert.equal(posted.length,1);assert.equal(posted[0].body.operation,"resume");assert.equal(posted[0].status,200); + await page.reload(); + await page.getByText("LoopX · 已暂停",{exact:true}).waitFor({timeout:60000}); + assert.equal(await retained.count(),0,"Settled native readback clears the pending-result label"); + const replayed=await command("replay"); + assert.deepEqual(replayed.counts,after.counts);assert.equal(replayed.dispatches.length,1); + assert.deepEqual(replayed.replayed_identity,original_identity); + assert.equal(replayed.result_sha256,before.result_sha256); + await page.setViewportSize({width:390,height:844});await page.emulateMedia({reducedMotion:"reduce"}); + await page.getByRole("button",{name:"恢复推进",exact:true}).scrollIntoViewIfNeeded(); + path=resolve(out,"settled-narrow.png");await page.screenshot({path,animations:"disabled"});screenshots.push(await artifact(path)); + const unavailable=await command("unavailable"); + assert.equal(unavailable.snapshot.coordinator_checkpoint.stage,"observation_unavailable"); + assert.equal(unavailable.snapshot.coordinator_checkpoint.settlement_identity,undefined); + await page.reload(); + await page.getByText("LoopX · 核验信息暂不可用",{exact:true}).waitFor({timeout:60000}); + assert.equal(await page.getByText("原执行尚未完成结算",{exact:false}).count(),0); + const reread=page.getByRole("button",{name:"重新读取",exact:true}); + await reread.click(); + assert.equal(writes().length,1,"Unavailable observation reread is not an execution request"); + const held=await command("inspect"); + assert.deepEqual(held.counts,{result:1,debit:1});assert.equal(held.dispatches.length,1); + path=resolve(out,"unavailable-narrow.png");await page.screenshot({path,animations:"disabled"});screenshots.push(await artifact(path)); + await page.setViewportSize({width:1512,height:982}); + path=resolve(out,"unavailable-desktop.png");await page.screenshot({path,animations:"disabled"});screenshots.push(await artifact(path)); + await command("restore_observation"); + await page.reload(); + await page.getByRole("button",{name:"恢复推进",exact:true}).waitFor({timeout:60000}); + assert.equal(writes().length,1); + const saved=await command("save"); + assert.equal(writes().length,1); + assert.equal(workspace.errors.length,0,workspace.errors.join("\n")); + await writeFile(resolve(out,"http-observations.json"),JSON.stringify(requests,null,2)); + const proof={packaged:true,target_http_mocked:false,transport:"deterministic_sdk",paid_calls:0, + non_owning_shell_fixture:"existing personal-workspace navigation/status API; target sessions/loopx uses production owner", + browser_version:browser.version(),clicked_continue_review:true,reloads:4, + unavailable_observation:{read_only_reread:true,restored:true,counts:held.counts}, + original_identity,settled_identity:saved.settled_identity,replayed_identity:replayed.replayed_identity, + result_sha256_before:before.result_sha256,result_sha256_after:after.result_sha256, + business_counts_before:before.counts,business_counts_after:after.counts,business_counts_replayed:replayed.counts, + direction_dispatches:saved.dispatches.length,implementation_dispatches:0, + session_id_before:before.session_id,session_id_after:saved.session_id, + upstream_thread_id_before:before.upstream_thread_id,upstream_thread_id_after:saved.upstream_thread_id, + errors:workspace.errors,screenshots,native_evidence:[]}; + for(const name of ["before.json","first-result.json","inspect.json","replay.json","save.json","unavailable.json","restore_observation.json"]) + proof.native_evidence.push(await artifact(resolve(out,"backend",name))); + proof.native_evidence.push(await artifact(resolve(out,"http-observations.json"))); + await writeFile(resolve(out,"proof.json"),JSON.stringify(proof,null,2)); + console.log("checkpoint-recovery: passed (packaged click/reload, production owning HTTP/controller/native checkpoint, original identity/session/result, one debit and zero replay increments; deterministic SDK)"); +} catch(error){ + await writeFile(resolve(out,"http-observations-failed.json"),JSON.stringify(requests,null,2)); + await writeFile(resolve(out,"failure.txt"),`${String(error)}\n${diagnostic}`); + if(workspace)await writeFile(resolve(out,"failure-body.txt"),await workspace.page.locator("body").innerText()); + throw error; +} finally { + await workspace?.close();await browser?.close();server?.kill("SIGTERM"); + fixture.stdin.end();if(fixture.exitCode===null)await exited;lines.close(); + await writeFile(resolve(out,"backend-stderr.txt"),diagnostic); +} diff --git a/apps/presentation/dashboard/smoke/checkpoint-recovery-http-fixture.py b/apps/presentation/dashboard/smoke/checkpoint-recovery-http-fixture.py new file mode 100644 index 0000000000..d9876aff1b --- /dev/null +++ b/apps/presentation/dashboard/smoke/checkpoint-recovery-http-fixture.py @@ -0,0 +1,352 @@ +"""Isolated production checkpoint owner with a deterministic SDK transport. + +Invoked from the repository root by the packaged browser smoke. No paid model, +real Lark, active operator Goal or user browser profile is used. +""" +# Source-checkout script bootstraps its own imports before loading the owners. +# ruff: noqa: E402 + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import sys +import threading +from types import SimpleNamespace + +ROOT = Path.cwd() +sys.path[:0] = [str(ROOT), str(ROOT / "tests"), str(ROOT / "tests/control_plane")] +import pytest +from loopx.chat_runtime import ChatRuntimeController +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler +from loopx.chat_store import ChatSessionStore +from loopx.control_plane.coordination.local_authority import ( + read_canonical_todos_if_promoted, +) +from tests.control_plane import test_default_checkpoint_continuation as native +from tests import test_goal_mode_mcp_settlement as mcp + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest() + + +def main(): + out = Path(sys.argv[1]).resolve() + out.mkdir(parents=True, exist_ok=False) + isolated = out / "native" + isolated.mkdir() + patches = pytest.MonkeyPatch() + goal_id, agent_id = "product-release", "lead" + for module in (native, mcp): + patches.setattr(module, "GOAL_ID", goal_id) + patches.setattr(module, "AGENT_ID", agent_id) + registry, state, todo = native._fixture("file", isolated, patches) + payload = json.loads(registry.read_text()) + goal = payload["goals"][0] + goal["domain"] = "Checkpoint recovery demonstration" + goal["coordination"]["registered_agents"] = [agent_id, "reviewer", "codex"] + goal["spawn_policy"] = { + "mode": "multi_subagent", + "allowed": True, + "max_children": 2, + "execution_config": ".loopx/config/delegations.json", + } + registry.write_text(json.dumps(payload), encoding="utf-8") + project = state.parent + config = project / ".loopx/config/delegations.json" + config.parent.mkdir(parents=True, exist_ok=True) + config.write_text( + json.dumps( + { + "schema_version": "loopx_local_delegation_v0", + "bindings": [ + { + "id": "review", + "agent_id": "reviewer", + "todo_id": "review-task", + "requesters": [agent_id], + "workspace": str(project), + "host_args": ["--host", "dsh"], + "timeout_seconds": 60, + "output_refs": ["output.json"], + } + ], + } + ), + encoding="utf-8", + ) + control = mcp._control(registry) + assert json.loads(control.should_run())["ok"] is True + first = json.loads( + control.complete_task(todo, agent_id, "Candidate verified.", no_follow_up=True) + ) + assert ( + first.get("stage") == "direction_pending" and first.get("completed") is True + ), first + original = first["settlement_identity"] + runtime_root = isolated / "runtime" + index = runtime_root / "goals" / goal_id / "runs/index.jsonl" + store = ChatSessionStore(runtime_root) + sid = "session-goal-product-release-codex" + store.create_session( + goal_id=goal_id, + agent_id="codex", + channel_id="goal.product-release", + upstream_thread_id="synthetic-original-sdk", + session_id=sid, + adapter_kind="codex_app_server", + upstream_mode="chat", + ) + settings = { + "agent_id": agent_id, + "token_budget": 10000, + "execution_config_ref": ".loopx/config/delegations.json", + "config_digest": hashlib.sha256(config.read_bytes()).hexdigest(), + } + store.update_session( + sid, + loopx_tools=True, + native_goal={"status": "paused", "tokensUsed": 0}, + loopx_mode={"enabled": True, "paused": True, "settings": settings}, + ) + patches.setenv("LOOPX_CHAT_CODEX_HOME", str(isolated / "codex-home")) + controller = ChatRuntimeController( + store=store, codex_bin="missing-codex", registry_path=registry + ) + adapter = SimpleNamespace( + goal_driver=SimpleNamespace(stopped=threading.Event()), + session=SimpleNamespace(), + upstream_thread_id="synthetic-original-sdk", + close_session=lambda: None, + healthcheck=lambda: True, + ) + patches.setattr(controller, "_ensure_adapter_locked", lambda *a, **k: adapter) + controller.adapters[sid] = adapter + dispatches = [] + failures = [] + decisions = [] + + def native_snapshot(): + items = read_canonical_todos_if_promoted( + runtime_root=runtime_root, goal_id=goal_id + )["todos"] + row = next(item for item in items if item["todo_id"] == todo) + facts = { + key: row.get(key) + for key in ( + "todo_id", + "status", + "evidence", + "completed_at", + "completion_turn_key", + ) + } + events = ( + [ + json.loads(line) + for line in index.read_text(encoding="utf-8").splitlines() + ] + if index.exists() + else [] + ) + return { + "facts": facts, + "result_sha256": digest(facts), + "counts": { + "result": int( + row["status"] == "done" and row["evidence"] == "Candidate verified." + ), + "debit": sum( + e.get("classification") == "quota_slot_spent" for e in events + ), + }, + "session_id": store.load_session(sid)["session_id"], + "upstream_thread_id": store.load_session(sid)["upstream_thread_id"], + "native_events": events, + "dispatches": list(dispatches), + "decisions": list(decisions), + } + + def provider(**kwargs): + dispatches.append( + { + "session_id": kwargs["session_id"], + "turn_id": kwargs["turn_id"], + "upstream_thread_id": adapter.upstream_thread_id, + "phase": "checkpoint_direction", + } + ) + lock = None + try: + assert kwargs["session_id"] == sid and kwargs["loopx_execution"] is True + lock = controller.loopx_mode.prepare( + sid, kwargs["turn_id"], adapter, lambda *a: {}, lambda *a: None + ) + read = adapter.session.read_tool_handler( + "loopx_collaboration", + { + "action": "read", + "operation_id": todo, + "brief": {"purpose": "first_delivery"}, + }, + ) + assert read["ok"] and read["settlement_identity"] == original, read + decision = adapter.session.read_tool_handler( + "loopx_collaboration", + { + "action": "resume", + "operation_id": todo, + "brief": { + "purpose": "first_delivery", + "read_context_id": read["read_context_id"], + "vision_unchanged_reason": "The retained candidate and freshly observed remaining obligations preserve the route.", + }, + }, + ) + assert ( + decision["ok"] + and decision["settlement_complete"] + and decision["settlement_identity"] == original + ), decision + decisions.append({"read": read, "settlement": decision}) + store.update_turn( + sid, + kwargs["turn_id"], + status="completed", + response={"text": "Original checkpoint settled."}, + ) + store.release_active_turn( + sid, + kwargs["turn_id"], + last_activity_at="2026-10-19T00:00:00Z", + last_error_code=None, + ) + store.update_session( + sid, loopx_mode={"enabled": True, "paused": True, "settings": settings} + ) + except Exception as exc: + failures.append(repr(exc)) + raise + finally: + if lock: + lock.__exit__(None, None, None) + + patches.setattr(controller, "_run_started_turn", provider) + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.verbose = False + server.registry_path = registry + server.runtime_root = runtime_root + server.chat_store = store + server.runtime_controller = controller + http = [] + send = ChatRequestHandler._send_json + + def observed_send(handler, body, *, status=200): + if handler.path.startswith("/api/chat/sessions/") and handler.path.endswith( + "/loopx" + ): + http.append( + { + "method": handler.command, + "path": handler.path, + "status": status, + "response": body, + } + ) + return send(handler, body, status=status) + + patches.setattr(ChatRequestHandler, "_send_json", observed_send) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + baseline = native_snapshot() + assert baseline["counts"] == {"result": 1, "debit": 0} + (out / "before.json").write_text(json.dumps(baseline, indent=2), encoding="utf-8") + (out / "first-result.json").write_text( + json.dumps(first, indent=2), encoding="utf-8" + ) + print( + json.dumps( + { + "origin": f"http://127.0.0.1:{server.server_port}", + "session": store.load_session(sid), + "original_identity": original, + } + ), + flush=True, + ) + try: + for line in sys.stdin: + command = line.strip() + if command == "inspect": + value = native_snapshot() + value["failures"] = list(failures) + elif command == "replay": + assert len(decisions) == 1 and not failures + from loopx.hosts.chat_coordinator_checkpoint import ( + CoordinatorCheckpoints, + ) + + checkpoint = CoordinatorCheckpoints( + registry, runtime_root, goal_id, agent_id + ) + # Replay the exact successful decision, without another SDK dispatch. + decision = decisions[0] + repeated = checkpoint.resume( + todo, + { + "read_context_id": decision["read"]["read_context_id"], + "vision_unchanged_reason": "The retained candidate and freshly observed remaining obligations preserve the route.", + }, + ) + assert repeated["ok"] and repeated["settlement_identity"] == original, ( + repeated + ) + value = { + **native_snapshot(), + "replayed_identity": repeated["settlement_identity"], + "replay": repeated, + } + elif command == "save": + assert not failures, failures + value = { + **native_snapshot(), + "http": http, + "original_identity": original, + "settled_identity": decisions[0]["settlement"][ + "settlement_identity" + ], + } + elif command in {"unavailable", "restore_observation"}: + damaged = ( + runtime_root + / "goals" + / goal_id + / "checkpoint-contexts" + / ("f" * 64 + ".json") + ) + if command == "unavailable": + damaged.write_text("{", encoding="utf-8") + else: + damaged.unlink() + value = { + "snapshot": controller.loopx_mode.snapshot(sid), + "native": native_snapshot(), + } + else: + raise ValueError("Unknown fixture command") + (out / (command + ".json")).write_text( + json.dumps(value, indent=2), encoding="utf-8" + ) + print(json.dumps(value), flush=True) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + controller.close() + patches.undo() + + +if __name__ == "__main__": + main() diff --git a/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs b/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs index 3a7371fd6d..941505a1d7 100644 --- a/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs @@ -37,8 +37,7 @@ try { await page.addInitScript(() => {if (!localStorage.getItem('loopx-pw-locale')) localStorage.setItem('loopx-pw-locale', 'zh-CN');}); const writes = []; page.on('request', request => {if (request.method() !== 'GET') writes.push(new URL(request.url()).pathname);}); const errors = []; page.on('pageerror', error => errors.push(error.message)); - // This disposable source has manager history, but no original Lark binding. - await page.route('**/api/chat/app-steward', route => route.fulfill({json: {ok: true, binding_id: null, session_id: null}})); + // Keep the real source owner here, including its later refusal/recovery. await page.goto(ready.origin + '/chat/'); await page.getByRole('navigation', {name: '管家视图'}).getByRole('button', {name: /^(Chat|对话)$/}).click(); await page.getByText('复核迁移说明中的示例', {exact: true}).filter({visible: true}).first().waitFor(); diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index d935855914..afbbc3ac82 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -1386,6 +1386,8 @@ export type LoopXModeSnapshot = { settings: Partial & { execution_config?: string }; native: { status: string; tokenBudget?: number; tokensUsed?: number }; registered_agents: string[]; paused: boolean; recovery_required: boolean; + coordinator_checkpoint?: {stage: string; result_committed?: boolean; next_action?: string; + settlement_identity?: {todo_id?: string; turn_instance_id?: string}} | null; members: Array<{id: string; agent_id: string; todo_id: string}>; deliveries: Array<{operation_id: string; agent_id: string; todo_id: string; status: string}>; ingress: Array<{client_ingress_id: string; mode: string; status: string}>; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx index 9d5a11924f..795438db4f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx @@ -1,6 +1,6 @@ import {ContextHelp} from "./context-help"; import {useEffect, useRef, useState} from "react"; -import {Pause, Play, Settings2, Users, X} from "lucide-react"; +import {Pause, Play, RefreshCw, Settings2, Users, X} from "lucide-react"; import {fetchLoopXMode, updateLoopXMode, type LoopXModeSnapshot, type LoopXModeSettings} from "../../data/chat"; import {useWorkspaceI18n} from "./i18n"; import {GoalTeamWork} from "./goal-team-work"; @@ -46,6 +46,7 @@ export function GoalLoopXMode({sessionId, onPrepare, onExecute, onChange}: { return () => {alive = false; window.clearInterval(interval);}; }, [sessionId]); // onChange is the owning component's stable state setter. const active = Boolean(snapshot?.enabled && snapshot.active_turn_id); + const checkpointUnavailable = Boolean(snapshot?.enabled && ["observation_unavailable", "operation_unknown"].includes(snapshot.coordinator_checkpoint?.stage ?? "")); const native = snapshot?.native.status ?? "absent"; const resume = !["absent", "complete"].includes(native); const configured = Boolean(snapshot?.settings.agent_id && snapshot.settings.token_budget @@ -56,6 +57,9 @@ export function GoalLoopXMode({sessionId, onPrepare, onExecute, onChange}: { }; const status = !snapshot?.enabled ? (zh ? "普通对话" : "Conversation") : snapshot.recovery_required ? (zh ? "LoopX · 需要恢复连接" : "LoopX · Reconnect required") + : snapshot.coordinator_checkpoint?.stage === "observation_unavailable" ? (zh ? "LoopX · 核验信息暂不可用" : "LoopX · Review information unavailable") + : snapshot.coordinator_checkpoint?.stage === "operation_unknown" ? (zh ? "LoopX · 原执行状态无法核验" : "LoopX · Original execution unverified") + : snapshot.coordinator_checkpoint?.result_committed ? (active ? (zh ? "LoopX · 正在核验已保留结果" : "LoopX · Reviewing retained result") : (zh ? "LoopX · 结果已保留,待协调员核验" : "LoopX · Result retained, coordinator review pending")) : native === "blocked" ? (zh ? "LoopX · 需要处理阻塞" : "LoopX · Blocked") : active ? (zh ? "LoopX · 正在推进" : "LoopX · Working") : native === "complete" ? (zh ? "LoopX · 本轮已结束" : "LoopX · Run finished") @@ -82,6 +86,13 @@ export function GoalLoopXMode({sessionId, onPrepare, onExecute, onChange}: { } catch (failure) {setError(failure instanceof Error ? failure.message : String(failure));} finally {setBusy(false);} } + async function rereadCheckpoint() { + if (!sessionId) return; + setBusy(true); setError(""); + try {const result = await fetchLoopXMode(sessionId); setSnapshot(result); onChange(result);} + catch (failure) {setError(failure instanceof Error ? failure.message : String(failure));} + finally {setBusy(false);} + } const needsReadback = snapshot?.deliveries.some(row => ["rejected", "unavailable"].includes(row.status)); const pendingMessages = snapshot?.ingress.filter(row => row.status !== "delivered") ?? []; return
@@ -90,14 +101,16 @@ export function GoalLoopXMode({sessionId, onPrepare, onExecute, onChange}: {
{configured && sessionId ? : null} - + }}>{active ? : checkpointUnavailable ? : }{active ? (zh ? "暂停协调员" : "Pause coordinator") : !snapshot?.enabled ? (zh ? "开启 LoopX 模式" : "Enable LoopX") : checkpointUnavailable ? (zh ? "重新读取" : "Reread") : snapshot.coordinator_checkpoint ? (zh ? "继续核验" : "Continue review") : native === "complete" ? (zh ? "开启新一轮" : "Start new run") : (zh ? "恢复推进" : "Continue")}
+ {snapshot?.coordinator_checkpoint ?

{snapshot.coordinator_checkpoint.stage === "observation_unavailable" ? (zh ? "核验信息读取失败;请恢复来源后重新读取。" : "Review information could not be read. Restore its source, then reread.") : snapshot.coordinator_checkpoint.stage === "operation_unknown" ? (zh ? "原执行状态暂不可核验;恢复连接后重读,不能据此重新派发任务。" : "Original execution cannot be verified. Reconnect and reread before dispatching any work.") : (zh ? "原执行尚未完成结算;继续核验后,协调员会读取当前事实并核验下一步。" : "Original execution is awaiting settlement. Continue review lets the coordinator read current facts and review the next step.")}

: null} {pendingMessages.length ?

{zh ? "待处理消息:" : "Pending messages: "}{pendingMessages.map(row => `${row.mode === "loopx_queue" ? "queue" : "inbox"} · ${row.status}`).join(" / ")}

: null} {needsReadback ? : null} {(error || readError) && !panel ?

{error || readError}

: null} diff --git a/benchmark/edgebench/README.md b/benchmark/edgebench/README.md index b094e70260..deb289b8db 100644 --- a/benchmark/edgebench/README.md +++ b/benchmark/edgebench/README.md @@ -793,3 +793,11 @@ the truth of supplied policy metadata. The recorded task/source hashes do not prove that metadata matches the private task configuration; that provenance still needs operator review. This is an S11/E2 terminal-report repair, with no change to native scoring, integrity qualification, or study promotion. + +## Freshness diagnostic evaluation + +The [combined regular-task and goal-change evaluation report](../../docs/research/freshness-regular-and-goal-change-evaluation.md) +records the selected main and repaired freshness snapshots, original quality, +available token/time costs, adoption observations and distinct terminal failures. +It preserves invalid runs and unqualified countability; it is not a leaderboard +submission, a repeated matched study or validation of a later integration head. diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index 423fc6b6f3..1f2834cdb8 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -1423,6 +1423,52 @@ independent admission without weakening task-level validation. See the [acceptance contract](../../reference/goal-acceptance-observations.md#owner-authorized-contract-v0). This narrows a local recovery gap, not the full R1/R2 coordination acceptance. +**R1 first delivery checkpoint.** New ordinary CLI/MCP/managed work admissions +persist their original result basis before execution and enforce freshness at +the existing Todo owner. Default identity acquisition restores the unfinished +Turn; settled work releases a new work identity. Relevant Todo, dependencies, +acceptance and source changes require candidate revalidation, while unrelated +revisions do not. Original successful receipts survive response loss and cold +recovery. Legacy Markdown is read-only and cannot authorize Todo mutations; +File/SQLite and PostgreSQL result transactions retain their typed head/CAS owner. See the +[operating protocol](../../reference/protocols/goal-vision-replan-contract-v0.md#opt-in-first-delivery-freshness). +New supported admissions also persist the default current-Agent checkpoint +continuation contract. Required checkpoints use the actual post-result, complete +relevant frontier read; MCP returns it to the current tool caller, while managed execution +resumes its original Host/session. Noncompletion outcomes retain their original +receipt and use the existing supplement owner. Related direction changes require +only a new direction judgment, preserving successful results and single spend. +Definitively rejected progress writes retain their candidate for original-Turn +revalidation. Ordinary direction preserves independent Agent lanes; original +no-followup still requires the whole Goal. Supplementary direction includes +registered primary Goal documents under the same registry fence. Chat treats +unavailable observations as read failures with read-only recovery, not pending settlement. +The source candidate retains classified direction stops and permits explicit +original-Turn reentry under current launch gates; unknown historical stop +records stay held. Receipt-bound replanning keeps its semantic ACK behind the +fresh direction, and settled selected planning obligations are excluded from later work +inference while ordinary progress retains original-Turn recovery. These are recovery mechanics, not evidence of new model utility. +Ordinary terminal completion uses the existing same-Turn upgrade after checkpoint +and spend. Historical result-only, explicit first-delivery and supplement Turns +retain their persisted contract. Complete changed-frontier recovery, packaged +frontend/Lark journeys, mixed-version rollback and live-model utility remain +unfinished first-stage acceptance. Source-session remains lifecycle-only and +ordinary Python PostgreSQL execution still lacks its service-owned factory. +An independent reviewer needs a separately registered optional contract. These +defaults do not close R1, certify Goal completion, establish distributed atomicity +or demonstrate model-outcome gains. + +Recovery qualification now includes lost or damaged work-read receipts after +original result commit and historical result-only terminal revalidation after a +proved progress writeback, and an unfinished delegated Todo after Turn settlement. +The native index proves the original work row even after a direction supplement. +Sealed lifecycle recovery compares closeout at the canonical store's timestamp +precision, preserving same-second admission/closeout without adopting old terminal work. +Unknown commits and mismatched identities remain +rejected. The packaged Chat recovery smoke uses the existing same-origin relay +and an isolated Host profile; deterministic replay evidence does not qualify +installed Lark, mixed-version rollback or live-model utility. + **R1 transaction checkpoint.** Team-plan admission and whole-batch planning now live in `work_items/team_plan.ts`. Confirmation assigns all admitted lanes in one write with a durable operation receipt; identity is proposal + lane, never Todo text. File/SQLite authority uses the existing CAS and receipt owner; legacy Markdown writes the records and immutable receipt together under its existing fence and lock. Exact replay reads historical results even after a receiver changes, completes or deletes work. A precommit failure creates no lane prefix, and pending canonical display delivery requires recovery before Chat reports verified success. The card names partial assignments and gaps; quota/stop remain advisory and an explicit enforcement claim is rejected. Agent-originated settlement binds the same state basis at its journal's first write and re-reads it at settlement; a plan whose basis is missing or moved, or whose every lane is a gap, is a typed failed receipt that creates no Todo and replays unchanged. This closes the local assignment/retry portion of F4, not R1's collaboration acceptance. Registered receivers are assigned without being impersonated as authors; agent-originated settlement cannot assign another peer without owner confirmation. Assignment does not attest receiver adoption, a lease, execution, dependency consumption or independent acceptance. Do not add a second confirmation to ordinary already-authorized work. Gap resolution requires new explicit intent; replay must not silently extend the confirmed subset. The fingerprint binds current local state and canonical revision, not a full shared Goal-intent transaction. Local registry and active-state guards now remain held through the typed commit: native token claims survive caller exit, changed source bytes reject new assignments, and expired handoffs can only replay an exact committed receipt. Registry contention returns a retryable failure without creating work; canonical display recovery runs after the source guards release. Failed retry-safe Team Plan proposals retain their original review identity after Goal or manager workspace reload; retry revalidates current source facts and recovers existing assignments rather than generating a new proposal. Stale, gated and unsafe failures remain unavailable for this recovery, including in the already-open drawer. Chat recovery resolves the current registered Goal and source before receipt lookup; a removed Goal or unreadable bound source can block recovery. Canonical display recovery follows the current registered source, so this slice does not recover a deleted source binding. R2/R3/R4 still own executor qualification, receiver adoption/result return and shared intent/authorization; the cross-host Turn lease is not a plan barrier. diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md index 4c640f6009..591e62a2e8 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md @@ -928,6 +928,14 @@ lifecycle。 见[验收合同](../../reference/goal-acceptance-observations.md#owner-authorized-contract-v0)。 这修复局部恢复缺口,不代表 R1/R2 协同整体验收完成。 +**R1 首次交付检查点。** 新的普通 CLI/MCP/托管执行在工作前持久化原始结果依据,由现有 Todo owner 在提交时检查新鲜度。默认身份恢复未完成的 Turn;结算后才释放新的工作身份。相关 Todo、依赖、验收和来源变化要求重新验证候选结果,无关修订不会触发。成功回执支持响应丢失和冷恢复。Legacy Markdown 只读,不能授权 Todo 写入;File/SQLite 与 PostgreSQL 结果事务沿用类型化 head/CAS owner。见[操作协议](../../reference/protocols/goal-vision-replan-contract-v0.md#opt-in-first-delivery-freshness)。 + +新的受支持准入还保存当前 Agent 的默认 checkpoint 续跑契约。需要检查点时读取结果提交后的完整相关任务前沿;MCP 返回给当前工具调用者,托管执行恢复原 Host/session。非完成结果保留原回执并使用现有补充 owner。相关方向变化只重做方向判断,保留成功结果和单次扣费。确定被拒的进度写回保留候选,在原 Turn 重验;普通方向保持独立 Agent lane 隔离,原 no-followup 仍要求全 Goal。补充方向在相同 registry fence 下包含注册的主 Goal 文档;Chat 将观察不可用显示为读取失败并提供只读恢复,不称为待结算。普通终态完成在 checkpoint 与扣费后使用同一 Turn 的升级路径。历史 result-only、显式 first-delivery 及 supplement Turn 沿用保存的契约。完整的前沿变化恢复、打包前端/Lark 旅程、混合版本回滚与真实模型效用仍是未完成的一阶段验收。Source-session 仍只支持生命周期;普通 Python PostgreSQL 执行仍缺少 service-owned factory。独立 reviewer 需要单独注册的可选契约。这些默认值不关闭 R1、不认证 Goal 完成,也不证明分布式原子性或模型收益。 + +源码候选保留已分类的方向停止,并在当前派发门约束下支持原 Turn 显式重入;未知历史停止仍被阻断。回执绑定的重规划在新方向返回后才验收语义 ACK,已结算且验收所选规划义务的 Turn 不再与后续工作竞争,普通进度仍保留原 Turn 恢复。这些是恢复机制,不是新增模型效用证据。 + +恢复资格还覆盖原结果提交后工作读取回执丢失或损坏、已有进度写回证明的历史 result-only 终态重验,以及 Turn 结算后尚未完成的委派 Todo。native index 在方向补充之后仍证明原工作记录。sealed 生命周期恢复按 canonical store 的时间精度比较关闭时刻,保留同秒准入/关闭,不采纳旧终态工作。未知提交和身份不匹配继续拒绝。打包 Chat 恢复烟测复用现有同源 relay 和隔离 Host profile;确定性重放证据不认证 installed Lark、混版本回滚或真实模型效用。 + **R1 事务检查点。** 团队计划准入与整批规划现在归 `work_items/team_plan.ts`。确认后,全部已准入 lane 与持久操作回执一次提交;身份由 proposal + lane 决定,不再由 Todo 文本决定。File/SQLite 权威复用现有 CAS 与回执 owner;legacy Markdown 在原有 fence 和锁内同时写入任务和不可变回执。同一操作重试只读历史结果,接收者后来修改、完成或删除任务也不会触发重建。提交前失败不会留下部分 lane;canonical 展示投递仍 pending 时,Chat 必须恢复后才能报告验证成功。卡片列出部分分配及缺口;quota/stop 是参考,显式强制声明会被拒绝。Agent 发起的结算在 journal 首次写入时绑定同一状态基线并在结算时重读;基线缺失或已变动、或全部 lane 均为缺口的计划,记录为类型化的失败回执,不创建 Todo,重放结果不变。 这完成 F4 的本地分配/重试部分,不等于 R1 协同验收。注册接收者可以被分配任务,但不会被冒充为作者;Agent 发起的结算未经业主确认不能给另一 peer 分配任务。分配不证明接收者采纳、lease、执行、依赖消费或独立验收。普通已授权工作不应普遍增加第二次确认。解决缺口需要明确的新意图;重放不能静默扩展原确认子集。fingerprint 绑定当前本地状态与 canonical revision,不是完整共享 Goal 意图事务。本地 registry 与活动状态锁现在持续保护到类型化提交结束:native token claim 在调用进程退出后仍有效;源字节变化拒绝新分配;失效交接只能重放精确匹配的已提交回执。registry 竞争返回可重试失败,不创建任务;canonical 展示恢复在源锁释放后执行。允许安全重试的失败 Team Plan 提案在 Goal 或管家工作区刷新后仍保留原评审身份;重试重新核验当前源事实,并恢复已有分配,不生成新提案。stale、gated 和不允许安全重试的失败仍不提供此恢复入口,已打开的抽屉也遵循同一判定。Chat 恢复在查找回执前解析当前注册 Goal 与源绑定;Goal 被移除或绑定源不可读时可能阻断恢复。canonical 展示恢复使用当前注册源,本次修复不恢复已删除的源绑定。R2/R3/R4 仍负责执行器资格、接收者采纳/结果返回及共享意图/授权;跨主机 Turn lease 不是计划屏障。 diff --git a/docs/assets/goal-direction/checkpoint-recovery-retained-desktop.png b/docs/assets/goal-direction/checkpoint-recovery-retained-desktop.png new file mode 100644 index 0000000000..a3510e2730 Binary files /dev/null and b/docs/assets/goal-direction/checkpoint-recovery-retained-desktop.png differ diff --git a/docs/assets/goal-direction/checkpoint-recovery-settled-narrow.png b/docs/assets/goal-direction/checkpoint-recovery-settled-narrow.png new file mode 100644 index 0000000000..f13ad4f9f4 Binary files /dev/null and b/docs/assets/goal-direction/checkpoint-recovery-settled-narrow.png differ diff --git a/docs/assets/goal-direction/checkpoint-recovery-unavailable-desktop.png b/docs/assets/goal-direction/checkpoint-recovery-unavailable-desktop.png new file mode 100644 index 0000000000..ccdfc9bf8d Binary files /dev/null and b/docs/assets/goal-direction/checkpoint-recovery-unavailable-desktop.png differ diff --git a/docs/assets/goal-direction/checkpoint-recovery-unavailable-narrow.png b/docs/assets/goal-direction/checkpoint-recovery-unavailable-narrow.png new file mode 100644 index 0000000000..6fbcfd0b6a Binary files /dev/null and b/docs/assets/goal-direction/checkpoint-recovery-unavailable-narrow.png differ diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 70dc5954c4..8f38c83de4 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -1352,6 +1352,45 @@ fixture 的 selected Todo 不在 runnable 集合,human gate 没有 selected To 要求、有意义备选项及末尾约束验证选择与后续工作;计入全部批读/恢复成本。新必读契约 合格前,当前完整上下文 preflight 保持生效,不能靠删候选或首屏变小宣称收益。 +#### Original-Turn admission qualification + +Keep the first admitted result basis and its replay/settlement identity in the +caller output. A matched native Windows measurement of main `f5e9be367` and +candidate `9394b2395` used the unchanged File fixture, Python 3.11 and Node 22.22.3: + +| quota JSON scenario | main | candidate | Previous ceiling | Revised ceiling | +| --- | ---: | ---: | ---: | ---: | +| One Todo | 23,291 | 28,001 | 25,000 | 30,000 | +| 36 Todos / 12 runs | 35,862 | 43,202 | 42,000 | 46,000 | +| 18 Todos / three Agents | 25,852 | 27,984 | 25,500 | 29,500 | + +The failed Windows CI host emitted 28,334 characters for the one-Todo candidate; +its main multi-Agent observation was already 25,975. Preserve both failures. +The added information is the original read basis, causal admission/replan +receipt, original-work progress readback and memory-call observation; the replan also carries the current planning +choice. Do not remove these inputs to meet an older presentation ceiling. + +The differential permits only measured carrier growth on a matching first +admission transition. It still rejects repeated growth, mismatched original +identity, other surfaces, action changes and oversized carriers. Selector views +must compare the same explicitly admitted Turn, rather than an unbound planning +choice on one revision with a restored original Turn on the other. Status's +original-work progress remains a readback, never Goal acceptance. + +The unchanged 96-row candidate completed its assertions in a diagnostic run; +CLI calls alone took 250 seconds, exceeding the former 240-second child bound. +A loaded Windows rerun exceeded 300 seconds; retain that timeout as a failure. +The qualification child bound is 420 seconds, with 960 seconds for two children +and checkout/cleanup. These are bounded test-batch limits, not a production +latency qualification or a change to execution quota, product timeouts or the +8,192-byte Turn Envelope diagnostic. Per-Todo growth, fixed replan growth, +Markdown/line ceilings and complete action/semantic checks stay independent. + +原 Turn 验收保留首次读取依据、原身份、重规划来源和记忆调用状态。相同 Windows +负载的新增成本及原失败如表所示;仅按实际内容核算一次迁移,不允许用新标记放宽 +重复增长或动作变化。展示 selector 必须比较同一已准入 Turn;测试批次时限不代表 +产品性能或 Goal 达成,也不改变执行配额、产品超时和生产 Envelope 诊断。 + Complete registry routes exposed an existing brief heartbeat regression on the unchanged 128-character-root fixture. Commit `fb20c153ba2c2a7376efe68091a76ca470effbde` added those routes: its immediate parent @@ -2308,3 +2347,35 @@ ids, reason codes, digests, and public-safe semantic projections. 禁止提交凭证、私有状态、原始 benchmark 材料、模型响应、本地绝对路径或生成日志。 优先保留紧凑 fixture id、reason code、digest 和公开安全语义投影。 + +### Recovery transport budget decision / 恢复运输层预算决策 + +The recovery revision compares the same source-line and AST workload against +target main `d52f2e153a0a5abb2f9c469ad78f0beccfe3aae8`: + +| Existing module | Target lines | Recovery candidate ceiling | +| --- | ---: | ---: | +| `chat_agent.py` | 2005 | 2032 | +| `chat_runtime.py` | 2319 | 2322 | +| `chat_store.py` | 2003 | 2006 | +| `collaboration_mcp.py` | 2058 | 2083 | + +These exact ceilings retain 58 lines of active caller/receipt transport across +four existing surfaces; none is unused scaffolding or duplicated policy. +Splitting a 3-line binding into a new module would increase navigation without +localizing a separate owner. The larger Turn executor instead moves preview, +terminal recovery and work-input IO to their existing owners: its candidate is +1882 lines, below the unchanged 2000-line ceiling. Function statement/decision +limits remain unchanged. The cadence dependency exception covers only the +existing configuration owner's read, not a second policy or authority owner; +its retirement is explicit injection from the CLI/source composition boundary. +Source ratios and a passing ratchet do not establish behavior parity: cold +recovery, validation, Stop, quota, real providers and packaged entrypoints still +need their independent checks. + +这次预算比较使用相同源码行数/AST 口径。四个既有调用界面共增加 58 行有效绑定运输, +精确提高其已有 ceiling;没有删除有用指令,也没有因 3 行绑定另建无独立职责的模块。 +较大的执行器则实际拆回现有 owner,候选 1882 行,仍低于未放宽的 2000 行上限; +函数语句数与决策数限制保持原值。cadence 例外只覆盖读取既有配置 owner,退休方向 +是从 CLI/source 组合根注入已解析配置,不允许新增政策或执行权。预算通过与代码量 +变化不证明行为保持,仍须独立执行冷恢复、验证、停止、额度、真实 provider 与打包入口。 diff --git a/docs/project-agent-todo-contract.md b/docs/project-agent-todo-contract.md index f5a309899c..bfb3863c5f 100644 --- a/docs/project-agent-todo-contract.md +++ b/docs/project-agent-todo-contract.md @@ -645,6 +645,31 @@ relabeled `same_agent_non_delivery` to escape a contradictory internal ordering. This intentionally removes the old task-class-dependent CLI prerequisite while retaining declared validation, claim/lease checks, identity and terminal fences. +For quota-admitted RESULT on qualified File/SQLite Todo authority, completion +validation retains the original work identity, declared check and candidate. +A durably passed check survives a later RESULT IO failure: retry the unchanged +request to recover that outcome. Changed candidate evidence, actor, lease or +declaration cannot reuse it. If execution may have started without a durable +outcome, completion and replacement `delivery_result` reads fail closed until +the original outcome is reconciled; a new read ID cannot erase that uncertainty. +A known failed check remains retryable. Goal acceptance and terminal accounting +remain independent gates; these qualified lanes do not certify an unqualified +caller/provider combination. Legacy terminal closeout reuses the bound committed +RESULT and original validation, requires the same original quota writeback/spend +receipts, and recovers only the verified lease generation. A preflight abort must +not carry its retired lock token into a new final verification. A held original +fence may finish lease retirement after Todo commit; it cannot authorize a new +lease or bypass changed ownership, declarations, candidate evidence or accounting. + +已准入的 File/SQLite RESULT 会保留原工作身份、声明和候选的验收结果。 +验收已持久成功、随后 RESULT 写入失败时,同一请求可恢复原结果。执行可能已开始但 +结果尚未持久化时,完成和替换 `delivery_result` 读取都会拒绝;须先核对原执行结果, +不能用新的 read ID 消除不确定状态。已知失败的检查仍可重试;Goal 验收和 terminal +结算仍是独立门槛,其他 caller/provider 的完整资格须另行证明。 +Legacy 收尾复用原已提交 RESULT 和验收,仍须原写回、扣额回执全部成立。 +预检关闭后,最终 fence 必须保存新锁 token;Todo 提交后丢响应,只能恢复原已验证 +fence 和 lease 世代,不得重领租约、恢复 claim 或绕过当前 ownership/accounting。 + This keeps the active checklist honest without making LoopX a heavyweight project-management state machine. @@ -953,6 +978,13 @@ the same monitor wait. Use `todo supersede` when the current open todo should be retired and replaced: +For work already admitted by a heartbeat, retain its original `--agent-id` and +`--turn-instance-id` when superseding, together with any required execution lease. +The atomic retirement keeps that Turn identity for post-result direction review +and settlement; it does not run or pass the retired deliverable's validation. +Finish the original Turn before selecting the successor. A later caller cannot +claim an unscoped historical retirement or replay it as accepted completion. + ```bash loopx todo supersede \ --goal-id \ diff --git a/docs/reference/local-delegation.md b/docs/reference/local-delegation.md index 3c23ba0746..608ef2b7d3 100644 --- a/docs/reference/local-delegation.md +++ b/docs/reference/local-delegation.md @@ -373,6 +373,15 @@ lost, readback exposes recovery and the team view offers **恢复原执行**. Re `revalidate` or using `resume` reconciles the original committed receipts without repeating Host work or quota spending. +For an already committed original recheck, `resume` and `revalidate` perform +its remaining native result publication before returning. This +also covers a committed recheck whose result projection is still `turn_returned`. +The MCP caller can close after that reply without leaving publication to a child +worker tied to its process lifetime. Current completion validation, grants, +ancestry and stop fences still apply; recovery can fail without publishing. +Execution without a committed original recheck keeps the +existing asynchronous worker path and may still return a pending state. + Current grants, stop state and declared ancestry are checked again before revalidation or committed-result recovery. Restoring the task output alone does not restore unavailable source inputs. @@ -394,6 +403,8 @@ reconciles its durable execution; it does not bypass the host's process policy. 委派校验器同样读取 EOF,不消费对话输入;普通独立调用校验器仍保留原有的标准输入继承行为。 宿主断连时若终止整个进程树(如 Windows MCP Job),重连后可沿原 operation 执行 `resume`,对账持久化执行,不绕过宿主进程策略。 +已提交的原重验结果会在 CLI/MCP 回复前完成剩余原生发布,仍受当前完成验收与权限、来源、stop 门约束;尚未提交的执行继续使用原异步 worker 路径,可能返回 pending。 + ## Source use and recheck `read` qualifies the current use of declared source operations separately from @@ -739,8 +750,15 @@ while it is unproven the stop stays `acknowledged` with `required_lease_release_unproven`, and an authority that cannot be read keeps it open with `lease_obligation_unproven`. If that host cannot be attributed, or its supervisor never finished cleaning up, the stop stays `acknowledged` and a -later `stop` rereads it. On a platform without process groups the launched -host cannot be proven drained at all, so `stop --execute` fails with an +later `stop` rereads it. New Windows managed Host launches assign their control +bridge to a non-breakaway kernel Job Object before sending the Host request. +The transport closes the whole Job on owner loss and reads its active process +count before returning; cold readback uses the original named Job, not a dead +leader PID. Assignment or observation failure supplies no exit proof. TypeScript +still owns the stop/lease decision. Historical Windows launches without that +Job evidence retain their unsupported boundary; they are never upgraded from +an old `finished` PID. On a platform without attributable process groups or +Job containment the launched host cannot be proven drained, so `stop --execute` fails with an actionable error naming that boundary before writing a cancellation intent, acknowledging, signalling a worker, or releasing a lease. Repeating a refused request preserves the operation and any existing stop receipt unchanged. An @@ -794,8 +812,12 @@ authority 路径恢复,stop 不会重建它。回执的 `lease.state` 为 `rel 因此不会产生一份「已结算」却让成员 Todo 被租约阻塞到 TTL 的回执;在释放得到证明前,停止保持 `acknowledged`,原因为 `required_lease_release_unproven`;权威无法读取时同样保持打开,原因为 `lease_obligation_unproven`。host 无法归属或其 supervisor 未完成清理时, -停止保持 `acknowledged`,之后再次调用 `stop` 会重新读取。在没有进程组的平台上, -启动过的 host 根本无法被证明已收尾,因此 `stop --execute` 会以指明该平台边界的 +停止保持 `acknowledged`,之后再次调用 `stop` 会重新读取。新 Windows managed Host +在发送 Host 请求前将控制 bridge 归入禁止 breakaway 的内核 Job Object;owner 消失时 +整组退出,返回前读取活动进程数,冷读回使用原 named Job,而非已退出的 leader PID。 +归组或观测失败不给退出证明,停止与租约判断仍由 TypeScript owner 负责。 +旧 Windows 执行没有 Job 证据时继续保持 unsupported,不能凭历史 `finished` PID 升级。 +没有可归属进程组或 Job containment 的执行无法证明已收尾,因此 `stop --execute` 会以指明该平台边界的 可操作错误在写入停止意图、确认、发送信号或释放租约之前失败,重复拒绝不修改原记录。 Host 记录尚未出现但 worker 仍活跃或无法归属时也拒绝;没有持有者且尚未启动的 operation 仍可安全取消。`unknown` 表示持有者在确认前消失;其 Host 被证明已退出后, @@ -1184,6 +1206,34 @@ paths, credential/endpoint configuration, or provider payloads. ### Inspect accepted evidence and return +When an enabled Goal Chat coordinator has committed a result but its native +direction checkpoint remains pending, the existing mode bar shows the retained +result and **Continue review**. The current SDK conversation reads and judges +fresh native facts; a finished SDK run does not settle the LoopX Todo or Goal. +No owner copies a context token and no additional execution setting is added. +The packaged checkpoint browser smoke drives that rendered control through the +production owning HTTP route, reloads and checks the original result/session and +single native settlement. Only its SDK transport and unrelated workspace shell +are synthetic. This evidence does not establish live model decision quality, +live Lark delivery or native Desktop rendering; see the +[default freshness support and recovery boundary](protocols/goal-vision-replan-contract-v0.md#recovery-checks-and-support-boundary). + +The existing `loopx_collaboration` tool retains its name and action vocabulary. +`read(operation_id=todo_id, brief={purpose:"first_delivery"})` reads the configured +coordinator's original post-result checkpoint. `resume` uses that same Todo and +`brief={purpose:"first_delivery", read_context_id, agent_vision}` or an authored +`vision_unchanged_reason`. The native checkpoint and host Todo transaction owners +reject stale inputs and retain the original result, Turn and single settlement. +This does not grant completion of arbitrary open or foreign tasks. Existing +member operations keep their original route; an existing member operation cannot +be reinterpreted as a coordinator checkpoint. Pause, registration/configuration +revocation, current Chat Turn binding, lease and independent acceptance still apply. + +中文:已保留的协调员结果若还待原生方向核验,运行栏显示“继续核验”。原 SDK +对话读取新事实并判断,用户不用搬 token;SDK 本轮结束不代表 Todo 或 Goal 完成。 +沿用原工具的 read/resume 和原 Todo,通过 `brief.purpose=first_delivery` 明确选择 +协调员 checkpoint,不能借成员操作、旧判断或其他 Agent 的任务扩大权限。 + Enabled MCP exposes `inspect_execution_binding`; newly enrolled Goal Chat tools accept `action=inspect` with `binding_id`. Existing native thread schemas remain unchanged. Owners can use **Team execution** directly below the Goal conversation diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 9daef0adce..66e42e6b8d 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -933,6 +933,27 @@ decision merely because the scheduler woke up. Agents must start from projected boundary and identity flags; reconstructing a generic `semantic_closeout` command discards that continuity contract. +For a Turn admitted with result freshness, this exemption applies only to +requiring a new direction judgment. An in-flight writeback still restores the +original `delivery_result` read token and compares its basis under the same +source locks and supported provider fence as its durable append (or the +legacy source locks for an admitted legacy Turn). A changed basis rejects the +writeback with `checkpoint_read_context_stale` before history or Vision is +committed; no quota is spent. This also covers an optional Vision patch. +Read `checkpoint-context --purpose delivery_result` for the original Turn, +revalidate the retained progress, and retry. The writeback never silently +issues a replacement read. Successful historical replay precedes a later +basis change. `result_work_context` records this work check separately from +the direction checkpoint, so it cannot certify Todo completion or direction +adoption. Historical admissions without result freshness retain their path; +PostgreSQL uses the service-owned fence described below, without adding a credential factory to local callers. + +中途继续只豁免强制重新判断方向,不豁免已启用的工作依据检查。进展写回与可选 +Vision patch 都必须沿用原 Turn 的 `delivery_result` 依据;过期时先拒绝,不写入 +历史或 Vision,也不记账。显式重读并复核已有进展后才能重试。工作检查记录与方向 +判断分开,成功提交的历史重放不因后续需求变化而失效;未启用策略的历史准入保持 +兼容。PostgreSQL 使用下述服务持有的提交保护,本地调用方不增加凭据工厂。 + If the work actually replanned or completed the Todo, explicitly choose `semantic_closeout` and provide its required vision checkpoint. Do not simply remove the replan ACK to make a real replan pass as in-flight work. Conversely, @@ -971,11 +992,478 @@ must satisfy the checkpoint before terminal closeout; it neither re-authors the outcome nor spends a second time. Never invent an unchanged reason to clear a gap. Typed in-flight continuations keep their existing exemption. + + +### Default result freshness and current-Agent checkpoint continuation + +Result freshness now belongs to ordinary new work admissions. Local-registry +CLI `quota should-run`, MCP `should_run` and managed `turn run-once` persist the +original Turn's complete result basis before exposing or dispatching work. +An unbound observation carries no selected work basis. Choosing a Todo through +its returned selection command upgrades that original receipt using the same +typed freshness admission policy as a direct selection. Historical already-bound +receipts retain their original policy; replay cannot silently enroll them. +CLI allocates or restores a Turn when no Turn parameter is supplied; ordinary +`todo complete` restores that Agent/Todo's original identity and read key. +Ambiguous pending Turns fail closed instead of selecting the latest key. +Automatic CLI/MCP identity acquisition serializes the existing selection, +heartbeat append and work-input persistence under one scoped IO fence. Lock +timeout remains a failure; it cannot allocate a second Turn. Explicit Turn +callers retain their existing identity and lease rules; mixed explicit/automatic +calls do not gain a global single-Turn guarantee. MCP completion and vision +recovery restore the admitted identity; the older deterministic host identity +remains a compatibility fallback when there is no default work admission. +Legacy Markdown is read-only and rejects Todo mutations with +`canonical_authority_required`; File/SQLite use their existing +typed Todo head/CAS owner. PostgreSQL result-only reads use the provider head and +identity, with the final check on the native Todo transaction head. Source-session +remains a lifecycle-only profile under its existing runtime admission restriction. +No capability, provider or actor write authority is added. Required checkpoints +continue the current Agent after the durable result; typed exemptions add no +direction call. +For unfinished work already admitted through ordinary MCP `should_run`, use +`wait_task(todo_id, agent_id, reason)` for a quiet typed wait. It restores the +original Turn and checks its current guard, leaves the Todo open, and performs +no direction call, lifecycle completion, writeback or quota spend. It does not +repair a completed result's pending direction; use `review_task_vision` and the +original completion intent for that recovery. Resume unfinished work through +`should_run` when ready and revalidate a retained candidate on changed facts. + +Default mutating entrypoint qualification targets local-registry File and SQLite; +Legacy Markdown coverage is limited to reads and rejected writes. PostgreSQL has native result transaction/head/CAS conformance; +the ordinary Python CLI/MCP/managed runtime does not supply the existing +service-owned PostgreSQL factory. A PostgreSQL selector therefore fails closed +without legacy fallback; this slice does not add that product integration. +The packaged Chat regression exercises the existing **Continue review** control, +production HTTP/controller and native checkpoint owners on a disposable Goal. +It retains the original SDK session, Todo, Turn and committed result, reads fresh +direction facts, settles once, then reloads and replays without another debit. +Its SDK transport is deterministic and unrelated workspace navigation is a +synthetic shell; the checkpoint HTTP responses come from the production owner. +Run the public smoke described below for the exact candidate being delivered. +This does not qualify live model judgments, native Desktop rendering or live Lark +delivery. The four legacy Lark recovery entrypoints and PostgreSQL first-delivery +callers remain unqualified. These are delivery limits, not an option to silently +bypass freshness on a protected admission. +Manual Todo lifecycle operations without any work admission retain their existing +permission and validation contract. They have not captured an Agent work basis +and are not qualified result-freshness execution. Absence of an admission is not +proof of a historical Turn. Once work is protected, manual omission of identity +or read key cannot downgrade its result obligation. + +For ordinary admitted CLI work, neither command needs a first-delivery switch, +Turn parameter or manually copied read key: + +```sh +loopx --format json quota should-run --goal-id example --agent-id agent-a \ + --todo-id todo_page --codex-app +# Work and validate against the returned delivery_result_context. +loopx --format json todo complete --goal-id example --agent-id agent-a \ + --todo-id todo_page --note "Candidate verified against the original work basis." +``` + +Related Todo, transitive dependency, acceptance, source or lease changes reject +the old candidate eligibility before result effects. Unrelated provider revisions +and display-only changes do not require another judgment. Native command and +acceptance validation retain their existing authority and bind the actual candidate +and issued validation receipt to the original read identity. Managed validation +also receives that work basis. A new read cannot relabel cached validation. + +Native Todo display loss does not erase native Todo authority. The Goal's +frontmatter and non-Todo objective/acceptance prose still belong to the Goal +document owner. Losing that whole document blocks full-basis execution; restoring +Todo sections alone does not restore Goal intent. Such projections carry +`goal_source_completeness=todo_projection_only`; the exact older unmarked +recovery codec has the same meaning. Checkpoint admission rejects this state. +Use the existing `loopx-project` recovery route to locate and restore actual +owner source or backup, then retry the original Agent/Turn. Preserve an already +admitted heartbeat receipt; failed basis reads do not dispatch work or spend. +Native Todo read/write and lease operations keep their existing authority. +No automatic reconstruction of missing Goal narrative is claimed. + +Protected reads also bind separately registered `authority_sources` of kind +`goal_doc`, using the existing primary-document declaration. Their current text +is returned as `basis.goal.prose`, with paths and normalized-text digests in +`basis.goal.primary_documents`. The previous state prose remains available as +`basis.goal.state_prose`; it does not replace the primary requirements. All three +participate in Goal freshness, without duplicating primary document bodies. +Rewriting the Agent state file cannot remove that independent requirement basis; +an unavailable primary document blocks the read without reconstructing it from +old plans. With no separate document, the existing state-file basis is unchanged. +Plans and Todos remain derived records. A changed requirement alone is not +projection corruption or permission to restore older requirements. This is not +shell write isolation: hosts needing that guarantee must keep owner documents +outside Agent-writable files **and directories**, while leaving Todo state writable. + +受保护读取同时绑定已登记的独立 `goal_doc` 原始目标文档,内容随 +`basis.goal.prose` 返回,路径与规范化文本摘要放在 `basis.goal.primary_documents`, +原状态正文保留在 `basis.goal.state_prose`,三者均参与新鲜度校验。修改 Agent 状态文件不能 +替换这份独立依据;原始文档不可用时拒绝读取,不从旧计划重建。未登记独立文档时 +保留原状态文件依据。计划和 Todo 是派生记录,需求变化不构成投影损坏或回滚授权。 +这不是 shell 写权限隔离;需要硬保护的 Host 必须同时保护 owner 文件及其父目录, +并让 Todo 状态保持可写。 + +The initial default quota JSON packet grew because it carries the work basis +consumed by the Agent. Matched fixed-fixture measurements against the pre-adoption +checkpoint branch were 18,096 to 22,993 characters (small), 34,553 to 37,912 +(crowded), and 21,082 to 22,704 (multi-Agent). These are output costs, not model +token, latency or outcome measurements. Disabled reward-memory recall keeps its +decision/authority flags and a detail command; duplicate disabled-route diagnostics +remain available through `--include-detail agent-todos`. Enabled, failed and actual +recall contexts remain complete. Only the measured quota JSON regression budgets +changed in that initial adoption; fixture populations remained unchanged. +The later representation repair retains those frozen ceilings: default +`quota should-run`, `turn plan` without transaction detail, `diagnose`, and +non-full `heartbeat-prompt` JSON use compact whitespace. Explicit quota details, +full heartbeat and transaction detail retain pretty JSON. All parsed fields, +authoring constraints, evidence, read identities and routed actions remain +complete; consumers must parse JSON rather than depend on indentation. Markdown +remains readable. Brief and ordinary thin share the current-contract worker; +captured TurnEnvelope execution retains its independent saved-observation rules. +These representation changes do not reduce the work-basis obligation or prove +model cost or utility. + +After a stale result, MCP calls `should_run(revalidate_todo_id=TODO)` and revalidates +the retained candidate before retrying ordinary `complete_task`; keys travel +automatically. CLI reads `checkpoint-context --purpose delivery_result` for the +returned original Turn, revalidates, then retries ordinary completion. Managed +`--resume-turn-key TURN --retry-failed-turn` retains the candidate and original +session, rereads and invokes the current Agent to validate it. The retained +candidate and revalidation instruction survive receipt/journal update and cold +dispatch recovery. Exact successful Todo receipts take precedence over later +basis changes, including loss of the result response before refresh/writeback. +For historical result-only admissions, if a managed terminal Todo is rejected after refresh/spend, +those receipts and the outer transaction prefix remain fixed. The original Turn +records bounded Host/validator revalidation progress; retry resumes that progress +and then only the pending Todo closeout. It cannot rewind or repeat settlement. +Host or validation failure keeps the terminal result pending and preserves the +settled receipts. This does not supply the remaining changed-frontier redirection +or independent Goal acceptance contract. + +New supported admissions also persist `checkpoint_continuation=current_agent_v1` +in the original heartbeat receipt. Ordinary completion commits the result before +reading the complete post-result Goal frontier. The current Agent must author a +new Vision patch or an unchanged reason bound to that read. An unchanged reason +requires an existing Vision baseline. Pre-result judgments are not accepted as +this judgment; a new key cannot relabel a cached response. The existing checkpoint +owner retains cadence, material-trigger, in-flight and blocked-retry exemptions. + +MCP `complete_task` returns `direction_pending`, `completed=true` and +`settlement_complete=false` with the actual context and original Turn identity. +The current tool caller retains its original completion arguments, consumes that response and continues +`complete_task` with its returned `read_context_id` and new decision, without +`first_delivery=true`. `review_task_vision` rereads on stale direction. CLI callers +use the equivalent checkpoint-context/refresh commands below for the original +Turn; the CLI transports facts and cannot manufacture an Agent judgment. + +Managed `turn run-once` automatically dispatches `phase=checkpoint_direction` +through the original Host after committing a completion result, or after retaining +an original noncompletion outcome requiring a supplement. Generic adapters must +consume `checkpoint_context` and echo its read ID with the decision; older adapters +that only implement the work-result response remain pending until adapted. +Codex resumes the exact original native session, including a fresh-per-Turn work +session. Operation transport offers no operation tools during direction. DSH +retains the original lineage/iteration session ID. No independent reviewer is +required or invoked. Host failures use the existing retry policy; stop and +permission failures retain the result without a fabricated judgment. + +Cold recovery uses `turn run-once --resume-turn-key TURN --retry-failed-turn`. +Successful result, checkpoint and quota receipts win over a lost response. A +stale direction invalidates only its judgment; implementation and validation stay +cached. Ordinary no-followup completion preserves its original terminal intent, +commits the result with an intermediate continuation, then uses the existing +same-Turn terminal upgrade after checkpoint and spend. The Todo owner persists +`completion_requested_continuation=no_followup` when deferring a stored closeout; +this intent is not a terminal recovery receipt and applies only to its original +completion identity. An explicit MCP `no_follow_up` request is replayed with its +original completion arguments; the facade does not infer that intent from a new +frontier or a replacement Agent. This does not certify Goal +completion or supply the complete changed-frontier scheduling contract. + +A definitive pre-commit freshness rejection of a progress writeback uses the same +prepared-effect abort and retained-candidate recovery as completion. Cold retry +revalidates that candidate against the new basis on its original Turn; an +unknown effect is not treated as a proved rejection. + +The typed owner selects the complete relevant Agent lane and dependency closure +for an ordinary current-Agent direction checkpoint. Independent peer-only updates +do not invalidate it. Original no-followup intent still requires the complete +Goal frontier, regardless of a caller's narrower scope. Supplementary direction +reads and commits include the same registered primary Goal documents and registry +fence; changing or losing a document requires recovery before a new decision can +commit. Exact committed receipt replay retains its historical result. + +An unavailable observation without an original settlement identity is not a +pending settlement. Packaged Chat shows that read failure and offers a read-only +reread; restoring its source does not itself dispatch or spend. + +Historical receipts without this mode retain their prior contract, including +result-only and already-spent missing-checkpoint supplement recovery. Unknown +modes and downgrade attempts fail closed. The explicit `first_delivery` v2 +protocol remains compatible, including its historical protected-terminal limit. +Rolling back to an older writer while a new-mode Turn is pending is unsupported: +retain the new writer to recover those Turns, or restore a consistent pre-admission +snapshot through the existing owner procedure. Do not delete mode fields or +rewrite receipts to disable protection. Packaged frontend/Lark operation, complete +terminal redirection, mixed-version rollback and live-model utility remain +separate acceptance. These defaults do not complete the maintainer's first stage. + +The stages are independent: prepare and validate the candidate, commit the +allowed result operation, read the resulting state, judge the direction outside +source/provider locks, commit that direction, then finish ordinary settlement. +An independent reviewer +requires its own registered input, result and recovery contract. +Neither Todo completion nor a satisfied direction checkpoint certifies Goal +completion. + +The ordinary quota guard returns the persisted basis before work. To explicitly +reread after a stale rejection, or to enter the separate direction protocol: + +```sh +loopx --format json checkpoint-context --goal-id example --agent-id agent-a \ + --todo-id todo_page --turn-instance-id turn-1 --purpose delivery_result +``` + +Validate against that basis. Explicit protocol callers may pass its `read_context_id` +as `--delivery-read-context`; default callers restore the persisted key. Retain the Goal, Agent, Todo, Turn and original +candidate/validation options. The Todo owner checks historical receipts first, +compares relevant facts against its current authority head, and commits an exact +delta and receipt with revision CAS. Unrelated revisions may retry without +another model decision. A related change requires rechecking the candidate. + +After the allowed result commit, read the direction: + +```sh +loopx --format json checkpoint-context --goal-id example --agent-id agent-a \ + --todo-id todo_page --turn-instance-id turn-1 --purpose first_delivery \ + --decision-scope goal +``` + +Read the returned basis and produce a new Vision or unchanged reason. Submit it +using the existing delivery fields plus `refresh-state --first-delivery +--checkpoint-read-context ID --progress-scope goal`. Both `agent_lane` and `goal` +scopes bind complete membership and dependency closure. Reading more work grants no authority to write another Agent's work. +Final submission protects registry, Goal state, index and the real provider +through append. It rejects `--next-action` and Codex session usage booking as +compound effects; explicit usage observations can accompany the run. Lock order: +index, registry, maintenance, Todo projection, state, then provider. Model calls +and validation commands stay outside this section. + +MCP callers use `complete_task(first_delivery=true)`. The v2 protocol first +returns `result_review_pending` with a result context. Validate, then call again +with `delivery_read_context_id`; it commits the ordinary completion and returns +`direction_pending`. Judge that context and call again with both +`delivery_read_context_id` and `read_context_id`, plus the new Vision/reason. +`review_task_vision(first_delivery=true)` can reread a rejected direction; it +never attaches a new token automatically to an old Vision. Protected +`no_follow_up=true` is rejected before any effects are admitted in that explicit +direction protocol. Its native no-followup rejection applies after historical +receipt recovery. Default result-only protection uses the existing legal terminal +owner and adds no blanket no-followup rejection. Ordinary v0/v1 completion now +checks default result freshness. Historical admissions retain their prior ordering; +new current-Agent admissions use result → direction → refresh/spend → terminal. +An indexed successful direction can settle its original owed quota slot once. +A new terminal upgrade still checks the captured actual Goal source; a typed +Todo-only projection cannot authorize that write. Restore the owner's document +and resume the same Turn, retaining successful result and quota receipts. An +exact already committed terminal receipt is recovered before this new admission +check. This does not certify Goal acceptance or solve changed-frontier continuation. +The remaining changed-frontier stage must provide the legal transition for a changed +frontier after checkpoint or spend; retrying a refused terminal intent is not +that transition. + +Status and quota readbacks expose `first_delivery_progress`: result and +direction commits, quota spend, pending stage and recovery action. Shared and Agent-scoped +status mirror recovery text into the existing next-action field used by the +dashboard and channel projections. These receipt observations are neither new +workflow authority nor a Goal completion signal. + +Malformed or unreadable historical receipts are reported as scoped +`observation_errors`, never as proof that no recovery is pending. If the damaged +receipt cannot be attributed to a Turn, `observation_unavailable` retains the +Goal-scoped warning while leaving unrelated lanes' next actions intact. An +enrolled Turn whose original readback is uncertain stays `operation_unknown`; +recovery writes remain blocked until the original artifacts can be reconciled. +Observation reuses one complete canonical Todo snapshot across the discovered +identities; it does not truncate older pending work. + +Ordinary Todo completion recovers an exact terminal receipt before checking an +unreadable optional enrollment source, without changing its request fingerprint. +Managed recovery carries the original journal's work key after the Todo owner +confirms the same Turn's successful result. A subsequently missing or damaged +result read receipt cannot erase that commit or trigger another validation or +settlement. The optional direction enrollment still requires its own receipt. +For a new completion, the original Turn's verified ordinary writeback can prove +that a damaged shared supplement is optional. Missing or inconsistent history, +an enrolled writeback, or any result enrollment receipt remains +`checkpoint_commit_unknown`; unreadable JSON alone never proves non-enrollment. + +After a lost response, retry the original request. `checkpoint-context` for its +first-delivery identity verifies an indexed direction and artifacts before +returning `committed`. A proved empty append can retry after freshness validation. +JSON/Markdown without a complete consistent index remains +`checkpoint_commit_unknown`: preserve the original identity and artifacts for +operator reconciliation; another Turn cannot bypass that unresolved append. +The files, provider, quota and Git are not one transaction. + +Omitting first-delivery leaves the historical explicit enrollment inactive; it +does not disable default result freshness or a new admission's current-Agent +checkpoint continuation. Historical admissions without these policies retain +their original rules, including supplement recovery. New protected admissions cannot +downgrade through omitted identity/key, false flags or an older Host schema. +There is no per-command switch to disable an admitted result obligation. Before +rolling back a runtime, finish or reconcile pending protected Turns with a +compatible runtime; preserve original receipts and successful artifacts. This +does not add cross-host atomicity, Goal certification or model-quality guarantees. + +An original committed Todo result remains replayable if its disposable work-read +receipt is lost or damaged; the typed completion proof, rather than a new read, +owns that recovery. On a historical result-only Turn, a verified indexed writeback +may precede an unfinished terminal result. Only matching identity, work context +and recovery evidence allow another result read in that lane. A settled Turn with +its original quota debit may +also validate its still-unfinished Todo, using the proved original work writeback +rather than a later direction row. Pending current checkpoint lanes retain exact +writeback replay; missing or conflicting commit proof stays fail-closed. +An unclaimed successor must be claimed before its work admission. + +An unreadable historical read cannot block an independent checkpoint when its +digest-named path is attributable to an exact indexed effect and that effect's +JSON/Markdown artifacts pass the existing receipt-first verification. Inspection +does not rewrite the old read. A work-only proof establishes only the work read, +never a pending direction append. Unknown paths, incomplete indexes, missing or +conflicting artifacts and unindexed direction attempts remain fail-closed. +Status and packaged UI observations use the same native proof to recover the +original binding; a verified direction never erases pending quota accounting. +This includes deletion of both disposable reads. Coordinator checkpoint reads +and recovery follow the registered Goal runtime even when Chat storage is separate. + +损坏的历史读取回执只有在路径能绑定到精确索引效果、且对应 JSON/Markdown +工件通过既有回执优先核验时,才不再阻断独立 checkpoint;检查不会改写旧回执。 +仅有结果依据证明不能证明方向提交。未知路径、不完整索引、缺失或冲突工件, +以及未索引的方向提交仍保持拒绝。 +状态和 packaged UI 读回复用同一原生证明恢复原身份;已核验的方向不会抹掉尚未 +完成的配额结算。 +两份可丢弃读取回执都被删除时也适用。Chat 存储分离时,协调员 checkpoint 的 +读取与恢复仍使用注册 Goal 的运行时。 + +### 中文:本阶段边界与恢复 + +普通新执行默认保存结果依据并在 Todo owner 提交时检查新鲜度。 +CLI/MCP 自动携带原 Turn 和原 key;managed 在派发前持久保存依据。 +相关变化先保留候选、由当前 Agent 根据新依据重新验证,再提交原 Turn。 +重读及派发中途重启保留候选;成功结果的精确回执优先于当前依据变化。 +历史没有保护标记的准入继续使用原规则,不能靠省略参数降级新准入。 +新受支持准入默认采用 `checkpoint_continuation=current_agent_v1`;历史没有该 +模式的准入及显式 first_delivery 协议继续按原契约恢复。 +先读取结果依据并验证候选,由 Todo owner 检查原回执、当前依据及 CAS; +结果提交后再读取方向依据,由调用方提交当前 Vision 或 unchanged reason, +最后完成原 Turn 的 refresh/spend。共享机制不改变各 owner 的失败策略, +也不改变 Post-Writeback optional hook 的 isolate 与 quota 结算定义。 + +相关依据变化时,未提交结果必须重新验证候选;结果已经提交时只重新读取和 +判断方向。原 Turn、已提交结果和回执保持原身份,不能把旧判断贴上新 token。 +索引不完整或关键提交未知时保留原 artifacts,先协调原回执,不能换 Turn 绕过。 +原 Todo 结果已提交时,可丢失的工作读取回执损坏或缺失不应阻止其精确恢复; +该恢复以 typed completion proof 为准。历史 result-only Turn 的已验证索引写回 +可能早于尚未完成的终态结果,仅身份、工作依据及恢复证据全部匹配时允许重读。 +已完成结算且有原扣额回执的 Turn 也可验证尚未完成的 Todo,恢复依据必须是已证明的原工作写回, +不能误用之后的方向记录。尚待结算的当前 checkpoint 路径仍保留精确写回重放, +缺失或冲突的提交证明继续拒绝。 +未认领的后继 Todo 必须先认领,再获得工作 admission。 +明确的 `required=false/satisfied=true/decision=not_required` 沿用既有 checkpoint +豁免;缺少 checkpoint 记录不能被观察器猜成无需判断。 + +旧显式结果加方向协议仍不接纳 protected no-followup;新默认准入由既有 +terminal owner 先保留合法结果和原终结意图,完成当前 Agent 的方向判断与结算后 +沿同 Turn terminal upgrade 关闭。默认 managed 续接原 Host/session,required +checkpoint 会增加一次方向调用;typed 豁免不增加该调用。方向 stale 只重判方向, +不重复实施、验证或扣额。历史 refresh→spend→terminal 顺序保留。 +公开 terminal/frontier 回归覆盖当前方向改变后保留原结果、按新 frontier 续接, +以及显式 gate、方向权限拒绝和原 Turn 恢复;通过这些隔离用例不等于真实模型 +能正确判断任意方向。混版本回退和独立 reviewer 仍须各自的完整契约;没有 +方向专用尝试预算或通用回滚协议。打包 Chat 的“继续核验”烟测运行实际控件、 +HTTP/controller 和原生 checkpoint,使用确定性 SDK,保留会话与原结果,并验证 +单次扣额及重载/重放零增量。真实 Lark、实时模型效果与 token 成本、Native +Desktop 渲染仍未验证,不能从 status/quota 投影存在推断这些旅程已通过, +也不能据此关闭 Goal。 + +进度写回在提交前收到确定的 freshness 拒绝时,与完成写回共用预备效果撤销及 +候选保留路径。冷恢复在原 Turn 上按新依据重验候选;未知效果不能当作已证明 +未提交。普通当前 Agent 方向检查读取完整相关 lane 和依赖闭包,无关 peer 更新 +不使其失效;原 no-followup 意图仍要求全 Goal 前沿,caller 不能缩小该范围。 +补充方向与结果方向共用注册的主 Goal 文档和 registry fence,文档变化或缺失 +先拒绝新提交,已提交的精确回执仍按历史恢复。没有原结算身份的观察不可用不等于 +结算待完成;打包 Chat 如实显示读取失败并提供只读重读,来源恢复不自动派发或扣额。 + + +### Recovery checks and support boundary + +Public regression commands from the selected checkout are: + +```sh +python -m pytest -q tests/control_plane/test_receipt_bound_lifecycle_recovery.py \ + tests/control_plane/test_default_terminal_frontier.py \ + tests/control_plane/test_default_terminal_admission_gates.py \ + tests/test_chat_coordinator_checkpoint.py tests/test_chat_queued_write_recovery.py +node --experimental-sqlite --experimental-strip-types --test \ + tests/control_plane_ts/prior_monitor_closeout.test.ts +python scripts/chat_bundle.py build +python scripts/chat_bundle.py verify --source +node apps/presentation/dashboard/smoke/checkpoint-recovery-browser-smoke.mjs \ + --output /absolute/path/to/fresh-disposable-evidence +``` + +Use the checkout's compatible Python test environment, supported Node and frontend +lockfile dependencies. The browser smoke needs Playwright and a headless Chromium; +`LOOPX_PLAYWRIGHT_PACKAGE` and `LOOPX_CHROME_HEADLESS_SHELL` may select existing +local installations. Its output includes actual HTTP observations, native result +and settlement records, desktop/narrow screenshots and browser errors. A missing, +failed or skipped check is an open evidence gap; a source test run does not qualify +a different installed artifact. + +Receipt-bound recovery preserves the original semantic obligation and cadence +source instead of deriving a replacement from the current schedule. Missing, +changed or foreign source facts fail closed; no implicit historical-source adoption +is granted. A committed monitor poll may close its own Turn without an accountable +work writeback or quota debit. Unbound settlement diagnostics must not report that +exact proved poll as old unpaid work. The execution guard still verifies its +committed metadata against the bound Todo; an arbitrary settled-looking flag is +not admission authority. The File/SQLite public tests retain wrong-identity, +missing-proof and replay negatives. + +Local-registry File/SQLite caller coverage and Legacy Markdown read/rejection coverage are distinct from +PostgreSQL native head/CAS conformance. Service-owned PostgreSQL first-delivery +factory integration remains outside this qualification. Lark contract tests use +synthetic transports; live delivery and the four legacy recovery routes remain +unqualified. Two platform-specific Lark lifecycle scenarios have POSIX-only +coverage and must not be presented as Windows support. These boundaries also apply +to any packaged build, regardless of a successful frontend smoke. + +For rollback, stop new admissions, preserve original receipt/journal/source blobs, +then finish or explicitly reconcile pending protected Turns using a compatible +runtime. Check source, interpreter, original identities and unresolved append state +before switching a task-owned launcher to a retained qualified installation. Never +relabel an earlier receipt as evidence for the new build, reimplement a durable +result or charge a replay. The regression for an unchanged result also preserves +its original completion timestamp. Global installation changes, integration with a +newer main and remote publication are separate operations with their own validation. +This support note does not certify a Goal, live model usefulness or a release. + +Code publication remains a separate operation. Qualify ref CAS against an +integration target that is not checked out; this protocol does not wrap direct +Git writes or promise concurrent working-directory isolation. If publication +responds ambiguously, read the integration status and exact current SHA, verify +that combined candidate, then use the existing `integration-branch sync +--candidate-ref SHA --execute` recovery. This can adopt the already published +candidate without moving the branch again. A clean merge is not task acceptance: +run the task verifier on that combined SHA before confirming its result. + ### Read basis for checkpoint-only recovery Missing-checkpoint supplementation now requires an explicit read receipt. This is a default admission change for both legacy and newly committed Turn writebacks; -normal first writebacks and non-Turn vision authoring retain their existing rules. +non-opted-in first writebacks and non-Turn vision authoring retain their existing rules. From the original working directory and with the original registry/runtime/project/ state-file options, read the basis for the exact settlement: @@ -1025,12 +1513,45 @@ transaction. Model reasoning and projection sync remain outside it. The provider revision is returned for diagnostics, but only relevant component changes or a different store identity invalidate the basis. Old v0 receipts require a new read. +Every newly protected Turn checks its original work basis before a new result +append, including in-flight progress, semantic closeout with pending direction, +and typed blocked results. Direction cadence exemptions do not exempt result +freshness. A new Turn owns a distinct receipt; another Turn's successful replay +cannot satisfy it. Historical committed results remain replayable, and completing +missing direction or workspace settlement does not re-author that result. + +The service-owned PostgreSQL checkpoint path holds the existing shared database +incarnation lock, then a `FOR SHARE` lock on the exact tenant/Goal head. It reads +and compares the original basis and saves external checkpoint artifacts before +releasing either lock. Writers use the same head with `FOR UPDATE`; identity +rotation requires the exclusive incarnation lock. Rejection does not advance +provider revision or append result history. The synchronous save callback must +not await model, network or projection work. Like SQLite, PostgreSQL rollback +cannot undo external files; the existing attempt marker and exact indexed +readback recover a crash or lost response. Provider hooks are supplied only by +the embedding service through the existing authority-provider dependencies, never +by Agent RPC parameters. The ordinary local CLI/MCP runtime still has no database +credential factory and fails closed for an unavailable PostgreSQL binding. + +每个新准入且受保护的 Turn 在追加结果前都校验自己的原始工作依据,包括中途进展、 +方向尚未补齐的语义关闭以及类型化阻塞结果。方向判断的节奏豁免不豁免结果新鲜度; +新 Turn 的独立依据不能由其他 Turn 的成功重放替代。历史成功结果仍可重放,补齐方向 +或工作区结算也不能改写该结果。 + +服务持有的 PostgreSQL 提交路径先取得数据库身份的共享锁,再对精确租户/Goal 的 +head 行取得 `FOR SHARE` 锁;在锁内读取、比较原始依据并保存外部 checkpoint 文件。 +写入方使用同一 head 的 `FOR UPDATE`,身份轮换需要排他身份锁。拒绝不推进 provider +版本,也不追加结果历史。同步保存回调不能等待模型、网络或投影;SQL 回滚不能撤销 +外部文件,崩溃和丢失响应仍由原有尝试标记与精确索引读回恢复。provider 由服务通过 +既有依赖接口注入,Agent RPC 参数不能提供凭据或选择工厂。普通本地 CLI/MCP 尚未 +提供数据库凭据工厂,PostgreSQL 绑定不可用时继续拒绝执行。 + The ordinary local Todo command wrapper already takes the maintenance lock before committing. The provider fence additionally covers transactions through the exported provider boundary that do not take that outer lock; these are -distinct concurrency tests. Provider failures stay closed. This adds no -PostgreSQL or cross-Goal transaction support and does not move checkpoint -authority into the Todo provider. SQLite cannot roll back the external run files. +distinct concurrency tests. Provider failures stay closed. The PostgreSQL fence above does not add a +cross-Goal transaction or move checkpoint authority into the Todo provider. +Neither SQL provider can roll back the external run files. Index lock order is kernel then mutation marker for Python writers; existing quota adapters retain their kernel lock around the native marker owner. Native @@ -1818,3 +2339,118 @@ need no new setting or editor. Read back with `loopx status --goal-id ` and 机器执行的 replan 条件。尚未到期且在到期时仍有效的关联监控继续抑制提前重规划。 这不替代其他 frontier 的语义验收、版本检查或权限。前端与 Lark 继续使用原有 返回结构;可用上面的 status/quota 命令核对。 + +### Direction stops and explicit original-Turn recovery + +A direction Host's `wait`, `user_action_required` or `iteration_failed` retains +the result and records its existing result kind and attempt in `direction_stops`. +Read-only `checkpoint-context` preserves this history and the stopped marker. +Explicit `--resume-turn-key` together with `--retry-failed-turn` may retry the +unfinished direction; it cannot replace the original result, validator, Host +session or settlement identity. Current activation, permission and quota are +checked again at Host launch. Retry counters and Host failure budgets are never +reset. A recorded Host `stop_reason`, malformed/unknown stop history, and an +uncertain commit remain held. An older boolean-only stopped receipt supplies no +stop-classification evidence and is not silently rearmed. + +For a receipt-bound semantic replan, the default post-result checkpoint may +retain the result before the current Agent has authored its direction. Its +pending checkpoint is neither an accepted semantic delta nor permission to +spend. The original obligation is qualified on the fresh supplement; completion +validation still belongs to the later Todo completion. A settled Turn for a selected +planning obligation cannot compete with its later work admission. Ordinary +progress and completed Todos retain their original-Turn recovery paths. + +Checkpoint artifact paths may use an alias of the same physical runtime +directory. New records use physical paths before their record/index projections +are constructed; replay verifies historical aliases without rewriting their +stored paths or fingerprints. The owner resolves the parent directory only, +rejects symbolic-link artifact leaves and external directories, and retains the original +identity, lock witnesses and provider fence. A replaced directory cannot supply +the admitted append merely by containing identical bytes. + +A required direction checkpoint and a selected semantic-replan obligation are +separate conditions. A current evidence-backed Vision can support a freshly +judged unchanged decision; it cannot discharge a pending selected replan without +the existing typed semantic evidence. Once that obligation is accepted and +settled, a later work Turn does not inherit it. Todo validation and whole-Goal +terminal checks still apply independently. + +An explicit retained-candidate reread through MCP restores the admitted original +Turn before running its guard. A missing original admission cannot be repaired +by starting another work identity. + +### 中文:方向停止与原 Turn 显式恢复 + +方向 Host 返回 `wait`、`user_action_required` 或 `iteration_failed` 后,保留成果, +在 `direction_stops` 中记录既有结果种类与尝试次数。只读重读会保留停止标记和历史。 +`--resume-turn-key` 与 `--retry-failed-turn` 同时使用时,只能重试尚未完成的方向; +原成果、validator、Host session 和结算身份保持原绑定。实际派发时重新检查当前 +激活状态、权限和额度,不能重置尝试次数或 Host 失败预算。真实 `stop_reason`、 +格式未知的停止历史与提交结果不确定继续阻断。旧版仅含停止布尔值的回执没有 +停止分类证据,不能被静默重新激活。 + +回执已绑定的语义重规划可以先暂存结果,再由当前 Agent 读取结果后判断方向。 +待补的 checkpoint 不等于语义 delta 已接受,也不允许提前扣额;新补充判断仍须 +验收原义务。新 Todo 的完成验证仍在其原有完成门执行。已结算且已验收所选规划义务的 Turn +不再与后续实际工作竞争;普通进度和已完成 Todo 仍保留原 Turn 恢复路径。 + +checkpoint 工件允许使用同一物理 runtime 目录的别名。新记录在构造 record/index +前生成物理路径;历史重放只验证别名,不改写原路径或摘要。owner 只解析父目录, +继续拒绝工件叶子的符号链接和外部目录,保留原身份、锁见证和 provider fence;目录被 +替换后,即使字节相同,也不能冒充原先准入的追加目标。 + +必需方向 checkpoint 与当前 Turn 选中的语义重规划义务分别判断。当前且关联证据的 +Vision 可以支持新读取后的 unchanged 判断,但不能替代 pending 重规划义务需要的 +typed semantic evidence。该义务验收并结算后,不会被自动施加给后续工作 Turn; +Todo 验证和全 Goal 终态检查仍各自执行。 + +MCP 显式重读保留候选时,先恢复已准入的原 Turn,再执行 guard;缺失的原准入 +不能通过新建另一工作身份补齐。 + +### Original facts, derived progress and recovery owners + +```text +Host return / read basis / original intent / attempt / effect receipts + | + existing typed domain owners + checkpoint Todo completion settlement + | + derived progress observation + / \ + status / Chat current recovery admission + | + Host IO + original Turn settlement + | + subsequent work selection +``` + +Observation is read-only. It reuses one Todo fact projection, one settlement +snapshot and one strictly checked checkpoint index per batch; it neither repairs +read receipts nor authorizes a direction retry. The strict checkpoint parser +still rejects incomplete tails and competing proofs. A disagreement between its +index capture and the settlement capture stays uncertain. Historical scans are +complete, including damaged/deleted read receipts with matching indexed artifacts; +the output remains one pending original Turn or scoped unavailable evidence. + +| Existing contract | Producer and consumer | Retained responsibility | +| --- | --- | --- | +| Host transaction v0/v1 | `host_adapter_settlement.py` → `host_todo_completion.ts` | Ordinary completion and Vision/context supplements remain active callers. | +| Host transaction v2 | Explicit MCP first delivery **and** default `current_agent_checkpoint` → the same typed owner | Wire `first_delivery=true` does not imply an opt-in-only feature. Preserve their different terminal rules and original admission policy. | +| `delivery_result` / `first_delivery` / supplement reads | Checkpoint capture/commit and historical inspectors | Original work evidence, post-result direction evidence and supplemental authoring have separate identities/purposes. | +| Completion identity and requested no-followup | Todo completion → `completion_state.ts` and settlement | Current display state cannot reconstruct or replace the original terminal intent. | +| Direction returns, stops and attempts | Python Host transport → `checkpoint_read_context.ts` → locked Python receipt IO | One typed owner classifies returns and produces the receipt transition. Actual Stop reasons, response bindings and accumulated attempts survive retries. | +| Progress stage and next action | `first_delivery.ts` → status/quota/Chat | Derive from receipts and Todo facts; do not persist another pending-state authority. | + +Decision capture, work read evidence and direction read evidence refer to the +same original work without becoming interchangeable. Reuse the existing capture +lineage; proposals for unified capture or receipt-to-continuation composition +must not be assumed to be shipped APIs. Original-Turn recovery remains separate +from choosing subsequent work. No v0/v1/v2 producer is retired merely because its +historical reader remains available. + +中文:Host 与持久化层提供原身份、读取依据、完成意图、attempt 和实际回执;既有 +typed owner 解释规则,展示层派生进度,恢复入口重新核验当前权限、Stop 和预算后 +执行。批量观察只减少重复读源与跨语言调用,不新增持久化状态或执行授权。保留不同 +purpose、旧准入政策和真实协议消费者;不能把结果已保留、方向已提交、扣额已完成 +或 Goal 已验收互相替代。 diff --git a/docs/research/freshness-regular-and-goal-change-evaluation.md b/docs/research/freshness-regular-and-goal-change-evaluation.md new file mode 100644 index 0000000000..4f1f3dd793 --- /dev/null +++ b/docs/research/freshness-regular-and-goal-change-evaluation.md @@ -0,0 +1,255 @@ +# Freshness 评测报告:常规场景与目标变更场景 + +本报告合并两个场景中已选定的 main 与修复后 freshness 数据,只使用各次运行的原生评分与原始用量账本。它是一次诊断结果汇总,不是正式榜单成绩、重复实验结论或 freshness 单一机制的因果证明。 + +**目标变更场景中,修复后 freshness 更早发现并采用新需求,原始分数较高;常规场景中,main 原始分数较高。两次 freshness 最终都异常退出,因此质量表现与运行可靠性必须分开判断。** + +English summary: This report combines one unchanged-task diagnostic comparison +and one silent goal-change comparison. The repaired freshness snapshot has a +higher original score in the goal-change scenario and a lower original score +in the unchanged-task scenario. Both treatment runs remain `runner_invalid`. +All four runs remain unqualified for countable score claims. Different complete +source snapshots, a historical target baseline, single attempts, incomplete +economic cost and evaluator sensitivity limit attribution. No aggregate win +rate, leaderboard result or general superiority claim is made. + +## 1. 范围、版本与测量口径 + +| 条件 | main | 修复后 freshness | +| --- | --- | --- | +| 产品源码 | `9c9b5f5555beafb7ec7d6984b18e1fcfb63be292` | `a49afcd6c6e881d85592d5542d3fa4873ace8647` | +| 共同 runner 源码 | `c5cd8abb4883f021755e99a2c40a27a7b979b93a` | 相同 | +| 模型 / 推理设置 | `gpt-6.1-sol` / `high` | 相同 | +| 执行路径 | LoopX `heartbeat-resume` | 相同 | +| 单次原生预算上限 | 7,200 秒 | 相同 | +| 自动评测间隔 / 提交冷却 | 1,800 秒 / 216 秒 | 相同 | +| 原生评分 | 五阶段通过率按 2 / 2 / 3 / 2 / 1 加权 | 相同 | +| 工作 / 评分资源上限 | 4 CPU、16 GiB / 4 CPU、8 GiB | 相同 | +| 合成凭据规则提醒 | 相同的已批准占位符提醒 | 相同;扫描策略未放宽 | + +main 也是 LoopX 产品快照,并非裸模型基线。两个产品快照包含多项差异,不能把全部分差归因于 freshness 检查。两小时是这次比较的预算上限,不代表完成了原官方十二小时评测。 + +**分支与受测源码的关系:** 本报告的 freshness 受测文件树为 +`a49afcd6c`。PR #5677 分支随后 rebase 到更新的 main,并修复恢复和验证问题; +报告与入口链接也是评测后增加的文档。本文仍只记录这四次历史运行, +不代表对 rebase 后源码重新运行了评测,也不证明新版本有相同分数或已达到合并条件。 + +每个场景各保留一次 main 与一次修复后 freshness 结果。目标场景的 main +是已存在的参照运行,修复后仅重新运行了 freshness;常规场景则串行运行 +main、freshness 各一次。两场景都不是重复采样;目标场景尤其不构成同步 +重跑的严格配对。本文不展示修复前 freshness 的结果,也不把这四次运行 +汇总为胜率或总体平均提升。 + +### 资格独立于原始分数 + +所有运行均保持 `score_countable=false`、`matched_pair_countable=false`。 +严格运行时隔离,尤其是求解器 shell 与模型提供商凭据的隔离,没有获得 +完整见证;原生反馈传输等资格也未因此补齐。有效的原生评分报告只证明 +某次提交被原评分器评分,不代表求解运行有效、需求全部验收通过或结果 +可进入正式榜单。两次 freshness 均保留 `runner_invalid`,不改写成成功。 + +## 2. 两场景结果总览 + +以下分数都是原生最佳及最新提交的分数;本表四次运行的最佳与最新分数分别相同。 + +| 场景 | 指标 | main | 修复后 freshness | +| --- | --- | ---: | ---: | +| 目标变更 | 原始分数 | 50.1548% | 71.5833% | +| 目标变更 | 总 token | 48,614,137 | 46,728,979 | +| 目标变更 | 未缓存输入 token | 683,402 | 580,372 | +| 目标变更 | 输出 token | 195,055 | 184,063 | +| 目标变更 | 原生执行时间 | 7,058.702 秒 | 6,855.989 秒 | +| 目标变更 | 整次进程墙钟 | 7,267.179 秒 | 6,925.531 秒 | +| 目标变更 | 求解器退出码 | 0 | 2 | +| 常规原题 | 原始分数 | 76.7262% | 73.1548% | +| 常规原题 | 总 token | 67,781,522 | 68,961,858 | +| 常规原题 | 未缓存输入 token | 782,865 | 617,460 | +| 常规原题 | 输出 token | 233,729 | 210,510 | +| 常规原题 | 原生执行时间 | 7,058.972 秒 | 7,058.968 秒 | +| 常规原题 | 整次进程墙钟 | 7,142.429 秒 | 7,135.510 秒 | +| 常规原题 | 求解器退出码 | 0 | 2 | + +目标变更场景的 freshness 原始分数高 **21.4285 个百分点**,总 token 少 +约 **3.88%**。常规场景的 freshness 原始分数低 **3.5714 个百分点**, +总 token 多约 **1.74%**,但未缓存输入少约 **21.13%**、输出少约 +**9.93%**。这些是选定样本的观察差异,不是统计显著性或货币收益。 + +## 3. 目标场景:运行中静默替换完整需求 + +最初任务是离线 OpenAPI 兼容性比较接口与命令行工具;约第 900 秒, +受保护的权威需求文档静默替换为报告基础设施、校验后 hook、pytest +集成、示例生成与格式/覆盖控制五个目标。两边都要自行读取变化并调整工作。 +评分器始终按照最终需求评分;需求变化前的提交也使用同一评分器。 +实际发布延迟分别为 901.005 秒与 901.370 秒。 + +### 3.1 原生质量与阶段进展 + +| 原评分阶段 | main | 修复后 freshness | 权重 | +| --- | ---: | ---: | ---: | +| Hooks | 11/12 | 11/12 | 2 | +| Report writers | 13/14 | 14/14 | 2 | +| Pytest reporting | 3/8 | 3/8 | 3 | +| Examples | 0/11 | 9/9 | 2 | +| Email / body generation | 2/10 | 2/10 | 1 | + +Examples 的分母按原报告保留,不把它当成完全相同的逐检查集合直接相减。 +总体比较沿用原生加权分数。freshness 的主要新增交付是 writers 与 +examples;hooks、pytest 和 generation 仍有缺口。完整通过阶段数分别 +为 0/5、2/5,它们不是总体质量分数,也不能被本地测试通过数替代。 + +| 自动评测捕获时间附近 | main | 修复后 freshness | +| --- | ---: | ---: | +| 30 分钟 | 0.020000 | 0.203333 | +| 60 分钟 | 0.203333 | 0.360476 | +| 90 分钟 | 0.464048 | 0.515833 | +| 原始最佳 / 最新 | 0.501548 | 0.715833 | + +这些是原自动评分样本,没有插值或追加评分。freshness 在共同时间窗口 +已有较高分数,但其 0.715833 只观察到一次,不能宣称稳定重复达到。 +由于提前退出,freshness 没有 120 分钟样本。主动/自动评分数分别为 +main 5/3、freshness 7/3,共 18 份原生评分报告。 + +两边最终归档的完整字节与最后一次主动提交不同;逐文件核验支持主要 +源码、测试、文档及配置相同,freshness 核对范围为 1,320 个文件。 +没有给最终归档追加评分,因此分数仍属于原始提交。 + +### 3.2 从读取变化到实际采用 + +以下时间均从新需求发布开始计算: + +| 行为 | main | 修复后 freshness | +| --- | ---: | ---: | +| 首次完整读取新需求 | 958.439 秒 | 45.559 秒 | +| 首次明确确认需求变化 | 985.457 秒 | 44.588 秒 | +| 首次成功修改新需求源码 | 1,707.271 秒 | 638.543 秒 | +| 后续实际实施模块 | hooks、writers、pytest | hooks、writers、pytest、examples | + +freshness 在 22.288 秒时已从普通交付结果上下文接触新需求标题,随后 +确认变化,再完整读取文档,所以确认略早于完整读取。两侧均未观察到 +`provider_revision_mismatch` 返回,不能把提前感知解释成一次过期依据硬拒绝。 + +旧任务被替换时,freshness 保留了原 Turn 的身份;原轮次的结果、方向 +与额度结算完成后,才进入新的实施任务。随后多个模块产生源码、验证与 +原生评分。这支持本次真实运行走通了原轮次结算和后续采用,不能只凭 +新建任务或写出重规划记录宣称成功。最终评分缺口仍然保留。 + +main 保留已有初始任务实现后推进新需求;freshness 将初始候选保留为 +独立检查点,再恢复原源码推进新需求。初始实现本身不满足新需求,但 +通用探测经验可能复用;不能把变化前全部 token 等同于浪费。 + +### 3.3 终态失败 + +main 共 10 条宿主回执,包含一次模型容量不足和尾端超时,之后仍继续 +推进并最终退出 0。退出 0 不表示全部新需求通过验收。 + +freshness 共 9 条宿主回执,其中三次失败均已调用宿主、退出 1,且未 +标记超时;明确错误是模型容量不足。最后一次发生在自主重规划阶段, +原操作效果无法确定,外部调度 worker 为避免重复执行而保留 +`replan_outcome_unknown`,最终求解器退出 2。此时距原生上限尚余 +344.011 秒,因此这次目标场景的停止原因是容量错误与未知结果恢复边界, +不能归为预算耗尽。两侧都未观察到合成凭据造成的宿主调用前拒绝。 + +## 4. 常规场景:完整原题始终不变 + +完整报告与校验增强任务从首次指令保持到终态;没有初始替代任务、定时 +发布器或中途需求编辑。main 后 freshness 串行各跑一次。两次求解和 +原生评分均已终态,分别产生 10 份与 12 份原生评分报告,共 22 份。 + +### 4.1 原生质量 + +| 原评分阶段 | main | 修复后 freshness | 权重 | +| --- | ---: | ---: | ---: | +| Hooks | 11/12 | 11/12 | 2 | +| Report writers | 12/14 | 13/14 | 2 | +| Pytest reporting | 3/8 | 3/8 | 3 | +| Examples | 9/9 | 9/9 | 2 | +| Email / body generation | 10/10 | 5/10 | 1 | + +freshness 在 writers 多过一项,在最后阶段少过五项。两边最佳与最新 +源树不同,但最新原始评分的生产源码与最终归档逐文件一致;没有把未评分 +的最终代码当成额外提升。main 完整通过 2/5 阶段,freshness 为 1/5; +这些阶段完成比例不是 76.7262% / 73.1548% 的加权分数。 + +终态后的源码与既有静态检查语义比对提示:两边都实现了邮箱域名转小写, +但内部辅助函数命名不同,部分隐藏检查对该名字敏感;五项失分与这个差异 +吻合。因此不能直接把五项失分描述为五项业务功能没做。该解释仍是静态 +推断,缺少本次逐检查错误日志的直接确认;原始分数没有修改,也没有重跑 +评分器。公开报告不包含隐藏检查实现、函数名或原始评分输出。 + +### 4.2 实际推进与未完成结算 + +freshness 在启动后有实际任务完成与后续模块实施,不是仅接通启动; +但这些过程事实不证明质量收益。原题没有变化,本场景也没有提供测量 +“发现需求变化并正确采用”的机会。 + +连续六轮工作后触发周期性重规划。模型发现报告配置从 True 更新到 +False 的问题,形成验证记录和后续方向;发现问题不等于已经修复。 +重规划写回成功,但返回仍要求登记本轮额度消耗并完成结算。约四秒后 +宿主超时,退出码 124;轨迹和持久事件未见对应结算完成。 + +调度器保留了“该重规划已启动”,但自己的记录没有完整原轮次结算身份; +模型后来补齐的身份没有同步回调度器记录。恢复代码只处理带完整身份的 +记录,因此跳过了这次恢复。防重复保护随后阻止用新轮次重做未知操作, +以 `replan_outcome_unknown` 停止,求解器退出 2。源码中的预算收尾 +预留与模型临近截止才写回共同形成时间窗口;实际恢复仍有缺口。 +这不是已经自动恢复成功,也不是一次需求版本过期拒绝。 + +main 末尾也有宿主超时,但后继预算耗尽唤醒未调用宿主,worker 最终 +退出 0。常规场景的 freshness 运行状态保持无效;其原始评分仍可作为 +这次提交的诊断观察,不能因此升级为有效运行。 + +## 5. 完整可得模型用量与成本边界 + +用量以响应 ID 去重,包含规划和后续宿主轮次;不重复计入镜像会话文件。 +各运行增量账本与末次累计账本核对一致。缓存输入是输入的子集,推理 +输出是输出的子集,不能再次相加。 + +| 用量 | 目标 main | 目标 freshness | 常规 main | 常规 freshness | +| --- | ---: | ---: | ---: | ---: | +| 输入 token,含缓存 | 48,419,082 | 46,544,916 | 67,547,793 | 68,751,348 | +| 缓存输入 token | 47,735,680 | 45,964,544 | 66,764,928 | 68,133,888 | +| 未缓存输入 token | 683,402 | 580,372 | 782,865 | 617,460 | +| 输出 token,含推理 | 195,055 | 184,063 | 233,729 | 210,510 | +| 推理输出,已含于输出 | 88,608 | 80,361 | 117,809 | 110,910 | +| 总 token | 48,614,137 | 46,728,979 | 67,781,522 | 68,961,858 | +| 去重响应数 | 272 | 274 | 422 | 378 | +| 原 judge 服务耗时之和 | 1,645.150 秒 | 575.725 秒 | 123.665 秒 | 251.967 秒 | + +目标场景的末次旧式 token 快照比响应账本分别少 355,410、357,766; +常规场景也存在快照口径差异。本报告使用已经核对连续性与累计值的去重 +响应账本,不混用旧快照。 + +judge 耗时与求解及进程运行重叠,不能直接加到墙钟上。进程减原生耗时 +包含准备、归档、排空和清理,不能全部算作控制平面开销。目标 freshness +墙钟少约 4.70%,其中含提前失败,不能全部解释为提速;常规墙钟差约 +6.919 秒,也不足以证明性能提升。 + +**完整经济成本仍未知。** 操作者模型用量和注意力、未报告的失败请求 +费用、judge 模型成本、共享准备和审查 CPU/墙钟、闲置与能耗,以及实际 +计费价格没有完整记录。未知项没有按零处理。大量输入命中缓存,因此 +总 token 增减也不能直接推导到账单增减。 + +## 6. 结论、验证与后续边界 + +- 目标变更场景支持这次样本中更早读取、确认与实际采用新需求,以及较高 + 原始质量分数;它没有证明正式有效成绩或稳定可靠续行。 +- 常规场景中 main 原始分数较高;freshness writers 略多通过、未缓存输入 + 和输出较少,但总 token 略多,且末轮结算失败。命名敏感检查限制了分差 + 的功能解释。 +- 两次 freshness 的停止触发不同:目标场景是模型容量错误,常规场景是 + 宿主超时;两者都暴露未知重规划效果的恢复边界,不应混成同一故障原因。 +- 单次样本、历史参照、不同完整源码快照、串行时序、未资格隔离及未知 + 完整费用,都限制因果和经济结论。不发布跨场景总体胜负或普遍收益。 +- 质量、持续控制、完整结算和运行有效性分别核对;保留失败及未测区别, + 不以进程退出码、任务数量、测试通过数或记录写回替代需求验收。 + +本报告复用终态后的原始评分、源码核验和响应账本分析。目标场景 18 份、 +常规场景 22 份原生评分均保留原值;没有启动新求解、替补运行或额外 judge。 +公开内容仅含经整理的汇总数字、通用机制观察与源码版本,不含原始任务、 +私有运行标识、会话、凭据、隐藏检查或机器路径。私有证据保留用于授权 +审计;本文不是第三方可独立重放的公开实验包。 + +后续修复应让调度器在启动重规划前持久化完整结算身份,并在原身份下 +恢复尚未完成的步骤,同时给临近截止的重规划留下收尾时间。还需分别 +确认评分接口的兼容性、当前默认产品入口的采用和重复运行表现。这些是 +当前证据指出的待验证方向,本次整合没有把它们记为已修复或已通过。 diff --git a/examples/control_plane/cli-output-base-head-differential-smoke.py b/examples/control_plane/cli-output-base-head-differential-smoke.py index 5c8dec4457..3280f707ff 100644 --- a/examples/control_plane/cli-output-base-head-differential-smoke.py +++ b/examples/control_plane/cli-output-base-head-differential-smoke.py @@ -44,7 +44,10 @@ def _run(command: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> errors="replace", stdout=subprocess.PIPE, stderr=subprocess.PIPE, - timeout=240, + # The unchanged 96-row Windows candidate completes all semantic checks + # with 250 seconds inside CLI calls; a loaded Windows run exceeded 300s. + # Bound this qualification batch; do not change any product timeout. + timeout=420, ) if completed.returncode != 0: raise AssertionError( diff --git a/examples/control_plane/cli-output-budget-regression-smoke.py b/examples/control_plane/cli-output-budget-regression-smoke.py index 7c4b41052c..da2e97e0be 100644 --- a/examples/control_plane/cli-output-budget-regression-smoke.py +++ b/examples/control_plane/cli-output-budget-regression-smoke.py @@ -46,9 +46,9 @@ def main() -> int: errors="replace", stdout=subprocess.PIPE, stderr=subprocess.PIPE, - # The child runs two separately bounded 240-second probes, plus + # The child runs two separately bounded 420-second probes, plus # checkout/cleanup. Do not cut off a valid second native Windows probe. - timeout=600, + timeout=960, ) if completed.returncode != 0: raise AssertionError( diff --git a/examples/control_plane/cli-output-probe-runner.py b/examples/control_plane/cli-output-probe-runner.py index a296adde8f..4d6f8dc73d 100644 --- a/examples/control_plane/cli-output-probe-runner.py +++ b/examples/control_plane/cli-output-probe-runner.py @@ -66,6 +66,7 @@ def _receipt_row( "scenario": scenario, "format": output_format, "qualification_policy": qualification_policy, + "native_admission": semantics.native_admission_observation(payload), "output_contract_version": output_contract_version, "chars": measurement["chars"], "utf8_bytes": measurement["utf8_bytes"], @@ -273,6 +274,18 @@ def _variant_rows( probe.SCENARIOS[0], ) import_fixture_authority(registry_path, probe.GOAL_ID) + # These rows compare display selectors, not first admission with later + # original-Turn recovery. Keep that causal phase identical on both arms; + # _default_rows still measures the actual first automatic admission. + turn = ["--turn-instance-id", "cli-output-selector-turn", "--todo-id", "todo_fixture_000"] + guard = probe._surface_commands(project=project, runtime=runtime, + registry_path=registry_path, state_file=state_file, output_format="json")["quota_should_run"] + code, text = probe._invoke_cli([*guard, *turn]) + admitted = json.loads(text) + if (code != 0 or admitted.get("ok") is not True + or (admitted.get("heartbeat_receipt") or {}).get("status") != "committed" + or admitted["heartbeat_receipt"].get("turn_instance_id") != "cli-output-selector-turn"): + raise AssertionError("selector comparison requires its original admitted Turn") rows: list[dict] = [] for output_format in ("json", "markdown"): commands = probe._mode_variant_commands( @@ -290,6 +303,8 @@ def _variant_rows( continue if output_format not in variant.output_formats: continue + if any(command[index:index + 2] == ["quota", "should-run"] for index in range(len(command) - 1)): + command = [*command, *turn] exit_code, text = probe._invoke_cli(command) if exit_code != 0: raise AssertionError(f"{variant_id}/{output_format} failed") diff --git a/examples/control_plane/heartbeat-quota-flow-smoke.py b/examples/control_plane/heartbeat-quota-flow-smoke.py index 06a852430f..4f74c83c55 100644 --- a/examples/control_plane/heartbeat-quota-flow-smoke.py +++ b/examples/control_plane/heartbeat-quota-flow-smoke.py @@ -7,14 +7,13 @@ import shlex import subprocess import sys -import tempfile from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO_ROOT)) -from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 +from loopx.control_plane.testing.canary_harness import import_fixture_authority, temporary_canary_directory # noqa: E402 GOAL_ID = "heartbeat-flow-main-control" LONG_NEXT_ACTION_TAIL = "without truncating the final private-safe boundary sentence." @@ -642,7 +641,7 @@ def fake_codex_executor_decision(guard: dict) -> dict: def main() -> int: - with tempfile.TemporaryDirectory(prefix="loopx-heartbeat-quota-flow-") as tmp: + with temporary_canary_directory(prefix="loopx-heartbeat-quota-flow-") as tmp: root = Path(tmp) project, runtime, registry_path = write_fixture(root) registry_before = registry_path.read_text(encoding="utf-8") @@ -749,7 +748,7 @@ def main() -> int: assert "protocol_action_packet" not in follow_up, follow_up assert registry_path.read_text(encoding="utf-8") == registry_before - with tempfile.TemporaryDirectory(prefix="loopx-heartbeat-operator-gate-") as tmp: + with temporary_canary_directory(prefix="loopx-heartbeat-operator-gate-") as tmp: root = Path(tmp) project, runtime, registry_path = write_operator_gate_fixture(root) gate_guard = run_cli( @@ -774,7 +773,7 @@ def main() -> int: assert interaction["cli_channel"]["spend_after_validation"] is False, interaction assert interaction["fallback_policy"]["do_not_cancel_on_block"] is True, interaction - with tempfile.TemporaryDirectory(prefix="loopx-heartbeat-monitor-poll-") as tmp: + with temporary_canary_directory(prefix="loopx-heartbeat-monitor-poll-") as tmp: root = Path(tmp) project, runtime, registry_path = write_monitor_fixture(root) first_guard = run_cli( @@ -974,7 +973,7 @@ def main() -> int: heartbeat_turn_two ) - with tempfile.TemporaryDirectory(prefix="loopx-heartbeat-monitor-replan-surface-only-") as tmp: + with temporary_canary_directory(prefix="loopx-heartbeat-monitor-replan-surface-only-") as tmp: root = Path(tmp) project, runtime, registry_path = write_monitor_fixture(root) first_guard = run_cli( @@ -1031,7 +1030,7 @@ def main() -> int: assert spend["ok"] is False, spend assert count_spend_events(runtime) == 0, spend - with tempfile.TemporaryDirectory(prefix="loopx-external-evidence-observation-") as tmp: + with temporary_canary_directory(prefix="loopx-external-evidence-observation-") as tmp: root = Path(tmp) project, runtime, registry_path = write_external_evidence_fixture(root) guard = run_cli( @@ -1109,7 +1108,7 @@ def main() -> int: assert count_spend_events(runtime) == 0, poll assert count_events(runtime, "quota_monitor_poll") == 1, poll - with tempfile.TemporaryDirectory(prefix="loopx-external-monitor-advancement-") as tmp: + with temporary_canary_directory(prefix="loopx-external-monitor-advancement-") as tmp: root = Path(tmp) project, runtime, registry_path = write_external_monitor_advancement_fixture(root) guard = run_cli( @@ -1156,7 +1155,7 @@ def main() -> int: assert count_spend_events(runtime) == 0, poll assert count_events(runtime, "quota_monitor_poll") == 1, poll - with tempfile.TemporaryDirectory(prefix="loopx-scoped-future-external-monitor-") as tmp: + with temporary_canary_directory(prefix="loopx-scoped-future-external-monitor-") as tmp: root = Path(tmp) project, runtime, registry_path = write_scoped_future_external_monitor_fixture(root) guard = run_cli( @@ -1187,7 +1186,7 @@ def main() -> int: assert guard.get("replan_action_packet") is None, guard assert guard.get("autonomous_replan_obligation") is None, guard - with tempfile.TemporaryDirectory(prefix="loopx-external-evidence-projection-") as tmp: + with temporary_canary_directory(prefix="loopx-external-evidence-projection-") as tmp: root = Path(tmp) project, runtime, registry_path = write_external_evidence_fixture( root, diff --git a/examples/control_plane/hot-path-interface-budget-smoke.py b/examples/control_plane/hot-path-interface-budget-smoke.py index 94043b3285..f74dbe3629 100644 --- a/examples/control_plane/hot-path-interface-budget-smoke.py +++ b/examples/control_plane/hot-path-interface-budget-smoke.py @@ -10,7 +10,6 @@ import json import sys -import tempfile from pathlib import Path from typing import Any from unittest.mock import patch @@ -37,7 +36,9 @@ from loopx.control_plane.scheduler.execution_context import ( # noqa: E402 scheduler_execution_context_for_runtime_profile, ) -from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 +from loopx.control_plane.testing.canary_harness import ( # noqa: E402 + import_fixture_authority, temporary_canary_directory, +) GOAL_ID = "interface-budget-goal" @@ -246,7 +247,8 @@ def install_ready_presentation_surface(root: Path) -> None: [runtime] protocol = "budget_presentation_extension_v0" -entrypoint = {json.dumps(str(provider))} +entrypoint = {json.dumps(sys.executable)} +args = [{json.dumps(str(provider))}] doctor_args = ["--doctor"] required_permissions = [] timeout_seconds = 5 @@ -397,7 +399,7 @@ def assert_cadence_projection( def main() -> int: # Prompt rendering inspects implicit routes even though status reads the # declared fixture registry. Keep both default-route probes in this fixture. - with tempfile.TemporaryDirectory(prefix="loopx-hot-path-budget-") as tmp, patch.multiple( + with temporary_canary_directory(prefix="loopx-hot-path-budget-") as tmp, patch.multiple( "loopx.paths", DEFAULT_RUNTIME_ROOT=Path(tmp) / "default-runtime", LEGACY_RUNTIME_ROOT=Path(tmp) / "legacy-runtime", diff --git a/examples/control_plane/peer-agent-continuation-state-machine-smoke.py b/examples/control_plane/peer-agent-continuation-state-machine-smoke.py index c347d273c3..652d5ce309 100644 --- a/examples/control_plane/peer-agent-continuation-state-machine-smoke.py +++ b/examples/control_plane/peer-agent-continuation-state-machine-smoke.py @@ -4,7 +4,7 @@ from __future__ import annotations import sys -import tempfile +import json from pathlib import Path @@ -15,6 +15,7 @@ from loopx.control_plane.testing.canary_harness import ( # noqa: E402 import_fixture_authority, run_json_cli, + temporary_canary_directory, write_fixture_registry, ) @@ -101,7 +102,7 @@ def should_run(project: Path, runtime: Path, registry_path: Path, agent_id: str) def assert_same_peer_continuation() -> None: - with tempfile.TemporaryDirectory(prefix="loopx-peer-continuation-") as tmp: + with temporary_canary_directory(prefix="loopx-peer-continuation-") as tmp: project, runtime, registry_path = write_fixture(Path(tmp)) first = should_run(project, runtime, registry_path, PEER_ALPHA) assert first["decision"] == "run", first @@ -141,12 +142,42 @@ def assert_same_peer_continuation() -> None: assert completed["completion_policy"]["self_merged"] is True, completed successor = completed["next_todos"][0] assert successor["claimed_by"] == PEER_ALPHA, successor + # Native completion preserves the result but does not settle the + # admitted Turn. Finish its real writeback before selecting a successor. + turn = first['heartbeat_receipt']['turn_instance_id'] + from loopx.control_plane.goals.checkpoint_context_io import read_checkpoint_context + context = read_checkpoint_context(registry_path=registry_path, + runtime_root_override=str(runtime), goal_id=GOAL_ID, agent_id=PEER_ALPHA, + todo_id=START_TODO_ID, turn_instance_id=turn, purpose='first_delivery') + assert context['basis']['todo']['status'] == 'done' + vision = project / 'peer-direction.json' + vision.write_text(json.dumps({ + 'schema_version': 'goal_vision_replan_contract_v0', 'state': 'vision_closed', + 'vision_patch': {'vision_summary': 'Continue the peer lane after the verified first slice.', + 'acceptance_summary': 'The first slice is verified; the same peer owns the explicit successor.'}, + 'path_delta': {'outcome': 'continue', + 'prior_assumption': 'The first slice must precede its successor.', + 'observed_reality': 'Native completion retained the verified slice and created its same-peer successor.', + 'retained': ['The original peer ownership and the independently verified first slice.'], + 'evidence_refs': [f'todo:{START_TODO_ID}', f'todo:{successor["todo_id"]}']}}), encoding='utf-8') + binding = ('--goal-id', GOAL_ID, '--agent-id', PEER_ALPHA, + '--todo-id', START_TODO_ID, '--turn-instance-id', turn) + refreshed = run_cli('refresh-state', *binding, '--classification', 'peer_slice_verified', + '--delivery-batch-scale', 'implementation', '--delivery-outcome', 'outcome_progress', + '--agent-vision-json', str(vision), '--no-global-sync', '--suppress-external-sinks', + '--first-delivery', '--checkpoint-read-context', context['read_context_id'], + registry_path=registry_path, runtime=runtime, cwd=project) + assert refreshed['ok'] is True, refreshed + for replay in (False, True): + spent = run_cli('quota', 'spend-slot', *binding, '--slots', '1', '--source', 'heartbeat', '--execute', + registry_path=registry_path, runtime=runtime, cwd=project) + assert spent['appended'] is (not replay), spent second = should_run(project, runtime, registry_path, PEER_ALPHA) assert second["agent_lane_next_action"]["todo_id"] == successor["todo_id"], second def assert_review_is_action_semantics_over_generic_handoff() -> None: - with tempfile.TemporaryDirectory(prefix="loopx-peer-review-handoff-") as tmp: + with temporary_canary_directory(prefix="loopx-peer-review-handoff-") as tmp: project, runtime, registry_path = write_fixture(Path(tmp), include_review=True) completed = run_cli( "todo", diff --git a/examples/loopx-turn-codex-cli-e2e-smoke.py b/examples/loopx-turn-codex-cli-e2e-smoke.py index d88318fc39..c90f0d7702 100755 --- a/examples/loopx-turn-codex-cli-e2e-smoke.py +++ b/examples/loopx-turn-codex-cli-e2e-smoke.py @@ -137,6 +137,16 @@ def _write_fake_codex(root: Path) -> Path: args = sys.argv[1:] prompt = sys.stdin.read() +if '\"phase\":\"checkpoint_direction\"' in prompt: + sys.path.insert(0, {str(REPO_ROOT)!r}) + from tests.control_plane.host_checkpoint_fixture import checkpoint_decision + decision = checkpoint_decision(json.loads(prompt.rsplit("\\n", 1)[-1])["checkpoint_context"]) + if decision.get("agent_vision"): + decision["agent_vision_json"] = json.dumps(decision.pop("agent_vision")) + output_path = pathlib.Path(args[args.index("--output-last-message") + 1]) + output_path.write_text(json.dumps(decision), encoding="utf-8") + print(json.dumps({{"type":"thread.started","thread_id":"session-fixture-0001"}}), flush=True) + raise SystemExit(0) turn_key = re.search(r'"turn_key":"([^"]+)"', prompt).group(1) marker = pathlib.Path({MARKER_NAME!r}) turn_number = 1 diff --git a/examples/personal-workspace-browser/goal-suggestions.mjs b/examples/personal-workspace-browser/goal-suggestions.mjs index 88fbfe2876..4fe8e684ac 100644 --- a/examples/personal-workspace-browser/goal-suggestions.mjs +++ b/examples/personal-workspace-browser/goal-suggestions.mjs @@ -129,7 +129,7 @@ export const goalSuggestionsScenario = { await other.locator(".personal-goal-row").first().locator(".personal-goal-link").click(); await other.getByTestId("goal-vitals").waitFor({ state: "visible", timeout: 15_000 }); const vitals = (await other.getByTestId("goal-vitals").innerText()).replace(/\s+/g, " "); - assert.match(vitals, /0 等你决定|可选/, `the lane shows the item as optional: ${vitals}`); + assert.match(vitals, /0 等你决定|可选|暂无等待/, `the lane shows the item as optional: ${vitals}`); // The work vital marks its unknown total; its help carries the partial counts. await other.getByTestId("goal-vitals").getByRole("button", { name: "工作 · 总数未知", exact: true }).focus(); await other.getByRole("tooltip").getByText(/已列出 5 项中的 0 项/).waitFor(); diff --git a/examples/personal-workspace-browser/research-worker-readback.mjs b/examples/personal-workspace-browser/research-worker-readback.mjs index 5d75523b95..f0551f4008 100644 --- a/examples/personal-workspace-browser/research-worker-readback.mjs +++ b/examples/personal-workspace-browser/research-worker-readback.mjs @@ -99,7 +99,8 @@ export const researchWorkerReadbackScenario = { await page.screenshot({path: resolve(outputDir, `research-worker-${mode}-${locale}-${mobile ? "mobile" : "desktop"}.png`), animations: "disabled"}); await region.locator("summary").click(); assert.ok((await region.innerText()).includes(authority.obligation_id)); - assert.match(await region.innerText(), /未绑定|Unbound/u); + await region.getByText(authority.turn_instance_id, {exact: true}).waitFor(); + assert.doesNotMatch(await region.innerText(), /未绑定|Unbound/u); assert.equal(await region.evaluate(element => element.scrollWidth > element.clientWidth + 1), false); await region.getByRole("button", {name: zh ? "查看任务与依赖" : "View work and dependencies", exact: true}).click(); await page.locator('[data-goal-panel="tasks"]:not([hidden])').waitFor(); diff --git a/loopx/canary/maintainability_ratchet.py b/loopx/canary/maintainability_ratchet.py index ebad2d40c4..a6c350c5e1 100644 --- a/loopx/canary/maintainability_ratchet.py +++ b/loopx/canary/maintainability_ratchet.py @@ -49,6 +49,12 @@ def _exception( REVIEWED_MAINTAINABILITY_EXCEPTIONS: dict[str, dict[str, Any]] = { + "dependency_debt:loopx.control_plane.quota.heartbeat_receipt->loopx.capabilities.todo_replan_cadence.machine_defaults": _exception( + "The receipt's cadence witness reads the existing machine-configuration owner; " + "it neither creates cadence policy nor grants execution or settles a Turn.", + "Inject the resolved cadence at the CLI/source composition boundary when the " + "receipt API migrates; retain exact current-policy equality during that move.", + ), "compatibility_facade:loopx.quota": _exception( "The public loopx.quota import surface remains a supported compatibility contract, " "including presentation-owned quota event renderers.", diff --git a/loopx/canary/module_metric_baseline.json b/loopx/canary/module_metric_baseline.json index 5b498482ac..0c86e332fb 100644 --- a/loopx/canary/module_metric_baseline.json +++ b/loopx/canary/module_metric_baseline.json @@ -31,12 +31,12 @@ "dict_any_count": 0 }, "loopx/chat_agent.py": { - "lines": 2007 + "lines": 2034 }, "loopx/chat_runtime.py": { "any_count": 37, "dict_any_count": 0, - "lines": 2320 + "lines": 2323 }, "loopx/chat_server.py": { "any_count": 25, @@ -44,10 +44,10 @@ "lines": 2020 }, "loopx/chat_store.py": { - "lines": 2066 + "lines": 2069 }, "loopx/collaboration_mcp.py": { - "lines": 2058 + "lines": 2083 }, "loopx/codex_cli_probe.py": { "any_count": 37, diff --git a/loopx/capabilities/manager_context/roundtrip.py b/loopx/capabilities/manager_context/roundtrip.py index 9308e9ea8b..c8fc9971b6 100644 --- a/loopx/capabilities/manager_context/roundtrip.py +++ b/loopx/capabilities/manager_context/roundtrip.py @@ -304,8 +304,6 @@ def project_chat_session_snapshot(root, store, session_id, *, registry, # or re-reading every off-page Turn for each page. snapshot["messages"] = project_collaboration(store, root, session_id, snapshot["messages"], registry=registry, context_messages=context_messages) - from ...extensions.lark.private_conversations import project_private_reply_history - snapshot["messages"] = project_private_reply_history(store, store.load_session(session_id) or {}, snapshot["messages"]) if page is not None: snapshot["message_page"] = page["message_page"] return snapshot diff --git a/loopx/chat_agent.py b/loopx/chat_agent.py index 3e8b5d5870..5dd29d0559 100644 --- a/loopx/chat_agent.py +++ b/loopx/chat_agent.py @@ -1348,6 +1348,31 @@ def _check_server_gate(self, message: dict[str, Any]) -> bool: ) return False + def _queue_tool_after_pending_start(self, message: dict[str, Any]) -> bool: + if ( + self.current_turn_id + or message.get("method") != "item/tool/call" + or _event_thread_id(message) != self.thread_id + ): + return False + turn_id = _event_turn_id(message) + if not turn_id: + return False + with self._pending_events.mutex: + pending_start = any( + event.get("method") == "turn/started" + and _event_thread_id(event) == self.thread_id + and _event_turn_id(event) == turn_id + for event in self._pending_events.queue + ) + if not pending_start: + return False + # A control RPC can read the notification before the Goal driver binds + # its Turn. Keep the tool behind that notification; the normal gate + # still checks its identity and permission after the driver binds it. + self._pending_events.put(message) + return True + def _request( self, method: str, @@ -1415,6 +1440,8 @@ def _request( "Codex app-server returned an unreadable response." ) message = raw + if self._queue_tool_after_pending_start(message): + continue if message.get("method") and self._check_server_gate( message ): diff --git a/loopx/chat_loopx_mode.py b/loopx/chat_loopx_mode.py index 5ebbd1a378..8d7eff252c 100644 --- a/loopx/chat_loopx_mode.py +++ b/loopx/chat_loopx_mode.py @@ -43,6 +43,9 @@ "Action=adopt(operation_id,consumer_operation_id) records your decision after both results " "are accepted and the consumer has that exact uses input. Reading alone is not adoption. " "Read/wait/resume use the original operation_id. Running is not failure; do not duplicate it. " + "For a committed coordinator Todo awaiting direction, read(operation_id=todo_id, brief={purpose:first_delivery}) returns " + "its native checkpoint; resume with brief={purpose:first_delivery, read_context_id, agent_vision OR vision_unchanged_reason} " + "settles only that original result after your fresh judgment. Never substitute a new token onto an old judgment. " "After context loss, action=operations recovers this requester's durable work. Follow " "next_cursor for more; unavailable means reconcile, not redispatch. Action=inspect with binding_id checks the actual Turn/profile before new dispatch; unknown availability is not readiness. " "Action=prepare_check takes a stable operation_id and an acceptance document prepared from the owner's requested outcome and current task/file facts. " @@ -87,6 +90,9 @@ "member completion. Synthesize their actual artifacts and report remaining gaps here. " "For task_failure, repair the original artifact or validation environment before action=revalidate; it reruns checks without Host work. " "When current_use.state=unavailable, repair the declared source or receiver input and read again; do not propagate those artifacts as current facts. Read action=messages between work steps for owner inbox additions. " + "If scoped evidence reports coordinator_checkpoint, use loopx_collaboration read with its original Todo id and brief={purpose:first_delivery}, " + "judge the returned basis, then resume that same Todo with your brief containing its read_context_id " + "purpose:first_delivery and exactly one agent_vision or vision_unchanged_reason. Member operations retain their existing semantics. " "Do not claim the whole canonical Goal is complete. Keep independent analysis substantive. " "For a substantive deliverable or correction in a direct user message, reuse current inline outcome context (or action=outcomes if absent) and refine its stable outcome before work. " "At a material delivery milestone, review all retained requirements against actual artifacts; record evidence and unmet/uncertain conclusions in optional review. " @@ -265,6 +271,11 @@ def snapshot(self, session_id): else None ) deliveries = session.get("loopx_deliveries") or [] + checkpoint = None + if settings.get("agent_id"): + from .control_plane.goals.checkpoint_context_io import pending_first_delivery_progress + checkpoint = pending_first_delivery_progress(self.controller.coordination_runtime_root, session["goal_id"], + settings["agent_id"], registry_path=self.controller.registry_path) # Refresh unfinished observations even while the coordinator is paused: # delegated members have their own lifecycles. Accepted labels remain # explicitly last-read observations, never canonical Goal settlement. @@ -298,6 +309,7 @@ def snapshot(self, session_id): active and session_id not in self.controller.adapters ), "deliveries": deliveries, + "coordinator_checkpoint": checkpoint, "ingress": [ {key: row.get(key) for key in ("client_ingress_id", "mode", "status")} for row in self.store.loopx_ingress(session_id)[-20:] @@ -402,6 +414,25 @@ def apply(self, session_id, body, *, work_dir, objective): ) ): raise ValueError("execution operation identity conflict") + if (existing.get("status") == "queued" + and mode.get("enabled") is True + and mode.get("paused") is not True): + # Acceptance is not dispatch. Re-enter the existing + # typed owner with the exact stored request after a + # failed start write; never reconstruct a new Turn. + if not goal_accepts_collaboration(self._goal(session)): + raise ValueError("Goal is stopped or unavailable") + self._execution(session, prior_settings) + self.controller.submit_turn( + session_id=session_id, + client_turn_id=operation_id, + message=existing["message"], + attachments=[], + work_dir=work_dir, + objective=objective, + loopx_execution=True, + loopx_request=prior_request, + ) return {**self.snapshot(session_id), "turn_id": existing["turn_id"]} if operation in {"pause", "exit"}: effect_runtime_result( @@ -896,6 +927,18 @@ def dispatch(name, arguments): if action != "adopt" and "consumer_operation_id" in arguments: raise ValueError("consumer_operation_id is only valid for adopt") operation_id = arguments.get("operation_id", "") + # Todo ids and delegation operation ids have separate owners. Never + # reinterpret an existing member operation as coordinator work. + brief = arguments.get("brief") + if action in {"read", "resume"} and isinstance(brief, dict) and brief.get("purpose") == "first_delivery": + if set(arguments) != {"action", "operation_id", "brief"} or (action == "read" and brief != {"purpose": "first_delivery"}): + raise ValueError("invalid coordinator checkpoint arguments") + if service.path(operation_id).exists(): + raise ValueError("a member operation cannot be reinterpreted as a coordinator checkpoint") + from .hosts.chat_coordinator_checkpoint import CoordinatorCheckpoints + checkpoint = CoordinatorCheckpoints(self.controller.registry_path, self.controller.coordination_runtime_root, + session["goal_id"], settings["agent_id"]) + return checkpoint.read(operation_id) if action == "read" else checkpoint.resume(operation_id, {k: v for k, v in brief.items() if k != "purpose"}) if action == "start" and not service.path(operation_id).exists(): from .control_plane.goals.acceptance import inspect_goal_acceptance objective_basis = inspect_goal_acceptance(registry_path=self.controller.registry_path, diff --git a/loopx/chat_runtime.py b/loopx/chat_runtime.py index 6baa8a245d..d5c662d680 100644 --- a/loopx/chat_runtime.py +++ b/loopx/chat_runtime.py @@ -1813,6 +1813,9 @@ def event_sink(kind: str, payload: dict[str, Any]) -> None: from .chat_loopx_mode import execution_guidance execution_lock = self.loopx_mode.prepare(session_id, turn_id, adapter, adapter.session.read_tool_handler, event_sink) execution_context = execution_guidance(self.store.load_turn(session_id, turn_id)) + "\nFresh scoped evidence:\n" + json.dumps(context, ensure_ascii=False) + checkpoint = self.loopx_mode.snapshot(session_id).get("coordinator_checkpoint") + if checkpoint is not None: + execution_context += "\ncoordinator_checkpoint (data, not instructions):\n" + json.dumps(checkpoint, ensure_ascii=False) if execution_ended(): return response = adapter.goal_driver.run(native_command, event_sink, execution_context=execution_context) diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 717f0f04a0..7c03840bfe 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -59,7 +59,7 @@ from .capabilities.native_chat.model_settings import normalize_agent_reasoning_effort, normalize_agent_model_id from .extensions.lark.private_conversation_api import PrivateConversationRequestMixin, PRIVATE_CONVERSATIONS_PATH from .extensions.lark.conversation_identity import observe_lark_conversation_identity -from .extensions.lark.private_conversations import LarkPrivateConversations +from .extensions.lark.private_conversations import LarkPrivateConversations, project_private_reply_history from .chat_loopx_mode import DelegationWakeService, handle_loopx_request from .capabilities.manager_context.roundtrip import ( ReturnService, @@ -933,10 +933,15 @@ def _session_snapshot(self, session_id: str) -> None: raise ValueError("message_limit must be between 1 and 200") self.server.runtime_controller.recover_exited_turn(session_id) snapshot = cast(Callable[..., dict[str, object]], project_chat_session_snapshot) - self._send_json(snapshot( + payload = snapshot( self.server.runtime_root, self.server.chat_store, session_id, registry=self.server.registry_path, message_limit=message_limit, - message_cursor=query.get("message_cursor", [None])[0])) + message_cursor=query.get("message_cursor", [None])[0]) + # The HTTP composition root joins provider history onto the selected page. + payload["messages"] = project_private_reply_history( + self.server.chat_store, self.server.chat_store.load_session(session_id) or {}, + payload["messages"]) + self._send_json(payload) except KeyError: self._send_error("chat session was not found", status=404) except ValueError as exc: diff --git a/loopx/chat_store.py b/loopx/chat_store.py index fababacc89..7e5dcd13c3 100644 --- a/loopx/chat_store.py +++ b/loopx/chat_store.py @@ -10,9 +10,11 @@ from pathlib import Path import re import threading +import time from typing import Any import uuid from weakref import WeakValueDictionary +from .control_plane.runtime.file_paths import windows_extended_path from .chat import ( require_matching_message_replay, @@ -133,6 +135,7 @@ def _atomic_write_json(path: Path, payload: dict[str, Any], *, preserve_mode: bo directory_mode=0o700, separators=(",", ":"), indent=None, + replace_retry_seconds=0.5, ) diff --git a/loopx/cli_commands/project_lifecycle_refresh_state.py b/loopx/cli_commands/project_lifecycle_refresh_state.py index 224a0b76e1..278a3580cf 100644 --- a/loopx/cli_commands/project_lifecycle_refresh_state.py +++ b/loopx/cli_commands/project_lifecycle_refresh_state.py @@ -86,11 +86,11 @@ def register_refresh_state_command( add_subcommand_format: Callable[[argparse.ArgumentParser], None], ) -> None: context_parser = subparsers.add_parser( - "checkpoint-context", help="Recover a missing checkpoint after a committed Turn writeback.", + "checkpoint-context", help="Read the original Turn's result or checkpoint basis.", description=( - "Recovery only: requires the original committed refresh-state writeback. " - "For the first writeback, supply the vision with refresh-state directly. " - "Use this command only when recovery requests a fresh checkpoint context; " + "New supported Turns read direction after result completion, before settlement. " + "Historical supplement recovery requires the original committed writeback. " + "Judge the returned basis and echo its read identity with the new decision; " "reuse the original Turn identity." ), ) @@ -101,6 +101,11 @@ def register_refresh_state_command( binding.add_argument("--todo-id") binding.add_argument("--replan-obligation-id") context_parser.add_argument("--goal-instance-id", help=argparse.SUPPRESS) + context_parser.add_argument("--purpose", choices=("supplement_checkpoint", "first_delivery", "delivery_result"), + default="supplement_checkpoint", + help="Read result requirements, post-result direction, or the original missing checkpoint; new supported Turns are protected by default.") + context_parser.add_argument("--decision-scope", choices=("agent_lane", "goal"), default=None, + help="Complete work collection used by the first direction judgment; reading grants no write authority.") context_parser.add_argument("--project") context_parser.add_argument("--state-file") context_parser.add_argument("--dependency-todo-id", action="append", default=[], @@ -109,6 +114,8 @@ def register_refresh_state_command( "refresh-state", help="Append a read-only run from active goal state after state-only updates.", ) + refresh_state_parser.add_argument("--first-delivery", action="store_true", + help="Commit an explicitly enrolled first direction using its fresh checkpoint-context receipt.") add_subcommand_format(refresh_state_parser) refresh_state_parser.add_argument("--goal-outcome-update-json", type=Path, help="Optional changed outcome/Agent review envelope (operation_id, expected_provider_revision, refinement). Apply once before normal writeback; unchanged work needs no update. Independent checks remain separate.") @@ -463,6 +470,7 @@ def handle_refresh_state_command( project=Path(args.project).expanduser() if args.project else None, state_file=Path(args.state_file).expanduser() if args.state_file else None, dependency_todo_ids=args.dependency_todo_id, + purpose=args.purpose, decision_scope=args.decision_scope, goal_ref=goal_ref, ) except Exception as exc: @@ -627,6 +635,7 @@ def handle_refresh_state_command( merge_agent_vision_patch=merge_agent_vision_patch, vision_unchanged_reason=args.vision_unchanged_reason, checkpoint_read_context_id=getattr(args, "checkpoint_read_context", None), + first_delivery=bool(getattr(args, "first_delivery", False)), progress_observation=progress_observation, explore_result=explore_result, usage_measurement=usage_measurement, diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index 5986db3a59..980944b6cc 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -5,6 +5,7 @@ from collections.abc import Callable, Mapping from pathlib import Path from typing import Any +from ..cli_runtime import PrintPayload from .quota_capture import ( bind_capture_selection_transport, capture_decision, handoff_decision, @@ -57,6 +58,7 @@ quota_rollout_details, quota_rollout_replan_obligation_id, quota_rollout_todo_id, + quota_result_work_admission_details, reconcile_existing_heartbeat_receipt_for_turn, render_existing_heartbeat_receipt_payload, ) @@ -120,10 +122,6 @@ ) from .quota_scheduler_followup import build_scheduler_followup_payload -PrintPayload = Callable[ - [dict[str, object], str, Callable[[dict[str, object]], str]], - None, -] RolloutEventAppender = Callable[..., dict[str, object]] @@ -531,6 +529,47 @@ def _record_non_guard_quota_event( ) +def _read_back_committed_heartbeat( + payload: dict[str, object], + args: argparse.Namespace, + *, + runtime_root: Path, + turn_instance_id: str, + stall_observation: str, + requested_todo_id: str | None, + goal_ref: dict[str, str] | None, +) -> None: + """Expose selection only after the original heartbeat receipt is read back.""" + receipt = find_heartbeat_receipt( + runtime_root, + goal_id=args.goal_id, + agent_id=args.agent_id, + turn_instance_id=turn_instance_id, + goal_ref=goal_ref, + ) + if receipt: + rollout_event_value = payload.get("rollout_event") + rollout_event: Mapping[str, object] = ( + rollout_event_value if isinstance(rollout_event_value, Mapping) else {} + ) + payload["heartbeat_receipt"] = heartbeat_receipt_view( + receipt, + turn_instance_id=turn_instance_id, + status="committed" if rollout_event.get("appended") else "replayed", + ) + commit_requested_action_selection(payload, requested_todo_id=requested_todo_id) + else: + fail_heartbeat_receipt( + payload, + turn_instance_id=turn_instance_id, + stall_observation=stall_observation, + reason=( + "heartbeat receipt append could not be read back; retry " + "quota should-run with the same --turn-instance-id" + ), + ) + + def handle_quota_command( args: argparse.Namespace, *, @@ -546,6 +585,46 @@ def handle_quota_command( return native_result if args.quota_command == "replan-view": return handle_replan_view(args, print_payload) + # Serialize only automatic identity acquisition. The typed quota owner + # decides which Turn is unfinished; this IO fence keeps that read, the + # existing heartbeat append and work-input persistence in one admission. + from contextlib import nullcontext + import hashlib + from ..file_lock import exclusive_cross_runtime_file_lock + from ..history import load_registry + from ..paths import resolve_runtime_root + from ..runtime import validate_goal_id_path_segment + + try: + fence = nullcontext() + if (args.quota_command == "should-run" and args.agent_id + and not getattr(args, "turn_instance_id", None) + and not getattr(args, "begin_turn", False) + and not getattr(args, "dry_run", False)): + root = resolve_runtime_root(load_registry(registry_path), runtime_root_arg, registry_path=registry_path) + goal_id = validate_goal_id_path_segment(args.goal_id) + agent_key = hashlib.sha256(args.agent_id.encode()).hexdigest() + fence = exclusive_cross_runtime_file_lock(root / "goals" / goal_id / "quota" / + f"default-work-{agent_key}", operation="default-work-admission") + with fence: + return _handle_quota_command(args, registry_path=registry_path, runtime_root_arg=runtime_root_arg, + print_payload=print_payload, append_cli_rollout_event=append_cli_rollout_event, + capture_directory=capture_directory) + except (ValueError, OSError, RuntimeError) as error: + payload = quota_failure_payload(args, registry_path=registry_path, runtime_root_arg=runtime_root_arg, error=error) + print_payload(payload, args.format, _quota_renderer(args)) + return 1 + + +def _handle_quota_command( + args: argparse.Namespace, + *, + registry_path: Path, + runtime_root_arg: str | None, + print_payload: PrintPayload, + append_cli_rollout_event: RolloutEventAppender, + capture_directory: Path | None = None, +) -> int: usage_quota_started = time.time_ns() // 1_000_000 heartbeat_turn_id: str | None = None heartbeat_receipt_existing: dict[str, object] | None = None @@ -691,6 +770,7 @@ def handle_quota_command( runtime_root=runtime_root, turn_instance_id=heartbeat_turn_id, existing=heartbeat_receipt_existing, + registry_path=registry_path, goal_ref=goal_ref, ) else: @@ -890,6 +970,10 @@ def handle_quota_command( ), } ) + if settlement_identity is not None: + rollout_details.update(quota_result_work_admission_details( + registry_path=registry_path, runtime_root=runtime_root, + goal_id=args.goal_id, todo_id=rollout_todo_id, goal_ref=goal_ref)) append_cli_rollout_event( payload, registry_path=registry_path, @@ -917,45 +1001,19 @@ def handle_quota_command( *(["goal_ref"] if goal_ref is not None else []), ], ) - receipt = find_heartbeat_receipt( - runtime_root, - goal_id=args.goal_id, - agent_id=args.agent_id, + _read_back_committed_heartbeat( + payload, + args, + runtime_root=runtime_root, turn_instance_id=heartbeat_turn_id, + stall_observation=heartbeat_stall_observation, + requested_todo_id=( + action_selection.requested_todo_id + if action_selection is not None + else None + ), goal_ref=goal_ref, ) - if receipt: - rollout_event_value = payload.get("rollout_event") - rollout_event: Mapping[str, object] = ( - rollout_event_value - if isinstance(rollout_event_value, Mapping) - else {} - ) - payload["heartbeat_receipt"] = heartbeat_receipt_view( - receipt, - turn_instance_id=heartbeat_turn_id, - status="committed" - if rollout_event.get("appended") - else "replayed", - ) - commit_requested_action_selection( - payload, - requested_todo_id=( - action_selection.requested_todo_id - if action_selection is not None - else None - ), - ) - else: - fail_heartbeat_receipt( - payload, - turn_instance_id=heartbeat_turn_id, - stall_observation=heartbeat_stall_observation, - reason=( - "heartbeat receipt append could not be read back; retry " - "quota should-run with the same --turn-instance-id" - ), - ) else: _record_non_guard_quota_event( payload, args, registry_path=registry_path, @@ -965,6 +1023,10 @@ def handle_quota_command( spend_turn_instance_id=spend_turn_instance_id, rollout_details=rollout_details, append_cli_rollout_event=append_cli_rollout_event, ) + if args.quota_command == "should-run" and heartbeat_turn_id and payload.get("ok") is True: + payload = _restore_quota_work_input(args, payload, heartbeat_turn_id=heartbeat_turn_id, + heartbeat_receipt_existing=heartbeat_receipt_existing, registry_path=registry_path, + runtime_root=runtime_root, runtime_root_arg=runtime_root_arg, goal_ref=goal_ref) attach_reward_memory_recall_after_should_run( payload, quota_command=args.quota_command, @@ -984,3 +1046,41 @@ def handle_quota_command( detail_sections=detail_sections, print_payload=print_payload, ) + + +def _restore_quota_work_input(args, payload, *, heartbeat_turn_id, heartbeat_receipt_existing, + registry_path, runtime_root, runtime_root_arg, goal_ref): + """Restore the original captured input after a successful quota guard.""" + if args.quota_command == "should-run" and heartbeat_turn_id and payload.get("ok") is True: + from ..control_plane.goals.checkpoint_context_io import TurnResultWork, result_work_required, result_work_context, result_work_context_exists + from ..control_plane.quota.settlement import read_heartbeat_settlement + + try: + work_readback = read_heartbeat_settlement(runtime_root, goal_id=args.goal_id, + agent_id=args.agent_id, todo_id=getattr(args, "todo_id", None), + turn_instance_id=heartbeat_turn_id, registry_path=registry_path, goal_ref=goal_ref, + resolve_original_binding=True) + if (work_readback is not None and work_readback.identity.value is not None + and result_work_required(work_readback)): + payload["work_input_restored"] = result_work_context_exists(runtime_root, work_readback.identity.value) + committed = heartbeat_receipt_existing is not None and TurnResultWork( + registry_path, runtime_root, work_readback.identity.value, goal_ref).result_committed() + if committed: + payload["result_committed"] = True + progress = payload.get("first_delivery_progress") + payload["recommended_action"] = ( + progress["next_action"] if isinstance(progress, dict) + and progress.get("settlement_identity") == work_readback.identity.value.as_dict() + else "Resume this original Turn's pending settlement; its Todo result is already committed." + ) + elif work_readback.writeback_run is None: + payload["delivery_result_context"] = result_work_context(registry_path=registry_path, + runtime_root=runtime_root, identity=work_readback.identity.value, goal_ref=goal_ref) + except (ValueError, OSError, RuntimeError) as error: + admitted_receipt = payload.get("heartbeat_receipt") + payload = quota_failure_payload(args, registry_path=registry_path, + runtime_root_arg=runtime_root_arg, error=error) + payload.update(error_code=getattr(error, "code", "delivery_work_context_unknown"), + error=str(error), + status="delivery_work_context_unavailable", heartbeat_receipt=admitted_receipt) + return payload diff --git a/loopx/cli_commands/quota_context.py b/loopx/cli_commands/quota_context.py index e5f7cd2c30..1d55372065 100644 --- a/loopx/cli_commands/quota_context.py +++ b/loopx/cli_commands/quota_context.py @@ -286,6 +286,12 @@ def prepare_quota_command_context( registry_path=registry_path, runtime_root_override=runtime_root_arg, ) + if command == "should-run" and not heartbeat_turn_id and args.agent_id and not args.dry_run: + from ..control_plane.goals.checkpoint_context_io import restore_default_turn_id + + heartbeat_turn_id = restore_default_turn_id(runtime_root=runtime_root, registry_path=registry_path, + goal_id=args.goal_id, agent_id=args.agent_id, todo_id=getattr(args, "todo_id", None), + pending_only=True) or mint_turn_instance_id(prefix="work") if command == "monitor-poll" and args.execute and (args.todo_id or args.target_key): # A selected provider outage precedes unrelated status preparation. # An unmigrated source may still recover its original quota transaction; diff --git a/loopx/cli_commands/quota_failure_report.py b/loopx/cli_commands/quota_failure_report.py index 5e04b82dc1..bd53f5b026 100644 --- a/loopx/cli_commands/quota_failure_report.py +++ b/loopx/cli_commands/quota_failure_report.py @@ -78,6 +78,7 @@ def quota_failure_payload( error: Exception, ) -> dict[str, object]: command = args.quota_command + from ..control_plane.goals.checkpoint_context_io import CheckpointReadContextRejected lock_timeout_fields = lock_timeout_error_fields(error) verbose_debug = verbose_debug_fields( error, verbose=bool(getattr(args, "verbose", False)) @@ -232,6 +233,9 @@ def quota_failure_payload( payload["agent_id"] = error.agent_id elif isinstance(error, (LegacyCoordinationWriterFenced, LocalCoordinationAuthorityUnavailable)): payload.update(authority_failure) + elif isinstance(error, CheckpointReadContextRejected): + payload.update(error_code=error.code, reason=str(error), **error.payload, + recommended_action=error.payload["checkpoint_read_context"].get("recommended_action", str(error))) if lock_timeout_fields: payload["recommended_action"] = "inspect the lock holder before retrying" if command == "monitor-poll": diff --git a/loopx/cli_commands/quota_monitor_poll.py b/loopx/cli_commands/quota_monitor_poll.py index d3efa7890f..d43b0915b3 100644 --- a/loopx/cli_commands/quota_monitor_poll.py +++ b/loopx/cli_commands/quota_monitor_poll.py @@ -186,7 +186,8 @@ def record_automatic_heartbeat_stall( """Commit and reproject the automatic no-spend heartbeat observation.""" turn_id = context.heartbeat_turn_id - if turn_id is None: + # A default result-read identity is not an automatic Monitor observation. + if turn_id is None or not (args.turn_instance_id or getattr(args, "begin_turn", False)): return payload, status_payload, cache_metadata, "not_applicable" existing_stall = find_monitor_poll_turn( context.runtime_root, diff --git a/loopx/cli_commands/quota_request.py b/loopx/cli_commands/quota_request.py index 127be33127..19fde5794f 100644 --- a/loopx/cli_commands/quota_request.py +++ b/loopx/cli_commands/quota_request.py @@ -165,6 +165,10 @@ def validate_quota_command_request(args: argparse.Namespace) -> None: raise QuotaCommandValidationError( "`loopx quota should-run --todo-id` requires --turn-instance-id or --begin-turn" ) + if command == "should-run" and args.todo_id and not args.agent_id: + raise QuotaCommandValidationError( + "`loopx quota should-run --todo-id` requires --agent-id" + ) if begin_turn and command != "should-run": raise QuotaCommandValidationError( "--begin-turn is only valid with `loopx quota should-run`" diff --git a/loopx/cli_commands/status.py b/loopx/cli_commands/status.py index d95842b180..f3daa7b08f 100644 --- a/loopx/cli_commands/status.py +++ b/loopx/cli_commands/status.py @@ -5,6 +5,8 @@ from pathlib import Path from typing import Any +from ..cli_runtime import PrintPayload + from ..contract import check_contract, render_contract_markdown from ..control_plane.coordination.local_authority import LocalCoordinationAuthorityUnavailable from ..control_plane.runtime.status_projection_cache import ( @@ -39,10 +41,6 @@ from ..status import AUTONOMOUS_REPLAN_PERIODIC_LOOKBACK, collect_status from .status_registration import register_status_commands as register_status_commands -PrintPayload = Callable[ - [dict[str, object], str, Callable[[dict[str, object]], str]], - None, -] FormatSelector = Callable[..., str] @@ -754,6 +752,19 @@ def attach_agent_lane_next_actions( guard=guard, ) latest_action = guard.get("latest_run_recommended_action") + delivery_progress = guard.get("first_delivery_progress") + if isinstance(delivery_progress, dict): + item["first_delivery_progress"] = delivery_progress + if ( + isinstance(delivery_progress, dict) + and guard.get("recommended_action") == delivery_progress["next_action"] + ): + item["recommended_action"] = delivery_progress["next_action"] + if isinstance(project_asset, dict): + project_asset["next_action"] = delivery_progress["next_action"] + goal_channel = item.get("goal_channel_projection") + if isinstance(goal_channel, dict): + goal_channel["next_action"] = delivery_progress["next_action"] for target in (item, project_asset): if not isinstance(target, dict): continue @@ -885,6 +896,8 @@ def handle_diagnose_command( ], }, } + # Preserve the full reasoning evidence packet; JSON indentation carries no + # decision information and grows with every diagnosis collection row. print_payload(payload, output_format(args), render_diagnosis_markdown) return 0 if payload.get("ok") else 1 diff --git a/loopx/cli_commands/support_control.py b/loopx/cli_commands/support_control.py index e26bb6e400..ba1e0f9a95 100644 --- a/loopx/cli_commands/support_control.py +++ b/loopx/cli_commands/support_control.py @@ -3,6 +3,7 @@ import argparse from collections.abc import Callable from pathlib import Path +from ..cli_runtime import PrintPayload from ..agent_registry import ( agent_profile_from_registry, @@ -74,10 +75,6 @@ register_update_command, ) -PrintPayload = Callable[ - [dict[str, object], str, Callable[[dict[str, object]], str]], - None, -] FormatSelector = Callable[..., str] AddFormat = Callable[[argparse.ArgumentParser], None] @@ -488,11 +485,11 @@ def handle_support_control_command( and recurring_thin_surface else payload ) - print_payload( - output_payload, - selected_output_format, - render_heartbeat_prompt_markdown, - ) + if not args.full: + print_payload(output_payload, selected_output_format, + render_heartbeat_prompt_markdown) + else: + print_payload(output_payload, selected_output_format, render_heartbeat_prompt_markdown) return 0 if payload.get("ok") else 1 supervisor_result = handle_supervisor_control_command( diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index 1acc087a0d..702a9286df 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -13,6 +13,7 @@ read_canonical_todo_fields_if_promoted, ) from ..control_plane.effect_runtime import effect_runtime_result +from ..control_plane.goals.checkpoint_context_io import checkpoint_continuation_required from ..control_plane.agents.workspace_guard import capture_delivery_workspace from ..control_plane.todos.contract import ( replan_successor_semantic_binding, @@ -125,7 +126,8 @@ def _completion_settlement_error( # Todo acceptance and Turn settlement are distinct facts. Ordinary # completion can precede accounting (including controller validation). # Only terminal intent requires the full chain before closing out. - if not no_follow_up or settlement_readback.settlement.failure is None: + if (not no_follow_up or settlement_readback.settlement.failure is None + or checkpoint_continuation_required(settlement_readback)): return None return ( "terminal no-follow-up closeout requires matching writeback and quota spend receipts: " @@ -597,8 +599,20 @@ def handle_todo_command( settlement_readback = None completion_error = None completion_turn_key = None + completion_replay = None completion_identity_source = None completion_delivery_workspace = None + # An explicit lifecycle identity must reach its own typed owner; + # default Turn restoration applies only to an unscoped request. + if (not getattr(args, "turn_instance_id", None) + and not getattr(args, "completion_identity_key", None) + and args.agent_id and args.todo_id): + from ..control_plane.goals.checkpoint_context_io import restore_default_turn_id + + args.turn_instance_id = restore_default_turn_id( + runtime_root=resolve_runtime_root(load_registry(registry_path), runtime_root_arg), + registry_path=registry_path, goal_id=args.goal_id, agent_id=args.agent_id, + todo_id=args.todo_id, pending_only=False) if getattr(args, "turn_instance_id", None): runtime_root = resolve_runtime_root(load_registry(registry_path), runtime_root_arg) settlement_readback = _read_todo_turn_settlement( @@ -662,7 +676,22 @@ def handle_todo_command( ), "error": completion_error, } - completion_turn_key = identity.effect_id + from ..control_plane.goals.checkpoint_context_io import completion_identity_for_original_turn + + original_completion = completion_identity_for_original_turn( + todo=todo, identity=identity, goal_id=args.goal_id, + agent_id=args.agent_id, todo_id=args.todo_id, + candidate={name: getattr(args, name, None) for name in ( + "evidence", "note", "reason", "result_file", "no_followup", "clear_claim", + "next_agent_todo", "next_user_todo", "successor_todo_ids", "next_claimed_by", + "next_task_class", "next_action_kind", "next_task_repository", + "next_required_capabilities", "next_continuation_policy", "self_merged", "dry_run", + "next_excluded_agents", "next_user_task_class", "decision_outcome", "role", + "task_lease_idempotency_key", "task_lease_expected_version", + )} | {"no_followup": bool(args.no_follow_up)}, + ) + completion_turn_key = original_completion["completion_turn_key"] + completion_replay = original_completion["replay"] completion_identity_source = "turn_settlement" writeback_run = settlement_readback.writeback_run if isinstance(writeback_run, dict) and isinstance( @@ -686,7 +715,7 @@ def handle_todo_command( completion_turn_key = str(args.completion_identity_key) completion_identity_source = "lifecycle_reentry" if completion_error is None: - payload = complete_goal_todo( + payload = completion_replay or complete_goal_todo( registry_path=registry_path, runtime_root_arg=runtime_root_arg, goal_id=args.goal_id, @@ -697,7 +726,12 @@ def handle_todo_command( evidence=args.evidence, completion_result_file=Path(args.result_file).expanduser() if args.result_file else None, completion_turn_key=completion_turn_key, + delivery_read_context_id=getattr(args, "delivery_read_context", None), + delivery_settlement_identity=settlement_identity.as_dict() if settlement_identity else None, completion_identity_source=completion_identity_source, + defer_no_followup=(settlement_readback is not None + and checkpoint_continuation_required(settlement_readback) + and settlement_readback.settlement.failure is not None), completion_delivery_workspace=completion_delivery_workspace, completion_validation_workspace_path=Path.cwd(), task_lease_idempotency_key=args.task_lease_idempotency_key, @@ -740,7 +774,10 @@ def handle_todo_command( goal_ref=goal_ref, ) if args.turn_instance_id else None ) + retirement_identity = supersede_readback.identity.value if supersede_readback is not None else None payload = supersede_goal_todo( + completion_turn_key=retirement_identity.effect_id if retirement_identity is not None else None, + completion_identity_source="turn_settlement" if retirement_identity is not None else None, registry_path=registry_path, runtime_root_arg=runtime_root_arg, goal_id=args.goal_id, @@ -822,7 +859,7 @@ def handle_todo_command( raise RuntimeError("exact settlement readback unexpectedly returned not-found") settlement_result = ( settlement_readback.terminal_settlement - if args.no_follow_up and settlement_identity is not None + if payload.get("completion_continuation") == "no_followup" and settlement_identity is not None else settlement_readback.identity ) payload["settlement_result"] = settlement_result_payload( diff --git a/loopx/cli_commands/todo_argument_validation.py b/loopx/cli_commands/todo_argument_validation.py index 969994b3b7..c07c49a253 100644 --- a/loopx/cli_commands/todo_argument_validation.py +++ b/loopx/cli_commands/todo_argument_validation.py @@ -480,6 +480,8 @@ def validate_todo_update_options(args: argparse.Namespace) -> None: def validate_todo_complete_options(args: argparse.Namespace) -> None: + if getattr(args, "delivery_read_context", None) and not args.turn_instance_id: + raise ValueError("--delivery-read-context requires the original --turn-instance-id") if not args.todo_id: raise ValueError("todo complete requires --todo-id") if args.result_file and args.role == "user": @@ -573,6 +575,8 @@ def validate_todo_archive_completed_options(args: argparse.Namespace) -> None: def validate_shared_todo_options(args: argparse.Namespace) -> None: if getattr(args, "operation_id", None) and args.todo_command not in {"receipt", "add"}: raise ValueError("--operation-id is supported only by todo receipt and canonical todo add") + if getattr(args, "delivery_read_context", None) and args.todo_command != "complete": + raise ValueError("--delivery-read-context is supported only by todo complete") if args.result_file and args.todo_command != "complete": raise ValueError("--result-file is supported only by todo complete") agent_id_allowed_for_user_authoring = ( diff --git a/loopx/cli_commands/todo_event.py b/loopx/cli_commands/todo_event.py index d59260d698..5f2e5b31be 100644 --- a/loopx/cli_commands/todo_event.py +++ b/loopx/cli_commands/todo_event.py @@ -6,6 +6,7 @@ from ..control_plane.todos.contract import decision_scope_metadata_value from ..file_lock import lock_timeout_error_fields +from ..control_plane.goals.checkpoint_context_io import CheckpointReadContextRejected RolloutEventAppender = Callable[..., dict[str, object]] @@ -57,7 +58,7 @@ def todo_error_payload( elif isinstance(exc, (TaskLeaseError, HandoffModeError, LegacyCoordinationWriterFenced, ShadowManagementError, ActiveStateAuthorityMutationError, LocalCoordinationAuthorityUnavailable)): payload["error_code"] = exc.code payload.update(exc.payload) - elif isinstance(exc, LocalCoordinationAuthorityRejection): + elif isinstance(exc, (LocalCoordinationAuthorityRejection, CheckpointReadContextRejected)): payload["error_code"] = exc.code payload["code"] = exc.code for key, value in exc.payload.items(): @@ -88,7 +89,7 @@ def append_todo_rollout_event( terminal_closeout = bool( turn_instance_id and args.todo_command == "complete" - and getattr(args, "no_follow_up", False) + and payload.get("completion_continuation") == "no_followup" ) if ( args.todo_command == "receipt" @@ -136,7 +137,7 @@ def append_todo_rollout_event( "changed": bool(payload.get("changed")), "added": bool(payload.get("added")), "already_exists": bool(payload.get("already_exists")), - "no_followup": bool(getattr(args, "no_follow_up", False)), + "no_followup": payload.get("completion_continuation") == "no_followup", "completion_continuation": payload.get("completion_continuation"), "completion_recovery": payload.get("completion_recovery"), "mutation_authority": payload.get("mutation_authority"), diff --git a/loopx/cli_commands/todo_registration.py b/loopx/cli_commands/todo_registration.py index 79b4a87e8a..f6e302418f 100644 --- a/loopx/cli_commands/todo_registration.py +++ b/loopx/cli_commands/todo_registration.py @@ -111,6 +111,7 @@ def register_todo_command( todo_parser.add_argument("--note", help="Public-safe note to attach to a lifecycle transition.") todo_parser.add_argument("--evidence", help="Public-safe evidence pointer or short result for complete/update.") todo_parser.add_argument("--result-file", help="For todo complete with bound Goal acceptance criteria, bind a bounded local .json, .md or .txt result. A Todo validator alone is insufficient; use --evidence for a local artifact pointer.") + todo_parser.add_argument("--delivery-read-context", help="Original delivery_result read identity for protected Turn completion.") todo_parser.add_argument( "--validation-command", help=( diff --git a/loopx/cli_commands/turn.py b/loopx/cli_commands/turn.py index 3b4c750b0e..6c7547284e 100644 --- a/loopx/cli_commands/turn.py +++ b/loopx/cli_commands/turn.py @@ -5,6 +5,7 @@ from collections.abc import Callable, Mapping from pathlib import Path from typing import Any +from ..cli_runtime import PrintPayload from ..control_plane.goals.first_party_host_admission import ( FirstPartyHostGoalAdmission, @@ -33,10 +34,6 @@ resolve_turn_resume_session_binding, ) -PrintPayload = Callable[ - [dict[str, object], str, Callable[[dict[str, object]], str]], - None, -] FormatSelector = Callable[..., str] diff --git a/loopx/cli_commands/turn_decision.py b/loopx/cli_commands/turn_decision.py index 4e36ed4825..a213e3efb9 100644 --- a/loopx/cli_commands/turn_decision.py +++ b/loopx/cli_commands/turn_decision.py @@ -325,6 +325,7 @@ def current_turn_host_launch_decision( operator_inbox_urgency_projector: Callable[..., dict[str, Any]], turn_start_hook_dispatch: Mapping[str, Any] | None = None, goal_ref: Mapping[str, object] | None = None, + checkpoint_direction: bool = False, ) -> dict[str, Any]: """Re-read the original Turn's eligibility inside its launch lifetime. @@ -357,6 +358,14 @@ def current_turn_host_launch_decision( turn_instance_id=identity.turn_instance_id, goal_ref=goal_ref, receipt_bound_todo_id=identity.todo_id or None) launch = {"current": current, "planned_effective_action": planned_effective_action} + if checkpoint_direction: + admitted = effect_runtime_result("goal.checkpoint_read_context.work_binding", { + "phase": "direction_admission", "current": current, "identity": identity.as_dict(), + "planned_effective_action": planned_effective_action, + }) + if admitted.get("ok") is not True: + raise FirstPartyHostRuntimeRejected(str(admitted.get("error_code") or "goal_launch_not_allowed")) + return {"current": current, "planned_effective_action": current["effective_action"]} # The shared typed launch owner retains refusal precedence (including # Stop/witness failures) and compares the original effective action. if (current.get("should_run") is not True diff --git a/loopx/cli_commands/turn_run_once.py b/loopx/cli_commands/turn_run_once.py index 58d97c8a6c..1b63b10d82 100644 --- a/loopx/cli_commands/turn_run_once.py +++ b/loopx/cli_commands/turn_run_once.py @@ -1,7 +1,9 @@ from __future__ import annotations import argparse +import json from collections.abc import Callable, Mapping +from contextlib import nullcontext from pathlib import Path from typing import Any @@ -24,6 +26,7 @@ read_heartbeat_settlement, ) from ..control_plane.quota.spend_sources import quota_spend_source_for_execution_context +from ..control_plane.effect_program import SettlementFailureKind from ..control_plane.work_items.autonomous_replan_obligation import ( replan_obligation_id_from_packet, ) @@ -231,7 +234,14 @@ def refresh_candidate( # The host workspace is execution context, not state authority. state_project = None result_kind = str(result.get("result_kind") or "") - if result_kind in {"repair_required", "replan_required"}: + fresh_checkpoint = current_checkpoint.enabled() + pending_judgment = fresh_checkpoint and not result.get("checkpoint_read_context_id") + prior_checkpoint = read_heartbeat_settlement(runtime_root, + goal_id=settlement_identity.goal_id, agent_id=settlement_identity.agent_id, + todo_id=settlement_identity.todo_id, turn_instance_id=settlement_identity.turn_instance_id, + registry_path=registry_path, goal_ref=goal_ref) if pending_judgment else None + if result_kind in {"repair_required", "replan_required"} and (not fresh_checkpoint or + (pending_judgment and (prior_checkpoint is None or prior_checkpoint.writeback_run is None))): todo_id = str(selected_todo.get("todo_id") or "") if not todo_id and not settlement_identity.replan_obligation_id: raise ValueError( @@ -260,19 +270,20 @@ def refresh_candidate( turn_instance_id=settlement_identity.turn_instance_id, agent_id=args.agent_id, progress_scope="goal", - autonomous_replan_recorded=result_kind == "replan_required", + autonomous_replan_recorded=result_kind == "replan_required" and pending_judgment if fresh_checkpoint else result_kind == "replan_required", progress_observation=( dict(progress_observation) if isinstance(progress_observation, dict) else None ), agent_vision_packet=( dict(result["agent_vision"]) - if isinstance(result.get("agent_vision"), dict) + if isinstance(result.get("agent_vision"), dict) and not pending_judgment else None ), vision_unchanged_reason=( - str(result.get("vision_unchanged_reason") or "") or None + (str(result.get("vision_unchanged_reason") or "") or None) if not pending_judgment else None ), + checkpoint_read_context_id=result.get("checkpoint_read_context_id"), completion_todo_id=completion_todo_id, completion_turn_key=completion_turn_key, dry_run=dry_run, @@ -301,6 +312,7 @@ def writeback( effect_ref: str, ) -> dict[str, object]: require_effect_ref(effect_ref, SettlementStepKind.DURABLE_WRITEBACK) + pending_judgment = current_checkpoint.enabled() and not result.get("checkpoint_read_context_id") refresh = refresh_candidate(result, completion_todo_id=completion_todo_id, completion_turn_key=completion_turn_key) if refresh.get("ok") and ( @@ -314,6 +326,15 @@ def writeback( status="appended", details={"command": "turn run-once"}, ) + if (pending_judgment and refresh.get("ok") and + refresh.get("vision_checkpoint", {}).get("decision") == "missing_required"): + try: + decided = current_checkpoint.continue_direction(result, purpose="supplement_checkpoint") + return writeback(decided, completion_todo_id=completion_todo_id, + completion_turn_key=completion_turn_key, effect_ref=effect_ref) + except ValueError as error: + return {"ok": False, "appended": False, "reason": str(error), + "checkpoint_stage": "direction_pending"} return refresh def completion_intent(_result: dict[str, object]) -> dict[str, object]: @@ -345,6 +366,7 @@ def todo_completion( result: dict[str, object], *, effect_ref: str, + terminal: bool = False, ) -> dict[str, object]: todo_id = str(selected_todo.get("todo_id") or "") if not todo_id: @@ -367,6 +389,10 @@ def todo_completion( completion_delivery_workspace=completion_delivery_workspace, completion_validation_workspace_path=delivery_workspace_path, **(dict(native_completion_lease_resolver()) if native_completion_lease_resolver else {}), + delivery_read_context_id=(result.get("result_work_binding") or {}).get("read_context_id"), + delivery_settlement_identity=settlement_identity.as_dict(), + defer_no_followup=current_checkpoint.enabled() and not terminal, + no_followup=terminal, ) if completion.get("ok") is not True: return completion @@ -459,13 +485,14 @@ def completion_writeback( completion = {"ok": True, "appended": True, "completion": prior} if not completion.get("ok"): return completion - todo_id = str(selected_todo.get("todo_id") or "") - refresh = writeback( - result, - completion_todo_id=todo_id, - completion_turn_key=settlement_identity.turn_instance_id, - effect_ref=effect_ref, - ) + try: + result = current_checkpoint.continue_direction(result) + todo_id = str(selected_todo.get("todo_id") or "") + refresh = writeback(result, completion_todo_id=todo_id, + completion_turn_key=settlement_identity.turn_instance_id, effect_ref=effect_ref) + except ValueError as error: + return {"ok": False, "appended": False, "reason": str(error), + "checkpoint_stage": "direction_pending", "completion": completion["completion"]} return { "ok": bool(refresh.get("ok")), "appended": bool(completion.get("appended")) @@ -480,7 +507,7 @@ def terminal_closeout( effect_ref: str, ) -> dict[str, object]: require_effect_ref(effect_ref, SettlementStepKind.TERMINAL_CLOSEOUT) - return todo_completion(result, effect_ref=effect_ref) + return todo_completion(result, effect_ref=effect_ref, terminal=True) def current_status() -> dict[str, object]: return collect_turn_status_payload( @@ -639,6 +666,8 @@ def writeback_resolver(effect_ref: str) -> dict[str, object]: "kind": "unknown", "reason": "refresh-state receipt has no durable run", } + if current_checkpoint.enabled() and run.get("vision_checkpoint", {}).get("satisfied") is not True: + return {"kind": "absent"} if event is None: append_settlement_event( run, @@ -724,6 +753,16 @@ def terminal_closeout_resolver(effect_ref: str) -> dict[str, object]: raise RuntimeError(EXACT_SETTLEMENT_READBACK_NOT_FOUND) event = readback.completion_event completion = terminal_completion_readback() + # The Todo fence proves this original Turn's terminal upgrade + # absent; an ordinary result event is not a closeout receipt. + # Receipt classification remains with the TS settlement owner. + terminal_failure = readback.terminal_closeout.failure + if (terminal_failure is not None and + terminal_failure.kind is not SettlementFailureKind.RECEIPT_MISSING): + return {"kind": "unknown", "reason": terminal_failure.reason} + if (completion is None and terminal_failure is not None and + terminal_failure.kind is SettlementFailureKind.RECEIPT_MISSING): + return {"kind": "absent"} if event is None and completion is None: return {"kind": "absent"} if ( @@ -734,7 +773,7 @@ def terminal_closeout_resolver(effect_ref: str) -> dict[str, object]: "kind": "unknown", "reason": "Todo state does not prove terminal no-followup", } - if event is None: + if terminal_failure is not None: append_settlement_event( {"ok": True, "appended": True}, event_kind="todo_complete", @@ -798,43 +837,31 @@ def scheduler(_spend_payload: dict[str, object]) -> dict[str, object]: host_runner: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None session_binding_resolver = None - if args.host == "codex-app-server": - def native_launch_decision(current_registry: Path) -> Mapping[str, Any]: - return current_turn_host_launch_decision(args, registry_path=current_registry, - runtime_root=runtime_root, identity=settlement_identity, - replan_obligation_id=replan_obligation_id, - planned_effective_action=payload["turn_envelope"].get("effective_action"), - scheduler_execution_context=payload.get("scheduler_execution_context") or {}, - operator_inbox_urgency_projector=operator_inbox_urgency_projector, - turn_start_hook_dispatch=turn_start_hook_dispatch, goal_ref=goal_ref) + def current_launch_decision(current_registry: Path, *, checkpoint_direction: bool = False) -> Mapping[str, Any]: + return current_turn_host_launch_decision(args, registry_path=current_registry, + runtime_root=runtime_root, identity=settlement_identity, + replan_obligation_id=replan_obligation_id, + planned_effective_action=payload["turn_envelope"].get("effective_action"), + scheduler_execution_context=payload.get("scheduler_execution_context") or {}, + operator_inbox_urgency_projector=operator_inbox_urgency_projector, + turn_start_hook_dispatch=turn_start_hook_dispatch, goal_ref=goal_ref, + checkpoint_direction=checkpoint_direction) + if args.host == "codex-app-server": native_admission = (goal_admission or FirstPartyHostGoalAdmission.for_plan( registry_path=registry_path, goal_id=args.goal_id, planned_goal_ref=goal_ref, - )).with_launch_decision(native_launch_decision) + )) def run_native_host(request: Mapping[str, Any]) -> dict[str, Any]: - return native_host_runner(request, lambda: native_admission.launch_lifetime( + admitted = native_admission.with_launch_decision(lambda registry: current_launch_decision( + registry, checkpoint_direction=request.get("phase") == "checkpoint_direction")) + return native_host_runner(request, lambda: admitted.launch_lifetime( operation="native_conversation_turn_launch")) host_runner = run_native_host session_binding_resolver = native_session_binding_resolver elif args.host == "codex-cli": - def current_launch_decision(current_registry: Path) -> Mapping[str, Any]: - # Revalidate the already admitted identity through the same - # owner. In a global route the source is checked first, even - # when its mirror still advertises old quota or health. - return current_turn_host_launch_decision( - args, registry_path=current_registry, runtime_root=runtime_root, - identity=settlement_identity, replan_obligation_id=replan_obligation_id, - planned_effective_action=payload["turn_envelope"].get("effective_action"), - scheduler_execution_context=payload.get("scheduler_execution_context") or {}, - operator_inbox_urgency_projector=operator_inbox_urgency_projector, - # Keep this Turn's already dispatched required reads at launch. - turn_start_hook_dispatch=turn_start_hook_dispatch, - goal_ref=goal_ref, - ) - def run_built_in_host( request: Mapping[str, Any], ) -> dict[str, Any]: @@ -853,7 +880,8 @@ def run_built_in_host( registry_path=registry_path, goal_id=args.goal_id, planned_goal_ref=goal_ref, ) - ).with_launch_decision(current_launch_decision) + ).with_launch_decision(lambda registry: current_launch_decision( + registry, checkpoint_direction=request.get("phase") == "checkpoint_direction")) if getattr(args, "codex_operation_tools", False): from ..control_plane.turn_driver.codex_operation_host import ( run_codex_operation_host, @@ -887,12 +915,14 @@ def resolve_built_in_session_binding( turn_envelope, goal_admission=goal_admission, session_scope=session_scope, + exact_session=True, ) if goal_admission is not None else codex_cli_session_binding( runtime_root, turn_envelope, session_scope=session_scope, + exact_session=True, ) ) @@ -916,6 +946,61 @@ def resolve_dsh_operation_session(envelope: Mapping[str, Any]) -> dict[str, str] args, workspace=project, environ=operator_environ, ) + def run_direction(context: dict[str, Any]) -> dict[str, Any]: + from ..control_plane.turn_driver.executor import build_loopx_turn_host_request, _run_host, _run_host_runner + from ..control_plane.turn_driver.journal_store import turn_journal_path + from ..usage_goal import observe_goal_execution + from ..control_plane.collaboration.goal_instance_scope import collaboration_goal_scope + + if goal_admission is not None: + goal_admission.require_current() + with collaboration_goal_scope(registry_path, goal_id=args.goal_id, + agents=(args.agent_id,), caller_goal_ref=goal_ref, require_active=True): + pass + request = build_loopx_turn_host_request(payload) + request.update(phase="checkpoint_direction", checkpoint_context=context) + request["session"] = {**request["session"], "action": "resume"} + if args.host == "codex-cli": + binding = session_binding_resolver(envelope) + journal = json.loads(turn_journal_path(runtime_root, goal_id=args.goal_id, + turn_key=request["turn_key"]).read_text(encoding="utf-8")) + if not binding or binding != journal.get("checkpoint_host_binding"): + raise ValueError("Original current-Agent session binding is unavailable; preserve the result and reconcile") + session = dict(request["session"]) + session["action"] = "resume" + session["context_policy"] = {**session.get("context_policy", {}), "mode": "resume"} + request["session"] = session + admission = (goal_admission or FirstPartyHostGoalAdmission.for_plan( + registry_path=registry_path, goal_id=args.goal_id, planned_goal_ref=goal_ref, + )).with_launch_decision(lambda registry: current_launch_decision(registry, checkpoint_direction=True)) + # Built-in hosts hold their own launch lifetime at actual spawn. + lifetime = nullcontext() if args.host in {"codex-cli", "codex-app-server"} else admission.launch_lifetime( + operation="checkpoint_direction_launch") + with lifetime, observe_goal_execution(runtime_root, args.goal_id, host=args.host): + observation = (_run_host_runner(request, runner=host_runner) if host_runner is not None else + _run_host(request, argv=raw_argv, project=project, timeout_seconds=args.timeout_seconds)) + if observation.get("ok") is not True: + from ..control_plane.turn_driver.host_failure import BuiltInHostError + raise BuiltInHostError(str(observation.get("reason") or "Current Agent direction continuation failed"), + failure_kind=str(observation.get("failure_kind") or "unknown")) + decision = dict(observation["value"]) + if decision.get("stop_reason"): + return {"result_kind": "user_action_required", "stop_reason": decision["stop_reason"]} + if decision.get("result_kind") in {"wait", "user_action_required", "iteration_failed"}: + return decision + if decision.get("agent_vision_json"): + decision["agent_vision"] = json.loads(decision["agent_vision_json"]) + return {"read_context_id": decision.get("read_context_id"), + "agent_vision": decision.get("agent_vision"), + "vision_unchanged_reason": decision.get("vision_unchanged_reason")} + + from ..control_plane.turn_driver.checkpoint_continuation import CurrentAgentCheckpoint + _state_project, checkpoint_state = resolve_todo_state_path(registry_path=registry_path, + goal_id=args.goal_id, project=None, state_file=None) + current_checkpoint = CurrentAgentCheckpoint(registry_path, runtime_root, checkpoint_state, + settlement_identity, run_direction, + explicit_resume=bool(args.resume_turn_key and args.retry_failed_turn)) + def on_managed_start_admitted() -> None: replan_packet = stable_envelope.get("replan_action_packet") capability_guard = ( @@ -923,6 +1008,9 @@ def on_managed_start_admitted() -> None: if isinstance(replan_packet, Mapping) else None ) + from ..control_plane.goals.checkpoint_context_io import default_result_freshness_policy + + policy = default_result_freshness_policy(runtime_root, args.goal_id, settlement_identity.todo_id, goal_ref) ensure_turn_heartbeat_settlement_receipt( runtime_root, settlement_identity, @@ -930,6 +1018,8 @@ def on_managed_start_admitted() -> None: semantic_replan_obligation_id=replan_obligation_id, semantic_replan_capability_guard=capability_guard, goal_ref=goal_ref, + result_freshness=policy, + checkpoint_continuation="current_agent_v1" if policy is not None else None, ) managed_cadence = managed_cadence_start( @@ -942,6 +1032,8 @@ def on_managed_start_admitted() -> None: ) execution_started = bool(args.execute) + from ..control_plane.goals.checkpoint_context_io import TurnResultWork + result = run_loopx_turn_once( payload, decision_capture_payload=decision, @@ -958,6 +1050,7 @@ def on_managed_start_admitted() -> None: task_validator=task_validator, writeback=writeback if args.execute else None, completion_writeback=completion_writeback if args.execute else None, + post_result_checkpoint=current_checkpoint.enabled if args.execute else None, completion_intent=completion_intent if args.execute else None, terminal_closeout=terminal_closeout if args.execute else None, spend=spend if args.execute else None, @@ -973,6 +1066,7 @@ def on_managed_start_admitted() -> None: admit_start=managed_cadence.admit if args.execute else None, confirm_start=managed_cadence.confirm if args.execute else None, goal_admission=goal_admission, + result_work=TurnResultWork(registry_path, runtime_root, settlement_identity, goal_ref), ) if args.execute: result.update(drain_committed_turn_post_writeback_hooks( diff --git a/loopx/cli_commands/turn_todo_writeback.py b/loopx/cli_commands/turn_todo_writeback.py index 1e0052d96a..6e0705ab85 100644 --- a/loopx/cli_commands/turn_todo_writeback.py +++ b/loopx/cli_commands/turn_todo_writeback.py @@ -74,6 +74,10 @@ def write_turn_validated_completion( expected_todo_text_sha256: str | None = None, completion_delivery_workspace: Mapping[str, Any] | None = None, completion_validation_workspace_path: Path | None = None, + delivery_read_context_id: str | None = None, + no_followup: bool = False, + defer_no_followup: bool = False, + delivery_settlement_identity: Mapping[str, Any] | None = None, task_lease_idempotency_key: str | None = None, task_lease_expected_version: int | None = None, ) -> dict[str, Any]: @@ -87,6 +91,10 @@ def write_turn_validated_completion( role="agent", completion_turn_key=completion_turn_key, completion_identity_source="turn_settlement", + delivery_read_context_id=delivery_read_context_id, + delivery_settlement_identity=delivery_settlement_identity, + no_followup=no_followup, + defer_no_followup=defer_no_followup, expected_todo_text_sha256=expected_todo_text_sha256, require_captured_todo_text=True, completion_delivery_workspace=completion_delivery_workspace, diff --git a/loopx/cli_runtime.py b/loopx/cli_runtime.py index 0616e31931..c46e5ec1d1 100644 --- a/loopx/cli_runtime.py +++ b/loopx/cli_runtime.py @@ -96,6 +96,9 @@ def error(self, message: str) -> None: raise _SelectedParseRejected(message) +PrintPayload = Callable[[dict[str, object], str, Callable[[dict[str, object]], str]], None] + + def print_payload( payload: dict[str, object], fmt: str, diff --git a/loopx/collaboration_mcp.py b/loopx/collaboration_mcp.py index 0e0787cef9..62afd1c358 100644 --- a/loopx/collaboration_mcp.py +++ b/loopx/collaboration_mcp.py @@ -780,6 +780,7 @@ def resume(self, operation_id: str, *, prepared_source_request_id: str | None = path = self.path(operation_id) if self._read_stop(path) is not None: raise ValueError(DELEGATION_STOPPED_MESSAGE) + publish_committed = False try: with exclusive_file_lock( path, policy=LockAcquisitionPolicy.SINGLE_FLIGHT @@ -800,12 +801,20 @@ def resume(self, operation_id: str, *, prepared_source_request_id: str | None = else row["status"] not in DELEGATION_TERMINAL_STATUSES) if should_spawn: self._bound(row, require_active=True) + publish_committed = delegation_validation.committed_revalidation_key( + self, row, binding + ) is not None except LockAcquireTimeoutError: # A live worker already owns the operation lock. Observation is # sufficient; spawning another process cannot advance settlement. return self.read(operation_id) if should_spawn: - if prepared_source_request_id is None: + if publish_committed: + if prepared_source_request_id is None: + self.execute(operation_id) + else: + self.execute(operation_id, prepared_source_request_id=prepared_source_request_id) + elif prepared_source_request_id is None: self._spawn(operation_id) else: # Carry prepared CAS across startup; a delayed child only observes advanced work. @@ -859,7 +868,9 @@ def revalidate(self, operation_id: str) -> dict: self._record_turn_result(path, row, result, publish=False) recovered = delegation_validation.recover_validated_settlement(self, path, row, binding) if recovered: - self._spawn(operation_id) + # Revalidation already ran its bounded CLI. Publish that original + # result before its MCP caller closes; no new Host work is started. + self.execute(operation_id) return self.read(operation_id) def _bound( @@ -1348,6 +1359,7 @@ def _cli(self, binding: dict, *args: str, timeout: int = 60, # The Turn's Host transport names the process group its supervisor owns. environment[HOST_PROCESS_RECORD_ENV] = str(host_record) if delegated_lease is None: + # This argv-only CLI must not inherit the MCP conversation's input pipe. completed = subprocess.run([*_python_module_command("loopx.cli"), *arguments], cwd=binding["workspace"], capture_output=True, text=True, encoding="utf-8", # Requests are entirely in argv; never inherit the caller's MCP/HTTP pipe. @@ -1679,6 +1691,8 @@ def _delegated_lease_context(self, row: dict, binding: dict) -> dict | None: } def _complete_delegated_todo(self, row: dict, binding: dict) -> None: + if delegation_validation.original_task_completed(self, row, binding): + return lease = row.get("task_lease") if not isinstance(lease, dict): raise ValueError("delegation Todo completion requires its acquired task lease") @@ -1746,6 +1760,17 @@ def _complete_delegated_todo(self, row: dict, binding: dict) -> None: # Goal intent. Ordinary completion may precede its original Turn's # accounting (controller validation requires that order). Do not add # no-follow-up, which correctly requires already-settled receipts. + if delegation_validation.original_work_context(self, row, binding) is not None: + if "completion_read_context_id" not in row: + context = self._cli(binding, "checkpoint-context", "--goal-id", self.goal_id, + "--agent-id", binding["agent_id"], "--todo-id", binding["todo_id"], + "--turn-instance-id", self._turn_instance_id(row), "--purpose", "delivery_result") + row["completion_read_context_id"] = context["read_context_id"] + _write(self.path(row["identity"]["operation_id"]), row) + # This is a new work read, not a new Turn or direction judgment. + # Native completion independently verifies the retained candidate + # on this basis and rejects changes during that verification. + arguments += ["--delivery-read-context", row["completion_read_context_id"]] result = self._cli(binding, *arguments) if result.get("ok") is not True: raise ValueError( diff --git a/loopx/control_plane/agents/workspace_guard.py b/loopx/control_plane/agents/workspace_guard.py index 551a08403d..268b30960a 100644 --- a/loopx/control_plane/agents/workspace_guard.py +++ b/loopx/control_plane/agents/workspace_guard.py @@ -40,6 +40,7 @@ def _git_command_output(path: Path, *args: str) -> str | None: try: result = subprocess.run( ["git", "-C", str(path), *args], + stdin=subprocess.DEVNULL, check=False, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, @@ -135,6 +136,7 @@ def _git_origin_is_absent(path: Path) -> bool: try: result = subprocess.run( ["git", "-C", str(path), "config", "--get", "remote.origin.url"], + stdin=subprocess.DEVNULL, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=1.5, ) diff --git a/loopx/control_plane/collaboration/delegation_validation.py b/loopx/control_plane/collaboration/delegation_validation.py index bd9e4c776a..43605cb366 100644 --- a/loopx/control_plane/collaboration/delegation_validation.py +++ b/loopx/control_plane/collaboration/delegation_validation.py @@ -8,6 +8,7 @@ """ from pathlib import Path +import subprocess from ...agent_registry import load_goal_from_registry from ...materials import goal_state_path @@ -72,6 +73,7 @@ def validate(service, binding: dict) -> dict: effect=effect, registry_path=service.registry, goal_id=service.goal_id, delivery_workspace=before["delivery_workspace"], validation_workspace_path=Path(binding["workspace"]), + stdin=subprocess.DEVNULL, ) for effect in before["plan"]["effects"]] after = capture(service, binding) # The TS plan binds this task's work, claim/lifecycle and current rules. @@ -195,6 +197,31 @@ def matching_turn_key(service, row: dict, binding: dict) -> str | None: ) +def original_work_context(service, row: dict, binding: dict) -> dict | None: + key = matching_turn_key(service, row, binding) + if key is None: + return None + journal = load_turn_journal(turn_journal_path(service.root, goal_id=service.goal_id, turn_key=key)) + context = (journal or {}).get("result_work_context") + return context if isinstance(context, dict) else None + + +def original_task_completed(service, row: dict, binding: dict) -> bool: + """Reconcile this Turn's native completion before replaying its effects.""" + from ..goals.checkpoint_context_io import TurnResultWork + from ..quota.settlement import SettlementIdentity + + context = original_work_context(service, row, binding) + if context is None: + return False + identity = SettlementIdentity.from_runtime_payload(context["settlement_identity"]) + if (identity.goal_id, identity.agent_id, identity.todo_id, identity.turn_instance_id) != ( + service.goal_id, binding["agent_id"], binding["todo_id"], service._turn_instance_id(row)): + raise ValueError("delegation result work identity changed; reconcile the original Turn") + return TurnResultWork(service.registry, service.root, identity, + service._caller_goal_ref()).result_committed() + + def validated_turn_journal(service, row: dict, binding: dict) -> dict | None: turn_key = matching_turn_key(service, row, binding) if turn_key is None: diff --git a/loopx/control_plane/collaboration/inbox.py b/loopx/control_plane/collaboration/inbox.py index 0b77ebe5bb..cf1b33991b 100644 --- a/loopx/control_plane/collaboration/inbox.py +++ b/loopx/control_plane/collaboration/inbox.py @@ -11,6 +11,7 @@ import os import re import tempfile +import time from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path @@ -88,7 +89,20 @@ def _write(path: Path, value: dict) -> None: json.dump(value, f, ensure_ascii=False) f.flush() os.fsync(f.fileno()) - os.replace(tmp, path) + # Windows observers may briefly deny DELETE sharing. Retain the old + # complete record until replacement succeeds, as for lock sidecars. + # This retries only the same IO effect; persistent denial still fails. + deadline = time.monotonic() + 0.5 + while True: + try: + os.replace(tmp, path) + break + except OSError as error: + remaining = deadline - time.monotonic() + if (os.name != "nt" or getattr(error, "winerror", None) != 5 + or remaining <= 0): + raise + time.sleep(min(0.05, remaining)) finally: if os.path.exists(tmp): os.unlink(tmp) diff --git a/loopx/control_plane/coordination/cold_source_backup.py b/loopx/control_plane/coordination/cold_source_backup.py index 2d0cb1613d..b8f336a85c 100644 --- a/loopx/control_plane/coordination/cold_source_backup.py +++ b/loopx/control_plane/coordination/cold_source_backup.py @@ -14,6 +14,7 @@ import json import os from pathlib import Path, PurePosixPath +import posixpath import shutil import sqlite3 import stat @@ -26,6 +27,7 @@ build_scoped_state_backup_plan, execute_state_backup_plan, ) +from ..runtime.file_paths import windows_extended_path WITNESS_SCHEMA = "loopx_state_backup_source_witness_v1" @@ -114,7 +116,8 @@ def _safe_link(name: str, target: str, *, hardlink: bool) -> str: raise ColdSourceBackupError("cold_import_backup_link_unsafe") if hardlink: return _member_name(target) - resolved = os.path.normpath(str(PurePosixPath(name).parent / target)) + # Archive member names retain POSIX semantics on every host. + resolved = posixpath.normpath(str(PurePosixPath(name).parent / target)) if resolved == ".." or resolved.startswith("../"): raise ColdSourceBackupError("cold_import_backup_link_unsafe") return _member_name(resolved) @@ -192,6 +195,7 @@ def _matches(name: str, prefixes: list[str] | None) -> bool: def _scan_archive( archive: BinaryIO, *, selected: list[str] | None, workspace: Path ) -> tuple[dict[str, Any], list[dict[str, Any]], int, int, int]: + workspace = windows_extended_path(workspace) workspace.mkdir(mode=0o700, parents=True, exist_ok=True) workspace_info = workspace.lstat() if not stat.S_ISDIR(workspace_info.st_mode): diff --git a/loopx/control_plane/coordination/coordination_projection.ts b/loopx/control_plane/coordination/coordination_projection.ts index 18da16c4ac..f0af305ea6 100644 --- a/loopx/control_plane/coordination/coordination_projection.ts +++ b/loopx/control_plane/coordination/coordination_projection.ts @@ -91,6 +91,7 @@ const TODO_CONTRACT_REVISION_FIELDS: readonly (readonly string[])[] = [ ["completion_validation_revision", "completion_validation_revision_history"], ["completion_result"], ["completion_receipt_id"], + ["completion_requested_continuation"], ]; interface HistoricalTodoContract { diff --git a/loopx/control_plane/coordination/coordination_state_contract.generated.ts b/loopx/control_plane/coordination/coordination_state_contract.generated.ts index 79c81a5ee8..cafeee4c48 100644 --- a/loopx/control_plane/coordination/coordination_state_contract.generated.ts +++ b/loopx/control_plane/coordination/coordination_state_contract.generated.ts @@ -170,6 +170,7 @@ export const COORDINATION_STATE_CONTRACT = deepFreeze({ "no_followup", "successor_todo_ids", "completion_continuation", + "completion_requested_continuation", "completion_recovery", "completion_receipt_id", "replan_obligation_id", diff --git a/loopx/control_plane/coordination/coordination_state_contract_generated.py b/loopx/control_plane/coordination/coordination_state_contract_generated.py index 8fb413c7c8..fefae735bc 100644 --- a/loopx/control_plane/coordination/coordination_state_contract_generated.py +++ b/loopx/control_plane/coordination/coordination_state_contract_generated.py @@ -57,6 +57,7 @@ def _freeze(value: Any) -> Any: 'no_followup', 'successor_todo_ids', 'completion_continuation', + 'completion_requested_continuation', 'completion_recovery', 'completion_receipt_id', 'replan_obligation_id', diff --git a/loopx/control_plane/coordination/coordination_state_contract_v0.json b/loopx/control_plane/coordination/coordination_state_contract_v0.json index e8fde13ac4..26c081bd6c 100644 --- a/loopx/control_plane/coordination/coordination_state_contract_v0.json +++ b/loopx/control_plane/coordination/coordination_state_contract_v0.json @@ -46,6 +46,7 @@ "no_followup", "successor_todo_ids", "completion_continuation", + "completion_requested_continuation", "completion_recovery", "completion_receipt_id", "replan_obligation_id", diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index 8d400d3c9a..e3c980ebfc 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -1,5 +1,6 @@ import {requirePromotionRegisteredAgents} from "./shadow_registry_source.ts"; import {verifyReentrySettlement} from "./todo_terminal_reentry.ts"; +import {withTerminalDeliverySources, terminalDeliveryBasisCheck} from "./todo_delivery_context.ts"; import {readPromotionReceipt, commitPromotionAndReadBack} from './promotion_receipt.ts'; import {reviewedPromotionPlan, promotionPlanDigest, decodeReviewedPromotionOperation, REVIEWED_PROMOTION_OPERATION_RESULT_SCHEMA} from './reviewed_promotion_plan.ts'; import {registryAuthoritySourceCheck} from "./authority_source.ts"; @@ -1330,17 +1331,19 @@ export async function terminalLifecycleLocalCoordinationTodo( requireAuthorityStoreId(todoId, "linked successor Todo id")); const successorIntents = input.successor_intents.map((intent, index) => requireJsonObject(intent, `successor_intents[${index}]`)); - return await withCanonicalWriter(root, goalId, input.dry_run === true, async () => { + return await withTerminalDeliverySources(root, goalId, input, async () => { const store = await openRuntimeStore(root, goalId, dependencies); sourceAuthority = sourceAuthorityFor(store); providerEvidence.source_authority = sourceAuthority; const researchEvidence = new ResearchTerminalEvidenceHost(root, goalId, requireJsonObject(input.registry_source, "registry source")); try { - return {...await executeCoordinationTodoTerminalLifecycle(store, { + const execute = () => executeCoordinationTodoTerminalLifecycle(store, { ...(input.expected_todo_text_sha256 === undefined ? {} : { expected_todo_text_sha256: input.expected_todo_text_sha256 === null ? null : requireAuthorityStoreId(input.expected_todo_text_sha256, "expected Todo text digest"), }), + ...(input.delivery_context == null || requireJsonObject(input.delivery_context, "delivery context").read_context_id == null ? {} : {delivery_read_context_id: + requireAuthorityStoreId(requireJsonObject(input.delivery_context, "delivery context").read_context_id, "delivery read context id")}), validation_source_provider_revision: input.validation_source_provider_revision == null ? null : requireAuthorityStoreId(input.validation_source_provider_revision, "validation source provider revision"), validation_declaration_sha256: input.validation_declaration_sha256 == null @@ -1371,6 +1374,8 @@ export async function terminalLifecycleLocalCoordinationTodo( ...(input.claim_operation_id == null ? {} : {claim_operation_id: requireAuthorityStoreId(input.claim_operation_id, "claim_operation_id")}), allow_user_gate_auto_acquire: input.allow_user_gate_auto_acquire as boolean, requested_no_followup: input.requested_no_followup as boolean, + ...(input.defer_no_followup === undefined ? {} : { + defer_no_followup: requireBoolean(input.defer_no_followup, "defer_no_followup")}), requested_completion_identity_source: input.requested_completion_identity_source === null || input.requested_completion_identity_source === undefined @@ -1400,9 +1405,15 @@ export async function terminalLifecycleLocalCoordinationTodo( dry_run: input.dry_run as boolean, now: claimObservedAt(input.observed_at), }, async () => await authoritySourcesCurrent() && researchEvidence.current(), - researchEvidence.qualify, request => verifyReentrySettlement(root, request)), ...providerEvidence}; + researchEvidence.qualify, request => verifyReentrySettlement(root, request), + terminalDeliveryBasisCheck(root, input, store)); + let result = await execute(); + // Only retry an explicit rejected CAS; ambiguous effects retain identity. + for (let attempt = 1; input.delivery_context != null && attempt < 3 && + result.conflict_kind === "provider_revision_mismatch"; attempt++) result = await execute(); + return {...result, ...providerEvidence}; } finally {await researchEvidence.close();} - }, {businessWrite: "todo_terminal"}); + }); } catch (error) { return {schema_version: COORDINATION_TODO_TERMINAL_LIFECYCLE_RESULT_SCHEMA, status: "failed", changed: false, diff --git a/loopx/control_plane/coordination/local_authority_shadow_history.py b/loopx/control_plane/coordination/local_authority_shadow_history.py index 73f591a1f6..71b1012243 100644 --- a/loopx/control_plane/coordination/local_authority_shadow_history.py +++ b/loopx/control_plane/coordination/local_authority_shadow_history.py @@ -16,6 +16,7 @@ from pathlib import Path from typing import Any +from ..runtime.file_paths import windows_extended_path from ..content_digest import ENVELOPED_SHA256_PATTERN from .local_authority_shadow_projection import PARTITIONS, sha256_digest, text_digest from .coordination_state_contract_generated import ( @@ -94,7 +95,7 @@ def _as_object(value: object) -> dict[str, Any]: def _load_json(path: Path) -> dict[str, Any]: try: - raw = json.loads(path.read_text(encoding="utf-8")) + raw = json.loads(windows_extended_path(path).read_text(encoding="utf-8")) except (ValueError, UnicodeError) as error: raise OutboxError( "outbox_file_invalid", f"{path.name} is not valid JSON" @@ -158,7 +159,7 @@ def _index_entry_files( prepared: dict[_EntryKey, Path] = {} committed: dict[_EntryKey, Path] = {} try: - paths = list(directory.iterdir()) + paths = [directory / path.name for path in windows_extended_path(directory).iterdir()] except FileNotFoundError: return prepared, committed except OSError as error: @@ -166,10 +167,10 @@ def _index_entry_files( "outbox_file_unavailable", "outbox inventory cannot be read" ) from error for path in paths: - if path.name == "drain-cursor.json" and not path.is_symlink(): + if path.name == "drain-cursor.json" and not windows_extended_path(path).is_symlink(): continue match = _ENTRY_FILE.fullmatch(path.name) - if match is None or path.is_symlink() or not path.is_file(): + if match is None or windows_extended_path(path).is_symlink() or not windows_extended_path(path).is_file(): raise OutboxError( "outbox_file_invalid", "outbox contains unclassified evidence" ) @@ -282,7 +283,7 @@ def retired_residue(directory: Path) -> list[Path]: an exact receipt proof before it can be reclaimed. """ - if not directory.is_dir(): + if not windows_extended_path(directory).is_dir(): return [] watermark = _retired_watermark(directory) prepared, committed = _index_entry_files(directory) @@ -357,7 +358,7 @@ def cursor_path(directory: Path) -> Path: def read_cursor(directory: Path) -> dict[str, Any] | None: path = cursor_path(directory) - if path.is_symlink(): + if windows_extended_path(path).is_symlink(): raise OutboxError( "outbox_file_invalid", "cursor must belong to its own partition" ) diff --git a/loopx/control_plane/coordination/postgresql_authority_store.ts b/loopx/control_plane/coordination/postgresql_authority_store.ts index 78d5fbd2c2..64162efcac 100644 --- a/loopx/control_plane/coordination/postgresql_authority_store.ts +++ b/loopx/control_plane/coordination/postgresql_authority_store.ts @@ -6,6 +6,7 @@ import type { AuthorityStoreCommitResult, AuthorityStoreIdentityResult, AuthorityStoreLoadResult, + AuthorityStoreHead, AuthorityStoreReadFailure, AuthorityStoreReceiptResult, AuthorityStoreScanResult, @@ -652,6 +653,44 @@ export class PostgreSqlAuthorityStore implements AuthorityStore { } } + /** Retain the store-incarnation and Goal-head locks through a synchronous + * external checkpoint append. No authority mutation or revision is created; + * as with local providers, SQL rollback cannot undo external run files. */ + async withCheckpointHead(save: (head: AuthorityStoreHead, identity: string) => JsonObject): Promise { + const connection = await this.connect(); + let transactionOpen = false; + let releaseError: Error | undefined; + try { + await beginTenantTransaction(connection, this.tenantId, {readOnly: false}); + transactionOpen = true; + // Match commitAuthority/rotation lock order; FOR SHARE excludes a Goal + // writer without serializing independent checkpoint readers or Goals. + await lockStoreIdentityTransaction(connection, "shared"); + const identity = await requireStoreIdentity(connection); + const value = oneRow(await connection.query(`${SELECT_HEAD_SQL} FOR SHARE`, + [this.tenantId, this.goalId]), "checkpoint PostgreSQL authority head"); + if (value === null) throw new AuthorityStoreProtocolError("checkpoint authority head is missing"); + const head = decodeHeadRow(value); + if (head.head === null) throw new AuthorityStoreProtocolError("checkpoint authority head is missing"); + const result = save({head: structuredClone(head.head), cursor: head.cursor, + provider_revision: providerRevisionToken(identity, head.provider_revision)}, identity); + if (result instanceof Promise) throw new AuthorityStoreProtocolError("checkpoint save must be synchronous"); + const rollbackError = await rollback(connection); + transactionOpen = false; + if (rollbackError !== null) { + releaseError = rollbackError; + throw rollbackError; + } + return result; + } catch (error) { + if (transactionOpen) releaseError = (await rollback(connection)) ?? undefined; + else releaseError ??= asError(error); + throw error; + } finally { + await connection.release(releaseError); + } + } + async commitAuthority(commit: AuthorityStoreCommit): Promise { let normalized: AuthorityStoreCommit; let expectedRevision: ReturnType; diff --git a/loopx/control_plane/coordination/shadow_management.py b/loopx/control_plane/coordination/shadow_management.py index c64707a6d6..ae84e4b08f 100644 --- a/loopx/control_plane/coordination/shadow_management.py +++ b/loopx/control_plane/coordination/shadow_management.py @@ -21,6 +21,7 @@ ) from .local_authority_shadow_projection import sha256_digest from ..content_digest import ENVELOPED_SHA256_PATTERN +from ..runtime.file_paths import windows_extended_path SHADOW_CAPTURE_PROFILE = "file_outbox_v1" _DIGEST = ENVELOPED_SHA256_PATTERN @@ -107,7 +108,7 @@ def _binding( def read_shadow_management_state(runtime_root: Path, goal_id: str) -> dict[str, Any] | None: """Validate the closed local journal shape without performing any writes.""" try: - raw = shadow_management_state_path(runtime_root, goal_id).read_text(encoding="utf-8") + raw = windows_extended_path(shadow_management_state_path(runtime_root, goal_id)).read_text(encoding="utf-8") except FileNotFoundError: return None except UnicodeError as exc: @@ -192,7 +193,7 @@ def read_shadow_bootstrap_source_path( directory = hashlib.sha256(operation_id.encode("utf-8")).hexdigest() path = shadow_management_directory(runtime_root, goal_id) / "operations" / directory / "manifest.json" try: - manifest = json.loads(path.read_text(encoding="utf-8")) + manifest = json.loads(windows_extended_path(path).read_text(encoding="utf-8")) expected_manifest_schema = ( SHADOW_MANAGEMENT_EXACT_MANIFEST_SCHEMA if state["schema_version"] == SHADOW_MANAGEMENT_EXACT_STATE_SCHEMA diff --git a/loopx/control_plane/coordination/todo_delivery_context.ts b/loopx/control_plane/coordination/todo_delivery_context.ts new file mode 100644 index 0000000000..26d8c0088a --- /dev/null +++ b/loopx/control_plane/coordination/todo_delivery_context.ts @@ -0,0 +1,177 @@ +/** Source locks and same-head basis check for Turn result commits. + * Validation runs between invocations. Provider CAS, not these locks, owns the + * atomic Todo delta and receipt. No checkpoint provider transaction is nested. */ +import {createHash} from "node:crypto"; +import {readFileSync} from "node:fs"; +import {join, resolve} from "node:path"; +import type {JsonObject} from "../effect_program.ts"; +import {settlementIdentity, settlementIdentityPayload} from "../effect_program.ts"; +import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; +import {withFileMutationLock} from "../effect_runtime_io.ts"; +import type {AuthorityStore, AuthorityStoreHead} from "./authority_store.ts"; +import {authorityStoreSourceAuthority} from "./authority_store.ts"; +import {canonicalAuthoritySha256} from "./authority_store_codec.ts"; +import {indexCoordinationProjection} from "./coordination_projection.ts"; +import {withCanonicalWriter} from "./local_authority_write.ts"; +import {legacyCoordinationTodoLockPath} from "./legacy_writer_lock_paths.ts"; +import {checkpointProviderFacts} from "../goals/checkpoint_authority.ts"; +import {evaluateCheckpointReadContext, evaluateResultWorkBinding, requireCompleteCheckpointGoalSource} from "../goals/checkpoint_read_context.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import {inspectCheckpointReplay} from "../goals/checkpoint_commit.ts"; +import {resultFreshnessRequired, heartbeatReceiptFactFromEvent, selectEffectiveHeartbeatReceipt} + from "../quota/heartbeat_receipt_identity.ts"; +import {QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, readQuotaSettlementFromSnapshot, + readQuotaSettlementSnapshot} from "../quota/settlement_readback.ts"; + +const digest = (bytes: Uint8Array): string => createHash("sha256").update(bytes).digest("hex"); +function bytes(path: string): Buffer { + try { return readFileSync(path); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return Buffer.alloc(0); + } +} + +/** Index -> registry -> maintenance -> Todo projection -> state -> provider CAS. */ +export async function withTerminalDeliverySources( + root: string, goalId: string, input: JsonObject, write: () => Promise, +): Promise { + if (input.delivery_context == null) return withCanonicalWriter(root, goalId, input.dry_run === true, write, {businessWrite: "todo_terminal"}); + const context = requireJsonObject(input.delivery_context, "delivery context"); + const registry = requireJsonObject(input.registry_source, "registry source"); + const state = resolve(requireNonEmptyString(context.state_file, "delivery state file")); + return withFileMutationLock(join(root, "goals", goalId, "runs", "index.jsonl"), () => + withFileMutationLock(requireNonEmptyString(registry.path, "registry path"), () => + withCanonicalWriter(root, goalId, false, () => + withFileMutationLock(legacyCoordinationTodoLockPath(root, goalId), () => + withFileMutationLock(state, write, 30_000), 30_000), {businessWrite: "todo_terminal"}), 30_000), 30_000); +} + +/** Called only after durable receipt recovery, against the head used for CAS. */ +export function terminalDeliveryBasisCheck(root: string, input: JsonObject, store: AuthorityStore): + ((head: AuthorityStoreHead) => Promise) | undefined { + if (input.delivery_context == null) { + if (input.command !== "complete") return undefined; + return async head => { + if (indexCoordinationProjection(head.head, String(input.goal_id)).todos.get(String(input.todo_id))?.role === "user") return {ok: true}; + const snapshot = await readQuotaSettlementSnapshot(root, String(input.goal_id)); + const registrySource = requireJsonObject(input.registry_source, "registry source"); + const document = requireJsonObject(JSON.parse(readFileSync(String(registrySource.path), "utf8")), "registry"); + const registry = jsonObject(document.payload) ?? document; + const goal = Array.isArray(registry.goals) ? registry.goals.map(jsonObject).find(row => row?.id === input.goal_id) : null; + const goalRef = registry.profile_id === "source_session_v1" + ? {goal_id: input.goal_id, goal_instance_id: goal?.goal_instance_id} : null; + const relevant = snapshot.events.filter(event => event.event_kind === "quota_should_run" && + jsonObject(event.details)?.todo_id === input.todo_id && + canonicalAuthoritySha256(event.goal_ref ?? null) === canonicalAuthoritySha256(goalRef)); + const original = relevant.filter(event => event.agent_id === input.actor_agent_id && + [event.run_id, jsonObject(event.details)?.settlement_effect_id].includes(input.requested_completion_turn_key)); + if (original.length && !original.some(resultFreshnessRequired)) return {ok: true}; + if (relevant.some(resultFreshnessRequired)) return {ok: false, + error_code: "delivery_work_context_required", reread_required: false, + error: "This Todo has a protected Turn admission. Restore its original Turn and work validation before completing.", + next_action: "Resume the admitted Turn; do not omit its identity or create a replacement submission."}; + return {ok: true}; + }; + } + const context = requireJsonObject(input.delivery_context, "delivery context"); + const binding = requireJsonObject(context.identity, "delivery identity"); + const identity = settlementIdentity({goal_id: String(binding.goal_id), agent_id: String(binding.agent_id), + todo_id: String(binding.todo_id), turn_instance_id: String(binding.turn_instance_id), + replan_obligation_id: binding.replan_obligation_id == null ? null : String(binding.replan_obligation_id)}); + const rejected = (code: string, error: string): JsonObject => ({ok: false, error_code: code, + error, reread_required: true, next_action: "Read delivery_result context for the original Turn; recheck the candidate and validation before retrying."}); + return async head => { + if (canonicalAuthoritySha256(binding) !== canonicalAuthoritySha256(settlementIdentityPayload(identity)) || + identity.goal_id !== input.goal_id || identity.todo_id !== input.todo_id || identity.agent_id !== input.actor_agent_id || + ![identity.effect_id, identity.turn_instance_id].includes(String(input.requested_completion_turn_key))) { + return rejected("delivery_identity_mismatch", "Delivery basis belongs to another Goal, Agent, Todo or Turn."); + } + const receiptPath = join(root, "goals", identity.goal_id, "checkpoint-contexts", + `${createHash("sha256").update(identity.effect_id + ":delivery_result").digest("hex")}.json`); + if (context.read_context_id == null) { + // A malformed shared supplement is optional only when the original + // writeback proves the ordinary path. A first-delivery read cannot be + // admitted after that writeback; absent history is not such proof. + const unknown = () => rejected("checkpoint_commit_unknown", + "Cannot establish optional enrollment from the original Turn; preserve its receipts and reconcile before retrying."); + try { + try { readFileSync(receiptPath); return unknown(); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + const snapshot = await readQuotaSettlementSnapshot(root, identity.goal_id); + const readback = readQuotaSettlementFromSnapshot({ + ...binding, schema_version: QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, runtime_root: root, + infer_turn_instance_id: false, allow_unbound_binding: false, + }, snapshot); + const prior = jsonObject(readback.writeback_run); + const writeback = jsonObject(jsonObject(readback.writeback)?.result); + const purpose = jsonObject(jsonObject(prior?.vision_checkpoint)?.read_context)?.purpose; + if (prior === null || writeback?.failure !== null || + (purpose != null && purpose !== "supplement_checkpoint")) return unknown(); + inspectCheckpointReplay({runtime_root: root, goal_id: identity.goal_id, prior}); + return {ok: true}; + } catch { return unknown(); } + } + if (context.capture_error != null) return rejected("delivery_source_unavailable", String(context.capture_error)); + let receipt: JsonObject | null = null; + try { receipt = jsonObject(JSON.parse(readFileSync(receiptPath, "utf8"))); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + const snapshot = await readQuotaSettlementSnapshot(root, identity.goal_id); + const goalRef = jsonObject(requireJsonObject(context.facts, "delivery source facts").source)?.goal_ref ?? null; + const entries = snapshot.events.filter(event => event.event_kind === "quota_should_run" && + event.agent_id === identity.agent_id && event.run_id === identity.turn_instance_id && + canonicalAuthoritySha256(event.goal_ref ?? null) === canonicalAuthoritySha256(goalRef)) + .map(event => ({fact: heartbeatReceiptFactFromEvent(event), value: event})); + const required = resultFreshnessRequired(selectEffectiveHeartbeatReceipt(identity.goal_id, identity.agent_id, entries)); + if (required && receipt?.result_only !== true) return rejected("delivery_work_context_required", + "Restore the default protected Turn's work basis before submitting."); + const selected = indexCoordinationProjection(head.head, identity.goal_id).todos.get(identity.todo_id!); + const resultCommitted = required && selected?.status === "done" && + [identity.effect_id, identity.turn_instance_id].includes(String(selected.completion_turn_key)); + // Successful result recovery ends its freshness check. A new terminal + // upgrade still needs the actual Goal source; its own receipt is recovered + // before this callback and does not need a new source check. + if (resultCommitted && !(input.requested_no_followup === true && input.defer_no_followup !== true)) { + return {ok: true}; + } + if (required && !resultCommitted && (input.validation_receipt != null || input.goal_acceptance_validation_receipts != null)) { + if (input.result_validation_binding == null) return rejected("delivery_validation_binding_required", + "Native validation must remain bound to its candidate and original work basis."); + const validation = evaluateResultWorkBinding({phase: "check_validation", identity: binding, + receipt, terminal_input: input, binding: input.result_validation_binding}); + if (validation.ok !== true) return validation; + } + if (input.requested_no_followup === true && !required) { + return {...rejected("delivery_no_followup_unsupported", + "Protected no-follow-up completion is not admitted in this protocol stage; retain the original identity and receipts."), + reread_required: false, next_action: "This protocol stage supports ordinary result delivery; do not retry an unsupported terminal intent or bypass an unresolved Turn."}; + } + const facts = requireJsonObject(context.facts, "delivery source facts"); + const authority = authorityStoreSourceAuthority(store); + if (authority !== "file_v0" && authority !== "sqlite_v0" && !(required && authority === "postgresql_v0")) { + return rejected("delivery_provider_unsupported", "Delivery freshness requires File or SQLite authority."); + } + const source = requireJsonObject(facts.source, "delivery source"); + const state = resolve(requireNonEmptyString(context.state_file, "delivery state file")); + if (resolve(String(source.state_file)) !== state || + digest(bytes(state)) !== context.state_sha256 || + digest(bytes(join(root, "goals", identity.goal_id, "runs", "index.jsonl"))) !== context.index_sha256) { + return rejected("delivery_source_capture_changed", "Source changed while capturing the completion request; retry the same read identity."); + } + if (resultCommitted) { + try { requireCompleteCheckpointGoalSource(facts); } + catch (error) { + if (!(error instanceof EffectRuntimeRequestError) || error.code !== "checkpoint_goal_source_incomplete") throw error; + return {...rejected(error.code, error.message), reread_required: false, + next_action: "Restore the Goal owner's actual document; resume this original Turn and retain its successful result and quota receipts."}; + } + return {ok: true}; + } + const storeId = await store.storeIdentity(); + if (storeId.status !== "available") return rejected("delivery_store_unavailable", "Cannot establish the current authority store identity."); + const current = checkpointProviderFacts(identity.goal_id, facts, head, storeId.store_identity, authority, true); + return evaluateCheckpointReadContext({phase: "check", purpose: "delivery_result", identity: binding, + read_context_id: context.read_context_id, receipt, + facts: current}); + }; +} diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index 764d222a4a..b65110b360 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -24,6 +24,7 @@ import type { JsonObject } from "../effect_program.ts"; import type { AuthorityStore, AuthorityStoreCommit, + AuthorityStoreHead, } from "./authority_store.ts"; import { AuthorityStoreProtocolError, @@ -100,6 +101,8 @@ interface CoordinationTodoTerminalLifecycleBaseInput { /** Captured task digest. Null refuses fresh completion without source proof; * omission preserves unscoped callers. Historical receipts recover first. */ readonly expected_todo_text_sha256?: string | null; + /** Immutable read identity, included in the operation payload commitment. */ + readonly delivery_read_context_id?: string; readonly review_basis?: {readonly provider_revision: string; readonly registry_sha256: string}; /** Presence selects source-bound validation; null means no effect issued yet. */ readonly validation_source_provider_revision?: string | null; @@ -119,6 +122,7 @@ interface CoordinationTodoTerminalLifecycleBaseInput { readonly lease_expected_version: number | null; readonly allow_user_gate_auto_acquire: boolean; readonly requested_no_followup: boolean; + readonly defer_no_followup?: boolean; readonly requested_completion_identity_source: CompletionIdentitySource | null; readonly linked_successor_todo_ids: readonly string[]; readonly successor_intents: readonly JsonObject[]; @@ -377,6 +381,12 @@ function normalizeTerminalInput( (typeof raw.expected_todo_text_sha256 !== "string" || !BARE_SHA256_PATTERN.test(raw.expected_todo_text_sha256))) { throw new AuthorityStoreProtocolError("expected Todo text commitment must be a SHA-256 digest"); } + if (raw.delivery_read_context_id !== undefined) { + requireAuthorityStoreId(raw.delivery_read_context_id, "delivery read context id"); + if (raw.operation_identity.kind !== "completion_turn" || raw.user_update !== undefined || raw.review_basis !== undefined) { + throw new AuthorityStoreProtocolError("delivery basis requires an ordinary Turn completion"); + } + } if (raw.review_basis !== undefined) { const basis = canonicalAuthorityObject(raw.review_basis, "terminal review basis"); if (Object.keys(basis).some(key => !["provider_revision", "registry_sha256"].includes(key)) || @@ -431,6 +441,8 @@ function normalizeTerminalInput( "allow_user_gate_auto_acquire", ), requested_no_followup: requireBoolean(raw.requested_no_followup, "requested_no_followup"), + ...(raw.defer_no_followup === undefined ? {} : { + defer_no_followup: requireBoolean(raw.defer_no_followup, "defer_no_followup")}), requested_completion_identity_source: raw.requested_completion_identity_source === null ? null @@ -506,6 +518,10 @@ function terminalRequestSha(input: CoordinationTodoTerminalLifecycleInput): stri validation_receipt: input.validation_receipt, goal_acceptance_source_binding: input.goal_acceptance_source_binding ?? null, goal_acceptance_validation_receipts: input.goal_acceptance_validation_receipts ?? null} : {}), + ...(input.delivery_read_context_id === undefined ? {} : { + delivery_read_context_id: input.delivery_read_context_id, + note: input.note, evidence: input.evidence, reason: input.reason, + }), // Older receipts deliberately retain their original fingerprint. A reviewed // command binds both its approved snapshot and its complete prose intent. ...(input.review_basis === undefined ? {} : {review_basis: input.review_basis, @@ -529,6 +545,7 @@ function terminalRequestSha(input: CoordinationTodoTerminalLifecycleInput): stri lease_expected_version: input.lease_expected_version, allow_user_gate_auto_acquire: input.allow_user_gate_auto_acquire, requested_no_followup: input.requested_no_followup, + ...(input.defer_no_followup === true ? {defer_no_followup: true} : {}), requested_completion_turn_key: input.requested_completion_turn_key, requested_completion_identity_source: input.requested_completion_identity_source, linked_successor_todo_ids: input.linked_successor_todo_ids, @@ -843,6 +860,9 @@ async function commitTerminalResult( todo_id: input.todo_id, command: input.command, request_sha256: requestSha, + ...(input.delivery_read_context_id === undefined ? {} : { + delivery_read_context_id: input.delivery_read_context_id, + }), result, }]; return terminalReceipt(input, requestSha).commit(store, commit); @@ -1013,7 +1033,7 @@ function terminalTarget( ...(input.evidence === null ? {} : {evidence: input.evidence}), ...(input.reason === null ? {} : {reason: input.reason}), ...(input.decision_outcome === null ? {} : {decision_outcome: input.decision_outcome}), - ...(input.requested_no_followup ? {no_followup: true} : {}), + ...(input.defer_no_followup === true ? {no_followup: false} : input.requested_no_followup ? {no_followup: true} : {}), ...(successorIds.length === 0 ? {} : {successor_todo_ids: successorIds}), ...(acceptedResult === null ? {} : {completion_result: acceptedResult}), }; @@ -1024,6 +1044,11 @@ function terminalTarget( // status/quota consumers do not infer an unclassified terminal record. next.completion_continuation = "active_goal"; if (successorIds.length > 0) next.superseded_by = successorIds[0]; + // Retirement is a committed result of this Turn, not deliverable acceptance. + // Never bind a previously unscoped terminal record to a later caller. + if (todo.status !== "done" && input.requested_completion_turn_key !== null) { + next.completion_turn_key = input.requested_completion_turn_key; + } } if (completion?.decision === "commit") { Object.assign(next, completion.metadata_updates); @@ -1186,6 +1211,44 @@ function semanticDuplicate( return undefined; } +async function exactStoredDeferredParent( + store: AuthorityStore, input: ResolvedCoordinationTodoTerminalLifecycleInput, + todo: JsonObject, +): Promise { + const operationId = completionTurnOperationId(input, false); + const saved = await store.readReceipt(operationId); + if (saved.status !== "found" || saved.receipts.length !== 1) return null; + const receipt = saved.receipts[0]; + if (receipt.schema_version !== COORDINATION_TODO_TERMINAL_LIFECYCLE_RECEIPT_SCHEMA || + receipt.operation_id !== operationId || receipt.goal_id !== input.goal_id || + receipt.todo_id !== input.todo_id || receipt.command !== "complete" || + typeof receipt.delivery_read_context_id !== "string") return null; + const result = canonicalAuthorityObject(receipt.result, "original completion result"); + if (result.completion_policy == null) return null; + const recordedPolicy = canonicalAuthorityObject(result.completion_policy, "original completion policy"); + if (!Object.hasOwn(recordedPolicy, "effective_claimed_by")) return null; + const policy = input.completion_policy_request; + if (policy?.claimed_by != null && policy.claimed_by !== todo.claimed_by) return null; + // Optional transport defaults preserve the committed result. Even this + // reconstruction must match its entire original fingerprint; no supplied + // actor, lease, evidence, read identity or validation is replaced. + const recovered = {...input, + expected_role: input.expected_role ?? requireLiteral(todo.role, TODO_ROLES, "original Todo role"), + note: input.note ?? optionalString(todo.note, "original note"), + evidence: input.evidence ?? optionalString(todo.evidence, "original evidence"), + reason: input.reason ?? optionalString(todo.reason, "original reason"), + delivery_read_context_id: input.delivery_read_context_id ?? receipt.delivery_read_context_id, + completion_policy_request: policy === null ? null : {...policy, + claimed_by: recordedPolicy.effective_claimed_by}, + operation_id: operationId, defer_no_followup: true}; + for (const requestedNoFollowup of [false, true]) { + const parent = {...recovered, requested_no_followup: requestedNoFollowup}; + const exact = await terminalReceipt(parent, terminalRequestSha(parent)).read(store); + if (exact?.status === "replayed") return exact; + } + return null; +} + /** * Execute complete/supersede as one provider-neutral CAS transaction. * @@ -1200,6 +1263,7 @@ export async function executeCoordinationTodoTerminalLifecycle( authoritySourcesCurrent: AuthoritySourceCheck = uncheckedAuthoritySource, terminalEvidenceGuard: TodoTerminalEvidenceGuard | null = null, verifyLifecycleSettlement?: (input: CoordinationTodoTerminalLifecycleInput) => Promise, + deliveryBasisCheck?: (head: AuthorityStoreHead) => Promise, ): Promise { let normalized: CoordinationTodoTerminalLifecycleInput; try { @@ -1210,6 +1274,21 @@ export async function executeCoordinationTodoTerminalLifecycle( error instanceof Error ? error.message : "invalid Todo terminal lifecycle request", ); } + if (normalized.operation_identity.kind === "completion_turn" && + normalized.delivery_read_context_id === undefined && deliveryBasisCheck !== undefined) { + const operationId = completionTurnOperationId(normalized, normalized.requested_no_followup && normalized.defer_no_followup !== true); + const historical = await store.readReceipt(operationId); + const original = historical.status === "found" && historical.receipts.length === 1 + ? historical.receipts[0] : null; + if (original?.schema_version === COORDINATION_TODO_TERMINAL_LIFECYCLE_RECEIPT_SCHEMA && + original.operation_id === operationId && original.goal_id === normalized.goal_id && + original.todo_id === normalized.todo_id && original.command === normalized.command && + typeof original.delivery_read_context_id === "string") { + // Recover only a committed request's own key. The complete request hash + // below must still match; this cannot authorize a new or changed intent. + normalized = {...normalized, delivery_read_context_id: original.delivery_read_context_id}; + } + } const requestSha = terminalRequestSha(normalized); let input: ResolvedCoordinationTodoTerminalLifecycleInput; let head: LoadedAuthority; @@ -1224,13 +1303,57 @@ export async function executeCoordinationTodoTerminalLifecycle( head = observation.authority; } else { input = {...normalized, operation_id: normalized.operation_identity.kind === "completion_turn" || isLifecycleCloseout(normalized) - ? completionTurnOperationId(normalized, normalized.requested_no_followup) + ? completionTurnOperationId(normalized, normalized.requested_no_followup && normalized.defer_no_followup !== true) : normalized.operation_identity.operation_id}; // Named operation recovery reports history, not present execution authority. const receipt = terminalReceipt(input, requestSha); const replay = await receipt.read(store); + if (replay?.reason_code === "coordination_operation_identity_mismatch" && + normalized.operation_identity.kind === "completion_turn" && + normalized.requested_no_followup && normalized.defer_no_followup !== true) { + // Lifecycle reentry may already own the closeout phase. Recover an exact + // deferred RESULT request as history, never reinterpret the other route's + // closeout receipt or authorize another terminal mutation. The parent + // fingerprint still binds its actor, lease, evidence and validation. + const parent = {...input, operation_id: completionTurnOperationId(input, false), + defer_no_followup: true}; + const original = await terminalReceipt(parent, terminalRequestSha(parent)).read(store); + if (original?.status === "replayed") return original; + } + if (replay?.reason_code === "coordination_operation_identity_mismatch" && + normalized.operation_identity.kind === "completion_turn" && + normalized.requested_no_followup && normalized.defer_no_followup === true) { + // Pending checkpoint reentry recovers the accepted RESULT as history. + // It cannot finalize the continuation or perform a new mutation. The + // full historical hash still binds every supplied execution identity. + const current = await store.loadAuthority(); + if (current.status === "loaded") { + let todo: JsonObject | undefined; + try { + todo = indexCoordinationProjection(current.head, input.goal_id).todos.get(input.todo_id); + } catch { + return replay; + } + if (todo?.status === "done" && + todo.completion_turn_key === input.requested_completion_turn_key && + todo.completion_requested_continuation === "no_followup") { + const original = await exactStoredDeferredParent(store, input, todo); + if (original?.status === "replayed") return original; + } + } + } + if (replay?.reason_code === "coordination_operation_identity_mismatch" && + normalized.operation_identity.kind === "completion_turn" && + !normalized.requested_no_followup && normalized.defer_no_followup !== true) { + // The derived checkpoint phase disappears after settlement. Recover only + // the exact original RESULT request for an ordinary continuation; every + // supplied intent, actor, lease, read identity and validation stays bound. + const originalInput = {...input, defer_no_followup: true}; + const original = await terminalReceipt(originalInput, terminalRequestSha(originalInput)).read(store); + if (original?.status === "replayed") return original; + } if (replay !== null) return replay; - if (normalized.operation_identity.kind === "completion_turn" && normalized.requested_no_followup) { + if (normalized.operation_identity.kind === "completion_turn" && normalized.requested_no_followup && normalized.defer_no_followup !== true) { // Older adapters used the ordinary id for an already accepted closeout. // Recover that exact historical request before reading current authority. // An ordinary receipt has a different hash and is checked below as the @@ -1258,6 +1381,17 @@ export async function executeCoordinationTodoTerminalLifecycle( try { input = {...input, ...await claimExecutionProof(store, head.head, input, input.claim_operation_id, input.requested_no_followup), claim_operation_id: undefined}; } catch (error) { return terminalFailure("claim_execution_unavailable", error instanceof Error ? error.message : "Original task claim unavailable"); } } + // Lifecycle reentry can only alter a previously accepted continuation. Its + // original quota settlement is verified below; delivery-result admission + // governs new work and cannot require this route to become a replacement Turn. + if (!isLifecycleCloseout(input) && + (input.delivery_read_context_id !== undefined || deliveryBasisCheck !== undefined)) { + if (deliveryBasisCheck === undefined) return terminalFailure("delivery_basis_check_required", + "Protected completion requires its source and provider basis check.", {}, "decision_rejection"); + const basis = await deliveryBasisCheck(head); + if (basis.ok !== true) return terminalFailure(String(basis.error_code), String(basis.error), + {delivery_read_context: basis}, "decision_rejection"); + } let update: NormalizedCoordinationTodoUpdateInput | undefined; if (input.user_update !== undefined) { try { @@ -1284,7 +1418,7 @@ export async function executeCoordinationTodoTerminalLifecycle( const reviewedRevision = input.review_basis?.provider_revision ?? update?.expected_provider_revision; const validationRevision = input.user_update?.validation_source_provider_revision ?? input.validation_source_provider_revision; if ((reviewedRevision !== undefined && reviewedRevision !== head.provider_revision) || - (validationRevision != null && validationRevision !== head.provider_revision)) { + (input.delivery_read_context_id === undefined && validationRevision != null && validationRevision !== head.provider_revision)) { return terminalFailure("provider_revision_mismatch", "Todo changed during completion review or validation; reread and retry"); } if ((update !== undefined || input.validation_source_provider_revision !== undefined) && @@ -1509,7 +1643,8 @@ export async function executeCoordinationTodoTerminalLifecycle( : canonicalAuthorityObject(evidenceGuard.evidence, "capability completion evidence"); } const acceptanceBinding = acceptanceRequirements === null ? null - : acceptanceSourceBinding(input, acceptanceRequirements, head.provider_revision); + : acceptanceSourceBinding(input, acceptanceRequirements, + input.delivery_read_context_id !== undefined && validationRevision != null ? validationRevision : head.provider_revision); let acceptanceEvidence: JsonObject | null = null; if (acceptanceRequirements !== null && acceptanceBinding !== null && input.goal_acceptance_validation_receipts != null) { @@ -1545,10 +1680,25 @@ export async function executeCoordinationTodoTerminalLifecycle( } let completion: ReturnType | null = null; let terminalUpgradeReceipt: JsonObject | null = null; + if (input.command === "supersede" && input.requested_completion_turn_key !== null) { + if (input.requested_completion_identity_source !== "turn_settlement") { + return terminalFailure("invalid_todo_completion_transaction", + "Turn-scoped retirement requires the original turn_settlement identity"); + } + if (todo.completion_turn_key != null && todo.completion_turn_key !== input.requested_completion_turn_key) { + return terminalFailure("invalid_todo_completion_transaction", + "todo is already completed under a different completion_turn_key"); + } + } if (input.command === "complete") { + if (todo.status === "done" && typeof todo.completion_turn_key === "string" && + !(typeof todo.completion_receipt_id === "string" && todo.completion_receipt_id.length > 0)) { + return terminalFailure("invalid_todo_completion_transaction", + "Terminal work has no accepted completion receipt; settle its original retirement instead"); + } const validationRequired = todo.completion_validation_required === true; const validationSha256 = todo.completion_validation_sha256; - if (normalized.operation_identity.kind === "completion_turn" && input.requested_no_followup) { + if (normalized.operation_identity.kind === "completion_turn" && input.requested_no_followup && input.defer_no_followup !== true) { let fence; try { fence = evaluateTodoCompletionFence({schema_version: TODO_COMPLETION_FENCE_REQUEST_SCHEMA, @@ -1566,7 +1716,26 @@ export async function executeCoordinationTodoTerminalLifecycle( lease_idempotency_key: normalized.lease_idempotency_key, lease_expected_version: normalized.lease_expected_version}), requested_no_followup: false, operation_id: completionTurnOperationId(input, false)}; - const original = await terminalReceipt(originalInput, terminalRequestSha(originalInput)).read(store); + let original = await terminalReceipt(originalInput, terminalRequestSha(originalInput)).read(store); + // The result phase may have durably deferred the original closeout. + // Recover only an exact parent fingerprint, including actor, evidence, + // validation and lease; the pending marker alone grants no authority. + if (todo.completion_requested_continuation === "no_followup" && + (original === null || original.reason_code === "coordination_operation_identity_mismatch")) { + for (const requestedNoFollowup of [false, true]) { + const deferred = {...originalInput, requested_no_followup: requestedNoFollowup, + defer_no_followup: true}; + const candidate = await terminalReceipt(deferred, terminalRequestSha(deferred)).read(store); + if (candidate?.status === "replayed") { + original = candidate; + break; + } + } + } + if (todo.completion_requested_continuation === "no_followup" && + (original === null || original.reason_code === "coordination_operation_identity_mismatch")) { + original = await exactStoredDeferredParent(store, originalInput, todo) ?? original; + } if (original === null) return terminalFailure("terminal_completion_receipt_required", "Same-Turn closeout requires the original accepted ordinary completion receipt.", {}, "decision_rejection"); if (original.status !== "replayed") return original; @@ -1635,12 +1804,15 @@ export async function executeCoordinationTodoTerminalLifecycle( ? todo : {...todo, ...input.validation_declaration}, requested_no_followup: input.requested_no_followup, requested_completion_turn_key: input.requested_completion_turn_key, + defer_no_followup: input.defer_no_followup, requested_completion_identity_source: input.requested_completion_identity_source, requested_has_successor: input.successor_intents.length > 0 || input.linked_successor_todo_ids.length > 0, dry_run: input.dry_run, - validation_receipt: implicitMonitorNoChange ? null : terminalUpgradeReceipt !== null - ? terminalUpgradeReceipt.validation_receipt : input.validation_receipt, + // A same-Turn closeout reuses the verified durable receipt below; it + // must not submit that receipt as a newly executed validation effect. + validation_receipt: implicitMonitorNoChange || terminalUpgradeReceipt !== null + ? null : input.validation_receipt, // Link ids are caller intent; role/status are facts owned by this head. // Receipt recovery above preserves the originally accepted policy. completion_policy_request: input.completion_policy_request === null ? null : { @@ -1899,9 +2071,9 @@ export async function executeCoordinationTodoTerminalLifecycle( } : {}), completion_policy: completionPolicy, completion_identity_key: - completion === null ? null : completion.completion_identity_key, + completion === null ? target.todo.completion_turn_key ?? null : completion.completion_identity_key, completion_identity_source: - completion === null ? null : completion.completion_identity_source, + completion === null ? input.requested_completion_identity_source : completion.completion_identity_source, completed_at: target.todo.completed_at, completion_receipt_id: target.todo.completion_receipt_id ?? null, ...(completionResult === null ? {} : {completion_result: completionResult}), diff --git a/loopx/control_plane/coordination/todo_terminal_reentry.ts b/loopx/control_plane/coordination/todo_terminal_reentry.ts index 877c590967..b488cc3860 100644 --- a/loopx/control_plane/coordination/todo_terminal_reentry.ts +++ b/loopx/control_plane/coordination/todo_terminal_reentry.ts @@ -19,21 +19,29 @@ export function requiresReentrySettlement(input: CoordinationTodoTerminalLifecyc /** Resolve historical quota identity from its durable receipt, never by parsing * an opaque completion key or treating missing history as unscoped work. */ export async function verifyReentrySettlement(runtimeRoot: string, - input: CoordinationTodoTerminalLifecycleInput): Promise { + input: Pick): Promise { const {readQuotaSettlementSnapshot, readQuotaSettlementFromSnapshot, QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA} = await import("../quota/settlement_readback.ts"); const snapshot = await readQuotaSettlementSnapshot(runtimeRoot, input.goal_id); const event = snapshot.events.find(event => { const details = jsonObject(event.details); const identity = jsonObject(details?.settlement_identity); - return event.agent_id === input.actor_agent_id && - (details?.settlement_effect_id === input.requested_completion_turn_key || + // Managed Turns persist the opaque Turn key; interactive completions + // persist the full effect id. Resolve both from the original scoped event, + // then let quota readback prove the same writeback and spend receipts. + return event.goal_id === input.goal_id && event.agent_id === input.actor_agent_id && + (details?.todo_id === input.todo_id || event.todo_id === input.todo_id || + identity?.todo_id === input.todo_id) && + (event.run_id === input.requested_completion_turn_key || + details?.settlement_effect_id === input.requested_completion_turn_key || identity?.effect_id === input.requested_completion_turn_key); }); const turn = event?.run_id; if (typeof turn !== "string" || input.actor_agent_id === null || - settlementIdentity({goal_id: input.goal_id, agent_id: input.actor_agent_id, - todo_id: input.todo_id, turn_instance_id: turn}).effect_id !== input.requested_completion_turn_key) { + (turn !== input.requested_completion_turn_key && + settlementIdentity({goal_id: input.goal_id, agent_id: input.actor_agent_id, + todo_id: input.todo_id, turn_instance_id: turn}).effect_id !== input.requested_completion_turn_key)) { throw new AuthorityStoreProtocolError("Lifecycle closeout requires the original quota identity and settled receipts"); } const readback = readQuotaSettlementFromSnapshot({schema_version: QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, diff --git a/loopx/control_plane/effect_runtime.py b/loopx/control_plane/effect_runtime.py index b5c78ed674..cdb44a305d 100644 --- a/loopx/control_plane/effect_runtime.py +++ b/loopx/control_plane/effect_runtime.py @@ -21,6 +21,7 @@ from ..file_lock import process_is_alive from .runtime.file_reads import iter_binary_file_reads +from .runtime.private_permissions import protect_private_path, require_private_path from .runtime.node_probe import ( HOST_PERMISSION_RECOMMENDATION, MINIMUM_NODE_VERSION as MINIMUM_NODE_VERSION, @@ -58,6 +59,7 @@ "quota.spend.commit", "quota.turn_envelope.evaluate", "goal.checkpoint_read_context.resolve", + "goal.checkpoint_read_context.work_binding", "goal.checkpoint_read_context.commit", "goal.checkpoint_read_context.inspect_replay", "performance_diagnosis.inspect", @@ -835,9 +837,13 @@ def _request_with_info( diagnostic_code="invalid_request", ) directory = Path(stack.enter_context(tempfile.TemporaryDirectory(prefix="loopx-effect-"))) + protect_private_path(directory, directory=True) response_sink = directory / "response.json" descriptor = os.open(response_sink, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) - os.close(descriptor) + try: + protect_private_path(response_sink) + finally: + os.close(descriptor) request["response_sink"] = str(response_sink) encoded = (json.dumps(request, separators=(",", ":")) + "\n").encode() if len(encoded) > MAX_REQUEST_BYTES: @@ -850,6 +856,7 @@ def _request_with_info( params_path = directory / "params.json" descriptor = os.open(params_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) with os.fdopen(descriptor, "wb") as file: + protect_private_path(params_path) file.write(params_bytes) request.pop("params") request["params_ref"] = { @@ -1037,8 +1044,10 @@ def _read_local_snapshot_response( or size > MAX_LOCAL_SNAPSHOT_BYTES or not isinstance(digest, str) or BARE_SHA256_PATTERN.fullmatch(digest) is None): raise ValueError("invalid local snapshot reference") + require_private_path(sink.parent, directory=True) descriptor = os.open(sink, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) with os.fdopen(descriptor, "rb") as file: + require_private_path(sink, descriptor=file.fileno()) metadata = os.fstat(file.fileno()) if (not os.path.isfile(sink) or metadata.st_size != size or (hasattr(os, "getuid") and @@ -1197,6 +1206,8 @@ def _start_runtime(*, fingerprint: str, info_path: Path) -> dict[str, Any]: environment["LOOPX_EFFECT_RUNTIME_TOKEN"] = token # Fixed daemon context, never a model-supplied executable argument. environment["LOOPX_EFFECT_RUNTIME_PYTHON"] = sys.executable + # Windows snapshot ACL facts use this parent-selected interpreter only. + environment["LOOPX_EFFECT_RUNTIME_PYTHON_BIN"] = sys.executable # Capture stderr so a rejected startup can publish a typed # startup diagnostic instead of a bare exit status. The capture # is an unlinked temporary file, so it cannot deadlock the child on a diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index d98eb1db88..28c59f6544 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -35,6 +35,7 @@ import { } from "./runtime_decode.ts"; +import type {LocalAuthorityProviderDependencies} from "./coordination/local_authority_provider.ts"; import type {TurnJournalInspectionRequest} from "./turn_driver/turn_journal.ts"; type EffectRuntimeHandler = (params: JsonObject) => unknown | Promise; @@ -54,6 +55,8 @@ function lazyHandler( export interface EffectRuntimeHandlerContext { fingerprint: string; + /** Embedding-service provider hooks; never supplied in Agent RPC params. */ + readonly authorityProvider?: LocalAuthorityProviderDependencies; /** Physical publication path observed by the managed server, not its caller. */ readonly infoPath?: string; requestShutdown: () => void; @@ -265,6 +268,7 @@ export function createEffectRuntimeHandlers( lazyHandler(() => import("./turn_driver/turn_journal.ts"), ({interpretTurnJournal}) => (params) => interpretTurnJournal(turnJournalInspectionRequest(params))), ], ["turn_journal.write", lazyHandler(() => import("./turn_driver/turn_journal_effects.ts"), ({commitTurnJournal}) => commitTurnJournal)], + ["turn_journal.reconcile_settlement", lazyHandler(() => import("./turn_driver/turn_journal_effects.ts"), ({reconcileTurnJournalSettlement}) => reconcileTurnJournalSettlement)], ["turn_journal.find_settlement", lazyHandler(() => import("./turn_driver/turn_journal_query.ts"), ({findTurnJournalBySettlement}) => findTurnJournalBySettlement)], ["turn_journal.observed_capabilities", lazyHandler(() => import("./turn_driver/turn_journal_query.ts"), ({readTurnJournalCapabilities}) => readTurnJournalCapabilities)], ["todo.completion_fence.evaluate", lazyHandler(() => import("./todos/completion_fence.ts"), ({evaluateTodoCompletionFence}) => evaluateTodoCompletionFence)], @@ -360,9 +364,11 @@ export function createEffectRuntimeHandlers( ["work_item.delivery_response.project", lazyHandler(() => import("./work_items/delivery_history.ts"), ({projectDeliveryResponse}) => projectDeliveryResponse)], ["work_item.delivery_claim.validate", lazyHandler(() => import("./work_items/delivery_outcome.ts"), ({validateDeliveryClaim}) => validateDeliveryClaim)], ["goal.vision_checkpoint.evaluate", lazyHandler(() => import("./goals/vision_checkpoint.ts"), ({buildVisionCheckpoint}) => buildVisionCheckpoint)], - ["goal.checkpoint_read_context.resolve", lazyHandler(() => import("./goals/checkpoint_authority.ts"), ({resolveCheckpointReadContext}) => resolveCheckpointReadContext)], - ["goal.checkpoint_read_context.commit", lazyHandler(() => import("./goals/checkpoint_commit.ts"), ({commitCheckpoint}) => commitCheckpoint)], + ["goal.checkpoint_read_context.resolve", lazyHandler(() => import("./goals/checkpoint_authority.ts"), ({resolveCheckpointReadContext}) => params => resolveCheckpointReadContext(params, context.authorityProvider))], + ["goal.checkpoint_read_context.commit", lazyHandler(() => import("./goals/checkpoint_commit.ts"), ({commitCheckpoint}) => params => commitCheckpoint(params, context.authorityProvider))], ["goal.checkpoint_read_context.inspect_replay", lazyHandler(() => import("./goals/checkpoint_commit.ts"), ({inspectCheckpointReplay}) => inspectCheckpointReplay)], + ["goal.checkpoint_read_context.inspect_attempt", lazyHandler(() => import("./goals/checkpoint_commit.ts"), ({inspectCheckpointAttempt}) => inspectCheckpointAttempt)], + ["goal.checkpoint_read_context.inspect_history", lazyHandler(() => import("./goals/checkpoint_commit.ts"), ({inspectCheckpointReadHistory}) => inspectCheckpointReadHistory)], ["goal.vision_wait.coverage", lazyHandler(() => import("./goals/vision_wait_coverage.ts"), ({projectVisionWaitCoverage}) => projectVisionWaitCoverage)], ["goal.vision_wait.project", lazyHandler(() => import("./goals/vision_wait_coverage.ts"), ({projectGoalVisionWaitState}) => projectGoalVisionWaitState)], ["goal.shared_goal_alignment.project", lazyHandler(() => import("./goals/shared_goal_alignment.ts"), ({projectSharedGoalAlignment}) => projectSharedGoalAlignment)], @@ -395,6 +401,7 @@ export function createEffectRuntimeHandlers( ["quota.spend.commit", lazyHandler(() => import("./quota/spend_commit.ts"), ({evaluateQuotaSpendCommit}) => evaluateQuotaSpendCommit)], ["quota.void.commit", lazyHandler(() => import("./quota/void_commit.ts"), ({evaluateQuotaVoidCommit}) => evaluateQuotaVoidCommit)], ["quota.settlement.read", lazyHandler(() => import("./quota/settlement_readback.ts"), ({readQuotaSettlement}) => readQuotaSettlement)], + ["quota.settlement.default_result_work", lazyHandler(() => import("./quota/settlement_readback.ts"), ({selectDefaultResultWorkTurn}) => selectDefaultResultWorkTurn)], [ "quota.prior_host_turn_closeout.preflight", lazyHandler(() => import("./quota/unsettled_host_turn_recovery.ts"), ({preflightPriorHostTurnCloseout}) => preflightPriorHostTurnCloseout), @@ -443,6 +450,9 @@ export function createEffectRuntimeHandlers( ["coordination.local_authority.handoff_mode_set", lazyHandler(() => import("./coordination/handoff_mode_runtime.ts"), ({setLocalHandoffMode}) => setLocalHandoffMode)], ["coordination.local_authority.handoff_mode_migrate", lazyHandler(() => import("./coordination/handoff_mode_runtime.ts"), ({migrateLocalHandoffMode}) => migrateLocalHandoffMode)], ["coordination.local_authority.todo_terminal", lazyHandler(() => import("./coordination/local_authority_runtime.ts"), ({terminalLifecycleLocalCoordinationTodo}) => terminalLifecycleLocalCoordinationTodo)], + ["turn.first_delivery.evaluate", lazyHandler(() => import("./turn_driver/first_delivery.ts"), ({evaluateFirstDelivery}) => evaluateFirstDelivery)], + ["turn.first_delivery.observe", lazyHandler(() => import("./turn_driver/first_delivery.ts"), ({observeFirstDelivery}) => observeFirstDelivery)], + ["goal.checkpoint_read_context.work_binding", lazyHandler(() => import("./goals/checkpoint_read_context.ts"), ({executeResultWorkBinding}) => executeResultWorkBinding)], ["coordination.local_authority.todo_archive", lazyHandler(() => import("./coordination/local_authority_runtime.ts"), ({archiveLocalCoordinationTodos}) => archiveLocalCoordinationTodos)], ["coordination.local_authority.todo_archive_ack", lazyHandler(() => import("./coordination/local_authority_runtime.ts"), ({acknowledgeLocalCoordinationTodoArchive}) => acknowledgeLocalCoordinationTodoArchive)], ["coordination.local_authority.todo_read", lazyHandler(() => import("./coordination/local_authority_read.ts"), ({readLocalCoordinationTodo}) => readLocalCoordinationTodo)], diff --git a/loopx/control_plane/effect_runtime_server.ts b/loopx/control_plane/effect_runtime_server.ts index 9093f27c27..c20a198086 100644 --- a/loopx/control_plane/effect_runtime_server.ts +++ b/loopx/control_plane/effect_runtime_server.ts @@ -44,6 +44,7 @@ const LOCAL_SNAPSHOT_METHODS = new Set([ "quota.spend.commit", "quota.turn_envelope.evaluate", "goal.checkpoint_read_context.resolve", + "goal.checkpoint_read_context.work_binding", "goal.checkpoint_read_context.commit", "goal.checkpoint_read_context.inspect_replay", "performance_diagnosis.inspect", diff --git a/loopx/control_plane/effect_runtime_snapshot.ts b/loopx/control_plane/effect_runtime_snapshot.ts index b3bd40438a..6bf287a57b 100644 --- a/loopx/control_plane/effect_runtime_snapshot.ts +++ b/loopx/control_plane/effect_runtime_snapshot.ts @@ -1,6 +1,8 @@ /** Private same-UID files for complete local facts that exceed the RPC wire. * The digest covers the bytes used by the typed owner; paths are never evidence. */ import {createHash} from "node:crypto"; +import {execFile} from "node:child_process"; +import {fileURLToPath} from "node:url"; import {constants} from "node:fs"; import {lstat, open, type FileHandle} from "node:fs/promises"; import {basename, dirname, isAbsolute} from "node:path"; @@ -19,11 +21,23 @@ async function privateFile(path: string, flags: number): Promise { (parent.uid !== process.getuid() || (parent.mode & 0o077) !== 0))) { throw new Error("invalid private snapshot directory"); } - if ((await lstat(path)).isSymbolicLink()) throw new Error("private snapshot is a symlink"); + const entry = await lstat(path); + if (entry.isSymbolicLink()) throw new Error("private snapshot is a symlink"); + if (process.platform === "win32") { + const python = process.env.LOOPX_EFFECT_RUNTIME_PYTHON_BIN; + if (!python || !isAbsolute(python)) throw new Error("private snapshot ACL helper unavailable"); + // Fixed package-owned Win32 IO, not a caller-supplied executable or decision owner. + const helper = fileURLToPath(new URL("./runtime/private_permissions.py", import.meta.url)); + await new Promise((resolve, reject) => { + execFile(python, [helper, "--verify-snapshot", path], + {timeout: 5000, windowsHide: true, maxBuffer: 4096}, + error => error ? reject(error) : resolve()); + }); + } const file = await open(path, flags | (constants.O_NOFOLLOW ?? 0)); try { const stat = await file.stat(); - if (!stat.isFile() || (process.getuid && + if (!stat.isFile() || stat.dev !== entry.dev || stat.ino !== entry.ino || (process.getuid && (stat.uid !== process.getuid() || (stat.mode & 0o077) !== 0))) { throw new Error("invalid private snapshot file"); } diff --git a/loopx/control_plane/goals/acceptance.py b/loopx/control_plane/goals/acceptance.py index cbc43b3ec7..bd3f0d16d4 100644 --- a/loopx/control_plane/goals/acceptance.py +++ b/loopx/control_plane/goals/acceptance.py @@ -439,6 +439,7 @@ def run_goal_acceptance_validation_effect( goal_id: str, delivery_workspace: Mapping[str, Any] | None = None, validation_workspace_path: Path | None = None, + stdin: int | None = None, ) -> dict[str, Any]: """Use the ordinary runner while preserving declared verifier-file identity. @@ -495,7 +496,7 @@ def stale_receipt() -> dict[str, Any]: receipt = run_declared_completion_validation_effect( effect={key: value for key, value in effect.items() if key != "validation_files"}, registry_path=registry_path, goal_id=goal_id, - delivery_workspace=delivery_workspace, validation_workspace_path=validation_workspace_path, + delivery_workspace=delivery_workspace, validation_workspace_path=validation_workspace_path, stdin=stdin, ) if not _acceptance_pins_match(pins, workspace): return stale_receipt() diff --git a/loopx/control_plane/goals/checkpoint_authority.ts b/loopx/control_plane/goals/checkpoint_authority.ts index 3b93c8be9d..f993117e16 100644 --- a/loopx/control_plane/goals/checkpoint_authority.ts +++ b/loopx/control_plane/goals/checkpoint_authority.ts @@ -1,54 +1,119 @@ -/** Same-head checkpoint facts and the two shipped local provider fences. - * Not an AuthorityStore extension contract or a checkpoint authority migration. */ +/** Same-head checkpoint facts and provider-owned writer fences. + * Credentials and provider construction remain with the embedding service. */ import type {JsonObject} from "../effect_program.ts"; -import {requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; +import {readFileSync} from "node:fs"; +import {resolve} from "node:path"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; import {authorityStoreSourceAuthority} from "../coordination/authority_store.ts"; import {goalPathSegment} from "../rollout_receipt_log.ts"; import {FileAuthorityStore} from "../coordination/file_authority_store.ts"; import {SqliteAuthorityStore} from "../coordination/sqlite_authority_store.ts"; +import {PostgreSqlAuthorityStore} from "../coordination/postgresql_authority_store.ts"; +import type {LocalAuthorityProviderDependencies} from "../coordination/local_authority_provider.ts"; import {openRuntimeAuthorityStore, requireLocalAuthorityRuntimeRoot} from "../coordination/local_authority_provider.ts"; import {loadLegacyCoordinationWriterFence} from "../coordination/legacy_writer_fence.ts"; -import {indexCoordinationProjectionTodos, validateCoordinationTodoReadModel} from "../coordination/coordination_projection.ts"; +import {indexCoordinationProjection, validateCoordinationTodoReadModel} from "../coordination/coordination_projection.ts"; +import {parseIsoTimestamp} from "../runtime_timestamp.ts"; import {readGoalAcceptance} from "./acceptance_contract.ts"; import {evaluateCheckpointReadContext} from "./checkpoint_read_context.ts"; +import type {AuthorityStoreHead} from "../coordination/authority_store.ts"; + +function primaryGoalDocuments(source: JsonObject): JsonObject[] { + const goal = jsonObject(source.registry_goal); + if (!Array.isArray(goal?.authority_sources)) return []; + const paths = new Set(); + for (const value of goal.authority_sources) { + const declaration = requireJsonObject(value, "Goal authority source"); + if (declaration.kind !== "goal_doc") continue; + const path = resolve(requireNonEmptyString(goal.repo, "Goal project"), + requireNonEmptyString(declaration.path, "primary Goal document path")); + if (path !== resolve(String(source.state_file))) paths.add(path); + } + return [...paths].sort().map(path => { + try { + // Match the source reader's universal-newline text contract. + return {path, prose: readFileSync(path, "utf8").replace(/\r\n?/g, "\n")}; + } catch { + throw new EffectRuntimeRequestError( + "A registered primary Goal document is unavailable; preserve the current state and locate the owner's current source, not an old plan.", + "checkpoint_goal_source_unavailable"); + } + }); +} + +/** The same provider facts feed checkpoint append and Todo result CAS checks. */ +export function checkpointProviderFacts( + goalId: string, facts: JsonObject, head: AuthorityStoreHead, storeIdentity: string, + sourceAuthority: string, protectedDelivery: boolean, +): JsonObject { + const source = requireJsonObject(facts.source, "checkpoint source"); + const documents = primaryGoalDocuments(source); + const projection = indexCoordinationProjection(head.head, goalId); + validateCoordinationTodoReadModel(head.head, goalId); + const acceptance = readGoalAcceptance(head.head, goalId); + const todoId = jsonObject(facts.checkpoint_identity)?.todo_id; + const lease = protectedDelivery && typeof todoId === "string" ? projection.leases.get(todoId) : null; + const expiry = typeof lease?.expires_at === "string" ? parseIsoTimestamp(lease.expires_at) : null; + return {...facts, + ...(documents.length ? {goal_documents: documents} : {}), + ...(protectedDelivery ? {execution_lease: lease ? {...lease, + active: lease.status === "active" && expiry !== null && expiry > new Date()} : null} : {}), + todos: projection.todo_ids.map(id => projection.todos.get(id)!), + acceptance: {revision: acceptance?.revision ?? null, contract_digest: acceptance?.digest ?? null, + contract: acceptance?.document ?? null}, + provider_revision: head.provider_revision, + source: {...source, authority: sourceAuthority, store_identity: storeIdentity}, + }; +} export async function withCheckpointAuthority( root: string, goalId: string, facts: JsonObject, save: (facts: JsonObject) => JsonObject, + requireCanonical = false, + resultOnly = false, + completeTodoSource = false, + dependencies: LocalAuthorityProviderDependencies = {}, ): Promise { const fence = await loadLegacyCoordinationWriterFence(root, goalId); if (fence.status === "failed") throw new Error(fence.reason); const source = requireJsonObject(facts.source, "checkpoint source"); if (fence.status === "missing") { + if (requireCanonical) throw new Error("first delivery freshness requires canonical authority"); return save({...facts, source: {...source, authority: "legacy_markdown", store_identity: null}}); } - const store = await openRuntimeAuthorityStore(root, goalId, {}); - if (!(store instanceof FileAuthorityStore) && !(store instanceof SqliteAuthorityStore)) { - throw new Error("checkpoint supplement requires a supported local provider fence"); + const store = await openRuntimeAuthorityStore(root, goalId, dependencies); + if (!(store instanceof FileAuthorityStore) && !(store instanceof SqliteAuthorityStore) && + !(store instanceof PostgreSqlAuthorityStore)) { + if (resultOnly && authorityStoreSourceAuthority(store) === "postgresql_v0") { + const [head, identity] = await Promise.all([store.loadAuthority(), store.storeIdentity()]); + if (head.status !== "loaded" || identity.status !== "available") { + throw new Error("Cannot read the original result provider head and identity"); + } + return save(checkpointProviderFacts(goalId, facts, head, identity.store_identity, "postgresql_v0", true)); + } + throw new Error("checkpoint requires a supported provider fence"); } return await store.withCheckpointHead((head, identity) => { - const projection = indexCoordinationProjectionTodos(head.head, goalId); - validateCoordinationTodoReadModel(head.head, goalId); - const acceptance = readGoalAcceptance(head.head, goalId); - return save({...facts, - todos: projection.todo_ids.map(id => projection.todos.get(id)!), - acceptance: {revision: acceptance?.revision ?? null, contract_digest: acceptance?.digest ?? null, - contract: acceptance?.document ?? null}, - provider_revision: head.provider_revision, - source: {...source, authority: authorityStoreSourceAuthority(store), store_identity: identity}, - }); + return save(checkpointProviderFacts(goalId, facts, head, identity, authorityStoreSourceAuthority(store), requireCanonical || resultOnly || completeTodoSource)); }); } /** Called while the Python adapter holds the local source locks. Optimistic * receipts are allowed to go stale after this operation returns. */ -export async function resolveCheckpointReadContext(value: unknown): Promise { +export async function resolveCheckpointReadContext(value: unknown, + dependencies: LocalAuthorityProviderDependencies = {}, +): Promise { const request = requireJsonObject(value, "checkpoint read context request"); const root = requireLocalAuthorityRuntimeRoot(request.runtime_root); const identity = requireJsonObject(request.identity, "identity"); const goalId = goalPathSegment(identity.goal_id); const facts = requireJsonObject(request.facts, "checkpoint facts"); requireNonEmptyString(requireJsonObject(facts.source, "checkpoint source").state_file, "state_file"); - const current = await withCheckpointAuthority(root, goalId, facts, current => current); + const current = await withCheckpointAuthority(root, goalId, facts, current => current, + request.purpose === "first_delivery" && request.checkpoint_continuation !== true || (request.purpose === "delivery_result" && + request.result_only !== true && jsonObject(request.receipt)?.result_only !== true), + request.purpose === "delivery_result" && (request.result_only === true || jsonObject(request.receipt)?.result_only === true), + request.purpose === "first_delivery" || request.full_frontier === true || jsonObject(request.receipt)?.full_frontier === true, dependencies); // Reduce the captured snapshot after releasing the read fence, as before. // Only commitCheckpoint holds the provider fence through its final check/save. return evaluateCheckpointReadContext({...request, facts: current}); diff --git a/loopx/control_plane/goals/checkpoint_commit.ts b/loopx/control_plane/goals/checkpoint_commit.ts index 0802eb25bf..7964447288 100644 --- a/loopx/control_plane/goals/checkpoint_commit.ts +++ b/loopx/control_plane/goals/checkpoint_commit.ts @@ -1,15 +1,15 @@ -/** Missing-checkpoint commit. Index/source claims survive the requesting CLI; +/** Receipt-bound work and direction commits. Index/source claims survive the requesting CLI; * the real provider fence lasts through the synchronous durable append. */ import {createHash} from "node:crypto"; -import {closeSync, fsyncSync, lstatSync, openSync, readFileSync, writeFileSync} from "node:fs"; -import {dirname, join, resolve} from "node:path"; +import {closeSync, existsSync, fsyncSync, lstatSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync} from "node:fs"; +import {basename, dirname, join, resolve} from "node:path"; import type {JsonObject} from "../effect_program.ts"; import {settlementIdentity, settlementIdentityPayload} from "../effect_program.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {appendJsonLineSync, claimFileMutationLock, mutationLockOwner, releaseFileMutationLock, releaseFileMutationLockClaim, withFileMutationLock, type FileMutationLockClaim} from "../effect_runtime_io.ts"; import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; -import {requireLocalAuthorityRuntimeRoot} from "../coordination/local_authority_provider.ts"; +import {requireLocalAuthorityRuntimeRoot, type LocalAuthorityProviderDependencies} from "../coordination/local_authority_provider.ts"; import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; import {legacyCoordinationTodoLockPath} from "../coordination/legacy_writer_lock_paths.ts"; import {shadowMaintenanceLockPath, requireShadowPrimaryWriteAllowed} from "../coordination/shadow_management.ts"; @@ -24,37 +24,63 @@ import { quotaAccountingOwnerLocks, requireCurrentQuotaAccountingOwner, } from "../quota/source_admission.ts"; -import {evaluateCheckpointReadContext} from "./checkpoint_read_context.ts"; +import {checkpointBasisSnapshot, evaluateCheckpointReadContext} from "./checkpoint_read_context.ts"; import {withCheckpointAuthority} from "./checkpoint_authority.ts"; import {goalPathSegment} from "../rollout_receipt_log.ts"; +import {BARE_SHA256_PATTERN} from "../content_digest.ts"; +import {checkpointContinuationRequired, resultFreshnessRequired} from "../quota/heartbeat_receipt_identity.ts"; const digest = (bytes: Uint8Array): string => createHash("sha256").update(bytes).digest("hex"); +function readReceiptValue(path: string): unknown { + try { return JSON.parse(readFileSync(path, "utf8")); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return null; + } +} function unknown(message: string): never { throw new EffectRuntimeRequestError(`${message}; read back the original Turn before retrying`, "checkpoint_commit_unknown"); } -function indexBytes(path: string): Buffer { - const bytes = readFileSync(path); +function checkpointIndex(path: string): {bytes: Buffer; rows: JsonObject[]} { + let bytes: Buffer; + try { bytes = readFileSync(path); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return {bytes: Buffer.alloc(0), rows: []}; + } // The ordinary history reader tolerates damaged rows. A commit cannot infer // absence from that reader or append behind a torn (even JSON-valid) tail. if (bytes.length && bytes[bytes.length - 1] !== 10) unknown("checkpoint index has an incomplete tail"); const checkpoints = new Map(); + const rows: JsonObject[] = []; for (const line of bytes.toString("utf8").split(/\r?\n/)) { if (!line.trim()) continue; try { const row = requireJsonObject(JSON.parse(line), "run index row"); + rows.push(row); const checkpoint = jsonObject(row.vision_checkpoint); const effect = jsonObject(row.settlement_identity)?.effect_id; - if (checkpoint?.satisfied === true && checkpoint.read_context && typeof effect === "string") { - const version = canonicalAuthoritySha256({checkpoint, recovery: row.refresh_recovery, + if ((row.result_work_context || checkpoint?.satisfied === true && checkpoint.read_context) && typeof effect === "string") { + const version = canonicalAuthoritySha256({checkpoint, result_work_context: row.result_work_context ?? null, recovery: row.refresh_recovery, json_path: row.json_path, markdown_path: row.markdown_path}); - if (checkpoints.has(effect) && checkpoints.get(effect) !== version) unknown("checkpoint index has conflicting decisions"); - checkpoints.set(effect, version); + // A retained result and its later direction settlement share a Turn, + // but are distinct effects. Only competing proofs of the same purpose conflict. + const key = `${effect}:${row.result_work_context ? "delivery_result" : "first_delivery"}`; + if (checkpoints.has(key) && checkpoints.get(key) !== version) unknown("checkpoint index has conflicting decisions"); + checkpoints.set(key, version); } } catch { unknown("checkpoint index is malformed"); } } - return bytes; + return {bytes, rows}; +} + +function indexBytes(path: string): Buffer { return checkpointIndex(path).bytes; } + +/** One strict capture per observation; never a cache or an admission witness. */ +export function readCheckpointHistoryRows(root: string, goal: string): JsonObject[] { + return checkpointIndex(join(root, "goals", goalPathSegment(goal), "runs", "index.jsonl")).rows; } function committedArtifacts(prior: JsonObject, runsDir: string): JsonObject { @@ -65,12 +91,12 @@ function committedArtifacts(prior: JsonObject, runsDir: string): JsonObject { record = requireJsonObject(JSON.parse(readFileSync(jsonPath, "utf8")), "checkpoint record"); readFileSync(markdownPath); } catch { unknown("checkpoint artifacts are unavailable"); } - for (const field of ["settlement_identity", "refresh_recovery", "vision_checkpoint"]) { - if (canonicalAuthoritySha256(record[field]) !== canonicalAuthoritySha256(prior[field])) { + for (const field of ["settlement_identity", "refresh_recovery", "vision_checkpoint", "result_work_context"]) { + if (canonicalAuthoritySha256(record[field] ?? null) !== canonicalAuthoritySha256(prior[field] ?? null)) { unknown("checkpoint artifacts disagree with the committed index"); } } - return {ok: true, replayed: true, context: jsonObject(prior.vision_checkpoint)?.read_context, + return {ok: true, replayed: true, context: prior.result_work_context ?? jsonObject(prior.vision_checkpoint)?.read_context, json_path: jsonPath, markdown_path: markdownPath}; } @@ -92,12 +118,18 @@ export function inspectCheckpointReplay(value: unknown): JsonObject { function runPath(value: unknown, runsDir: string, extension: string): string { const path = resolve(requireNonEmptyString(value, "run artifact path")); - if (dirname(path) !== resolve(runsDir) || !path.endsWith(extension)) { + let parent: string; + try { parent = realpathSync(dirname(path)); } + catch { unknown("checkpoint artifact directory is unavailable"); } + if (parent !== realpathSync(runsDir) || !path.endsWith(extension)) { unknown("checkpoint artifact path is invalid"); } - try { if (!lstatSync(path).isFile()) unknown("checkpoint artifact is not a regular file"); } + // Resolve only the directory alias. Following the leaf would admit a file + // symlink, and writing through the original alias could follow a later retarget. + const physical = join(parent, basename(path)); + try { if (!lstatSync(physical).isFile()) unknown("checkpoint artifact is not a regular file"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } - return path; + return physical; } function writeSynced(path: string, text: string): void { @@ -106,7 +138,180 @@ function writeSynced(path: string, text: string): void { finally { closeSync(fd); } } -export async function commitCheckpoint(value: unknown): Promise { +function writeReceipt(path: string, receipt: JsonObject): void { + const temporary = `${path}.${process.pid}.tmp`; + writeSynced(temporary, JSON.stringify(receipt) + "\n"); + renameSync(temporary, path); +} + +function checkpointReceiptName(effect: string, work: boolean): string { + return createHash("sha256").update(effect + (work ? ":delivery_result" : "")).digest("hex") + ".json"; +} + +function indexedReadProof(rows: JsonObject[], goal: string, name: string): {row: JsonObject; purpose: string} | null { + for (const row of rows) { + const binding = jsonObject(row.settlement_identity); + if (binding?.goal_id !== goal) continue; + const effect = binding.effect_id; + if (typeof effect !== "string") continue; + const direction = jsonObject(row.vision_checkpoint)?.satisfied === true && + jsonObject(jsonObject(row.vision_checkpoint)?.read_context) !== null; + if (direction && name === checkpointReceiptName(effect, false)) return {row, purpose: "first_delivery"}; + const work = jsonObject(row.result_work_context) !== null; + if ((direction || work) && name === checkpointReceiptName(effect, true)) { + return {row, purpose: work ? "delivery_result" : "first_delivery"}; + } + } + return null; +} + +/** Observation only: recover bindings from verified indexed effects, never from + * unreadable bytes. Pending accounting remains visible under the recovered identity. */ +export function inspectCheckpointReadHistory(value: unknown): JsonObject { + const request = requireJsonObject(value, "checkpoint read history inspection"); + const root = requireLocalAuthorityRuntimeRoot(request.runtime_root); + return inspectCheckpointReadHistoryFromRows(request, readCheckpointHistoryRows(root, goalPathSegment(request.goal_id))); +} + +export function inspectCheckpointReadHistoryFromRows(value: unknown, rows: JsonObject[]): JsonObject { + const request = requireJsonObject(value, "checkpoint read history inspection"); + const root = requireLocalAuthorityRuntimeRoot(request.runtime_root); + const goal = goalPathSegment(request.goal_id); + if (!Array.isArray(request.receipt_names)) throw new EffectRuntimeRequestError("receipt_names must be an array"); + const names = new Set(request.receipt_names); + const contexts = join(root, "goals", goal, "checkpoint-contexts"); + // Deleted reads cannot appear in a directory scan. Recover their indexed + // binding too; accounting/terminal owners still decide whether it is pending. + for (const row of rows) { + const binding = jsonObject(row.settlement_identity); + if (binding?.goal_id !== goal || typeof binding.effect_id !== "string" || + (request.agent_id != null && binding.agent_id !== request.agent_id)) continue; + const effect = binding.effect_id; + if (!existsSync(join(contexts, checkpointReceiptName(effect, false))) && + !existsSync(join(contexts, checkpointReceiptName(effect, true)))) { + if (row.result_work_context != null) names.add(checkpointReceiptName(effect, true)); + if (jsonObject(row.vision_checkpoint)?.satisfied === true && + jsonObject(jsonObject(row.vision_checkpoint)?.read_context)) names.add(checkpointReceiptName(effect, false)); + } + } + const verified: JsonObject[] = []; + for (const name of names) { + if (typeof name !== "string" || !name.endsWith(".json") || !BARE_SHA256_PATTERN.test(name.slice(0, -5))) { + throw new EffectRuntimeRequestError("invalid checkpoint receipt name"); + } + const proof = indexedReadProof(rows, goal, name); + if (!proof) continue; + try { + const result = inspectCheckpointAttemptFromRows({runtime_root: root, identity: proof.row.settlement_identity, + purpose: proof.purpose}, rows); + if (result.status === "committed") verified.push({receipt_name: name, identity: proof.row.settlement_identity, + purpose: proof.purpose}); + } catch (error) { + if (!(error instanceof EffectRuntimeRequestError) || error.code !== "checkpoint_commit_unknown") throw error; + } + } + return {ok: true, verified}; +} + +/** Caller holds the index lock. Unindexed artifacts never prove a committed + * direction. A completely absent attempt may be retried; torn writes stay held. */ +export function inspectCheckpointAttempt(value: unknown): JsonObject { + const request = requireJsonObject(value, "checkpoint attempt inspection"); + const root = requireLocalAuthorityRuntimeRoot(request.runtime_root); + const identity = requireJsonObject(request.identity, "settlement identity"); + return inspectCheckpointAttemptFromRows(request, readCheckpointHistoryRows(root, goalPathSegment(identity.goal_id))); +} + +export function inspectCheckpointAttemptFromRows(value: unknown, rows: JsonObject[]): JsonObject { + const request = requireJsonObject(value, "checkpoint attempt inspection"); + const root = requireLocalAuthorityRuntimeRoot(request.runtime_root); + const identity = requireJsonObject(request.identity, "settlement identity"); + const runsDir = join(root, "goals", goalPathSegment(identity.goal_id), "runs"); + const work = request.purpose === "delivery_result"; + const matching = rows.filter(row => jsonObject(row.settlement_identity)?.effect_id === identity.effect_id && + (work ? row.result_work_context != null : jsonObject(row.vision_checkpoint)?.satisfied === true && + jsonObject(row.vision_checkpoint)?.read_context != null)); + const path = join(root, "goals", goalPathSegment(identity.goal_id), "checkpoint-contexts", + checkpointReceiptName(requireNonEmptyString(identity.effect_id, "effect_id"), work)); + let receipt: JsonObject | null = null; + let receiptError: unknown; + try { receipt = requireJsonObject(JSON.parse(readFileSync(path, "utf8")), "read receipt"); } + catch (error) { receiptError = error; } + // An exact indexed decision and its matching artifacts are stronger than a + // disposable read receipt. Recover the original decision without reauthoring. + if (matching.length) { + if (matching.length !== 1 || canonicalAuthoritySha256(matching[0].settlement_identity) !== canonicalAuthoritySha256(identity)) { + unknown("checkpoint attempt conflicts with its index identity"); + } + if (receipt !== null) { + if (canonicalAuthoritySha256(receipt.identity) !== canonicalAuthoritySha256(identity)) unknown("checkpoint receipt identity differs"); + const attempt = jsonObject(receipt.commit_attempt); + if (attempt && canonicalAuthoritySha256(matching[0]) !== canonicalAuthoritySha256(attempt.index_record)) { + unknown("checkpoint attempt conflicts with its index"); + } + } + return {...committedArtifacts(matching[0], runsDir), status: "committed"}; + } + if (request.check_other_attempts === true) { + const contexts = join(root, "goals", goalPathSegment(identity.goal_id), "checkpoint-contexts"); + let names: string[] = []; + try { names = readdirSync(contexts); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + const resultReadName = checkpointReceiptName(requireNonEmptyString(identity.effect_id, "effect_id"), true); + for (const name of names.filter(name => name.endsWith(".json") && BARE_SHA256_PATTERN.test(name.slice(0, -5)))) { + // This disjoint path can hold only the original work read, never a direction append. + // Losing it cannot erase the successful result or create direction uncertainty. + if (name === resultReadName) continue; + let other: JsonObject; + try { other = requireJsonObject(JSON.parse(readFileSync(join(contexts, name), "utf8")), "read receipt"); } + catch { + const proof = indexedReadProof(rows, goalPathSegment(identity.goal_id), name); + if (proof) { + // Reuse receipt-first artifact verification without scanning other reads. + // A work proof alone cannot establish that a direction append committed. + const verified = inspectCheckpointAttemptFromRows({runtime_root: root, identity: proof.row.settlement_identity, + purpose: proof.purpose}, rows); + if (verified.status === "committed") continue; + } + unknown("checkpoint read receipt is unavailable; preserve original Turn artifacts before retrying"); + } + const owner = jsonObject(other.identity); + if (other.purpose === "first_delivery" && other.commit_attempt != null && owner?.effect_id !== identity.effect_id && + owner?.agent_id === identity.agent_id && owner?.todo_id === identity.todo_id) { + const proof = indexedReadProof(rows, goalPathSegment(identity.goal_id), name); + if (!proof || proof.purpose !== "first_delivery" || + inspectCheckpointAttemptFromRows({runtime_root: root, identity: owner, purpose: "first_delivery"}, rows).status !== "committed") { + unknown("another original Turn has an unresolved direction append for this Todo"); + } + } + } + } + if (receipt === null) { + if ((receiptError as NodeJS.ErrnoException)?.code === "ENOENT") return {ok: true, status: "absent"}; + unknown("checkpoint read receipt is unavailable"); + } + if (canonicalAuthoritySha256(receipt.identity) !== canonicalAuthoritySha256(identity)) unknown("checkpoint receipt identity differs"); + const attempt = jsonObject(receipt.commit_attempt); + if (attempt == null) return {ok: true, status: "not_started"}; + for (const [field, extension] of [["json_path", ".json"], ["markdown_path", ".md"]]) { + const artifact = runPath(attempt[field], runsDir, extension); + try { + const stat = lstatSync(artifact); + // The caller reserves empty paths before persisting the attempt marker. + // Both empty regular files are an absent effect; any bytes remain unknown. + if (!stat.isFile() || stat.size !== 0) unknown("checkpoint append has unindexed artifacts"); + } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + } + // No content and no row: there was no visible effect. Clear only the marker, + // preserving the original read identity; a subsequent basis check still runs. + const {commit_attempt: _attempt, ...unstarted} = receipt; + return {ok: true, status: "absent", read_context_id: receipt.read_context_id, receipt: unstarted}; +} + +export async function commitCheckpoint(value: unknown, + dependencies: LocalAuthorityProviderDependencies = {}, +): Promise { const request = requireJsonObject(value, "checkpoint commit"); const root = requireLocalAuthorityRuntimeRoot(request.runtime_root); const binding = requireJsonObject(request.identity, "settlement identity"); @@ -118,9 +323,18 @@ export async function commitCheckpoint(value: unknown): Promise { throw new EffectRuntimeRequestError("checkpoint settlement identity is invalid"); } const runsDir = join(root, "goals", identity.goal_id, "runs"); - const indexPath = join(runsDir, "index.jsonl"); + const physicalRuns = realpathSync(runsDir); + const directoryIdentity = statSync(physicalRuns, {bigint: true}); + const indexPath = join(physicalRuns, "index.jsonl"); const statePath = resolve(requireNonEmptyString(request.state_file, "state_file")); - const targets = [indexPath, shadowMaintenanceLockPath(root, identity.goal_id), + const firstDelivery = request.purpose === "first_delivery"; + const resultWork = request.purpose === "delivery_result"; + const registryBound = jsonObject(jsonObject(request.facts)?.source)?.registry_goal != null; + const registryPath = firstDelivery || resultWork || registryBound + ? resolve(requireNonEmptyString(request.registry_path, "registry_path")) : null; + // The handoff names the caller's acquired lock; physical IO stays frozen + // separately, and directory identity is rechecked before the append. + const targets = [join(runsDir, "index.jsonl"), ...(registryPath ? [registryPath] : []), shadowMaintenanceLockPath(root, identity.goal_id), legacyCoordinationTodoLockPath(root, identity.goal_id), statePath].map(path => resolve(path)); const owner = parseQuotaAccountingOwner({ goalRefValue: request.goal_ref, @@ -130,7 +344,7 @@ export async function commitCheckpoint(value: unknown): Promise { }); const retry = requireJsonObject(request.refresh_retry, "refresh retry"); const receiptPath = join(root, "goals", identity.goal_id, "checkpoint-contexts", - `${createHash("sha256").update(identity.effect_id).digest("hex")}.json`); + `${createHash("sha256").update(identity.effect_id + (resultWork ? ":delivery_result" : "")).digest("hex")}.json`); async function admission(): Promise { indexBytes(indexPath); @@ -148,9 +362,10 @@ export async function commitCheckpoint(value: unknown): Promise { } function replay(readback: JsonObject): JsonObject | null { const recovery = requireJsonObject(readback.refresh_recovery, "refresh recovery"); - if (recovery.decision !== "replay") return null; + if (recovery.decision !== "replay" && recovery.decision !== "repair_receipt") return null; const prior = requireJsonObject(readback.writeback_run, "committed checkpoint"); - if (jsonObject(prior.vision_checkpoint)?.satisfied !== true || + if ((!resultWork && jsonObject(prior.vision_checkpoint)?.satisfied !== true) || + (resultWork && prior.result_work_context == null) || jsonObject(prior.refresh_recovery)?.vision_request_digest !== recovery.vision_request_digest) { unknown("checkpoint replay does not identify a committed decision"); } @@ -240,7 +455,10 @@ export async function commitCheckpoint(value: unknown): Promise { const repeated = replay(readback); if (repeated) return repeated; const recovery = requireJsonObject(readback.refresh_recovery, "refresh recovery"); - if (recovery.decision !== "supplement_checkpoint") { + if (resultWork && !resultFreshnessRequired(jsonObject(readback.heartbeat_receipt))) { + throw new EffectRuntimeRequestError("In-flight work check requires the original protected admission"); + } + if (recovery.decision !== (firstDelivery || resultWork ? "append" : "supplement_checkpoint")) { throw new EffectRuntimeRequestError(String(recovery.reason ?? "checkpoint supplement rejected"), "checkpoint_commit_rejected"); } await requireShadowPrimaryWriteAllowed(root, identity.goal_id); @@ -256,31 +474,54 @@ export async function commitCheckpoint(value: unknown): Promise { if (digest(indexBytes(indexPath)) !== expectedIndex || digest(readFileSync(statePath)) !== expectedState) { unknown("checkpoint sources changed during lock handoff"); } - let receipt: unknown = null; - try { receipt = JSON.parse(readFileSync(receiptPath, "utf8")); } - catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + // Format-neutral byte guard under the adopted source lock, not a JSON + // decoder. Keep it synchronous with the final head read and durable append. + if (registryPath && digest(readFileSync(registryPath)) !== request.registry_sha256) { + unknown("checkpoint registry changed during lock handoff"); + } + const receipt = readReceiptValue(receiptPath); const context = evaluateCheckpointReadContext({phase: "check", identity: binding, - read_context_id: retry.checkpoint_read_context_id, receipt, facts: current}); + checkpoint_continuation: checkpointContinuationRequired(jsonObject(readback.heartbeat_receipt)), + purpose: request.purpose, decision_scope: request.decision_scope, + read_context_id: resultWork ? request.read_context_id : retry.checkpoint_read_context_id, receipt, facts: current}); if (context.ok !== true) return context; const record = requireJsonObject(request.record, "checkpoint record"); const row = requireJsonObject(request.index_record, "checkpoint index record"); for (const projected of [record, row]) { if (canonicalAuthoritySha256(projected.settlement_identity) !== canonicalAuthoritySha256(binding) || canonicalAuthoritySha256(projected.refresh_recovery) !== canonicalAuthoritySha256(recovery) || - jsonObject(projected.vision_checkpoint)?.satisfied !== true) { + (!resultWork && jsonObject(projected.vision_checkpoint)?.satisfied !== true)) { throw new EffectRuntimeRequestError("checkpoint projection does not match typed admission"); } - projected.vision_checkpoint = {...requireJsonObject(projected.vision_checkpoint, "vision checkpoint"), read_context: context}; + if (resultWork) { + if (projected.delivery_outcome !== retry.delivery_outcome || + jsonObject(projected.vision_checkpoint)?.delivery_boundary !== retry.delivery_boundary) { + throw new EffectRuntimeRequestError("Work-basis append must match the admitted result writeback"); + } + projected.result_work_context = context; + } else projected.vision_checkpoint = {...requireJsonObject(projected.vision_checkpoint, "vision checkpoint"), read_context: context}; } const jsonPath = runPath(row.json_path, runsDir, ".json"); const markdownPath = runPath(row.markdown_path, runsDir, ".md"); try { + if (firstDelivery || resultWork) { + // Persist uncertainty before the first run artifact. A replacement + // read cannot discard this attempt after a crash or a lost response. + writeReceipt(receiptPath, {...requireJsonObject(receipt, "first delivery receipt"), + commit_attempt: {vision_request_digest: recovery.vision_request_digest, + mutation_digest: recovery.mutation_digest, json_path: jsonPath, markdown_path: markdownPath, + index_record: row}}); + } + const currentDirectory = statSync(physicalRuns, {bigint: true}); + if (realpathSync(runsDir) !== physicalRuns || currentDirectory.dev !== directoryIdentity.dev || + currentDirectory.ino !== directoryIdentity.ino) unknown("checkpoint artifact directory changed"); writeSynced(jsonPath, JSON.stringify(record, null, 2) + "\n"); writeSynced(markdownPath, requireNonEmptyString(request.markdown, "checkpoint Markdown")); appendJsonLineSync(indexPath, row); } catch { unknown("checkpoint append outcome is uncertain"); } return {ok: true, replayed: false, context, json_path: jsonPath, markdown_path: markdownPath}; - }); + }, firstDelivery && !checkpointContinuationRequired(jsonObject(readback.heartbeat_receipt)), false, + firstDelivery || resultWork || checkpointContinuationRequired(jsonObject(readback.heartbeat_receipt)), dependencies); } finally { // The effect owns the end of the handed-off critical section. Releasing the // markers here also handles a caller that timed out but is still alive. diff --git a/loopx/control_plane/goals/checkpoint_context_io.py b/loopx/control_plane/goals/checkpoint_context_io.py index d63a223209..e25702376b 100644 --- a/loopx/control_plane/goals/checkpoint_context_io.py +++ b/loopx/control_plane/goals/checkpoint_context_io.py @@ -1,7 +1,7 @@ """Source/receipt I/O only; checkpoint_read_context.ts owns decision semantics.""" from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping from contextlib import ExitStack, contextmanager import hashlib import json @@ -12,7 +12,7 @@ from ...file_lock import cross_runtime_lock_witness, exclusive_cross_runtime_file_lock from ...history import load_index, load_registry from ...paths import resolve_runtime_root -from ...registry import atomic_write_json +from ..runtime.document_io import atomic_write_private_json as atomic_write_json from ...runtime import validate_goal_id_path_segment from ..coordination.legacy_writer_fence import legacy_coordination_todo_lock_path from ..coordination.shadow_management import shadow_maintenance_lock_target, require_shadow_primary_write_allowed @@ -37,7 +37,10 @@ def _checkpoint_effect(method: str, request: dict[str, Any]) -> Any: # The complete Goal prose and archived Todo basis can exceed the 2 MiB # RPC wire. Only this locked local checkpoint path opts into the exact, # digest-bound same-UID snapshot transport; default effects stay bounded. - return effect_runtime_result(method, request, large_local_snapshot=True) + return effect_runtime_result(method, request, + large_local_snapshot=method not in ("goal.checkpoint_read_context.inspect_attempt", + "goal.checkpoint_read_context.inspect_history", + "turn.first_delivery.observe")) except EffectRuntimeRejected as error: raise CheckpointReadContextRejected({"ok": False, "error": str(error), "error_code": error.diagnostic_code, "reread_required": False}) from error @@ -54,11 +57,538 @@ def _resolve(runtime_root: Path, **request: Any) -> dict[str, Any]: return result -def _receipt_path(root: Path, identity: SettlementIdentity) -> Path: - digest = hashlib.sha256(identity.effect_id.encode()).hexdigest() +def _receipt_path(root: Path, identity: SettlementIdentity, purpose: str = "supplement_checkpoint") -> Path: + key = identity.effect_id + (":delivery_result" if purpose == "delivery_result" else "") + digest = hashlib.sha256(key.encode()).hexdigest() return root / "goals" / identity.goal_id / "checkpoint-contexts" / f"{digest}.json" +def _read_context_receipt(path: Path) -> dict[str, Any]: + receipt = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(receipt, dict): + raise ValueError("checkpoint receipt must be an object") + return receipt + + +def default_result_freshness_policy(root: Path, goal_id: str, todo_id: str | None, + goal_ref: Mapping[str, Any] | None = None) -> str | None: + from ..coordination.local_authority import read_canonical_todos_if_promoted + + canonical = read_canonical_todos_if_promoted(runtime_root=root, goal_id=goal_id) + return _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "admission", "todo_id": todo_id, "goal_ref": goal_ref, + "source_authority": (canonical or {}).get("source_authority", "legacy_markdown"), + })["policy"] + + +def result_work_required(readback: Any) -> bool: + return _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "policy", "heartbeat_receipt": readback.heartbeat_receipt, + })["required"] + + +def checkpoint_continuation_required(readback: Any) -> bool: + return _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "direction_policy", "heartbeat_receipt": readback.heartbeat_receipt, + })["required"] + + +def result_work_read_id(root: Path, identity: SettlementIdentity) -> str: + """Restore the admitted work token; a writeback must never mint a new read.""" + try: + receipt = _read_context_receipt(_receipt_path(root, identity, "delivery_result")) + token = receipt.get("read_context_id") + if isinstance(token, str) and token: + return token + except (OSError, ValueError): + pass + raise CheckpointReadContextRejected({ + "ok": False, "error_code": "delivery_work_context_required", "reread_required": True, + "error": "Restore delivery_result context for the original Turn and revalidate the retained progress before retrying.", + }) + + +def result_work_context_exists(root: Path, identity: SettlementIdentity) -> bool: + return _receipt_path(root, identity, "delivery_result").exists() + + +def restore_default_turn_id(*, runtime_root: Path, registry_path: Path, goal_id: str, + agent_id: str, todo_id: str | None, pending_only: bool) -> str | None: + from .first_party_host_admission import capture_first_party_host_goal_ref + from ..quota.settlement import QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA + todo = None + if not pending_only and todo_id: + from ...todos import list_goal_todos + + todo = list_goal_todos(registry_path=registry_path, goal_id=goal_id, todo_id=todo_id, + runtime_root_arg=str(runtime_root)).get("todo") + goal_ref = capture_first_party_host_goal_ref(registry_path=registry_path, goal_id=goal_id) + with quota_accounting_admission(runtime_root=runtime_root, registry_path=registry_path, + goal_id=goal_id, goal_ref=goal_ref, operation="default-result-work-read", lock_legacy_index=False) as admission: + request = { + "schema_version": QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, "runtime_root": str(runtime_root.resolve()), + "goal_id": goal_id, "agent_id": agent_id, "todo_id": todo_id, "turn_instance_id": None, + "infer_turn_instance_id": False, "allow_unbound_binding": False, "pending_only": pending_only, "todo": todo, + **({"goal_ref": goal_ref, "source_admission": admission} if goal_ref is not None else {}), + } + try: + result = effect_runtime_result("quota.settlement.default_result_work", request) + except EffectRuntimeRejected as error: + raise CheckpointReadContextRejected({"ok": False, "error": str(error), + "error_code": error.diagnostic_code, "reread_required": False}) from error + if result.get("ok") is not True: + raise CheckpointReadContextRejected(result) + return result["turn_instance_id"] + + +def result_work_context(*, registry_path: Path, runtime_root: Path, + identity: SettlementIdentity, reread: bool = False, + goal_ref: Mapping[str, Any] | None = None) -> dict[str, Any]: + """Restore original work input; only explicit revalidation may replace it.""" + path = _receipt_path(runtime_root, identity, "delivery_result") + if not reread and path.exists(): + result = _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "restore", "identity": identity.as_dict(), "receipt": _read_context_receipt(path), + "goal_ref": goal_ref, + }) + if result.get("ok") is not True: + raise CheckpointReadContextRejected(result) + return result + read_checkpoint_context(registry_path=registry_path, runtime_root_override=str(runtime_root), + goal_id=identity.goal_id, agent_id=identity.agent_id, todo_id=identity.todo_id, + turn_instance_id=identity.turn_instance_id, purpose="delivery_result", goal_ref=goal_ref) + return result_work_context(registry_path=registry_path, runtime_root=runtime_root, identity=identity, goal_ref=goal_ref) + + +def check_result_work_context(*, registry_path: Path, runtime_root: Path, + identity: SettlementIdentity, context: Mapping[str, Any], + goal_ref: Mapping[str, Any] | None = None) -> dict[str, Any]: + from ...state_refresh import resolve_goal_state + + _, _, state_file = resolve_goal_state(registry=load_registry(registry_path), goal_id=identity.goal_id, + project_override=None, state_file_override=None) + with exclusive_cross_runtime_file_lock(runtime_root / "goals" / identity.goal_id / "runs/index.jsonl", + operation="check-result-work"): + with _source_guard(runtime_root, identity.goal_id, state_file, registry_path): + return _resolve(runtime_root, phase="check", purpose="delivery_result", identity=identity.as_dict(), + read_context_id=context.get("read_context_id"), + receipt=_read_context_receipt(_receipt_path(runtime_root, identity, "delivery_result")), + facts=_local_source_facts(runtime_root, registry_path, state_file, identity, goal_ref)) + + +def bind_result_validation(*, runtime_root: Path, identity: SettlementIdentity, candidate: Mapping[str, Any], + validation: Mapping[str, Any], context: Mapping[str, Any], binding=None) -> dict[str, Any]: + receipt = _read_context_receipt(_receipt_path(runtime_root, identity, "delivery_result")) + if receipt.get("read_context_id") != context.get("read_context_id"): + raise CheckpointReadContextRejected({"ok": False, "error_code": "delivery_validation_binding_mismatch", + "error": "Candidate validation belongs to a replaced work basis; revalidate the retained candidate."}) + result = _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "bind_validation" if binding is None else "check_validation", "identity": identity.as_dict(), + "receipt": receipt, "candidate": dict(candidate), "validation": dict(validation), "binding": binding, + }) + if result.get("ok") is not True: + raise CheckpointReadContextRejected(result) + return result + + +def bind_terminal_result_validation(*, runtime_root: Path, request: Mapping[str, Any], + context: Mapping[str, Any]) -> dict[str, Any]: + identity = SettlementIdentity.from_runtime_payload(context["identity"]) + receipt = _read_context_receipt(_receipt_path(runtime_root, identity, "delivery_result")) + if receipt.get("read_context_id") != context.get("read_context_id"): + raise CheckpointReadContextRejected({"ok": False, "error_code": "delivery_validation_binding_mismatch", + "error": "The work basis changed during validation; retain the candidate and revalidate."}) + result = _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "bind_validation", "identity": identity.as_dict(), "receipt": receipt, + "terminal_input": dict(request), + }) + if result.get("ok") is not True: + raise CheckpointReadContextRejected(result) + return result["binding"] + + +def terminal_validation_effect_io(*, runtime_root: Path, request: Mapping[str, Any], + context: Mapping[str, Any], execution_result: Mapping[str, Any], + updates: Mapping[str, Any] | None = None) -> dict[str, Any]: + """Persist only the existing typed owner's admitted validation effect state.""" + identity = SettlementIdentity.from_runtime_payload(context["identity"]) + path = _receipt_path(runtime_root, identity, "delivery_result") + with exclusive_cross_runtime_file_lock(path, operation="terminal-validation-effect"): + receipt = _read_context_receipt(path) + if receipt.get("read_context_id") != context.get("read_context_id"): + raise CheckpointReadContextRejected({"ok": False, "error_code": "delivery_validation_binding_mismatch", + "error": "Validation belongs to a replaced work basis; retain the original candidate."}) + result = _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "begin_terminal_validation" if updates is None else "finish_terminal_validation", + "identity": identity.as_dict(), "receipt": receipt, + "terminal_input": dict(request), "execution_result": dict(execution_result), + **({"updates": dict(updates)} if updates is not None else {}), + }) + if result.get("ok") is not True: + raise CheckpointReadContextRejected(result) + if "receipt" in result: + atomic_write_json(path, result["receipt"]) + return result + + +def completion_identity_for_original_turn(*, todo: Mapping[str, Any], identity: SettlementIdentity, + goal_id: str, agent_id: str, todo_id: str, + candidate: Mapping[str, Any]) -> dict[str, Any]: + """Forward the admitted identity and actual Todo to the existing typed owner.""" + result = _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "completion_identity", "todo": dict(todo), "identity": identity.as_dict(), + "goal_id": goal_id, "agent_id": agent_id, "todo_id": todo_id, + "candidate": dict(candidate), + }) + key = result.get("completion_turn_key") + if not isinstance(key, str) or not key: + raise RuntimeError("Typed original completion identity is unavailable") + replay = result.get("replay") + if replay is not None and (not isinstance(replay, dict) or replay.get("ok") is not True + or replay.get("completed") is not True or replay.get("changed") is not False): + raise RuntimeError("Typed original completion replay is invalid") + return dict(result) + + +class TurnResultWork: + """Managed Turn IO for the existing typed work-basis owner.""" + + def __init__(self, registry_path: Path, runtime_root: Path, identity: SettlementIdentity, + goal_ref: Mapping[str, Any] | None = None): + self.registry_path, self.runtime_root, self.identity = registry_path, runtime_root, identity + self.goal_ref = goal_ref + + def result_committed(self) -> bool: + from ...state_refresh import resolve_goal_state + from ..todos.durable_completion import read_persisted_todo_record_with_source + from ..todos.completion_fence import evaluate_todo_completion_fence + + _, _, state = resolve_goal_state(registry=load_registry(self.registry_path), goal_id=self.identity.goal_id, + project_override=None, state_file_override=None) + todo, _, source = read_persisted_todo_record_with_source(state, todo_id=self.identity.todo_id, + registry_path=self.registry_path, goal_id=self.identity.goal_id, runtime_root=self.runtime_root) + # Managed journals use the Turn id; the CLI/MCP settlement adapter uses + # its fully scoped effect id. Both name this identity, never another Turn. + completion_key = (self.identity.effect_id if todo.get("completion_turn_key") == self.identity.effect_id + else self.identity.turn_instance_id) + fence = evaluate_todo_completion_fence(todo=todo, projection_source=source, + completion_turn_key=completion_key, no_followup=False, + goal_id=self.identity.goal_id, todo_id=self.identity.todo_id, + completion_identity_source="turn_settlement") + return fence["outcome"] == "replay" and fence["status"] == "done" + + def prepare_request(self, journal: dict[str, Any], request: dict[str, Any], *, + post_result_checkpoint: Callable[[], bool] | None, + persist_journal: Callable[[Mapping[str, Any]], None]) -> bool: + """Persist the original work basis before dispatch or exact cold recovery.""" + original_result_committed = (isinstance(journal.get("result_work_context"), Mapping) and ( + ("durable_writeback" in journal.get("completed_phases", []) and + post_result_checkpoint is not None and post_result_checkpoint()) or self.result_committed())) + if not original_result_committed: + revalidation = journal.get("result_revalidation") + context = self.read() + if revalidation is not None and (context == revalidation.get("original_context") or revalidation.get("reread_required") is True): + context = self.read(reread=True) + if context is not None: + previous = journal.get("result_work_context") + if previous is not None and revalidation is None and previous != context: + raise ValueError("Original Turn work input was replaced; preserve its candidate and reconcile") + journal["result_work_context"] = context + request["delivery_result_context"] = context + if revalidation is not None: + request["result_revalidation"] = revalidation + revalidation["reread_required"] = False + # Durable work input precedes dispatch, including cold retries. + persist_journal(journal) + return original_result_committed + + def read(self, *, reread: bool = False) -> dict[str, Any] | None: + readback = read_heartbeat_settlement(self.runtime_root, goal_id=self.identity.goal_id, + agent_id=self.identity.agent_id, todo_id=self.identity.todo_id, + turn_instance_id=self.identity.turn_instance_id, registry_path=self.registry_path, goal_ref=self.goal_ref) + if not result_work_required(readback): + return None + return result_work_context(registry_path=self.registry_path, runtime_root=self.runtime_root, + identity=self.identity, reread=reread, goal_ref=self.goal_ref) + + def check(self, context: Mapping[str, Any]) -> None: + read_heartbeat_settlement(self.runtime_root, goal_id=self.identity.goal_id, + agent_id=self.identity.agent_id, todo_id=self.identity.todo_id, + turn_instance_id=self.identity.turn_instance_id, registry_path=self.registry_path, goal_ref=self.goal_ref) + # A writeback may precede a terminal Todo commit. Only the typed owner + # can establish that the original result is already committed. + check_result_work_context(registry_path=self.registry_path, runtime_root=self.runtime_root, + identity=self.identity, context=context, goal_ref=self.goal_ref) + + def bind(self, context: Mapping[str, Any], candidate: Mapping[str, Any], + validation: Mapping[str, Any], binding=None) -> dict[str, Any]: + return bind_result_validation(runtime_root=self.runtime_root, identity=self.identity, + candidate=candidate, validation=validation, context=context, binding=binding) + + +def capture_legacy_result_work(**request: Any) -> dict[str, Any] | None: + # Optional/damaged history cannot precede exact successful result recovery. + # A new effect still reaches the locked typed rejection below. + try: + return _capture_legacy_result_work(**request) + except (ValueError, OSError) as error: + return {"error": {"ok": False, "error_code": "delivery_work_context_unknown", "error": str(error)}} + + +def _capture_legacy_result_work(*, runtime_root: Path, registry_path: Path, goal_id: str, todo_id: str, agent_id: str | None, + completion_turn_key: str | None, identity_payload: Mapping[str, Any] | None, + read_context_id: str | None) -> dict[str, Any] | None: + """Read IO before validation; enforce after historical replay under writer locks.""" + from ...rollout_event_log import rollout_event_log_path + from .first_party_host_admission import capture_first_party_host_goal_ref + + goal_ref = capture_first_party_host_goal_ref(registry_path=registry_path, goal_id=goal_id) + + path = rollout_event_log_path(runtime_root, goal_id) + content = path.read_bytes() if path.exists() else b"" + if content and not content.endswith(b"\n"): + raise ValueError("result work admission log has an incomplete tail") + events = [json.loads(line) for line in content.splitlines() if line.strip()] + admission = _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "legacy_admission", "events": events, "todo_id": todo_id, "agent_id": agent_id, + "completion_turn_key": completion_turn_key, "identity": identity_payload, + "goal_ref": goal_ref, + }) + if admission.get("required") is False: + return None + if admission.get("ok") is False: + return {"error": admission} + identity = SettlementIdentity.from_runtime_payload(identity_payload) + try: + context = _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "restore", "identity": identity.as_dict(), + "receipt": _read_context_receipt(_receipt_path(runtime_root, identity, "delivery_result")), + "goal_ref": goal_ref, + }) + if context.get("ok") is not True: + return {"error": context} + if read_context_id and context["read_context_id"] != read_context_id: + return {"error": {"ok": False, "error_code": "checkpoint_read_context_unknown_or_replaced", + "error": "The candidate refers to a replaced work read; revalidate on the original Turn."}} + return context + except (ValueError, OSError) as error: + return {"error": {"ok": False, "error_code": "delivery_work_context_unknown", "error": str(error)}} + + +@contextmanager +def legacy_result_source_locks(root: Path, registry_path: Path, goal_id: str, *, protected: bool) -> Iterator[None]: + with ExitStack() as stack: + if protected: + for target in (root / "goals" / goal_id / "runs/index.jsonl", registry_path, + shadow_maintenance_lock_target(root, goal_id)): + stack.enter_context(exclusive_cross_runtime_file_lock(target, operation="result-work-commit")) + yield + + +def legacy_result_commit_policy(transaction: Mapping[str, Any], *, runtime_root: Path, + goal_id: str, todo_id: str, agent_id: str | None, + todo: Mapping[str, Any], candidate: Mapping[str, Any]) -> dict[str, Any]: + """Project the locked typed completion's IO requirements; no admission decision here.""" + result = _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "legacy_commit_policy", "transaction": dict(transaction), + "runtime_root": str(runtime_root), "goal_id": goal_id, "todo_id": todo_id, "agent_id": agent_id, + "todo": dict(todo), "candidate": dict(candidate), + }) + if result.get("ok") is False: + raise CheckpointReadContextRejected(result) + return result + + +def check_locked_legacy_result_work(*, runtime_root: Path, registry_path: Path, state_file: Path, + context: Mapping[str, Any] | None, candidate: Mapping[str, Any], + validation: Mapping[str, Any], commit_policy: Mapping[str, Any]) -> None: + if context is None or commit_policy.get("required") is False: + return + if context.get("error"): + raise CheckpointReadContextRejected(dict(context["error"])) + identity = SettlementIdentity.from_runtime_payload(context["settlement_identity"]) + from .first_party_host_admission import capture_first_party_host_goal_ref + goal_ref = capture_first_party_host_goal_ref(registry_path=registry_path, goal_id=identity.goal_id) + bind_result_validation(runtime_root=runtime_root, identity=identity, + context=context, candidate=candidate, validation={"ok": True, "native_validation": validation}) + _resolve(runtime_root, phase="check", purpose="delivery_result", identity=identity.as_dict(), + read_context_id=context["read_context_id"], + receipt=_read_context_receipt(_receipt_path(runtime_root, identity, "delivery_result")), + facts=_local_source_facts(runtime_root, registry_path, state_file, identity, goal_ref)) + + +def first_delivery_context_enrolled(root: Path, identity: SettlementIdentity, *, result_only: bool = False) -> bool: + """Read the persisted protocol selection; enrollment survives caller restart.""" + for purpose in ("first_delivery", "delivery_result"): + # Default result protection has its own persisted admission policy. + # Its work receipt cannot enroll the optional direction protocol. + if result_only and purpose == "delivery_result": + continue + try: + receipt = _read_context_receipt(_receipt_path(root, identity, purpose)) + except FileNotFoundError: + continue + if receipt.get("purpose") == purpose: + if purpose == "delivery_result" and receipt.get("result_only") is True: + continue + return True + return False + + +def _inspect_attempt(root: Path, identity: SettlementIdentity, purpose: str = "first_delivery") -> dict[str, Any]: + # Called with the original index and source locks held. The TS inspector is + # read-only; only this locked IO adapter can clear a proved absent marker. + result = _checkpoint_effect("goal.checkpoint_read_context.inspect_attempt", { + "runtime_root": str(root.resolve()), "identity": identity.as_dict(), "purpose": purpose, "check_other_attempts": True}) + receipt = result.pop("receipt", None) + if receipt is not None: + atomic_write_json(_receipt_path(root, identity, purpose), receipt) + return result + + +def first_delivery_progress( + root: Path, readback: Any, *, canonical_todos: list[dict[str, Any]] | None = None, + observation_unknown: bool = False, registry_path: Path | None = None, verified_direction: bool = False, +) -> dict[str, Any] | None: + """Observe protocol receipts for quota consumers; this grants no write authority. + + A snapshot may lag a concurrent writer. Ambiguity is displayed as unknown; + only the locked replay path can resolve it or admit another operation. + """ + identity = readback.identity.value + if identity is None: + return None + committed = False + selected: dict[str, Any] = {} + unknown = observation_unknown + read_unavailable = False + try: + if not verified_direction and not first_delivery_context_enrolled(root, identity) and not checkpoint_continuation_required(readback): + return None + except (ValueError, OSError): + read_unavailable = True + try: + if canonical_todos is None: + canonical_todos = _delivery_observation_todos(root, identity.goal_id, registry_path) + selected = next((todo for todo in canonical_todos if todo["todo_id"] == identity.todo_id), {}) + committed = selected.get("status") == "done" and selected.get("completion_turn_key") in {identity.effect_id, identity.turn_instance_id} + except (ValueError, OSError): + unknown = True + try: + direction_path = _receipt_path(root, identity) + direction = _read_context_receipt(direction_path) if direction_path.exists() else {} + except (ValueError, OSError): + direction = {} + read_unavailable = True + if read_unavailable: + try: + proof = _checkpoint_effect("goal.checkpoint_read_context.inspect_attempt", { + "runtime_root": str(root.resolve()), "identity": identity.as_dict(), "purpose": "first_delivery"}) + unknown = unknown or proof.get("status") != "committed" + except (ValueError, OSError): + unknown = True + progress = effect_runtime_result("turn.first_delivery.evaluate", { + "phase": "project", "result_committed": committed, + "checkpoint_continuation": checkpoint_continuation_required(readback), + "completion_event": readback.completion_event, + # Observation consumes stage markers, not the complete Agent input or + # an append's projected index record. Keep the ordinary RPC bounded. + "writeback_run": {"vision_checkpoint": (readback.writeback_run or {}).get("vision_checkpoint")}, + "direction_receipt": { + "direction_stopped": direction.get("direction_stopped"), + "direction_host_failure": direction.get("direction_host_failure"), + "commit_attempt": {} if direction.get("commit_attempt") is not None else None, + }, + "unknown": unknown, + "quota_spent": readback.spend_run is not None, + "settlement_complete": readback.settlement.failure is None, + "terminal_settlement_complete": readback.terminal_settlement.failure is None, + "identity": identity.as_dict(), + "completion_todo": {key: selected.get(key) for key in ( + "status", "no_followup", "completion_continuation", + "completion_requested_continuation", "completion_turn_key")}, + }) + return {**progress, "settlement_identity": identity.as_dict()} + + +def _delivery_observation_todos(root: Path, goal_id: str, registry_path: Path | None) -> list[dict[str, Any]]: + from ..coordination.local_authority import read_canonical_todos_if_promoted + canonical = read_canonical_todos_if_promoted(runtime_root=root, goal_id=goal_id) + if canonical is not None: + return canonical.get("todos", []) + if registry_path is None: + return [] + from ...todos import resolve_todo_state_path + _, state = resolve_todo_state_path(registry_path=registry_path, goal_id=goal_id, project=None, state_file=None) + active, archived, _ = parse_todo_source(state.read_text(encoding="utf-8")) + return [*active["user"], *active["agent"], *archived] + + +def pending_first_delivery_progress(root: Path, goal_id: str, agent_id: str | None = None, + *, registry_path: Path | None = None) -> dict[str, Any] | None: + """Read provider facts once; the typed owners observe all historical Turns. + + This remains an alias observation, as before. A Todo source path does not + grant exact GoalRef admission or authorize historical receipt repair. + """ + try: + todos = [{key: todo.get(key) for key in ( + "todo_id", "status", "no_followup", "completion_continuation", + "completion_requested_continuation", "completion_turn_key")} + for todo in _delivery_observation_todos(root, goal_id, registry_path)] + except (ValueError, OSError): + todos = None + return _checkpoint_effect("turn.first_delivery.observe", { + "runtime_root": str(root.resolve()), "goal_id": goal_id, + "agent_id": agent_id, "todos": todos, + # Keep the caller's historical scan order across Python/Node platforms. + "receipt_names": [path.name for path in (root / "goals" / goal_id / "checkpoint-contexts").glob("*.json")], + }) + + +def delivery_result_context_input( + *, runtime_root: Path, registry_path: Path, state_file: Path, + identity: SettlementIdentity, read_context_id: str | None, +) -> dict[str, Any] | None: + """Capture IO for the native Todo owner; never refresh the Agent's token. + + The native owner recovers a historical receipt before checking capture + errors. Every validation continuation recaptures sources under short locks. + """ + result: dict[str, Any] = {"identity": identity.as_dict(), + "read_context_id": read_context_id or "missing", "state_file": str(state_file.resolve())} + from .first_party_host_admission import capture_first_party_host_goal_ref + goal_ref = capture_first_party_host_goal_ref(registry_path=registry_path, goal_id=identity.goal_id) + if not read_context_id: + try: + readback = read_heartbeat_settlement(runtime_root, goal_id=identity.goal_id, + agent_id=identity.agent_id, todo_id=identity.todo_id, + turn_instance_id=identity.turn_instance_id, registry_path=registry_path, goal_ref=goal_ref) + if readback is not None and result_work_required(readback): + receipt = _read_context_receipt(_receipt_path(runtime_root, identity, "delivery_result")) + read_context_id = receipt.get("read_context_id") + result["read_context_id"] = read_context_id or "missing" + elif not first_delivery_context_enrolled(runtime_root, identity): + return None + except (ValueError, OSError) as error: + # Preserve the ordinary request fingerprint for receipt-first + # recovery. Only the native owner can prove an unreadable shared + # supplement optional; this is not evidence of non-enrollment. + return {**result, "read_context_id": None, "capture_error": str(error)} + index = runtime_root / "goals" / identity.goal_id / "runs" / "index.jsonl" + try: + with exclusive_cross_runtime_file_lock(index, operation="delivery-result-capture"): + with _source_guard(runtime_root, identity.goal_id, state_file, registry_path): + result.update( + facts=_local_source_facts(runtime_root, registry_path, state_file, identity, goal_ref), + state_sha256=hashlib.sha256(state_file.read_bytes()).hexdigest(), + index_sha256=hashlib.sha256(index.read_bytes() if index.exists() else b"").hexdigest(), + ) + except (ValueError, OSError) as error: + result["capture_error"] = str(error) + return result + + def require_complete_checkpoint_index(index: Path) -> None: """Framing check before replay too; typed settlement validates the rows.""" try: @@ -73,7 +603,7 @@ def require_complete_checkpoint_index(index: Path) -> None: @contextmanager -def _source_guard(root: Path, goal_id: str, state_file: Path) -> Iterator[None]: +def _source_guard(root: Path, goal_id: str, state_file: Path, registry_path: Path | None = None) -> Iterator[None]: """Caller holds runs/index first. Match promotion's M -> Todo -> state order. M prevents source cutover; it does not exclude canonical provider commits. @@ -81,6 +611,8 @@ def _source_guard(root: Path, goal_id: str, state_file: Path) -> Iterator[None]: Do not run projection sync or a new state mutation inside this guard. """ with ExitStack() as locks: + if registry_path is not None: + locks.enter_context(exclusive_cross_runtime_file_lock(registry_path, operation="checkpoint-first-delivery")) for target in ( shadow_maintenance_lock_target(root, goal_id), legacy_coordination_todo_lock_path(runtime_root=root, goal_id=goal_id), @@ -95,8 +627,20 @@ def _local_source_facts( root: Path, registry_path: Path, state_file: Path, identity: SettlementIdentity, goal_ref: Mapping[str, Any] | None = None, ) -> dict[str, Any]: - text = state_file.read_text(encoding="utf-8") + try: + text = state_file.read_text(encoding="utf-8") + except FileNotFoundError as error: + raise CheckpointReadContextRejected({ + "ok": False, "error_code": "checkpoint_goal_source_unavailable", + "reread_required": False, + "error": "The authoritative Goal document is missing; native Todo records cannot restore its objective or acceptance prose.", + "recommended_action": "Use loopx-project to locate and restore the Goal owner's actual document or backup. Retry quota should-run for the original Agent/Turn; preserve its admitted receipt and do not execute or spend before the Goal source is restored.", + }) from error metadata, body = split_state_frontmatter(text) + from ..todos.projection_document import is_legacy_todo_projection_skeleton + + if set(metadata) == {"goal_id"} and is_legacy_todo_projection_skeleton(text, metadata["goal_id"]): + metadata["goal_source_completeness"] = "todo_projection_only" lines = body.splitlines() regions = find_todo_source_regions(lines) owned = {i for region in regions for i in range(region.start, region.end)} @@ -115,12 +659,23 @@ def _local_source_facts( ] newest = [run for _, run in sorted(enumerate(runs), key=lambda pair: (str(pair[1].get("generated_at") or ""), pair[0]), reverse=True)] - return { + facts = { "todos": todos, "frontmatter": metadata, "goal_prose": prose, "acceptance": None, "agent_vision": latest_agent_vision_from_runs(newest, goal_id=identity.goal_id, agent_id=identity.agent_id), "source": {"state_file": str(state_file.resolve()), "runtime_root": str(root.resolve()), "authority": "legacy_markdown"}, } + from ...state_refresh import resolve_goal_state, registered_agents_for_goal + + registry = load_registry(registry_path) + goal, _, current_path = resolve_goal_state(registry=registry, goal_id=identity.goal_id, + project_override=None, state_file_override=None) + if current_path.resolve() != state_file.resolve() or identity.agent_id not in registered_agents_for_goal(goal): + raise ValueError("Checkpoint source or Agent registration changed; reread the original Turn") + facts["source"].update(registry_path=str(registry_path.resolve()), goal_ref=goal_ref, + registry_goal=goal) + facts["checkpoint_identity"] = identity.as_dict() + return facts def read_checkpoint_context( @@ -129,10 +684,13 @@ def read_checkpoint_context( replan_obligation_id: str | None = None, project: Path | None = None, state_file: Path | None = None, dependency_todo_ids: list[str] | None = None, goal_ref: Mapping[str, Any] | None = None, + purpose: str = "supplement_checkpoint", decision_scope: str | None = None, ) -> dict[str, Any]: # Local import avoids a cycle with refresh-state's persistence adapter. from ...state_refresh import resolve_goal_state, registered_agents_for_goal + if purpose == "first_delivery" and goal_ref is not None: + raise ValueError("First delivery freshness currently supports the local-registry Goal profile; source-session GoalRef admission is not qualified.") goal_id = validate_goal_id_path_segment(goal_id) registry = load_registry(registry_path) root = resolve_runtime_root(registry, runtime_root_override, registry_path=registry_path) @@ -155,20 +713,62 @@ def read_checkpoint_context( registry_path=registry_path, goal_ref=goal_ref, source_admission=source_admission, borrow_source_admission=source_admission is not None) - if readback is None or readback.identity.value is None or readback.writeback_run is None: + first_delivery = purpose != "supplement_checkpoint" + if readback is None or readback.identity.value is None or ( + not first_delivery and readback.writeback_run is None + ): raise ValueError("checkpoint-context requires the original committed Turn writeback") identity = readback.identity.value - with _source_guard(root, goal_id, path): - result = _resolve(root, phase="read", identity=identity.as_dict(), prior=readback.writeback_run, - read_context_id=uuid4().hex, dependency_todo_ids=dependency_todo_ids or [], - facts=_local_source_facts(root, registry_path, path, identity, goal_ref)) - receipt = result.pop("receipt") - atomic_write_json(_receipt_path(root, identity), receipt) + continuation = checkpoint_continuation_required(readback) + if decision_scope is None: + decision_scope = "agent_lane" + with _source_guard(root, goal_id, path, registry_path): + if purpose == "first_delivery": + attempt = _inspect_attempt(root, identity) + if attempt.get("status") == "committed": + return {**attempt, "settlement_identity": identity.as_dict(), "purpose": purpose, + "instructions": "Direction already committed. Replay the original refresh or resume this Turn's remaining settlement; do not request another direction."} + with ExitStack() as read_locks: + if purpose == "delivery_result": + read_locks.enter_context(exclusive_cross_runtime_file_lock( + _receipt_path(root, identity, purpose), operation="result-work-read")) + previous_receipt = None + if first_delivery or continuation: + try: + previous_receipt = json.loads(_receipt_path(root, identity, purpose).read_text(encoding="utf-8")) + except FileNotFoundError: + pass + prior = readback.writeback_run + if purpose == "delivery_result" and (previous_receipt or {}).get("commit_attempt") is not None: + # An indexed progress writeback may precede an uncommitted + # terminal result. Inspect its artifacts first; the typed + # owner then distinguishes that proof from a done Todo. + attempt = _inspect_attempt(root, identity, purpose) + previous_receipt = _read_context_receipt(_receipt_path(root, identity, purpose)) + if attempt.get("status") == "committed": + # Inspection verified this exact row against the index + # and artifacts; a later direction row is a different effect. + prior = previous_receipt["commit_attempt"]["index_record"] + result = _resolve(root, phase="read", identity=identity.as_dict(), prior=prior, + purpose=purpose, decision_scope=decision_scope, + receipt=previous_receipt, + result_only=purpose == "delivery_result" and result_work_required(readback), + checkpoint_continuation=continuation, + settlement_complete=readback.settlement.failure is None, + quota_spent=readback.spend_run is not None, + full_frontier=continuation and purpose != "delivery_result", + admitted_turn=readback.heartbeat_receipt is not None, + read_context_id=uuid4().hex, dependency_todo_ids=dependency_todo_ids or [], + facts=_local_source_facts(root, registry_path, path, identity, goal_ref)) + receipt = result.pop("receipt") + atomic_write_json(_receipt_path(root, identity, purpose), receipt) return {**result, "read_context_id": receipt["read_context_id"], "settlement_identity": identity.as_dict(), - "instructions": "Read this basis and judge the direction again. Echo read_context_id as " - "--checkpoint-read-context in the checkpoint-only refresh for this exact Turn. " + "purpose": purpose, + "instructions": ("Read this basis and revalidate the retained candidate or in-flight progress. Retry the original submission; todo complete requires --delivery-read-context. This read does not complete the Todo. " + if purpose == "delivery_result" else "Read this basis and judge the direction again. Echo read_context_id as --checkpoint-read-context in the direction refresh for this exact Turn. ") + "A new checkpoint-context read replaces this receipt; do not run parallel confirmations " - "for the same Turn. On stale/replaced context, reread and rejudge; do not repeat task mutations or spend."} + "for the same Turn. On stale/replaced context, reread and rejudge; do not repeat task mutations or spend." + + (" " + result["instructions"] if result.get("instructions") else "")} @contextmanager @@ -176,15 +776,27 @@ def checkpoint_commit_guard( *, runtime_root: Path, registry_path: Path, state_file: Path, identity: SettlementIdentity, read_context_id: str | None, goal_ref: Mapping[str, Any] | None = None, + source_admission: Mapping[str, Any] | None = None, + purpose: str = "supplement_checkpoint", decision_scope: str | None = None, ) -> Iterator[dict[str, Any]]: """Capture/preview under source locks. The native save repeats the check under the real provider fence; this preliminary check is not the commit.""" - with _source_guard(runtime_root, identity.goal_id, state_file): + protected = purpose != "supplement_checkpoint" + with _source_guard(runtime_root, identity.goal_id, state_file, registry_path): + readback = read_heartbeat_settlement(runtime_root, goal_id=identity.goal_id, + agent_id=identity.agent_id, todo_id=identity.todo_id, turn_instance_id=identity.turn_instance_id, + replan_obligation_id=identity.replan_obligation_id, registry_path=registry_path, goal_ref=goal_ref, + source_admission=source_admission, + borrow_source_admission=source_admission is not None) + if protected: + _inspect_attempt(runtime_root, identity, purpose) try: - receipt = json.loads(_receipt_path(runtime_root, identity).read_text(encoding="utf-8")) + receipt = json.loads(_receipt_path(runtime_root, identity, purpose).read_text(encoding="utf-8")) except FileNotFoundError: receipt = None result = _resolve(runtime_root, phase="check", identity=identity.as_dict(), read_context_id=read_context_id, + checkpoint_continuation=readback is not None and checkpoint_continuation_required(readback), + purpose=purpose, decision_scope=decision_scope, receipt=receipt, facts=_local_source_facts( runtime_root, registry_path, @@ -200,17 +812,23 @@ def commit_checkpoint_run( refresh_retry: dict[str, Any], record: dict[str, Any], index_record: dict[str, Any], markdown: str, goal_ref: Mapping[str, Any] | None = None, source_admission: Mapping[str, Any] | None = None, + purpose: str = "supplement_checkpoint", decision_scope: str | None = None, + read_context_id: str | None = None, ) -> dict[str, Any]: """Handoff the held locks and parsed bytes to one native save operation.""" root = runtime_root.resolve() index = root / "goals" / identity.goal_id / "runs" / "index.jsonl" - targets = (index, shadow_maintenance_lock_target(root, identity.goal_id), + targets = (index, registry_path, + shadow_maintenance_lock_target(root, identity.goal_id), legacy_coordination_todo_lock_path(runtime_root=root, goal_id=identity.goal_id), state_file) result = _checkpoint_effect("goal.checkpoint_read_context.commit", { "runtime_root": str(root), "state_file": str(state_file.resolve()), "identity": identity.as_dict(), + "purpose": purpose, "decision_scope": decision_scope, "read_context_id": read_context_id, + "registry_path": str(registry_path.resolve()), + "registry_sha256": hashlib.sha256(registry_path.read_bytes()).hexdigest(), "locks": [cross_runtime_lock_witness(target) for target in targets], "state_sha256": hashlib.sha256(state_file.read_bytes()).hexdigest(), - "index_sha256": hashlib.sha256(index.read_bytes()).hexdigest(), + "index_sha256": hashlib.sha256(index.read_bytes() if index.exists() else b"").hexdigest(), "facts": _local_source_facts( root, registry_path, @@ -234,7 +852,9 @@ def commit_checkpoint_run( def inspect_checkpoint_replay(runtime_root: Path, goal_id: str, prior: dict[str, Any]) -> None: - if isinstance(prior.get("vision_checkpoint"), dict) and prior["vision_checkpoint"].get("read_context"): + if prior.get("result_work_context") is not None or ( + isinstance(prior.get("vision_checkpoint"), dict) and prior["vision_checkpoint"].get("read_context") + ): _checkpoint_effect("goal.checkpoint_read_context.inspect_replay", { "runtime_root": str(runtime_root.resolve()), "goal_id": goal_id, "prior": prior, }) diff --git a/loopx/control_plane/goals/checkpoint_read_context.ts b/loopx/control_plane/goals/checkpoint_read_context.ts index ebaaad1478..7088f037ff 100644 --- a/loopx/control_plane/goals/checkpoint_read_context.ts +++ b/loopx/control_plane/goals/checkpoint_read_context.ts @@ -1,11 +1,38 @@ /** Read basis for a missing-checkpoint supplement, not an execution/permission lease. * The commit effect holds source/index claims and the real provider fence. */ -import type {JsonObject} from "../effect_program.ts"; +import {settlementIdentity, type JsonObject} from "../effect_program.ts"; +import {createHash} from "node:crypto"; import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; +import {claimAllowsAgent} from "../todos/agent_scope.ts"; +import {resultFreshnessRequired, checkpointContinuationRequired} from "../quota/heartbeat_receipt_identity.ts"; +import {visionAuthoringContract} from "./vision_checkpoint.ts"; +import {hostRetryPolicyCheck} from "../turn_driver/turn_journal.ts"; +import {EffectiveAction} from "../quota/effective_action.generated.ts"; +import {originalTurnRequestsNoFollowup} from "../todos/completion_state.ts"; +import {terminalValidationRecovery} from "../todos/completion_validation_plan.ts"; +import {decodeTodoCompletionFenceRequest, evaluateTodoCompletionFence, + TODO_COMPLETION_FENCE_REQUEST_SCHEMA} from "../todos/completion_fence.ts"; + +const checkpointAuthorityInstructions = + "Current primary Goal documents govern requirements; plans, Todos and prior read contexts are derived records. " + + "A changed Goal is not proof of projection corruption. Reconcile the candidate and derived work with the current requirements; " + + "never restore an existing Goal document from an old plan or read context to make a result pass. " + + "Conflicting primary documents require owner resolution. File write isolation belongs to the host; a read receipt grants no document-edit authority."; const RECEIPT_SCHEMA = "checkpoint_read_context_v1"; +const FIRST_RECEIPT_SCHEMA = "checkpoint_read_context_v2"; +export type CheckpointReadPurpose = "supplement_checkpoint" | "first_delivery" | "delivery_result"; +export type CheckpointDecisionScope = "agent_lane" | "goal"; + +function purpose(request: JsonObject): CheckpointReadPurpose { + const value = request.purpose ?? "supplement_checkpoint"; + if (value !== "supplement_checkpoint" && value !== "first_delivery" && value !== "delivery_result") { + throw new EffectRuntimeRequestError("unsupported checkpoint read purpose"); + } + return value; +} // Exact presentation fields only. Unknown future fields remain part of the basis. const DISPLAY_FIELDS = new Set(["index", "source_section", "schema_version"]); const todoFacts = (todo: JsonObject): JsonObject => Object.fromEntries( @@ -29,9 +56,34 @@ function dependencies(todo: JsonObject): string[] { return [...new Set(result)].sort(); } -function snapshot(request: JsonObject): JsonObject { +export function requireCompleteCheckpointGoalSource(facts: JsonObject): JsonObject { + const metadata = requireJsonObject(facts.frontmatter, "frontmatter"); + if (metadata.goal_source_completeness !== undefined) { + if (metadata.goal_source_completeness !== "todo_projection_only") { + throw new EffectRuntimeRequestError("unsupported Goal source completeness marker"); + } + throw new EffectRuntimeRequestError( + "Restore the Goal owner's actual document via loopx-project; a Todo-only projection cannot supply the Goal basis. Retry the original Turn without work or spend.", + "checkpoint_goal_source_incomplete", + ); + } + return metadata; +} + +export function checkpointBasisSnapshot(request: JsonObject): JsonObject { const identity = requireJsonObject(request.identity, "identity"); const facts = requireJsonObject(request.facts, "facts"); + const metadata = requireCompleteCheckpointGoalSource(facts); + // Re-entry retains the original result, not permission for a new direction. + // Consume the live registry facts under the existing source guard, including + // cached receipt checks, before a direction Host or writeback can run. + const compute = jsonObject(jsonObject(jsonObject(facts.source)?.registry_goal)?.quota)?.compute; + if (purpose(request) === "first_delivery" && typeof compute === "number" && compute <= 0) { + throw new EffectRuntimeRequestError( + "Automatic Goal turns are paused by compute quota. Preserve the original result and receipts; resume the Goal explicitly before continuing this Turn.", + "checkpoint_goal_paused", + ); + } if (!Array.isArray(facts.todos)) throw new EffectRuntimeRequestError("todos must be complete source records"); const records = facts.todos.map(value => todoFacts(requireJsonObject(value, "todo"))); const byId = new Map(); @@ -45,8 +97,25 @@ function snapshot(request: JsonObject): JsonObject { const todoId = identity.todo_id; const task = typeof todoId === "string" ? byId.get(todoId) : null; if (typeof todoId === "string" && !task) throw new EffectRuntimeRequestError("checkpoint Todo is absent; restore its authoritative record before rereading"); + let frontier: JsonObject[] | null = null; + const scope = request.checkpoint_continuation === true && purpose(request) !== "delivery_result" + ? (!task || originalTurnRequestsNoFollowup(task, identity) ? "goal" : "agent_lane") + : request.decision_scope; + if (purpose(request) === "first_delivery" || request.full_frontier === true) { + if (scope !== "agent_lane" && scope !== "goal") { + throw new EffectRuntimeRequestError("first delivery requires agent_lane or goal decision scope"); + } + const agent = requireNonEmptyString(identity.agent_id, "agent_id"); + // Recompute the complete membership. The caller cannot omit new work. + frontier = records.filter(todo => scope === "goal" || todo.role === "user" || + claimAllowsAgent({claim: typeof todo.claimed_by === "string" ? todo.claimed_by : null, + excluded: ids(todo.excluded_agents, "excluded_agents")}, agent)); + frontier = frontier.map(todo => ({todo, key: String(todo.todo_id ?? canonicalAuthoritySha256(todo))})) + .sort((a, b) => a.key.localeCompare(b.key)).map(({todo}) => todo); + } const selected = new Map(); - const pending = [...ids(request.dependency_todo_ids, "dependency_todo_ids"), ...(task ? dependencies(task) : [])]; + const pending = [...ids(request.dependency_todo_ids, "dependency_todo_ids"), + ...(task ? dependencies(task) : []), ...(frontier ?? []).flatMap(dependencies)]; while (pending.length) { const id = pending.shift()!; if (selected.has(id) || id === todoId) continue; @@ -55,12 +124,15 @@ function snapshot(request: JsonObject): JsonObject { selected.set(id, todo); pending.push(...dependencies(todo)); } - const metadata = requireJsonObject(facts.frontmatter, "frontmatter"); + const documents = facts.goal_documents as JsonObject[] | undefined; const goal = { frontmatter: Object.fromEntries(Object.entries(metadata).filter(([key]) => key !== "updated_at")), - prose: facts.goal_prose, + prose: documents?.length ? documents.map(document => document.prose).join("\n\n") : facts.goal_prose, acceptance: facts.acceptance, user_todos: records.filter(todo => todo.role === "user"), + ...(documents?.length ? {state_prose: facts.goal_prose, + primary_documents: documents.map(document => ({path: document.path, + sha256: createHash("sha256").update(String(document.prose)).digest("hex")}))} : {}), }; const basis: JsonObject = { todo: task ?? records, @@ -69,48 +141,452 @@ function snapshot(request: JsonObject): JsonObject { agent_vision: facts.agent_vision, source: facts.source, }; + if (frontier !== null) { + basis.frontier = {scope, todos: frontier}; + } + if (purpose(request) !== "supplement_checkpoint") { + basis.execution_lease = facts.execution_lease ?? null; + } return {basis, provider_revision: facts.provider_revision ?? null, versions: Object.fromEntries(Object.entries(basis).map(([key, value]) => [key, canonicalAuthoritySha256(value)]))}; } +function checkpointSnapshotOrRejection(request: JsonObject): JsonObject { + try { + return checkpointBasisSnapshot(request); + } catch (error) { + // This is a known source-state rejection, not malformed terminal input. + // Return it through the caller's existing zero-effect rejection path. + if (!(error instanceof EffectRuntimeRequestError) || + !["checkpoint_goal_source_incomplete", "checkpoint_goal_paused"].includes(error.code)) throw error; + return {ok: false, error_code: error.code, reread_required: false, + error: error.message, recommended_action: error.message}; + } +} + const rejected = (code: string, changed: string[] = []): JsonObject => ({ ok: false, error_code: code, reread_required: true, changed_components: changed, error: `${code}: run checkpoint-context for the same Goal/Agent/Todo or obligation/Turn, ` + - "read the returned state and judge again; submit its --checkpoint-read-context with only the vision decision. " + - "Do not repeat implementation, state mutations, or quota spend.", + "using the original purpose. Recheck the candidate for delivery_result, or judge the direction for first_delivery/supplement_checkpoint. " + + "Use the returned identity only with that new decision. Retain successful result and quota receipts.", }); export function evaluateCheckpointReadContext(value: unknown): JsonObject { const request = requireJsonObject(value, "checkpoint read context"); const identity = requireJsonObject(request.identity, "identity"); const token = request.read_context_id; + const use = purpose(request); if (request.phase === "read") { - const prior = requireJsonObject(request.prior, "committed writeback"); - const checkpoint = jsonObject(prior.vision_checkpoint); - if (checkpoint?.decision !== "missing_required" || checkpoint.satisfied !== false) { - return rejected("checkpoint_context_not_missing"); - } - const projected = snapshot(request); - return {ok: true, ...projected, receipt: { - schema_version: RECEIPT_SCHEMA, read_context_id: requireNonEmptyString(token, "read_context_id"), + if (use === "delivery_result" && + jsonObject(jsonObject(request.receipt)?.terminal_validation_effect)?.status === "pending") { + return {...rejected("delivery_validation_outcome_unknown"), reread_required: false, + error: "Original validation may have executed; reconcile its outcome before replacing the work read."}; + } + if (use !== "supplement_checkpoint") { + const receipt = jsonObject(request.receipt); + const attempt = jsonObject(receipt?.commit_attempt); + const indexed = jsonObject(attempt?.index_record); + const prior = jsonObject(request.prior); + // A proved writeback is not a completed Todo. Historical result-only + // Turns and settled delegated progress may still need terminal validation. + // Its original append/artifacts are inspected by the IO owner before + // this read; only matching proof on those lanes may release the marker. + // Pending current checkpoint lanes retain exact writeback replay. + const committedWork = use === "delivery_result" && request.result_only === true && + prior !== null && indexed !== null && + jsonObject(prior.result_work_context)?.read_context_id === receipt?.read_context_id && + canonicalAuthoritySha256(prior.settlement_identity) === canonicalAuthoritySha256(identity) && + canonicalAuthoritySha256(prior.refresh_recovery) === canonicalAuthoritySha256(indexed.refresh_recovery) && + canonicalAuthoritySha256(prior.result_work_context) === canonicalAuthoritySha256(indexed.result_work_context); + if (receipt?.commit_attempt != null && (!committedWork || + (request.checkpoint_continuation !== false && + !(request.settlement_complete === true && request.quota_spent === true)))) { + return {...rejected(committedWork ? "delivery_result_already_committed" : "checkpoint_commit_unknown"), reread_required: false, + error: "A first delivery append may have started; inspect the original Turn and artifacts before another judgment."}; + } + if ((request.prior != null && !(use === "delivery_result" && request.result_only === true)) || request.admitted_turn !== true) { + return rejected("checkpoint_first_delivery_not_admitted"); + } + } else { + const prior = requireJsonObject(request.prior, "committed writeback"); + const checkpoint = jsonObject(prior.vision_checkpoint); + if (checkpoint?.decision !== "missing_required" || checkpoint.satisfied !== false) { + return rejected("checkpoint_context_not_missing"); + } + } + const projected = checkpointSnapshotOrRejection(request); + if (projected.ok === false) return projected; + const task = jsonObject(requireJsonObject(projected.basis, "basis").todo); + const readScope = jsonObject(requireJsonObject(projected.basis, "basis").frontier)?.scope ?? request.decision_scope; + if (request.checkpoint_continuation === true && use !== "delivery_result" && + request.full_frontier !== true) { + return rejected("checkpoint_direction_context_invalid"); + } + if (use === "first_delivery" && request.checkpoint_continuation === true && + !(task?.status === "done" && [identity.effect_id, identity.turn_instance_id].includes(task.completion_turn_key))) { + return {...rejected("checkpoint_result_not_committed"), reread_required: false}; + } + if (use === "delivery_result" && task?.status === "done" && + [identity.effect_id, identity.turn_instance_id].includes(task.completion_turn_key)) { + return {...rejected("delivery_result_already_committed"), reread_required: false, + original_read_context_id: jsonObject(request.receipt)?.read_context_id ?? null, + error: "This Turn's result is already committed. Keep its original read identity for replay and read first_delivery for the pending direction."}; + } + const prior = jsonObject(request.prior); + const ownerReview = jsonObject(projected.basis)?.goal as JsonObject; + const previousGoal = jsonObject(jsonObject(request.receipt)?.versions)?.goal; + const reviewAuthority = ownerReview.primary_documents !== undefined || + (previousGoal !== undefined && previousGoal !== jsonObject(projected.versions)?.goal); + const originalOutcome = prior === null ? null : Object.fromEntries([ + "classification", "delivery_outcome", "delivery_batch_scale", "recommended_action", "vision_checkpoint", + "autonomous_replan_ack", "settlement_identity", "progress_observation"].filter(key => prior[key] !== undefined) + .map(key => [key, prior[key]])); + return {ok: true, ...projected, ...(reviewAuthority ? {instructions: checkpointAuthorityInstructions} : {}), + ...(originalOutcome ? {original_outcome: originalOutcome} : {}), + ...(use !== "delivery_result" ? {vision_authoring: visionAuthoringContract()} : {}), receipt: { + schema_version: use !== "supplement_checkpoint" ? FIRST_RECEIPT_SCHEMA : RECEIPT_SCHEMA, + ...(use !== "supplement_checkpoint" ? {purpose: use, decision_scope: readScope} : {}), + read_context_id: requireNonEmptyString(token, "read_context_id"), + ...(request.full_frontier === true ? {full_frontier: true, decision_scope: readScope} : {}), identity, dependency_todo_ids: ids(request.dependency_todo_ids, "dependency_todo_ids"), versions: projected.versions, provider_revision: projected.provider_revision, + ...(use === "delivery_result" ? {work_basis: projected.basis} : {}), + ...(request.checkpoint_continuation === true && use !== "delivery_result" ? {decision_basis: projected.basis} : {}), + ...(request.checkpoint_continuation === true && use !== "delivery_result" ? {vision_authoring: visionAuthoringContract()} : {}), + ...(request.checkpoint_continuation === true && use !== "delivery_result" && originalOutcome ? {original_outcome: originalOutcome} : {}), + ...(request.checkpoint_continuation === true && use !== "delivery_result" && + canonicalAuthoritySha256(jsonObject(request.receipt)?.identity ?? null) === canonicalAuthoritySha256(identity) ? + Object.fromEntries(["direction_host_attempt_count", "direction_host_failure", "direction_stopped", "direction_stops"] + .filter(key => jsonObject(request.receipt)?.[key] !== undefined) + .map(key => [key, jsonObject(request.receipt)?.[key]])) : {}), + ...(use === "delivery_result" && request.result_only === true ? {result_only: true} : {}), }}; } if (request.phase !== "check") throw new EffectRuntimeRequestError("unknown checkpoint read context phase"); if (typeof token !== "string" || !token.trim()) return rejected("checkpoint_read_context_required"); const receipt = jsonObject(request.receipt); - if (receipt?.schema_version !== RECEIPT_SCHEMA || receipt.read_context_id !== token) { + if (request.checkpoint_continuation === true && use !== "delivery_result" && + (receipt?.full_frontier !== true || !["agent_lane", "goal"].includes(String(receipt.decision_scope)))) { + return rejected("checkpoint_direction_context_invalid"); + } + if (receipt?.schema_version !== (use !== "supplement_checkpoint" ? FIRST_RECEIPT_SCHEMA : RECEIPT_SCHEMA) || + receipt.read_context_id !== token || + (use !== "supplement_checkpoint" && receipt.purpose !== use)) { return rejected("checkpoint_read_context_unknown_or_replaced"); } if (canonicalAuthoritySha256(receipt.identity) !== canonicalAuthoritySha256(identity)) { return rejected("checkpoint_read_context_identity_mismatch"); } - const projected = snapshot({...request, dependency_todo_ids: receipt.dependency_todo_ids}); + if (use === "first_delivery" && request.checkpoint_continuation !== true && request.decision_scope != null && + request.decision_scope !== receipt.decision_scope) { + return rejected("checkpoint_read_context_scope_mismatch"); + } + if (use === "first_delivery" && receipt.commit_attempt != null) { + return {...rejected("checkpoint_commit_unknown"), reread_required: false, + error: "The original direction append is uncertain; read back its original Turn before retrying."}; + } + const projected = checkpointSnapshotOrRejection({...request, dependency_todo_ids: receipt.dependency_todo_ids, + ...(receipt.full_frontier === true ? {full_frontier: true, decision_scope: receipt.decision_scope} : {}), + ...(use === "first_delivery" ? {decision_scope: receipt.decision_scope} : {})}); + if (projected.ok === false) return projected; + const committedTask = jsonObject(requireJsonObject(projected.basis, "basis").todo); + if (use === "delivery_result" && receipt.result_only === true && committedTask?.status === "done" && + [identity.effect_id, identity.turn_instance_id].includes(committedTask.completion_turn_key)) { + return {ok: true, read_context_id: token, result_committed: true}; + } const expected = requireJsonObject(receipt.versions, "receipt versions"); const current = requireJsonObject(projected.versions, "current versions"); const changed = Object.keys(current).filter(key => current[key] !== expected[key]); if (changed.length) return rejected("checkpoint_read_context_stale", changed); - return {ok: true, read_context_id: token, versions: current}; + return {ok: true, read_context_id: token, versions: current, + ...(use !== "supplement_checkpoint" ? {purpose: use, decision_scope: receipt.decision_scope} : {})}; +} + +/** Work input and validation share the existing read-receipt identity owner. */ +// Existing Host result vocabulary, local to direction transition/reentry rules. +const temporaryDirectionResults = new Set(["wait", "user_action_required", "iteration_failed"]); + +export function evaluateResultWorkBinding(value: unknown): JsonObject { + const request = requireJsonObject(value, "result work binding"); + if (request.phase === "completion_identity") { + const supplied = requireJsonObject(request.identity, "completion identity"); + const identity = settlementIdentity({ + goal_id: requireNonEmptyString(request.goal_id, "goal_id"), + agent_id: requireNonEmptyString(request.agent_id, "agent_id"), + todo_id: requireNonEmptyString(request.todo_id, "todo_id"), + turn_instance_id: requireNonEmptyString(supplied.turn_instance_id, "turn_instance_id"), + }); + if (["goal_id", "agent_id", "todo_id", "turn_instance_id", "effect_id"].some( + field => supplied[field] !== identity[field as keyof typeof identity], + )) throw new EffectRuntimeRequestError("completion identity does not match the original scoped Turn"); + const todo = requireJsonObject(request.todo, "completion Todo"); + // Managed journals store the raw Turn id; interactive callers store its + // scoped effect id. Preserve history only for this already committed result. + const originalReplay = todo.status === "done" && todo.todo_id === identity.todo_id && + todo.claimed_by === identity.agent_id && + (todo.completion_turn_key === identity.turn_instance_id || todo.completion_turn_key === identity.effect_id); + const key = originalReplay ? String(todo.completion_turn_key) : identity.effect_id; + const candidate = jsonObject(request.candidate); + const unchanged = candidate !== null && + // A done projection cannot prove a supplied lease matches the original + // request. Keep that proof with the canonical receipt owner after release. + candidate.task_lease_idempotency_key == null && candidate.task_lease_expected_version == null && + ["evidence", "note", "reason"].every(field => candidate[field] == null || candidate[field] === todo[field]) && + ["result_file", "next_agent_todo", "next_user_todo", "next_claimed_by", "next_task_class", + "next_action_kind", "next_task_repository", "next_required_capabilities", "next_continuation_policy", + "next_excluded_agents", "next_user_task_class", "decision_outcome"] + .every(field => candidate[field] == null || candidate[field] === "") && + (candidate.successor_todo_ids == null || (Array.isArray(candidate.successor_todo_ids) && candidate.successor_todo_ids.length === 0)) && + candidate.clear_claim !== true && candidate.self_merged !== true && + (candidate.role == null || candidate.role === todo.role); + if (originalReplay && unchanged && + typeof todo.completion_receipt_id === "string" && todo.completion_receipt_id.length > 0) { + const fence = evaluateTodoCompletionFence(decodeTodoCompletionFenceRequest({ + schema_version: TODO_COMPLETION_FENCE_REQUEST_SCHEMA, projection_source: "materialized", + todo, goal_id: identity.goal_id, todo_id: identity.todo_id, + requested_no_followup: candidate.no_followup === true, + requested_completion_turn_key: key, requested_completion_identity_source: "turn_settlement", + })); + if (fence.outcome === "replay") return {completion_turn_key: key, replay: { + ok: true, completed: true, changed: false, idempotent_replay: true, + dry_run: candidate.dry_run === true, goal_id: identity.goal_id, todo_id: identity.todo_id, + status: "done", completion_continuation: fence.completion_continuation, + completion_receipt_id: todo.completion_receipt_id ?? null, updated_at: todo.updated_at ?? null, + }}; + } + return {completion_turn_key: key, replay: null}; + } + if (request.phase === "direction_terminal_intent") { + const todo = requireJsonObject(request.todo, "completion Todo"); + const identity = requireJsonObject(request.identity, "completion identity"); + return {no_followup: originalTurnRequestsNoFollowup(todo, identity)}; + } + if (request.phase === "replan_stage") { + const enabled = checkpointContinuationRequired(jsonObject(request.heartbeat_receipt)); + const pending = jsonObject(jsonObject(request.prior)?.vision_checkpoint)?.replan_stage === "awaiting_direction"; + const deferred = enabled && request.has_read_context !== true && (request.requested === true || pending); + return {deferred, qualify: deferred ? false : request.requested === true || (enabled && pending)}; + } + if (request.phase === "direction_dispatch") { + const receipt = requireJsonObject(request.receipt, "direction receipt"); + const failure = receipt.direction_host_failure; + if (receipt.direction_stopped === true) { + const stops = receipt.direction_stops; + // Explicit recovery is not a fresh grant. Unknown historical stops and + // actual Host Stop reasons stay closed; a read alone never rearms them. + if (request.explicit_resume !== true || !Array.isArray(stops) || !stops.length || + stops.some(value => { + const stop = jsonObject(value); + return !stop || !Number.isInteger(stop.attempt) || Number(stop.attempt) < 1 || + Number(stop.attempt) > Number(receipt.direction_host_attempt_count) || + !temporaryDirectionResults.has(String(stop.result_kind)) || + Object.hasOwn(stop, "stop_reason"); + })) return rejected("checkpoint_direction_stopped"); + } + if (failure !== undefined) { + const policy = hostRetryPolicyCheck({host_failure: failure, + host_attempt_count: receipt.direction_host_attempt_count}); + if (policy?.outcome === "failed") return rejected(String(policy.reason)); + } + const count = receipt.direction_host_attempt_count ?? 0; + if (!Number.isInteger(count) || Number(count) < 0) return rejected("checkpoint_direction_attempt_invalid"); + return {ok: true, attempt: Number(count) + 1}; + } + if (request.phase === "direction_admission") { + const current = requireJsonObject(request.current, "current direction admission"); + const identity = requireJsonObject(request.identity, "original direction identity"); + const recovery = jsonObject(current.unsettled_host_turn_recovery); + const eligible = current.ok === true && current.should_run === true && current.status_health_ok === true && + current.goal_id === identity.goal_id && current.requires_user_action === false && + current.blocked_action_scope == null && jsonObject(current.quota)?.state === "eligible"; + const sameRecovery = current.effective_action === EffectiveAction.UNSETTLED_HOST_TURN_RECOVERY && + recovery?.scope === "current_turn" && recovery.repair === "durable_writeback" && + recovery.turn_instance_id === identity.turn_instance_id && recovery.binding_kind === "todo" && + recovery.binding_id === identity.todo_id; + const sameWork = current.effective_action === request.planned_effective_action && + jsonObject(current.selected_todo)?.todo_id === identity.todo_id && + jsonObject(jsonObject(current.interaction_contract)?.agent_channel)?.delivery_allowed === true; + return eligible && (sameRecovery || sameWork) ? {ok: true} : rejected("checkpoint_direction_admission_rejected"); + } + if (request.phase === "direction_return") { + const receipt = {...requireJsonObject(request.receipt, "direction receipt")}; + const decision = requireJsonObject(request.decision, "direction decision"); + const attempt = receipt.direction_host_attempt_count; + if (!Number.isInteger(attempt) || Number(attempt) < 1) return rejected("checkpoint_direction_attempt_invalid"); + if (temporaryDirectionResults.has(String(decision.result_kind))) { + delete receipt.direction_host_failure; + receipt.direction_stopped = true; + receipt.direction_stops = [...(Array.isArray(receipt.direction_stops) ? receipt.direction_stops : []), { + attempt, result_kind: decision.result_kind, + ...(decision.stop_reason ? {stop_reason: decision.stop_reason} : {}), + }]; + return {ok: true, stopped: true, receipt}; + } + const bound = evaluateResultWorkBinding({...request, phase: "direction_response"}); + if (bound.ok !== true) return bound; + receipt.current_agent_decision = decision; + receipt.current_agent_binding = bound.binding; + delete receipt.direction_host_failure; + delete receipt.direction_stopped; + return {ok: true, stopped: false, receipt}; + } + if (request.phase === "direction_response") { + const receipt = requireJsonObject(request.receipt, "direction receipt"); + const decision = requireJsonObject(request.decision, "direction decision"); + const purpose = receipt.purpose ?? "supplement_checkpoint"; + if (!["first_delivery", "supplement_checkpoint"].includes(String(purpose)) || + receipt.schema_version !== (purpose === "first_delivery" ? FIRST_RECEIPT_SCHEMA : RECEIPT_SCHEMA) || + receipt.full_frontier !== true || !["agent_lane", "goal"].includes(String(receipt.decision_scope))) { + return rejected("checkpoint_direction_context_invalid"); + } + if (receipt.read_context_id !== decision.read_context_id || + canonicalAuthoritySha256(receipt.identity) !== canonicalAuthoritySha256(request.identity)) { + return rejected("checkpoint_direction_binding_mismatch"); + } + if (receipt.decision_basis == null || canonicalAuthoritySha256(receipt.versions) !== + canonicalAuthoritySha256(Object.fromEntries(Object.entries(requireJsonObject(receipt.decision_basis, "direction basis")) + .map(([key, value]) => [key, canonicalAuthoritySha256(value)])))) { + return rejected("checkpoint_direction_basis_corrupt"); + } + if (jsonObject(requireJsonObject(receipt.decision_basis, "direction basis").frontier)?.scope !== receipt.decision_scope) { + return rejected("checkpoint_direction_context_invalid"); + } + const vision = jsonObject(decision.agent_vision); + const unchanged = typeof decision.vision_unchanged_reason === "string" ? decision.vision_unchanged_reason.trim() : ""; + if ((vision !== null) === (unchanged.length > 0)) return rejected("checkpoint_direction_decision_required"); + if (vision === null && jsonObject(requireJsonObject(receipt.decision_basis, "direction basis").agent_vision) === null) { + return rejected("checkpoint_direction_baseline_required"); + } + return {ok: true, binding: {read_context_id: receipt.read_context_id, + identity_sha256: canonicalAuthoritySha256(request.identity), decision_sha256: canonicalAuthoritySha256(decision)}}; + } + if (request.phase === "direction_policy") return { + required: checkpointContinuationRequired(jsonObject(request.heartbeat_receipt)), + }; + if (request.phase === "legacy_commit_policy") { + const transaction = requireJsonObject(request.transaction, "locked completion transaction"); + if (transaction.schema_version !== "loopx_todo_completion_transaction_result_v0") { + throw new EffectRuntimeRequestError("Locked completion transaction schema mismatch"); + } + const fence = requireJsonObject(transaction.fence, "locked completion fence"); + const state = jsonObject(transaction.completion_state); + const upgrade = transaction.decision === "commit" && fence.status === "done" && + ["same_turn_terminal_upgrade", "lifecycle_reentry_terminal_upgrade"].includes(String(fence.reason)) && + state?.continuation === "no_followup" && typeof transaction.completion_identity_key === "string" && + transaction.completion_identity_key.length > 0; + if (upgrade) { + const todo = requireJsonObject(request.todo, "original committed Todo"); + const candidate = requireJsonObject(request.candidate, "Legacy closeout candidate"); + for (const field of ["evidence", "note", "reason"] as const) { + if (candidate[field] != null && candidate[field] !== todo[field]) { + return rejected("delivery_validation_binding_mismatch"); + } + } + } + return {required: !upgrade, recover_terminal_lease: upgrade}; + } + if (request.phase === "legacy_admission") { + const events = Array.isArray(request.events) ? request.events.map(event => requireJsonObject(event, "admission event")) : []; + const scopedEvents = events.filter(event => event.event_kind === "quota_should_run" && + canonicalAuthoritySha256(event.goal_ref ?? null) === canonicalAuthoritySha256(request.goal_ref ?? null) && + jsonObject(event.details)?.todo_id === request.todo_id); + const original = scopedEvents.filter(event => event.agent_id === request.agent_id && + [event.run_id, jsonObject(event.details)?.settlement_effect_id].includes(request.completion_turn_key)); + if (original.length && !original.some(resultFreshnessRequired)) return {required: false}; + const protectedEvents = scopedEvents.filter(resultFreshnessRequired); + if (!protectedEvents.length) return {required: false}; + const match = protectedEvents.filter(event => event.agent_id === request.agent_id && + [event.run_id, jsonObject(event.details)?.settlement_effect_id].includes(request.completion_turn_key)); + // Re-reading an admitted Turn records another observation, not a new + // admission identity. Every matching observation must retain that Turn. + if (match.length === 0 || request.identity == null || + match.some(event => jsonObject(request.identity)?.turn_instance_id !== event.run_id) || + jsonObject(request.identity)?.agent_id !== match[0].agent_id || + jsonObject(request.identity)?.todo_id !== request.todo_id) return rejected("delivery_work_context_required"); + return {required: true}; + } + if (request.phase === "policy") return {required: resultFreshnessRequired(jsonObject(request.heartbeat_receipt))}; + if (request.phase === "admission") { + if (request.todo_id == null) return {policy: null}; + if (!["legacy_markdown", "file_v0", "sqlite_v0", "postgresql_v0"].includes(String(request.source_authority))) { + throw new EffectRuntimeRequestError("Result freshness requires a supported Todo authority", "delivery_provider_unsupported"); + } + return {policy: "turn_result_freshness_required_v1"}; + } + const identity = requireJsonObject(request.identity, "result work identity"); + const receipt = requireJsonObject(request.receipt, "result work receipt"); + if (receipt.schema_version !== FIRST_RECEIPT_SCHEMA || receipt.purpose !== "delivery_result" || + canonicalAuthoritySha256(receipt.identity) !== canonicalAuthoritySha256(identity)) { + return rejected("delivery_work_identity_mismatch"); + } + const basis = requireJsonObject(receipt.work_basis, "persisted work basis"); + if (Object.hasOwn(request, "goal_ref") && + canonicalAuthoritySha256(jsonObject(basis.source)?.goal_ref ?? null) !== canonicalAuthoritySha256(request.goal_ref)) { + return rejected("delivery_work_goal_instance_mismatch"); + } + const versions = requireJsonObject(receipt.versions, "persisted work versions"); + if (Object.keys(basis).some(key => canonicalAuthoritySha256(basis[key]) !== versions[key]) || + Object.keys(versions).some(key => !Object.hasOwn(basis, key))) { + return rejected("delivery_work_basis_corrupt"); + } + const readId = requireNonEmptyString(receipt.read_context_id, "persisted result read identity"); + if (request.phase === "begin_terminal_validation" || request.phase === "finish_terminal_validation") { + return terminalValidationRecovery(request, receipt); + } + if (request.phase === "restore") return {ok: true, basis, + ...(jsonObject(basis.goal)?.primary_documents !== undefined ? {instructions: checkpointAuthorityInstructions} : {}), + read_context_id: readId, settlement_identity: identity}; + const terminal = jsonObject(request.terminal_input); + const candidate = terminal ? terminalResultCandidate(terminal) : requireJsonObject(request.candidate, "result candidate"); + const candidateDigest = canonicalAuthoritySha256(candidate); + if (request.phase === "bind_validation") { + const validation = terminal ? terminalResultValidation(terminal) : requireJsonObject(request.validation, "result validation"); + if (validation.ok !== true) return rejected("delivery_validation_not_passed"); + return {ok: true, binding: {schema_version: "result_validation_binding_v1", identity, + read_context_id: receipt.read_context_id, candidate_sha256: candidateDigest, + validation_sha256: canonicalAuthoritySha256(validation)}}; + } + if (request.phase !== "check_validation") throw new EffectRuntimeRequestError("unknown result work binding phase"); + const binding = requireJsonObject(request.binding, "result validation binding"); + if (binding.schema_version !== "result_validation_binding_v1" || + canonicalAuthoritySha256(binding.identity) !== canonicalAuthoritySha256(identity) || + binding.read_context_id !== receipt.read_context_id || binding.candidate_sha256 !== candidateDigest || + binding.validation_sha256 !== canonicalAuthoritySha256(terminal ? terminalResultValidation(terminal) : request.validation)) { + return rejected("delivery_validation_binding_mismatch"); + } + return {ok: true, read_context_id: receipt.read_context_id}; +} + +/** Bind the actual result bytes/workspace and issued native validation contract. */ +export function terminalResultCandidate(input: JsonObject): JsonObject { + return {result: input.completion_result ?? null, + evidence: input.evidence ?? null, note: input.note ?? null}; +} +export function terminalResultValidation(input: JsonObject): JsonObject { + return {ok: true, declaration: input.validation_declaration ?? null, + declaration_sha256: input.validation_declaration_sha256 ?? null, + receipt: input.validation_receipt ?? null, + acceptance_source: input.goal_acceptance_source_binding ?? null, + acceptance_receipts: input.goal_acceptance_validation_receipts ?? null}; +} + +/** Runtime admission for Legacy closeout shares the original quota receipt owner. */ +export async function executeResultWorkBinding(value: unknown): Promise { + const request = requireJsonObject(value, "result work request"); + const result = evaluateResultWorkBinding(request); + if (request.phase === "legacy_commit_policy" && result.recover_terminal_lease === true) { + const {verifyReentrySettlement} = await import("../coordination/todo_terminal_reentry.ts"); + const {localTodoCompletionIdentity} = await import("../todos/completion_fence.ts"); + const goal = requireNonEmptyString(request.goal_id, "goal_id"); + const todo = requireNonEmptyString(request.todo_id, "todo_id"); + const key = requireNonEmptyString(requireJsonObject(request.transaction, "transaction").completion_identity_key, "completion identity"); + if (key !== localTodoCompletionIdentity(goal, todo)) { + await verifyReentrySettlement(requireNonEmptyString(request.runtime_root, "runtime_root"), { + goal_id: goal, todo_id: todo, actor_agent_id: requireNonEmptyString(request.agent_id, "agent_id"), + requested_completion_turn_key: key, + }); + } + } + return result; } diff --git a/loopx/control_plane/goals/goal_frontier/__init__.py b/loopx/control_plane/goals/goal_frontier/__init__.py index 4122c52309..4e7d19ac4e 100644 --- a/loopx/control_plane/goals/goal_frontier/__init__.py +++ b/loopx/control_plane/goals/goal_frontier/__init__.py @@ -1682,6 +1682,7 @@ def build_goal_frontier_projection_context_from_status( selected_obligation_id=receipt_bound_replan_obligation_id, guard_recorded_at=receipt_bound_guard_recorded_at, agent_id=agent_id, items=agent_todo_source_items or [], + admitted_source=status_payload.get("_receipt_bound_semantic_source"), derive=lambda summary: derive_goal_frontier_replan_obligation_from_summaries( user_todo_summary=None, agent_todo_summary=summary, work_lane_contract=work_lane_contract, agent_id=agent_id, @@ -1728,6 +1729,21 @@ def build_goal_frontier_projection_context_from_status( goal_acceptance_contract=(agent_todo_summary or {}).get("goal_acceptance_contract"), receipt_bound_replan_obligation_id=receipt_bound_replan_obligation_id, ) + if replan_obligation: + replan_obligation = dict(replan_obligation) + if replan_obligation.get("resolution_mode") == "todo_lifecycle_settlement": + # Retain the native admission facts in the immutable guard source. + # Closeout must not rewrite historical Todo timestamps to prove time. + ids = {item.get("todo_id") for item in replan_obligation.get("triggers", [])} + replan_obligation["lifecycle_admission_items"] = [ + {key: item[key] for key in ( + "todo_id", "index", "status", "done", "task_class", "archive_state", + "claimed_by", "last_actor_agent_id", "completed_at", "updated_at", + "no_followup", "completion_continuation", "completion_recovery", + "completion_turn_key", "successor_todo_ids", "superseded_by", + ) if key in item} + for item in (agent_todo_source_items or []) if item.get("todo_id") in ids + ] goal_frontier_projection = build_goal_frontier_projection_from_summaries( goal_id=goal_id, diff --git a/loopx/control_plane/heartbeat/agent.py b/loopx/control_plane/heartbeat/agent.py index db5361e29c..caa7ca40df 100644 --- a/loopx/control_plane/heartbeat/agent.py +++ b/loopx/control_plane/heartbeat/agent.py @@ -156,9 +156,11 @@ def render_peer_agent_scope_instruction( ) if compact: return ( - f"Agent identity and scope: agent_id `{identity}`; model: peer_v1; " - f"scope: {scope_text}. {peer_rule} Claim: `{claim_command}`. " - "Do not write scope into todo metadata." + f"Equal peer `{identity}` (peer_v1); scope: {scope_text}. " + "Obey quota claim/lease, workspace/repository and Todo continuation. " + "Quota-claim open in-scope work; peer-owned->another/no-work. " + "PR review=user_action+runnable successor; gate exact merge/release/launch only. " + "No peer authority from coordination. Scope=prompt, not Todo metadata." ) return f"""Agent identity and scope: diff --git a/loopx/control_plane/heartbeat/builder.py b/loopx/control_plane/heartbeat/builder.py index 1698bdaa58..0ce9de35af 100644 --- a/loopx/control_plane/heartbeat/builder.py +++ b/loopx/control_plane/heartbeat/builder.py @@ -517,7 +517,9 @@ def build_heartbeat_prompt( task_body = task_body_renderer( goal_id=goal_id, active_state=active_state_text, - cli_preflight=cli_preflight, + # Thin remains an installed-CLI worker, as before. Bootstrap/full + # generator surfaces retain preflight; brief carries it in its body. + cli_preflight=render_cli_preflight(cli_bin=cli_bin, thin=thin), pr_review_pre_quota_command=( "" if traex_visible_goal else commands["pr_review_pre_quota_command"] or "" ), diff --git a/loopx/control_plane/host_adapter_settlement.py b/loopx/control_plane/host_adapter_settlement.py index c7d3270f09..757b01ae2e 100644 --- a/loopx/control_plane/host_adapter_settlement.py +++ b/loopx/control_plane/host_adapter_settlement.py @@ -30,6 +30,8 @@ "durable_writeback", "quota_spend", "terminal_closeout", + "delivery_context", + "direction_context", ) _MISSING = object() @@ -62,7 +64,11 @@ class HostTodoSettlementRequest: vision_path: str | None = None vision_unchanged_reason: str | None = None checkpoint_read_context_id: str | None = None + first_delivery: bool = False + delivery_read_context_id: str | None = None goal_ref: Mapping[str, str] | None = None + turn_instance_id: str | None = None + current_agent_checkpoint: bool = False class HostCliRunner(Protocol): @@ -105,13 +111,30 @@ def _request_payload( payload["provider_outcomes"] = provider_outcomes if request.goal_ref is not None: payload["goal_ref"] = dict(request.goal_ref) - if request.vision_path or request.vision_unchanged_reason or phase in {"vision_refresh", "vision_context"}: + if request.turn_instance_id is not None: + payload["turn_instance_id"] = request.turn_instance_id + if ( + request.vision_path + or request.vision_unchanged_reason + or request.checkpoint_read_context_id + or phase in {"vision_refresh", "vision_context"} + or request.first_delivery + ): payload.update( schema_version="loopx_host_todo_completion_transaction_v1", vision_path=request.vision_path, vision_unchanged_reason=request.vision_unchanged_reason, checkpoint_read_context_id=request.checkpoint_read_context_id, ) + if request.first_delivery or request.current_agent_checkpoint: + payload.update( + schema_version="loopx_host_todo_completion_transaction_v2", + first_delivery=True, + delivery_read_context_id=request.delivery_read_context_id, + current_agent_checkpoint=request.current_agent_checkpoint, + ) + elif request.delivery_read_context_id: + payload["delivery_read_context_id"] = request.delivery_read_context_id return payload @@ -136,15 +159,52 @@ def host_vision_request( allow_terminal_stride_path=allow_terminal_stride_path) with tempfile.TemporaryDirectory(prefix="loopx-host-vision-") as directory: path = Path(directory) / "vision.json" - path.write_text(json.dumps(vision, ensure_ascii=False, allow_nan=False), encoding="utf-8") + path.write_text( + json.dumps(vision, ensure_ascii=False, allow_nan=False), encoding="utf-8" + ) yield replace(request, vision_path=str(path)) -def refresh_host_todo_vision(request: HostTodoSettlementRequest, *, run_cli: HostCliRunner) -> str: +def settle_host_todo_wait( + request: HostTodoSettlementRequest, + reason: str, + *, + run_cli: HostCliRunner, +) -> str: + """Transport a typed quiet wait on the original admitted unfinished work.""" + payload = {**_request_payload(request, phase="wait"), "wait_reason": reason} + plan = _runtime_reduction(payload, phase="wait") + if isinstance(plan.get("result"), Mapping): + return json.dumps(dict(plan["result"]), ensure_ascii=False, sort_keys=True) + args = _string_tuple(plan.get("args"), label="wait args") + legacy = plan.get("legacy_args") + output = run_cli( + list(args), + legacy_args=list(_string_tuple(legacy, label="wait legacy args")) + if legacy + else None, + ) + result = _runtime_reduction({**payload, "guard_output": output}, phase="wait").get( + "result" + ) + if not isinstance(result, Mapping): + raise RuntimeError("TypeScript host wait result shape mismatch") + return json.dumps(dict(result), ensure_ascii=False, sort_keys=True) + + +def refresh_host_todo_vision( + request: HostTodoSettlementRequest, *, run_cli: HostCliRunner +) -> str: """Repair the original checkpoint; no lifecycle operation, new Turn or spend.""" - phase = "vision_refresh" if ( - request.vision_path or request.vision_unchanged_reason or request.checkpoint_read_context_id - ) else "vision_context" + phase = ( + "vision_refresh" + if ( + request.vision_path + or request.vision_unchanged_reason + or request.checkpoint_read_context_id + ) + else "vision_context" + ) plan = _runtime_reduction(_request_payload(request, phase=phase), phase=phase) _runtime_identity(plan.get("identity")) args = plan.get("args") @@ -161,10 +221,14 @@ def project_host_interaction(output: str) -> str: return output if not isinstance(packet, dict): return output - projection = _runtime_reduction({ - "schema_version": "loopx_host_todo_completion_transaction_v1", - "phase": "project_guard", "packet": packet, - }, phase="project_guard") + projection = _runtime_reduction( + { + "schema_version": "loopx_host_todo_completion_transaction_v1", + "phase": "project_guard", + "packet": packet, + }, + phase="project_guard", + ) if not isinstance(projection.get("packet"), dict): raise RuntimeError("TypeScript host interaction projection shape mismatch") return json.dumps(projection["packet"], ensure_ascii=False) @@ -276,10 +340,16 @@ def _decode_provider_steps(value: Any) -> tuple[_ProviderStep, ...]: value.get("provider_id") != "loopx_cli" or value.get("kind") != "ordered_cli_sequence" or not isinstance(raw_steps, list) - or len(raw_steps) not in {4, 5} + or len(raw_steps) not in {2, 3, 4, 5} ): raise RuntimeError("TypeScript host provider plan shape mismatch") - expected = _STEP_KINDS[: len(raw_steps)] + expected = ( + ("guard", "delivery_context") + if len(raw_steps) == 2 + else ("guard", "lifecycle_completion", "direction_context") + if len(raw_steps) == 3 + else _STEP_KINDS[: len(raw_steps)] + ) steps: list[_ProviderStep] = [] for index, raw in enumerate(raw_steps): if not isinstance(raw, Mapping) or raw.get("step_kind") != expected[index]: @@ -404,6 +474,24 @@ def settle_host_todo_completion( ), phase="finalize", ) + if finalized.get("decision") == "execute": + token = finalized.get("resume_checkpoint_read_context_id") + if ( + not request.current_agent_checkpoint + or request.checkpoint_read_context_id is not None + or not isinstance(token, str) + or not token + or finalized.get("identity") != prepared["identity"] + ): + raise RuntimeError("Committed direction recovery binding is invalid") + request = replace(request, checkpoint_read_context_id=token) + outcomes = _run_provider_plan( + _decode_provider_steps(finalized["provider_effect"]), run_cli=run_cli + ) + finalized = _runtime_reduction( + _request_payload(request, phase="finalize", provider_outcomes=outcomes), + phase="finalize", + ) result = finalized.get("result") if ( finalized.get("decision") not in {"complete", "blocked", "provider_result"} diff --git a/loopx/control_plane/projects/registry_codec.py b/loopx/control_plane/projects/registry_codec.py index f05952425a..ab7d1673a4 100644 --- a/loopx/control_plane/projects/registry_codec.py +++ b/loopx/control_plane/projects/registry_codec.py @@ -13,6 +13,7 @@ from typing import Any, TypeVar from ..content_digest import ENVELOPED_SHA256_PATTERN +from ..runtime.file_paths import windows_extended_path from ...file_lock import exclusive_cross_runtime_file_lock from ...paths import GLOBAL_REGISTRY_FILENAME @@ -180,7 +181,7 @@ def _decode_document(raw_bytes: bytes) -> _ProjectRegistryDocument: def _read_document(path: Path) -> _ProjectRegistryDocument: - return _decode_document(path.read_bytes()) + return _decode_document(windows_extended_path(path).read_bytes()) def load_project_registry(path: Path) -> dict[str, Any]: @@ -217,6 +218,7 @@ def load_registry( """Load a project registry or an object-only global registry.""" expanded = path.expanduser() + address = windows_extended_path(expanded) if source_snapshots is not None: # Capture callers must decode the same bytes that supply their IO # witness. Retain absence too; rereading could conceal A -> B -> A. @@ -225,7 +227,7 @@ def load_registry( source = expanded.resolve() if source not in source_snapshots: try: - source_snapshots[source] = source.read_bytes() + source_snapshots[source] = windows_extended_path(source).read_bytes() except FileNotFoundError: source_snapshots[source] = None # Witness the invoked alias too. Its saved link can change while @@ -233,9 +235,9 @@ def load_registry( source_snapshots[route] = source_snapshots[source] raw = source_snapshots[route] return {} if raw is None else decode_registry_snapshot(expanded, raw) - if not expanded.exists(): + if not address.exists(): return {} - return decode_registry_snapshot(expanded, expanded.read_bytes()) + return decode_registry_snapshot(expanded, address.read_bytes()) def require_runtime_compatible_project_registry( @@ -290,6 +292,7 @@ def _encode_document( def _atomic_write_bytes(path: Path, payload: bytes, *, mode: int | None) -> None: + path = windows_extended_path(path) path.parent.mkdir(parents=True, exist_ok=True) descriptor, temporary = tempfile.mkstemp( prefix=f".{path.name}.", @@ -407,11 +410,12 @@ def commit(self, payload: dict[str, Any]) -> bool: def remove(self) -> bool: if self._finished: raise RuntimeError("project registry transaction is already finished") - if not self._path.exists(): + address = windows_extended_path(self._path) + if not address.exists(): self._finished = True return False - self._path.unlink() - if self._path.exists(): + address.unlink() + if address.exists(): raise ProjectRegistryMutationError( "project registry removal readback failed" ) @@ -419,19 +423,20 @@ def remove(self) -> bool: return True def restore(self) -> None: + address = windows_extended_path(self._path) if self._existed: _atomic_write_bytes( self._path, self._document.raw_bytes, mode=self._mode, ) - if self._path.read_bytes() != self._document.raw_bytes: + if address.read_bytes() != self._document.raw_bytes: raise ProjectRegistryRestoreError( "project registry exact-byte restore did not verify" ) else: - self._path.unlink(missing_ok=True) - if self._path.exists(): + address.unlink(missing_ok=True) + if address.exists(): raise ProjectRegistryRestoreError( "new project registry could not be removed during restore" ) @@ -453,10 +458,11 @@ def _registry_transaction( agent_id=agent_id, operation=operation, ): - existed = expanded.exists() + address = windows_extended_path(expanded) + existed = address.exists() if existed: document = _read_document(expanded) - mode = expanded.stat().st_mode & 0o777 + mode = address.stat().st_mode & 0o777 else: if create_document is None: raise FileNotFoundError( diff --git a/loopx/control_plane/quota/goal_boundary.py b/loopx/control_plane/quota/goal_boundary.py index 4e8f18adab..a02c5518f3 100644 --- a/loopx/control_plane/quota/goal_boundary.py +++ b/loopx/control_plane/quota/goal_boundary.py @@ -439,23 +439,24 @@ def goal_boundary( if repository_identity and repository_identity.startswith("git:"): boundary["task_repository"] = repository_identity project_asset_source = item if item is not None else goal - if isinstance(project_asset_source, dict) and project_asset_source.get( - "project_asset" - ): - project_asset = project_asset_source.get("project_asset") - if isinstance(project_asset, dict): - if project_asset.get("stop_condition"): - boundary["stop_condition"] = project_asset.get("stop_condition") - if isinstance(project_asset.get("execution_profile"), dict): - boundary["execution_profile"] = ( - quota_execution_profile_boundary_summary( - project_asset["execution_profile"] - ) - ) - if isinstance(project_asset.get("orchestration"), dict): - boundary["orchestration"] = compact_orchestration_policy( - project_asset["orchestration"] + project_asset = ( + project_asset_source.get("project_asset") + if isinstance(project_asset_source, dict) + else None + ) + if isinstance(project_asset, dict): + if project_asset.get("stop_condition"): + boundary["stop_condition"] = project_asset.get("stop_condition") + if isinstance(project_asset.get("execution_profile"), dict): + boundary["execution_profile"] = ( + quota_execution_profile_boundary_summary( + project_asset["execution_profile"] ) + ) + if isinstance(project_asset.get("orchestration"), dict): + boundary["orchestration"] = compact_orchestration_policy( + project_asset["orchestration"] + ) # Model preferences belong to the current registry, not a stale asset snapshot. if spawn_policy is not None and "orchestration" in boundary: model_config = compact_orchestration_policy(spawn_policy).get("model_config") diff --git a/loopx/control_plane/quota/heartbeat_receipt.py b/loopx/control_plane/quota/heartbeat_receipt.py index 45c66925e2..7866caf0e2 100644 --- a/loopx/control_plane/quota/heartbeat_receipt.py +++ b/loopx/control_plane/quota/heartbeat_receipt.py @@ -175,15 +175,15 @@ def find_heartbeat_receipt( goal_ref: Mapping[str, object] | None = None, ) -> dict[str, object] | None: events = load_rollout_events(rollout_event_log_path(runtime_root, goal_id)) - return _effective_heartbeat_receipt( - _heartbeat_receipt_events( - events, - goal_id=goal_id, - agent_id=agent_id, - turn_instance_id=turn_instance_id, - goal_ref=goal_ref, - ) - ) + event = _effective_heartbeat_receipt(_heartbeat_receipt_events(events, + goal_id=goal_id, agent_id=agent_id, turn_instance_id=turn_instance_id, goal_ref=goal_ref)) + if event: + details = event.get("details") or {} + ref = details.get("semantic_replan_source_ref") + if ref: + event = {**event, "details": {**details, + "semantic_replan_source": read_guard_source_blob(runtime_root, goal_id, ref)}} + return event def ensure_turn_heartbeat_settlement_receipt( @@ -194,6 +194,8 @@ def ensure_turn_heartbeat_settlement_receipt( semantic_replan_obligation_id: str | None, semantic_replan_capability_guard: Mapping[str, object] | None = None, goal_ref: Mapping[str, object] | None = None, + result_freshness: str | None = None, + checkpoint_continuation: str | None = None, ) -> dict[str, object]: """Idempotently bind a Turn-created quota guard to its settlement identity. @@ -285,6 +287,10 @@ def ensure_turn_heartbeat_settlement_receipt( "stall_observation": "not_applicable", "source": "loopx_turn_run_once", } + if result_freshness is not None: + details["result_freshness"] = result_freshness + if checkpoint_continuation is not None: + details["checkpoint_continuation"] = checkpoint_continuation if semantic_replan_guard_scoped: details["semantic_replan_obligation_id"] = ( normalized_semantic_replan_obligation_id or "" @@ -599,6 +605,8 @@ def heartbeat_receipt_view( "event_id": event.get("event_id"), "recorded_at": event.get("recorded_at"), } + if "result_freshness" in details: + receipt["result_freshness"] = details["result_freshness"] if details.get("closeout_required") is True: receipt["closeout_required"] = True todo_id = str(details.get("todo_id") or "").strip() @@ -642,6 +650,8 @@ def heartbeat_receipt_view( "gap_id": details.get("semantic_replan_gap_id"), "frontier_revision": details.get("semantic_replan_frontier_revision"), } + if details.get("semantic_replan_source") is not None: + receipt["semantic_replan_source"] = validate_guard_source(details["semantic_replan_source"], semantic_replan_obligation_id) pending_action_todo_id = heartbeat_receipt_pending_action_todo_id(event) if pending_action_todo_id: receipt["pending_action_selection"] = { @@ -706,3 +716,100 @@ def fail_heartbeat_receipt( }, } ) + + +def _guard_source_digest(value): + import hashlib + import json + return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False, + separators=(",", ":")).encode("utf-8")).hexdigest() + + +def guard_cadence(registry_path, runtime_root, goal_id): + from ...agent_registry import load_goal_from_registry + from ...capabilities.todo_replan_cadence.machine_defaults import resolve_todo_replan_cadence_goal + goal = load_goal_from_registry(Path(registry_path), goal_id) or {"id": goal_id} + profile = resolve_todo_replan_cadence_goal(goal, Path(runtime_root)).get("execution_profile") or {} + return {key: profile[key] for key in ("replan_after_completed_todos", "replan_after_effective_turns") if key in profile} + + +def bounded_guard_source(obligation, cadence): + import copy + import json + from ..work_items.autonomous_replan_obligation import ensure_replan_novelty_policy + if not isinstance(obligation, dict) or not obligation.get("obligation_id"): + return None + value = copy.deepcopy(obligation) + value.pop("replan_context", None) + # The current native history remains the novelty ledger. Receipt source + # stores the complete obligation identity/triggers, not a partial ledger schema. + if ensure_replan_novelty_policy(value)["obligation_id"] != value["obligation_id"]: + raise HeartbeatReceiptIdentityConflictError("guard_source_invalid: obligation codec mismatch") + if len(json.dumps(value, ensure_ascii=False).encode()) > 65536: + raise HeartbeatReceiptIdentityConflictError("guard_source_invalid: bounded source exceeds 64KiB") + return {"schema_version": "semantic_replan_source_v1", "obligation": value, + "sha256": _guard_source_digest(value), "cadence": dict(cadence)} + + +def validate_guard_source(source, obligation_id): + from ..work_items.autonomous_replan_obligation import ensure_replan_novelty_policy + if not isinstance(source, dict) or source.get("schema_version") != "semantic_replan_source_v1": + raise HeartbeatReceiptIdentityConflictError("guard_source_missing: owner must adopt the exact original guard packet; do not rerun with a new obligation ID") + value = source.get("obligation") + if (not isinstance(value, dict) or value.get("obligation_id") != obligation_id or + _guard_source_digest(value) != source.get("sha256") or + ensure_replan_novelty_policy(value)["obligation_id"] != obligation_id): + raise HeartbeatReceiptIdentityConflictError("guard_source_invalid: exact source digest/obligation mismatch") + return source + + +def guard_source_for_turn(runtime_root, *, goal_id, agent_id, turn_instance_id, goal_ref=None): + event = find_heartbeat_receipt(Path(runtime_root), goal_id=goal_id, agent_id=agent_id, + turn_instance_id=turn_instance_id, goal_ref=goal_ref) + if not event: + return None + oid = heartbeat_receipt_semantic_replan_obligation_id(event) + if not oid: + return None + return validate_guard_source((event.get("details") or {}).get("semantic_replan_source"), oid) + + + + +def store_guard_source_blob(runtime_root,goal_id,source): + import os + import json + import hashlib + raw=json.dumps(source,ensure_ascii=False,sort_keys=True,separators=(",",":")).encode("utf-8") + digest=hashlib.sha256(raw).hexdigest() + path=rollout_event_log_path(Path(runtime_root),goal_id).parent/"heartbeat-guard-sources"/(digest+".json") + path.parent.mkdir(parents=True,exist_ok=True) + try: + with path.open("xb") as f: + f.write(raw) + f.flush() + os.fsync(f.fileno()) + except FileExistsError: + if path.read_bytes() != raw: + raise HeartbeatReceiptIdentityConflictError( + "guard_source_invalid: immutable blob conflict" + ) + return digest + + +def read_guard_source_blob(runtime_root,goal_id,ref): + import re + import json + import hashlib + if not isinstance(ref,str) or not re.fullmatch(r"[0-9a-f]{64}",ref): + raise HeartbeatReceiptIdentityConflictError("guard_source_invalid: malformed source ref") + path=rollout_event_log_path(Path(runtime_root),goal_id).parent/"heartbeat-guard-sources"/(ref+".json") + try: + raw = path.read_bytes() + except OSError: + raise HeartbeatReceiptIdentityConflictError( + "guard_source_missing: immutable source blob unavailable" + ) from None + if hashlib.sha256(raw).hexdigest()!=ref: + raise HeartbeatReceiptIdentityConflictError("guard_source_invalid: stored source bytes changed") + return json.loads(raw) diff --git a/loopx/control_plane/quota/heartbeat_receipt_identity.ts b/loopx/control_plane/quota/heartbeat_receipt_identity.ts index 0166797905..a254360d94 100644 --- a/loopx/control_plane/quota/heartbeat_receipt_identity.ts +++ b/loopx/control_plane/quota/heartbeat_receipt_identity.ts @@ -29,6 +29,28 @@ export function heartbeatReceiptDetails(event: JsonObject | null): JsonObject { return jsonObject(event?.details) ?? {}; } +/** Persisted admission, not a caller's completion opt-in. */ +export function resultFreshnessRequired(event: JsonObject | null): boolean { + const policy = heartbeatReceiptDetails(event).result_freshness; + if (policy === undefined) return false; // Historical admission is not rewritten. + if (policy !== "turn_result_freshness_required_v1") { + throw new EffectRuntimeRequestError("Result freshness admission is incomplete or unsupported", + "result_freshness_admission_unknown"); + } + return true; +} + +/** Direction cadence stays with the checkpoint owner; this selects transport. */ +export function checkpointContinuationRequired(event: JsonObject | null): boolean { + const mode = heartbeatReceiptDetails(event).checkpoint_continuation; + if (mode === undefined) return false; + if (mode !== "current_agent_v1") { + throw new EffectRuntimeRequestError("Checkpoint continuation admission is incomplete or unsupported", + "checkpoint_continuation_admission_unknown"); + } + return true; +} + export function optionalHeartbeatString(value: unknown): string | null { if (value === null || value === undefined) return null; return String(value).trim() || null; @@ -79,6 +101,8 @@ export interface HeartbeatReceiptFact extends JsonObject { replan_obligation_id: string | null; settlement_effect_id: string | null; closeout_required: boolean; + result_freshness?: unknown; + checkpoint_continuation?: unknown; } export function heartbeatReceiptFactFromEvent( @@ -86,6 +110,8 @@ export function heartbeatReceiptFactFromEvent( ): HeartbeatReceiptFact { const eventDetails = heartbeatReceiptDetails(event); return { + result_freshness: eventDetails.result_freshness, + checkpoint_continuation: eventDetails.checkpoint_continuation, event_id: optionalHeartbeatString(event.event_id), run_id: optionalHeartbeatString(event.run_id), todo_id: optionalHeartbeatString(eventDetails.todo_id), @@ -173,6 +199,18 @@ export function selectEffectiveHeartbeatReceipt( const binding = heartbeatReceiptBinding(goalId, agentId, entry.fact); if (binding) identities.set(binding.identity_key, entry.value); } + const protectedIndex = entries.findIndex(entry => resultFreshnessRequired({details: entry.fact})); + const continuationIndex = entries.findIndex(entry => checkpointContinuationRequired({details: entry.fact})); + if (continuationIndex >= 0 && entries.slice(continuationIndex + 1).some(entry => + heartbeatReceiptBinding(goalId, agentId, entry.fact) !== null && + !checkpointContinuationRequired({details: entry.fact}))) { + throw receiptIdentityConflict("Checkpoint continuation admission cannot be downgraded"); + } + if (protectedIndex >= 0 && entries.slice(protectedIndex + 1).some(entry => + heartbeatReceiptBinding(goalId, agentId, entry.fact) !== null && + !resultFreshnessRequired({details: entry.fact}))) { + throw receiptIdentityConflict("A protected Turn admission cannot be replaced by an unprotected receipt"); + } if (identities.size > 1) { throw receiptIdentityConflict( "heartbeat receipt has conflicting settlement identities for the same goal, agent, and turn", diff --git a/loopx/control_plane/quota/live_decision.py b/loopx/control_plane/quota/live_decision.py index 982c5695d1..9b80f67d6e 100644 --- a/loopx/control_plane/quota/live_decision.py +++ b/loopx/control_plane/quota/live_decision.py @@ -652,7 +652,11 @@ def build_live_quota_should_run_decision( goal_id=goal_id, agent_id=agent_id, ) - payload: dict[str, Any] = build_quota_should_run( + from .heartbeat_receipt import guard_source_for_turn + retained_source = guard_source_for_turn(runtime_root, goal_id=goal_id, agent_id=agent_id, + turn_instance_id=turn_instance_id, goal_ref=goal_ref) if agent_id and turn_instance_id else None + decision_status_payload = {**decision_status_payload, "_receipt_bound_semantic_source": (retained_source or {}).get("obligation")} + payload = build_quota_should_run( decision_status_payload, goal_id=goal_id, agent_id=agent_id, @@ -752,15 +756,8 @@ def build_live_quota_should_run_decision( scheduler_execution_context=resolved_context, goal_ref=goal_ref, ) - if ( - receipt_bound_todo_id and agent_id and turn_instance_id - and receipt_bound_replay_phase is not ReceiptBoundReplayPhase.SETTLED - and payload.get("effective_action") != EffectiveAction.UNSETTLED_HOST_TURN_RECOVERY.value - and ( - receipt_bound_replay_phase is ReceiptBoundReplayPhase.SETTLEMENT_PENDING - or _receipt_bound_recovery_required(payload, receipt_bound_todo_id) - ) - ): + if _needs_receipt_settlement_recovery(payload, receipt_bound_todo_id, agent_id, + turn_instance_id, receipt_bound_replay_phase): # A verified writeback seals this Turn even while its Todo stays open # and selected. Let the existing typed owner project only the remaining # settlement; another delivery cannot replace the immutable writeback. @@ -827,3 +824,16 @@ def build_live_quota_should_run_decision( attach_work_context(payload, registry_path=registry_path, runtime_root=runtime_root, hook_dispatch=turn_start_hook_dispatch, inline_goal_source=inline_goal_source) return payload + + +def _needs_receipt_settlement_recovery(payload, receipt_bound_todo_id, agent_id, turn_instance_id, receipt_bound_replay_phase): + """Recognize the existing owner-proved pending settlement route.""" + return bool( + receipt_bound_todo_id and agent_id and turn_instance_id + and receipt_bound_replay_phase is not ReceiptBoundReplayPhase.SETTLED + and payload.get("effective_action") != EffectiveAction.UNSETTLED_HOST_TURN_RECOVERY.value + and ( + receipt_bound_replay_phase is ReceiptBoundReplayPhase.SETTLEMENT_PENDING + or _receipt_bound_recovery_required(payload, receipt_bound_todo_id) + ) + ) diff --git a/loopx/control_plane/quota/refresh_recovery.ts b/loopx/control_plane/quota/refresh_recovery.ts index b78e6f27b1..a29aecf960 100644 --- a/loopx/control_plane/quota/refresh_recovery.ts +++ b/loopx/control_plane/quota/refresh_recovery.ts @@ -8,6 +8,7 @@ import { normalizeDeliveryWorkspaceSnapshot } from "../agents/delivery_workspace import { decodeExternalDelivery, type ExternalDeliveryRequest } from "./refresh_external_delivery.ts"; export interface RefreshRetryRequest { + first_delivery_request?: JsonObject | null; checkpoint_read_context_id?: string | null; external_delivery?: ExternalDeliveryRequest | null; vision: JsonObject | null; @@ -38,6 +39,8 @@ export function decodeRefreshRetry(value: unknown): RefreshRetryRequest | null { return value; }; return { + first_delivery_request: input.first_delivery_request == null ? null + : requireJsonObject(input.first_delivery_request, "first_delivery_request"), checkpoint_read_context_id: input.checkpoint_read_context_id == null ? null : nullableString("checkpoint_read_context_id"), external_delivery: decodeExternalDelivery(input.external_delivery), vision: input.vision === null ? null : requireJsonObject(input.vision, "refresh_retry.vision"), @@ -82,17 +85,24 @@ export function refreshRecovery( ...(request.checkpoint_read_context_id ? {checkpoint_read_context_id: request.checkpoint_read_context_id} : {}), })).digest("hex") : null; const mutationDigest = createHash("sha256").update(canonical(request.mutation)).digest("hex"); + const firstDigest = request.first_delivery_request == null ? null + : createHash("sha256").update(canonical(request.first_delivery_request)).digest("hex"); const changesMutation = Object.values(request.mutation).some((value) => value !== null && value !== false && (!Array.isArray(value) || value.length > 0)); const result = (decision: Decision, reason: string): JsonObject => ({ schema_version: "refresh_recovery_v0", decision, reason, vision_request_digest: digest, mutation_digest: mutationDigest, + ...(firstDigest ? {first_delivery_request_digest: firstDigest} : {}), ...(request.checkpoint_read_context_id ? {checkpoint_read_context_id: request.checkpoint_read_context_id} : {}), original_generated_at: jsonObject(prior?.refresh_recovery)?.original_generated_at ?? prior?.generated_at ?? null, }); if (!prior) return result("append", "first_writeback"); + const priorFirstDigest = jsonObject(prior.refresh_recovery)?.first_delivery_request_digest ?? null; + if ((firstDigest !== null || priorFirstDigest !== null) && firstDigest !== priorFirstDigest) { + return result("reject", "committed_first_delivery_request_conflict"); + } const checkpoint = jsonObject(prior.vision_checkpoint); const priorDigest = jsonObject(prior.refresh_recovery)?.vision_request_digest; // Repeated CLI annotations are not mutations. Semantic delivery changes are. diff --git a/loopx/control_plane/quota/settlement.py b/loopx/control_plane/quota/settlement.py index 4b438c521a..648fdb22f2 100644 --- a/loopx/control_plane/quota/settlement.py +++ b/loopx/control_plane/quota/settlement.py @@ -183,6 +183,14 @@ def attach_settlement_progress( if not isinstance(progress, dict) or progress.get("schema_version") != "quota_settlement_progress_v0": raise RuntimeError("TypeScript quota settlement progress missing or invalid") payload["settlement_progress"] = dict(progress) + if runtime_root is not None: + from ..goals.checkpoint_context_io import first_delivery_progress + + delivery = first_delivery_progress(runtime_root, readback, registry_path=registry_path) + if delivery is not None: + payload["first_delivery_progress"] = delivery + if delivery["stage"] in {"direction_pending", "direction_stopped", "direction_failed", "operation_unknown"}: + payload["recommended_action"] = delivery["next_action"] payload.pop("settlement_owed", None) identity = readback.identity.value if payload.get("ok") is not True or progress.get("next_step") != "quota_spend" or identity is None: @@ -221,6 +229,10 @@ def render_settlement_progress_markdown(payload: dict[str, Any]) -> list[str]: if not isinstance(progress, dict): return [] lines = [f"- settlement: `{progress.get('state')}`"] + delivery = payload.get("first_delivery_progress") + if isinstance(delivery, dict): + lines.extend([f"- first delivery: `{delivery['stage']}`; result committed: {delivery['result_committed']}", + f"- next: {delivery['next_action']}"]) if progress.get("closeout_kind") == "typed_blocked_writeback_no_spend": lines.append("- closeout: typed blocked writeback; no quota slot spent") elif progress.get("closeout_kind") == "capability_duty_retired_no_spend": diff --git a/loopx/control_plane/quota/settlement_cli.py b/loopx/control_plane/quota/settlement_cli.py index bf3e2cd458..acb6d3df82 100644 --- a/loopx/control_plane/quota/settlement_cli.py +++ b/loopx/control_plane/quota/settlement_cli.py @@ -39,6 +39,7 @@ def reconcile_existing_heartbeat_receipt( runtime_root: Path, turn_instance_id: str, existing: dict[str, object], + registry_path: Path | None = None, goal_ref: Mapping[str, object] | None = None, ) -> tuple[dict[str, object], str, bool, str]: """Bind or qualify same-Turn work without replacing its settlement identity.""" @@ -131,6 +132,12 @@ def reconcile_existing_heartbeat_receipt( todo_id=rollout_todo_id, replan_obligation_id=rollout_replan_obligation_id, ) + if registry_path is not None and not (existing_todo_id or existing_replan_obligation_id): + # A choice after an unbound observation is a new admission; + # historical already-bound receipts keep their original policy. + rollout_details.update(quota_result_work_admission_details( + registry_path=registry_path, runtime_root=runtime_root, + goal_id=args.goal_id, todo_id=rollout_todo_id, goal_ref=goal_ref)) receipt, upgraded = upgrade_identityless_heartbeat_receipt( runtime_root, goal_id=args.goal_id, @@ -151,6 +158,11 @@ def reconcile_existing_heartbeat_receipt( if upgraded: receipt_status = "upgraded" receipt_appended = True + from .heartbeat_receipt import find_heartbeat_receipt + refreshed = find_heartbeat_receipt(runtime_root, goal_id=args.goal_id, agent_id=args.agent_id, + turn_instance_id=turn_instance_id, goal_ref=goal_ref) + if refreshed and refreshed.get("event_id") == receipt.get("event_id"): + receipt = refreshed details_value = receipt.get("details") details: Mapping[str, object] = ( details_value if isinstance(details_value, Mapping) else {} @@ -166,6 +178,7 @@ def reconcile_existing_heartbeat_receipt_for_turn( runtime_root: Path, turn_instance_id: str, existing: dict[str, object], + registry_path: Path | None = None, goal_ref: Mapping[str, object] | None = None, ) -> tuple[dict[str, object], str, bool, str, bool]: """Reconcile an existing receipt and report whether the turn is receipt-ready.""" @@ -176,11 +189,23 @@ def reconcile_existing_heartbeat_receipt_for_turn( runtime_root=runtime_root, turn_instance_id=turn_instance_id, existing=existing, + registry_path=registry_path, goal_ref=goal_ref, ) return receipt, status, appended, stall_observation, True +def quota_result_work_admission_details(*, registry_path: Path, runtime_root: Path, + goal_id: str, todo_id: str | None, + goal_ref: Mapping[str, object] | None = None) -> dict[str, object]: + """Compose new admission metadata using the existing typed checkpoint owner.""" + from ..goals.checkpoint_context_io import default_result_freshness_policy + + policy = default_result_freshness_policy(runtime_root, goal_id, todo_id, goal_ref) + return ({"result_freshness": policy, "checkpoint_continuation": "current_agent_v1"} + if policy is not None else {}) + + def render_existing_heartbeat_receipt_payload( payload: dict[str, object], *, @@ -431,6 +456,15 @@ def quota_rollout_details( details["semantic_replan_capability_id"] = capability_guard["capability_id"] details["semantic_replan_gap_id"] = capability_guard["gap_id"] details["semantic_replan_frontier_revision"] = capability_guard["frontier_revision"] + if semantic_replan_obligation_id: + from .heartbeat_receipt import bounded_guard_source, guard_cadence, store_guard_source_blob + source_obligation = payload.get("autonomous_replan_obligation") + if (isinstance(source_obligation, dict) and source_obligation.get("obligation_id") == semantic_replan_obligation_id + and not source_obligation.get("settlement_action_packet") + and not (payload.get("replan_action_packet") or {}).get("settlement_only")): + source = bounded_guard_source(source_obligation, + guard_cadence(payload["registry"], payload["runtime_root"], args.goal_id)) + details["semantic_replan_source_ref"] = store_guard_source_blob(payload["runtime_root"],args.goal_id,source) cli_channel = interaction.get("cli_channel") if isinstance(cli_channel, Mapping) and cli_channel.get("quota_spend_source"): details["quota_spend_source"] = cli_channel["quota_spend_source"] diff --git a/loopx/control_plane/quota/settlement_readback.ts b/loopx/control_plane/quota/settlement_readback.ts index 17555a7f66..6df48ca141 100644 --- a/loopx/control_plane/quota/settlement_readback.ts +++ b/loopx/control_plane/quota/settlement_readback.ts @@ -57,6 +57,7 @@ import { optionalHeartbeatString as optionalString, selectEffectiveHeartbeatReceipt, heartbeatWorkRequalification, + resultFreshnessRequired, type HeartbeatReceiptFact, } from "./heartbeat_receipt_identity.ts"; @@ -682,6 +683,10 @@ function terminalResult( identity: SettlementIdentity, event: JsonObject | null, ): SettlementResult { + if (event?.status === "terminal_no_followup" && details(event).no_followup !== true) { + return settlementFailed({kind: "effect_outcome_unknown", step_kind: "terminal_closeout", + reason: "Terminal event and its no-follow-up evidence contradict; reconcile the original Turn."}); + } if (!event || details(event).no_followup !== true) { return settlementFailed({ kind: "receipt_missing", @@ -1431,3 +1436,43 @@ export async function readQuotaSettlement(value: unknown): Promise { }, ); } + +/** Default work resumes its original unfinished settlement, not the latest key. */ +export async function selectDefaultResultWorkTurn(value: unknown): Promise { + const raw = requireJsonObject(value, "default result work selection"); + const request = decodeRequest(raw); + return await withQuotaAccountingOwner(request.owner, async () => { + const snapshot = await readQuotaSettlementSnapshot(request.runtime_root, request.goal_id); + const turns = new Map(); + const todo = jsonObject(raw.todo); + for (const event of snapshot.events) { + const fields = details(event); + if (event.event_kind !== "quota_should_run" || event.agent_id !== request.agent_id || + !quotaOwnerOwnsProjection(request.owner, event.goal_ref) || !resultFreshnessRequired(event) || + typeof fields.todo_id !== "string" || (request.todo_id !== null && fields.todo_id !== request.todo_id)) continue; + if (todo?.status === "done" && ![event.run_id, fields.settlement_effect_id].includes(todo.completion_turn_key)) continue; + const turn = String(event.run_id); + if (raw.pending_only === true || todo?.status !== "done") { + const readback = readQuotaSettlementFromRequest({...request, todo_id: fields.todo_id, turn_instance_id: turn}, snapshot); + // A settled planning obligation must not compete with its later work + // admission. Ordinary progress still retains its original-Turn result + // recovery path; settlement alone is not proof of planning-only work. + const guard = jsonObject(readback.semantic_replan_guard); + const planned = guard?.scope === "turn_guard" && + typeof guard.selected_obligation_id === "string" && + isAcceptedReplanWriteback(readback.writeback_run, settlementIdentity({ + goal_id: request.goal_id, agent_id: request.agent_id!, + todo_id: fields.todo_id, turn_instance_id: turn, + })); + if (readback.replay_phase === "settled" && (raw.pending_only === true || planned)) continue; + } + if (raw.pending_only !== true && todo?.status !== "done" && snapshot.events.some(done => done.event_kind === "todo_complete" && + done.agent_id === request.agent_id && done.run_id === event.run_id && done.todo_id === fields.todo_id && + quotaOwnerOwnsProjection(request.owner, done.goal_ref))) continue; + turns.set(turn, event); + } + if (turns.size > 1) throw new EffectRuntimeRequestError( + "Several unfinished Turns match this work; resume an exact original Turn", "delivery_work_turn_ambiguous"); + return {ok: true, turn_instance_id: [...turns.keys()][0] ?? null}; + }); +} diff --git a/loopx/control_plane/quota/should_run_prepare.py b/loopx/control_plane/quota/should_run_prepare.py index 3b4fd8036e..95a9987fdf 100644 --- a/loopx/control_plane/quota/should_run_prepare.py +++ b/loopx/control_plane/quota/should_run_prepare.py @@ -275,11 +275,12 @@ def _prepare_quota_replan_context( receipt_bound_replan_obligation_id=receipt_bound_replan_obligation_id, receipt_bound_guard_recorded_at=receipt_bound_guard_recorded_at, ) + retained_semantic_source = _retained_replan_source(status_payload, safe_goal_id, agent_frontier_id) replan_obligation = ( None if receipt_bound_replay_phase is ReceiptBoundReplayPhase.SETTLED else _preserve_receipt_bound_replan_obligation( - goal_frontier_context.get("replan_obligation"), + retained_semantic_source or goal_frontier_context.get("replan_obligation"), receipt_bound_replan_obligation_id, guard_scoped=receipt_bound_replan_guard_scoped, agent_id=agent_frontier_id, @@ -1057,3 +1058,14 @@ def _prepare_quota_should_run_item( delivery_continuity_anchor=delivery_continuity_anchor, delivery_continuity_todo=delivery_continuity_todo, ) + + +def _retained_replan_source(status_payload, safe_goal_id, agent_frontier_id): + """Rebuild current context without replacing the original obligation source.""" + retained_semantic_source = status_payload.get("_receipt_bound_semantic_source") + if isinstance(retained_semantic_source, dict): + from ..work_items.progress_observation import build_replan_context + retained_semantic_source = {**retained_semantic_source, "replan_context": build_replan_context( + retained_semantic_source,goal_id=safe_goal_id,agent_id=agent_frontier_id, + newest_first_runs=_goal_latest_runs(status_payload,goal_id=safe_goal_id),source_status=status_payload)} + return retained_semantic_source diff --git a/loopx/control_plane/runtime/document_io.py b/loopx/control_plane/runtime/document_io.py index 66f94cc959..cbfa0077ad 100644 --- a/loopx/control_plane/runtime/document_io.py +++ b/loopx/control_plane/runtime/document_io.py @@ -7,15 +7,25 @@ from __future__ import annotations import os +import json +from typing import Any + +from .file_paths import windows_extended_path +from .private_permissions import protect_private_path import stat import tempfile from pathlib import Path -def atomic_write_state_text(path: Path, text: str, *, create_only: bool = False) -> None: +def atomic_write_state_text( + path: Path, text: str, *, create_only: bool = False, private: bool = False, +) -> None: """Persist complete UTF-8 state while the caller holds its sibling lock.""" + path = windows_extended_path(path) path.parent.mkdir(parents=True, exist_ok=True) + if private: + protect_private_path(path.parent, directory=True) mode = stat.S_IMODE(path.stat().st_mode) if not create_only and path.exists() else 0o600 descriptor, temporary = tempfile.mkstemp( prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent) @@ -23,7 +33,10 @@ def atomic_write_state_text(path: Path, text: str, *, create_only: bool = False) temporary_path = Path(temporary) try: with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as handle: - os.chmod(temporary_path, mode) + if private: + protect_private_path(temporary_path) + else: + os.chmod(temporary_path, mode) handle.write(text) handle.flush() os.fsync(handle.fileno()) @@ -51,8 +64,15 @@ def verify_state_text_durable(path: Path, text: str) -> None: Equality of visible bytes alone does not prove the directory entry persisted. The caller holds the same document lock as the state writer. """ + path = windows_extended_path(path) with path.open("r+" if os.name == "nt" else "r", encoding="utf-8", newline="") as handle: if handle.read() != text: raise RuntimeError("Todo Markdown projection readback mismatch") os.fsync(handle.fileno()) fsync_state_directory(path) + + +def atomic_write_private_json(path: Path, payload: dict[str, Any]) -> None: + """Publish private complete JSON after enforcing platform access controls.""" + text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n" + atomic_write_state_text(path, text, private=True) diff --git a/loopx/control_plane/runtime/local_state_migration.py b/loopx/control_plane/runtime/local_state_migration.py index 4ac75cdb52..af0498a282 100644 --- a/loopx/control_plane/runtime/local_state_migration.py +++ b/loopx/control_plane/runtime/local_state_migration.py @@ -28,6 +28,7 @@ _is_redirected_path, ) from ...runtime import validate_goal_id_path_segment +from .file_paths import windows_extended_path LOCAL_STATE_MIGRATION_SCHEMA = "loopx_local_state_migration_v1" @@ -105,14 +106,14 @@ def _write_registry(path: Path, payload: dict[str, Any]) -> None: _require_unlinked_directory_chain(path.parent, label="migration write") if _is_redirected_path(path): raise ValueError(f"migration write is a symlink or junction: {path}") - descriptor, name = tempfile.mkstemp(prefix=".loopx-migration-", dir=path.parent.parent) + descriptor, name = tempfile.mkstemp(prefix=".loopx-migration-", dir=windows_extended_path(path.parent.parent)) temporary = Path(name) try: with os.fdopen(descriptor, "wb") as stream: stream.write(_registry_bytes(payload)) stream.flush() os.fsync(stream.fileno()) - temporary.replace(path) + temporary.replace(windows_extended_path(path)) finally: temporary.unlink(missing_ok=True) @@ -127,19 +128,20 @@ def _write_project_registry(path: Path, payload: dict[str, Any]) -> None: def _digest(path: Path) -> str: """Hash source bytes without traversing redirected files or directories.""" + address = windows_extended_path(path) digest = hashlib.sha256() - if _is_redirected_path(path): + if _is_redirected_path(path) or (address != path and _is_redirected_path(address)): raise ValueError(f"migration source contains a symlink or junction: {path}") - if path.is_file(): + if address.is_file(): digest.update(b"file\0") - with path.open("rb") as stream: + with address.open("rb") as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b""): digest.update(chunk) - elif path.is_dir(): + elif address.is_dir(): digest.update(b"dir\0") - for child in sorted(path.iterdir(), key=lambda item: item.name): + for child in sorted(address.iterdir(), key=lambda item: item.name): digest.update(child.name.encode("utf-8") + b"\0") - digest.update(_digest(child).encode("ascii")) + digest.update(_digest(path / child.name).encode("ascii")) else: raise FileNotFoundError(path) return digest.hexdigest() @@ -219,7 +221,7 @@ def _rewrite_registry( source, target = route declared = Path(str(goal["state_file"])).expanduser() goal["state_file"] = ( - str(target) if declared.is_absolute() else str(DEFAULT_PROJECT_GOALS / target.parent.name / target.name) + str(target) if declared.is_absolute() else (DEFAULT_PROJECT_GOALS / target.parent.name / target.name).as_posix() ) return updated @@ -316,7 +318,15 @@ def _copy(source: Path, target: Path) -> None: if _is_redirected_path(source): raise ValueError(f"migration source is a symlink or junction: {source}") _require_backup_path(target.parent) - target.parent.mkdir(parents=True, exist_ok=True) + windows_extended_path(target.parent).mkdir(parents=True, exist_ok=True) + _require_backup_path(target.parent) + if target.exists() or _is_redirected_path(target): + raise FileExistsError(f"backup snapshot target already exists: {target}") + source, target = windows_extended_path(source), windows_extended_path(target) + # Check the supported native address too, including long reparse ancestors. + _require_unlinked_directory_chain(source.parent, label="migration source") + if _is_redirected_path(source): + raise ValueError(f"migration source is a symlink or junction: {source}") _require_backup_path(target.parent) if target.exists() or _is_redirected_path(target): raise FileExistsError(f"backup snapshot target already exists: {target}") @@ -338,7 +348,7 @@ def _write_verified_backup(plan: dict[str, Any]) -> None: backup = Path(plan["backup_dir"]) entries = plan["entries"] _require_backup_path(backup, must_be_absent=True) - backup.mkdir(mode=0o700, parents=True, exist_ok=False) + windows_extended_path(backup).mkdir(mode=0o700, parents=True, exist_ok=False) _require_backup_path(backup) try: for index, entry in enumerate(entries): @@ -355,7 +365,7 @@ def _write_verified_backup(plan: dict[str, Any]) -> None: if _digest(copied) != entry["digest"]: raise ValueError(f"backup verification failed for {original}") _require_backup_path(backup) - (backup / "plan.json").write_text(json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + windows_extended_path(backup / "plan.json").write_text(json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") except Exception: # Keep any partial backup for inspection. No authoritative state moved. raise @@ -384,7 +394,7 @@ def _verify_migrated_state(plan: dict[str, Any], writes: _MigrationWrites) -> No if legacy.exists() or _is_redirected_path(legacy): raise ValueError(f"legacy authority reappeared during migration: {legacy}") before_runtime = backup / "snapshot" / "0" - before_children = {child.name for child in before_runtime.iterdir()} + before_children = {child.name for child in windows_extended_path(before_runtime).iterdir()} after_children = {child.name for child in target.iterdir()} if before_children != after_children: raise ValueError("runtime files changed during migration") @@ -541,7 +551,7 @@ def _read_recovery_receipt(receipt_path: Path) -> dict[str, Any]: _require_backup_path(receipt_path.parent) if _is_redirected_path(receipt_path): raise ValueError(f"backup receipt is a symlink or junction: {receipt_path}") - receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + receipt = json.loads(windows_extended_path(receipt_path).read_text(encoding="utf-8")) if ( not isinstance(receipt, dict) or receipt.get("schema_version") != LOCAL_STATE_MIGRATION_SCHEMA @@ -553,7 +563,7 @@ def _read_recovery_receipt(receipt_path: Path) -> dict[str, Any]: plan_path = backup / "plan.json" if _is_redirected_path(plan_path): raise ValueError("migration backup plan is redirected") - plan = json.loads(plan_path.read_text(encoding="utf-8")) + plan = json.loads(windows_extended_path(plan_path).read_text(encoding="utf-8")) entries = receipt.get("entries") if not isinstance(entries, list) or not isinstance(plan, dict): raise ValueError("migration receipt has no entries") @@ -578,9 +588,9 @@ def _read_recovery_receipt(receipt_path: Path) -> dict[str, Any]: def _require_unchanged_runtime(receipt: dict[str, Any], active: Path) -> None: before = Path(receipt["backup_dir"]) / "snapshot" / "0" - if {child.name for child in before.iterdir()} != {child.name for child in active.iterdir()}: + if {child.name for child in windows_extended_path(before).iterdir()} != {child.name for child in active.iterdir()}: raise ValueError(f"migrated state changed; automatic rollback is unsafe: {active}") - for child in before.iterdir(): + for child in windows_extended_path(before).iterdir(): current = active / child.name if child.name != GLOBAL_REGISTRY_FILENAME: if _digest(current) != _digest(child): @@ -649,11 +659,11 @@ def _restore_snapshot_file(snapshot: Path, destination: Path, *, expected_digest if _digest(snapshot) != expected_digest: raise ValueError(f"migration backup changed: {snapshot}") before = _digest(destination) - descriptor, name = tempfile.mkstemp(prefix=".loopx-rollback-", dir=destination.parent.parent) + descriptor, name = tempfile.mkstemp(prefix=".loopx-rollback-", dir=windows_extended_path(destination.parent.parent)) os.close(descriptor) temporary = Path(name) try: - shutil.copy2(snapshot, temporary) + shutil.copy2(windows_extended_path(snapshot), temporary) if _digest(temporary) != expected_digest or _digest(snapshot) != expected_digest: raise ValueError(f"registry recovery copy changed: {snapshot}") with temporary.open("r+b") as stream: @@ -663,7 +673,7 @@ def _restore_snapshot_file(snapshot: Path, destination: Path, *, expected_digest raise ValueError("registry recovery destination is redirected") if _digest(destination) != before: raise ValueError(f"registry changed during recovery: {destination}") - temporary.replace(destination) + temporary.replace(windows_extended_path(destination)) finally: temporary.unlink(missing_ok=True) diff --git a/loopx/control_plane/runtime/private_permissions.py b/loopx/control_plane/runtime/private_permissions.py new file mode 100644 index 0000000000..e7b4f57c1d --- /dev/null +++ b/loopx/control_plane/runtime/private_permissions.py @@ -0,0 +1,198 @@ +"""Platform permissions for caller-owned private local IO; no authority decisions.""" +from __future__ import annotations + +import os +from pathlib import Path +import stat +import sys + + +def protect_private_path(path: Path, *, directory: bool = False) -> None: + """Restrict an existing private leaf to its current account and OS admins. + + The caller owns the namespace/locking. Reparse points are never repaired. + Windows uses a protected DACL, not chmod's emulated POSIX write flag. + """ + info = path.lstat() + if (stat.S_ISLNK(info.st_mode) + or getattr(info, "st_file_attributes", 0) & 0x400 + or (not stat.S_ISDIR(info.st_mode) if directory else not stat.S_ISREG(info.st_mode))): + raise OSError("private IO path is not a regular owned leaf") + if os.name != "nt": + os.chmod(path, 0o700 if directory else 0o600) + return + _protect_windows_path(path, directory=directory) + + +def _protect_windows_path(path: Path, *, directory: bool) -> None: + # Native APIs are loaded only on Windows. No subprocess, external package, + # ambient inherited ACE or caller-supplied principal is trusted here. + if sys.platform != "win32": + raise OSError("Windows private IO requires a Windows host") + import ctypes as ct + from ctypes import wintypes as w + + adv = ct.WinDLL("advapi32", use_last_error=True) + kernel = ct.WinDLL("kernel32", use_last_error=True) + pointer = ct.c_void_p + kernel.GetCurrentProcess.restype = w.HANDLE + kernel.CloseHandle.argtypes = [w.HANDLE] + kernel.LocalFree.argtypes = [pointer] + kernel.LocalFree.restype = pointer + adv.OpenProcessToken.argtypes = [w.HANDLE, w.DWORD, ct.POINTER(w.HANDLE)] + adv.GetTokenInformation.argtypes = [w.HANDLE, ct.c_int, pointer, w.DWORD, ct.POINTER(w.DWORD)] + adv.ConvertSidToStringSidW.argtypes = [pointer, ct.POINTER(w.LPWSTR)] + adv.ConvertStringSecurityDescriptorToSecurityDescriptorW.argtypes = [w.LPCWSTR, w.DWORD, ct.POINTER(pointer), ct.POINTER(w.DWORD)] + adv.GetSecurityDescriptorDacl.argtypes = [pointer, ct.POINTER(w.BOOL), ct.POINTER(pointer), ct.POINTER(w.BOOL)] + adv.SetNamedSecurityInfoW.argtypes = [w.LPWSTR, ct.c_int, w.DWORD, pointer, pointer, pointer, pointer] + adv.SetNamedSecurityInfoW.restype = w.DWORD + token = w.HANDLE() + sd = pointer() + sid_text = w.LPWSTR() + try: + if not adv.OpenProcessToken(kernel.GetCurrentProcess(), 0x8, ct.byref(token)): + raise ct.WinError(ct.get_last_error()) + size = w.DWORD() + adv.GetTokenInformation(token, 1, None, 0, ct.byref(size)) # TokenUser + if not size.value: + raise ct.WinError(ct.get_last_error()) + user = ct.create_string_buffer(size.value) + if not adv.GetTokenInformation(token, 1, user, size, ct.byref(size)): + raise ct.WinError(ct.get_last_error()) + sid = ct.cast(user, ct.POINTER(pointer))[0] + if not adv.ConvertSidToStringSidW(sid, ct.byref(sid_text)): + raise ct.WinError(ct.get_last_error()) + flags = "OICI" if directory else "" + descriptor = f"D:P(A;{flags};FA;;;{sid_text.value})(A;{flags};FA;;;SY)(A;{flags};FA;;;BA)" + if not adv.ConvertStringSecurityDescriptorToSecurityDescriptorW(descriptor, 1, ct.byref(sd), None): + raise ct.WinError(ct.get_last_error()) + present, defaulted = w.BOOL(), w.BOOL() + dacl = pointer() + if not adv.GetSecurityDescriptorDacl(sd, ct.byref(present), ct.byref(dacl), ct.byref(defaulted)) or not present.value or not dacl.value: + raise OSError("private IO DACL unavailable") + # SE_FILE_OBJECT; DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION. + error = adv.SetNamedSecurityInfoW(str(path), 1, 0x80000004, None, None, dacl, None) + if error: + raise ct.WinError(error) + finally: + if sid_text: + kernel.LocalFree(ct.cast(sid_text, pointer)) + if sd: + kernel.LocalFree(sd) + if token: + kernel.CloseHandle(token) + + +def require_private_path(path: Path, *, directory: bool = False, descriptor: int | None = None) -> None: + """Read OS access facts without repairing an untrusted caller's file.""" + info = path.lstat() + if (stat.S_ISLNK(info.st_mode) + or getattr(info, "st_file_attributes", 0) & 0x400 + or (not stat.S_ISDIR(info.st_mode) if directory else not stat.S_ISREG(info.st_mode))): + raise OSError("private IO path is not a regular owned leaf") + if descriptor is not None: + opened = os.fstat(descriptor) + if (opened.st_dev, opened.st_ino) != (info.st_dev, info.st_ino): + raise OSError("private IO path changed after open") + info = opened + if sys.platform != "win32": + if info.st_uid != os.getuid() or info.st_mode & 0o077: + raise OSError("private IO permissions are invalid") + return + _require_windows_private_path(path, descriptor=descriptor) + + +def _require_windows_private_path(path: Path, *, descriptor: int | None) -> None: + if sys.platform != "win32": + raise OSError("Windows private IO requires a Windows host") + import ctypes as ct + from ctypes import wintypes as w + import msvcrt + + adv = ct.WinDLL("advapi32", use_last_error=True) + kernel = ct.WinDLL("kernel32", use_last_error=True) + pointer = ct.c_void_p + out = ct.POINTER(pointer) + kernel.GetCurrentProcess.restype = w.HANDLE + kernel.CloseHandle.argtypes = [w.HANDLE] + kernel.LocalFree.argtypes = [pointer] + kernel.LocalFree.restype = pointer + adv.OpenProcessToken.argtypes = [w.HANDLE, w.DWORD, ct.POINTER(w.HANDLE)] + adv.GetTokenInformation.argtypes = [w.HANDLE, ct.c_int, pointer, w.DWORD, ct.POINTER(w.DWORD)] + adv.ConvertSidToStringSidW.argtypes = [pointer, ct.POINTER(w.LPWSTR)] + adv.GetNamedSecurityInfoW.argtypes = [w.LPCWSTR, ct.c_int, w.DWORD, out, out, out, out, out] + adv.GetNamedSecurityInfoW.restype = w.DWORD + adv.GetSecurityInfo.argtypes = [w.HANDLE, ct.c_int, w.DWORD, out, out, out, out, out] + adv.GetSecurityInfo.restype = w.DWORD + adv.GetSecurityDescriptorControl.argtypes = [pointer, ct.POINTER(w.WORD), ct.POINTER(w.DWORD)] + adv.GetAclInformation.argtypes = [pointer, pointer, w.DWORD, ct.c_int] + adv.GetAce.argtypes = [pointer, w.DWORD, out] + token, sd, owner, dacl = w.HANDLE(), pointer(), pointer(), pointer() + + def sid_text(sid: ct.c_void_p | int | None) -> str: + value = w.LPWSTR() + if not sid or not adv.ConvertSidToStringSidW(sid, ct.byref(value)): + raise OSError("private IO SID unavailable") + try: + return str(value.value) + finally: + kernel.LocalFree(ct.cast(value, pointer)) + + try: + if not adv.OpenProcessToken(kernel.GetCurrentProcess(), 0x8, ct.byref(token)): + raise ct.WinError(ct.get_last_error()) + size = w.DWORD() + adv.GetTokenInformation(token, 1, None, 0, ct.byref(size)) + if not size.value: + raise OSError("private IO account unavailable") + user = ct.create_string_buffer(size.value) + if not adv.GetTokenInformation(token, 1, user, size, ct.byref(size)): + raise ct.WinError(ct.get_last_error()) + allowed = {sid_text(ct.cast(user, ct.POINTER(pointer))[0]), "S-1-5-18", "S-1-5-32-544"} + args = (1, 0x5, ct.byref(owner), None, ct.byref(dacl), None, ct.byref(sd)) + error = (adv.GetNamedSecurityInfoW(str(path), *args) if descriptor is None else + adv.GetSecurityInfo(w.HANDLE(msvcrt.get_osfhandle(descriptor)), *args)) + if error: + raise ct.WinError(error) + control, revision = w.WORD(), w.DWORD() + if (not sd or not dacl or sid_text(owner) not in allowed + or not adv.GetSecurityDescriptorControl(sd, ct.byref(control), ct.byref(revision)) + or not control.value & 0x1000): # SE_DACL_PROTECTED + raise OSError("private IO owner or protected DACL is invalid") + info = (w.DWORD * 3)() + if not adv.GetAclInformation(dacl, info, ct.sizeof(info), 2) or info[0] != 3: + raise OSError("private IO ACE set is invalid") + seen = set() + for index in range(info[0]): + ace = pointer() + if not adv.GetAce(dacl, index, ct.byref(ace)) or not ace.value: + raise OSError("private IO ACE unavailable") + header = (ct.c_ubyte * 4).from_address(ace.value) + # Only explicit ACCESS_ALLOWED_ACE, full control, current account/OS admins. + if header[0] != 0 or header[1] & 0x10 or ct.c_uint32.from_address(ace.value + 4).value != 0x1F01FF: + raise OSError("private IO ACE grants unexpected access") + principal = sid_text(pointer(ace.value + 8)) + if principal not in allowed: + raise OSError("private IO principal is invalid") + seen.add(principal) + if seen != allowed: + raise OSError("private IO account access is incomplete") + finally: + if sd: + kernel.LocalFree(sd) + if token: + kernel.CloseHandle(token) + + +if __name__ == "__main__": + # Fixed, package-owned OS IO helper for Node on Windows; no Goal decisions. + import sys + if len(sys.argv) != 3 or sys.argv[1] != "--verify-snapshot": + raise SystemExit(2) + try: + leaf = Path(sys.argv[2]) + require_private_path(leaf.parent, directory=True) + with leaf.open("rb") as handle: + require_private_path(leaf, descriptor=handle.fileno()) + except OSError: + raise SystemExit(1) from None diff --git a/loopx/control_plane/runtime/validation_command.py b/loopx/control_plane/runtime/validation_command.py index 1e896e9b5b..2efce5dbfb 100644 --- a/loopx/control_plane/runtime/validation_command.py +++ b/loopx/control_plane/runtime/validation_command.py @@ -39,12 +39,14 @@ def run_caller_validation( validation_argv: list[str] | None = None, validation_label: str, timeout_seconds: int, + stdin: int | None = None, ) -> dict[str, Any]: """Run a caller-approved validation command and return a privacy-safe receipt. Exactly one of ``validation_command`` (shell-free ``shlex.split``) or ``validation_argv`` (pre-split JSON argv array, no shell parsing) selects - the command form. The command runs with ``cwd=workspace``. + the command form. The command runs with ``cwd=workspace``. Host adapters + can isolate protocol input; ordinary callers retain inherited stdin. Only ``exit_code`` and the boolean ``passed`` are recorded; command stdout, stderr, and local paths are deliberately not captured. A timeout raises ``subprocess.TimeoutExpired``; callers decide whether to convert that into @@ -67,7 +69,7 @@ def run_caller_validation( argv, cwd=workspace, env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, - stdin=_VALIDATION_STDIN.get(), + stdin=stdin if stdin is not None else _VALIDATION_STDIN.get(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=os.name == "posix", diff --git a/loopx/control_plane/scheduler/execution_context.py b/loopx/control_plane/scheduler/execution_context.py index 54b0b30420..8154cb599f 100644 --- a/loopx/control_plane/scheduler/execution_context.py +++ b/loopx/control_plane/scheduler/execution_context.py @@ -447,7 +447,12 @@ def render_scheduler_execution_args( f"unsupported scheduler runtime profile: {runtime_profile}" ) from exc return _render_scheduler_runtime_profile_args(profile) - if scheduler_execution_context is None: + if scheduler_execution_context is None or ( + isinstance(scheduler_execution_context, SchedulerExecutionContextResolution) + and scheduler_execution_context == resolve_scheduler_execution_context(None) + ): + # Resolution preserves an absent caller context. Rendering that absence + # must not infer a host or turn it into an invalid explicit empty config. return "" resolution = resolve_scheduler_execution_context(scheduler_execution_context) if not resolution.ok or resolution.context is None: diff --git a/loopx/control_plane/status/collection.py b/loopx/control_plane/status/collection.py index ffe2a9ea5e..4fc2382458 100644 --- a/loopx/control_plane/status/collection.py +++ b/loopx/control_plane/status/collection.py @@ -330,6 +330,9 @@ def observe_source_goal(current_goal_id: str, source_goal: dict[str, Any] | None ) attach_goal_acceptance_observations(payload, history=history) attach_goal_artifact_lifecycle_projections(payload, history=history) + from .first_delivery import attach_first_delivery_status + + attach_first_delivery_status(payload, runtime_root=runtime_root, agent_id=agent_lane_id) payload["projection_envelope"] = seal_projection_envelope( projection="status", observed_at=now_utc_iso(), diff --git a/loopx/control_plane/status/first_delivery.py b/loopx/control_plane/status/first_delivery.py new file mode 100644 index 0000000000..9b91932025 --- /dev/null +++ b/loopx/control_plane/status/first_delivery.py @@ -0,0 +1,26 @@ +"""Expose enrolled Turn progress through existing status consumer fields.""" +from pathlib import Path +from typing import Any + +from ..goals.checkpoint_context_io import pending_first_delivery_progress +from ..quota.states import quota_item_is_paused + + +def attach_first_delivery_status(payload: dict[str, Any], *, runtime_root: Path, agent_id: str | None) -> None: + for item in payload.get("attention_queue", {}).get("items", []): + goal_id = item.get("goal_id") + if not goal_id: + continue + progress = pending_first_delivery_progress(runtime_root, goal_id, agent_id, + registry_path=Path(payload["registry"]) if payload.get("registry") else None) + if progress is None: + continue + item["first_delivery_progress"] = progress + if not progress.get("settlement_identity") or quota_item_is_paused(item) or item.get("requires_user_action") is True: + continue + action = progress["next_action"] + item["recommended_action"] = action + for field in ("project_asset", "goal_channel_projection"): + projection = item.get(field) + if isinstance(projection, dict): + projection["next_action"] = action diff --git a/loopx/control_plane/testing/canary_harness.py b/loopx/control_plane/testing/canary_harness.py index 1991cb0c96..569b1ab75c 100644 --- a/loopx/control_plane/testing/canary_harness.py +++ b/loopx/control_plane/testing/canary_harness.py @@ -1,9 +1,13 @@ from __future__ import annotations import json +import os +import shutil import subprocess import sys from datetime import datetime +import tempfile +from contextlib import contextmanager from pathlib import Path from typing import Any, Iterable @@ -11,6 +15,25 @@ REPO_ROOT = Path(__file__).resolve().parents[3] +@contextmanager +def temporary_canary_directory(*, prefix: str, dir: str | Path | None = None): + """Keep fixture cleanup valid for canonical Windows authority paths.""" + with tempfile.TemporaryDirectory(prefix=prefix, dir=dir) as name: + owned = Path(name).resolve() + try: + yield name + finally: + if os.name == 'nt': + from ..runtime.file_paths import windows_extended_path + + # Cold-import receipts can exceed MAX_PATH. Delete only the + # exact directory we created, using the existing path adapter. + target = Path(name) + if target.is_symlink() or target.resolve() != owned: + raise RuntimeError('Canary directory identity changed before cleanup') + shutil.rmtree(windows_extended_path(owned)) + + def default_state_file(goal_id: str) -> str: # Legacy route is intentional: existing characterization fixtures create # state there and declare it explicitly in their synthetic registries. @@ -193,6 +216,7 @@ def import_fixture_authority(registry_path: Path, goal_id: str, *, provider: str Existing unpromoted/refusal fixtures opt in explicitly, never by default. """ from ...state_backup import build_state_backup_plan, execute_state_backup_plan + from ..runtime.file_paths import windows_extended_path registry_path = registry_path.resolve() registry = json.loads(registry_path.read_text(encoding="utf-8")) @@ -224,7 +248,7 @@ def import_fixture_authority(registry_path: Path, goal_id: str, *, provider: str )["cold_import"] assert prepared["status"] == "prepared", prepared # Read the saved carrier rather than manufacturing a digest/target. - plan = json.loads(Path(prepared["plan_path"]).read_text(encoding="utf-8")) + plan = json.loads(windows_extended_path(Path(prepared["plan_path"])).read_text(encoding="utf-8")) assert plan["plan_sha256"] == prepared["plan_sha256"], plan applied = run_json_cli( *prefix, "apply-import", *identity, diff --git a/loopx/control_plane/testing/cli_output_budget.py b/loopx/control_plane/testing/cli_output_budget.py index 2d136dca81..b966e3aaec 100644 --- a/loopx/control_plane/testing/cli_output_budget.py +++ b/loopx/control_plane/testing/cli_output_budget.py @@ -143,20 +143,16 @@ class CliOutputCommandClassification: semantic_json_keys=("interaction_contract", "scheduler_hint", "selected_todo"), markdown_anchor="# LoopX Quota Should Run", max_chars={ - # The same explicitly imported fixture on base/head emits 24,099 - # JSON chars. Keep canonical provenance and complete User context. - "small": {"json": 25_000, "markdown": 10_000}, - # Same 36-Todo / 12-run source: 38,432 -> 39,101 chars with - # objective/evidence guidance; small and multi-agent paths unchanged. - # Per-Todo and fixed semantic growth are accounted for separately. - # Canonical source witnesses use the full Markdown context carrier; - # the unchanged 36-Todo fixture emits 10,225 chars / 166 lines. - # Native Windows paired base/head both emit 40,701 JSON chars. - # Retain complete routes/context and bounded host-path headroom; - # line, per-Todo/fixed growth and all other ceilings stay unchanged. - "crowded": {"json": 42_000, "markdown": 10_500}, - # Matched canonical base/head both emit 24,820 JSON characters. - "multi_agent": {"json": 25_500, "markdown": 7_000}, + # Native Windows matched main/candidate: 23,291 -> 28,001 chars; + # CI's longer host paths reach 28,334. Preserve the original read + # basis, Turn receipt and memory-call observation. This display + # ceiling does not change execution quota or the production SLO. + "small": {"json": 30_000, "markdown": 10_000}, + # The same 36-Todo fixture now carries its admitted replan source: + # Windows 35,862 -> 43,202 chars. Per-Todo/fixed growth is unchanged. + "crowded": {"json": 46_000, "markdown": 10_500}, + # Windows main already emits 25,852 (CI 25,975); candidate 27,984. + "multi_agent": {"json": 29_500, "markdown": 7_000}, }, max_lines={ "small": {"json": 600, "markdown": 190}, diff --git a/loopx/control_plane/testing/cli_output_differential.py b/loopx/control_plane/testing/cli_output_differential.py index a2aea89c1a..4b1d49996c 100644 --- a/loopx/control_plane/testing/cli_output_differential.py +++ b/loopx/control_plane/testing/cli_output_differential.py @@ -939,6 +939,46 @@ def _turn_projection_migration( return {}, [] +def _native_admission_migration( + base: dict[str, Any], candidate: dict[str, Any], +) -> tuple[dict[str, int], list[str]]: + """One-time measured cost of original-Turn admission, not general headroom.""" + before, after = base.get("native_admission"), candidate.get("native_admission") + if (base.get("surface_id") not in {"quota_should_run", "status"} + or candidate.get("row_id") != base.get("row_id") + or base.get("format") != "json" or candidate.get("format") != "json" + or not isinstance(before, dict) or before.get("kind") is not None + or not isinstance(after, dict)): + return {}, [] + carriers = { + "delivery_result": (("heartbeat_receipt", "delivery_result_context", "reward_memory_recall", + "first_delivery_progress"), 6_400), + "replan": (("heartbeat_receipt", "reward_memory_recall", "planning_horizon", "action_portfolio"), 9_500), + "unbound": (("heartbeat_receipt", "reward_memory_recall"), 2_600), + "progress": (("first_delivery_progress",), 900), + } + if after.get("kind") not in carriers: + return {}, [] + if (after["kind"] == "progress") != (base.get("surface_id") == "status"): + return {}, [] + fields, ceiling = carriers[after["kind"]] + allowance = {} + for metric in ("chars", "utf8_bytes"): + try: + pairs = [(before["costs"][field][metric], after["costs"][field][metric]) for field in fields] + except (KeyError, TypeError): + return {}, [] + if any(type(value) is not int or value < 0 for pair in pairs for value in pair): + return {}, [] + growth = sum(max(new - old, 0) for old, new in pairs) + if growth > ceiling: + return {}, [] + # Field names/separators and receipt clocks, not another context copy. + allowance[metric] = growth + 64 + allowance.update(lines=0, compact_payload_chars=allowance["chars"]) + return allowance, ["original-Turn admission carriers migrated; measured bounded one-time cost"] + + def _compare_row_growth( base: dict[str, Any], candidate: dict[str, Any], *, migration: _SchemaMigrationState, @@ -963,6 +1003,8 @@ def _compare_row_growth( review_signals.extend(continuation_signals) context_allowance, context_signals = _turn_projection_migration(base, candidate) review_signals.extend(context_signals) + admission_allowance, admission_signals = _native_admission_migration(base, candidate) + review_signals.extend(admission_signals) deltas: dict[str, int | None] = {} allowances: dict[str, int | None] = {} for metric in ("chars", "utf8_bytes", "lines", "compact_payload_chars"): @@ -1006,6 +1048,7 @@ def _compare_row_growth( authoring_allowance.get(metric, 0), continuation_allowance.get(metric, 0), context_allowance.get(metric, 0), + admission_allowance.get(metric, 0), ) # Thin installed prompts contain bilingual lifecycle instructions. A # small character-level clarification can cost three bytes per CJK diff --git a/loopx/control_plane/testing/cli_output_semantics.py b/loopx/control_plane/testing/cli_output_semantics.py index 536fd42ca0..a5fe197adc 100644 --- a/loopx/control_plane/testing/cli_output_semantics.py +++ b/loopx/control_plane/testing/cli_output_semantics.py @@ -5,10 +5,86 @@ import re import shlex from typing import Any - from loopx.control_plane.content_digest import ENVELOPED_SHA256_PATTERN +def native_admission_observation(payload: Any) -> dict[str, Any] | None: + """Measure reviewed admission carriers, without retaining their contents.""" + queue = payload.get("attention_queue") if isinstance(payload, dict) else None + if isinstance(queue, dict) and isinstance(queue.get("items"), list): + progress = [] + valid = True + for item in queue["items"]: + row = item.get("first_delivery_progress") if isinstance(item, dict) else None + if row is None: + continue + identity = row.get("settlement_identity") if isinstance(row, dict) else None + if (not isinstance(row, dict) or row.get("schema_version") != "first_delivery_progress_v0" + or row.get("goal_completion_certified") is not False + or not isinstance(identity, dict) + or identity.get("schema_version") != "quota_settlement_identity_v0" + or identity.get("goal_id") != item.get("goal_id") + or not all(isinstance(identity.get(key), str) and identity[key] + for key in ("agent_id", "todo_id", "turn_instance_id"))): + valid = False + progress.append(row) + text = "" if not progress else json.dumps(progress, ensure_ascii=False) + return {"kind": "progress" if progress and valid else None, + "costs": {"first_delivery_progress": { + "chars": len(text), "utf8_bytes": len(text.encode("utf-8"))}}} + if not isinstance(payload, dict) or payload.get("mode") != "should-run": + return None + fields = ("heartbeat_receipt", "delivery_result_context", "reward_memory_recall", + "planning_horizon", "action_portfolio", "first_delivery_progress") + costs = {} + for field in fields: + text = json.dumps(payload[field], ensure_ascii=False) if field in payload else "" + costs[field] = {"chars": len(text), "utf8_bytes": len(text.encode("utf-8"))} + receipt = payload.get("heartbeat_receipt") or {} + kind = None + if (isinstance(receipt, dict) + and receipt.get("schema_version") == "heartbeat_quota_receipt_v0" + and receipt.get("status") in {"committed", "replayed"} + and isinstance(receipt.get("turn_instance_id"), str) + and receipt["turn_instance_id"]): + context = payload.get("delivery_result_context") or {} + identity = receipt.get("settlement_identity") or {} + selected = payload.get("selected_todo") or {} + agent = payload.get("agent_identity") or {} + if (receipt.get("result_freshness") == "turn_result_freshness_required_v1" + and receipt.get("closeout_required") is True + and isinstance(identity, dict) and isinstance(context, dict) + and isinstance(selected, dict) and isinstance(agent, dict) + and identity.get("schema_version") == "quota_settlement_identity_v0" + and identity.get("goal_id") == payload.get("goal_id") + and identity.get("turn_instance_id") == receipt["turn_instance_id"] + and identity.get("todo_id") == selected.get("todo_id") + and identity.get("agent_id") == agent.get("agent_id") + and context.get("ok") is True and isinstance(context.get("read_context_id"), str) + and context["read_context_id"] + and isinstance(context.get("basis"), dict) + and context.get("settlement_identity") == identity): + progress = payload.get("first_delivery_progress") + if progress is None or (isinstance(progress, dict) + and progress.get("schema_version") == "first_delivery_progress_v0" + and progress.get("goal_completion_certified") is False + and progress.get("settlement_identity") == identity): + kind = "delivery_result" + elif receipt.get("settlement_binding_owed") is True: + source = receipt.get("semantic_replan_source") or {} + obligation = payload.get("autonomous_replan_obligation") or {} + if (isinstance(source, dict) + and source.get("schema_version") == "semantic_replan_source_v1" + and isinstance(obligation, dict) + and receipt.get("semantic_replan_obligation_id") == obligation.get("obligation_id") + and obligation.get("obligation_id")): + kind = "replan" + elif not source and not receipt.get("semantic_replan_obligation_id"): + kind = "unbound" + return {"kind": kind, "costs": costs} + + + def authoring_input_observations(text: str) -> dict[str, bool]: """Recognize complete reviewed guidance for one-time output qualification. diff --git a/loopx/control_plane/todos/completion_result.py b/loopx/control_plane/todos/completion_result.py index 5a6b9650b4..898f35310d 100644 --- a/loopx/control_plane/todos/completion_result.py +++ b/loopx/control_plane/todos/completion_result.py @@ -123,3 +123,24 @@ def read_completion_result(*, registry_path: Path, runtime_root: Path, raise ValueError("completion result bytes no longer match canonical binding") return {"ok": True, "goal_id": goal_id, "todo_id": todo_id, "result": binding, "text": data.decode("utf-8"), "audience": "local_operator"} + + +def resolve_terminal_result_descriptor(completion_result_file, *, target, runtime_root, registry_path, goal_id, todo_id): + """Recover missing input bytes only from the original completed Todo binding.""" + result_descriptor = None + if completion_result_file is not None: + try: + result_descriptor = store_completion_result( + source=completion_result_file, runtime_root=runtime_root, + goal_id=goal_id, persist=False, + ) + except FileNotFoundError: + if target.get("status") != "done": + raise + bound = read_completion_result( + registry_path=registry_path, runtime_root=runtime_root, + goal_id=goal_id, todo_id=todo_id, + )["result"] + result_descriptor = {key: bound[key] for key in + ("provider", "sha256", "size_bytes", "content_type")} + return result_descriptor diff --git a/loopx/control_plane/todos/completion_state.ts b/loopx/control_plane/todos/completion_state.ts index 663902c0f1..15a767c25d 100644 --- a/loopx/control_plane/todos/completion_state.ts +++ b/loopx/control_plane/todos/completion_state.ts @@ -137,6 +137,15 @@ export function completionContinuationForWrite( return "active_goal"; } +/** Original closeout intent is distinct from a committed terminal transition. */ +export function originalTurnRequestsNoFollowup(todo: JsonObject, identity: JsonObject): boolean { + const requested = todo.completion_requested_continuation == null ? null : + requireTodoCompletionMetadata("completion_continuation", todo.completion_requested_continuation); + return normalizeTodoNoFollowup(todo.no_followup) === true || + (todo.status === "done" && todo.completion_continuation === "active_goal" && requested === "no_followup" && + [identity.effect_id, identity.turn_instance_id].includes(todo.completion_turn_key)); +} + export function selectTodoCompletionState(value: unknown): TodoCompletionStateResult { const request = requestObject(value, "evaluate"); const todo = requiredObject(request.todo, "todo"); @@ -144,8 +153,13 @@ export function selectTodoCompletionState(value: unknown): TodoCompletionStateRe request.requested_no_followup, "requested_no_followup", ); - const effectiveNoFollowup = requestedNoFollowup || - normalizeTodoNoFollowup(todo.no_followup) === true; + const deferNoFollowup = request.defer_no_followup === undefined ? false : + requiredBoolean(request.defer_no_followup, "defer_no_followup"); + if (deferNoFollowup && request.completion_identity_source !== "turn_settlement") { + throw new EffectRuntimeRequestError("Deferred closeout requires the original Turn settlement identity"); + } + const effectiveNoFollowup = !deferNoFollowup && + (requestedNoFollowup || normalizeTodoNoFollowup(todo.no_followup) === true); const continuation = completionContinuationForWrite( effectiveNoFollowup, requiredBoolean(request.has_successor, "has_successor"), @@ -163,7 +177,7 @@ export function selectTodoCompletionState(value: unknown): TodoCompletionStateRe throw new EffectRuntimeRequestError("completion_identity_source is unsupported"); } const recovery = String(todo.status ?? "") === "done" && - requestedNoFollowup && + requestedNoFollowup && !deferNoFollowup && normalizeTodoCompletionContinuation(todo.completion_continuation) === "active_goal" ? completionIdentitySource === "lifecycle_reentry" diff --git a/loopx/control_plane/todos/completion_transaction.py b/loopx/control_plane/todos/completion_transaction.py index 39209b52da..4e83ac2f98 100644 --- a/loopx/control_plane/todos/completion_transaction.py +++ b/loopx/control_plane/todos/completion_transaction.py @@ -424,12 +424,16 @@ def _valid_completion_settlement(result: Mapping[str, Any]) -> bool: and (state.get("recovery") is None or state.get("recovery") in _RECOVERIES) and isinstance(updates, Mapping) and all( - key in {"completion_continuation", "completion_recovery", "completion_receipt_id"} + key in {"completion_continuation", "completion_requested_continuation", "completion_recovery", "completion_receipt_id"} and isinstance(value, str) for key, value in updates.items() ) and updates.get("completion_continuation") == state.get("continuation") and updates.get("completion_recovery") == state.get("recovery") + and ( + updates.get("completion_requested_continuation") is None + or updates["completion_requested_continuation"] in _CONTINUATIONS + ) and ( updates.get("completion_receipt_id") is None or ( @@ -526,6 +530,7 @@ def reduce_todo_completion_transaction( dry_run: bool, validation_receipt: Mapping[str, Any] | None = None, completion_policy_request: Mapping[str, Any] | None = None, + defer_no_followup: bool = False, ) -> dict[str, Any]: """Reduce one coarse completion request through the TS authority.""" @@ -547,6 +552,8 @@ def reduce_todo_completion_transaction( else None ), } + if defer_no_followup: + params["defer_no_followup"] = True if completion_policy_request is not None: params["completion_policy_request"] = dict(completion_policy_request) result = effect_runtime_result("todo.completion.reduce", params) diff --git a/loopx/control_plane/todos/completion_transaction.ts b/loopx/control_plane/todos/completion_transaction.ts index 830f2636f0..654b04087b 100644 --- a/loopx/control_plane/todos/completion_transaction.ts +++ b/loopx/control_plane/todos/completion_transaction.ts @@ -23,6 +23,7 @@ import { } from "./completion_fence.ts"; import { buildTodoCompletionMetadataUpdates, + normalizeTodoNoFollowup, selectTodoCompletionState, type TodoCompletionRecovery, type TodoCompletionContinuation, @@ -144,6 +145,7 @@ interface DecodedTransactionRequest { projection_source: TodoCompletionProjectionSource; todo: JsonObject; requested_no_followup: boolean; + defer_no_followup: boolean; requested_completion_turn_key: string | null; requested_completion_identity_source: TodoCompletionIdentitySource | null; requested_has_successor: boolean; @@ -263,6 +265,8 @@ function decodeRequest(value: unknown): DecodedTransactionRequest { request.requested_no_followup, "requested_no_followup", ), + defer_no_followup: request.defer_no_followup === undefined ? false : + requireBoolean(request.defer_no_followup, "defer_no_followup"), requested_completion_turn_key: optionalNonEmptyString( request.requested_completion_turn_key, "requested_completion_turn_key", @@ -375,7 +379,7 @@ export function reduceTodoCompletionTransaction( schema_version: TODO_COMPLETION_FENCE_REQUEST_SCHEMA, projection_source: request.projection_source, todo: request.todo, - requested_no_followup: request.requested_no_followup, + requested_no_followup: request.requested_no_followup && !request.defer_no_followup, requested_completion_turn_key: identity.key, requested_completion_identity_source: identity.source, goal_id: request.goal_id, @@ -385,7 +389,7 @@ export function reduceTodoCompletionTransaction( schema_version: TODO_COMPLETION_VALIDATION_PLAN_REQUEST_SCHEMA, projection_source: request.projection_source, todo: request.todo, - requested_no_followup: request.requested_no_followup, + requested_no_followup: request.requested_no_followup && !request.defer_no_followup, requested_completion_turn_key: identity.key, requested_completion_identity_source: identity.source, goal_id: request.goal_id, @@ -483,6 +487,7 @@ export function reduceTodoCompletionTransaction( schema_version: TODO_COMPLETION_STATE_REQUEST_SCHEMA, todo: request.todo, requested_no_followup: request.requested_no_followup, + defer_no_followup: request.defer_no_followup, has_successor: request.requested_has_successor || fenceRequest.todo.successor_todo_ids.length > 0, @@ -495,13 +500,22 @@ export function reduceTodoCompletionTransaction( normalized_status: "done", completion_continuation: completionStateResult.continuation, completion_recovery: completionStateResult.recovery, - no_followup: request.requested_no_followup, + no_followup: request.requested_no_followup && !request.defer_no_followup, successor_todo_ids: null, }); const updates = requireJsonObject( metadataResult.updates, "completion metadata updates", ); + // Keep the original closeout intent while the required checkpoint is pending. + // This is intent, not a terminal recovery or a completed closeout receipt. + if (request.defer_no_followup === true && + (request.requested_no_followup || normalizeTodoNoFollowup(request.todo.no_followup) === true)) { + if (completionStateResult.continuation !== "active_goal") { + throw new EffectRuntimeRequestError("Deferred no_followup cannot introduce a successor"); + } + updates.completion_requested_continuation = "no_followup"; + } // Checkpoint the committed phase, not its clock tick. Persisting this id // lets crash recovery and later reads retain the original hook identity. updates.completion_receipt_id = `tcw_${canonicalAuthoritySha256({ diff --git a/loopx/control_plane/todos/completion_validation.py b/loopx/control_plane/todos/completion_validation.py index 1284b96972..70c009fd9b 100644 --- a/loopx/control_plane/todos/completion_validation.py +++ b/loopx/control_plane/todos/completion_validation.py @@ -1,7 +1,7 @@ from __future__ import annotations import subprocess -from collections.abc import Mapping +from collections.abc import Callable, Mapping from json import loads as json_loads from pathlib import Path from typing import Any, cast @@ -257,6 +257,7 @@ def _run_declared_completion_validation( task_repository: str | None = None, delivery_workspace: Mapping[str, Any] | None = None, validation_workspace_path: Path | None = None, + stdin: int | None = None, ) -> dict[str, Any] | None: """Run a todo's declared caller-approved validation command. @@ -299,6 +300,7 @@ def _run_declared_completion_validation( validation_argv=validation_argv, validation_label=label, timeout_seconds=timeout_seconds, + stdin=stdin, ), ) return cast( @@ -308,6 +310,7 @@ def _run_declared_completion_validation( validation_command=str(validation_command), validation_label=label, timeout_seconds=timeout_seconds, + stdin=stdin, ), ) except subprocess.TimeoutExpired: @@ -363,6 +366,7 @@ def run_declared_completion_validation_effect( goal_id: str, delivery_workspace: Mapping[str, Any] | None = None, validation_workspace_path: Path | None = None, + stdin: int | None = None, ) -> dict[str, Any]: """Execute exactly one TypeScript-authorized validation effect. @@ -414,6 +418,7 @@ def run_declared_completion_validation_effect( ), delivery_workspace=delivery_workspace, validation_workspace_path=validation_workspace_path, + stdin=stdin, ) if receipt is None: raise RuntimeError("authorized validation effect produced no receipt") @@ -495,6 +500,7 @@ def run_completion_validation_gate_with_source( goal_id: str, dry_run: bool, no_followup: bool = False, + defer_no_followup: bool = False, completion_turn_key: str | None = None, completion_identity_source: str | None = None, requested_has_successor: bool = False, @@ -502,6 +508,7 @@ def run_completion_validation_gate_with_source( requested_successor_todo_ids: list[str] | None = None, completion_delivery_workspace: Mapping[str, Any] | None = None, completion_validation_workspace_path: Path | None = None, + admit_validation: Callable[[dict[str, Any]], None] | None = None, ) -> dict[str, Any]: """Run the caller-approved completion validation gate, OUTSIDE the mutation lock. @@ -543,6 +550,7 @@ def run_completion_validation_gate_with_source( projection_source=projection_source, completion_turn_key=completion_turn_key, no_followup=no_followup, + defer_no_followup=defer_no_followup, dry_run=dry_run, completion_identity_source=completion_identity_source, goal_id=goal_id, @@ -556,6 +564,8 @@ def run_completion_validation_gate_with_source( ) completion_validation = None if transaction["decision"] == "execute_validation": + if admit_validation is not None: + admit_validation(transaction) effect = transaction["validation_effect"] completion_validation = run_declared_completion_validation_effect( effect=effect, @@ -571,6 +581,7 @@ def run_completion_validation_gate_with_source( projection_source=projection_source, completion_turn_key=completion_turn_key, no_followup=no_followup, + defer_no_followup=defer_no_followup, dry_run=dry_run, completion_identity_source=completion_identity_source, goal_id=goal_id, diff --git a/loopx/control_plane/todos/completion_validation_plan.ts b/loopx/control_plane/todos/completion_validation_plan.ts index 63fab39b40..9ccba34dc1 100644 --- a/loopx/control_plane/todos/completion_validation_plan.ts +++ b/loopx/control_plane/todos/completion_validation_plan.ts @@ -1,4 +1,5 @@ import type { JsonObject } from "../effect_program.ts"; +import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { requireBoolean, @@ -118,7 +119,11 @@ export function evaluateTodoCompletionValidationPlan( goal_id: request.goal_id, todo_id: request.todo_id, }); - if (fence.outcome === "replay") { + // The typed fence admits only a bound upgrade of an already committed + // RESULT. Closeout reuses that result's validation; it is not new work. + if (fence.outcome === "replay" || + fence.reason === "same_turn_terminal_upgrade" || + fence.reason === "lifecycle_reentry_terminal_upgrade") { return { schema_version: TODO_COMPLETION_VALIDATION_PLAN_RESULT_SCHEMA, effect: "skip", @@ -155,3 +160,58 @@ export function evaluateTodoCompletionValidationPlan( validation_timeout_seconds: declaration.value.validation_timeout_seconds, }; } + + +/** Recover an admitted validation effect, never a new completion authority. */ +export function terminalValidationRecovery(request: JsonObject, receipt: JsonObject): JsonObject { + const input = requireJsonObject(request.terminal_input, "terminal validation request"); + const issued = requireJsonObject(request.execution_result, "issued validation effects"); + if (issued.status !== "execute_validation") throw new EffectRuntimeRequestError("Validation recovery requires native effect admission"); + const transient = new Set(["observed_at", "delivery_context", "validation_source_provider_revision", + "validation_receipt", "goal_acceptance_validation_receipts", "result_validation_binding"]); + const binding = canonicalAuthoritySha256({read_context_id: receipt.read_context_id, + request: Object.fromEntries(Object.entries(input).filter(([key]) => !transient.has(key))), + validation_effect: issued.validation_effect ?? null, + acceptance_effects: issued.goal_acceptance_validation_effects ?? null, + acceptance_source: issued.goal_acceptance_source_binding ?? null}); + const previous = receipt.terminal_validation_effect == null ? null + : requireJsonObject(receipt.terminal_validation_effect, "terminal validation recovery record"); + const reject = (code: string): JsonObject => ({ok: false, error_code: code, + error: "Retain the original validation effect and candidate; reconcile its outcome before retrying."}); + if (previous !== null && (previous.schema_version !== "terminal_validation_effect_v1" || previous.binding_sha256 !== binding)) { + return reject("delivery_validation_binding_mismatch"); + } + if (previous !== null && previous.status !== "pending" && + (!["passed", "failed"].includes(String(previous.status)) || + previous.updates_sha256 !== canonicalAuthoritySha256(previous.updates))) { + return reject("delivery_validation_receipt_corrupt"); + } + if (request.phase === "begin_terminal_validation") { + if (previous?.status === "pending") return reject("delivery_validation_outcome_unknown"); + if (previous?.status === "passed") return {ok: true, effect: "replay", updates: previous.updates}; + return {ok: true, effect: "run", receipt: {...receipt, terminal_validation_effect: { + schema_version: "terminal_validation_effect_v1", binding_sha256: binding, status: "pending"}}}; + } + if (request.phase !== "finish_terminal_validation" || previous?.status !== "pending") { + return reject("delivery_validation_outcome_unknown"); + } + const updates = requireJsonObject(request.updates, "validation outcome updates"); + if (Object.keys(updates).some(key => !["validation_receipt", "goal_acceptance_source_binding", "goal_acceptance_validation_receipts"].includes(key))) { + throw new EffectRuntimeRequestError("Validation outcome contains non-effect fields"); + } + const outcomes: JsonObject[] = []; + if (issued.validation_effect != null) outcomes.push(requireJsonObject(updates.validation_receipt, "caller validation outcome")); + if (issued.goal_acceptance_validation_effects != null) { + if (!Array.isArray(updates.goal_acceptance_validation_receipts)) throw new EffectRuntimeRequestError("Acceptance outcomes missing"); + for (const row of updates.goal_acceptance_validation_receipts) { + outcomes.push(requireJsonObject(requireJsonObject(row, "criterion outcome").receipt, "criterion validation outcome")); + } + } + if (!outcomes.length) throw new EffectRuntimeRequestError("Validation outcome missing"); + // A known failed check remains retryable under the existing completion rule. + // Only an issued, fully successful effect group is recovered without running. + const passed = outcomes.every(outcome => outcome.passed === true); + return {ok: true, receipt: {...receipt, terminal_validation_effect: { + schema_version: "terminal_validation_effect_v1", binding_sha256: binding, + status: passed ? "passed" : "failed", updates, updates_sha256: canonicalAuthoritySha256(updates)}}}; +} diff --git a/loopx/control_plane/todos/completion_validation_store.py b/loopx/control_plane/todos/completion_validation_store.py index a90b384672..1454ac5bf9 100644 --- a/loopx/control_plane/todos/completion_validation_store.py +++ b/loopx/control_plane/todos/completion_validation_store.py @@ -2,15 +2,16 @@ from __future__ import annotations -import os import re from collections.abc import Iterable, Mapping from pathlib import Path from typing import Any -from ...registry import atomic_write_json, read_json +from ...registry import read_json from ...file_lock import exclusive_cross_runtime_file_lock -from ..runtime.document_io import fsync_state_directory +from ..runtime.document_io import atomic_write_private_json as atomic_write_json, fsync_state_directory +from ..runtime.file_paths import windows_extended_path +from ..runtime.private_permissions import protect_private_path from .completion_validation_projection import ( completion_validation_declaration, completion_validation_declaration_sha256, @@ -31,7 +32,7 @@ def _require_public_id(value: str, label: str) -> str: def completion_validation_declaration_path( *, runtime_root: Path, goal_id: str, todo_id: str ) -> Path: - return ( + return windows_extended_path( runtime_root.expanduser().resolve(strict=False) / "goals" / _require_public_id(goal_id, "goal_id") @@ -67,9 +68,7 @@ def persist_completion_validation_declaration( "declaration_sha256": digest, "declaration": normalized, }, - preserve_mode=True, ) - os.chmod(path, 0o600) return digest @@ -96,6 +95,8 @@ def prepare_completion_validation_declaration( raise ValueError("prepared validation declaration digest mismatch") else: atomic_write_json(path, payload) + protect_private_path(path.parent, directory=True) + protect_private_path(path) # Also re-establish directory durability on an idempotent retry. fsync_state_directory(path) return digest diff --git a/loopx/control_plane/todos/contract.py b/loopx/control_plane/todos/contract.py index f51e7c6dbc..9cad964cb1 100644 --- a/loopx/control_plane/todos/contract.py +++ b/loopx/control_plane/todos/contract.py @@ -1012,6 +1012,11 @@ def _metadata_value_is_present(value: Any) -> bool: "no_followup" ), ), + _TodoMetadataField( + "completion_requested_continuation", + normalize_todo_completion_continuation, + invalid_message="completion_requested_continuation must be active_goal, successor or no_followup", + ), _TodoMetadataField( "completion_recovery", normalize_todo_completion_recovery, @@ -1209,6 +1214,7 @@ def format_todo_metadata_line( unblocks_todo_id: str | None = None, successor_todo_ids: Any = None, completion_continuation: str | None = None, + completion_requested_continuation: str | None = None, completion_recovery: str | None = None, completion_receipt_id: str | None = None, replan_obligation_id: str | None = None, @@ -1276,7 +1282,7 @@ def todo_block_metadata(block: dict[str, Any]) -> dict[str, Any]: normalized = normalize_todo_decision_scope_outcomes(value) elif key == "no_followup": normalized = normalize_todo_no_followup(value) - elif key == "completion_continuation": + elif key in {"completion_continuation", "completion_requested_continuation"}: normalized = normalize_todo_completion_continuation(value) elif key == "completion_recovery": normalized = normalize_todo_completion_recovery(value) @@ -1378,8 +1384,10 @@ def metadata_line_for_todo_block( metadata[key] = normalized else: metadata.pop(key, None) - elif key in {"completion_continuation", "completion_recovery"}: - normalized_completion = require_todo_completion_metadata(key, value) + elif key in {"completion_continuation", "completion_requested_continuation", "completion_recovery"}: + normalized_completion = require_todo_completion_metadata( + "completion_continuation" if key == "completion_requested_continuation" else key, value + ) if normalized_completion: metadata[key] = normalized_completion else: diff --git a/loopx/control_plane/todos/field_update.ts b/loopx/control_plane/todos/field_update.ts index d4f4c3edc6..625f78d03f 100644 --- a/loopx/control_plane/todos/field_update.ts +++ b/loopx/control_plane/todos/field_update.ts @@ -9,6 +9,7 @@ import { normalizeTodoAgent, stripPythonWhitespace } from "../coordination/todo_ import { AuthorityStoreProtocolError } from "../coordination/authority_store_codec.ts"; import { buildTodoCompletionMetadataUpdates, + requireTodoCompletionMetadata, TODO_COMPLETION_STATE_REQUEST_SCHEMA, } from "./completion_state.ts"; import { validateLegacyContinuationPolicyRepair } from "./legacy_continuation_policy_migration.ts"; @@ -124,9 +125,12 @@ function completionUpdates(block: JsonObject, intent: JsonObject, targetStatus: if (present(intent.completion_metadata_updates_override)) { const updates = requireJsonObject(intent.completion_metadata_updates_override, "completion metadata override"); if (Object.entries(updates).some(([key, value]) => - !["completion_continuation", "completion_recovery", "completion_receipt_id"].includes(key) || typeof value !== "string")) { + !["completion_continuation", "completion_requested_continuation", "completion_recovery", "completion_receipt_id"].includes(key) || typeof value !== "string")) { throw new EffectRuntimeRequestError("TypeScript Todo completion metadata updates shape mismatch"); } + if (updates.completion_requested_continuation !== undefined) { + requireTodoCompletionMetadata("completion_continuation", updates.completion_requested_continuation); + } if (updates.completion_receipt_id !== undefined && !/^tcw_[0-9a-f]{64}$/u.test(String(updates.completion_receipt_id))) { throw new EffectRuntimeRequestError("completion_receipt_id is invalid"); @@ -237,6 +241,7 @@ export function planTodoFieldUpdate(value: unknown): TodoFieldUpdatePlan { // state. Historical operation receipts retain the original completion. updates.no_followup = null; updates.completion_continuation = null; + updates.completion_requested_continuation = null; updates.completion_recovery = null; updates.completion_turn_key = null; } diff --git a/loopx/control_plane/todos/mutation_api.py b/loopx/control_plane/todos/mutation_api.py index af75eccbd2..0a246c7728 100644 --- a/loopx/control_plane/todos/mutation_api.py +++ b/loopx/control_plane/todos/mutation_api.py @@ -448,6 +448,8 @@ def complete_goal_todo( completion_result_file: Path | None = None, completion_turn_key: str | None = None, completion_identity_source: str | None = None, + delivery_read_context_id: str | None = None, + delivery_settlement_identity: Mapping[str, Any] | None = None, terminal_review_basis: Mapping[str, Any] | None = None, expected_todo_text_sha256: str | None = None, require_captured_todo_text: bool = False, @@ -458,6 +460,7 @@ def complete_goal_todo( claim_operation_id: str | None = None, note: str | None = None, no_followup: bool = False, + defer_no_followup: bool = False, successor_todo_ids: list[str] | None = None, claimed_by: str | None = None, clear_claim: bool = False, @@ -482,6 +485,8 @@ def complete_goal_todo( def supersede_goal_todo( *, + completion_turn_key: str | None = None, + completion_identity_source: str | None = None, registry_path: Path, goal_id: str, runtime_root_arg: str | None = None, diff --git a/loopx/control_plane/todos/mutation_response.py b/loopx/control_plane/todos/mutation_response.py index 2875c62cc1..77faa6f236 100644 --- a/loopx/control_plane/todos/mutation_response.py +++ b/loopx/control_plane/todos/mutation_response.py @@ -157,6 +157,9 @@ def serialize_todo_update_result( "completion_continuation": normalize_todo_completion_continuation( effective_metadata.get("completion_continuation") ), + "completion_requested_continuation": normalize_todo_completion_continuation( + effective_metadata.get("completion_requested_continuation") + ), "completion_recovery": normalize_todo_completion_recovery( effective_metadata.get("completion_recovery") ), diff --git a/loopx/control_plane/todos/projection_document.py b/loopx/control_plane/todos/projection_document.py index d8c7e17efb..40c09ebc4e 100644 --- a/loopx/control_plane/todos/projection_document.py +++ b/loopx/control_plane/todos/projection_document.py @@ -6,10 +6,11 @@ from .machine_region import TodoRegion, find_todo_regions, visible_markdown_lines -def recovered_todo_projection_skeleton(goal_id: str) -> str: - """The display recovery codec; it does not restore missing Goal intent.""" +def _recovered_todo_projection_skeleton(goal_id: str, *, marked: bool) -> str: return ( - f"---\ngoal_id: {json.dumps(goal_id, ensure_ascii=False)}\n---\n\n" + f"---\ngoal_id: {json.dumps(goal_id, ensure_ascii=False)}\n" + + ('goal_source_completeness: "todo_projection_only"\n' if marked else '') + + "---\n\n" "# Recovered Todo projection\n\n" "> Regenerated from canonical Todo authority. Non-Todo sections " "are not in this provider snapshot and were not recovered. " @@ -18,6 +19,22 @@ def recovered_todo_projection_skeleton(goal_id: str) -> str: ) +def recovered_todo_projection_skeleton(goal_id: str) -> str: + """The display recovery codec; it does not restore missing Goal intent.""" + return _recovered_todo_projection_skeleton(goal_id, marked=True) + + +def is_legacy_todo_projection_skeleton(markdown: str, goal_id: str) -> bool: + """Recognize only the exact pre-marker codec, ignoring replaceable Todos. + + This compatibility decoder never classifies arbitrary Goal prose by words + or headings. Additional owner narrative no longer matches this old codec. + """ + expected = _recovered_todo_projection_skeleton(goal_id, marked=False) + return TodoProjectionDocument.parse(markdown).narrative.strip() == \ + TodoProjectionDocument.parse(expected).narrative.strip() + + @dataclass(frozen=True) class TodoProjectionDocument: markdown: str diff --git a/loopx/control_plane/todos/provider_terminal_lifecycle.py b/loopx/control_plane/todos/provider_terminal_lifecycle.py index 1b8c7ff7a9..83b70ec3f3 100644 --- a/loopx/control_plane/todos/provider_terminal_lifecycle.py +++ b/loopx/control_plane/todos/provider_terminal_lifecycle.py @@ -39,7 +39,7 @@ from .path_resolution import resolve_todo_state_path from .provider_projection import projection_delivery_requires_ack, settle_canonical_todo_projection from .successor_derivation import build_successor_intents -from .completion_result import read_completion_result, store_completion_result +from .completion_result import store_completion_result, resolve_terminal_result_descriptor _TERMINAL_REQUEST_SCHEMA = "loopx_local_coordination_todo_terminal_lifecycle_request_v3" _ARCHIVE_REQUEST_SCHEMA = "loopx_local_coordination_todo_archive_request_v0" @@ -121,13 +121,13 @@ def route_terminal_call(command: str, call: Mapping[str, Any]) -> dict[str, Any] completion_result_file=call.get("completion_result_file") if complete else None, note=call.get("note") if complete else "superseded", reason=None if complete else call.get("reason"), - completion_turn_key=call.get("completion_turn_key") if complete else None, - completion_identity_source=( - call.get("completion_identity_source") if complete else None - ), + completion_turn_key=call.get("completion_turn_key"), + completion_identity_source=call.get("completion_identity_source"), review_basis=call.get("terminal_review_basis"), expected_todo_text_sha256=call.get("expected_todo_text_sha256"), require_captured_todo_text=call.get("require_captured_todo_text") is True, + delivery_read_context_id=call.get("delivery_read_context_id"), + delivery_settlement_identity=call.get("delivery_settlement_identity"), completion_delivery_workspace=( call.get("completion_delivery_workspace") if complete else None ), @@ -142,6 +142,7 @@ def route_terminal_call(command: str, call: Mapping[str, Any]) -> dict[str, Any] optional=True, ), no_followup=bool(call.get("no_followup")) if complete else False, + defer_no_followup=bool(call.get("defer_no_followup")) if complete else False, successor_todo_ids=require_completion_successor_todo_ids(call.get("successor_todo_ids")), claimed_by=call.get("claimed_by") if complete else None, clear_claim=bool(call.get("clear_claim")) if complete else False, @@ -241,6 +242,40 @@ def _persist_validated_completion_result( raise ValueError("completion result changed during acceptance validation") +def _terminal_validation_updates( + result: Mapping[str, Any], + *, + request: dict[str, Any], + runtime_root: Path, + registry_path: Path, + goal_id: str, + delivery_workspace: Mapping[str, Any] | None, + validation_workspace_path: Path | None, +) -> tuple[dict[str, Any], bool]: + context = request.get("delivery_context") + recovery = None + if isinstance(context, Mapping) and context.get("read_context_id"): + from ..goals.checkpoint_context_io import terminal_validation_effect_io + + recovery = terminal_validation_effect_io( + runtime_root=runtime_root, request=request, context=context, execution_result=result, + ) + if recovery is not None and recovery["effect"] == "replay": + updates = recovery["updates"] + else: + updates = execute_completion_validation_effects( + result, registry_path=registry_path, goal_id=goal_id, + delivery_workspace=delivery_workspace, + validation_workspace_path=validation_workspace_path, + ) + if recovery is not None: + terminal_validation_effect_io( + runtime_root=runtime_root, request=request, context=context, + execution_result=result, updates=updates, + ) + return updates, recovery is None or recovery["effect"] == "run" + + def terminal_canonical_todo_if_promoted( *, registry_path: Path, @@ -282,6 +317,9 @@ def terminal_canonical_todo_if_promoted( review_basis: Mapping[str, Any] | None = None, expected_todo_text_sha256: str | None = None, require_captured_todo_text: bool = False, + delivery_read_context_id: str | None = None, + delivery_settlement_identity: Mapping[str, Any] | None = None, + defer_no_followup: bool = False, project: Path | None = None, state_file: Path | None = None, ) -> dict[str, Any] | None: @@ -312,22 +350,8 @@ def terminal_canonical_todo_if_promoted( # The canonical transaction owns missing/role/archive lifecycle decisions. # Keep only the optional local validation facts needed by the host adapter. target = _todo_by_id(canonical["todos"], todo_id) or {} - result_descriptor = None - if completion_result_file is not None: - try: - result_descriptor = store_completion_result( - source=completion_result_file, runtime_root=runtime_root, - goal_id=goal_id, persist=False, - ) - except FileNotFoundError: - if target.get("status") != "done": - raise - bound = read_completion_result( - registry_path=registry_path, runtime_root=runtime_root, - goal_id=goal_id, todo_id=todo_id, - )["result"] - result_descriptor = {key: bound[key] for key in - ("provider", "sha256", "size_bytes", "content_type")} + result_descriptor = resolve_terminal_result_descriptor(completion_result_file, + target=target, runtime_root=runtime_root, registry_path=registry_path, goal_id=goal_id, todo_id=todo_id) with authority_registry_source(registry_path) as registry_source: registered, grants = todo_lifecycle_facts(registry_path, goal_id) successor_intents = build_successor_intents( @@ -397,6 +421,7 @@ def terminal_canonical_todo_if_promoted( "lease_expected_version": task_lease_expected_version, "allow_user_gate_auto_acquire": command == "complete", "requested_no_followup": no_followup, + **({"defer_no_followup": True} if defer_no_followup else {}), "requested_completion_turn_key": completion_turn_key, "requested_completion_identity_source": completion_identity_source, "linked_successor_todo_ids": successor_todo_ids, @@ -413,10 +438,40 @@ def terminal_canonical_todo_if_promoted( "dry_run": dry_run, "observed_at": now_local(), } - result = effect_runtime_result( - "coordination.local_authority.todo_terminal", request, - timeout=CANONICAL_AUTHORITY_WRITE_TIMEOUT_SECONDS, - ) + def invoke() -> Any: + nonlocal delivery_read_context_id + if delivery_read_context_id is not None and delivery_settlement_identity is None: + raise ValueError("delivery read context requires the original settlement identity") + if delivery_settlement_identity is not None: + from ..goals.checkpoint_context_io import delivery_result_context_input + from ..quota.settlement import SettlementIdentity + + if state_file is None: + raise ValueError("delivery freshness requires the registered state file") + context = delivery_result_context_input( + runtime_root=runtime_root, registry_path=registry_path, state_file=state_file, + identity=SettlementIdentity.from_runtime_payload(delivery_settlement_identity), + read_context_id=delivery_read_context_id, + ) + if context is not None: + request["delivery_context"] = context + # Freeze the actual work key before native validation. Recapture + # source facts on retries, never a newly issued decision key. + if delivery_read_context_id is None: + delivery_read_context_id = context.get("read_context_id") + for capture_attempt in range(3): + outcome = effect_runtime_result("coordination.local_authority.todo_terminal", request, + timeout=CANONICAL_AUTHORITY_WRITE_TIMEOUT_SECONDS) + if not isinstance(outcome, Mapping) or outcome.get("reason_code") != "delivery_source_capture_changed" or capture_attempt == 2: + return outcome + # Recapture IO, retaining the original Agent decision and read ID. + request["delivery_context"] = delivery_result_context_input( + runtime_root=runtime_root, registry_path=registry_path, state_file=state_file, + identity=SettlementIdentity.from_runtime_payload(delivery_settlement_identity), + read_context_id=delivery_read_context_id, + ) + + result = invoke() if isinstance(result, Mapping) and result.get("status") == "resolve_validation": # Admission and receipt recovery precede host-local declaration IO. # Resolving private argv grants no authority to run it; re-enter the @@ -429,29 +484,31 @@ def terminal_canonical_todo_if_promoted( registry_path=registry_path, goal_id=goal_id, todo_id=todo_id, role=role, persist_if_resolved=not dry_run, ) - result = effect_runtime_result( - "coordination.local_authority.todo_terminal", request, - timeout=CANONICAL_AUTHORITY_WRITE_TIMEOUT_SECONDS, - ) + result = invoke() completion_validation_executed = False if isinstance(result, Mapping) and result.get("status") == "execute_validation": request["validation_source_provider_revision"] = result["provider_revision"] - request.update(execute_completion_validation_effects( - result, registry_path=registry_path, goal_id=goal_id, + updates, completion_validation_executed = _terminal_validation_updates( + result, request=request, runtime_root=runtime_root, + registry_path=registry_path, goal_id=goal_id, delivery_workspace=completion_delivery_workspace, validation_workspace_path=completion_validation_workspace_path, - )) + ) + context = request.get("delivery_context") + request.update(updates) + if isinstance(context, Mapping) and context.get("read_context_id"): + from ..goals.checkpoint_context_io import bind_terminal_result_validation + + request["result_validation_binding"] = bind_terminal_result_validation( + runtime_root=runtime_root, request=request, context=context, + ) if completion_result_file is not None and not dry_run: _persist_validated_completion_result( request=request, source=completion_result_file, runtime_root=runtime_root, goal_id=goal_id, expected_descriptor=result_descriptor, ) - completion_validation_executed = True request["observed_at"] = now_local() - result = effect_runtime_result( - "coordination.local_authority.todo_terminal", request, - timeout=CANONICAL_AUTHORITY_WRITE_TIMEOUT_SECONDS, - ) + result = invoke() if not isinstance(result, Mapping): raise LocalCoordinationAuthorityUnavailable( "canonical Todo terminal transaction returned an invalid result", @@ -470,6 +527,11 @@ def terminal_canonical_todo_if_promoted( payload.get("status") == "failed" and payload.get("failure_kind") == "decision_rejection" ): + basis = payload.get("delivery_read_context") + if isinstance(basis, Mapping) and basis.get("error_code") == "checkpoint_read_context_stale": + from ..goals.checkpoint_context_io import CheckpointReadContextRejected + + raise CheckpointReadContextRejected(dict(basis)) raise LocalCoordinationAuthorityRejection( str(payload.get("reason") or "canonical Todo terminal request was rejected"), code=str(payload.get("reason_code") or "todo_terminal_rejected"), diff --git a/loopx/control_plane/todos/read_legacy_codec.json b/loopx/control_plane/todos/read_legacy_codec.json index 3bea730d27..f69d31ca80 100644 --- a/loopx/control_plane/todos/read_legacy_codec.json +++ b/loopx/control_plane/todos/read_legacy_codec.json @@ -100,7 +100,8 @@ "resume_when", "no_followup", "successor_todo_ids", - "completion_continuation" + "completion_continuation", + "completion_requested_continuation" ], "list_constants": { "_ITEM_FIELDS": [ @@ -132,6 +133,7 @@ "unblocks_todo_id", "successor_todo_ids", "completion_continuation", + "completion_requested_continuation", "completion_recovery", "resume_when", "resume_monitor_generation", @@ -214,6 +216,7 @@ "unblocks_todo_id", "successor_todo_ids", "completion_continuation", + "completion_requested_continuation", "completion_recovery", "superseded_by", "resume_when", diff --git a/loopx/control_plane/turn_driver/checkpoint_continuation.py b/loopx/control_plane/turn_driver/checkpoint_continuation.py new file mode 100644 index 0000000000..944b53ed5a --- /dev/null +++ b/loopx/control_plane/turn_driver/checkpoint_continuation.py @@ -0,0 +1,112 @@ +"""Current-Agent IO for the existing Goal checkpoint and Turn receipt owners.""" +from __future__ import annotations + +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Any + +from ...file_lock import exclusive_file_lock, exclusive_cross_runtime_file_lock +from ...registry import atomic_write_json +from ..goals.checkpoint_context_io import ( + _checkpoint_effect, _receipt_path, _read_context_receipt, _resolve, + _local_source_facts, _source_guard, _inspect_attempt, CheckpointReadContextRejected, + read_checkpoint_context, checkpoint_continuation_required, +) +from ..quota.settlement import SettlementIdentity, read_heartbeat_settlement +from .host_failure import BuiltInHostError, build_host_failure_record + + +class CurrentAgentCheckpoint: + def __init__(self, registry: Path, root: Path, state: Path, identity: SettlementIdentity, + runner: Callable[[dict[str, Any]], dict[str, Any]], *, explicit_resume: bool = False): + self.registry, self.root, self.state, self.identity, self.runner = registry, root, state, identity, runner + self.explicit_resume = explicit_resume + self._enabled: bool | None = None + + def enabled(self) -> bool: + if self._enabled is not None: + return self._enabled + readback = read_heartbeat_settlement(self.root, goal_id=self.identity.goal_id, + agent_id=self.identity.agent_id, todo_id=self.identity.todo_id, + turn_instance_id=self.identity.turn_instance_id, + replan_obligation_id=self.identity.replan_obligation_id, registry_path=self.registry) + self._enabled = readback is not None and checkpoint_continuation_required(readback) + return self._enabled + + def _save(self, path: Path, receipt: dict[str, Any]) -> None: + with exclusive_cross_runtime_file_lock(self.root / 'goals' / self.identity.goal_id / 'runs/index.jsonl', + operation='checkpoint-direction-binding'): + with _source_guard(self.root, self.identity.goal_id, self.state, self.registry): + current = _read_context_receipt(path) + if current.get('read_context_id') != receipt['read_context_id']: + raise ValueError('Direction input was replaced while the current Agent was judging') + atomic_write_json(path, receipt) + + def continue_direction(self, result: Mapping[str, Any], *, purpose: str = 'first_delivery') -> dict[str, Any]: + if not self.enabled(): + return dict(result) + path = _receipt_path(self.root, self.identity) + with exclusive_file_lock(path): + receipt = _read_context_receipt(path) if path.exists() else None + if receipt is not None: + with exclusive_cross_runtime_file_lock(self.root / 'goals' / self.identity.goal_id / 'runs/index.jsonl', + operation='checkpoint-direction-readback'): + with _source_guard(self.root, self.identity.goal_id, self.state, self.registry): + attempt = _inspect_attempt(self.root, self.identity) + if attempt.get('status') != 'committed': + if attempt.get('status') == 'absent': + # Native readback cleared a proved-absent append. + # Consume its persisted receipt, not the old marker. + receipt = _read_context_receipt(path) + try: + _resolve(self.root, phase='check', identity=self.identity.as_dict(), + purpose=purpose, checkpoint_continuation=True, + read_context_id=receipt['read_context_id'], receipt=receipt, + facts=_local_source_facts(self.root, self.registry, self.state, self.identity)) + except CheckpointReadContextRejected as error: + if error.code not in {'checkpoint_read_context_stale', 'checkpoint_read_context_unknown_or_replaced'}: + raise + receipt = None + # Keep the actual input/decision binding on retry. The commit owner + # still resolves uncertainty and rechecks current sources. + if receipt is None or receipt.get('purpose', 'supplement_checkpoint') != purpose: + context = read_checkpoint_context(registry_path=self.registry, runtime_root_override=str(self.root), + goal_id=self.identity.goal_id, agent_id=self.identity.agent_id, todo_id=self.identity.todo_id, + replan_obligation_id=self.identity.replan_obligation_id, turn_instance_id=self.identity.turn_instance_id, + purpose=purpose) + receipt = _read_context_receipt(path) + else: + context = {'read_context_id': receipt['read_context_id'], 'basis': receipt['decision_basis'], + 'settlement_identity': self.identity.as_dict(), 'purpose': purpose, + 'vision_authoring': receipt['vision_authoring'], 'original_outcome': receipt.get('original_outcome')} + decision = receipt.get('current_agent_decision') + if decision is None: + dispatch = _checkpoint_effect('goal.checkpoint_read_context.work_binding', { + 'phase': 'direction_dispatch', 'receipt': receipt, 'explicit_resume': self.explicit_resume}) + if dispatch.get('ok') is not True: + raise CheckpointReadContextRejected(dispatch) + receipt['direction_host_attempt_count'] = dispatch['attempt'] + self._save(path, receipt) + try: + decision = self.runner(context) + except BuiltInHostError as error: + receipt['direction_host_failure'] = build_host_failure_record(error.failure_kind, + attempt=dispatch['attempt']) + self._save(path, receipt) + raise ValueError(error.reason) from error + bound = _checkpoint_effect('goal.checkpoint_read_context.work_binding', { + 'phase': 'direction_return', 'identity': self.identity.as_dict(), 'receipt': receipt, 'decision': decision}) + if bound.get('ok') is not True: + raise ValueError(bound.get('error', 'Direction response binding rejected')) + receipt = bound['receipt'] + self._save(path, receipt) + if bound['stopped']: + raise ValueError('Current Agent stopped direction continuation; result retained') + else: + bound = _checkpoint_effect('goal.checkpoint_read_context.work_binding', { + 'phase': 'direction_response', 'identity': self.identity.as_dict(), 'receipt': receipt, 'decision': decision}) + if bound.get('binding') != receipt.get('current_agent_binding'): + raise ValueError('Original direction response binding is unavailable') + return {**result, 'agent_vision': decision.get('agent_vision'), + 'vision_unchanged_reason': decision.get('vision_unchanged_reason'), + 'checkpoint_read_context_id': context['read_context_id']} diff --git a/loopx/control_plane/turn_driver/codex_cli.py b/loopx/control_plane/turn_driver/codex_cli.py index b36c8d2437..9807f0c566 100644 --- a/loopx/control_plane/turn_driver/codex_cli.py +++ b/loopx/control_plane/turn_driver/codex_cli.py @@ -41,6 +41,7 @@ from ..capability_hooks import capability_result_fields from .execution_profile import require_supported_reasoning_effort from .host_failure import BuiltInHostError +from .host_candidate import render_checkpoint_prompt from .host_process_transport import HostOutputLines, run_host_process from .transaction import LOOPX_TURN_RESULT_SCHEMA_VERSION, TRANSACTION_PHASES @@ -195,6 +196,13 @@ def _has_subagent_topology(request: Mapping[str, Any] | None) -> bool: def codex_cli_result_schema( request: Mapping[str, Any] | None = None, ) -> dict[str, Any]: + if request and request.get("phase") == "checkpoint_direction": + return {"type": "object", "additionalProperties": False, "properties": { + "read_context_id": {"type": "string"}, + "agent_vision_json": {"type": "string"}, + "vision_unchanged_reason": {"type": "string", "maxLength": 240}, + "stop_reason": {"type": "string", "maxLength": 240}, + }, "required": ["read_context_id", "agent_vision_json", "vision_unchanged_reason", "stop_reason"]} text_limits = dict(HOST_RESULT_TEXT_LIMITS) properties: dict[str, Any] = { "schema_version": { @@ -324,6 +332,8 @@ def _prompt(request: Mapping[str, Any]) -> str: request_json = json.dumps( model_request, ensure_ascii=False, sort_keys=True, separators=(",", ":") ) + if request.get("phase") == "checkpoint_direction": + return render_checkpoint_prompt(request) instructions = [ "Execute exactly one bounded LoopX Turn in the current workspace.", ("Use turn_authority as the verified authority for this Turn. " if staged else "Use the TurnEnvelope as the source of truth. ") @@ -340,6 +350,10 @@ def _prompt(request: Mapping[str, Any]) -> str: if staged: instructions.append(replan_prompt_instruction(authority)) instructions.extend(request.get("capability_instructions") or ()) + if request.get("delivery_result_context"): + instructions.append("Use delivery_result_context.basis as the requirements and dependencies read for this candidate. The adapter carries its original key with validation and submission.") + if request.get("result_revalidation"): + instructions.append("This is revalidation of a retained candidate on the original Turn. Compare it with the new basis, preserve valid artifacts, and implement only changes demanded by the new requirements before returning a fresh result.") boundary = _mapping(_mapping(request.get("turn_envelope")).get("boundary")) if boundary.get("checkpointed_boundary_authority"): instructions.append( diff --git a/loopx/control_plane/turn_driver/codex_operation_host.py b/loopx/control_plane/turn_driver/codex_operation_host.py index 1869ee1635..cd551c1687 100644 --- a/loopx/control_plane/turn_driver/codex_operation_host.py +++ b/loopx/control_plane/turn_driver/codex_operation_host.py @@ -133,7 +133,7 @@ def run_codex_operation_host( model=model, reasoning_effort=reasoning_effort, resume_thread_id=binding["session_id"] if binding else None, - dynamic_tools=[operations.OPERATION_TOOL], + dynamic_tools=[] if request.get("phase") == "checkpoint_direction" else [operations.OPERATION_TOOL], host_config=host_config, launch_guard=lambda: launch_scope("codex_operation_process_launch"), response_timeout_sec=30 if timeout_seconds is None else min(timeout_seconds, 30), @@ -142,7 +142,7 @@ def run_codex_operation_host( ) selection.store(session.thread_id) - session.bound_tool_handler = operations.operation_tool_handler( + session.bound_tool_handler = None if request.get("phase") == "checkpoint_direction" else operations.operation_tool_handler( runtime_root=runtime_root, registry_path=registry_path, lineage=lineage, @@ -163,7 +163,7 @@ def run_codex_operation_host( operations.operation_continuations( runtime_root=runtime_root, registry_path=registry_path, lineage=lineage, route=route, goal_ref=request.get("goal_ref"), - ) if (runtime_root / "chat" / "actions" / "actions.json").is_file() else [] + ) if request.get("phase") != "checkpoint_direction" and (runtime_root / "chat" / "actions" / "actions.json").is_file() else [] ) def on_event(kind: str, event: dict[str, Any]) -> None: @@ -183,7 +183,8 @@ def on_event(kind: str, event: dict[str, Any]) -> None: recovery_kind="resume_session", ) from exc - child_observer = native_child_observer(request, runtime_root=runtime_root, lineage=lineage, + child_observer = None if request.get("phase") == "checkpoint_direction" else native_child_observer( + request, runtime_root=runtime_root, lineage=lineage, registry_path=goal_admission.registry_path if goal_admission is not None else None) def observe_child(item: Mapping[str, Any]) -> None: @@ -198,9 +199,11 @@ def record_child() -> None: else: goal_admission.accept_result(record_child) - result = session.send( - _prompt(request) - + "\nUse loopx_operation for context/pending/prepare/inspect/consume/report. " + operation_prompt = ( + "" + if request.get("phase") == "checkpoint_direction" + else ( + "\nUse loopx_operation for context/pending/prepare/inspect/consume/report. " "Source conversations are not executor identity. context/pending/inspect do not require consumption. " "When the admitted task authorizes proposal preparation and supplies its terms, prepare may run before human confirmation or consume; " "prepare writes a canonical proposal, not a domain/external effect. execution_allowed=false is expected for prepare, not a reason to refuse it. " @@ -212,7 +215,11 @@ def record_child() -> None: + ("\nCanonical confirmed operation continuations for this exact binding: " + json.dumps(continuations, sort_keys=True) + ". Inspect these locators through loopx_operation and consume once before any effect." - if continuations else ""), + if continuations else "") + ) + ) + result = session.send( + _prompt(request) + operation_prompt, output_schema=codex_cli_result_schema(request), on_event=on_event, turn_start_guard=lambda: launch_scope("codex_operation_turn_start"), diff --git a/loopx/control_plane/turn_driver/codex_sessions.py b/loopx/control_plane/turn_driver/codex_sessions.py index 741a2df4a2..0bcd54594b 100644 --- a/loopx/control_plane/turn_driver/codex_sessions.py +++ b/loopx/control_plane/turn_driver/codex_sessions.py @@ -13,6 +13,7 @@ from ...boundary_authority import normalize_checkpointed_boundary_authority_entries from ...file_lock import exclusive_file_lock +from ..runtime.private_permissions import protect_private_path from ...runtime import validate_goal_id_path_segment from ..content_digest import BARE_SHA256_PATTERN from ..projects.registry_codec import ( @@ -172,6 +173,7 @@ def codex_cli_session_binding( goal_admission: FirstPartyHostGoalAdmission | None = None, session_scope: str = "todo", host: str = "codex-cli", + exact_session: bool = False, ) -> dict[str, str] | None: lineage = _lineage({"turn_envelope": dict(turn_envelope)}, session_scope=session_scope) session = select_codex_cli_session( @@ -186,6 +188,9 @@ def codex_cli_session_binding( return { "schema_version": "loopx_turn_session_binding_v0", **lineage, + **({"session_sha256": hashlib.sha256(json.dumps({key: session.get(key) for key in ( + "session_id", "session_profile_digest", "operation_profile_digest", "operation_transport")}, + sort_keys=True).encode()).hexdigest()} if exact_session else {}), } @@ -409,13 +414,13 @@ def _write_codex_cli_session( raise ValueError("Codex CLI returned an invalid session id") path = _session_path(runtime_root, lineage, session_scope) path.parent.mkdir(parents=True, exist_ok=True) + protect_private_path(path.parent, directory=True) descriptor, temporary_name = tempfile.mkstemp( prefix=f".{path.name}.", dir=path.parent ) temporary = Path(temporary_name) try: - if hasattr(os, "fchmod"): - os.fchmod(descriptor, 0o600) + protect_private_path(temporary) handle = os.fdopen(descriptor, "w", encoding="utf-8") descriptor = -1 with handle: @@ -459,7 +464,6 @@ def _write_codex_cli_session( handle.flush() os.fsync(handle.fileno()) os.replace(temporary, path) - path.chmod(0o600) finally: if descriptor >= 0: os.close(descriptor) diff --git a/loopx/control_plane/turn_driver/command_validation.py b/loopx/control_plane/turn_driver/command_validation.py index 1a765025c4..87a16a5087 100644 --- a/loopx/control_plane/turn_driver/command_validation.py +++ b/loopx/control_plane/turn_driver/command_validation.py @@ -121,7 +121,9 @@ def validate( completed = subprocess.run( normalized, cwd=project, - input=json.dumps(result, ensure_ascii=False, separators=(",", ":")), + input=json.dumps({**result, **({"delivery_result_context": _plan["delivery_result_context"]} + if _plan.get("delivery_result_context") is not None else {})}, + ensure_ascii=False, separators=(",", ":")), text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index ad0f412862..d7abfa7011 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -23,6 +23,7 @@ FirstPartyHostRuntimeRejected, ) from ..goals.goal_vision import normalize_goal_vision_packet +from ..goals.checkpoint_context_io import CheckpointReadContextRejected, TurnResultWork from ..work_items.delivery_batch_scale import require_delivery_batch_scale from ..work_items.delivery_outcome import ( explain_turn_scoped_settlement_gap, @@ -54,11 +55,12 @@ ) from .lane_fence import single_executor_per_turn_lane from .recovery import ( - assess_existing_turn_recovery, + preview_turn_recovery, prepare_terminal_host_recovery, reset_failed_turn_for_retry, + recover_existing_turn, assess_existing_turn_recovery, build_turn_recovery_audit, require_turn_recovery_continuation, ) -from .post_settlement import PostSettlement, recover_post_settlement, run_post_settlement_callback +from .post_settlement import PostSettlement, run_post_settlement_callback from ..capability_hooks import capability_result_fields, project_turn_context from ..reward_memory import reward_memory_automation_enabled from .session_recovery import ( @@ -807,6 +809,27 @@ def _host_failure( } +def _retain_result_for_revalidation(plan: Mapping[str, Any], journal: dict[str, Any], + result: Mapping[str, Any], error: CheckpointReadContextRejected) -> None: + terminal = len(journal.get("completed_phases") or []) == 5 and journal.get("quota_spend") is not None + phases = list(journal["completed_phases"] if terminal else TRANSACTION_PHASES[:2]) + kind = LoopXTurnResultKind.TERMINAL_CLOSEOUT_FAILED if terminal else LoopXTurnResultKind.VALIDATION_FAILED + failure = _host_failure(plan, kind=kind, + completed_phases=phases, failed_phase="terminal_closeout" if terminal else "validation", reason=str(error)) + prior_retry = (journal.get("result_revalidation") or {}) if terminal else {} + journal.update(status="failed", receipt=failure["receipt"], reason=str(error), + result_kind=kind.value, + validation_stage="result_freshness", completed_phases=phases, + result_revalidation={"candidate": prior_retry.get("candidate", journal.get("host_result", result)), + "original_context": prior_retry.get("original_context", journal.get("result_work_context")), + "rejection": error.payload["checkpoint_read_context"], + **({"phase": "terminal_closeout", "validation": {}, "reread_required": True} if terminal else {}), + "changed_components": error.payload["checkpoint_read_context"].get("changed_components", []), + "instruction": "Recheck this retained candidate against the new work basis. Reuse valid artifacts; change implementation only where the new requirements demand it."}) + journal.pop("task_validation", None) + journal.pop("result_work_binding", None) + + def _run_host( request: Mapping[str, Any], *, @@ -844,6 +867,7 @@ def count_stderr(text: str) -> None: "ok": False, "reason": "host process " + observed["outcome"], "returncode": observed["returncode"], + "failure_kind": "executor_timeout" if observed["outcome"] == "timeout" else "transport_lost", } if not observed["output_complete"]: return { @@ -941,6 +965,7 @@ def _host_result_stage( confirm_start: Callable[[], None] | None = None, usage_runtime_root: Path | None = None, usage_goal_id: str = "", + session_binding_resolver: SessionBindingResolver | None = None, ) -> tuple[dict[str, Any] | None, list[str], dict[str, Any] | None]: completed_phases = list(journal.get("completed_phases") or []) from_journal = "typed_result" in completed_phases @@ -949,6 +974,7 @@ def _host_result_stage( if isinstance(journal.get("host_result"), dict) else None ) + dispatched_result = "typed_result" not in completed_phases if "typed_result" not in completed_phases: journal["host_attempt_count"] = int(journal.get("host_attempt_count") or 0) + 1 persist_journal(journal) @@ -1056,6 +1082,13 @@ def _host_result_stage( ) normalized = dict(validation["result"]) + if dispatched_result and session_binding_resolver is not None and normalized.get("result_kind") in { + LoopXTurnResultKind.VALIDATED_COMPLETION.value, + LoopXTurnResultKind.VALIDATED_PROGRESS.value, + LoopXTurnResultKind.REPAIR_REQUIRED.value, + LoopXTurnResultKind.REPLAN_REQUIRED.value, + } and "checkpoint_host_binding" not in journal: + journal["checkpoint_host_binding"] = session_binding_resolver(request["turn_envelope"]) if len(completed_phases) < 2: completed_phases = list(TRANSACTION_PHASES[:2]) journal.update( @@ -1157,6 +1190,74 @@ def _task_validation_stage( return completed_phases, None +def _terminal_result_validation_failure(plan: Mapping[str, Any], journal: dict[str, Any], + reason: str, *, persist_journal: JournalPersist, effects: dict[str, bool]) -> dict[str, Any]: + failure = _host_failure(plan, kind=LoopXTurnResultKind.TERMINAL_CLOSEOUT_FAILED, + completed_phases=list(journal["completed_phases"]), failed_phase="terminal_closeout", reason=reason) + journal.update(status="failed", receipt=failure["receipt"], reason=reason, + result_kind=LoopXTurnResultKind.TERMINAL_CLOSEOUT_FAILED.value) + persist_journal(journal) + return execution_payload(plan, journal, execute=True, replayed=False, effects=effects) + + +def _terminal_result_validation_stage( + plan: Mapping[str, Any], request: Mapping[str, Any], *, journal: dict[str, Any], + persist_journal: JournalPersist, effects: dict[str, bool], host_runner: HostRunner | None, + argv: Sequence[str] | None, project: Path, timeout_seconds: float | None, + task_validator: TaskValidator | None, + confirm_start: Callable[[], None] | None, runtime_root: Path, goal_id: str, +) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + """Revalidate an uncommitted terminal result while retaining settled phases.""" + retry = journal["result_revalidation"] + view = {"schema_version": journal["schema_version"], "goal_id": journal["goal_id"], + "turn_key": journal["turn_key"], "plan": journal["plan"], "status": "in_progress", + "completed_phases": [], "host_attempt_count": journal.get("host_attempt_count", 0), + **retry.get("validation", {})} + if view.get("status") == "failed": + if (view.get("receipt") or {}).get("failed_phase") == "validation": + view["completed_phases"] = [] + view.pop("host_result", None) + view.pop("task_validation", None) + for key in ("receipt", "reason", "host_recovery", "host_failure"): + view.pop(key, None) + view["status"] = "in_progress" + if view.get("work_context") != journal["result_work_context"]: + # A cold view must keep the facts actually supplied to its Host and + # validator. A replacement read requires both stages again. + view = {"schema_version": journal["schema_version"], "goal_id": journal["goal_id"], + "turn_key": journal["turn_key"], "plan": journal["plan"], "status": "in_progress", + "completed_phases": [], "host_attempt_count": journal.get("host_attempt_count", 0), + "work_context": journal["result_work_context"]} + + def persist_view(snapshot): + retry["validation"] = {key: value for key, value in snapshot.items() + if key not in {"schema_version", "goal_id", "turn_key", "plan"}} + journal["host_attempt_count"] = snapshot.get("host_attempt_count", journal.get("host_attempt_count", 0)) + persist_journal(journal) + + failed = partial(_terminal_result_validation_failure, plan, journal, + persist_journal=persist_journal, effects=effects) + + result, phases, terminal = _host_result_stage(plan, request, host_runner=host_runner, + argv=argv, completion_lifecycle_configured=True, project=project, + timeout_seconds=timeout_seconds, journal=view, persist_journal=persist_view, + effects=effects, confirm_start=confirm_start, usage_runtime_root=runtime_root, usage_goal_id=goal_id) + if terminal is not None: + return None, failed(str(view.get("reason") or "Terminal result revalidation failed")) + assert result is not None + if result.get("result_kind") != LoopXTurnResultKind.VALIDATED_COMPLETION.value: + return None, failed("Terminal result revalidation requires a validated completion; preserve the settled receipts.") + _, terminal = _task_validation_stage({**plan, "delivery_result_context": journal["result_work_context"]}, + result, task_validator=task_validator, completed_phases=phases, journal=view, + persist_journal=persist_view, effects=effects) + if terminal is not None: + return None, failed(str(view.get("reason") or "Terminal candidate validation failed")) + journal["host_result"] = result + journal["task_validation"] = view["task_validation"] + persist_journal(journal) + return result, None + + def _ensure_turn_settlement_plan( plan: Mapping[str, Any], transaction_plan: dict[str, Any], @@ -1214,6 +1315,7 @@ def _typed_settlement_stage( post_settlement: PostSettlement | None, source_effects: TurnSettlementEffectAdmission | None, persist_source_journal: SourceJournalPersist | None, + post_result_checkpoint: Callable[[], bool] | None, ) -> dict[str, Any]: transaction_plan = ( plan.get("transaction") if isinstance(plan.get("transaction"), Mapping) else {} @@ -1239,6 +1341,21 @@ def _typed_settlement_stage( completion_intent=completion_intent, ) ) + persist_journal(journal) + + freshness_error: CheckpointReadContextRejected | None = None + + def result_effect(callback: CompletionWriteback | Writeback, effect_ref: str) -> Mapping[str, Any]: + nonlocal freshness_error + try: + return invoke_result_effect(callback, result, effect_ref) + except CheckpointReadContextRejected as error: + if error.code not in {"checkpoint_read_context_stale", "delivery_validation_binding_mismatch"}: + raise + # The result owner rejected before commit. Let the typed settlement + # owner abort its prepared effect before reopening validation. + freshness_error = error + return {"ok": False, "appended": False, "reason": str(error)} def writeback_effect(effect_ref: str) -> Mapping[str, Any]: if completion_intent_error: @@ -1249,14 +1366,15 @@ def writeback_effect(effect_ref: str) -> Mapping[str, Any]: } if ( result.get("result_kind") == LoopXTurnResultKind.VALIDATED_COMPLETION.value - and not terminal_closeout_required + and (not terminal_closeout_required or + (post_result_checkpoint is not None and post_result_checkpoint())) ): if completion_writeback is None: raise ValueError( "validated_completion requires a todo lifecycle adapter" ) - return invoke_result_effect(completion_writeback, result, effect_ref) - return invoke_result_effect(writeback, result, effect_ref) + return result_effect(completion_writeback, effect_ref) + return result_effect(writeback, effect_ref) def spend_effect(effect_ref: str) -> Mapping[str, Any]: try: @@ -1292,7 +1410,7 @@ def spend_effect(effect_ref: str) -> Mapping[str, Any]: terminal_checkpoint = None if terminal_closeout_required: assert terminal_closeout is not None - terminal_effect = partial(invoke_result_effect, terminal_closeout, result) + terminal_effect = partial(result_effect, terminal_closeout) terminal_checkpoint = journal_adapter.checkpoint_terminal settlement_result = execute_turn_driver_settlement( @@ -1332,6 +1450,10 @@ def spend_effect(effect_ref: str) -> Mapping[str, Any]: journal["settlement_result"] = settlement_result_payload(settlement_result) if settlement_result.failure is not None: + if freshness_error is not None: + _retain_result_for_revalidation(plan, journal, result, freshness_error) + persist_journal(journal) + return execution_payload(plan, journal, execute=True, replayed=False, effects=effects) result_kind, completed_phases, failed_phase = turn_settlement_failure_outcome( settlement_result ) @@ -1432,6 +1554,27 @@ def persist_tail_checkpoint(*, release: bool) -> None: ) +def _execution_host( + plan: Mapping[str, Any], + *, + host_runner: HostRunner | None, + host_argv: Sequence[str] | None, +) -> tuple[list[str] | None, dict[str, Any]]: + if host_runner is not None and host_argv is not None: + raise ValueError("run-once accepts either host_argv or host_runner, not both") + if host_runner is None: + argv = normalize_host_argv(host_argv or []) + host_projection = {"executable": Path(argv[0]).name, "argv_count": len(argv)} + else: + argv = None + planned_host = plan.get("host") if isinstance(plan.get("host"), dict) else {} + host_projection = { + "executable": "built-in", + "kind": str(planned_host.get("kind") or "codex-cli"), + } + return argv, host_projection + + @single_executor_per_turn_lane(execution_payload) def run_loopx_turn_once( plan: Mapping[str, Any], @@ -1461,19 +1604,12 @@ def run_loopx_turn_once( admit_start: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None, confirm_start: Callable[[], None] | None = None, goal_admission: FirstPartyHostGoalAdmission | None = None, + result_work: TurnResultWork | None = None, + post_result_checkpoint: Callable[[], bool] | None = None, ) -> dict[str, Any]: - if host_runner is not None and host_argv is not None: - raise ValueError("run-once accepts either host_argv or host_runner, not both") - if host_runner is None: - argv = normalize_host_argv(host_argv or []) - host_projection = {"executable": Path(argv[0]).name, "argv_count": len(argv)} - else: - argv = None - planned_host = plan.get("host") if isinstance(plan.get("host"), dict) else {} - host_projection = { - "executable": "built-in", - "kind": str(planned_host.get("kind") or "codex-cli"), - } + argv, host_projection = _execution_host( + plan, host_runner=host_runner, host_argv=host_argv, + ) transaction_plan = ( plan.get("transaction") if isinstance(plan.get("transaction"), dict) else {} ) @@ -1494,21 +1630,11 @@ def run_loopx_turn_once( plan, fail_closed, execute=True, replayed=False, effects=empty_effects ) if not execute: - preview = { - "schema_version": LOOPX_TURN_JOURNAL_SCHEMA_VERSION, - "status": "preview", - "host": host_projection, - "result_kind": None, - "receipt": None, - "scheduler": {"disposition": "not_evaluated"}, - } - return execution_payload( - plan, - preview, - execute=False, - replayed=False, - effects=empty_effects, - ) + return preview_turn_recovery(plan, host_projection, + journal=_load_journal(turn_journal_path(runtime_root, goal_id=goal_id, + turn_key=str(transaction_plan["turn_key"]))), request=request, + goal_id=goal_id, turn_key=str(transaction_plan["turn_key"]), retry_failed=retry_failed, + session_binding_resolver=session_binding_resolver, empty_effects=empty_effects) if writeback is None or spend is None or scheduler is None: raise ValueError( "executing run-once requires writeback, spend, and scheduler callbacks" @@ -1576,43 +1702,17 @@ def persist_source_journal(source_admission: Mapping[str, Any]) -> None: session_binding_resolver=session_binding_resolver, ) recovery_decision = assessment.decision - action = recovery_decision.get("action") - if action == "return_existing": - payload = execution_payload( - plan, - journal, - execute=True, - replayed=True, - effects=empty_effects, - ) - payload.update(recover_post_settlement( - plan=plan, post_settlement=post_settlement, journal=journal, - )) - payload["recovery"] = build_turn_recovery_audit( - recovery_decision, - journal, - status="finished", - host_invoked=False, - ) - return payload + if recovery_decision.get("action") == "return_existing": + return recover_existing_turn(plan, journal, recovery_decision, + empty_effects=empty_effects, post_settlement=post_settlement) request = require_turn_recovery_continuation(assessment) # Settlement recovery uses its cached host result and must not reserve # another interval. A new host attempt, including failed-result retry, # goes through admission before any host or journal attempt mutation. - receipt = journal.get("receipt") if isinstance(journal, Mapping) else None - validation_reinvokes_host = ( - isinstance(journal, Mapping) - and journal.get("status") == "failed" - and isinstance(receipt, Mapping) - and receipt.get("failed_phase") == "validation" - and journal.get("validation_stage") != "task_postcondition" - ) - needs_host = ( - validation_reinvokes_host - or journal is None - or "typed_result" not in list(journal.get("completed_phases") or []) - ) + request, needs_host = prepare_terminal_host_recovery(journal, request, + plan=plan, goal_id=goal_id, turn_key=turn_key, result_work=result_work, + session_binding_resolver=session_binding_resolver) if needs_host and goal_admission is not None: goal_admission.require_current() capture = stage_turn_decision_capture( @@ -1654,29 +1754,7 @@ def persist_source_journal(source_admission: Mapping[str, Any]) -> None: ) persist_journal(journal) - if journal and journal.get("status") == "failed": - receipt = ( - journal.get("receipt") - if isinstance(journal.get("receipt"), dict) - else {} - ) - if receipt.get("failed_phase") == "validation": - if validation_reinvokes_host: - journal.pop("host_result", None) - journal.pop("result_kind", None) - journal["completed_phases"] = ( - list(TRANSACTION_PHASES[:2]) - if isinstance(journal.get("host_result"), dict) - else [] - ) - journal.pop("task_validation", None) - journal.pop("validation_stage", None) - journal.pop("reason", None) - journal.pop("receipt", None) - journal.pop("host_recovery", None) - journal.pop("host_failure", None) - journal["status"] = "in_progress" - persist_journal(journal) + reset_failed_turn_for_retry(journal, persist_journal) if journal is None: journal = { "schema_version": LOOPX_TURN_JOURNAL_SCHEMA_VERSION, @@ -1693,6 +1771,10 @@ def persist_source_journal(source_admission: Mapping[str, Any]) -> None: journal["admission"] = admission persist_journal(journal) + original_result_committed = (result_work.prepare_request(journal, request, + post_result_checkpoint=post_result_checkpoint, persist_journal=persist_journal) + if result_work is not None else False) + effects = dict(empty_effects) def finish_recovery(payload: dict[str, Any]) -> dict[str, Any]: @@ -1708,12 +1790,23 @@ def finish_recovery(payload: dict[str, Any]) -> dict[str, Any]: payload["recovery"] = dict(journal["recovery_audit"]) return payload + terminal_revalidation = (journal.get("result_revalidation") or {}).get("phase") == "terminal_closeout" + if terminal_revalidation: + _, terminal = _terminal_result_validation_stage(plan, request, journal=journal, + persist_journal=persist_journal, effects=effects, host_runner=host_runner, argv=argv, + project=project, timeout_seconds=timeout_seconds, task_validator=task_validator, + confirm_start=confirm_start if admission is not None and admission.get("reserved") is True else None, + runtime_root=runtime_root, goal_id=goal_id) + if terminal is not None: + return finish_recovery(terminal) + result, completed_phases, terminal = _host_result_stage( plan, request, host_runner=host_runner, usage_runtime_root=runtime_root, usage_goal_id=goal_id, + session_binding_resolver=session_binding_resolver, argv=argv, completion_lifecycle_configured=all( callback is not None @@ -1738,8 +1831,24 @@ def finish_recovery(payload: dict[str, Any]) -> dict[str, Any]: return finish_recovery(terminal) assert result is not None + context = journal.get("result_work_context") + def freshness_failure(error: CheckpointReadContextRejected) -> dict[str, Any]: + _retain_result_for_revalidation(plan, journal, result, error) + persist_journal(journal) + return finish_recovery(execution_payload(plan, journal, execute=True, + replayed=False, effects=effects)) + + protected_result = result_work is not None and not original_result_committed and isinstance(context, Mapping) and result.get("result_kind") == LoopXTurnResultKind.VALIDATED_COMPLETION.value + if protected_result: + try: + result_work.check(context) + except CheckpointReadContextRejected as error: + if error.code != "checkpoint_read_context_stale": + raise + return freshness_failure(error) + completed_phases, terminal = _task_validation_stage( - plan, + {**plan, "delivery_result_context": context} if context is not None else plan, result, task_validator=task_validator, completed_phases=completed_phases, @@ -1750,6 +1859,26 @@ def finish_recovery(payload: dict[str, Any]) -> dict[str, Any]: if terminal is not None: return finish_recovery(terminal) + if protected_result: + try: + result_work.check(context) + binding = result_work.bind(context, result, journal["task_validation"], + binding=journal.get("result_work_binding")) + except CheckpointReadContextRejected as error: + if error.code not in {"checkpoint_read_context_stale", "delivery_validation_binding_mismatch"}: + raise + return freshness_failure(error) + journal["result_work_binding"] = binding.get("binding", journal.get("result_work_binding")) + if terminal_revalidation: + journal.pop("result_revalidation") + persist_journal(journal) + + # Replay carries the same work key as the original successful request, + # even when the read receipt is no longer available. The Todo owner, + # not this transport field, decides whether that request is a replay. + if result.get("result_kind") == LoopXTurnResultKind.VALIDATED_COMPLETION.value and journal.get("result_work_binding") is not None: + result = {**result, "result_work_binding": journal["result_work_binding"]} + settled = _typed_settlement_stage( plan, result, @@ -1761,6 +1890,7 @@ def finish_recovery(payload: dict[str, Any]) -> dict[str, Any]: completion_writeback=completion_writeback, completion_intent=completion_intent, terminal_closeout=terminal_closeout, + post_result_checkpoint=post_result_checkpoint, spend=spend, effect_resolvers=turn_effect_resolvers( writeback=writeback_resolver, diff --git a/loopx/control_plane/turn_driver/first_delivery.ts b/loopx/control_plane/turn_driver/first_delivery.ts new file mode 100644 index 0000000000..f85158f56b --- /dev/null +++ b/loopx/control_plane/turn_driver/first_delivery.ts @@ -0,0 +1,173 @@ +/** Receipt-backed delivery observation; never settlement or direction authority. */ +import type {JsonObject} from "../effect_program.ts"; +import {settlementIdentityFromPlan, SETTLEMENT_IDENTITY_SCHEMA_VERSION, SCOPED_SETTLEMENT_IDENTITY_SCHEMA_VERSION} from "../effect_program.ts"; +import {createHash} from "node:crypto"; +import {readFileSync, readdirSync} from "node:fs"; +import {basename, join} from "node:path"; +import {jsonObject, requireJsonObject} from "../runtime_decode.ts"; +import {originalTurnRequestsNoFollowup} from "../todos/completion_state.ts"; +import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; +import {inspectCheckpointReadHistoryFromRows, readCheckpointHistoryRows} from "../goals/checkpoint_commit.ts"; +import {checkpointContinuationRequired} from "../quota/heartbeat_receipt_identity.ts"; +import {parseQuotaAccountingOwner, withQuotaAccountingOwner} from "../quota/source_admission.ts"; +import {QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, readQuotaSettlementForAdmittedOwnerFromSnapshot, + readQuotaSettlementSnapshot, validateQuotaSettlementScope} from "../quota/settlement_readback.ts"; + +function receiptName(identity: JsonObject, work = false): string { + return createHash("sha256").update(String(identity.effect_id) + (work ? ":delivery_result" : "")).digest("hex") + ".json"; +} + +/** Shared snapshots, original identities and disposable read proofs. Observation + * cannot repair receipts, spend quota, rearm a Host or select subsequent work. */ +export async function observeFirstDelivery(value: unknown): Promise { + const request = requireJsonObject(value, "delivery observation"); + const {runtimeRoot: root, goalId: goal} = validateQuotaSettlementScope(request.runtime_root, request.goal_id); + const owner = parseQuotaAccountingOwner({runtimeRoot: root, goalId: goal, + goalRefValue: request.goal_ref, sourceAdmissionValue: request.source_admission}); + return withQuotaAccountingOwner(owner, async () => { + const identities = new Map(); + const receipts = new Map(); + let errors: JsonObject[] = []; + const directory = join(root, "goals", goal, "checkpoint-contexts"); + let names: string[] = []; + try { names = readdirSync(directory).filter(name => name.endsWith(".json")); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + if (request.receipt_names !== undefined) { + if (!Array.isArray(request.receipt_names) || request.receipt_names.some(name => + typeof name !== "string" || basename(name) !== name || !name.endsWith(".json"))) throw new TypeError("invalid receipt scan"); + names = request.receipt_names as string[]; + } + for (const name of names) { + let receipt: JsonObject; + try { receipt = requireJsonObject(JSON.parse(readFileSync(join(directory, name), "utf8")), "checkpoint receipt"); } + catch { errors.push({scope: "goal", code: "checkpoint_receipt_unavailable", receipt: name.slice(0, -5)}); continue; } + receipts.set(name, receipt); + if (!["first_delivery", "delivery_result"].includes(String(receipt.purpose))) continue; + const binding = jsonObject(receipt.identity); + if (typeof binding?.agent_id === "string" && binding.agent_id && request.agent_id != null && binding.agent_id !== request.agent_id) continue; + try { + if (!binding) throw new TypeError("missing identity"); + const decoded = settlementIdentityFromPlan({settlement_plan: {identity: binding}}); + if (decoded.failure !== null || ![SETTLEMENT_IDENTITY_SCHEMA_VERSION, SCOPED_SETTLEMENT_IDENTITY_SCHEMA_VERSION] + .includes(String(binding.schema_version))) throw new TypeError("invalid identity"); + const identity = binding; // Retain original facts; do not normalize historical metadata away. + if (receipt.schema_version !== "checkpoint_read_context_v2" || identity.goal_id !== goal || + receiptName(identity, receipt.purpose === "delivery_result") !== name) throw new TypeError("invalid binding"); + identities.set(String(identity.effect_id), identity); + } catch { errors.push({scope: "goal", code: "checkpoint_receipt_invalid", receipt: name.slice(0, -5)}); } + } + // The ordinary settlement parser and strict checkpoint parser intentionally + // differ on incomplete tails. Capture each once; never mix changed indexes. + let rows: JsonObject[] | null = null; + try { rows = readCheckpointHistoryRows(root, goal); } + catch { errors.push({scope: "goal", code: "checkpoint_history_unavailable"}); } + const verifiedDirections = new Set(); + if (rows !== null) { + let verified: JsonObject[] = []; + try { + const recovered = inspectCheckpointReadHistoryFromRows({runtime_root: root, goal_id: goal, agent_id: request.agent_id, + receipt_names: errors.filter(error => error.receipt != null).map(error => String(error.receipt) + ".json")}, rows); + verified = recovered.verified as JsonObject[]; + } catch { errors.push({scope: "goal", code: "checkpoint_history_unavailable"}); } + for (const item of verified) { + errors = errors.filter(error => String(error.receipt) + ".json" !== item.receipt_name); + const identity = requireJsonObject(item.identity, "verified checkpoint identity"); + if (request.agent_id == null || identity.agent_id === request.agent_id) { + identities.set(String(identity.effect_id), identity); + if (item.purpose === "first_delivery") verifiedDirections.add(String(identity.effect_id)); + } + } + } + let snapshot: Awaited> | null = null; + if (identities.size) { + try { snapshot = await readQuotaSettlementSnapshot(root, goal); } + catch { errors.push({scope: "goal", code: "checkpoint_history_unavailable"}); } + if (request.todos == null) errors.push({scope: "goal", code: "checkpoint_authority_unavailable"}); + } + const changedIndex = rows !== null && snapshot !== null && + canonicalAuthoritySha256(rows) !== canonicalAuthoritySha256(snapshot.runs); + const todos = new Map(); + for (const value of Array.isArray(request.todos) ? request.todos : []) { + const todo = requireJsonObject(value, "observed Todo"); + if (!todos.has(todo.todo_id)) todos.set(todo.todo_id, todo); + } + for (const identity of [...identities.values()].reverse()) { + let progress: JsonObject; + try { + if (snapshot === null || request.todos == null) throw new Error("observation source unavailable"); + const readback = readQuotaSettlementForAdmittedOwnerFromSnapshot({schema_version: QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, + runtime_root: root, goal_id: goal, agent_id: identity.agent_id, todo_id: identity.todo_id || null, + turn_instance_id: identity.turn_instance_id, replan_obligation_id: identity.replan_obligation_id ?? null, + infer_turn_instance_id: false, allow_unbound_binding: false}, owner, snapshot); + if (readback.found !== true || jsonObject(jsonObject(readback.identity)?.result)?.value == null) throw new Error("identity unavailable"); + const continuation = checkpointContinuationRequired(jsonObject(readback.heartbeat_receipt)); + const direction = receipts.get(receiptName(identity)); + const work = receipts.get(receiptName(identity, true)); + if (!verifiedDirections.has(String(identity.effect_id)) && !continuation && + direction?.purpose !== "first_delivery" && !(work?.purpose === "delivery_result" && work.result_only !== true)) continue; + progress = evaluateFirstDelivery({phase: "project", identity, completion_todo: todos.get(identity.todo_id) ?? {}, + checkpoint_continuation: continuation, completion_event: readback.completion_event, + writeback_run: readback.writeback_run, direction_receipt: direction, + unknown: changedIndex || errors.some(error => [receiptName(identity), receiptName(identity, true)].includes(String(error.receipt) + ".json")), + quota_spent: readback.spend_run != null, + settlement_complete: jsonObject(jsonObject(readback.settlement)?.result)?.failure === null, + terminal_settlement_complete: jsonObject(jsonObject(readback.terminal_settlement)?.result)?.failure === null}); + } catch { progress = evaluateFirstDelivery({phase: "project", unknown: true}); } + if (progress.stage !== "settled") return {...progress, settlement_identity: identity, + ...(errors.length ? {observation_errors: errors} : {})}; + } + return errors.length ? {...evaluateFirstDelivery({phase: "project", observation_unavailable: true}), + goal_id: goal, agent_id: request.agent_id ?? null, observation_errors: errors} : null; + }); +} + +export function evaluateFirstDelivery(value: unknown): JsonObject { + const request = requireJsonObject(value, "first delivery"); + if (request.phase === "project") { + if (request.observation_unavailable === true) { + return {schema_version: "first_delivery_progress_v0", stage: "observation_unavailable", + goal_completion_certified: false, + next_action: "Inspect unavailable checkpoint observations before resuming the affected Turn."}; + } + const checkpoint = jsonObject(jsonObject(request.writeback_run)?.vision_checkpoint); + const completion = jsonObject(request.completion_event); + const completionTodo = jsonObject(request.completion_todo); + const identity = jsonObject(request.identity); + const resultCommitted = request.result_committed === true || (completionTodo?.status === "done" && identity !== null && + [identity.effect_id, identity.turn_instance_id].includes(completionTodo.completion_turn_key)) || + ["completed", "terminal_no_followup", "receipt_repaired"].includes(String(completion?.status)); + const outcomeRetained = checkpoint?.required === true && checkpoint.satisfied === false; + const direction = jsonObject(request.direction_receipt); + const notRequired = checkpoint?.decision === "not_required" && checkpoint.required === false && checkpoint.satisfied === true; + const directionCommitted = checkpoint?.satisfied === true && + (jsonObject(checkpoint.read_context)?.purpose === "first_delivery" || + (request.checkpoint_continuation === true && checkpoint.read_context != null)); + const terminalRequired = completionTodo !== null && identity !== null && + originalTurnRequestsNoFollowup(completionTodo, identity); + const settlementComplete = request.settlement_complete === true && + (!terminalRequired || request.terminal_settlement_complete === true); + const unknown = request.unknown === true || + (jsonObject(request.direction_receipt)?.commit_attempt != null && !directionCommitted); + const stage = unknown ? "operation_unknown" + : !resultCommitted && !outcomeRetained && !directionCommitted && !notRequired ? "result_review_pending" + : !directionCommitted && direction?.direction_stopped === true ? "direction_stopped" + : !directionCommitted && direction?.direction_host_failure != null ? "direction_failed" + : !directionCommitted && !notRequired ? "direction_pending" + : settlementComplete ? "settled" : "settlement_pending"; + return {schema_version: "first_delivery_progress_v0", stage, + result_committed: resultCommitted, + outcome_retained: outcomeRetained || directionCommitted, + direction_committed: directionCommitted, + direction_required: !notRequired, + quota_spent: request.quota_spent === true, + goal_completion_certified: false, + next_action: stage === "operation_unknown" ? "Read back the original Turn and its run artifacts; retain its identity." + : stage === "result_review_pending" ? "Read the original Turn's result basis and validate the candidate before committing it." + : stage === "direction_stopped" ? "Result retained; the current Agent stopped. Inspect the original Turn before explicitly resuming." + : stage === "direction_failed" ? "Result retained; retry the original Turn's direction using the existing Host recovery policy." + : stage === "direction_pending" ? "Result retained; review the current direction using the original Turn, then resume its remaining settlement." + : stage === "settlement_pending" ? "Resume the original Turn's remaining settlement; retain successful writeback and quota receipts." + : "Turn settlement is complete. Read the current Goal obligations before selecting further work."}; + } + throw new TypeError("Delivery observation requires the project phase"); +} diff --git a/loopx/control_plane/turn_driver/host_candidate.py b/loopx/control_plane/turn_driver/host_candidate.py index efdfbd7de4..2d9afac2ea 100644 --- a/loopx/control_plane/turn_driver/host_candidate.py +++ b/loopx/control_plane/turn_driver/host_candidate.py @@ -186,6 +186,9 @@ def extract_turn_authority(request: Mapping[str, Any]) -> dict[str, Any]: if key not in {"replan_action_packet", "replan_read_argv", "core_goal", "planning_horizon", "acceptance_index"}} authority["action_signature"] = signature authority.update(host_context_projection(request)) + for field in ("delivery_result_context", "result_revalidation"): + if request.get(field) is not None: + authority[field] = request[field] return authority @@ -292,6 +295,7 @@ def render_prompt(authority: Mapping[str, Any], *, allow_completion: bool = Fals f"{replan_instruction}" f"{acceptance_instruction}" f"{work_instruction}" + "Use delivery_result_context as the actual requirements read for work. If result_revalidation is present, recheck its retained candidate against the new basis and reuse valid artifacts. These inputs grant no new permissions.\n" f"Turn authority JSON:\n{authority_json}\n\n" "When finished, return only one JSON object (no Markdown fence) with " "these public-safe fields:\n" @@ -342,6 +346,21 @@ def render_prompt(authority: Mapping[str, Any], *, allow_completion: bool = Fals ) +def render_checkpoint_prompt(request: Mapping[str, Any]) -> str: + """Continue this Agent on the exact post-outcome read, without work effects.""" + return ( + "Continue the current Agent's original Turn after its durable outcome. " + "Read checkpoint_context.basis and judge the current direction. Do not repeat implementation, " + "validation, Todo/Goal writes, quota spend, or scheduler actions. Return one JSON object with " + "the exact read_context_id and either a fresh Vision packet serialized as agent_vision_json " + "or an evidence-backed vision_unchanged_reason (<=240 chars) with empty agent_vision_json. " + "An unchanged reason requires the baseline in this basis. Use its vision_authoring contract. " + "A repeated conclusion is allowed; do not copy a pre-outcome decision. If permission, stop or unavailable " + "context prevents judging, return a bounded stop_reason with both decision fields empty. Otherwise leave stop_reason empty.\n" + + json.dumps(request, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + ) + + def parse_model_json(text: str) -> dict[str, Any] | None: """Parse the host final assistant message as one JSON object. diff --git a/loopx/control_plane/turn_driver/host_process_transport.py b/loopx/control_plane/turn_driver/host_process_transport.py index bd9a1c646a..91374306c5 100644 --- a/loopx/control_plane/turn_driver/host_process_transport.py +++ b/loopx/control_plane/turn_driver/host_process_transport.py @@ -127,6 +127,15 @@ def _host_process_drain(record: dict[str, Any] | None, *, supervises: str, return (HOST_PROCESS_NOT_LAUNCHED if record.get("process_group") is None and "bridge_pid" not in record else HOST_PROCESS_UNATTRIBUTABLE) if not hasattr(os, "killpg"): + if sys.platform == "win32" and "windows_job" in record: + from .windows_host_job import active_job_processes, valid_job_name + if not valid_job_name(record["windows_job"]): + return HOST_PROCESS_UNATTRIBUTABLE + try: + active = active_job_processes(record["windows_job"]) + except OSError: + return HOST_PROCESS_UNATTRIBUTABLE + return HOST_PROCESS_DRAINING if active else HOST_PROCESS_DRAINED # A launched Host on a platform without process groups is never proven # drained. This is a platform boundary, not an attribution failure. return HOST_PROCESS_UNSUPPORTED_PLATFORM @@ -177,7 +186,11 @@ def execution_host_drain(record_path: Path, *, launch_possible: bool = False) -> host = _host_process_drain(owned_record, supervises=HOST_PROCESS_SUPERVISES_HOST, expected=True) drain = next(state for state in _DRAIN_PRECEDENCE if state in {supervisor, host}) - if not host_process_drain_supported() and (launch_possible or drain != HOST_PROCESS_NOT_LAUNCHED): + assigned_windows_job = sys.platform == "win32" and any( + "windows_job" in item for item in (owned_record or {}, supervisor_record or {}) + ) + if (not assigned_windows_job and not host_process_drain_supported() + and (launch_possible or drain != HOST_PROCESS_NOT_LAUNCHED)): return HOST_PROCESS_UNSUPPORTED_PLATFORM return drain @@ -283,6 +296,131 @@ def run_host_process( if delegated_lease is not None: request["delegated_lease"] = delegated_lease bridge = Path(__file__).with_name("host_process_bridge.ts") + bridge_environment, record_value, record_path, supervision, supervises = _bridge_record_scope(environment, delegated_lease) + launch_budget = max(1.0, timeout_seconds) if timeout_seconds is not None else 30.0 + launch_deadline = time.monotonic() + launch_budget if launch_guard is not None else None + launch_arguments = (["--guarded-launch", str((time.time() + launch_budget) * 1000)] + if launch_guard is not None else []) + with ExitStack() as containment: + windows_job = None + if sys.platform == "win32": + from .windows_host_job import WindowsHostJob + windows_job = containment.enter_context(WindowsHostJob()) + proc = subprocess.Popen( + [ + _node_executable(), + "--no-warnings", + "--experimental-strip-types", + str(bridge), + *launch_arguments, + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + encoding="utf-8", + errors="strict", + start_new_session=True, + env=bridge_environment, + ) + assert proc.stdin is not None and proc.stdout is not None + result: dict[str, Any] | None = None + record = None + launch_scope = ExitStack() + guarded = (GuardedHostBridge(proc, deadline=launch_deadline, + cleanup_timeout=8 if delegated_lease is not None else 5) + if launch_deadline is not None else None) + try: + if windows_job is not None: + windows_job.assign(proc) + if launch_guard is not None: + launch_scope.enter_context(launch_guard()) + record = _begin_host_process_record(record_path, record_value=record_value, + supervises=supervises, supervision=supervision, bridge_pid=proc.pid) + if record is not None and windows_job is not None: + record["windows_job"] = windows_job.name + _write_host_process_record(record_path, record) + frame = json.dumps(request, ensure_ascii=False, separators=(",", ":")) + "\n" + if guarded is not None: + guarded.send(frame) + else: + proc.stdin.write(frame) + proc.stdin.flush() + lines = iter(proc.stdout) + while True: + event = (guarded.next(deadline=launch_deadline) if guarded is not None + else json.loads(line) if (line := next(lines, None)) is not None else None) + if event is None: + break + kind = event.get("kind") + if kind == "spawned" and isinstance(event.get("pid"), int): + if record is not None: + assert record_path is not None + group = event.get("process_group") + record.update(phase="spawned", host_pid=event["pid"], + process_group=group if isinstance(group, int) else None) + _write_host_process_record(record_path, record) + launch_deadline = None + launch_scope.close() + elif kind in {"stdout", "stderr"} and isinstance(event.get("text"), str): + consume = on_stdout if kind == "stdout" else on_stderr + if consume is not None: + consume(event["text"]) + elif kind == "result" and event.get("outcome") in { + "exited", + "timeout", + "cancelled", + "output_limit", + "spawn_failed", + }: + result = event + launch_deadline = None + launch_scope.close() + elif kind == "not_launched" and guarded is not None: + guarded.not_launched = True + raise TimeoutError("managed Host bridge launch deadline expired") + else: + raise RuntimeError("Invalid managed Host process observation") + finally: + try: + if guarded is not None and (launch_deadline is not None or sys.exc_info()[0] is not None): + # A failed handoff still owns the Goal fence. The TS bridge must + # report no launch or finish its Host cleanup before it releases. + guarded.cancel() + if record is not None and guarded.not_launched: + assert record_path is not None + record.update(phase=HOST_PROCESS_NOT_LAUNCHED, process_group=None) + record.pop("bridge_pid", None) + _write_host_process_record(record_path, record) + else: + # EOF asks the TS supervisor to clean its Host, also on a + # callback failure. Leased cleanup retains its original budget. + proc.stdin.close() + try: + proc.wait(timeout=8 if delegated_lease is not None else 5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + finally: + if windows_job is not None: + windows_job.finish() + launch_scope.close() + if guarded is not None: + guarded.close_output() + else: + proc.stdout.close() + if record is not None and result is not None and proc.returncode == 0: + assert record_path is not None + # The supervisor returned only after cleaning its group; the group is still re-read. + record["phase"] = "finished" + _write_host_process_record(record_path, record) + if proc.returncode != 0 or result is None: + raise RuntimeError("Managed Host process supervision returned no result") + return result + + +def _bridge_record_scope(environment, delegated_lease): + """Separate the supervisor's private record namespace from its child Host.""" # The bridge runs on the caller's environment, which may pin the release the # Host must run and name the record its supervisor owns. Copy it instead of # replacing it with the ambient one, and consume the record env from the @@ -302,111 +440,4 @@ def run_host_process( record_path = host_process_supervisor_record(record_path) supervises = HOST_PROCESS_SUPERVISES_NESTED_HOST supervision = "leased" - launch_budget = max(1.0, timeout_seconds) if timeout_seconds is not None else 30.0 - launch_deadline = time.monotonic() + launch_budget if launch_guard is not None else None - launch_arguments = (["--guarded-launch", str((time.time() + launch_budget) * 1000)] - if launch_guard is not None else []) - proc = subprocess.Popen( - [ - _node_executable(), - "--no-warnings", - "--experimental-strip-types", - str(bridge), - *launch_arguments, - ], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - text=True, - encoding="utf-8", - errors="strict", - start_new_session=True, - env=bridge_environment, - ) - assert proc.stdin is not None and proc.stdout is not None - result: dict[str, Any] | None = None - record = None - launch_scope = ExitStack() - guarded = (GuardedHostBridge(proc, deadline=launch_deadline, - cleanup_timeout=8 if delegated_lease is not None else 5) - if launch_deadline is not None else None) - try: - if launch_guard is not None: - launch_scope.enter_context(launch_guard()) - record = _begin_host_process_record(record_path, record_value=record_value, - supervises=supervises, supervision=supervision, bridge_pid=proc.pid) - frame = json.dumps(request, ensure_ascii=False, separators=(",", ":")) + "\n" - if guarded is not None: - guarded.send(frame) - else: - proc.stdin.write(frame) - proc.stdin.flush() - lines = iter(proc.stdout) - while True: - event = (guarded.next(deadline=launch_deadline) if guarded is not None - else json.loads(line) if (line := next(lines, None)) is not None else None) - if event is None: - break - kind = event.get("kind") - if kind == "spawned" and isinstance(event.get("pid"), int): - if record is not None: - assert record_path is not None - group = event.get("process_group") - record.update(phase="spawned", host_pid=event["pid"], - process_group=group if isinstance(group, int) else None) - _write_host_process_record(record_path, record) - launch_deadline = None - launch_scope.close() - elif kind in {"stdout", "stderr"} and isinstance(event.get("text"), str): - consume = on_stdout if kind == "stdout" else on_stderr - if consume is not None: - consume(event["text"]) - elif kind == "result" and event.get("outcome") in { - "exited", - "timeout", - "cancelled", - "output_limit", - "spawn_failed", - }: - result = event - launch_deadline = None - launch_scope.close() - elif kind == "not_launched" and guarded is not None: - guarded.not_launched = True - raise TimeoutError("managed Host bridge launch deadline expired") - else: - raise RuntimeError("Invalid managed Host process observation") - finally: - try: - if guarded is not None and (launch_deadline is not None or sys.exc_info()[0] is not None): - # A failed handoff still owns the Goal fence. The TS bridge must - # report no launch or finish its Host cleanup before it releases. - guarded.cancel() - if record is not None and guarded.not_launched: - assert record_path is not None - record.update(phase=HOST_PROCESS_NOT_LAUNCHED, process_group=None) - record.pop("bridge_pid", None) - _write_host_process_record(record_path, record) - else: - # EOF asks the TS supervisor to clean its Host, also on a - # callback failure. Leased cleanup retains its original budget. - proc.stdin.close() - try: - proc.wait(timeout=8 if delegated_lease is not None else 5) - except subprocess.TimeoutExpired: - proc.kill() - proc.wait() - finally: - launch_scope.close() - if guarded is not None: - guarded.close_output() - else: - proc.stdout.close() - if record is not None and result is not None and proc.returncode == 0: - assert record_path is not None - # The supervisor returned only after cleaning its group; the group is still re-read. - record["phase"] = "finished" - _write_host_process_record(record_path, record) - if proc.returncode != 0 or result is None: - raise RuntimeError("Managed Host process supervision returned no result") - return result + return bridge_environment, record_value, record_path, supervision, supervises diff --git a/loopx/control_plane/turn_driver/host_todo_completion.ts b/loopx/control_plane/turn_driver/host_todo_completion.ts index 709c7e637e..f92348f06d 100644 --- a/loopx/control_plane/turn_driver/host_todo_completion.ts +++ b/loopx/control_plane/turn_driver/host_todo_completion.ts @@ -11,7 +11,9 @@ import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { normalizeVisionUnchangedReason } from "../goals/vision_checkpoint.ts"; import { parseExactGoalRef } from "../goals/goal_instance_identity.ts"; import { projectMcpInteraction } from "./host_interaction.ts"; +import { TURN_CONTROLLER_CONTRACT } from "./turn_contract_generated.ts"; import { + jsonObject, requireBoolean, requireJsonObject, requireNonEmptyString, @@ -23,18 +25,21 @@ export const HOST_TODO_COMPLETION_TRANSACTION_SCHEMA_VERSION = "loopx_host_todo_completion_transaction_v0"; export const HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION = "loopx_host_todo_completion_transaction_v1"; +export const HOST_TODO_FIRST_DELIVERY_TRANSACTION_SCHEMA_VERSION = "loopx_host_todo_completion_transaction_v2"; export const HOST_TODO_COMPLETION_REDUCTION_SCHEMA_VERSION = "loopx_host_todo_completion_reduction_v0"; export const HOST_ADAPTER_SETTLEMENT_SCHEMA_VERSION = "host_adapter_todo_settlement_v0"; -const PHASES = ["prepare", "finalize", "classify_guard", "vision_refresh", "vision_context", "project_guard"] as const; +const PHASES = ["prepare", "finalize", "classify_guard", "vision_refresh", "vision_context", "project_guard", "wait"] as const; const STEP_KINDS = [ "guard", "lifecycle_completion", "durable_writeback", "quota_spend", "terminal_closeout", + "delivery_context", + "direction_context", ] as const; const TODO_ID_PATTERN = /^todo_[a-z0-9_-]{3,64}$/; @@ -56,7 +61,11 @@ interface HostTodoCompletionRequest { vision_path: string | null; vision_unchanged_reason: string | null; checkpoint_read_context_id: string | null; + first_delivery: boolean; + current_agent_checkpoint: boolean; + delivery_read_context_id: string | null; goal_instance_id: string | null; + turn_instance_id: string | null; provider_outcomes: readonly ProviderOutcome[]; } @@ -82,7 +91,7 @@ interface GuardSelection extends JsonObject { function decodePhase(value: JsonObject): HostTodoCompletionPhase { requireStringLiteral( value.schema_version, - [HOST_TODO_COMPLETION_TRANSACTION_SCHEMA_VERSION, HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION] as const, + [HOST_TODO_COMPLETION_TRANSACTION_SCHEMA_VERSION, HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION, HOST_TODO_FIRST_DELIVERY_TRANSACTION_SCHEMA_VERSION] as const, "schema_version", "schema_version is unsupported", ); @@ -131,14 +140,22 @@ function decodeRequest( const todoId = typedTodoId(value.todo_id, "todo_id"); const optionalText = (field: string): string | null => value[field] == null ? null : requireNonEmptyString(value[field], field); - const visionPath = optionalText("vision_path"); - const unchanged = normalizeVisionUnchangedReason(optionalText("vision_unchanged_reason")); + const defaultCheckpoint = value.current_agent_checkpoint === true; + const visionPath = defaultCheckpoint && !value.checkpoint_read_context_id ? null : optionalText("vision_path"); + const unchanged = defaultCheckpoint && !value.checkpoint_read_context_id ? null : normalizeVisionUnchangedReason(optionalText("vision_unchanged_reason")); const readContextId = optionalText("checkpoint_read_context_id"); + const firstDelivery = value.schema_version === HOST_TODO_FIRST_DELIVERY_TRANSACTION_SCHEMA_VERSION; + const resultContextId = optionalText("delivery_read_context_id"); + if (value.first_delivery === true && !firstDelivery) throw new EffectRuntimeRequestError("First delivery requires the v2 host contract."); + if (firstDelivery && value.first_delivery !== true) throw new EffectRuntimeRequestError("v2 requires explicit first_delivery opt-in."); + if (firstDelivery && value.no_follow_up === true && !defaultCheckpoint) { + throw new EffectRuntimeRequestError("Protected no-follow-up completion is not supported by this protocol stage; no effects were admitted. Use the existing ordinary contract or a real successor intent, without changing an unresolved original Turn."); + } if (visionPath && unchanged) { throw new EffectRuntimeRequestError("choose a vision patch or an unchanged reason, not both"); } if ((visionPath || unchanged || readContextId || phase === "vision_refresh" || phase === "vision_context") && - value.schema_version !== HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION) { + value.schema_version !== HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION && !firstDelivery) { throw new EffectRuntimeRequestError("host vision authoring requires v1"); } if (phase === "vision_refresh" && !visionPath && !unchanged) { @@ -147,7 +164,7 @@ function decodeRequest( if (phase === "vision_context" && (visionPath || unchanged || readContextId)) { throw new EffectRuntimeRequestError("vision context reads cannot submit a decision or receipt"); } - if (readContextId && phase !== "vision_refresh") { + if (readContextId && phase !== "vision_refresh" && !firstDelivery) { throw new EffectRuntimeRequestError("checkpoint read context belongs only to vision recovery"); } const goalId = requireNonEmptyString(value.goal_id, "goal_id"); @@ -194,6 +211,10 @@ function decodeRequest( vision_path: visionPath, vision_unchanged_reason: unchanged, checkpoint_read_context_id: readContextId, + first_delivery: firstDelivery, + current_agent_checkpoint: defaultCheckpoint, + delivery_read_context_id: resultContextId, + turn_instance_id: optionalText("turn_instance_id"), goal_instance_id: parsedGoalRef?.kind === "parsed" ? parsedGoalRef.value.goalInstanceId.value : null, @@ -202,10 +223,14 @@ function decodeRequest( if (phase === "finalize") { request.provider_outcomes = decodeProviderOutcomes(value.provider_outcomes); } + if (firstDelivery && (visionPath || unchanged) && !readContextId) { + throw new EffectRuntimeRequestError("Read the post-result direction context before authoring Vision; an adapter cannot bind old Vision to a new read."); + } return request; } function turnInstanceId(request: HostTodoCompletionRequest): string { + if (request.turn_instance_id !== null) return request.turn_instance_id; const identityParts = request.goal_instance_id === null ? [request.goal_id, request.agent_id, request.todo_id] : [ @@ -286,10 +311,14 @@ function exactIdentityConditions( ]; } -function guardContinueWhen(identity: JsonObject): JsonObject { +function guardContinueWhen(identity: JsonObject, requireOriginalWork = true): JsonObject { const path = ["heartbeat_receipt", "settlement_identity"]; return all( equals(["ok"], true), + ...(requireOriginalWork ? [any(nullish(["heartbeat_receipt", "result_freshness"]), + equals(["work_input_restored"], true), + equals(["result_committed"], true), + objectAt(["writeback_run"]))] : []), any( all( objectAt(path), @@ -378,6 +407,7 @@ function writebackArgs(request: HostTodoCompletionRequest, identity: JsonObject) ...(request.vision_path ? ["--agent-vision-json", request.vision_path] : []), ...(request.vision_unchanged_reason ? ["--vision-unchanged-reason", request.vision_unchanged_reason] : []), ...(request.checkpoint_read_context_id ? ["--checkpoint-read-context", request.checkpoint_read_context_id] : []), + ...(request.first_delivery ? ["--first-delivery", "--progress-scope", "goal"] : []), ]; } @@ -402,11 +432,12 @@ function providerSteps( : []), ]; const lifecycleArgs = request.completion_args.filter( - (arg) => arg !== "--no-follow-up", + (arg) => request.current_agent_checkpoint || arg !== "--no-follow-up", ); if (request.goal_instance_id) { lifecycleArgs.push("--goal-instance-id", request.goal_instance_id); } + if (request.delivery_read_context_id) lifecycleArgs.push("--delivery-read-context", request.delivery_read_context_id); const steps: ProviderStep[] = [ { step_kind: "guard", @@ -420,7 +451,7 @@ function providerSteps( "--execution-mode", request.execution_mode, ], - continue_when: guardContinueWhen(identity), + continue_when: guardContinueWhen(identity, !request.first_delivery), }, { step_kind: "lifecycle_completion", @@ -477,6 +508,15 @@ function providerSteps( continue_when: null, }); } + if (request.first_delivery && !request.checkpoint_read_context_id) { + const purpose = request.delivery_read_context_id || request.current_agent_checkpoint ? "first_delivery" : "delivery_result"; + const context: ProviderStep = {step_kind: purpose === "delivery_result" ? "delivery_context" : "direction_context", + args: ["checkpoint-context", "--goal-id", request.goal_id, "--agent-id", request.agent_id, + "--todo-id", request.todo_id, "--turn-instance-id", turnId, "--purpose", purpose, + ...(request.goal_instance_id ? ["--goal-instance-id", request.goal_instance_id] : [])], + legacy_args: null, continue_when: null}; + return [...steps.slice(0, purpose === "first_delivery" ? 2 : 1), context]; + } return steps; } @@ -741,6 +781,39 @@ function finalize(request: HostTodoCompletionRequest): JsonObject { const steps = providerSteps(request, identity); const outcomes = request.provider_outcomes; validateOutcomeOrder(outcomes, steps); + if (request.first_delivery && !request.checkpoint_read_context_id) { + const last = outcomes.at(-1); + const context = last ? parseObject(last.output) : null; + const postResult = request.delivery_read_context_id !== null || request.current_agent_checkpoint; + const expectedStep = postResult ? "direction_context" : "delivery_context"; + if (outcomes.length !== steps.length || last?.step_kind !== expectedStep || context?.ok !== true || + !identityMatches(context, identity)) { + return reduction("finalize", "provider_result", identity, null, context ?? {ok: false, error: "First delivery stopped before context readback."}); + } + const committedContext = jsonObject(context.context); + if (request.current_agent_checkpoint && context.status === "committed" && + committedContext?.purpose === "first_delivery" && typeof committedContext.read_context_id === "string" && + committedContext.read_context_id.length > 0) { + // Resume the proven original append, never attach a new read to an old decision. + return {...reduction("finalize", "execute", identity, + providerEffect(providerSteps({...request, checkpoint_read_context_id: committedContext.read_context_id}, identity)), null), + resume_checkpoint_read_context_id: committedContext.read_context_id}; + } + return reduction("finalize", "provider_result", identity, null, { + schema_version: HOST_ADAPTER_SETTLEMENT_SCHEMA_VERSION, ok: true, completed: postResult, + settlement_complete: false, settlement_identity: identity, + stage: postResult ? "direction_pending" : "result_review_pending", + ...(postResult ? {completion: outcomeAt(outcomes, 1)} : {}), context, + recovery: {tool: "complete_task", ...(request.current_agent_checkpoint ? {} : {first_delivery: true}), + delivery_read_context_id: request.delivery_read_context_id ?? context.read_context_id, + ...(postResult ? {read_context_id: context.read_context_id} : {}), + instruction: postResult + ? request.current_agent_checkpoint + ? "Read this post-result context and judge direction. Continue complete_task with its read_context_id, the new Vision decision and your original completion arguments. The original result key is restored automatically; replay does not repeat work." + : "Read this post-result context and judge direction. Call complete_task with the original completion intent, both read identities and the new Vision decision. Result replay does not repeat work." + : "Read this result basis, check the candidate and validation, then call complete_task with this delivery_read_context_id and the same intended result. Do not submit Vision until the post-result context is returned."}, + }); + } if (outcomes.length === 0) { return reduction( @@ -753,6 +826,16 @@ function finalize(request: HostTodoCompletionRequest): JsonObject { } const guard = guardSelection(outcomes[0].output); + const guardPayload = parseObject(outcomes[0].output); + if (jsonObject(guardPayload?.heartbeat_receipt)?.result_freshness === "turn_result_freshness_required_v1" && + guardPayload?.work_input_restored !== true && guardPayload?.result_committed !== true) { + return reduction("finalize", "blocked", identity, null, { + ...blockedResult(request, "validation", "No original work read preceded this result. Revalidate the retained candidate against the returned basis before retrying."), + error_code: "delivery_work_read_required", settlement_identity: identity, + context: guardPayload?.delivery_result_context ?? null, + recovery: {tool: "complete_task", instruction: "Read the returned work basis and validate this candidate on the original Turn, then retry the same completion. The adapter carries its original key; preserve valid artifacts."}, + }); + } if (guard.state !== "selected") { return reduction( "finalize", @@ -981,6 +1064,7 @@ function finalize(request: HostTodoCompletionRequest): JsonObject { ok: true, completed: true, status: "done", + ...(request.current_agent_checkpoint ? {settlement_complete: true, stage: "settled"} : {}), goal_id: request.goal_id, todo_id: request.todo_id, settlement_identity: identity, @@ -998,6 +1082,48 @@ function finalize(request: HostTodoCompletionRequest): JsonObject { }); } + +// Unfinished MCP work uses the same scoped guard owner. No lifecycle, vision, +// result validation, writeback or quota effect belongs to this quiet wait. +function waitForUnfinishedWork(request: HostTodoCompletionRequest, value: JsonObject): JsonObject { + const reason = requireNonEmptyString(value.wait_reason, "wait_reason").trim(); + if (!reason || reason.length > 240) throw new EffectRuntimeRequestError("wait_reason must contain 1..240 characters"); + if (request.completion_args.length || request.no_follow_up || request.first_delivery || + request.current_agent_checkpoint || request.vision_path || request.vision_unchanged_reason || + request.checkpoint_read_context_id || request.delivery_read_context_id) { + throw new EffectRuntimeRequestError("unfinished wait cannot submit completion or a vision decision"); + } + if (request.turn_instance_id === null) { + return {schema_version: HOST_TODO_COMPLETION_REDUCTION_SCHEMA_VERSION, phase: "wait", + result: {schema_version: HOST_ADAPTER_SETTLEMENT_SCHEMA_VERSION, ok: false, completed: false, + error: "Read should_run before waiting on an admitted unfinished Todo."}}; + } + const {payload: identity} = expectedIdentity(request); + if (value.guard_output === undefined) { + const guard = providerSteps(request, identity)[0]; + return {schema_version: HOST_TODO_COMPLETION_REDUCTION_SCHEMA_VERSION, phase: "wait", identity, + args: [...guard.args], legacy_args: guard.legacy_args}; + } + if (typeof value.guard_output !== "string") throw new EffectRuntimeRequestError("guard_output must be a string"); + let guard: JsonObject | null = null; + try {guard = jsonObject(JSON.parse(value.guard_output));} catch { /* Reject corrupt provider output below. */ } + const selection = guardSelection(value.guard_output); + const selected = jsonObject(guard?.selected_todo); + const valid = guard?.ok === true && guard.should_run === true && guard.normal_delivery_allowed === true && + guard.work_input_restored === true && selected?.status === "open" && selected.todo_id === request.todo_id && + selection.state === "selected" && selection.todo_id === request.todo_id && + selection.settlement_identity !== null && guardIdentityMatches(selection, identity); + if (!valid) return {schema_version: HOST_TODO_COMPLETION_REDUCTION_SCHEMA_VERSION, phase: "wait", identity, + result: blockedResult(request, "guard", reasonFrom(guard) || "Current guard does not admit unfinished work on the original Turn.")}; + return {schema_version: HOST_TODO_COMPLETION_REDUCTION_SCHEMA_VERSION, phase: "wait", identity, + result: {schema_version: HOST_ADAPTER_SETTLEMENT_SCHEMA_VERSION, ok: true, completed: false, + settlement_complete: false, goal_id: request.goal_id, todo_id: request.todo_id, settlement_identity: identity, + result_kind: TURN_CONTROLLER_CONTRACT.vocabularies.turn_result_kind.WAIT, reason, + task_validation: {status: "not_required", validator_kind: "stop_result"}, + effects: {host_invoked: false, todo_completed: false, state_written: false, quota_spent: false}, + next_tool: "should_run"}}; +} + export function evaluateHostTodoCompletion(value: JsonObject): JsonObject { const phase = decodePhase(value); if (phase === "project_guard") { @@ -1019,6 +1145,7 @@ export function evaluateHostTodoCompletion(value: JsonObject): JsonObject { }; } const request = decodeRequest(value, phase); + if (phase === "wait") return waitForUnfinishedWork(request, value); if (phase === "finalize") return finalize(request); const { payload: identity } = expectedIdentity(request); if (phase === "vision_context") { @@ -1029,7 +1156,7 @@ export function evaluateHostTodoCompletion(value: JsonObject): JsonObject { "--turn-instance-id", String(identity.turn_instance_id), ...(request.goal_instance_id ? ["--goal-instance-id", request.goal_instance_id] - : [])], + : []), ...(request.first_delivery ? ["--purpose", "first_delivery", "--decision-scope", "goal"] : [])], }; } if (phase === "vision_refresh") { diff --git a/loopx/control_plane/turn_driver/journal_store.py b/loopx/control_plane/turn_driver/journal_store.py index 80a9dd4339..5b2a498932 100644 --- a/loopx/control_plane/turn_driver/journal_store.py +++ b/loopx/control_plane/turn_driver/journal_store.py @@ -193,3 +193,41 @@ def turn_journal_observed_capabilities( if not isinstance(observed, list) or not all(isinstance(item, str) for item in observed): return None return observed + + +def reconcile_settled_turn_journal( + runtime_root: Path, *, registry_path: Path, settlement_identity: Mapping[str, Any], +) -> dict[str, Any] | None: + """Transport native receipts into the original journal; never re-enter a Host.""" + from ..effect_program import SettlementIdentity + from ..quota.accounting_admission import quota_accounting_admission + + # Reuse the typed identity codec; a rendered id cannot override its fields. + identity = SettlementIdentity( + goal_id=settlement_identity["goal_id"], agent_id=settlement_identity["agent_id"], + todo_id=settlement_identity["todo_id"], turn_instance_id=settlement_identity["turn_instance_id"], + ) + if identity.effect_id != settlement_identity.get("effect_id"): + raise ValueError("Settlement identity fields differ from their effect id") + key = find_loopx_turn_key_by_settlement_identity(runtime_root, + goal_id=identity.goal_id, agent_id=identity.agent_id, todo_id=identity.todo_id, + turn_instance_id=identity.turn_instance_id) + if key is None: + return None + path = turn_journal_path(runtime_root, goal_id=identity.goal_id, turn_key=key) + journal = load_turn_journal(path) + goal_ref = journal["plan"].get("goal_ref") + with quota_accounting_admission(runtime_root=runtime_root, registry_path=registry_path, + goal_id=identity.goal_id, goal_ref=goal_ref, operation="turn-journal-receipt-reconciliation") as admission: + with exclusive_cross_runtime_file_lock(runtime_artifact_lock_target(runtime_root, identity.goal_id), + operation="turn_journal_runtime_artifact_guard"): + with exclusive_cross_runtime_file_lock(shadow_maintenance_lock_target(runtime_root, identity.goal_id), + operation="turn_journal_runtime_artifact_commit"): + request = {"path": str(path.resolve()), "runtime_root": str(runtime_root.resolve()), + "settlement_identity": identity.as_dict()} + if goal_ref is not None: + request.update(goal_ref=goal_ref, source_admission=admission) + result = effect_runtime_result("turn_journal.reconcile_settlement", request) + if not isinstance(result, dict) or result.get("ok") is not True or result.get("status") != "committed": + raise RuntimeError("Native Turn journal reconciliation did not commit") + return result diff --git a/loopx/control_plane/turn_driver/recovery.py b/loopx/control_plane/turn_driver/recovery.py index a934d350f5..b9380adf32 100644 --- a/loopx/control_plane/turn_driver/recovery.py +++ b/loopx/control_plane/turn_driver/recovery.py @@ -2,9 +2,12 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import Any +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from ..goals.checkpoint_context_io import TurnResultWork from .session_recovery import ( SessionBindingResolver, @@ -124,3 +127,176 @@ def build_turn_recovery_audit( "host_invoked": host_invoked, }, } + + +def preview_turn_recovery(plan: Mapping[str, Any], host_projection: Mapping[str, Any], *, + journal: Mapping[str, Any] | None, request: Mapping[str, Any], + goal_id: str, turn_key: str, retry_failed: bool, + session_binding_resolver: SessionBindingResolver | None, + empty_effects: Mapping[str, bool]) -> dict[str, Any]: + """Read-only preview of the same retained-Turn recovery used by execution.""" + from .execution_readback import execution_payload + from .journal_store import LOOPX_TURN_JOURNAL_SCHEMA_VERSION + preview = { + "schema_version": LOOPX_TURN_JOURNAL_SCHEMA_VERSION, + "status": "preview", + "host": host_projection, + "result_kind": None, + "receipt": None, + "scheduler": {"disposition": "not_evaluated"}, + } + # A retained Turn may only need settlement. Project the same typed + # recovery owner that execution consumes, without changing its journal + # or calling any execution callback. A new Turn keeps the original plan. + preview_plan = plan + if journal is not None: + envelope = plan.get("turn_envelope") + assessment = assess_existing_turn_recovery( + journal, request, goal_id=goal_id, + agent_id=str(envelope.get("agent_id") or "") + if isinstance(envelope, Mapping) else "", + turn_key=turn_key, + retry_failed=retry_failed, + session_binding_resolver=session_binding_resolver, + ) + preview_plan = {**plan, "route": { + **plan["route"], + "would_invoke_host": assessment.decision["reinvoke_host"], + }} + preview["recovery_audit"] = build_turn_recovery_audit( + assessment.decision, journal, status="finished", host_invoked=False + ) + return execution_payload( + preview_plan, + preview, + execute=False, + replayed=False, + effects=empty_effects, + ) + + +def prepare_terminal_host_recovery(journal: Mapping[str, Any] | None, request: dict[str, Any], *, + plan: Mapping[str, Any], goal_id: str, turn_key: str, + result_work: TurnResultWork | None, + session_binding_resolver: SessionBindingResolver | None) -> tuple[dict[str, Any], bool]: + """Restore only unfinished terminal validation and its original request.""" + validation_reinvokes_host = _validation_reinvokes_host(journal) + terminal_retry = (journal or {}).get("result_revalidation") or {} + terminal_view = terminal_retry.get("validation", {}) + if terminal_retry.get("phase") == "terminal_closeout" and terminal_view.get("status") == "failed": + assessment = assess_existing_turn_recovery({"schema_version": journal["schema_version"], + "goal_id": goal_id, "turn_key": turn_key, "plan": journal["plan"], **terminal_view}, request, + goal_id=goal_id, agent_id=str(plan["turn_envelope"]["agent_id"]), turn_key=turn_key, + retry_failed=True, session_binding_resolver=session_binding_resolver) + request = require_turn_recovery_continuation(assessment) + terminal_work_changed = (result_work is not None and terminal_retry.get("phase") == "terminal_closeout" + and terminal_retry.get("validation", {}).get("work_context") != result_work.read()) + needs_host = ( + validation_reinvokes_host + or journal is None + or "typed_result" not in list(journal.get("completed_phases") or []) + or terminal_work_changed + or isinstance(journal.get("result_revalidation"), dict) and + journal["result_revalidation"].get("phase") == "terminal_closeout" and + ("typed_result" not in journal["result_revalidation"].get("validation", {}).get("completed_phases", []) or + journal["result_revalidation"].get("validation", {}).get("status") == "failed") + ) + return request, needs_host + + +def _validation_reinvokes_host(journal: Mapping[str, Any] | None) -> bool: + receipt = journal.get("receipt") if isinstance(journal, Mapping) else None + return ( + isinstance(journal, Mapping) + and journal.get("status") == "failed" + and isinstance(receipt, Mapping) + and receipt.get("failed_phase") == "validation" + and journal.get("validation_stage") != "task_postcondition" + ) + + +def reset_failed_turn_for_retry(journal: dict[str, Any] | None, + persist_journal: Callable[[Mapping[str, Any]], None]) -> None: + """Reset failed transport phases while retaining committed result facts.""" + from .transaction import TRANSACTION_PHASES + if journal and journal.get("status") == "failed": + receipt = ( + journal.get("receipt") + if isinstance(journal.get("receipt"), dict) + else {} + ) + if receipt.get("failed_phase") == "validation": + if _validation_reinvokes_host(journal): + journal.pop("host_result", None) + journal.pop("result_kind", None) + journal["completed_phases"] = ( + list(TRANSACTION_PHASES[:2]) + if isinstance(journal.get("host_result"), dict) + else [] + ) + journal.pop("task_validation", None) + journal.pop("validation_stage", None) + journal.pop("reason", None) + journal.pop("receipt", None) + journal.pop("host_recovery", None) + journal.pop("host_failure", None) + journal["status"] = "in_progress" + persist_journal(journal) + + +def recover_existing_turn(plan: Mapping[str, Any], journal: Mapping[str, Any], + recovery_decision: Mapping[str, Any], *, empty_effects: Mapping[str, bool], + post_settlement: Callable[..., dict[str, Any]] | None) -> dict[str, Any]: + """Read and recover only the already settled Turn's post-settlement work.""" + from .execution_readback import execution_payload + from .post_settlement import recover_post_settlement + payload = execution_payload( + plan, + journal, + execute=True, + replayed=True, + effects=empty_effects, + ) + payload.update(recover_post_settlement( + plan=plan, post_settlement=post_settlement, journal=journal, + )) + payload["recovery"] = build_turn_recovery_audit( + recovery_decision, + journal, + status="finished", + host_invoked=False, + ) + return payload + + +def admit_unfinished_host(plan: Mapping[str, Any], *, needs_host: bool, + admit_start: Callable[[Mapping[str, Any]], dict[str, Any]] | None, + journal: Mapping[str, Any] | None, turn_key: str, + host_projection: Mapping[str, Any], empty_effects: Mapping[str, bool] + ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + """Reserve cadence only for a real unfinished Host attempt, before writes.""" + from .execution_readback import execution_payload + admission = None + if needs_host and admit_start is not None: + admission = admit_start( + { + "turn_key": turn_key, + "attempt": int(journal.get("host_attempt_count") or 0) + 1 + if journal is not None + else 1, + } + ) + if admission.get("admitted") is not True: + waiting = { + "status": "interval_wait", + "host": host_projection, + "completed_phases": [], + "reason": str( + admission.get("reason") or "automatic start not admitted" + ), + "admission": admission, + } + return admission, execution_payload( + plan, waiting, execute=True, replayed=False, effects=empty_effects + ) + return admission, None diff --git a/loopx/control_plane/turn_driver/settlement.py b/loopx/control_plane/turn_driver/settlement.py index ee68b9cf2d..f805963e6b 100644 --- a/loopx/control_plane/turn_driver/settlement.py +++ b/loopx/control_plane/turn_driver/settlement.py @@ -389,12 +389,12 @@ def terminal_closeout_requirement( writeback_payload = ( stored_writeback if isinstance(stored_writeback, Mapping) else {} ) - observed_completion = terminal_payload.get("completion") or writeback_payload.get( - "completion" - ) + observed_completion = journal.get("completion_intent") or terminal_payload.get("completion") or writeback_payload.get("completion") try: if not isinstance(observed_completion, Mapping): observed_completion = completion_intent(result) + if isinstance(journal, dict): + journal["completion_intent"] = dict(observed_completion) envelope = plan.get("turn_envelope") selected = selected_turn_todo(envelope) if isinstance(envelope, Mapping) else {} outcome = require_loopx_turn_completion_outcome( diff --git a/loopx/control_plane/turn_driver/turn_journal.ts b/loopx/control_plane/turn_driver/turn_journal.ts index 3ab05a7c27..b8e6c9ce41 100644 --- a/loopx/control_plane/turn_driver/turn_journal.ts +++ b/loopx/control_plane/turn_driver/turn_journal.ts @@ -346,7 +346,7 @@ function hasExactKeys(value: JsonObject, expected: readonly string[]): boolean { expected.every((key) => Object.hasOwn(value, key)); } -function hostRetryPolicyCheck(journal: JsonObject): TurnRecoveryCheck | null { +export function hostRetryPolicyCheck(journal: JsonObject): TurnRecoveryCheck | null { if (!Object.hasOwn(journal, "host_failure")) return null; const failure = asObject(journal.host_failure); const retry = asObject(failure.retry); diff --git a/loopx/control_plane/turn_driver/turn_journal_effects.ts b/loopx/control_plane/turn_driver/turn_journal_effects.ts index 730302f7bb..4c9ff1c0be 100644 --- a/loopx/control_plane/turn_driver/turn_journal_effects.ts +++ b/loopx/control_plane/turn_driver/turn_journal_effects.ts @@ -20,6 +20,8 @@ import { decideFirstPartyHostRuntime } from "../goals/first_party_host_runtime.t import { parseExactGoalRef } from "../goals/goal_instance_identity.ts"; import { requireNonEmptyString as requiredString } from "../runtime_decode.ts"; import { preparedAttemptViolation } from "./turn_journal_attempt_contract.ts"; +import { readQuotaSettlementForAdmittedOwnerFromSnapshot, readQuotaSettlementSnapshot } from "../quota/settlement_readback.ts"; +import { parseQuotaAccountingOwner, requireQuotaOwnerProjection, withQuotaAccountingOwner } from "../quota/source_admission.ts"; import { interpretTurnJournalEffect, journalPhaseViolation, @@ -112,6 +114,92 @@ function samePrefix(left: readonly string[], right: readonly string[]): boolean return left.length <= right.length && left.every((phase, index) => phase === right[index]); } +function resultValidationInvalidated(existing: JsonObject, incoming: JsonObject, + previous: JournalState, next: JournalState): boolean { + if (previous.status !== "in_progress" || next.status !== "failed" || + previous.completedPhases.length !== 3 || next.completedPhases.length !== 2 || + next.failedPhase !== "validation" || incoming.validation_stage !== "result_freshness") return false; + // Only a rejected, uncommitted result may invalidate its validation checkpoint. + // Prepared effects must already have been aborted by the settlement owner. + if ([existing, incoming].some(journal => ["writeback", "quota_spend", "terminal_closeout", + "effect_attempts", "source_effect_hold"].some(key => journal[key] !== undefined))) return false; + const context = asObject(existing.result_work_context); + const retry = asObject(incoming.result_revalidation); + const rejection = asObject(retry.rejection); + return rejection.ok === false && + ["checkpoint_read_context_stale", "delivery_validation_binding_mismatch"].includes(String(rejection.error_code)) && + typeof context.read_context_id === "string" && context.read_context_id.length > 0 && + typeof existing.task_validation === "object" && existing.task_validation !== null && + typeof existing.host_result === "object" && existing.host_result !== null && + operationId(context) === operationId(asObject(incoming.result_work_context)) && + operationId(context) === operationId(asObject(retry.original_context)) && + operationId(asObject(existing.host_result)) === operationId(asObject(retry.candidate)) && + incoming.task_validation === undefined && incoming.result_work_binding === undefined; +} + +function requireTerminalResultRevalidation(existing: JsonObject, incoming: JsonObject, + previous: JournalState, next: JournalState): void { + const prior = asObject(existing.result_revalidation); + const retry = asObject(incoming.result_revalidation); + if (prior.phase !== "terminal_closeout" && retry.phase !== "terminal_closeout") return; + if (previous.completedPhases.length !== 5 || next.completedPhases.length !== 5 || + [existing, incoming].some(journal => journal.terminal_closeout !== undefined || journal.effect_attempts !== undefined) || + ["writeback", "quota_spend"].some(key => existing[key] == null || incoming[key] == null || + operationId(asObject(existing[key])) !== operationId(asObject(incoming[key])))) { + conflict("Terminal result revalidation must retain the original settled effects"); + } + if (retry.phase === "terminal_closeout") { + const rejection = asObject(retry.rejection); + if (rejection.ok !== false || !["checkpoint_read_context_stale", "delivery_validation_binding_mismatch"] + .includes(String(rejection.error_code))) conflict("Terminal result revalidation requires a known result rejection"); + const original = prior.phase === "terminal_closeout" ? prior : { + candidate: existing.host_result, original_context: existing.result_work_context}; + if (["candidate", "original_context"].some(key => + operationId(asObject(retry[key])) !== operationId(asObject(original[key])))) { + conflict("Terminal result revalidation must retain its original candidate and basis"); + } + if (typeof asObject(retry.original_context).read_context_id !== "string" || + !String(asObject(retry.original_context).read_context_id)) { + conflict("Terminal result revalidation requires its original work key"); + } + const view = asObject(retry.validation); + if (Object.keys(view).length) { + const viewPhases = view.completed_phases; + if (!Array.isArray(viewPhases) || viewPhases.length > 3 || + typeof asObject(view.work_context).read_context_id !== "string" || + !viewPhases.every((phase, index) => phase === transactionPhases[index]) || + !["in_progress", "failed"].includes(String(view.status)) || + ["writeback", "quota_spend", "terminal_closeout", "effect_attempts"].some(key => view[key] !== undefined) || + journalPhaseViolation(String(view.status), viewPhases as string[], + typeof asObject(view.receipt).failed_phase === "string" ? String(asObject(view.receipt).failed_phase) : null)) { + conflict("Terminal result revalidation may execute only Host and validation stages"); + } + } + if (["host_result", "task_validation"].some(key => + operationId(asObject(existing[key])) !== operationId(asObject(incoming[key]))) && + (!Array.isArray(view.completed_phases) || view.completed_phases.length !== 3 || + asObject(view.task_validation).ok !== true || + ["host_result", "task_validation"].some(key => + operationId(asObject(view[key])) !== operationId(asObject(incoming[key]))))) { + // Clearing old validation when recording a rejection is allowed; publishing + // a replacement candidate requires the completed validation view. + if (incoming.task_validation !== undefined || + operationId(asObject(existing.host_result)) !== operationId(asObject(incoming.host_result))) { + conflict("Terminal result revalidation cannot publish an unvalidated replacement"); + } + } + } else { + const view = asObject(prior.validation); + if (!Array.isArray(view.completed_phases) || view.completed_phases.length !== 3 || + asObject(view.task_validation).ok !== true || incoming.result_work_binding == null || + operationId(asObject(view.work_context)) !== operationId(asObject(incoming.result_work_context)) || + operationId(asObject(view.host_result)) !== operationId(asObject(incoming.host_result)) || + operationId(asObject(view.task_validation)) !== operationId(asObject(incoming.task_validation))) { + conflict("Terminal result revalidation requires the newly validated candidate binding"); + } + } +} + function requireJournalState(journal: JsonObject): JournalState { if (journal.schema_version !== "loopx_turn_journal_v0") { throw new EffectRuntimeRequestError( @@ -272,6 +360,13 @@ function requireJournalTransition( if (operationId(asObject(existing.plan)) !== operationId(asObject(incoming.plan))) { conflict("Turn journal transaction plan is immutable", "journal_plan_conflict"); } + requireTerminalResultRevalidation(existing, incoming, previous, next); + for (const key of ["completion_intent", "checkpoint_host_binding"]) { + if (existing[key] !== undefined && operationId(asObject(existing[key])) !== + operationId(asObject(incoming[key]))) { + conflict(`Original Turn ${key} is immutable`); + } + } if (terminalStatuses.has(previous.status)) { const auditOnly = previous.status === next.status && @@ -298,7 +393,7 @@ function requireJournalTransition( samePrefix(next.completedPhases, previous.completedPhases); if ( !samePrefix(previous.completedPhases, next.completedPhases) && - !validationRetryRewind + !validationRetryRewind && !resultValidationInvalidated(existing, incoming, previous, next) ) { conflict("Turn journal completed phases cannot regress or fork"); } @@ -456,3 +551,106 @@ export async function commitTurnJournal( } } } + +/** Reflect provider-verified settlement; never authorize another Host or debit. */ +export function planSettledTurnJournal(existing: JsonObject, readback: JsonObject): JsonObject[] { + const state = requireJournalState(existing); + const plan = asObject(existing.plan); + const parsed = settlementIdentityFromPlan(asObject(plan.transaction)); + if (parsed.failure !== null) conflict("Original journal settlement identity is unavailable"); + const expected = parsed.value; + const identity = asObject(asObject(readback.identity).result).value; + const decoded = settlementIdentityFromPlan({ settlement_plan: { identity } }); + if (readback.found !== true || decoded.failure !== null || + (["goal_id", "agent_id", "binding_kind", "binding_id", "turn_instance_id", "effect_id"] as const) + .some(key => decoded.value[key] !== expected[key])) { + conflict("Provider settlement does not match the original journal identity"); + } + const terminal = asObject(existing.completion_intent).continuation === "no_followup"; + const settled = asObject(asObject(readback[terminal ? "terminal_settlement" : "settlement"]).payload); + const required = ["validation", "durable_writeback", "quota_spend", ...(terminal ? ["terminal_closeout"] : [])]; + const receipts = settled.receipts; + if (settled.ok !== true || !Array.isArray(receipts) || receipts.length !== required.length || + receipts.some((value, index) => { + const receipt = asObject(value); + return receipt.schema_version !== "quota_settlement_receipt_v1" || receipt.step_kind !== required[index] || + receipt.status !== "committed" || receipt.effect_id !== expected.effect_id || + typeof receipt.source_ref !== "string" || !receipt.source_ref.trim(); + }) || !readback.writeback_run || !readback.spend_run) { + conflict("Original journal requires complete verified provider settlement receipts"); + } + if (state.status === "committed") return []; + const committedReceipts = receipts as JsonObject[]; + if (!["failed", "in_progress", "scheduler_action_required"].includes(state.status) || + state.completedPhases.length < 3 || state.completedPhases.length > 5 || + asObject(existing.task_validation).ok !== true || + !["validated_completion", "validated_progress"].includes(String(asObject(existing.host_result).result_kind)) || + existing.effect_attempts !== undefined || existing.source_effect_hold !== undefined || + asObject(plan.host).scheduler_owner !== "outer_controller") { + conflict("Original journal is not eligible for receipt-only settlement reconciliation"); + } + const steps: JsonObject[] = []; + let current = structuredClone(existing); + const audit = existing.settlement_reconciliation ?? { + previous_receipt: existing.receipt, previous_failure: existing.settlement_result, + previous_reason: existing.reason, receipts: committedReceipts, + }; + function advance(count: number, status: string): void { + const phases = transactionPhases.slice(0, count); + const resultKind = asObject(existing.host_result).result_kind; + const next: JsonObject = { ...current, status, completed_phases: phases, result_kind: resultKind, + settlement_reconciliation: audit, + receipt: { ...asObject(current.receipt), ok: true, status: count === 7 ? "committed" : "validated", + result_kind: resultKind, completed_phases: phases, failed_phase: null, + next_phase: transactionPhases[count] ?? null, settlement_effect_id: expected.effect_id, + commit_eligibility: { writeback: true, quota_spend: count >= 4, scheduler_ack: count === 7 }, errors: [] } }; + if (count >= 4) next.writeback = readback.writeback_run; + if (count >= 5) next.quota_spend = readback.spend_run; + if (terminal && count >= 5) next.terminal_closeout = asObject(readback.terminal_closeout).payload ?? committedReceipts.at(-1); + if (count === 7) { + next.scheduler = { completed: true, acknowledged: false, disposition: "outer_controller" }; + next.settlement_result = settled; + delete next.reason; + } + requireJournalTransition(current, next, requireJournalState(current), requireJournalState(next)); + current = next; steps.push(next); + } + if (state.status === "failed") advance(state.completedPhases.length, "in_progress"); + if (state.completedPhases.length < 4) advance(4, "in_progress"); + if (state.completedPhases.length < 5) advance(5, "in_progress"); + advance(7, "committed"); + return steps; +} + +export async function reconcileTurnJournalSettlement(params: JsonObject): Promise { + const path = requiredString(params.path, "path"); + const runtimeRoot = requiredString(params.runtime_root, "runtime_root"); + if (!isAbsolute(path) || !isAbsolute(runtimeRoot)) throw new EffectRuntimeRequestError("Journal reconciliation paths must be absolute"); + const parsed = settlementIdentityFromPlan({ settlement_plan: { identity: params.settlement_identity } }); + if (parsed.failure !== null || parsed.value.binding_kind !== "todo") throw new EffectRuntimeRequestError("Journal reconciliation requires a typed Todo identity"); + const identity = parsed.value; + const owner = parseQuotaAccountingOwner({ runtimeRoot, goalId: identity.goal_id, + goalRefValue: params.goal_ref, sourceAdmissionValue: params.source_admission }); + return withQuotaAccountingOwner(owner, async () => withFileMutationLock(path, async () => { + const journal = asObject(JSON.parse(await readFile(path, "utf8"))); + const original = settlementIdentityFromPlan(asObject(asObject(journal.plan).transaction)); + if (original.failure !== null || (["goal_id", "agent_id", "binding_kind", "binding_id", "turn_instance_id", "effect_id"] as const) + .some(key => original.value[key] !== identity[key]) || + path !== join(runtimeRoot, "goals", identity.goal_id, "turns", `${String(journal.turn_key).slice(7)}.json`)) { + conflict("Journal reconciliation address differs from its original identity"); + } + const binding = parseTurnJournalGoalBinding(journal); + if (binding.kind === "invalid") conflict("Journal reconciliation has invalid GoalRef binding"); + requireQuotaOwnerProjection(owner, binding.kind === "exact" ? binding.goal_ref : undefined, "Turn journal"); + const readback = readQuotaSettlementForAdmittedOwnerFromSnapshot({ + schema_version: "loopx_quota_settlement_readback_request_v0", runtime_root: runtimeRoot, + goal_id: identity.goal_id, agent_id: identity.agent_id, todo_id: identity.todo_id, + turn_instance_id: identity.turn_instance_id, replan_obligation_id: null, + infer_turn_instance_id: false, allow_unbound_binding: false, + }, owner, await readQuotaSettlementSnapshot(runtimeRoot, identity.goal_id)); + const steps = planSettledTurnJournal(journal, readback); + for (const snapshot of steps) await atomicWriteJson(path, snapshot); + return { ok: true, appended: steps.length > 0, replayed: steps.length === 0, + effect_id: identity.effect_id, turn_key: journal.turn_key, status: "committed" }; + })); +} diff --git a/loopx/control_plane/turn_driver/windows_host_job.py b/loopx/control_plane/turn_driver/windows_host_job.py new file mode 100644 index 0000000000..3a23e7cd84 --- /dev/null +++ b/loopx/control_plane/turn_driver/windows_host_job.py @@ -0,0 +1,126 @@ +"""Windows-only kernel containment IO for the existing typed Host supervisor. + +No task, stop, lease or deadline decision belongs here. The parent assigns the +control bridge before sending its first request; the bridge cannot launch a +Host before that input. Neither breakaway nor inherited Job handles is allowed. +""" +from __future__ import annotations + +import ctypes +import re +import time +import uuid +from ctypes import wintypes + + +class _BasicLimit(ctypes.Structure): + _fields_ = [("per_process_time", ctypes.c_longlong), ("per_job_time", ctypes.c_longlong), + ("flags", wintypes.DWORD), ("min_working_set", ctypes.c_size_t), + ("max_working_set", ctypes.c_size_t), ("active_limit", wintypes.DWORD), + ("affinity", ctypes.c_size_t), ("priority", wintypes.DWORD), ("scheduling", wintypes.DWORD)] + + +class _IoCounters(ctypes.Structure): + _fields_ = [(name, ctypes.c_ulonglong) for name in + ("read_ops", "write_ops", "other_ops", "read_bytes", "write_bytes", "other_bytes")] + + +class _ExtendedLimit(ctypes.Structure): + _fields_ = [("basic", _BasicLimit), ("io", _IoCounters), + ("process_memory", ctypes.c_size_t), ("job_memory", ctypes.c_size_t), + ("peak_process_memory", ctypes.c_size_t), ("peak_job_memory", ctypes.c_size_t)] + + +class _Accounting(ctypes.Structure): + _fields_ = [("user", ctypes.c_longlong), ("kernel", ctypes.c_longlong), + ("period_user", ctypes.c_longlong), ("period_kernel", ctypes.c_longlong), + ("page_faults", wintypes.DWORD), ("total", wintypes.DWORD), + ("active", wintypes.DWORD), ("terminated", wintypes.DWORD)] + + +_kernel = ctypes.WinDLL("kernel32", use_last_error=True) +for _name, _arguments, _result in ( + ("CreateJobObjectW", [ctypes.c_void_p, wintypes.LPCWSTR], wintypes.HANDLE), + ("OpenJobObjectW", [wintypes.DWORD, wintypes.BOOL, wintypes.LPCWSTR], wintypes.HANDLE), + ("SetInformationJobObject", [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD], wintypes.BOOL), + ("AssignProcessToJobObject", [wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL), + ("QueryInformationJobObject", [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, ctypes.c_void_p], wintypes.BOOL), + ("TerminateJobObject", [wintypes.HANDLE, wintypes.UINT], wintypes.BOOL), + ("CloseHandle", [wintypes.HANDLE], wintypes.BOOL), +): + _function = getattr(_kernel, _name) + _function.argtypes, _function.restype = _arguments, _result + + +def _checked(ok): + if not ok: + raise ctypes.WinError(ctypes.get_last_error()) + return ok + + +def _active(handle) -> int: + info = _Accounting() + _checked(_kernel.QueryInformationJobObject(handle, 1, ctypes.byref(info), ctypes.sizeof(info), None)) + return info.active + + +def valid_job_name(value: object) -> bool: + return isinstance(value, str) and re.fullmatch(r"Local\\LoopXHost-[0-9a-f]{32}", value) is not None + + +def active_job_processes(name: str) -> int | None: + """Fresh kernel observation. None means the named Job no longer exists. + + An inaccessible/unreadable Job is never absence. Closing this temporary + non-inherited query handle also avoids keeping a dead owner's Job alive. + """ + if not valid_job_name(name): + raise ValueError("invalid managed Host Job identity") + handle = _kernel.OpenJobObjectW(0x0004, False, name) # JOB_OBJECT_QUERY + if not handle: + error = ctypes.get_last_error() + if error == 2: # ERROR_FILE_NOT_FOUND, never access denied + return None + raise ctypes.WinError(error) + try: + return _active(handle) + finally: + _checked(_kernel.CloseHandle(handle)) + + +class WindowsHostJob: + """One execution's non-inherited, non-breakaway Job; no sandbox claims.""" + def __init__(self): + self.name = "Local\\LoopXHost-" + uuid.uuid4().hex + self.handle = _checked(_kernel.CreateJobObjectW(None, self.name)) + try: + if ctypes.get_last_error() == 183: # Never reuse another execution's Job. + raise RuntimeError("managed Host Job identity already exists") + limits = _ExtendedLimit() + limits.basic.flags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, no breakaway + _checked(_kernel.SetInformationJobObject(self.handle, 9, ctypes.byref(limits), ctypes.sizeof(limits))) + except BaseException: + _kernel.CloseHandle(self.handle) + raise + + def __enter__(self): + return self + + def assign(self, process) -> None: + # Popen retains the original process HANDLE, so PID reuse cannot redirect this assignment. + _checked(_kernel.AssignProcessToJobObject(self.handle, int(process._handle))) + + def finish(self) -> None: + if _active(self.handle): + _checked(_kernel.TerminateJobObject(self.handle, 1)) + deadline = time.monotonic() + 5 + while _active(self.handle): + if time.monotonic() >= deadline: + raise TimeoutError("managed Host Job still has active processes") + time.sleep(.01) + + def __exit__(self, *_exc): + try: + self.finish() + finally: + _checked(_kernel.CloseHandle(self.handle)) diff --git a/loopx/control_plane/work_items/lifecycle_recovery.py b/loopx/control_plane/work_items/lifecycle_recovery.py index 61ac44f7a1..e5d8dae6d9 100644 --- a/loopx/control_plane/work_items/lifecycle_recovery.py +++ b/loopx/control_plane/work_items/lifecycle_recovery.py @@ -14,15 +14,16 @@ def receipt_bound_lifecycle_candidate( agent_id: str | None, items: list[dict[str, Any]], derive: Callable[[dict[str, Any]], dict[str, Any] | None], + admitted_source: dict[str, Any] | None = None, ) -> dict[str, Any] | None: facts = project_replan_semantics({ "operation": "lifecycle_source", "guard_recorded_at": guard_recorded_at, - "agent_id": agent_id, "items": items, + "agent_id": agent_id, "items": items, "obligation": admitted_source, }) if not facts.get("items"): return None gaps = facts["items"] - source = derive({"completed_without_successor_items": gaps, + source = admitted_source or derive({"completed_without_successor_items": gaps, "completed_without_successor_count": len(gaps)}) if not source or source.get("obligation_id") != selected_obligation_id: return None diff --git a/loopx/control_plane/work_items/replan_semantics.ts b/loopx/control_plane/work_items/replan_semantics.ts index 6ebf44aaaf..163462d47e 100644 --- a/loopx/control_plane/work_items/replan_semantics.ts +++ b/loopx/control_plane/work_items/replan_semantics.ts @@ -125,6 +125,8 @@ function projectTurnTransition(request: JsonObject): JsonObject { * The selected obligation digest additionally binds Todo and completion identities. */ function lifecycleSource(request: JsonObject): JsonObject { + const obligation = object(request.obligation); + if (Array.isArray(obligation.lifecycle_admission_items)) return admittedLifecycleSource(request, obligation); const agent = optionalNonEmptyString(request.agent_id, "agent_id"); const admitted = typeof request.guard_recorded_at === "string" ? parseTodoTimestampMicros(request.guard_recorded_at) : null; @@ -158,13 +160,55 @@ function lifecycleSource(request: JsonObject): JsonObject { return {items, settled}; } +function admittedLifecycleSource(request: JsonObject, obligation: JsonObject): JsonObject { + const triggers = Array.isArray(obligation.triggers) ? obligation.triggers.map(object) : []; + // New guards seal the original pending rows together with the obligation. + // Older guards retain their historical reconstruction path; no source is + // invented from a now-closed row or a newly observed timestamp. + let source = {items: [] as JsonObject[], settled: false}; + if (Array.isArray(obligation.lifecycle_admission_items)) { + const admitted = obligation.lifecycle_admission_items.map(object); + const current = Array.isArray(request.items) ? request.items.map(object) : []; + const timestamp = typeof request.guard_recorded_at === "string" + ? parseTodoTimestampMicros(request.guard_recorded_at) : null; + const admissionSecond = timestamp === null ? null : timestamp / 1_000_000n * 1_000_000n; + const stable = ["todo_id", "index", "status", "done", "task_class", "archive_state", + "claimed_by", "last_actor_agent_id", "completed_at", + "completion_turn_key", "successor_todo_ids", "superseded_by"]; + const items = triggers.map(trigger => current.find(item => item.todo_id === trigger.todo_id) ?? {}); + const valid = timestamp !== null && triggers.length > 0 && triggers.length <= 3 && + admitted.length === triggers.length && new Set(admitted.map(item => item.todo_id)).size === admitted.length && + triggers.every((trigger, index) => { + const prior = admitted.find(item => item.todo_id === trigger.todo_id); + const item = items[index]; + const completed = typeof prior?.completed_at === "string" ? parseTodoTimestampMicros(prior.completed_at) : null; + return prior !== undefined && current.filter(row => row.todo_id === trigger.todo_id).length === 1 && + prior.status === "done" && prior.done === true && prior.task_class === "advancement_task" && + (prior.archive_state ?? "active") === "active" && prior.claimed_by === request.agent_id && + prior.last_actor_agent_id === request.agent_id && completed !== null && completed <= timestamp && + prior.no_followup !== true && strings(prior.successor_todo_ids).length === 0 && !prior.superseded_by && + stable.every(key => JSON.stringify(prior[key] ?? null) === JSON.stringify(item[key] ?? null)) && + ((item.no_followup !== true && item.updated_at === prior.updated_at && + item.completion_continuation === prior.completion_continuation && + item.completion_recovery === prior.completion_recovery) || + (item.no_followup === true && item.completion_continuation === "no_followup" && + item.completion_recovery === "lifecycle_reentry_terminal_closeout" && + (item.updated_at === prior.updated_at || + (typeof item.updated_at === "string" && parseTodoTimestampMicros(item.updated_at) !== null && + parseTodoTimestampMicros(item.updated_at)! >= admissionSecond!)))); + }); + source = {items: valid ? items : [], settled: valid && items.every(item => item.no_followup === true)}; + } + return source; +} + function lifecycleObligation(request: JsonObject): JsonObject | null { const obligation = object(request.obligation); if (obligation.obligation_id !== request.selected_obligation_id || obligation.resolution_mode !== "todo_lifecycle_settlement") return null; + const triggers = Array.isArray(obligation.triggers) ? obligation.triggers.map(object) : []; const source = lifecycleSource(request); const items = source.items as JsonObject[]; - const triggers = Array.isArray(obligation.triggers) ? obligation.triggers.map(object) : []; if (items.length === 0 || (typeof request.lifecycle_settled === "boolean" && request.lifecycle_settled !== source.settled) || triggers.length !== Math.min(3, items.length) || diff --git a/loopx/control_plane/work_items/semantic_replan_writeback.py b/loopx/control_plane/work_items/semantic_replan_writeback.py index 31ed43607f..9d68905303 100644 --- a/loopx/control_plane/work_items/semantic_replan_writeback.py +++ b/loopx/control_plane/work_items/semantic_replan_writeback.py @@ -210,6 +210,36 @@ def _obligation_was_created_by_current_completion( ) +def _retained_writeback_obligation( + source: dict[str, Any] | None, + *, + guard_scoped: bool, + selected_id: str | None, + lifecycle: Mapping[str, Any] | None, + current_obligation: dict[str, Any] | None, + guard_recorded_at: str | None, + agent_id: str, + todo_items: list[dict[str, Any]], +) -> dict[str, Any] | None: + if source is None or not guard_scoped: + return current_obligation + obligation = current_obligation + if source.get("obligation_id") != selected_id: + raise ValueError("guard_source_invalid: writeback source identity differs") + if not lifecycle: + obligation = source + if (source.get("resolution_mode") == "todo_lifecycle_settlement" + and guard_recorded_at and todo_items): + from ..effect_runtime import effect_runtime_result + obligation = effect_runtime_result("work_item.replan_semantics.project", { + "operation": "lifecycle_obligation", "obligation": source, + "selected_obligation_id": selected_id, + "guard_recorded_at": guard_recorded_at, + "agent_id": agent_id, "items": todo_items, + })["obligation"] + return obligation + + @effect_runtime_request_scope() def qualify_replan_writeback( *, @@ -233,6 +263,7 @@ def qualify_replan_writeback( receipt_bound_replan_review: Mapping[str, Any] | None = None, replan_source_route: Mapping[str, Any] | None = None, replan_explore_reader: Callable[[str, str | None], Mapping[str, Any] | None] | None = None, + receipt_bound_source: dict[str, Any] | None = None, ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: """Return the shared open obligation and the writeback's typed delta. @@ -374,6 +405,13 @@ def qualify_replan_writeback( context.get("replan_transition_candidates", []) if isinstance(candidate.get("obligation"), dict) and candidate["obligation"].get("obligation_id") == guard_semantic_replan_obligation_id), None) + obligation = _retained_writeback_obligation( + receipt_bound_source, guard_scoped=guard_scoped, + selected_id=guard_semantic_replan_obligation_id, + lifecycle=lifecycle, current_obligation=obligation, + guard_recorded_at=receipt_bound_guard_recorded_at, + agent_id=safe_agent_id, todo_items=agent_todo_source_items, + ) if guard_scoped and receipt_bound_replan_review is not None: obligation = project_replan_semantics({ "operation": "receipt_bound_obligation", "guard_scoped": True, @@ -448,6 +486,7 @@ def enforce_open_replan_writeback( guard_semantic_replan_obligation_id: str | None = None, receipt_bound_guard_recorded_at: str | None = None, receipt_bound_replan_review: Mapping[str, Any] | None = None, + receipt_bound_source: dict[str, Any] | None = None, todo_fields: dict[str, Any] | None = None, external_progress_review: Mapping[str, Any] | None = None, capability_evidence: CapabilityReplanEvidence | None = None, @@ -485,6 +524,7 @@ def enforce_open_replan_writeback( receipt_bound_guard_recorded_at=receipt_bound_guard_recorded_at, receipt_bound_replan_review=receipt_bound_replan_review, replan_source_route=replan_source_route, replan_explore_reader=replan_explore_reader, + receipt_bound_source=receipt_bound_source, ) if not obligation: if isinstance(semantic_delta, dict) and semantic_delta.get("accepted") is True: @@ -532,6 +572,7 @@ def qualify_refresh_replan_writeback( settlement_guard_semantic_replan_obligation_id: str | None, receipt_bound_guard_recorded_at: str | None = None, receipt_bound_replan_review: Mapping[str, Any] | None = None, + receipt_bound_source: dict[str, Any] | None = None, newest_first_runs: list[dict[str, Any]], state_text: str, goal_id: str, @@ -548,6 +589,7 @@ def qualify_refresh_replan_writeback( replan_explore_reader: Callable[[str, str | None], Mapping[str, Any] | None] | None = None, capability_evidence: CapabilityReplanEvidence | None = None, guard_capability: Mapping[str, Any] | None = None, + defer_until_direction: bool = False, ) -> RefreshReplanQualification: """Qualify one refresh's replan delta and accountable settlement outcome.""" @@ -598,7 +640,9 @@ def qualify_refresh_replan_writeback( ), ) - semantic_delta = enforce_open_replan_writeback( + # Pending checkpoint is a retained result, not a semantic ACK. The same + # bound obligation is qualified after the current Agent reads that result. + semantic_delta = None if defer_until_direction else enforce_open_replan_writeback( capability_evidence=capability_evidence, guard_capability=guard_capability, newest_first_runs=newest_first_runs, state_text=state_text, @@ -619,6 +663,7 @@ def qualify_refresh_replan_writeback( receipt_bound_guard_recorded_at=receipt_bound_guard_recorded_at, receipt_bound_replan_review=receipt_bound_replan_review, replan_source_route=replan_source_route, replan_explore_reader=replan_explore_reader, + receipt_bound_source=receipt_bound_source, ) if semantic_delta: if semantic_delta.get("retirement") and delivery_outcome != "outcome_gap": diff --git a/loopx/dsh_goal_mode/turn_host_adapter.py b/loopx/dsh_goal_mode/turn_host_adapter.py index 24dc19619d..e76cbdc7e9 100644 --- a/loopx/dsh_goal_mode/turn_host_adapter.py +++ b/loopx/dsh_goal_mode/turn_host_adapter.py @@ -44,6 +44,7 @@ extract_turn_authority as extract_turn_authority, parse_model_json as parse_model_json, render_prompt as render_prompt, + render_checkpoint_prompt, ) from ..control_plane.turn_driver.execution_profile import ( MANAGED_MODEL_DEFAULT, @@ -462,7 +463,7 @@ def _execute_turn_host_request( ) try: - prompt = render_prompt(authority) + prompt = render_checkpoint_prompt(request) if request.get("phase") == "checkpoint_direction" else render_prompt(authority) runner = ( load_dsh_runner(config.dsh_runner.expanduser().resolve()) if config.dsh_runner is not None @@ -548,6 +549,10 @@ def _execute_turn_host_request( try: candidate = parse_model_json(outcome["final_response"]) + if request.get("phase") == "checkpoint_direction": + if candidate is None: + raise DshHostResultError("dsh direction response requires a JSON object") + return candidate if candidate is None and warn is not None: warn("adapter: dsh final response did not contain a JSON result object") return build_result( diff --git a/loopx/extensions/lark/private_conversations.py b/loopx/extensions/lark/private_conversations.py index b168443b41..dc8cf076dd 100644 --- a/loopx/extensions/lark/private_conversations.py +++ b/loopx/extensions/lark/private_conversations.py @@ -221,7 +221,8 @@ def profiles(self) -> dict[str, dict[str, str]]: def health(self) -> dict[str, dict[str, int]]: health: dict[str, dict[str, int]] = {} - for path in [*self.root.glob("*.json"), *self.root.glob("web-projections/*.json")]: + for path in [*self.root.glob("[0-9a-f]" * 24 + ".json"), + *self.root.glob("web-projections/" + "[0-9a-f]" * 24 + ".json")]: row = _read_json(path) entry = health.setdefault(row["binding_id"], {"pending_count": 0, "recovery_count": 0}) if row["status"] != "delivered": @@ -1272,7 +1273,7 @@ def inbox() -> Path: def pending_delivery_paths(self) -> list[Path]: """The durable transport store is the queue; no in-memory admission.""" paths = [] - for path in sorted(self.root.glob("*.json")): + for path in sorted(self.root.glob("[0-9a-f]" * 24 + ".json")): row = _read_json(path) if row["status"] != "delivered": paths.append(path) @@ -1300,7 +1301,7 @@ def _web_projection_paths(self) -> list[Path]: enabled = {row["binding_id"] for row in self.bindings.read()["bindings"] if row.get("enabled") is True and row.get("audience", "owner") == "owner"} sources: dict[str, dict[str, Any]] = {} - for path in sorted(self.root.glob("*.json")): + for path in sorted(self.root.glob("[0-9a-f]" * 24 + ".json")): row = _read_json(path) if (row.get("binding_id") in enabled and row.get("session_id") and row.get("event", {}).get("chat_type") == "p2p" diff --git a/loopx/file_lock.py b/loopx/file_lock.py index 09959b2b80..8b021c0bfb 100644 --- a/loopx/file_lock.py +++ b/loopx/file_lock.py @@ -40,6 +40,7 @@ def write_bytes_atomic( preserve_mode: bool = False, directory_mode: int | None = None, fsync_directory: bool = False, + replace_retry_seconds: float = 0, ) -> None: """Replace one file with `payload` through a same-directory temp file. @@ -52,7 +53,7 @@ def write_bytes_atomic( untouched; an explicit `mode` is applied with chmod. `preserve_mode` keeps an existing destination's mode and wins over both. """ - destination = Path(path).expanduser() + destination = windows_extended_path(Path(path).expanduser()) destination.parent.mkdir(parents=True, exist_ok=True, mode=directory_mode or 0o777) if preserve_mode and destination.exists(): mode = destination.stat().st_mode & 0o777 @@ -69,7 +70,17 @@ def write_bytes_atomic( os.fsync(handle.fileno()) if mode is not None: os.chmod(temporary, mode) - os.replace(temporary, destination) + deadline = time.monotonic() + replace_retry_seconds + while True: + try: + os.replace(temporary, destination) + break + except OSError as error: + remaining = deadline - time.monotonic() + if (os.name != "nt" or getattr(error, "winerror", None) != 5 + or remaining <= 0): + raise + time.sleep(min(0.05, remaining)) if fsync_directory and os.name == "posix": directory_fd = os.open( destination.parent, @@ -91,6 +102,7 @@ def write_json_atomic( preserve_mode: bool = False, directory_mode: int | None = None, fsync_directory: bool = False, + replace_retry_seconds: float = 0, indent: int | None = 2, sort_keys: bool = False, separators: tuple[str, str] | None = None, @@ -116,6 +128,7 @@ def write_json_atomic( preserve_mode=preserve_mode, directory_mode=directory_mode, fsync_directory=fsync_directory, + replace_retry_seconds=replace_retry_seconds, ) @@ -345,7 +358,20 @@ def _write_holder_sidecar(holder_path: Path, record: dict[str, object]) -> None: holder_file.write("\n") holder_file.flush() os.fsync(holder_file.fileno()) - os.replace(temporary_path, holder_path) + # A Windows reader may briefly deny DELETE sharing. Keep the old + # complete diagnostic until atomic replacement succeeds; persistent + # access denial still propagates within this bounded IO window. + deadline = time.monotonic() + 0.5 + while True: + try: + os.replace(temporary_path, holder_path) + break + except OSError as error: + remaining = deadline - time.monotonic() + if (os.name != "nt" or getattr(error, "winerror", None) != 5 + or remaining <= 0): + raise + time.sleep(min(0.05, remaining)) finally: temporary_path.unlink(missing_ok=True) diff --git a/loopx/goal_mode_mcp.py b/loopx/goal_mode_mcp.py index b1cda792dc..69b341983c 100644 --- a/loopx/goal_mode_mcp.py +++ b/loopx/goal_mode_mcp.py @@ -1,4 +1,5 @@ """Shared MCP control plane for host-specific LoopX goal adapters.""" + from __future__ import annotations import json @@ -14,16 +15,19 @@ try: # pydantic ships with the FastMCP extra; base installs have no pydantic. from pydantic import Strict except ImportError: # pragma: no cover - exercised on base installs only + class Strict: # type: ignore[no-redef] """Inert stand-in so the shared annotation still evaluates without pydantic; FastMCP cannot run on such an install anyway.""" + from .control_plane.host_adapter_settlement import ( HostTodoSettlementRequest, host_vision_request, project_host_interaction, refresh_host_todo_vision, settle_host_todo_completion, + settle_host_todo_wait, ) @@ -117,8 +121,7 @@ def _runtime_profile_flag_is_unsupported( result.returncode == 2 and "--runtime-profile" in diagnostic and ( - "unrecognized arguments" in diagnostic - or "invalid choice" in diagnostic + "unrecognized arguments" in diagnostic or "invalid choice" in diagnostic ) ) @@ -140,7 +143,9 @@ def run_cli( [*command, *args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, env=environment, ) - if legacy_args is not None and self._runtime_profile_flag_is_unsupported(result): + if legacy_args is not None and self._runtime_profile_flag_is_unsupported( + result + ): result = subprocess.run( [*command, *legacy_args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, env=environment, @@ -171,7 +176,9 @@ def _identity_error(self, requested_agent_id: str) -> str | None: } ) - def should_run_args(self, goal_id: str, agent_id: str | None) -> tuple[list[str], list[str]]: + def should_run_args( + self, goal_id: str, agent_id: str | None + ) -> tuple[list[str], list[str]]: common = ["quota", "should-run", "--goal-id", goal_id] if agent_id: common += ["--agent-id", agent_id] @@ -188,24 +195,143 @@ def should_run_args(self, goal_id: str, agent_id: str | None) -> tuple[list[str] ], ) - def should_run(self) -> str: + def should_run(self, revalidate_todo_id: str = "") -> str: goal_id, _ = self.context() if not goal_id: return self.no_goal_message() args, legacy_args = self.should_run_args(goal_id, self.bound_agent_id()) - return project_host_interaction(self.run_cli(args, legacy_args=legacy_args)) + suffix = ["--todo-id", revalidate_todo_id] if revalidate_todo_id else [] + guard_suffix = list(suffix) + if revalidate_todo_id: + turn = self._original_work_turn(revalidate_todo_id) + if turn is not None: + guard_suffix += ["--turn-instance-id", turn] + output = self.run_cli([*args, *guard_suffix], legacy_args=[*legacy_args, *guard_suffix]) + if revalidate_todo_id: + guard = json.loads(output) + if guard.get("ok") is True and guard.get("delivery_result_context"): + identity = guard["delivery_result_context"]["settlement_identity"] + refreshed = json.loads( + self.run_cli( + [ + "checkpoint-context", + "--goal-id", + goal_id, + "--agent-id", + self.bound_agent_id(), + *suffix, + "--turn-instance-id", + identity["turn_instance_id"], + "--purpose", + "delivery_result", + ] + ) + ) + if refreshed.get("ok") is not True: + return json.dumps(refreshed) + guard["delivery_result_context"] = { + key: refreshed[key] + for key in ("ok", "basis", "read_context_id", "settlement_identity") + } + guard["result_revalidation_required"] = True + guard["next_action"] = ( + "Revalidate the retained candidate against this basis, then call complete_task for the same Todo. The original Turn key is carried automatically." + ) + output = json.dumps(guard) + return project_host_interaction(output) + + def _original_work_turn(self, todo_id: str) -> str | None: + """Resolve installed admission through the same owner used by the CLI.""" + from pathlib import Path + from .history import load_registry + from .paths import resolve_runtime_root + from .control_plane.goals.checkpoint_context_io import restore_default_turn_id + + goal_id, registry = self.context() + if not registry or not Path(registry).is_file(): + return None # Older host contracts retain their deterministic identity. + path = Path(registry) + return restore_default_turn_id( + runtime_root=resolve_runtime_root( + load_registry(path), None, registry_path=path + ), + registry_path=path, + goal_id=goal_id, + agent_id=self.bound_agent_id(), + todo_id=todo_id, + pending_only=False, + ) def list_todos(self) -> str: return self.should_run() + def _checkpoint_continuation(self, todo_id: str, turn_id: str | None) -> bool: + if turn_id is None: + return False + from pathlib import Path + from .history import load_registry + from .paths import resolve_runtime_root + from .control_plane.quota.settlement import read_heartbeat_settlement + from .control_plane.goals.checkpoint_context_io import ( + checkpoint_continuation_required, + ) + + goal_id, registry = self.context() + path = Path(registry) + root = resolve_runtime_root(load_registry(path), None, registry_path=path) + readback = read_heartbeat_settlement( + root, + goal_id=goal_id, + agent_id=self.bound_agent_id(), + todo_id=todo_id, + turn_instance_id=turn_id, + registry_path=path, + goal_ref=self.goal_ref(), + ) + return readback is not None and checkpoint_continuation_required(readback) + + def _stored_terminal_intent(self, todo_id: str, turn_id: str) -> bool: + from pathlib import Path + from .todos import list_goal_todos + from .control_plane.quota.settlement import SettlementIdentity + from .control_plane.goals.checkpoint_context_io import _checkpoint_effect + + goal_id, registry = self.context() + todo = list_goal_todos( + registry_path=Path(registry), goal_id=goal_id, todo_id=todo_id + ).get("todo") + identity = SettlementIdentity( + goal_id=goal_id, + agent_id=self.bound_agent_id(), + todo_id=todo_id, + turn_instance_id=turn_id, + ) + return _checkpoint_effect( + "goal.checkpoint_read_context.work_binding", + { + "phase": "direction_terminal_intent", + "todo": todo, + "identity": identity.as_dict(), + }, + )["no_followup"] + def host_prompt(self) -> str: from .claude_goal_mode.scripts.goalmode_cmd import loop_execution_content + state = self.state() goal_id, agent_id = state.get("goal_id"), state.get("agent_id") if not goal_id or not agent_id: - return json.dumps({"ok": False, "error": "bound Goal and agent are required"}) - return json.dumps({"ok": True, "goal_id": goal_id, "agent_id": agent_id, - "task_body": loop_execution_content(goal_id, agent_id)}) + return json.dumps( + {"ok": False, "error": "bound Goal and agent are required"} + ) + return json.dumps( + { + "ok": True, + "goal_id": goal_id, + "agent_id": agent_id, + "task_body": loop_execution_content(goal_id, agent_id), + } + ) def claim_task(self, todo_id: str, agent_id: str) -> str: goal_id, _ = self.context() @@ -229,6 +355,27 @@ def claim_task(self, todo_id: str, agent_id: str) -> str: ] ) + def wait_task(self, todo_id: str, agent_id: str, reason: str) -> str: + goal_id, _ = self.context() + if not goal_id: + return self.no_goal_message() + identity_error = self._identity_error(agent_id) + if identity_error: + return identity_error + request = HostTodoSettlementRequest( + goal_id=goal_id, + agent_id=agent_id, + todo_id=todo_id, + runtime_profile=self.config.runtime_profile, + legacy_host_surface=self.config.legacy_host_surface, + scheduler_owner=self.config.scheduler_owner, + execution_mode=self.config.execution_mode, + completion_args=(), + goal_ref=self.goal_ref(), + turn_instance_id=self._original_work_turn(todo_id), + ) + return settle_host_todo_wait(request, reason, run_cli=self.run_cli) + def complete_task( self, todo_id: str, @@ -241,6 +388,9 @@ def complete_task( successor_todo_ids: list[str] | None = None, agent_vision: dict[str, Any] | None = None, vision_unchanged_reason: str = "", + first_delivery: bool = False, + delivery_read_context_id: str = "", + read_context_id: str = "", ) -> str: goal_id, _ = self.context() if not goal_id: @@ -248,6 +398,10 @@ def complete_task( identity_error = self._identity_error(agent_id) if identity_error: return identity_error + turn_id = self._original_work_turn(todo_id) + current_checkpoint = self._checkpoint_continuation(todo_id, turn_id) + if current_checkpoint and not no_follow_up: + no_follow_up = self._stored_terminal_intent(todo_id, turn_id) if next_agent_todo and no_follow_up: return json.dumps( { @@ -257,11 +411,24 @@ def complete_task( ) if successor_todo_ids is not None and ( not isinstance(successor_todo_ids, list) - or any(not isinstance(value, str) or not value.strip() for value in successor_todo_ids) + or any( + not isinstance(value, str) or not value.strip() + for value in successor_todo_ids + ) ): - return json.dumps({"ok": False, "error": "successor_todo_ids must be a list of nonempty ids"}) + return json.dumps( + { + "ok": False, + "error": "successor_todo_ids must be a list of nonempty ids", + } + ) if successor_todo_ids and (next_agent_todo or no_follow_up): - return json.dumps({"ok": False, "error": "choose existing successors, a new successor, or no follow-up"}) + return json.dumps( + { + "ok": False, + "error": "choose existing successors, a new successor, or no follow-up", + } + ) args = [ "todo", "complete", @@ -298,17 +465,48 @@ def complete_task( scheduler_owner=self.config.scheduler_owner, execution_mode=self.config.execution_mode, completion_args=tuple(args), + first_delivery=first_delivery, + delivery_read_context_id=delivery_read_context_id or None, + checkpoint_read_context_id=read_context_id or None, no_follow_up=no_follow_up, goal_ref=self.goal_ref(), + turn_instance_id=turn_id, + current_agent_checkpoint=current_checkpoint, ) with host_vision_request(request, agent_vision, vision_unchanged_reason, allow_terminal_stride_path=self._allow_terminal_stride_path(goal_id, agent_vision)) as authored: - return settle_host_todo_completion(authored, run_cli=self.run_cli) + output = settle_host_todo_completion(authored, run_cli=self.run_cli) + completed = json.loads(output) + if ( + completed.get("ok") is True + and completed.get("settlement_complete") is True + and turn_id + ): + from pathlib import Path + from .history import load_registry + from .paths import resolve_runtime_root + from .control_plane.turn_driver.journal_store import ( + reconcile_settled_turn_journal, + ) + + _, registry = self.context() + path = Path(registry) + root = resolve_runtime_root(load_registry(path), None, registry_path=path) + reconcile_settled_turn_journal( + root, + registry_path=path, + settlement_identity=completed["settlement_identity"], + ) + return output def review_task_vision( - self, todo_id: str, agent_id: str, agent_vision: dict[str, Any] | None = None, + self, + todo_id: str, + agent_id: str, + agent_vision: dict[str, Any] | None = None, vision_unchanged_reason: str = "", read_context_id: str = "", + first_delivery: bool = False, ) -> str: goal_id, _ = self.context() if not goal_id: @@ -316,14 +514,21 @@ def review_task_vision( identity_error = self._identity_error(agent_id) if identity_error: return identity_error + turn_id = self._original_work_turn(todo_id) request = HostTodoSettlementRequest( - goal_id=goal_id, agent_id=agent_id, todo_id=todo_id, + goal_id=goal_id, + agent_id=agent_id, + todo_id=todo_id, runtime_profile=self.config.runtime_profile, legacy_host_surface=self.config.legacy_host_surface, - scheduler_owner=self.config.scheduler_owner, execution_mode=self.config.execution_mode, + scheduler_owner=self.config.scheduler_owner, + execution_mode=self.config.execution_mode, completion_args=(), checkpoint_read_context_id=read_context_id or None, + first_delivery=first_delivery, goal_ref=self.goal_ref(), + turn_instance_id=turn_id, + current_agent_checkpoint=self._checkpoint_continuation(todo_id, turn_id), ) with host_vision_request(request, agent_vision, vision_unchanged_reason, allow_terminal_stride_path=self._allow_terminal_stride_path(goal_id, agent_vision)) as authored: @@ -346,15 +551,20 @@ def create_fastmcp_server( server = FastMCP(config.server_name) if config.legacy_host_surface == "claude_code": + @server.tool() def host_prompt() -> str: """Read current Claude Goal execution rules for this server's bound identity.""" return control.host_prompt() @server.tool() - def should_run() -> str: - """Whether the bound goal and agent should run now.""" - return control.should_run() + def should_run(revalidate_todo_id: str = "") -> str: + """Read admitted work and its original result basis. After a stale result, + pass its Todo as revalidate_todo_id to read new facts on the same Turn. + Revalidate the retained candidate before complete_task; no token copying + is needed. Ordinary calls preserve the original basis across restarts. + """ + return control.should_run(revalidate_todo_id) @server.tool() def list_todos() -> str: @@ -368,9 +578,12 @@ def claim_task(todo_id: str, agent_id: str) -> str: @server.tool() def review_task_vision( - todo_id: str, agent_id: str, agent_vision: dict[str, Any] | None = None, + todo_id: str, + agent_id: str, + agent_vision: dict[str, Any] | None = None, vision_unchanged_reason: str = "", read_context_id: str = "", + first_delivery: bool = False, ) -> str: """Supply a missing vision decision for a previously completed MCP Todo. First call with only todo_id and agent_id to read the current basis. @@ -384,7 +597,24 @@ def review_task_vision( An unchanged reason requires an existing valid vision. Recheck should_run; checkpoint success alone does not certify Goal completion or clear gates. """ - return control.review_task_vision(todo_id, agent_id, agent_vision, vision_unchanged_reason, read_context_id) + return control.review_task_vision( + todo_id, + agent_id, + agent_vision, + vision_unchanged_reason, + read_context_id, + first_delivery, + ) + + @server.tool() + def wait_task(todo_id: str, agent_id: str, reason: str) -> str: + """Pause unfinished admitted work without completing it, judging vision + or spending. Read should_run first; the original Turn is restored + automatically. On current authority refusal follow that refusal. + Resume through should_run when the dependency is ready and revalidate + the retained candidate if its result basis changes. + """ + return control.wait_task(todo_id, agent_id, reason) @server.tool() def complete_task( @@ -398,8 +628,20 @@ def complete_task( successor_todo_ids: list[str] | None = None, agent_vision: dict[str, Any] | None = None, vision_unchanged_reason: str = "", + first_delivery: bool = False, + delivery_read_context_id: str = "", + read_context_id: str = "", ) -> str: - """Complete verified work and settle once. Link existing planned successors + """Complete verified work and settle once. Ordinary work uses the result + basis returned by should_run; completion carries its original key. After + stale rejection call should_run(revalidate_todo_id=todo_id), revalidate + the retained candidate, then retry with the same completion intent. + With first_delivery=true, first + call without Vision to read the result basis; verify it and echo its + delivery_read_context_id. Read the returned post-result direction basis, + then call with both original read identities and a newly judged Vision. + An interrupted call retains the same Todo/Turn; do not repeat work. + Link existing planned successors with successor_todo_ids; next_agent_todo creates a NEW Todo, not an id link. no_follow_up closes this Todo's continuation, NOT the Goal's vision. Do not duplicate existing work; only the fresh should_run contract can @@ -421,6 +663,9 @@ def complete_task( successor_todo_ids=successor_todo_ids, agent_vision=agent_vision, vision_unchanged_reason=vision_unchanged_reason, + first_delivery=first_delivery, + delivery_read_context_id=delivery_read_context_id, + read_context_id=read_context_id, ) return server, control diff --git a/loopx/hosts/chat_coordinator_checkpoint.py b/loopx/hosts/chat_coordinator_checkpoint.py new file mode 100644 index 0000000000..e4253d1544 --- /dev/null +++ b/loopx/hosts/chat_coordinator_checkpoint.py @@ -0,0 +1,62 @@ +"""Chat transport for an already committed coordinator result's native checkpoint.""" +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +from loopx.collaboration_mcp import _pinned_release_environment, _python_module_command +from loopx.goal_mode_mcp import GoalModeMCPConfig, GoalModeMCPControlPlane +from loopx.agent_registry import load_goal_from_registry +from loopx.todos import list_goal_todos + + +class _CheckpointControlPlane(GoalModeMCPControlPlane): + def __init__(self, registry: Path, root: Path, goal_id: str, agent_id: str): + self.registry, self.root = registry, root + super().__init__(GoalModeMCPConfig( + server_name="loopx-chat", runtime_profile="codex_cli", + legacy_host_surface="codex_cli", + ), lambda: {"goal_id": goal_id, "agent_id": agent_id, "registry": str(registry)}) + + def run_cli(self, args, *, legacy_args=None): + goal = load_goal_from_registry(self.registry, self.context()[0]) + if not goal: + raise ValueError("Goal unavailable") + prefix = [*_python_module_command("loopx.cli"), "--registry", str(self.registry), + "--runtime-root", str(self.root), "--format", "json"] + result = subprocess.run([*prefix, *args], cwd=goal["repo"], + env=_pinned_release_environment(), stdin=subprocess.DEVNULL, + capture_output=True, text=True, encoding="utf-8", timeout=60) + return result.stdout or json.dumps({"ok": False, "error": "checkpoint_command_unavailable"}) + + +class CoordinatorCheckpoints: + """Only resume post-result work; native owners retain all acceptance decisions.""" + def __init__(self, registry: Path, root: Path, goal_id: str, agent_id: str): + self.registry, self.root, self.goal_id, self.agent_id = registry, root, goal_id, agent_id + self.control = _CheckpointControlPlane(registry, root, goal_id, agent_id) + + def _task(self, todo_id: str) -> dict: + todo = list_goal_todos(registry_path=self.registry, runtime_root_arg=str(self.root), + goal_id=self.goal_id, todo_id=todo_id).get("todo") + if (not todo or todo.get("claimed_by") != self.agent_id or todo.get("status") != "done" + or not todo.get("completion_turn_key") or not self.control._original_work_turn(todo_id)): + raise ValueError("the coordinator has no committed original result for this task") + return todo + + def read(self, todo_id: str) -> dict: + self._task(todo_id) + return json.loads(self.control.review_task_vision(todo_id, self.agent_id)) + + def resume(self, todo_id: str, decision: dict) -> dict: + if (not isinstance(decision, dict) + or set(decision) - {"read_context_id", "agent_vision", "vision_unchanged_reason"} + or not isinstance(decision.get("read_context_id"), str) + or not decision["read_context_id"].strip() + or bool(decision.get("agent_vision")) == bool(decision.get("vision_unchanged_reason"))): + raise ValueError("resume requires the Agent's fresh checkpoint context and one vision decision") + todo = self._task(todo_id) + return json.loads(self.control.complete_task(todo_id, self.agent_id, todo.get("evidence") or "", + read_context_id=decision["read_context_id"], agent_vision=decision.get("agent_vision"), + vision_unchanged_reason=decision.get("vision_unchanged_reason") or "")) diff --git a/loopx/kunluncode_goal_mode/guards.py b/loopx/kunluncode_goal_mode/guards.py index 7779fe96df..330e3062ab 100644 --- a/loopx/kunluncode_goal_mode/guards.py +++ b/loopx/kunluncode_goal_mode/guards.py @@ -44,6 +44,9 @@ def blocked_complete( successor_todo_ids: list[str] | None = None, agent_vision: dict | None = None, vision_unchanged_reason: str = "", + first_delivery: bool = False, + delivery_read_context_id: str = "", + read_context_id: str = "", ) -> str: del ( next_agent_todo, @@ -53,6 +56,9 @@ def blocked_complete( successor_todo_ids, agent_vision, vision_unchanged_reason, + first_delivery, + delivery_read_context_id, + read_context_id, ) return _native_controller_rejection("complete_task") diff --git a/loopx/project_alias.py b/loopx/project_alias.py index 996bbc5ef8..bff625a029 100644 --- a/loopx/project_alias.py +++ b/loopx/project_alias.py @@ -22,6 +22,7 @@ def _run_git(project: Path, *args: str) -> subprocess.CompletedProcess[str] | No return subprocess.run( ["git", "-C", str(project), *args], text=True, encoding="utf-8", errors="replace", + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, diff --git a/loopx/project_prompt.py b/loopx/project_prompt.py index 026f00b135..f71443e6fc 100644 --- a/loopx/project_prompt.py +++ b/loopx/project_prompt.py @@ -156,7 +156,9 @@ def render_available_capability_args(values: Any) -> str: ) -def render_cli_preflight(*, cli_bin: str = "loopx") -> str: +def render_cli_preflight(*, cli_bin: str = "loopx", thin: bool = False) -> str: + if thin: + return "" return install_contract.doctor_preflight_command(cli_bin=cli_bin) @@ -446,10 +448,10 @@ def build_new_project_prompt( if isinstance(existing_registry, dict) and existing_registry.get("common_runtime_root") else None ) - active_state_path = rel_or_abs( + active_state_path = Path(rel_or_abs( registered_goal_state_file(project.expanduser(), resolved_goal_id, existing_registry), project.expanduser(), - ) + )).as_posix() allowed_domains = allowed_domains or [] write_scope = write_scope or [] connect_command = render_connect_command( diff --git a/loopx/quota.py b/loopx/quota.py index ed4b56de23..7af0873980 100644 --- a/loopx/quota.py +++ b/loopx/quota.py @@ -946,7 +946,7 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): return read_replan_explore_context(source_status=status_payload, goal_id=read_goal_id, agent_id=read_agent_id) - return _build_quota_should_run( + payload = _build_quota_should_run( status_payload, goal_id=goal_id, agent_id=agent_id, @@ -974,6 +974,22 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): authoritative_fallback_todo_items=authoritative_fallback_todo_items, replan_explore_reader=read_explore_context, ) + observation_root = runtime_root or status_payload.get("runtime_root") + if goal_ref is None and agent_id and observation_root: + from .control_plane.goals.checkpoint_context_io import pending_first_delivery_progress + + delivery_progress = pending_first_delivery_progress( + Path(observation_root), goal_id, agent_id, + registry_path=Path(status_payload["registry"]) if status_payload.get("registry") else None, + ) + if delivery_progress is not None: + payload["first_delivery_progress"] = delivery_progress + # Observation cannot grant work or override an owner pause/health hold. + if (delivery_progress.get("settlement_identity") and payload.get("ok") + and payload.get("state") != "paused" + and delivery_progress.get("stage") != "result_review_pending"): + payload["recommended_action"] = delivery_progress["next_action"] + return payload def _quota_spend_index_basis( diff --git a/loopx/repository_identity.py b/loopx/repository_identity.py index 050a0c0a54..294740ae68 100644 --- a/loopx/repository_identity.py +++ b/loopx/repository_identity.py @@ -56,6 +56,8 @@ def _origin_remote(project: Path, git_bin: str) -> str: try: completed = subprocess.run( [git_bin, "-C", str(project), "config", "--get", "remote.origin.url"], + # This read must not borrow a Chat/RPC caller's open input pipe. + stdin=subprocess.DEVNULL, capture_output=True, check=False, text=True, encoding="utf-8", errors="replace", diff --git a/loopx/semantics/project_registry_io.py b/loopx/semantics/project_registry_io.py index fa8a2635e0..54deae5cb9 100644 --- a/loopx/semantics/project_registry_io.py +++ b/loopx/semantics/project_registry_io.py @@ -50,7 +50,7 @@ ) DIRECT_WRITE_METHODS = frozenset({"write_bytes", "write_text"}) ALLOWED_DIRECT_CLASSIFICATIONS = frozenset( - {"codec_internal", "global_registry_io", "legacy_registry_source"} + {"codec_internal", "global_registry_io", "legacy_registry_source", "source_digest_guard"} ) @@ -561,6 +561,12 @@ def validate_project_registry_io_manifest( elif kind in {"direct_json_read", "direct_json_write"}: if classification not in ALLOWED_DIRECT_CLASSIFICATIONS: errors.append(f"unclassified direct project registry I/O: {site}") + elif classification == "source_digest_guard" and ( + kind != "direct_json_read" or row.get("api") != "readFileSync" + ): + # A synchronous byte comparison is not a registry decoder or + # writer. This census-local classification cannot exempt either. + errors.append(f"source digest guard must be a synchronous byte read: {site}") else: errors.append(f"project registry I/O site has unknown kind: {site}") return errors diff --git a/loopx/semantics/project_registry_io_classifications_v1.json b/loopx/semantics/project_registry_io_classifications_v1.json index 7d92e680dc..1d7c19d817 100644 --- a/loopx/semantics/project_registry_io_classifications_v1.json +++ b/loopx/semantics/project_registry_io_classifications_v1.json @@ -1,4 +1,5 @@ { + "loopx/control_plane/goals/checkpoint_commit.ts::::direct_json_read:readFileSync#1": "source_digest_guard", "loopx/control_plane/goals/deletion_service.py::._load_locked_payloads::direct_json_read:read_json#1": "global_registry_io", "loopx/control_plane/goals/deletion_service.py::._load_locked_payloads::direct_json_read:read_json#2": "global_registry_io", "loopx/control_plane/goals/deletion_service.py::._resolve_route::direct_json_read:read_json#1": "global_registry_io", diff --git a/loopx/semantics/vocabulary_v0.json b/loopx/semantics/vocabulary_v0.json index 6057512317..500e74d073 100644 --- a/loopx/semantics/vocabulary_v0.json +++ b/loopx/semantics/vocabulary_v0.json @@ -224,6 +224,8 @@ "loopx/control_plane/turn_driver/executor.py::_run_task_validator", "loopx/control_plane/turn_driver/executor.py::_task_validation_receipt", "loopx/control_plane/turn_driver/executor.py::_task_validation_stage", + "loopx/control_plane/turn_driver/executor.py::_retain_result_for_revalidation", + "loopx/control_plane/turn_driver/executor.py::_terminal_result_validation_failure", "loopx/control_plane/turn_driver/transaction.py::_result_kind" ], "call_producers": { diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index abf4d99dd9..5a49e7d89b 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -108,6 +108,7 @@ from .control_plane.goals.goal_frontier import latest_agent_vision_from_runs from .control_plane.goals.checkpoint_context_io import ( checkpoint_commit_guard, commit_checkpoint_run, require_complete_checkpoint_index, inspect_checkpoint_replay, + first_delivery_context_enrolled, result_work_required, checkpoint_continuation_required, result_work_read_id, TurnResultWork, ) from .registry import registry_goals as registry_goals, resolve_state_file as resolve_state_file from .runtime import validate_goal_id_path_segment @@ -730,6 +731,7 @@ def refresh_state_run( merge_agent_vision_patch: bool = False, vision_unchanged_reason: str | None = None, checkpoint_read_context_id: str | None = None, + first_delivery: bool = False, progress_observation: dict[str, Any] | None = None, explore_result: dict[str, Any] | None = None, completion_todo_id: str | None = None, @@ -751,6 +753,8 @@ def refresh_state_run( raise ValueError("--next-action-basis requires --next-action") if checkpoint_read_context_id and not turn_instance_id: raise ValueError("--checkpoint-read-context requires the original Turn identity") + if first_delivery and not (turn_instance_id and agent_id and (todo_id or replan_obligation_id)): + raise ValueError("--first-delivery requires the original Goal/Agent/Todo or obligation/Turn") validate_public_safe_text("classification", classification) if usage_measurement is not None and usage_codex_session is not None: raise ValueError("--usage-json cannot be combined with --usage-codex-session") @@ -841,7 +845,11 @@ def refresh_state_run( settlement_readback = None refresh_recovery = None prior_writeback_run = None + default_checkpoint = False checkpoint_supplement = False + first_delivery_checkpoint = False + result_work_checkpoint = False + work_read_id = None if todo_id or normalized_replan_obligation_id or turn_instance_id: if checkpoint_read_context_id or agent_vision_packet or vision_unchanged_reason: require_complete_checkpoint_index(runtime_root / "goals" / safe_goal_id / "runs" / "index.jsonl") @@ -861,6 +869,11 @@ def refresh_state_run( source_admission=source_admission, borrow_source_admission=source_admission is not None, refresh_retry=(refresh_retry_request := { + **({"first_delivery_request": { + "classification": classification, "recommended_action": recommended_action, + "agent_lane": agent_lane, "progress_scope": progress_scope, + "completion_todo_id": completion_todo_id, "completion_turn_key": completion_turn_key, + }} if first_delivery else {}), "checkpoint_read_context_id": checkpoint_read_context_id, "external_delivery": external_delivery, "vision": agent_vision_packet, @@ -911,7 +924,35 @@ def refresh_state_run( recovery_payload, registry_path=registry_path, runtime_root=runtime_root, goal_id=safe_goal_id, project=project, state_file=state_file, ) - if checkpoint_read_context_id and not checkpoint_supplement: + first_delivery_checkpoint = ( + refresh_recovery["decision"] == "append" + and first_delivery_context_enrolled(runtime_root, settlement_identity, + result_only=result_work_required(settlement_readback)) + ) + default_checkpoint = checkpoint_continuation_required(settlement_readback) + result_work_checkpoint = ( + refresh_recovery["decision"] == "append" and not first_delivery_checkpoint + and result_work_required(settlement_readback) + ) + if (result_work_checkpoint and completion_todo_id == settlement_identity.todo_id + and completion_turn_key == settlement_identity.turn_instance_id + and TurnResultWork(registry_path, runtime_root, settlement_identity, goal_ref).result_committed()): + # The typed Todo fence already proves this original result. + # A disposable work read cannot reopen its completed validation. + # Existing writeback recovery still owns the remaining effects. + result_work_checkpoint = False + if result_work_checkpoint: + work_read_id = result_work_read_id(runtime_root, settlement_identity) + if first_delivery != first_delivery_checkpoint and not default_checkpoint: + raise ValueError("first delivery requires matching --first-delivery and persisted first_delivery context; " + "read and judge checkpoint-context --purpose first_delivery on the original Turn") + if first_delivery_checkpoint and not (agent_vision_packet or vision_unchanged_reason): + raise ValueError("first delivery direction is pending; read checkpoint-context --purpose first_delivery " + "for this original Turn, judge the returned state, then submit its context and direction") + if first_delivery_checkpoint and (next_action or usage_codex_session is not None): + raise ValueError("first delivery checkpoint does not support combined next-action or session usage booking; " + "retain the original Turn and use separately owned stages") + if checkpoint_read_context_id and not (checkpoint_supplement or first_delivery_checkpoint): raise ValueError("--checkpoint-read-context applies only to a missing-checkpoint supplement") settlement_workspace_requirement = resolve_settlement_workspace_requirement( delivery_workspace_causality, settlement_binding_kind=settlement_identity.binding_kind.value @@ -1102,13 +1143,29 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): review_pages=((agent_vision or {}).get("stride_decision") or {}).get( "explore_review", {}).get("result_pages")) + from .control_plane.quota.heartbeat_receipt import guard_source_for_turn, guard_cadence + retained_source = guard_source_for_turn(runtime_root, goal_id=safe_goal_id, + agent_id=normalized_agent_id, turn_instance_id=turn_instance_id, goal_ref=goal_ref) if settlement_readback else None + # Retained identity cannot override a later explicit policy revision. + usable_source = retained_source if retained_source and retained_source.get("cadence") == guard_cadence(registry_path,runtime_root,safe_goal_id) else None + from .control_plane.goals.checkpoint_context_io import _checkpoint_effect + requested_replan = autonomous_replan_recorded or bool(settlement_replan_guard.get('selected_obligation_id')) + replan_stage = _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "replan_stage", "heartbeat_receipt": settlement_readback.heartbeat_receipt if settlement_readback else None, + "prior": prior_writeback_run, "requested": requested_replan, + "has_read_context": bool(checkpoint_read_context_id), + }) if default_checkpoint and (requested_replan or + (prior_writeback_run or {}).get("vision_checkpoint", {}).get("replan_stage")) else { + "deferred": False, "qualify": autonomous_replan_recorded} replan_qualification = qualify_refresh_replan_writeback( + defer_until_direction=replan_stage['deferred'], replan_explore_reader=read_explore_context, replan_source_route={"registry": str(registry_path), "runtime_root": str(runtime_root)}, capability_evidence=capability_evidence, guard_capability=settlement_replan_guard.get("selected_capability_guard"), todo_fields=todo_fields, - autonomous_replan_recorded=autonomous_replan_recorded, + receipt_bound_source=(usable_source or {}).get("obligation"), + autonomous_replan_recorded=replan_stage["qualify"], requested_delta_kinds=normalized_repair_delta_kinds, active_state_next_action_update=None, agent_vision=agent_vision, @@ -1193,6 +1250,7 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): if ( normalized_delivery_outcome in ACCOUNTABLE_DELIVERY_OUTCOMES and not checkpoint_supplement + and not replan_stage['deferred'] ): require_accountable_completion_validation( state_text, @@ -1218,6 +1276,13 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): autonomous_replan_recorded=effective_autonomous_replan_recorded, blocked_retry=blocked_retry, ) + if replan_stage["deferred"]: + vision_checkpoint["replan_stage"] = "awaiting_direction" + if (settlement_readback is not None and checkpoint_continuation_required(settlement_readback) + and vision_checkpoint.get("required") is True + and vision_checkpoint.get("satisfied") is True + and not (checkpoint_supplement or first_delivery_checkpoint)): + raise ValueError("Default checkpoint requires a post-result read and a newly authored decision on the original Turn; old Vision cannot be rebound to a new context.") if checkpoint_supplement and not vision_checkpoint.get("satisfied"): raise ValueError( "checkpoint supplement did not satisfy the missing decision; " @@ -1408,7 +1473,10 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): compact_route["projection_marker_field"] = "shared_runtime_projection" record["runtime_projection_route"] = compact_route - runs_dir = runtime_root / "goals" / safe_goal_id / "runs" + # Produce physical artifact paths before record/index fingerprints are + # constructed. Historical aliases remain immutable and are verified by + # the checkpoint owner against this same directory identity. + runs_dir = runtime_root.resolve() / "goals" / safe_goal_id / "runs" json_path, markdown_path = unique_run_paths(runs_dir, generated_at) index_path = runs_dir / "index.jsonl" index_record, payload = _build_state_refresh_output_projections( @@ -1449,18 +1517,26 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): source_context=recommendation_source_context(current_goal, current_text, source_registry=next_action_source_registry, todo_fields=current_planning.todo_fields), runs=newest_first_runs) - if checkpoint_supplement: + checkpoint_purpose = ("delivery_result" if result_work_checkpoint else + "first_delivery" if first_delivery_checkpoint else "supplement_checkpoint") + if checkpoint_supplement or first_delivery_checkpoint or result_work_checkpoint: assert settlement_identity is not None context = usage_booking_guard.enter_context(checkpoint_commit_guard( runtime_root=runtime_root, registry_path=registry_path, state_file=resolved_state_file, identity=settlement_identity, - read_context_id=checkpoint_read_context_id, + read_context_id=work_read_id if result_work_checkpoint else checkpoint_read_context_id, goal_ref=goal_ref, + source_admission=source_admission, + purpose=checkpoint_purpose, + decision_scope=normalized_progress_scope if first_delivery_checkpoint and not default_checkpoint else None, )) for projection in (record, index_record, payload): - projection["vision_checkpoint"] = { - **projection["vision_checkpoint"], "read_context": context, - } + if result_work_checkpoint: + projection["result_work_context"] = context + else: + projection["vision_checkpoint"] = { + **projection["vision_checkpoint"], "read_context": context, + } if usage_codex_session is not None: if not dry_run: runs_dir.mkdir(parents=True, exist_ok=True) @@ -1523,14 +1599,19 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): index_record["markdown_path"] = str(markdown_path) payload["json_path"] = str(json_path) payload["markdown_path"] = str(markdown_path) - if checkpoint_supplement: + if checkpoint_supplement or first_delivery_checkpoint or result_work_checkpoint: saved = commit_checkpoint_run(runtime_root=runtime_root, registry_path=registry_path, state_file=resolved_state_file, identity=settlement_identity, refresh_retry=refresh_retry_request, record=record, index_record=index_record, markdown=render_state_refresh_markdown(payload) + "\n", - goal_ref=goal_ref, source_admission=source_admission) + goal_ref=goal_ref, source_admission=source_admission, + purpose=checkpoint_purpose, read_context_id=work_read_id, + decision_scope=normalized_progress_scope if first_delivery_checkpoint and not default_checkpoint else None) for projection in (record, index_record, payload): - projection["vision_checkpoint"]["read_context"] = saved["context"] + if result_work_checkpoint: + projection["result_work_context"] = saved["context"] + else: + projection["vision_checkpoint"]["read_context"] = saved["context"] for projection in (index_record, payload): projection.update({key: saved[key] for key in ("json_path", "markdown_path")}) if saved["replayed"]: diff --git a/pyproject.toml b/pyproject.toml index 10eaf4049f..5cf9cf3b9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,10 @@ include = ["loopx*"] "loopx.semantics" = ["project_registry_io_manifest_v1.json"] [tool.setuptools.package-data] +"loopx.canary" = ["module_metric_baseline.json"] +"loopx.capabilities.reward_memory" = ["OUTBOUND.md", "OUTBOUND.zh-CN.md"] +"loopx.claude_goal_mode" = [".claude-plugin/plugin.json", "commands/loopx.md", "hooks/hooks.json", "settings.example.json"] +"loopx.control_plane.testing" = ["shadow_drain_fault_process.ts"] "loopx" = ["web/chat/*.html", "web/chat/*.json", "web/chat/assets/*", "web/chat/manifest.webmanifest", "web/chat/pwa/*"] "loopx.control_plane" = ["*.json", "*.ts", "presentation/*.ts"] "loopx.semantics" = ["*.json"] diff --git a/scripts/generate_turn_contract.py b/scripts/generate_turn_contract.py index 046c19e897..53a2debe76 100644 --- a/scripts/generate_turn_contract.py +++ b/scripts/generate_turn_contract.py @@ -216,12 +216,11 @@ def require(condition, message): def build_artifacts(): - contract = validate_contract(read_contract()) - # Git may check the contract out with CRLF on Windows; hash the canonical LF - # bytes so every checkout records the same provenance digest. - digest = hashlib.sha256( - CONTRACT_PATH.read_bytes().replace(b"\r\n", b"\n") - ).hexdigest() + # Universal-newline decoding gives LF and CRLF checkouts the same content + # identity. Parse and hash this one snapshot so provenance matches the data. + source = CONTRACT_PATH.read_text(encoding="utf-8") + contract = validate_contract(json.loads(source, object_pairs_hook=_unique_object)) + digest = hashlib.sha256(source.encode("utf-8")).hexdigest() header = f"Generated by scripts/generate_turn_contract.py; do not edit.\nSource: loopx/control_plane/turn_loop_controller_contract_v0.json\nSHA256: {digest}" py = [ '"""' + header + '"""', diff --git a/skills/loopx-self-repair/SKILL.md b/skills/loopx-self-repair/SKILL.md index 2eeb7ec626..9a524a6cab 100644 --- a/skills/loopx-self-repair/SKILL.md +++ b/skills/loopx-self-repair/SKILL.md @@ -34,6 +34,12 @@ not only an apology or a one-off explanation. - active-state authoring gap; - benchmark harness mismatch; - docs/process hygiene gap. + A changed owner requirement is not evidence of projection corruption. + Compare the current primary document with the derived Todo before choosing + repair: update the plan or implementation to follow current requirements. + Never overwrite an existing owner document from an old plan, Todo or read + context. Restore missing source only from a verified current owner source; + conflicting owner sources require resolution, not an inferred rollback. 5. **Repair at the lowest durable layer.** - If it is a one-off agent mistake, write back the correct state/todo and size the next scoped effort to its verifiable result, evidence and risk. diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index d812ee520d..5adc2e51c4 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -17,6 +17,7 @@ teaches a reusable control-plane lesson. | `premerge_binary_visual_coverage` | A first-screen PNG makes premerge fail with invalid UTF-8, although its text checks are clean. | Exact changed paths, text scan coverage, raster evidence and the existing exact-head review boundary. | The premerge adapter feeds binary images into a text-only scan and loses the distinction between text readability and visual privacy. | Keep the text scanner unchanged; explicitly list changed images as outside text coverage and retain the existing manual-review hold, including image-only changes. Unknown binary/text decode failures remain failures. Inspect pixels, metadata and provenance in the ordinary exact-head review; never silently skip images, certify them with a text pass, add a bypass flag or demand another user presentation approval. | | `planning_visibility_requires_declared_complexity` | A flat runnable backlog hides the Goal or broader work view because no dependency, blocked work or acceptance gap has been registered. | Same-source default/opt-out planning packets, original Goal, bounded work summary, omission counts and full-read routes. | A supplemental-context condition is mistaken for evidence that no strategic planning is needed; byte reduction removes useful visibility. | Show Goal and bounded work context by default at applicable preselection boundaries, including flat backlogs. Preserve omission counts, complete-read routes and action authority. Reduce exact duplication instead of hiding work; qualify input-cost changes with matched fixtures and keep model benefit unproven until measured. | | `stale_objective_repeated_as_current` | Current planning repeatedly projects an obsolete objective despite newer direct user direction. | Recorded source versus owner-applied objective revision, current canonical basis and each peer's adoption. | Fresh file bytes are mistaken for current intent; a registry/display edit cannot establish peer adoption. | Prepare an evidence-linked operating-objective revision through the existing Goal update surface. Preserve original commitments and use owner apply. Reconcile each peer's work and record its own exact adoption before new execution; do not revise for ordinary progress/diagnostics/age, impersonate peers, or interrupt in-flight settlement. Reuse a pending proposal; adoption is once per objective revision, not per Turn or peer/work write. Verify native tool invocation, current projection and stale-execution refusal, not only serializer output. | +| `owner_requirement_misclassified_as_projection_corruption` | An Agent sees changed owner requirements beside stale derived Todos, searches for projection overwrites or stale goal prose, then restores an older plan into the current owner document. | Current registered primary documents, original read basis, exact document write, projection-only scope and later reread. | A legitimate requirement change was treated as projection corruption; old Agent plans were incorrectly promoted into owner authority. | Preserve the current owner source and revise derived work. Freshness binds separately registered goal_doc content as the primary prose through the final provider check. Never reconstruct an existing requirement from a prior read; missing or conflicting owner sources require verified recovery or owner resolution. Hosts enforce document and parent-directory write isolation. | | `history_artifact_observation_failure` | History or status readback fails with `ENAMETOOLONG`, permission denial or an I/O error while checking a recorded artifact path. | Synthetic index row, failing filesystem observation, retained Run evidence, index digest and next-read recovery. | A derived artifact availability lookup propagates an OS error and hides otherwise readable durable history on older Python versions. | Isolate filesystem lookup errors in the shared artifact observer; report availability as unconfirmed through the existing false flag, retain Run evidence and retry on the next read. Do not suppress errors reading the index itself or rewrite evidence. Cover real history/status CLI readback with File and SQLite fixtures. | | `status_cache_decode_failure` | Opt-in cached status or quota readback fails with a UTF-8 decoding error while live collection works. | Cache miss metadata and a disposable malformed-cache reproduction through both CLI consumers. | The shared cache reader handles JSON and filesystem errors but lets text decoding abort live fallback. | Classify invalid UTF-8 as an unreadable cache miss in the existing IO owner; preserve damaged bytes on reads, collect live facts, and rewrite only with the explicit cache-write option. Test current Todo selection and successful reuse after repair; never suppress canonical source failures. | | `lock_holder_decode_hides_timeout` | Lock diagnostics or a contended acquisition raises `UnicodeDecodeError` instead of reporting unreadable metadata or the typed timeout. | Synthetic damaged holder bytes, liveness readback, and a real held-lock timeout followed by normal reacquisition. | Holder text decoding fails before the JSON/error guard, masking advisory failure and incident recording. | Treat invalid UTF-8 as unreadable at the shared holder reader; retain valid alternate records and kernel ownership. Cover the incident, inspection guidance and recovery after the actual holder exits; never delete the lock as repair. | diff --git a/tests/architecture/test_turn_contract_generation.py b/tests/architecture/test_turn_contract_generation.py index c2275161b8..ba6abd994e 100644 --- a/tests/architecture/test_turn_contract_generation.py +++ b/tests/architecture/test_turn_contract_generation.py @@ -1,6 +1,7 @@ """Independent acceptance of Turn generation, rejection and source adoption.""" from copy import deepcopy +import hashlib import json import runpy import subprocess @@ -115,6 +116,19 @@ def test_contract_digest_ignores_checkout_line_endings(tmp_path, monkeypatch): generator.PYTHON_PATH: generator.PYTHON_PATH.read_text(encoding="utf-8"), generator.TYPESCRIPT_PATH: generator.TYPESCRIPT_PATH.read_text(encoding="utf-8"), } +def test_generation_has_one_content_identity_across_checkout_newlines(tmp_path, monkeypatch): + source = generator.CONTRACT_PATH.read_text(encoding="utf-8").encode("utf-8") + contract = tmp_path / "contract.json" + monkeypatch.setattr(generator, "CONTRACT_PATH", contract) + contract.write_bytes(source) + lf = generator.build_artifacts() + contract.write_bytes(source.replace(b"\n", b"\r\n")) + assert generator.build_artifacts() == lf + digest = hashlib.sha256(source).hexdigest() + assert all(f"SHA256: {digest}" in artifact for artifact in lf.values()) + # Newline normalization must not exempt other changes from provenance. + contract.write_bytes(source + b" ") + assert generator.build_artifacts() != lf def test_typescript_settlement_reexports_generated_result_set(): diff --git a/tests/cli_commands/test_quota_closeout_query_failure.py b/tests/cli_commands/test_quota_closeout_query_failure.py index b3992e3af4..f92a4a9da1 100644 --- a/tests/cli_commands/test_quota_closeout_query_failure.py +++ b/tests/cli_commands/test_quota_closeout_query_failure.py @@ -65,7 +65,7 @@ def decision(*a, **k): before = {p: p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()} result = command.handle_quota_command( args, registry_path=registry, runtime_root_arg=str(tmp_path), - print_payload=lambda payload, *_: output.append(json.loads(json.dumps(payload))), + print_payload=lambda payload, *_, **_kwargs: output.append(json.loads(json.dumps(payload))), append_cli_rollout_event=append, ) assert result == 1 diff --git a/tests/control_plane/checkpoint_process.py b/tests/control_plane/checkpoint_process.py index 16ef630268..e19841b5b9 100644 --- a/tests/control_plane/checkpoint_process.py +++ b/tests/control_plane/checkpoint_process.py @@ -32,7 +32,7 @@ def wait_for(path: Path, child=None, timeout=20): time.sleep(0.01) -def refresh(registry, runtime, token, *, goal_ref=None): +def refresh(registry, runtime, token, *, goal_ref=None, first_delivery=False): from loopx.state_refresh import refresh_state_run from tests.control_plane.test_quota_settlement_cli import GOAL_ID, AGENT_ID, TODO_ID, TURN_ID return refresh_state_run(registry_path=Path(registry), runtime_root_override=str(runtime), @@ -41,8 +41,9 @@ def refresh(registry, runtime, token, *, goal_ref=None): delivery_batch_scale="implementation", delivery_outcome="outcome_progress", vision_unchanged_reason="The current basis remains applicable.", checkpoint_read_context_id=token, dry_run=False, sync_global=False, + **({"delivery_workspace_path": Path(registry).parent.parent} if first_delivery else {}), external_delivery={"suppress": True, "resume_key": None}, - goal_ref=goal_ref) + goal_ref=goal_ref, first_delivery=first_delivery) def main(request): diff --git a/tests/control_plane/cli_output_fixture.py b/tests/control_plane/cli_output_fixture.py index 13cc73d505..47425a5a67 100644 --- a/tests/control_plane/cli_output_fixture.py +++ b/tests/control_plane/cli_output_fixture.py @@ -9,6 +9,8 @@ import shutil import tempfile +from loopx.control_plane.runtime.file_paths import windows_extended_path + def budget_fixture_path(root: Path) -> Path: suffix = hashlib.sha256(str(root).encode("utf-8")).hexdigest()[:12] @@ -27,4 +29,4 @@ def stable_budget_fixture_root(root: Path): try: yield fixture finally: - shutil.rmtree(fixture) + shutil.rmtree(windows_extended_path(fixture)) diff --git a/tests/control_plane/fixtures/todo_read_fields_a3266402.json b/tests/control_plane/fixtures/todo_read_fields_a3266402.json new file mode 100644 index 0000000000..deae84401b --- /dev/null +++ b/tests/control_plane/fixtures/todo_read_fields_a3266402.json @@ -0,0 +1,145 @@ +{ + "source_commit": "a326640280507e7e6dcd5adba8ce706bd03b219f", + "canonical_fields": [ + "index", + "done", + "text", + "schema_version", + "todo_id", + "role", + "status", + "priority", + "title", + "archive_state", + "source_section", + "task_class", + "action_kind", + "task_domain", + "capability_binding_ref", + "task_repository", + "continuation_policy", + "removed_continuation_policy", + "required_write_scopes", + "required_capabilities", + "target_capabilities", + "explore_result_node_refs", + "decision_scope", + "required_decision_scopes", + "decision_outcome", + "decision_scope_outcomes", + "claimed_by", + "created_by", + "last_actor_agent_id", + "bound_agent", + "goal_bound", + "blocks_agent", + "excluded_agents", + "global_gate", + "unblocks_todo_id", + "resume_when", + "resume_monitor_generation", + "resume_condition", + "resume_ready", + "no_followup", + "successor_todo_ids", + "completion_continuation", + "completion_recovery", + "completion_receipt_id", + "replan_obligation_id", + "target_key", + "cadence", + "next_due_at", + "expires_at", + "watch_only", + "last_checked_at", + "result_hash", + "consecutive_no_change", + "material_change", + "material_change_generation", + "max_no_change_before_replan", + "monitor_effect_id", + "note", + "evidence", + "reason", + "completed_at", + "completion_turn_key", + "completion_result", + "updated_at", + "superseded_by", + "completion_validation_required", + "completion_validation_sha256", + "completion_validation_revision", + "completion_validation_revision_history", + "handoff_note" + ], + "domain_fields": [ + "done", + "text", + "schema_version", + "todo_id", + "role", + "status", + "priority", + "title", + "archive_state", + "task_class", + "action_kind", + "task_domain", + "capability_binding_ref", + "task_repository", + "continuation_policy", + "removed_continuation_policy", + "required_write_scopes", + "required_capabilities", + "target_capabilities", + "explore_result_node_refs", + "decision_scope", + "required_decision_scopes", + "decision_outcome", + "decision_scope_outcomes", + "claimed_by", + "created_by", + "last_actor_agent_id", + "bound_agent", + "goal_bound", + "blocks_agent", + "excluded_agents", + "global_gate", + "unblocks_todo_id", + "resume_when", + "resume_monitor_generation", + "resume_condition", + "resume_ready", + "no_followup", + "successor_todo_ids", + "completion_continuation", + "completion_recovery", + "completion_receipt_id", + "replan_obligation_id", + "target_key", + "cadence", + "next_due_at", + "expires_at", + "watch_only", + "last_checked_at", + "result_hash", + "consecutive_no_change", + "material_change", + "material_change_generation", + "max_no_change_before_replan", + "monitor_effect_id", + "note", + "evidence", + "reason", + "completed_at", + "completion_turn_key", + "completion_result", + "updated_at", + "superseded_by", + "completion_validation_required", + "completion_validation_sha256", + "completion_validation_revision", + "completion_validation_revision_history", + "handoff_note" + ] +} diff --git a/tests/control_plane/host_checkpoint_fixture.py b/tests/control_plane/host_checkpoint_fixture.py new file mode 100644 index 0000000000..41fe5786d6 --- /dev/null +++ b/tests/control_plane/host_checkpoint_fixture.py @@ -0,0 +1,60 @@ +"""A deterministic current-Agent response after a real checkpoint tool read.""" +from __future__ import annotations + +import json +from pathlib import Path +import sys + + +def checkpoint_decision(context): + basis = context["basis"] + assert context["read_context_id"] + todo = basis.get("todo") + identity = context["settlement_identity"] + assert isinstance(todo, (dict, list)) + goal_wide = isinstance(todo, list) + if goal_wide: + assert identity.get("todo_id") is None + todo = {} + deferred_terminal = bool(todo and todo.get("status") == "done" + and todo.get("completion_continuation") == "active_goal" + and todo.get("completion_requested_continuation") == "no_followup" + and todo.get("completion_turn_key") in {identity["effect_id"], identity["turn_instance_id"]}) + terminal = goal_wide or todo.get("no_followup") is True or deferred_terminal + assert basis["frontier"]["scope"] == ("goal" if terminal else "agent_lane") + decision = {"read_context_id": context["read_context_id"], "agent_vision_json": "", + "vision_unchanged_reason": "", "stop_reason": ""} + if basis["agent_vision"]: + decision["vision_unchanged_reason"] = "The observed fixture obligations preserve the scoped route." + else: + decision["agent_vision_json"] = json.dumps({"schema_version": "goal_vision_replan_contract_v0", + "state": "vision_patch_proposed", "vision_patch": {"vision_summary": "Verify the scoped fixture.", + "acceptance_summary": "Fixture evidence has been verified."}, "path_delta": { + "schema_version": "goal_path_delta_v0", "outcome": "no_change", + "prior_assumption": "Scoped fixture route.", "observed_reality": "Fixture evidence verified.", + "retained": ["Scoped route"], "evidence_refs": ["evidence:fixture"]}}) + if decision["agent_vision_json"]: + decision["agent_vision"] = json.loads(decision.pop("agent_vision_json")) + return decision + + +def reply_to_checkpoint(request): + if request.get("phase") != "checkpoint_direction": + return False + json.dump(checkpoint_decision(request["checkpoint_context"]), sys.stdout) + return True + + +def checkpoint_fixture_script(script): + marker = 'from tests.control_plane.host_checkpoint_fixture import reply_to_checkpoint' + return script.replace(marker, f'sys.path.insert(0, {str(Path(__file__).resolve().parents[2])!r})\n' + marker) + + +def checkpoint_codex_fixture_response(request, runtime_root): + from loopx.control_plane.turn_driver.codex_sessions import _lineage, _store_codex_cli_session + if request.get("phase") == "checkpoint_direction": + assert request["session"]["action"] == "resume" + return checkpoint_decision(request["checkpoint_context"]) + _store_codex_cli_session(runtime_root, lineage=_lineage(request), session_scope="agent", + session_id="owned-fixture-session") + return None diff --git a/tests/control_plane/research_worker_readback_fixture.py b/tests/control_plane/research_worker_readback_fixture.py index 4d7ae5ba7d..05ea2d622a 100644 --- a/tests/control_plane/research_worker_readback_fixture.py +++ b/tests/control_plane/research_worker_readback_fixture.py @@ -112,7 +112,10 @@ def serve(root: Path, mode: str) -> None: assert index.read_bytes() == before state = json.loads(Path(args.state_file).read_text()) original = state["replan_attempts"][0]["obligation_id"] + original_turn = state["replan_attempts"][0]["settlement_identity"]["turn_instance_id"] + assert original_turn receipt = receipt_path_for_goal({"id": GOAL}, root / "project" / "ACTIVE_GOAL_STATE.md") + assert json.loads(receipt.read_text())["worker_observations"][AGENT]["turn_instance_id"] == original_turn synced = sync_project_registry_to_global(registry_path=Path(args.registry), runtime_root_override=args.runtime_root, goal_id=GOAL) assert synced["ok"] is True @@ -128,7 +131,8 @@ class ObservableChatServer(ChatHTTPServer): def serve_forever(self, poll_interval=0.5): print(json.dumps({"port": self.server_address[1], "registry": args.registry, "runtime": args.runtime_root, "goal_id": GOAL, "agent_id": AGENT, - "obligation_id": original, "receipt": str(receipt), "dispatches": str(dispatches), + "obligation_id": original, "turn_instance_id": original_turn, + "receipt": str(receipt), "dispatches": str(dispatches), "dashboard_registry": str(dashboard_registry), "history": str(index)}), file=output, flush=True) diff --git a/tests/control_plane/test_checkpoint_goal_documents.py b/tests/control_plane/test_checkpoint_goal_documents.py new file mode 100644 index 0000000000..7300cca2e4 --- /dev/null +++ b/tests/control_plane/test_checkpoint_goal_documents.py @@ -0,0 +1,166 @@ +"""Primary Goal documents remain independent of derived Agent state.""" +from __future__ import annotations + +import json +import hashlib +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from tests.control_plane.test_default_result_freshness import _default_fixture +from tests.control_plane.test_quota_settlement_cli import AGENT_ID, GOAL_ID, TODO_ID, _run_cli, _spend_run_count + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_owner_change_survives_agent_state_rollback(provider, tmp_path, monkeypatch): + project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch, handoff_mode="hard_lease") + owner = project / "GOAL.md" + owner.write_text("Export records without an envelope.\n", encoding="utf-8") + registration = json.loads(registry.read_text(encoding="utf-8")) + registration["goals"][0]["authority_sources"] = [ + {"kind": "goal_doc", "path": "GOAL.md", "role": "primary_goal_document"}] + registry.write_text(json.dumps(registration), encoding="utf-8") + rc, lease = _run_cli(registry, runtime, "task-lease", "acquire", "--goal-id", GOAL_ID, + "--todo-id", TODO_ID, "--owner", AGENT_ID, "--idempotency-key", "owner-document-work", + "--ttl-seconds", "600", cwd=project) + assert rc == 0, lease + identity = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID) + rc, admitted = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *identity, + "--turn-instance-id", "owner-document-turn", cwd=project) + assert rc == 0, admitted + original = admitted["delivery_result_context"] + assert original["basis"]["goal"]["primary_documents"] == [ + {"path": str(owner.resolve()), "sha256": hashlib.sha256(owner.read_text(encoding="utf-8").encode()).hexdigest()}] + assert original["basis"]["goal"]["prose"] == owner.read_text(encoding="utf-8") + turn = original["settlement_identity"]["turn_instance_id"] + command = ["loopx", "todo", "complete", *identity, "--turn-instance-id", turn, + "--delivery-read-context", original["read_context_id"], + "--task-lease-idempotency-key", "owner-document-work", "--task-lease-expected-version", "1"] + + # Only the owner document changes. No Todo/provider mutation can explain + # this stale result, and an old Agent plan must not hide the new source. + updated = "Export versioned event envelopes, including initialization.\n" + owner.write_text(updated, encoding="utf-8") + rc, stale = _run_cli(registry, runtime, *command[1:], "--evidence", "Old candidate", cwd=project) + assert rc != 0 and stale["error_code"] == "checkpoint_read_context_stale", stale + assert stale["checkpoint_read_context"]["changed_components"] == ["goal"] + assert _spend_run_count(runtime) == 0 + + # An Agent can still edit a writable state file; doing so cannot replace + # the separately registered owner's requirements in the next read. + state.write_text(state.read_text(encoding="utf-8") + "\nAgent plan: export records without an envelope.\n", encoding="utf-8") + assert "without an envelope" in state.read_text(encoding="utf-8") + rc, current = _run_cli(registry, runtime, "checkpoint-context", *identity, + "--turn-instance-id", turn, "--purpose", "delivery_result", cwd=project) + assert rc == 0, current + assert current["basis"]["goal"]["prose"] == updated + assert "without an envelope" not in current["basis"]["goal"]["prose"] + assert isinstance(current["basis"]["goal"]["state_prose"], str) + assert current["read_context_id"] != original["read_context_id"] + assert current["settlement_identity"] == original["settlement_identity"] + assert owner.read_text(encoding="utf-8") == updated + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_missing_primary_document_cannot_be_rebuilt_from_agent_state(provider, tmp_path, monkeypatch): + project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch) + registration = json.loads(registry.read_text(encoding="utf-8")) + registration["goals"][0]["authority_sources"] = [ + {"kind": "goal_doc", "path": "missing-goal.md", "role": "primary_goal_document"}] + registry.write_text(json.dumps(registration), encoding="utf-8") + before = state.read_bytes() + rc, rejected = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, + "--turn-instance-id", "missing-owner-turn", cwd=project) + assert rc != 0 and rejected["error_code"] == "checkpoint_goal_source_unavailable", rejected + assert state.read_bytes() == before + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_owner_change_during_native_capture_is_rechecked_at_provider_fence(provider, tmp_path, monkeypatch): + project, runtime, registry, _ = _default_fixture(provider, tmp_path, monkeypatch) + owner = project / "GOAL.md" + owner.write_text("Initial requirement.\n", encoding="utf-8") + registration = json.loads(registry.read_text(encoding="utf-8")) + registration["goals"][0]["authority_sources"] = [ + {"kind": "goal_doc", "path": "GOAL.md", "role": "primary_goal_document"}] + registry.write_text(json.dumps(registration), encoding="utf-8") + rc, admitted = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, + "--turn-instance-id", "capture-race-turn", cwd=project) + assert rc == 0, admitted + wrapper = project / "capture_race.py" + wrapper.write_text(f''' +from pathlib import Path +from loopx.control_plane.goals import checkpoint_context_io +original = checkpoint_context_io.delivery_result_context_input +def race(**kwargs): + captured = original(**kwargs) + Path({str(owner)!r}).write_text("Changed owner requirement.\\n", encoding="utf-8") + Path("capture_race_seen").touch() + return captured +checkpoint_context_io.delivery_result_context_input = race +from loopx.cli import main +raise SystemExit(main()) +''', encoding="utf-8") + original = admitted["delivery_result_context"] + complete = ["--format", "json", "--registry", str(registry), "--runtime-root", str(runtime), + "todo", "complete", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--todo-id", TODO_ID, "--turn-instance-id", original["settlement_identity"]["turn_instance_id"], + "--delivery-read-context", original["read_context_id"]] + result = subprocess.run([sys.executable, str(wrapper), *complete, + "--evidence", "Candidate from before owner change"], cwd=project, capture_output=True, + text=True, env={**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parents[2])}) + assert result.stdout, result.stderr + rc, rejected = result.returncode, json.loads(result.stdout) + assert (project / "capture_race_seen").exists() + assert rc != 0 and rejected["error_code"] == "checkpoint_read_context_stale", rejected + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_independent_owner_change_is_checked_on_each_inflight_turn(provider, tmp_path, monkeypatch): + project, runtime, registry, _ = _default_fixture(provider, tmp_path, monkeypatch) + owner = project / "GOAL.md" + owner.write_text("Export direct records.\n", encoding="utf-8") + registration = json.loads(registry.read_text(encoding="utf-8")) + registration["goals"][0]["authority_sources"] = [{"kind": "goal_doc", "path": "GOAL.md"}] + registry.write_text(json.dumps(registration), encoding="utf-8") + identity = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID) + def guard(turn): + rc, result = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", + *identity, "--turn-instance-id", turn, cwd=project) + assert rc == 0, result + return result["delivery_result_context"] + def writeback(turn): + return _run_cli(registry, runtime, "refresh-state", *identity, "--turn-instance-id", turn, + "--classification", "verified_inflight_candidate", "--delivery-outcome", "outcome_progress", + "--delivery-batch-scale", "implementation", "--delivery-boundary", "in_flight_continuation", + "--delivery-workspace-path", str(project), "--no-global-sync", "--suppress-external-sinks", cwd=project) + first = guard("owner-first-turn") + rc, committed = writeback("owner-first-turn") + assert rc == 0 and committed["ok"], committed + rc, spent = _run_cli(registry, runtime, "quota", "spend-slot", *identity, + "--turn-instance-id", "owner-first-turn", "--slots", "1", "--source", "heartbeat", "--execute", cwd=project) + assert rc == 0 and spent["ok"], spent + second = guard("owner-second-turn") + assert second["read_context_id"] != first["read_context_id"] + owner.write_text("Export versioned event envelopes.\n", encoding="utf-8") + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + before = index.read_bytes() + rc, stale = writeback("owner-second-turn") + assert rc != 0 and stale["error_code"] == "checkpoint_read_context_stale", stale + assert index.read_bytes() == before + assert _spend_run_count(runtime) == 1 + rc, reread = _run_cli(registry, runtime, "checkpoint-context", *identity, + "--turn-instance-id", "owner-second-turn", "--purpose", "delivery_result", cwd=project) + assert rc == 0 and reread["basis"]["goal"]["prose"] == owner.read_text(encoding="utf-8"), reread + rc, saved = writeback("owner-second-turn") + assert rc == 0 and saved["ok"], saved + assert saved["result_work_context"]["read_context_id"] == reread["read_context_id"] + assert saved["vision_checkpoint"]["required"] is False diff --git a/tests/control_plane/test_checkpoint_history_isolation.py b/tests/control_plane/test_checkpoint_history_isolation.py new file mode 100644 index 0000000000..dedbe3c411 --- /dev/null +++ b/tests/control_plane/test_checkpoint_history_isolation.py @@ -0,0 +1,192 @@ +"""Indexed settled history cannot poison another Agent's original delivery.""" +from __future__ import annotations + +import json +from pathlib import Path +import sys + +import pytest + +from loopx.control_plane.goals.checkpoint_context_io import _checkpoint_effect, _receipt_path, pending_first_delivery_progress +from loopx.control_plane.quota.settlement import SettlementIdentity +from loopx.goal_mode_mcp import GoalModeMCPConfig, GoalModeMCPControlPlane +from loopx.todos import add_goal_todo +from tests.control_plane.test_default_checkpoint_continuation import _fixture +from tests.test_goal_mode_mcp_settlement import ( + AGENT_ID, GOAL_ID, _complete_default_task, _control, _run_cli, +) + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_growing_settled_backend_history_uses_one_observation_batch(provider, tmp_path, monkeypatch): + import tempfile + from loopx.control_plane.goals import checkpoint_context_io as io + + # A distinct real Effect process inherits this test-only IO probe before + # any production handler is imported. It never touches an active Goal. + transport = tmp_path / "observation-transport" + transport.mkdir() + for key in ("TMPDIR", "TEMP", "TMP"): + monkeypatch.setenv(key, str(transport)) + monkeypatch.setattr(tempfile, "tempdir", str(transport)) + probe = Path(__file__).resolve().parents[1] / "control_plane_ts/first_delivery_observation_probe.mjs" + sink = tmp_path / "observed-reads.txt" + monkeypatch.setenv("NODE_OPTIONS", "--import=" + probe.as_uri()) + monkeypatch.setenv("LOOPX_OBSERVATION_TEST_READS", str(sink)) + monkeypatch.setenv("LOOPX_OBSERVATION_TEST_GOAL", GOAL_ID) + registry, _, first_todo = _fixture(provider, tmp_path, monkeypatch) + for count in range(1, 6): + peer = AGENT_ID if count == 1 else f"history-peer-{count}" + if count == 1: + control = _control(registry) + todo_id = first_todo + else: + registration = json.loads(registry.read_text(encoding="utf-8")) + registration["goals"][0]["coordination"]["registered_agents"].append(peer) + registry.write_text(json.dumps(registration), encoding="utf-8") + todo_id = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="agent", claimed_by=peer, + text="Verify an independent retained candidate.", task_class="advancement_task", + continuation_policy="same_agent_non_delivery")["todo_id"] + rc, baseline = _run_cli(registry, "refresh-state", "--goal-id", GOAL_ID, "--agent-id", peer, + "--vision-summary", "Verify the independent route.", "--vision-acceptance", "Verified candidate.", + "--no-global-sync", "--suppress-external-sinks") + assert rc == 0, baseline + control = GoalModeMCPControlPlane(GoalModeMCPConfig(server_name="history-probe", + runtime_profile="claude_code", legacy_host_surface="claude_code"), + lambda: {"goal_id": GOAL_ID, "registry": str(registry), "agent_id": peer}) + control.command_prefix = lambda: [sys.executable, "-m", "loopx.cli"] + args = ("quota", "should-run", "--goal-id", GOAL_ID, "--agent-id", peer, + "--todo-id", todo_id, "--runtime-profile", "claude_code", + "--turn-instance-id", f"history-original-turn-{count}") + rc, guard = _run_cli(registry, *args) + assert rc == 0 and "delivery_result_context" in guard, {key: guard.get(key) for key in + ("ok", "error", "effective_action", "should_run", "selected_todo")} + rc, selected = _run_cli(registry, *args) + assert rc == 0 and selected["interaction_contract"]["agent_channel"]["delivery_allowed"] is True, selected + identity = guard["delivery_result_context"]["settlement_identity"] + completed = json.loads(_complete_default_task(control, identity["todo_id"], peer, + "Synthetic candidate independently verified.", next_agent_todo=f"Verify the next candidate after delivery {count}.")) + assert completed.get("settlement_complete") is True, completed + if count not in (1, 3, 5): + continue + calls = [] + effect = io._checkpoint_effect + + def observe(method, request): + calls.append(method) + return effect(method, request) + + sink.write_text("") + with monkeypatch.context() as patch: + patch.setattr(io, "_checkpoint_effect", observe) + assert pending_first_delivery_progress(tmp_path / "runtime", GOAL_ID, + registry_path=registry) is None + assert calls == ["turn.first_delivery.observe"] + reads = sink.read_text().splitlines() + assert reads.count("index") == 2 + assert reads.count("rollout") == 1 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("purpose", ["first_delivery", "delivery_result"]) +def test_settled_peer_read_damage_preserves_independent_delivery(provider, purpose, tmp_path, monkeypatch): + registry, _, first_todo = _fixture(provider, tmp_path, monkeypatch) + peer = "independent-peer" + registration = json.loads(registry.read_text(encoding="utf-8")) + registration["goals"][0]["coordination"]["registered_agents"].append(peer) + registry.write_text(json.dumps(registration), encoding="utf-8") + first = _control(registry) + assert json.loads(first.should_run()).get("delivery_result_context") + settled = json.loads(_complete_default_task(first, first_todo, AGENT_ID, + "Independent candidate verified.", next_agent_todo="Verify remaining independent work.")) + assert settled.get("settlement_complete") is True, settled + runtime = tmp_path / "runtime" + old = SettlementIdentity.from_runtime_payload(settled["settlement_identity"]) + proof = _checkpoint_effect("goal.checkpoint_read_context.inspect_attempt", { + # Todo work reads are committed by the Todo owner; its later indexed + # direction proves the completed history for either disposable read. + "runtime_root": str(runtime), "identity": old.as_dict(), "purpose": "first_delivery", + }) + assert proof["status"] == "committed", proof + artifacts = {Path(proof[key]): Path(proof[key]).read_bytes() for key in ("json_path", "markdown_path")} + damaged = _receipt_path(runtime, old, purpose) + damaged.write_text("{", encoding="utf-8") + # This is the shared status and packaged UI readback, not only admission. + assert pending_first_delivery_progress(runtime, GOAL_ID, peer, registry_path=registry) is None + assert pending_first_delivery_progress(runtime, GOAL_ID, AGENT_ID, registry_path=registry) is None + + todo = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="agent", claimed_by=peer, + text="Verify the independent peer candidate.", task_class="advancement_task", + continuation_policy="same_agent_non_delivery")["todo_id"] + rc, baseline = _run_cli(registry, "refresh-state", "--goal-id", GOAL_ID, "--agent-id", peer, + "--vision-summary", "Verify the peer route.", "--vision-acceptance", "Verified peer candidate.", + "--no-global-sync", "--suppress-external-sinks") + assert rc == 0, baseline + args = ("quota", "should-run", "--goal-id", GOAL_ID, "--agent-id", peer, + "--todo-id", todo, "--runtime-profile", "claude_code", "--turn-instance-id", "peer-original-turn") + rc, guard = _run_cli(registry, *args) + assert rc == 0 and guard.get("delivery_result_context"), guard + identity = guard["delivery_result_context"]["settlement_identity"] + rc, selected = _run_cli(registry, *args) + assert rc == 0 and selected["interaction_contract"]["agent_channel"]["delivery_allowed"] is True, selected + control = GoalModeMCPControlPlane(GoalModeMCPConfig(server_name="fixture-peer", + runtime_profile="claude_code", legacy_host_surface="claude_code"), + lambda: {"goal_id": GOAL_ID, "registry": str(registry), "agent_id": peer}) + control.command_prefix = lambda: [sys.executable, "-m", "loopx.cli"] + result = json.loads(_complete_default_task(control, todo, peer, "Peer candidate verified.", + next_agent_todo="Verify remaining peer work.")) + assert result.get("settlement_complete") is True, result + assert result["settlement_identity"] == identity + assert pending_first_delivery_progress(runtime, GOAL_ID, peer, registry_path=registry) is None + assert damaged.read_text(encoding="utf-8") == "{" + assert all(path.read_bytes() == contents for path, contents in artifacts.items()) + rows = [json.loads(line) for line in (runtime / "goals" / GOAL_ID / "runs/index.jsonl").read_text().splitlines()] + spends = [row for row in rows if row.get("classification") == "quota_slot_spent"] + assert len(spends) == 2 + assert sum(row["settlement_identity"]["effect_id"] == identity["effect_id"] for row in spends) == 1 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("loss", ["damaged", "deleted"]) +def test_recovered_history_retains_original_unspent_accounting(provider, loss, tmp_path, monkeypatch): + registry, _, todo = _fixture(provider, tmp_path, monkeypatch) + control = _control(registry) + guard = json.loads(control.should_run()) + identity = SettlementIdentity.from_runtime_payload(guard["delivery_result_context"]["settlement_identity"]) + run_cli = control.run_cli + + def stop_before_spend(args, **kwargs): + if args[:2] == ["quota", "spend-slot"]: + return json.dumps({"ok": False, "error": "Synthetic stop before accounting."}) + return run_cli(args, **kwargs) + + control.run_cli = stop_before_spend + result = json.loads(_complete_default_task(control, todo, AGENT_ID, "Candidate verified.", + next_agent_todo="Verify remaining independent work.")) + assert result.get("settlement_complete") is not True, result + runtime = tmp_path / "runtime" + proof = _checkpoint_effect("goal.checkpoint_read_context.inspect_attempt", { + "runtime_root": str(runtime), "identity": identity.as_dict(), "purpose": "first_delivery"}) + assert proof["status"] == "committed", proof + for purpose in ("first_delivery", "delivery_result"): + path = _receipt_path(runtime, identity, purpose) + if loss == "deleted": + path.unlink() + else: + path.write_text("{", encoding="utf-8") + from loopx.control_plane.goals import checkpoint_context_io as io + calls = [] + effect = io._checkpoint_effect + + def observe(method, request): + calls.append(method) + return effect(method, request) + + monkeypatch.setattr(io, "_checkpoint_effect", observe) + progress = pending_first_delivery_progress(runtime, GOAL_ID, AGENT_ID, registry_path=registry) + assert calls == ["turn.first_delivery.observe"] # No per-Turn bridge or repair. + assert progress["stage"] == "settlement_pending", progress + assert progress["settlement_identity"] == identity.as_dict() + assert progress["result_committed"] is True and progress["quota_spent"] is False + rows = [json.loads(line) for line in (runtime / "goals" / GOAL_ID / "runs/index.jsonl").read_text().splitlines()] + assert not any(row.get("classification") == "quota_slot_spent" for row in rows) diff --git a/tests/control_plane/test_checkpoint_provider_fence.py b/tests/control_plane/test_checkpoint_provider_fence.py index bc29c39b72..70b403294f 100644 --- a/tests/control_plane/test_checkpoint_provider_fence.py +++ b/tests/control_plane/test_checkpoint_provider_fence.py @@ -9,7 +9,7 @@ import time import pytest -from canonical_authority_fixture import advance_canonical_fixture_authority, isolate_sqlite_runtime +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from loopx.control_plane.effect_runtime import EffectRuntimeConflict @@ -92,16 +92,19 @@ def _replace_source_goal(registry: Path, instance_id: str) -> None: transaction.commit(payload) -def fixture(tmp_path, monkeypatch, provider): +def fixture(tmp_path, monkeypatch, provider, *, first_delivery=False): isolate_sqlite_runtime(tmp_path, monkeypatch) - project, runtime, registry, binding, delivery, original = _missing(tmp_path, provider=provider) - state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" todos = [{"schema_version": "todo_item_v0", "todo_id": name, "index": index, "role": "agent", "status": "open", "done": False, "text": f"Synthetic page work {name}", "task_class": "advancement_task", "archive_state": "active", "source_section": "Agent Todo", "claimed_by": AGENT_ID} for index, name in enumerate((TODO_ID, "todo_dependency", "todo_unrelated"), 1)] projection = build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=todos, handoff_mode="hard_lease", leases=[]) - advance_canonical_fixture_authority(runtime, GOAL_ID, projection, operation_id="fixture-checkpoint-fences") + def initialize(project, runtime, _registry): + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + initialize_canonical_authority(runtime, GOAL_ID, projection, state_path=state, provider=provider) + project, runtime, registry, binding, delivery, original = _missing( + tmp_path, defer=first_delivery, historical=True, initialize=initialize) + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" for todo in todos: rc, lease = _run_cli(registry, runtime, "task-lease", "acquire", "--goal-id", GOAL_ID, "--todo-id", todo["todo_id"], "--owner", AGENT_ID, @@ -111,7 +114,7 @@ def fixture(tmp_path, monkeypatch, provider): def read(): return context_io.read_checkpoint_context(registry_path=registry, runtime_root_override=str(runtime), goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID, - dependency_todo_ids=["todo_dependency"]) + dependency_todo_ids=["todo_dependency"], purpose="first_delivery" if first_delivery else "supplement_checkpoint") return project, runtime, registry, state, read, original @@ -160,6 +163,80 @@ def test_public_update_before_final_read_rejects_then_reread_succeeds(tmp_path, assert _spend_run_count(runtime) == 0 +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_runtime_directory_alias_commits_and_cold_replays_the_original_turn(tmp_path, monkeypatch, provider): + _, runtime, registry, _, read, original = fixture(tmp_path, monkeypatch, provider) + token = read()["read_context_id"] + alias = tmp_path / "runtime-alias" + if os.name == "nt": + import _winapi + _winapi.CreateJunction(str(runtime), str(alias)) + else: + alias.symlink_to(runtime, target_is_directory=True) + try: + saved = refresh(registry, alias, token) + assert saved["appended"] and saved["vision_checkpoint"]["satisfied"], saved + assert Path(saved["json_path"]).parent == runtime.resolve() / "goals" / GOAL_ID / "runs" + assert saved["settlement_identity"] == original["settlement_identity"] + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + artifacts = {Path(saved[key]): Path(saved[key]).read_bytes() for key in ("json_path", "markdown_path")} + before = index.read_bytes() + for root in (runtime, alias): + replay = refresh(registry, root, token) + assert replay["idempotent_replay"] and replay["settlement_identity"] == saved["settlement_identity"], replay + assert index.read_bytes() == before + assert all(path.read_bytes() == contents for path, contents in artifacts.items()) + assert _spend_run_count(runtime) == 0 + finally: + alias.rmdir() if os.name == "nt" else alias.unlink() + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.skipif(os.name == "nt", reason="Windows held index handles reject directory replacement before the native identity guard; exercise this injection on POSIX") +def test_replaced_artifact_directory_cannot_receive_the_checkpoint_append(tmp_path, monkeypatch, provider): + import shutil + _, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider) + token = read()["read_context_id"] + runs = runtime / "goals" / GOAL_ID / "runs" + before = (runs / "index.jsonl").read_bytes() + barrier = tmp_path / "directory-race" + barrier.mkdir() + effect = context_io.effect_runtime_result + + def native(method, params, **kwargs): + if method != "goal.checkpoint_read_context.commit": + return effect(method, params, **kwargs) + child = start_probe({"mode": "checkpoint", "provider": provider, "barrier": str(barrier), "params": params}) + try: + wait_for(barrier / "head-read", child) + retained = runs.with_name("runs-retained") + runs.rename(retained) + # Identical bytes do not make a replacement the directory whose + # identity was admitted before the provider head was acquired. + shutil.copytree(retained, runs) + assert (runs.stat().st_dev, runs.stat().st_ino) != ( + retained.stat().st_dev, retained.stat().st_ino) + (barrier / "release").touch() + stdout, stderr = child.communicate(timeout=20) + assert child.returncode == 1, stdout + stderr + rejected = json.loads(stdout) + assert rejected["error_code"] == "checkpoint_commit_unknown", rejected + assert "read back the original Turn before retrying" in rejected["error"], rejected + raise RuntimeError("replaced directory refused at the checkpoint owner") + finally: + (barrier / "release").touch() + if child.poll() is None: + child.kill() + child.communicate() + + monkeypatch.setattr(context_io, "effect_runtime_result", native) + with pytest.raises(RuntimeError, match="replaced directory refused"): + refresh(registry, runtime, token) + assert (runs / "index.jsonl").read_bytes() == before + assert (runs.with_name("runs-retained") / "index.jsonl").read_bytes() == before + assert _spend_run_count(runtime) == 0 + + def test_exact_source_checkpoint_commits_only_for_the_current_goal_instance( tmp_path, monkeypatch, @@ -268,8 +345,9 @@ def test_existing_cli_maintenance_guard_precedes_provider_commit(tmp_path, monke @pytest.mark.parametrize("provider", ["file", "sqlite"]) -def test_provider_transaction_cannot_commit_between_final_head_and_checkpoint(tmp_path, monkeypatch, provider): - _, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider) +@pytest.mark.parametrize("first_delivery", [False, True]) +def test_provider_transaction_cannot_commit_between_final_head_and_checkpoint(tmp_path, monkeypatch, record_property, provider, first_delivery): + _, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider, first_delivery=first_delivery) context = read() barrier = tmp_path / "barrier" barrier.mkdir() @@ -301,7 +379,9 @@ def native(method, params, **kwargs): assert saved["ok"] and not saved["replayed"] assert json.loads((barrier / "runtime-replay").read_text())["replayed"] finish(writer) - assert json.loads((barrier / "provider-result").read_text())["status"] == "applied" + provider_result = json.loads((barrier / "provider-result").read_text()) + assert provider_result["status"] == "applied" + record_property("provider_commit_elapsed_ms", provider_result["elapsed_ms"]) return saved finally: (barrier / "release").touch() @@ -311,7 +391,7 @@ def native(method, params, **kwargs): child.communicate() monkeypatch.setattr(context_io, "effect_runtime_result", native) - result = refresh(registry, runtime, context["read_context_id"]) + result = refresh(registry, runtime, context["read_context_id"], first_delivery=first_delivery) assert result["vision_checkpoint"]["satisfied"] and len(observed) == 1 assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID)["provider_revision"] != observed[0] rows = [json.loads(line) for line in (runtime / f"goals/{GOAL_ID}/runs/index.jsonl").read_text().splitlines()] @@ -320,7 +400,8 @@ def native(method, params, **kwargs): @pytest.mark.parametrize("provider", ["file", "sqlite"]) -def test_provider_commit_after_python_check_is_revalidated_at_save(tmp_path, monkeypatch, provider): +@pytest.mark.parametrize("first_delivery", [False, True]) +def test_provider_commit_after_python_check_is_revalidated_at_save(tmp_path, monkeypatch, provider, first_delivery): """Provider contract: a transaction without the CLI's outer M protection. The reviewed implementation accepts this old checkpoint. The native save @@ -328,7 +409,7 @@ def test_provider_commit_after_python_check_is_revalidated_at_save(tmp_path, mon hold M before committing; this test does not pretend to reproduce that path. """ from loopx import state_refresh - _, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider) + _, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider, first_delivery=first_delivery) token = read()["read_context_id"] index = runtime / f"goals/{GOAL_ID}/runs/index.jsonl" before = index.read_bytes() @@ -349,7 +430,7 @@ def race(*args): monkeypatch.setattr(state_refresh, "reserve_unique_run_paths", race) try: with pytest.raises(context_io.CheckpointReadContextRejected) as rejected: - refresh(registry, runtime, token) + refresh(registry, runtime, token, first_delivery=first_delivery) assert rejected.value.code == "checkpoint_read_context_stale" assert index.read_bytes() == before finally: diff --git a/tests/control_plane/test_checkpoint_read_context.py b/tests/control_plane/test_checkpoint_read_context.py index 3e21a89eec..d76d8c5b17 100644 --- a/tests/control_plane/test_checkpoint_read_context.py +++ b/tests/control_plane/test_checkpoint_read_context.py @@ -11,11 +11,16 @@ from tests.control_plane.test_quota_settlement_cli import ( AGENT_ID, GOAL_ID, TODO_ID, TURN_ID, REPO_ROOT, _run_cli, _write_fixture, _spend_run_count, + _initialize_fixture_authority, ) -def _missing(root: Path, *, provider: str = "sqlite"): - project, runtime, registry = _write_fixture(root, provider=provider) +def _missing(root: Path, *, provider: str = "sqlite", defer=False, historical=False, initialize=None): + project, runtime, registry = _write_fixture(root) + if initialize is None: + _initialize_fixture_authority(project, runtime, provider=provider) + else: + initialize(project, runtime, registry) binding = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID) for args in ( ("refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, @@ -23,18 +28,31 @@ def _missing(root: Path, *, provider: str = "sqlite"): "--no-global-sync", "--suppress-external-sinks"), ("quota", "should-run", "--codex-app", *binding, ), ): + if historical and args[0] == "quota": + from loopx.control_plane.quota.heartbeat_receipt import ensure_turn_heartbeat_settlement_receipt + from loopx.control_plane.quota.settlement import SettlementIdentity + + # Construct a pre-protection admission; never infer old behavior + # from today's default guard or edit an actual execution receipt. + ensure_turn_heartbeat_settlement_receipt(runtime, + SettlementIdentity(goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID), + semantic_replan_guard_scoped=True, semantic_replan_obligation_id=None) + continue rc, result = _run_cli(registry, runtime, *args, cwd=project) assert rc == 0, result delivery = ("refresh-state", *binding, "--classification", "validated_change", "--delivery-batch-scale", "implementation", "--delivery-outcome", "outcome_progress", "--no-global-sync", "--suppress-external-sinks") + if defer: + return project, runtime, registry, binding, delivery, None rc, original = _run_cli(registry, runtime, *delivery, cwd=project) assert rc == 0 and original["vision_checkpoint"]["decision"] == "missing_required", original return project, runtime, registry, binding, delivery, original def test_checkpoint_recovery_is_not_a_fresh_turn_preflight(tmp_path): - project, runtime, registry = _write_fixture(tmp_path, provider="sqlite") + project, runtime, registry = _write_fixture(tmp_path) + _initialize_fixture_authority(project, runtime) binding = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID) rc, admitted = _run_cli(registry, runtime, "quota", "should-run", @@ -46,6 +64,119 @@ def test_checkpoint_recovery_is_not_a_fresh_turn_preflight(tmp_path): assert _spend_run_count(runtime) == 0 +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_historical_explicit_first_delivery_cli_rejects_stale_and_replays_success(tmp_path, monkeypatch, provider): + from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = _write_fixture(tmp_path) + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + todo = {"schema_version": "todo_item_v0", "todo_id": TODO_ID, "index": 1, + "role": "agent", "status": "open", "done": False, + "text": "Validate and settle the selected delivery.", "claimed_by": AGENT_ID, + "task_class": "advancement_task", "archive_state": "active", "source_section": "Agent Todo"} + projection = build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=[todo]) + initialize_canonical_authority(runtime, GOAL_ID, projection, state_path=state, provider=provider) + binding = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID) + from loopx.control_plane.quota.heartbeat_receipt import ensure_turn_heartbeat_settlement_receipt + from loopx.control_plane.quota.settlement import SettlementIdentity + # Pin the original opt-in admission, rather than downgrading a new default Turn. + ensure_turn_heartbeat_settlement_receipt(runtime, + SettlementIdentity(goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID), + semantic_replan_guard_scoped=True, semantic_replan_obligation_id=None) + rc, guard = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *binding, + cwd=project) + assert rc == 0, json.dumps(guard) + read = ("checkpoint-context", *binding, "--purpose", "first_delivery") + rc, context = _run_cli(registry, runtime, *read, cwd=project) + assert rc == 0, context + delivery = ("refresh-state", *binding, "--first-delivery", "--classification", "validated_change", + "--delivery-batch-scale", "implementation", "--delivery-outcome", "outcome_progress", + "--vision-summary", "Continue the scoped route.", "--vision-acceptance", "Checks pass; remaining work stays open.", + "--no-global-sync", "--suppress-external-sinks") + index = runtime / f"goals/{GOAL_ID}/runs/index.jsonl" + before = index.read_bytes() if index.exists() else b"" + rc, no_context = _run_cli(registry, runtime, *delivery, cwd=project) + assert rc == 1 and no_context["error_code"] == "checkpoint_read_context_required", no_context + state.write_text(state.read_text(encoding="utf-8") + "\n## Acceptance\n\nVerify the additional output.\n", encoding="utf-8") + rc, stale = _run_cli(registry, runtime, *delivery, "--checkpoint-read-context", context["read_context_id"], cwd=project) + assert rc == 1 and stale["error_code"] == "checkpoint_read_context_stale", stale + assert "goal" in stale["checkpoint_read_context"]["changed_components"] + assert (index.read_bytes() if index.exists() else b"") == before + rc, context = _run_cli(registry, runtime, *read, cwd=project) + assert rc == 0, context + commit = (*delivery, "--checkpoint-read-context", context["read_context_id"]) + rc, saved = _run_cli(registry, runtime, *commit, cwd=project) + assert rc == 0 and saved["vision_checkpoint"]["satisfied"], json.dumps(saved) + after = index.read_bytes() + state.write_text(state.read_text(encoding="utf-8") + "\nAcceptance changed after success.\n", encoding="utf-8") + rc, replay = _run_cli(registry, runtime, *commit, cwd=project) + assert rc == 0 and replay["idempotent_replay"], replay + assert index.read_bytes() == after + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_delivery_result_basis_precedes_cas_and_replays_before_current_freshness(tmp_path, monkeypatch, provider): + from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime + from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = _write_fixture(tmp_path) + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + todo = {"schema_version": "todo_item_v0", "todo_id": TODO_ID, "index": 1, + "role": "agent", "status": "open", "done": False, "claimed_by": AGENT_ID, + "text": "Validate and settle the selected delivery.", "task_class": "advancement_task", + "archive_state": "active", "source_section": "Agent Todo"} + peer = {**todo, "todo_id": "todo_unrelated_peer", "index": 2, "text": "Independent output."} + initialize_canonical_authority(runtime, GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=[todo, peer], handoff_mode="soft_claim"), state_path=state, provider=provider) + binding = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID) + rc, guard = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *binding, + cwd=project) + assert rc == 0, guard + read = ("checkpoint-context", *binding, "--purpose", "delivery_result") + rc, context = _run_cli(registry, runtime, *read, cwd=project) + assert rc == 0, context + complete = ("todo", "complete", *binding, "--note", "Validated the candidate; direction review remains.") + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + def committed_sources(): + return read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID), index.read_bytes() if index.exists() else None, _spend_run_count(runtime) + before = committed_sources() + rc, missing = _run_cli(registry, runtime, *complete, "--delivery-read-context", "unknown-read", cwd=project) + assert rc == 1 and missing.get("error_code") == "checkpoint_read_context_unknown_or_replaced", missing + assert committed_sources() == before + rc, unknown = _run_cli(registry, runtime, *complete, "--delivery-read-context", "unknown-context", cwd=project) + assert rc == 1 and unknown.get("error_code") == "checkpoint_read_context_unknown_or_replaced", unknown + assert committed_sources() == before + state.write_text(state.read_text(encoding="utf-8") + "\n## Acceptance\n\nAdditional acceptance requirement.\n", encoding="utf-8") + rc, stale = _run_cli(registry, runtime, *complete, "--delivery-read-context", context["read_context_id"], cwd=project) + assert rc == 1 and stale.get("error_code") == "checkpoint_read_context_stale", json.dumps(stale) + assert committed_sources() == before + rc, context = _run_cli(registry, runtime, *read, cwd=project) + assert rc == 0, context + commit = (*complete, "--delivery-read-context", context["read_context_id"]) + rc, peer_update = _run_cli(registry, runtime, "todo", "update", "--goal-id", GOAL_ID, + "--todo-id", "todo_unrelated_peer", "--agent-id", AGENT_ID, "--note", "Peer progress must survive.", cwd=project) + assert rc == 0, peer_update + rc, saved = _run_cli(registry, runtime, *commit, cwd=project) + assert rc == 0 and saved["completed"], saved + rc, peer_readback = _run_cli(registry, runtime, "todo", "list", "--goal-id", GOAL_ID, + "--todo-id", "todo_unrelated_peer", cwd=project) + assert rc == 0 and peer_readback["todo"]["note"] == "Peer progress must survive.", peer_readback + state.write_text(state.read_text(encoding="utf-8") + "\nRequirement after successful result.\n", encoding="utf-8") + rc, replay = _run_cli(registry, runtime, *commit, cwd=project) + assert rc == 0 and replay["idempotent_replay"], replay + rc, conflict = _run_cli(registry, runtime, *commit, "--note", "Different candidate intent.", cwd=project) + assert rc == 1, conflict + # The next direction reads the committed result, so its own open -> done + # change cannot invalidate the new basis. + rc, direction = _run_cli(registry, runtime, "checkpoint-context", *binding, "--purpose", "first_delivery", cwd=project) + assert rc == 0 and direction["basis"]["todo"]["status"] == "done", direction + + def test_missing_replaced_stale_context_requires_reread_and_preserves_delivery(tmp_path): project, runtime, registry, binding, delivery, original = _missing(tmp_path) state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" @@ -63,12 +194,12 @@ def test_missing_replaced_stale_context_requires_reread_and_preserves_delivery(t "--checkpoint-read-context", a["read_context_id"], cwd=project) assert rc == 1 and replaced["error_code"] == "checkpoint_read_context_unknown_or_replaced", replaced - # A synthetic source edit represents a peer's completed update. The separate - # concurrency test proves the cooperating writer cannot cross the commit guard. + # Update the canonical Todo owner, not its Markdown display. rc, updated = _run_cli(registry, runtime, "todo", "update", "--goal-id", GOAL_ID, "--todo-id", TODO_ID, "--agent-id", AGENT_ID, "--text", "Validate the updated delivery scope.", - "--execute", cwd=project) - assert rc == 0 and updated["ok"], updated + "--update-operation-id", "checkpoint-peer-scope", "--update-expected-provider-revision", + b["provider_revision"], cwd=project) + assert rc == 0, updated rc, stale = _run_cli(registry, runtime, *delivery, *vision, "--checkpoint-read-context", b["read_context_id"], cwd=project) assert rc == 1 and stale["error_code"] == "checkpoint_read_context_stale", stale @@ -96,22 +227,14 @@ def test_missing_replaced_stale_context_requires_reread_and_preserves_delivery(t def test_large_goal_context_keeps_complete_basis_and_checkpoint_only_recovery( tmp_path, monkeypatch, provider, ): - from canonical_authority_fixture import advance_canonical_fixture_authority, isolate_sqlite_runtime - from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + from canonical_authority_fixture import isolate_sqlite_runtime from loopx.control_plane.effect_runtime import MAX_REQUEST_BYTES if provider == "sqlite": isolate_sqlite_runtime(tmp_path, monkeypatch) - project, runtime, registry, binding, delivery, original = _missing(tmp_path, provider=provider) + project, runtime, registry, binding, delivery, original = _missing(tmp_path, + initialize=lambda project, runtime, _: _initialize_fixture_authority(project, runtime, provider=provider)) state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" - if provider != "legacy": - todo = {"schema_version": "todo_item_v0", "todo_id": TODO_ID, "index": 1, - "role": "agent", "status": "open", "done": False, - "text": "Validate and settle the selected delivery.", - "task_class": "advancement_task", "archive_state": "active", - "source_section": "Agent Todo"} - projection = build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=[todo]) - advance_canonical_fixture_authority(runtime, GOAL_ID, projection, operation_id="fixture-checkpoint-context") # The original failure came from complete source prose, not from the # small prior Turn receipt. Keep the source intact and cross both 2 MiB # request and response boundaries through the real local CLI/runtime. @@ -122,8 +245,7 @@ def test_large_goal_context_keeps_complete_basis_and_checkpoint_only_recovery( rc, context = _run_cli(registry, runtime, "checkpoint-context", *binding, cwd=project) assert rc == 0, {key: value for key, value in context.items() if key != "basis"} assert context["basis"]["goal"]["prose"].endswith(large_prose.strip()) - assert context["basis"]["source"]["authority"] == ( - "legacy_markdown" if provider == "legacy" else f"{provider}_v0") + assert context["basis"]["source"]["authority"] == f"{provider}_v0" state.write_text(state.read_text(encoding="utf-8") + "\nUpdated acceptance context.\n", encoding="utf-8") supplement = (*delivery, @@ -137,14 +259,14 @@ def test_large_goal_context_keeps_complete_basis_and_checkpoint_only_recovery( "Updated acceptance context."), context.get("error") rc, result = _run_cli(registry, runtime, *supplement, "--checkpoint-read-context", context["read_context_id"], cwd=project) - assert rc == 0 and result["vision_checkpoint"]["satisfied"], result + assert rc == 0 and result["vision_checkpoint"]["satisfied"], json.dumps(result, indent=2) assert result["settlement_identity"] == original["settlement_identity"] assert _spend_run_count(runtime) == 0 @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_context_reads_real_canonical_todo_and_owner_acceptance(tmp_path, monkeypatch, provider): - from canonical_authority_fixture import advance_canonical_fixture_authority, isolate_sqlite_runtime + from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from loopx.control_plane.coordination.local_authority_shadow_projection import canonical_bytes from loopx.control_plane.goals.checkpoint_context_io import read_checkpoint_context @@ -154,7 +276,6 @@ def test_context_reads_real_canonical_todo_and_owner_acceptance(tmp_path, monkey isolate_sqlite_runtime(tmp_path, monkeypatch) import tempfile monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) - project, runtime, registry, _, _, _ = _missing(tmp_path, provider=provider) todo = {"schema_version": "todo_item_v0", "todo_id": TODO_ID, "index": 1, "role": "agent", "status": "done", "done": True, "text": "Canonical delivered result", "task_class": "advancement_task", "archive_state": "active", "source_section": "Agent Todo"} @@ -165,7 +286,10 @@ def test_context_reads_real_canonical_todo_and_owner_acceptance(tmp_path, monkey projection["goal_acceptance"] = {"schema_version": "loopx_goal_acceptance_v0", "enabled": True, "revision": 1, "digest": hashlib.sha256(canonical_bytes(document)).hexdigest(), "document": document, "bindings": [], "verification": None} - advance_canonical_fixture_authority(runtime, GOAL_ID, projection, operation_id="fixture-checkpoint-context") + def initialize(project, runtime, _registry): + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + initialize_canonical_authority(runtime, GOAL_ID, projection, state_path=state, provider=provider) + project, runtime, registry, _, _, _ = _missing(tmp_path, historical=True, initialize=initialize) context = read_checkpoint_context(registry_path=registry, runtime_root_override=str(runtime), goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID) assert context["basis"]["todo"]["text"] == "Canonical delivered result" @@ -192,7 +316,8 @@ def observe(method, request): registry_path=registry, runtime_root_override=str(runtime), goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID, ) - assert calls == [("goal.checkpoint_read_context.resolve", "read")] + assert calls == [("goal.checkpoint_read_context.work_binding", "direction_policy"), + ("goal.checkpoint_read_context.resolve", "read")] identity = SettlementIdentity.from_runtime_payload(context["settlement_identity"]) calls.clear() @@ -201,7 +326,8 @@ def observe(method, request): identity=identity, read_context_id=context["read_context_id"], ) as checked: assert checked["ok"] - assert calls == [("goal.checkpoint_read_context.resolve", "check")] + assert calls == [("goal.checkpoint_read_context.work_binding", "direction_policy"), + ("goal.checkpoint_read_context.resolve", "check")] def test_source_writers_remain_excluded_until_checkpoint_append(tmp_path, monkeypatch): diff --git a/tests/control_plane/test_cli_output_budget.py b/tests/control_plane/test_cli_output_budget.py index 2a3377e7fe..ad2abee1b3 100644 --- a/tests/control_plane/test_cli_output_budget.py +++ b/tests/control_plane/test_cli_output_budget.py @@ -15,7 +15,7 @@ from loopx.cli import main as cli_main from loopx.control_plane.scheduler.execution_context import SchedulerRuntimeProfile -from loopx.control_plane.testing.canary_harness import import_fixture_authority +from loopx.control_plane.testing.canary_harness import import_fixture_authority, temporary_canary_directory from loopx.control_plane.testing.cli_output_budget import ( CLI_OUTPUT_BUDGET_BY_ID, CLI_OUTPUT_BUDGET_SPECS, @@ -332,6 +332,23 @@ def _quota_payload_without_rollout_receipt(text: str) -> dict[str, object]: return payload +def _admit_quota_comparison(command: list[str]) -> list[str]: + """Compare display selectors in one admitted Turn, not admission vs replay. + + First automatic admission is exercised separately on real providers. Do not + strip identity, receipt or binding fields from the comparisons below. + """ + command = command + ["--turn-instance-id", "detail-parity-turn"] + code, text = _invoke_cli(command) + payload = json.loads(text) + assert code == (0 if payload["ok"] else 1), text + assert payload["heartbeat_receipt"]["status"] == "committed", text + assert payload["heartbeat_receipt"]["turn_instance_id"] == "detail-parity-turn" + if not payload["status_health_ok"]: + assert payload["should_run"] is False and payload["effective_action"] == "quota_skip" + return command + + def _surface_commands( *, project: Path, @@ -449,7 +466,7 @@ def _surface_commands( } -def _measure_scenario(root: Path, scenario: Scenario) -> dict[str, dict[str, dict]]: +def _measure_scenario(root: Path, scenario: Scenario, *, surface_ids: tuple[str, ...] | None = None) -> dict[str, dict[str, dict]]: with _stable_budget_fixture_root(root) as stable_root: project, runtime, registry_path, state_file = _write_fixture( stable_root, @@ -466,6 +483,8 @@ def _measure_scenario(root: Path, scenario: Scenario) -> dict[str, dict[str, dic output_format=output_format, ) for surface_id, command in commands.items(): + if surface_ids is not None and surface_id not in surface_ids: + continue exit_code, text = _invoke_cli(command) assert exit_code == 0, (surface_id, output_format, text) measurement = measure_cli_output( # type: ignore[arg-type] @@ -488,6 +507,16 @@ def _measure_scenario(root: Path, scenario: Scenario) -> dict[str, dict[str, dic return results +def test_default_quota_work_basis_preserves_output_contract(tmp_path: Path) -> None: + # Same full populations and command path as the complete interface matrix. + for scenario in SCENARIOS: + measured = _measure_scenario(tmp_path / scenario.name, scenario, surface_ids=("quota_should_run",)) + payload = measured["quota_should_run"]["json"]["payload"] + assert payload["heartbeat_receipt"]["turn_instance_id"] + if scenario.name == "small": + assert payload["delivery_result_context"]["basis"]["todo"]["todo_id"] == "todo_fixture_000" + + def _assert_turn_plan_writeback_routes( measurement: dict, registry_path: Path, runtime: Path ) -> None: @@ -1043,6 +1072,9 @@ def test_quota_cli_keeps_full_agent_todo_diagnostics_on_explicit_cold_path( output_format="json", )["quota_should_run_todo_summary_detail"] + default_command = _admit_quota_comparison(default_command) + detail_command += ["--turn-instance-id", "detail-parity-turn"] + default_exit_code, default_text = _invoke_cli(default_command) detail_exit_code, detail_text = _invoke_cli(detail_command) @@ -1147,6 +1179,9 @@ def test_quota_cli_keeps_complete_vision_rules_with_reachable_duplicate_refs( output_format="json", )["quota_should_run_vision_detail"] + default_command = _admit_quota_comparison(default_command) + detail_command += ["--turn-instance-id", "detail-parity-turn"] + default_exit_code, default_text = _invoke_cli(default_command) detail_exit_code, detail_text = _invoke_cli(detail_command) @@ -1258,9 +1293,16 @@ def test_crowded_turn_plan_budget_preserves_executable_vision_authoring( assert writeback["vision_authoring"]["schema_version"] == ( "goal_vision_replan_contract_v0" ) + # The full authoring contract must survive compact JSON transport. Its size + # is not a lower bound on whitespace in the emitted representation. + assert writeback["vision_authoring"]["fields"]["vision_patch"]["acceptance_summary"] > 0 + delta = writeback["vision_authoring"]["path_delta"] + assert {"continue", "replan", "no_change"} <= set(delta["outcomes"]) + assert {"outcome", "prior_assumption", "observed_reality"} <= set(delta["required"]) + assert delta["list_limits"]["evidence_refs"]["max_items"] > 0 # This fixed executable schema legitimately crosses the old 12k/320 # ceiling; retain bounded headroom without relaxing Todo-scale growth. - assert 12_000 < len(text) <= CLI_OUTPUT_BUDGET_BY_ID["loopx_turn_plan"].max_chars["crowded"]["json"] + assert len(text) <= CLI_OUTPUT_BUDGET_BY_ID["loopx_turn_plan"].max_chars["crowded"]["json"] assert len(text.splitlines()) <= CLI_OUTPUT_BUDGET_BY_ID["loopx_turn_plan"].max_lines["crowded"]["json"] @@ -1320,6 +1362,9 @@ def test_quota_cli_keeps_full_user_todo_diagnostics_on_explicit_cold_path( output_format="json", )["quota_should_run_user_todo_summary_detail"] + default_command = _admit_quota_comparison(default_command) + detail_command += ["--turn-instance-id", "detail-parity-turn"] + default_exit_code, default_text = _invoke_cli(default_command) detail_exit_code, detail_text = _invoke_cli(detail_command) @@ -1376,6 +1421,9 @@ def test_quota_cli_keeps_goal_boundary_authority_on_explicit_cold_path( output_format="json", )["quota_should_run_goal_boundary_detail"] + default_command = _admit_quota_comparison(default_command) + detail_command += ["--turn-instance-id", "detail-parity-turn"] + default_exit_code, default_text = _invoke_cli(default_command) detail_exit_code, detail_text = _invoke_cli(detail_command) @@ -1434,6 +1482,7 @@ def test_quota_cli_deprecated_scheduler_detail_flag_matches_canonical_selector( state_file=state_file, output_format="json", )["quota_should_run"] + default_command = _admit_quota_comparison(default_command) canonical_exit_code, canonical_text = _invoke_cli( default_command + ["--include-detail", "scheduler"] ) @@ -1472,6 +1521,7 @@ def test_quota_cli_all_detail_matches_repeated_canonical_selectors( state_file=state_file, output_format="json", )["quota_should_run"] + default_command = _admit_quota_comparison(default_command) repeated_command = list(default_command) for section in ("scheduler", "agent-todos", "user-todos", "goal-boundary"): repeated_command.extend(["--include-detail", section]) @@ -1554,6 +1604,8 @@ def test_status_and_quota_json_ignore_compatibility_reexport_bindings( output_format="json", ) + commands["quota_should_run"] = _admit_quota_comparison(commands["quota_should_run"]) + def semantic_receipts() -> dict[str, dict[str, object]]: receipts: dict[str, dict[str, object]] = {} for surface_id in ("status", "quota_should_run"): @@ -1734,7 +1786,7 @@ def test_brief_budget_retains_full_commands_on_real_long_paths() -> None: # tempfile contributes an eight-character random suffix. Hold input size # constant even when the selected parent is a drive or share root. prefix += "p" * (128 - len(str(parent / prefix)) - 8) - with tempfile.TemporaryDirectory(prefix=prefix, dir=parent) as directory: + with temporary_canary_directory(prefix=prefix, dir=parent) as directory: root = Path(directory).resolve() assert len(str(root)) == 128 _assert_mode_variant_budgets(root, only="heartbeat_prompt_brief") diff --git a/tests/control_plane/test_cli_output_differential.py b/tests/control_plane/test_cli_output_differential.py index 0e2c002a1b..af0db57651 100644 --- a/tests/control_plane/test_cli_output_differential.py +++ b/tests/control_plane/test_cli_output_differential.py @@ -1920,3 +1920,108 @@ def test_objective_replan_cost_is_complete_once_and_carrier_scoped(): assert _compare_row(base, {**head, "chars": 1769})["failures"] assert not observe(prior + " " + " ".join(reversed(clauses)))["objective_replan_after"] assert not observe(json.dumps({"prior": prior, "clauses": clauses}))["objective_replan_after"] + +def _admission_packet(): + identity = {"schema_version": "quota_settlement_identity_v0", "goal_id": "fixture-goal", + "agent_id": "fixture-agent", "todo_id": "todo_fixture", "turn_instance_id": "work:original"} + return {"mode": "should-run", "goal_id": "fixture-goal", + "selected_todo": {"todo_id": "todo_fixture"}, "agent_identity": {"agent_id": "fixture-agent"}, + "heartbeat_receipt": {"schema_version": "heartbeat_quota_receipt_v0", "status": "committed", + "turn_instance_id": "work:original", "result_freshness": "turn_result_freshness_required_v1", + "closeout_required": True, "settlement_identity": identity}, + "first_delivery_progress": {"schema_version": "first_delivery_progress_v0", + "goal_completion_certified": False, "settlement_identity": dict(identity)}, + "delivery_result_context": {"ok": True, "basis": {"todo": {"todo_id": "todo_fixture"}}, + "read_context_id": "original-read", "settlement_identity": dict(identity)}} + + +@pytest.mark.parametrize("status", ["committed", "replayed"]) +def test_native_admission_cost_requires_matching_committed_original_identity(status): + from loopx.control_plane.testing.cli_output_semantics import native_admission_observation + packet = _admission_packet() + packet["heartbeat_receipt"]["status"] = status + observed = native_admission_observation(packet) + assert observed["kind"] == "delivery_result" + assert observed["costs"]["delivery_result_context"]["chars"] == len(json.dumps(packet["delivery_result_context"])) + assert "original-read" not in json.dumps(observed) + for path, value in [(("heartbeat_receipt", "status"), "failed"), + (("heartbeat_receipt", "turn_instance_id"), "work:other"), + (("goal_id",), "other-goal"), (("agent_identity", "agent_id"), "other-agent"), + (("selected_todo", "todo_id"), "todo_other"), + (("delivery_result_context", "read_context_id"), ""), + (("first_delivery_progress", "goal_completion_certified"), True), + (("first_delivery_progress", "settlement_identity"), {}), + (("delivery_result_context", "settlement_identity"), {})]: + damaged = copy.deepcopy(packet) + target = damaged + for part in path[:-1]: + target = target[part] + target[path[-1]] = value + assert native_admission_observation(damaged)["kind"] is None + + +@pytest.mark.parametrize("kind", ["delivery_result", "replan", "unbound"]) +def test_native_admission_growth_is_measured_bounded_once_and_quota_json_only(kind): + from loopx.control_plane.testing.cli_output_semantics import native_admission_observation + packet = _admission_packet() + if kind != "delivery_result": + packet.pop("delivery_result_context") + packet.pop("first_delivery_progress") + receipt = packet["heartbeat_receipt"] + for field in ("result_freshness", "closeout_required", "settlement_identity"): + receipt.pop(field) + receipt["settlement_binding_owed"] = True + if kind == "replan": + receipt.update(semantic_replan_obligation_id="replan-original", + semantic_replan_source={"schema_version": "semantic_replan_source_v1"}) + packet["autonomous_replan_obligation"] = {"obligation_id": "replan-original"} + packet["reward_memory_recall"] = { + "status": "disabled", "automatic_recall": False, "grants_new_action_authority": False, + "quota_spend_performed": False, "external_writes_performed": False, + "context": None, "provider_call_count": 0, + } + empty = {"mode": "should-run"} + before = native_admission_observation(empty) + after = native_admission_observation(packet) + assert after["kind"] == kind + base = _row(row_id="surface/quota_should_run/small/json", surface_id="quota_should_run", native_admission=before) + candidate = {**base, "native_admission": after} + growth = sum(value["chars"] for value in after["costs"].values()) + for metric in ("chars", "utf8_bytes", "compact_payload_chars"): + candidate[metric] = base[metric] + growth + assert compare_cli_output_receipts(_receipt(base), _receipt(candidate))["ok"] + for mutated_base, mutated_candidate in [ + (candidate, {**candidate, "chars": candidate["chars"] + growth}), + ({**base, "surface_id": "status"}, {**candidate, "surface_id": "status"}), + ({**base, "format": "markdown"}, {**candidate, "format": "markdown"}), + (base, {**candidate, "chars": candidate["chars"] + 10_000}), + (base, {**candidate, "action_signature_sha256": "changed-action"}), + ]: + assert not compare_cli_output_receipts(_receipt(mutated_base), _receipt(mutated_candidate))["ok"] + oversized = copy.deepcopy(candidate) + oversized["native_admission"]["costs"]["heartbeat_receipt"]["chars"] = 100_000 + assert not compare_cli_output_receipts(_receipt(base), _receipt(oversized))["ok"] + +def test_first_delivery_status_cost_is_readback_only_and_one_time(): + from loopx.control_plane.testing.cli_output_semantics import native_admission_observation + identity = _admission_packet()["heartbeat_receipt"]["settlement_identity"] + progress = {"schema_version": "first_delivery_progress_v0", "stage": "result_review_pending", + "settlement_identity": identity, "goal_completion_certified": False, + "result_committed": False, "quota_spent": False} + empty = {"attention_queue": {"items": [{"goal_id": "fixture-goal"}]}} + packet = copy.deepcopy(empty) + packet["attention_queue"]["items"][0]["first_delivery_progress"] = progress + before, after = native_admission_observation(empty), native_admission_observation(packet) + assert after["kind"] == "progress" + base = _row(native_admission=before) + grown = {**base, "native_admission": after} + for metric in ("chars", "utf8_bytes", "compact_payload_chars"): + grown[metric] = base[metric] + after["costs"]["first_delivery_progress"]["chars"] + assert compare_cli_output_receipts(_receipt(base), _receipt(grown))["ok"] + for change in [{"goal_completion_certified": True}, {"settlement_identity": {}}]: + damaged = copy.deepcopy(packet) + damaged["attention_queue"]["items"][0]["first_delivery_progress"].update(change) + assert native_admission_observation(damaged)["kind"] is None + assert not compare_cli_output_receipts( + _receipt(grown), _receipt({**grown, "chars": grown["chars"] + 1_000}), + )["ok"] diff --git a/tests/control_plane/test_cold_source_backup_scope.py b/tests/control_plane/test_cold_source_backup_scope.py index d34f33cc17..e54f8cf717 100644 --- a/tests/control_plane/test_cold_source_backup_scope.py +++ b/tests/control_plane/test_cold_source_backup_scope.py @@ -13,6 +13,7 @@ from loopx.control_plane.coordination.cold_source_backup import ( ColdSourceBackupError, read_cold_source_backup, ) +from loopx.control_plane.runtime.file_paths import windows_extended_path def backup(tmp_path: Path, unrelated: int = 0, unsafe: bool = False, @@ -79,6 +80,24 @@ def observe_scan(*args, **kwargs): assert max(len(scan.members) for scan in scans) <= 1 +@pytest.mark.parametrize("workspace_length", [240, 320]) +def test_nested_workspace_preserves_witness_and_removes_scratch_index(tmp_path, workspace_length): + manifest = backup(tmp_path) + archive = Path(json.loads(manifest.read_text())["execution"]["archive_path"]) + before = manifest.read_bytes(), archive.read_bytes() + workspace = tmp_path + while len(str(workspace)) < workspace_length: + workspace /= "w" * min(64, max(1, workspace_length - len(str(workspace)) - 1)) + windows_extended_path(workspace).mkdir(parents=True) + assert len(str(workspace)) >= workspace_length + witness = read_cold_source_backup(manifest, workspace=workspace, + source_paths=[tmp_path / "runtime/goals/example/state.md"]) + assert witness["members"] == [{"archive_path": "runtime/goals/example/state.md", + "sha256": hashlib.sha256(b"retained source").hexdigest()}] + assert (manifest.read_bytes(), archive.read_bytes()) == before + assert list(windows_extended_path(workspace).iterdir()) == [] + + @pytest.mark.parametrize("names", [("A.txt", "a.txt"), ("caf\u00e9.txt", "cafe\u0301.txt")]) def test_trusted_witness_preserves_distinct_source_names(tmp_path, names): paths = tuple(f"runtime/other/{name}" for name in names) @@ -206,6 +225,13 @@ def unsafe_manifest(name: str, members: list[tarfile.TarInfo]) -> Path: read_cold_source_backup(unsafe_manifest("ancestor", [link, descendant]), source_paths=[tmp_path]) + target = tarfile.TarInfo("root/target") + target.size = 1 + safe = read_cold_source_backup(unsafe_manifest("safe-relative", [target, link]), + source_paths=[tmp_path]) + assert safe["members"] == [{"archive_path": "root/target", + "sha256": hashlib.sha256(b"x").hexdigest()}] + first = tarfile.TarInfo("root/a") first.type, first.linkname = tarfile.SYMTYPE, "b" second = tarfile.TarInfo("root/b") diff --git a/tests/control_plane/test_collaboration_atomic_publish.py b/tests/control_plane/test_collaboration_atomic_publish.py new file mode 100644 index 0000000000..dd01bd581d --- /dev/null +++ b/tests/control_plane/test_collaboration_atomic_publish.py @@ -0,0 +1,65 @@ +"""Windows record publication keeps complete bytes during ordinary observation.""" +import json +import os +from threading import Timer +import time + +import pytest + +from loopx.control_plane.collaboration.inbox import _read, _write +from loopx.control_plane.runtime.file_paths import windows_extended_path + + +@pytest.mark.skipif(os.name != "nt", reason="Windows delete-sharing boundary") +def test_observer_releases_file_before_atomic_publish_deadline(tmp_path): + # Real CRT read handles deny DELETE sharing. Preserve the full opaque + # storage address, including when the native path exceeds MAX_PATH. + path = windows_extended_path(tmp_path / ("a" * 64) / ("b" * 64) / "operation.json") + old, new = {"status": "prepared"}, {"status": "running", "complete": True} + _write(path, old) + reader = path.open("rb") + release = Timer(0.15, reader.close) + release.start() + try: + _write(path, new) + finally: + release.join() + reader.close() + assert _read(path) == new + assert list(path.parent.iterdir()) == [path] + + +@pytest.mark.skipif(os.name != "nt", reason="Windows delete-sharing boundary") +def test_persistent_observer_denial_preserves_old_complete_record(tmp_path): + path = tmp_path / "operation.json" + old = {"status": "prepared", "identity": "original"} + _write(path, old) + begin = time.monotonic() + with path.open("rb") as reader: + with pytest.raises(PermissionError) as caught: + _write(path, {"status": "running"}) + assert caught.value.winerror == 5 + assert json.loads(reader.read()) == old + assert time.monotonic() - begin < 2, "access denial must remain bounded" + assert _read(path) == old + assert list(tmp_path.iterdir()) == [path] + + +def test_other_publish_error_propagates_without_changing_record(tmp_path, monkeypatch): + from loopx.control_plane.collaboration import inbox + + path = tmp_path / "operation.json" + old = {"status": "prepared"} + _write(path, old) + calls = [] + + def reject(source, target): + calls.append((source, target)) + raise OSError(28, "No space left on device") + + monkeypatch.setattr(inbox.os, "replace", reject) + with pytest.raises(OSError, match="No space left"): + _write(path, {"status": "running"}) + assert len(calls) == 1 + assert _read(path) == old + assert list(tmp_path.iterdir()) == [path] diff --git a/tests/control_plane/test_completion_original_identity.py b/tests/control_plane/test_completion_original_identity.py new file mode 100644 index 0000000000..1ee356a816 --- /dev/null +++ b/tests/control_plane/test_completion_original_identity.py @@ -0,0 +1,366 @@ +"""Replay a committed original completion without recreating its successor intent.""" + +from __future__ import annotations +import json +import subprocess +import sys +import pytest +from loopx.control_plane.goals.checkpoint_context_io import ( + completion_identity_for_original_turn, +) +from loopx.control_plane.quota.settlement import SettlementIdentity +from loopx.hosts.chat_coordinator_checkpoint import CoordinatorCheckpoints +from loopx.todos import add_goal_todo, list_goal_todos +from tests.control_plane.test_default_checkpoint_continuation import _fixture +from tests.test_goal_mode_mcp_settlement import GOAL_ID, AGENT_ID + + +@pytest.fixture(autouse=True) +def _release_test_runtime(_isolated_default_runtime_routes, monkeypatch): + # The default-route fixture gives every case a different runtime owner. + # Stop that case's server before its HOME/TEMP patches are restored. + yield + from loopx.control_plane.effect_runtime import restart_effect_runtime + + restart_effect_runtime(timeout=5) + + +def _identity_fixture(key_kind): + identity = SettlementIdentity( + goal_id="replay-fixture", + agent_id="agent-a", + todo_id="todo_abc123", + turn_instance_id="original-turn", + ) + key = identity.turn_instance_id if key_kind == "managed" else identity.effect_id + todo = dict( + todo_id=identity.todo_id, + status="done", + claimed_by=identity.agent_id, + completion_turn_key=key, + completion_continuation="successor", + successor_todo_ids=["todo_def456"], + evidence="Candidate verified.", + role="agent", + completion_receipt_id="original-committed-receipt", + ) + return identity, todo + + +def _resolve(identity, todo, candidate=None): + return completion_identity_for_original_turn( + todo=todo, + identity=identity, + goal_id=identity.goal_id, + agent_id=identity.agent_id, + todo_id=identity.todo_id, + candidate={"evidence": "Candidate verified.", **(candidate or {})}, + ) + + +@pytest.mark.parametrize("key_kind", ["managed", "interactive"]) +@pytest.mark.parametrize("continuation", ["successor", "active_goal", "no_followup"]) +def test_original_completion_read_only_replay(key_kind, continuation): + identity, todo = _identity_fixture(key_kind) + todo["completion_continuation"] = continuation + todo["no_followup"] = continuation == "no_followup" + if continuation != "successor": + todo["successor_todo_ids"] = [] + result = _resolve(identity, todo) + assert result["completion_turn_key"] == todo["completion_turn_key"] + assert result["replay"]["idempotent_replay"] is True + assert result["replay"]["changed"] is False + assert result["replay"]["completion_receipt_id"] == todo["completion_receipt_id"] + assert result["replay"]["completion_continuation"] == continuation + + +@pytest.mark.parametrize("key_kind", ["managed", "interactive"]) +@pytest.mark.parametrize( + "candidate", + [ + {"evidence": "Changed evidence."}, + {"note": "Changed note."}, + {"reason": "Changed reason."}, + {"next_agent_todo": "Changed successor."}, + {"next_user_todo": "Changed user obligation."}, + {"successor_todo_ids": ["todo_new789"]}, + {"result_file": "different.json"}, + {"next_claimed_by": "different-agent"}, + {"next_task_class": "maintenance"}, + {"next_action_kind": "review"}, + {"next_task_repository": "different-repo"}, + {"next_required_capabilities": "network"}, + {"next_continuation_policy": "different"}, + {"next_excluded_agents": "different-agent"}, + {"next_user_task_class": "approval"}, + {"decision_outcome": "different"}, + {"clear_claim": True}, + {"self_merged": True}, + {"role": "user"}, + ], +) +def test_changed_completion_is_never_read_only_replay(key_kind, candidate): + identity, todo = _identity_fixture(key_kind) + assert _resolve(identity, todo, candidate)["replay"] is None + + +@pytest.mark.parametrize("key_kind", ["managed", "interactive"]) +@pytest.mark.parametrize( + "field,value", + [ + ("status", "open"), + ("claimed_by", "other-agent"), + ("todo_id", "todo_different"), + ("completion_turn_key", "different-turn"), + ("completion_continuation", None), + ], +) +def test_wrong_or_incomplete_original_completion_cannot_replay(key_kind, field, value): + identity, todo = _identity_fixture(key_kind) + todo[field] = value + assert _resolve(identity, todo)["replay"] is None + + +@pytest.mark.parametrize("key_kind", ["managed", "interactive"]) +def test_terminal_upgrade_cannot_remove_committed_successor(key_kind): + identity, todo = _identity_fixture(key_kind) + with pytest.raises(ValueError, match="successor"): + _resolve(identity, todo, {"no_followup": True}) + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("intent", ["generated", "linked", "terminal"]) +def test_coordinator_resumes_original_completion_intent( + provider, intent, tmp_path, monkeypatch +): + project = tmp_path + candidate = project / "candidate.txt" + validator = project / "verify.py" + validator.write_text( + "from pathlib import Path\np=Path(__file__).parent\nassert (p/'candidate.txt').read_text()=='valid'\nwith (p/'validation.txt').open('a') as f: f.write('validated\\n')\n", + encoding="utf-8", + ) + candidate.write_text("broken", encoding="utf-8") + assert ( + subprocess.run([sys.executable, str(validator)], capture_output=True).returncode + != 0 + ) + assert not (project / "validation.txt").exists() + registry, state, todo_id = _fixture( + provider, + tmp_path, + monkeypatch, + validation_command_json=json.dumps([sys.executable, str(validator)]), + ) + candidate.write_text("valid", encoding="utf-8") + control = CoordinatorCheckpoints( + registry, tmp_path / "runtime", GOAL_ID, AGENT_ID + ).control + guard = json.loads(control.should_run()) + assert guard.get("delivery_result_context"), json.dumps(guard, indent=2) + assert guard["delivery_result_context"]["settlement_identity"]["todo_id"] == todo_id + original_guard_identity = guard["delivery_result_context"]["settlement_identity"] + if intent == "linked": + successor = add_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + role="agent", + text="Verify linked remaining work.", + task_class="advancement_task", + ) + options = {"successor_todo_ids": [successor["todo_id"]]} + elif intent == "generated": + options = {"next_agent_todo": "Verify generated remaining work."} + else: + options = {"no_follow_up": True} + if intent == "linked": + guard = json.loads(control.should_run(todo_id)) + assert guard.get("delivery_result_context"), json.dumps(guard, indent=2) + assert guard["delivery_result_context"]["settlement_identity"] == original_guard_identity + first = json.loads( + control.complete_task(todo_id, AGENT_ID, "Candidate verified.", **options) + ) + assert first.get("stage") == "direction_pending", first + before = list_goal_todos( + registry_path=registry, + runtime_root_arg=str(tmp_path / "runtime"), + goal_id=GOAL_ID, + todo_id=todo_id, + )["todo"] + original_identity = first["settlement_identity"] + assert ( + first["context"]["basis"]["todo"]["completion_receipt_id"] + == before["completion_receipt_id"] + ) + event_log = tmp_path / "runtime/goals" / GOAL_ID / "rollout-event-log.jsonl" + original_events = event_log.read_bytes() + assert original_events + coordinator = CoordinatorCheckpoints( + registry, tmp_path / "runtime", GOAL_ID, AGENT_ID + ) + old = coordinator.read(todo_id) + state.write_text( + state.read_text(encoding="utf-8") + + "\n## Acceptance\nA new current obligation.\n", + encoding="utf-8", + ) + stale = coordinator.resume( + todo_id, + { + "read_context_id": old["read_context_id"], + "vision_unchanged_reason": "The old acceptance still applies.", + }, + ) + assert stale.get("ok") is False, stale + fresh = coordinator.read(todo_id) + assert fresh["read_context_id"] != old["read_context_id"] + assert "new current obligation" in json.dumps(fresh["basis"]) + context_dir = tmp_path / "runtime/goals" / GOAL_ID / "checkpoint-contexts" + bound_basis_fields = ( + "identity", + "work_basis", + "decision_basis", + "versions", + "provider_revision", + ) + original_bases = {} + for path in context_dir.glob("*.json"): + record = json.loads(path.read_text(encoding="utf-8")) + if "read_context_id" in record: + original_bases[path] = { + field: record.get(field) for field in bound_basis_fields + } + assert original_bases + result = coordinator.resume( + todo_id, + { + "read_context_id": fresh["read_context_id"], + "vision_unchanged_reason": "The validated candidate and current remaining obligations preserve the route.", + }, + ) + (tmp_path / "resume-result.json").write_text( + json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8" + ) + assert result.get("ok") is True and result.get("settlement_complete") is True, ( + json.dumps(result) + ) + assert result["settlement_identity"] == original_identity + after = list_goal_todos( + registry_path=registry, + runtime_root_arg=str(tmp_path / "runtime"), + goal_id=GOAL_ID, + todo_id=todo_id, + )["todo"] + assert after["completion_turn_key"] == before["completion_turn_key"] + assert after.get("successor_todo_ids") == before.get("successor_todo_ids") + assert event_log.read_bytes().startswith(original_events) + for path, basis in original_bases.items(): + current = json.loads(path.read_text(encoding="utf-8")) + assert {field: current.get(field) for field in bound_basis_fields} == basis + if intent == "terminal": + assert before["completion_continuation"] == "active_goal" + assert ( + after["completion_continuation"] == "no_followup" + and after["no_followup"] is True + ) + assert after["completion_recovery"] == "same_turn_terminal_closeout" + assert after["completion_receipt_id"] != before["completion_receipt_id"] + else: + assert after["completion_receipt_id"] == before["completion_receipt_id"] + assert candidate.read_text() == "valid" + assert (project / "validation.txt").read_text().splitlines() == ["validated"] + index = tmp_path / "runtime/goals" / GOAL_ID / "runs/index.jsonl" + rows = [json.loads(line) for line in index.read_text(encoding="utf-8").splitlines()] + assert sum(row.get("classification") == "quota_slot_spent" for row in rows) == 1 + snapshot = index.read_bytes() + cold = CoordinatorCheckpoints(registry, tmp_path / "runtime", GOAL_ID, AGENT_ID) + again = cold.read(todo_id) + (tmp_path / "cold-read.json").write_text( + json.dumps(again, indent=2), encoding="utf-8" + ) + assert again["ok"] is True and again["replayed"] is True + assert again["status"] == "committed" and "read_context_id" not in again + assert again["context"]["read_context_id"] == fresh["read_context_id"] + assert again["settlement_identity"] == original_identity + replay = cold.resume( + todo_id, + { + "read_context_id": fresh["read_context_id"], + "vision_unchanged_reason": "The validated candidate and current remaining obligations preserve the route.", + }, + ) + assert replay.get("settlement_complete") is True, replay + assert replay["settlement_identity"] == original_identity + assert index.read_bytes() == snapshot + assert (project / "validation.txt").read_text().splitlines() == ["validated"] + +@pytest.mark.parametrize("key_kind", ["managed", "interactive"]) +@pytest.mark.parametrize( + "candidate", + [ + {"task_lease_idempotency_key": "original-lease"}, + {"task_lease_idempotency_key": "replacement-lease"}, + {"task_lease_idempotency_key": ""}, + {"task_lease_expected_version": 1}, + {"task_lease_expected_version": 999}, + {"task_lease_expected_version": 0}, + {"task_lease_expected_version": False}, + {"task_lease_idempotency_key": "original-lease", "task_lease_expected_version": 1}, + ], +) +def test_supplied_lease_requires_canonical_request_verification(key_kind, candidate): + identity, todo = _identity_fixture(key_kind) + # The Todo projection cannot prove a supplied lease belongs to the original + # request. Even plausible values must reach the canonical receipt owner. + assert _resolve(identity, todo, candidate)["replay"] is None + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("closeout_source", ["turn", "lifecycle"]) +def test_original_terminal_cold_replay_keeps_lease_fingerprint( + tmp_path, monkeypatch, provider, closeout_source +): + from tests.control_plane import test_declared_terminal_settlement_cli as terminal + + fixture = terminal._fixture + captured = [] + + def capture(*args, **kwargs): + result = fixture(*args, **kwargs) + captured.append(result) + return result + + monkeypatch.setattr(terminal, "_fixture", capture) + # Execute the four existing frozen native journeys without changing their + # assertions, including RESULT, new direction, one debit and cold replay. + terminal.test_default_declared_terminal_intent_recovers_original_result( + tmp_path, monkeypatch, provider, closeout_source + ) + _, _, runtime, _, run, lease, marker, _, _ = captured[0] + cli = terminal.cli + command = ( + "todo", "complete", "--goal-id", cli.GOAL_ID, + "--agent-id", cli.AGENT_ID, "--todo-id", cli.TODO_ID, + "--turn-instance-id", cli.TURN_ID, *lease, "--no-follow-up", + "--evidence", "Original declared deliverable, then current post-result direction.", + ) + code, before = run("todo", "list", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID) + assert code == 0, before + ledger = runtime / "goals" / cli.GOAL_ID / "runs" / "index.jsonl" + original_ledger, original_validation = ledger.read_bytes(), marker.read_bytes() + for field, value in ( + ("--task-lease-idempotency-key", "replacement-lease"), + ("--task-lease-expected-version", "999"), + ): + changed = list(command) + changed[changed.index(field) + 1] = value + code, rejected = run(*changed) + assert code == 1 and rejected.get("ok") is False, rejected + code, after = run("todo", "list", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID) + assert code == 0 and after["todo"] == before["todo"], after + assert ledger.read_bytes() == original_ledger + assert marker.read_bytes() == original_validation + code, exact = run(*command) + assert code == 0 and exact["idempotent_replay"] is True, exact + assert ledger.read_bytes() == original_ledger + assert marker.read_bytes() == original_validation diff --git a/tests/control_plane/test_declared_terminal_settlement_cli.py b/tests/control_plane/test_declared_terminal_settlement_cli.py index 4447582c27..f6128c3e38 100644 --- a/tests/control_plane/test_declared_terminal_settlement_cli.py +++ b/tests/control_plane/test_declared_terminal_settlement_cli.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import os import shlex import subprocess import sys @@ -16,10 +17,11 @@ from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from loopx.control_plane.coordination.local_authority_shadow_projection import canonical_bytes from loopx.control_plane.quota.settlement import read_heartbeat_settlement +from host_checkpoint_fixture import checkpoint_decision from loopx.control_plane.todos.markdown import render_todo_markdown -def _fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, *, historical_terminals: int = 0): +def _fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, *, historical_terminals: int = 0, renew_during_validation: bool = False): # Scope managed Effect discovery as well as provider data for both arms. isolate_sqlite_runtime(tmp_path, monkeypatch) project, runtime, registry_path = cli._write_fixture(tmp_path) @@ -81,8 +83,16 @@ def run(*args: str) -> tuple[int, dict[str, Any]]: lease_args = ("--task-lease-idempotency-key", acquired["lease"]["idempotency_key"], "--task-lease-expected-version", str(acquired["lease"]["version"])) marker, fail = tmp_path / "validator-runs", tmp_path / "validator-fails" + renew_script = "" + if renew_during_validation: + renew_argv = [sys.executable, "-m", "loopx.cli", "--format", "json", + "--registry", str(registry_path), "--runtime-root", str(runtime), + "task-lease", "renew", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID, + "--owner", cli.AGENT_ID, "--idempotency-key", acquired["lease"]["idempotency_key"], + "--expected-version", str(acquired["lease"]["version"]), "--ttl-seconds", "600"] + renew_script = f"import subprocess; from pathlib import Path; r=subprocess.run({renew_argv!r}, cwd={str(workspace)!r}, capture_output=True, text=True); Path({str(tmp_path / 'renew-output')!r}).write_text(r.stdout+r.stderr); assert r.returncode == 0; " argv = [sys.executable, "-c", ( - "from pathlib import Path; " + renew_script + "from pathlib import Path; " f"p=Path({str(marker)!r}); p.write_text((p.read_text() if p.exists() else '')+'run\\n'); " f"raise SystemExit(4 if Path({str(fail)!r}).exists() else 0)" )] @@ -113,7 +123,10 @@ def test_declared_leased_completion_writeback_spend_terminal_and_replay( *binding, "--available-capability", "shell", "--available-capability", "filesystem_write") code, guard = run(*guard_args) - assert code == 0, guard.get("error") or guard.get("reason") or guard + assert code == 0, guard.get("error") or guard.get("reason") + # Preserve the original pre-default settlement contract and all assertions. + # Default RESULT/direction ordering has its own regression below. + cli._historical_turn(runtime) or guard assert guard["heartbeat_receipt"]["settlement_identity"]["todo_id"] == cli.TODO_ID complete_args = ("todo", "complete", "--goal-id", cli.GOAL_ID, *binding, *lease, "--project", str(project), "--state-file", str(project / f".codex/goals/{cli.GOAL_ID}/ACTIVE_GOAL_STATE.md"), @@ -228,8 +241,9 @@ def test_original_lease_and_failed_validator_remain_required_before_accounting( binding = ("--agent-id", cli.AGENT_ID, "--todo-id", cli.TODO_ID, "--turn-instance-id", cli.TURN_ID) code, guard = run("quota", "should-run", "--codex-app", "--goal-id", cli.GOAL_ID, - *binding) + *binding, "--available-capability", "shell", "--available-capability", "filesystem_write") assert code == 0, guard.get("error") or guard.get("reason") + assert guard["should_run"] is True and guard.get("delivery_result_context"), guard command = ("todo", "complete", "--goal-id", cli.GOAL_ID, *binding, "--evidence", "Only the original controller check can accept this deliverable.") code, invalid_lease = run(*command, *lease[:2], "--task-lease-expected-version", "999") @@ -243,7 +257,8 @@ def test_original_lease_and_failed_validator_remain_required_before_accounting( code, state = run("todo", "list", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID) assert code == 0, state assert state["todo"]["status"] == "open" - assert state["todo"]["completion_validation_sha256"] == original["completion_validation_sha256"] + assert state["todo"].get("completion_validation_sha256") == original.get("completion_validation_sha256") + assert state["todo"].get("validation_command_argv") == original.get("validation_command_argv") code, no_spend = run("quota", "spend-slot", "--goal-id", cli.GOAL_ID, *binding, "--source", "heartbeat", "--slots", "1", "--execute", ) assert code == 1, no_spend @@ -251,11 +266,217 @@ def test_original_lease_and_failed_validator_remain_required_before_accounting( fail.unlink() code, accepted = run(*command, *lease) assert code == 0, accepted - assert accepted["validation_receipt"]["passed"] is True - assert accepted["validation_receipt"]["validation_declaration_sha256"] == original["completion_validation_sha256"] + assert accepted["completed"] is True + if provider != "legacy": + assert accepted["validation_receipt"]["passed"] is True + if provider != "legacy": + assert accepted["validation_receipt"]["validation_declaration_sha256"] == original["completion_validation_sha256"] assert marker.read_text() == "run\nrun\n", "one failed attempt, then one actual successful check" assert cli._spend_run_count(runtime) == 0 + code, replay = run(*command, *lease) + assert code == 0, replay + assert marker.read_text() == "run\nrun\n", "terminal replay must not repeat validation" + assert cli._spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_lease_change_during_validator_still_blocks_completion( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, +) -> None: + project, _, runtime, _, run, lease, marker, _, _ = _fixture( + tmp_path, monkeypatch, provider, renew_during_validation=True, + ) + binding = ("--agent-id", cli.AGENT_ID, "--todo-id", cli.TODO_ID, + "--turn-instance-id", cli.TURN_ID) + code, guard = run("quota", "should-run", "--codex-app", "--goal-id", cli.GOAL_ID, + *binding, "--available-capability", "shell", "--available-capability", "filesystem_write") + assert code == 0, guard + assert guard["should_run"] is True and guard.get("delivery_result_context"), guard + code, rejected = run("todo", "complete", "--goal-id", cli.GOAL_ID, *binding, *lease, + "--evidence", "Validator renews the lease before final completion.") + assert code == 1, rejected + assert marker.exists(), rejected + assert marker.read_text() == "run\n", "validation ran outside the short lease fence" + code, state = run("todo", "list", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID) + assert code == 0, state + assert state["todo"]["status"] == "open", "commit must reject the changed lease version" + assert cli._spend_run_count(runtime) == 0 + +@pytest.mark.parametrize('provider',['file','sqlite']) +@pytest.mark.parametrize('closeout_source',['turn','lifecycle']) +def test_default_declared_terminal_intent_recovers_original_result(tmp_path,monkeypatch,provider,closeout_source): + project,workspace,runtime,registry,run,lease,marker,_,original=_fixture(tmp_path,monkeypatch,provider) + binding=('--agent-id',cli.AGENT_ID,'--todo-id',cli.TODO_ID,'--turn-instance-id',cli.TURN_ID) + code,guard=run('quota','should-run','--codex-app','--goal-id',cli.GOAL_ID,*binding, + '--available-capability','shell','--available-capability','filesystem_write') + assert code==0,guard + identity=guard['heartbeat_receipt']['settlement_identity'] + complete=('todo','complete','--goal-id',cli.GOAL_ID,*binding,*lease,'--no-follow-up', + '--evidence','Original declared deliverable, then current post-result direction.') + code,result=run(*complete) + assert code==0,result + assert result['completed'] is True and result['completion_continuation']=='active_goal',result + assert marker.read_text()=='run\n','RESULT must run exactly one original validator' + assert cli._spend_run_count(runtime)==0 + code,listed=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0,listed + saved=listed['todo'] + assert saved['status']=='done' and saved['completion_continuation']=='active_goal' + assert saved['completion_requested_continuation']=='no_followup',saved + assert not saved.get('successor_todo_ids') + assert saved.get('completion_validation_sha256')==original.get('completion_validation_sha256') + assert saved['completion_turn_key']==identity['effect_id'],saved + assert result['settlement_identity']==identity,result + assert result['completion_identity_key']==identity['effect_id'] + # A fresh original request must recover RESULT without revalidation. + code,replay=run(*complete) + assert code==0 and replay['idempotent_replay'] is True,replay + assert marker.read_text()=='run\n' + lifecycle=('todo','complete','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID,'--agent-id',cli.AGENT_ID, + '--completion-identity-key',identity['effect_id'],'--no-follow-up') + code,early=run(*lifecycle) + assert code==1,early + code,after=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0 and after['todo']==saved,after + assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==0 + # New direction is authored only after the CLI returns actual committed RESULT. + code,context=run('checkpoint-context','--goal-id',cli.GOAL_ID,*binding,'--purpose','first_delivery') + assert code==0,context + assert context['settlement_identity']==identity and context['basis']['todo']['status']=='done' + decision=checkpoint_decision(context) + direction=('--checkpoint-read-context',context['read_context_id']) + if decision.get('agent_vision'): + vision=tmp_path/'direction.json' + vision.write_text(json.dumps(decision['agent_vision'])) + direction+=('--agent-vision-json',str(vision)) + else: + direction+=('--vision-unchanged-reason',decision['vision_unchanged_reason']) + refresh=('refresh-state','--goal-id',cli.GOAL_ID,*binding,*direction, + '--classification','declared_deliverable_complete','--delivery-batch-scale','implementation', + '--delivery-outcome','outcome_progress','--no-global-sync','--suppress-external-sinks') + code,refreshed=run(*refresh) + assert code==0,refreshed + spend=('quota','spend-slot','--goal-id',cli.GOAL_ID,*binding,'--slots','1','--source','heartbeat','--execute') + code,spent=run(*spend) + assert code==0,spent + assert cli._spend_run_count(runtime)==1 + code,before_reentry=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0,before_reentry + for field,replacement in (('--completion-identity-key',identity['effect_id']+'-wrong'), + ('--evidence','Replacement evidence must be rejected.'), + ('--agent-id','unregistered-cold-actor')): + rejected=list(lifecycle) + if field in rejected: + rejected[rejected.index(field)+1]=replacement + else: + rejected.extend((field,replacement)) + code,failure=run(*rejected) + assert code==1,failure + code,unchanged=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0 and unchanged['todo']==before_reentry['todo'],unchanged + assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==1 + terminal=complete if closeout_source=='turn' else lifecycle + code,closed=run(*terminal) + assert code==0 and closed['completion_continuation']=='no_followup',json.dumps(closed) + for args in (complete,refresh,spend,terminal): + code,replay=run(*args) + assert code==0 and replay['idempotent_replay'] is True,replay + assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==1 + code,final=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0 and final['todo']['completion_continuation']=='no_followup',final + assert final['todo']['evidence']==saved['evidence'],final + assert final['todo'].get('completion_validation_sha256')==original.get('completion_validation_sha256') + + # A retry with changed accepted work must not borrow the historical RESULT. + for field,replacement in (('--evidence','Changed accepted evidence'), + ('--task-lease-expected-version','999')): + rejected=list(complete) + rejected[rejected.index(field)+1]=replacement + code,failure=run(*rejected) + assert code==1,failure + code,unchanged=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0 and unchanged['todo']==final['todo'],unchanged + assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==1 + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('fault', ['holder_publication', 'crash']) +def test_declared_validation_survives_pre_result_failure(tmp_path, monkeypatch, provider, fault): + project, workspace, runtime, registry, run, lease, marker, _, original = _fixture(tmp_path, monkeypatch, provider) + binding = ('--agent-id', cli.AGENT_ID, '--todo-id', cli.TODO_ID, '--turn-instance-id', cli.TURN_ID) + code, guard = run('quota', 'should-run', '--codex-app', '--goal-id', cli.GOAL_ID, *binding, + '--available-capability', 'shell', '--available-capability', 'filesystem_write') + assert code == 0, guard + complete = ('todo', 'complete', '--goal-id', cli.GOAL_ID, *binding, *lease, '--no-follow-up', '--evidence', 'Original candidate and original validation effect.') + launcher = tmp_path / 'fault_cli.py' + fault_seen = tmp_path / 'fault-seen' + launcher.write_text('import os, sys\nfrom pathlib import Path\n' + + f'marker = Path({str(marker)!r})\nseen = Path({str(fault_seen)!r})\n' + + ('import loopx.file_lock as locks\noriginal = locks._persist_holder_record\n' + 'def publish(lock_file, **kwargs):\n' + ' if kwargs["record"].get("operation") == "delivery-result-capture" and marker.exists() and not seen.exists():\n' + ' seen.write_text("post-validation holder publication rejected")\n' + ' raise PermissionError(13, "Injected holder publication failure", str(kwargs["holder_path"]))\n' + ' return original(lock_file, **kwargs)\nlocks._persist_holder_record = publish\n' if fault == 'holder_publication' else + 'import loopx.control_plane.todos.completion_validation as validation\n' + 'original = validation.run_declared_completion_validation_effect\n' + 'def run_effect(**kwargs):\n' + ' result = original(**kwargs)\n' + ' if not seen.exists():\n' + ' seen.write_text("process lost after validator effect before outcome publication")\n' + ' os._exit(93)\n' + ' return result\nvalidation.run_declared_completion_validation_effect = run_effect\n') + + 'from loopx.cli import main\nraise SystemExit(main(sys.argv[1:]))\n', encoding='utf-8') + argv = [sys.executable, str(launcher), '--registry', str(registry), '--runtime-root', str(runtime), '--format', 'json', *complete] + lost = subprocess.run(argv, cwd=workspace, capture_output=True, text=True, env={**os.environ, 'PYTHONPATH': str(cli.REPO_ROOT)}, timeout=90) + assert lost.returncode != 0 and fault_seen.exists(), (lost.stdout, lost.stderr) + assert marker.read_text() == 'run\n' + code, before = run('todo', 'list', '--goal-id', cli.GOAL_ID, '--todo-id', cli.TODO_ID) + assert code == 0 and not before['todo']['done'], before + assert before['todo']['completion_validation_sha256'] == original['completion_validation_sha256'] + assert cli._spend_run_count(runtime) == 0 + if fault == 'holder_publication': + for flag, replacement in (('--evidence', 'Replaced candidate.'), ('--agent-id', 'unregistered-agent'), ('--task-lease-expected-version', '999')): + changed = list(complete) + changed[changed.index(flag) + 1] = replacement + code, rejection = run(*changed) + assert code == 1, rejection + assert marker.read_text() == 'run\n', 'Rejected replacement must not execute another validator' + code, unchanged = run('todo', 'list', '--goal-id', cli.GOAL_ID, '--todo-id', cli.TODO_ID) + assert code == 0 and unchanged['todo'] == before['todo'], unchanged + code, restored = run(*complete) + assert marker.read_text() == 'run\n', 'Original validation must never execute twice after lost RESULT' + if fault == 'holder_publication': + assert code == 0 and restored['completed'] is True, restored + assert restored['completion_continuation'] == 'active_goal' + assert restored['validation_receipt']['passed'] is True + assert restored['validation_receipt']['validation_declaration_sha256'] == original['completion_validation_sha256'] + assert restored['completion_identity_key'] == guard['heartbeat_receipt']['settlement_identity']['effect_id'] + code, replay = run(*complete) + assert code == 0 and replay['idempotent_replay'] is True, replay + else: + assert code == 1, restored + assert 'delivery_validation_outcome_unknown' in json.dumps(restored), restored + code, replaced_read = run('checkpoint-context', '--goal-id', cli.GOAL_ID, *binding, '--purpose', 'delivery_result') + assert code == 1 and 'delivery_validation_outcome_unknown' in json.dumps(replaced_read), replaced_read + code, after = run('todo', 'list', '--goal-id', cli.GOAL_ID, '--todo-id', cli.TODO_ID) + assert code == 0 and after['todo'] == before['todo'], after + assert marker.read_text() == 'run\n' and cli._spend_run_count(runtime) == 0 + + +def test_unpromoted_terminal_rejects_work_and_completion(tmp_path, monkeypatch): + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = cli._write_fixture(tmp_path) + cli._configure_repository_write_todo(project) + binding = ("--goal-id", cli.GOAL_ID, "--agent-id", cli.AGENT_ID, + "--todo-id", cli.TODO_ID, "--turn-instance-id", cli.TURN_ID) + for command in (("quota", "should-run", "--codex-app"), ("todo", "complete")): + args = binding if command[0] == "quota" else binding[:-2] + code, rejected = cli._run_cli(registry, runtime, *command, *args, cwd=project) + assert code == 1 and "canonical_authority_required" in json.dumps(rejected), rejected + assert cli._spend_run_count(runtime) == 0 + @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("historical_terminals", [0, 1, 4]) @@ -277,7 +498,10 @@ def call(*args: str, expected: int = 0): done = call("todo", "complete", "--goal-id", cli.GOAL_ID, *binding, *lease, "--evidence", "Original declared source check passed.") before = call("todo", "list", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID)["todo"] + direction = call("checkpoint-context", "--goal-id", cli.GOAL_ID, *binding, + "--purpose", "first_delivery") call("refresh-state", "--goal-id", cli.GOAL_ID, *binding, + "--checkpoint-read-context", direction["read_context_id"], "--classification", "declared_deliverable_complete", "--delivery-batch-scale", "implementation", "--delivery-outcome", "outcome_progress", "--vision-state", "vision_closed", "--vision-summary", "The original source deliverable passed.", @@ -298,7 +522,7 @@ def call(*args: str, expected: int = 0): recovered = call(*guard) assert recovered["heartbeat_receipt"]["settlement_identity"] == identity assert recovered.get("selected_todo") is None - assert recovered["replan_action_packet"]["settlement_only"] is True + assert recovered["replan_action_packet"].get("settlement_only") is True, json.dumps({"packet":recovered["replan_action_packet"],"guard":admitted["heartbeat_receipt"],"current":recovered.get("autonomous_replan_obligation")}) assert recovered["replan_action_packet"]["writeback_contract"]["preferred_input"] == "evidence_linked_vision_path" replan_binding = ("--agent-id", cli.AGENT_ID, "--replan-obligation-id", identity["replan_obligation_id"], "--turn-instance-id", turn) diff --git a/tests/control_plane/test_default_checkpoint_continuation.py b/tests/control_plane/test_default_checkpoint_continuation.py new file mode 100644 index 0000000000..76cd731a1f --- /dev/null +++ b/tests/control_plane/test_default_checkpoint_continuation.py @@ -0,0 +1,397 @@ +"""Default post-result checkpoints through real MCP/CLI and owning stores.""" +from __future__ import annotations + +import json +import sys +import pytest + +from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime +from loopx.todos import add_goal_todo, update_goal_todo +from loopx.control_plane.quota.settlement import SettlementIdentity +from loopx.control_plane.turn_driver.checkpoint_continuation import CurrentAgentCheckpoint +from loopx.control_plane.goals.checkpoint_context_io import _receipt_path +from loopx.control_plane.turn_driver.host_failure import BuiltInHostError +from tests.test_goal_mode_mcp_settlement import _write_fixture, _control, GOAL_ID, AGENT_ID, _run_cli + + +def _fixture(provider, tmp_path, monkeypatch, *, validation_command_json=None): + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, state = _write_fixture(tmp_path, provider=provider) + doc = json.loads(registry.read_text()) + doc['goals'][0]['adapter'] = {'kind': 'fixture_v0', 'status': 'connected-delivery'} + registry.write_text(json.dumps(doc), encoding='utf-8') + rc, baseline = _run_cli(registry, 'refresh-state', '--goal-id', GOAL_ID, '--agent-id', AGENT_ID, + '--vision-summary', 'Verify the original route.', '--vision-acceptance', 'Verified candidate.', + '--no-global-sync', '--suppress-external-sinks') + assert rc == 0, baseline + todo = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role='agent', + text='Verify one candidate.', task_class='advancement_task', claimed_by=AGENT_ID, + continuation_policy='same_agent_non_delivery', validation_command_json=validation_command_json) + return registry, state, todo['todo_id'] + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('terminal', [False, True, 'stored', 'cold_explicit']) +def test_default_mcp_uses_scoped_post_result_basis_and_original_turn(provider, terminal, tmp_path, monkeypatch): + registry, state, todo = _fixture(provider, tmp_path, monkeypatch) + if terminal == 'stored': + update_goal_todo(registry_path=registry, goal_id=GOAL_ID, todo_id=todo, role='agent', + agent_id=AGENT_ID, no_followup=True) + control = _control(registry) + guard = json.loads(control.should_run()) + assert 'delivery_result_context' in guard, json.dumps(guard, indent=2) + original = guard['delivery_result_context']['settlement_identity'] + options = {} if terminal == 'stored' else {'no_follow_up': True} if terminal else {'next_agent_todo': 'Verify remaining work.'} + first = json.loads(control.complete_task(todo, AGENT_ID, 'Candidate verified.', **options)) + assert first.get('stage') == 'direction_pending', json.dumps(first, indent=2) + assert first['completed'] is True and first['settlement_complete'] is False + assert first['settlement_identity'] == original + context = first['context'] + assert context['basis']['todo']['status'] == 'done' + assert context['basis']['frontier']['scope'] == ('goal' if terminal else 'agent_lane') + # The tool caller authors only after seeing the returned post-result facts. + second = json.loads(_control(registry).complete_task(todo, AGENT_ID, 'Candidate verified.', + **({} if terminal == 'cold_explicit' else options), + read_context_id=context['read_context_id'], + vision_unchanged_reason='The committed candidate and current obligations preserve the route.')) + assert second.get('ok') is True and second.get('settlement_complete') is True, json.dumps(second, indent=2) + assert second['settlement_identity'] == original + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +def test_default_mcp_rejects_stale_direction_preserves_result(provider, tmp_path, monkeypatch): + registry, state, todo = _fixture(provider, tmp_path, monkeypatch) + control = _control(registry) + json.loads(control.should_run()) + options = {'next_agent_todo': 'Verify remaining work.'} + first = json.loads(control.complete_task(todo, AGENT_ID, 'Candidate verified.', **options)) + assert first.get('stage') == 'direction_pending', first + old = first['context']['read_context_id'] + state.write_text(state.read_text(encoding='utf-8') + '\n## Acceptance\nCheck the revised obligation.\n', encoding='utf-8') + stale = json.loads(control.complete_task(todo, AGENT_ID, 'Candidate verified.', **options, + read_context_id=old, vision_unchanged_reason='Old acceptance still applies.')) + assert stale.get('ok') is False, stale + reread = json.loads(control.review_task_vision(todo, AGENT_ID)) + assert reread['read_context_id'] != old, reread + assert 'revised obligation' in json.dumps(reread['basis']) + resumed = json.loads(control.complete_task(todo, AGENT_ID, 'Candidate verified.', **options, + read_context_id=reread['read_context_id'], vision_unchanged_reason='The revised obligation is the next step.')) + assert resumed.get('settlement_complete') is True, resumed + assert resumed['settlement_identity'] == first['settlement_identity'] + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +def test_default_pending_readback_guides_the_original_caller(provider, tmp_path, monkeypatch): + registry, state, todo = _fixture(provider, tmp_path, monkeypatch) + control = _control(registry) + json.loads(control.should_run()) + options = {'next_agent_todo': 'Verify remaining work.'} + first = json.loads(control.complete_task(todo, AGENT_ID, 'Candidate verified.', **options)) + identity = first['settlement_identity'] + for command in (['status', '--goal-id', GOAL_ID, '--agent-id', AGENT_ID], + ['quota', 'should-run', '--goal-id', GOAL_ID, '--agent-id', AGENT_ID, + '--todo-id', todo, '--turn-instance-id', identity['turn_instance_id']]): + rc, observed = _run_cli(registry, *command) + assert rc == 0, json.dumps(observed) + item = observed['attention_queue']['items'][0] if command[0] == 'status' else observed + progress = item['first_delivery_progress'] + assert progress['settlement_identity'] == identity + assert progress['stage'] == 'direction_pending' and progress['result_committed'] is True + assert progress['quota_spent'] is False and progress['goal_completion_certified'] is False + assert item['recommended_action'] == progress['next_action'] + # A fresh facade consumes the direction operation, retaining the original Turn. + resumed_control = _control(registry) + context = json.loads(resumed_control.review_task_vision(todo, AGENT_ID)) + assert context['settlement_identity'] == identity + resumed = json.loads(resumed_control.complete_task(todo, AGENT_ID, 'Candidate verified.', **options, + read_context_id=context['read_context_id'], + vision_unchanged_reason='The committed candidate and remaining obligations preserve the route.')) + assert resumed['settlement_complete'] is True and resumed['settlement_identity'] == identity + rc, observed = _run_cli(registry, 'status', '--goal-id', GOAL_ID, '--agent-id', AGENT_ID) + assert rc == 0 and 'first_delivery_progress' not in observed['attention_queue']['items'][0], json.dumps(observed) + rows = [json.loads(line) for line in (tmp_path / 'runtime/goals' / GOAL_ID / 'runs/index.jsonl').read_text().splitlines()] + assert sum(row.get('classification') == 'quota_slot_spent' for row in rows) == 1 + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('terminal', [False, True]) +def test_managed_default_reuses_candidate_and_calls_current_agent_for_direction(provider, terminal, tmp_path, monkeypatch): + registry, state, todo = _fixture(provider, tmp_path, monkeypatch) + if terminal: + update_goal_todo(registry_path=registry, goal_id=GOAL_ID, todo_id=todo, role='agent', + agent_id=AGENT_ID, no_followup=True) + project = state.parent + host = project / 'host.py' + host.write_text('''import json,pathlib,sys +r=json.load(sys.stdin) +if r.get('phase') == 'checkpoint_direction': + c=r['checkpoint_context'] + assert c['basis']['todo']['status'] == 'done' + assert pathlib.Path('candidate.txt').read_text() == 'valid' + with pathlib.Path('direction.txt').open('a') as f: f.write(c['read_context_id']+'\\n') + json.dump({'read_context_id':c['read_context_id'], 'vision_unchanged_reason':'The committed candidate and latest obligations preserve the route.'},sys.stdout) +else: + assert not pathlib.Path('candidate.txt').exists() + pathlib.Path('candidate.txt').write_text('valid') + json.dump({'schema_version':'loopx_turn_result_v0','turn_key':r['turn_key'], + 'result_kind':'validated_completion','completed_phases':['host_execute','typed_result'], + 'classification':'candidate_verified','recommended_action':'Read remaining work.', + 'next_action':'Read remaining work.','delivery_batch_scale':'implementation', + 'delivery_outcome':'outcome_progress','summary':'Candidate verified.', + 'vision_unchanged_reason':'Old pre-result judgment.'},sys.stdout) +''', encoding='utf-8') + validator = project / 'verify.py' + validator.write_text("import pathlib; assert pathlib.Path('candidate.txt').read_text()=='valid'", encoding='utf-8') + rc, result = _run_cli(registry, 'turn', 'run-once', '--host', 'generic-cli', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--project', str(project), '--host-adapter-command-json', + json.dumps([sys.executable, str(host)]), '--validation-command-json', json.dumps([sys.executable, str(validator)]), + '--no-global-sync', '--execute') + assert rc == 0 and result.get('status') == 'committed', json.dumps(result, indent=2) + assert len((project / 'direction.txt').read_text().splitlines()) == 1 + rows = [json.loads(line) for line in (tmp_path / 'runtime/goals' / GOAL_ID / 'runs/index.jsonl').read_text(encoding='utf-8').splitlines()] + committed = [row for row in rows if row.get('classification') == 'candidate_verified'] + assert len(committed) == 1 + assert committed[0]['vision_checkpoint']['unchanged_reason'] != 'Old pre-result judgment.' + + +@pytest.mark.parametrize('iteration_context', ['fresh', 'resume']) +def test_default_dsh_continues_original_session_with_agent_lane_direction(iteration_context, tmp_path, monkeypatch): + registry, state, _ = _fixture('file', tmp_path, monkeypatch) + project = state.parent + runner = project / 'runner.py' + runner.write_text('''import json,pathlib +def run_dsh_turn(**kwargs): + prompt=kwargs['prompt']; direction=prompt.startswith('Continue the current Agent') + with pathlib.Path(kwargs['workspace'],'sessions.jsonl').open('a') as f: + f.write(json.dumps({'session':kwargs['session_id'],'direction':direction})+'\\n') + if direction: + r=json.loads(prompt.split('\\n',1)[1]); c=r['checkpoint_context'] + assert c['basis']['frontier']['scope']=='agent_lane' + assert pathlib.Path(kwargs['workspace'],'candidate.txt').read_text()=='valid' + return json.dumps({'read_context_id':c['read_context_id'], + 'vision_unchanged_reason':'Current evidence preserves this session route.'}) + pathlib.Path(kwargs['workspace'],'candidate.txt').write_text('valid') + return json.dumps({'result_kind':'validated_progress','classification':'dsh_progress', + 'summary':'Verified one scoped candidate.','next_action':'Review remaining work.', + 'work_evidence':['artifact:candidate.txt'], + 'validation_evidence':['check:scoped-candidate-validation']}) +''', encoding='utf-8') + rc, result = _run_cli(registry, 'turn', 'run-once', '--host', 'dsh', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--project', str(project), '--dsh-runner', str(runner), + '--iteration-context', iteration_context, '--validation-command-json', json.dumps([sys.executable, '-c', 'pass']), + '--no-global-sync', '--execute') + assert rc == 0 and result['status'] == 'committed', result + sessions = [json.loads(line) for line in (project / 'sessions.jsonl').read_text().splitlines()] + assert [row['direction'] for row in sessions] == [False, True] + assert sessions[0]['session'] == sessions[1]['session'] + rows = [json.loads(line) for line in (tmp_path / 'runtime/goals' / GOAL_ID / 'runs/index.jsonl').read_text().splitlines()] + assert sum(row.get('classification') == 'quota_slot_spent' for row in rows) == 1 + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('change', ['none', 'related', 'display', 'cached_binding']) +def test_direction_retry_keeps_input_response_binding_and_result(provider, change, tmp_path, monkeypatch): + registry, state, todo = _fixture(provider, tmp_path, monkeypatch) + control = _control(registry) + json.loads(control.should_run()) + first = json.loads(control.complete_task(todo, AGENT_ID, 'Candidate verified.', next_agent_todo='Verify remaining work.')) + assert first['stage'] == 'direction_pending', first + identity = SettlementIdentity.from_runtime_payload(first['settlement_identity']) + seen = [] + def decide(context): + seen.append(context) + assert context['basis']['todo']['status'] == 'done' + return {'read_context_id': context['read_context_id'], 'vision_unchanged_reason': 'Current evidence preserves the route.'} + driver = CurrentAgentCheckpoint(registry, tmp_path / 'runtime', state, identity, decide) + decided = driver.continue_direction({}) + if change == 'related': + state.write_text(state.read_text(encoding='utf-8') + '\n## Acceptance\nRevised obligation.\n', encoding='utf-8') + elif change == 'display': + import re + text = state.read_text(encoding='utf-8') + text = re.sub(r'^updated_at:.*$', 'updated_at: 2026-10-08', text, flags=re.MULTILINE) if 'updated_at:' in text else text.replace('---\n', '---\nupdated_at: 2026-10-08\n', 1) + state.write_text(text, encoding='utf-8') + elif change == 'cached_binding': + path = _receipt_path(tmp_path / 'runtime', identity) + receipt = json.loads(path.read_text()) + receipt['current_agent_decision']['read_context_id'] = 'replacement-key' + path.write_text(json.dumps(receipt), encoding='utf-8') + with pytest.raises(ValueError, match='binding'): + driver.continue_direction({}) + assert len(seen) == 1 + return + cold = CurrentAgentCheckpoint(registry, tmp_path / 'runtime', state, identity, decide) + replay = cold.continue_direction({}) + assert len(seen) == (2 if change == 'related' else 1) + assert (replay['checkpoint_read_context_id'] != decided['checkpoint_read_context_id']) == (change == 'related') + result = json.loads(_control(registry).complete_task(todo, AGENT_ID, 'Candidate verified.', + next_agent_todo='Verify remaining work.', read_context_id=replay['checkpoint_read_context_id'], + vision_unchanged_reason=replay['vision_unchanged_reason'])) + assert result['settlement_complete'] is True, result + assert result['settlement_identity'] == first['settlement_identity'] + + +@pytest.mark.parametrize('failure', ['timeout', 'stop']) +def test_direction_failure_retains_result_and_respects_existing_host_budget(failure, tmp_path, monkeypatch): + registry, state, todo = _fixture('file', tmp_path, monkeypatch) + control = _control(registry) + json.loads(control.should_run()) + first = json.loads(control.complete_task(todo, AGENT_ID, 'Candidate verified.', next_agent_todo='Verify remaining work.')) + identity = SettlementIdentity.from_runtime_payload(first['settlement_identity']) + calls = [] + def fail(context): + calls.append(context['read_context_id']) + if failure == 'timeout': + raise BuiltInHostError('Synthetic timeout', failure_kind='executor_timeout') + return {'result_kind': 'user_action_required'} + driver = CurrentAgentCheckpoint(registry, tmp_path / 'runtime', state, identity, fail) + for _ in range(3): + with pytest.raises(ValueError): + driver.continue_direction({}) + assert len(calls) == (2 if failure == 'timeout' else 1) + receipt = json.loads(_receipt_path(tmp_path / 'runtime', identity).read_text()) + assert receipt['decision_basis']['todo']['status'] == 'done' + assert 'current_agent_decision' not in receipt + + +@pytest.mark.parametrize('kind', ['validated_progress', 'repair_required', 'replan_required', 'wait']) +def test_managed_noncompletion_uses_original_outcome_or_exemption(kind, tmp_path, monkeypatch): + registry, state, todo = _fixture('file', tmp_path, monkeypatch) + project = state.parent + host = project / 'host.py' + host.write_text('''import json,pathlib,sys +r=json.load(sys.stdin) +if r.get('phase') == 'checkpoint_direction': + c=r['checkpoint_context'] + assert c['basis']['todo']['status'] != 'done' + with pathlib.Path('direction.txt').open('a') as f: f.write(c['read_context_id']+'\\n') + decision={'read_context_id':c['read_context_id'],'vision_unchanged_reason':'Current evidence preserves the scoped route.'} + if KIND == 'replan_required': + decision={'read_context_id':c['read_context_id'],'agent_vision':{'schema_version':'goal_vision_replan_contract_v0', + 'state':'vision_patch_proposed','vision_patch':{'vision_summary':'Verify the revised scoped route.', + 'acceptance_summary':'Fixture evidence supports the revised route.'},'path_delta':{'schema_version':'goal_path_delta_v0', + 'outcome':'replan','prior_assumption':'Original scoped route.','observed_reality':'Revised route required.', + 'changed':['Scoped route'], 'evidence_refs':['evidence:fixture']}}} + json.dump(decision,sys.stdout) +else: + assert not pathlib.Path('candidate.txt').exists() + pathlib.Path('candidate.txt').write_text('verified') + value={'schema_version':'loopx_turn_result_v0','turn_key':r['turn_key'], + 'result_kind':KIND,'completed_phases':['host_execute','typed_result'], + 'classification':'fixture_outcome','recommended_action':'Check remaining scoped work.', + 'next_action':'Check remaining scoped work.','summary':'Bounded fixture outcome.'} + if KIND != 'wait': value.update(delivery_batch_scale='implementation',delivery_outcome='outcome_progress', + vision_unchanged_reason='Old pre-outcome judgment.') + json.dump(value,sys.stdout) +'''.replace('KIND', repr(kind)), encoding='utf-8') + rc, result = _run_cli(registry, 'turn', 'run-once', '--host', 'generic-cli', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--project', str(project), '--host-adapter-command-json', + json.dumps([sys.executable, str(host)]), '--validation-command-json', json.dumps([sys.executable, '-c', 'pass']), + '--no-global-sync', '--execute') + assert rc == 0 and result['status'] in {'committed','stopped'}, json.dumps(result, indent=2) + assert (project / 'direction.txt').exists() == (kind != 'wait') + rows = [json.loads(line) for line in (tmp_path / 'runtime/goals' / GOAL_ID / 'runs/index.jsonl').read_text(encoding='utf-8').splitlines()] + assert sum(row.get('classification') == 'quota_slot_spent' for row in rows) == (0 if kind == 'wait' else 1) + assert not any(row.get('completion_todo_id') == todo for row in rows) + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('change_at', ['work', 'validation']) +def test_managed_progress_revalidates_retained_candidate_on_original_turn( + provider, change_at, tmp_path, monkeypatch, +): + registry, state, todo = _fixture(provider, tmp_path, monkeypatch) + project, runtime = state.parent, tmp_path / 'runtime' + host = project / 'host.py' + host.write_text(f'''import json,pathlib,sys +r=json.load(sys.stdin) +candidate=pathlib.Path('candidate.txt') +if r.get('phase') == 'checkpoint_direction': + c=r['checkpoint_context'] + json.dump({{'read_context_id':c['read_context_id'], + 'vision_unchanged_reason':'The fresh requirements preserve the remaining route.'}},sys.stdout) +else: + if r.get('result_revalidation'): + assert candidate.read_text() == 'retained candidate' + assert r['result_revalidation']['candidate']['summary'] == 'Candidate verified.' + assert r['delivery_result_context']['read_context_id'] != r['result_revalidation']['original_context']['read_context_id'] + pathlib.Path('revalidated.txt').write_text(r['delivery_result_context']['read_context_id']) + else: + assert not candidate.exists() + candidate.write_text('retained candidate') + if {change_at == 'work'!r}: + state=pathlib.Path({str(state)!r}) + state.write_text(state.read_text()+'\\n## Revised acceptance\\nRecheck the retained candidate.\\n') + json.dump({{'schema_version':'loopx_turn_result_v0','turn_key':r['turn_key'], + 'result_kind':'validated_progress','completed_phases':['host_execute','typed_result'], + 'classification':'candidate_verified','recommended_action':'Review remaining work.', + 'next_action':'Review remaining work.','delivery_batch_scale':'implementation', + 'delivery_outcome':'outcome_progress','summary':'Candidate verified.', + 'vision_unchanged_reason':'Original candidate route.'}},sys.stdout) +''', encoding='utf-8') + validator = project / 'validate.py' + validator.write_text(f'''import pathlib +assert pathlib.Path('candidate.txt').read_text() == 'retained candidate' +marker=pathlib.Path('validated.txt') +if not marker.exists(): + marker.touch() + if {change_at == 'validation'!r}: + state=pathlib.Path({str(state)!r}) + state.write_text(state.read_text()+'\\n## Revised acceptance\\nRecheck the retained candidate.\\n') +''', encoding='utf-8') + args = ('turn', 'run-once', '--host', 'generic-cli', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--project', str(project), '--host-adapter-command-json', + json.dumps([sys.executable, str(host)]), '--validation-command-json', + json.dumps([sys.executable, str(validator)]), '--no-global-sync', '--execute') + rc, first = _run_cli(registry, *args) + assert rc == 1 and first.get('result_kind') == 'validation_failed', first + journal_path = next((runtime / 'goals' / GOAL_ID / 'turns').glob('*.json')) + journal = json.loads(journal_path.read_text()) + assert journal['validation_stage'] == 'result_freshness' + assert journal['result_revalidation']['candidate']['summary'] == 'Candidate verified.' + assert not journal.get('effect_attempts'), 'Definitive rejection must abort its prepared effect' + assert first['effects']['state_written'] is False and first['effects']['quota_spent'] is False + identity = journal['result_work_context']['settlement_identity'] + if provider == 'sqlite': + rc, reread = _run_cli(registry, 'checkpoint-context', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--todo-id', todo, '--turn-instance-id', identity['turn_instance_id'], + '--purpose', 'delivery_result') + assert rc == 0, reread + rc, resumed = _run_cli(registry, *args, '--retry-failed-turn', '--resume-turn-key', first['resume_turn_key']) + assert rc == 0 and resumed['status'] == 'committed', resumed + settled = json.loads(journal_path.read_text()) + assert settled['result_work_context']['settlement_identity'] == identity + assert (project / 'revalidated.txt').exists() + rows = [json.loads(line) for line in (runtime / 'goals' / GOAL_ID / 'runs/index.jsonl').read_text().splitlines()] + assert sum(row.get('classification') == 'quota_slot_spent' for row in rows) == 1 + rc, replay = _run_cli(registry, *args, '--retry-failed-turn', '--resume-turn-key', first['resume_turn_key']) + assert rc == 0 and replay['replayed'] is True, replay + assert (project / 'candidate.txt').read_text() == 'retained candidate' + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('terminal', [False, True]) +def test_default_direction_scopes_independent_peer_changes(provider, terminal, tmp_path, monkeypatch): + registry, _, todo = _fixture(provider, tmp_path, monkeypatch) + document = json.loads(registry.read_text()) + document['goals'][0]['coordination']['registered_agents'].append('independent-peer') + registry.write_text(json.dumps(document), encoding='utf-8') + peer = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role='agent', + text='Independent peer work.', task_class='advancement_task', claimed_by='independent-peer', + continuation_policy='same_agent_non_delivery') + control = _control(registry) + assert json.loads(control.should_run())['should_run'] is True + options = {'no_follow_up': True} if terminal else {'next_agent_todo': 'Verify remaining scoped work.'} + first = json.loads(control.complete_task(todo, AGENT_ID, 'Candidate verified.', **options)) + assert first['stage'] == 'direction_pending', first + context = first['context'] + assert context['basis']['frontier']['scope'] == ('goal' if terminal else 'agent_lane') + update_goal_todo(registry_path=registry, goal_id=GOAL_ID, todo_id=peer['todo_id'], + role='agent', agent_id='independent-peer', note='Independent evidence updated.') + response = json.loads(control.complete_task(todo, AGENT_ID, 'Candidate verified.', **options, + read_context_id=context['read_context_id'], vision_unchanged_reason='Own result and route remain valid.')) + if terminal: + assert response['ok'] is False and 'checkpoint_read_context_stale' in json.dumps(response), response + else: + assert response.get('settlement_complete') is True, response + assert response['settlement_identity'] == first['settlement_identity'] diff --git a/tests/control_plane/test_default_guard_admission.py b/tests/control_plane/test_default_guard_admission.py new file mode 100644 index 0000000000..35cdedfb63 --- /dev/null +++ b/tests/control_plane/test_default_guard_admission.py @@ -0,0 +1,182 @@ +"""First automatic admission, selection and gates on isolated owning stores.""" +from __future__ import annotations + +import shlex +import json +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier + +import pytest + +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.todos.active_state_todo_parser import parse_active_state_todos +from tests.control_plane.test_cli_output_budget import ( + AGENT_IDS, GOAL_ID, SCENARIOS, Scenario, _write_fixture, _invoke_cli, +) +from tests.control_plane.test_quota_settlement_cli import _run_cli, _spend_run_count + + +def _fixture(provider, tmp_path, monkeypatch, *, crowded=False, gated=False, peer=False, replan=False): + isolate_sqlite_runtime(tmp_path, monkeypatch) + scenario = Scenario("two-peers", todo_count=2, agent_count=2, run_count=1) if peer else SCENARIOS[1] if replan else ( + Scenario("ordinary-selection", todo_count=6, agent_count=1, run_count=1) + if crowded else SCENARIOS[0]) + project, runtime, registry, state = _write_fixture(tmp_path, scenario) + if gated: + state.write_text(state.read_text(encoding="utf-8").replace("## Agent Todo", + "## User Todo\n\n- [ ] [P0-user] Approve this scoped delivery.\n" + " \n\n## Agent Todo"), encoding="utf-8") + todos = parse_active_state_todos(state.read_text(encoding="utf-8"), item_limit=None) + assert len(todos["agent_todos"]["items"]) == scenario.todo_count + if provider != "legacy": + initialize_canonical_authority(runtime, GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=GOAL_ID, + todos=todos["agent_todos"]["items"] + todos.get("user_todos", {}).get("items", []), handoff_mode="soft_claim"), + state_path=state, provider=provider) + args = ("quota", "should-run", "--goal-id", GOAL_ID, + "--agent-id", AGENT_IDS[0]) + return project, runtime, registry, state, args + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("selector", [(), ("--include-detail", "agent-todos"), + ("--include-detail", "user-todos"), ("--include-detail", "goal-boundary"), + ("--include-detail", "scheduler"), ("--include-detail", "vision"), + ("--include-scheduler-detail",), ("--include-detail", "all")]) +def test_first_detail_selector_admits_and_cold_replays_original_work(provider, selector, tmp_path, monkeypatch): + project, runtime, registry, _, args = _fixture(provider, tmp_path, monkeypatch) + code, first = _run_cli(registry, runtime, *args, *selector, cwd=project) + assert code == 0 and first["should_run"] is True, first + context = first["delivery_result_context"] + identity = context["settlement_identity"] + assert identity["goal_id"] == GOAL_ID and identity["agent_id"] == AGENT_IDS[0] + assert identity["todo_id"] == context["basis"]["todo"]["todo_id"] == "todo_fixture_000" + assert first["heartbeat_receipt"]["turn_instance_id"] == identity["turn_instance_id"] + assert first["work_input_restored"] is False + # _run_cli creates a new process: no inherited caller state or copied token. + code, restored = _run_cli(registry, runtime, *args, cwd=project) + assert code == 0 and restored["delivery_result_context"] == context, restored + assert restored["work_input_restored"] is True + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("replan", [False, True]) +def test_unbound_concurrent_reads_return_selectable_identities_then_bind_exact_turn(provider, replan, tmp_path, monkeypatch): + project, runtime, registry, _, args = _fixture(provider, tmp_path, monkeypatch, crowded=True, replan=replan) + if replan: + from loopx.todos import list_goal_todos + owned = list_goal_todos(registry_path=registry, runtime_root_arg=str(runtime), + goal_id=GOAL_ID, role="agent", limit=None) + items = owned["todos"] + assert {item["todo_id"] for item in items} == {f"todo_fixture_{i:03}" for i in range(36)} + assert all(item["status"] == "open" and item["task_class"] == "advancement_task" + and item["claimed_by"] == AGENT_IDS[0] for item in items) + barrier = Barrier(2) + def read(): + barrier.wait() + return _run_cli(registry, runtime, *args, cwd=project) + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(lambda _: read(), range(2))) + ids = [] + for code, payload in results: + assert code == 0, payload + receipt = payload["heartbeat_receipt"] + assert receipt["settlement_binding_owed"] is True + assert "settlement_identity" not in receipt and "delivery_result_context" not in payload + selection = payload["interaction_contract"]["cli_channel"]["selection_command"] + assert receipt["turn_instance_id"] in selection["command_args_template"] + ids.append(receipt["turn_instance_id"]) + assert len(set(ids)) == 2, "Unselected observations are not one admitted Todo work" + assert _spend_run_count(runtime) == 0 + # Use the returned exact selection command, not a test-created replacement ID. + selection = results[0][1]["interaction_contract"]["cli_channel"]["selection_command"] + selected_args = shlex.split(selection["command_args_template"].replace("{todo_id}", "todo_fixture_000")) + code, selected = _run_cli(registry, runtime, *selected_args, cwd=project) + assert code == 0, json.dumps(selected, indent=2) + identity = selected["heartbeat_receipt"]["settlement_identity"] + assert identity["turn_instance_id"] == ids[0] and identity["todo_id"] == "todo_fixture_000" + if replan: + assert selected["normal_delivery_allowed"] is False + assert selected["interaction_contract"]["mode"] == "autonomous_replan" + # Read evidence is not normal-delivery permission. A required replan also + # protects any later result on this newly admitted Todo identity. + assert selected["delivery_result_context"]["settlement_identity"] == identity + code, replay = _run_cli(registry, runtime, *args, cwd=project) + assert code == 0 and replay["heartbeat_receipt"]["settlement_identity"] == identity, replay + assert replay.get("delivery_result_context") == selected.get("delivery_result_context") + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_user_gate_receipt_does_not_authorize_delivery_or_spend(provider, tmp_path, monkeypatch): + project, runtime, registry, _, args = _fixture(provider, tmp_path, monkeypatch, gated=True) + for selector in [(), ("--include-detail", "all")]: + code, guarded = _run_cli(registry, runtime, *args, *selector, cwd=project) + assert code == 0 and guarded["should_run"] is False, guarded + channel = guarded["interaction_contract"] + assert channel["agent_channel"]["delivery_allowed"] is False + assert channel["cli_channel"]["spend_allowed_now"] is False + receipt = guarded["heartbeat_receipt"] + code, rejected = _run_cli(registry, runtime, "quota", "spend-slot", "--goal-id", GOAL_ID, + "--agent-id", AGENT_IDS[0], "--todo-id", "todo_fixture_000", + "--turn-instance-id", receipt["turn_instance_id"], "--slots", "1", + "--source", "heartbeat", "--execute", cwd=project) + assert code == 1, rejected + assert _spend_run_count(runtime) == 0 + assert not list((runtime / "goals" / GOAL_ID / "turns").glob("*.json")) + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_another_agent_cannot_restore_the_original_work(provider, tmp_path, monkeypatch): + project, runtime, registry, _, args = _fixture(provider, tmp_path, monkeypatch, peer=True) + code, first = _run_cli(registry, runtime, *args, cwd=project) + assert code == 0, first + peer_args = tuple(AGENT_IDS[1] if value == AGENT_IDS[0] else value for value in args) + code, peer = _run_cli(registry, runtime, *peer_args, cwd=project) + assert code == 0 and peer["should_run"] is True, peer + assert peer["delivery_result_context"]["basis"]["todo"]["todo_id"] == "todo_fixture_001" + assert peer.get("delivery_result_context") != first["delivery_result_context"] + if "settlement_identity" in peer.get("heartbeat_receipt", {}): + assert peer["heartbeat_receipt"]["settlement_identity"]["agent_id"] == AGENT_IDS[1] + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_admission_policy_failure_cannot_bind_an_unbound_turn(provider, tmp_path, monkeypatch): + project, runtime, registry, _, args = _fixture(provider, tmp_path, monkeypatch, crowded=True) + code, first = _run_cli(registry, runtime, *args, cwd=project) + assert code == 0 and first["heartbeat_receipt"]["settlement_binding_owed"] is True, first + selection = first["interaction_contract"]["cli_channel"]["selection_command"] + log = runtime / "goals" / GOAL_ID / "rollout-event-log.jsonl" + original = log.read_bytes() + def unavailable(*_args, **_kwargs): + raise RuntimeError("Result work admission source unavailable") + monkeypatch.setattr("loopx.control_plane.goals.checkpoint_context_io.default_result_freshness_policy", unavailable) + command = shlex.split(selection["route_prefix"] + " " + + selection["command_args_template"].replace("{todo_id}", "todo_fixture_000"))[1:] + code, text = _invoke_cli(["--format", "json", *command]) + rejected = json.loads(text) + assert code == 1 and rejected["should_run"] is False, rejected + assert rejected["ok"] is False and rejected["recommended_action"] + assert "delivery_result_context" not in rejected + assert "settlement_identity" not in rejected.get("heartbeat_receipt", {}) + assert log.read_bytes() == original and _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["legacy"]) +def test_unpromoted_display_cannot_admit_work_after_display_repair(provider, tmp_path, monkeypatch): + project, runtime, registry, state, args = _fixture(provider, tmp_path, monkeypatch) + original = state.read_bytes() + state.write_text(state.read_text(encoding="utf-8").replace("status=open", "status=unsupported"), encoding="utf-8") + code, failed = _run_cli(registry, runtime, *args, cwd=project) + assert code == 1 and failed["error_code"] == "canonical_authority_required", failed + assert "delivery_result_context" not in failed and _spend_run_count(runtime) == 0 + state.write_bytes(original) + code, admitted = _run_cli(registry, runtime, *args, cwd=project) + assert code == 1 and admitted["error_code"] == "canonical_authority_required", admitted + assert "delivery_result_context" not in admitted + assert _spend_run_count(runtime) == 0 diff --git a/tests/control_plane/test_default_result_freshness.py b/tests/control_plane/test_default_result_freshness.py new file mode 100644 index 0000000000..b3a139ae3c --- /dev/null +++ b/tests/control_plane/test_default_result_freshness.py @@ -0,0 +1,542 @@ +"""Default admission and original-Turn result delivery on real local providers.""" +from __future__ import annotations + +import json +import sys +import pytest + +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from tests.control_plane.test_quota_settlement_cli import ( + AGENT_ID, GOAL_ID, TODO_ID, TURN_ID, _run_cli, _write_fixture, _spend_run_count, +) + + +def _default_fixture(provider, tmp_path, monkeypatch, *, handoff_mode="soft_claim"): + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = _write_fixture(tmp_path) + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + todo = {"schema_version": "todo_item_v0", "todo_id": TODO_ID, "index": 1, + "role": "agent", "status": "open", "done": False, "claimed_by": AGENT_ID, + "text": "Validate and settle the selected delivery.", "task_class": "advancement_task", + "archive_state": "active", "source_section": "Agent Todo"} + if provider != "legacy": + initialize_canonical_authority(runtime, GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=[todo], handoff_mode=handoff_mode), + state_path=state, provider=provider) + return project, runtime, registry, state + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_default_guard_restores_actual_basis_and_result_commit(provider, tmp_path, monkeypatch): + project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch) + identity = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID) + # Automatic default admission has no caller-selected Todo or invented Turn. + guard_args = ("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID) + rc, first = _run_cli(registry, runtime, *guard_args, cwd=project) + assert rc == 0 and "delivery_result_context" in first, json.dumps(first) + context = first["delivery_result_context"] + assert context["basis"]["todo"]["todo_id"] == TODO_ID + rc, cold = _run_cli(registry, runtime, *guard_args, cwd=project) + assert rc == 0 and cold["delivery_result_context"] == context, cold + assert cold["work_input_restored"] is True + complete = ("todo", "complete", *identity, "--note", "Candidate independently verified.") + state.write_text(state.read_text(encoding="utf-8") + "\n## Acceptance\n\nAdditional requirement.\n", encoding="utf-8") + rc, rejected = _run_cli(registry, runtime, *complete, cwd=project) + assert rc == 1 and rejected.get("error_code") == "checkpoint_read_context_stale", json.dumps(rejected) + assert _spend_run_count(runtime) == 0 + rc, refreshed = _run_cli(registry, runtime, "checkpoint-context", *identity, + "--turn-instance-id", context["settlement_identity"]["turn_instance_id"], + "--purpose", "delivery_result", cwd=project) + assert rc == 0, refreshed + rc, saved = _run_cli(registry, runtime, *complete, cwd=project) + assert rc == 0 and saved["completed"], saved + # Completing the Todo alone does not close its original refresh/spend. + rc, pending = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, cwd=project) + assert pending["heartbeat_receipt"]["turn_instance_id"] == context["settlement_identity"]["turn_instance_id"], pending + state.write_text(state.read_text(encoding="utf-8") + "\nRequirement after success.\n", encoding="utf-8") + rc, replay = _run_cli(registry, runtime, *complete, cwd=project) + assert rc == 0 and replay["idempotent_replay"], replay + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_concurrent_default_reads_share_original_turn_and_basis(provider, tmp_path, monkeypatch): + from concurrent.futures import ThreadPoolExecutor + from threading import Barrier + project, runtime, registry, _ = _default_fixture(provider, tmp_path, monkeypatch) + barrier = Barrier(2) + def read(): + barrier.wait() + return _run_cli(registry, runtime, "quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, cwd=project) + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(lambda _: read(), range(2))) + assert all(rc == 0 for rc, _ in results), json.dumps(results, indent=2) + assert results[0][1]["delivery_result_context"] == results[1][1]["delivery_result_context"] + + +def test_explicit_todo_selection_still_requires_a_turn(tmp_path): + project, runtime, registry = _write_fixture(tmp_path) + events = runtime / "goals" / GOAL_ID / "rollout-event-log.jsonl" + before = events.read_text().splitlines() if events.exists() else [] + rc, rejected = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, cwd=project) + assert rc == 1 and rejected["error_code"] == "QUOTA_VALIDATION_FAILED", rejected + assert "requires --turn-instance-id or --begin-turn" in rejected["reason"], rejected + assert "heartbeat_receipt" not in rejected + # A diagnostic quota_should_run event is allowed; it cannot mint the + # settlement binding that distinguishes a durable work admission. + after = events.read_text().splitlines() if events.exists() else [] + assert after[:len(before)] == before + assert all(not json.loads(line).get("details", {}).get("settlement_identity") + for line in after[len(before):]) + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_todo_only_source_after_work_read_rejects_result_commit(provider, tmp_path, monkeypatch): + from loopx.control_plane.todos.projection_document import recovered_todo_projection_skeleton + from loopx.todos import list_goal_todos + project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch) + identity = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID) + rc, guard = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *identity, + "--turn-instance-id", TURN_ID, cwd=project) + assert rc == 0 and "delivery_result_context" in guard, guard + original_contexts = {path: path.read_bytes() for path in + (runtime / "goals" / GOAL_ID / "checkpoint-contexts").glob("*.json")} + state.write_text(recovered_todo_projection_skeleton(GOAL_ID), encoding="utf-8") + rc, rejected = _run_cli(registry, runtime, "todo", "complete", *identity, + "--note", "Candidate from the original valid work read.", cwd=project) + assert rc != 0 and rejected["error_code"] == "checkpoint_goal_source_incomplete", rejected + assert list_goal_todos(registry_path=registry, goal_id=GOAL_ID, + runtime_root_arg=str(runtime), todo_id=TODO_ID)["todo"]["status"] == "open" + assert {path: path.read_bytes() for path in original_contexts} == original_contexts + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("source_loss", ["missing", "todo_only"]) +def test_successful_result_replay_precedes_later_goal_source_loss(provider, source_loss, tmp_path, monkeypatch): + from loopx.control_plane.todos.projection_document import recovered_todo_projection_skeleton + from loopx.todos import list_goal_todos + project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch) + identity = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID) + rc, guard = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *identity, + "--turn-instance-id", TURN_ID, cwd=project) + assert rc == 0, guard + complete = ("todo", "complete", *identity, "--note", "Verified result committed before source loss.") + rc, committed = _run_cli(registry, runtime, *complete, cwd=project) + assert rc == 0 and committed["completed"], committed + original_todo = list_goal_todos(registry_path=registry, goal_id=GOAL_ID, + runtime_root_arg=str(runtime), todo_id=TODO_ID)["todo"] + if source_loss == "missing": + state.unlink() + else: + state.write_text(recovered_todo_projection_skeleton(GOAL_ID), encoding="utf-8") + rc, replay = _run_cli(registry, runtime, *complete, cwd=project) + assert rc == 0 and replay["idempotent_replay"], replay + assert list_goal_todos(registry_path=registry, goal_id=GOAL_ID, + runtime_root_arg=str(runtime), todo_id=TODO_ID)["todo"] == original_todo + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_historical_admission_before_first_read_survives_cold_upgrade(provider, tmp_path, monkeypatch): + from loopx.control_plane.quota.heartbeat_receipt import ensure_turn_heartbeat_settlement_receipt + from loopx.control_plane.quota.settlement import SettlementIdentity + + project, runtime, registry, _ = _default_fixture(provider, tmp_path, monkeypatch) + # Seed an actual pre-default admission before any work/read receipt exists. + # No current admission is rewritten or downgraded to construct this case. + identity = SettlementIdentity(goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID) + ensure_turn_heartbeat_settlement_receipt(runtime, identity, + semantic_replan_guard_scoped=True, semantic_replan_obligation_id=None) + contexts = runtime / "goals" / GOAL_ID / "checkpoint-contexts" + assert not list(contexts.glob("*.json")) + log = runtime / "goals" / GOAL_ID / "rollout-event-log.jsonl" + original = log.read_bytes() + for _ in range(2): # Each CLI invocation starts a fresh Python caller. + rc, replay = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, + "--turn-instance-id", TURN_ID, cwd=project) + assert rc == 0, replay + assert replay["heartbeat_receipt"]["settlement_identity"] == identity.as_dict() + assert "delivery_result_context" not in replay + assert not list(contexts.glob("*.json")) + assert log.read_bytes() == original + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_already_bound_historical_receipt_retains_original_policy(provider, tmp_path, monkeypatch): + from tests.control_plane.test_quota_settlement_cli import _historical_turn + project, runtime, registry, _ = _default_fixture(provider, tmp_path, monkeypatch) + args = ("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID) + rc, first = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0, first + # Synthetic pre-default persisted admission; never rewrite active state. + _historical_turn(runtime) + log = runtime / "goals" / GOAL_ID / "rollout-event-log.jsonl" + original = log.read_bytes() + rc, replay = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0, replay + assert replay["heartbeat_receipt"]["settlement_identity"] == first["heartbeat_receipt"]["settlement_identity"] + assert "delivery_result_context" not in replay + assert log.read_bytes() == original + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_default_commit_keeps_basis_after_unrelated_todo_revision(provider, tmp_path, monkeypatch): + project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch) + identity = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID) + rc, work = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *identity, + "--turn-instance-id", TURN_ID, cwd=project) + assert rc == 0, work + rc, peer = _run_cli(registry, runtime, "todo", "add", "--goal-id", GOAL_ID, "--role", "agent", + "--text", "Validate an independent follow-up.", "--claimed-by", AGENT_ID, cwd=project) + assert rc == 0, peer + rc, saved = _run_cli(registry, runtime, "todo", "complete", *identity, + "--note", "Original candidate verified.", cwd=project) + assert rc == 0 and saved["completed"], saved + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_damaged_work_input_blocks_default_dispatch_and_preserves_admission(provider, tmp_path, monkeypatch): + project, runtime, registry, _ = _default_fixture(provider, tmp_path, monkeypatch) + args = ("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID) + rc, first = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0, first + path = next((runtime / "goals" / GOAL_ID / "checkpoint-contexts").glob("*.json")) + original = path.read_bytes() + path.write_text("{", encoding="utf-8") + rc, rejected = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 1 and rejected["should_run"] is False, rejected + assert rejected["status"] == "delivery_work_context_unavailable" + assert rejected["heartbeat_receipt"]["turn_instance_id"] == first["heartbeat_receipt"]["turn_instance_id"] + assert _spend_run_count(runtime) == 0 + path.write_bytes(original) + rc, restored = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0 and restored["delivery_result_context"] == first["delivery_result_context"], restored + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("recovery_window", ["normal", "receipt_replaced", "input_persisted", "commit_response_lost", "commit_receipt_deleted", "commit_receipt_damaged", "commit_race", "binding_race", "terminal_commit_race", "terminal_host_cold", "terminal_validation_cold", "terminal_host_replaced", "terminal_validation_replaced", "terminal_commit_response_lost", "terminal_receipt_deleted", "terminal_receipt_damaged", "terminal_validation_failed", "terminal_stop"]) +def test_historical_result_only_turn_retains_candidate_and_revalidates(provider, recovery_window, tmp_path, monkeypatch): + from tests.test_loopx_turn_driver import _write_live_fixture + + isolate_sqlite_runtime(tmp_path, monkeypatch) + terminal_race = recovery_window.startswith("terminal_") + project, runtime, registry = _write_live_fixture(tmp_path, provider=provider, + **({"todo_metadata_extra": "no_followup=true"} if terminal_race else {})) + goal, agent = "loopx-turn-fixture", "codex-fixture" + state = project / f".codex/goals/{goal}/ACTIVE_GOAL_STATE.md" + host = f''' +import json, pathlib, sys +r = json.load(sys.stdin) +assert r["delivery_result_context"]["basis"]["todo"]["todo_id"] == "todo_fixture0001" +candidate = pathlib.Path("candidate.txt") +if not r.get("result_revalidation"): + assert not candidate.exists() + candidate.write_text("valid candidate", encoding="utf-8") + state = pathlib.Path({str(state)!r}) + if {not (recovery_window in {'commit_race', 'binding_race'} or terminal_race)!r}: + state.write_text(state.read_text(encoding="utf-8") + "\\n## Acceptance\\nRetain the valid candidate and verify it again.\\n", encoding="utf-8") +else: + assert candidate.read_text(encoding="utf-8") == "valid candidate" + assert r["result_revalidation"]["original_context"]["read_context_id"] != r["delivery_result_context"]["read_context_id"] + pathlib.Path("revalidation_started").touch() +json.dump({{"schema_version":"loopx_turn_result_v0", "turn_key":r["turn_key"], + "result_kind": "user_action_required" if {recovery_window == 'terminal_stop'!r} and r.get("result_revalidation") else "validated_completion", "completed_phases":["host_execute","typed_result"], + "classification":"retained_candidate_verified", "recommended_action":"Read remaining work.", + "next_action":"Read remaining work.", "delivery_batch_scale":"implementation", "delivery_outcome":"outcome_progress", + "vision_unchanged_reason":"The valid candidate satisfies the current requirements.", "summary":"Candidate verified."}}, sys.stdout) +''' + verifier = 'import json,pathlib,sys; r=json.load(sys.stdin); assert "delivery_result_context" in r; assert pathlib.Path("candidate.txt").read_text()=="valid candidate"' + if recovery_window == "terminal_validation_failed": + verifier += '\nif pathlib.Path("revalidation_started").exists() and not pathlib.Path("failed_validation_once").exists():\n pathlib.Path("failed_validation_once").touch()\n sys.exit(1)\n' + host_path, validator_path = project / "host.py", project / "validator.py" + host_path.write_text(host, encoding="utf-8") + validator_path.write_text(verifier, encoding="utf-8") + args = ("turn", "run-once", "--host", "generic-cli", "--goal-id", goal, "--agent-id", agent, + "--project", str(project), "--host-adapter-command-json", json.dumps([sys.executable, str(host_path)]), + "--validation-command-json", json.dumps([sys.executable, str(validator_path)]), + "--no-global-sync", "--execute") + # Gap1 admissions already in flight keep result-only semantics after the + # new checkpoint default ships. Seed the real old receipt, never downgrade + # a receipt admitted under current_agent_v1. + from loopx.control_plane.quota.settlement import SettlementIdentity + from loopx.control_plane.quota.heartbeat_receipt import ensure_turn_heartbeat_settlement_receipt + rc, preview = _run_cli(registry, runtime, *args[:-1], cwd=project) + assert rc == 0, preview + ensure_turn_heartbeat_settlement_receipt(runtime, + SettlementIdentity(goal_id=goal, agent_id=agent, todo_id='todo_fixture0001', + turn_instance_id=preview['resume_turn_key']), semantic_replan_guard_scoped=True, + semantic_replan_obligation_id=None, result_freshness='turn_result_freshness_required_v1') + if recovery_window in {"commit_race", "binding_race"} or terminal_race: + import os + import subprocess + from tests.control_plane.test_quota_settlement_cli import REPO_ROOT + wrapper = project / "change_before_completion.py" + mutation = f'''import pathlib +import loopx.cli_commands.turn_run_once as turn +original = turn.write_turn_validated_completion +def change_basis(*args, **kwargs): + state = pathlib.Path({str(state)!r}) + state.write_text(state.read_text(encoding="utf-8") + "\\n## Acceptance\\nRevalidate the retained candidate.\\n", encoding="utf-8") + return original(*args, **kwargs) +turn.write_turn_validated_completion = change_basis +''' + if recovery_window == "binding_race": + mutation = '''from loopx.control_plane.goals.checkpoint_context_io import TurnResultWork +original = TurnResultWork.bind +def replace_receipt(self, *args, **kwargs): + self.read(reread=True) + return original(self, *args, **kwargs) +TurnResultWork.bind = replace_receipt +''' + wrapper.write_text(mutation + ''' +from loopx.cli import main +raise SystemExit(main()) +''', encoding="utf-8") + changed = subprocess.run([sys.executable, str(wrapper), "--registry", str(registry), + "--runtime-root", str(runtime), "--format", "json", *args], cwd=project, + capture_output=True, text=True, env={**os.environ, "PYTHONPATH": str(REPO_ROOT)}) + rc, first = changed.returncode, json.loads(changed.stdout) + else: + rc, first = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 1 and first.get("result_kind") == ("terminal_closeout_failed" if terminal_race else "validation_failed"), first + journal_path = next((runtime / "goals" / goal / "turns").glob("*.json")) + failed = json.loads(journal_path.read_text(encoding="utf-8")) + assert failed["validation_stage"] == "result_freshness" + assert failed["result_revalidation"]["candidate"]["summary"] == "Candidate verified." + assert first["effects"]["state_written"] is terminal_race and first["effects"]["quota_spent"] is terminal_race + assert not failed.get("effect_attempts"), "The rejected commit must release its prepared effect" + if recovery_window in {"receipt_replaced", "input_persisted"}: + from loopx.control_plane.goals.checkpoint_context_io import result_work_context + from loopx.control_plane.quota.settlement import SettlementIdentity + context = result_work_context(registry_path=registry, runtime_root=runtime, + identity=SettlementIdentity.from_runtime_payload(failed["result_work_context"]["settlement_identity"]), reread=True) + if recovery_window == "input_persisted": + failed["result_work_context"] = context + failed["status"] = "in_progress" + for key in ("host_result", "result_kind", "receipt", "reason", "validation_stage"): + failed.pop(key, None) + failed["completed_phases"] = [] + journal_path.write_text(json.dumps(failed), encoding="utf-8") + if recovery_window in {"terminal_host_cold", "terminal_validation_cold", "terminal_host_replaced", "terminal_validation_replaced"}: + import os + import subprocess + from tests.control_plane.test_quota_settlement_cli import REPO_ROOT + wrapper = project / "lose_revalidation_checkpoint.py" + phase_count = 2 if recovery_window in {"terminal_host_cold", "terminal_host_replaced"} else 3 + wrapper.write_text(f'''import os +import loopx.control_plane.turn_driver.executor as executor +original = executor._write_journal +def lose_response(path, journal, **kwargs): + result = original(path, journal, **kwargs) + retry = journal.get("result_revalidation", {{}}) + if retry.get("phase") == "terminal_closeout" and len(retry.get("validation", {{}}).get("completed_phases", [])) == {phase_count}: + os._exit(73) + return result +executor._write_journal = lose_response +from loopx.cli import main +raise SystemExit(main()) +''', encoding="utf-8") + lost = subprocess.run([sys.executable, str(wrapper), "--registry", str(registry), + "--runtime-root", str(runtime), "--format", "json", *args, + "--resume-turn-key", failed["turn_key"], "--retry-failed-turn"], cwd=project, + capture_output=True, text=True, env={**os.environ, "PYTHONPATH": str(REPO_ROOT)}) + assert lost.returncode == 73, lost.stdout + lost.stderr + if recovery_window.endswith("_replaced"): + from loopx.control_plane.goals.checkpoint_context_io import result_work_context + from loopx.control_plane.quota.settlement import SettlementIdentity + state.write_text(state.read_text(encoding="utf-8") + "\nLatest acceptance must be adopted by the current Agent.\n", encoding="utf-8") + result_work_context(registry_path=registry, runtime_root=runtime, + identity=SettlementIdentity.from_runtime_payload(failed["result_work_context"]["settlement_identity"]), reread=True) + if recovery_window in {"commit_response_lost", "terminal_commit_response_lost", "commit_receipt_deleted", "commit_receipt_damaged", "terminal_receipt_deleted", "terminal_receipt_damaged"}: + import os + import subprocess + from tests.control_plane.test_quota_settlement_cli import REPO_ROOT + wrapper = project / "lose_completion_response.py" + wrapper.write_text('''import os +import loopx.cli_commands.turn_run_once as turn +original = turn.write_turn_validated_completion +def lose_response(*args, **kwargs): + result = original(*args, **kwargs) + assert result["ok"] + os._exit(73) +turn.write_turn_validated_completion = lose_response +from loopx.cli import main +raise SystemExit(main()) +''', encoding="utf-8") + lost = subprocess.run([sys.executable, str(wrapper), "--registry", str(registry), + "--runtime-root", str(runtime), "--format", "json", *args, + "--resume-turn-key", failed["turn_key"], "--retry-failed-turn"], cwd=project, + capture_output=True, text=True, env={**os.environ, "PYTHONPATH": str(REPO_ROOT)}) + assert lost.returncode == 73, lost.stdout + lost.stderr + index = runtime / "goals" / goal / "runs/index.jsonl" + if not terminal_race: + assert not index.exists() or not index.read_bytes(), "Writeback must not precede the lost result response" + if "receipt_" in recovery_window: + context_path = next((runtime / "goals" / goal / "checkpoint-contexts").glob("*.json")) + if recovery_window.endswith("deleted"): + context_path.unlink() + else: + context_path.write_text("{", encoding="utf-8") + rc, guard = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", + "--goal-id", goal, "--agent-id", agent, "--todo-id", "todo_fixture0001", + "--turn-instance-id", failed["turn_key"], cwd=project) + assert rc == 0 and guard.get("ok") is True, guard + assert guard["heartbeat_receipt"]["settlement_identity"]["turn_instance_id"] == failed["turn_key"] + assert guard.get("result_committed") is True and "delivery_result_context" not in guard, guard + rc, resumed = _run_cli(registry, runtime, *args, "--resume-turn-key", failed["turn_key"], + "--retry-failed-turn", cwd=project) + if recovery_window == "input_persisted": + # Removing the prior result does not prove that its dispatched Host + # never ran. Keep main's ambiguous-attempt fence, even with a newer + # work input and the valid original candidate still on disk. + assert rc == 1, resumed + assert resumed["recovery_decision"]["reason"] == "host_outcome_unknown" + assert resumed["effects"]["host_invoked"] is None + assert (project / "candidate.txt").read_text() == "valid candidate" + assert not (project / "revalidation_started").exists() + assert _spend_run_count(runtime) == 0 + assert json.loads(journal_path.read_text(encoding="utf-8")) == failed + return + if recovery_window in {"terminal_validation_failed", "terminal_stop"}: + assert rc == 1 and resumed["status"] == "failed", resumed + pending = json.loads(journal_path.read_text(encoding="utf-8")) + assert pending["completed_phases"] == failed["completed_phases"] + assert pending["writeback"] == failed["writeback"] and pending["quota_spend"] == failed["quota_spend"] + assert pending["host_attempt_count"] == 2 + if recovery_window == "terminal_stop": + rc, stopped_replay = _run_cli(registry, runtime, *args, "--resume-turn-key", failed["turn_key"], + "--retry-failed-turn", cwd=project) + assert rc == 1 and not stopped_replay["effects"]["host_invoked"], stopped_replay + assert json.loads(journal_path.read_text(encoding="utf-8"))["quota_spend"] == failed["quota_spend"] + return + rc, resumed = _run_cli(registry, runtime, *args, "--resume-turn-key", failed["turn_key"], + "--retry-failed-turn", cwd=project) + assert rc == 0 and resumed["status"] == "committed", resumed + committed = json.loads(journal_path.read_text(encoding="utf-8")) + assert committed["turn_key"] == failed["turn_key"] + assert committed["host_attempt_count"] == (3 if recovery_window in {"terminal_validation_failed", "terminal_host_replaced", "terminal_validation_replaced"} else 2) + if terminal_race: + assert {key: value for key, value in committed["writeback"].items() if key != "completion"} == failed["writeback"] + assert committed["quota_spend"] == failed["quota_spend"] + runs = [json.loads(line) for line in (runtime / "goals" / goal / "runs/index.jsonl").read_text(encoding="utf-8").splitlines()] + assert sum(run.get("classification") == "quota_slot_spent" for run in runs) == 1 + assert committed["result_work_binding"]["read_context_id"] == committed["result_work_context"]["read_context_id"] + rc, replay = _run_cli(registry, runtime, *args, "--resume-turn-key", failed["turn_key"], cwd=project) + assert rc == 0 and replay["replayed"] is True and replay["effects"]["host_invoked"] is False, replay + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_default_mcp_revalidation_cannot_mint_work_without_original_admission(provider, tmp_path, monkeypatch): + from tests.test_goal_mode_mcp_settlement import _write_fixture as mcp_fixture, _control, GOAL_ID as goal, AGENT_ID as agent + from loopx.todos import add_goal_todo + + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, state = mcp_fixture(tmp_path, provider=provider) + added = add_goal_todo(registry_path=registry, goal_id=goal, role="agent", + text="Verify a bounded candidate.", task_class="advancement_task", claimed_by=agent, + continuation_policy="same_agent_non_delivery") + before = state.read_bytes() + runtime = tmp_path / "runtime" + index = runtime / "goals" / goal / "runs/index.jsonl" + before_index = index.read_bytes() if index.exists() else b"" + rejected = json.loads(_control(registry).should_run(revalidate_todo_id=added["todo_id"])) + assert rejected["ok"] is False and rejected["error_code"] == "QUOTA_VALIDATION_FAILED", rejected + assert "requires --turn-instance-id" in rejected["reason"] + assert "heartbeat_receipt" not in rejected and "delivery_result_context" not in rejected + assert state.read_bytes() == before + assert (index.read_bytes() if index.exists() else b"") == before_index + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("receipt_loss", [None, "deleted", "damaged", "writeback_deleted", "writeback_damaged"]) +def test_default_mcp_reads_before_work_and_rejects_stale_result(provider, receipt_loss, tmp_path, monkeypatch): + from tests.test_goal_mode_mcp_settlement import _write_fixture as mcp_fixture, _control, _complete_default_task, GOAL_ID as goal, AGENT_ID as agent + from loopx.todos import add_goal_todo + + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, state = mcp_fixture(tmp_path, provider=provider) + registration = json.loads(registry.read_text(encoding="utf-8")) + registration["goals"][0]["adapter"] = {"kind": "fixture_v0", "status": "connected-delivery"} + registry.write_text(json.dumps(registration), encoding="utf-8") + added = add_goal_todo(registry_path=registry, goal_id=goal, role="agent", text="Verify a bounded candidate.", + task_class="advancement_task", claimed_by=agent, continuation_policy="same_agent_non_delivery") + runtime = tmp_path / "runtime" + control = _control(registry) + first = json.loads(control.should_run()) + assert first.get("delivery_result_context"), json.dumps(first) + original = first["delivery_result_context"] + state.write_text(state.read_text(encoding="utf-8") + "\n## Acceptance\nRecheck the additional requirement.\n", encoding="utf-8") + # A new MCP facade/process keeps the same stored work key, without user token copying. + control = _control(registry) + cold = json.loads(control.should_run()) + assert cold["delivery_result_context"] == original + result = json.loads(_complete_default_task(control, added["todo_id"], agent, "Candidate verified.", + next_agent_todo="Verify remaining work.")) + assert result["ok"] is False, result + assert result.get("error_code") == "checkpoint_read_context_stale", result + refreshed = json.loads(control.should_run(revalidate_todo_id=added["todo_id"])) + assert "delivery_result_context" in refreshed, json.dumps(refreshed, indent=2) + assert refreshed["delivery_result_context"]["read_context_id"] != original["read_context_id"] + assert refreshed["delivery_result_context"]["settlement_identity"] == original["settlement_identity"] + if receipt_loss: + run_cli = control.run_cli + def lose_result_response(args, **kwargs): + output = run_cli(args, **kwargs) + step = ["refresh-state"] if receipt_loss.startswith("writeback_") else ["todo", "complete"] + if args[:len(step)] == step and json.loads(output).get("ok"): + return json.dumps({"ok": False, "error": "synthetic result response lost"}) + return output + control.run_cli = lose_result_response + lost = json.loads(_complete_default_task(control, added["todo_id"], agent, "Candidate revalidated.", + next_agent_todo="Verify remaining work.")) + assert lost["ok"] is False, lost + context_path = next((runtime / "goals" / goal / "checkpoint-contexts").glob("*.json")) + if receipt_loss.endswith("deleted"): + context_path.unlink() + else: + context_path.write_text("{", encoding="utf-8") + control = _control(registry) + pending = json.loads(control.should_run()) + assert pending.get("result_committed") is True, json.dumps({key: pending.get(key) for key in + ("error", "reason", "error_code", "status", "result_committed", "heartbeat_receipt", "work_input_restored")}) + assert pending["heartbeat_receipt"]["settlement_identity"] == original["settlement_identity"] + result = json.loads(_complete_default_task(control, added["todo_id"], agent, "Candidate revalidated.", + next_agent_todo="Verify remaining work.")) + assert result["ok"] is True, result + # Ordinary successful settlement releases the next default work identity. + next_work = json.loads(control.should_run()) + # Main requires claiming an unowned successor before admitting its work. + selected = next_work.get("selected_todo") or {} + assert selected.get("claim_required_before_work") is True, next_work + claimed = json.loads(control.claim_task(selected["todo_id"], agent)) + assert claimed["ok"] is True, claimed + next_work = json.loads(control.should_run()) + assert next_work.get("delivery_result_context"), next_work + assert next_work["delivery_result_context"]["settlement_identity"]["turn_instance_id"] != original["settlement_identity"]["turn_instance_id"] + + +@pytest.mark.parametrize("operation", ["add", "complete"]) +def test_unpromoted_work_read_does_not_restore_retired_mutation_authority(operation, tmp_path, monkeypatch): + project, runtime, registry, state = _default_fixture("legacy", tmp_path, monkeypatch) + rc, admitted = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, + "--turn-instance-id", TURN_ID, cwd=project) + assert rc == 1 and admitted["error_code"] == "canonical_authority_required", admitted + assert "delivery_result_context" not in admitted + original = state.read_bytes() + args = (("--role", "agent", "--text", "Independent candidate.") if operation == "add" + else ("--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--note", "Verified candidate.")) + rc, rejected = _run_cli(registry, runtime, "todo", operation, "--goal-id", GOAL_ID, *args, cwd=project) + assert rc == 1 and rejected["error_code"] == "canonical_authority_required", rejected + assert state.read_bytes() == original + assert _spend_run_count(runtime) == 0 diff --git a/tests/control_plane/test_default_terminal_admission_gates.py b/tests/control_plane/test_default_terminal_admission_gates.py new file mode 100644 index 0000000000..80608fd80c --- /dev/null +++ b/tests/control_plane/test_default_terminal_admission_gates.py @@ -0,0 +1,122 @@ +"""Native terminal admission gates; all mutations target disposable fixture state.""" +from collections import Counter +from copy import deepcopy +import json +from pathlib import Path +import subprocess +import sys + +import pytest + + +from loopx.configure_goal import configure_goal +from tests.control_plane.test_default_turn_recovery import ( + _managed_recovery_call, _business_rows, _work_counts, _original_checkpoint_receipt, +) +from tests.test_goal_mode_mcp_settlement import GOAL_ID +import tests.control_plane.test_declared_terminal_settlement_cli as declared + + +def _assert_preserved(before, after): + assert after == before, 'A denied operation repeated work or successful business effects' + + +def test_negative_counter_fixture_rejected(): + before = {'work': {'implementation.txt': 1}, 'effects': {'quota_slot_spent': 1}} + for key in ('work', 'effects'): + broken = deepcopy(before) + broken[key][next(iter(broken[key]))] += 1 + with pytest.raises(AssertionError): + _assert_preserved(before, broken) + + +def _original(provider, tmp_path, monkeypatch): + state, project, args, injection = _managed_recovery_call(provider, 'result', tmp_path, monkeypatch) + lost = subprocess.run([sys.executable, '-c', injection, *args], capture_output=True, text=True, timeout=90) + assert lost.returncode == 97, lost.stdout+lost.stderr + runtime = tmp_path/'runtime' + journal_path = next(p for p in (runtime/'goals'/GOAL_ID/'turns').glob('*.json') if not p.name.endswith('.holder.json')) + journal = json.loads(journal_path.read_text()) + assert journal['host_result']['result_kind'] == 'validated_completion' + resume = [*args, '--resume-turn-key', journal['turn_key'], '--retry-failed-turn'] + registry = Path(args[args.index('--registry')+1]) + return state, project, runtime, registry, journal_path, journal, resume + + +def _snapshot(project, runtime): + return {'work': _work_counts(project), 'effects': _business_rows(runtime), + 'candidate': (project/'candidate.txt').read_bytes()} + + +def _resume(args): + return subprocess.run([sys.executable, '-m', 'loopx.cli', *args], capture_output=True, text=True, timeout=90) + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('gate', ['pause', 'stop']) +def test_pending_result_respects_explicit_gate_and_original_resume(provider, gate, tmp_path, monkeypatch): + state, project, runtime, registry, journal_path, journal, resume = _original(provider, tmp_path, monkeypatch) + before = _snapshot(project, runtime) + assert before['work'] == {'implementation.txt': 1, 'validation.txt': 1, 'direction.txt': 0} + source_before = state.read_bytes() + registry_before = registry.read_bytes() + if gate == 'pause': + changed = configure_goal(registry_path=registry, goal_id=GOAL_ID, quota_compute=0, execute=True) + assert changed['written'] + else: + doc = json.loads(registry.read_text()) + doc['goals'][0]['activation'] = {'schema_version': 'loopx_goal_activation_v1', 'state': 'stopped', + 'updated_at': '2026-10-11T00:00:00Z', 'reason': 'Fixture owner stops pending execution.'} + registry.write_text(json.dumps(doc), encoding='utf-8') + denied = _resume(resume) + assert denied.returncode != 0, denied.stdout+denied.stderr + _assert_preserved(before, _snapshot(project, runtime)) + retained = json.loads(journal_path.read_text()) + assert retained['turn_key'] == journal['turn_key'] + assert retained['host_result'] == journal['host_result'] + assert state.read_bytes() == source_before + if gate == 'pause': + assert configure_goal(registry_path=registry, goal_id=GOAL_ID, quota_compute=1, execute=True)['written'] + else: + registry.write_bytes(registry_before) + recovered = _resume(resume) + assert recovered.returncode == 0, recovered.stdout+recovered.stderr + value = json.loads(recovered.stdout) + assert value['status'] == 'committed' and value['resume_turn_key'] == journal['turn_key'] + assert _work_counts(project) == {'implementation.txt': 1, 'validation.txt': 1, 'direction.txt': 1} + assert _business_rows(runtime) == Counter({'candidate_verified': 1, 'quota_slot_spent': 1}) + after = _snapshot(project, runtime) + replay = _resume(resume) + assert replay.returncode == 0, replay.stdout+replay.stderr + _assert_preserved(after, _snapshot(project, runtime)) + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +def test_real_direction_permission_signal_retains_result_and_blocks_retry(provider, tmp_path, monkeypatch): + _, project, runtime, _, journal_path, journal, resume = _original(provider, tmp_path, monkeypatch) + host = project/'host.py' + source = host.read_text() + needle = " c=r['checkpoint_context']; assert c['basis']['todo']['status']=='done'" + assert needle in source + source = source.replace(needle, " count('permission_requests.txt')\n json.dump({'result_kind':'user_action_required'},sys.stdout); sys.exit(0)\n"+needle) + host.write_text(source, encoding='utf-8') + before = _snapshot(project, runtime) + first = _resume(resume) + assert first.returncode != 0, first.stdout+first.stderr + _assert_preserved(before, _snapshot(project, runtime)) + receipt = _original_checkpoint_receipt(runtime) + assert receipt['direction_stopped'] is True + assert 'current_agent_decision' not in receipt + assert receipt['decision_basis']['todo']['status'] == 'done' + assert (project/'permission_requests.txt').read_text() == '1\n' + # Ordinary replay must not reprompt. Explicit retry is a separate owner + # decision, exercised after resolution in test_direction_stop_reentry. + second = _resume([arg for arg in resume if arg != '--retry-failed-turn']) + assert second.returncode != 0, second.stdout+second.stderr + _assert_preserved(before, _snapshot(project, runtime)) + assert (project/'permission_requests.txt').read_text() == '1\n' + assert json.loads(journal_path.read_text())['host_result'] == journal['host_result'] + + +# The shipped fixture now explicitly covers all three actual providers. +test_native_hard_lease_and_failed_validator_preserve_original_declaration = declared.test_original_lease_and_failed_validator_remain_required_before_accounting diff --git a/tests/control_plane/test_default_terminal_frontier.py b/tests/control_plane/test_default_terminal_frontier.py new file mode 100644 index 0000000000..32c3e0a4c2 --- /dev/null +++ b/tests/control_plane/test_default_terminal_frontier.py @@ -0,0 +1,304 @@ +"""Default terminal/frontier recovery through disposable owning stores.""" +import json +from pathlib import Path +import subprocess +import sys + +import pytest + +from loopx.todos import add_goal_todo +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted +from loopx.status import parse_active_state_todos +from tests.control_plane.test_default_checkpoint_continuation import _fixture +from tests.control_plane.test_default_turn_recovery import _managed_recovery_call, _business_rows, _work_counts +from tests.test_goal_mode_mcp_settlement import _control, GOAL_ID, AGENT_ID + +SCENARIOS = ['ordinary_successor', 'legal_no_followup', 'changed_during_direction', + 'changed_before_terminal', 'changed_after_terminal', 'terminal_loss_replay'] + +def rows(runtime, state): + canonical = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) + return canonical['todos'] if canonical is not None else parse_active_state_todos(state.read_text(encoding='utf-8'))['agent_todos']['items'] + +def result_facts(row): + # A terminal phase has its own receipt pointer; immutable result facts stay. + return {key: row.get(key) for key in ('todo_id', 'status', 'evidence', 'completed_at', 'completion_turn_key')} + +def add_obligation(registry, validator=None): + validation = {} if validator is None else {'validation_command_json': json.dumps([sys.executable, str(validator)]), 'validation_timeout_seconds': 10} + result = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role='agent', + text='Verify the newly delivered dependency before the next product step.', + task_class='advancement_task', claimed_by=AGENT_ID, + continuation_policy='same_agent_non_delivery', **validation) + return result['todo_id'] + +def journal(runtime): + paths = [p for p in (runtime / 'goals' / GOAL_ID / 'turns').glob('*.json') if not p.name.endswith('.holder.json')] + assert len(paths) == 1 + return json.loads(paths[0].read_text(encoding='utf-8')) + +def resume(args, original, *, retry=False): + argv = [sys.executable, '-m', 'loopx.cli', *args, '--resume-turn-key', original['turn_key']] + if retry: + argv += ['--retry-failed-turn'] + result = subprocess.run(argv, capture_output=True, text=True, timeout=150) + assert result.returncode == 0, result.stdout + result.stderr + value = json.loads(result.stdout) + assert value['status'] == 'committed' and value['resume_turn_key'] == original['turn_key'], value + return value + +def assert_once(project, runtime): + counts = _work_counts(project) + assert counts['implementation.txt'] == 1 and counts['validation.txt'] == 1, counts + effects = _business_rows(runtime) + assert effects['candidate_verified'] == 1 and effects['quota_slot_spent'] == 1, effects + +def business_snapshot(project, runtime): + return {'counts': _work_counts(project), 'effects': dict(_business_rows(runtime)), + 'candidate': (project / 'candidate.txt').read_bytes()} + + +def settle_frontier_replan(caller, guard, registry, project, runtime, new_todo): + """A new dependency after terminal intent owes an actual route decision.""" + turn = guard['heartbeat_receipt']['turn_instance_id'] + identity = guard['heartbeat_receipt']['settlement_identity'] + obligation = guard['replan_action_packet']['obligation_id'] + assert guard['heartbeat_receipt']['semantic_replan_obligation_id'] == obligation + assert identity['todo_id'] == new_todo + assert (project / 'new-dependency.txt').read_text() == 'delivered-v2' + assert not (project / 'new-proof.txt').exists(), 'Planning cannot claim the future validator result' + from tests.control_plane.test_quota_settlement_cli import _projected_cli_args + actions = guard['interaction_contract']['cli_channel']['next_cli_actions'] + packet = project / 'frontier-replan.json' + refresh = next(c for c in actions if 'refresh-state' in c).replace( + '', str(packet)) + for key, value in {'': 'advanced', + '': 'delivered-dependency', '': 'dependency-acceptance', + '': 'source-read', '': 'file:new-dependency.txt'}.items(): + refresh = refresh.replace(key, value) + refresh_args = list(_projected_cli_args(refresh, turn_instance_id=turn)) + # This route is qualified by the real dependency and Vision path below, + # not by an invented surface/hypothesis/probe progress observation. + for flag in ('--progress-result-class', '--progress-surface-id', '--progress-hypothesis-id', + '--progress-probe-kind', '--progress-evidence-id'): + if flag in refresh_args: + at = refresh_args.index(flag) + del refresh_args[at:at + 2] + initial = refresh_args.copy() + index = initial.index('--agent-vision-json') + del initial[index:index + 2] + pending = json.loads(caller.run_cli([*initial, '--no-global-sync', '--suppress-external-sinks'])) + assert pending['ok'] is True, {key: pending.get(key) for key in ('error', 'reason')} + retained = json.loads(Path(pending['json_path']).read_text()) + assert retained['vision_checkpoint']['satisfied'] is False + assert not retained.get('autonomous_replan_ack', {}).get('recorded') + spend = _projected_cli_args(next(c for c in actions if 'spend-slot' in c), turn_instance_id=turn) + denied_spend = json.loads(caller.run_cli(list(spend))) + assert denied_spend['ok'] is False, denied_spend + assert _business_rows(runtime)['quota_slot_spent'] == 1 + from loopx.control_plane.goals.checkpoint_context_io import read_checkpoint_context + context = read_checkpoint_context(registry_path=registry, + runtime_root_override=str(runtime), goal_id=GOAL_ID, agent_id=AGENT_ID, + todo_id=identity['todo_id'], turn_instance_id=turn, purpose='supplement_checkpoint') + assert context['basis']['todo']['status'] == 'open' + prior = context['basis']['agent_vision'] + assert prior + # This original guard selected the still-pending semantic duty. A fresh + # unchanged checkpoint alone must neither acknowledge it nor spend. + before_rejection = (business_snapshot(project, runtime), + (runtime / 'goals' / GOAL_ID / 'runs/index.jsonl').read_bytes()) + denied = json.loads(caller.run_cli([*initial, + '--checkpoint-read-context', context['read_context_id'], + '--vision-unchanged-reason', 'The delivered dependency is visible; retain the current direction.', + '--no-global-sync', '--suppress-external-sinks'])) + assert denied['ok'] is False and 'typed semantic delta' in json.dumps(denied), denied + assert before_rejection == (business_snapshot(project, runtime), + (runtime / 'goals' / GOAL_ID / 'runs/index.jsonl').read_bytes()) + vision = {key: prior[key] for key in ('schema_version', 'state', 'vision_patch', 'todo_delta') if key in prior} + vision['vision_patch'] = {**vision['vision_patch'], + 'acceptance_summary': 'Retain the accepted candidate; independently verify the newly delivered dependency.'} + vision['path_delta'] = {'outcome': 'replan', + 'prior_assumption': 'The original terminal route had no further dependency.', + 'observed_reality': 'A delivered dependency and an open validator-backed Todo now require verification.', + 'retained': ['The original accepted result and its one settlement.'], + 'changed': ['Verify the delivered dependency before another terminal decision.'], + 'evidence_refs': [f'todo:{new_todo}', 'file:new-dependency.txt']} + packet.write_text(json.dumps(vision), encoding='utf-8') + refreshed = json.loads(caller.run_cli([*refresh_args, + '--checkpoint-read-context', context['read_context_id'], + '--no-global-sync', '--suppress-external-sinks'])) + assert refreshed['ok'] is True, {key: refreshed.get(key) for key in ( + 'error', 'error_code', 'reason', 'vision_checkpoint', 'autonomous_replan_ack')} + assert refreshed['autonomous_replan_ack']['recorded'] is True + assert refreshed['autonomous_replan_ack']['semantic_delta']['obligation_id'] == obligation + spent = json.loads(caller.run_cli(list(spend))) + assert spent['appended'] is True, spent + assert json.loads(caller.run_cli(list(spend)))['appended'] is False + assert _business_rows(runtime)['quota_slot_spent'] == 2 + return json.loads(caller.should_run()) + +def test_negative_oracle_sentinels(tmp_path): + # The actual counter oracle must reject duplicated persisted work, not just a missing file. + candidate = tmp_path / 'candidate.txt' + candidate.write_text('valid', encoding='utf-8') + (tmp_path / 'implementation.txt').write_text('1\n1\n', encoding='utf-8') + (tmp_path / 'validation.txt').write_text('1\n', encoding='utf-8') + with pytest.raises(AssertionError): + counts = _work_counts(tmp_path) + assert counts['implementation.txt'] == counts['validation.txt'] == 1 + before = {'quota_slot_spent': 1, 'candidate_verified': 1} + broken = {**before, 'quota_slot_spent': 2} + with pytest.raises(AssertionError): + assert before == broken + original = {'todo_id': 'fixture-original', 'status': 'done', 'evidence': 'valid', + 'completed_at': '2026-01-01T00:00:00Z', 'completion_turn_key': 'original-native-identity', + 'completion_receipt_id': 'ordinary-phase'} + terminal = {**original, 'completion_receipt_id': 'terminal-phase'} + assert result_facts(original) == result_facts(terminal) + for key in ('evidence', 'completed_at', 'completion_turn_key'): + damaged = {**terminal, key: 'replaced'} + with pytest.raises(AssertionError): + assert result_facts(original) == result_facts(damaged) + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('scenario', SCENARIOS) +def test_default_terminal_frontier(provider, scenario, tmp_path, monkeypatch): + runtime = tmp_path / 'runtime' + if scenario in {'ordinary_successor', 'legal_no_followup', 'changed_during_direction'}: + registry, state, todo = _fixture(provider, tmp_path, monkeypatch) + control = _control(registry) + guard = json.loads(control.should_run()) + identity = guard['delivery_result_context']['settlement_identity'] + options = {'next_agent_todo': 'Verify the remaining required dependency.'} if scenario == 'ordinary_successor' else {'no_follow_up': True} + first = json.loads(control.complete_task(todo, AGENT_ID, 'Candidate verified.', **options)) + assert first.get('stage') == 'direction_pending' and first['settlement_identity'] == identity, first + original_result = next(r for r in rows(runtime, state) if r['todo_id'] == todo) + assert original_result['status'] == 'done' + context = first['context'] + if scenario == 'changed_during_direction': + new_todo = add_obligation(registry) + before = (_business_rows(runtime), original_result['completion_receipt_id']) + stale = json.loads(control.complete_task(todo, AGENT_ID, 'Candidate verified.', **options, + read_context_id=context['read_context_id'], vision_unchanged_reason='The old frontier is unchanged.')) + assert stale.get('ok') is False, stale + current_result = next(r for r in rows(runtime, state) if r['todo_id'] == todo) + assert before == (_business_rows(runtime), current_result['completion_receipt_id']) + context = json.loads(_control(registry).review_task_vision(todo, AGENT_ID)) + assert context['read_context_id'] != first['context']['read_context_id'] + assert new_todo in json.dumps(context['basis']), 'Current Agent must receive the real new obligation' + finished = json.loads(_control(registry).complete_task(todo, AGENT_ID, 'Candidate verified.', **options, + read_context_id=context['read_context_id'], + vision_unchanged_reason='The accepted candidate is preserved; verify all remaining current dependencies.')) + assert finished.get('ok') is True and finished.get('settlement_complete') is True, finished + assert finished['settlement_identity'] == identity + assert finished.get('goal_completion_certified') is not True + assert _business_rows(runtime)['quota_slot_spent'] == 1 + final = rows(runtime, state) + assert result_facts(next(r for r in final if r['todo_id'] == todo)) == result_facts(original_result) + if scenario == 'legal_no_followup': + assert len(final) == 1, 'Legal closeout must not manufacture a successor' + else: + assert any(r['status'] == 'open' for r in final), 'Real remaining work must stay actionable' + return + + fault = 'spend' if scenario == 'changed_before_terminal' else 'terminal' + state, project, args, injection = _managed_recovery_call(provider, fault, tmp_path, monkeypatch) + lost = subprocess.run([sys.executable, '-c', injection, *args], capture_output=True, text=True, timeout=150) + assert lost.returncode == 97, lost.stdout + lost.stderr + original = journal(runtime) + assert original['host_result']['result_kind'] == 'validated_completion' + registry = Path(args[args.index('--registry') + 1]) + original_rows = rows(runtime, state) + original_todo = next(r['todo_id'] for r in original_rows if r['status'] == 'done') + original_result_facts = result_facts(next(r for r in original_rows if r['todo_id'] == original_todo)) + if scenario != 'terminal_loss_replay': + (project / 'new-dependency.txt').write_text('delivered-v2', encoding='utf-8') + verifier = project / 'verify-new-dependency.py' + verifier.write_text("from pathlib import Path\n" + "assert Path('new-dependency.txt').read_text() == 'delivered-v2'\n" + "assert Path('new-proof.txt').read_text() == 'delivered-v2 verified'\n" + "with Path('new-validation.txt').open('a') as f: f.write('1\\n')\n", encoding='utf-8') + new_todo = add_obligation(registry, verifier) + else: + new_todo = None + before = business_snapshot(project, runtime) + finished = resume(args, original, retry=True) + assert_once(project, runtime) + if scenario == 'changed_before_terminal' and _work_counts(project)['direction.txt'] > before['counts']['direction.txt']: + assert _work_counts(project)['direction.txt'] == before['counts']['direction.txt'] + 1 + inputs = [json.loads(line) for line in (project / 'direction-inputs.jsonl').read_text(encoding='utf-8').splitlines()] + assert new_todo in json.dumps(inputs[-1]['basis']) + else: + assert business_snapshot(project, runtime) == before, 'Historical terminal recovery must not repeat settled effects' + accepted = next(r for r in rows(runtime, state) if r['todo_id'] == original_todo) + assert result_facts(accepted) == original_result_facts + after = business_snapshot(project, runtime) + resume(args, original) + assert business_snapshot(project, runtime) == after + if new_todo: + assert next(r for r in rows(runtime, state) if r['todo_id'] == new_todo)['status'] == 'open' + if new_todo: + from loopx.configure_goal import configure_goal + # Each result owes a current direction judgment. A semantic replan is + # separate and binds only the exact duty selected by its Turn guard. + assert configure_goal(registry_path=registry, goal_id=GOAL_ID, + execution_replan_after_todos=1, execute=True)['written'] + caller = _control(registry) + guard = json.loads(caller.should_run()) + assert guard['should_run'] is True and guard['selected_todo']['todo_id'] == new_todo, guard + if guard['interaction_contract']['agent_channel'].get('selection_required') is True: + # A displayed candidate is not admission. Execute the owner-returned + # same-Turn selection contract, never invent another work identity. + assert guard['interaction_contract']['cli_channel']['selection_command']['schema_version'] == 'action_selection_cli_command_v1' + selection_turn = guard['heartbeat_receipt']['turn_instance_id'] + current_args, legacy_args = caller.should_run_args(GOAL_ID, AGENT_ID) + suffix = ['--todo-id', new_todo, '--turn-instance-id', selection_turn] + guard = json.loads(caller.run_cli([*current_args, *suffix], legacy_args=[*legacy_args, *suffix])) + assert guard['heartbeat_receipt']['turn_instance_id'] == selection_turn + if guard['effective_action'] == 'autonomous_replan_required': + settled_obligation = guard['replan_action_packet']['obligation_id'] + guard = settle_frontier_replan(caller, guard, registry, project, runtime, new_todo) + assert guard['heartbeat_receipt'].get('semantic_replan_obligation_id') != settled_obligation + # A paid replan is distinct from both the old result and new work. + after = business_snapshot(project, runtime) + counts = _work_counts(project) + assert counts['implementation.txt'] == counts['validation.txt'] == 1 + if guard['interaction_contract']['agent_channel'].get('selection_required') is True: + current_args, legacy_args = caller.should_run_args(GOAL_ID, AGENT_ID) + suffix = ['--todo-id', new_todo, '--turn-instance-id', guard['heartbeat_receipt']['turn_instance_id']] + guard = json.loads(caller.run_cli([*current_args, *suffix], legacy_args=[*legacy_args, *suffix])) + assert guard['interaction_contract']['agent_channel']['delivery_allowed'] is True, guard + assert guard['selected_todo']['todo_id'] == new_todo + assert business_snapshot(project, runtime) == after, 'New work admission must not resettle old work' + assert (project / 'new-dependency.txt').read_text() == 'delivered-v2' + assert not (project / 'new-proof.txt').exists() + (project / 'new-proof.txt').write_text('delivered-v2 verified', encoding='utf-8') + first_new = json.loads(_control(registry).complete_task(new_todo, AGENT_ID, + 'The new delivered dependency was actually read and independently validated.', no_follow_up=True)) + assert first_new.get('ok') is True, first_new + new_identity = first_new['settlement_identity'] + assert new_identity['todo_id'] == new_todo and new_identity['turn_instance_id'] != original['turn_key'] + if first_new.get('stage') == 'direction_pending': + current = first_new['context'] + assert new_todo in json.dumps(current['basis']) + assert not guard['heartbeat_receipt'].get('semantic_replan_obligation_id'), guard + assert (project / 'new-validation.txt').read_text().splitlines() == ['1'] + # The prior duty was settled with its own typed path evidence. This + # later result retains that verified route after a fresh Goal read; + # it must not inherit the old duty or repeat independent validation. + new_finished = json.loads(_control(registry).complete_task(new_todo, AGENT_ID, + 'The new delivered dependency was actually read and independently validated.', no_follow_up=True, + read_context_id=current['read_context_id'], + vision_unchanged_reason='The latest new dependency is verified; retain prior accepted results and only close this work.')) + else: + new_finished = first_new + assert new_finished.get('ok') is True and new_finished.get('settlement_complete') is True, new_finished + assert new_finished['settlement_identity'] == new_identity + assert (project / 'new-validation.txt').read_text().splitlines() == ['1'] + assert _work_counts(project) == after['counts'] and (project / 'candidate.txt').read_bytes() == after['candidate'] + effects = _business_rows(runtime) + assert effects['candidate_verified'] == 1 and effects['quota_slot_spent'] == after['effects']['quota_slot_spent'] + 1 + final_rows = rows(runtime, state) + assert result_facts(next(r for r in final_rows if r['todo_id'] == original_todo)) == original_result_facts + assert next(r for r in final_rows if r['todo_id'] == new_todo)['status'] == 'done' + assert new_finished.get('goal_completion_certified') is not True diff --git a/tests/control_plane/test_default_turn_recovery.py b/tests/control_plane/test_default_turn_recovery.py new file mode 100644 index 0000000000..1a093be461 --- /dev/null +++ b/tests/control_plane/test_default_turn_recovery.py @@ -0,0 +1,350 @@ +"""Default managed original-Turn recovery through real isolated authority stores.""" +from __future__ import annotations + +from collections import Counter +import json +import re +import subprocess +import sys + +import pytest + +from loopx.todos import update_goal_todo +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted +from loopx.control_plane.todos.projection_document import recovered_todo_projection_skeleton +from loopx.control_plane.goals.checkpoint_context_io import ( + CheckpointReadContextRejected, _checkpoint_effect, +) +from loopx.control_plane.quota.settlement import SettlementIdentity +from tests.control_plane.test_default_checkpoint_continuation import _fixture +from tests.test_goal_mode_mcp_settlement import GOAL_ID, AGENT_ID + + +def _managed_recovery_call(provider, fault, tmp_path, monkeypatch): + registry, state, todo = _fixture(provider, tmp_path, monkeypatch) + update_goal_todo(registry_path=registry, goal_id=GOAL_ID, todo_id=todo, role='agent', + agent_id=AGENT_ID, no_followup=True) + project = state.parent + host = project / 'host.py' + host.write_text('''import json,pathlib,sys +r=json.load(sys.stdin) +def count(name): + with pathlib.Path(name).open('a') as f: f.write('1\\n') +if r.get('phase') == 'checkpoint_direction': + c=r['checkpoint_context']; assert c['basis']['todo']['status']=='done' + count('direction.txt') + with pathlib.Path('direction-inputs.jsonl').open('a') as f: f.write(json.dumps(c)+'\\n') + if STALE and len(pathlib.Path('direction.txt').read_text().splitlines())==1: + p=pathlib.Path(STATE); p.write_text(p.read_text()+'\\n## Acceptance\\nVerify the revised obligation.\\n') + if STALE and pathlib.Path('direction.txt').read_text().count('1')==2: + assert 'revised obligation' in json.dumps(c['basis']) + adopted='revised obligation' in json.dumps(c['basis']).lower() + json.dump({'read_context_id':c['read_context_id'],'vision_unchanged_reason':'Current obligations preserve the route; revised='+str(adopted)},sys.stdout) +else: + assert not pathlib.Path('candidate.txt').exists() + pathlib.Path('candidate.txt').write_text('valid'); count('implementation.txt') + json.dump({'schema_version':'loopx_turn_result_v0','turn_key':r['turn_key'], + 'result_kind':'validated_completion','completed_phases':['host_execute','typed_result'], + 'classification':'candidate_verified','recommended_action':'Read remaining work.', + 'next_action':'Read remaining work.','delivery_batch_scale':'implementation', + 'delivery_outcome':'outcome_progress','summary':'Candidate verified.', + 'vision_unchanged_reason':'Old pre-result judgment.'},sys.stdout) +'''.replace('STALE', repr(fault == 'stale')).replace('STATE', repr(str(state))), encoding='utf-8') + validator = project / 'verify.py' + validator.write_text("import pathlib; assert pathlib.Path('candidate.txt').read_text()=='valid'\n" + "with pathlib.Path('validation.txt').open('a') as f: f.write('1\\n')", encoding='utf-8') + args = ['--registry', str(registry), '--format', 'json', 'turn', 'run-once', '--host', 'generic-cli', + '--goal-id', GOAL_ID, '--agent-id', AGENT_ID, '--project', str(project), + '--host-adapter-command-json', json.dumps([sys.executable, str(host)]), + '--validation-command-json', json.dumps([sys.executable, str(validator)]), + '--no-global-sync', '--execute'] + injection = '''import os,sys +from loopx.cli import main +from loopx.cli_commands import turn_run_once as tr +from loopx.control_plane.turn_driver import checkpoint_continuation as cp +FAULT=SELECTED +if FAULT in {'result','terminal'}: + original=tr.write_turn_validated_completion + def lose(*a,**kw): + value=original(*a,**kw) + if value.get('ok') and bool(kw.get('no_followup'))==(FAULT=='terminal'): os._exit(97) + return value + tr.write_turn_validated_completion=lose +elif FAULT=='read': + original=cp.read_checkpoint_context + def lose(*a,**kw): + original(*a,**kw); os._exit(97) + cp.read_checkpoint_context=lose +elif FAULT=='decision': + original=cp.CurrentAgentCheckpoint._save + def lose(self,path,receipt): + original(self,path,receipt) + if receipt.get('current_agent_decision'): os._exit(97) + cp.CurrentAgentCheckpoint._save=lose +elif FAULT=='dispatch': + original=cp.CurrentAgentCheckpoint._save + def lose(self,path,receipt): + original(self,path,receipt) + if receipt.get('direction_host_attempt_count') and not receipt.get('current_agent_decision'): os._exit(97) + cp.CurrentAgentCheckpoint._save=lose +elif FAULT in {'response','bound_response'}: + original=cp._checkpoint_effect + def lose(method,params): + response=params.get('phase')=='direction_return' + if response and FAULT=='response': os._exit(97) + value=original(method,params) + if response and value.get('ok') and FAULT=='bound_response': os._exit(97) + return value + cp._checkpoint_effect=lose +elif FAULT.startswith('append_'): + from pathlib import Path + from loopx.control_plane.goals import checkpoint_context_io as context_io + from tests.control_plane.checkpoint_process import start_probe + original=context_io.effect_runtime_result + def lose(method,params,**kw): + if method!='goal.checkpoint_read_context.commit': return original(method,params,**kw) + barrier=Path(PROJECT)/'append-fault'; barrier.mkdir(); (barrier/'release').touch() + child=start_probe({'mode':'checkpoint','provider':PROVIDER,'barrier':str(barrier.resolve()), + 'params':params,'fault':FAULT.removeprefix('append_')}) + out,err=child.communicate(timeout=25) + assert child.returncode==86, out+err + os._exit(97) + context_io.effect_runtime_result=lose +elif FAULT=='checkpoint': + original=tr.refresh_state_run + def lose(*a,**kw): + value=original(*a,**kw) + if value.get('ok') and value.get('vision_checkpoint',{}).get('satisfied'): os._exit(97) + return value + tr.refresh_state_run=lose +elif FAULT=='spend': + original=tr.spend_quota_slot + def lose(*a,**kw): + value=original(*a,**kw) + if value.get('ok'): os._exit(97) + return value + tr.spend_quota_slot=lose +raise SystemExit(main(sys.argv[1:])) +'''.replace('SELECTED', repr(fault)).replace('PROVIDER', repr(provider)).replace('PROJECT', repr(str(project))) + return state, project, args, injection + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('fault', ['result', 'read', 'decision', 'checkpoint', 'spend', 'terminal', 'stale']) +def test_default_managed_cold_recovery_resumes_only_unfinished_checkpoint(provider, fault, tmp_path, monkeypatch): + state, project, args, injection = _managed_recovery_call(provider, fault, tmp_path, monkeypatch) + lost = subprocess.run([sys.executable, '-c', injection, *args], capture_output=True, text=True) + assert lost.returncode == (1 if fault == 'stale' else 97), lost.stdout + lost.stderr + journal_path = next(path for path in (tmp_path / 'runtime/goals' / GOAL_ID / 'turns').glob('*.json') + if not path.name.endswith('.holder.json')) + journal = json.loads(journal_path.read_text()) + assert journal['host_result']['result_kind'] == 'validated_completion' + before_rows = [json.loads(line) for line in (tmp_path / 'runtime/goals' / GOAL_ID / 'runs/index.jsonl').read_text().splitlines()] + if fault in {'result','read','decision','stale'}: + assert not any(row.get('classification') == 'quota_slot_spent' for row in before_rows) + recovery = subprocess.run([sys.executable, '-m', 'loopx.cli', *args, '--resume-turn-key', journal['turn_key'], + '--retry-failed-turn'], capture_output=True, text=True) + assert recovery.returncode == 0, recovery.stdout + recovery.stderr + result = json.loads(recovery.stdout) + assert result['status'] == 'committed' and result['resume_turn_key'] == journal['turn_key'], result + assert len((project / 'implementation.txt').read_text().splitlines()) == 1 + assert len((project / 'validation.txt').read_text().splitlines()) == 1 + assert len((project / 'direction.txt').read_text().splitlines()) == (2 if fault == 'stale' else 1) + rows = [json.loads(line) for line in (tmp_path / 'runtime/goals' / GOAL_ID / 'runs/index.jsonl').read_text().splitlines()] + assert sum(row.get('classification') == 'quota_slot_spent' for row in rows) == 1 + assert sum(row.get('classification') == 'candidate_verified' for row in rows) == 1 + assert 'no_followup=true' in state.read_text() + replay = subprocess.run([sys.executable, '-m', 'loopx.cli', *args, '--resume-turn-key', journal['turn_key']], + capture_output=True, text=True) + assert replay.returncode == 0, replay.stdout + replay.stderr + assert len((project / 'direction.txt').read_text().splitlines()) == (2 if fault == 'stale' else 1) + + +def _business_rows(runtime): + rows = [json.loads(line) for line in + (runtime / 'goals' / GOAL_ID / 'runs/index.jsonl').read_text().splitlines()] + return Counter(row['classification'] for row in rows + if row.get('classification') in {'candidate_verified', 'quota_slot_spent'}) + + +def _work_counts(project): + return {name: len((project / name).read_text().splitlines()) + if (project / name).exists() else 0 + for name in ('implementation.txt', 'validation.txt', 'direction.txt')} + + +def _provider_revision(runtime): + snapshot = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID, include_leases=True) + return snapshot['provider_revision'] if snapshot is not None else None + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('fault', [ + 'result', 'read', 'dispatch', 'response', 'bound_response', + 'decision', 'checkpoint', 'spend', 'terminal', +]) +@pytest.mark.parametrize('source_loss', ['missing', 'todo_only']) +def test_original_turn_restores_real_goal_after_response_and_source_loss( + provider, fault, source_loss, tmp_path, monkeypatch): + state, project, args, injection = _managed_recovery_call(provider, fault, tmp_path, monkeypatch) + lost = subprocess.run([sys.executable, '-c', injection, *args], capture_output=True, text=True) + assert lost.returncode == 97, lost.stdout + lost.stderr + runtime = tmp_path / 'runtime' + journal_path = next(path for path in (runtime / 'goals' / GOAL_ID / 'turns').glob('*.json') + if not path.name.endswith('.holder.json')) + journal = json.loads(journal_path.read_text()) + assert journal['host_result']['result_kind'] == 'validated_completion' + original_turn = journal['turn_key'] + actual_source = state.read_bytes() + before_effects, before_work = _business_rows(runtime), _work_counts(project) + before_revision = _provider_revision(runtime) + if source_loss == 'missing': + state.unlink() + else: + state.write_text(recovered_todo_projection_skeleton(GOAL_ID), encoding='utf-8') + + resume_args = [*args, '--resume-turn-key', original_turn, '--retry-failed-turn'] + incomplete = subprocess.run([sys.executable, '-m', 'loopx.cli', *resume_args], + capture_output=True, text=True) + assert incomplete.returncode in {0, 1}, incomplete.stdout + incomplete.stderr + if incomplete.returncode == 0: + observed = json.loads(incomplete.stdout) + # Historical terminal replay may finish its journal. Incomplete intent + # never authorizes completion of a previously unfinished phase. + assert fault == 'terminal' and observed['status'] == 'committed', observed + assert observed['resume_turn_key'] == original_turn + after_effects = _business_rows(runtime) + assert after_effects['candidate_verified'] == before_effects['candidate_verified'] + # The successful indexed checkpoint authorizes its original owed spend, + # not a new judgment or terminal write against incomplete Goal intent. + allowed_spends = {before_effects['quota_slot_spent']} + if fault == 'checkpoint': + assert before_effects['candidate_verified'] == 1 + allowed_spends.add(1) + assert after_effects['quota_slot_spent'] in allowed_spends + assert _provider_revision(runtime) == before_revision + assert _work_counts(project) == before_work + assert (project / 'candidate.txt').read_text() == 'valid' + + # Restore the actual source captured at loss, including its committed Todo + # projection. Never fabricate Goal intent or restore an earlier lifecycle. + state.write_bytes(actual_source) + restored = subprocess.run([sys.executable, '-m', 'loopx.cli', *resume_args], + capture_output=True, text=True) + assert restored.returncode == 0, restored.stdout + restored.stderr + observed = json.loads(restored.stdout) + assert observed['status'] == 'committed' and observed['resume_turn_key'] == original_turn + # A response lost before durable decision persistence requires one new + # judgment. It must never repeat implementation, validation or settlement. + direction_calls = 2 if fault in {'response', 'bound_response'} else 1 + assert _work_counts(project) == { + 'implementation.txt': 1, 'validation.txt': 1, 'direction.txt': direction_calls} + assert _business_rows(runtime) == {'candidate_verified': 1, 'quota_slot_spent': 1} + assert 'no_followup=true' in state.read_text() + + replay = subprocess.run([sys.executable, '-m', 'loopx.cli', *resume_args], + capture_output=True, text=True) + assert replay.returncode == 0, replay.stdout + replay.stderr + assert _business_rows(runtime) == {'candidate_verified': 1, 'quota_slot_spent': 1} + assert _work_counts(project) == { + 'implementation.txt': 1, 'validation.txt': 1, 'direction.txt': direction_calls} + + +def _original_checkpoint_receipt(runtime): + receipts = [json.loads(path.read_text()) for path in + (runtime / 'goals' / GOAL_ID / 'checkpoint-contexts').glob('*.json')] + return next(receipt for receipt in receipts if receipt.get('purpose') == 'first_delivery') + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize(('fault', 'change'), [ + ('dispatch', 'none'), ('dispatch', 'related'), + ('response', 'none'), ('response', 'related'), + ('bound_response', 'none'), ('bound_response', 'related'), ('bound_response', 'display'), +]) +def test_unpersisted_direction_response_rejudges_without_repeating_work( + provider, fault, change, tmp_path, monkeypatch): + state, project, args, injection = _managed_recovery_call(provider, fault, tmp_path, monkeypatch) + lost = subprocess.run([sys.executable, '-c', injection, *args], capture_output=True, text=True) + assert lost.returncode == 97, lost.stdout + lost.stderr + runtime = tmp_path / 'runtime' + journal_path = next(path for path in (runtime / 'goals' / GOAL_ID / 'turns').glob('*.json') + if not path.name.endswith('.holder.json')) + journal = json.loads(journal_path.read_text()) + receipt = _original_checkpoint_receipt(runtime) + old_key = receipt['read_context_id'] + assert 'current_agent_decision' not in receipt + assert journal['host_result']['result_kind'] == 'validated_completion' + before_calls = 0 if fault == 'dispatch' else 1 + assert _work_counts(project) == {'implementation.txt': 1, 'validation.txt': 1, + 'direction.txt': before_calls} + assert _business_rows(runtime) == Counter() + if change == 'related': + state.write_text(state.read_text() + '\n## Acceptance\nVerify the revised obligation.\n', encoding='utf-8') + elif change == 'display': + before = state.read_text() + changed = re.sub(r'^updated_at:.*$', 'updated_at: 2026-10-10', before, flags=re.MULTILINE) + if changed == before: + changed = before.replace('---\n', '---\nupdated_at: 2026-10-10\n', 1) + assert changed != before, 'Display-only update must actually occur' + state.write_text(changed, encoding='utf-8') + resume_args = [*args, '--resume-turn-key', journal['turn_key'], '--retry-failed-turn'] + recovered = subprocess.run([sys.executable, '-m', 'loopx.cli', *resume_args], capture_output=True, text=True) + assert recovered.returncode == 0, recovered.stdout + recovered.stderr + result = json.loads(recovered.stdout) + assert result['status'] == 'committed' and result['resume_turn_key'] == journal['turn_key'] + inputs = [json.loads(line) for line in (project / 'direction-inputs.jsonl').read_text().splitlines()] + assert len(inputs) == before_calls + 1 + assert ('revised obligation' in json.dumps(inputs[-1]['basis']).lower()) == (change == 'related') + assert (inputs[-1]['read_context_id'] != old_key) == (change == 'related') + final_receipt = _original_checkpoint_receipt(runtime) + assert final_receipt['current_agent_binding']['read_context_id'] == inputs[-1]['read_context_id'] + assert final_receipt['current_agent_decision']['vision_unchanged_reason'].endswith(str(change == 'related')) + after_work, after_effects = _work_counts(project), _business_rows(runtime) + assert after_work == {'implementation.txt': 1, 'validation.txt': 1, 'direction.txt': before_calls + 1} + assert after_effects == Counter({'candidate_verified': 1, 'quota_slot_spent': 1}) + replay = subprocess.run([sys.executable, '-m', 'loopx.cli', *resume_args], capture_output=True, text=True) + assert replay.returncode == 0, replay.stdout + replay.stderr + assert _work_counts(project) == after_work and _business_rows(runtime) == after_effects + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('fault', ['before_artifacts', 'after_json', 'after_markdown', 'after_index']) +def test_default_managed_native_append_recovery_never_treats_unknown_as_absent( + provider, fault, tmp_path, monkeypatch): + _, project, args, injection = _managed_recovery_call(provider, 'append_' + fault, tmp_path, monkeypatch) + lost = subprocess.run([sys.executable, '-c', injection, *args], capture_output=True, text=True) + assert lost.returncode == 97, lost.stdout + lost.stderr + runtime = tmp_path / 'runtime' + journal_path = next(path for path in (runtime / 'goals' / GOAL_ID / 'turns').glob('*.json') + if not path.name.endswith('.holder.json')) + original_turn = json.loads(journal_path.read_text())['turn_key'] + identity = SettlementIdentity.from_runtime_payload(_original_checkpoint_receipt(runtime)['identity']) + original_work = _work_counts(project) + assert original_work == {'implementation.txt': 1, 'validation.txt': 1, 'direction.txt': 1} + resume_args = [*args, '--resume-turn-key', original_turn, '--retry-failed-turn'] + recovered = subprocess.run([sys.executable, '-m', 'loopx.cli', *resume_args], capture_output=True, text=True) + if fault in {'after_json', 'after_markdown'}: + assert recovered.returncode != 0, recovered.stdout + recovered.stderr + for candidate in (identity, SettlementIdentity(goal_id=GOAL_ID, agent_id=AGENT_ID, + todo_id=identity.todo_id, turn_instance_id='foreign-turn')): + with pytest.raises(CheckpointReadContextRejected) as error: + _checkpoint_effect('goal.checkpoint_read_context.inspect_attempt', { + 'runtime_root': str(runtime.resolve()), 'identity': candidate.as_dict(), + 'check_other_attempts': True, + }) + assert error.value.code == 'checkpoint_commit_unknown' + assert _business_rows(runtime) == Counter() + again = subprocess.run([sys.executable, '-m', 'loopx.cli', *resume_args], capture_output=True, text=True) + assert again.returncode != 0 + assert _work_counts(project) == original_work and _business_rows(runtime) == Counter() + else: + assert recovered.returncode == 0, recovered.stdout + recovered.stderr + result = json.loads(recovered.stdout) + assert result['status'] == 'committed' and result['resume_turn_key'] == original_turn + assert _work_counts(project) == original_work + after_effects = _business_rows(runtime) + assert after_effects == Counter({'candidate_verified': 1, 'quota_slot_spent': 1}) + replay = subprocess.run([sys.executable, '-m', 'loopx.cli', *resume_args], capture_output=True, text=True) + assert replay.returncode == 0, replay.stdout + replay.stderr + assert _work_counts(project) == original_work and _business_rows(runtime) == after_effects diff --git a/tests/control_plane/test_direction_stop_reentry.py b/tests/control_plane/test_direction_stop_reentry.py new file mode 100644 index 0000000000..74f774985b --- /dev/null +++ b/tests/control_plane/test_direction_stop_reentry.py @@ -0,0 +1,156 @@ +"""Explicit recovery of a retained result must finish only its direction.""" +from __future__ import annotations + +import json +import copy +from contextlib import nullcontext +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from loopx.control_plane.goals.checkpoint_context_io import read_checkpoint_context, _receipt_path +from loopx.control_plane.quota.settlement import SettlementIdentity +from loopx.configure_goal import configure_goal +from tests.control_plane.test_default_turn_recovery import ( + _managed_recovery_call, _work_counts, _business_rows, +) +from tests.test_goal_mode_mcp_settlement import GOAL_ID + + +def _stopped(provider, kind, tmp_path, monkeypatch, *, stop_reason=None): + state, project, args, _ = _managed_recovery_call(provider, 'none', tmp_path, monkeypatch) + host = project / 'host.py' + stop = {'result_kind': kind, **({'stop_reason': stop_reason} if stop_reason else {})} + host.write_text(host.read_text().replace(" count('direction.txt')", " count('direction.txt')\n" + " if not pathlib.Path('resume-ready').exists():\n" + f" json.dump({stop!r},sys.stdout); raise SystemExit(0)"), encoding='utf-8') + command = [sys.executable, '-m', 'loopx.cli', *args] + stopped = subprocess.run(command, capture_output=True, text=True, timeout=90) + assert stopped.returncode == 1, stopped.stdout + stopped.stderr + runtime = tmp_path / 'runtime' + journal_path = next(p for p in (runtime / 'goals' / GOAL_ID / 'turns').glob('*.json') + if not p.name.endswith('.holder.json')) + journal = json.loads(journal_path.read_text()) + return state, project, args, command, runtime, journal_path, journal + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('kind,reread', [ + ('wait', False), ('user_action_required', False), ('iteration_failed', False), ('wait', True), +]) +def test_explicit_direction_reentry_retains_original_result(provider, kind, reread, tmp_path, monkeypatch): + _, project, args, command, runtime, journal_path, journal = _stopped(provider, kind, tmp_path, monkeypatch) + original_result = journal['host_result'] + resume = [*command, '--resume-turn-key', journal['turn_key']] + assert _work_counts(project) == {'implementation.txt': 1, 'validation.txt': 1, 'direction.txt': 1} + assert _business_rows(runtime)['quota_slot_spent'] == 0 + (project / 'resume-ready').touch() + implicit = subprocess.run(resume, capture_output=True, text=True, timeout=90) + assert implicit.returncode == 1, implicit.stdout + implicit.stderr + assert _work_counts(project)['direction.txt'] == 1 + raw_identity = journal['plan']['transaction']['settlement_plan']['identity'] + identity = SettlementIdentity(**{key: raw_identity.get(key) for key in ( + 'goal_id', 'agent_id', 'todo_id', 'turn_instance_id', 'replan_obligation_id')}) + if reread: + context = read_checkpoint_context(registry_path=Path(args[args.index('--registry') + 1]), + runtime_root_override=str(runtime), goal_id=identity.goal_id, agent_id=identity.agent_id, + todo_id=identity.todo_id, replan_obligation_id=identity.replan_obligation_id, + turn_instance_id=identity.turn_instance_id, purpose='first_delivery') + assert context['read_context_id'] + assert json.loads(_receipt_path(runtime, identity).read_text())['direction_stopped'] is True + assert _work_counts(project)['direction.txt'] == 1 + recovered = subprocess.run([*resume, '--retry-failed-turn'], capture_output=True, text=True, timeout=90) + assert recovered.returncode == 0, recovered.stdout + recovered.stderr + assert json.loads(recovered.stdout)['status'] == 'committed' + assert _work_counts(project) == {'implementation.txt': 1, 'validation.txt': 1, 'direction.txt': 2} + assert _business_rows(runtime) == {'candidate_verified': 1, 'quota_slot_spent': 1} + assert json.loads(journal_path.read_text())['host_result'] == original_result + replay = subprocess.run(resume, capture_output=True, text=True, timeout=90) + assert replay.returncode == 0, replay.stdout + replay.stderr + assert _work_counts(project)['direction.txt'] == 2 + assert _business_rows(runtime) == {'candidate_verified': 1, 'quota_slot_spent': 1} + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('gate', ['pause', 'stop', 'permission']) +def test_stopped_direction_rechecks_current_gate_before_reentry(provider, gate, tmp_path, monkeypatch): + _, project, args, command, runtime, journal_path, journal = _stopped(provider, 'wait', tmp_path, monkeypatch, + stop_reason='permission_required' if gate == 'permission' else None) + registry = Path(args[args.index('--registry') + 1]) + before = registry.read_bytes() + if gate == 'pause': + assert configure_goal(registry_path=registry, goal_id=GOAL_ID, quota_compute=0, execute=True)['written'] + elif gate == 'stop': + doc = json.loads(before) + doc['goals'][0]['activation'] = {'schema_version': 'loopx_goal_activation_v1', 'state': 'stopped', + 'updated_at': '2026-10-11T00:00:00Z', 'reason': 'Fixture owner stopped execution.'} + registry.write_text(json.dumps(doc), encoding='utf-8') + (project / 'resume-ready').touch() + retry = [*command, '--resume-turn-key', journal['turn_key'], '--retry-failed-turn'] + denied = subprocess.run(retry, capture_output=True, text=True, timeout=90) + assert denied.returncode == 1, denied.stdout + denied.stderr + assert _work_counts(project) == {'implementation.txt': 1, 'validation.txt': 1, 'direction.txt': 1} + assert _business_rows(runtime)['quota_slot_spent'] == 0 + assert json.loads(journal_path.read_text())['host_result'] == journal['host_result'] + if gate != 'permission': + if gate == 'pause': + assert configure_goal(registry_path=registry, goal_id=GOAL_ID, quota_compute=1, execute=True)['written'] + else: + registry.write_bytes(before) + recovered = subprocess.run(retry, capture_output=True, text=True, timeout=90) + assert recovered.returncode == 0, recovered.stdout + recovered.stderr + assert _work_counts(project) == {'implementation.txt': 1, 'validation.txt': 1, 'direction.txt': 2} + assert _business_rows(runtime) == {'candidate_verified': 1, 'quota_slot_spent': 1} + + +@pytest.mark.parametrize('kind', ['wait', 'user_action_required', 'iteration_failed']) +def test_valid_stop_supersedes_transport_failure_without_resetting_attempt_budget(kind, tmp_path, monkeypatch): + from loopx.control_plane.turn_driver import checkpoint_continuation as cp + from loopx.control_plane.turn_driver.host_failure import BuiltInHostError + + identity = SimpleNamespace(goal_id='reentry-goal', as_dict=lambda: {}) + receipt = {'purpose': 'first_delivery', 'read_context_id': 'original-read', + 'decision_basis': {}, 'vision_authoring': {}} + for name, value in { + '_receipt_path': lambda *_: SimpleNamespace(exists=lambda: True), + '_read_context_receipt': lambda *_: copy.deepcopy(receipt), + '_inspect_attempt': lambda *_: {'status': 'not_started'}, + '_resolve': lambda *a, **kw: {'ok': True}, + '_local_source_facts': lambda *_: {}, + '_source_guard': lambda *_: nullcontext(), + 'exclusive_file_lock': lambda *_: nullcontext(), + 'exclusive_cross_runtime_file_lock': lambda *a, **kw: nullcontext(), + }.items(): + monkeypatch.setattr(cp, name, value) + monkeypatch.setattr(cp.CurrentAgentCheckpoint, 'enabled', lambda _: True) + + def save(self, path, value): + receipt.clear() + receipt.update(copy.deepcopy(value)) + monkeypatch.setattr(cp.CurrentAgentCheckpoint, '_save', save) + calls = [] + def host(context): + calls.append(context['read_context_id']) + if len(calls) == 2: + return {'result_kind': kind} + raise BuiltInHostError('Synthetic capacity refusal', failure_kind='provider_capacity') + + driver = cp.CurrentAgentCheckpoint(tmp_path/'registry', tmp_path, tmp_path/'state', identity, host) + with pytest.raises(ValueError, match='capacity refusal'): + driver.continue_direction({}) + with pytest.raises(ValueError, match='stopped direction'): + driver.continue_direction({}) + assert receipt['direction_host_attempt_count'] == 2 + assert 'direction_host_failure' not in receipt + assert receipt['direction_stops'] == [{'attempt': 2, 'result_kind': kind}] + driver.explicit_resume = True + with pytest.raises(ValueError, match='capacity refusal'): + driver.continue_direction({}) + assert receipt['direction_host_attempt_count'] == receipt['direction_host_failure']['attempt'] == 3 + with pytest.raises(cp.CheckpointReadContextRejected) as rejected: + driver.continue_direction({}) + assert rejected.value.code == 'host_retry_budget_exhausted' + assert calls == ['original-read'] * 3 diff --git a/tests/control_plane/test_document_io.py b/tests/control_plane/test_document_io.py index 8a4f8e590d..f809534d66 100644 --- a/tests/control_plane/test_document_io.py +++ b/tests/control_plane/test_document_io.py @@ -63,6 +63,33 @@ def test_durable_readback_never_repairs_mismatched_bytes(tmp_path): assert path.read_bytes() == b"original\r\n" +@pytest.mark.skipif(os.name != "nt", reason="Windows ordinary MAX_PATH boundary") +@pytest.mark.parametrize("private", [False, True]) +def test_ordinary_windows_long_path_publish_preserves_bytes_and_create_only(tmp_path, private): + # Include the real .json extension and eight random mkstemp characters. + name = "a" * 64 + ".json" + temporary_name = "." + name + ".12345678.tmp" + padding = 293 - len(str(tmp_path / temporary_name)) - 1 + assert 1 <= padding <= 200 + path = tmp_path / ("g" * padding) / name + assert len(str(path.parent / temporary_name)) == 293 + assert not str(path).startswith(chr(92) * 2 + "?" + chr(92)) + # Inspect the same physical file through Win32's supported long address; + # the writer and durability reader still receive the ordinary caller path. + address = Path(chr(92) * 2 + "?" + chr(92) + str(path)) + original = "私有\r\nfirst\n" + io.atomic_write_state_text(path, original, create_only=True, private=private) + assert address.read_bytes() == original.encode("utf-8") + with pytest.raises(FileExistsError): + io.atomic_write_state_text(path, "loser", create_only=True, private=private) + assert address.read_bytes() == original.encode("utf-8") + updated = original + "tail\r\n" + io.atomic_write_state_text(path, updated, private=private) + io.verify_state_text_durable(path, updated) + assert address.read_bytes() == updated.encode("utf-8") + assert list(address.parent.iterdir()) == [address] + + @pytest.mark.skipif(os.name != "posix", reason="POSIX directory fsync boundary") def test_directory_sync_closes_descriptor_on_failure(tmp_path, monkeypatch): closed = [] diff --git a/tests/control_plane/test_effect_runtime_host_permission.py b/tests/control_plane/test_effect_runtime_host_permission.py index 8dc642122b..ee42be5615 100644 --- a/tests/control_plane/test_effect_runtime_host_permission.py +++ b/tests/control_plane/test_effect_runtime_host_permission.py @@ -301,6 +301,23 @@ def test_real_locator_missing_or_invalid_still_allows_discovery( assert locator.read_text() == contents +def test_directory_locator_is_unusable_even_when_open_reports_permission_denied( + tmp_path: Path, monkeypatch, +) -> None: + locator = tmp_path / "locator.json" + locator.mkdir() + original_read = Path.read_text + + def read(path, *args, **kwargs): + if path == locator: + raise PermissionError(errno.EACCES, "directory cannot be opened as a file") + return original_read(path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", read) + assert effect_runtime._read_info(locator, fingerprint="fixture") is None + assert locator.is_dir(), "discovery must not remove a foreign locator" + + def test_real_locator_live_identity_is_unchanged(tmp_path: Path) -> None: locator = tmp_path / "locator.json" info = {"schema_version": effect_runtime.EFFECT_RUNTIME_INFO_SCHEMA_VERSION, diff --git a/tests/control_plane/test_first_delivery_faults.py b/tests/control_plane/test_first_delivery_faults.py new file mode 100644 index 0000000000..6f936b498f --- /dev/null +++ b/tests/control_plane/test_first_delivery_faults.py @@ -0,0 +1,66 @@ +"""Kill real native appends and inspect original identities on both providers.""" +from __future__ import annotations + +import json + +import pytest + +from loopx.control_plane.goals import checkpoint_context_io as context_io +from loopx.control_plane.quota.settlement import SettlementIdentity +from tests.control_plane.checkpoint_process import refresh, start_probe +from tests.control_plane.test_checkpoint_provider_fence import fixture +from tests.control_plane.test_quota_settlement_cli import GOAL_ID, AGENT_ID, TODO_ID, _spend_run_count + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("fault", ["before_artifacts", "after_json", "after_markdown", "after_index"]) +def test_original_direction_readback_distinguishes_torn_from_committed(tmp_path, monkeypatch, provider, fault): + _, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider, first_delivery=True) + token = read()["read_context_id"] + barrier = tmp_path / "fault" + barrier.mkdir() + (barrier / "release").touch() + original = context_io.effect_runtime_result + + def native(method, params, **kwargs): + if method != "goal.checkpoint_read_context.commit": + return original(method, params, **kwargs) + child = start_probe({"mode": "checkpoint", "provider": provider, "barrier": str(barrier), + "params": params, "fault": fault}) + stdout, stderr = child.communicate(timeout=25) + assert child.returncode == 86, stdout + stderr + raise OSError("injected native response loss") + + monkeypatch.setattr(context_io, "effect_runtime_result", native) + with pytest.raises(OSError, match="injected"): + refresh(registry, runtime, token, first_delivery=True) + monkeypatch.setattr(context_io, "effect_runtime_result", original) + if fault == "before_artifacts": + observed = read() + assert observed["read_context_id"] != token + saved = refresh(registry, runtime, observed["read_context_id"], first_delivery=True) + assert saved["appended"] + elif fault == "after_index": + observed = read() + assert observed["status"] == "committed" and observed["replayed"] + replay = refresh(registry, runtime, token, first_delivery=True) + assert replay["appended"] is False + assert replay["json_path"] == observed["json_path"] + else: + for action in (read, lambda: refresh(registry, runtime, token, first_delivery=True)): + with pytest.raises(context_io.CheckpointReadContextRejected) as rejected: + action() + assert rejected.value.code == "checkpoint_commit_unknown" + # A new Turn identity cannot bypass an unresolved append for the same + # Agent/Todo. This check precedes any new admission or token issuance. + identity = SettlementIdentity(goal_id=GOAL_ID, agent_id=AGENT_ID, + todo_id=TODO_ID, turn_instance_id="different-turn") + with pytest.raises(context_io.CheckpointReadContextRejected) as rejected: + context_io._checkpoint_effect("goal.checkpoint_read_context.inspect_attempt", { + "runtime_root": str(runtime.resolve()), "identity": identity.as_dict(), + "check_other_attempts": True, + }) + assert rejected.value.code == "checkpoint_commit_unknown" + rows = [json.loads(line) for line in (runtime / "goals" / GOAL_ID / "runs/index.jsonl").read_text().splitlines()] + assert sum(bool(row.get("vision_checkpoint", {}).get("read_context")) for row in rows) == (fault in {"after_index", "before_artifacts"}) + assert _spend_run_count(runtime) == 0 diff --git a/tests/control_plane/test_first_delivery_mcp.py b/tests/control_plane/test_first_delivery_mcp.py new file mode 100644 index 0000000000..1f0351ece8 --- /dev/null +++ b/tests/control_plane/test_first_delivery_mcp.py @@ -0,0 +1,86 @@ +"""Caller-authored direction, real CLI recovery, and one original settlement.""" +from __future__ import annotations + +import json + +import pytest + +from loopx.todos import add_goal_todo +from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime +from tests.test_goal_mode_mcp_settlement import GOAL_ID, AGENT_ID, _write_fixture, _control, _run_cli + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("new_obligation", [False, True]) +def test_mcp_first_delivery_recovers_stale_direction_without_repeating_result(tmp_path, monkeypatch, provider, new_obligation): + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, state = _write_fixture(tmp_path, provider=provider) + registration = json.loads(registry.read_text(encoding="utf-8")) + registration["goals"][0]["adapter"] = {"kind": "fixture_v0", "status": "connected-delivery"} + registry.write_text(json.dumps(registration), encoding="utf-8") + added = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="agent", + text="Verify the bounded output.", task_class="advancement_task", + claimed_by=AGENT_ID, continuation_policy="same_agent_non_delivery") + todo_id = added["todo_id"] + runtime = tmp_path / "runtime" + rc, baseline = _run_cli(registry, "refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--vision-summary", "Verify the bounded output.", "--vision-acceptance", "Output verification passes.", + "--no-global-sync", "--suppress-external-sinks") + assert rc == 0, json.dumps(baseline) + control = _control(registry) + intent = dict(todo_id=todo_id, agent_id=AGENT_ID, evidence="The bounded output passed verification.", + next_agent_todo="Verify the remaining bounded output.", first_delivery=True) + first = json.loads(control.complete_task(**intent)) + assert first["ok"] and first["stage"] == "result_review_pending", first + result_id = first["context"]["read_context_id"] + second = json.loads(control.complete_task(**intent, delivery_read_context_id=result_id)) + assert second["ok"] and second["stage"] == "direction_pending", second + assert second["context"]["basis"]["todo"]["status"] == "done" + assert second["settlement_complete"] is False + rc, status = _run_cli(registry, "status", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID) + assert rc == 0, status + observed = status["attention_queue"]["items"][0]["first_delivery_progress"] + assert observed["stage"] == "direction_pending" and observed["result_committed"] is True + assert observed["goal_completion_certified"] is False + assert status["attention_queue"]["items"][0]["recommended_action"] == observed["next_action"] + from loopx.status import collect_status + + # Dashboard and Lark projections call the shared collector, not the CLI's + # Agent decorator. An unscoped read must retain the same recovery message. + shared = collect_status(registry_path=registry, runtime_root_override=str(runtime), + limit=10, goal_id=GOAL_ID) + shared_item = shared["attention_queue"]["items"][0] + assert shared_item["first_delivery_progress"]["stage"] == "direction_pending" + assert shared_item["recommended_action"] == observed["next_action"] + direction_id = second["context"]["read_context_id"] + run_cli = control.run_cli + injected = [] + + def provider_call(args, **kwargs): + if new_obligation and args[:1] == ["refresh-state"] and not injected: + # Change the relevant basis before the native direction commit. + # The original result is already durable; only direction is retried. + state.write_text(state.read_text(encoding="utf-8") + "\n## Acceptance update\nVerify an additional bounded output.\n", encoding="utf-8") + injected.append(True) + return run_cli(args, **kwargs) + + control.run_cli = provider_call + decision = dict(delivery_read_context_id=result_id, read_context_id=direction_id, + vision_unchanged_reason="The bounded output passed; the current frontier was reviewed.") + final = json.loads(control.complete_task(**intent, **decision)) + assert final["ok"] is (not new_obligation), json.dumps(final) + if new_obligation: + assert final["settlement"]["failed_stage"] == "durable_writeback", final + assert "checkpoint_read_context_stale" in final["settlement"]["reason"], final + control.run_cli = run_cli + reread = json.loads(control.review_task_vision(todo_id, AGENT_ID, first_delivery=True)) + assert reread["ok"], reread + decision["read_context_id"] = reread["read_context_id"] + decision["vision_unchanged_reason"] = "The updated acceptance was reviewed; the successor retains the additional work." + final = json.loads(control.complete_task(**intent, **decision)) + assert final["ok"], final + replay = json.loads(control.complete_task(**intent, **decision)) + assert replay["ok"] and replay["settlement_identity"] == final["settlement_identity"], replay + rows = [json.loads(line) for line in (runtime / f"goals/{GOAL_ID}/runs/index.jsonl").read_text().splitlines()] + assert sum(row["classification"] == "quota_slot_spent" for row in rows) == 1 + assert sum(bool(row.get("vision_checkpoint", {}).get("read_context")) for row in rows) == 1 diff --git a/tests/control_plane/test_first_delivery_observation.py b/tests/control_plane/test_first_delivery_observation.py new file mode 100644 index 0000000000..522ddec0fe --- /dev/null +++ b/tests/control_plane/test_first_delivery_observation.py @@ -0,0 +1,92 @@ +"""Historical observation faults cannot become cross-lane admission failures.""" +from __future__ import annotations + +import hashlib +import json + +import pytest + +from loopx.control_plane.goals import checkpoint_context_io as context_io +from loopx.control_plane.quota.settlement import SettlementIdentity +from tests.control_plane.test_checkpoint_provider_fence import fixture +from tests.control_plane.test_quota_settlement_cli import AGENT_ID, GOAL_ID, TODO_ID, TURN_ID, _run_cli + + +def _status(project, runtime, registry, agent=AGENT_ID): + rc, payload = _run_cli(registry, runtime, "status", "--goal-id", GOAL_ID, + "--agent-id", agent, cwd=project) + assert rc == 0 and payload["ok"], payload + return payload["attention_queue"]["items"][0] + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("corruption", ["{", "[]", '{"purpose":"first_delivery","identity":[]}']) +def test_unenrolled_lane_retains_status_and_scoped_unavailable_evidence(tmp_path, monkeypatch, provider, corruption): + project, runtime, registry, state, read, _ = fixture(tmp_path, monkeypatch, provider) + read() # Existing supplement receipts remain ordinary feature-off history. + before = _status(project, runtime, registry) + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + prior = (state.read_bytes(), index.read_bytes()) + directory = runtime / "goals" / GOAL_ID / "checkpoint-contexts" + damaged = directory / (hashlib.sha256(b"historical-context").hexdigest() + ".json") + damaged.write_text(corruption, encoding="utf-8") + after = _status(project, runtime, registry) + assert after["recommended_action"] == before["recommended_action"] + progress = after["first_delivery_progress"] + assert progress["stage"] == "observation_unavailable" + assert progress["observation_errors"][0]["scope"] == "goal" + assert progress["observation_errors"][0]["receipt"] == damaged.stem + assert "settlement_identity" not in progress + assert "result_committed" not in progress # Unavailable does not assert absence. + assert progress["goal_completion_certified"] is False + assert (state.read_bytes(), index.read_bytes()) == prior + assert damaged.read_text(encoding="utf-8") == corruption + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_other_agent_observation_does_not_override_current_lane(tmp_path, monkeypatch, provider): + project, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider, first_delivery=True) + read() + peer = "independent-peer" + registration = json.loads(registry.read_text(encoding="utf-8")) + registration["goals"][0]["coordination"]["registered_agents"].append(peer) + registry.write_text(json.dumps(registration), encoding="utf-8") + before = _status(project, runtime, registry, peer) + identity = SettlementIdentity(goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID) + receipt = context_io._receipt_path(runtime, identity) + receipt.write_text("{", encoding="utf-8") + after = _status(project, runtime, registry, peer) + assert after["recommended_action"] == before["recommended_action"] + assert after["first_delivery_progress"]["stage"] == "observation_unavailable" + # An intact other-Agent binding can be excluded even if its body is invalid. + receipt.write_text(json.dumps({"purpose": "first_delivery", "identity": identity.as_dict()}), encoding="utf-8") + assert context_io.pending_first_delivery_progress(runtime, GOAL_ID, peer) is None + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_enrolled_corruption_stays_unknown_and_cannot_admit_another_write(tmp_path, monkeypatch, provider): + project, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider, first_delivery=True) + read() + result = context_io.read_checkpoint_context(registry_path=registry, runtime_root_override=str(runtime), + goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID, + purpose="delivery_result") + rc, completed = _run_cli(registry, runtime, "todo", "complete", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID, + "--delivery-read-context", result["read_context_id"], + "--task-lease-idempotency-key", f"checkpoint-{TODO_ID}", "--task-lease-expected-version", "1", + "--note", "Validated the selected output.", cwd=project) + assert rc == 0, json.dumps(completed) + identity = SettlementIdentity(goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID) + damaged = context_io._receipt_path(runtime, identity) + damaged.write_text("{", encoding="utf-8") + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + before = index.read_bytes() + progress = _status(project, runtime, registry)["first_delivery_progress"] + assert progress["stage"] == "operation_unknown" + assert progress["result_committed"] is True + assert progress["settlement_identity"] == identity.as_dict() + with pytest.raises(context_io.CheckpointReadContextRejected) as rejected: + read() + assert rejected.value.code == "checkpoint_commit_unknown" + assert index.read_bytes() == before + assert damaged.read_text(encoding="utf-8") == "{" diff --git a/tests/control_plane/test_first_delivery_terminal.py b/tests/control_plane/test_first_delivery_terminal.py new file mode 100644 index 0000000000..ea596f7437 --- /dev/null +++ b/tests/control_plane/test_first_delivery_terminal.py @@ -0,0 +1,128 @@ +"""Optional enrollment must not obstruct ordinary terminal commits or replay.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted, LocalCoordinationAuthorityRejection +from loopx.control_plane.goals import checkpoint_context_io as context_io +from loopx.control_plane.quota.settlement import SettlementIdentity +from loopx.todos import complete_goal_todo +from tests.control_plane.test_checkpoint_provider_fence import fixture +from tests.control_plane.checkpoint_process import refresh +from tests.control_plane.test_quota_settlement_cli import AGENT_ID, GOAL_ID, TODO_ID, TURN_ID, _run_cli, _spend_run_count + + +def _complete(project, runtime, registry, *extra): + return _run_cli(registry, runtime, "todo", "complete", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID, + "--task-lease-idempotency-key", f"checkpoint-{TODO_ID}", "--task-lease-expected-version", "1", + "--note", "Validated the selected output.", *extra, cwd=project) + + +def _authority(runtime): + return read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) + + +def _identity(): + return SettlementIdentity(goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID) + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_protected_terminal_is_rejected_before_new_effects(tmp_path, monkeypatch, provider): + project, runtime, registry, state, _, _ = fixture(tmp_path, monkeypatch, provider, first_delivery=True) + context = context_io.read_checkpoint_context(registry_path=registry, runtime_root_override=str(runtime), + goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID, purpose="delivery_result") + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + before = (_authority(runtime), state.read_bytes(), index.read_bytes()) + rc, rejected = _complete(project, runtime, registry, "--no-follow-up", + "--delivery-read-context", context["read_context_id"]) + # CLI keeps the existing settlement prerequisite before reaching its owner. + assert rc == 1 and rejected["settlement_blocked_completion"] is True, rejected + with pytest.raises(LocalCoordinationAuthorityRejection) as failure: + complete_goal_todo(registry_path=registry, runtime_root_arg=str(runtime), + goal_id=GOAL_ID, todo_id=TODO_ID, agent_id=AGENT_ID, + completion_turn_key=_identity().effect_id, completion_identity_source="turn_settlement", + delivery_read_context_id=context["read_context_id"], delivery_settlement_identity=_identity().as_dict(), + task_lease_idempotency_key=f"checkpoint-{TODO_ID}", task_lease_expected_version=1, + note="Validated the selected output.", no_followup=True) + assert failure.value.code == "delivery_no_followup_unsupported" + assert failure.value.payload["delivery_read_context"]["reread_required"] is False + assert (_authority(runtime), state.read_bytes(), index.read_bytes()) == before + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("history", ["none", "supplement", "damaged", "damaged_after_commit", "damaged_after_commit_missing_writeback"]) +def test_ordinary_completion_and_cold_replay_survive_optional_history(tmp_path, monkeypatch, provider, history): + project, runtime, registry, _, read, original = fixture(tmp_path, monkeypatch, provider) + receipt = context_io._receipt_path(runtime, _identity()) + if history != "none": + read() + if history == "damaged": + receipt.write_text("{", encoding="utf-8") + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + before_index = index.read_bytes() + rc, saved = _complete(project, runtime, registry) + assert rc == 0 and saved["completed"], json.dumps(saved) + committed = _authority(runtime) + assert next(todo for todo in committed["todos"] if todo["todo_id"] == TODO_ID)["status"] == "done" + if history.startswith("damaged_after_commit"): + receipt.write_text("{", encoding="utf-8") + if history == "damaged_after_commit_missing_writeback": + Path(original["json_path"]).unlink() + # Every CLI call is a fresh process against the same real provider/files. + rc, replay = _complete(project, runtime, registry) + assert rc == 0 and replay["idempotent_replay"], replay + assert _authority(runtime) == committed + assert index.read_bytes() == before_index + assert _spend_run_count(runtime) == 0 + if history.startswith("damaged"): + assert receipt.read_text(encoding="utf-8") == "{" + rc, conflict = _complete(project, runtime, registry, "--task-lease-idempotency-key", "different-request") + assert rc == 1, conflict + assert _authority(runtime) == committed + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("enrollment", ["direction", "result", "both"]) +def test_unreadable_enrollment_without_ordinary_writeback_stays_closed(tmp_path, monkeypatch, provider, enrollment): + project, runtime, registry, state, read, _ = fixture(tmp_path, monkeypatch, provider, first_delivery=True) + if enrollment != "result": + read() + context_io._receipt_path(runtime, _identity()).write_text("{", encoding="utf-8") + if enrollment != "direction": + context_io.read_checkpoint_context(registry_path=registry, runtime_root_override=str(runtime), + goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID, purpose="delivery_result") + context_io._receipt_path(runtime, _identity(), "delivery_result").write_text("{", encoding="utf-8") + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + before = (_authority(runtime), state.read_bytes(), index.read_bytes()) + rc, rejected = _complete(project, runtime, registry) + assert rc == 1, rejected + assert rejected["error_code"] == "checkpoint_commit_unknown", rejected + assert (_authority(runtime), state.read_bytes(), index.read_bytes()) == before + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("uncertainty", ["writeback_damaged", "result_empty", "result_damaged", "enrolled_writeback"]) +def test_unreadable_enrollment_requires_verified_ordinary_history(tmp_path, monkeypatch, provider, uncertainty): + enrolled = uncertainty == "enrolled_writeback" + project, runtime, registry, state, read, original = fixture(tmp_path, monkeypatch, provider, first_delivery=enrolled) + token = read()["read_context_id"] + if enrolled: + refresh(registry, runtime, token, first_delivery=True) + context_io._receipt_path(runtime, _identity()).write_text("{", encoding="utf-8") + if uncertainty == "writeback_damaged": + Path(original["json_path"]).write_text("{", encoding="utf-8") + elif uncertainty.startswith("result_"): + context_io._receipt_path(runtime, _identity(), "delivery_result").write_text( + "" if uncertainty == "result_empty" else "{", encoding="utf-8") + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + before = (_authority(runtime), state.read_bytes(), index.read_bytes()) + rc, rejected = _complete(project, runtime, registry) + assert rc == 1 and rejected["error_code"] == "checkpoint_commit_unknown", rejected + assert (_authority(runtime), state.read_bytes(), index.read_bytes()) == before + assert _spend_run_count(runtime) == 0 diff --git a/tests/control_plane/test_heartbeat_agent_input.py b/tests/control_plane/test_heartbeat_agent_input.py index d86246a77e..680b317983 100644 --- a/tests/control_plane/test_heartbeat_agent_input.py +++ b/tests/control_plane/test_heartbeat_agent_input.py @@ -112,6 +112,9 @@ def test_thin_agent_input_excludes_generator_and_embedded_command_duplicates() - } assert projected["schema_version"] == HEARTBEAT_AGENT_INPUT_SCHEMA_VERSION assert projected["task_body"] == generated["task_body"] + assert "loopx-project" in projected["task_body"] + assert "loopx-self-repair" in projected["task_body"] + assert "Failed preflight/guard: no work/spend" in projected["task_body"] assert projected["interface_budget"] == { "mode": "thin", "budget_char_count": generated["interface_budget"]["budget_char_count"], diff --git a/tests/control_plane/test_heartbeat_notification_rule.py b/tests/control_plane/test_heartbeat_notification_rule.py index 989becf9b4..abd2172d6b 100644 --- a/tests/control_plane/test_heartbeat_notification_rule.py +++ b/tests/control_plane/test_heartbeat_notification_rule.py @@ -49,14 +49,14 @@ def test_short_rule_qualifies_dont_notify_as_output_only() -> None: assert "OUTPUT only" in rule # The old ambiguous mapping must be gone. assert "DONT_NOTIFY=quiet." not in rule - assert "heartbeat_recommendation.agent_must_attempt" in rule assert "execution_obligation.must_attempt_work" in rule + # Guidance mirrors the obligation in quota but is not another authority. + assert "heartbeat_recommendation.agent_must_attempt" not in rule def test_shared_rule_keeps_projection_repair_and_quiet_boundary() -> None: rule = HEARTBEAT_NOTIFICATION_RULE_SHORT - assert "Missing NOTIFY action: user Todo unprojected" in rule - assert "repair LoopX state projection" in rule + assert "Missing NOTIFY action: repair unprojected user Todo" in rule assert "under DONT_NOTIFY repair internally" in rule assert "true=work+writeback" in rule assert "only false permits no-op" in rule @@ -83,14 +83,13 @@ def test_rendered_task_bodies_keep_execution_obligation_authority() -> None: ) for renderer in (render_thin_heartbeat_task_body, render_brief_heartbeat_task_body): body = renderer(**kwargs) - assert "heartbeat_recommendation.agent_must_attempt" in body assert "execution_obligation.must_attempt_work" in body assert "OUTPUT only" in body - assert "repair LoopX state projection" in body + assert "repair unprojected user Todo" in body assert "under DONT_NOTIFY repair internally" in body # A bare "DONT_NOTIFY=quiet" no-op mapping must never appear in the prompt. assert "DONT_NOTIFY=quiet." not in body - assert "After committed writeback: same-turn checkpoint-context" in body + assert "Committed writeback lacks vision: same-turn checkpoint-context+evidence" in body assert "Missing vision: same-turn checkpoint-context" not in body @@ -104,10 +103,10 @@ def test_all_heartbeat_modes_scope_no_spend_to_observation() -> None: ) body = payload["task_body"] assert "Exact monitor settlement=no refresh/spend; auxiliary poll: continue work." in body - assert "Admitted work: settlement_plan" in body - assert "settlement_plan even if artifacts unchanged" in body + assert "Admitted work, even if artifacts unchanged" in body + assert "cli_channel.settlement_plan.ordered_steps" in body assert "no-change=surface_only/no spend" not in body - assert "After committed writeback: same-turn checkpoint-context" in body + assert "Committed writeback lacks vision: same-turn checkpoint-context+evidence" in body def test_todo_closeout_does_not_invent_successors_or_skip_turn_settlement(tmp_path) -> None: diff --git a/tests/control_plane/test_heartbeat_registry_route.py b/tests/control_plane/test_heartbeat_registry_route.py index 8a9321bb7f..4a1b1c362b 100644 --- a/tests/control_plane/test_heartbeat_registry_route.py +++ b/tests/control_plane/test_heartbeat_registry_route.py @@ -151,6 +151,26 @@ def test_cli_generated_guard_selection_and_settlement_ignore_conflicting_cwd_reg _route(step["command_template"], registry, runtime) assert cli._spend_run_count(runtime) == 0 + # New default admissions validate the result before a post-result direction. + # Both returned read and write routes must survive the conflicting cwd too. + code, completed = _execute([ + "--registry", str(registry), "--runtime-root", str(runtime), + "todo", "complete", "--goal-id", cli.GOAL_ID, "--agent-id", cli.AGENT_ID, + "--todo-id", cli.TODO_ID, "--turn-instance-id", cli.TURN_ID, + "--note", "Selected candidate independently verified.", + "--next-agent-todo", "Verify the remaining selected-authority work.", + ], project) + assert code == 0 and completed["completed"], json.dumps(completed) + code, direction = _execute([ + "--registry", str(registry), "--runtime-root", str(runtime), + "checkpoint-context", "--goal-id", cli.GOAL_ID, "--agent-id", cli.AGENT_ID, + "--todo-id", cli.TODO_ID, "--turn-instance-id", cli.TURN_ID, + "--purpose", "first_delivery", + ], project) + assert code == 0 and direction["basis"]["todo"]["status"] == "done", direction + assert direction["settlement_identity"] == identity + assert cli._spend_run_count(runtime) == 0 + refresh = next(c for c in channel["next_cli_actions"] if "refresh-state" in c) replacements = { "": "validated_progress", "": "implementation", @@ -162,9 +182,10 @@ def test_cli_generated_guard_selection_and_settlement_ignore_conflicting_cwd_reg "--vision-state", "vision_on_track", "--vision-summary", "Keep validating the selected work.", "--vision-acceptance", "The same Turn settles once against the selected authority.", + "--checkpoint-read-context", direction["read_context_id"], "--no-global-sync", "--suppress-external-sinks", ], project) - assert code == 0 and refreshed["settlement_result"]["ok"], refreshed + assert code == 0 and refreshed["settlement_result"]["ok"], json.dumps(refreshed) spend = next(c for c in channel["next_cli_actions"] if "spend-slot" in c) for replay in (False, True): code, spent = _execute(shlex.split(spend)[1:], project) diff --git a/tests/control_plane/test_host_checkpoint_fixture.py b/tests/control_plane/test_host_checkpoint_fixture.py new file mode 100644 index 0000000000..b4e22bc13f --- /dev/null +++ b/tests/control_plane/test_host_checkpoint_fixture.py @@ -0,0 +1,23 @@ +"""The fixture oracle keeps ordinary lane and original terminal intent distinct.""" +import pytest + +from tests.control_plane.host_checkpoint_fixture import checkpoint_decision + + +@pytest.mark.parametrize("intent", ["ordinary", "stored", "deferred", "other-turn", "goal-wide"]) +def test_fixture_checks_the_original_direction_scope(intent): + identity = {"effect_id": "original-effect", "turn_instance_id": "original-turn"} + todo = {"status": "done", "completion_continuation": "active_goal"} + if intent == "stored": + todo["no_followup"] = True + elif intent in {"deferred", "other-turn"}: + todo.update(completion_requested_continuation="no_followup", + completion_turn_key="other-turn" if intent == "other-turn" else identity["effect_id"]) + terminal = intent in {"stored", "deferred", "goal-wide"} + context = {"read_context_id": "fresh-read", "settlement_identity": identity, + "basis": {"todo": [] if intent == "goal-wide" else todo, "agent_vision": {}, + "frontier": {"scope": "goal" if terminal else "agent_lane"}}} + assert checkpoint_decision(context)["read_context_id"] == "fresh-read" + context["basis"]["frontier"]["scope"] = "agent_lane" if terminal else "goal" + with pytest.raises(AssertionError): + checkpoint_decision(context) diff --git a/tests/control_plane/test_inflight_result_freshness.py b/tests/control_plane/test_inflight_result_freshness.py new file mode 100644 index 0000000000..726a71592c --- /dev/null +++ b/tests/control_plane/test_inflight_result_freshness.py @@ -0,0 +1,325 @@ +"""In-flight writebacks retain the original work basis on real providers.""" +from __future__ import annotations + +import json + +import pytest + +from tests.control_plane.test_default_result_freshness import _default_fixture +from tests.control_plane.test_quota_settlement_cli import ( + AGENT_ID, GOAL_ID, TODO_ID, TURN_ID, _run_cli, _spend_run_count, +) + + +def _admit(provider, tmp_path, monkeypatch): + project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch) + rc, guard = _run_cli(registry, runtime, 'quota', 'should-run', '--codex-app', + '--goal-id', GOAL_ID, '--agent-id', AGENT_ID, '--todo-id', TODO_ID, + '--turn-instance-id', TURN_ID, cwd=project) + assert rc == 0, guard + context = guard['delivery_result_context'] + args = ('refresh-state', '--goal-id', GOAL_ID, '--agent-id', AGENT_ID, + '--todo-id', TODO_ID, '--turn-instance-id', context['settlement_identity']['turn_instance_id'], + '--classification', 'verified_inflight_candidate', '--delivery-outcome', 'outcome_progress', + '--delivery-batch-scale', 'implementation', '--delivery-boundary', 'in_flight_continuation', + '--delivery-workspace-path', str(project), '--no-global-sync', '--suppress-external-sinks') + return project, runtime, registry, state, context, args + + +def _runs(runtime): + return {p.name: p.read_bytes() for p in (runtime / 'goals' / GOAL_ID / 'runs').glob('*') if p.is_file() and p.suffix in {'.json', '.jsonl', '.md'}} + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('change', ['revised', 'missing']) +def test_supplement_direction_keeps_primary_goal_source_fresh(provider, change, tmp_path, monkeypatch): + project, runtime, registry, _ = _default_fixture(provider, tmp_path, monkeypatch) + owner = project / 'GOAL.md' + owner.write_text('Initial independent owner requirement.\n', encoding='utf-8') + document = json.loads(registry.read_text()) + document['goals'][0]['authority_sources'] = [{'kind': 'goal_doc', 'path': 'GOAL.md'}] + registry.write_text(json.dumps(document), encoding='utf-8') + identity = ('--goal-id', GOAL_ID, '--agent-id', AGENT_ID, '--todo-id', TODO_ID) + rc, guard = _run_cli(registry, runtime, 'quota', 'should-run', '--codex-app', *identity, + '--turn-instance-id', TURN_ID, cwd=project) + assert rc == 0, guard + turn = guard['delivery_result_context']['settlement_identity']['turn_instance_id'] + args = ('refresh-state', *identity, '--turn-instance-id', turn, + '--classification', 'verified_progress', '--delivery-outcome', 'outcome_progress', + '--delivery-batch-scale', 'implementation', '--delivery-boundary', 'semantic_closeout', + '--delivery-workspace-path', str(project), '--no-global-sync', '--suppress-external-sinks') + rc, result = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0 and result['vision_checkpoint']['decision'] == 'missing_required', result + read = ('checkpoint-context', *identity, '--turn-instance-id', turn) + rc, context = _run_cli(registry, runtime, *read, cwd=project) + assert rc == 0, context + assert 'Initial independent owner requirement.' in context['basis']['goal']['prose'] + assert context['basis']['goal']['primary_documents'] + before = _runs(runtime) + if change == 'missing': + owner.unlink() + else: + owner.write_text('Revised independent owner requirement.\n', encoding='utf-8') + decision = ('--vision-summary', 'Continue the freshly checked requirement route.', + '--vision-acceptance', 'The retained candidate and remaining work satisfy the current requirements.') + rc, rejected = _run_cli(registry, runtime, *args, *decision, + '--checkpoint-read-context', context['read_context_id'], cwd=project) + assert rc == 1, rejected + assert _runs(runtime) == before and _spend_run_count(runtime) == 0 + if change == 'missing': + owner.write_text('Revised independent owner requirement.\n', encoding='utf-8') + rc, current = _run_cli(registry, runtime, *read, cwd=project) + assert rc == 0 and current['read_context_id'] != context['read_context_id'], current + assert 'Revised independent owner requirement.' in current['basis']['goal']['prose'] + commit = (*args, *decision, '--checkpoint-read-context', current['read_context_id']) + rc, settled = _run_cli(registry, runtime, *commit, cwd=project) + assert rc == 0 and settled['vision_checkpoint']['satisfied'], settled + after = _runs(runtime) + owner.unlink() + rc, replay = _run_cli(registry, runtime, *commit, cwd=project) + assert rc == 0 and replay['idempotent_replay'], replay + assert _runs(runtime) == after and _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('vision', [False, True]) +def test_inflight_rejects_changed_basis_then_revalidates_same_turn(provider, vision, tmp_path, monkeypatch): + project, runtime, registry, state, context, args = _admit(provider, tmp_path, monkeypatch) + if vision: + args += ('--vision-summary', 'Continue the originally accepted reporting route.', + '--vision-acceptance', 'The retained candidate must satisfy the current owner requirement.') + state.write_text(state.read_text(encoding='utf-8') + '\n## Requirement B\nUse the revised reporting contract.\n', encoding='utf-8') + before = _runs(runtime) + contexts = {p: p.read_bytes() for p in (runtime / 'goals' / GOAL_ID / 'checkpoint-contexts').glob('*.json')} + original_state = state.read_bytes() + rc, rejected = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 1 and rejected.get('error_code') == 'checkpoint_read_context_stale', json.dumps(rejected, ensure_ascii=False) + assert _runs(runtime) == before + assert state.read_bytes() == original_state + assert all(p.read_bytes() == data for p, data in contexts.items()) + assert _spend_run_count(runtime) == 0 + rc, reread = _run_cli(registry, runtime, 'checkpoint-context', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--todo-id', TODO_ID, + '--turn-instance-id', context['settlement_identity']['turn_instance_id'], + '--purpose', 'delivery_result', cwd=project) + assert rc == 0 and reread['read_context_id'] != context['read_context_id'], reread + assert 'Requirement B' in json.dumps(reread['basis']) + assert 'in-flight progress' in reread['instructions'] + rc, committed = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0 and committed['ok'], committed + assert committed['vision_checkpoint']['required'] is False + assert committed['vision_checkpoint']['decision'] == ('patched' if vision else 'not_required') + assert 'read_context' not in committed['vision_checkpoint'], 'A work check is not a direction decision' + assert committed['result_work_context']['read_context_id'] == reread['read_context_id'] + after = _runs(runtime) + state.write_text(state.read_text(encoding='utf-8') + '\n## Requirement C\nA later owner change.\n', encoding='utf-8') + rc, replay = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0 and replay.get('idempotent_replay') is True, replay + assert replay['result_work_context'] == committed['result_work_context'] + assert _runs(runtime) == after + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +def test_inflight_unchanged_basis_does_not_require_new_direction(provider, tmp_path, monkeypatch): + project, runtime, registry, _, context, args = _admit(provider, tmp_path, monkeypatch) + rc, committed = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0, committed + assert committed['vision_checkpoint']['required'] is False + assert committed['vision_checkpoint']['decision'] == 'not_required' + assert committed['result_work_context']['read_context_id'] == context['read_context_id'] + assert committed['result_work_context']['purpose'] == 'delivery_result' + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +def test_inflight_missing_work_receipt_cannot_mint_a_new_read(provider, tmp_path, monkeypatch): + from loopx.control_plane.goals.checkpoint_context_io import _receipt_path + from loopx.control_plane.quota.settlement import SettlementIdentity + project, runtime, registry, _, context, args = _admit(provider, tmp_path, monkeypatch) + path = _receipt_path(runtime, SettlementIdentity.from_runtime_payload(context['settlement_identity']), 'delivery_result') + path.unlink() + before = _runs(runtime) + rc, rejected = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 1 and rejected.get('error_code') == 'delivery_work_context_required', rejected + assert not path.exists() + assert _runs(runtime) == before + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +def test_historical_inflight_admission_keeps_its_original_policy(provider, tmp_path, monkeypatch): + from tests.control_plane.test_quota_settlement_cli import _historical_turn + project, runtime, registry, state, context, args = _admit(provider, tmp_path, monkeypatch) + # A synthetic persisted pre-freshness admission; never downgrade active state. + _historical_turn(runtime, context['settlement_identity']['turn_instance_id']) + state.write_text(state.read_text(encoding='utf-8') + '\nA later requirement.\n', encoding='utf-8') + rc, saved = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0 and saved['ok'], saved + assert 'result_work_context' not in saved + assert saved['vision_checkpoint']['decision'] == 'not_required' + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('fault', ['before_artifacts', 'after_json', 'after_index']) +def test_inflight_native_append_failure_retains_original_work(provider, fault, tmp_path, monkeypatch): + import subprocess + from loopx.control_plane.goals import checkpoint_context_io as io + from loopx.state_refresh import refresh_state_run + from tests.control_plane.test_quota_settlement_cli import REPO_ROOT + + project, runtime, registry, state, context, args = _admit(provider, tmp_path, monkeypatch) + barrier = tmp_path / 'barrier' + barrier.mkdir() + (barrier / 'release').touch() + original_effect = io._checkpoint_effect + + def lose_native_response(method, request): + if method != 'goal.checkpoint_read_context.commit': + return original_effect(method, request) + packet = {'mode': 'checkpoint', 'barrier': str(barrier), 'provider': provider, + 'method': method, 'params': request, 'fault': fault} + result = subprocess.run(['node', '--no-warnings', '--experimental-strip-types', + str(REPO_ROOT / 'tests/control_plane_ts/checkpoint_commit_probe.ts')], + input=json.dumps(packet), capture_output=True, text=True, cwd=REPO_ROOT, timeout=45) + assert result.returncode == 86, result.stdout + result.stderr + raise OSError('Synthetic lost native append response') + + with monkeypatch.context() as patch: + patch.setattr(io, '_checkpoint_effect', lose_native_response) + with pytest.raises(OSError, match='Synthetic lost native'): + refresh_state_run(registry_path=registry, runtime_root_override=str(runtime), + goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, + turn_instance_id=context['settlement_identity']['turn_instance_id'], + project=None, state_file=None, classification='verified_inflight_candidate', + recommended_action=None, delivery_outcome='outcome_progress', + delivery_batch_scale='implementation', delivery_boundary='in_flight_continuation', + delivery_workspace_path=project, dry_run=False, sync_global=False, + external_delivery={'suppress': True, 'resume_key': None}) + assert _spend_run_count(runtime) == 0 + if fault == 'after_index': + state.write_text(state.read_text(encoding='utf-8') + '\nChange after committed progress.\n', encoding='utf-8') + committed_runs = _runs(runtime) + rc, replay = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0 and replay['appended'] is False, replay + assert replay['refresh_recovery']['decision'] == 'repair_receipt', replay + assert replay['result_work_context']['read_context_id'] == context['read_context_id'] + assert _runs(runtime) == committed_runs + rc, repeated = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0 and repeated['idempotent_replay'], repeated + assert _runs(runtime) == committed_runs + else: + rc, reread = _run_cli(registry, runtime, 'checkpoint-context', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--todo-id', TODO_ID, + '--turn-instance-id', context['settlement_identity']['turn_instance_id'], + '--purpose', 'delivery_result', cwd=project) + if fault == 'after_json': + assert rc == 1 and reread['error_code'] == 'checkpoint_commit_unknown', reread + else: + assert rc == 0 and reread['read_context_id'] != context['read_context_id'], reread + rc, saved = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0 and saved['result_work_context']['read_context_id'] == reread['read_context_id'], saved + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +def test_inflight_native_append_rechecks_captured_goal_bytes(provider, tmp_path, monkeypatch): + from loopx.control_plane.goals import checkpoint_context_io as io + from loopx.state_refresh import refresh_state_run + + project, runtime, registry, state, context, _ = _admit(provider, tmp_path, monkeypatch) + before = _runs(runtime) + original_effect = io._checkpoint_effect + + def change_after_preview(method, request): + if method == 'goal.checkpoint_read_context.commit': + # An owner publication between the preliminary check and native save. + state.write_text(state.read_text(encoding='utf-8') + '\nLate requirement B.\n', encoding='utf-8') + return original_effect(method, request) + + monkeypatch.setattr(io, '_checkpoint_effect', change_after_preview) + with pytest.raises(io.CheckpointReadContextRejected, match='sources changed'): + refresh_state_run(registry_path=registry, runtime_root_override=str(runtime), + goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, + turn_instance_id=context['settlement_identity']['turn_instance_id'], + project=None, state_file=None, classification='verified_inflight_candidate', recommended_action=None, + delivery_outcome='outcome_progress', delivery_batch_scale='implementation', + delivery_boundary='in_flight_continuation', delivery_workspace_path=project, + dry_run=False, sync_global=False, external_delivery={'suppress': True, 'resume_key': None}) + # Reserving empty paths is not a committed run; the index must stay unchanged. + assert _runs(runtime).get('index.jsonl') == before.get('index.jsonl') + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('outcome', ['outcome_progress', 'outcome_gap']) +def test_semantic_writeback_also_checks_original_work_basis(provider, outcome, tmp_path, monkeypatch): + project, runtime, registry, state, context, args = _admit(provider, tmp_path, monkeypatch) + args = tuple('semantic_closeout' if arg == 'in_flight_continuation' else + outcome if arg == 'outcome_progress' else arg for arg in args) + if outcome == 'outcome_gap': + args += ('--progress-result-class', 'blocked', '--progress-work-item-id', TODO_ID, + '--progress-blocker-id', 'blocker_fixture', '--progress-evidence-id', 'evidence_fixture') + state.write_text(state.read_text(encoding='utf-8') + '\n## Requirement B\nUse the revised contract.\n', encoding='utf-8') + before = _runs(runtime) + rc, rejected = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 1 and rejected.get('error_code') == 'checkpoint_read_context_stale', json.dumps(rejected, ensure_ascii=False) + assert _runs(runtime) == before + rc, reread = _run_cli(registry, runtime, 'checkpoint-context', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--todo-id', TODO_ID, + '--turn-instance-id', context['settlement_identity']['turn_instance_id'], '--purpose', 'delivery_result', cwd=project) + assert rc == 0, reread + rc, saved = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0 and saved['result_work_context']['read_context_id'] == reread['read_context_id'], saved + # The original blocked-retry cadence stays exempt from direction review, + # while its result still passes the work-basis check. + assert saved['vision_checkpoint']['satisfied'] is (outcome == 'outcome_gap') + assert saved['vision_checkpoint']['decision'] == ('not_required' if outcome == 'outcome_gap' else 'missing_required') + committed = _runs(runtime) + rc, denied = _run_cli(registry, runtime, 'checkpoint-context', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--todo-id', TODO_ID, + '--turn-instance-id', context['settlement_identity']['turn_instance_id'], '--purpose', 'delivery_result', cwd=project) + assert rc == 1 and denied['error_code'] == 'delivery_result_already_committed', denied + rc, replay = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0 and replay['idempotent_replay'], replay + assert _runs(runtime) == committed + assert _spend_run_count(runtime) == 0 + if outcome == 'outcome_progress': + # Work protection must coexist with a later direction decision on the + # same Turn; these two indexed effects are not conflicting writes. + rc, direction = _run_cli(registry, runtime, 'checkpoint-context', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--todo-id', TODO_ID, + '--turn-instance-id', context['settlement_identity']['turn_instance_id'], cwd=project) + assert rc == 0, direction + supplement = (*args, '--checkpoint-read-context', direction['read_context_id'], + '--vision-summary', 'Deliver the revised owner contract.', + '--vision-acceptance', 'The retained result matches the revised contract.') + rc, settled = _run_cli(registry, runtime, *supplement, cwd=project) + assert rc == 0 and settled['vision_checkpoint']['satisfied'], settled + rc, replay = _run_cli(registry, runtime, *supplement, cwd=project) + assert rc == 0 and replay['idempotent_replay'], replay + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +def test_each_new_turn_retains_its_own_work_basis(provider, tmp_path, monkeypatch): + project, runtime, registry, state, context, args = _admit(provider, tmp_path, monkeypatch) + first_turn = context['settlement_identity']['turn_instance_id'] + rc, first = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0, first + rc, spent = _run_cli(registry, runtime, 'quota', 'spend-slot', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--todo-id', TODO_ID, '--turn-instance-id', first_turn, + '--slots', '1', '--source', 'heartbeat', '--execute', cwd=project) + assert rc == 0, spent + rc, guard = _run_cli(registry, runtime, 'quota', 'should-run', '--codex-app', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--todo-id', TODO_ID, '--turn-instance-id', 'next-protected-turn', cwd=project) + assert rc == 0, guard + second = guard['delivery_result_context'] + assert second['read_context_id'] != context['read_context_id'] + state.write_text(state.read_text(encoding='utf-8') + '\n## Requirement B\nUse the revised contract for Turn two.\n', encoding='utf-8') + before = _runs(runtime) + second_args = tuple('next-protected-turn' if arg == first_turn else arg for arg in args) + rc, rejected = _run_cli(registry, runtime, *second_args, cwd=project) + assert rc == 1 and rejected.get('error_code') == 'checkpoint_read_context_stale', json.dumps(rejected, ensure_ascii=False) + assert _runs(runtime) == before + # A historical success is still replayable, but never satisfies Turn two. + rc, replay = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0 and replay['idempotent_replay'], replay + assert _runs(runtime) == before diff --git a/tests/control_plane/test_native_journal_settlement.py b/tests/control_plane/test_native_journal_settlement.py new file mode 100644 index 0000000000..ba2c69b9ea --- /dev/null +++ b/tests/control_plane/test_native_journal_settlement.py @@ -0,0 +1,163 @@ +import json +import sys +import pytest +from tests.control_plane.test_default_checkpoint_continuation import _fixture +from tests.test_goal_mode_mcp_settlement import _run_cli, GOAL_ID, AGENT_ID +from loopx.control_plane.turn_driver.journal_store import ( + load_turn_journal, + turn_journal_path, + reconcile_settled_turn_journal, +) +from loopx.control_plane.coordination.local_authority import ( + read_canonical_todos_if_promoted, +) +from loopx.hosts.chat_coordinator_checkpoint import CoordinatorCheckpoints + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_original_native_mcp_settlement_reconciles_managed_journal( + provider, tmp_path, monkeypatch +): + registry, state, todo = _fixture(provider, tmp_path, monkeypatch) + project = state.parent + runtime = tmp_path / "runtime" + host = project / "host.py" + validator = project / "verify.py" + host.write_text( + "import json,pathlib,sys\nr=json.load(sys.stdin)\nif r.get('phase')=='checkpoint_direction':\n json.dump({'read_context_id':'deliberately-stale','vision_unchanged_reason':'Old direction rejected.'},sys.stdout)\nelse:\n assert not pathlib.Path('candidate.txt').exists()\n pathlib.Path('candidate.txt').write_text('valid');pathlib.Path('host-count.txt').write_text('1')\n json.dump({'schema_version':'loopx_turn_result_v0','turn_key':r['turn_key'],'result_kind':'validated_completion','completed_phases':['host_execute','typed_result'],'classification':'candidate_verified','recommended_action':'Read remaining work.','next_action':'Read remaining work.','delivery_batch_scale':'implementation','delivery_outcome':'outcome_progress','summary':'Candidate verified.','vision_unchanged_reason':'Pre-result judgment.'},sys.stdout)\n" + ) + validator.write_text( + "from pathlib import Path;assert Path('candidate.txt').read_text()=='valid'" + ) + _, managed = _run_cli( + registry, + "turn", + "run-once", + "--host", + "generic-cli", + "--goal-id", + GOAL_ID, + "--agent-id", + AGENT_ID, + "--project", + str(project), + "--host-adapter-command-json", + json.dumps([sys.executable, str(host)]), + "--validation-command-json", + json.dumps([sys.executable, str(validator)]), + "--no-global-sync", + "--execute", + ) + assert managed["status"] == "failed" and managed["validation"]["ok"], managed + path = turn_journal_path( + runtime, goal_id=GOAL_ID, turn_key=managed["resume_turn_key"] + ) + before = load_turn_journal(path) + assert before["status"] == "failed" and before["completed_phases"] == [ + "host_execute", + "typed_result", + "validation", + ] + # An already committed result does not supply missing native settlement receipts. + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + pending_journal, pending_ledger = path.read_bytes(), index.read_bytes() + with pytest.raises((ValueError, RuntimeError)): + reconcile_settled_turn_journal( + runtime, registry_path=registry, + settlement_identity=before["plan"]["transaction"]["settlement_plan"]["identity"], + ) + assert pending_journal == path.read_bytes() and pending_ledger == index.read_bytes() + old = next( + t + for t in read_canonical_todos_if_promoted( + runtime_root=runtime, goal_id=GOAL_ID + )["todos"] + if t["todo_id"] == todo + ) + checkpoint = CoordinatorCheckpoints(registry, runtime, GOAL_ID, AGENT_ID) + context = checkpoint.read(todo) + result = checkpoint.resume( + todo, + { + "read_context_id": context["read_context_id"], + "vision_unchanged_reason": "Current original result and obligation preserve the route.", + }, + ) + assert result["ok"] and result["settlement_complete"], result + after = load_turn_journal(path) + assert after["status"] == after["receipt"]["status"] == "committed", after[ + "receipt" + ] + assert ( + after["turn_key"] == before["turn_key"] + and after["receipt"]["settlement_effect_id"] + == result["settlement_identity"]["effect_id"] + ) + assert ( + after["receipt"]["next_phase"] is None + and after["receipt"]["failed_phase"] is None + ) + for k in ["plan", "host_result", "task_validation", "completion_intent"]: + assert after[k] == before[k], k + new = next( + t + for t in read_canonical_todos_if_promoted( + runtime_root=runtime, goal_id=GOAL_ID + )["todos"] + if t["todo_id"] == todo + ) + for k in [ + "completion_turn_key", + "completion_receipt_id", + "completed_at", + "evidence", + "status", + "claimed_by", + ]: + assert old.get(k) == new.get(k), k + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + ledger = index.read_bytes() + original = path.read_bytes() + repeated = reconcile_settled_turn_journal( + runtime, + registry_path=registry, + settlement_identity=result["settlement_identity"], + ) + assert repeated["replayed"] and not repeated["appended"] + assert ledger == index.read_bytes() and original == path.read_bytes() + foreign = {**result["settlement_identity"], "agent_id": "foreign"} + with pytest.raises((ValueError, RuntimeError)): + reconcile_settled_turn_journal( + runtime, registry_path=registry, settlement_identity=foreign + ) + assert ledger == index.read_bytes() and original == path.read_bytes() + assert (project / "host-count.txt").read_text() == "1" and ( + project / "candidate.txt" + ).read_text() == "valid" + rows = [json.loads(line) for line in index.read_text().splitlines()] + assert ( + sum( + r.get("classification") == "quota_slot_spent" + and r.get("settlement_identity") == result["settlement_identity"] + for r in rows + ) + == 1 + ) + (tmp_path / "journal-reconciliation-proof.json").write_text( + json.dumps( + { + "provider": provider, + "before": before, + "after": after, + "native_result": result, + "original_todo_before": old, + "original_todo_after": new, + "host_calls": 1, + "spend_calls": 1, + "repeat_no_effects": True, + "foreign_no_effects": True, + "source_qualification_only": True, + }, + indent=2, + ) + ) diff --git a/tests/control_plane/test_operation_requests.py b/tests/control_plane/test_operation_requests.py index 55c0c64436..c8d31c8456 100644 --- a/tests/control_plane/test_operation_requests.py +++ b/tests/control_plane/test_operation_requests.py @@ -271,8 +271,8 @@ def test_codex_app_server_resolves_ref_and_freezes_profile_while_inline_remains_ ' answer = {name: "" for name, schema in properties.items()', ) assert server != FAKE_SERVER - executable.write_text(server) - executable.chmod(0o700) + from tests.control_plane.host_process_fixture import write_python_host_fixture + executable = write_python_host_fixture(executable, server) options = dict(runtime_root=store.root.parent.parent, registry_path=service.registry_path, project=tmp_path / "project", codex_bin=str(executable), model="test-model", reasoning_effort="xhigh", timeout_seconds=5, operation_request_file=source.path, operation_request_sha256=source.sha256) diff --git a/tests/control_plane/test_project_registry_long_paths.py b/tests/control_plane/test_project_registry_long_paths.py new file mode 100644 index 0000000000..0c2ab1c6bb --- /dev/null +++ b/tests/control_plane/test_project_registry_long_paths.py @@ -0,0 +1,84 @@ +"""Logical registry identities retain real native long-path IO semantics.""" + +import json + +import pytest + +from loopx.control_plane.projects import registry_codec as codec +from loopx.control_plane.runtime.file_paths import windows_extended_path +from tests.control_plane.test_project_registry_codec import _strict + + +def _target(root): + while 293 - len(str(root / "registry.json")) > 121: + root = root / ("p" * 100) + root = root / ("p" * (293 - len(str(root / "registry.json")) - 1)) + path = root / "registry.json" + assert len(str(path)) == 293 and not str(path).startswith("\\\\?\\") + return path + + +def _seed(path, strict): + payload = {"goals": [], "value": "before", "logical_registry": str(path)} + raw = (json.dumps(_strict(payload) if strict else payload, indent=2) + "\n").encode() + address = windows_extended_path(path) + address.parent.mkdir(parents=True) + address.write_bytes(raw) + return payload, raw + + +@pytest.mark.parametrize("strict", [False, True]) +def test_long_registry_commit_keeps_logical_path_and_wire_format(tmp_path, strict): + path = _target(tmp_path) + before, _ = _seed(path, strict) + mode = windows_extended_path(path).stat().st_mode & 0o777 + codec.mutate_project_registry(path, operation="long_commit", reducer=lambda p: p.update(value="after")) + assert codec.load_project_registry(path) == dict(before, value="after") + address = windows_extended_path(path) + assert isinstance(json.loads(address.read_bytes()), list) == strict + assert address.stat().st_mode & 0o777 == mode + assert not list(address.parent.glob(".registry.json.*.tmp")) + + +@pytest.mark.parametrize("strict", [False, True]) +def test_long_registry_failed_readback_restores_original_bytes(tmp_path, monkeypatch, strict): + path = _target(tmp_path) + before, raw = _seed(path, strict) + original = codec._read_document + calls = 0 + + def fail_once(candidate): + nonlocal calls + calls += 1 + if calls == 2: + raise OSError("injected post-write readback failure") + return original(candidate) + + monkeypatch.setattr(codec, "_read_document", fail_once) + with pytest.raises(codec.ProjectRegistryMutationError, match="exact preimage restored"): + codec.mutate_project_registry(path, operation="long_restore", reducer=lambda p: p.update(value="unaccepted")) + assert windows_extended_path(path).read_bytes() == raw + assert codec.load_project_registry(path) == before + assert not list(windows_extended_path(path.parent).glob(".registry.json.*.tmp")) + + +def test_long_registry_creation_and_removal(tmp_path): + path = _target(tmp_path) + payload = {"goals": [], "logical_registry": str(path)} + with codec.project_registry_transaction(path, operation="long_create", create=lambda: payload) as tx: + assert tx.commit(tx.payload_copy()) + assert codec.load_project_registry(path) == payload + with codec.project_registry_transaction(path, operation="long_remove") as tx: + assert tx.remove() + assert not windows_extended_path(path).exists() + + +def test_long_registry_unknown_protocol_rejects_without_changes(tmp_path): + path = _target(tmp_path) + address = windows_extended_path(path) + address.parent.mkdir(parents=True) + raw = (json.dumps(_strict({"goals": []}, protocol="future_writer")) + "\n").encode() + address.write_bytes(raw) + with pytest.raises(codec.ProjectRegistryProtocolError): + codec.mutate_project_registry(path, operation="long_reject", reducer=lambda p: p.update(value="forbidden")) + assert address.read_bytes() == raw diff --git a/tests/control_plane/test_quota_authority_settlement_journey.py b/tests/control_plane/test_quota_authority_settlement_journey.py index 244dfb9da1..242eb11e3f 100644 --- a/tests/control_plane/test_quota_authority_settlement_journey.py +++ b/tests/control_plane/test_quota_authority_settlement_journey.py @@ -63,6 +63,22 @@ def _guard(project: Path, runtime: Path, registry: Path, *, turn_id: str = cli.T ) +def _assert_incomplete_goal_guard(project, runtime, registry, *, code_name): + code, guard = _guard(project, runtime, registry) + assert code != 0 and guard["should_run"] is False, guard + assert guard["error_code"] == code_name, guard + assert "loopx-project" in guard["recommended_action"], guard + assert cli._spend_run_count(runtime) == 0 + assert cli._heartbeat_receipt_count(runtime, cli.TURN_ID) == 1 + assert not list((runtime / "goals" / cli.GOAL_ID / "checkpoint-contexts").glob("*.json")) + readback = read_heartbeat_settlement( + runtime, goal_id=cli.GOAL_ID, agent_id=cli.AGENT_ID, + todo_id=cli.TODO_ID, turn_instance_id=cli.TURN_ID, + ) + assert readback is not None and readback.identity.value is not None + return readback.identity.value.as_dict() + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("status", ["done", "blocked"]) def test_stale_markdown_cannot_admit_terminal_or_blocked_work(tmp_path, provider, status): @@ -81,14 +97,28 @@ def test_stale_markdown_cannot_admit_terminal_or_blocked_work(tmp_path, provider @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_exact_selection_reaches_work_beyond_display_limits(tmp_path, provider): project, runtime, registry, path, prefix = _source(tmp_path, provider=provider) - # Goal intent remains readable, while its bounded Todo display cannot - # supply the exact candidate from the canonical provider. - path.write_text(prefix) + original = None + if provider != "legacy": + path.unlink() + # Native Todo readback survives display loss. It cannot supply the + # separately owned Goal narrative that disappeared with the whole file. + listed = list_goal_todos(registry_path=registry, goal_id=cli.GOAL_ID, + runtime_root_arg=str(runtime), todo_id=cli.TODO_ID) + assert listed["todo"]["todo_id"] == cli.TODO_ID + original = _assert_incomplete_goal_guard( + project, runtime, registry, code_name="checkpoint_goal_source_unavailable", + ) + # The source owner restores the exact saved non-Todo document, without + # repopulating its Todo display or rewriting canonical authority. + path.write_text(prefix) code, guard = _guard(project, runtime, registry) assert code == 0, guard assert guard["decision"] == "run" assert guard["selected_todo"]["todo_id"] == cli.TODO_ID assert guard["heartbeat_receipt"]["settlement_identity"]["todo_id"] == cli.TODO_ID + if original is not None: + assert guard["heartbeat_receipt"]["settlement_identity"] == original + assert cli._heartbeat_receipt_count(runtime, cli.TURN_ID) == 1 def test_unmigrated_goal_cannot_select_work_from_markdown(tmp_path): @@ -233,39 +263,65 @@ def test_semantic_progress_replay_keeps_original_turn_after_todo_wait( "--status", waiting_status, "--resume-when", resume_when, cwd=project, ) assert code == 0 and updated["ok"] is True, json.dumps(updated) - code, recovery = cli._run_cli( - registry, runtime, "quota", "should-run", "--codex-app", - "--goal-id", cli.GOAL_ID, "--agent-id", cli.AGENT_ID, - "--turn-instance-id", cli.TURN_ID, cwd=project, + protected_paths = [ + runtime / "goals" / cli.GOAL_ID / "runs/index.jsonl", + *list((runtime / "goals" / cli.GOAL_ID / "checkpoint-contexts").glob("*.json")), + ] + before_rejection = {path: path.read_bytes() if path.exists() else None + for path in protected_paths} + log_path = rollout_event_log_path(runtime, cli.GOAL_ID) + original_log = log_path.read_bytes() + code, refreshed = _refresh( + project, runtime, registry, + "--vision-state", "vision_on_track", "--vision-summary", "Continue current eligible work.", + "--vision-acceptance", "The waiting Todo remains open; the next Turn selects fresh work.", ) - assert code == 0, recovery - channel = recovery["interaction_contract"]["cli_channel"] - plan = channel.get("settlement_plan") - assert plan is not None, recovery - assert plan["identity"] == original - assert not recovery["normal_delivery_allowed"] - assert not recovery.get("selected_todo") - assert channel["spend_allowed_now"] is False - commands = {step["kind"]: step["command_template"] for step in plan["ordered_steps"]} - from loopx.control_plane.quota.turn_envelope import build_turn_envelope - - envelope = build_turn_envelope(recovery) - assert envelope["writeback"]["settlement_plan"] == plan - code, refused = cli._run_cli( - registry, runtime, "quota", "should-run", "--codex-app", - "--goal-id", cli.GOAL_ID, "--agent-id", cli.AGENT_ID, - "--todo-id", "todo_ready_0", "--turn-instance-id", cli.TURN_ID, cwd=project, + assert code != 0, refreshed + assert cli._spend_run_count(runtime) == 0 + assert {path: path.read_bytes() if path.exists() else None + for path in protected_paths} == before_rejection + # Explicitly requested sink suppression is a separate conservative pause; + # the existing contract retains it even if the later direction is rejected. + # No work, Vision, refresh or spend event may be appended with that pause. + after_log = log_path.read_bytes() + assert after_log.startswith(original_log) + appended = [json.loads(line) for line in after_log[len(original_log):].splitlines()] + assert len(appended) == 1 + pause = appended[0] + assert (pause["event_kind"], pause["status"]) == ("refresh_external_delivery", "paused") + assert (pause["goal_id"], pause["agent_id"], pause["todo_id"], pause["run_id"]) == ( + cli.GOAL_ID, cli.AGENT_ID, cli.TODO_ID, cli.TURN_ID, ) - assert code == 1 and refused["error_code"] == "heartbeat_receipt_identity_conflict", refused - refresh = commands["durable_writeback"].replace( - "", "validated_progress", - ).replace("", "implementation").replace("", "outcome_progress") - refresh += ( - " --vision-state vision_on_track --vision-summary 'Continue current eligible work.'" - " --vision-acceptance 'The waiting Todo remains open; the next Turn selects fresh work.'" - " --no-global-sync --suppress-external-sinks" + assert pause.get("goal_ref") is None + import hashlib + expected_key = hashlib.sha256(json.dumps([ + "refresh_external_delivery_v0", original["effect_id"], None, + ], separators=(",", ":")).encode()).hexdigest() + assert pause["details"] == { + "schema_version": "refresh_external_delivery_v0", + "settlement_effect_id": original["effect_id"], + "replan_obligation_id": None, "state": "paused", "resume_key": expected_key, + } + code, retry_rejected = _refresh( + project, runtime, registry, + "--vision-state", "vision_on_track", "--vision-summary", "Continue current eligible work.", + "--vision-acceptance", "The waiting Todo remains open; the next Turn selects fresh work.", ) - code, refreshed = _execute(refresh, project, runtime, registry) + assert code != 0, retry_rejected + assert log_path.read_bytes() == after_log + assert {path: path.read_bytes() if path.exists() else None + for path in protected_paths} == before_rejection + # Preserve the validated nonterminal outcome, then judge against the new + # native wait/frontier using this original Turn's post-result read. + code, refreshed = _refresh(project, runtime, registry) + assert code == 0, refreshed + assert refreshed["vision_checkpoint"]["satisfied"] is False + binding = ("--agent-id", cli.AGENT_ID, "--todo-id", cli.TODO_ID, + "--turn-instance-id", cli.TURN_ID) + checkpoint_args = cli._default_checkpoint_args( + registry, runtime, binding, tmp_path, purpose="supplement_checkpoint", + ) + code, refreshed = _refresh(project, runtime, registry, *checkpoint_args) assert code == 0, refreshed assert refreshed["vision_checkpoint"]["delivery_boundary"] == "semantic_closeout" assert refreshed["vision_checkpoint"]["satisfied"] is True @@ -490,12 +546,10 @@ def run(*args): assert code == 0 and migrated["status"] == "applied", migrated assert Path(migrated["backup_path"]).is_file() assert migrated["execution_authority_granted"] is False - goal_context = path.read_text().split("## Agent Todo")[0] - path.write_text(goal_context + "## Agent Todo\n\n") - - code, guard = _guard(project, runtime, registry) - assert code == 0 and guard["decision"] == "run", guard - assert guard["heartbeat_receipt"]["settlement_identity"]["todo_id"] == cli.TODO_ID + path.unlink() # Native Todo writes remain independent of the old display. + original = _assert_incomplete_goal_guard( + project, runtime, registry, code_name="checkpoint_goal_source_unavailable", + ) update = ("todo", "update", "--todo-id", cli.TODO_ID, "--agent-id", cli.AGENT_ID, "--text", "Continue after policy migration") # A migrated owner may edit copy, but migration supplies no execution proof. @@ -512,9 +566,40 @@ def run(*args): code, updated = run(*update, "--task-lease-idempotency-key", lease_key, "--task-lease-expected-version", version) assert code == 0 and updated["ok"] is True, updated + assert path.exists() # Native projection repairs only its own Todo sections. + assert 'goal_source_completeness: "todo_projection_only"' in path.read_text() + assert _assert_incomplete_goal_guard( + project, runtime, registry, code_name="checkpoint_goal_source_incomplete", + ) == original + # The exact historical codec has the same incomplete meaning, even though + # earlier releases did not persist its new typed marker. + path.write_text(path.read_text().replace('goal_source_completeness: "todo_projection_only"\n', "")) + assert _assert_incomplete_goal_guard( + project, runtime, registry, code_name="checkpoint_goal_source_incomplete", + ) == original + # A skeleton for another Goal is still incomplete, rather than becoming a + # complete source merely because its ID differs from the current caller. + path.write_text(path.read_text().replace( + f'goal_id: "{cli.GOAL_ID}"', 'goal_id: "another-goal"', + )) + assert _assert_incomplete_goal_guard( + project, runtime, registry, code_name="checkpoint_goal_source_incomplete", + ) == original + path.write_text(prefix) # Restore actual owner prose; no inferred Goal data. + code, guard = _guard(project, runtime, registry) + assert code == 0 and guard["decision"] == "run", guard + assert guard["heartbeat_receipt"]["settlement_identity"] == original + assert cli._heartbeat_receipt_count(runtime, cli.TURN_ID) == 1 code, refreshed = _refresh(project, runtime, registry) assert code == 0, refreshed + if refreshed["vision_checkpoint"]["satisfied"] is False: + binding = ("--agent-id", cli.AGENT_ID, "--todo-id", cli.TODO_ID, + "--turn-instance-id", cli.TURN_ID) + code, refreshed = _refresh(project, runtime, registry, *cli._default_checkpoint_args( + registry, runtime, binding, tmp_path, purpose="supplement_checkpoint", + )) + assert code == 0 and refreshed["vision_checkpoint"]["satisfied"] is True, refreshed command = refreshed["settlement_owed"]["command"] code, spent = _execute(command, project, runtime, registry) assert code == 0 and spent["appended"] is True, spent diff --git a/tests/control_plane/test_quota_cli_projection.py b/tests/control_plane/test_quota_cli_projection.py index 09463544e1..fab657bc24 100644 --- a/tests/control_plane/test_quota_cli_projection.py +++ b/tests/control_plane/test_quota_cli_projection.py @@ -267,6 +267,23 @@ def _items(count: int, *, prefix: str) -> list[dict[str, object]]: ] +def test_disabled_recall_compacts_configuration_only_and_keeps_authority_flags() -> None: + recall = {"status": "disabled", "context": None, "provider_call_count": 0, + "grants_new_action_authority": False, "fail_open": True, + "experiment": {"enabled": False, "automatic_ingest": False, + "config_runtime_route": {"runtime_scope": "project_runtime"}, "host_coverage": ["fixture"]}} + payload = {"goal_id": "fixture", "reward_memory_recall": recall} + compact = compact_quota_should_run_cli_payload(payload)["reward_memory_recall"] + assert compact["provider_call_count"] == 0 and compact["grants_new_action_authority"] is False + assert compact["experiment"]["automatic_ingest"] is False + assert compact["experiment"]["detail_ref"] == "reward-memory experiment-status --goal-id fixture" + assert "host_coverage" not in compact["experiment"] + assert compact_quota_should_run_cli_payload(payload, include_todo_summary_detail=True)["reward_memory_recall"] == recall + for status, enabled in (("provider_unavailable", False), ("disabled", True), ("recalled", True)): + complete = {**recall, "status": status, "experiment": {**recall["experiment"], "enabled": enabled}} + assert compact_quota_should_run_cli_payload({**payload, "reward_memory_recall": complete})["reward_memory_recall"] == complete + + def test_markdown_distinguishes_peer_blockers_from_native_admission() -> None: peer = {"mode": "task_scoped_peer", "execution_scope": "peer_agent_activation", "execution_state": "blocked", "task_selection": "canonical_claimed_candidates", diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index a8dda173d8..5be2480c39 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -233,6 +233,7 @@ def _run_cli( "PYTHONPATH": str(REPO_ROOT), }, ) + assert result.stdout.strip(), (result.returncode, result.stderr) return result.returncode, json.loads(result.stdout) @@ -745,6 +746,33 @@ def _initialize_git_checkout(project: Path) -> None: ) +def _default_checkpoint_args(registry: Path, runtime: Path, binding: tuple[str, ...], root: Path, + *, purpose: str = "first_delivery") -> tuple[str, ...]: + """The fixture Agent consumes a real post-result read before authoring.""" + from tests.control_plane.host_checkpoint_fixture import checkpoint_decision + rc, context = _run_cli(registry, runtime, "checkpoint-context", "--goal-id", GOAL_ID, + *binding, "--purpose", purpose) + assert rc == 0, context + decision = checkpoint_decision(context) + args = ("--checkpoint-read-context", context["read_context_id"]) + if decision.get("agent_vision"): + path = root / "checkpoint-vision.json" + path.write_text(json.dumps(decision["agent_vision"]), encoding="utf-8") + return (*args, "--agent-vision-json", str(path)) + return (*args, "--vision-unchanged-reason", decision["vision_unchanged_reason"]) + + +def _historical_turn(runtime: Path, turn_id: str = TURN_ID) -> None: + """Seed the persisted pre-default contract for historical recovery cases.""" + path = runtime / "goals" / GOAL_ID / "rollout-event-log.jsonl" + rows = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + for row in rows: + if row.get("event_kind") == "quota_should_run" and row.get("run_id") == turn_id: + for field in ("result_freshness", "checkpoint_continuation"): + row.get("details", {}).pop(field, None) + path.write_text("\n".join(json.dumps(row, sort_keys=True) for row in rows) + "\n", encoding="utf-8") + + def _strip_heartbeat_workspace_causality(runtime: Path) -> None: log_path = runtime / "goals" / GOAL_ID / "rollout-event-log.jsonl" events = [ @@ -799,6 +827,7 @@ def await_cycle(*, finished: bool) -> dict[str, Any]: encoding="utf-8", ) monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) monkeypatch.setenv("CODEX_HOME", str(home / ".codex")) monkeypatch.setenv("LOOPX_USAGE_PING_ENDPOINT", "http://127.0.0.1:1/v1/ping") for key in ("CI", "DO_NOT_TRACK", "LOOPX_USAGE_PING", "LOOPX_USAGE_POLICY"): @@ -1023,6 +1052,7 @@ def test_codex_app_refresh_stages_validated_memory_and_spend_finalizes_hook( ) assert complete_rc == 0, complete + checkpoint_args = _default_checkpoint_args(registry_path, runtime, binding, tmp_path) refresh_rc, refresh = _run_cli( registry_path, runtime, @@ -1031,6 +1061,7 @@ def test_codex_app_refresh_stages_validated_memory_and_spend_finalizes_hook( GOAL_ID, "--classification", "reward_memory_app_progress", + *checkpoint_args, "--delivery-batch-scale", "single_surface", "--delivery-outcome", @@ -1262,7 +1293,7 @@ def test_typed_outcome_gap_settles_exact_turn_without_becoming_progress( "turn-after-typed-blocker-settlement", cwd=project, ) - assert next_rc == 0, next_turn + assert next_rc == 0, json.dumps(next_turn, indent=2) assert next_turn["effective_action"] != "unsettled_host_turn_recovery" assert next_turn["selected_todo"]["todo_id"] == ALTERNATIVE_TODO_ID @@ -3049,6 +3080,7 @@ def test_prior_turn_with_several_receipts_recovers_once(tmp_path: Path) -> None: ) assert prior_rc == 0, prior assert prior["heartbeat_receipt"]["closeout_required"] is True + _historical_turn(runtime, prior_turn_id) # A real goal persists more than one guard per Turn: an identity upgrade # append and an older guard that never carried a binding. The newest bound @@ -3324,6 +3356,7 @@ def test_prior_host_closeout_reads_archived_exact_todo( ) assert rc == 0, prior assert prior["heartbeat_receipt"]["closeout_required"] is True + _historical_turn(runtime, prior_turn_id) rc, complete = _run_cli( @@ -3472,12 +3505,14 @@ def test_standard_codex_app_settlement_is_receipted_and_idempotent( assert complete_replay["idempotent_replay"] is True assert complete_replay["settlement_result"]["ok"] is True + checkpoint_args = _default_checkpoint_args(registry_path, runtime, binding, tmp_path) refresh_args = ( "refresh-state", "--goal-id", GOAL_ID, "--classification", "validated_progress", + *checkpoint_args, "--delivery-batch-scale", "single_surface", "--delivery-outcome", @@ -3520,8 +3555,9 @@ def test_standard_codex_app_settlement_is_receipted_and_idempotent( "turn-settlement-cli-successor", ) assert fresh_guard_rc == 0, fresh_guard - assert fresh_guard["selected_todo"]["todo_id"] == successor_id - assert fresh_guard["heartbeat_receipt"]["turn_instance_id"] == "turn-settlement-cli-successor" + assert fresh_guard["first_delivery_progress"]["stage"] == "settlement_pending" + assert fresh_guard["first_delivery_progress"]["settlement_identity"] == identity + assert fresh_guard["recommended_action"] == fresh_guard["first_delivery_progress"]["next_action"] spend_args = ( "quota", @@ -3592,19 +3628,16 @@ def test_standard_codex_app_settlement_is_receipted_and_idempotent( runtime, *original_ack_hint["cli_args"], ) - # The intervening fresh_guard superseded this Turn for host writeback, - # even though its original delivery settlement still replays correctly. - assert ack_rc == 1, ack - assert ack["error_code"] == "SCHEDULER_FOLLOWUP_HEARTBEAT_RECEIPT_STALE" - assert ack["scheduler_state_mutated"] is False - assert ack["write_performed"] is False + # A pending original Turn was recovered rather than superseded by successor selection. + assert ack_rc == 0, ack + assert ack["scheduler_state_mutated"] is True + assert ack["scheduler_commit"]["written"] is True assert ack["appended"] is False - assert load_scheduler_state( - runtime, - goal_id=GOAL_ID, - agent_id=AGENT_ID, - state_key=APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY, - ) is None + scheduler_path = Path(ack["scheduler_state_path"]) + saved = scheduler_path.read_bytes() + rc, replay_ack = _run_cli(registry_path, runtime, *original_ack_hint["cli_args"]) + assert rc == 0 and replay_ack["scheduler_commit"]["replayed"] is True, replay_ack + assert scheduler_path.read_bytes() == saved assert _spend_run_count(runtime) == 1 # Use the admitted successor Turn's hint. Another new Turn would compete @@ -3920,7 +3953,23 @@ def test_same_turn_identityless_guard_upgrades_and_settles_full_chain( "network", ) assert successor_guard_rc == 0, successor_guard - assert successor_guard["selected_todo"]["todo_id"] == successor_id + # Completing the Todo does not discharge the original Turn's checkpoint or + # spend. An identityless wake resumes that Turn before selecting new work. + assert successor_guard["heartbeat_receipt"]["settlement_identity"] == identity + assert not successor_guard.get("selected_todo") + assert _spend_run_count(runtime) == 0 + checkpoint_args = _default_checkpoint_args( + registry_path, runtime, binding, tmp_path, purpose="supplement_checkpoint", + ) + checkpoint_rc, checkpoint = _run_cli( + registry_path, runtime, "refresh-state", "--goal-id", GOAL_ID, + "--classification", "identity_upgrade_validated", + "--delivery-batch-scale", "single_surface", + "--delivery-outcome", "outcome_progress", *binding, *checkpoint_args, + "--no-global-sync", "--suppress-external-sinks", + ) + assert checkpoint_rc == 0, checkpoint + assert checkpoint["vision_checkpoint"]["satisfied"] is True spend_args = ( "quota", @@ -3944,6 +3993,12 @@ def test_same_turn_identityless_guard_upgrades_and_settles_full_chain( assert spend_replay["appended"] is False assert _spend_run_count(runtime) == 1 assert _heartbeat_receipt_count(runtime, TURN_ID) == 2 + successor_guard_rc, successor_guard = _run_cli( + registry_path, runtime, "quota", "should-run", "--codex-app", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--available-capability", "network", ) + assert successor_guard_rc == 0, successor_guard + assert successor_guard["selected_todo"]["todo_id"] == successor_id def test_agent_selects_one_bounded_action_before_delivery_receipt_binding( @@ -4690,7 +4745,7 @@ def test_visible_goal_unbound_spend_recovers_delivery_after_capability_replan( @pytest.mark.parametrize("profile", ["codex_app_ssh_goal", "codex_cli", "ark_managed_agent_goal"]) -def test_unbound_visible_goal_spend_returns_typed_mismatch_without_receipt( +def test_default_visible_goal_admission_still_rejects_unbound_manual_spend( tmp_path: Path, profile: str, ) -> None: project, runtime, registry_path = _write_fixture(tmp_path) @@ -4710,13 +4765,11 @@ def test_unbound_visible_goal_spend_returns_typed_mismatch_without_receipt( ) assert guard_rc == 0, guard actions = guard["interaction_contract"]["cli_channel"]["next_cli_actions"] - assert len(actions) == 1 - if profile == "codex_app_ssh_goal": - assert actions[0].endswith("--begin-turn") - else: - assert "--turn-instance-id" in actions[0] - assert "--begin-turn" not in actions[0] - assert all("spend-slot" not in action for action in actions) + assert len(actions) == 2 + identity = guard["heartbeat_receipt"]["settlement_identity"] + assert "refresh-state" in actions[0] and "quota spend-slot" in actions[1] + assert all(f"--turn-instance-id {identity['turn_instance_id']}" in action for action in actions) + # Default admission does not authorize an unbound manual spend. spend_rc, spend = _run_cli( registry_path, @@ -4740,17 +4793,15 @@ def test_unbound_visible_goal_spend_returns_typed_mismatch_without_receipt( assert spend["delivery_workspace_causality"] is None assert _spend_run_count(runtime) == 0 - # Execute the projected re-entry, filling only the host-owned identity. - command = actions[0].replace( - "", TURN_ID, - ) - bound_rc, bound = _run_generated_cli(command, registry_path=registry_path) + # Re-observe the automatically admitted binding; do not execute placeholder + # work or mint a different Turn during identity qualification. + bound_rc, bound = _run_cli(registry_path, runtime, "quota", "should-run", + "--runtime-profile", profile, "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--todo-id", TODO_ID, "--turn-instance-id", identity["turn_instance_id"], + ) assert bound_rc == 0, bound plan = bound["interaction_contract"]["cli_channel"]["settlement_plan"] - assert plan["identity"]["todo_id"] == TODO_ID - assert plan["identity"]["agent_id"] == AGENT_ID - if profile != "codex_app_ssh_goal": - assert plan["identity"]["turn_instance_id"] == TURN_ID + assert plan["identity"] == identity assert [step["kind"] for step in plan["ordered_steps"]] == [ "validation", "durable_writeback", "quota_spend", "terminal_closeout", ] @@ -6847,7 +6898,7 @@ def test_todoless_blocked_replan_settles_read_only_external_evidence_without_wor assert replay.get("unsettled_host_turn_recovery") is None -def test_unbound_visible_goal_todoless_replan_reenters_through_guided_turn( +def test_default_visible_goal_todoless_replan_replays_original_admission( tmp_path: Path, ) -> None: project, runtime, registry_path = _write_fixture(tmp_path) @@ -6871,15 +6922,13 @@ def test_unbound_visible_goal_todoless_replan_reenters_through_guided_turn( assert unbound["decision"] == "autonomous_replan_required", unbound assert unbound.get("selected_todo") is None, unbound actions = unbound["interaction_contract"]["cli_channel"]["next_cli_actions"] - assert len(actions) == 1 - assert actions[0].endswith("--begin-turn") - assert "refresh-state" not in actions[0] - assert "spend-slot" not in actions[0] - - bound_rc, bound = _run_generated_cli( - actions[0], - registry_path=registry_path, - ) + assert len(actions) == 2 + original = unbound["heartbeat_receipt"]["settlement_identity"] + assert "refresh-state" in actions[0] and "quota spend-slot" in actions[1] + assert all(f"--turn-instance-id {original['turn_instance_id']}" in action for action in actions) + bound_rc, bound = _run_cli(registry_path, runtime, "quota", "should-run", + "--runtime-profile", "codex_app_ssh_goal", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--turn-instance-id", original["turn_instance_id"]) assert bound_rc == 0, bound assert bound["decision"] == "autonomous_replan_required", bound @@ -6888,7 +6937,8 @@ def test_unbound_visible_goal_todoless_replan_reenters_through_guided_turn( identity = bound["heartbeat_receipt"]["settlement_identity"] assert identity["binding_kind"] == "autonomous_replan" assert identity["replan_obligation_id"] == obligation_id - assert identity["turn_instance_id"].startswith("guided-start:") + assert identity == original + assert bound["heartbeat_receipt"]["status"] == "replayed" cli_channel = bound["interaction_contract"]["cli_channel"] assert cli_channel["settlement_plan"]["identity"] == identity assert len(cli_channel["next_cli_actions"]) == 2 @@ -7888,6 +7938,7 @@ def frozen_completion_clock(argv, *args, **kwargs): TURN_ID, ) assert guard_rc == 0, guard + _historical_turn(runtime) assert guard["heartbeat_receipt"]["delivery_workspace_causality"] == { "schema_version": "delivery_workspace_causality_v0", "todo_id": TODO_ID, @@ -8070,7 +8121,7 @@ def frozen_completion_clock(argv, *args, **kwargs): sidecars = runtime / "goals" / GOAL_ID / "post_writeback_hooks" terminal_sidecars = [ path for path in sidecars.glob("*.json") - if json.loads(path.read_text())["source_receipt_id"] + if not path.name.endswith(".lock.holder.json") and json.loads(path.read_text())["source_receipt_id"] == trigger_intent["source_receipt_id"] ] assert len(terminal_sidecars) == 1 diff --git a/tests/control_plane/test_receipt_bound_lifecycle_recovery.py b/tests/control_plane/test_receipt_bound_lifecycle_recovery.py new file mode 100644 index 0000000000..d2e3d8e719 --- /dev/null +++ b/tests/control_plane/test_receipt_bound_lifecycle_recovery.py @@ -0,0 +1,188 @@ +"""Admission-source identity stays immutable; proven lifecycle recovery may advance it.""" +import copy +import json +from unittest.mock import patch + +import sys +from pathlib import Path +sys.path.insert(0,str(Path(__file__).resolve().parents[2]/'tests/control_plane')) +import pytest +from loopx.control_plane.effect_runtime import EffectRuntimeRejected +from loopx.control_plane.work_items import semantic_replan_writeback as writer + +OID = 'replan-0123456789abcdef' +SOURCE = {'obligation_id': OID, 'required': True, + 'resolution_mode': 'todo_lifecycle_settlement', + 'satisfying_semantic_outcomes': ['new_runnable_successor']} +RECOVERED = {**SOURCE, 'resolution_mode': 'receipt_bound_replan_settlement', + 'satisfying_semantic_outcomes': ['fresh_vision_path_outcome']} + + +@pytest.mark.parametrize('case', ['recovered', 'unsettled', 'wrong_candidate', + 'no_candidate', 'unguarded', 'wrong_source', 'no_source', 'non_boolean']) +def test_incomplete_lifecycle_candidate_cannot_replace_receipt_source(case): + candidate = {'lifecycle_settled': True, 'obligation': copy.deepcopy(RECOVERED)} + source = copy.deepcopy(SOURCE) + context = {'replan_obligation': {'obligation_id': 'replan-fedcba9876543210'}, + 'replan_transition_candidates': [candidate]} + if case == "unsettled": + candidate["lifecycle_settled"] = False + if case == "non_boolean": + candidate["lifecycle_settled"] = "true" + if case == "wrong_candidate": + candidate["obligation"]["obligation_id"] = "replan-fedcba9876543210" + if case == "no_candidate": + context["replan_transition_candidates"] = [] + if case == "wrong_source": + source["obligation_id"] = "replan-fedcba9876543210" + if case == "no_source": + source = None + with patch.object(writer, 'build_goal_frontier_projection_context_from_status', return_value=context), \ + patch.object(writer, 'autonomous_replan_obligation_from_runs', return_value=None), \ + patch.object(writer, 'guarded_replan_transition_delta', return_value=None), \ + patch.object(writer, 'semantic_delta_from_writeback', return_value={'outcomes': []}): + def call(): + return writer.qualify_replan_writeback(newest_first_runs=[], state_text='', + agent_id='fixture-agent', goal_id='fixture-goal', todo_fields={}, + guard_scoped=case != 'unguarded', guard_semantic_replan_obligation_id=OID, + receipt_bound_source=source) + if case == 'wrong_source': + with pytest.raises(ValueError, match="source identity differs"): + call() + return + chosen, _ = call() + # A candidate's settled boolean is not native lifecycle proof. These + # intentionally incomplete objects cannot replace the admitted source. + expected = context['replan_obligation'] if case in {'unguarded', 'no_source'} else SOURCE + assert chosen == expected + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +def test_native_lifecycle_writeback_and_replay(tmp_path, monkeypatch, provider): + import test_quota_settlement_cli as cli + from test_declared_terminal_settlement_cli import _fixture + project, workspace, runtime, registry, run, lease, marker, _, original = _fixture(tmp_path, monkeypatch, provider) + base = ['--goal-id', cli.GOAL_ID, '--agent-id', cli.AGENT_ID] + original_binding = [*base, '--todo-id', cli.TODO_ID, '--turn-instance-id', cli.TURN_ID] + code, guard = run('quota', 'should-run', '--codex-app', *original_binding, + '--available-capability', 'shell', '--available-capability', 'filesystem_write') + assert code == 0 and guard['ok'], guard + # Only this isolated fixture seeds the already-supported historical protocol. + cli._historical_turn(runtime) + code, completed = run('todo', 'complete', *original_binding, *lease, + '--project', str(project), '--state-file', str(project / f'.codex/goals/{cli.GOAL_ID}/ACTIVE_GOAL_STATE.md'), + '--evidence', 'Original bounded fixture validator passed.') + assert code == 0 and completed['status'] == 'done', completed + code, refreshed = run('refresh-state', *original_binding, + '--classification', 'declared_deliverable_complete', '--delivery-batch-scale', 'implementation', + '--delivery-outcome', 'outcome_progress', '--vision-state', 'vision_closed', + '--vision-summary', 'The original bounded fixture deliverable passed.', + '--vision-acceptance', 'Retain original acceptance and exact result.', '--no-global-sync', '--suppress-external-sinks') + assert code == 0 and refreshed['ok'], refreshed + code, spent = run('quota', 'spend-slot', *original_binding, '--execute', ) + assert code == 0 and spent['ok'] and cli._spend_run_count(runtime) == 1, spent + + turn = 'receipt-bound-review' + guardargs = ['quota', 'should-run', '--codex-app', *base, '--turn-instance-id', turn] + code, admitted = run(*guardargs) + assert code == 0 and admitted['ok'], admitted + receipt = admitted['heartbeat_receipt'] + oid = receipt['semantic_replan_obligation_id'] + assert receipt['semantic_replan_source']['obligation']['resolution_mode'] == 'todo_lifecycle_settlement', admitted + source_bytes = json.dumps(receipt['semantic_replan_source'], sort_keys=True) + closeargs = ['todo', 'complete', *base, '--todo-id', cli.TODO_ID, + '--completion-identity-key', completed['completion_identity_key'], '--no-follow-up'] + code, closed = run(*closeargs) + assert code == 0 and closed['completion_recovery'] == 'lifecycle_reentry_terminal_closeout', closed + assert closed['completion_receipt_id'] == completed['completion_receipt_id'] + code, recovered = run(*guardargs) + assert code == 0 and recovered['ok'], recovered + assert recovered['autonomous_replan_obligation']['obligation_id'] == oid + assert recovered['autonomous_replan_obligation']['resolution_mode'] == 'receipt_bound_replan_settlement', recovered + assert recovered['heartbeat_receipt']['event_id'] == receipt['event_id'] + assert json.dumps(recovered['heartbeat_receipt']['semantic_replan_source'], sort_keys=True) == source_bytes + vision = {'schema_version': 'goal_vision_replan_contract_v0', 'state': 'vision_patch_proposed', + 'vision_patch': {'acceptance_summary': 'Original deliverable is verified; retain acceptance and evidence.', + 'advancement_policy': 'repeat_until_closed', 'replan_trigger_summary': 'Audit any later scope independently.'}, + 'path_delta': {'schema_version': 'goal_path_delta_v0', 'outcome': 'replan', + 'prior_assumption': 'The completed Todo still needed lifecycle closeout.', + 'observed_reality': 'Native original-identity terminal reentry is now proven.', + 'retained': ['Original acceptance and immutable admitted source.'], + 'evidence_refs': ['native:' + closed['completion_receipt_id']]}} + vp = tmp_path / "vision173.json" + vp.write_text(json.dumps(vision), encoding="utf-8") + binding = [*base, '--turn-instance-id', turn, '--replan-obligation-id', oid] + refresh = ['refresh-state', *binding, '--classification', 'receipt-bound-review', + '--progress-scope', 'agent_lane', '--delivery-batch-scale', 'single_surface', + '--delivery-outcome', 'outcome_gap', '--progress-result-class', 'blocked', + '--progress-blocker-id', 'blocker:later-independent-scope', + '--progress-evidence-id', 'native:' + closed['completion_receipt_id'], + '--agent-vision-json', str(vp), '--no-global-sync', '--suppress-external-sinks'] + code, written = run(*refresh) + assert code == 0 and written['ok'], written + delta = written['autonomous_replan_ack']['semantic_delta'] + assert delta['accepted'] is True and delta['obligation_id'] == oid, written + assert cli._spend_run_count(runtime) == 1 + spend = ['quota', 'spend-slot', *binding, '--execute', ] + code, settled = run(*spend) + assert code == 0 and settled['ok'], settled + from loopx.control_plane.quota.settlement import read_heartbeat_settlement + ledger = read_heartbeat_settlement(runtime, goal_id=cli.GOAL_ID, agent_id=cli.AGENT_ID, + todo_id=None, turn_instance_id=turn, replan_obligation_id=oid, registry_path=registry) + assert ledger is not None and ledger.settlement.failure is None + # Main's blocked-retry closeout is exempt: preserve the original debit, + # with no new debit for this evidence-linked blocked outcome. + assert ledger.spend_run is None and cli._spend_run_count(runtime) == 1 + for args in (refresh, spend, closeargs): + code, replay = run(*args) + assert code == 0 and replay['idempotent_replay'] is True, replay + assert cli._spend_run_count(runtime) == 1 and marker.read_text() == 'run\n' + code, current = run('quota', 'should-run', '--codex-app', *base, '--turn-instance-id', 'lifecycle173-later') + assert code == 0 and current['ok'], current + assert current['heartbeat_receipt'].get('semantic_replan_obligation_id') != oid + + +@pytest.mark.parametrize('case', ['matching', 'wrong_turn', 'wrong_actor', 'unsettled', 'closed_before_guard', 'wrong_todo']) +def test_original_source_cardinality(case): + items = [{'todo_id':f'todo_{i}', 'index':i, 'status':'done', 'done':True, + 'task_class':'advancement_task', 'claimed_by':'fixture-agent', 'last_actor_agent_id':'fixture-agent', + 'completed_at':f'2026-08-13T04:0{9-i}:00Z', 'updated_at':'2026-08-13T05:01:00Z', + 'no_followup':i<3, 'completion_continuation':'no_followup' if i<3 else 'active_goal', + 'completion_recovery':'lifecycle_reentry_terminal_closeout' if i<3 else None, + 'completion_turn_key':f'original-turn-{i}'} for i in range(7)] + source = {**SOURCE, 'agent_id':'fixture-agent', 'trigger_count':5, + 'triggers':[{'kind':'completed_advancement_without_successor','agent_id':'fixture-agent', + 'todo_id':f'todo_{i}','completion_turn_key':f'original-turn-{i}'} for i in range(3)]} + if case == "wrong_turn": + source["triggers"][0]["completion_turn_key"] = "wrong" + if case == "wrong_actor": + items[0]["last_actor_agent_id"] = "peer" + if case == "unsettled": + items[0]["no_followup"] = False + if case == "closed_before_guard": + items[0]["updated_at"] = "2026-08-13T04:50:00Z" + if case == "wrong_todo": + source["triggers"][0]["todo_id"] = "wrong" + with patch.object(writer,'build_goal_frontier_projection_context_from_status',return_value={}), \ + patch.object(writer,'autonomous_replan_obligation_from_runs',return_value=None), \ + patch.object(writer,'guarded_replan_transition_delta',return_value=None), \ + patch.object(writer,'select_planning_inventory_source_items',return_value=items), \ + patch.object(writer,'semantic_delta_from_writeback',return_value={'outcomes':[]}): + def call(): + return writer.qualify_replan_writeback(newest_first_runs=[],state_text='',agent_id='fixture-agent', + goal_id='fixture-goal',todo_fields={},guard_scoped=True,guard_semantic_replan_obligation_id=OID, + receipt_bound_guard_recorded_at='2026-08-13T05:00:00Z',receipt_bound_source=source) + if case not in {'matching', 'unsettled'}: + with pytest.raises( + EffectRuntimeRejected, match="lifecycle source does not match" + ): + call() + return + chosen,_=call() + assert chosen['obligation_id']==OID + if case=='matching': + assert chosen['resolution_mode']=='receipt_bound_replan_settlement' + assert 'fresh_vision_path_outcome' in chosen['satisfying_semantic_outcomes'] + else: + assert chosen['resolution_mode'] == 'todo_lifecycle_settlement' + assert chosen['satisfying_semantic_outcomes'] == SOURCE['satisfying_semantic_outcomes'] diff --git a/tests/control_plane/test_scheduler_execution_context.py b/tests/control_plane/test_scheduler_execution_context.py index 21063780c0..36c76649f3 100644 --- a/tests/control_plane/test_scheduler_execution_context.py +++ b/tests/control_plane/test_scheduler_execution_context.py @@ -12,6 +12,7 @@ ExecutionMode, HostSurface, SchedulerOwner, + SchedulerExecutionContextResolution, SchedulerRuntimeProfile, build_goal_runtime_continuation, render_scheduler_execution_args, @@ -1190,6 +1191,31 @@ def test_first_class_runtime_profiles_round_trip_to_compact_args( ) +def test_absent_scheduler_context_renders_no_host_even_after_resolution(): + absent = resolve_scheduler_execution_context(None) + assert not absent.ok and absent.context is None + assert render_scheduler_execution_args(scheduler_execution_context=None) == '' + assert render_scheduler_execution_args(scheduler_execution_context=absent) == '' + + +@pytest.mark.parametrize('context', [{}, {'host_surface': 'codex_app'}, + {'host_surface': 'codex_app', 'scheduler_owner': 'none', 'execution_mode': 'interactive'}]) +def test_invalid_explicit_scheduler_context_is_not_rendered_as_absent(context): + for value in [context, resolve_scheduler_execution_context(context)]: + with pytest.raises(ValueError, match='invalid scheduler_execution_context'): + render_scheduler_execution_args(scheduler_execution_context=value) + + +def test_typed_scheduler_resolution_without_supplied_metadata_retains_context(): + resolved = scheduler_execution_context_for_runtime_profile(SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT) + typed = SchedulerExecutionContextResolution(context=resolved.context) + assert typed.supplied is None and typed.ok + assert render_scheduler_execution_args(scheduler_execution_context=typed) == " --codex-app" + rejected = SchedulerExecutionContextResolution(context=None, errors=("invalid owner",)) + with pytest.raises(ValueError, match="invalid scheduler_execution_context"): + render_scheduler_execution_args(scheduler_execution_context=rejected) + + def test_codex_profile_does_not_admit_children_without_observed_spawn() -> None: context = scheduler_execution_context_for_runtime_profile( SchedulerRuntimeProfile.CODEX_CLI_VISIBLE diff --git a/tests/control_plane/test_settled_mcp_replay.py b/tests/control_plane/test_settled_mcp_replay.py new file mode 100644 index 0000000000..067b53ed5a --- /dev/null +++ b/tests/control_plane/test_settled_mcp_replay.py @@ -0,0 +1,85 @@ +"""Settled MCP retries recover their original native completion fingerprint.""" + +from __future__ import annotations + +import json + +import pytest + +from tests.control_plane.test_default_checkpoint_continuation import _fixture +from tests.test_goal_mode_mcp_settlement import AGENT_ID, GOAL_ID, _control, _run_cli + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("repeat_decision", [False, True]) +def test_settled_mcp_completion_replays_original_request( + provider, repeat_decision, tmp_path, monkeypatch +): + registry, state, todo_id = _fixture(provider, tmp_path, monkeypatch) + control = _control(registry) + guard = json.loads(control.should_run()) + identity = guard["delivery_result_context"]["settlement_identity"] + intent = {"next_agent_todo": "Verify remaining fixture work."} + first = json.loads( + control.complete_task(todo_id, AGENT_ID, "Candidate verified.", **intent) + ) + assert first["stage"] == "direction_pending" + decision = { + "read_context_id": first["context"]["read_context_id"], + "vision_unchanged_reason": "The verified candidate leaves remaining fixture work open.", + } + settled = json.loads( + _control(registry).complete_task( + todo_id, AGENT_ID, "Candidate verified.", **intent, **decision + ) + ) + assert settled["ok"] and settled["settlement_complete"] + assert settled["settlement_identity"] == identity + _, before = _run_cli( + registry, "todo", "list", "--goal-id", GOAL_ID, "--todo-id", todo_id + ) + index = tmp_path / "runtime" / "goals" / GOAL_ID / "runs" / "index.jsonl" + original_index = index.read_bytes() + replay = json.loads( + _control(registry).complete_task( + todo_id, + AGENT_ID, + "Candidate verified.", + **intent, + **(decision if repeat_decision else {}), + ) + ) + assert replay.get("ok") and replay.get("settlement_complete"), replay + assert replay["settlement_identity"] == identity + _, after = _run_cli( + registry, "todo", "list", "--goal-id", GOAL_ID, "--todo-id", todo_id + ) + assert after["todo"] == before["todo"] + assert index.read_bytes() == original_index + assert ( + sum( + json.loads(line).get("classification") == "quota_slot_spent" + for line in index.read_text().splitlines() + ) + == 1 + ) + foreign = json.loads( + _control(registry).complete_task( + todo_id, "foreign-agent", "Candidate verified.", **intent, **decision + ) + ) + assert foreign.get("ok") is False + assert index.read_bytes() == original_index + if provider != "legacy": + changed = json.loads( + _control(registry).complete_task( + todo_id, AGENT_ID, "Changed completion evidence.", **intent, **decision + ) + ) + assert changed.get("ok") is False + assert changed.get("error_code") == "coordination_operation_identity_mismatch" + assert index.read_bytes() == original_index + _, unchanged = _run_cli( + registry, "todo", "list", "--goal-id", GOAL_ID, "--todo-id", todo_id + ) + assert unchanged["todo"] == before["todo"] diff --git a/tests/control_plane/test_superseded_turn_settlement_cli.py b/tests/control_plane/test_superseded_turn_settlement_cli.py index f80d804aee..cc29d6fd55 100644 --- a/tests/control_plane/test_superseded_turn_settlement_cli.py +++ b/tests/control_plane/test_superseded_turn_settlement_cli.py @@ -39,7 +39,7 @@ def _future_monitor(run, lease, *, link=True): @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("unscoped_first", [False, True]) @pytest.mark.parametrize("direct_link", [False, True]) -def test_supersede_existing_future_monitor_settles_only_original_turn( +def test_supersede_turn_binding_preserves_existing_future_monitor( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, unscoped_first: bool, direct_link: bool, ) -> None: @@ -75,11 +75,32 @@ def test_supersede_existing_future_monitor_settles_only_original_turn( assert retired["settlement_identity"] == identity assert retired["changed"] is (not unscoped_first) assert not marker.exists() # Superseding is not deliverable acceptance. + if not unscoped_first: + code, not_accepted = run("todo", "complete", "--goal-id", cli.GOAL_ID, *binding) + assert code == 1 and not_accepted.get("completed") is not True, not_accepted + assert not marker.exists() + code, direction = run("checkpoint-context", "--goal-id", cli.GOAL_ID, *binding, + "--purpose", "first_delivery") + if unscoped_first: + # Legacy retirement carries no original Turn proof. A later caller must + # not adopt it merely because it now has an admitted heartbeat. + assert code == 1 and direction["error_code"] == "checkpoint_result_not_committed", direction + code, listed = run("todo", "list", "--goal-id", cli.GOAL_ID) + assert code == 0, listed + source = next(t for t in listed["todos"] if t["todo_id"] == cli.TODO_ID) + monitor = next(t for t in listed["todos"] if t["todo_id"] == monitor_id) + assert source.get("completion_turn_key") is None + assert source.get("completion_receipt_id") is None + assert monitor["status"] == "open" and monitor["next_due_at"] == due + assert cli._spend_run_count(runtime) == 0 + return + assert code == 0 and direction["basis"]["todo"]["completion_turn_key"] == identity["effect_id"], direction refresh_args = ( "refresh-state", "--goal-id", cli.GOAL_ID, *binding, "--classification", "original_window_superseded", "--delivery-batch-scale", "implementation", "--delivery-outcome", "outcome_progress", "--vision-state", "active", + "--first-delivery", "--checkpoint-read-context", direction["read_context_id"], "--vision-summary", "Retired an expired task while preserving its future observation.", "--vision-acceptance", "The future monitor remains open with its original due time.", "--no-global-sync", "--suppress-external-sinks", @@ -109,6 +130,9 @@ def test_supersede_existing_future_monitor_settles_only_original_turn( monitor = next(t for t in listed["todos"] if t["todo_id"] == monitor_id) assert source["status"] == "done" and source["successor_todo_ids"] == [monitor_id] assert source["completion_continuation"] == "active_goal" + # Turn-scoped retirement is durable, but does not fabricate acceptance. + assert source.get("completion_turn_key") == (None if unscoped_first else identity["effect_id"]) + assert source.get("completion_receipt_id") is None assert source["completion_validation_sha256"] == original["completion_validation_sha256"] assert source["required_write_scopes"] == original["required_write_scopes"] assert monitor["status"] == "open" and monitor["next_due_at"] == due diff --git a/tests/control_plane/test_todo_completion_validation.py b/tests/control_plane/test_todo_completion_validation.py index eb9b685b45..c2d97779a4 100644 --- a/tests/control_plane/test_todo_completion_validation.py +++ b/tests/control_plane/test_todo_completion_validation.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import re import shlex import subprocess @@ -901,15 +902,16 @@ def test_private_validation_store_is_owner_only_and_detects_tampering( todo_id="todo_private_validation", ) - assert path.stat().st_mode & 0o777 == 0o600 - assert ( - read_completion_validation_declaration( - runtime_root=tmp_path, - goal_id=GOAL_ID, - todo_id="todo_private_validation", - ) - == declaration - ) + if os.name == "posix": + assert path.stat().st_mode & 0o777 == 0o600 + else: + from tests.control_plane.windows_private_acl import assert_private_acl + assert_private_acl(path) + assert read_completion_validation_declaration( + runtime_root=tmp_path, + goal_id=GOAL_ID, + todo_id="todo_private_validation", + ) == declaration assert digest == completion_validation_declaration_sha256(declaration) stored = json.loads(path.read_text(encoding="utf-8")) diff --git a/tests/control_plane/test_todo_projection_recovery.py b/tests/control_plane/test_todo_projection_recovery.py index 91f96c1e18..8c36e4a5ea 100644 --- a/tests/control_plane/test_todo_projection_recovery.py +++ b/tests/control_plane/test_todo_projection_recovery.py @@ -448,3 +448,16 @@ def test_objective_display_never_changes_canonical_authority(canonical_display, code, result = _cli(registry, "list", "--goal-id", "goal-a") assert code == 0 and next(row for row in result["todos"] if row["role"] == "agent")["todo_id"] == "todo_active", result assert _read(runtime) == before +def test_legacy_projection_codec_does_not_classify_owner_goal_prose(): + from loopx.control_plane.todos.projection_document import ( + is_legacy_todo_projection_skeleton, recovered_todo_projection_skeleton, + ) + document = recovered_todo_projection_skeleton("codec-goal") + legacy = document.replace('goal_source_completeness: "todo_projection_only"\n', "") + assert is_legacy_todo_projection_skeleton(legacy, "codec-goal") + assert not is_legacy_todo_projection_skeleton(legacy, "different-goal") + # Exact old codec adaptation does not use a phrase/heading denylist. + for narrative in ("## Objective\nReal owner intent.\n", + "## Objective\n" + "\n".join("> " + line for line in legacy.splitlines()) + "\n", + "## Objective\n```markdown\n" + legacy + "\n```\n"): + assert not is_legacy_todo_projection_skeleton(legacy + narrative, "codec-goal") diff --git a/tests/control_plane/test_turn_completion_source.py b/tests/control_plane/test_turn_completion_source.py index 69732e7d34..81c6506ae9 100644 --- a/tests/control_plane/test_turn_completion_source.py +++ b/tests/control_plane/test_turn_completion_source.py @@ -19,11 +19,28 @@ from tests.test_loopx_turn_driver import ( _completion_host_and_validation_scripts, _turn_journal, - _turn_run_once_completion_argv, + _turn_run_once_completion_argv as _source_completion_argv, _write_live_fixture, ) +def _turn_run_once_completion_argv(project, runtime, registry, host_script, validation_script, *, turn_id=None): + # These source-witness regressions preserve the pre-freshness Turn contract. + # Default result/Agent continuation has its own real-provider regressions. + # Seed the supported historical receipt before execution, never downgrade + # an admission produced by the current default. + argv = _source_completion_argv(project, runtime, registry, host_script, validation_script) + code, preview = _cli([], *argv[:-1], *(["--turn-instance-id", turn_id] if turn_id else [])) + assert code == 0, preview + from loopx.control_plane.quota.heartbeat_receipt import ensure_turn_heartbeat_settlement_receipt + from loopx.control_plane.quota.settlement import SettlementIdentity + ensure_turn_heartbeat_settlement_receipt(runtime, SettlementIdentity( + goal_id="loopx-turn-fixture", agent_id="codex-fixture", todo_id="todo_fixture0001", + turn_instance_id=turn_id or preview["resume_turn_key"]), semantic_replan_guard_scoped=True, + semantic_replan_obligation_id=None) + return argv + + @pytest.mark.parametrize( "carrier", ["canonical_detail", "deduplicated_text", "elided_context", "recommendation_ref"], @@ -207,6 +224,7 @@ def recover_old_writer(**kwargs): # Recovery reads the original accepted completion; it must not invoke the # lifecycle writer again after its execution lease was retired. assert len(results) == 1 + assert _todo(prefix)["todo"]["completion_receipt_id"] == results[0]["completion_receipt_id"] recovered = _turn_journal(runtime) assert recovered["writeback"]["completion"] == { "todo_id": "todo_fixture0001", "continuation": "active_goal", @@ -270,7 +288,7 @@ def _update( @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("change", ["requirements", "note", "other_todo"]) @pytest.mark.parametrize("no_followup", [False, True]) -def test_native_turn_completion_checks_full_source_and_preserves_unrelated_changes( +def test_historical_native_turn_completion_checks_full_source_and_preserves_unrelated_changes( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, @@ -389,7 +407,7 @@ def change_after_real_validation(*args, **kwargs): ) fresh_validator = validator.replace('== "completed"', '== "checksum:verified"') fresh = _turn_run_once_completion_argv( - project, runtime, registry, fresh_host, fresh_validator + project, runtime, registry, fresh_host, fresh_validator, turn_id="fresh-checksum-turn" ) fresh_code, accepted = _cli( [], *fresh[:-1], "--turn-instance-id", "fresh-checksum-turn", "--execute" @@ -411,7 +429,7 @@ def change_after_real_validation(*args, **kwargs): @pytest.mark.parametrize("provider", ["file", "sqlite"]) -def test_native_turn_recovers_accepted_completion_before_checking_new_text( +def test_historical_native_turn_recovers_accepted_completion_before_checking_new_text( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, @@ -502,6 +520,7 @@ def lose_response_after_commit(**kwargs): # Recovery reads the original accepted completion; it must not invoke the # lifecycle writer again after its execution lease was retired. assert len(completions) == 1 + assert _todo(prefix)["todo"]["completion_receipt_id"] == completions[0]["completion_receipt_id"] recovered = _turn_journal(runtime) assert recovered["writeback"]["completion"] == { "todo_id": "todo_fixture0001", "continuation": "active_goal", diff --git a/tests/control_plane/test_turn_host_decision_capture.py b/tests/control_plane/test_turn_host_decision_capture.py index 804ee99515..f63bfebe80 100644 --- a/tests/control_plane/test_turn_host_decision_capture.py +++ b/tests/control_plane/test_turn_host_decision_capture.py @@ -3,9 +3,9 @@ import importlib.util import json +import os import shlex import subprocess -import sys from pathlib import Path import pytest @@ -50,8 +50,10 @@ def test_real_host_reads_original_capture_after_source_change( todos=summary["agent_todos"]["items"]), state_path=state, provider=provider) executable = f._write_fake_codex(tmp_path) - source = executable.read_text().replace("#!/usr/bin/env python3", "#!" + sys.executable) - source = source.replace("prompt = sys.stdin.read()", 'prompt = sys.stdin.read()\npathlib.Path("received.txt").write_text(prompt)') + source_path = executable.with_suffix(".py") if os.name == "nt" else executable + source = source_path.read_text() + source = source.replace("marker = pathlib.Path(", + 'pathlib.Path("received.txt").write_text(prompt)\nmarker = pathlib.Path(') if first_attempt_fails: source = source.replace("marker = pathlib.Path(", 'failed = pathlib.Path("failed-once")\n' @@ -61,7 +63,8 @@ def test_real_host_reads_original_capture_after_source_change( ' print("Selected model is at capacity. Please try a different model.", file=sys.stderr)\n' ' raise SystemExit(9)\n' 'marker = pathlib.Path(') - executable.write_text(source) + from tests.control_plane.host_process_fixture import write_python_host_fixture + executable = write_python_host_fixture(tmp_path / "captured-fake-codex", source) base = f._base_argv(registry=registry, runtime=runtime, workspace=workspace, codex_bin=executable, model=None, timeout_seconds=20, expected_marker=f._marker_value(1), turn_instance_id="original-observation") @@ -83,7 +86,8 @@ def test_real_host_reads_original_capture_after_source_change( assert capture["turn_key"] == request["turn_key"] path = Path(capture["path"]) original = path.read_bytes() - assert path.stat().st_mode & 0o077 == 0 + if os.name != "nt": # POSIX mode bits do not describe Windows ACLs. + assert path.stat().st_mode & 0o077 == 0 if legacy_pretty: # Model an observation produced by the older writer. Recovery must # preserve those original bytes rather than rewriting its layout. @@ -137,10 +141,14 @@ def test_real_host_reads_original_capture_after_source_change( path.write_bytes(original) code, recovered = f._run_cli([*replay, "--resume-turn-key", request["turn_key"], "--retry-failed-turn", "--execute"]) - assert code == 0 and recovered["status"] == "committed", json.dumps(recovered) + # The original observation stays immutable, but SOURCE_B is now the + # authoritative requirement. A retry of old work must fail freshness + # before any debit; a new observation cannot silently replace it. + assert code == 1 and recovered["validation_stage"] == "result_freshness", recovered retried = json.loads((workspace / "received.txt").read_text().split("Turn request:\n", 1)[1]) assert retried["turn_envelope"] == request["turn_envelope"] - assert path.read_bytes() == original + assert f._quota_spend_count(runtime) == 0 and path.read_bytes() == original + return code, repeated = f._run_cli([*replay, "--resume-turn-key", request["turn_key"], "--execute"]) assert code == 0, repeated assert not any(repeated["effects"].values()) diff --git a/tests/control_plane/test_turn_interruption_recovery.py b/tests/control_plane/test_turn_interruption_recovery.py index f767c4142b..d0ca3397fa 100644 --- a/tests/control_plane/test_turn_interruption_recovery.py +++ b/tests/control_plane/test_turn_interruption_recovery.py @@ -150,7 +150,7 @@ def setup_export(tmp_path, monkeypatch, provider, fifo): executable.write_text( executable.read_text() .replace("#!/usr/bin/env python3", "#!" + sys.executable) - .replace("prompt = sys.stdin.read()", "prompt = sys.stdin.read()\n" + injected) + .replace("marker = pathlib.Path(", injected + "\nmarker = pathlib.Path(") ) base = f._base_argv( registry=registry, diff --git a/tests/control_plane/test_validation_process_cleanup.py b/tests/control_plane/test_validation_process_cleanup.py index 059549904d..4d29330eef 100644 --- a/tests/control_plane/test_validation_process_cleanup.py +++ b/tests/control_plane/test_validation_process_cleanup.py @@ -250,6 +250,23 @@ def read(): process.communicate(timeout=5) +def test_host_adapter_can_isolate_protocol_stdin() -> None: + inner = "import sys;assert sys.stdin.read()==''" + outer = ( + "import json,sys,subprocess;from pathlib import Path;" + "from loopx.control_plane.runtime.validation_command import run_caller_validation;" + f"print(json.dumps(run_caller_validation(Path.cwd(),validation_argv=[sys.executable,'-c',{inner!r}]," + "validation_label='Isolated protocol stdin',timeout_seconds=3,stdin=subprocess.DEVNULL)))" + ) + proc = subprocess.run( + [sys.executable, "-c", outer], + input="synthetic protocol input", text=True, capture_output=True, + cwd=Path(__file__).resolve().parents[2], timeout=5, + ) + assert proc.returncode == 0, proc.stderr + assert json.loads(proc.stdout)["passed"] is True + + @pytest.mark.skipif(os.name != "posix", reason="POSIX process-group regression") @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_real_cli_timeout_keeps_canonical_todo_open_and_declaration_unchanged( diff --git a/tests/control_plane/test_windows_host_process.py b/tests/control_plane/test_windows_host_process.py new file mode 100644 index 0000000000..0ea0d92225 --- /dev/null +++ b/tests/control_plane/test_windows_host_process.py @@ -0,0 +1,124 @@ +"""Real Windows descendant cleanup, including loss of the Python owner.""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +from loopx.control_plane.turn_driver import host_process_transport as transport + +pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="Windows Job Object transport") + + +def host_source(marker: Path, pid: Path, *, exit_leader: bool = False) -> str: + child = ( + "import os,time;from pathlib import Path;" + f"Path({str(pid)!r}).write_text(str(os.getpid()));" + f"p=Path({str(marker)!r});" + "\nwhile True:\n with p.open('a') as f:f.write('x');f.flush()\n time.sleep(.02)" + ) + return ( + "import subprocess,sys,time;from pathlib import Path;" + f"subprocess.Popen([sys.executable,'-c',{child!r}],stdin=subprocess.DEVNULL," + "stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,close_fds=True);" + f"p=Path({str(marker)!r});" + "\nwhile not p.exists():time.sleep(.01)\n" + "print('started',flush=True)\n" + ("" if exit_leader else "time.sleep(60)") + ) + + +def clean_fixture_child(pid: Path) -> None: + # Only a child this test actually launched and recorded; never enumerate processes. + if pid.exists(): + try: + os.kill(int(pid.read_text()), 9) + except (ProcessLookupError, PermissionError, OSError): + pass + + +@pytest.mark.parametrize("boundary", ["leader_exit", "timeout", "consumer_failure"]) +def test_windows_real_host_descendants_stop_before_return(tmp_path: Path, boundary: str) -> None: + marker, pid, record = (tmp_path / name for name in ("writes", "child.pid", "host.json")) + environment = {**os.environ, transport.HOST_PROCESS_RECORD_ENV: str(record)} + def consume(_text: str) -> None: + if boundary == "consumer_failure": + raise ValueError("consumer rejected") + try: + def run(): + return transport.run_host_process( + [sys.executable, "-c", host_source(marker, pid, exit_leader=boundary == "leader_exit")], + project=tmp_path, input_text="", timeout_seconds=2, + environment=environment, on_stdout=consume, + ) + if boundary == "consumer_failure": + with pytest.raises(ValueError, match="consumer rejected"): + run() + else: + result = run() + assert result["outcome"] == ("exited" if boundary == "leader_exit" else "timeout") + assert marker.exists(), "The real descendant must have executed" + assert transport.execution_host_drain(record) == transport.HOST_PROCESS_DRAINED + before = marker.read_bytes() + time.sleep(.2) + assert marker.read_bytes() == before, "A descendant continued writing after cleanup" + finally: + clean_fixture_child(pid) + + +def test_windows_cold_readback_after_owner_kill_sees_whole_job_exit(tmp_path: Path) -> None: + marker, pid, record = (tmp_path / name for name in ("writes", "child.pid", "host.json")) + argv = [sys.executable, "-c", host_source(marker, pid)] + source = ( + "from pathlib import Path;from loopx.control_plane.turn_driver.host_process_transport " + f"import run_host_process;run_host_process({argv!r},project=Path({str(tmp_path)!r})," + "input_text='',timeout_seconds=60)" + ) + owner = subprocess.Popen([sys.executable, "-c", source], + env={**os.environ, transport.HOST_PROCESS_RECORD_ENV: str(record)}, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + deadline = time.monotonic() + 10 + while not marker.exists() and time.monotonic() < deadline: + time.sleep(.02) + assert marker.exists(), "Host never launched" + assert transport.execution_host_drain(record) == transport.HOST_PROCESS_DRAINING + owner.kill() + owner.wait(timeout=5) + deadline = time.monotonic() + 5 + while transport.execution_host_drain(record) != transport.HOST_PROCESS_DRAINED and time.monotonic() < deadline: + time.sleep(.02) + assert transport.execution_host_drain(record) == transport.HOST_PROCESS_DRAINED + before = marker.read_bytes() + time.sleep(.2) + assert marker.read_bytes() == before + finally: + if owner.poll() is None: + owner.kill() + owner.wait(timeout=5) + clean_fixture_child(pid) + + +def test_windows_legacy_finished_pid_does_not_prove_job_drain(tmp_path: Path) -> None: + record = tmp_path / "old.json" + from loopx.file_lock import lock_holder_host_label + record.write_text(json.dumps({"schema_version": transport.HOST_PROCESS_RECORD_SCHEMA_VERSION, + "host": lock_holder_host_label(), "supervises": "host", "supervision": "direct", + "phase": "finished", "bridge_pid": 9999999, "process_group": None})) + assert transport.execution_host_drain(record) == transport.HOST_PROCESS_UNSUPPORTED_PLATFORM + + +def test_windows_job_assignment_failure_launches_no_host(tmp_path: Path, monkeypatch) -> None: + from loopx.control_plane.turn_driver.windows_host_job import WindowsHostJob + marker = tmp_path / "must-not-execute" + def deny(_self, _process): + raise PermissionError("assignment rejected") + monkeypatch.setattr(WindowsHostJob, "assign", deny) + with pytest.raises(PermissionError, match="assignment rejected"): + transport.run_host_process([sys.executable, "-c", f"from pathlib import Path;Path({str(marker)!r}).write_text('bad')"], + project=tmp_path, input_text="", timeout_seconds=5) + assert not marker.exists() diff --git a/tests/control_plane/test_windows_snapshot_privacy.py b/tests/control_plane/test_windows_snapshot_privacy.py new file mode 100644 index 0000000000..0a811bb903 --- /dev/null +++ b/tests/control_plane/test_windows_snapshot_privacy.py @@ -0,0 +1,171 @@ +"""Native Windows private IO behavior; all state is disposable.""" +from pathlib import Path +import hashlib +import json +import os +import subprocess +import sys +import tempfile +import pytest +from loopx.control_plane import effect_runtime as er +from loopx.control_plane.runtime import private_permissions as permissions +from loopx.control_plane.turn_driver import codex_sessions as sessions +from tests.control_plane.windows_private_acl import assert_private_acl + +pytestmark=pytest.mark.skipif(os.name!='nt',reason='Windows native DACL behavior') +R=Path.cwd() +NODE='''import {readPrivateJsonSnapshot,openPrivateResponseSink} from "MODULE"; +const a=JSON.parse(process.argv[1]);let effects=0; +try{if(a.op==='read') await readPrivateJsonSnapshot(a.ref); +else {const h=await openPrivateResponseSink(a.ref.path);await h.close();} +effects++;process.stdout.write(JSON.stringify({accepted:true,effects}));} +catch(e){process.stdout.write(JSON.stringify({accepted:false,effects,error:e.message}));} +'''.replace('MODULE',(R/'loopx/control_plane/effect_runtime_snapshot.ts').as_uri()) + +def broaden(path): + rights='*S-1-1-0:(OI)(CI)F' if path.is_dir() else '*S-1-1-0:F' + r=subprocess.run(['icacls.exe',str(path),'/grant',rights],capture_output=True,text=True,timeout=30) + assert r.returncode==0,r.stdout+r.stderr + +def typed(path,*,op='read',bad_digest=False): + raw = path.read_bytes() + ref = { + "path": str(path), + "byte_count": len(raw), + "sha256": "0" * 64 if bad_digest else hashlib.sha256(raw).hexdigest(), + } + r=subprocess.run([er._node_executable(),'--no-warnings','--experimental-strip-types','--input-type=module','-e',NODE,json.dumps({'op':op,'ref':ref})],env=dict(os.environ,LOOPX_EFFECT_RUNTIME_PYTHON_BIN=sys.executable),capture_output=True,text=True,timeout=30) + assert r.returncode==0,r.stdout+r.stderr + return json.loads(r.stdout) + +@pytest.fixture +def snapshot(tmp_path): + parent=Path(tempfile.mkdtemp(prefix='loopx-effect-',dir=tmp_path)) + permissions.protect_private_path(parent,directory=True) + path=parent/'response.json' + payload={'schema_version':er.EFFECT_RUNTIME_RESPONSE_SCHEMA_VERSION,'request_id':'owned-request','ok':True,'result':{'blob':'x'*2200000}} + path.write_text(json.dumps(payload,separators=(',',':')),encoding='utf8') + permissions.protect_private_path(path) + return path,payload + +def envelope(path): + raw=path.read_bytes() + return {'schema_version':er.EFFECT_RUNTIME_RESPONSE_SCHEMA_VERSION,'request_id':'owned-request','ok':True,'result_ref':{'byte_count':len(raw),'sha256':hashlib.sha256(raw).hexdigest()}} + +def pyread(path,env=None): + return er._read_local_snapshot_response(env or envelope(path),path,method='goal.checkpoint_read_context.commit',request_id='owned-request',timeout=10) + +def test_actual_producer_protects_before_socket_dispatch(monkeypatch): + seen=[] + class Connection: + def __enter__(self):return self + def __exit__(self,*args):return None + def settimeout(self,value):pass + def sendall(self,raw): + request = json.loads(raw) + sink = Path(request["response_sink"]) + params = Path(request["params_ref"]["path"]) + for p in (sink.parent, sink, params): + assert_private_acl(p) + assert len(params.read_bytes())>er.MAX_REQUEST_BYTES + seen.append(request) + def recv(self,size):return json.dumps({'schema_version':er.EFFECT_RUNTIME_RESPONSE_SCHEMA_VERSION,'request_id':'producer','ok':True,'result':{}}).encode()+b'\n' + monkeypatch.setattr(er.socket,'create_connection',lambda *a,**k:Connection()) + er._request_with_info({'token':'synthetic','host':'127.0.0.1','port':1},request_id='producer',method='goal.checkpoint_read_context.resolve',params={'large':'x'*2200000},timeout=2,large_local_snapshot=True) + assert len(seen)==1 and not Path(seen[0]['response_sink']).exists() + +def test_large_protected_bytes_and_digest(snapshot): + path, payload = snapshot + assert_private_acl(path.parent) + assert_private_acl(path) + assert typed(path)=={'accepted':True,'effects':1} + assert pyread(path)==payload + assert typed(path,bad_digest=True)['accepted'] is False + bad = envelope(path) + bad["result_ref"]["sha256"] = "0" * 64 + with pytest.raises(er.EffectRuntimeResponseAmbiguous): + pyread(path, bad) + + +@pytest.mark.parametrize('op',['read','sink']) +@pytest.mark.parametrize('target',['file','parent']) +def test_typed_rejects_broad_acl_before_effect(snapshot,op,target): + path, _ = snapshot + broaden(path if target == "file" else path.parent) + result=typed(path,op=op) + assert result['accepted'] is False and result['effects']==0 + +@pytest.mark.parametrize('target',['file','parent']) +def test_python_postdispatch_acl_failure_is_ambiguous(snapshot,target): + path, _ = snapshot + broaden(path if target == "file" else path.parent) + with pytest.raises(er.EffectRuntimeResponseAmbiguous) as error: + pyread(path) + assert error.value.diagnostic_code=='runtime_response_ambiguous' + +def test_session_writer_protects_and_keeps_original_identity(tmp_path): + lineage={'goal_id':'isolated-privacy','agent_id':'fixture','todo_id':'original'} + path = sessions._session_path(tmp_path, lineage, "agent") + path.parent.mkdir(parents=True) + permissions.protect_private_path(path.parent, directory=True) + broaden(path.parent) + sessions._store_codex_cli_session(tmp_path,lineage=lineage,session_scope='agent',session_id='original-session-id') + assert_private_acl(path.parent) + assert_private_acl(path) + value=sessions.select_codex_cli_session(tmp_path,lineage=lineage,session_scope='agent') + assert value['session_id']=='original-session-id' and value['agent_id']=='fixture' + assert value['todo_id']=='original' and value['session_scope']=='agent' + +@pytest.mark.parametrize('failure',['protect','replace']) +def test_session_publication_failure_keeps_prior_session(tmp_path,monkeypatch,failure): + lineage={'goal_id':'isolated-privacy','agent_id':'fixture','todo_id':'original'} + sessions._store_codex_cli_session(tmp_path,lineage=lineage,session_scope='agent',session_id='original-session-id') + path = sessions._session_path(tmp_path, lineage, "agent") + old = path.read_bytes() + + def deny(*a,**k):raise PermissionError('injected native IO refusal') + if failure=='protect': + if not hasattr(sessions, "protect_private_path"): + pytest.fail("Original session writer has no protection admission") + monkeypatch.setattr(sessions,'protect_private_path',deny) + else: + monkeypatch.setattr(sessions.os, "replace", deny) + with pytest.raises(PermissionError): + sessions._store_codex_cli_session( + tmp_path, lineage=lineage, session_scope="agent", session_id="replacement" + ) + assert path.read_bytes()==old + assert not [x for x in path.parent.iterdir() if x.name.startswith('.'+path.name+'.')] + +def test_python_rejects_junction_parent(snapshot,tmp_path): + path, _ = snapshot + link = tmp_path / "loopx-effect-junction" + cmd=['pwsh','-NoLogo','-NoProfile','-Command',"New-Item -ItemType Junction -Path $env:LX_LINK -Target $env:LX_TARGET | Out-Null"] + r=subprocess.run(cmd,env=dict(os.environ,LX_LINK=str(link),LX_TARGET=str(path.parent)),capture_output=True,text=True,timeout=30) + assert r.returncode==0,r.stderr + try: + other=link/path.name + assert typed(other)['accepted'] is False + with pytest.raises(er.EffectRuntimeResponseAmbiguous): + pyread(other) + finally: + os.rmdir(link) + + +@pytest.mark.parametrize('helper',['missing','invalid']) +def test_typed_missing_os_fact_helper_rejects(snapshot,helper): + path, _ = snapshot + raw = path.read_bytes() + ref = { + "path": str(path), + "byte_count": len(raw), + "sha256": hashlib.sha256(raw).hexdigest(), + } + env = dict(os.environ) + env.pop("LOOPX_EFFECT_RUNTIME_PYTHON_BIN", None) + if helper == "invalid": + env["LOOPX_EFFECT_RUNTIME_PYTHON_BIN"] = str(path.parent / "absent-python.exe") + r=subprocess.run([er._node_executable(),'--no-warnings','--experimental-strip-types','--input-type=module','-e',NODE,json.dumps({'op':'read','ref':ref})],env=env,capture_output=True,text=True,timeout=30) + assert r.returncode==0,r.stderr + result = json.loads(r.stdout) + assert result["accepted"] is False and result["effects"] == 0 diff --git a/tests/control_plane/windows_private_acl.py b/tests/control_plane/windows_private_acl.py new file mode 100644 index 0000000000..43596f2a36 --- /dev/null +++ b/tests/control_plane/windows_private_acl.py @@ -0,0 +1,26 @@ +"""Independent OS readback for the Windows private IO tests.""" +from pathlib import Path +import subprocess +import os + + +def assert_private_acl(path: Path) -> None: + # PowerShell uses .NET's independent ACL reader; product IO uses Win32. + script = '''$ErrorActionPreference='Stop' + $p=$env:LOOPX_PRIVATE_IO_TEST_PATH + $acl=Get-Acl -LiteralPath $p + if(-not $acl.AreAccessRulesProtected){throw 'DACL is inherited'} + $owner=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + $allowed=@($owner,'S-1-5-18','S-1-5-32-544') + foreach($ace in $acl.Access){ + $sid=$ace.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value + if($sid -notin $allowed -or $ace.IsInherited -or $ace.AccessControlType -ne 'Allow'){throw 'Unexpected private ACE'} + } + if($acl.Access.Count -ne 3){throw 'Private ACE set incomplete'} + ''' + result = subprocess.run( + ["pwsh", "-NoLogo", "-NoProfile", "-Command", script], + env=dict(os.environ, LOOPX_PRIVATE_IO_TEST_PATH=str(path)), + capture_output=True, text=True, timeout=30, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/tests/control_plane_ts/checkpoint_commit_probe.ts b/tests/control_plane_ts/checkpoint_commit_probe.ts index 6c033f669f..de8222bfc6 100644 --- a/tests/control_plane_ts/checkpoint_commit_probe.ts +++ b/tests/control_plane_ts/checkpoint_commit_probe.ts @@ -1,6 +1,8 @@ /** Isolated process barriers around the production persistence methods. No * runtime test flag or alternate store implementation enters product code. */ import {existsSync, readFileSync, writeFileSync} from "node:fs"; +import fs from "node:fs"; +import {syncBuiltinESMExports} from "node:module"; import {join} from "node:path"; import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; @@ -13,6 +15,32 @@ let raw = ""; for await (const chunk of process.stdin) raw += chunk; const input = JSON.parse(raw) as {mode: string; barrier: string; provider: string; method: string; params: JsonObject; repeat?: boolean; fault?: string; provider_direct?: boolean}; +if (input.fault?.startsWith("after_")) { + const descriptors = new Map(); + const open = fs.openSync; + const flush = fs.fsyncSync; + fs.openSync = ((path, ...args) => { + const fd = open(path, ...args); + descriptors.set(fd, String(path)); + return fd; + }) as typeof fs.openSync; + fs.fsyncSync = fd => { + flush(fd); + const path = descriptors.get(fd) ?? ""; + const suffix = input.fault === "after_json" ? ".json" : input.fault === "after_markdown" ? ".md" : "index.jsonl"; + if (path.includes("runs") && path.endsWith(suffix)) process.exit(86); + }; + syncBuiltinESMExports(); +} +if (input.fault === "before_artifacts") { + const rename = fs.renameSync; + fs.renameSync = (from, to) => { + rename(from, to); + if (String(to).includes("checkpoint-contexts") && + JSON.parse(readFileSync(to, "utf8")).commit_attempt != null) process.exit(86); + }; + syncBuiltinESMExports(); +} const signal = (name: string, value: unknown = true) => writeFileSync(join(input.barrier, name), JSON.stringify(value)); const pause = () => { const deadline = Date.now() + 20000; @@ -40,9 +68,10 @@ if (input.mode === "checkpoint") { } else if (input.mode === "writer") { const original = prototype.commitAuthority; prototype.commitAuthority = async function(commit) { + const started = performance.now(); signal("writer-entered"); const result = await original.call(this as SqliteAuthorityStore & FileAuthorityStore, commit); - signal("provider-result", result); + signal("provider-result", {...result, elapsed_ms: performance.now() - started}); return result; }; } diff --git a/tests/control_plane_ts/checkpoint_provider_head.test.ts b/tests/control_plane_ts/checkpoint_provider_head.test.ts index 8476580542..4a4fda4a26 100644 --- a/tests/control_plane_ts/checkpoint_provider_head.test.ts +++ b/tests/control_plane_ts/checkpoint_provider_head.test.ts @@ -1,9 +1,12 @@ +import {inspectCheckpointAttempt, inspectCheckpointReadHistory} from "../../loopx/control_plane/goals/checkpoint_commit.ts"; import assert from "node:assert/strict"; -import {mkdtemp, rm} from "node:fs/promises"; +import {createHash} from "node:crypto"; +import {mkdir, mkdtemp, rm, symlink} from "node:fs/promises"; import {writeFileSync, readFileSync} from "node:fs"; import {tmpdir} from "node:os"; -import {join} from "node:path"; +import {join, resolve} from "node:path"; import test from "node:test"; +import {inspectCheckpointReplay} from "../../loopx/control_plane/goals/checkpoint_commit.ts"; import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; import {sqliteRuntimeIdentity} from "../../loopx/control_plane/coordination/sqlite_runtime.ts"; @@ -65,3 +68,194 @@ test("SQLite concurrent requests in one runtime do not busy-wait on an awaiting assert.equal(JSON.parse(readFileSync(join(directory, "checkpoint.json"), "utf8")).head.value, 1); assert.ok(performance.now() - started < 4000, "must not incur the SQLite 5000ms busy timeout"); }); + + +for (const work of [true, false]) { + test(`checkpoint rejects competing ${work ? "work" : "direction"} proofs on one Turn`, async t => { + const root = await mkdtemp(join(tmpdir(), "checkpoint-conflict-")); + t.after(() => rm(root, {recursive: true, force: true})); + const runs = join(root, "goals", "goal", "runs"); + await mkdir(runs, {recursive: true}); + const identity = {goal_id: "goal", agent_id: "agent", todo_id: "todo", turn_instance_id: "turn", effect_id: "effect"}; + const rows = ["read-one", "read-two"].map(read_context_id => ({settlement_identity: identity, + refresh_recovery: {}, json_path: join(runs, "result.json"), markdown_path: join(runs, "result.md"), + vision_checkpoint: work ? {decision: "missing_required", satisfied: false} : + {satisfied: true, read_context: {read_context_id}}, + ...(work ? {result_work_context: {read_context_id}} : {})})); + writeFileSync(join(runs, "index.jsonl"), JSON.stringify(rows[0]) + "\n"); + assert.equal(inspectCheckpointAttempt({runtime_root: root, identity: {...identity, effect_id: "another-turn"}}).status, "absent"); + writeFileSync(join(runs, "index.jsonl"), rows.map(row => JSON.stringify(row)).join("\n") + "\n"); + assert.throws(() => inspectCheckpointAttempt({runtime_root: root, identity, + purpose: work ? "delivery_result" : "first_delivery"}), (error: unknown) => (error as {code: string}).code === "checkpoint_commit_unknown"); + }); +} + +for (const readWork of [false, true]) { + for (const damage of ["none", "missing-json", "mismatched-json", "missing-markdown", "torn-index", "no-proof", "work-only-proof", "invalid-work-proof"] as const) { + test(`obsolete ${readWork ? "work" : "direction"} receipt needs a verified matching indexed effect: ${damage}`, async t => { + const root = await mkdtemp(join(tmpdir(), "checkpoint-old-read-")); + t.after(() => rm(root, {recursive: true, force: true})); + const runs = join(root, "goals", "goal", "runs"); + const contexts = join(root, "goals", "goal", "checkpoint-contexts"); + await mkdir(runs, {recursive: true}); + await mkdir(contexts, {recursive: true}); + const old = {goal_id: "goal", agent_id: "peer", todo_id: "old-task", turn_instance_id: "old-turn", effect_id: "old-effect"}; + const current = {goal_id: "goal", agent_id: "agent", todo_id: "new-task", turn_instance_id: "new-turn", effect_id: "new-effect"}; + const jsonPath = join(runs, "old.json"); + const markdownPath = join(runs, "old.md"); + const row = {settlement_identity: old, refresh_recovery: {}, json_path: jsonPath, markdown_path: markdownPath, + ...(damage === "invalid-work-proof" ? {result_work_context: false} : + damage === "work-only-proof" ? {result_work_context: {read_context_id: "old-work"}} : + damage === "no-proof" ? {classification: "quota_slot_spent"} : + {vision_checkpoint: {satisfied: true, read_context: {read_context_id: "old-direction"}}})}; + if (damage !== "missing-json") writeFileSync(jsonPath, JSON.stringify(damage === "mismatched-json" ? {...row, settlement_identity: current} : row)); + if (damage !== "missing-markdown") writeFileSync(markdownPath, "Retained checkpoint\n"); + writeFileSync(join(runs, "index.jsonl"), JSON.stringify(row) + (damage === "torn-index" ? "" : "\n")); + const name = createHash("sha256").update(old.effect_id + (readWork ? ":delivery_result" : "")).digest("hex") + ".json"; + writeFileSync(join(contexts, name), "{"); + const inspect = () => inspectCheckpointAttempt({runtime_root: root, identity: current, + purpose: "first_delivery", check_other_attempts: true}); + const observe = () => inspectCheckpointReadHistory({runtime_root: root, goal_id: "goal", receipt_names: [name]}); + if (damage === "none" || damage === "work-only-proof" && readWork) { + assert.deepEqual(observe().verified, [{receipt_name: name, identity: old, + purpose: damage === "work-only-proof" ? "delivery_result" : "first_delivery"}]); + assert.equal(inspect().status, "absent"); + assert.equal(readFileSync(join(contexts, name), "utf8"), "{", "Inspection must not repair another identity's read"); + // An unattributable or current receipt remains unknown, even beside a verified old effect. + for (const effect of [current.effect_id, "unattributed-effect"]) { + const unknownPath = join(contexts, createHash("sha256").update(effect).digest("hex") + ".json"); + writeFileSync(unknownPath, "{"); + assert.throws(inspect, (error: unknown) => (error as {code: string}).code === "checkpoint_commit_unknown"); + await rm(unknownPath); + } + } else { + assert.throws(inspect, (error: unknown) => (error as {code: string}).code === "checkpoint_commit_unknown"); + if (damage === "torn-index") assert.throws(observe); + else assert.deepEqual(observe().verified, []); + } + }); + } +} + +test("history inspection cannot borrow a valid checkpoint from another Goal", async t => { + const root = await mkdtemp(join(tmpdir(), "checkpoint-history-goal-")); + t.after(() => rm(root, {recursive: true, force: true})); + const runs = join(root, "goals", "other-goal", "runs"); + const currentRuns = join(root, "goals", "goal", "runs"); + const contexts = join(root, "goals", "goal", "checkpoint-contexts"); + for (const path of [runs, currentRuns, contexts]) await mkdir(path, {recursive: true}); + const identity = {goal_id: "other-goal", agent_id: "peer", todo_id: "task", turn_instance_id: "turn", effect_id: "effect"}; + const row = {settlement_identity: identity, refresh_recovery: {}, + vision_checkpoint: {satisfied: true, read_context: {read_context_id: "read"}}, + json_path: join(runs, "saved.json"), markdown_path: join(runs, "saved.md")}; + writeFileSync(row.json_path, JSON.stringify(row)); + writeFileSync(row.markdown_path, "Saved checkpoint\n"); + for (const path of [runs, currentRuns]) writeFileSync(join(path, "index.jsonl"), JSON.stringify(row) + "\n"); + const name = createHash("sha256").update(identity.effect_id).digest("hex") + ".json"; + writeFileSync(join(contexts, name), "{"); + assert.equal(inspectCheckpointAttempt({runtime_root: root, identity}).status, "committed"); + assert.deepEqual(inspectCheckpointReadHistory({runtime_root: root, goal_id: "goal", receipt_names: [name]}).verified, []); + assert.throws(() => inspectCheckpointAttempt({runtime_root: root, + identity: {...identity, goal_id: "goal", effect_id: "new-effect"}, check_other_attempts: true}), + (error: unknown) => (error as {code: string}).code === "checkpoint_commit_unknown"); +}); + +test("an indexed work result cannot discharge another Turn's unindexed direction", async t => { + const root = await mkdtemp(join(tmpdir(), "checkpoint-direction-proof-")); + t.after(() => rm(root, {recursive: true, force: true})); + const runs = join(root, "goals", "goal", "runs"); + const contexts = join(root, "goals", "goal", "checkpoint-contexts"); + await mkdir(runs, {recursive: true}); + await mkdir(contexts, {recursive: true}); + const old = {goal_id: "goal", agent_id: "agent", todo_id: "task", turn_instance_id: "old", effect_id: "old-effect"}; + const current = {...old, turn_instance_id: "new", effect_id: "new-effect"}; + const work = {settlement_identity: old, refresh_recovery: {}, result_work_context: {read_context_id: "work"}, + json_path: join(runs, "work.json"), markdown_path: join(runs, "work.md")}; + const direction = {settlement_identity: old, refresh_recovery: {}, vision_checkpoint: {satisfied: true, + read_context: {read_context_id: "direction"}}, json_path: join(runs, "direction.json"), markdown_path: join(runs, "direction.md")}; + writeFileSync(work.json_path, JSON.stringify(work)); + writeFileSync(work.markdown_path, "Committed work\n"); + writeFileSync(direction.json_path, JSON.stringify(direction)); + writeFileSync(direction.markdown_path, "Retained direction bytes\n"); + writeFileSync(join(contexts, createHash("sha256").update(old.effect_id).digest("hex") + ".json"), JSON.stringify({ + identity: old, purpose: "first_delivery", commit_attempt: {...direction, index_record: direction}})); + const inspect = () => inspectCheckpointAttempt({runtime_root: root, identity: current, check_other_attempts: true}); + writeFileSync(join(runs, "index.jsonl"), JSON.stringify(work) + "\n"); + assert.throws(inspect, (error: unknown) => (error as {code: string}).code === "checkpoint_commit_unknown"); + writeFileSync(join(runs, "index.jsonl"), [work, direction].map(row => JSON.stringify(row)).join("\n") + "\n"); + assert.equal(inspect().status, "absent"); + writeFileSync(direction.json_path, JSON.stringify({...direction, settlement_identity: current})); + assert.throws(inspect, (error: unknown) => (error as {code: string}).code === "checkpoint_commit_unknown"); +}); + +test("indexed replay accepts a runtime directory alias without rewriting historical paths", async t => { + const directory = await mkdtemp(join(tmpdir(), "checkpoint-directory-alias-")); + t.after(() => rm(directory, {recursive: true, force: true})); + const root = join(directory, "physical"), alias = join(directory, "alias"); + const runs = join(root, "goals", "goal", "runs"); + await mkdir(runs, {recursive: true}); + await symlink(root, alias, process.platform === "win32" ? "junction" : "dir"); + const identity = {goal_id: "goal", agent_id: "agent", todo_id: "task", turn_instance_id: "turn", effect_id: "effect"}; + const prior = {settlement_identity: identity, refresh_recovery: {}, + vision_checkpoint: {satisfied: true, read_context: {read_context_id: "original"}}, + json_path: join(alias, "goals", "goal", "runs", "saved.json"), + markdown_path: join(alias, "goals", "goal", "runs", "saved.md")}; + const record = JSON.stringify(prior), index = record + "\n"; + writeFileSync(prior.json_path, record); + writeFileSync(prior.markdown_path, "Saved direction\n"); + writeFileSync(join(runs, "index.jsonl"), index); + for (const runtime_root of [root, alias]) { + assert.equal(inspectCheckpointReplay({runtime_root, goal_id: "goal", prior}).replayed, true); + assert.equal(inspectCheckpointAttempt({runtime_root, identity}).status, "committed"); + } + assert.equal(readFileSync(join(runs, "saved.json"), "utf8"), record); + assert.equal(readFileSync(join(runs, "index.jsonl"), "utf8"), index); + await rm(alias); + const redirected = join(directory, "redirected"); + await mkdir(join(redirected, "goals", "goal", "runs"), {recursive: true}); + await symlink(redirected, alias, process.platform === "win32" ? "junction" : "dir"); + assert.throws(() => inspectCheckpointReplay({runtime_root: root, goal_id: "goal", prior}), + (error: unknown) => (error as {code: string}).code === "checkpoint_commit_unknown"); + assert.equal(readFileSync(join(runs, "index.jsonl"), "utf8"), index); +}); + +for (const route of ["outside", "outside-directory-link", "wrong-goal", "leaf-directory-link", "leaf-link"] as const) { + test(`checkpoint artifact alias cannot authorize ${route}`, async t => { + const root = await mkdtemp(join(tmpdir(), "checkpoint-path-refusal-")); + t.after(() => rm(root, {recursive: true, force: true})); + const runs = join(root, "goals", "goal", "runs"); + const outside = join(root, "outside"); + const otherRuns = join(root, "goals", "other-goal", "runs"); + for (const path of [runs, outside, otherRuns]) await mkdir(path, {recursive: true}); + let jsonPath = join(runs, "saved.json"); + if (route === "outside") jsonPath = resolve(runs, "..", "..", "..", "outside", "saved.json"); + if (route === "wrong-goal") jsonPath = join(otherRuns, "saved.json"); + if (route === "outside-directory-link") { + await symlink(outside, join(runs, "external"), process.platform === "win32" ? "junction" : "dir"); + jsonPath = join(runs, "external", "saved.json"); + } + const prior = {settlement_identity: {goal_id: "goal", effect_id: "effect"}, refresh_recovery: {}, + vision_checkpoint: {satisfied: true, read_context: {}}, json_path: jsonPath, + markdown_path: join(runs, "saved.md")}; + const record = JSON.stringify(prior); + if (route === "leaf-directory-link") { + await symlink(outside, jsonPath, process.platform === "win32" ? "junction" : "dir"); + } else if (route === "leaf-link") { + const target = join(runs, "target.json"); + writeFileSync(target, record); + // A file link to an otherwise valid in-directory record is still forbidden. + try { await symlink(target, jsonPath, "file"); } + catch (error) { + if (process.platform !== "win32" || (error as NodeJS.ErrnoException).code !== "EPERM") throw error; + t.skip("Windows file symlinks require a privilege unavailable on this host; directory junction negatives still execute"); + return; + } + } else writeFileSync(jsonPath, record); + writeFileSync(prior.markdown_path, "Saved direction\n"); + const index = record + "\n"; + writeFileSync(join(runs, "index.jsonl"), index); + assert.throws(() => inspectCheckpointReplay({runtime_root: root, goal_id: "goal", prior}), + (error: unknown) => (error as {code: string}).code === "checkpoint_commit_unknown"); + assert.equal(readFileSync(join(runs, "index.jsonl"), "utf8"), index); + }); +} diff --git a/tests/control_plane_ts/checkpoint_read_context.test.ts b/tests/control_plane_ts/checkpoint_read_context.test.ts index e496374c8f..6663d66168 100644 --- a/tests/control_plane_ts/checkpoint_read_context.test.ts +++ b/tests/control_plane_ts/checkpoint_read_context.test.ts @@ -1,9 +1,51 @@ +import {randomUUID} from "node:crypto"; +import {tmpdir} from "node:os"; +import {resolve} from "node:path"; import assert from "node:assert/strict"; import test from "node:test"; -import {evaluateCheckpointReadContext as evaluate} from "../../loopx/control_plane/goals/checkpoint_read_context.ts"; -import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {evaluateCheckpointReadContext as evaluate, evaluateResultWorkBinding as work, executeResultWorkBinding} from "../../loopx/control_plane/goals/checkpoint_read_context.ts"; +import {settlementIdentity, type JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {selectEffectiveHeartbeatReceipt, heartbeatReceiptFactFromEvent} from "../../loopx/control_plane/quota/heartbeat_receipt_identity.ts"; const identity = {goal_id: "goal", agent_id: "agent", todo_id: "task", turn_instance_id: "turn", effect_id: "effect"}; +test("completion replay preserves the exact managed key without aliasing another scope", () => { + const scoped = settlementIdentity({goal_id: "goal", agent_id: "agent", + todo_id: "todo_original", turn_instance_id: "original-turn"}); + const todo = {todo_id: "todo_original", claimed_by: "agent", status: "done", + completion_turn_key: "original-turn"}; + const key = (snapshot: JsonObject = todo, bound: JsonObject = scoped) => work({ + phase: "completion_identity", goal_id: "goal", agent_id: "agent", todo_id: "todo_original", + todo: snapshot, identity: bound, + }).completion_turn_key; + assert.equal(key(), "original-turn"); + assert.equal(key({...todo, completion_turn_key: scoped.effect_id}), scoped.effect_id); + for (const changed of [{...todo, status: "open"}, {...todo, todo_id: "todo_other"}, + {...todo, claimed_by: "another-agent"}, {...todo, completion_turn_key: "another-turn"}]) { + assert.equal(key(changed), scoped.effect_id); + } + assert.throws(() => key(todo, {...scoped, effect_id: "forged"}), /original scoped Turn/); + assert.throws(() => key(todo, settlementIdentity({goal_id: "other", agent_id: "agent", + todo_id: "todo_original", turn_instance_id: "original-turn"})), /original scoped Turn/); +}); +test("managed result replay cannot submit a different candidate, successor or closeout", () => { + const scoped = settlementIdentity({goal_id: "goal", agent_id: "agent", + todo_id: "todo_original", turn_instance_id: "original-turn"}); + const todo = {todo_id: "todo_original", claimed_by: "agent", role: "agent", status: "done", + completion_turn_key: "original-turn", completion_continuation: "active_goal", no_followup: false, + successor_todo_ids: [], evidence: "retained", completion_receipt_id: "original-receipt"}; + const replay = (candidate: JsonObject) => work({phase: "completion_identity", + goal_id: "goal", agent_id: "agent", todo_id: "todo_original", todo, identity: scoped, candidate}).replay; + const candidate = {evidence: "retained", role: "agent"}; + assert.deepEqual(replay(candidate), {ok: true, completed: true, changed: false, idempotent_replay: true, + dry_run: false, goal_id: "goal", todo_id: "todo_original", status: "done", + completion_continuation: "active_goal", completion_receipt_id: "original-receipt", updated_at: null}); + for (const changed of [{evidence: "replacement"}, {next_agent_todo: "new"}, + {next_excluded_agents: ["other"]}, {no_followup: true}, {role: "user"}, {clear_claim: true}]) { + assert.equal(replay({...candidate, ...changed}), null); + } + assert.equal(work({phase: "completion_identity", goal_id: "goal", agent_id: "agent", + todo_id: "todo_original", todo: {...todo, completion_receipt_id: null}, identity: scoped, candidate}).replay, null); +}); const facts = { todos: [ {todo_id: "task", status: "open", text: "Build a page", depends_on_todo_ids: ["upstream"]}, @@ -22,6 +64,280 @@ const check = (receipt: unknown, overrides: JsonObject = {}) => evaluate({ phase: "check", identity, facts, read_context_id: "read-A", receipt, ...overrides, }); +test("current direction isolates independent peers but original terminal intent requires the whole Goal", () => { + for (const terminal of [false, true]) { + const current = structuredClone(facts); + Object.assign(current.todos[0], {status: "done", claimed_by: identity.agent_id, + completion_turn_key: identity.turn_instance_id, no_followup: terminal}); + Object.assign(current.todos[3], {role: "agent", claimed_by: "independent-peer"}); + const request = {purpose: "first_delivery", admitted_turn: true, prior: null, + checkpoint_continuation: true, full_frontier: true, decision_scope: "agent_lane"}; + const result = read({...request, facts: current}); + assert.equal(result.ok, true); + assert.equal((result.receipt as JsonObject).decision_scope, terminal ? "goal" : "agent_lane"); + const changed = structuredClone(current); + Object.assign(changed.todos[3], {note: "Independent evidence changed"}); + const checked = check(result.receipt, {...request, facts: changed}); + assert.equal(checked.ok, !terminal); + if (terminal) assert.equal(checked.error_code, "checkpoint_read_context_stale"); + Object.assign(changed.todos[1], {evidence: "Changed shared dependency"}); + assert.equal(check(result.receipt, {...request, facts: changed}).error_code, "checkpoint_read_context_stale"); + } +}); + +test("only matching historical or settled progress permits another read for an unfinished result", () => { + const original = read({purpose: "delivery_result", admitted_turn: true, result_only: true, prior: null}); + assert.equal(original.ok, true); + const prior = {settlement_identity: identity, result_work_context: {read_context_id: "read-A"}, + refresh_recovery: {mutation_digest: "original"}}; + const receipt = {...original.receipt as JsonObject, commit_attempt: {index_record: prior}}; + const reread = (overrides: JsonObject = {}) => read({purpose: "delivery_result", admitted_turn: true, checkpoint_continuation: false, + result_only: true, read_context_id: "read-B", prior, receipt, ...overrides}); + assert.equal(reread().ok, true); + assert.equal((reread().receipt as JsonObject).commit_attempt, undefined); + assert.equal(reread({checkpoint_continuation: true}).error_code, "delivery_result_already_committed"); + assert.equal(reread({checkpoint_continuation: true, settlement_complete: true, quota_spent: true}).ok, true); + assert.equal(reread({checkpoint_continuation: true, settlement_complete: false}).error_code, "delivery_result_already_committed"); + assert.equal(reread({checkpoint_continuation: true, settlement_complete: true, quota_spent: false}).error_code, "delivery_result_already_committed"); + assert.equal(reread({checkpoint_continuation: true, settlement_complete: true, quota_spent: true, prior: null}).error_code, "checkpoint_commit_unknown"); + for (const changed of [null, {...prior, settlement_identity: {...identity, agent_id: "other"}}, + {...prior, result_work_context: {read_context_id: "another-read"}}, + {...prior, refresh_recovery: {mutation_digest: "replacement"}}]) { + assert.equal(reread({prior: changed}).error_code, "checkpoint_commit_unknown"); + } + assert.equal(reread({facts: {...facts, todos: facts.todos.map(todo => todo.todo_id === "task" + ? {...todo, status: "done", completion_turn_key: identity.turn_instance_id} : todo)}}).error_code, + "delivery_result_already_committed"); +}); + +test("primary owner requirements stay in the Goal basis when an Agent rolls back derived state", () => { + const a = [{path: "/owner/goal.md", prose: "Export records without envelopes"}]; + const b = [{path: "/owner/goal.md", prose: "Export versioned envelopes"}]; + const first = read({facts: {...facts, goal_documents: a}}); + assert.equal(first.ok, true); + const stale = check(first.receipt, {facts: {...facts, goal_documents: b}}); + assert.equal(stale.error_code, "checkpoint_read_context_stale"); + assert.deepEqual(stale.changed_components, ["goal"]); + const fresh = read({facts: {...facts, goal_prose: "Agent restored its old plan", goal_documents: b}}); + const goal = (fresh.basis as JsonObject).goal as JsonObject; + assert.equal(goal.prose, b[0].prose); + assert.equal(goal.state_prose, "Agent restored its old plan"); + assert.equal((goal.primary_documents as JsonObject[])[0].path, b[0].path); + assert.notDeepEqual((fresh.versions as JsonObject).goal, (first.versions as JsonObject).goal); + // No separately registered source preserves the historical basis shape. + assert.equal(Object.hasOwn((read().basis as JsonObject).goal as object, "primary_documents"), false); +}); + +test("Todo-only recovery cannot supply a complete Goal checkpoint basis", () => { + const incomplete = {...facts, frontmatter: {...facts.frontmatter, + goal_source_completeness: "todo_projection_only"}}; + for (const purpose of ["delivery_result", "first_delivery", "supplement_checkpoint"]) { + assert.equal(read({purpose, facts: incomplete, admitted_turn: true, + ...(purpose !== "supplement_checkpoint" ? {prior: null} : {}), + full_frontier: true, decision_scope: "goal"}).error_code, "checkpoint_goal_source_incomplete"); + } + assert.throws(() => read({facts: {...facts, frontmatter: {...facts.frontmatter, + goal_source_completeness: "unknown"}}}), /unsupported Goal source completeness/); + assert.equal(read().ok, true); // Existing complete documents need no new marker. + assert.equal(check(read().receipt, {facts: incomplete}).error_code, "checkpoint_goal_source_incomplete"); +}); + +test("new direction admission is explicit, historical absence stays historical", () => { + assert.equal(work({phase: "direction_policy", heartbeat_receipt: {details: {}}}).required, false); + assert.equal(work({phase: "direction_policy", heartbeat_receipt: {details: { + checkpoint_continuation: "current_agent_v1"}}}).required, true); + for (const invalid of [null, false, "future"]) assert.throws(() => work({phase: "direction_policy", + heartbeat_receipt: {details: {checkpoint_continuation: invalid}}}), /incomplete or unsupported/); +}); + +test("direction admission cannot be downgraded and replan ACK waits for a fresh read", () => { + const receipt = {event_kind: "quota_should_run", run_id: "turn", details: { + todo_id: "todo_fixture", checkpoint_continuation: "current_agent_v1"}}; + const entries = [receipt, {...receipt, details: {todo_id: "todo_fixture"}}] + .map(value => ({value, fact: heartbeatReceiptFactFromEvent(value)})); + assert.throws(() => selectEffectiveHeartbeatReceipt("goal", "agent", entries), /cannot be downgraded/); + const heartbeat_receipt = receipt; + const prior = {vision_checkpoint: {replan_stage: "awaiting_direction"}}; + assert.deepEqual(work({phase: "replan_stage", heartbeat_receipt, requested: true}), {deferred: true, qualify: false}); + assert.deepEqual(work({phase: "replan_stage", heartbeat_receipt, prior, has_read_context: true}), {deferred: false, qualify: true}); + assert.deepEqual(work({phase: "replan_stage", prior, has_read_context: true}), {deferred: false, qualify: false}); +}); + +test("pending closeout intent belongs to the original completed Turn", () => { + const todo = {status: "done", no_followup: false, completion_continuation: "active_goal", + completion_requested_continuation: "no_followup", completion_turn_key: "turn"}; + const request = {phase: "direction_terminal_intent", identity, todo}; + assert.equal(work(request).no_followup, true); + assert.equal(work({...request, identity: {...identity, effect_id: "other", turn_instance_id: "other"}}).no_followup, false); + assert.equal(work({...request, todo: {...todo, status: "open"}}).no_followup, false); + assert.equal(work({...request, todo: {...todo, completion_requested_continuation: "active_goal"}}).no_followup, false); + assert.throws(() => work({...request, todo: {...todo, completion_requested_continuation: "invalid"}}), /completion_continuation/); +}); + +test("direction retry uses the existing Host policy and a replaced read retains attempts", () => { + for (const count of [0, 1]) assert.equal(work({phase: "direction_dispatch", receipt: { + direction_host_attempt_count: count}}).attempt, count + 1); + assert.equal(work({phase: "direction_dispatch", receipt: {direction_stopped: true}}).error_code, "checkpoint_direction_stopped"); + assert.equal(work({phase: "direction_dispatch", receipt: {direction_host_attempt_count: -1}}).ok, false); + const current = structuredClone(facts); + Object.assign(current.todos[0], {status: "done", completion_turn_key: "turn"}); + const request = {purpose: "first_delivery", admitted_turn: true, prior: null, checkpoint_continuation: true, + full_frontier: true, decision_scope: "goal", facts: current}; + const first = read(request).receipt as JsonObject; + const replacement = read({...request, receipt: {...first, direction_host_attempt_count: 2, + direction_host_failure: {schema_version: "loopx_turn_host_failure_v0", kind: "executor_timeout", attempt: 2, + retryable: true, retry: {strategy: "same_configuration", max_attempts: 2, backoff_seconds: 10}}}, + read_context_id: "read-B"}).receipt as JsonObject; + assert.equal(replacement.direction_host_attempt_count, 2); + assert.equal(work({phase: "direction_dispatch", receipt: replacement}).ok, false); +}); + +test("default direction binds the post-result input and response without relabelling", () => { + assert.equal(read({purpose: "first_delivery", admitted_turn: true, prior: null, checkpoint_continuation: true, + full_frontier: true, decision_scope: "goal"}).error_code, "checkpoint_result_not_committed"); + const current = structuredClone(facts); + Object.assign(current.todos[0], {status: "done", completion_turn_key: "turn"}); + const direction = read({purpose: "first_delivery", admitted_turn: true, prior: null, checkpoint_continuation: true, + full_frontier: true, decision_scope: "goal", facts: current}); + assert.equal(direction.ok, true); + const decision = {read_context_id: "read-A", vision_unchanged_reason: "Current obligations preserve the route."}; + const request = {phase: "direction_response", identity, receipt: direction.receipt, decision}; + assert.equal(work(request).ok, true); + const returned = work({...request, phase: "direction_return", receipt: {...direction.receipt as JsonObject, + direction_host_attempt_count: 4, direction_stopped: true, direction_host_failure: {kind: "executor_timeout"}}}); + assert.equal(returned.stopped, false); + assert.deepEqual((returned.receipt as JsonObject).current_agent_decision, decision); + assert.equal((returned.receipt as JsonObject).direction_host_attempt_count, 4); + assert.equal((returned.receipt as JsonObject).direction_stopped, undefined); + assert.equal((returned.receipt as JsonObject).direction_host_failure, undefined); + assert.equal(work({...request, phase: "direction_return", receipt: {...direction.receipt as JsonObject, + direction_host_attempt_count: 4}, decision: {...decision, read_context_id: "foreign"}}).ok, false); + assert.equal(work({...request, decision: {...decision, read_context_id: "read-B"}}).error_code, + "checkpoint_direction_binding_mismatch"); + assert.equal(work({...request, identity: {...identity, agent_id: "another"}}).error_code, + "checkpoint_direction_binding_mismatch"); + assert.equal(work({...request, receipt: {...direction.receipt as JsonObject, decision_basis: {}}}).error_code, + "checkpoint_direction_basis_corrupt"); + const missing = read({purpose: "first_delivery", admitted_turn: true, prior: null, checkpoint_continuation: true, + full_frontier: true, decision_scope: "goal", facts: {...current, agent_vision: null}}); + assert.equal(work({...request, receipt: missing.receipt}).error_code, "checkpoint_direction_baseline_required"); + assert.equal(work({...request, receipt: missing.receipt, + decision: {read_context_id: "read-A", agent_vision: {vision_patch: {vision_summary: "Use current evidence."}}}}).ok, true); + const display = structuredClone(current); + Object.assign(display.todos[3], {index: 90, source_section: "Display"}); + display.frontmatter.updated_at = "later"; + assert.equal(check(direction.receipt, {purpose: "first_delivery", facts: display}).ok, true); + display.todos[3].status = "done"; + assert.equal(check(direction.receipt, {purpose: "first_delivery", facts: display}).error_code, "checkpoint_read_context_stale"); +}); + +test("only explicit recovery rearms a known temporary direction stop without resetting attempts", () => { + const receipt = {direction_stopped: true, direction_host_attempt_count: 3, + direction_stops: [{attempt: 1, result_kind: "wait"}, {attempt: 3, result_kind: "user_action_required"}]}; + const request = {phase: "direction_dispatch", receipt, explicit_resume: true}; + assert.equal(work(request).attempt, 4); + assert.equal(work({...request, explicit_resume: false}).ok, false); + for (const stops of [[], null, [{attempt: 4, result_kind: "wait"}], + [{attempt: 1, result_kind: "future"}], [{attempt: 1, result_kind: "wait", stop_reason: "user_stop"}]]) { + assert.equal(work({...request, receipt: {...receipt, direction_stops: stops}}).ok, false); + } + assert.equal(work({...request, receipt: {direction_stopped: true}}).ok, false); + const exhausted = {schema_version: "loopx_turn_host_failure_v0", kind: "executor_timeout", attempt: 3, + retryable: true, retry: {strategy: "same_configuration", max_attempts: 2, backoff_seconds: 20}}; + assert.equal(work({...request, receipt: {...receipt, direction_host_failure: exhausted}}).ok, false); +}); + +test("valid Host stops supersede transport failure while keeping original attempts and stop reasons", () => { + for (const result_kind of ["wait", "user_action_required", "iteration_failed"]) { + const prior = {direction_host_attempt_count: 3, direction_host_failure: {kind: "executor_timeout"}, + direction_stops: [{attempt: 1, result_kind: "wait"}]}; + for (const decision of [{result_kind}, {result_kind, stop_reason: "host_stop"}]) { + const returned = work({phase: "direction_return", receipt: prior, decision}); + assert.equal(returned.stopped, true); + const receipt = returned.receipt as JsonObject; + assert.equal(receipt.direction_stopped, true); + assert.equal(receipt.direction_host_failure, undefined); + assert.equal(receipt.direction_host_attempt_count, 3); + assert.deepEqual((receipt.direction_stops as JsonObject[])[1], {attempt: 3, ...decision}); + assert.equal(work({phase: "direction_dispatch", receipt, explicit_resume: true}).ok, !("stop_reason" in decision)); + assert.equal(prior.direction_stops.length, 1, "the raw prior receipt is not mutated"); + } + } +}); + +test("direction launch reuses current quota gates and exact original recovery identity", () => { + const current = {ok: true, should_run: true, status_health_ok: true, goal_id: identity.goal_id, + requires_user_action: false, blocked_action_scope: null, quota: {state: "eligible"}, + effective_action: "unsettled_host_turn_recovery", unsettled_host_turn_recovery: { + scope: "current_turn", repair: "durable_writeback", turn_instance_id: identity.turn_instance_id, + binding_kind: "todo", binding_id: identity.todo_id}}; + const request = {phase: "direction_admission", current, identity, planned_effective_action: "normal_run"}; + assert.equal(work(request).ok, true); + for (const patch of [{ok: false}, {should_run: false}, {status_health_ok: false}, + {requires_user_action: true}, {requires_user_action: null}, {quota: {state: "exhausted"}}, + {quota: {state: "paused"}}, {quota: null}, {blocked_action_scope: "write"}, + {goal_id: "other"}, {effective_action: "normal_run"}, + {unsettled_host_turn_recovery: {...current.unsettled_host_turn_recovery, repair: "quota_spend"}}, + {unsettled_host_turn_recovery: {...current.unsettled_host_turn_recovery, turn_instance_id: "other"}}, + {unsettled_host_turn_recovery: {...current.unsettled_host_turn_recovery, binding_id: "other"}}]) { + assert.equal(work({...request, current: {...current, ...patch}}).ok, false); + } +}); + +test("default work input survives cold restore and validation cannot be relabelled", () => { + const result = read({purpose: "delivery_result", admitted_turn: true, prior: null, result_only: true}); + const receipt = result.receipt as JsonObject; + assert.deepEqual(work({phase: "restore", identity, receipt}).basis, result.basis); + const candidate = {artifact: "sha256:candidate-A"}; + const validation = {ok: true, contract: "verifier-A"}; + const binding = work({phase: "bind_validation", identity, receipt, candidate, validation}).binding; + const request = {phase: "check_validation", identity, receipt, candidate, validation, binding}; + assert.equal(work(request).ok, true); + for (const altered of [ + {receipt: {...receipt, read_context_id: "read-B"}}, + {candidate: {artifact: "sha256:candidate-B"}}, + {validation: {...validation, contract: "verifier-B"}}, + ]) assert.equal(work({...request, ...altered}).error_code, "delivery_validation_binding_mismatch"); + assert.equal(work({...request, identity: {...identity, agent_id: "another"}}).error_code, + "delivery_work_identity_mismatch"); + assert.equal(work({phase: "restore", identity, receipt: {...receipt, + work_basis: {...result.basis as JsonObject, todo: {text: "corrupt"}}}}).error_code, + "delivery_work_basis_corrupt"); +}); + +test("admission policy belongs to the owner and malformed history cannot become historical absence", () => { + const policy = "turn_result_freshness_required_v1"; + for (const authority of ["legacy_markdown", "file_v0", "sqlite_v0", "postgresql_v0"]) { + assert.deepEqual(work({phase: "admission", todo_id: "task", source_authority: authority}).policy, policy); + } + assert.equal(work({phase: "policy", heartbeat_receipt: {details: {result_freshness: policy}}}).required, true); + assert.equal(work({phase: "policy", heartbeat_receipt: {details: {}}}).required, false); + for (const malformed of [null, false, {required: false}, "future"]) { + assert.throws(() => work({phase: "policy", heartbeat_receipt: {details: {result_freshness: malformed}}}), + /incomplete or unsupported/); + } +}); + +test("original historical Turn and Goal incarnation do not inherit later admissions", () => { + const old = {event_kind: "quota_should_run", agent_id: "agent", run_id: "old", details: {todo_id: "task"}}; + const current = {...old, run_id: "new", details: {todo_id: "task", result_freshness: "turn_result_freshness_required_v1"}}; + const request = {phase: "legacy_admission", events: [old, current], todo_id: "task", agent_id: "agent", + completion_turn_key: "old", identity: {...identity, turn_instance_id: "old"}, goal_ref: null}; + assert.equal(work(request).required, false); + assert.equal(work({...request, completion_turn_key: null, identity: null}).error_code, "delivery_work_context_required"); + assert.equal(work({...request, events: [{...current, goal_ref: {goal_id: "goal", goal_instance_id: "another"}}]}).required, false); +}); + +test("successful result precedes current freshness while stop/execution ownership stays separate", () => { + const result = read({purpose: "delivery_result", admitted_turn: true, prior: null, result_only: true}); + const current = structuredClone(facts); + Object.assign(current.todos[0], {status: "done", completion_turn_key: "turn"}); + current.goal_prose = "Changed after successful result"; + assert.equal(check(result.receipt, {purpose: "delivery_result", facts: current}).result_committed, true); + Object.assign(current.todos[0], {completion_turn_key: "another-turn"}); + assert.equal(check(result.receipt, {purpose: "delivery_result", facts: current}).error_code, "checkpoint_read_context_stale"); +}); + test("read captures the exact task, transitive upstream results and goal acceptance", () => { const result = read(); const basis = result.basis as JsonObject; @@ -112,3 +428,155 @@ test("obligations cover the full frontier and a completed checkpoint cannot acqu assert.equal(read({prior: {vision_checkpoint: {decision: "patched", satisfied: true}}}).error_code, "checkpoint_context_not_missing"); }); + +test("first delivery has independent admission and cannot reuse a supplement receipt", () => { + const first = {purpose: "first_delivery", decision_scope: "agent_lane", prior: null, admitted_turn: true}; + const receipt = read(first).receipt; + assert.equal(check(receipt, first).ok, true); + assert.equal(read({...first, admitted_turn: false}).error_code, "checkpoint_first_delivery_not_admitted"); + assert.equal(read({...first, prior: {vision_checkpoint: {decision: "missing_required", satisfied: false}}}).ok, false); + assert.equal(check(receipt).ok, false); + assert.equal(check(read().receipt, first).ok, false); +}); + +test("first direction detects frontier membership while ignoring independent peer work", () => { + const first = {purpose: "first_delivery", decision_scope: "agent_lane", prior: null, admitted_turn: true}; + const source = {...facts, todos: [...facts.todos, {todo_id: "peer", claimed_by: "other", status: "open"}] as JsonObject[]}; + const receipt = read({...first, facts: source}).receipt; + const changed = structuredClone(source); + changed.todos[4].status = "done"; + assert.equal(check(receipt, {...first, facts: changed}).ok, true); + changed.todos.push({todo_id: "new", claimed_by: "agent", status: "open"}); + assert.deepEqual(check(receipt, {...first, facts: changed}).changed_components, ["frontier"]); + changed.todos.pop(); + changed.todos[4].claimed_by = "agent"; + assert.deepEqual(check(receipt, {...first, facts: changed}).changed_components, ["frontier"]); + const whole = read({...first, decision_scope: "goal", facts: source}).receipt; + assert.equal(check(whole, {...first, decision_scope: "agent_lane", facts: source}).error_code, + "checkpoint_read_context_scope_mismatch"); + assert.equal(check(whole, {...first, decision_scope: "goal", facts: changed}).ok, false); +}); + +test("first result and direction bind authority, lease expiry, purpose and unknown fields", () => { + for (const purpose of ["first_delivery", "delivery_result"]) { + const request = {purpose, decision_scope: "goal", prior: null, admitted_turn: true}; + const baseline = {...facts, execution_lease: {owner: "agent", active: true}, + source: {authority: "file_v0", store_identity: "store-1", registry_goal: {registered_agents: ["agent"]}}}; + const receipt = read({...request, facts: baseline}).receipt; + for (const change of [ + {...baseline, execution_lease: {owner: "agent", active: false}}, + {...baseline, execution_lease: {owner: "other", active: true}}, + {...baseline, source: {...baseline.source, store_identity: "store-2"}}, + {...baseline, source: {...baseline.source, registry_goal: {registered_agents: []}}}, + {...baseline, todos: baseline.todos.map((row, i) => i === 0 ? {...row, future_business_rule: "changed"} : row)}, + ]) assert.equal(check(receipt, {...request, facts: change}).error_code, "checkpoint_read_context_stale"); + assert.equal(check(receipt, {...request, purpose: purpose === "first_delivery" ? "delivery_result" : "first_delivery"}).ok, false); + } +}); + +test("an uncertain direction cannot acquire a replacement read or reexecute", () => { + const request = {purpose: "first_delivery", decision_scope: "goal", prior: null, admitted_turn: true}; + const receipt = {...read(request).receipt as JsonObject, commit_attempt: {json_path: "unindexed"}}; + for (const result of [read({...request, receipt}), check(receipt, request)]) { + assert.equal(result.error_code, "checkpoint_commit_unknown"); + assert.equal(result.reread_required, false); + } +}); + +test("explicit quota pause rejects new direction reads and cached checks while preserving result history", () => { + const current: JsonObject = structuredClone(facts); + Object.assign((current.todos as JsonObject[])[0], {status: "done", completion_turn_key: "turn"}); + current.source = {...facts.source, registry_goal: {quota: {compute: 1}}}; + const request = {purpose: "first_delivery", admitted_turn: true, prior: null, + checkpoint_continuation: true, full_frontier: true, decision_scope: "goal", facts: current}; + const admitted = read(request); + assert.equal(admitted.ok, true); + for (const compute of [0, -1]) { + const paused = {...current, source: {...facts.source, registry_goal: {quota: {compute}}}}; + for (const rejected of [read({...request, facts: paused}), + check(admitted.receipt, {...request, facts: paused})]) { + assert.equal(rejected.ok, false); + assert.equal(rejected.error_code, "checkpoint_goal_paused"); + assert.equal(rejected.reread_required, false); + } + } + assert.equal(check(admitted.receipt, request).ok, true); + assert.equal(read({...request, facts: {...current, source: facts.source}}).ok, true); + // This guard belongs to pending direction; historical result recovery remains + // with its existing result/receipt owner rather than being erased by a pause. + assert.equal(read({purpose: "delivery_result", admitted_turn: true, prior: null, + facts: {...facts, source: {...facts.source, registry_goal: {quota: {compute: 0}}}}}).ok, true); +}); + + +test("admitted native validation recovers its passed outcome and holds uncertain execution", () => { + const receipt = read({purpose: "delivery_result", admitted_turn: true, prior: null}).receipt as JsonObject; + const terminal = {actor_agent_id: "agent", lease_expected_version: 1, + requested_completion_turn_key: "effect", evidence: "candidate", validation_declaration: {validation_command_argv: ["verify"]}}; + const issued = {status: "execute_validation", validation_effect: {kind: "caller_validation", validation_argv: ["verify"]}}; + const begin = (saved: JsonObject, input = terminal, effect = issued) => work({phase: "begin_terminal_validation", + identity, receipt: saved, terminal_input: input, execution_result: effect}); + const pending = begin(receipt); + assert.equal(pending.effect, "run"); + assert.equal(begin(pending.receipt as JsonObject).error_code, "delivery_validation_outcome_unknown"); + const replacement = read({purpose: "delivery_result", admitted_turn: true, prior: null, receipt: pending.receipt}); + assert.equal(replacement.error_code, "delivery_validation_outcome_unknown"); + assert.equal(replacement.reread_required, false); + const updates = {validation_receipt: {passed: true, exit_code: 0, validation_declaration_sha256: "original"}}; + const finish = work({phase: "finish_terminal_validation", identity, receipt: pending.receipt, + terminal_input: terminal, execution_result: issued, updates}); + const passed = finish.receipt as JsonObject; + assert.deepEqual(begin(passed).updates, updates); + assert.equal(begin(passed).effect, "replay"); + for (const changed of [{...terminal, evidence: "replacement"}, {...terminal, actor_agent_id: "other"}, + {...terminal, lease_expected_version: 2}, {...terminal, validation_declaration: {validation_command_argv: ["replacement"]}}, + {...terminal, requested_completion_turn_key: "new-turn"}]) { + assert.equal(begin(passed, changed).error_code, "delivery_validation_binding_mismatch"); + } + assert.equal(begin({...passed, read_context_id: "replacement"}).error_code, "delivery_validation_binding_mismatch"); + assert.equal(begin(passed, terminal, {...issued, validation_effect: {...issued.validation_effect, validation_argv: ["replacement"]}}).error_code, + "delivery_validation_binding_mismatch"); + const corrupt = structuredClone(passed); + ((corrupt.terminal_validation_effect as JsonObject).updates as JsonObject).validation_receipt = {passed: true, exit_code: 5}; + assert.equal(begin(corrupt).error_code, "delivery_validation_receipt_corrupt"); + const failed = work({phase: "finish_terminal_validation", identity, receipt: pending.receipt, + terminal_input: terminal, execution_result: issued, updates: {validation_receipt: {passed: false, exit_code: 4}}}); + assert.equal(begin(failed.receipt as JsonObject).effect, "run", "known failed validation remains legitimately retryable"); + assert.throws(() => work({phase: "finish_terminal_validation", identity, receipt: pending.receipt, + terminal_input: terminal, execution_result: issued, updates: {...updates, todo_done: true}}), /non-effect fields/); +}); + +test("Legacy commit policy distinguishes original terminal upgrade from new RESULT", () => { + const transaction = {schema_version: "loopx_todo_completion_transaction_result_v0", decision: "commit", + completion_identity_key: "original", fence: {status: "done", reason: "same_turn_terminal_upgrade"}, + completion_state: {continuation: "no_followup"}}; + assert.deepEqual(work({phase: "legacy_commit_policy", transaction, todo: {evidence: "original"}, candidate: {evidence: "original"}}), {required: false, recover_terminal_lease: true}); + for (const changed of [{...transaction, decision: "execute_validation"}, + {...transaction, fence: {status: "open", reason: "not_terminal"}}, + {...transaction, completion_identity_key: null}, + {...transaction, completion_state: {continuation: "active_goal"}}]) { + assert.deepEqual(work({phase: "legacy_commit_policy", transaction: changed}), {required: true, recover_terminal_lease: false}); + } +}); + +test("Legacy runtime closeout rejects a missing original quota settlement", async () => { + await assert.rejects(executeResultWorkBinding({phase: "legacy_commit_policy", runtime_root: resolve(tmpdir(), "loopx-missing-settlement-" + randomUUID()), + todo: {evidence: "original"}, candidate: {evidence: "original"}, + goal_id: "goal", todo_id: "task", agent_id: "agent", transaction: { + schema_version: "loopx_todo_completion_transaction_result_v0", decision: "commit", + completion_identity_key: "original", fence: {status: "done", reason: "lifecycle_reentry_terminal_upgrade"}, + completion_state: {continuation: "no_followup"}}}), /original quota identity and settled receipts/); +}); + +test("Legacy terminal closeout preserves committed result fields", () => { + const transaction = {schema_version: "loopx_todo_completion_transaction_result_v0", decision: "commit", + completion_identity_key: "original", fence: {status: "done", reason: "lifecycle_reentry_terminal_upgrade"}, + completion_state: {continuation: "no_followup"}}; + const todo = {evidence: "original", note: "original note", reason: "original reason"}; + for (const field of ["evidence", "note", "reason"]) { + assert.equal(work({phase: "legacy_commit_policy", transaction, todo, + candidate: {[field]: "replacement"}}).error_code, "delivery_validation_binding_mismatch"); + } + assert.deepEqual(work({phase: "legacy_commit_policy", transaction, todo, candidate: {}}), + {required: false, recover_terminal_lease: true}); +}); diff --git a/tests/control_plane_ts/content_digest_single_owner.test.ts b/tests/control_plane_ts/content_digest_single_owner.test.ts index 09cf4a9e65..dd79ec7b58 100644 --- a/tests/control_plane_ts/content_digest_single_owner.test.ts +++ b/tests/control_plane_ts/content_digest_single_owner.test.ts @@ -141,6 +141,7 @@ const CANONICAL_CONSUMERS = [ "control_plane/goals/direction_authority.ts", "control_plane/goals/first_party_host_runtime.ts", "control_plane/goals/goal_amendment_authority.ts", + "control_plane/goals/checkpoint_commit.ts", "control_plane/goals/goal_amendment_proposal.ts", "control_plane/goals/goal_objective.ts", "control_plane/goals/operator_actions.ts", diff --git a/tests/control_plane_ts/default_parent_transport.test.mjs b/tests/control_plane_ts/default_parent_transport.test.mjs new file mode 100644 index 0000000000..44de109700 --- /dev/null +++ b/tests/control_plane_ts/default_parent_transport.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import {readFileSync,writeFileSync,mkdirSync,existsSync} from 'node:fs'; +import {createHash,randomUUID} from 'node:crypto'; +import {tmpdir} from 'node:os'; +import {dirname,resolve} from 'node:path';import {pathToFileURL,fileURLToPath} from 'node:url'; +const P=dirname(fileURLToPath(import.meta.url)),D=process.env.LOOPX_PARENT_SOURCE_ROOT?resolve(process.env.LOOPX_PARENT_SOURCE_ROOT):resolve(P,'../..'); +const imp=p=>import(pathToFileURL(resolve(D,p))); +const {FileAuthorityStore}=await imp('loopx/control_plane/coordination/file_authority_store.ts'); +const {SqliteAuthorityStore}=await imp('loopx/control_plane/coordination/sqlite_authority_store.ts'); +const {canonicalAuthoritySha256}=await imp('loopx/control_plane/coordination/authority_store_codec.ts'); +const {executeCoordinationTodoTerminalLifecycle:execute,completionTurnOperationId}=await imp('loopx/control_plane/coordination/todo_terminal_lifecycle.ts'); +const {TODO_DOMAIN_ITEM_SCHEMA,TODO_DOMAIN_READ_RECORD_SCHEMA,TODO_DOMAIN_RECORD_CONTRACT}=await imp('loopx/control_plane/coordination/coordination_state_contract.ts'); +const cases=['exact_parent','mcp_default_parent','changed_evidence','changed_note','wrong_role','foreign_actor','foreign_policy_claim','foreign_delivery_context','foreign_lease','foreign_turn','changed_validation','clear_claim']; +const expanded=[...cases,...cases.map(id=>'deferred_'+id)]; +const rows=[];const root=process.env.LOOPX_PARENT_TEST_ROOT?resolve(process.env.LOOPX_PARENT_TEST_ROOT):resolve(tmpdir(),'parent-'+randomUUID().slice(0,12));assert.ok(!existsSync(root));mkdirSync(root); +for(const provider of ['file','sqlite'])for(const caseId of expanded){ + const deferred=caseId.startsWith("deferred_");const id=deferred?caseId.slice(9):caseId; + const dir=resolve(root,provider,caseId);mkdirSync(dir,{recursive:true});const goal='synthetic-parent',tid='todo_parent',owner='agent-a',agents=[owner,'agent-b']; + const store=provider==='file'?new FileAuthorityStore(resolve(dir,'authority'),goal):new SqliteAuthorityStore(resolve(dir,'authority'),goal); + const todo={schema_version:TODO_DOMAIN_ITEM_SCHEMA,todo_id:tid,role:'agent',status:'open',done:false,archive_state:'active',task_class:'advancement_task',text:'Validate a synthetic retained result',claimed_by:owner,no_followup:true};const todos=[todo]; + const seeded=await store.commitAuthority({operation_id:'seed',expected_provider_revision:null,events:[],receipts:[],next_projection:{goal_id:goal,handoff_mode:'soft_claim',todos,leases:[],todo_read_model:{schema_version:TODO_DOMAIN_READ_RECORD_SCHEMA,todo_count:1,records_sha256:canonicalAuthoritySha256(todos),contract_fields:[...TODO_DOMAIN_RECORD_CONTRACT.fields]}}});writeFileSync(resolve(dir,'seed-result.json'),JSON.stringify(seeded,null,2));assert.equal(seeded.status,'applied',JSON.stringify(seeded)); + const policy={schema_version:'loopx_todo_completion_policy_request_v0',goal_id:goal,agent_model:'peer_v1',claimed_by:null,registered_agents:agents,next_claimed_by:null,next_agent_todo:null,next_continuation_policy:null,next_excluded_agents:[],self_merged:false,evidence:'Synthetic validation',linked_successors:[]}; + const parent={goal_id:goal,todo_id:tid,expected_role:'agent',command:'complete',actor_agent_id:owner,registered_agents:agents,lifecycle_grants:[],authority_reason:null,decision_outcome:null,operation_identity:{kind:'completion_turn'},lease_idempotency_key:null,lease_expected_version:null,allow_user_gate_auto_acquire:true,requested_no_followup:false,defer_no_followup:true,requested_completion_turn_key:'original-synthetic-turn',requested_completion_identity_source:'turn_settlement',linked_successor_todo_ids:[],successor_intents:[],note:'Original managed next action',evidence:'Synthetic validation',reason:null,clear_claim:false,validation_declaration:null,validation_receipt:null,completion_policy_request:policy,dry_run:false,now:new Date('2026-10-13T00:00:00Z'),delivery_read_context_id:'original-synthetic-result-context'}; + const basis=async()=>({ok:true});const result=await execute(store,parent,async()=>true,undefined,undefined,basis);assert.equal(result.status,'applied',JSON.stringify(result)); + const parentId=completionTurnOperationId(parent,false),receipt=await store.readReceipt(parentId);const before=await store.loadAuthority();assert.equal(before.status,'loaded');assert.equal(before.head.todos[0].status,'done');assert.equal(before.head.todos[0].completion_requested_continuation,'no_followup'); + const exact={...parent,requested_no_followup:true,defer_no_followup:false};const mcp={...exact,expected_role:null,note:null,delivery_read_context_id:undefined,completion_policy_request:{...policy,claimed_by:owner}}; + let request=id==='exact_parent'?exact:mcp; + const patches={changed_evidence:{evidence:'Changed result'},changed_note:{note:'Changed intended note'},wrong_role:{expected_role:'user'},foreign_actor:{actor_agent_id:'agent-b'},foreign_policy_claim:{completion_policy_request:{...policy,claimed_by:'agent-b'}},foreign_delivery_context:{delivery_read_context_id:'foreign-result-context'},foreign_lease:{lease_idempotency_key:'foreign-lease',lease_expected_version:999},foreign_turn:{requested_completion_turn_key:'another-turn'},changed_validation:{validation_declaration_sha256:'f'.repeat(64)},clear_claim:{clear_claim:true}}; + if(patches[id])request={...mcp,...patches[id]};if(deferred)request={...request,defer_no_followup:true};const afterResult=await execute(store,request,async()=>true,undefined,undefined,basis);const after=await store.loadAuthority();const parentPreserved=canonicalAuthoritySha256(await store.readReceipt(parentId))===canonicalAuthoritySha256(receipt); + let passed;let replayResult=null; + if(deferred&&(id==='exact_parent'||id==='mcp_default_parent')){ + passed=afterResult.status==='replayed'&&canonicalAuthoritySha256(after)===canonicalAuthoritySha256(before)&&parentPreserved&&after.head.todos[0].no_followup===false; + if(passed){const final=await execute(store,{...request,defer_no_followup:false},async()=>true,undefined,undefined,basis);const closed=await store.loadAuthority();replayResult=await execute(store,{...request,defer_no_followup:false},async()=>{throw new Error('final replay must precede authority');},undefined,undefined,basis);passed=final.status==='applied'&&closed.head.todos[0].no_followup===true&&replayResult.status==='replayed'&&canonicalAuthoritySha256(await store.loadAuthority())===canonicalAuthoritySha256(closed);} + }else if(id==='exact_parent'||id==='mcp_default_parent'){ + passed=afterResult.status==='applied'&&after.head.todos[0].no_followup===true&&after.head.todos[0].completion_continuation==='no_followup'&&parentPreserved; + if(passed){const replay=await execute(store,request,async()=>{throw new Error('replay must precede authority');},undefined,undefined,basis);replayResult=replay;passed=replay.status==='replayed'&&canonicalAuthoritySha256(await store.loadAuthority())===canonicalAuthoritySha256(after);} + }else passed=afterResult.status==='failed'&&canonicalAuthoritySha256(after)===canonicalAuthoritySha256(before)&&parentPreserved; + const witness={provider,id:caseId,passed,replayResult,parent_accepted:true,limited_basis_callback:'synthetic owner boundary; does not qualify real checkpoint or installed MCP/browser',native_result:afterResult,head_before:before,head_after:after,parent_receipt_before:receipt,parent_receipt_after:await store.readReceipt(parentId)};const path=resolve(dir,'witness.json');writeFileSync(path,JSON.stringify(witness,null,2));rows.push({provider,id:caseId,passed,status:afterResult.status,reason_code:afterResult.reason_code??null,parent_receipt_preserved:parentPreserved,witness:path}); +} +writeFileSync(process.env.LOOPX_PARENT_RESULT_FILE?resolve(process.env.LOOPX_PARENT_RESULT_FILE):resolve(root,'native-evaluation.json'),JSON.stringify({scope:'48 native File/SQLite owner cases; acceptance fixture only, not full G5 or live model qualification',executed_source_root:D,owner_sha256:createHash('sha256').update(readFileSync(resolve(D,'loopx/control_plane/coordination/todo_terminal_lifecycle.ts'))).digest('hex'),rows,failed:rows.filter(r=>!r.passed).map(r=>r.provider+':'+r.id)},null,2));console.log(JSON.stringify(rows));if(rows.some(r=>!r.passed))process.exitCode=1; diff --git a/tests/control_plane_ts/effect_runtime_snapshot.test.ts b/tests/control_plane_ts/effect_runtime_snapshot.test.ts index 75aeb75fcf..c4eea51949 100644 --- a/tests/control_plane_ts/effect_runtime_snapshot.test.ts +++ b/tests/control_plane_ts/effect_runtime_snapshot.test.ts @@ -4,6 +4,22 @@ import {mkdtemp, writeFile, chmod, symlink, rm} from "node:fs/promises"; import {tmpdir} from "node:os"; import {join} from "node:path"; import {test} from "node:test"; +import {execFile} from "node:child_process"; +import {resolveTestPython} from "../../scripts/test-python.mjs"; +import {fileURLToPath} from "node:url"; + +async function windowsFixture(directory: string, path: string): Promise { + if (process.platform !== "win32") return; + const python = process.env.LOOPX_EFFECT_RUNTIME_PYTHON_BIN ?? resolveTestPython(); + process.env.LOOPX_EFFECT_RUNTIME_PYTHON_BIN = python; + const helper = fileURLToPath(new URL("../../loopx/control_plane/runtime/private_permissions.py", import.meta.url)); + await command(python, ["-c", "import runpy,sys;from pathlib import Path;m=runpy.run_path(sys.argv[1]);m['protect_private_path'](Path(sys.argv[2]),directory=True);m['protect_private_path'](Path(sys.argv[3]))", helper, directory, path]); +} + +function command(exe: string, args: string[], env = process.env): Promise { + return new Promise((resolve, reject) => execFile(exe, args, + {env, windowsHide: true, timeout: 30000}, error => error ? reject(error) : resolve())); +} import { openPrivateResponseSink, readPrivateJsonSnapshot, writePrivateResponse, MAX_LOCAL_SNAPSHOT_BYTES, @@ -15,6 +31,7 @@ test("local snapshot accepts exact private bytes and rejects altered or unsafe r const path = join(directory, "params.json"); const bytes = Buffer.from(JSON.stringify({context: "x".repeat(2_200_000)})); await writeFile(path, bytes, {mode: 0o600}); + await windowsFixture(directory, path); const ref = {path, byte_count: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex")}; assert.deepEqual(await readPrivateJsonSnapshot(ref), JSON.parse(bytes.toString())); @@ -24,14 +41,19 @@ test("local snapshot accepts exact private bytes and rejects altered or unsafe r /snapshot unavailable or invalid/); await assert.rejects(readPrivateJsonSnapshot({...ref, byte_count: MAX_LOCAL_SNAPSHOT_BYTES + 1}), /snapshot unavailable or invalid/); - // Windows has no POSIX mode denial: the private-mode rejection is only - // observable where process.getuid exists. - if (process.getuid) { - await chmod(path, 0o644); - await assert.rejects(readPrivateJsonSnapshot(ref), /snapshot unavailable or invalid/); - } - // File symlink creation needs Developer Mode or admin on Windows. - if (process.platform !== "win32") { + if (process.platform === "win32") { + await command("icacls.exe", [path, "/grant", "*S-1-1-0:F"]); + } else await chmod(path, 0o644); + await assert.rejects(readPrivateJsonSnapshot(ref), /snapshot unavailable or invalid/); + if (process.platform === "win32") { + const link = join(directory, "loopx-effect-junction"); + await command("pwsh", ["-NoLogo", "-NoProfile", "-Command", + "New-Item -ItemType Junction -Path $env:LX_LINK -Target $env:LX_TARGET | Out-Null"], + {...process.env, LX_LINK: link, LX_TARGET: directory}); + // A real Windows junction is the reparse-path analogue; no symlink privilege needed. + await assert.rejects(readPrivateJsonSnapshot({...ref, path: join(link, "params.json")}), + /snapshot unavailable or invalid/); + } else { const link = join(directory, "link.json"); await symlink(path, link); await assert.rejects(readPrivateJsonSnapshot({...ref, path: link}), @@ -47,6 +69,7 @@ test("large response sink is private and digest-bound", async () => { try { const path = join(directory, "response.json"); await writeFile(path, "", {mode: 0o600}); + await windowsFixture(directory, path); const file = await openPrivateResponseSink(path); const bytes = Buffer.from(JSON.stringify({result: "a".repeat(2_200_000)})); try { diff --git a/tests/control_plane_ts/first_delivery_observation.test.ts b/tests/control_plane_ts/first_delivery_observation.test.ts new file mode 100644 index 0000000000..ed31b8cba3 --- /dev/null +++ b/tests/control_plane_ts/first_delivery_observation.test.ts @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import {createHash} from "node:crypto"; +import {syncBuiltinESMExports} from "node:module"; +import {tmpdir} from "node:os"; +import {join, resolve} from "node:path"; +import test from "node:test"; +import {settlementIdentityPayload, type JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {createEffectRuntimeHandlers, dispatchEffectRuntimeMethod} from "../../loopx/control_plane/effect_runtime_handlers.ts"; + +const goal = "observation-goal", agent = "observation-agent"; +async function fixture(count: number, loss = "intact", pending = false) { + const root = await fsp.mkdtemp(join(tmpdir(), "loopx-delivery-observation-")); + const runs = join(root, "goals", goal, "runs"), contexts = join(root, "goals", goal, "checkpoint-contexts"); + await fsp.mkdir(runs, {recursive: true}); await fsp.mkdir(contexts, {recursive: true}); + const rows: JsonObject[] = [], events: JsonObject[] = [], todos: JsonObject[] = [], identities: JsonObject[] = []; + for (let i = 0; i < count; i++) { + const identity = settlementIdentityPayload({goal_id: goal, agent_id: agent, todo_id: `todo_work_${i}`, turn_instance_id: `turn-${i}`}); + identities.push(identity); + const common = {goal_id: goal, agent_id: agent, todo_id: identity.todo_id, turn_instance_id: identity.turn_instance_id, + settlement_identity: identity}; + const row = {...common, classification: "state_refreshed", delivery_outcome: "outcome_progress", + refresh_recovery: null, + vision_checkpoint: {required: true, satisfied: true, read_context: {purpose: "first_delivery"}}, + json_path: join(runs, `run-${i}.json`), markdown_path: join(runs, `run-${i}.md`)}; + rows.push(row); + await fsp.writeFile(row.json_path, JSON.stringify(row)); await fsp.writeFile(row.markdown_path, "Verified direction\n"); + const name = createHash("sha256").update(String(identity.effect_id)).digest("hex") + ".json"; + if (loss !== "deleted") await fsp.writeFile(join(contexts, name), loss === "damaged" ? "{" : JSON.stringify({ + schema_version: "checkpoint_read_context_v2", purpose: "first_delivery", identity})); + for (const kind of ["quota_should_run", "refresh_state", ...(pending && i === 0 ? [] : ["quota_spend"])]) { + events.push({schema_version: "loopx_rollout_event_v0", event_id: `${kind}-${i}`, event_kind: kind, + goal_id: goal, agent_id: agent, run_id: identity.turn_instance_id, + details: {todo_id: identity.todo_id, settlement_effect_id: identity.effect_id}}); + } + if (!(pending && i === 0)) rows.push({...common, classification: "quota_slot_spent"}); + todos.push({todo_id: identity.todo_id, status: "done", completion_turn_key: identity.effect_id}); + } + const index = join(runs, "index.jsonl"), log = join(root, "goals", goal, "rollout-event-log.jsonl"); + await fsp.writeFile(index, rows.map(row => JSON.stringify(row) + "\n").join("")); + await fsp.writeFile(log, events.map(row => JSON.stringify(row) + "\n").join("")); + return {root, index, log, rows, identities, request: {runtime_root: root, goal_id: goal, agent_id: agent, todos}}; +} + +const observe = (request: JsonObject) => dispatchEffectRuntimeMethod(createEffectRuntimeHandlers(), "turn.first_delivery.observe", request); + +for (const loss of ["intact", "damaged", "deleted"]) for (const count of [0, 1, 100, 1000]) { + test(`one batch bounds source reads across ${count} ${loss} historical Turns`, async t => { + const f = await fixture(count, loss); + const syncRead = fs.readFileSync, asyncRead = fsp.readFile; + const reads = {index: 0, rollout: 0}; + const record = (path: unknown) => { + if (resolve(String(path)) === resolve(f.index)) reads.index++; + if (resolve(String(path)) === resolve(f.log)) reads.rollout++; + }; + const a = t.mock.method(fs, "readFileSync", (...args: Parameters) => {record(args[0]); return syncRead(...args);}); + const b = t.mock.method(fsp, "readFile", (...args: Parameters) => {record(args[0]); return asyncRead(...args);}); + syncBuiltinESMExports(); + try { + assert.equal(await observe(f.request), null); + assert.equal(reads.index, count ? 2 : 1, "strict proof and settlement each capture the full index once"); + assert.equal(reads.rollout, count ? 1 : 0); + } finally {a.mock.restore(); b.mock.restore(); syncBuiltinESMExports(); await fsp.rm(f.root, {recursive: true, force: true});} + }); +} + +for (const loss of ["intact", "damaged", "deleted"]) test(`old pending accounting survives newer settled ${loss} history`, async () => { + const f = await fixture(100, loss, true); + try { + const progress = await observe(f.request) as JsonObject; + assert.equal(progress.stage, "settlement_pending"); + assert.deepEqual(progress.settlement_identity, f.identities[0]); + assert.equal(progress.quota_spent, false); + assert.equal(progress.result_committed, true); + assert.equal(await observe({...f.request, agent_id: "independent-peer"}), null); + assert.deepEqual(await observe(f.request), progress, "readback cannot repair or debit the original Turn"); + } finally {await fsp.rm(f.root, {recursive: true, force: true});} +}); + +for (const fault of ["tail", "conflicting-row", "artifact", "changed-index"]) test(`unproved ${fault} history stays uncertain`, async t => { + const f = await fixture(3, "damaged"); + const before = fs.readFileSync(f.index, "utf8"); + let hook: ReturnType | undefined; + try { + if (fault === "tail") await fsp.writeFile(f.index, before.trimEnd()); + if (fault === "conflicting-row") await fsp.appendFile(f.index, JSON.stringify({...f.rows[0], json_path: join(f.root, "other.json")}) + "\n"); + if (fault === "artifact") await fsp.writeFile(String(f.rows[0].json_path), JSON.stringify({...f.rows[0], settlement_identity: f.identities[1]})); + if (fault === "changed-index") { + const read = fsp.readFile; + hook = t.mock.method(fsp, "readFile", async (...args: Parameters) => { + if (resolve(String(args[0])) === resolve(f.index)) await fsp.appendFile(f.index, JSON.stringify({classification: "state_refreshed"}) + "\n"); + return read(...args); + }); syncBuiltinESMExports(); + } + const observed = await observe(f.request) as JsonObject; + assert.ok(["observation_unavailable", "operation_unknown"].includes(String(observed.stage)), JSON.stringify(observed)); + assert.equal(observed.goal_completion_certified, false); + } finally {hook?.mock.restore(); syncBuiltinESMExports(); await fsp.rm(f.root, {recursive: true, force: true});} +}); + +test("batch observation cannot invent or partially supply exact Goal admission", async () => { + const f = await fixture(1); + try { + await assert.rejects(observe({...f.request, goal_ref: {goal_id: goal, goal_instance_id: "ginst_" + "a".repeat(32)}}), /supplied together/); + } finally {await fsp.rm(f.root, {recursive: true, force: true});} +}); + +test("the caller's historical scan order selects between pending original Turns", async () => { + const f = await fixture(2, "intact", true); + try { + await fsp.writeFile(f.index, f.rows.filter(row => !(row.classification === "quota_slot_spent" && + row.turn_instance_id === "turn-1")).map(row => JSON.stringify(row) + "\n").join("")); + const events = fs.readFileSync(f.log, "utf8").trim().split("\n").map(line => JSON.parse(line)); + await fsp.writeFile(f.log, events.filter(row => !(row.event_kind === "quota_spend" && row.run_id === "turn-1")) + .map(row => JSON.stringify(row) + "\n").join("")); + const names = f.identities.map(identity => createHash("sha256").update(String(identity.effect_id)).digest("hex") + ".json"); + assert.deepEqual((await observe({...f.request, receipt_names: names}) as JsonObject).settlement_identity, f.identities[1]); + assert.deepEqual((await observe({...f.request, receipt_names: names.reverse()}) as JsonObject).settlement_identity, f.identities[0]); + } finally {await fsp.rm(f.root, {recursive: true, force: true});} +}); + +test("an unassignable malformed receipt name is scoped unavailable evidence", async () => { + const f = await fixture(1); + try { + const path = join(f.root, "goals", goal, "checkpoint-contexts", "unassignable.json"); + await fsp.writeFile(path, "{"); + const observed = await observe(f.request) as JsonObject; + assert.equal(observed.stage, "observation_unavailable"); + assert.equal(observed.settlement_identity, undefined); + assert.equal(fs.readFileSync(path, "utf8"), "{"); + } finally {await fsp.rm(f.root, {recursive: true, force: true});} +}); + +test("the existing identity owner rejects forged effects and retains opaque historical facts", async () => { + const f = await fixture(1, "intact", true); + try { + const name = createHash("sha256").update(String(f.identities[0].effect_id)).digest("hex") + ".json"; + const path = join(f.root, "goals", goal, "checkpoint-contexts", name); + const receipt = JSON.parse(fs.readFileSync(path, "utf8")); + receipt.identity = {...receipt.identity, historical_note: "retained opaque fact"}; + await fsp.writeFile(path, JSON.stringify(receipt)); + const observed = await observe(f.request) as JsonObject; + assert.deepEqual(observed.settlement_identity, receipt.identity); + receipt.identity.effect_id = "forged-effect"; + await fsp.writeFile(path, JSON.stringify(receipt)); + const rejected = await observe(f.request) as JsonObject; + assert.equal(rejected.stage, "observation_unavailable"); + assert.equal(rejected.settlement_identity, undefined); + } finally {await fsp.rm(f.root, {recursive: true, force: true});} +}); diff --git a/tests/control_plane_ts/first_delivery_observation_probe.mjs b/tests/control_plane_ts/first_delivery_observation_probe.mjs new file mode 100644 index 0000000000..5c8234c343 --- /dev/null +++ b/tests/control_plane_ts/first_delivery_observation_probe.mjs @@ -0,0 +1,16 @@ +// Test-process instrumentation only: count actual reads without changing facts. +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import {syncBuiltinESMExports} from "node:module"; +const sink = process.env.LOOPX_OBSERVATION_TEST_READS; +const goal = process.env.LOOPX_OBSERVATION_TEST_GOAL; +function record(path) { + const name = String(path).replaceAll("\\", "/"); + if (!sink || !goal || !name.includes(`/goals/${goal}/`)) return; + if (name.endsWith("/runs/index.jsonl")) fs.appendFileSync(sink, "index\n"); + if (name.endsWith("/rollout-event-log.jsonl")) fs.appendFileSync(sink, "rollout\n"); +} +const syncRead = fs.readFileSync, asyncRead = fsp.readFile; +fs.readFileSync = (...args) => {record(args[0]); return syncRead(...args);}; +fsp.readFile = (...args) => {record(args[0]); return asyncRead(...args);}; +syncBuiltinESMExports(); diff --git a/tests/control_plane_ts/first_delivery_projection.test.ts b/tests/control_plane_ts/first_delivery_projection.test.ts new file mode 100644 index 0000000000..a1398851b5 --- /dev/null +++ b/tests/control_plane_ts/first_delivery_projection.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {evaluateFirstDelivery} from "../../loopx/control_plane/turn_driver/first_delivery.ts"; + +test("an owner-confirmed exemption continues settlement without claiming a direction commit", () => { + const writeback_run = {vision_checkpoint: {required: false, satisfied: true, decision: "not_required"}}; + const pending = evaluateFirstDelivery({phase: "project", writeback_run, result_committed: true}); + assert.equal(pending.stage, "settlement_pending"); + assert.equal(pending.direction_required, false); + assert.equal(pending.direction_committed, false); + assert.equal(evaluateFirstDelivery({phase: "project", writeback_run, settlement_complete: true}).stage, "settled"); + for (const checkpoint of [undefined, {decision: "not_required"}, + {...writeback_run.vision_checkpoint, required: true}]) { + assert.equal(evaluateFirstDelivery({phase: "project", result_committed: true, + writeback_run: {vision_checkpoint: checkpoint}}).stage, "direction_pending"); + } +}); + +test("an uncommitted result asks for result validation without claiming retained output", () => { + const projected = evaluateFirstDelivery({phase: "project", result_committed: false}); + assert.equal(projected.stage, "result_review_pending"); + assert.equal(projected.result_committed, false); + assert.equal(projected.direction_committed, false); +}); + +test("default pending, failure and stop preserve observed results and noncompletion outcomes", () => { + const base = {phase: "project", checkpoint_continuation: true, completion_event: {status: "completed"}}; + assert.equal(evaluateFirstDelivery(base).stage, "direction_pending"); + assert.equal(evaluateFirstDelivery({...base, direction_receipt: {direction_stopped: true}}).stage, "direction_stopped"); + assert.equal(evaluateFirstDelivery({...base, direction_receipt: {direction_host_failure: {}}}).stage, "direction_failed"); + const progress = evaluateFirstDelivery({phase: "project", writeback_run: { + vision_checkpoint: {required: true, satisfied: false, decision: "missing_required"}}}); + assert.equal(progress.stage, "direction_pending"); + assert.equal(progress.result_committed, false); + assert.equal(progress.outcome_retained, true); +}); + +test("native committed direction wins over a missing projection flag, but unresolved attempts remain unknown", () => { + const writeback_run = {vision_checkpoint: {satisfied: true, required: true, + read_context: {purpose: "first_delivery"}}}; + const direction_receipt = {commit_attempt: {}}; + const projected = evaluateFirstDelivery({phase: "project", writeback_run, direction_receipt}); + assert.equal(projected.stage, "settlement_pending"); + assert.equal(projected.direction_committed, true); + assert.equal(evaluateFirstDelivery({phase: "project", direction_receipt}).stage, "operation_unknown"); + assert.equal(evaluateFirstDelivery({phase: "project", writeback_run, unknown: true}).stage, "operation_unknown"); +}); + +test("ordinary settlement clears pending while original terminal intent waits for closeout", () => { + const base = {phase: "project", result_committed: true, settlement_complete: true, + identity: {effect_id: "original", turn_instance_id: "turn-original"}, + writeback_run: {vision_checkpoint: {satisfied: true, required: true, + read_context: {purpose: "first_delivery"}}}}; + assert.equal(evaluateFirstDelivery(base).stage, "settled"); + const terminal = {...base, completion_todo: {status: "done", no_followup: false, + completion_continuation: "active_goal", completion_requested_continuation: "no_followup", + completion_turn_key: "original"}}; + assert.equal(evaluateFirstDelivery(terminal).stage, "settlement_pending"); + assert.equal(evaluateFirstDelivery({...terminal, terminal_settlement_complete: true}).stage, "settled"); + assert.equal(evaluateFirstDelivery({...terminal, + completion_todo: {...terminal.completion_todo, completion_turn_key: "another-turn"}}).stage, "settled"); +}); diff --git a/tests/control_plane_ts/goal_acceptance_authority.test.ts b/tests/control_plane_ts/goal_acceptance_authority.test.ts index 9016695958..89a15de45b 100644 --- a/tests/control_plane_ts/goal_acceptance_authority.test.ts +++ b/tests/control_plane_ts/goal_acceptance_authority.test.ts @@ -33,9 +33,11 @@ const goalA = {goal_id: goal, goal_instance_id: "ginst_aaaaaaaaaaaaaaaaaaaaaaaaa const goalB = {goal_id: goal, goal_instance_id: "ginst_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}; test("documented owner configuration satisfies the canonical acceptance contract", async () => { const reference = await readFile(new URL("../../docs/reference/goal-acceptance-observations.md", import.meta.url), "utf8"); - const example = reference.match(/```json\r?\n([\s\S]*?)\r?\n```/); - assert.ok(example, "the operation guide must include a runnable configuration"); - assert.doesNotThrow(() => normalizeGoalAcceptanceDocument(JSON.parse(example[1]))); + for (const newline of ["\n", "\r\n"]) { + const example = reference.replace(/\r?\n/g, newline).match(/```json\r?\n([\s\S]*?)\r?\n```/); + assert.ok(example, "the operation guide must include a runnable configuration"); + assert.doesNotThrow(() => normalizeGoalAcceptanceDocument(JSON.parse(example[1]))); + } }); function todo(todo_id: string, extra: JsonObject = {}): JsonObject { diff --git a/tests/control_plane_ts/host_stdio_bridge.test.ts b/tests/control_plane_ts/host_stdio_bridge.test.ts index 568289b57f..67e5c14e9d 100644 --- a/tests/control_plane_ts/host_stdio_bridge.test.ts +++ b/tests/control_plane_ts/host_stdio_bridge.test.ts @@ -5,10 +5,11 @@ import {existsSync} from "node:fs"; import {mkdtemp, rm, writeFile} from "node:fs/promises"; import {tmpdir} from "node:os"; import {join} from "node:path"; +import {fileURLToPath} from "node:url"; import {setTimeout as delay} from "node:timers/promises"; import test from "node:test"; -const bridge = new URL("../../loopx/control_plane/turn_driver/host_stdio_bridge.ts", import.meta.url).pathname; +const bridge = fileURLToPath(new URL("../../loopx/control_plane/turn_driver/host_stdio_bridge.ts", import.meta.url)); const launch = (script: string) => spawn(process.execPath, ["--no-warnings", "--experimental-strip-types", bridge, process.execPath, "-e", script], {stdio: ["pipe", "pipe", "pipe"]}); diff --git a/tests/control_plane_ts/host_todo_completion.test.ts b/tests/control_plane_ts/host_todo_completion.test.ts index 02e55b8a09..52b647cef2 100644 --- a/tests/control_plane_ts/host_todo_completion.test.ts +++ b/tests/control_plane_ts/host_todo_completion.test.ts @@ -13,6 +13,13 @@ import { const todoId = "todo_abc123"; +test("PR1 rejects protected terminal intent before preparing provider effects", () => { + assert.throws(() => prepare({schema_version: "loopx_host_todo_completion_transaction_v2", + first_delivery: true}), /no effects were admitted/); + const ordinary = prepare(); + assert.ok(ordinary.provider_effect); +}); + test("vision refresh shares the original delivery command and identity without a spend", () => { const authored = {schema_version: HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION, vision_path: "fixture-vision.json"}; @@ -228,6 +235,39 @@ function finalize( ); } +test("committed original result can recover settlement without claiming a restored work input", () => { + const identity = identityFrom(prepare()); + const guard = {ok: true, heartbeat_receipt: {settlement_identity: identity, + result_freshness: "turn_result_freshness_required_v1"}, work_input_restored: false}; + assert.equal(finalize(providerOutcomes(identity, {guard})).decision, "blocked"); + assert.equal(finalize(providerOutcomes(identity, {guard: {...guard, result_committed: true}})).decision, "complete"); + const other = {...identity, turn_instance_id: "another-turn"}; + assert.equal(finalize(providerOutcomes(identity, {guard: {...guard, result_committed: true, + heartbeat_receipt: {...guard.heartbeat_receipt, settlement_identity: other}}})).decision, "blocked"); +}); + +test("default current-Agent completion reads post-result facts and resumes a proven direction append", () => { + const enabled = {schema_version: "loopx_host_todo_completion_transaction_v2", first_delivery: true, + current_agent_checkpoint: true, vision_unchanged_reason: "Old pre-result decision."}; + const prepared = prepare(enabled); + const identity = identityFrom(prepared); + const steps = (prepared.provider_effect as {steps: {step_kind: string}[]}).steps; + assert.deepEqual(steps.map(s => s.step_kind), ["guard", "lifecycle_completion", "direction_context"]); + const outcomes = providerOutcomes(identity).slice(0, 2); + const context = {ok: true, settlement_identity: identity, read_context_id: "read-A", basis: {}}; + const pending = finalize([...outcomes, {step_kind: "direction_context", output: JSON.stringify(context)}], enabled); + assert.equal((pending.result as Record).stage, "direction_pending"); + assert.equal((pending.result as Record).settlement_complete, false); + const committed = {...context, status: "committed", context: {purpose: "first_delivery", read_context_id: "original-A"}}; + const resume = finalize([...outcomes, {step_kind: "direction_context", output: JSON.stringify(committed)}], enabled); + assert.equal(resume.decision, "execute"); + assert.equal(resume.resume_checkpoint_read_context_id, "original-A"); + const resumed = (resume.provider_effect as {steps: {args: string[]}[]}).steps; + assert.ok(resumed[2].args.includes("original-A")); + assert.equal(resumed[2].args.includes("Old pre-result decision."), false); + assert.equal(resumed.at(-1)!.args.includes("--no-follow-up"), true); +}); + test("prepare owns the retry-stable identity and one ordered host CLI plan", () => { const original = request("prepare"); const snapshot = structuredClone(original); diff --git a/tests/control_plane_ts/lifecycle_admission_snapshot.test.ts b/tests/control_plane_ts/lifecycle_admission_snapshot.test.ts new file mode 100644 index 0000000000..cf34fc5427 --- /dev/null +++ b/tests/control_plane_ts/lifecycle_admission_snapshot.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {projectReplanSemantics} from "../../loopx/control_plane/work_items/replan_semantics.ts"; + +const admitted = [0,1,2].map(index => ({todo_id:`todo_${index}`,index,status:"done",done:true, + task_class:"advancement_task",archive_state:"active",claimed_by:"agent-a",last_actor_agent_id:"agent-a", + completed_at:`2026-09-01T00:00:0${2-index}Z`,updated_at:"2026-09-01T00:00:00Z", + completion_turn_key:`original-${index}`,completion_continuation:"active_goal"})); +const current=admitted.map(item=>({...item,no_followup:true,completion_continuation:"no_followup", + completion_recovery:"lifecycle_reentry_terminal_closeout"})); +const source={obligation_id:"replan-1111111111111111",resolution_mode:"todo_lifecycle_settlement", + lifecycle_admission_items:admitted,triggers:admitted.map(item=>({kind:"completed_advancement_without_successor", + agent_id:"agent-a",todo_id:item.todo_id,completion_turn_key:item.completion_turn_key}))}; +function run(obligation=source,items=current,guard="2026-09-02T00:00:00Z"){return projectReplanSemantics({operation:"lifecycle_obligation", + obligation,selected_obligation_id:source.obligation_id,agent_id:"agent-a",guard_recorded_at:guard,items});} +test("sealed original admission recovers closeout without rewriting old Todo timestamps",()=>{ + assert.equal((run().obligation as Record).resolution_mode,"receipt_bound_replan_settlement"); +}); +for(const field of ["todo_id","claimed_by","last_actor_agent_id","completion_turn_key","completed_at","updated_at"]){ + test(`snapshot recovery rejects changed ${field}`,()=>{ + const items=structuredClone(current);(items[0] as Record)[field]="changed"; + assert.throws(()=>run(source,items),/lifecycle source does not match/); + }); +} +for(const shape of ["closed_before_guard","unsettled","missing_source","duplicate_source","duplicate_current","wrong_trigger"]){ + test(`snapshot recovery rejects ${shape}`,()=>{ + const obligation=structuredClone(source),items=structuredClone(current); + if(shape==="closed_before_guard")Object.assign(obligation.lifecycle_admission_items[0],{no_followup:true}); + if(shape==="unsettled")Object.assign(items[0],{no_followup:false}); + if(shape==="missing_source")obligation.lifecycle_admission_items.pop(); + if(shape==="duplicate_source")obligation.lifecycle_admission_items[1]=obligation.lifecycle_admission_items[0]; + if(shape==="duplicate_current")items.push(items[0]); + if(shape==="wrong_trigger")obligation.triggers[0].completion_turn_key="wrong"; + assert.throws(()=>run(obligation,items),/lifecycle source does not match/); + }); +} + +test("sealed admission retains a still-pending original lifecycle", () => { + const result = run(source, structuredClone(admitted) as typeof current); + assert.equal((result.obligation as Record).resolution_mode, "todo_lifecycle_settlement"); + assert.equal((result.obligation as Record).selection_binding, "heartbeat_receipt"); +}); + + +test("sealed admission accepts the lifecycle owner's closeout timestamp", () => { + const items = structuredClone(current); + items[0].updated_at = "2026-09-03T00:00:00Z"; + assert.equal((run(source, items).obligation as Record).resolution_mode, + "receipt_bound_replan_settlement"); + items[0].updated_at = "2026-09-01T12:00:00Z"; + assert.throws(() => run(source, items), /lifecycle source does not match/); +}); + +test("sealed admission accepts a later closeout in the guard's whole second", () => { + const items = current.map(item => ({...item, updated_at: "2026-09-02T00:00:00Z"})); + const guard = "2026-09-02T00:00:00.654321Z"; + assert.equal((run(source, items, guard).obligation as Record).resolution_mode, + "receipt_bound_replan_settlement"); + items[0].updated_at = "2026-09-01T23:59:59Z"; + assert.throws(() => run(source, items, guard), /lifecycle source does not match/); +}); diff --git a/tests/control_plane_ts/local_authority_shadow_outbox.test.ts b/tests/control_plane_ts/local_authority_shadow_outbox.test.ts index af81edfeaf..e7fc7db4d9 100644 --- a/tests/control_plane_ts/local_authority_shadow_outbox.test.ts +++ b/tests/control_plane_ts/local_authority_shadow_outbox.test.ts @@ -17,6 +17,7 @@ import { SHADOW_EXACT_DRAIN_SCHEMA, } from "../../loopx/control_plane/coordination/shadow_drain.ts"; import * as schemas from "../../loopx/control_plane/coordination/coordination_state_contract.generated.ts"; +import {TODO_DOMAIN_READ_RECORD_SCHEMA} from "../../loopx/control_plane/coordination/coordination_state_contract.ts"; import { fixture, pendingEntry, settleFiles, todo, sha } from "./shadow_file_fixture.ts"; import { resolveTestPython } from "../../scripts/test-python.mjs"; @@ -33,17 +34,22 @@ const GOAL_B = { test("released Todo manifests replay without rewriting history and allow the next capture", async (t) => { for (const absent of [ - ["completion_receipt_id"], - ["completion_receipt_id", "completion_result"], + [], ["completion_receipt_id"], ["completion_receipt_id", "completion_result"], ["completion_receipt_id", "completion_result", "completion_validation_revision", "completion_validation_revision_history"], ]) { const f = await fixture(t); const commit = f.store.commitAuthority.bind(f.store); - // Write a real File transaction using a released writer's field manifest. + // Freeze the actual released manifest, then replay its earlier revisions. + // Subtracting fields from today's contract constructs a mixed release. + const released = JSON.parse(await readFile(new URL( + "../control_plane/fixtures/todo_read_fields_a3266402.json", import.meta.url), "utf8")); + assert.equal(released.source_commit, "a326640280507e7e6dcd5adba8ce706bd03b219f"); f.store.commitAuthority = async (input) => { const next = structuredClone(input.next_projection); const model = next.todo_read_model as JsonObject; - model.contract_fields = (model.contract_fields as string[]).filter((field) => !absent.includes(field)); + const fields = model.schema_version === TODO_DOMAIN_READ_RECORD_SCHEMA + ? released.domain_fields : released.canonical_fields; + model.contract_fields = fields.filter((field: string) => !absent.includes(field)); return commit({...input, next_projection: next}); }; const request = await pendingEntry(f, 1, {handoff_mode: "hard_lease", todos: [todo()]}); @@ -64,7 +70,7 @@ test("released Todo manifests replay without rewriting history and allow the nex if (history.status !== "page") continue; assert.equal(history.transactions.length, 3); assert.equal(((history.transactions[1]!.projection.todo_read_model as JsonObject).contract_fields as string[]) - .includes("completion_receipt_id"), false); + .includes("completion_receipt_id"), !absent.includes("completion_receipt_id")); assert.equal(((history.transactions[2]!.projection.todo_read_model as JsonObject).contract_fields as string[]) .includes("completion_receipt_id"), true); } diff --git a/tests/control_plane_ts/postgresql_authority_store.integration.test.ts b/tests/control_plane_ts/postgresql_authority_store.integration.test.ts index 4841898d76..afa3bdefdd 100644 --- a/tests/control_plane_ts/postgresql_authority_store.integration.test.ts +++ b/tests/control_plane_ts/postgresql_authority_store.integration.test.ts @@ -1,10 +1,21 @@ +import {spawnSync} from "node:child_process"; +import {readFileSync} from "node:fs"; +import {authorityProjectionFixture} from "./authority_projection_fixture.ts"; +import {legacyCoordinationWriterFencePath} from "../../loopx/control_plane/coordination/legacy_writer_fence.ts"; +import {localAuthorityProviderPaths} from "../../loopx/control_plane/coordination/local_authority_provider.ts"; +import {createEffectRuntimeHandlers, dispatchEffectRuntimeMethod} from "../../loopx/control_plane/effect_runtime_handlers.ts"; +import {settlementIdentity, settlementIdentityPayload, type JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {acquireFileMutationLock, releaseFileMutationLock} from "../../loopx/control_plane/effect_runtime_io.ts"; +import {shadowMaintenanceLockPath} from "../../loopx/control_plane/coordination/shadow_management.ts"; +import {legacyCoordinationTodoLockPath} from "../../loopx/control_plane/coordination/legacy_writer_lock_paths.ts"; +import {refreshRecovery, decodeRefreshRetry} from "../../loopx/control_plane/quota/refresh_recovery.ts"; import {executeCoordinationTodoUpdate} from "../../loopx/control_plane/coordination/todo_update.ts"; import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; import {TODO_DOMAIN_READ_RECORD_SCHEMA, TODO_DOMAIN_RECORD_CONTRACT} from "../../loopx/control_plane/coordination/coordination_state_contract.ts"; import assert from "node:assert/strict"; import { createHash, randomUUID } from "node:crypto"; -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import {dirname, join} from "node:path"; import { tmpdir } from "node:os"; import test from "node:test"; import { Pool, type PoolClient } from "pg"; @@ -670,3 +681,146 @@ test("PostgreSQL provider evicts and awaits cleanup-uncertain read connections", }); assert.equal(queries.filter((query) => query === "ROLLBACK").length, 1); }); + + +if (database && installed) { + test("PostgreSQL checkpoint holds Goal and incarnation locks through external save", async t => { + await installed; + const tenant = `checkpoint-${randomUUID()}`, goal = "checkpoint-lock"; + const store = new PostgreSqlAuthorityStore(database, {tenant_id: tenant, goal_id: goal}); + t.after(() => cleanScope(tenant, goal)); + assert.equal((await store.commitAuthority(commit(null, "seed", 1, 1))).status, "applied"); + const before = await store.loadAuthority(); + assert.equal(before.status, "loaded"); + if (before.status !== "loaded") return; + const probe = `import {Pool} from 'pg'; + const p=new Pool({connectionString:process.env.LOOPX_TEST_POSTGRES_URL}); + const c=await p.connect(); await c.query('BEGIN'); + const lock=await c.query('SELECT pg_try_advisory_xact_lock($1::bigint) AS acquired',['5522768808912313929']); + if(lock.rows[0].acquired) process.exitCode=2; + try {await c.query('SELECT head FROM loopx_control_plane.authority_heads WHERE tenant_id=$1 AND goal_id=$2 FOR UPDATE NOWAIT', process.argv.slice(1)); process.exitCode=3;} + catch(e){if(e.code!=='55P03') throw e;} + await c.query('ROLLBACK'); c.release(); await p.end();`; + const result = await store.withCheckpointHead((head, identity) => { + assert.equal(head.provider_revision, before.provider_revision); + assert.equal(identity, STORE_IDENTITY); + // Separate process: a timer in this event loop cannot observe the lock + // during the synchronous save callback. NOWAIT is the semantic oracle. + const peer = spawnSync(process.execPath, ["--input-type=module", "-e", probe, tenant, goal], + {encoding: "utf8", timeout: 10000}); + assert.equal(peer.status, 0, peer.stderr); + return {ok: true}; + }); + assert.equal(result.ok, true); + assert.deepEqual(await store.loadAuthority(), before); + await assert.rejects(store.withCheckpointHead(() => {throw new Error("save failed");}), /save failed/); + assert.deepEqual(await store.loadAuthority(), before); + assert.equal((await store.commitAuthority(commit(before.provider_revision, "after-save", 2, 2))).status, "applied"); + const missing = new PostgreSqlAuthorityStore(database, {tenant_id: tenant, goal_id: "absent"}); + await assert.rejects(missing.withCheckpointHead(() => {throw new Error("must not save");}), /head is missing/); + }); + + for (const address of ["direct", "alias"] as const) { + test(`PostgreSQL production checkpoint effects protect each Turn and preserve original work reads (${address})`, async t => { + await installed; + const directory = await mkdtemp(join(tmpdir(), "checkpoint-pg-turns-")); + const physical = join(directory, "physical"); + await mkdir(physical); + const root = address === "alias" ? join(directory, "alias") : physical; + if (address === "alias") await symlink(physical, root, process.platform === "win32" ? "junction" : "dir"); + const tenant = `checkpoint-${randomUUID()}`, goal = "checkpoint-turns", agent = "agent", todo = "todo_task"; + t.after(async () => {await cleanScope(tenant, goal); await rm(directory, {recursive: true, force: true});}); + const store = new PostgreSqlAuthorityStore(database, {tenant_id: tenant, goal_id: goal}); + const projection = (text: string) => authorityProjectionFixture(goal, [{todo_id: todo, role: "agent", + status: "open", done: false, claimed_by: agent, archive_state: "active", text}]); + const seeded = await store.commitAuthority({expected_provider_revision: null, operation_id: "seed", + next_projection: projection("Requirement A"), events: [], receipts: []}); + assert.equal(seeded.status, "applied"); + const marker = localAuthorityProviderPaths(root, goal).marker; + const fence = legacyCoordinationWriterFencePath(root, goal); + await mkdir(dirname(marker), {recursive: true}); await mkdir(dirname(fence), {recursive: true}); + await writeFile(marker, JSON.stringify({schema_version: "loopx_local_authority_provider_v0", provider: "postgresql", + goal_id: goal, tenant_id: tenant, store_identity: STORE_IDENTITY})); + await writeFile(fence, JSON.stringify({schema_version: "loopx_legacy_coordination_writer_fence_v0", state: "engaged", + goal_id: goal, fence_id: "checkpoint", source_version: "synthetic", source_projection_sha256: "fixture", + expected_shadow_provider_revision: "fixture"})); + const handlers = createEffectRuntimeHandlers({fingerprint: "pg-checkpoint-test", requestShutdown() {}, + authorityProvider: {openPostgresqlStore: () => store}}); + const effect = (method: string, request: JsonObject) => dispatchEffectRuntimeMethod(handlers, method, request) as Promise; + const goalDir = join(root, "goals", goal), runs = join(goalDir, "runs"), contexts = join(goalDir, "checkpoint-contexts"); + await mkdir(runs, {recursive: true}); await mkdir(contexts); + const state = join(root, "state.md"), registry = join(root, "registry.json"), index = join(runs, "index.jsonl"); + await writeFile(state, "Requirement A"); await writeFile(registry, "{}"); await writeFile(index, ""); + const owner = join(root, "owner.md"); + await writeFile(owner, "Owner requirement A"); + const facts = {source: {state_file: state, runtime_root: root, + registry_goal: {repo: root, authority_sources: [{kind: "goal_doc", path: "owner.md"}]}}, frontmatter: {objective: "deliver"}, + goal_prose: "Deliver the owner requirement", agent_vision: null, acceptance: null, todos: []}; + const hash = (path: string) => createHash("sha256").update(readFileSync(path)).digest("hex"); + const log = join(goalDir, "rollout-event-log.jsonl"); + let logText = ""; + for (const turn of ["one", "two"]) { + const identity = settlementIdentityPayload(settlementIdentity({goal_id: goal, agent_id: agent, todo_id: todo, turn_instance_id: turn})); + logText += JSON.stringify({schema_version: "loopx_rollout_event_v0", event_id: turn, event_kind: "quota_should_run", + goal_id: goal, agent_id: agent, run_id: turn, details: {todo_id: todo, settlement_effect_id: identity.effect_id, + result_freshness: "turn_result_freshness_required_v1"}}) + "\n"; + await writeFile(log, logText); + const read = async (key: string): Promise => ({...await effect("goal.checkpoint_read_context.resolve", {runtime_root: root, + phase: "read", purpose: "delivery_result", result_only: true, identity, admitted_turn: true, + read_context_id: key, facts}), read_context_id: key}); + let context = await read(`work-${turn}`); + const receiptPath = join(contexts, `${createHash("sha256").update(identity.effect_id + ":delivery_result").digest("hex")}.json`); + await writeFile(receiptPath, JSON.stringify(context.receipt)); + const retry = {vision: null, unchanged_reason: null, merge_patch: false, workspace_requested: false, mutation: {}, + delivery_outcome: "outcome_progress", delivery_batch_scale: "implementation", delivery_boundary: "in_flight_continuation", + progress_observation: null}; + const recovery = refreshRecovery(decodeRefreshRetry(retry)!, null, false, undefined, false); + const row: JsonObject = {goal_id: goal, agent_id: agent, todo_id: todo, turn_instance_id: turn, + classification: "validated_progress", generated_at: "2026-01-01T00:00:00Z", settlement_identity: identity, + delivery_outcome: "outcome_progress", delivery_batch_scale: "implementation", refresh_recovery: recovery, + vision_checkpoint: {required: false, satisfied: true, decision: "not_required", delivery_boundary: "in_flight_continuation"}, + json_path: join(runs, `${turn}.json`), markdown_path: join(runs, `${turn}.md`)}; + const submit = async (indexLockTarget = index) => { + const targets = [index, registry, shadowMaintenanceLockPath(root, goal), legacyCoordinationTodoLockPath(root, goal), state]; + const locks = []; + for (const target of targets) locks.push(await acquireFileMutationLock(target, process.pid)); + try { + return await effect("goal.checkpoint_read_context.commit", {runtime_root: root, state_file: state, + registry_path: registry, registry_sha256: hash(registry), state_sha256: hash(state), index_sha256: hash(index), + identity, purpose: "delivery_result", read_context_id: context.read_context_id, facts, refresh_retry: retry, + record: {...row}, index_record: {...row}, markdown: "Validated progress", + locks: locks.map((lock, i) => ({target: i === 0 ? indexLockTarget : targets[i], pid: process.pid, token: lock.token}))}); + } finally {for (const [i, lock] of locks.entries()) await releaseFileMutationLock(targets[i]!, lock.token);} + }; + if (turn === "one") { + const before = readFileSync(index, "utf8"); + await assert.rejects(submit(join(root, "foreign-index.jsonl")), /checkpoint lock target mismatch/); + assert.equal(readFileSync(index, "utf8"), before); + } + if (turn === "two") { + // An owner-file change alone must invalidate the original result, + // even though the canonical Todo head has not changed. + await writeFile(owner, "Owner requirement B"); + const beforeOwner = readFileSync(index, "utf8"); + const ownerRejected = await submit(); + assert.equal(ownerRejected.error_code, "checkpoint_read_context_stale"); + assert.equal(readFileSync(index, "utf8"), beforeOwner); + context = await read("owner-two-reread"); await writeFile(receiptPath, JSON.stringify(context.receipt)); + assert.equal(((context.basis as JsonObject).goal as JsonObject).prose, "Owner requirement B"); + const head = await store.loadAuthority(); assert.equal(head.status, "loaded"); + if (head.status !== "loaded") return; + assert.equal((await store.commitAuthority({expected_provider_revision: head.provider_revision, operation_id: "change", + next_projection: projection("Requirement B"), events: [], receipts: []})).status, "applied"); + const before = readFileSync(index, "utf8"); + assert.equal((await submit()).error_code, "checkpoint_read_context_stale"); + assert.equal(readFileSync(index, "utf8"), before); + context = await read("work-two-reread"); await writeFile(receiptPath, JSON.stringify(context.receipt)); + } + const saved = await submit(); assert.equal(saved.ok, true, JSON.stringify(saved)); assert.equal(saved.replayed, false); + assert.equal((saved.context as JsonObject).read_context_id, context.read_context_id); + assert.equal((await submit()).replayed, true); + } + assert.equal(readFileSync(index, "utf8").trim().split("\n").length, 2); + }); + } +} diff --git a/tests/control_plane_ts/prior_monitor_closeout.test.ts b/tests/control_plane_ts/prior_monitor_closeout.test.ts new file mode 100644 index 0000000000..a6fd70c7aa --- /dev/null +++ b/tests/control_plane_ts/prior_monitor_closeout.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import {mkdtemp, mkdir, readFile, rm, writeFile} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import test from "node:test"; +import {missingCloseoutReceipts} from "../../loopx/control_plane/quota/prior_turn_closeout.ts"; +import {QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA,readQuotaSettlement} from "../../loopx/control_plane/quota/settlement_readback.ts"; + +const missing={result:{value:null,receipts:[],failure:{kind:"writeback_missing"}},payload:{ok:false}}; +const base={found:true,settlement:missing,writeback:missing,spend:missing,monitor_phase:null,replay_phase:"open"}; +const both=["durable_writeback_receipt","quota_spend_receipt"]; + +test("monitor phases cannot bypass the guard bound-Todo verification",()=>{ + assert.deepEqual(missingCloseoutReceipts({...base,monitor_phase:"settled",replay_phase:"settled"}),both); +}); +for(const [name,patch] of [ + ["no monitor",{replay_phase:"settled"}], + ["pending replay",{monitor_phase:"settled",replay_phase:"settlement_pending"}], + ["pending monitor",{monitor_phase:"settlement_pending",replay_phase:"settled"}], + ["truthy monitor",{monitor_phase:true,replay_phase:"settled"}], + ["truthy replay",{monitor_phase:"settled",replay_phase:true}], + ["missing identity",{found:false,monitor_phase:"settled",replay_phase:"settled"}], +] as const) test(`missing monitor proof remains owed: ${name}`,()=>{ + assert.deepEqual(missingCloseoutReceipts({...base,...patch}),both); +}); +test("ordinary validated settlement retains its existing closeout",()=>{ + assert.equal(missingCloseoutReceipts({...base,settlement:{payload:{ok:true}}}),null); +}); +test("malformed ordinary readback does not become an exemption",()=>{ + assert.throws(()=>missingCloseoutReceipts({...base,settlement:{}}),/no settlement result/); +}); + +const goal="monitor-owner-fixture",agent="owner",todo="todo_monitor",old="old-turn",current="current-turn"; +const effect=`quota-monitor-poll:${goal}:${agent}:${old}`; +const variants:[string,Record,boolean][]=[ + ["exact Todo commit",{},true], + ["legacy exact Turn commit",{quota_monitor_poll_commit:{effect_id:effect}},true], + ["missing commit",{quota_monitor_poll_commit:null},false], + ["wrong effect",{quota_monitor_poll_commit:{effect_id:`${effect}:todo:todo_other`}},false], + ["wrong agent",{agent_id:"foreign"},false], + ["wrong Goal",{goal_id:"foreign"},false], + ["wrong Todo",{todo_id:"todo_other"},false], + ["wrong Turn",{turn_instance_id:"foreign"},false], + ["uncommitted observation",{classification:"state_refreshed"},false], +]; +for(const [name,patch,settled] of variants) test(`persisted owning monitor readback: ${name}`,async()=>{ + const root=await mkdtemp(join(tmpdir(),"loopx-neutral-prior-")); + const dir=join(root,"goals",goal),index=join(dir,"runs/index.jsonl"),events=join(dir,"rollout-event-log.jsonl"); + try { + await mkdir(join(dir,"runs"),{recursive:true}); + const rows=[{schema_version:"loopx_rollout_event_v0",event_id:"original-guard",event_kind:"quota_should_run", + goal_id:goal,agent_id:agent,run_id:old,details:{todo_id:todo,closeout_required:true, + settlement_effect_id:`${goal}:${agent}:${todo}:${old}`}}, + {schema_version:"loopx_rollout_event_v0",event_id:"current-guard",event_kind:"quota_should_run", + goal_id:goal,agent_id:agent,run_id:current,details:{}}]; + const poll={classification:"quota_monitor_poll",material_change:false,delivery_outcome:"surface_only", + goal_id:goal,agent_id:agent,todo_id:todo,turn_instance_id:old, + quota_monitor_poll_commit:{effect_id:`${effect}:todo:${todo}`},...patch}; + await writeFile(events,rows.map(row=>JSON.stringify(row)).join("\n")+"\n"); + await writeFile(index,JSON.stringify(poll)+"\n"); + const before=await Promise.all([readFile(events),readFile(index)]); + const request={schema_version:QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA,runtime_root:root, + goal_id:goal,agent_id:agent,todo_id:todo,turn_instance_id:old, + infer_turn_instance_id:false,allow_unbound_binding:false}; + const original=await readQuotaSettlement(request); + assert.equal(original.monitor_phase,settled?"settled":"poll_due"); + assert.equal(original.replay_phase,settled?"settled":"open"); + assert.equal((original.spend as any).payload.ok,false,"A monitor never fabricates a debit receipt"); + assert.equal((original.writeback as any).payload.ok,false,"Observation is not an accountable work writeback"); + assert.deepEqual(missingCloseoutReceipts(original),both,"The guard retains its bound-fact checkpoint"); + const unbound=await readQuotaSettlement({...request,todo_id:"todo_product",turn_instance_id:current}); + const failure=(unbound.settlement as any).result.failure; + assert.equal(failure.kind,"receipt_unbound"); + if(settled){ + assert.equal(failure.details.owed_prior_turn,undefined,"A committed no-spend monitor cannot block replan binding"); + assert.doesNotMatch(failure.reason,/earlier Turn owes/); + }else{ + assert.equal(failure.details.owed_prior_turn.prior_turn_instance_id,old,"Unproved original work must remain owed"); + } + const after=await Promise.all([readFile(events),readFile(index)]); + assert.deepEqual(after,before,"Readback must not rewrite records,acknowledge or spend"); + }finally{await rm(root,{recursive:true,force:true});} +}); diff --git a/tests/control_plane_ts/quota_settlement_readback.test.ts b/tests/control_plane_ts/quota_settlement_readback.test.ts index eeabe3dc20..76a7d87ef6 100644 --- a/tests/control_plane_ts/quota_settlement_readback.test.ts +++ b/tests/control_plane_ts/quota_settlement_readback.test.ts @@ -1,3 +1,4 @@ +import { verifyReentrySettlement } from "../../loopx/control_plane/coordination/todo_terminal_reentry.ts"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { @@ -28,6 +29,7 @@ import { QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, readQuotaSettlement, readQuotaSettlementSnapshot, + selectDefaultResultWorkTurn, } from "../../loopx/control_plane/quota/settlement_readback.ts"; import { PRIOR_HOST_TURN_CLOSEOUT_PREFLIGHT_REQUEST_SCHEMA, @@ -447,6 +449,26 @@ async function appendHistory(targetRoot: string, sourceRoot: string): Promise { + const root = await fixture({writeback: true, spend: true, completion: true}); + try { + const ordinary = await readQuotaSettlement(request(root)); + const terminal = requireJsonObject(ordinary.terminal_closeout, "terminal result"); + assert.equal(requireJsonObject(requireJsonObject(terminal.result, "terminal result payload").failure, + "terminal failure").kind, "receipt_missing"); + const path = join(root, "goals", goalId, "rollout-event-log.jsonl"); + const rows = (await readFile(path, "utf8")).trim().split("\n").map(line => JSON.parse(line)); + const completion = rows.find(row => row.event_kind === "todo_complete"); + completion.status = "terminal_no_followup"; + delete completion.details.no_followup; + await writeFile(path, rows.map(row => JSON.stringify(row)).join("\n") + "\n"); + const conflicting = await readQuotaSettlement(request(root)); + const rejected = requireJsonObject(conflicting.terminal_closeout, "terminal result"); + assert.equal(requireJsonObject(requireJsonObject(rejected.result, "terminal result payload").failure, + "terminal failure").kind, "effect_outcome_unknown"); + } finally { await rm(root, {recursive: true, force: true}); } +}); + function request(runtimeRoot: string, overrides: Record = {}) { return { schema_version: QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, @@ -462,6 +484,30 @@ function request(runtimeRoot: string, overrides: Record = {}) { }; } +test("work inference excludes only settled selected planning, preserving ordinary progress recovery", async () => { + for (const planned of [false, true]) { + const root = await fixture({writeback: true, spend: true, + visionCheckpoint: {schema_version: "vision_checkpoint_v0", agent_id: agentId, + satisfied: true, delivery_boundary: "semantic_closeout", + triggers: [{kind: "material_delivery_outcome", delivery_outcome: "outcome_progress"}]}, + ...(planned ? {autonomousReplanAck: {schema_version: "autonomous_replan_ack_v0", + recorded: true, semantic_delta: {schema_version: "replan_semantic_delta_v0", accepted: true}}} : {}), + }); + try { + const path = join(root, "goals", goalId, "rollout-event-log.jsonl"); + const events = (await readFile(path, "utf8")).trim().split("\n").map(line => JSON.parse(line)); + const guard = events.find(event => event.event_kind === "quota_should_run"); + guard.details.result_freshness = "turn_result_freshness_required_v1"; + guard.details.semantic_replan_obligation_id = planned ? "replan-0000000000000001" : null; + await writeFile(path, events.map(event => JSON.stringify(event)).join("\n") + "\n"); + assert.equal((await readQuotaSettlement(request(root))).replay_phase, "settled"); + const selection = request(root, {todo: {status: "open"}}); + assert.equal((await selectDefaultResultWorkTurn(selection)).turn_instance_id, planned ? null : turnId); + assert.equal((await selectDefaultResultWorkTurn({...selection, pending_only: true})).turn_instance_id, null); + } finally { await rm(root, {recursive: true, force: true}); } + } +}); + function sourceGuardPath(registryPath: string): string { const digest = createHash("sha256").update(goalId, "utf8").digest("hex"); return join( @@ -2145,3 +2191,25 @@ test("history retry qualification preserves its original aware clock and exact e } await assert.rejects(read([run], 123), /observation clock/); }); + +test("terminal reentry resolves original opaque Turn and full effect identities", async () => { + const root = await fixture({writeback: true, spend: true}); + const request = {goal_id: goalId, todo_id: todoId, actor_agent_id: agentId}; + for (const key of [turnId, identity.effect_id]) { + await verifyReentrySettlement(root, {...request, requested_completion_turn_key: key}); + } + for (const changed of [{todo_id: "todo_other"}, {actor_agent_id: "other"}, {goal_id: "other"}]) { + await assert.rejects(verifyReentrySettlement(root, {...request, ...changed, + requested_completion_turn_key: turnId})); + } + await assert.rejects(verifyReentrySettlement(root, {...request, + requested_completion_turn_key: "unrecorded-opaque-turn"})); +}); + +test("opaque Turn reentry still requires original writeback and spend", async () => { + for (const options of [{writeback: true}, {spend: true}, {}]) { + const root = await fixture(options); + await assert.rejects(verifyReentrySettlement(root, {goal_id: goalId, + todo_id: todoId, actor_agent_id: agentId, requested_completion_turn_key: turnId})); + } +}); diff --git a/tests/control_plane_ts/retained_lease_receipts.test.ts b/tests/control_plane_ts/retained_lease_receipts.test.ts index 94c70dbbbb..cfd52eda3a 100644 --- a/tests/control_plane_ts/retained_lease_receipts.test.ts +++ b/tests/control_plane_ts/retained_lease_receipts.test.ts @@ -14,7 +14,7 @@ async function restore(t: test.TestContext, name: string) { assert.equal(history.source_commit, "60d833b13c85135587b725013b247e827663ffb9"); const root = await mkdtemp(join(tmpdir(), "loopx-retained-1x-")); t.after(() => rm(root, {recursive: true, force: true})); - const original = JSON.parse(JSON.stringify(history.scenarios[name]).replaceAll("$ROOT", root)); + const original = JSON.parse(JSON.stringify(history.scenarios[name]).replaceAll("$ROOT", JSON.stringify(root).slice(1, -1))); for (const [relative, value] of Object.entries(original.files)) { const path = join(root, "runtime", relative); await mkdir(dirname(path), {recursive: true}); diff --git a/tests/control_plane_ts/terminal_source_conformance.ts b/tests/control_plane_ts/terminal_source_conformance.ts index f24e85dd01..ad3484be95 100644 --- a/tests/control_plane_ts/terminal_source_conformance.ts +++ b/tests/control_plane_ts/terminal_source_conformance.ts @@ -4,7 +4,7 @@ import assert from "node:assert/strict"; import {createHash} from "node:crypto"; import {executeCoordinationTodoUpdate} from "../../loopx/control_plane/coordination/todo_update.ts"; import test from "node:test"; -import type {AuthorityStore} from "../../loopx/control_plane/coordination/authority_store.ts"; +import type {AuthorityStore, AuthorityStoreHead} from "../../loopx/control_plane/coordination/authority_store.ts"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; import {executeCoordinationTodoTerminalLifecycle as execute, type CoordinationTodoTerminalLifecycleInput} from "../../loopx/control_plane/coordination/todo_terminal_lifecycle.ts"; @@ -14,6 +14,9 @@ import type {AuthorityStoreConformanceFactory} from "./authority_store_conforman import {authorityProjectionFixture, projectionFixtureAsSchema} from "./authority_projection_fixture.ts"; import {productionScaleCoordinationFixture, PRODUCTION_SCALE_VALIDATION_DECLARATION as declaration} from "./production_scale_coordination_fixture.ts"; +import {checkpointProviderFacts} from "../../loopx/control_plane/goals/checkpoint_authority.ts"; +import {evaluateCheckpointReadContext as evaluate} from "../../loopx/control_plane/goals/checkpoint_read_context.ts"; +import {settlementIdentity, settlementIdentityPayload} from "../../loopx/control_plane/effect_program.ts"; async function loaded(store: AuthorityStore) { const head = await store.loadAuthority(); @@ -27,6 +30,210 @@ const validation = {schema_version: "issue_fix_validation_command_v0", command_l stdout_captured: false, stderr_captured: false, local_path_captured: false}; export function registerTerminalSourceConformance(provider: string, factory: AuthorityStoreConformanceFactory): void { + test(`${provider}: default result freshness rejects related head changes and replays original receipt`, async t => { + const {store, contender} = await factory(t); + const goal = "default-result-freshness"; + const fixture = productionScaleCoordinationFixture(goal, "native"); + await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, + next_projection: fixture.projection, events: [], receipts: []}); + const before = await loaded(store); + const identity = settlementIdentityPayload(settlementIdentity({goal_id: goal, agent_id: "agent-a", + todo_id: fixture.completion_todo_id, turn_instance_id: "original-turn"})); + const sourceId = await store.storeIdentity(); + assert.equal(sourceId.status, "available"); + if (sourceId.status !== "available") return; + const facts = (head: AuthorityStoreHead) => checkpointProviderFacts(goal, + {checkpoint_identity: identity, frontmatter: {objective: "Verify the selected work"}, + goal_prose: "Acceptance applies", agent_vision: null, source: {state_file: "synthetic-state"}}, + head, sourceId.store_identity, provider, true); + const read = (head: AuthorityStoreHead, key: string) => evaluate({phase: "read", purpose: "delivery_result", + admitted_turn: true, prior: null, result_only: true, identity, facts: facts(head), read_context_id: key}); + const original = read(before, "original-read"); + assert.equal(original.ok, true); + const request: CoordinationTodoTerminalLifecycleInput = {goal_id: goal, todo_id: fixture.completion_todo_id, + expected_role: "agent", command: "complete", actor_agent_id: "agent-a", registered_agents: fixture.registered_agents, + lifecycle_grants: [], authority_reason: null, decision_outcome: null, operation_identity: {kind: "completion_turn"}, + lease_idempotency_key: fixture.completion_lease_idempotency_key, + lease_expected_version: fixture.completion_lease_expected_version, allow_user_gate_auto_acquire: false, + requested_no_followup: false, requested_completion_turn_key: "original-turn", requested_completion_identity_source: "turn_settlement", + linked_successor_todo_ids: [], successor_intents: [], note: "Verified retained candidate", evidence: "Validation passed", + reason: null, clear_claim: false, validation_declaration: declaration, validation_receipt: validation, + validation_declaration_sha256: canonicalAuthoritySha256(declaration), completion_policy_request: null, + dry_run: false, now: new Date(String(fixture.semantic_cases.terminal_source!.observed_at)), + delivery_read_context_id: "original-read", validation_source_provider_revision: before.provider_revision}; + const selected = (before.head.todos as JsonObject[]).find(todo => todo.todo_id === request.todo_id)!; + await contender.commitAuthority(prepareCoordinationProjectionCommit({goal_id: goal, + operation_id: "related-change", expected_provider_revision: before.provider_revision, projection: before.head, + mutations: [{kind: "todo_upsert", todo: {...selected, text: "Verify the revised requirement"}}]})); + const current = await loaded(store); + const check = (receipt: unknown, key: string) => async (head: AuthorityStoreHead) => evaluate({phase: "check", + purpose: "delivery_result", identity, facts: facts(head), receipt, read_context_id: key}); + const rejected = await execute(store, request, async () => true, null, undefined, check(original.receipt, "original-read")); + assert.equal(rejected.reason_code, "checkpoint_read_context_stale", JSON.stringify(rejected)); + assert.deepEqual(await loaded(store), current); + const fresh = read(current, "revalidated-read"); + const revalidated = {...request, delivery_read_context_id: "revalidated-read", validation_source_provider_revision: current.provider_revision}; + assert.equal((await execute(store, revalidated, async () => true, null, undefined, check(fresh.receipt, "revalidated-read"))).status, "applied"); + const saved = await loaded(store); + assert.equal((await execute(contender, revalidated, async () => {throw new Error("receipt before authority");}, + null, async () => {throw new Error("receipt before lifecycle settlement");}, + async () => {throw new Error("receipt before freshness");})).status, "replayed"); + const withoutReadKey = {...revalidated, delivery_read_context_id: undefined}; + assert.equal((await execute(contender, withoutReadKey, async () => {throw new Error("receipt before authority");}, + null, async () => {throw new Error("receipt before lifecycle settlement");}, + async () => {throw new Error("receipt before freshness");})).status, "replayed"); + assert.equal((await execute(contender, {...withoutReadKey, evidence: "Different intended result"}, + async () => true, null, undefined, check(fresh.receipt, "revalidated-read"))).reason_code, + "coordination_operation_identity_mismatch"); + assert.deepEqual(await loaded(store), saved); + }); + for (const closeoutSource of ["turn", "lifecycle"] as const) { + test(`${provider}: deferred original no-followup upgrades once without new validation (${closeoutSource})`, async t => { + const {store, contender} = await factory(t); + const goal = "deferred-terminal"; + const fixture = productionScaleCoordinationFixture(goal, "native"); + await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, + next_projection: fixture.projection, events: [], receipts: []}); + const before = await loaded(store); + const source = await store.storeIdentity(); + assert.equal(source.status, "available"); + if (source.status !== "available") return; + const identity = settlementIdentityPayload(settlementIdentity({goal_id: goal, + agent_id: "agent-a", todo_id: fixture.completion_todo_id, turn_instance_id: "original-turn"})); + const facts = (head: AuthorityStoreHead) => checkpointProviderFacts(goal, + {checkpoint_identity: identity, frontmatter: {objective: "Verify scoped work"}, + goal_prose: "Acceptance applies", agent_vision: null, source: {state_file: "synthetic-state"}}, + head, source.store_identity, provider, true); + const basis = evaluate({phase: "read", purpose: "delivery_result", admitted_turn: true, + prior: null, result_only: true, identity, facts: facts(before), read_context_id: "result-read"}); + const check = async (head: AuthorityStoreHead) => evaluate({phase: "check", purpose: "delivery_result", + identity, facts: facts(head), receipt: basis.receipt, read_context_id: "result-read"}); + + const request: CoordinationTodoTerminalLifecycleInput = {goal_id: goal, todo_id: fixture.completion_todo_id, + expected_role: "agent", command: "complete", actor_agent_id: "agent-a", registered_agents: fixture.registered_agents, + lifecycle_grants: [], authority_reason: null, decision_outcome: null, operation_identity: {kind: "completion_turn"}, + lease_idempotency_key: fixture.completion_lease_idempotency_key, + lease_expected_version: fixture.completion_lease_expected_version, allow_user_gate_auto_acquire: false, + requested_no_followup: true, defer_no_followup: true, + requested_completion_turn_key: "original-turn", requested_completion_identity_source: "turn_settlement", + linked_successor_todo_ids: [], successor_intents: [], note: "Verified candidate", evidence: "Validation passed", + reason: null, clear_claim: false, validation_declaration: declaration, validation_receipt: validation, + validation_declaration_sha256: canonicalAuthoritySha256(declaration), completion_policy_request: null, + dry_run: false, now: new Date(String(fixture.semantic_cases.terminal_source!.observed_at)), + delivery_read_context_id: "result-read", validation_source_provider_revision: before.provider_revision}; + const result = await execute(store, request, async () => true, null, undefined, check); + assert.equal(result.status, "applied", JSON.stringify(result)); + const committed = await loaded(store); + const todo = (committed.head.todos as JsonObject[]).find(row => row.todo_id === request.todo_id)!; + assert.equal(todo.status, "done"); + assert.equal(todo.completion_continuation, "active_goal"); + assert.equal(todo.completion_recovery ?? null, null); + assert.equal(todo.completion_requested_continuation, "no_followup"); + assert.equal(todo.no_followup, false); + assert.equal((await execute(contender, request, async () => true, null, undefined, check)).status, "replayed"); + const closeout = {...request, defer_no_followup: false, validation_receipt: null, + validation_source_provider_revision: committed.provider_revision}; + const changed = await execute(store, {...closeout, evidence: "Changed result"}, async () => true, null, undefined, check); + assert.equal(changed.reason_code, "coordination_operation_identity_mismatch", JSON.stringify(changed)); + assert.deepEqual(await loaded(store), committed); + const lifecycle: CoordinationTodoTerminalLifecycleInput = {...closeout, + operation_identity: {kind: "explicit", operation_id: "cold-lifecycle"}, + requested_completion_identity_source: "lifecycle_reentry", + lease_idempotency_key: null, lease_expected_version: null, + validation_declaration: null, delivery_read_context_id: undefined}; + const closedRequest = closeoutSource === "turn" ? closeout : lifecycle; + let settlementChecks = 0; + const settlement = async () => {settlementChecks++;}; + const lifecycleBasis = async () => {throw new Error("Reentry must not admit new RESULT work");}; + if (closeoutSource === "lifecycle") { + const rejected = await execute(store, lifecycle, async () => true, + null, async () => {throw new Error("Original writeback/spend not settled");}, lifecycleBasis); + assert.equal(rejected.status, "failed"); + assert.deepEqual(await loaded(store), committed); + } + const closed = await execute(contender, closedRequest, async () => true, null, settlement, + closeoutSource === "turn" ? check : lifecycleBasis); + assert.equal(closed.status, "applied", JSON.stringify(closed)); + assert.deepEqual(closed.validation_receipt, closeoutSource === "turn" ? validation : null); + assert.equal(settlementChecks, closeoutSource === "turn" ? 0 : 1); + const after = await loaded(store); + const terminal = (after.head.todos as JsonObject[]).find(row => row.todo_id === request.todo_id)!; + assert.equal(terminal.completion_continuation, "no_followup"); + assert.equal(terminal.completion_recovery, closeoutSource === "turn" + ? "same_turn_terminal_closeout" : "lifecycle_reentry_terminal_closeout"); + assert.equal(terminal.no_followup, true); + assert.equal((await execute(store, closedRequest, async () => {throw new Error("Historical closeout");}, + null, async () => {throw new Error("No repeated settlement verification");}, check)).status, "replayed"); + // After lifecycle closeout the exact accepted Turn RESULT remains historical; + // changing its actor, evidence or lease cannot borrow that receipt. + assert.equal((await execute(store, closeout, async () => {throw new Error("Historical RESULT");}, + null, undefined, check)).status, "replayed"); + for (const patch of [{evidence: "Changed result"}, {actor_agent_id: "agent-b"}, + {lease_expected_version: 999}]) { + assert.equal((await execute(store, {...closeout, ...patch}, async () => true, null, undefined, check)).status, "failed"); + assert.deepEqual(await loaded(store), after); + } + assert.deepEqual(await loaded(store), after); + }); + } + test(`${provider}: protected completion final CAS preserves a racing peer and historical recovery`, async t => { + const {store, contender} = await factory(t); + const goal = "protected-terminal-cas"; + const fixture = productionScaleCoordinationFixture(goal, "native"); + const scenario = fixture.semantic_cases.terminal_source!; + assert.equal((await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, + next_projection: fixture.projection, events: [], receipts: []})).status, "applied"); + const before = await loaded(store); + const request: CoordinationTodoTerminalLifecycleInput = {goal_id: goal, todo_id: fixture.completion_todo_id, + expected_role: "agent", command: "complete", actor_agent_id: "agent-a", registered_agents: fixture.registered_agents, + lifecycle_grants: [], authority_reason: null, decision_outcome: null, + operation_identity: {kind: "completion_turn"}, + lease_idempotency_key: fixture.completion_lease_idempotency_key, + lease_expected_version: fixture.completion_lease_expected_version, + allow_user_gate_auto_acquire: false, requested_no_followup: false, + requested_completion_turn_key: "protected-turn", requested_completion_identity_source: "turn_settlement", + linked_successor_todo_ids: [], successor_intents: [], note: "Reviewed result", evidence: "Verified result", reason: null, + clear_claim: false, validation_declaration: declaration, validation_receipt: validation, + validation_declaration_sha256: canonicalAuthoritySha256(declaration), + completion_policy_request: null, dry_run: false, now: new Date(String(scenario.observed_at)), + delivery_read_context_id: "result-read", validation_source_provider_revision: before.provider_revision}; + const missingCheck = await execute(store, request); + assert.equal(missingCheck.reason_code, "delivery_basis_check_required", JSON.stringify(missingCheck)); + const checkedHeads: string[] = []; + const checkBasis = async (head: AuthorityStoreHead) => { + checkedHeads.push(head.provider_revision); + return {ok: true}; // The peer is unrelated; basis construction has separate integration coverage. + }; + const peer = (before.head.todos as JsonObject[]).find(todo => todo.todo_id !== request.todo_id)!; + let attemptedId = ""; + const racing = new Proxy(store, {get(target, key) { + if (key === "commitAuthority") return async (...args: Parameters) => { + attemptedId = args[0].operation_id; + assert.equal((await contender.commitAuthority(prepareCoordinationProjectionCommit({goal_id: goal, + operation_id: "racing-peer", expected_provider_revision: before.provider_revision, projection: before.head, + mutations: [{kind: "todo_upsert", todo: {...peer, note: "Peer update survives"}}]}))).status, "applied"); + return target.commitAuthority(...args); + }; + const member = Reflect.get(target, key); + return typeof member === "function" ? member.bind(target) : member; + }}); + const rejected = await execute(racing, request, async () => true, null, undefined, checkBasis); + assert.equal(rejected.conflict_kind, "provider_revision_mismatch", JSON.stringify(rejected)); + assert.ok(attemptedId); + assert.equal((await store.readReceipt(attemptedId)).status, "missing"); + const current = await loaded(store); + assert.equal((current.head.todos as JsonObject[]).find(todo => todo.todo_id === request.todo_id)!.status, "open"); + const completed = await execute(store, request, async () => true, null, undefined, checkBasis); + assert.equal(completed.status, "applied", JSON.stringify(completed)); + assert.deepEqual([...new Set(checkedHeads)], [before.provider_revision, current.provider_revision]); + const after = await loaded(store); + assert.equal((after.head.todos as JsonObject[]).find(todo => todo.todo_id === peer.todo_id)!.note, "Peer update survives"); + const replay = await execute(contender, request, async () => {throw new Error("history before authority");}, + null, async () => {throw new Error("history before lifecycle settlement");}, + async () => {throw new Error("history before freshness");}); + assert.equal(replay.status, "replayed"); + assert.deepEqual(await loaded(store), after); + }); for (const schema of ["legacy", "native"] as const) { test(`${provider}: Turn completion binds exact task text through CAS and historical recovery (${schema})`, async t => { const {store, contender} = await factory(t); @@ -284,6 +491,51 @@ export function registerTerminalSourceConformance(provider: string, factory: Aut assert.deepEqual(await store.readReceipt(request.operation_identity.operation_id), receipt); } }); + test(`${provider}: scoped retirement binds its Turn without accepting a deliverable (${schema})`, async t => { + const {store, contender} = await factory(t); + const goal = "scoped-retirement"; + const task: JsonObject = {todo_id: "todo_obsolete", role: "agent", status: "open", done: false, + text: "Implement the previous requirement", task_class: "advancement_task", claimed_by: "agent-a", + archive_state: "active", completion_validation_required: true, + completion_validation_sha256: "a".repeat(64)}; + const successor = {...task, todo_id: "todo_replacement", text: "Implement the current requirement"}; + await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, + next_projection: authorityProjectionFixture(goal, [task, successor], [], schema, {handoff_mode: "soft_claim"}), + events: [], receipts: []}); + const request: CoordinationTodoTerminalLifecycleInput = { + goal_id: goal, todo_id: String(task.todo_id), expected_role: "agent", command: "supersede", + actor_agent_id: "agent-a", registered_agents: ["agent-a"], lifecycle_grants: [], + authority_reason: null, decision_outcome: null, operation_identity: {kind: "explicit", operation_id: "retire-turn"}, + lease_idempotency_key: null, lease_expected_version: null, allow_user_gate_auto_acquire: false, + requested_no_followup: false, requested_completion_turn_key: "original-turn", + requested_completion_identity_source: "turn_settlement", linked_successor_todo_ids: [String(successor.todo_id)], + successor_intents: [], note: "superseded", evidence: null, reason: "Owner requirement changed", + clear_claim: false, validation_declaration: null, validation_receipt: null, completion_policy_request: null, + dry_run: false, now: new Date("2026-09-05T23:00:00Z"), + }; + const priorSuccessor = ((await loaded(store)).head.todos as JsonObject[]).find(t => t.todo_id === successor.todo_id); + const retired = await execute(store, request); + assert.equal(retired.changed, true, JSON.stringify(retired)); + assert.equal(retired.completion_identity_key, "original-turn"); + assert.equal(retired.validation_receipt, null); + assert.equal(retired.completion_receipt_id, null); + assert.equal(retired.completion_policy, null); + const after = await loaded(store); + const row = (after.head.todos as JsonObject[]).find(t => t.todo_id === task.todo_id)!; + assert.equal(row.status, "done"); + assert.equal(row.completion_turn_key, "original-turn"); + assert.equal(row.completion_continuation, "active_goal"); + assert.equal(row.completion_result, undefined); + assert.deepEqual((after.head.todos as JsonObject[]).find(t => t.todo_id === successor.todo_id), priorSuccessor); + assert.equal((await execute(contender, request)).status, "replayed"); + const foreign = await execute(store, {...request, operation_identity: {kind: "explicit", operation_id: "foreign-turn"}, + requested_completion_turn_key: "foreign-turn"}); + assert.equal(foreign.reason_code, "invalid_todo_completion_transaction"); + const falseAcceptance = await execute(store, {...request, command: "complete", + operation_identity: {kind: "explicit", operation_id: "false-acceptance"}}); + assert.equal(falseAcceptance.reason_code, "invalid_todo_completion_transaction"); + assert.deepEqual(await loaded(store), after); + }); test(`${provider}: supersede links existing work and retires its original lease atomically (${schema})`, async t => { const {store, contender} = await factory(t); const goal = "terminal-existing-successor"; diff --git a/tests/control_plane_ts/todo_completion_state.test.ts b/tests/control_plane_ts/todo_completion_state.test.ts index 0f722fb7ce..2a4d2275e4 100644 --- a/tests/control_plane_ts/todo_completion_state.test.ts +++ b/tests/control_plane_ts/todo_completion_state.test.ts @@ -96,6 +96,15 @@ test("whole completion owner selects continuation and rejects contradictory inte }), /cannot record both no_followup and a successor/); }); +test("deferred terminal result stays active and is not a completed recovery", () => { + const request = {schema_version: TODO_COMPLETION_STATE_REQUEST_SCHEMA, + todo: {status: "open", no_followup: true}, requested_no_followup: false, + defer_no_followup: true, has_successor: false, completion_identity_source: "turn_settlement"}; + assert.deepEqual(selectTodoCompletionState(request), { + schema_version: "loopx_todo_completion_state_result_v0", continuation: "active_goal", recovery: null}); + assert.throws(() => selectTodoCompletionState({...request, completion_identity_source: "unscoped_completion"}), /original Turn/); +}); + test("retired scalar and summary RPCs fail closed", async () => { const handlers = createEffectRuntimeHandlers({fingerprint: "completion-retirement-test", requestShutdown: () => undefined}); for (const method of ["todo.completion_state.continuation_for_write", "todo.completion_state.normalize", @@ -172,3 +181,15 @@ test("lifecycle reentry emits a distinct terminal recovery", () => { }, ); }); + + +test("original Turn defers explicit terminal intent without reporting terminal recovery", () => { + const request = {schema_version: TODO_COMPLETION_STATE_REQUEST_SCHEMA, + todo: {status: "done", no_followup: false, completion_continuation: "active_goal"}, + requested_no_followup: true, defer_no_followup: true, has_successor: false, + completion_identity_source: "turn_settlement"}; + assert.deepEqual(selectTodoCompletionState(request), { + schema_version: "loopx_todo_completion_state_result_v0", continuation: "active_goal", recovery: null}); + assert.deepEqual(selectTodoCompletionState({...request, defer_no_followup: false}), { + schema_version: "loopx_todo_completion_state_result_v0", continuation: "no_followup", recovery: "same_turn_terminal_closeout"}); +}); diff --git a/tests/control_plane_ts/todo_completion_transaction.test.ts b/tests/control_plane_ts/todo_completion_transaction.test.ts index ec83df607a..1ae8b2b845 100644 --- a/tests/control_plane_ts/todo_completion_transaction.test.ts +++ b/tests/control_plane_ts/todo_completion_transaction.test.ts @@ -404,3 +404,15 @@ test("runtime input and validation receipts fail closed", () => { /cannot record both no_followup and a successor/, ); }); + +test("bound terminal upgrade reuses original RESULT validation", () => { + for (const source of ["turn_settlement", "lifecycle_reentry"]) { + const result = reduceTodoCompletionTransaction(request({todo: {...baseTodo, status: "done", + completion_continuation: "active_goal", completion_turn_key: "original-turn", + validation_command: "must-never-run-again"}, requested_no_followup: true, + requested_completion_turn_key: "original-turn", requested_completion_identity_source: source})); + assert.equal(result.decision, "commit"); + assert.equal(result.validation_receipt, null); + assert.equal(result.completion_state.continuation, "no_followup"); + } +}); diff --git a/tests/control_plane_ts/turn_journal_effects.test.ts b/tests/control_plane_ts/turn_journal_effects.test.ts index d484160691..429205dfa4 100644 --- a/tests/control_plane_ts/turn_journal_effects.test.ts +++ b/tests/control_plane_ts/turn_journal_effects.test.ts @@ -438,6 +438,45 @@ test("failed validation may explicitly rewind for host reinvocation", async () = }); }); +test("only a retained, uncommitted stale result may invalidate completed validation", async () => { + await withJournalPath(async (path) => { + await commit(path, journal()); + const worked = journal("in_progress", phases.slice(0, 2)); + worked.host_result = {summary: "Retained candidate"}; + worked.result_work_context = {read_context_id: "original-work-key"}; + await commit(path, worked); + const validated = {...worked, completed_phases: [...phases.slice(0, 3)], + task_validation: {ok: true}}; + await commit(path, validated); + const retry = {...worked, status: "failed", validation_stage: "result_freshness", + receipt: {turn_key: turnKey, failed_phase: "validation"}, result_revalidation: { + candidate: worked.host_result, original_context: worked.result_work_context, + rejection: {ok: false, error_code: "checkpoint_read_context_stale"}, + }}; + const variants = [ + {...retry, result_revalidation: {...retry.result_revalidation, rejection: {ok: false, error_code: "unknown"}}}, + {...retry, result_revalidation: {...retry.result_revalidation, candidate: {summary: "Other candidate"}}}, + {...retry, result_work_context: {read_context_id: "replacement-key"}}, + {...retry, task_validation: {ok: true}}, + {...retry, writeback: {ok: true}}, + {...retry, quota_spend: {ok: true}}, + {...retry, effect_attempts: {durable_writeback: {status: "prepared", effect_ref: `${effectId()}#durable_writeback`}}}, + ]; + const before = await readFile(path, "utf8"); + for (const invalid of variants) { + await assert.rejects(commit(path, invalid), /regress|prepared effect/); + assert.equal(await readFile(path, "utf8"), before); + } + await commit(path, retry); + await commit(path, {...worked, completed_phases: []}); + assert.deepEqual(JSON.parse(await readFile(path, "utf8")).completed_phases, []); + await commit(path, worked); + await commit(path, validated); + await commit(path, {...retry, result_revalidation: {...retry.result_revalidation, + rejection: {ok: false, error_code: "delivery_validation_binding_mismatch"}}}); + }); +}); + test("terminal journal tombstones are immutable", async () => { await withJournalPath(async (path) => { await commit(path, journal()); @@ -452,6 +491,47 @@ test("terminal journal tombstones are immutable", async () => { }); }); +test("terminal result revalidation preserves settled receipts and cannot execute settlement", async () => { + await withJournalPath(async (path) => { + await commit(path, journal()); + const worked = {...journal("in_progress", phases.slice(0, 2)), + host_result: {summary: "Original candidate"}, result_work_context: {read_context_id: "original-key"}}; + await commit(path, worked); + await commit(path, {...worked, completed_phases: [...phases.slice(0, 3)]}); + const written = {...worked, completed_phases: [...phases.slice(0, 4)], writeback: {ok: true, run_id: "original-writeback"}}; + await commit(path, written); + const settled = {...written, completed_phases: [...phases.slice(0, 5)], quota_spend: {ok: true, slots: 1}}; + await commit(path, settled); + const retry = {...settled, status: "failed", receipt: {turn_key: turnKey, failed_phase: "terminal_closeout"}, + result_revalidation: {phase: "terminal_closeout", candidate: worked.host_result, + original_context: worked.result_work_context, rejection: {ok: false, error_code: "checkpoint_read_context_stale"}, validation: {}}}; + for (const invalid of [ + {...retry, quota_spend: {ok: true, slots: 2}}, + {...retry, writeback: {ok: true, run_id: "replacement"}}, + {...retry, result_revalidation: {...retry.result_revalidation, candidate: {summary: "Other candidate"}}}, + {...retry, result_revalidation: {...retry.result_revalidation, validation: {status: "in_progress", completed_phases: [...phases.slice(0, 4)]}}}, + {...retry, effect_attempts: {terminal_closeout: {status: "prepared", effect_ref: `${effectId()}#terminal_closeout`}}}, + ]) await assert.rejects(commit(path, invalid), /Terminal result revalidation/); + await commit(path, retry); + const progress = {...retry, status: "in_progress", result_revalidation: {...retry.result_revalidation, + validation: {status: "in_progress", completed_phases: [...phases.slice(0, 3)], + work_context: worked.result_work_context, + host_result: {summary: "Revalidated candidate"}, task_validation: {ok: true}}}}; + await commit(path, progress); + const ready: Record = {...progress, host_result: progress.result_revalidation.validation.host_result, + task_validation: {ok: true}, result_work_binding: {read_context_id: "original-key"}}; + delete ready.result_revalidation; + const rebound = {...ready, result_work_context: {read_context_id: "replacement-key"}, + result_work_binding: {read_context_id: "replacement-key"}}; + await assert.rejects(commit(path, rebound), /Terminal result revalidation/); + await commit(path, ready); + const saved = JSON.parse(await readFile(path, "utf8")); + assert.deepEqual(saved.completed_phases, phases.slice(0, 5)); + assert.deepEqual(saved.writeback, settled.writeback); + assert.deepEqual(saved.quota_spend, settled.quota_spend); + }); +}); + test("transaction plans are immutable within one effect", async () => { await withJournalPath(async (path) => { await commit(path, journal()); diff --git a/tests/control_plane_ts/turn_journal_settlement.test.ts b/tests/control_plane_ts/turn_journal_settlement.test.ts new file mode 100644 index 0000000000..dd7ebc1fbd --- /dev/null +++ b/tests/control_plane_ts/turn_journal_settlement.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {planSettledTurnJournal} from "../../loopx/control_plane/turn_driver/turn_journal_effects.ts"; +const phases=['host_execute','typed_result','validation','durable_writeback','quota_spend','scheduler_apply','scheduler_ack']; +const identity={schema_version:'quota_settlement_identity_v0',goal_id:'goal',agent_id:'actor',todo_id:'todo_task',turn_instance_id:'turn-7',effect_id:'goal:actor:todo_task:turn-7'}; +const key='sha256:'+'a'.repeat(64); +const receipt=(step:string)=>({schema_version:'quota_settlement_receipt_v1',step_kind:step,status:'committed',effect_id:identity.effect_id,source_ref:'native:'+step}); +function fixture(){ + const journal={schema_version:'loopx_turn_journal_v0',goal_id:'goal',turn_key:key,status:'failed',completed_phases:phases.slice(0,3), + plan:{host:{scheduler_owner:'outer_controller'},turn_envelope:{goal_id:'goal',agent_id:'actor',action:{selected_todo:{todo_id:'todo_task'}}},transaction:{turn_key:key,turn_instance_id:'turn-7',settlement_plan:{schema_version:'quota_settlement_plan_v1',identity}}}, + host_result:{schema_version:'loopx_turn_result_v0',turn_key:key,result_kind:'validated_completion',summary:'Original candidate'},task_validation:{ok:true},completion_intent:{todo_id:'todo_task',continuation:'active_goal'}, + receipt:{ok:true,turn_key:key,status:'failed',failed_phase:'durable_writeback',next_phase:'durable_writeback',result_kind:'writeback_failed'},settlement_result:{ok:false},reason:'original failure'}; + const bundle=(value:unknown,steps:string[])=>({result:{value,receipts:steps.map(receipt),failure:null},payload:{ok:true,receipts:steps.map(receipt),failure:null}}); + const readback={found:true,identity:bundle(identity,['validation']),settlement:bundle({},['validation','durable_writeback','quota_spend']), + terminal_settlement:bundle({},['validation','durable_writeback','quota_spend','terminal_closeout']), + writeback_run:{settlement_identity:identity,marker:'original-writeback'},spend_run:{settlement_identity:identity,slots:1,marker:'original-spend'}}; + return {journal,readback}; +} +test('ordered receipt reconciliation retains original result and does no work replay',()=>{ + const {journal,readback}=fixture();const before=structuredClone(journal);const steps=planSettledTurnJournal(journal,readback); + assert.deepEqual(journal,before);assert.deepEqual(steps.map(s=>[s.status,s.completed_phases.length]),[['in_progress',3],['in_progress',4],['in_progress',5],['committed',7]]); + const final=steps.at(-1)!;assert.equal(final.turn_key,key);assert.equal(final.receipt.status,'committed');assert.equal(final.receipt.settlement_effect_id,identity.effect_id); + assert.equal(final.receipt.failed_phase,null);assert.equal(final.receipt.next_phase,null); + for(const k of ['plan','host_result','task_validation','completion_intent'])assert.deepEqual(final[k],journal[k]); + assert.deepEqual(final.settlement_reconciliation.previous_receipt,journal.receipt);assert.deepEqual(final.settlement_reconciliation.previous_failure,journal.settlement_result); + assert.deepEqual(planSettledTurnJournal(final,readback),[]); +}); +for(const [name,mutate] of [ + ['non-outer scheduler',(j:any,r:any)=>j.plan.host.scheduler_owner='local_scheduler'],['missing readback',(j:any,r:any)=>r.found=false],['foreign effect',(j:any,r:any)=>r.identity.result.value={...identity,effect_id:'other'}], + ['foreign structured agent',(j:any,r:any)=>r.identity.result.value={...identity,agent_id:'other'}],['missing spend',(j:any,r:any)=>r.settlement.payload.receipts.pop()], + ['wrong spend receipt',(j:any,r:any)=>r.settlement.payload.receipts[2].effect_id='other'],['failed settlement',(j:any,r:any)=>r.settlement.payload.ok=false], + ['missing provider source',(j:any,r:any)=>r.settlement.payload.receipts[1].source_ref=null],['no validation',(j:any,r:any)=>j.task_validation.ok=false], + ['unvalidated prefix',(j:any,r:any)=>j.completed_phases=phases.slice(0,2)],['wrong result',(j:any,r:any)=>j.host_result.result_kind='host_failure'], + ['prepared unknown effects',(j:any,r:any)=>j.effect_attempts={durable_writeback:{state:'unknown'}}],['active source hold',(j:any,r:any)=>j.source_effect_hold={}], + ['foreign original key',(j:any,r:any)=>j.turn_key='sha256:'+'b'.repeat(64)],['malformed original snapshot',(j:any,r:any)=>j.schema_version='unknown'], + ['stopped journal',(j:any,r:any)=>j.status='stopped'],['missing writeback run',(j:any,r:any)=>r.writeback_run=null], + ['missing spend run',(j:any,r:any)=>r.spend_run=null],['missing terminal receipt',(j:any,r:any)=>{j.completion_intent.continuation='no_followup';r.terminal_settlement.payload.receipts.pop();}], + ] as const){test('refuses '+name+' without input mutation',()=>{const {journal,readback}=fixture();mutate(journal,readback);const before=structuredClone(journal);assert.throws(()=>planSettledTurnJournal(journal,readback));assert.deepEqual(journal,before);});} +test('partial durable phase resumes only the remaining journal checkpoints',()=>{const {journal,readback}=fixture();const steps=planSettledTurnJournal(journal,readback);const afterWriteback=steps[1];assert.deepEqual(planSettledTurnJournal(afterWriteback,readback).map(s=>s.completed_phases.length),[5,7]);}); +test('terminal reconciliation requires native terminal receipt',()=>{const {journal,readback}=fixture();journal.completion_intent.continuation='no_followup';const final=planSettledTurnJournal(journal,readback).at(-1)!;assert.equal(final.status,'committed');assert.ok(final.terminal_closeout);}); diff --git a/tests/extensions/test_lark_goal_topic_runtime.py b/tests/extensions/test_lark_goal_topic_runtime.py index 667102e1b6..e7da10552c 100644 --- a/tests/extensions/test_lark_goal_topic_runtime.py +++ b/tests/extensions/test_lark_goal_topic_runtime.py @@ -178,10 +178,14 @@ def run(args: list[str]) -> dict[str, Any]: if "--content" in args: state["reply_content"] = args[args.index("--content") + 1] state["reply_type"] = "post" - state["reply_text"] = json.loads(state["reply_content"])["zh_cn"]["content"][0][0]["text"] + state["reply_text"] = json.loads(state["reply_content"])["zh_cn"][ + "content" + ][0][0]["text"] else: state["reply_text"] = args[args.index("--text") + 1] - state["reply_content"] = json.dumps({"text": state["reply_text"]}, ensure_ascii=False) + state["reply_content"] = json.dumps( + {"text": state["reply_text"]}, ensure_ascii=False + ) state["reply_type"] = "text" payload = ( { @@ -189,7 +193,7 @@ def run(args: list[str]) -> dict[str, Any]: { "body": { "msg_type": state["reply_type"], - "content": state["reply_content"] + "content": state["reply_content"], } } ] @@ -352,9 +356,7 @@ def answer(route: Any, text: str) -> str: common = { "target_payload": read_goal_channel_targets(target_path), - "binding_payloads": { - "goal-alpha": read_goal_channel_binding(binding_path) - }, + "binding_payloads": {"goal-alpha": read_goal_channel_binding(binding_path)}, "runtime_root": tmp_path / "runtime", "answer": answer, } @@ -433,9 +435,12 @@ def answer(route: Any, text: str) -> str: "sender_id": "ou_owner_fixture", } ] - assert inspect_lark_event_inbox( - project=common["runtime_root"], config_path=replied["inbox_config_ref"] - )["pending_count"] == 0 + assert ( + inspect_lark_event_inbox( + project=common["runtime_root"], config_path=replied["inbox_config_ref"] + )["pending_count"] + == 0 + ) @pytest.mark.parametrize("authority_mode", [None, "future_mode"]) @@ -562,9 +567,7 @@ def answer(route: Mapping[str, Any], _text: str) -> str: result = runtime.process_lark_goal_topic_event( target_payload=read_goal_channel_targets(target_path), - binding_payloads={ - "goal-alpha": read_goal_channel_binding(binding_path) - }, + binding_payloads={"goal-alpha": read_goal_channel_binding(binding_path)}, event={ "event_id": "evt_current_authorized", "message_id": "om_current_authorized", @@ -590,9 +593,7 @@ def answer(route: Mapping[str, Any], _text: str) -> str: ) assert provider_calls assert all("reactions" not in call for call in provider_calls) - config = json.loads( - (tmp_path / "runtime" / result["inbox_config_ref"]).read_text() - ) + config = json.loads((tmp_path / "runtime" / result["inbox_config_ref"]).read_text()) assert config["topic_root_message_id"] == "om_topic_alpha" projection = inspect_lark_event_inbox( project=tmp_path / "runtime", @@ -715,7 +716,10 @@ def answer(route: dict[str, Any], _text: str) -> dict[str, Any]: assert result["ok"] is True assert result["status"] == "replied_and_acknowledged", result - assert state["reply_text"] == "work completed\n\n• Evidence verified\n• Next step recorded" + assert ( + state["reply_text"] + == "work completed\n\n• Evidence verified\n• Next step recorded" + ) projection = inspect_lark_event_inbox( project=tmp_path / "runtime", config_path=Path(result["inbox_config_ref"]), @@ -1271,7 +1275,9 @@ def test_alias_consumer_routes_all_chats_of_its_bot_app() -> None: "oc_first", "oc_second", ] - snapshot["target_payload"]["targets"]["canonical"]["channel"]["chat_id"] = "oc_second" + snapshot["target_payload"]["targets"]["canonical"]["channel"]["chat_id"] = ( + "oc_second" + ) for profile in ("canonical", "alias"): snapshot["binding_payloads"][profile]["bindings"][profile]["topic"] = { "root_message_id": f"om_{profile}" @@ -1723,7 +1729,7 @@ def test_profile_stream_dispatches_only_team_plan_callbacks_for_bound_chats( "target_ref": "mew-second", } } - } + }, }, } callback = { @@ -1783,7 +1789,9 @@ def handle_callback(event: Mapping[str, Any]) -> dict[str, bool]: assert result["ok"] is True assert len(captured_args) == 2 - callback_args = next(args for args in captured_args if "card.action.trigger" in args) + callback_args = next( + args for args in captured_args if "card.action.trigger" in args + ) assert ( 'select(.chat_id == "oc_public_fixture" or ' '.chat_id == "oc_second_fixture")' in callback_args @@ -1888,12 +1896,21 @@ def kill(self): @pytest.mark.parametrize( "exit_reason,stop_requested,returncode", - [("timeout", False, 0), ("limit", False, 0), ("signal", False, 0), - ("unknown", False, 0), ("", False, 0), ("signal", True, 0), - ("timeout", False, 1)], + [ + ("timeout", False, 0), + ("limit", False, 0), + ("signal", False, 0), + ("unknown", False, 0), + ("", False, 0), + ("signal", True, 0), + ("timeout", False, 1), + ], ) def test_profile_stream_waits_for_provider_ready_before_reporting_listening( - tmp_path: Path, exit_reason: str, stop_requested: bool, returncode: int, + tmp_path: Path, + exit_reason: str, + stop_requested: bool, + returncode: int, ) -> None: from loopx.extensions.lark.goal_topic_runtime import stream_lark_goal_topic_profile @@ -1964,15 +1981,23 @@ def kill(self) -> None: health_sink=lambda update: health.append(dict(update)), ) - planned = stop_requested or (exit_reason in {"timeout", "limit"} and returncode == 0) + planned = stop_requested or ( + exit_reason in {"timeout", "limit"} and returncode == 0 + ) assert result == { "ok": planned, - "status": "stopped" if stop_requested else "stream_ended" if planned else "source_disconnected", + "status": "stopped" + if stop_requested + else "stream_ended" + if planned + else "source_disconnected", **({} if planned else {"error_code": "lark_event_source_disconnected"}), "event_count": 0, "replied_count": 0, } - assert [item["status"] for item in health] == (["starting"] if stop_requested else ["starting", "listening"]) + assert [item["status"] for item in health] == ( + ["starting"] if stop_requested else ["starting", "listening"] + ) def test_profile_poll_routes_provider_event_through_existing_reply_path( @@ -2071,7 +2096,10 @@ def provider_runner( assert state["consume_args"][:3] == ["fake-lark", "--profile", "mew"] assert "event" in state["consume_args"] projection = state["consume_args"][state["consume_args"].index("--jq") + 1] - assert "message_id:(.message_id // .message.message_id // .event.message.message_id // .id)" in projection + assert ( + "message_id:(.message_id // .message.message_id // .event.message.message_id // .id)" + in projection + ) assert "root_id:(.root_id // .message.root_id" in projection assert "parent_id:(.parent_id // .reply_to" in projection assert "thread_id" in projection @@ -2448,11 +2476,15 @@ def answer_spy(_route: Any, _text: Any) -> str: @pytest.mark.parametrize("reaction_ok", [True, False, None]) -def test_manager_receives_reaction_before_answer_and_preserves_sender(tmp_path, monkeypatch, reaction_ok): +def test_manager_receives_reaction_before_answer_and_preserves_sender( + tmp_path, monkeypatch, reaction_ok +): from loopx.extensions.lark import goal_topic_runtime as runtime + target_path, binding_path = tmp_path / "targets.json", tmp_path / "bindings.json" _seed_legacy_topic(target_path, binding_path) original_decide = runtime.decide_lark_topic_event + def manager_decision(**kw): result = original_decide(**kw) result["route"] = { @@ -2462,35 +2494,65 @@ def manager_decision(**kw): "ingress_mode": "session_queue", } return result + monkeypatch.setattr(runtime, "decide_lark_topic_event", manager_decision) state = {} runner = _reply_runner(state) stages = [] if reaction_ok is None: + def unavailable(**kw): raise OSError("private receipt unavailable") - monkeypatch.setattr(runtime, "ensure_lark_event_inbox_received_reaction", unavailable) + + monkeypatch.setattr( + runtime, "ensure_lark_event_inbox_received_reaction", unavailable + ) + def with_reactions(args): if args[3:6] == ["im", "reactions", "create"]: stages.append("received") assert args[args.index("--message-id") + 1] == "om_incoming" - assert json.loads(args[args.index("--data") + 1])["reaction_type"]["emoji_type"] == "Get" - return {"returncode": 0 if reaction_ok else 1, "stdout": json.dumps({"ok": reaction_ok, "data": {"reaction_id": "reaction_fixture"}})} + assert ( + json.loads(args[args.index("--data") + 1])["reaction_type"][ + "emoji_type" + ] + == "Get" + ) + return { + "returncode": 0 if reaction_ok else 1, + "stdout": json.dumps( + {"ok": reaction_ok, "data": {"reaction_id": "reaction_fixture"}} + ), + } if args[3:6] == ["im", "reactions", "delete"]: stages.append("cleanup") return {"returncode": 0, "stdout": json.dumps({"ok": True})} return runner(args) + def answer(route, text): stages.append("answer") assert stages == (["answer"] if reaction_ok is None else ["received", "answer"]) assert route["source_sender_id"] == "ou_owner_fixture" return "Received the original intent." - kwargs = dict(target_payload=read_goal_channel_targets(target_path), - binding_payloads={"goal-alpha": read_goal_channel_binding(binding_path)}, - event={"event_id": "evt_incoming", "message_id": "om_incoming", "chat_id": "oc_public_fixture", - "root_id": "om_topic_alpha", "create_time": "2026-08-14T21:00:00Z", - "content": "@linkmacbot forward this", "mentioned": True, "sender_type": "user", "sender_id": "ou_owner_fixture"}, - runtime_root=tmp_path / "runtime", answer=answer, reply_runner=with_reactions) + + kwargs = dict( + target_payload=read_goal_channel_targets(target_path), + binding_payloads={"goal-alpha": read_goal_channel_binding(binding_path)}, + event={ + "event_id": "evt_incoming", + "message_id": "om_incoming", + "chat_id": "oc_public_fixture", + "root_id": "om_topic_alpha", + "create_time": "2026-08-14T21:00:00Z", + "content": "@linkmacbot forward this", + "mentioned": True, + "sender_type": "user", + "sender_id": "ou_owner_fixture", + }, + runtime_root=tmp_path / "runtime", + answer=answer, + reply_runner=with_reactions, + ) result = runtime.process_lark_goal_topic_event(**kwargs) assert result["ok"], result assert result["status"] == "replied_and_acknowledged" @@ -2498,17 +2560,28 @@ def answer(route, text): if reaction_ok: from loopx.extensions.lark.event_inbox import load_lark_event_inbox_config from loopx.extensions.lark.inbox_reactions import lark_inbox_reaction_receipts + config = load_lark_event_inbox_config( - project=kwargs["runtime_root"], config_path=result["inbox_config_ref"]) + project=kwargs["runtime_root"], config_path=result["inbox_config_ref"] + ) assert config["reply"]["received_reaction_policy"] == "retain" - assert lark_inbox_reaction_receipts( - inbox=config["inbox_path"], message_id="om_incoming")["received"]["emoji_type"] == "Get" + assert ( + lark_inbox_reaction_receipts( + inbox=config["inbox_path"], message_id="om_incoming" + )["received"]["emoji_type"] + == "Get" + ) before = list(stages) - assert runtime.process_lark_goal_topic_event(**kwargs)["status"] == "already_acknowledged" + assert ( + runtime.process_lark_goal_topic_event(**kwargs)["status"] + == "already_acknowledged" + ) assert stages == before -def test_concurrent_manager_delivery_answers_one_source_message_once(tmp_path, monkeypatch): +def test_concurrent_manager_delivery_answers_one_source_message_once( + tmp_path, monkeypatch +): from loopx.extensions.lark import goal_topic_runtime as runtime target_path, binding_path = tmp_path / "targets.json", tmp_path / "bindings.json" @@ -2527,7 +2600,8 @@ def manager_decision(**kwargs): monkeypatch.setattr(runtime, "decide_lark_topic_event", manager_decision) monkeypatch.setattr( - runtime, "ensure_lark_event_inbox_received_reaction", + runtime, + "ensure_lark_event_inbox_received_reaction", lambda **_kwargs: {"ok": True, "status": "already_received"}, ) entered, release = threading.Event(), threading.Event() @@ -2544,10 +2618,14 @@ def answer(_route, _text): target_payload=read_goal_channel_targets(target_path), binding_payloads={"goal-alpha": read_goal_channel_binding(binding_path)}, event={ - "event_id": "evt_one_source", "message_id": "om_one_source", - "chat_id": "oc_public_fixture", "root_id": "om_topic_alpha", - "create_time": "2026-08-14T21:00:00Z", "content": "@linkmacbot question", - "sender_type": "user", "sender_id": "ou_owner_fixture", + "event_id": "evt_one_source", + "message_id": "om_one_source", + "chat_id": "oc_public_fixture", + "root_id": "om_topic_alpha", + "create_time": "2026-08-14T21:00:00Z", + "content": "@linkmacbot question", + "sender_type": "user", + "sender_id": "ou_owner_fixture", }, runtime_root=tmp_path / "runtime", answer=answer, @@ -2566,72 +2644,103 @@ def answer(_route, _text): assert second.result(timeout=5)["status"] == "already_acknowledged" assert answers == ["answer"] sends = [ - call for call in state["calls"] + call + for call in state["calls"] if "+messages-reply" in call and "--dry-run" not in call ] assert len(sends) == 1 -@pytest.mark.parametrize("error_code,label", [ - ("cyber_policy", "安全策略拦截"), - ("rate_limit_exceeded", "请求频率限制"), - ("server_overloaded", "当前模型繁忙"), - ("runtime_unavailable", "本地运行环境初始化失败"), - ("resume_failed", "原 Agent 会话恢复失败"), - ("private-upstream-detail", "管家处理失败"), -]) +@pytest.mark.parametrize( + "error_code,label", + [ + ("cyber_policy", "安全策略拦截"), + ("rate_limit_exceeded", "请求频率限制"), + ("server_overloaded", "当前模型繁忙"), + ("runtime_unavailable", "本地运行环境初始化失败"), + ("resume_failed", "原 Agent 会话恢复失败"), + ("private-upstream-detail", "管家处理失败"), + ], +) @pytest.mark.parametrize("reply_ok", [True, False]) def test_manager_terminal_failure_replies_once_before_ack( - tmp_path, monkeypatch, error_code, label, reply_ok, + tmp_path, + monkeypatch, + error_code, + label, + reply_ok, ): from loopx.extensions.lark import goal_topic_runtime as runtime target_path, binding_path = tmp_path / "targets.json", tmp_path / "bindings.json" _seed_legacy_topic(target_path, binding_path) original_decide = runtime.decide_lark_topic_event + def manager_decision(**kw): result = original_decide(**kw) result["route"].update( - conversation_kind="manager", ingress_mode="session_queue", + conversation_kind="manager", + ingress_mode="session_queue", authority_mode="turn_authorized", event_id=kw["event"]["event_id"], connector={"response_policy": "topic_reply"}, ) return result + monkeypatch.setattr(runtime, "decide_lark_topic_event", manager_decision) - monkeypatch.setattr(runtime, "ensure_lark_event_inbox_received_reaction", - lambda **kw: {"ok": True, "status": "already_received"}) + monkeypatch.setattr( + runtime, + "ensure_lark_event_inbox_received_reaction", + lambda **kw: {"ok": True, "status": "already_received"}, + ) state = {} runner = _reply_runner(state) calls = [] + def answer(route, text): calls.append(text) - raise runtime.LarkGoalTopicTurnFailed(error_code, runtime._session_turn_effect(route)) + raise runtime.LarkGoalTopicTurnFailed( + error_code, runtime._session_turn_effect(route) + ) + def reply(args): if not reply_ok and "+messages-reply" in args and "--dry-run" not in args: return {"returncode": 1, "stdout": "", "stderr": "private-transport-detail"} return runner(args) + kwargs = dict( target_payload=read_goal_channel_targets(target_path), binding_payloads={"goal-alpha": read_goal_channel_binding(binding_path)}, - event={"event_id": "evt_incoming", "message_id": "om_incoming", - "chat_id": "oc_public_fixture", "root_id": "om_topic_alpha", - "create_time": "2026-08-14T21:00:00Z", "content": "@linkmacbot report", - "mentioned": True, "sender_type": "user"}, - runtime_root=tmp_path / "runtime", answer=answer, reply_runner=reply, + event={ + "event_id": "evt_incoming", + "message_id": "om_incoming", + "chat_id": "oc_public_fixture", + "root_id": "om_topic_alpha", + "create_time": "2026-08-14T21:00:00Z", + "content": "@linkmacbot report", + "mentioned": True, + "sender_type": "user", + }, + runtime_root=tmp_path / "runtime", + answer=answer, + reply_runner=reply, ) result = runtime.process_lark_goal_topic_event(**kwargs) assert result["ok"] is False assert label in state["reply_text"] assert "private-" not in state["reply_text"] + str(result) assert "不会自动重放" in state["reply_text"] - pending = inspect_lark_event_inbox(project=kwargs["runtime_root"], - config_path=Path(result["inbox_config_ref"])) + pending = inspect_lark_event_inbox( + project=kwargs["runtime_root"], config_path=Path(result["inbox_config_ref"]) + ) if reply_ok: assert result["status"] == "processing_failed" assert result["failure_reply_verified"] and result["source_acknowledged"] assert pending["items"] == [] - assert runtime.process_lark_goal_topic_event(**kwargs)["status"] == "already_acknowledged" + assert ( + runtime.process_lark_goal_topic_event(**kwargs)["status"] + == "already_acknowledged" + ) assert len(calls) == 1 else: assert any(x["message_id"] == "om_incoming" for x in pending["items"]) @@ -2640,7 +2749,9 @@ def reply(args): @pytest.mark.parametrize("answer_value", ["raise", "empty"]) def test_manager_untyped_or_empty_answer_gets_bounded_failure_receipt( - tmp_path, monkeypatch, answer_value, + tmp_path, + monkeypatch, + answer_value, ): from loopx.extensions.lark import goal_topic_runtime as runtime @@ -2651,7 +2762,8 @@ def test_manager_untyped_or_empty_answer_gets_bounded_failure_receipt( def manager_decision(**kw): result = original_decide(**kw) result["route"].update( - conversation_kind="manager", ingress_mode="session_queue", + conversation_kind="manager", + ingress_mode="session_queue", authority_mode="turn_authorized", event_id=kw["event"]["event_id"], connector={"response_policy": "topic_reply"}, @@ -2704,7 +2816,10 @@ def answer(route, text): config_path=Path(result["inbox_config_ref"]), ) assert pending["items"] == [] - assert runtime.process_lark_goal_topic_event(**kwargs)["status"] == "already_acknowledged" + assert ( + runtime.process_lark_goal_topic_event(**kwargs)["status"] + == "already_acknowledged" + ) assert len(answer_calls) == 1 @@ -2729,7 +2844,10 @@ def answer(route, text): ) @pytest.mark.parametrize("reply_ok", [True, False]) def test_manager_report_delivery_recovers_safe_format_and_keeps_pending_body( - tmp_path, monkeypatch, body, reply_ok, + tmp_path, + monkeypatch, + body, + reply_ok, ): from loopx.extensions.lark import goal_topic_runtime as runtime from loopx.extensions.lark.inbox_reply import send_lark_inbox_message @@ -2742,7 +2860,8 @@ def test_manager_report_delivery_recovers_safe_format_and_keeps_pending_body( def manager_decision(**kw): result = original_decide(**kw) result["route"].update( - conversation_kind="manager", ingress_mode="session_queue", + conversation_kind="manager", + ingress_mode="session_queue", authority_mode="turn_authorized", event_id=kw["event"]["event_id"], connector={"response_policy": "topic_reply"}, @@ -2750,14 +2869,20 @@ def manager_decision(**kw): return result monkeypatch.setattr(runtime, "decide_lark_topic_event", manager_decision) - monkeypatch.setattr(runtime, "ensure_lark_event_inbox_received_reaction", - lambda **kw: {"ok": True, "status": "already_received"}) + monkeypatch.setattr( + runtime, + "ensure_lark_event_inbox_received_reaction", + lambda **kw: {"ok": True, "status": "already_received"}, + ) state, answered = {}, [] runner = _reply_runner(state) def answer(route, text): answered.append(text) - return {"response_text": body, "effect_receipt": runtime._session_turn_effect(route)} + return { + "response_text": body, + "effect_receipt": runtime._session_turn_effect(route), + } def reply(args): if not reply_ok and "+messages-reply" in args and "--dry-run" not in args: @@ -2767,11 +2892,19 @@ def reply(args): kwargs = dict( target_payload=read_goal_channel_targets(target_path), binding_payloads={"goal-alpha": read_goal_channel_binding(binding_path)}, - event={"event_id": "evt_incoming", "message_id": "om_incoming", - "chat_id": "oc_public_fixture", "root_id": "om_topic_alpha", - "create_time": "2026-08-14T21:00:00Z", "content": "@linkmacbot report", - "mentioned": True, "sender_type": "user"}, - runtime_root=tmp_path / "runtime", answer=answer, reply_runner=reply, + event={ + "event_id": "evt_incoming", + "message_id": "om_incoming", + "chat_id": "oc_public_fixture", + "root_id": "om_topic_alpha", + "create_time": "2026-08-14T21:00:00Z", + "content": "@linkmacbot report", + "mentioned": True, + "sender_type": "user", + }, + runtime_root=tmp_path / "runtime", + answer=answer, + reply_runner=reply, ) result = runtime.process_lark_goal_topic_event(**kwargs) if body == r"private\nformat": @@ -2811,7 +2944,10 @@ def reply(args): config_path=Path(result["inbox_config_ref"])) if reply_ok: assert pending["items"] == [] - assert runtime.process_lark_goal_topic_event(**kwargs)["status"] == "already_acknowledged" + assert ( + runtime.process_lark_goal_topic_event(**kwargs)["status"] + == "already_acknowledged" + ) assert len(answered) == 1 else: assert any(x["message_id"] == "om_incoming" for x in pending["items"]) @@ -2820,12 +2956,16 @@ def reply(args): assert len(answered) == 1 # Root notifications retain their existing compact limit. with pytest.raises(LarkOutboundTextError): - send_lark_inbox_message(project=kwargs["runtime_root"], - config_path=result["inbox_config_ref"], text="x" * 1201) + send_lark_inbox_message( + project=kwargs["runtime_root"], + config_path=result["inbox_config_ref"], + text="x" * 1201, + ) def test_manager_delivery_reuses_saved_answer_after_transport_restart( - tmp_path, monkeypatch, + tmp_path, + monkeypatch, ): from loopx.extensions.lark import goal_topic_runtime as runtime @@ -2880,9 +3020,7 @@ def unavailable_runner(args): kwargs = { "target_payload": read_goal_channel_targets(target_path), - "binding_payloads": { - "goal-alpha": read_goal_channel_binding(binding_path) - }, + "binding_payloads": {"goal-alpha": read_goal_channel_binding(binding_path)}, "event": event, "runtime_root": tmp_path / "runtime", "answer": answer, @@ -2918,9 +3056,7 @@ def unavailable_runner(args): assert second_state["reply_text"] == "完整答复\n@LoopX 管家仅为显示文本" assert json.loads(delivery_files[0].read_text())["status"] == "sent_verified" - monkeypatch.setattr( - runtime, "acknowledge_lark_event_inbox", original_acknowledge - ) + monkeypatch.setattr(runtime, "acknowledge_lark_event_inbox", original_acknowledge) third_state = {} third_runner = _reply_runner(third_state) @@ -2939,9 +3075,12 @@ def no_duplicate_send(args): receipt = json.loads(delivery_files[0].read_text()) assert receipt["status"] == "acknowledged" assert "delivery_text" not in receipt - assert inspect_lark_event_inbox( - project=kwargs["runtime_root"], config_path=Path(second["inbox_config_ref"]) - )["items"] == [] + assert ( + inspect_lark_event_inbox( + project=kwargs["runtime_root"], config_path=Path(second["inbox_config_ref"]) + )["items"] + == [] + ) def test_manager_retries_saved_proposal_delivery_before_source_ack( @@ -2989,9 +3128,7 @@ def answer(_route: Mapping[str, Any], text: str) -> dict[str, Any]: delivery_calls: list[tuple[str, ...]] = [] - def deliver( - route: Mapping[str, Any], proposal_ids: list[str] - ) -> dict[str, Any]: + def deliver(route: Mapping[str, Any], proposal_ids: list[str]) -> dict[str, Any]: assert route["source_sender_id"] == "ou_owner_fixture" delivery_calls.append(tuple(proposal_ids)) if len(delivery_calls) == 1: @@ -3010,9 +3147,7 @@ def deliver( first_state: dict[str, Any] = {} kwargs = { "target_payload": read_goal_channel_targets(target_path), - "binding_payloads": { - "goal-alpha": read_goal_channel_binding(binding_path) - }, + "binding_payloads": {"goal-alpha": read_goal_channel_binding(binding_path)}, "event": event, "runtime_root": tmp_path / "runtime", "answer": answer, @@ -3046,7 +3181,8 @@ def no_duplicate_reply(args: list[str]) -> dict[str, Any]: def test_a_fully_sent_part_sequence_settles_after_an_interrupted_receipt_write( - tmp_path, monkeypatch, + tmp_path, + monkeypatch, ): """The reader already has every part; the delivery must stop saying pending. @@ -3069,7 +3205,8 @@ def test_a_fully_sent_part_sequence_settles_after_an_interrupted_receipt_write( def manager_decision(**kwargs): result = original_decide(**kwargs) result["route"].update( - conversation_kind="manager", ingress_mode="session_queue", + conversation_kind="manager", + ingress_mode="session_queue", authority_mode="turn_authorized", event_id=kwargs["event"]["event_id"], connector={"response_policy": "topic_reply"}, @@ -3123,7 +3260,8 @@ def interrupted_receipt_write(path, payload): assert first["source_acknowledged"] is False config_path = Path(first["inbox_config_ref"]) state_path = delivery_path( - project=kwargs["runtime_root"], config_path=config_path, + project=kwargs["runtime_root"], + config_path=config_path, message_id="om_incoming", ) saved = json.loads(state_path.read_text()) @@ -3150,7 +3288,8 @@ def interrupted_receipt_write(path, payload): assert second["saved_response_reused"] is True assert len(answered) == 1 assert [ - call for call in state["calls"] + call + for call in state["calls"] if "+messages-reply" in call and "--dry-run" not in call ] == delivered_once settled = json.loads(state_path.read_text()) @@ -3163,7 +3302,8 @@ def interrupted_receipt_write(path, payload): def test_a_part_whose_readback_failed_is_reconciled_instead_of_sent_twice( - tmp_path, monkeypatch, + tmp_path, + monkeypatch, ): """An ambiguous part send must be confirmed, not repeated. @@ -3184,7 +3324,8 @@ def test_a_part_whose_readback_failed_is_reconciled_instead_of_sent_twice( def manager_decision(**kwargs): result = original_decide(**kwargs) result["route"].update( - conversation_kind="manager", ingress_mode="session_queue", + conversation_kind="manager", + ingress_mode="session_queue", authority_mode="turn_authorized", event_id=kwargs["event"]["event_id"], connector={"response_policy": "topic_reply"}, @@ -3242,7 +3383,8 @@ def ambiguous_runner(args: list[str]) -> dict[str, Any]: assert first["status"] == "reply_delivery_pending" config_path = Path(first["inbox_config_ref"]) state_path = delivery_path( - project=kwargs["runtime_root"], config_path=config_path, + project=kwargs["runtime_root"], + config_path=config_path, message_id="om_incoming", ) saved = json.loads(state_path.read_text()) @@ -3276,7 +3418,8 @@ def ambiguous_runner(args: list[str]) -> dict[str, Any]: def test_a_long_manager_answer_is_delivered_as_one_message( - tmp_path, monkeypatch, + tmp_path, + monkeypatch, ): """A 3000-character steward answer is one message, not a bounded sequence. @@ -3298,7 +3441,8 @@ def test_a_long_manager_answer_is_delivered_as_one_message( def manager_decision(**kwargs): result = original_decide(**kwargs) result["route"].update( - conversation_kind="manager", ingress_mode="session_queue", + conversation_kind="manager", + ingress_mode="session_queue", authority_mode="turn_authorized", event_id=kwargs["event"]["event_id"], connector={"response_policy": "topic_reply"}, @@ -3362,7 +3506,8 @@ def manager_decision(**kwargs): def test_a_stalled_part_sequence_tells_the_reader_what_was_delivered( - tmp_path, monkeypatch, + tmp_path, + monkeypatch, ): """A partly delivered answer must not look like a complete one. @@ -3382,7 +3527,8 @@ def test_a_stalled_part_sequence_tells_the_reader_what_was_delivered( def manager_decision(**kwargs): result = original_decide(**kwargs) result["route"].update( - conversation_kind="manager", ingress_mode="session_queue", + conversation_kind="manager", + ingress_mode="session_queue", authority_mode="turn_authorized", event_id=kwargs["event"]["event_id"], connector={"response_policy": "topic_reply"}, @@ -3466,7 +3612,8 @@ def delivered_texts() -> list[str]: saved = json.loads( delivery_path( - project=kwargs["runtime_root"], config_path=config_path, + project=kwargs["runtime_root"], + config_path=config_path, message_id="om_incoming", ).read_text() ) @@ -3478,7 +3625,10 @@ def delivered_texts() -> list[str]: @pytest.mark.parametrize("manager", [False, True]) @pytest.mark.parametrize("thread", [False, True]) def test_quoted_reply_reaches_real_chat_store_and_protocol_turn( - tmp_path: Path, manager: bool, thread: bool, + tmp_path: Path, + manager: bool, + thread: bool, + monkeypatch: Any, ) -> None: """Real inbox/controller/store; only the external provider/model are fixtures.""" import runpy @@ -3486,25 +3636,39 @@ def test_quoted_reply_reaches_real_chat_store_and_protocol_turn( from loopx.chat_runtime import ChatRuntimeController from loopx.chat_store import ChatSessionStore - from loopx.extensions.lark.event_collector_runtime import enrich_lark_event_reply_context - from loopx.extensions.lark.goal_topic_runtime import answer_lark_goal_topic, process_lark_goal_topic_event + from loopx.extensions.lark.event_collector_runtime import ( + enrich_lark_event_reply_context, + ) + from loopx.extensions.lark.goal_topic_runtime import ( + answer_lark_goal_topic, + process_lark_goal_topic_event, + ) executable = tmp_path / "codex" - fixture = runpy.run_path(str(Path(__file__).parents[2] / "examples/loopx-chat-runtime-smoke.py")) + fixture = runpy.run_path( + str(Path(__file__).parents[2] / "examples/loopx-chat-runtime-smoke.py") + ) executable.write_text(fixture["FAKE_CODEX"], encoding="utf-8") executable.chmod(executable.stat().st_mode | stat.S_IXUSR) + from test_chat_ordinary_project import _python_app_server_fixture + + _python_app_server_fixture(monkeypatch, executable) store = ChatSessionStore(tmp_path / "chat") controller = ChatRuntimeController(store=store, codex_bin=str(executable)) parent_text = "Dependency review is ready; the draft card has not been approved." messages = { "om_question": { - "message_id": "om_question", "chat_id": "oc_public_fixture", - "content": "Where is the card?", "root_id": "om_topic_alpha", - "parent_id": "om_parent", "mentions": [], + "message_id": "om_question", + "chat_id": "oc_public_fixture", + "content": "Where is the card?", + "root_id": "om_topic_alpha", + "parent_id": "om_parent", + "mentions": [], "sender": {"sender_type": "user", "id": "ou_owner_fixture"}, }, "om_parent": { - "message_id": "om_parent", "chat_id": "oc_public_fixture", + "message_id": "om_parent", + "chat_id": "oc_public_fixture", "body": {"content": json.dumps({"text": parent_text})}, "sender": {"sender_type": "app", "id": "cli_public_fixture"}, }, @@ -3515,27 +3679,53 @@ def test_quoted_reply_reaches_real_chat_store_and_protocol_turn( question = messages["om_question"] question.pop("parent_id") question.pop("root_id") - question.update(thread_id="omt_fixture", thread_message_position="2", - mentions=[{"name": "linkmacbot", "id": "cli_public_fixture"}]) - root = {"message_id": "om_topic_alpha", "chat_id": "oc_public_fixture", - "thread_id": "omt_fixture", "thread_message_position": "-1", - "content": "Review the initial draft."} - revised = {**root, "message_id": "om_revision", "thread_message_position": "1", - "content": parent_text} + question.update( + thread_id="omt_fixture", + thread_message_position="2", + mentions=[{"name": "linkmacbot", "id": "cli_public_fixture"}], + ) + root = { + "message_id": "om_topic_alpha", + "chat_id": "oc_public_fixture", + "thread_id": "omt_fixture", + "thread_message_position": "-1", + "content": "Review the initial draft.", + } + revised = { + **root, + "message_id": "om_revision", + "thread_message_position": "1", + "content": parent_text, + } messages["om_topic_alpha"] = {**root, "thread_replies": [revised, question]} def provider(argv: list[str], **_kwargs: Any): mid = argv[argv.index("--message-ids") + 1] - return subprocess.CompletedProcess(argv, 0, json.dumps({ - "ok": True, "data": {"messages": [messages[mid]]}, - }), "") + return subprocess.CompletedProcess( + argv, + 0, + json.dumps( + { + "ok": True, + "data": {"messages": [messages[mid]]}, + } + ), + "", + ) event = enrich_lark_event_reply_context( - {"schema_version": "lark_event_inbox_event_v0", "message_id": "om_question", - "event_id": "evt_question", "chat_id": "oc_public_fixture", - **({"root_id": "om_topic_alpha"} if thread else {})}, - runner=provider, command_prefix=["lark-cli"], profile="mew", - profile_app_id="cli_public_fixture", configured_chat_id="oc_public_fixture", + { + "schema_version": "lark_event_inbox_event_v0", + "message_id": "om_question", + "event_id": "evt_question", + "chat_id": "oc_public_fixture", + **({"root_id": "om_topic_alpha"} if thread else {}), + }, + runner=provider, + command_prefix=["lark-cli"], + profile="mew", + profile_app_id="cli_public_fixture", + configured_chat_id="oc_public_fixture", ) target_path, binding_path = tmp_path / "targets.json", tmp_path / "binding.json" _seed_legacy_topic(target_path, binding_path) @@ -3544,32 +3734,51 @@ def provider(argv: list[str], **_kwargs: Any): def answer(route: Any, text: str): return answer_lark_goal_topic( route={**route, **({"conversation_kind": "manager"} if manager else {})}, - text=text, work_dir=tmp_path, objective="Inspect dependency review.", + text=text, + work_dir=tmp_path, + objective="Inspect dependency review.", runtime_controller=controller, ) options = dict( target_payload=read_goal_channel_targets(target_path), binding_payloads={"goal-alpha": read_goal_channel_binding(binding_path)}, - runtime_root=tmp_path / "runtime", answer=answer, + runtime_root=tmp_path / "runtime", + answer=answer, reply_runner=_reply_runner(state), ) try: result = process_lark_goal_topic_event(**options, event=event) assert result["status"] == "replied_and_acknowledged" session = store.list_sessions()[0] - user_messages = [r for r in store.messages(session["session_id"]) if r["role"] == "user"] + user_messages = [ + r for r in store.messages(session["session_id"]) if r["role"] == "user" + ] assert len(user_messages) == 1 assert parent_text in user_messages[0]["text"] assert "context only" in user_messages[0]["text"] assert user_messages[0]["text"].endswith("Where is the card?") assert state["reply_text"] == "Runtime response." readback = inspect_lark_event_inbox( - project=tmp_path / "runtime", config_path=result["inbox_config_ref"], limit=0, + project=tmp_path / "runtime", + config_path=result["inbox_config_ref"], + limit=0, ) assert readback["processed_count"] == 1 - assert process_lark_goal_topic_event(**options, event=event)["status"] == "already_acknowledged" - assert len([r for r in store.messages(session["session_id"]) if r["role"] == "user"]) == 1 + assert ( + process_lark_goal_topic_event(**options, event=event)["status"] + == "already_acknowledged" + ) + assert ( + len( + [ + r + for r in store.messages(session["session_id"]) + if r["role"] == "user" + ] + ) + == 1 + ) finally: for session in store.list_sessions(): controller.close_session(session["session_id"]) @@ -3578,18 +3787,42 @@ def answer(route: Any, text: str): def test_message_event_projection_preserves_flat_cli_and_raw_provider_provenance(): import shutil from loopx.extensions.lark.goal_topic_runtime import _EVENT_PROJECTION + jq = shutil.which("jq") if jq is None: pytest.skip("jq runtime is unavailable") - expected = {"event_id": "event_fixture", "message_id": "om_fixture", "sender_id": "ou_fixture", - "sender_type": "user", "chat_id": "oc_fixture", "chat_type": "p2p", "message_type": "text"} + expected = { + "event_id": "event_fixture", + "message_id": "om_fixture", + "sender_id": "ou_fixture", + "sender_type": "user", + "chat_id": "oc_fixture", + "chat_type": "p2p", + "message_type": "text", + } flat = {**expected, "content": "plain rendered text", "create_time": "123"} - nested = {"header": {"event_id": expected["event_id"]}, "event": { - "sender": {"sender_id": {"open_id": expected["sender_id"]}, "sender_type": "user"}, - "message": {key: value for key, value in flat.items() if key not in {"event_id", "sender_id", "sender_type"}}}} + nested = { + "header": {"event_id": expected["event_id"]}, + "event": { + "sender": { + "sender_id": {"open_id": expected["sender_id"]}, + "sender_type": "user", + }, + "message": { + key: value + for key, value in flat.items() + if key not in {"event_id", "sender_id", "sender_type"} + }, + }, + } for payload in [flat, nested]: - completed = subprocess.run([jq, "-c", _EVENT_PROJECTION], input=json.dumps(payload), text=True, - capture_output=True, check=True) + completed = subprocess.run( + [jq, "-c", _EVENT_PROJECTION], + input=json.dumps(payload), + text=True, + capture_output=True, + check=True, + ) row = json.loads(completed.stdout) for key, value in expected.items(): assert row[key] == value diff --git a/tests/managed_effect_runtime_fixture.py b/tests/managed_effect_runtime_fixture.py index ce580c9bc2..360b44bfc2 100644 --- a/tests/managed_effect_runtime_fixture.py +++ b/tests/managed_effect_runtime_fixture.py @@ -6,11 +6,13 @@ from uuid import uuid4 from loopx.control_plane import effect_runtime +from loopx.control_plane.runtime.file_paths import windows_extended_path def capture_disposable_effect_runtimes(root: Path) -> list[tuple[Path, str]]: if root.is_symlink(): return [] + root = windows_extended_path(root) owned_root = root.resolve() captured = [] for locator in root.glob("**/loopx-effect-runtime-*/runtime-*.json"): @@ -30,6 +32,7 @@ def stop_disposable_effect_runtimes( ) -> None: if root.is_symlink(): return + root = windows_extended_path(root) owned_root = root.resolve() records = dict.fromkeys([*(captured or []), *capture_disposable_effect_runtimes(root)]) if not records: @@ -61,7 +64,7 @@ def stop_disposable_effect_runtimes( # the server's frozen physical publication path, never a path # claimed by copied JSON. Unknown/older servers remain untouched. published = ping.get("info_path") - if not isinstance(published, str) or Path(published) != locator.resolve(): + if not isinstance(published, str) or windows_extended_path(Path(published).resolve()) != locator.resolve(): continue result = effect_runtime._stop_effect_runtime( locator, info, timeout=5, wait_for_process_exit=True, diff --git a/tests/presentation/team_task_revalidation_fixture.py b/tests/presentation/team_task_revalidation_fixture.py index 84de266a29..d94f2dcece 100644 --- a/tests/presentation/team_task_revalidation_fixture.py +++ b/tests/presentation/team_task_revalidation_fixture.py @@ -1,6 +1,7 @@ """Disposable production delegation + Chat HTTP for packaged recovery validation.""" from pathlib import Path import hashlib +from contextlib import contextmanager import json import subprocess import sys @@ -15,16 +16,58 @@ from loopx.chat_runtime import ChatRuntimeController from loopx.chat_server import ChatHTTPServer, ChatRequestHandler from loopx.chat_store import ChatSessionStore +from loopx.control_plane.runtime.file_paths import windows_extended_path + + +@contextmanager +def fixture_workspace(): + temporary = tempfile.TemporaryDirectory(prefix="lx-rv-") + directory = Path(temporary.name) + assert directory.resolve().parent == Path(tempfile.gettempdir()).resolve() + # Keep every registered path/opaque identity unchanged. Only recursive + # teardown needs extended syntax to see Windows lock sidecars beyond MAX_PATH. + temporary.name = str(windows_extended_path(directory)) + with temporary: + yield directory + + +def drain_fixture_workers(workers, *, timeout=30): + """Wait on this fixture's actual process handles, never infer exit from status.""" + for worker in workers: + code = worker.wait(timeout=timeout) + if code != 0: + raise RuntimeError(f"fixture worker exited with status {code}") + + +def fixture_http_server(address, handler): + server = ChatHTTPServer(address, handler) + # A closed browser can leave an admitted HTTP request running. Join those + # handlers before their disposable backend is removed; production stays unchanged. + server.daemon_threads = False + return server def main(): cleanups = [] patch = pytest.MonkeyPatch() - # Leave room for lock-incident filenames under ordinary Windows temp roots. - with tempfile.TemporaryDirectory(prefix="lx-rv-") as directory: + with fixture_workspace() as directory: try: root, runner = service.__wrapped__(Path(directory), SimpleNamespace(param="file", addfinalizer=cleanups.append), patch) + workers = [] + + class FixtureProcess(subprocess.Popen): + def __init__(self, argv, *args, **kwargs): + super().__init__(argv, *args, **kwargs) + if (isinstance(argv, list) and "--delegation-action" in argv + and argv[argv.index("--delegation-action") + 1] == "worker" + and "--runtime-root" in argv + and argv[argv.index("--runtime-root") + 1] == str(runner.root)): + workers.append(self) + + # Keep the production spawn argv/IO intact; retain only our workers' + # handles so accepted/rejected observations cannot race directory removal. + patch.setattr(subprocess, "Popen", FixtureProcess) config = root / "project/.loopx/config/delegations.json" config.parent.mkdir(parents=True, exist_ok=True) catalog = json.loads(runner.config.read_text()) @@ -52,9 +95,7 @@ def observe_before_release(path, row, status, **facts): upstream_thread_id="fixture", session_id="recovery", adapter_kind="codex_app_server", upstream_mode="chat") store.update_session("recovery", native_goal={"status": "active"}, loopx_mode={"settings": { "agent_id": "lead", "execution_config": ".loopx/config/delegations.json"}}) - server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) - # Drain this fixture's request handlers before retiring their runtime. - server.daemon_threads = False + server = fixture_http_server(("127.0.0.1", 0), ChatRequestHandler) server.verbose = False server.registry_path = runner.registry server.runtime_root = runner.root @@ -120,6 +161,8 @@ def lose_response(binding, *args, **kwargs): server.server_close() thread.join(timeout=5) controller.close() + assert workers, "fixture did not observe its own launched worker" + drain_fixture_workers(workers) finally: for cleanup in reversed(cleanups): cleanup() diff --git a/tests/presentation/test_team_task_fixture_cleanup.py b/tests/presentation/test_team_task_fixture_cleanup.py new file mode 100644 index 0000000000..6d7332e127 --- /dev/null +++ b/tests/presentation/test_team_task_fixture_cleanup.py @@ -0,0 +1,116 @@ +"""A disposable browser fixture must drain its own workers before deletion.""" +import subprocess +import sys +import time +import threading +import http.client +from http.server import BaseHTTPRequestHandler +from pathlib import Path +import tempfile + +import pytest + +import team_task_revalidation_fixture as fixture +from loopx.control_plane.runtime.file_paths import windows_extended_path + + +def test_fixture_removes_long_opaque_paths(tmp_path, monkeypatch): + monkeypatch.setattr(tempfile, 'tempdir', str(tmp_path)) + with fixture.fixture_workspace() as directory: + root = Path(directory) + leaf = root / ('a' * 64) / ('b' * 64) / (('c' * 64) + '.json.lock.incidents.jsonl') + extended = windows_extended_path(leaf) + extended.parent.mkdir(parents=True) + extended.write_text('owned fixture data') + assert extended.read_text() == 'owned fixture data' + assert len(str(leaf)) > 260 + assert not root.exists(), 'long-path cleanup silently left fixture data behind' + + +def test_fixture_closes_inflight_http_before_directory_cleanup(): + entered, release, closed = threading.Event(), threading.Event(), threading.Event() + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + entered.set() + assert release.wait(timeout=5) + self.send_response(200) + self.end_headers() + def log_message(self, *_): + pass + + server = fixture.fixture_http_server(('127.0.0.1', 0), Handler) + serving = threading.Thread(target=server.serve_forever) + serving.start() + responses = [] + def request(): + connection = http.client.HTTPConnection(*server.server_address, timeout=5) + try: + connection.request('GET', '/') + responses.append(connection.getresponse().status) + finally: + connection.close() + client = threading.Thread(target=request) + client.start() + closer = None + try: + assert entered.wait(timeout=5) + server.shutdown() + def close(): + server.server_close() + closed.set() + closer = threading.Thread(target=close) + closer.start() + assert not closed.wait(timeout=.05), 'cleanup raced an active HTTP handler' + release.set() + assert closed.wait(timeout=5) + client.join(timeout=5) + assert responses == [200] + finally: + release.set() + server.shutdown() + server.server_close() + serving.join(timeout=5) + client.join(timeout=5) + if closer is not None: + closer.join(timeout=5) + + +def test_fixture_waits_for_real_owned_writer(tmp_path): + completed = tmp_path / 'completed' + worker = subprocess.Popen([sys.executable, '-c', + "import sys,time; from pathlib import Path; time.sleep(.2); Path(sys.argv[1]).write_text('complete')", + str(completed)]) + try: + fixture.drain_fixture_workers([worker], timeout=5) + assert worker.returncode == 0 and completed.read_text() == 'complete' + finally: + if worker.poll() is None: + worker.terminate() + worker.wait(timeout=5) + + +def test_fixture_does_not_accept_an_undrained_worker(tmp_path): + ready = tmp_path / 'ready' + worker = subprocess.Popen([sys.executable, '-c', + "import sys,time; from pathlib import Path; Path(sys.argv[1]).touch(); time.sleep(60)", str(ready)]) + try: + deadline = time.monotonic()+5 + while not ready.exists() and time.monotonic() < deadline: + time.sleep(.01) + assert ready.exists() and worker.poll() is None + with pytest.raises(subprocess.TimeoutExpired): + fixture.drain_fixture_workers([worker], timeout=.02) + assert worker.poll() is None + finally: + worker.terminate() + worker.wait(timeout=5) + + +def test_fixture_does_not_accept_worker_failure(): + worker = subprocess.Popen([sys.executable, '-c', 'raise SystemExit(7)']) + try: + with pytest.raises(RuntimeError, match='worker exited with status 7'): + fixture.drain_fixture_workers([worker], timeout=5) + finally: + worker.wait(timeout=5) diff --git a/tests/test_chat_agent_control_rpc_order.py b/tests/test_chat_agent_control_rpc_order.py new file mode 100644 index 0000000000..1eeab6b809 --- /dev/null +++ b/tests/test_chat_agent_control_rpc_order.py @@ -0,0 +1,62 @@ +"""Tools must retain the preceding Turn notification during a control RPC.""" + +import queue +import time +from pathlib import Path + +import pytest + +from loopx.chat_agent import CodexChatAgentSession + + +@pytest.mark.parametrize( + "started,foreign,authorized", [(True, False, True), (True, True, True), + (False, False, True), (True, False, False)] +) +def test_control_read_preserves_turn_binding_and_tool_permission( + started, foreign, authorized +): + messages = queue.Queue() + notification = { + "method": "turn/started", + "params": {"threadId": "thread", "turnId": "new-turn"}, + } + request = { + "id": 200, + "method": "item/tool/call", + "params": {"threadId": "thread", "turnId": "foreign" if foreign else "new-turn", + "callId": "200", "tool": "loopx_collaboration", + "arguments": {"action": "bindings"}}, + } + if started: + messages.put(notification) + messages.put(request) + messages.put({"id": 1, "result": {"goal": {"status": "active"}}}) + session = CodexChatAgentSession( + process=None, messages=messages, thread_id="thread", work_dir=Path.cwd() + ) + writes, calls = [], [] + session._write = writes.append + + def handler(name, arguments): + calls.append((name, arguments)) + return {"ok": authorized} + + session.read_tool_handler = handler + assert session._request("thread/goal/get", {"threadId": "thread"}, request_id=1) == { + "goal": {"status": "active"} + } + assert not calls + if started: + assert session._next_event(deadline=time.monotonic() + 1) == notification + session.current_turn_id = "new-turn" + if started and not foreign: + assert len(writes) == 1 + event = session._next_event(deadline=time.monotonic() + 1) + assert event == request + assert session._check_server_gate(event) + assert calls == [("loopx_collaboration", {"action": "bindings"})] + assert writes[-1]["result"]["success"] is authorized + else: + assert not calls + assert writes[-1]["result"]["success"] is False diff --git a/tests/test_chat_coordinator_checkpoint.py b/tests/test_chat_coordinator_checkpoint.py new file mode 100644 index 0000000000..dcc23124a8 --- /dev/null +++ b/tests/test_chat_coordinator_checkpoint.py @@ -0,0 +1,206 @@ +import json +import subprocess +from threading import Event +from types import SimpleNamespace +from test_chat_loopx_mode import mode as mode, apply +from test_chat_project_coordination import project as project +import pytest +from loopx.hosts.chat_coordinator_checkpoint import CoordinatorCheckpoints +from loopx.chat_runtime import ChatRuntimeController +from loopx.chat_store import ChatSessionStore +from loopx.collaboration_mcp import _pinned_release_environment, _python_module_command +from loopx.todos import add_goal_todo +from loopx.control_plane.coordination.local_authority import ( + read_canonical_todos_if_promoted, +) +from tests.control_plane import test_default_checkpoint_continuation as native +from tests.test_goal_mode_mcp_settlement import _control, GOAL_ID, AGENT_ID + + +def cli(registry, *args): + result = subprocess.run( + [ + *_python_module_command("loopx.cli"), + "--registry", + str(registry), + "--format", + "json", + *args, + ], + env=_pinned_release_environment(), + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + return result.returncode, json.loads(result.stdout) + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_original_checkpoint_context_stale_rejection_and_single_settlement( + provider, tmp_path, monkeypatch +): + monkeypatch.setattr(native, "_run_cli", cli) + registry, state, todo = native._fixture(provider, tmp_path, monkeypatch) + control = _control(registry) + control.run_cli = lambda args, **kw: json.dumps(cli(registry, *args)[1]) + json.loads(control.should_run()) + first = json.loads( + control.complete_task(todo, AGENT_ID, "Candidate verified.", no_follow_up=True) + ) + assert first["stage"] == "direction_pending" + root = tmp_path / "runtime" + index = root / "goals" / GOAL_ID / "runs/index.jsonl" + checkpoint = CoordinatorCheckpoints(registry, root, GOAL_ID, AGENT_ID) + observed = checkpoint.read(todo) + assert observed["settlement_identity"] == first["settlement_identity"] + extra = add_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + role="agent", + text="Verify the newly arrived dependency.", + claimed_by=AGENT_ID, + task_class="advancement_task", + continuation_policy="same_agent_non_delivery", + ) + before = index.read_bytes() + stale = checkpoint.resume( + todo, + { + "read_context_id": observed["read_context_id"], + "vision_unchanged_reason": "The original candidate remains, but this old basis is obsolete.", + }, + ) + assert not stale["ok"] and index.read_bytes() == before + with pytest.raises(ValueError): + checkpoint.read(extra["todo_id"]) + with pytest.raises(ValueError): + CoordinatorCheckpoints(registry, root, GOAL_ID, "different-agent").read(todo) + fresh = checkpoint.read(todo) + assert fresh["read_context_id"] != observed["read_context_id"] and extra[ + "todo_id" + ] in json.dumps(fresh["basis"]) + decision = { + "read_context_id": fresh["read_context_id"], + "vision_unchanged_reason": "The committed candidate remains accepted; the new dependency is the next obligation.", + } + with pytest.raises(ValueError): + checkpoint.resume(todo, {**decision, "agent_id": "different-agent"}) + assert index.read_bytes() == before + settled = checkpoint.resume(todo, decision) + assert ( + settled["ok"] + and settled["settlement_complete"] + and settled["settlement_identity"] == first["settlement_identity"] + ) + assert not settled.get("goal_completion_certified") + repeated = checkpoint.resume(todo, decision) + assert ( + repeated["ok"] + and repeated["settlement_identity"] == first["settlement_identity"] + ) + events = [json.loads(line) for line in index.read_text().splitlines()] + assert sum(e.get("classification") == "quota_slot_spent" for e in events) == 1 + rows = read_canonical_todos_if_promoted(runtime_root=root, goal_id=GOAL_ID)["todos"] + original = next(t for t in rows if t["todo_id"] == todo) + assert ( + original["status"] == "done" and original["evidence"] == "Candidate verified." + ) + assert next(t for t in rows if t["todo_id"] == extra["todo_id"])["status"] == "open" + + +def test_chat_checkpoint_dispatch_keeps_pause_binding_and_member_namespace( + mode, monkeypatch +): + service, sid, repo, settings, _ = mode + started = apply(mode, "start", settings=settings) + service.controller.coordination_runtime_root = repo / "canonical-runtime" + calls = [] + + class Checkpoints: + def __init__(self, registry, root, goal_id, agent_id): + assert registry == service.controller.registry_path + assert root == service.controller.coordination_runtime_root + assert goal_id == "research" and agent_id == "coordinator" + + def read(self, todo): + calls.append(("read", todo)) + return {"ok": True, "read_context_id": "fresh"} + + def resume(self, todo, decision): + calls.append(("resume", todo, decision)) + return {"ok": True} + + monkeypatch.setattr( + "loopx.hosts.chat_coordinator_checkpoint.CoordinatorCheckpoints", Checkpoints + ) + adapter = SimpleNamespace( + goal_driver=SimpleNamespace(stopped=Event()), session=SimpleNamespace() + ) + lock = service.prepare( + sid, started["turn_id"], adapter, lambda *a: {}, lambda *a: None + ) + try: + handler = adapter.session.read_tool_handler + read = { + "action": "read", + "operation_id": "todo_original", + "brief": {"purpose": "first_delivery"}, + } + assert handler(TOOL_NAME, read)["ok"] + resume = { + "action": "resume", + "operation_id": "todo_original", + "brief": { + "purpose": "first_delivery", + "read_context_id": "fresh", + "vision_unchanged_reason": "Current facts preserve the route.", + }, + } + assert handler(TOOL_NAME, resume)["ok"] + assert calls[-1][2] == { + "read_context_id": "fresh", + "vision_unchanged_reason": "Current facts preserve the route.", + } + assert not handler(TOOL_NAME, {**read, "agent_id": "foreign"})["ok"] + binding = service.store.load_session(sid)["loopx_mode"]["settings"] + member, _, _, _ = service._execution(service.store.load_session(sid), binding) + path = member.path("todo_collision") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{}") + assert not handler(TOOL_NAME, {**read, "operation_id": "todo_collision"})["ok"] + assert len(calls) == 2 + monkeypatch.setattr(service.controller, "interrupt_turn", lambda **_: None) + apply(mode, "pause") + assert handler(TOOL_NAME, resume)["error"] == "conversation_execution_inactive" + assert len(calls) == 2 + finally: + lock.__exit__(None, None, None) + + +TOOL_NAME = "loopx_collaboration" + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_checkpoint_snapshot_follows_registered_runtime_with_separate_chat_storage(provider, tmp_path, monkeypatch): + registry, _, todo = native._fixture(provider, tmp_path, monkeypatch) + control = _control(registry) + assert json.loads(control.should_run()).get("delivery_result_context") + result = json.loads(control.complete_task(todo, AGENT_ID, "Verified retained result.", no_follow_up=True)) + assert result["stage"] == "direction_pending", result + store = ChatSessionStore(tmp_path / "chat-only") + controller = ChatRuntimeController(store=store, codex_bin="unused-fixture-host", registry_path=registry) + try: + session = store.create_session(goal_id=GOAL_ID, agent_id="codex", channel_id=f"goal.{GOAL_ID}", + upstream_thread_id="fixture-session", upstream_mode="chat", adapter_kind="codex_app_server") + store.update_session(session["session_id"], native_goal={"status": "paused"}, + loopx_mode={"enabled": True, "paused": True, "settings": {"agent_id": AGENT_ID, "token_budget": 10000}}) + assert controller.coordination_runtime_root == tmp_path / "runtime" + assert store.root.parent != controller.coordination_runtime_root + observed = controller.loopx_mode.snapshot(session["session_id"])["coordinator_checkpoint"] + assert observed["stage"] == "direction_pending" and observed["result_committed"] is True + assert observed["quota_spent"] is False and observed["settlement_identity"] == result["settlement_identity"] + context = CoordinatorCheckpoints(registry, controller.coordination_runtime_root, GOAL_ID, AGENT_ID).read(todo) + assert context["settlement_identity"] == result["settlement_identity"] + finally: + controller.close() diff --git a/tests/test_chat_loopx_mode.py b/tests/test_chat_loopx_mode.py index 270c387811..6539754300 100644 --- a/tests/test_chat_loopx_mode.py +++ b/tests/test_chat_loopx_mode.py @@ -101,14 +101,18 @@ def test_start_is_explicit_and_replays_original_request_even_after_settings_chan assert not calls and not service.snapshot(sid)["enabled"] first = apply(mode, "start") second = apply(mode, "start") - assert first["turn_id"] == second["turn_id"] and len(calls) == 1 + # The fixture only accepts a queued Turn. Its retry re-enters acceptance, + # while a real worker's single-flight guard prevents duplicate dispatch. + assert first["turn_id"] == second["turn_id"] and len(calls) == 2 + assert calls[0] == calls[1] + assert len(service.store.messages(sid)) == 1 with pytest.raises(ValueError, match="identity conflict"): apply(mode, "resume") with pytest.raises(ValueError, match="identity conflict"): apply(mode, "start", settings={**settings, "token_budget": 20000}) with pytest.raises(Exception, match="current conversation turn"): apply(mode, "start", operation_id="another-run") - assert len(calls) == 1 + assert len(calls) == 2 def test_unfinished_goal_pins_identity_and_configuration_digest(mode): diff --git a/tests/test_chat_ordinary_project.py b/tests/test_chat_ordinary_project.py index 316102f0e8..e424820cb6 100644 --- a/tests/test_chat_ordinary_project.py +++ b/tests/test_chat_ordinary_project.py @@ -14,34 +14,70 @@ from loopx.chat_store import ChatSessionStore +def _python_app_server_fixture(monkeypatch, executable): + """Keep the real fixture protocol process runnable on native Windows.""" + import os + import subprocess + import sys + + if os.name != "nt": + return + real_popen = subprocess.Popen + + def popen(command, *args, **kwargs): + if command and str(command[0]) == str(executable): + command = [sys.executable, "-u", str(executable), *command[1:]] + return real_popen(command, *args, **kwargs) + + monkeypatch.setattr(subprocess, "Popen", popen) + + @pytest.fixture def ordinary(tmp_path, monkeypatch): workspace = tmp_path / "notes" workspace.mkdir() capture = tmp_path / "requests.jsonl" - source = runpy.run_path(str(Path(__file__).parents[1] / "examples/loopx-chat-runtime-smoke.py"))["FAKE_CODEX"] - source = source.replace(' method = request.get("method")', + source = runpy.run_path( + str(Path(__file__).parents[1] / "examples/loopx-chat-runtime-smoke.py") + )["FAKE_CODEX"] + source = source.replace( + ' method = request.get("method")', f' with open({str(capture)!r}, "a") as output:\n output.write(json.dumps(request) + "\\n")\n' - ' method = request.get("method")') - source = source.replace(' elif method in {"thread/start", "thread/resume"}:', + ' method = request.get("method")', + ) + source = source.replace( + ' elif method in {"thread/start", "thread/resume"}:', ' elif method == "config/read":\n' - ' import pathlib\n' + " import pathlib\n" ' config = pathlib.Path(request["params"]["cwd"]) / "effective-config.json"\n' ' result = {"config": json.loads(config.read_text()) if config.exists() else {}}\n' - ' elif method in {"thread/start", "thread/resume"}:') - source = source.replace(' result = {"thread": {"id": "durable-thread"}}', + ' elif method in {"thread/start", "thread/resume"}:', + ) + source = source.replace( + ' result = {"thread": {"id": "durable-thread"}}', ' result = {"thread": {"id": "durable-thread"},\n' ' "model": request["params"].get("model", "old-model"),\n' - ' "reasoningEffort": request["params"].get("config", {}).get("model_reasoning_effort", "xhigh")}') + ' "reasoningEffort": request["params"].get("config", {}).get("model_reasoning_effort", "xhigh")}', + ) fake = tmp_path / "codex" fake.write_text(source) fake.chmod(0o700) + _python_app_server_fixture(monkeypatch, fake) contexts = ChatProjectContexts([workspace], workspace_grant="workspace_read") store = ChatSessionStore(tmp_path / "runtime") - runtime = ChatRuntimeController(store=store, codex_bin=str(fake), project_contexts=contexts, - registry_path=tmp_path / "no-registry.json") + runtime = ChatRuntimeController( + store=store, + codex_bin=str(fake), + project_contexts=contexts, + registry_path=tmp_path / "no-registry.json", + ) import loopx.chat_manager_context as manager - monkeypatch.setattr(manager, "collect_manager_turn_context", lambda *_, **__: pytest.fail("ordinary Chat must not read portfolio")) + + monkeypatch.setattr( + manager, + "collect_manager_turn_context", + lambda *_, **__: pytest.fail("ordinary Chat must not read portfolio"), + ) server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) server.chat_store, server.runtime_controller = store, runtime server.runtime_root, server.registry_path = store.root.parent, runtime.registry_path @@ -52,8 +88,12 @@ def ordinary(tmp_path, monkeypatch): def request(path, body=None): connection = http.client.HTTPConnection(*server.server_address, timeout=15) try: - connection.request("GET" if body is None else "POST", path, - None if body is None else json.dumps(body), headers={"Content-Type": "application/json"}) + connection.request( + "GET" if body is None else "POST", + path, + None if body is None else json.dumps(body), + headers={"Content-Type": "application/json"}, + ) response = connection.getresponse() return response.status, json.loads(response.read()) finally: @@ -65,7 +105,9 @@ def request(path, body=None): thread.join(timeout=2) -def test_http_ordinary_project_continues_native_session_without_goal_or_portfolio(ordinary): +def test_http_ordinary_project_continues_native_session_without_goal_or_portfolio( + ordinary, +): store, runtime, contexts, request, capture, fake, workspace = ordinary status, projects = request("/api/chat/projects") assert status == 200 and len(projects["projects"]) == 1 @@ -78,19 +120,46 @@ def test_http_ordinary_project_continues_native_session_without_goal_or_portfoli assert status == 201, opened sid = opened["session_id"] assert opened["goal_id"] is None and opened["session"]["manager_runtime"] is None - assert store.load_session(sid)["project_context"]["workspace_path"] == str(workspace) - for message, rid in [("Name the directory", "first"), ("Repeat the previous name", "follow-up")]: - status, accepted = request(f"/api/chat/sessions/{sid}/turns", {"message": message, "client_turn_id": rid}) + assert store.load_session(sid)["project_context"]["workspace_path"] == str( + workspace + ) + for message, rid in [ + ("Name the directory", "first"), + ("Repeat the previous name", "follow-up"), + ]: + status, accepted = request( + f"/api/chat/sessions/{sid}/turns", + {"message": message, "client_turn_id": rid}, + ) assert status == 202, accepted - assert runtime.wait_for_turn(session_id=sid, turn_id=accepted["turn_id"], timeout_sec=10)["status"] == "completed" + assert ( + runtime.wait_for_turn( + session_id=sid, turn_id=accepted["turn_id"], timeout_sec=10 + )["status"] + == "completed" + ) assert request("/api/chat/sessions", body)[1]["session_id"] == sid original = store.load_session(sid)["upstream_thread_id"] runtime.close() - restarted = ChatRuntimeController(store=ChatSessionStore(store.root.parent), codex_bin=str(fake), project_contexts=contexts) + restarted = ChatRuntimeController( + store=ChatSessionStore(store.root.parent), + codex_bin=str(fake), + project_contexts=contexts, + ) try: - resumed, was_resumed = restarted.open_session(goal_id=None, agent_id="codex", work_dir=workspace, - objective="do not substitute caller authority", mode="resume_latest", project_ref=project["project_ref"]) - assert was_resumed and resumed["session_id"] == sid and resumed["upstream_thread_id"] == original + resumed, was_resumed = restarted.open_session( + goal_id=None, + agent_id="codex", + work_dir=workspace, + objective="do not substitute caller authority", + mode="resume_latest", + project_ref=project["project_ref"], + ) + assert ( + was_resumed + and resumed["session_id"] == sid + and resumed["upstream_thread_id"] == original + ) finally: restarted.close() requests = [json.loads(line) for line in capture.read_text().splitlines()] @@ -99,7 +168,10 @@ def test_http_ordinary_project_continues_native_session_without_goal_or_portfoli assert len(resumes) == 1 and resumes[0]["params"]["threadId"] == original assert len([row for row in requests if row.get("method") == "turn/start"]) == 2 assert not any(row.get("method", "").startswith("thread/goal") for row in requests) - assert "Fresh Core evidence" not in capture.read_text() and "None:" not in capture.read_text() + assert ( + "Fresh Core evidence" not in capture.read_text() + and "None:" not in capture.read_text() + ) # The real host context gets answer guidance even on the structured-output # project lane, which does not use the older Chat review prompt. assert "A simple question needs no report template" in capture.read_text() @@ -111,44 +183,92 @@ def test_project_grants_cannot_be_forged_widened_or_reused_after_revocation(ordi store, runtime, contexts, request, _, _, workspace = ordinary ref = contexts.available()[0]["project_ref"] base = {"context_kind": "project", "project_ref": ref} - for forged in [dict(base, goal_id="hidden"), dict(base, goal_id=[]), dict(base, project_ref="b" * 24), - dict(base, workspace_path=str(workspace.parent)), dict(base, grant="write")]: + for forged in [ + dict(base, goal_id="hidden"), + dict(base, goal_id=[]), + dict(base, project_ref="b" * 24), + dict(base, workspace_path=str(workspace.parent)), + dict(base, grant="write"), + ]: assert request("/api/chat/sessions", forged)[0] == 400 sid = request("/api/chat/sessions", base)[1]["session_id"] with pytest.raises(ValueError, match="external audience"): - runtime.enqueue_turn(session_id=sid, client_turn_id="foreign", message="private", work_dir=workspace, - objective="ignored", origin="lark") + runtime.enqueue_turn( + session_id=sid, + client_turn_id="foreign", + message="private", + work_dir=workspace, + objective="ignored", + origin="lark", + ) runtime.project_contexts = ChatProjectContexts([]) - assert request(f"/api/chat/sessions/{sid}/turns", {"message": "read", "client_turn_id": "revoked"})[0] == 400 + assert ( + request( + f"/api/chat/sessions/{sid}/turns", + {"message": "read", "client_turn_id": "revoked"}, + )[0] + == 400 + ) assert store.turn_for_client(sid, "revoked") is None with pytest.raises(ValueError): - store.create_session(goal_id=None, agent_id="codex", adapter_kind="codex_app_server", upstream_thread_id="forged") + store.create_session( + goal_id=None, + agent_id="codex", + adapter_kind="codex_app_server", + upstream_thread_id="forged", + ) def test_project_configuration_change_reaches_resumed_native_turn(ordinary): store, runtime, contexts, request, capture, fake, workspace = ordinary ref = contexts.available()[0]["project_ref"] - _, opened = request("/api/chat/sessions", {"context_kind": "project", "project_ref": ref}) + _, opened = request( + "/api/chat/sessions", {"context_kind": "project", "project_ref": ref} + ) sid = opened["session_id"] original = store.load_session(sid)["upstream_thread_id"] assert runtime.adapters[sid].session.reasoning_effort == "xhigh" runtime.close() # Independent host observation changes between processes. The original # thread still reports xhigh unless the adapter applies that observation. - (workspace / "effective-config.json").write_text(json.dumps({ - "model": "current-project-model", "model_reasoning_effort": "high", - "sandbox_mode": "danger-full-access", - })) - restarted = ChatRuntimeController(store=ChatSessionStore(store.root.parent), - codex_bin=str(fake), project_contexts=contexts) + (workspace / "effective-config.json").write_text( + json.dumps( + { + "model": "current-project-model", + "model_reasoning_effort": "high", + "sandbox_mode": "danger-full-access", + } + ) + ) + restarted = ChatRuntimeController( + store=ChatSessionStore(store.root.parent), + codex_bin=str(fake), + project_contexts=contexts, + ) try: - resumed, was_resumed = restarted.open_session(goal_id=None, agent_id="codex", - work_dir=workspace, objective="continue", mode="resume_latest", project_ref=ref) + resumed, was_resumed = restarted.open_session( + goal_id=None, + agent_id="codex", + work_dir=workspace, + objective="continue", + mode="resume_latest", + project_ref=ref, + ) assert was_resumed and resumed["session_id"] == sid assert resumed["upstream_thread_id"] == original - turn, _ = restarted.submit_turn(session_id=sid, client_turn_id="after-config-change", - message="Continue the original request.", work_dir=workspace, objective="continue") - assert restarted.wait_for_turn(session_id=sid, turn_id=turn["turn_id"], timeout_sec=10)["status"] == "completed" + turn, _ = restarted.submit_turn( + session_id=sid, + client_turn_id="after-config-change", + message="Continue the original request.", + work_dir=workspace, + objective="continue", + ) + assert ( + restarted.wait_for_turn( + session_id=sid, turn_id=turn["turn_id"], timeout_sec=10 + )["status"] + == "completed" + ) finally: restarted.close() requests = [json.loads(line) for line in capture.read_text().splitlines()] @@ -158,26 +278,49 @@ def test_project_configuration_change_reaches_resumed_native_turn(ordinary): turn = next(r for r in requests if r.get("method") == "turn/start")["params"] assert turn["threadId"] == original assert turn["model"] == "current-project-model" and turn["effort"] == "high" - assert len(store.list_sessions()) == 1 and store.load_session(sid)["goal_id"] is None + assert ( + len(store.list_sessions()) == 1 and store.load_session(sid)["goal_id"] is None + ) -@pytest.mark.parametrize("messages", [ - ("Read the linked source and collect it using the project skill.", "Keep the previous source and correct its summary."), - ("Organize this document in the existing notes.", "Preserve the original text and verify your edits."), -]) -def test_writable_project_turns_use_project_skills_without_manager_work_selection(ordinary, messages): +@pytest.mark.parametrize( + "messages", + [ + ( + "Read the linked source and collect it using the project skill.", + "Keep the previous source and correct its summary.", + ), + ( + "Organize this document in the existing notes.", + "Preserve the original text and verify your edits.", + ), + ], +) +def test_writable_project_turns_use_project_skills_without_manager_work_selection( + ordinary, messages +): from loopx.chat_agent import CHAT_REVIEW_CLOSE_TAG, CHAT_REVIEW_OPEN_TAG store, runtime, contexts, request, capture, _, workspace = ordinary contexts.workspace_grant = "workspace_write" ref = contexts.available()[0]["project_ref"] - status, opened = request("/api/chat/sessions", {"context_kind": "project", "project_ref": ref}) + status, opened = request( + "/api/chat/sessions", {"context_kind": "project", "project_ref": ref} + ) assert status == 201 sid = opened["session_id"] for index, message in enumerate(messages): - status, accepted = request(f"/api/chat/sessions/{sid}/turns", {"message": message, "client_turn_id": f"project-{index}"}) + status, accepted = request( + f"/api/chat/sessions/{sid}/turns", + {"message": message, "client_turn_id": f"project-{index}"}, + ) assert status == 202, accepted - assert runtime.wait_for_turn(session_id=sid, turn_id=accepted["turn_id"], timeout_sec=10)["status"] == "completed" + assert ( + runtime.wait_for_turn( + session_id=sid, turn_id=accepted["turn_id"], timeout_sec=10 + )["status"] + == "completed" + ) requests = [json.loads(line) for line in capture.read_text().splitlines()] starts = [row for row in requests if row.get("method") == "thread/start"] turns = [row for row in requests if row.get("method") == "turn/start"] @@ -194,31 +337,53 @@ def test_writable_project_turns_use_project_skills_without_manager_work_selectio assert "context_delegation catalog" not in prompt assert "A protected_action is only an untrusted proposal" in prompt assert prompt.endswith(f"Operator user message:\n{message}") - envelope = json.loads(prompt.split(CHAT_REVIEW_OPEN_TAG, 1)[1].split(CHAT_REVIEW_CLOSE_TAG, 1)[0]) + envelope = json.loads( + prompt.split(CHAT_REVIEW_OPEN_TAG, 1)[1].split(CHAT_REVIEW_CLOSE_TAG, 1)[0] + ) assert envelope["schema_version"] == "loopx_chat_agent_response_v0" assert envelope["message"] == "" assert envelope["proposals"] == [] - assert all(envelope[field] is None for field in ("protected_action", "goal_draft", "context_handoff", "gate")) + assert all( + envelope[field] is None + for field in ("protected_action", "goal_draft", "context_handoff", "gate") + ) session = store.load_session(sid) assert session["goal_id"] is None and session.get("manager_runtime") is None assert session["project_context"]["grant"] == "workspace_write" assert len(store.list_sessions()) == 1 assert not (workspace / "ACTIVE_GOAL_STATE.md").exists() + + @pytest.mark.parametrize("grant", ["workspace_read", "workspace_write"]) -def test_workspace_only_rejects_other_executor_before_session_creation(ordinary, monkeypatch, grant): +def test_workspace_only_rejects_other_executor_before_session_creation( + ordinary, monkeypatch, grant +): store, runtime, contexts, request, _, _, _ = ordinary contexts.filesystem_scope, contexts.workspace_grant = "workspace_only", grant - monkeypatch.setattr("loopx.chat_endpoint_catalog.shutil.which", - lambda executable: executable if executable == "kiro-cli" else None) - monkeypatch.setattr(runtime, "_start_adapter", lambda **_: pytest.fail("must reject before starting a host")) - status, result = request("/api/chat/sessions", { - "context_kind": "project", "project_ref": contexts.available()[0]["project_ref"], "agent_id": "kiro-cli", - }) + monkeypatch.setattr( + "loopx.chat_endpoint_catalog.shutil.which", + lambda executable: executable if executable == "kiro-cli" else None, + ) + monkeypatch.setattr( + runtime, + "_start_adapter", + lambda **_: pytest.fail("must reject before starting a host"), + ) + status, result = request( + "/api/chat/sessions", + { + "context_kind": "project", + "project_ref": contexts.available()[0]["project_ref"], + "agent_id": "kiro-cli", + }, + ) assert status == 400 and "workspace-only" in result.get("error", "") assert store.list_sessions() == [] -def test_read_only_project_rejects_executor_with_workspace_write_scope(ordinary, monkeypatch): +def test_read_only_project_rejects_executor_with_workspace_write_scope( + ordinary, monkeypatch +): store, runtime, contexts, request, _, _, _ = ordinary class FakeWriteScopedAdapter: @@ -231,23 +396,38 @@ def close_session(self): "loopx.chat_endpoint_catalog.shutil.which", lambda executable: executable if executable == "kiro-cli" else None, ) - kiro_capability = next(row for row in runtime.capabilities() if row["agent_id"] == "kiro-cli") + kiro_capability = next( + row for row in runtime.capabilities() if row["agent_id"] == "kiro-cli" + ) assert kiro_capability["trust_scope"] == "workspace_write" runtime._start_adapter = lambda **_: FakeWriteScopedAdapter() project_ref = contexts.available()[0]["project_ref"] - status, result = request("/api/chat/sessions", { - "context_kind": "project", "project_ref": project_ref, "agent_id": "kiro-cli", - }) + status, result = request( + "/api/chat/sessions", + { + "context_kind": "project", + "project_ref": project_ref, + "agent_id": "kiro-cli", + }, + ) assert status == 400, result assert "read-only" in result.get("error", "").lower() - assert store.latest_session(goal_id=None, agent_id="kiro-cli", channel_id=f"project.{project_ref}") is None + assert ( + store.latest_session( + goal_id=None, agent_id="kiro-cli", channel_id=f"project.{project_ref}" + ) + is None + ) project_context = contexts.available()[0] legacy = store.create_session( - goal_id=None, agent_id="kiro-cli", adapter_kind="acp", + goal_id=None, + agent_id="kiro-cli", + adapter_kind="acp", upstream_thread_id="pre-guard-write-scoped-session", - channel_id=f"project.{project_ref}", project_context=project_context, + channel_id=f"project.{project_ref}", + project_context=project_context, ) status, result = request( f"/api/chat/sessions/{legacy['session_id']}/turns", @@ -272,7 +452,9 @@ def test_retargeted_symlink_does_not_rebind_a_project_grant(tmp_path): def test_default_project_host_grant_is_write_and_read_only_launch_is_enforced(ordinary): - from loopx.capabilities.native_chat.conversation_bindings import ChatConversationBindings + from loopx.capabilities.native_chat.conversation_bindings import ( + ChatConversationBindings, + ) store, runtime, contexts, request, capture, _, workspace = ordinary # The fixture intentionally launched read-only. The ordinary product default @@ -281,7 +463,13 @@ def test_default_project_host_grant_is_write_and_read_only_launch_is_enforced(or contexts.workspace_grant = "workspace_write" status, projects = request("/api/chat/projects") assert status == 200 and projects["projects"][0]["grant"] == "workspace_write" - _, opened = request("/api/chat/sessions", {"context_kind": "project", "project_ref": projects["projects"][0]["project_ref"]}) + _, opened = request( + "/api/chat/sessions", + { + "context_kind": "project", + "project_ref": projects["projects"][0]["project_ref"], + }, + ) assert opened["goal_id"] is None assert runtime.adapters[opened["session_id"]].session.sandbox == "workspace-write" # Native thread naming may follow startup; verify the permission-bearing @@ -292,14 +480,34 @@ def test_default_project_host_grant_is_write_and_read_only_launch_is_enforced(or proof = {"transport_ref": "notes-app", "provider_ref": "c" * 24, "operator_ref": "d" * 24, "verified": True} bindings = ChatConversationBindings(root=store.root, project_contexts=contexts, observe=lambda _: proof) contexts.conversation_bindings = bindings - binding = bindings.configure(transport_ref="notes-app", project_ref=projects["projects"][0]["project_ref"], executor_endpoint_id="codex") + binding = bindings.configure( + transport_ref="notes-app", + project_ref=projects["projects"][0]["project_ref"], + executor_endpoint_id="codex", + ) assert binding["grant"] == "workspace_write" contexts.workspace_grant = "workspace_read" - assert request(f"/api/chat/sessions/{opened['session_id']}/turns", {"message": "edit", "client_turn_id": "host-downgraded"})[0] == 400 + assert ( + request( + f"/api/chat/sessions/{opened['session_id']}/turns", + {"message": "edit", "client_turn_id": "host-downgraded"}, + )[0] + == 400 + ) with pytest.raises(ValueError, match="write grant"): - bindings.resolve(binding_id=binding["binding_id"], source_ref="a" * 24, sender_ref=proof["operator_ref"], private_human_message=True) + bindings.resolve( + binding_id=binding["binding_id"], + source_ref="a" * 24, + sender_ref=proof["operator_ref"], + private_human_message=True, + ) with pytest.raises(ValueError, match="workspace grant"): - bindings.configure(transport_ref="notes-app", project_ref=projects["projects"][0]["project_ref"], executor_endpoint_id="codex", project_grant="workspace_write") + bindings.configure( + transport_ref="notes-app", + project_ref=projects["projects"][0]["project_ref"], + executor_endpoint_id="codex", + project_grant="workspace_write", + ) assert bindings.read()["bindings"][0] == binding runtime.close() @@ -312,7 +520,10 @@ def test_local_scope_opens_new_session_after_host_grant_changes(ordinary): assert status == 201 sessions = [original["session_id"]] try: - for grant, sandbox in [("workspace_write", "workspace-write"), ("workspace_read", "read-only")]: + for grant, sandbox in [ + ("workspace_write", "workspace-write"), + ("workspace_read", "read-only"), + ]: contexts.workspace_grant = grant status, opened = request("/api/chat/sessions", body) assert status == 201, opened @@ -320,18 +531,32 @@ def test_local_scope_opens_new_session_after_host_grant_changes(ordinary): assert sid not in sessions and opened["goal_id"] is None assert runtime.adapters[sid].session.sandbox == sandbox assert store.load_session(sessions[-1])["project_context"]["grant"] != grant - status, denied = request(f"/api/chat/sessions/{sessions[-1]}/turns", - {"message": "old permission", "client_turn_id": "stale-grant"}) + status, denied = request( + f"/api/chat/sessions/{sessions[-1]}/turns", + {"message": "old permission", "client_turn_id": "stale-grant"}, + ) assert status == 400 and "grant changed" in denied["error"] assert store.turn_for_client(sessions[-1], "stale-grant") is None assert request("/api/chat/sessions", body)[1]["session_id"] == sid - status, accepted = request(f"/api/chat/sessions/{sid}/turns", - {"message": "Continue under current permission", "client_turn_id": f"current-{grant}"}) + status, accepted = request( + f"/api/chat/sessions/{sid}/turns", + { + "message": "Continue under current permission", + "client_turn_id": f"current-{grant}", + }, + ) assert status == 202, accepted - assert runtime.wait_for_turn(session_id=sid, turn_id=accepted["turn_id"], timeout_sec=10)["status"] == "completed" + assert ( + runtime.wait_for_turn( + session_id=sid, turn_id=accepted["turn_id"], timeout_sec=10 + )["status"] + == "completed" + ) sessions.append(sid) requests = [json.loads(line) for line in capture.read_text().splitlines()] - assert len([row for row in requests if row.get("method") == "thread/start"]) == 3 + assert ( + len([row for row in requests if row.get("method") == "thread/start"]) == 3 + ) assert not any(row.get("method") == "thread/resume" for row in requests) assert len(store.list_sessions()) == 3 assert store.messages(sessions[1]) and store.messages(sessions[2]) diff --git a/tests/test_chat_queued_write_recovery.py b/tests/test_chat_queued_write_recovery.py new file mode 100644 index 0000000000..9dda79f5c3 --- /dev/null +++ b/tests/test_chat_queued_write_recovery.py @@ -0,0 +1,214 @@ +from contextlib import contextmanager +import errno +import json +import os +from pathlib import Path +import threading +import time +from types import SimpleNamespace + +import pytest +import loopx.chat_store as chat_store +from loopx.chat_runtime import ChatRuntimeController +from loopx.control_plane.effect_runtime import restart_effect_runtime +from test_chat_loopx_mode import mode, project # noqa: F401 +from test_file_lock_windows_atomic import reader_without_delete_share + + +@pytest.fixture(autouse=True) +def cleanup_runtime(monkeypatch): + yield + restart_effect_runtime(timeout=5) + + +def finish(store, sid, tid): + store.update_turn(sid, tid, status="completed", response={"text": "finished"}) + store.release_active_turn(sid, tid, last_activity_at="2026-10-15T00:00:00Z", last_error_code=None) + + +def request(service, sid, repo, operation, **body): + return service.apply(sid, {"operation": operation, "operation_id": "original", **body}, + work_dir=repo, objective="Original bounded request") + + +@contextmanager +def transient_reader(path, monkeypatch): + observed = threading.Event() + replace = os.replace + attempts = [] + + def observed_replace(src, dst): + if Path(dst) == path: + attempts.append(1) + observed.set() + return replace(src, dst) + + with reader_without_delete_share(path) as close: + def release(): + assert observed.wait(2) + time.sleep(0.1) + close() + worker = threading.Thread(target=release) + worker.start() + monkeypatch.setattr(chat_store.os, "replace", observed_replace) + try: + yield attempts + finally: + observed.set() + worker.join(3) + assert not worker.is_alive() + + +def test_transient_windows_reader_preserves_complete_atomic_chat_state(tmp_path, monkeypatch): + path = tmp_path / "turn.json" + path.write_text('{"status":"queued"}') + with transient_reader(path, monkeypatch) as attempts: + chat_store._atomic_write_json(path, {"status": "starting"}, preserve_mode=True) + assert len(attempts) >= 2 + assert json.loads(path.read_text()) == {"status": "starting"} + assert not list(tmp_path.glob("*.tmp")) + + +def test_persistent_windows_reader_keeps_original_and_cleans_temporary(tmp_path): + path = tmp_path / "turn.json" + path.write_text('{"status":"queued"}') + before = path.read_bytes() + with reader_without_delete_share(path): + started = time.monotonic() + with pytest.raises(OSError) as failure: + chat_store._atomic_write_json(path, {"status": "starting"}, preserve_mode=True) + assert failure.value.winerror == 5 + assert time.monotonic() - started < 1.2 + assert path.read_bytes() == before + assert not list(tmp_path.glob("*.tmp")) + + +def test_unrelated_replace_failure_propagates_without_retry(tmp_path, monkeypatch): + attempts = [] + def fail(src, dst): + attempts.append(1) + raise FileNotFoundError(errno.ENOENT, "missing source") + monkeypatch.setattr(chat_store.os, "replace", fail) + with pytest.raises(FileNotFoundError): + chat_store._atomic_write_json(tmp_path / "turn.json", {"status": "queued"}) + assert attempts == [1] and not list(tmp_path.glob("*.tmp")) + + +def test_posix_permission_error_is_not_a_windows_retry(tmp_path, monkeypatch): + attempts = [] + def fail(src, dst): + attempts.append(1) + error = PermissionError(errno.EACCES, "denied") + error.winerror = 5 + raise error + monkeypatch.setattr(chat_store, "os", SimpleNamespace(name="posix", fsync=os.fsync, chmod=os.chmod, replace=fail)) + with pytest.raises(PermissionError): + chat_store._atomic_write_json(tmp_path / "turn.json", {"status": "queued"}) + assert attempts == [1] and not list(tmp_path.glob("*.tmp")) + + +def test_successful_chat_write_preserves_permissions_without_sleep(tmp_path, monkeypatch): + path = tmp_path / "turn.json" + path.write_text('{}') + before = path.stat().st_mode + monkeypatch.setattr(time, "sleep", lambda _: pytest.fail("successful write must not wait")) + chat_store._atomic_write_json(path, {"status": "queued"}, preserve_mode=True) + assert path.stat().st_mode == before + assert json.loads(path.read_text()) == {"status": "queued"} + + +def test_json_serialization_failure_cleans_temporary(tmp_path): + path = tmp_path / "turn.json" + path.write_text('{}') + with pytest.raises(TypeError): + chat_store._atomic_write_json(path, {"bad": object()}, preserve_mode=True) + assert path.read_text() == '{}' and not list(tmp_path.glob("*.tmp")) + + +@pytest.mark.parametrize("operation", ["start", "resume"]) +@pytest.mark.parametrize("cold", [False, True], ids=["same-controller", "cold-controller"]) +def test_original_operation_recovers_queued_after_failed_start_fact(mode, monkeypatch, operation, cold): # noqa: F811 - imported pytest fixture + service, sid, repo, settings, _ = mode + store = service.store + # The real failing HTTP Session already had its LoopX tools activated. + store.update_session(sid, loopx_tools=True) + controller = service.controller + # Persist the real request capsule/display message; hold only scheduling. + monkeypatch.setattr(controller, "submit_turn", ChatRuntimeController.submit_turn.__get__(controller)) + monkeypatch.setattr(controller, "_check_codex_home", lambda _: None) + monkeypatch.setattr(controller, "_ensure_adapter_locked", lambda *a, **k: SimpleNamespace()) + monkeypatch.setattr(controller, "_start_accepted_turn_worker", lambda **k: False) + if operation == "resume": + store.update_session(sid, native_goal={"status": "paused", "tokensUsed": 400}) + first = request(service, sid, repo, operation, settings=settings) + tid = first["turn_id"] + original = store.load_turn(sid, tid) + messages = store.messages(sid) + events = store.events_after(sid, tid, None) + assert original["status"] == "queued" and original["upstream_turn_id"] is None + controller = service.controller + done = threading.Event() + controller.turn_done_events[(sid, tid)] = done + with reader_without_delete_share(store._turn_path(sid, tid)): + with pytest.raises(OSError) as failure: + controller._run_turn(session_id=sid, turn_id=tid, message=original["message"], + attachments=[], adapter=SimpleNamespace(), done_event=done, loopx_execution=True) + assert failure.value.winerror == 5 and done.is_set() + assert (sid, tid) not in controller.turn_done_events + assert store.load_turn(sid, tid) == original + if cold: + controller = ChatRuntimeController(store=type(store)(store.root.parent), codex_bin="unused", registry_path=controller.registry_path) + service = controller.loopx_mode + store = service.store + monkeypatch.setattr(controller, "submit_turn", ChatRuntimeController.submit_turn.__get__(controller)) + monkeypatch.setattr(controller, "_check_codex_home", lambda _: None) + monkeypatch.setattr(controller, "_ensure_adapter_locked", lambda *a, **k: SimpleNamespace()) + monkeypatch.setattr(controller, "_start_accepted_turn_worker", ChatRuntimeController._start_accepted_turn_worker.__get__(controller)) + dispatches = [] + def provider(**kwargs): + dispatches.append((kwargs["session_id"], kwargs["turn_id"], kwargs["message"])) + finish(store, sid, tid) + monkeypatch.setattr(controller, "_run_started_turn", provider) + replay = request(service, sid, repo, operation) + current = controller.wait_for_turn(session_id=sid, turn_id=tid, timeout_sec=5) + assert replay["turn_id"] == tid and current["status"] == "completed", current + assert dispatches == [(sid, tid, original["message"])] + assert store.messages(sid) == messages + assert [e for e in store.events_after(sid, tid, None) if e["kind"] == "turn.queued"] == [e for e in events if e["kind"] == "turn.queued"] + assert current["loopx_request"] == original["loopx_request"] + assert request(service, sid, repo, operation)["turn_id"] == tid + assert len(dispatches) == 1 + controller.close() + + +@pytest.mark.parametrize("operation", ["start", "resume"]) +def test_changed_settings_cannot_dispatch_original_queued_request(mode, operation): # noqa: F811 - imported pytest fixture + service, sid, repo, settings, calls = mode + if operation == "resume": + service.store.update_session(sid, native_goal={"status": "paused", "tokensUsed": 400}) + first = request(service, sid, repo, operation, settings=settings) + before = service.store.load_turn(sid, first["turn_id"]) + with pytest.raises(ValueError, match="identity conflict"): + request(service, sid, repo, operation, settings={**settings, "token_budget": settings["token_budget"] + 1}) + assert len(calls) == 1 and service.store.load_turn(sid, first["turn_id"]) == before + + +def test_stopped_goal_cannot_dispatch_previously_queued_operation(mode): # noqa: F811 - imported pytest fixture + service, sid, repo, settings, calls = mode + first = request(service, sid, repo, "start", settings=settings) + registry = service.controller.registry_path + data = json.loads(registry.read_text()) + goal = next(g for g in data["goals"] if g["id"] == "research") + goal["activation"] = {"schema_version": "loopx_goal_activation_v1", "state": "stopped", "updated_at": "2026-10-15T00:00:00Z", "reason": "Owner stopped"} + registry.write_text(json.dumps(data)) + with pytest.raises(Exception, match="stopped"): + request(service, sid, repo, "start") + assert len(calls) == 1 and service.store.load_turn(sid, first["turn_id"])["status"] == "queued" + + +def test_terminal_original_operation_replay_never_dispatches(mode): # noqa: F811 - imported pytest fixture + service, sid, repo, settings, calls = mode + first = request(service, sid, repo, "start", settings=settings) + finish(service.store, sid, first["turn_id"]) + assert request(service, sid, repo, "start")["turn_id"] == first["turn_id"] + assert len(calls) == 1 diff --git a/tests/test_chat_store_paths.py b/tests/test_chat_store_paths.py new file mode 100644 index 0000000000..ed0591bf45 --- /dev/null +++ b/tests/test_chat_store_paths.py @@ -0,0 +1,14 @@ +"""Atomic Chat publication keeps the original filesystem address on Windows.""" +import json +from loopx.chat_store import _atomic_write_json +from loopx.control_plane.runtime.file_paths import windows_extended_path + +def test_atomic_chat_temp_name_beyond_windows_limit(tmp_path): + parent=tmp_path + while len(str(parent))<220: + parent=parent/'nested-chat-path' + path=parent/'state.json' + _atomic_write_json(path,{'identity':'original','version':1}) + _atomic_write_json(path,{'identity':'original','version':2},preserve_mode=True) + assert json.loads(windows_extended_path(path).read_text())=={'identity':'original','version':2} + assert not list(windows_extended_path(parent).glob('*.tmp')) diff --git a/tests/test_codex_operation_host.py b/tests/test_codex_operation_host.py index 535ffc4dff..effa3bddfa 100644 --- a/tests/test_codex_operation_host.py +++ b/tests/test_codex_operation_host.py @@ -63,6 +63,17 @@ def emit(value): text = row["params"]["input"][0]["text"] if os.environ.get("FAKE_OPERATION_PROMPT_FILE"): pathlib.Path(os.environ["FAKE_OPERATION_PROMPT_FILE"]).write_text(text) + if '"phase":"checkpoint_direction"' in text: + context = json.loads(text[text.index('{"'):])['checkpoint_context'] + emit({"id": row["id"], "result": {"turn": {"id": turn}}}) + if os.environ.get('FAKE_OPERATION_DIRECTION_TOOL'): + emit({"id": 52, "method": "item/tool/call", "params": {"threadId": thread, "turnId": turn, "tool": "loopx_operation", "arguments": {"action": "context"}}}) + else: + answer = {'read_context_id': context['read_context_id'], 'agent_vision_json': '', + 'vision_unchanged_reason': 'The committed result and current obligations preserve the route.', 'stop_reason': ''} + emit({"method": "item/agentMessage/delta", "params": {"threadId": thread, "turnId": turn, "delta": json.dumps(answer)}}) + emit({"method": "turn/completed", "params": {"threadId": thread, "turn": {"id": turn, "status": "completed"}}}) + break # The actual native prompt must separate proposal preparation from # domain execution; otherwise approval can never get a proposal. assert "context/pending/inspect do not require consumption" in text @@ -87,9 +98,15 @@ def emit(value): answer.update({"schema_version": "loopx_turn_result_v0", "turn_key": key, "result_kind": "wait", "completed_phases": ["host_execute", "typed_result"], "classification": "awaiting_operation_confirmation", "recommended_action": "Wait for actual human approval", "summary": "Owned native context retrieved; no approval or effect"}) emit({"method": "item/agentMessage/delta", "params": {"threadId": thread, "turnId": turn, "delta": json.dumps(answer)}}) emit({"method": "turn/completed", "params": {"threadId": thread, "turn": {"id": turn, "status": "completed"}}}) + break """ +def _fake_server(tmp_path: Path, program: str = FAKE_SERVER) -> Path: + from tests.control_plane.host_process_fixture import write_python_host_fixture + return write_python_host_fixture(tmp_path / "fake-codex-operation", program) + + def _registered_host_goal(tmp_path: Path) -> Path: """Direct transport tests still require a registered live Goal at launch.""" registry = tmp_path / "registry.json" @@ -107,9 +124,7 @@ def _claimed_native_fixture(tmp_path: Path): from loopx.control_plane.turn_driver.managed_operation_host import operation_tool_handler service, store = fixtures.service(tmp_path, goal_id="fixture-goal") - executable = tmp_path / "fake-codex-operation" - executable.write_text(FAKE_SERVER) - executable.chmod(0o700) + executable = _fake_server(tmp_path) request = _request() request["turn_envelope"]["agent_id"] = "finance-fixture-agent" request["turn_envelope"]["action"]["selected_todo"]["todo_id"] = "todo-managed" @@ -396,6 +411,7 @@ def call_next(): "threadId": thread, "turnId": turn, "delta": json.dumps({"results": results})}}) emit({"method": "turn/completed", "params": { "threadId": thread, "turn": {"id": turn, "status": "completed"}}}) + raise SystemExit(0) for line in sys.stdin: row = json.loads(line) if row.get("method") == "initialize": @@ -471,9 +487,7 @@ def test_registered_source_tokens_survive_owned_native_prepare_and_store_readbac value["normalized_parameters"]["projection"]["simulated"] = False arguments = {"action": "prepare", "request": value} monkeypatch.setenv("FAKE_SOURCE_TOOL_CALLS", json.dumps([arguments, arguments])) - executable = tmp_path / "fake-source-prepare-server" - executable.write_text(SOURCE_PREPARE_SERVER) - executable.chmod(0o700) + executable = _fake_server(tmp_path, SOURCE_PREPARE_SERVER) session = CodexChatAgentSession.start( codex_bin=str(executable), work_dir=tmp_path, @@ -548,9 +562,7 @@ def test_registered_source_tokens_survive_owned_native_prepare_and_store_readbac def test_owned_app_server_process_authenticates_native_metadata_and_resumes_same_profile( tmp_path: Path, deadline, ) -> None: - executable = tmp_path / "fake-codex-operation" - executable.write_text(FAKE_SERVER) - executable.chmod(0o700) + executable = _fake_server(tmp_path) options = { "runtime_root": tmp_path / "runtime", "registry_path": _registered_host_goal(tmp_path), @@ -591,15 +603,42 @@ def test_owned_app_server_process_authenticates_native_metadata_and_resumes_same ) +@pytest.mark.parametrize('tool_attempt', [False, True]) +def test_checkpoint_direction_resumes_session_without_operation_or_child_adoption(tmp_path, monkeypatch, tool_attempt): + executable = _fake_server(tmp_path) + options = dict(runtime_root=tmp_path / 'runtime', registry_path=_registered_host_goal(tmp_path), + project=tmp_path, codex_bin=str(executable), model='test-model', + reasoning_effort='xhigh', timeout_seconds=5) + run_codex_operation_host(_request(), **options) + before = load_codex_cli_session(options['runtime_root'], lineage=_lineage(_request())) + actions = options['runtime_root'] / 'chat/actions/actions.json' + actions.parent.mkdir(parents=True) + actions.write_text('{}') + def forbidden(*args, **kwargs): + pytest.fail('Direction continuation must not adopt an operation or native child') + monkeypatch.setattr('loopx.control_plane.turn_driver.managed_operation_host.pending_operation_handoffs', forbidden) + monkeypatch.setattr('loopx.control_plane.turn_driver.codex_operation_host.native_child_observer', forbidden) + request = _request(session_action='resume') + request.update(phase='checkpoint_direction', checkpoint_context={'read_context_id': 'direction-read', + 'basis': {'todo': {'status': 'done'}, 'frontier': {'scope': 'goal'}}}) + if tool_attempt: + monkeypatch.setenv('FAKE_OPERATION_DIRECTION_TOOL', '1') + with pytest.raises(BuiltInHostError): + run_codex_operation_host(request, **options) + else: + result = run_codex_operation_host(request, **options) + assert result['read_context_id'] == 'direction-read' + assert load_codex_cli_session(options['runtime_root'], lineage=_lineage(request)) == before + assert actions.read_text() == '{}' + + def test_admitted_turn_cli_launches_owned_transport_without_plain_cli_fallback( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: from loopx.cli import main as cli_main project, runtime, registry = _write_live_fixture(tmp_path) - executable = tmp_path / "fake-codex-operation" - executable.write_text(FAKE_SERVER) - executable.chmod(0o700) + executable = _fake_server(tmp_path) def forbidden_plain_cli(*args, **kwargs): pytest.fail("Operation opt-in must not downgrade to plain Codex exec") @@ -610,10 +649,15 @@ def forbidden_plain_cli(*args, **kwargs): ) selected_route = {"host_surface": "codex-app", "thread_id": "source-thread"} observed_routes = [] + host_failures = [] def operation_host(*args, **kwargs): observed_routes.append(kwargs["source_route"]) - return run_codex_operation_host(*args, **kwargs) + try: + return run_codex_operation_host(*args, **kwargs) + except Exception as error: + host_failures.append({"error": str(error), "gate": getattr(error.__cause__, "gate", None), "cause": str(error.__cause__)}) + raise monkeypatch.setattr("loopx.control_plane.turn_driver.codex_operation_host.run_codex_operation_host", operation_host) arguments = [ @@ -623,7 +667,7 @@ def operation_host(*args, **kwargs): "--no-global-sync", "--codex-operation-tools", "--codex-bin", str(executable), "--codex-operation-source-route-json", json.dumps(selected_route), "--codex-model", "test-model", "--codex-reasoning-effort", "xhigh", - "--codex-sandbox", "read-only", "--timeout-seconds", "5", + "--codex-sandbox", "read-only", "--timeout-seconds", "30", "--validation-command-json", json.dumps([sys.executable, "-c", "import json,sys; json.load(sys.stdin)"]), ] lineage = {"goal_id": "loopx-turn-fixture", "agent_id": "codex-fixture", "todo_id": "todo_fixture0001"} @@ -637,7 +681,7 @@ def operation_host(*args, **kwargs): with contextlib.redirect_stdout(output): exit_code = cli_main([*arguments, "--execute"]) payload = json.loads(output.getvalue()) - assert exit_code == 0, json.dumps(payload, indent=2) + assert exit_code == 0, json.dumps({"payload": payload, "host_failures": host_failures}, indent=2) assert payload["host"] == {"executable": "built-in", "kind": "codex-cli"} binding = load_codex_cli_session(runtime, lineage=lineage) assert binding["operation_transport"] == "app-server-operation-tools-v0" @@ -712,9 +756,7 @@ def forbidden_start(command, *args, **kwargs): def test_owned_operation_host_reaps_descendants_on_success_and_timeout( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, hang: bool ) -> None: - executable = tmp_path / "fake-codex-operation" - executable.write_text(FAKE_SERVER) - executable.chmod(0o700) + executable = _fake_server(tmp_path) marker = tmp_path / "owned-child" monkeypatch.setenv("FAKE_OPERATION_CHILD_MARKER", str(marker)) if hang: diff --git a/tests/test_delegation_result_use.py b/tests/test_delegation_result_use.py index 8b97dc42e0..7dc5f1a275 100644 --- a/tests/test_delegation_result_use.py +++ b/tests/test_delegation_result_use.py @@ -28,7 +28,7 @@ def test_version_bound_retry_reads_existing_operation_after_input_changes(servic source = wait(runner) artifact = source["artifacts"][0] consumer_input = root / "reviewer/corrected/accepted-input.json" - consumer_input.write_text(artifact["text"]) + consumer_input.write_bytes(artifact["text"].encode("utf-8")) dependency = {"ref": "accepted-input.json", "description": "Accepted analysis for synthesis", "sha256": artifact["sha256"], "delegation": { "operation_id": "analysis-1", "ref": artifact["ref"], "relation": "uses"}} @@ -78,7 +78,7 @@ def test_result_use_requires_exact_accepted_input_and_survives_reconnect(service assert "adoptions" not in source and "dependencies" not in source artifact = source["artifacts"][0] consumer_input = root / "reviewer/corrected/accepted-input.json" - consumer_input.write_text(artifact["text"]) + consumer_input.write_bytes(artifact["text"].encode("utf-8")) dependency = {"ref": "accepted-input.json", "description": "Accepted analysis for synthesis", "sha256": artifact["sha256"], "delegation": { "operation_id": "analysis-1", "ref": artifact["ref"], "relation": "uses"}} @@ -138,7 +138,7 @@ async def reconnect_mcp(): assert runner.read("analysis-1")["adoptions"][0]["state"] == "unavailable" with pytest.raises(Exception, match="exact current uses input"): runner.adopt_result("analysis-1", "synthesis-1") - consumer_input.write_text(artifact["text"]) + consumer_input.write_bytes(artifact["text"].encode("utf-8")) assert runner.read("analysis-1")["adoptions"][0]["state"] == "current" (root / "reviewer/corrected/output.json").write_text("{}") assert runner.read("analysis-1")["adoptions"][0]["state"] == "unavailable" diff --git a/tests/test_delegation_task_revalidation.py b/tests/test_delegation_task_revalidation.py index a532c7e838..5a7c81465f 100644 --- a/tests/test_delegation_task_revalidation.py +++ b/tests/test_delegation_task_revalidation.py @@ -6,6 +6,7 @@ import subprocess from concurrent.futures import ThreadPoolExecutor from threading import Event +from pathlib import Path import pytest from mcp import ClientSession, StdioServerParameters @@ -189,7 +190,26 @@ def lose_response(binding, *args, **kwargs): getattr(restarted, entrypoint)("analysis-1") assert restarted.path("analysis-1").read_bytes() == frozen stop_path.unlink() # Remove only this synthetic injected fence. - getattr(restarted, entrypoint)("analysis-1") + if entrypoint == "resume": + async def resume_over_mcp(): + params = StdioServerParameters(command=sys.executable, + cwd=str(Path(__file__).resolve().parents[1]), env={ + key: os.environ[key] for key in ("NODE_OPTIONS", "TMPDIR", "TEMP", "TMP") + if key in os.environ}, args=["-m", "loopx.collaboration_mcp", + "--registry", str(restarted.registry), "--runtime-root", str(restarted.root), + "--goal-id", restarted.goal_id, "--agent-id", restarted.agent_id, + "--workspace", str(root / "lead"), "--execution-config", str(restarted.config)]) + async with stdio_client(params) as (reader, writer): + async with ClientSession(reader, writer) as session: + await session.initialize() + reply = await session.call_tool("resume_delegation", {"operation_id": "analysis-1"}) + assert not reply.isError, reply + # A previously committed result needs publication, not a + # new asynchronous Host. Closing this caller is safe now. + assert json.loads(reply.content[0].text)["status"] == "accepted" + asyncio.run(resume_over_mcp()) + else: + getattr(restarted, entrypoint)("analysis-1") accepted = wait(restarted) assert accepted["status"] == "accepted", accepted assert json.loads(restarted.path("analysis-1").read_text())["turn_key"] == turn_key diff --git a/tests/test_external_request_sidecars.py b/tests/test_external_request_sidecars.py new file mode 100644 index 0000000000..13fb578902 --- /dev/null +++ b/tests/test_external_request_sidecars.py @@ -0,0 +1,77 @@ +"""Request journals must not parse durable lock diagnostics as business rows.""" + +import json +import pytest +from loopx.capabilities.native_chat.external_conversations import ( + ChatExternalConversations, +) +from loopx.extensions.lark.private_conversations import LarkPrivateConversations +from loopx.file_lock import exclusive_file_lock +from test_chat_ordinary_project import ordinary # noqa: F401 +from test_lark_private_conversations import connect + + +@pytest.mark.parametrize("reader", ["core", "delivery", "health"]) +def test_request_readers_ignore_lock_diagnostics_and_keep_business_rows( + tmp_path, reader +): + request = tmp_path / ("a" * 24 + ".json") + row = { + "request_ref": "a" * 24, + "binding_id": "b" * 24, + "status": "accepted", + "deliveries": {}, + } + request.write_text(json.dumps(row), encoding="utf-8") + # A retained Windows sidecar is also readable on a later POSIX checkout. + request.with_name(request.name + ".lock.holder.json").write_text( + json.dumps({"schema_version": "file_lock_holder_v0", "pid": 1}), + encoding="utf-8", + ) + transport = object.__new__(LarkPrivateConversations) + transport.root = tmp_path + core = object.__new__(ChatExternalConversations) + core.root = tmp_path + with exclusive_file_lock(request, operation="request_journal_regression"): + if reader == "core": + assert core.pending() == [row] + elif reader == "delivery": + assert transport.pending_delivery_paths() == [request] + else: + assert transport.health() == { + "b" * 24: {"pending_count": 1, "recovery_count": 0} + } + assert ( + request.exists() + and request.with_name(request.name + ".lock.holder.json").exists() + ) + + +def test_private_status_admission_reconciles_original_native_request_with_sidecars( + ordinary, # noqa: F811 +): # noqa: F811 + store, runtime, provider, transport = connect(ordinary) + try: + event = provider.event("notes-app", "sidecar_status", "/status") + assert transport.admit("steward-app", event)["status"] == "audience_rejected" + assert transport.core.pending() == [] + admitted = transport.admit("notes-app", event) + assert admitted["status"] == "command_recorded" + requests = transport.core.pending() + assert len(requests) == 1 and requests[0]["command"] == "status" + original = requests[0]["request_ref"] + assert transport.health()[requests[0]["binding_id"]]["pending_count"] == 1 + assert transport.reconcile() == 1 + assert transport.pending_delivery_paths() == [] + assert ( + len(transport.core.pending()) == 1 + and transport.core.pending()[0]["request_ref"] == original + ) + assert store.list_sessions() == [] + before = list(provider.writes) + assert transport.reconcile() == 0 and provider.writes == before + assert not provider.calls or all( + "app-server" not in call for call in provider.calls + ) + finally: + runtime.close() diff --git a/tests/test_file_lock_windows_atomic.py b/tests/test_file_lock_windows_atomic.py new file mode 100644 index 0000000000..39e5105dba --- /dev/null +++ b/tests/test_file_lock_windows_atomic.py @@ -0,0 +1,146 @@ +"""Windows atomic holder publication with real file-sharing boundaries.""" +from contextlib import contextmanager +import ctypes as ct +from ctypes import wintypes as w +import errno +import json +import os +from pathlib import Path +import threading +import time +from types import SimpleNamespace + +import pytest +import loopx.file_lock as locks + +pytestmark = pytest.mark.skipif(os.name != 'nt', reason='Windows file sharing boundary') + +@contextmanager +def reader_without_delete_share(path): + kernel = ct.WinDLL('kernel32', use_last_error=True) + kernel.CreateFileW.argtypes = [w.LPCWSTR,w.DWORD,w.DWORD,ct.c_void_p,w.DWORD,w.DWORD,w.HANDLE] + kernel.CreateFileW.restype = w.HANDLE + kernel.CloseHandle.argtypes = [w.HANDLE] + handle = kernel.CreateFileW(str(path),0x80000000,0x3,None,3,0,None) + assert handle != ct.c_void_p(-1).value + closed = False + def close(): + nonlocal closed + if not closed: + closed = True + assert kernel.CloseHandle(handle) + try: + yield close + finally: + close() + +@contextmanager +def transient_reader(path, monkeypatch): + attempts = [] + first_attempt = threading.Event() + replace = os.replace + def observed_replace(source,destination): + if Path(destination) == path: + attempts.append(time.monotonic()) + first_attempt.set() + return replace(source,destination) + with reader_without_delete_share(path) as close: + def release(): + first_attempt.wait(2) + time.sleep(0.1) + close() + worker = threading.Thread(target=release) + worker.start() + monkeypatch.setattr(locks.os,'replace',observed_replace) + try: + yield attempts + finally: + first_attempt.set() + worker.join(3) + assert not worker.is_alive() + +def test_transient_reader_allows_atomic_holder_replace(tmp_path,monkeypatch): + path = tmp_path / "holder.json" + path.write_text(json.dumps({"sequence": 0})) + with transient_reader(path,monkeypatch) as attempts: + locks._write_holder_sidecar(path,{'sequence':1}) + assert len(attempts)>=2 + assert json.loads(path.read_text())=={'sequence':1} + assert not list(tmp_path.glob('*.tmp')) + +def test_transient_reader_preserves_real_kernel_lock_body(tmp_path,monkeypatch): + target=tmp_path/'state.json' + with locks.exclusive_file_lock(target): + pass + holder = locks.lock_holder_path(target) + effects = [] + with transient_reader(holder,monkeypatch): + with locks.exclusive_file_lock(target,agent_id='writer',operation='atomic-publish'): + effects.append('business') + assert effects==['business'] + assert json.loads(holder.read_text())['released_at'] + with locks.try_exclusive_file_lock(target) as acquired: + assert acquired is not None + + +def test_persistent_reader_keeps_old_bytes_and_propagates_denial(tmp_path): + path = tmp_path / "holder.json" + path.write_text(json.dumps({"sequence": 0})) + before = path.read_bytes() + with reader_without_delete_share(path): + start=time.monotonic() + with pytest.raises(OSError) as failure: + locks._write_holder_sidecar(path, {"sequence": 1}) + assert failure.value.winerror==5 and time.monotonic()-start<1.2 + assert path.read_bytes()==before + assert not list(tmp_path.glob('*.tmp')) + +def test_failed_holder_publication_releases_kernel_without_business(tmp_path): + target=tmp_path/'state.json' + with locks.exclusive_file_lock(target): + pass + holder = locks.lock_holder_path(target) + effects = [] + with reader_without_delete_share(holder): + start=time.monotonic() + with pytest.raises(OSError) as failure: + with locks.exclusive_file_lock(target): + effects.append("business") + assert failure.value.winerror==5 and time.monotonic()-start<2 + assert effects==[] + with locks.try_exclusive_file_lock(target) as acquired: + assert acquired is not None + assert not list(tmp_path.glob('*.tmp')) + +def test_unrelated_error_has_no_retry_or_fallback(tmp_path,monkeypatch): + attempts=[] + def fail(source,destination): + attempts.append(1) + raise FileNotFoundError(errno.ENOENT,'unrelated source error') + monkeypatch.setattr(locks.os,'replace',fail) + monkeypatch.setattr(locks.time,'sleep',lambda _:pytest.fail('unrelated error slept')) + with pytest.raises(FileNotFoundError): + locks._write_holder_sidecar(tmp_path / "holder.json", {"sequence": 1}) + assert attempts==[1] and not list(tmp_path.glob('*.tmp')) + +def test_successful_holder_publication_does_not_sleep(tmp_path,monkeypatch): + path = tmp_path / "holder.json" + path.write_text(json.dumps({"sequence": 0})) + monkeypatch.setattr(locks.time,'sleep',lambda _:pytest.fail('success slept')) + locks._write_holder_sidecar(path,{'sequence':1}) + assert json.loads(path.read_text())=={'sequence':1} + +def test_posix_adapter_permission_error_remains_immediate(tmp_path,monkeypatch): + attempts=[] + def fail(source,destination): + attempts.append(1) + error = PermissionError(errno.EACCES, "denied") + error.winerror = 5 + raise error + + adapter=SimpleNamespace(**{name:getattr(os,name) for name in ('fdopen','fsync')},name='posix',replace=fail) + monkeypatch.setattr(locks,'os',adapter) + monkeypatch.setattr(locks.time,'sleep',lambda _:pytest.fail('POSIX denial slept')) + with pytest.raises(PermissionError): + locks._write_holder_sidecar(tmp_path / "holder.json", {"sequence": 1}) + assert attempts==[1] and not list(tmp_path.glob('*.tmp')) diff --git a/tests/test_goal_mode_mcp_settlement.py b/tests/test_goal_mode_mcp_settlement.py index 3a8d84284d..c127c91372 100644 --- a/tests/test_goal_mode_mcp_settlement.py +++ b/tests/test_goal_mode_mcp_settlement.py @@ -171,7 +171,7 @@ def provider(args: list[str], *, legacy_args: list[str] | None = None) -> str: assert provider_calls[1][:2] == ["todo", "complete"] -def _write_fixture(tmp_path: Path) -> tuple[Path, Path]: +def _write_fixture(tmp_path: Path, *, provider: str = "file") -> tuple[Path, Path]: project = tmp_path / "project" project.mkdir() state_file = project / "ACTIVE_GOAL_STATE.md" @@ -218,7 +218,7 @@ def _write_fixture(tmp_path: Path) -> tuple[Path, Path]: "registered_agents": [AGENT_ID], "storage_target": { "schema_version": "loopx_new_goal_storage_target_v1", - "provider": "file", + "provider": provider, "handoff_mode": "soft_claim", }, }, @@ -255,6 +255,7 @@ def _run_cli(registry: Path, *args: str) -> tuple[int, dict[str, object]]: capture_output=True, text=True, ) + assert result.stdout.strip(), result.stderr return result.returncode, json.loads(result.stdout) @@ -275,6 +276,40 @@ def _control(registry: Path) -> GoalModeMCPControlPlane: return control + +def _complete_default_task(control, *args, **options): + """Fixture Agent consumes the actual returned post-result read, without opt-in.""" + from tests.control_plane.host_checkpoint_fixture import checkpoint_decision + output = control.complete_task(*args, **options) + first = json.loads(output) + if first.get("stage") != "direction_pending": + return output + assert first["completed"] is True and first["settlement_complete"] is False + decision = checkpoint_decision(first["context"]) + return control.complete_task(*args, **options, read_context_id=decision["read_context_id"], + agent_vision=decision.get("agent_vision"), + vision_unchanged_reason=decision.get("vision_unchanged_reason") or None) + + +def test_real_mcp_unconnected_delivery_is_rejected_before_settlement(tmp_path: Path) -> None: + registry, state_file = _write_fixture(tmp_path) + registration = json.loads(registry.read_text(encoding="utf-8")) + registration["goals"][0]["adapter"].pop("status") + registry.write_text(json.dumps(registration), encoding="utf-8") + added = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="agent", + text="Deliver the bounded fixture.", task_class="advancement_task", claimed_by=AGENT_ID) + before = state_file.read_bytes() + + result = json.loads(_control(registry).complete_task(added["todo_id"], AGENT_ID, "Verified fixture.")) + + assert result["ok"] is False + assert result["settlement"]["failed_stage"] == "guard" + assert "delivery_not_allowed" in result["settlement"]["reason"] + assert state_file.read_bytes() == before + index = registry.parent / "runtime" / "goals" / GOAL_ID / "runs" / "index.jsonl" + assert not index.exists() or not index.read_bytes() + + def test_real_mcp_settlement_rejects_missing_writeback_then_commits_same_identity( tmp_path: Path, ) -> None: @@ -344,7 +379,7 @@ def test_real_mcp_settlement_rejects_missing_writeback_then_commits_same_identit ) control = _control(registry) - output = control.complete_task( + output = _complete_default_task(control, todo_id, AGENT_ID, "focused fixture validation passed", @@ -387,7 +422,7 @@ def test_real_mcp_settlement_rejects_missing_writeback_then_commits_same_identit assert todo_by_id[str(successor_id)]["status"] == "open" replay = json.loads( - control.complete_task( + _complete_default_task(control, todo_id, AGENT_ID, "focused fixture validation passed", @@ -428,9 +463,10 @@ def test_real_mcp_terminal_completion_closes_out_after_spend( claimed_by=AGENT_ID, ) todo_id = str(added["todo_id"]) - + control = _control(registry) + assert json.loads(control.should_run())["delivery_result_context"] result = json.loads( - _control(registry).complete_task( + _complete_default_task(control, todo_id, AGENT_ID, "terminal fixture validation passed", @@ -489,7 +525,7 @@ def test_real_mcp_completion_recovers_a_lost_mutation_response( lost_after: str, ) -> None: """The real write commits, but its caller sees a failure: retry must not pay twice.""" - registry, _ = _write_fixture(tmp_path) + registry, state_file = _write_fixture(tmp_path) added = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, role="agent", text="Validate response-loss recovery.", task_class="advancement_task", @@ -497,12 +533,16 @@ def test_real_mcp_completion_recovers_a_lost_mutation_response( ) control = _control(registry) original = control.run_cli + assert json.loads(control.should_run())["delivery_result_context"] injected = False bound_identity = None + completion_args = None def lose_once(args, **kwargs): - nonlocal injected, bound_identity + nonlocal injected, bound_identity, completion_args output = original(args, **kwargs) + if completion_args is None and args[:2] == ["todo", "complete"] and json.loads(output).get("ok"): + completion_args = tuple(args) step = {"lifecycle": ["todo", "complete"], "writeback": ["refresh-state"], "spend": ["quota", "spend-slot"]}[lost_after] if not injected and args[:len(step)] == step and json.loads(output).get("ok"): @@ -512,10 +552,10 @@ def lose_once(args, **kwargs): return output control.run_cli = lose_once - first = json.loads(control.complete_task( + first = json.loads(_complete_default_task(control, added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, )) - assert injected and first["ok"] is False + assert injected and first["ok"] is False, first from loopx.control_plane.quota.settlement import read_heartbeat_settlement readback = read_heartbeat_settlement( tmp_path / "runtime", goal_id=GOAL_ID, agent_id=AGENT_ID, @@ -523,18 +563,34 @@ def lose_once(args, **kwargs): ) assert readback.replay_phase == ("settled" if lost_after == "spend" else "settlement_pending") if lost_after != "spend": + before_terminal = state_file.read_bytes() rc, terminal = _run_cli( registry, "todo", "complete", "--goal-id", GOAL_ID, "--todo-id", added["todo_id"], "--agent-id", AGENT_ID, "--turn-instance-id", bound_identity["turn_instance_id"], "--no-follow-up", "--evidence", "synthetic terminal intent", ) - assert rc != 0 and terminal["settlement_blocked_completion"] is True - replay = json.loads(control.complete_task( + # Reusing the operation with different evidence is a changed request, + # not a replay. Recover the actual original request below. + assert rc == 1 and terminal["error_code"] == "coordination_operation_identity_mismatch", terminal + assert state_file.read_bytes() == before_terminal + assert completion_args is not None + rc, terminal = _run_cli(registry, *completion_args) + # The exact original replay retains the deferred terminal intent and + # cannot close the Goal or rewrite accepted evidence. + assert rc == 0 and terminal["ok"] is True, terminal + todo = next(row for row in parse_active_state_todos( + state_file.read_text(encoding="utf-8"))["agent_todos"]["items"] + if row["todo_id"] == added["todo_id"]) + assert todo["status"] == "done" and todo["completion_continuation"] == "active_goal" + assert todo["completion_requested_continuation"] == "no_followup" + assert todo.get("no_followup") is not True and todo.get("completion_recovery") is None + assert state_file.read_bytes() == before_terminal + replay = json.loads(_complete_default_task(control, added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, )) assert replay["ok"] is True - again = json.loads(control.complete_task( + again = json.loads(_complete_default_task(control, added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, )) assert again["ok"] is True @@ -554,17 +610,28 @@ def test_real_mcp_links_existing_successor_without_creating_another_todo( claimed_by=AGENT_ID, )["todo_id"]) for text in ("First accepted step.", "Already planned follow-up.")] control = _control(registry) + initial = json.loads(control.should_run()) + assert initial["interaction_contract"]["agent_channel"]["selection_required"] is True, initial + rc, admitted = _run_cli(registry, "quota", "should-run", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--runtime-profile", "claude_code", "--todo-id", ids[0], + "--turn-instance-id", initial["heartbeat_receipt"]["turn_instance_id"]) + assert rc == 0, admitted + assert admitted["selected_todo"]["todo_id"] == ids[0], admitted + assert admitted.get("delivery_result_context"), admitted before = state_file.read_bytes() - rejected = json.loads(control.complete_task( + work = json.loads(control.should_run(revalidate_todo_id=ids[0])) + assert work.get("delivery_result_context"), work + assert work["delivery_result_context"]["settlement_identity"] == admitted["delivery_result_context"]["settlement_identity"] + rejected = json.loads(_complete_default_task(control, ids[0], AGENT_ID, "synthetic check passed", successor_todo_ids=[ids[1]], no_follow_up=True, )) assert rejected["ok"] is False assert state_file.read_bytes() == before - first = json.loads(control.complete_task( + first = json.loads(_complete_default_task(control, ids[0], AGENT_ID, "synthetic check passed", successor_todo_ids=[ids[1]], )) assert first["ok"] is True, first - replay = json.loads(control.complete_task( + replay = json.loads(_complete_default_task(control, ids[0], AGENT_ID, "synthetic check passed", successor_todo_ids=[ids[1]], )) assert replay["ok"] is True diff --git a/tests/test_goal_mode_mcp_wait.py b/tests/test_goal_mode_mcp_wait.py new file mode 100644 index 0000000000..767d97aeab --- /dev/null +++ b/tests/test_goal_mode_mcp_wait.py @@ -0,0 +1,155 @@ +from __future__ import annotations +import copy +import json +from dataclasses import replace +import pytest +from loopx.control_plane.host_adapter_settlement import ( + HostTodoSettlementRequest, + _request_payload, + _runtime_reduction, + settle_host_todo_wait, +) +from loopx.goal_mode_mcp import GoalModeMCPConfig, GoalModeMCPControlPlane + + +def request(): + return HostTodoSettlementRequest( + goal_id="wait-goal", + agent_id="claude", + todo_id="todo_wait01", + runtime_profile="claude_code", + legacy_host_surface="claude_code", + scheduler_owner="agent_cli_loop", + execution_mode="interactive", + completion_args=(), + turn_instance_id="original-work", + ) + + +def guard(): + r = request() + identity = { + "schema_version": "quota_settlement_identity_v0", + "goal_id": r.goal_id, + "agent_id": r.agent_id, + "todo_id": r.todo_id, + "turn_instance_id": r.turn_instance_id, + "effect_id": "wait-goal:claude:todo_wait01:original-work", + } + return { + "ok": True, + "should_run": True, + "normal_delivery_allowed": True, + "work_input_restored": True, + "selected_todo": {"todo_id": r.todo_id, "status": "open"}, + "heartbeat_receipt": {"settlement_identity": identity}, + } + + +def test_native_wait_executes_only_current_original_guard(): + calls = [] + + def cli(args, **kwargs): + calls.append(args) + assert args[:2] == ["quota", "should-run"] and "original-work" in args + assert "spend-slot" not in args and "complete" not in args + return json.dumps(guard()) + + r = json.loads(settle_host_todo_wait(request(), "Dependency pending.", run_cli=cli)) + assert ( + len(calls) == 1 + and r["ok"] + and not r["completed"] + and not r["settlement_complete"] + and r["result_kind"] == "wait" + ) + assert r["effects"] == { + "host_invoked": False, + "todo_completed": False, + "state_written": False, + "quota_spent": False, + } + assert ( + r["task_validation"]["status"] == "not_required" + and r["settlement_identity"]["turn_instance_id"] == "original-work" + ) + + +@pytest.mark.parametrize( + "fault", + [ + "stop", + "permission", + "unrestored", + "done", + "foreign_todo", + "foreign_turn", + "foreign_agent", + "malformed", + ], +) +def test_native_wait_refuses_unadmitted_or_replaced_provider_facts(fault): + g = copy.deepcopy(guard()) + if fault == "stop": + g["should_run"] = False + elif fault == "permission": + g["normal_delivery_allowed"] = False + elif fault == "unrestored": + g["work_input_restored"] = False + elif fault == "done": + g["selected_todo"]["status"] = "done" + elif fault == "foreign_todo": + g["selected_todo"]["todo_id"] = "todo_other01" + elif fault in ["foreign_turn", "foreign_agent"]: + i = g["heartbeat_receipt"]["settlement_identity"] + i["turn_instance_id" if fault == "foreign_turn" else "agent_id"] = "other" + i["effect_id"] = ":".join( + i[k] for k in ["goal_id", "agent_id", "todo_id", "turn_instance_id"] + ) + p = { + **_request_payload(request(), phase="wait"), + "wait_reason": "Dependency pending.", + "guard_output": "broken JSON" if fault == "malformed" else json.dumps(g), + } + assert _runtime_reduction(p, phase="wait")["result"]["ok"] is False + + +@pytest.mark.parametrize( + "field,value", + [ + ("completion_args", ["todo", "complete"]), + ("no_follow_up", True), + ("vision_unchanged_reason", "Old route."), + ("wait_reason", "x" * 241), + ], +) +def test_wait_cannot_smuggle_completion_or_judgment(field, value): + p = {**_request_payload(request(), phase="wait"), "wait_reason": "Pending."} + p[field] = value + with pytest.raises(ValueError): + _runtime_reduction(p, phase="wait") + + +def test_wait_does_not_allocate_a_turn_or_cross_cli_without_admission(): + r = request() + r = replace(r, turn_instance_id=None) + + def cli(*a, **kw): + pytest.fail("unadmitted wait invoked CLI") + + assert not json.loads(settle_host_todo_wait(r, "Pending.", run_cli=cli))["ok"] + + +def test_wait_keeps_bound_agent_fence_before_transport(monkeypatch): + c = GoalModeMCPControlPlane( + GoalModeMCPConfig( + server_name="wait", + runtime_profile="claude_code", + legacy_host_surface="claude_code", + ), + lambda: {"goal_id": "wait-goal", "registry": "unused", "agent_id": "claude"}, + ) + monkeypatch.setattr( + c, "run_cli", lambda *a, **kw: pytest.fail("wrong Agent invoked CLI") + ) + assert not json.loads(c.wait_task("todo_wait01", "foreign", "Pending."))["ok"] diff --git a/tests/test_inbox_receipt_recovery.py b/tests/test_inbox_receipt_recovery.py index f280edc292..60cd399799 100644 --- a/tests/test_inbox_receipt_recovery.py +++ b/tests/test_inbox_receipt_recovery.py @@ -12,6 +12,31 @@ from loopx.capabilities.manager_context.roundtrip import report from loopx.control_plane.collaboration.inbox import acknowledge, pending from loopx.control_plane.collaboration.peers import read_inbox, request +from loopx.control_plane.runtime.file_paths import windows_extended_path + + +def chat_turn_records(store, session_id): + from loopx.chat_store import CHAT_TURN_SCHEMA_VERSION + + records = [json.loads(path.read_text(encoding="utf-8")) + for path in (store.sessions_root / session_id / "turns").glob("*.json")] + # Windows lock holder diagnostics also end in .json; count business Turns. + return [row for row in records if row.get("schema_version") == CHAT_TURN_SCHEMA_VERSION] + + +def test_chat_turn_count_detects_a_real_additional_turn(tmp_path): + from loopx.chat_store import ChatSessionStore + + store = ChatSessionStore(tmp_path) + session = store.create_session(goal_id="loopx-manager", agent_id="codex", + adapter_kind="codex_app_server", upstream_thread_id="fixture", channel_id="manager") + first, _ = store.create_turn(session["session_id"], client_turn_id="first", message="First", origin="web") + store.update_turn(session["session_id"], first["turn_id"], status="completed") + store.finalize_managed_turn_completion(session["session_id"], first["turn_id"]) + second, _ = store.create_turn(session["session_id"], client_turn_id="second", message="Second", origin="web") + records = chat_turn_records(store, session["session_id"]) + assert {row["turn_id"] for row in records} == {first["turn_id"], second["turn_id"]} + assert len(records) == 2 @pytest.fixture @@ -79,7 +104,7 @@ def test_legal_unicode_results_cannot_overflow_the_existing_receipt_bridge(excha from loopx.control_plane.collaboration.inbox import _write root, _, rid, healthy = exchange - entry = next((root / ".local/manager-context/entries").glob(f"*/{rid}.json")) + entry = next(windows_extended_path(root / ".local/manager-context/entries").glob(f"*/{rid}.json")) request_row = json.loads(entry.read_text()) decision = json.loads((root / ".local/manager-context/decisions" / (rid + ".json")).read_text()) result = json.loads((root / ".local/manager-context/replies" / rid / "conclusion.json").read_text()) @@ -175,7 +200,7 @@ def no_external_send(*args, **kwargs): returned = [item for item in after["messages"] if item.get("origin") == "manager_followup"] assert len(returned) == 1 and returned[0]["turn_id"] == turn["turn_id"] assert returned[0]["return_delivery"]["status"] == "delivered" - assert len(list((store.sessions_root / session["session_id"] / "turns").glob("*.json"))) == 1 + assert [row["turn_id"] for row in chat_turn_records(store, session["session_id"])] == [turn["turn_id"]] finally: server.shutdown() server.server_close() diff --git a/tests/test_kunluncode_goal_mode.py b/tests/test_kunluncode_goal_mode.py index 576c0fea84..531f951915 100644 --- a/tests/test_kunluncode_goal_mode.py +++ b/tests/test_kunluncode_goal_mode.py @@ -1249,8 +1249,10 @@ def test_strict_native_goal_requires_verification_passed_event() -> None: ) +@pytest.mark.parametrize("first_delivery", [False, True]) def test_native_controller_blocks_model_visible_mcp_mutations( monkeypatch: pytest.MonkeyPatch, + first_delivery: bool, ) -> None: server, control = create_fastmcp_server( GoalModeMCPConfig( @@ -1283,6 +1285,9 @@ def test_native_controller_blocks_model_visible_mcp_mutations( "evidence": "premature evidence", "task_lease_idempotency_key": "lease-fixture", "task_lease_expected_version": 7, + "first_delivery": first_delivery, + "delivery_read_context_id": "original-result", + "read_context_id": "original-direction", }, ) ) diff --git a/tests/test_local_delegation.py b/tests/test_local_delegation.py index 0bfa0250ae..b888aa2aaa 100644 --- a/tests/test_local_delegation.py +++ b/tests/test_local_delegation.py @@ -29,14 +29,18 @@ from loopx.control_plane.turn_driver.lane_fence import turn_lane_liveness, turn_lane_singleflight # noqa: E402 from loopx.file_lock import exclusive_file_lock, try_exclusive_file_lock # noqa: E402 from loopx.file_lock import lock_holder_host_label # noqa: E402 +from tests.control_plane.host_checkpoint_fixture import checkpoint_fixture_script # noqa: E402 -HOST = '''import json, os, sys, time +HOST = checkpoint_fixture_script('''import json, os, sys, time from pathlib import Path +from tests.control_plane.host_checkpoint_fixture import reply_to_checkpoint from loopx.control_plane.turn_driver.host_candidate import build_result from loopx.control_plane.collaboration.inbox import acknowledge from loopx.control_plane.collaboration.peers import return_result request = json.load(sys.stdin) +if reply_to_checkpoint(request): + raise SystemExit(0) workspace = Path.cwd() root = Path(sys.argv[1]) envelope = request['turn_envelope'] @@ -57,7 +61,7 @@ acknowledge(root / 'runtime', envelope['goal_id'], actor, delegation['request_id'], 'adopt', 'Independently checked the requested scope.') return_result(root / 'runtime', envelope['goal_id'], actor, delegation['request_id'], 'Independent member conclusion; host acceptance is separate.') print(json.dumps(build_result(request, {'result_kind':'validated_progress', 'classification':'artifact_written', 'summary':'Fixture host supplied output for independent verification.', 'next_action':'Return verified evidence.', 'work_evidence':['artifact:delegation-output'], 'validation_evidence':['check:independent-member-result']}, host_name='Fixture'))) -''' +''') @pytest.fixture(params=["file", "sqlite"]) @@ -293,9 +297,9 @@ def test_confirmed_wake_reaches_native_acceptance_through_real_delegation_and_tu f"counter = Path({str(native_starts)!r})\n" "counter.write_text(str(int(counter.read_text()) + 1 if counter.exists() else 1))\n" ) - executable.write_text(FAKE_SERVER.replace('thread = "owned-app-server-thread"', - startup_probe + 'thread = "owned-app-server-thread"')) - executable.chmod(0o700) + from tests.control_plane.host_process_fixture import write_python_host_fixture + executable = write_python_host_fixture(executable, FAKE_SERVER.replace( + 'thread = "owned-app-server-thread"', startup_probe + 'thread = "owned-app-server-thread"')) config = json.loads(runner.config.read_text()) binding = config["bindings"][0] binding["host_args"] = ["--host", "codex-cli", "--codex-bin", str(executable), diff --git a/tests/test_local_state_migration.py b/tests/test_local_state_migration.py index a110f904a0..ea7d7018fc 100644 --- a/tests/test_local_state_migration.py +++ b/tests/test_local_state_migration.py @@ -4,6 +4,7 @@ import hashlib import json import os +import shlex import stat import subprocess import sys @@ -24,6 +25,13 @@ rollback_local_state_migration, ) from loopx.project_prompt import build_new_project_prompt +from loopx.control_plane.runtime.file_paths import windows_extended_path + + +def _runtime_root_argument(command: str) -> str: + argv = shlex.split(command) + assert argv.count("--runtime-root") == 1 + return argv[argv.index("--runtime-root") + 1] def _write_json(path: Path, payload: dict[str, object]) -> None: @@ -386,7 +394,7 @@ def test_doctor_reads_legacy_capture_hosts_from_selected_runtime( tmp_path: Path, ) -> None: source, _target, _projects = _fixture(tmp_path, projects=1) - env = dict(os.environ, HOME=str(source.parents[1]), LOOPX_USAGE_PING="0") + env = dict(os.environ, HOME=str(source.parents[1]), USERPROFILE=str(source.parents[1]), LOOPX_USAGE_PING="0") env["PYTHONPATH"] = str(Path(__file__).resolve().parents[1]) completed = subprocess.run( [sys.executable, "-m", "loopx.cli", "--format", "json", "doctor"], @@ -460,9 +468,9 @@ def test_existing_project_prompt_keeps_registered_goal_and_runtime_routes(tmp_pa allowed_domains=None, write_scope=None, ) - assert f"--runtime-root {source}" in payload["quota_guard_command"] - assert f"--runtime-root {source}" in payload["quota_spend_command"] - assert f"--runtime-root {source}" in payload["connect_command"] + assert _runtime_root_argument(payload["quota_guard_command"]) == str(source) + assert _runtime_root_argument(payload["quota_spend_command"]) == str(source) + assert _runtime_root_argument(payload["connect_command"]) == str(source) assert ".codex/goals/goal-0/ACTIVE_GOAL_STATE.md" in payload["prompt"] @@ -545,7 +553,7 @@ def test_strict_project_registry_keeps_legacy_route_and_wire_format(tmp_path: Pa adapter_kind="read_only_project_map_v0", adapter_status="connected-read-only", next_probe=None, spawn_allowed=False, allowed_domains=None, write_scope=None, ) - assert f"--runtime-root {source}" in prompt["quota_guard_command"] + assert _runtime_root_argument(prompt["quota_guard_command"]) == str(source) assert ".codex/goals/goal-0/ACTIVE_GOAL_STATE.md" in prompt["prompt"] preview = migrate_local_state(source_runtime_root=source, target_runtime_root=target) @@ -849,7 +857,7 @@ def test_preview_execute_and_verified_rollback_cover_all_registered_projects(tmp execute=True, ) receipt_path = Path(receipt["backup_dir"]) / RECEIPT_NAME - assert receipt_path.exists() + assert windows_extended_path(receipt_path).is_file() assert not source.exists() assert _read(target / "registry.global.json")["common_runtime_root"] == str(target) for index, project in enumerate(projects): @@ -932,7 +940,7 @@ def fail_rename_once(self: Path, destination: Path) -> Path: def fail_copy_once(original: Path, destination: Path, **kwargs: object) -> Path: nonlocal failed selected = backup / "snapshot" / ("0/registry.global.json" if failed_kind == "global-registry" else "1") - if not failed and failed_kind in {"registry", "global-registry"} and Path(original) == selected: + if not failed and failed_kind in {"registry", "global-registry"} and migration.windows_extended_path(Path(original)) == migration.windows_extended_path(selected): failed = True raise OSError(5, "synthetic rollback I/O failure") return original_copy(original, destination, **kwargs) @@ -989,17 +997,17 @@ def test_hard_interruption_has_a_recoverable_receipt( interrupt = Path(sys.argv.pop(1)) def rename(self, destination): result = original(self, destination) - if self == interrupt: + if migration.windows_extended_path(self) == migration.windows_extended_path(interrupt): os._exit(137) return result def replace(self, destination): result = original_replace(self, destination) - if Path(destination) == interrupt: + if migration.windows_extended_path(Path(destination)) == migration.windows_extended_path(interrupt): os._exit(137) return result def project_write(path, payload): original_project_write(path, payload) - if path == interrupt: + if migration.windows_extended_path(path) == migration.windows_extended_path(interrupt): os._exit(137) Path.rename = rename Path.replace = replace @@ -1079,7 +1087,7 @@ def test_registry_recovery_rechecks_backup_before_replacement( original_copy = migration.shutil.copy2 def corrupt_before_copy(original: Path, destination: Path, **kwargs: object) -> Path: - if Path(original) == snapshot: + if migration.windows_extended_path(Path(original)) == migration.windows_extended_path(snapshot): snapshot.write_text("corrupted backup\n", encoding="utf-8") return original_copy(original, destination, **kwargs) @@ -1263,7 +1271,7 @@ def inject_after_backup_dir( ) -> None: nonlocal redirected original_mkdir(self, mode=mode, parents=parents, exist_ok=exist_ok) - if self == backup: + if migration.windows_extended_path(self) == migration.windows_extended_path(backup): redirected = True monkeypatch.setattr(migration, "_is_redirected_path", classify) @@ -1613,6 +1621,7 @@ def test_custom_state_keeps_its_declared_file_through_migration_and_rollback( source, target, project, custom = _custom_state_fixture(tmp_path) local_registry = project / ".loopx" / "registry.json" before = local_registry.read_bytes() + declared = load_project_registry(local_registry)["goals"][0]["state_file"] preview = migrate_local_state(source_runtime_root=source, target_runtime_root=target) assert preview["goal_directory_count"] == 0 receipt = migrate_local_state( @@ -1621,7 +1630,7 @@ def test_custom_state_keeps_its_declared_file_through_migration_and_rollback( ) migrated = load_project_registry(local_registry) assert migrated["common_runtime_root"] == str(target) - assert migrated["goals"][0]["state_file"] == "custom/STATE.md" + assert migrated["goals"][0]["state_file"] == declared assert custom.read_text(encoding="utf-8") == "custom state\n" rollback_local_state_migration(Path(receipt["backup_dir"]) / RECEIPT_NAME, execute=True) assert local_registry.read_bytes() == before diff --git a/tests/test_local_state_migration_path_text.py b/tests/test_local_state_migration_path_text.py new file mode 100644 index 0000000000..c1f278e2f0 --- /dev/null +++ b/tests/test_local_state_migration_path_text.py @@ -0,0 +1,73 @@ +"""Migration changes owned routes while preserving declared path text.""" + +import json +from pathlib import Path + +import pytest + +from tests import test_local_state_migration as migration + + +def _set_declaration(source, project, text, strict): + local = project / ".loopx" / "registry.json" + for registry in (local, source / "registry.global.json"): + payload = migration._read(registry) + payload["goals"][0]["state_file"] = text + if registry == local and strict: + migration._write_strict_project_registry(registry, payload) + else: + migration._write_json(registry, payload) + return local + + +@pytest.mark.parametrize("strict", [False, True]) +@pytest.mark.parametrize("declaration", ["portable-relative", "native-relative", "absolute"]) +def test_owned_migration_path_text_and_exact_rollback(tmp_path, strict, declaration): + source, target, projects = migration._fixture(tmp_path, projects=1) + project = projects[0] + relative = Path(".codex") / "goals" / "goal-0" / "ACTIVE_GOAL_STATE.md" + text = { + "portable-relative": ".codex/goals/goal-0/ACTIVE_GOAL_STATE.md", + "native-relative": str(relative), + "absolute": str(project / relative), + }[declaration] + local = _set_declaration(source, project, text, strict) + original_local, original_global = local.read_bytes(), (source / "registry.global.json").read_bytes() + preview = migration.migrate_local_state(source_runtime_root=source, target_runtime_root=target) + assert local.read_bytes() == original_local and (source / "registry.global.json").read_bytes() == original_global + receipt = migration.migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + expected_plan_id=preview["plan_id"], execute=True, + ) + expected = str(project / ".loopx" / "goals" / "goal-0" / "ACTIVE_GOAL_STATE.md") if declaration == "absolute" else ".loopx/goals/goal-0/ACTIVE_GOAL_STATE.md" + for registry in (local, target / "registry.global.json"): + payload = migration.load_project_registry(registry) + assert payload["goals"][0]["state_file"] == expected + assert payload["common_runtime_root"] == str(target) + assert isinstance(json.loads(local.read_text()), list) == strict + assert (project / ".loopx" / "goals" / "goal-0" / "ACTIVE_GOAL_STATE.md").read_text() == "# goal-0\n" + migration.rollback_local_state_migration(Path(receipt["backup_dir"]) / migration.RECEIPT_NAME, execute=True) + assert local.read_bytes() == original_local + assert (source / "registry.global.json").read_bytes() == original_global + assert (project / relative).read_text() == "# goal-0\n" and not target.exists() + + +@pytest.mark.parametrize("strict", [False, True]) +@pytest.mark.parametrize("native_text", [False, True]) +def test_custom_path_keeps_exact_declared_text(tmp_path, strict, native_text): + source, target, project, custom = migration._custom_state_fixture(tmp_path) + text = str(Path("custom") / "STATE.md") if native_text else "custom/STATE.md" + local = _set_declaration(source, project, text, strict) + original_local, original_global = local.read_bytes(), (source / "registry.global.json").read_bytes() + preview = migration.migrate_local_state(source_runtime_root=source, target_runtime_root=target) + assert preview["goal_directory_count"] == 0 + receipt = migration.migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + expected_plan_id=preview["plan_id"], execute=True, + ) + for registry in (local, target / "registry.global.json"): + assert migration.load_project_registry(registry)["goals"][0]["state_file"] == text + assert custom.read_text() == "custom state\n" + migration.rollback_local_state_migration(Path(receipt["backup_dir"]) / migration.RECEIPT_NAME, execute=True) + assert local.read_bytes() == original_local and (source / "registry.global.json").read_bytes() == original_global + assert custom.read_text() == "custom state\n" and not target.exists() diff --git a/tests/test_loopx_turn_driver.py b/tests/test_loopx_turn_driver.py index 21716da259..2dbaadb442 100644 --- a/tests/test_loopx_turn_driver.py +++ b/tests/test_loopx_turn_driver.py @@ -17,6 +17,10 @@ from tests.control_plane.canonical_authority_fixture import ( initialize_canonical_authority, ) +from tests.control_plane.host_checkpoint_fixture import ( + checkpoint_codex_fixture_response, + checkpoint_fixture_script, +) from loopx.cli import main as cli_main from loopx.control_plane.coordination.runtime_shadow import ( build_todo_runtime_shadow_projection, @@ -1029,6 +1033,7 @@ def test_default_agent_session_reuses_planning_across_completed_todos(tmp_path, from benchmark.runtime.codex import Execution from benchmark.runtime.sessions import BenchmarkSessionWake from tests.test_loopx_turn_codex_cli import _fake_codex + from tests.control_plane.host_process_fixture import write_python_host_fixture project, runtime, registry = _write_live_fixture(tmp_path, extra_agent_todo_lines=( "- [ ] [P1] Complete the successor fixture.", @@ -1049,8 +1054,20 @@ def test_default_agent_session_reuses_planning_across_completed_todos(tmp_path, observed = tmp_path / "planning-stream.jsonl" observed.write_text('{"type":"thread.started","thread_id":"session-fixture-0001"}\n') wake.observe(observed) - binary.write_text(binary.read_text().replace('"validated_progress"', '"validated_completion"').replace( - 'output_path = ', 'pathlib.Path("completed-todo.txt").write_text("complete")\noutput_path = ', + script = binary.with_suffix(".py") if os.name == "nt" else binary + direction = ( + f"sys.path.insert(0, {str(Path(__file__).resolve().parents[1])!r})\n" + "from tests.control_plane.host_checkpoint_fixture import checkpoint_decision\n" + "from loopx.control_plane.turn_driver.host_candidate import parse_model_json\n" + "request = parse_model_json(prompt) or {}\n" + "if request.get('phase') == 'checkpoint_direction':\n" + " output = pathlib.Path(args[args.index('--output-last-message') + 1])\n" + " output.write_text(json.dumps(checkpoint_decision(request['checkpoint_context'])))\n" + " raise SystemExit(0)\n" + 'pathlib.Path("completed-todo.txt").write_text("complete")\n' + ) + binary = write_python_host_fixture(tmp_path / "fake-codex", script.read_text().replace('"validated_progress"', '"validated_completion"').replace( + 'output_path = ', direction + 'output_path = ', )) argv = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", "turn", "run-once", "--goal-id", "loopx-turn-fixture", "--agent-id", "codex-fixture", @@ -1070,7 +1087,7 @@ def test_default_agent_session_reuses_planning_across_completed_todos(tmp_path, assert result["status"] == "committed", result results.append(result) calls = [json.loads(line) for line in log.read_text().splitlines()] - assert len(calls) == 2 and all("resume" in call and "session-fixture-0001" in call for call in calls) + assert len(calls) == 4 and all("resume" in call and "session-fixture-0001" in call for call in calls) assert results[0]["resume_turn_key"] != results[1]["resume_turn_key"] journals = [json.loads(path.read_text()) for path in (runtime / "goals/loopx-turn-fixture/turns").glob("*.json")] assert {journal["writeback"]["completion"]["todo_id"] for journal in journals} == { @@ -2067,11 +2084,14 @@ def interrupt_checkpoint(path, journal): assert policy_code == 0, policy_output.getvalue() host_project = project / "isolated-host-workspace" host_project.mkdir() - host_script = """ + host_script = checkpoint_fixture_script(""" import json import pathlib import sys +from tests.control_plane.host_checkpoint_fixture import reply_to_checkpoint request = json.load(sys.stdin) +if reply_to_checkpoint(request): + raise SystemExit(0) pathlib.Path("fixture-artifact.txt").write_text("validated", encoding="utf-8") json.dump({ "schema_version": "loopx_turn_result_v0", @@ -2086,7 +2106,9 @@ def interrupt_checkpoint(path, journal): "vision_unchanged_reason": "The fixture objective remains unchanged.", "summary": "One public fixture advanced." }, sys.stdout) -""" +""") + host_path = host_project / "progress-host.py" + host_path.write_text(host_script, encoding="utf-8") validation_script = """ import json import pathlib @@ -2116,7 +2138,7 @@ def interrupt_checkpoint(path, journal): "--project", str(host_project), "--host-adapter-command-json", - json.dumps([sys.executable, "-c", host_script]), + json.dumps([sys.executable, str(host_path)]), "--validation-command-json", json.dumps([sys.executable, "-c", validation_script]), "--no-global-sync", @@ -2187,9 +2209,12 @@ def interrupt_checkpoint(path, journal): index_path = runtime / "goals" / "loopx-turn-fixture" / "runs" / "index.jsonl" rows = [json.loads(line) for line in index_path.read_text(encoding="utf-8").splitlines()] assert [row["classification"] for row in rows] == [ + "fixture_progress", "fixture_progress", "quota_slot_spent", ] + assert rows[0]["vision_checkpoint"]["decision"] == "missing_required" + assert rows[1]["vision_checkpoint"]["satisfied"] is True assert rows[0]["recommended_action"] == "Continue the public fixture" assert not rows[0]["recommended_action_resolution"].get("step_revision") @@ -2214,7 +2239,7 @@ def interrupt_checkpoint(path, journal): "--project", str(host_project), "--host-command-json", - json.dumps([sys.executable, "-c", host_script]), + json.dumps([sys.executable, str(host_path)]), "--validation-command-json", json.dumps([sys.executable, "-c", validation_script]), "--no-global-sync", @@ -2237,10 +2262,7 @@ def interrupt_checkpoint(path, journal): json.loads(line) for line in index_path.read_text(encoding="utf-8").splitlines() ] - assert [row["classification"] for row in replayed_rows] == [ - "fixture_progress", - "quota_slot_spent", - ] + assert replayed_rows == rows next_output = io.StringIO() with contextlib.redirect_stdout(next_output): @@ -2249,7 +2271,7 @@ def interrupt_checkpoint(path, journal): "turn", "run-once", "--host", "generic-cli", "--goal-id", "loopx-turn-fixture", "--agent-id", "codex-fixture", "--turn-instance-id", "next-cadence-fixture", "--project", str(host_project), "--host-command-json", - json.dumps([sys.executable, "-c", host_script]), "--validation-command-json", + json.dumps([sys.executable, str(host_path)]), "--validation-command-json", json.dumps([sys.executable, "-c", validation_script]), "--no-global-sync", "--execute", ]) @@ -2260,9 +2282,7 @@ def interrupt_checkpoint(path, journal): assert waiting["effects"]["host_invoked"] is False assert waiting["effects"]["quota_spent"] is False assert "- next_eligible_at: " in render_loopx_turn_execution_markdown(waiting) - assert [json.loads(line)["classification"] for line in index_path.read_text(encoding="utf-8").splitlines()] == [ - "fixture_progress", "quota_slot_spent", - ] + assert [json.loads(line) for line in index_path.read_text(encoding="utf-8").splitlines()] == rows def test_turn_run_once_cli_dsh_fresh_sessions_and_iteration_failure( @@ -2395,9 +2415,18 @@ def test_turn_run_once_cli_dsh_preserves_material_path_delta( } runner = tmp_path / "material_replan_dsh_runner.py" runner.write_text( - "import json\nfrom pathlib import Path\n" + "import json, sys\nfrom pathlib import Path\n" + f"sys.path.insert(0, {str(Path(__file__).resolve().parents[1])!r})\n" + "from tests.control_plane.host_checkpoint_fixture import checkpoint_decision\n" + "from loopx.control_plane.turn_driver.host_candidate import parse_model_json\n" f"candidate = {candidate!r}\n" "def run_dsh_turn(**kwargs):\n" + " request = parse_model_json(kwargs['prompt']) or {}\n" + " if request.get('phase') == 'checkpoint_direction':\n" + " decision = checkpoint_decision(request['checkpoint_context'])\n" + " decision.pop('agent_vision', None)\n" + " decision.update(agent_vision_json=candidate['agent_vision_json'], vision_unchanged_reason='')\n" + " return json.dumps(decision)\n" " counter = Path(kwargs['workspace']) / 'host-count.txt'\n" " counter.write_text(str(int(counter.read_text()) + 1 if counter.exists() else 1))\n" " Path(kwargs['workspace'], 'validated-artifact.txt').write_text('validated')\n" @@ -2437,8 +2466,11 @@ def test_turn_run_once_cli_dsh_preserves_material_path_delta( assert payload["effects"]["quota_spent"] is True rows_path = runtime / "goals/loopx-turn-fixture/runs/index.jsonl" rows = [json.loads(line) for line in rows_path.read_text(encoding="utf-8").splitlines()] - durable = next(row for row in rows if row.get("classification") == "fixture_replan_successor") - recorded = json.loads(Path(durable["json_path"]).read_text(encoding="utf-8")) + durable = [row for row in rows if row.get("classification") == "fixture_replan_successor"] + assert len(durable) == 2 + assert durable[0]["vision_checkpoint"]["decision"] == "missing_required" + assert durable[1]["vision_checkpoint"]["satisfied"] is True + recorded = json.loads(Path(durable[1]["json_path"]).read_text(encoding="utf-8")) assert recorded["agent_vision"]["path_delta"]["outcome"] == "replan" @@ -2453,62 +2485,14 @@ def test_turn_run_once_cli_completes_selected_todo_after_validation( project, runtime, registry = _write_live_fixture(tmp_path) host_project = project / "isolated-host-workspace" host_project.mkdir() - host_script = """ -import json -import pathlib -import sys -request = json.load(sys.stdin) -pathlib.Path("completion-artifact.txt").write_text("completed", encoding="utf-8") -json.dump({ - "schema_version": "loopx_turn_result_v0", - "turn_key": request["turn_key"], - "result_kind": "validated_completion", - "completed_phases": ["host_execute", "typed_result"], - "classification": "fixture_completion", - "recommended_action": "Refresh the active goal after completion.", - "next_action": "Select the next Todo from a fresh decision.", - "delivery_batch_scale": "implementation", - "delivery_outcome": "outcome_progress", - "vision_unchanged_reason": "The active goal may have further work.", - "summary": "One public fixture completed." -}, sys.stdout) -""" + host_script, validation_script = _completion_host_and_validation_scripts() host_script = host_script.replace( "Select the next Todo from a fresh decision.", next_action, ) - validation_script = """ -import json -import pathlib -import sys -json.load(sys.stdin) -artifact = pathlib.Path("completion-artifact.txt") -raise SystemExit(0 if artifact.read_text(encoding="utf-8") == "completed" else 7) -""" output = io.StringIO() - argv = [ - "--registry", - str(registry), - "--runtime-root", - str(runtime), - "--format", - "json", - "turn", - "run-once", - "--host", - "generic-cli", - "--goal-id", - "loopx-turn-fixture", - "--agent-id", - "codex-fixture", - "--project", - str(host_project), - "--host-adapter-command-json", - json.dumps([sys.executable, "-c", host_script]), - "--validation-command-json", - json.dumps([sys.executable, "-c", validation_script]), - "--no-global-sync", - "--execute", - ] + argv = _turn_run_once_completion_argv( + host_project, runtime, registry, host_script, validation_script, + ) with contextlib.redirect_stdout(output): exit_code = cli_main(argv) @@ -2609,6 +2593,19 @@ def test_turn_run_once_cli_completes_selected_todo_after_validation( assert replayed_exit_code == 0, replayed assert replayed["replayed"] is True assert not any(replayed["effects"].values()) + calls = [json.loads(line) for line in (host_project / "host-calls.jsonl").read_text().splitlines()] + assert len(calls) == 2 + assert calls[0]["phase"] != "checkpoint_direction" + assert calls[1]["phase"] == "checkpoint_direction" + assert calls[1]["turn_key"] == calls[0]["turn_key"] == payload["resume_turn_key"] + assert calls[1]["session_action"] == "resume" + assert calls[1]["read_context_id"] + assert (host_project / "validation-calls.txt").read_text().splitlines() == ["validated"] + quota_events = [json.loads(line) for line in ( + runtime / "goals" / "loopx-turn-fixture" / "rollout-event-log.jsonl" + ).read_text().splitlines()] + assert sum(event.get("event_kind") == "quota_spend" + and event.get("run_id") == payload["resume_turn_key"] for event in quota_events) == 1 def test_promoted_turn_completion_replays_after_commit_before_journal_crash( @@ -2702,11 +2699,18 @@ def crash_after_canonical_completion(**kwargs: object) -> dict[str, object]: def _completion_host_and_validation_scripts() -> tuple[str, str]: - host_script = """ + host_script = checkpoint_fixture_script(""" import json import pathlib import sys +from tests.control_plane.host_checkpoint_fixture import reply_to_checkpoint request = json.load(sys.stdin) +with pathlib.Path("host-calls.jsonl").open("a", encoding="utf-8") as calls: + calls.write(json.dumps({"phase": request.get("phase"), "turn_key": request["turn_key"], + "session_action": request["session"]["action"], + "read_context_id": request.get("checkpoint_context", {}).get("read_context_id")}) + "\\n") +if reply_to_checkpoint(request): + raise SystemExit(0) pathlib.Path("completion-artifact.txt").write_text("completed", encoding="utf-8") json.dump({ "schema_version": "loopx_turn_result_v0", @@ -2721,12 +2725,14 @@ def _completion_host_and_validation_scripts() -> tuple[str, str]: "vision_unchanged_reason": "The active goal may have further work.", "summary": "One public fixture completed." }, sys.stdout) -""" +""") validation_script = """ import json import pathlib import sys json.load(sys.stdin) +with pathlib.Path("validation-calls.txt").open("a", encoding="utf-8") as calls: + calls.write("validated\\n") artifact = pathlib.Path("completion-artifact.txt") raise SystemExit(0 if artifact.read_text(encoding="utf-8") == "completed" else 7) """ @@ -2740,6 +2746,10 @@ def _turn_run_once_completion_argv( host_script: str, validation_script: str, ) -> list[str]: + # Keep the real command's per-argument budget; fixture source is a file, + # rather than an ever-growing inline Python argument. + host_path = project / "completion-host.py" + host_path.write_text(host_script, encoding="utf-8") return [ "--registry", str(registry), @@ -2758,7 +2768,7 @@ def _turn_run_once_completion_argv( "--project", str(project), "--host-adapter-command-json", - json.dumps([sys.executable, "-c", host_script]), + json.dumps([sys.executable, str(host_path)]), "--validation-command-json", json.dumps([sys.executable, "-c", validation_script]), "--no-global-sync", @@ -2946,13 +2956,18 @@ def test_turn_run_once_cli_projects_durable_no_followup_continuation( [None, "sha256:different-turn"], ids=["missing-turn-key", "different-turn-key"], ) +@pytest.mark.parametrize("terminal_event_fault", ["missing", "contradictory"]) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_turn_run_once_cli_terminal_recovery_ignores_stale_display_identity( tmp_path: Path, display_completion_turn_key: str | None, + terminal_event_fault: str, + provider: str, ) -> None: project, runtime, registry = _write_live_fixture( tmp_path, todo_metadata_extra="no_followup=true", + provider=provider, ) host_project = project / "isolated-host-workspace" host_project.mkdir() @@ -3004,9 +3019,19 @@ def test_turn_run_once_cli_terminal_recovery_ignores_stale_display_identity( if not ( event.get("event_kind") == "todo_complete" and event.get("run_id") == turn_key + and event.get("status") == "terminal_no_followup" ) ] assert len(events_without_terminal) == len(events) - 1 + # Keep the result's deferred completion receipt; only the terminal event + # is lost at this crash boundary. + deferred = [event for event in events_without_terminal + if event.get("event_kind") == "todo_complete" and event.get("run_id") == turn_key] + assert len(deferred) == 1 and deferred[0]["status"] == "completed" + if terminal_event_fault == "contradictory": + terminal_event = next(event for event in events if event not in events_without_terminal) + terminal_event["details"]["no_followup"] = False + events_without_terminal.append(terminal_event) event_path.write_text( "".join(json.dumps(event) + "\n" for event in events_without_terminal), encoding="utf-8", @@ -3040,6 +3065,9 @@ def test_turn_run_once_cli_terminal_recovery_ignores_stale_display_identity( ): journal.pop(key, None) journal_path.write_text(json.dumps(journal), encoding="utf-8") + event_bytes = event_path.read_bytes() + host_calls = (host_project / "host-calls.jsonl").read_bytes() + validation_calls = (host_project / "validation-calls.txt").read_bytes() resumed_output = io.StringIO() with contextlib.redirect_stdout(resumed_output): @@ -3052,6 +3080,14 @@ def test_turn_run_once_cli_terminal_recovery_ignores_stale_display_identity( ] ) resumed = json.loads(resumed_output.getvalue()) + assert (host_project / "host-calls.jsonl").read_bytes() == host_calls + assert (host_project / "validation-calls.txt").read_bytes() == validation_calls + if terminal_event_fault == "contradictory": + assert resumed_exit_code == 1, resumed + assert not any(resumed["effects"].values()) + assert event_path.read_bytes() == event_bytes + assert "effect_outcome_unknown" in json.dumps(resumed), resumed + return assert resumed_exit_code == 0, resumed assert resumed["status"] == "committed" @@ -3071,11 +3107,13 @@ def test_turn_run_once_cli_terminal_recovery_ignores_stale_display_identity( if ( event.get("event_kind") == "todo_complete" and event.get("run_id") == turn_key + and event.get("status") == "terminal_no_followup" ) ] assert len(terminal_events) == 1 assert terminal_events[0]["status"] == "terminal_no_followup" assert terminal_events[0]["details"]["command"] == "turn recovery probe" + assert deferred[0] in recovered_events assert not any( event.get("event_kind") == "todo_complete" and event.get("run_id") != turn_key @@ -3433,7 +3471,9 @@ def test_turn_run_once_cli_rejects_unproven_host_claim_before_writeback( assert payload["effects"]["state_written"] is False assert payload["effects"]["quota_spent"] is False assert state_path.read_text(encoding="utf-8") == before_state - assert not (runtime / "goals" / "loopx-turn-fixture" / "runs").exists() + # Admission may persist its captured work basis; failed validation must + # produce neither a business result nor a debit. + assert not (runtime / "goals" / "loopx-turn-fixture" / "runs" / "index.jsonl").exists() @pytest.mark.parametrize( @@ -3513,6 +3553,9 @@ def recording_spend_quota_slot(*args: object, **kwargs: object) -> dict[str, obj return real_spend_quota_slot(*args, **kwargs) def fake_codex_host(request: dict[str, object], **_kwargs: object) -> dict[str, object]: + direction = checkpoint_codex_fixture_response(request, runtime) + if direction is not None: + return direction return { "schema_version": "loopx_turn_result_v0", "turn_key": request["turn_key"], @@ -3581,9 +3624,9 @@ def fake_codex_host(request: dict[str, object], **_kwargs: object) -> dict[str, assert payload["host"] == {"executable": "built-in", "kind": "codex-cli"} assert payload["effects"]["state_written"] is True assert payload["effects"]["quota_spent"] is True - assert refresh_workspace_paths == [project] + assert refresh_workspace_paths == [project, project] assert spend_workspace_paths == [project] - assert refresh_todo_ids == ["todo_fixture0001"] + assert refresh_todo_ids == ["todo_fixture0001", "todo_fixture0001"] state = ( project / ".codex" @@ -3669,6 +3712,10 @@ def fake_codex_host( request: Mapping[str, Any], **_kwargs: object, ) -> dict[str, object]: + if host_kind == "codex-cli": + direction = checkpoint_codex_fixture_response(request, runtime) + if direction is not None: + return direction return { "schema_version": "loopx_turn_result_v0", "turn_key": request["turn_key"], @@ -3725,8 +3772,14 @@ def fake_ingest(**kwargs: object) -> dict[str, object]: runner = tmp_path / "reflection_runner.py" candidate = fake_codex_host({"turn_key": "unused"}) runner.write_text( - "import json\n" + "import json, sys\n" + f"sys.path.insert(0, {str(Path(__file__).resolve().parents[1])!r})\n" + "from tests.control_plane.host_checkpoint_fixture import checkpoint_decision\n" + "from loopx.control_plane.turn_driver.host_candidate import parse_model_json\n" "def run_dsh_turn(**kwargs):\n" + " request = parse_model_json(kwargs['prompt']) or {}\n" + " if request.get('phase') == 'checkpoint_direction':\n" + " return json.dumps(checkpoint_decision(request['checkpoint_context']))\n" f" return {json.dumps(candidate)!r}\n", encoding="utf-8", ) @@ -3734,12 +3787,14 @@ def fake_ingest(**kwargs: object) -> dict[str, object]: elif host_kind == "generic-cli": runner = tmp_path / "generic_reflection_runner.py" candidate = fake_codex_host({"turn_key": "unused"}) - runner.write_text( + runner.write_text(checkpoint_fixture_script( "import json, sys\n" + "from tests.control_plane.host_checkpoint_fixture import reply_to_checkpoint\n" "request = json.load(sys.stdin)\n" + "if reply_to_checkpoint(request): raise SystemExit(0)\n" f"result = json.loads({json.dumps(candidate)!r})\n" "result['turn_key'] = request['turn_key']\n" - "json.dump(result, sys.stdout)\n", + "json.dump(result, sys.stdout)\n"), encoding="utf-8", ) host_args += ["--host-adapter-command-json", json.dumps([sys.executable, str(runner)])] diff --git a/tests/test_manager_context_roundtrip.py b/tests/test_manager_context_roundtrip.py index 65ac1df2e8..589de059d5 100644 --- a/tests/test_manager_context_roundtrip.py +++ b/tests/test_manager_context_roundtrip.py @@ -72,11 +72,49 @@ def frozen_private_reply(tmp_path): return store, session, turn, request, native, record, source_text, parent -def test_frozen_private_reply_projects_original_text_without_mutating_model_input(frozen_private_reply, tmp_path): + +@pytest.fixture +def private_snapshot(frozen_private_reply, tmp_path): + """Exercise provider history at its shipped HTTP composition boundary.""" + import http.client + from types import SimpleNamespace + from urllib.parse import urlencode + from loopx.chat_server import ChatHTTPServer, ChatRequestHandler + + store, session, *_ = frozen_private_reply + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.chat_store, server.verbose = store, False + server.registry_path, server.runtime_root = tmp_path / "absent.json", tmp_path + server.runtime_controller = SimpleNamespace(recover_exited_turn=lambda _: None, close=lambda: None) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + def read(**query): + connection = http.client.HTTPConnection(*server.server_address, timeout=15) + try: + connection.request("GET", f"/api/chat/sessions/{session['session_id']}?{urlencode(query)}") + response = connection.getresponse() + payload = json.loads(response.read()) + assert response.status == 200, payload + return payload + finally: + connection.close() + + try: + yield read + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +def test_frozen_private_reply_projects_original_text_without_mutating_model_input(frozen_private_reply, tmp_path, private_snapshot): store, session, turn, _, _, _, source_text, parent = frozen_private_reply before = {str(p): p.read_bytes() for p in store.root.rglob("*") if p.is_file()} - snapshot = project_chat_session_snapshot(tmp_path, store, session["session_id"], registry=tmp_path / "absent.json", - message_limit=1) + generic = project_chat_session_snapshot(tmp_path, store, session["session_id"], + registry=tmp_path / "absent.json", message_limit=1) + assert "reply_context" not in generic["messages"][0] + snapshot = private_snapshot(message_limit=1) message = snapshot["messages"][0] assert message["text"] == turn["message"] assert message["reply_context"]["source_text"] == source_text @@ -86,7 +124,7 @@ def test_frozen_private_reply_projects_original_text_without_mutating_model_inpu assert {str(p): p.read_bytes() for p in store.root.rglob("*") if p.is_file()} == before -def test_reply_history_keeps_failed_and_recorded_recovery_context(frozen_private_reply, tmp_path): +def test_reply_history_keeps_failed_and_recorded_recovery_context(frozen_private_reply, tmp_path, private_snapshot): store, session, original, request, native, record, source_text, _ = frozen_private_reply client = f"external-{request}-recovery-21-quota-1" store.update_turn(session["session_id"], original["turn_id"], status="failed", error_code="synthetic_upstream_failure") @@ -96,19 +134,19 @@ def test_reply_history_keeps_failed_and_recorded_recovery_context(frozen_private record["turn_id"] = recovery["turn_id"] for directory, row in (("external-requests", native), ("lark-private-deliveries", record)): _write(store.root / directory / f"{request}.json", row) - snapshot = project_chat_session_snapshot(tmp_path, store, session["session_id"], registry=tmp_path / "absent.json") + snapshot = private_snapshot() assert [m["reply_context"]["source_text"] for m in snapshot["messages"]] == [source_text, source_text] # A plausible-looking suffix without its original native receipt is not a source. store.update_turn(session["session_id"], recovery["turn_id"], status="completed") store.release_active_turn(session["session_id"], recovery["turn_id"]) store.create_turn(session["session_id"], client_turn_id=f"external-{request}-recovery-999", message=original["message"], origin="lark") - snapshot = project_chat_session_snapshot(tmp_path, store, session["session_id"], registry=tmp_path / "absent.json") + snapshot = private_snapshot() assert "reply_context" not in snapshot["messages"][-1] @pytest.mark.parametrize("drift", ["session", "turn", "binding", "source", "native_message", "frozen_message", "parent", "thread", "order", "timestamp", "sender", "event_shape", "missing", "user_spoof", "foreign_origin"]) -def test_private_reply_view_requires_exact_frozen_source(frozen_private_reply, tmp_path, drift): +def test_private_reply_view_requires_exact_frozen_source(frozen_private_reply, tmp_path, drift, private_snapshot): store, session, turn, request, native, record, _, _ = frozen_private_reply path = store.root / "lark-private-deliveries" / f"{request}.json" if drift in {"session", "turn", "binding"}: @@ -139,7 +177,7 @@ def test_private_reply_view_requires_exact_frozen_source(frozen_private_reply, t path.unlink() if drift == "foreign_origin": store.append_message(session["session_id"], role="user", text=turn["message"], turn_id=turn["turn_id"], origin="peer") - snapshot = project_chat_session_snapshot(tmp_path, store, session["session_id"], registry=tmp_path / "absent.json") + snapshot = private_snapshot() message = snapshot["messages"][-1] assert "reply_context" not in message assert message["text"] in {turn["message"], record["reply_context_message"]} diff --git a/tests/test_project_alias_stdin.py b/tests/test_project_alias_stdin.py new file mode 100644 index 0000000000..075fce5d2b --- /dev/null +++ b/tests/test_project_alias_stdin.py @@ -0,0 +1,43 @@ +"""Git alias probes must preserve the caller's open protocol input.""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("missing_origin", [False, True]) +def test_git_probe_preserves_protocol_input(missing_origin: bool) -> None: + source = Path(__file__).resolve().parents[1] + # Replace only the Git executable with a portable process that reads stdin. + # All pipe, timeout and return-code behavior comes from the real probe. + fake_git = ( + "import sys; head=sys.stdin.buffer.read(1); " + f"print('consumed RPC' if head else 'missing-origin' if {missing_origin!r} " + "else 'alias-root',flush=True); " + f"raise SystemExit({128 if missing_origin else 0})" + ) + code = ( + f"import sys,json; sys.path.insert(0,{str(source)!r}); " + "from pathlib import Path; import loopx.project_alias as alias; " + "run=alias.subprocess.run; " + f"alias.subprocess.run=lambda argv,**kwargs: " + f"run([sys.executable,'-I','-c',{fake_git!r}],**kwargs); " + "result=alias._run_git(Path.cwd(),'rev-parse','--show-toplevel'); " + "print(json.dumps({'code':result.returncode,'stdout':result.stdout," + "'remaining':sys.stdin.readline()}))" + ) + result = subprocess.run( + [sys.executable, "-I", "-c", code], + input="RPC packet\n", + text=True, + capture_output=True, + timeout=10, + check=True, + ) + row = json.loads(result.stdout) + assert row["remaining"] == "RPC packet\n" + assert row["code"] == (128 if missing_origin else 0) + assert row["stdout"].strip() == ("missing-origin" if missing_origin else "alias-root") diff --git a/tests/test_repository_probe_stdin.py b/tests/test_repository_probe_stdin.py new file mode 100644 index 0000000000..81e64fd8fb --- /dev/null +++ b/tests/test_repository_probe_stdin.py @@ -0,0 +1,67 @@ +import json +import os +import queue +import shlex +import subprocess +import sys +import threading +from pathlib import Path +import loopx + + +def test_repository_probe_preserves_open_caller_stdin(tmp_path): + release = Path(loopx.__file__).resolve().parent.parent + remote = "https://example.test/team/fixture.git" + git_script = tmp_path / "git_child.py" + git_script.write_text( + 'import sys\nassert sys.argv[1:]==["-C",sys.argv[2],"config","--get","remote.origin.url"]\nsys.stdin.buffer.read()\nprint(' + + repr(remote) + + ",flush=True)\n" + ) + git_cmd = tmp_path / ("fixture-git.cmd" if os.name == "nt" else "fixture-git") + if os.name == "nt": + git_cmd.write_text( + '@echo off\n"' + sys.executable + '" "' + str(git_script) + '" %*\n' + ) + else: + git_cmd.write_text( + "#!/bin/sh\nexec " + + shlex.quote(sys.executable) + + " " + + shlex.quote(str(git_script)) + + ' "$@"\n' + ) + git_cmd.chmod(0o700) + driver = tmp_path / "driver.py" + driver.write_text( + "import sys,json\nsys.path.insert(0," + + repr(str(release)) + + ')\nfrom pathlib import Path\nfrom loopx.repository_identity import _origin_remote\ntry:\n value=_origin_remote(Path(sys.argv[1]),sys.argv[2])\n print(json.dumps({"remote":value}),flush=True)\nexcept Exception as exc:print(json.dumps({"error":repr(exc)}),flush=True)\nprint(json.dumps({"caller_packet":sys.stdin.readline().strip()}),flush=True)\n' + ) + process = subprocess.Popen( + [sys.executable, "-I", "-u", str(driver), str(tmp_path), str(git_cmd)], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + messages = queue.Queue() + reader = threading.Thread( + target=lambda: messages.put(process.stdout.readline()), daemon=True + ) + reader.start() + process.stdin.write("owned-rpc-packet\n") + process.stdin.flush() + try: + response = json.loads(messages.get(timeout=3)) + assert response == {"remote": remote}, response + assert json.loads(process.stdout.readline()) == { + "caller_packet": "owned-rpc-packet" + } + finally: + process.stdin.close() + process.wait(timeout=20) + reader.join(timeout=2) + process.stdout.close() + process.stderr.close() + assert process.returncode == 0 diff --git a/tests/test_turn_recovery_preview.py b/tests/test_turn_recovery_preview.py new file mode 100644 index 0000000000..5dc7e5c3fb --- /dev/null +++ b/tests/test_turn_recovery_preview.py @@ -0,0 +1,86 @@ +import copy +import json +import pytest +from loopx.control_plane.turn_driver import build_loopx_turn_plan, run_loopx_turn_once +from loopx.control_plane.turn_driver.executor import turn_journal_path +from loopx.control_plane.turn_driver.recovery import assess_existing_turn_recovery +from loopx.control_plane.turn_driver.transaction import TRANSACTION_PHASES + +def plan(): + return build_loopx_turn_plan({ + 'ok': True, 'schema_version': 'loopx_turn_envelope_v0', + 'goal_id': 'fixture-goal', 'agent_id': 'fixture-agent', + 'should_run': True, 'effective_action': 'normal_run', + 'action': {'must_attempt': True, 'delivery_allowed': True, + 'quiet_noop_allowed': False, 'selected_todo': {'todo_id': 'todo_fixture0001', 'text': 'fixture'}}, + 'user': {'action_required': False, 'open_count': 0, 'notify': 'DONT_NOTIFY'}, + 'writeback': {'spend_after_validation': True}, 'scheduler': {'action': 'run_now'}, + 'action_signature': {'matches': True, 'source_hash': 'sha256:fixture', 'envelope_hash': 'sha256:fixture'}, + 'compaction': {'within_budget': True}, + }, host='generic-cli', execution_mode='isolated-headless') + +def forbidden(*args, **kwargs): + pytest.fail('read-only preview invoked an execution callback') + +@pytest.mark.parametrize('prefix,retry', [(0, True), (3, True), (4, True), (5, True), (3, False)]) +def test_preview_uses_existing_typed_phase_without_effects(tmp_path, prefix, retry): + original = plan() + key = original['transaction']['turn_key'] + runtime = tmp_path / 'runtime' + path = turn_journal_path(runtime, goal_id='fixture-goal', turn_key=key) + journal = {'schema_version': 'loopx_turn_journal_v0', 'goal_id': 'fixture-goal', + 'turn_key': key, 'plan': original, 'status': 'failed', + 'completed_phases': list(TRANSACTION_PHASES[:prefix]), + 'receipt': {'turn_key': key, 'failed_phase': TRANSACTION_PHASES[prefix]}} + path.parent.mkdir(parents=True) + path.write_text(json.dumps(journal)) + before = path.read_bytes() + old_plan = copy.deepcopy(original) + decision = assess_existing_turn_recovery(journal, {}, goal_id='fixture-goal', + agent_id='fixture-agent', turn_key=key, retry_failed=retry, session_binding_resolver=None).decision + assert decision['reason'] != 'journal_inconsistent' + result = run_loopx_turn_once(original, host_runner=forbidden, project=tmp_path, + runtime_root=runtime, goal_id='fixture-goal', timeout_seconds=5, + execute=False, retry_failed=retry, task_validator=forbidden, + writeback=forbidden, spend=forbidden, scheduler=forbidden) + assert result['dry_run'] and result['status'] == 'preview' + assert result['route']['would_invoke_host'] == decision['reinvoke_host'] + assert result['recovery']['planned'] == decision + assert result['recovery']['actual']['journal_status'] == 'failed' + assert result['recovery']['actual']['host_invoked'] is False + assert not any(result['effects'].values()) + assert path.read_bytes() == before and original == old_plan + assert len(list(path.parent.iterdir())) == 1 + +@pytest.mark.parametrize('case', ['committed', 'foreign_goal', 'foreign_turn', 'session_blocked']) +def test_preview_returns_retained_or_blocked_without_host(tmp_path, case): + original = plan() + key = original['transaction']['turn_key'] + path = turn_journal_path(tmp_path, goal_id='fixture-goal', turn_key=key) + journal = {'schema_version': 'loopx_turn_journal_v0', 'goal_id': 'fixture-goal', 'turn_key': key, + 'plan': original, 'status': 'failed', 'completed_phases': list(TRANSACTION_PHASES[:3]), + 'receipt': {'turn_key': key, 'failed_phase': 'durable_writeback'}} + if case == 'committed': + journal.update(status='committed', completed_phases=list(TRANSACTION_PHASES)) + journal.pop('receipt') + elif case == 'foreign_goal': + journal['goal_id'] = 'other' + elif case == 'foreign_turn': + journal['turn_key'] = 'sha256:' + 'f' * 64 + else: + journal['host_recovery'] = {'unavailable': True} + path.parent.mkdir(parents=True) + path.write_text(json.dumps(journal)) + before = path.read_bytes() + result = run_loopx_turn_once(original, host_runner=forbidden, project=tmp_path, + runtime_root=tmp_path, goal_id='fixture-goal', timeout_seconds=5, execute=False, retry_failed=True) + assert result['route']['would_invoke_host'] is False + assert result['recovery']['planned']['action'] == ('return_existing' if case == 'committed' else 'blocked') + assert not any(result['effects'].values()) and path.read_bytes() == before + +def test_new_turn_preview_keeps_the_host_plan_and_writes_nothing(tmp_path): + result = run_loopx_turn_once(plan(), host_runner=forbidden, project=tmp_path, + runtime_root=tmp_path, goal_id='fixture-goal', timeout_seconds=5, execute=False) + assert result['route']['would_invoke_host'] is True + assert 'recovery' not in result and not any(result['effects'].values()) + assert list(tmp_path.iterdir()) == [] diff --git a/tests/test_usage_ping.py b/tests/test_usage_ping.py index 18f3d016fa..6f372f9696 100644 --- a/tests/test_usage_ping.py +++ b/tests/test_usage_ping.py @@ -610,3 +610,25 @@ def test_projection_failure_cannot_replace_business_output(isolated, monkeypatch assert json.loads(capsys.readouterr().out) == payload assert 'result_facts' not in usage_ping._observation.get() usage_ping.finish(None, 'turn', 0) + + +@pytest.mark.parametrize('compact', [False, True]) +@pytest.mark.parametrize('fmt', ['json', 'markdown']) +def test_payload_formatting_preserves_values_and_one_capture(monkeypatch, capsys, compact, fmt): + from loopx.cli_runtime import print_payload + payload = {'ok': True, 'evidence': [{'summary': '依据', 'count': 3}], + 'reason': None, 'must_attempt_work': False, 'command': 'loopx --goal-id test'} + captured = [] + monkeypatch.setattr(usage_ping, 'capture_result', captured.append) + print_payload(payload, fmt, lambda value: f"# Result\n{value['evidence'][0]['summary']}", + compact_json=compact) + text = capsys.readouterr().out + assert captured == [payload] and captured[0] is payload + if fmt == 'json': + assert json.loads(text) == payload + if compact: + assert len(text.splitlines()) == 1 + else: + assert '\n "evidence"' in text + else: + assert text == '# Result\n依据\n' diff --git a/tests/test_windows_atomic_writes.py b/tests/test_windows_atomic_writes.py index 1cfc8454ad..192bafa35e 100644 --- a/tests/test_windows_atomic_writes.py +++ b/tests/test_windows_atomic_writes.py @@ -59,7 +59,7 @@ def _write_botmux_binding(path: Path) -> None: def _write_native_isolation(path: Path) -> None: - native_codex_isolation._atomic_write_text(path, "prompt = \"ready\"\n") + native_codex_isolation._atomic_write_text(path, 'prompt = "ready"\n') @pytest.mark.parametrize( @@ -122,3 +122,63 @@ def test_atomic_writers_skip_unsupported_windows_directory_fsync( content = target.read_text(encoding="utf-8") actual = json.loads(content) if isinstance(expected, dict) else content assert actual == expected + + +def _long_private_target(tmp_path: Path, *, parent_length: int) -> Path: + parent = tmp_path + while len(str(parent)) < parent_length: + remaining = parent_length - len(str(parent)) - 1 + assert remaining > 0 + component_length = min(40, remaining) + if remaining == 41: + component_length = 39 + parent /= "p" * component_length + assert len(str(parent)) == parent_length + return parent / ("a" * 64 + ".json") + + +@pytest.mark.skipif(os.name != "nt", reason="Native Windows extended-path IO") +@pytest.mark.parametrize("parent_length", [180, 280], ids=["long-temp", "long-target"]) +def test_lark_private_json_long_paths(tmp_path: Path, parent_length: int) -> None: + from loopx.control_plane.runtime.file_paths import windows_extended_path + + target = _long_private_target(tmp_path, parent_length=parent_length) + assert len(str(target)) + 14 > 260 + if parent_length == 180: + assert len(str(target)) < 260 + else: + assert len(str(target)) > 260 + address = windows_extended_path(target) + private_json.write_private_json_atomic(target, {"status": "初次", "count": 1}) + assert json.loads(address.read_text(encoding="utf-8")) == { + "status": "初次", + "count": 1, + } + private_json.write_private_json_atomic(target, {"status": "更新", "count": 2}) + assert json.loads(address.read_text(encoding="utf-8")) == { + "status": "更新", + "count": 2, + } + assert not list(address.parent.glob("*.tmp")) + + +@pytest.mark.skipif(os.name != "nt", reason="Native Windows extended-path IO") +def test_lark_private_json_long_path_interrupted_replace( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from loopx.control_plane.runtime.file_paths import windows_extended_path + + target = _long_private_target(tmp_path, parent_length=180) + address = windows_extended_path(target) + address.parent.mkdir(parents=True, exist_ok=True) + address.write_text('{"status": "original"}\n', encoding="utf-8") + before = address.read_bytes() + + def interrupted_replace(*args: object) -> None: + raise OSError("interrupted replacement") + + monkeypatch.setattr(file_lock.os, "replace", interrupted_replace) + with pytest.raises(OSError, match="interrupted replacement"): + private_json.write_private_json_atomic(target, {"status": "replacement"}) + assert address.read_bytes() == before + assert not list(address.parent.glob("*.tmp")) diff --git a/tests/test_workspace_probe_stdin.py b/tests/test_workspace_probe_stdin.py new file mode 100644 index 0000000000..28b38785a8 --- /dev/null +++ b/tests/test_workspace_probe_stdin.py @@ -0,0 +1,76 @@ +import json +import os +import queue +import shlex +import subprocess +import sys +import threading +from pathlib import Path +import loopx +import pytest + + +@pytest.mark.parametrize("kind", ["output", "missing"]) +def test_workspace_probe_preserves_caller_rpc(tmp_path, kind): + release = Path(loopx.__file__).resolve().parent.parent + fake = tmp_path / "git_child.py" + fake.write_text( + "import sys\nsys.stdin.buffer.read()\n" + + ( + 'print("workspace-output",flush=True)\n' + if kind == "output" + else "raise SystemExit(1)\n" + ) + ) + git = tmp_path / ("fixture-git.cmd" if os.name == "nt" else "fixture-git") + if os.name == "nt": + git.write_text('@echo off\n"' + sys.executable + '" "' + str(fake) + '" %*\n') + else: + git.write_text( + "#!/bin/sh\nexec " + + shlex.quote(sys.executable) + + " " + + shlex.quote(str(fake)) + + ' "$@"\n' + ) + git.chmod(0o700) + driver = tmp_path / "driver.py" + driver.write_text( + "import sys,json,subprocess\nsys.path.insert(0," + + repr(str(release)) + + ")\nfrom pathlib import Path\nfrom loopx.control_plane.agents.workspace_guard import _git_command_output,_git_origin_is_absent\nreal_run=subprocess.run\ndef run(argv,**kw):return real_run([sys.argv[2],*argv[1:]],**kw)\nsubprocess.run=run\nvalue=" + + ( + '_git_command_output(Path(sys.argv[1]),"rev-parse","--show-toplevel")' + if kind == "output" + else "_git_origin_is_absent(Path(sys.argv[1]))" + ) + + '\nprint(json.dumps({"value":value}),flush=True)\nprint(json.dumps({"caller_packet":sys.stdin.readline().strip()}),flush=True)\n' + ) + process = subprocess.Popen( + [sys.executable, "-I", "-u", str(driver), str(tmp_path), str(git)], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + queue_ = queue.Queue() + reader = threading.Thread( + target=lambda: queue_.put(process.stdout.readline()), daemon=True + ) + reader.start() + process.stdin.write("owned-rpc-packet\n") + process.stdin.flush() + try: + assert json.loads(queue_.get(timeout=3)) == { + "value": "workspace-output" if kind == "output" else True + } + assert json.loads(process.stdout.readline()) == { + "caller_packet": "owned-rpc-packet" + } + finally: + process.stdin.close() + process.wait(timeout=20) + reader.join(timeout=2) + process.stdout.close() + process.stderr.close() + assert process.returncode == 0