diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index d38b114d1c..51a3935da5 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -13,6 +13,8 @@ export type WorkspaceLocale = "en" | "zh-CN"; const en = { "zcode.executionFailed": "ZCode native execution failed. Check the host session and model configuration, then read status again.", + "zcode.goalBasisChanged": "Goal acceptance criteria or declared sources changed after this native Goal started. The target stays paused; stop it and start again so the native session consumes the current basis.", + "zcode.goalBasisUnavailable": "No proven current Goal basis is available for this native Goal: either the current basis could not be read, or the binding started before a basis was recorded. The target stays paused; read status, then stop and start again so the native session consumes the current basis.", "zcode.currentModel": "Current model", "zcode.modelNotSelected": "Not selected", "zcode.model": "Configured model", @@ -1490,6 +1492,8 @@ export type WorkspaceMessageKey = keyof typeof en; const zhCN: Record = { "zcode.executionFailed": "ZCode 原生执行失败。请检查宿主会话与模型配置,再回读状态。", + "zcode.goalBasisChanged": "该原生 Goal 启动后,Goal 验收条件或声明来源已变更。目标保持暂停;请先停止再启动,让原生会话消费当前依据。", + "zcode.goalBasisUnavailable": "该原生 Goal 没有可证明的当前依据:当前依据无法读取,或该绑定在记录依据之前就已启动。目标保持暂停;请回读状态,然后先停止再启动,让原生会话消费当前依据。", "zcode.currentModel": "当前模型", "zcode.modelNotSelected": "未选择", "zcode.model": "已配置模型", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/zcode-goal-control.tsx b/apps/presentation/dashboard/src/features/personal-workspace/zcode-goal-control.tsx index eb594a0e78..585dda028a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/zcode-goal-control.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/zcode-goal-control.tsx @@ -5,8 +5,14 @@ import { useWorkspaceI18n, type WorkspaceTranslate } from "./i18n"; import type { WorkspaceGoal } from "./personal-workspace-model"; import "./zcode-goal-control.css"; +const ZCODE_REASON_KEYS: Partial[0]>> = { + zcode_native_execution_failed: "zcode.executionFailed", + goal_basis_changed: "zcode.goalBasisChanged", + goal_basis_unavailable: "zcode.goalBasisUnavailable", +}; function zcodeReason(reason: string | undefined, t: WorkspaceTranslate) { - return reason === "zcode_native_execution_failed" ? t("zcode.executionFailed") : reason; + const key = reason ? ZCODE_REASON_KEYS[reason] : undefined; + return key ? t(key) : reason; } export function ZCodeGoalControl({goal, readOnly}: {goal: WorkspaceGoal; readOnly: boolean}) { diff --git a/docs/architecture/long-running-execution-exploration-and-correction.md b/docs/architecture/long-running-execution-exploration-and-correction.md index 6ce9d516dc..87ffd4015c 100644 --- a/docs/architecture/long-running-execution-exploration-and-correction.md +++ b/docs/architecture/long-running-execution-exploration-and-correction.md @@ -754,7 +754,7 @@ operation and current source revisions to join observations, not a new ledger. | Injection or variant | Required observation | Existing seam and remaining qualification | | --- | --- | --- | -| Pause, change criteria/source scope without replacing the Goal instance, then resume; also deliver an amendment during restore | Keep native identity and original receipts; the first new protected action consumes current applicable criteria/sources or remains pending at an unsupported boundary | [Native controller tests](../../tests/control_plane_ts/zcode_native_goal.test.ts) cover identity/recovery; cross-owner resume-to-model behavior remains to qualify | +| Pause, change criteria/source scope without replacing the Goal instance, then resume; also deliver an amendment during restore | Keep native identity and original receipts; the first new protected action consumes current applicable criteria/sources or remains pending at an unsupported boundary | [Native controller tests](../../tests/control_plane_ts/zcode_native_goal.test.ts) cover identity/recovery and now the basis gate: a changed acceptance/source revision basis, an unreadable one, or a journal that never recorded one refuses a plain resume and keeps the target paused until stop/start records the current basis; cross-owner resume-to-model behavior remains to qualify | | Invalidate an ancestor in explicit A → B → C while C still passes its own validator | Preserve historical accepted/done facts; reject current use where ancestry is no longer eligible, without redispatching the original task | [Result-use integration](../../tests/test_delegation_result_use.py) covers this with a controlled worker; carry it through the selected Host entry | | Native reports completed but acceptance fails; selected-work contract passes while mandatory Goal work remains | Preserve provider completion and scoped acceptance without promoting either to whole-Goal achieved; execute owed settlement; optional research alone need not prevent closeout | [Acceptance authority tests](../../tests/control_plane_ts/goal_acceptance_authority.test.ts) and native completion tests cover separate owners; combined final readback remains | | Commit a result between final drain and idle; deliver duplicates and old wakes after stop, then a newly authorized input | Durable work remains discoverable; prior wakes cannot revive stopped execution; new input re-enters current admission through the same continuation owner | Existing [accepted-result wake](../../loopx/chat_loopx_mode.py) and Host continuation paths; qualify both no duplicate start and no lost work, not just a lock | diff --git a/docs/architecture/long-running-execution-exploration-and-correction.zh-CN.md b/docs/architecture/long-running-execution-exploration-and-correction.zh-CN.md index ed78d50859..7cfe08a4db 100644 --- a/docs/architecture/long-running-execution-exploration-and-correction.zh-CN.md +++ b/docs/architecture/long-running-execution-exploration-and-correction.zh-CN.md @@ -612,7 +612,7 @@ Grok 的[第三方声明](https://github.com/xai-org/grok-build/blob/2bdd1d6a636 | 注入或变体 | 应观察到的结果 | 已有接缝与剩余验证 | | --- | --- | --- | -| 暂停后保持 Goal instance,更新标准/来源范围再 resume;restore 期间再加入 amendment | 保留原生身份和原回执;第一项新受保护动作消费当前适用标准/来源,不支持的边界保持待处理 | [原生控制器测试](../../tests/control_plane_ts/zcode_native_goal.test.ts) 覆盖身份/恢复;跨 owner 恢复到真实模型行为仍待验 | +| 暂停后保持 Goal instance,更新标准/来源范围再 resume;restore 期间再加入 amendment | 保留原生身份和原回执;第一项新受保护动作消费当前适用标准/来源,不支持的边界保持待处理 | [原生控制器测试](../../tests/control_plane_ts/zcode_native_goal.test.ts) 覆盖身份/恢复以及依据门:acceptance/source revision 依据变化、不可读或 journal 从未记录依据时,拒绝直接 resume 并保持目标暂停,直到 stop/start 记录当前依据;跨 owner 恢复到真实模型行为仍待验 | | 显式 A → B → C 的祖先失效,但 C 自身 validator 仍通过 | 保留历史 accepted/done;祖先不合格时拒绝当前使用,不重新派发原任务 | [Result-use 集成测试](../../tests/test_delegation_result_use.py) 已由受控 worker 覆盖;再接所选 Host 入口 | | Native completed 而验收失败;selected-work 合同通过但仍有必要 Goal 工作 | 保留 provider 完成与局部 accepted,不升级为整个 Goal achieved;完成应做结算;仅可选研究未完成不必阻塞收口 | [验收 authority 测试](../../tests/control_plane_ts/goal_acceptance_authority.test.ts) 和原生完成测试覆盖独立 owner;最终组合回读仍待验 | | 最后 drain 与 idle 之间提交结果;重复通知、停止后旧 wake,再加入新获准输入 | 持久工作可发现;旧 wake 不复活已停执行;新输入由原接续 owner 按当前准入处理 | 复用[已验收结果 wake](../../loopx/chat_loopx_mode.py) 和 Host 接续路径;同时验证不重复启动与不丢工作,不能只验证锁 | diff --git a/docs/architecture/rfcs/agent-session-execution-modes-v0.md b/docs/architecture/rfcs/agent-session-execution-modes-v0.md index 6caf4ce13c..cef12d4dae 100644 --- a/docs/architecture/rfcs/agent-session-execution-modes-v0.md +++ b/docs/architecture/rfcs/agent-session-execution-modes-v0.md @@ -614,7 +614,7 @@ future evidence and must not be reported as green. | Admission under restart and crash | Kill the host during execution with a bound session | No orphan executor; replacement binding has one owner and correct mode | Unverified for new hosts | | Feature-off parity | Run without any attached binding and without the preview host | Existing managed behavior, routes, and defaults unchanged | Unverified for new hosts | | Declared input boundary | Deliver a correction during a tool batch, compaction and finalization | Exact target/source retained; next eligible Host input or truthful pending/refusal; no implicit tool cancellation or retargeting | Per-adapter qualification; protocol fixtures do not prove model adoption | -| Same native identity, changed basis | Pause, change an applicable criterion/source within the same Goal instance, then resume; also amend during restore | Original target/effect retained; first new protected action consumes current basis or remains pending/paused; no receipt rewriting | Combined qualification remains required beyond existing native identity/recovery tests | +| Same native identity, changed basis | Pause, change an applicable criterion/source within the same Goal instance, then resume; also amend during restore | Original target/effect retained; first new protected action consumes current basis or remains pending/paused; no receipt rewriting | The managed ZCode provider journals the acceptance/source revision basis at start and refuses a plain resume under a changed basis (`goal_basis_changed`) or without a proven basis, whether unreadable now or never recorded by a journal written before the basis existed (`goal_basis_unavailable`), keeping the target paused until stop/start re-delivers the objective and records its basis; covered, including the pre-basis journal upgrade path, by [`zcode_native_goal.test.ts`](../../../tests/control_plane_ts/zcode_native_goal.test.ts) and [`test_zcode_goal_bridge.py`](../../../tests/test_zcode_goal_bridge.py). Resume-to-model consumption, amendment during restore and other Hosts remain to qualify | Deterministic package tests are not sufficient for admission. A new host needs at least one real-host row: a live process, a real bind, and a real restart. diff --git a/docs/architecture/rfcs/agent-session-execution-modes-v0.zh-CN.md b/docs/architecture/rfcs/agent-session-execution-modes-v0.zh-CN.md index 26b4d79b18..bd71f1f614 100644 --- a/docs/architecture/rfcs/agent-session-execution-modes-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-session-execution-modes-v0.zh-CN.md @@ -489,7 +489,7 @@ root,都不同于注册身份数和展示上限。不要只为展示这些观 | 重启与崩溃下的接入 | 在已绑定会话期间杀掉宿主 | 无孤儿执行器;替换绑定只有一个属主且模式正确 | 对新宿主未验证 | | 关闭态一致性 | 在没有任何挂接绑定、也没有预览宿主的情况下运行 | 既有托管行为、路由与默认值不变 | 对新宿主未验证 | | 已声明输入边界 | 工具批次、压缩和终结期间交付纠正 | 保留确切目标/来源;进入下一合格 Host 输入或真实 pending/拒绝;不隐式取消工具或改投 | 按 adapter 验证;协议 fixture 不证明模型采用 | -| Native 身份不变但依据变化 | 暂停后在同 Goal instance 改适用 criterion/source 再恢复,另测 restore 中 amendment | 原 target/effect 保留;第一项新受保护动作消费当前依据或保持 pending/paused;不改旧回执 | 既有 native 身份/恢复测试之外,仍需组合资格验证 | +| Native 身份不变但依据变化 | 暂停后在同 Goal instance 改适用 criterion/source 再恢复,另测 restore 中 amendment | 原 target/effect 保留;第一项新受保护动作消费当前依据或保持 pending/paused;不改旧回执 | 托管 ZCode provider 在 start 时记录 acceptance/source revision 依据;依据变化时拒绝直接 resume(`goal_basis_changed`);没有可证明的依据时同样拒绝,无论是当前不可读,还是依据存在之前写入的 journal 从未记录(`goal_basis_unavailable`),目标保持暂停,直到 stop/start 重新交付 objective 并记录其依据;含依据前 journal 的升级路径,由 [`zcode_native_goal.test.ts`](../../../tests/control_plane_ts/zcode_native_goal.test.ts) 与 [`test_zcode_goal_bridge.py`](../../../tests/test_zcode_goal_bridge.py) 覆盖。resume 后模型实际消费、restore 中 amendment 及其他 Host 仍待资格验证 | 确定性的包测试不足以支撑接入。新宿主至少需要一条真实宿主行:一个真实进程、一次真实 绑定、一次真实重启。 diff --git a/loopx/zcode_goal_mode/README.md b/loopx/zcode_goal_mode/README.md index d8e633f296..741a781156 100644 --- a/loopx/zcode_goal_mode/README.md +++ b/loopx/zcode_goal_mode/README.md @@ -72,9 +72,21 @@ does not mint a substitute instance identity. Lifecycle-only `source_session_v1` registry profiles remain unavailable for this runtime, as required by Core. Replacement identity or changed authority rejects work. -Start uses the current canonical heartbeat task. Pause confirms cancellation -has drained; resume retains the same session and target. Repeated start cannot -replace an executing target. Cold recovery restores that session, pauses an +Start uses the current canonical heartbeat task and journals the Goal basis it +was delivered under: the acceptance-contract revision/digest, when a canonical +authority exists, and each declared authority source's revision. Pause confirms +cancellation has drained; resume retains the same session and target only while +Core presents the same basis. A changed criterion or source after start refuses +the plain resume with `goal_basis_changed`, and an unreadable basis refuses it +with `goal_basis_unavailable`; the target stays paused and the recorded basis is +not rewritten. Stop, then start, delivers a fresh objective under the current +basis. Bindings journaled before the basis existed remain readable, pausable +and stoppable, but carry no recorded proof of the basis they were delivered +under: a plain resume refuses them with `goal_basis_unavailable` and never +backfills a basis it did not deliver; stop, then start, records the current +basis with the fresh objective. A matching basis proves current owning +requirements were available at that boundary, not that the native model +understood or acted on them. Repeated start cannot replace an executing target. Cold recovery restores that session, pauses an active target and never automatically resumes. The managed broker serializes operations and owns the app-server process tree; a guardian closes that tree if the broker dies. Its session database is isolated from other CLI/Desktop diff --git a/loopx/zcode_goal_mode/bridge.py b/loopx/zcode_goal_mode/bridge.py index 4ce56fef3d..4cec542a58 100644 --- a/loopx/zcode_goal_mode/bridge.py +++ b/loopx/zcode_goal_mode/bridge.py @@ -11,7 +11,9 @@ import sys from typing import Any -from ..agent_registry import agent_profile_for_goal, registered_agent_ids_for_goal, require_registered_agent_id +from ..agent_registry import agent_profile_for_goal, load_goal_from_registry, registered_agent_ids_for_goal, require_registered_agent_id +from ..control_plane.coordination.local_authority import local_authority_is_promoted +from ..control_plane.goals.acceptance import inspect_goal_acceptance from ..control_plane.goals.activation import goal_is_stopped from ..control_plane.goals.goal_ref_validation import exact_goal_ref, require_goal_id from ..control_plane.runtime.goal_project_route import resolve_goal_project_route @@ -166,6 +168,46 @@ def _heartbeat_task(binding: dict[str, Any], loopx_command: list[str]) -> str: return body +def _goal_basis(binding: dict[str, Any]) -> dict[str, Any]: + """Read the current criterion/source revisions the native objective must be consumed under. + + This is the Session RFC basis: existing acceptance-contract and explicit + authority-source revisions, not a whole-prompt hash. An unpromoted Goal has + no acceptance owner and reports ``None``; an unreadable owner fails closed so + a stale basis is never presented as current. + """ + registry_path = Path(binding["registry"]) + goal = load_goal_from_registry(registry_path, binding["goal_id"]) + if goal is None: + raise ZCodeGoalBridgeError("Goal must be registered exactly once.", code="zcode_goal_authority_changed", status=409) + sources: list[dict[str, Any]] = [] + for item in goal.get("authority_sources") or []: + if not isinstance(item, dict): + continue + source_id = str(item.get("id") or item.get("source_id") or "") + if not source_id: + continue + revision = item.get("revision") + sources.append({"id": source_id, "revision": revision if isinstance(revision, (int, str)) and not isinstance(revision, bool) else None}) + sources.sort(key=lambda row: row["id"]) + acceptance: dict[str, Any] | None = None + if local_authority_is_promoted(runtime_root=Path(binding["runtime_root"]), goal_id=binding["goal_id"]): + try: + observed = inspect_goal_acceptance(registry_path=registry_path, goal_id=binding["goal_id"], runtime_root=binding["runtime_root"]) + except (ValueError, TypeError, OSError, RuntimeError) as exc: + raise ZCodeGoalBridgeError( + "Current Goal acceptance basis is unavailable. Read status and repair the Goal authority before starting or resuming ZCode.", + code="zcode_goal_basis_unavailable", status=503, + ) from exc + revision = observed.get("revision") + digest = observed.get("contract_digest") + acceptance = { + "revision": revision if isinstance(revision, int) and not isinstance(revision, bool) else None, + "contract_digest": digest if isinstance(digest, str) else None, + } + return {"acceptance": acceptance, "authority_sources": sources} + + def zcode_goal_operation( *, action: str, registry_path: Path, goal_id: str, agent_id: str, project: str | Path | None = None, cli_path: str | None = None, @@ -230,6 +272,8 @@ def zcode_goal_operation( request["cli_path"] = request["cli_command"][-2] if action in {"bind", "start"}: request["task_body"] = _heartbeat_task(binding, loopx_command) + if action in {"bind", "start", "resume"}: + request["goal_basis"] = _goal_basis(binding) payload = _run_json([node, "--experimental-strip-types", str(Path(__file__).with_name("cli.ts"))], project=binding["project"], request=request) for key in ("goal_id", "goal_ref", "goal_creation_operation_id", "agent_id"): if key not in payload or payload[key] != binding[key]: diff --git a/loopx/zcode_goal_mode/cli.ts b/loopx/zcode_goal_mode/cli.ts index 33e0785c02..aed919e0ff 100644 --- a/loopx/zcode_goal_mode/cli.ts +++ b/loopx/zcode_goal_mode/cli.ts @@ -7,7 +7,7 @@ import type {JsonObject} from "../control_plane/effect_program.ts"; import {BARE_SHA256_PATTERN} from "../control_plane/content_digest.ts"; import {dirname, isAbsolute, join} from "node:path"; import {fileURLToPath} from "node:url"; -import {ZCODE_GOAL_ACTIONS, sameBinding, ZCodeGoalError, safeFailure, type NativeRequest, type ZCodeGoalReadback} from "./contract.ts"; +import {ZCODE_GOAL_ACTIONS, isGoalBasis, sameBinding, ZCodeGoalError, safeFailure, type NativeRequest, type ZCodeGoalReadback} from "./contract.ts"; import {NativeGoalController, readState, atomicState, type BindingState} from "./runtime.ts"; import {ZCodeAppServer} from "./app-server.ts"; import {acquireFileMutationLock, releaseFileMutationLock, durableWriteJson} from "../control_plane/effect_runtime_io.ts"; @@ -33,6 +33,7 @@ function parseRequest(raw: unknown): NativeRequest { || !Array.isArray(r.loopx_command) || !r.loopx_command.length || !Array.isArray(r.validation_command) || !r.validation_command.length || (r.cli_command !== undefined && (!Array.isArray(r.cli_command) || !r.cli_command.length)) + || (r.goal_basis !== undefined && !isGoalBasis(r.goal_basis)) || ![...r.loopx_command, ...r.validation_command, ...(r.cli_command ?? [])].every(v => typeof v === "string" && v.length > 0)) { throw new ZCodeGoalError("Invalid ZCode binding operation"); } @@ -176,7 +177,7 @@ async function serve(request: NativeRequest): Promise { throw new ZCodeGoalError("ZCode controller binding changed"); } clearTimeout(idle); idle = undefined; - const reply = await controller!.operate(incoming.action, incoming.model_selection, incoming.task_body); + const reply = await controller!.operate(incoming.action, incoming.model_selection, incoming.task_body, incoming.goal_basis); if (reply.ok && (incoming.action === "start" || incoming.action === "resume")) { clearTimeout(executionDeadline); if (reply.native?.running || reply.native?.status === "active") { diff --git a/loopx/zcode_goal_mode/contract.ts b/loopx/zcode_goal_mode/contract.ts index 3d69183644..7dc74366b1 100644 --- a/loopx/zcode_goal_mode/contract.ts +++ b/loopx/zcode_goal_mode/contract.ts @@ -26,6 +26,12 @@ export type ZCodeGoalReadback = { native: NativeObservation | null; quota: QuotaObservation | null; actions: ZCodeGoalAction[]; }; +/** Current owning requirements the native objective was delivered under: criterion and + * explicit-source revisions, never a whole-prompt hash. Core supplies it on bind/start/resume. */ +export type GoalBasis = { + acceptance: {revision: number | null; contract_digest: string | null} | null; + authority_sources: {id: string; revision: number | string | null}[]; +}; export type NativeRequest = { action: ZCodeGoalAction; project: string; registry: string; goal_id: string; goal_ref: GoalRef; agent_id: string; @@ -33,7 +39,27 @@ export type NativeRequest = { cli_command?: string[]; cli_path?: string; loopx_command: string[]; task_body?: string; validation_command: string[]; goal_creation_operation_id?: string | null; model_selection?: ZCodeModelSelection; + goal_basis?: GoalBasis; }; +export function isGoalBasis(value: unknown): value is GoalBasis { + const basis = value as GoalBasis; + if (!basis || typeof basis !== "object" || Array.isArray(basis)) return false; + const acceptance = basis.acceptance; + if (acceptance !== null && (!acceptance || typeof acceptance !== "object" + || (acceptance.revision !== null && !Number.isSafeInteger(acceptance.revision)) + || (acceptance.contract_digest !== null && typeof acceptance.contract_digest !== "string"))) return false; + return Array.isArray(basis.authority_sources) && basis.authority_sources.length <= 4096 + && basis.authority_sources.every(row => row && typeof row === "object" && typeof row.id === "string" && row.id.length > 0 + && (row.revision === null || typeof row.revision === "string" || Number.isSafeInteger(row.revision))); +} +/** Canonical comparison: order of declared sources is not a semantic change. */ +export function sameGoalBasis(a: GoalBasis, b: GoalBasis): boolean { + const canonical = (basis: GoalBasis) => JSON.stringify({ + acceptance: basis.acceptance === null ? null : {revision: basis.acceptance.revision, contract_digest: basis.acceptance.contract_digest}, + authority_sources: [...basis.authority_sources].map(row => [row.id, row.revision]).sort((x, y) => String(x[0]).localeCompare(String(y[0]))), + }); + return canonical(a) === canonical(b); +} export function sameBinding(a: NativeRequest, b: NativeRequest): boolean { return a.project === b.project && a.registry === b.registry && a.agent_id === b.agent_id && a.goal_ref.goal_id === b.goal_ref.goal_id diff --git a/loopx/zcode_goal_mode/runtime.ts b/loopx/zcode_goal_mode/runtime.ts index f42c575e5f..8471c321cf 100644 --- a/loopx/zcode_goal_mode/runtime.ts +++ b/loopx/zcode_goal_mode/runtime.ts @@ -4,8 +4,8 @@ import {readFile} from "node:fs/promises"; import {durableWriteJson} from "../control_plane/effect_runtime_io.ts"; import {BARE_SHA256_PATTERN} from "../control_plane/content_digest.ts"; import type {JsonObject} from "../control_plane/effect_program.ts"; -import type {NativeObservation, NativeRequest, QuotaObservation, ZCodeModelSelection, ZCodeGoalAction, ZCodeGoalReadback} from "./contract.ts"; -import {sameBinding, ZCodeGoalError, safeFailure} from "./contract.ts"; +import type {GoalBasis, NativeObservation, NativeRequest, QuotaObservation, ZCodeModelSelection, ZCodeGoalAction, ZCodeGoalReadback} from "./contract.ts"; +import {isGoalBasis, sameBinding, sameGoalBasis, ZCodeGoalError, safeFailure} from "./contract.ts"; export interface NativeHost { initialize(): Promise; @@ -27,7 +27,13 @@ export type BindingState = { objective_sha256: string; start_pending?: boolean; last_execution_error?: "zcode_native_execution_failed"; + /** Basis the current native objective was set under. Absent for journals written before it was + * recorded or started without one; such a target cannot prove the current basis and refuses a plain + * resume until an explicit stop/start records the basis it delivers. */ + goal_basis?: GoalBasis; }; +export const GOAL_BASIS_CHANGED = "goal_basis_changed"; +export const GOAL_BASIS_UNAVAILABLE = "goal_basis_unavailable"; export type ControllerDependencies = { host: NativeHost; validate: (cleanupOnly?: boolean) => Promise; @@ -49,7 +55,8 @@ export async function readState(path: string): Promise { || (state.target_id !== null && (typeof state.target_id !== "string" || !state.target_id)) || (state.last_execution_error !== undefined && state.last_execution_error !== "zcode_native_execution_failed") || (state.start_pending !== undefined && typeof state.start_pending !== "boolean") - || typeof state.objective_sha256 !== "string" || !BARE_SHA256_PATTERN.test(state.objective_sha256)) { + || typeof state.objective_sha256 !== "string" || !BARE_SHA256_PATTERN.test(state.objective_sha256) + || (state.goal_basis !== undefined && !isGoalBasis(state.goal_basis))) { throw new ZCodeGoalError("Unsupported ZCode binding; inspect or remove it before binding"); } return state; @@ -165,7 +172,7 @@ export class NativeGoalController { return true; } - async operate(action: ZCodeGoalAction, model?: ZCodeModelSelection, taskBody?: string): Promise { + async operate(action: ZCodeGoalAction, model?: ZCodeModelSelection, taskBody?: string, goalBasis?: GoalBasis): Promise { return this.exclusive(async () => { try { // Read-only status may show revocation; cleanup of this exact native session remains allowed. @@ -208,6 +215,15 @@ export class NativeGoalController { if (!this.observed?.selected_model) { this.reason = "Select an available ZCode model before starting"; return this.readback(false); } + if (action === "resume") { + // A plain resume cannot re-deliver current requirements into the native conversation, so it + // must prove the objective was delivered under the current criterion/source basis. Without + // a recorded basis (a journal written before one existed) or a presented current basis + // there is no proof; the target stays paused until an explicit stop/start records the basis + // it delivers. Resume never backfills a basis it did not deliver. + if (!this.state.goal_basis || !goalBasis) { this.reason = GOAL_BASIS_UNAVAILABLE; return this.readback(false); } + if (!sameGoalBasis(this.state.goal_basis, goalBasis)) { this.reason = GOAL_BASIS_CHANGED; return this.readback(false); } + } const quota = await this.admission(); if (!quota.should_run) { this.reason = quota.reason ?? "quota_denied"; @@ -220,6 +236,7 @@ export class NativeGoalController { if (!objective?.trim()) throw new ZCodeGoalError("A canonical LoopX task body is required"); this.state.request.task_body = objective; this.state.objective_sha256 = objectiveDigest(objective); + if (goalBasis) this.state.goal_basis = goalBasis; else delete this.state.goal_basis; this.state.start_pending = true; await this.dependencies.persist(this.state); const next = await this.dependencies.host.setGoal(this.state.session_id, objective); @@ -250,6 +267,7 @@ export class NativeGoalController { } this.state.start_pending = false; delete this.state.last_execution_error; + delete this.state.goal_basis; await this.dependencies.persist(this.state); this.reason = "user_stopped"; } diff --git a/tests/control_plane_ts/zcode_native_goal.test.ts b/tests/control_plane_ts/zcode_native_goal.test.ts index 00df16f630..c2aa9c72f0 100644 --- a/tests/control_plane_ts/zcode_native_goal.test.ts +++ b/tests/control_plane_ts/zcode_native_goal.test.ts @@ -45,6 +45,18 @@ async function connected() { await controller.initialize(); return {...f, controller}; } +const basisA = {acceptance: {revision: 3, contract_digest: "a".repeat(64)}, authority_sources: [{id: "src-1", revision: 2}, {id: "src-0", revision: "r1"}]}; +const basisAReordered = {...basisA, authority_sources: [basisA.authority_sources[1], basisA.authority_sources[0]]}; +/** A journal written before any basis was recorded: the previous provider shape, same session and target. */ +async function legacyJournal() { + const f = fixture(); + const legacy: BindingState = {schema: "loopx_zcode_native_binding_v0", request: f.request, + session_id: "session-a", target_id: "target-a", objective_sha256: "a".repeat(64)}; + f.setObservation({target_id: "target-a", status: "paused", running: false, objective_sha256: "a".repeat(64)}); + const controller = new NativeGoalController(f.request, structuredClone(legacy), f.deps); + await controller.initialize(); + return {...f, controller, legacy}; +} test("binding creates a real host session without starting a native Goal", async () => { const f = await connected(); assert.deepEqual(f.calls, ["initialize", "create"]); @@ -60,12 +72,14 @@ test("quota denial starts no native Goal and preserves the decision", async () = }); test("pause and resume preserve exact native session and target", async () => { const f = await connected(); - const start = await f.controller.operate("start"); + const start = await f.controller.operate("start", undefined, undefined, basisA); assert.equal(start.native?.running, true); const paused = await f.controller.operate("pause"); assert.equal(paused.native?.status, "paused"); assert.equal(paused.native?.running, false); - const resumed = await f.controller.operate("resume"); + const resumed = await f.controller.operate("resume", undefined, undefined, basisA); + assert.equal(resumed.ok, true); + assert.equal(resumed.native?.running, true); assert.equal(resumed.native?.session_id, start.native?.session_id); assert.equal(resumed.native?.target_id, start.native?.target_id); assert.equal(f.calls.filter(c => c === "set").length, 1); @@ -80,11 +94,12 @@ test("concurrent starts are serialized and never replace an existing native Goal }); test("revoked quota pauses an executing target and blocks explicit resume", async () => { const f = await connected(); - await f.controller.operate("start"); f.setPermitted(false); + await f.controller.operate("start", undefined, undefined, basisA); f.setPermitted(false); await f.controller.check(); assert.equal(f.observation().status, "paused"); assert.equal(f.observation().running, false); - await f.controller.operate("resume"); + const refused = await f.controller.operate("resume", undefined, undefined, basisA); + assert.equal(refused.reason, "goal_stopped", "quota, not the unchanged basis, is what refuses here"); assert.equal(f.calls.includes("resume"), false); }); test("quota transport failure fails closed and is not a successful empty decision", async () => { @@ -237,3 +252,135 @@ test("a native background model failure is observed, paused and retained", async assert.equal(status.ok, false); assert.equal(status.reason, "zcode_native_execution_failed"); }); + +test("resume with the same recorded criterion/source basis keeps the native target running", async () => { + const f = await connected(); + await f.controller.operate("start", undefined, undefined, basisA); + assert.deepEqual(f.journals.at(-1)?.goal_basis, basisA); + await f.controller.operate("pause"); + const resumed = await f.controller.operate("resume", undefined, undefined, basisAReordered); + assert.equal(resumed.ok, true); + assert.equal(resumed.native?.running, true); + assert.equal(f.calls.filter(c => c === "resume").length, 1); +}); +test("a changed acceptance revision refuses a plain resume and keeps the target paused", async () => { + const f = await connected(); + await f.controller.operate("start", undefined, undefined, basisA); + await f.controller.operate("pause"); + const refused = await f.controller.operate("resume", undefined, undefined, + {...basisA, acceptance: {revision: 4, contract_digest: "b".repeat(64)}}); + assert.equal(refused.ok, false); + assert.equal(refused.reason, "goal_basis_changed"); + assert.equal(refused.native?.status, "paused"); + assert.equal(refused.native?.target_id, "target-a"); + assert.equal(f.calls.includes("resume"), false); + assert.deepEqual(f.journals.at(-1)?.goal_basis, basisA, "the recorded basis is not rewritten to follow new intent"); +}); +test("a changed declared-source revision refuses a plain resume", async () => { + const f = await connected(); + await f.controller.operate("start", undefined, undefined, basisA); + await f.controller.operate("pause"); + const refused = await f.controller.operate("resume", undefined, undefined, + {...basisA, authority_sources: [{id: "src-1", revision: 3}, {id: "src-0", revision: "r1"}]}); + assert.equal(refused.reason, "goal_basis_changed"); + assert.equal(f.calls.includes("resume"), false); +}); +test("a resume that cannot present the current basis stays paused", async () => { + const f = await connected(); + await f.controller.operate("start", undefined, undefined, basisA); + await f.controller.operate("pause"); + const refused = await f.controller.operate("resume"); + assert.equal(refused.ok, false); + assert.equal(refused.reason, "goal_basis_unavailable"); + assert.equal(f.calls.includes("resume"), false); +}); +test("stop then start consumes the current basis through a fresh native objective", async () => { + const f = await connected(); + await f.controller.operate("start", undefined, undefined, basisA); + await f.controller.operate("pause"); + const current = {...basisA, acceptance: {revision: 4, contract_digest: "b".repeat(64)}}; + assert.equal((await f.controller.operate("resume", undefined, undefined, current)).reason, "goal_basis_changed"); + const stopped = await f.controller.operate("stop"); + assert.equal(stopped.native?.target_id, null); + assert.equal(f.journals.at(-1)?.goal_basis, undefined); + const started = await f.controller.operate("start", undefined, undefined, current); + assert.equal(started.native?.running, true); + assert.deepEqual(f.journals.at(-1)?.goal_basis, current); + assert.equal(f.calls.filter(c => c === "set").length, 2); +}); +test("a journal recorded before any basis cannot prove the current one and keeps its target paused", async () => { + const f = await legacyJournal(); + const journalsAfterRecovery = f.journals.length; + for (const presented of [basisA, undefined]) { + const refused = await f.controller.operate("resume", undefined, undefined, presented); + assert.equal(refused.ok, false); + assert.equal(refused.reason, "goal_basis_unavailable"); + assert.equal(refused.native?.target_id, "target-a"); + assert.equal(refused.native?.status, "paused"); + } + assert.equal(f.calls.includes("resume"), false); + assert.equal(f.journals.length, journalsAfterRecovery, "a refused resume writes nothing"); + assert.equal(f.journals.at(-1)?.goal_basis, undefined, "resume never backfills a basis it did not deliver"); +}); +test("a legacy journal file is read back byte for byte and left unchanged by a refused resume", async () => { + const {mkdtemp, readFile, rm} = await import("node:fs/promises"); + const {join} = await import("node:path"); + const {tmpdir} = await import("node:os"); + const {atomicState, readState} = await import("../../loopx/zcode_goal_mode/runtime.ts"); + const dir = await mkdtemp(join(tmpdir(), "zcode-legacy-")); + try { + const path = join(dir, "binding.json"); + const f = fixture(); + const legacy: BindingState = {schema: "loopx_zcode_native_binding_v0", request: f.request, + session_id: "session-a", target_id: "target-a", objective_sha256: "a".repeat(64)}; + await atomicState(path, legacy); + const bytes = await readFile(path); + f.setObservation({target_id: "target-a", status: "paused", running: false, objective_sha256: "a".repeat(64)}); + const controller = new NativeGoalController(f.request, await readState(path), {...f.deps, persist: value => atomicState(path, value)}); + await controller.initialize(); + const refused = await controller.operate("resume", undefined, undefined, basisA); + assert.equal(refused.reason, "goal_basis_unavailable"); + assert.deepEqual(await readFile(path), bytes); + assert.equal((await readState(path))?.goal_basis, undefined); + } finally { await rm(dir, {recursive: true, force: true}); } +}); +test("a legacy journal stays readable and stoppable; stop then start records the basis it delivers", async () => { + const f = await legacyJournal(); + const status = await f.controller.operate("status"); + assert.equal(status.ok, true); + assert.equal(status.reason, undefined); + assert.deepEqual(status.actions, ["status", "stop", "resume"]); + assert.equal((await f.controller.operate("pause")).reason, "user_paused"); + const stopped = await f.controller.operate("stop"); + assert.equal(stopped.ok, true); + assert.equal(stopped.native?.target_id, null); + assert.equal(f.journals.at(-1)?.goal_basis, undefined); + const started = await f.controller.operate("start", undefined, undefined, basisA); + assert.equal(started.native?.running, true); + assert.deepEqual(f.journals.at(-1)?.goal_basis, basisA, "start records exactly the basis it delivered"); + await f.controller.operate("pause"); + const resumed = await f.controller.operate("resume", undefined, undefined, basisAReordered); + assert.equal(resumed.ok, true); + assert.equal(resumed.native?.running, true); + await f.controller.operate("pause"); + const changed = await f.controller.operate("resume", undefined, undefined, {...basisA, acceptance: {revision: 4, contract_digest: "b".repeat(64)}}); + assert.equal(changed.reason, "goal_basis_changed"); + assert.equal(f.calls.filter(c => c === "resume").length, 1); +}); +test("a malformed recorded basis is an unsupported binding", async () => { + const f = await connected(); + const state = structuredClone(f.controller.state); + (state as unknown as {goal_basis: unknown}).goal_basis = {acceptance: {revision: "3", contract_digest: null}, authority_sources: []}; + const {writeFile, mkdtemp, rm} = await import("node:fs/promises"); + const {join} = await import("node:path"); + const {tmpdir} = await import("node:os"); + const {readState} = await import("../../loopx/zcode_goal_mode/runtime.ts"); + const dir = await mkdtemp(join(tmpdir(), "zcode-basis-")); + try { + const path = join(dir, "binding.json"); + await writeFile(path, JSON.stringify(state)); + await assert.rejects(readState(path), /Unsupported ZCode binding/); + await writeFile(path, JSON.stringify({...state, goal_basis: basisA})); + assert.deepEqual((await readState(path))?.goal_basis, basisA); + } finally { await rm(dir, {recursive: true, force: true}); } +}); diff --git a/tests/test_zcode_goal_bridge.py b/tests/test_zcode_goal_bridge.py index d98f085de6..8c7d2095c3 100644 --- a/tests/test_zcode_goal_bridge.py +++ b/tests/test_zcode_goal_bridge.py @@ -686,3 +686,210 @@ def test_provider_must_echo_creation_witness_even_when_null(authority, monkeypat monkeypatch.setattr(bridge, "_run_json", lambda *args, **kwargs: {"ok": True, "goal_id": "delivery", "goal_ref": {"goal_id": "delivery", "goal_instance_id": INSTANCE_A}, "agent_id": "zcode-worker"}) with pytest.raises(bridge.ZCodeGoalBridgeError, match="did not match"): bridge.zcode_goal_operation(action="status", registry_path=registry, goal_id="delivery", agent_id="zcode-worker") + + +def test_bind_start_and_resume_carry_the_current_goal_basis(authority, monkeypatch, tmp_path): + registry, project, payload = authority + payload["goals"][0]["authority_sources"] = [ + {"id": "src-b", "revision": 2}, {"source_id": "src-a", "revision": "r7"}, {"id": "src-c"}, "not-a-source"] + registry.write_text(json.dumps(payload), encoding="utf-8") + monkeypatch.setattr(bridge, "inspect_goal_acceptance", lambda **kwargs: pytest.fail("an unpromoted Goal has no acceptance owner to read")) + requests = _stub_operation(monkeypatch) + cli = tmp_path / "zcode.mjs" + cli.write_text("", encoding="utf-8") + bridge.zcode_goal_operation(action="bind", registry_path=registry, goal_id="delivery", agent_id="zcode-worker", project=project, cli_path=str(cli)) + bridge.zcode_goal_operation(action="start", registry_path=registry, goal_id="delivery", agent_id="zcode-worker") + bridge.zcode_goal_operation(action="resume", registry_path=registry, goal_id="delivery", agent_id="zcode-worker") + expected = {"acceptance": None, "authority_sources": [ + {"id": "src-a", "revision": "r7"}, {"id": "src-b", "revision": 2}, {"id": "src-c", "revision": None}]} + native = [request for _, request in requests if request is not None] + assert [request["action"] for request in native] == ["bind", "start", "resume"] + assert all(request["goal_basis"] == expected for request in native) + assert "task_body" not in native[2], "resume re-reads the basis, not a whole prompt" + + +def test_promoted_acceptance_revision_is_part_of_the_basis(authority, monkeypatch): + registry, _, _ = authority + seen = [] + monkeypatch.setattr(bridge, "local_authority_is_promoted", lambda **kwargs: True) + monkeypatch.setattr(bridge, "inspect_goal_acceptance", lambda **kwargs: seen.append(kwargs) or { + "ok": True, "status": "loaded", "revision": 5, "contract_digest": "c" * 64, "provider_revision": "p9"}) + requests = _stub_operation(monkeypatch) + bridge.zcode_goal_operation(action="resume", registry_path=registry, goal_id="delivery", agent_id="zcode-worker") + assert seen[0]["goal_id"] == "delivery" and "agent_id" not in seen[0] + _, native = requests[0] + assert native["goal_basis"] == {"acceptance": {"revision": 5, "contract_digest": "c" * 64}, "authority_sources": []} + + +def test_unreadable_acceptance_basis_fails_closed_before_the_provider(authority, monkeypatch): + registry, _, _ = authority + monkeypatch.setattr(bridge, "local_authority_is_promoted", lambda **kwargs: True) + monkeypatch.setattr(bridge, "inspect_goal_acceptance", lambda **kwargs: (_ for _ in ()).throw(ValueError("authority unavailable"))) + requests = _stub_operation(monkeypatch) + with pytest.raises(bridge.ZCodeGoalBridgeError, match="acceptance basis is unavailable") as failure: + bridge.zcode_goal_operation(action="resume", registry_path=registry, goal_id="delivery", agent_id="zcode-worker") + assert failure.value.code == "zcode_goal_basis_unavailable" and failure.value.status == 503 + assert requests == [], "no provider effect is issued under an unknown basis" + + +def test_status_pause_and_stop_do_not_read_the_basis(authority, monkeypatch): + registry, _, _ = authority + monkeypatch.setattr(bridge, "local_authority_is_promoted", lambda **kwargs: pytest.fail("read-only and stop operations need no basis")) + requests = _stub_operation(monkeypatch) + for action in ("status", "pause", "stop"): + bridge.zcode_goal_operation(action=action, registry_path=registry, goal_id="delivery", agent_id="zcode-worker") + assert all("goal_basis" not in request for _, request in requests) + + +# A stateful NDJSON stand-in for the external ZCode host. It persists its one session in the +# isolated ZCODE_STORAGE_DIR so a rebuilt broker restores the same paused target. Nothing else in +# the path below is simulated: the Python bridge, the TS broker, binding validation, quota admission, +# the heartbeat task and the file-backed acceptance owner are the real ones. +_NATIVE_HOST_STUB = r""" +const fs = require("node:fs"); const path = require("node:path"); const readline = require("node:readline"); +if (process.argv[2] !== "app-server") process.exit(2); +const file = path.join(process.env.ZCODE_STORAGE_DIR, "fixture-state.json"); +let s = {target: null, revision: 0, running: false, selectedModel: {providerId: "synthetic", modelId: "synthetic"}}; +try { s = JSON.parse(fs.readFileSync(file, "utf8")); } catch {} +const save = () => fs.writeFileSync(file, JSON.stringify(s)); +const snapshot = () => ({protocol: {name: "ZCode Protocol", version: 1}, session: {sessionId: "sess_fixture"}, + settings: {model: {current: s.selectedModel, available: [{ref: {providerId: "synthetic", modelId: "synthetic"}, label: "Synthetic", + reasoning: {levels: [{value: "disabled", label: "disabled"}], defaultLevel: "disabled"}}]}}, + projection: {sessionId: "sess_fixture", status: s.running ? "running" : "idle", + target: s.target && {sessionId: "sess_fixture", targetId: s.target.id, objective: s.target.objective, status: s.target.status}}, + runtime: {stateRevision: s.revision}}); +const send = value => process.stdout.write(JSON.stringify(value) + "\n"); +readline.createInterface({input: process.stdin}).on("line", line => { + const r = JSON.parse(line); + if (r.result !== undefined || r.error !== undefined) return; + if (r.method === "runtime/capabilities") return send({id: r.id, result: {independentPlanState: true}}); + if (["session/create", "session/resume", "session/read"].includes(r.method)) return send({id: r.id, result: snapshot()}); + if (r.method !== "session/goal") return send({id: r.id, error: {code: -32601, message: "unsupported"}}); + if (r.params.expectedRevision !== s.revision) return send({id: r.id, error: {code: -32009, message: "CAS rejected"}}); + const a = r.params.action; + if (a === "set") { s.target = {id: "goal-" + (s.revision + 1), objective: r.params.objective.trim(), status: "active"}; s.running = true; } + if (a === "resume") { s.target.status = "active"; s.running = true; } + if (a === "pause") { if (s.target) s.target.status = "paused"; s.running = false; } + if (a === "clear") { s.target = null; s.running = false; } + s.revision++; save(); + send({id: r.id, result: {response: "", snapshot: snapshot(), startedTurn: a === "set" || a === "resume"}}); +}); +""" + + +def _acceptance_document(objective: str) -> dict: + return {"scope": {"kind": "all_advancement"}, "objective": objective, "non_goals": [], "bindings": [], + "criteria": [{"id": "proof", "description": "The independent check passes", "validation_argv": ["true"], + "validation_timeout_seconds": 5, "validation_files": []}]} + + +@pytest.fixture +def promoted_native_goal(authority, tmp_path, monkeypatch): + """A quota-admitted Goal whose acceptance contract lives in a real promoted file authority.""" + import hashlib + monkeypatch.syspath_prepend(str(Path(__file__).parent / "control_plane")) + from canonical_authority_fixture import initialize_canonical_authority + from loopx.control_plane.coordination.local_authority_shadow_projection import canonical_bytes + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + + registry, project, payload = authority + state_file = project / "state.md" + state_file.write_text("# Goal\n\n## Current State\n\n- active\n", encoding="utf-8") + payload["goals"][0].update({ + "state_file": str(state_file), "domain": "delivery", "adapter": {"kind": "generic_project_goal_v0", "status": "connected"}, + "quota": {"compute": 1.0, "window_hours": 24, "slot_minutes": 1, "allowed_slots": 10}, + }) + registry.write_text(json.dumps(payload), encoding="utf-8") + projection = build_todo_runtime_shadow_projection(goal_id="delivery", todos=[]) + document = _acceptance_document("Original objective") + projection["goal_acceptance"] = {"schema_version": "loopx_goal_acceptance_v0", "enabled": True, "revision": 1, "bindings": [], + "digest": hashlib.sha256(canonical_bytes(document)).hexdigest(), "document": document, "verification": None} + runtime_root = Path(payload["common_runtime_root"]) + initialize_canonical_authority(runtime_root, "delivery", projection, state_path=state_file, provider="file") + stub = tmp_path / "zcode-stub.cjs" + stub.write_text(_NATIVE_HOST_STUB, encoding="utf-8") + journal_dir = runtime_root / "zcode-goal" + yield registry, project, stub, journal_dir + for endpoint in journal_dir.glob("*.endpoint"): + _terminate_broker(endpoint) + + +def _terminate_broker(endpoint: Path) -> None: + import os + import signal + try: + pid = json.loads(endpoint.read_text(encoding="utf-8"))["pid"] + except (OSError, ValueError, KeyError): + return + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + return + import time + for _ in range(100): + try: + os.kill(pid, 0) + except ProcessLookupError: + return + time.sleep(0.1) + + +def _revise_acceptance(registry: Path, objective: str) -> dict: + from loopx.control_plane.goals.acceptance import configure_goal_acceptance, inspect_goal_acceptance + before = inspect_goal_acceptance(registry_path=registry, goal_id="delivery") + applied = configure_goal_acceptance(registry_path=registry, goal_id="delivery", expected_provider_revision=str(before["provider_revision"]), + document=_acceptance_document(objective), operation_id=f"revise-{objective}", execute=True) + assert applied["status"] == "applied" + return bridge._goal_basis(bridge.validate_zcode_binding(registry_path=registry, goal_id="delivery", agent_id="zcode-worker")) + + +def test_journal_without_recorded_basis_refuses_resume_until_stop_start_records_it(promoted_native_goal): + registry, project, stub, journal_dir = promoted_native_goal + + def operate(action, **extra): + return bridge.zcode_goal_operation(action=action, registry_path=registry, goal_id="delivery", agent_id="zcode-worker", **extra) + + assert operate("bind", project=project, cli_path=str(stub))["ok"] is True + started = operate("start") + assert started["ok"] is True and started["native"]["running"] is True + (journal,) = journal_dir.glob("*.json") + recorded = json.loads(journal.read_text(encoding="utf-8")) + assert recorded["goal_basis"]["acceptance"]["revision"] == 1 + assert operate("pause")["native"]["status"] == "paused" + # The previous provider release journaled the same session and target without any basis. + # Rebuild the broker on that journal exactly as an upgraded install would recover it. + _terminate_broker(journal.with_name(journal.name + ".endpoint")) + legacy = {key: value for key, value in recorded.items() if key != "goal_basis"} + legacy_bytes = (json.dumps(legacy, indent=1, ensure_ascii=False) + "\n").encode("utf-8") + journal.write_bytes(legacy_bytes) + current = _revise_acceptance(registry, "Revised objective") + assert current["acceptance"]["revision"] == 2 + + refused = operate("resume") + assert refused["ok"] is False + assert refused["reason"] == "goal_basis_unavailable", "an unrecorded basis is never compared as if it were the current one" + assert refused["native"]["target_id"] == recorded["target_id"] + assert refused["native"]["status"] == "paused" and refused["native"]["running"] is False + assert journal.read_bytes() == legacy_bytes, "recovery and a refused resume rewrite nothing" + status = operate("status") + assert status["ok"] is True and status["native"]["status"] == "paused" + assert status["actions"] == ["status", "select_model", "stop", "resume"] + assert operate("pause")["reason"] == "user_paused" + stopped = operate("stop") + assert stopped["ok"] is True and stopped["native"]["target_id"] is None + assert "goal_basis" not in json.loads(journal.read_text(encoding="utf-8")) + + restarted = operate("start") + assert restarted["ok"] is True and restarted["native"]["running"] is True + assert json.loads(journal.read_text(encoding="utf-8"))["goal_basis"] == current + assert operate("pause")["native"]["status"] == "paused" + resumed = operate("resume") + assert resumed["ok"] is True and resumed["native"]["running"] is True + assert resumed["native"]["target_id"] == restarted["native"]["target_id"] + assert operate("pause")["native"]["status"] == "paused" + assert _revise_acceptance(registry, "Revised again")["acceptance"]["revision"] == 3 + changed = operate("resume") + assert changed["ok"] is False and changed["reason"] == "goal_basis_changed" + assert changed["native"]["status"] == "paused" + assert json.loads(journal.read_text(encoding="utf-8"))["goal_basis"] == current, "the recorded basis never follows new intent" + assert operate("stop")["ok"] is True