diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 881556b017..ffb7a6d524 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -737,6 +737,125 @@ Onboarding 输入来自正式 guided packet builder;provider 调用前只替 门禁错误、写入或 quota 消耗。Human gate 的优先级是显式规则:等待用户时没有 executable work 属于预期状态,不能误判为 projection gap。 +## Release-only native Goal regression / 仅发布前的原生 Goal 回归 + +`scripts/qualify-native-goal-release.py` exercises the real Codex CLI app-server +Goal lifecycle on a disposable ledger project with two dependent Todos. It +reuses the shipped native Goal transport and current prompt, then checks an +independent acceptance oracle, completed Todos, unique bound spends, durable +writeback readback, and terminal no-follow-up quota. This is not a benchmark +score or evidence of universal model reliability. + +The Codex release arm requests the shipped bootstrap, not an injected private +work recipe. Deterministic regressions execute the saved CLI loader, change its +registry inputs, and prove fresh loading, non-recursion, preserved explicit +policy and removed-agent rejection. Claude's stdio regression loads +`host_prompt` through the real MCP transport and verifies the same bound Goal. +These tests are free of model calls; passing them is not a live model pass. + +Upgrade regression uses real temporary SQLite/TOML stores, a second connection, +writer-lock contention, injected mirror failure, journal recovery and stale or +custom-input rejection. New-runtime reconciliation is exercised through the +real CLI, while package installation is substituted in that focused test. +Running-App deployment additionally needs a selected owner-authorized canary +and delayed readback; synthetic SQLite tests alone do not qualify App caches. +The output differential permits a bounded one-time transition to the exact +static-safety marker, not permanent growth allowances or relaxed quota budgets. + +```bash +# No model invocation, no token cost; explicit skipped result, exit 0. +python3 scripts/qualify-native-goal-release.py +# Release operator opt-in only; explicit isolated API profile (Responses API). +# Supply LOOPX_CODEX_QUALIFICATION_API_KEY securely in this process, plus: +export LOOPX_CODEX_QUALIFICATION_MODEL='' +export LOOPX_CODEX_QUALIFICATION_BASE_URL='https://example.com/v1' +python3 scripts/qualify-native-goal-release.py --release-live +``` + +Do not add the live command to default pytest, PR CI, per-diff canaries, or +ordinary developer iteration. The deterministic runner-policy tests may run +there; they never opt into real model execution. Missing CLI, native Goals or +the explicit Codex API profile returns `skipped` and exit 0, not a claimed live pass. +Once qualification is attempted, failed acceptance, incomplete settlement, +blocked/unfinished Goals and deadline expiry fail with exit 1. The default +deadline is 1,200 seconds; this is a wall-clock ceiling, not a token budget. + +仅 release 前显式开启,避免默认消耗开发者 token。CI/本机环境不支持时跳过且不阻塞, +但保留 `skipped` 标记;真实执行后失败不能冒充环境跳过。使用操作者显式选择的 API +模型、地址与密钥,不导入日常 Codex 配置、登录或会话,不修改活跃 Goal/automation。 +任务、registry、runtime 与 Git worktree +在一次性目录内;沙箱允许该目录及本地 TS worker 所需的网络能力, +这不是网络隔离,任务不授权外部操作。回归脚本不采集或上传原始对话/工具日志, +公开结果仅包含状态、计数和错误类别;Codex 会话仅留在一次性隔离目录内。 +两个 runner 均从允许列表创建环境并隔离 HOME、配置和缓存;不透传其他 token、 +认证 socket、shell 启动变量或原始 ARK_API_KEY。Codex 工具 shell 从空环境注入必要 +运行变量,不继承 host API key。Claude host 仅接收所选 provider 的映射密钥;这不是 +对同用户进程或 Claude Bash 的凭据隔离沙箱,不能把真实业务秘密加入测试任务。 + +### Claude Code and release coverage / Claude Code 与发布覆盖 + +For focused thin/brief prompt-decision regression, use +`python3 scripts/qualify-host-prompt-release.py --release-live` only during +explicit release qualification. It defaults to no calls; missing credentials +report `skipped`, not a live pass. With securely injected `ARK_API_KEY`, it uses +Doubao evolving for two independent repetitions of quiet-work, notifying-wait, +quiet-wait and required-vision-replan cases in each mode. Expected decisions +remain outside model input. All attempts must pass; no answer correction or +retry-until-pass is used. Ordinary pytest only checks the probe and negative +oracles with scripted responses, without provider calls. + +This is a synthetic decision-level probe using current generated prompts, +not proof of tool execution, host scheduling, upgrade delivery or full-Goal +completion. Keep the Codex/Claude live Goal arms and real CLI/MCP/SQLite tests +as separate evidence. Only hashes and pass/fail receipts are emitted, not raw +prompts/responses. Model transport failures fail qualification rather than +becoming environment skips. + +仅发布前显式执行,普通 CI 不调用模型。检查静默不等于空转、等待不能擅自执行、 +vision replan 未关闭时不能提前结束 Goal;这不是完整 Claude/Codex 行为验收的替代。 + +```bash +# No provider call by default. Explicit release opt-in uses ARK_API_KEY from the environment. +python3 scripts/qualify-claude-goal-release.py --release-live +``` + +This arm uses the same ledger specification, independent oracle and durable +settlement readback as the Codex arm. It launches actual Claude Code with the +project's shipped `loop.md` and LoopX stdio MCP server, using +`doubao-seed-evolving` through Ark's Anthropic-compatible API. It does not +inherit another Anthropic account, install into the user's Claude configuration, +or retain host sessions. The subprocess timeout also cleans its process group +on POSIX. Allowed local development tools are not a security sandbox; the +synthetic task authorizes no external side effects. + +**A headless work-loop pass is not a `/loop` timer pass.** The release report +explicitly returns `scheduler_qualification=not_run_headless`; interactive +native wakeup, cancel/resume and process-restart behavior need their own host +qualification. Do not turn repeated `claude -p` invocations into a substitute +scheduler and claim host lifecycle coverage. + +Before calling a changed host surface release-qualified, distinguish: + +| Boundary | Required evidence | +| --- | --- | +| Work and terminal closeout | Final candidate, actual host, independent artifact checks, completed Todos and terminal quota; code delivery alone is insufficient. | +| Idempotency and failure | Real committed lifecycle/writeback/spend followed by lost-response injection and same-intent retries; one final spend. Failed declared validation must not complete or spend. | +| Authority and transport | Actual MCP initialization/tool invocation and mismatched-agent rejection; existing claim/lease and validation suites remain required. | +| Host lifecycle | Native scheduler wakeup/cancellation/resume on supported versions, reported separately from headless execution. | +| Upgrade and isolation | Exact managed-wrapper recognition, preview/apply revision checks, preserved scheduler state, explicit skips, no default model calls or leaked test processes. | + +普通 CI 只跑确定性规则、真实 CLI/MCP 和故障注入;模型执行仍仅 release 前显式启用。 +环境缺失可 skip 且退出成功,但最终版本没有完整的真实 host 结果时,不得写成 +“产品级发布验证通过”。单次成功也不是模型可靠性或长程调度 soak 的证明。 + +The real delivery regression covers ordinary Todo acceptance before internal +writeback/spend, existing-successor linking, and receipt-backed terminal closure. +Its task specification describes only the deliverable; the external oracle also +checks LoopX accounting. Passing non-delivery fixtures does not qualify delivery. +The same delivery class must pass failed-validation rejection and committed +response-loss recovery without duplicate spending or premature terminal closure. +Do not relabel delivery work or weaken the independent oracle to pass a host test. + ## Exact Release Commit Gate / 精确发布 Commit 门 The final release gate does not rerun tests through a second orchestration diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index 81062b0c22..75de259fc9 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -55,6 +55,118 @@ transport-neutral goal prompt and lets the goal runtime own inner iteration; see the host integration protocol instead of adapting this recurring automation contract. +### Native Goal bootstrap and live execution instructions + +Brief automation now uses the same fully qualified notification/execution rule +as thin, including `heartbeat_recommendation.agent_must_attempt` and +`execution_obligation.must_attempt_work`. Brief no longer embeds a second +static refresh/spend sequence: after validated work it follows the current +`interaction_contract.cli_channel.settlement_plan.ordered_steps`, or current +`next_cli_actions` when there is no plan. Generator command fields remain for +compatibility, not as a stale fallback. Todo acceptance alone is not Turn +settlement or terminal vision closure. The brief budget remains 3,500 characters. + +Brief 与 thin 共用完整执行义务路径;这次有意移除 brief 固定结算配方,而不是 +删除结算义务。真实 App preflight、registry scope、完整 guard 和静态安全规则 +均保留;结算顺序与身份以本轮动态 contract 为准,vision replan 不由历史成功清账。 + +New supported host activations use `heartbeat-prompt --bootstrap`: a saved +loader requests the installed rules rather than freezing a long execution body. +The loader and automation bootstrap share rendering and successful-response +checks. Registry-derived state is resolved at load time; explicitly supplied +policy remains bound. The inner command does not request another bootstrap. +Claude Code loads its inner body through the bound MCP `host_prompt` tool. +TraeX's separate capability projection remains separate, not embedded by this +loader. See [prompt upgrade lifecycle](reference/automation-prompt-upgrades.md) +for automatic exact-managed adoption during `update --apply`, including the +qualified running-App SQLite/TOML adapter and conflict recovery boundary. + +#### Static semantics retained across hosts + +Thinning removes duplicated recipes, not authority boundaries. The shared +runtime body keeps repository rules, credential/private-material protection, +explicit authorization for destructive Git/production actions, and exception +routes (`loopx-project` for lifecycle/registry, `loopx-self-repair` for drift). +These routes are conditional, not mandatory skill calls on every iteration. +Ordinary Claude MCP iterations still use MCP; the CLI route is not a second +accounting path. + +| Semantic | Owner after thinning | +| --- | --- | +| Privacy, repository rules, dangerous-action authority | Shared static safety rule; a trusted host is not blanket permission | +| Lifecycle or projection repair | Conditional static repair route; repair does not bypass gates | +| Selection, claims, vision replan, exact settlement identities/order | Current successful interaction contract, not saved command recipes | +| Blocked path vs whole Goal | Gate only the affected path; continue independent admitted work; only terminal no-follow-up completes the Goal | +| Git branch/worktree/PR policy | User and repository rules; no generic `No project branches` restriction | +| Prompt authoring/maintenance advice | Documentation, not per-iteration executor instructions | + +For heartbeat shells, assign `LOOPX_TURN` in a separate statement before the +guard, in the same shell invocation. A command-prefix assignment does not make +the variable available to argument expansion in Bash/zsh. Reuse the same value +on retries. Native Goal entry remains host-specific and does not inherit this +heartbeat bootstrap. + +Thin's ceiling is 2,500 characters (previously 1,900), and compact's is 6,500 +(previously 6,200): the additional room covers shared safety and an executable +Turn/guard block rather than omitting identities or static obligations. + +The automation lifecycle is the reference for shared execution, not a wrapper +around native Goal behavior. Thin automation and Codex CLI/SSH, TraeX and Ark +Managed Agent Goal bodies share quota dispatch: selection/re-entry, admitted work +and validation, then the current writeback/settlement instructions. They do not +share scheduler ownership, host completion, or blocked/resume rules. + +Native Goal bodies share a compact bootstrap. +Generate it with the host's existing profile (for example `heartbeat-prompt +--runtime-profile codex_cli --goal-id --agent-id `). +The persistent body binds the Goal/Agent and quota entrypoint; each work iteration +reads the current complete, successful quota JSON. The **inner execution +instructions remain in `interaction_contract`**, including selection/re-entry, +admitted work, and exact `cli_channel.settlement_plan.ordered_steps`. + +Native Goal bodies no longer embed a second static accountable refresh/spend +template. Those command fields remain available in the generator response for +compatibility/inspection, but are not a fallback for the live settlement plan. +When no ordered settlement plan applies, consume the current `next_cli_actions`, +including any required re-entry; do not substitute a saved generator command. +Execute selection/re-entry before work and writeback/spend only after the +corresponding validated work; a projected accounting command is not evidence +that work happened. Preserve the plan's identity and flags, and follow readback +or recovery after an ambiguous write instead of retrying a guessed command. +Failed or incomplete contract reads permit neither work nor spend. + +An unbound Codex CLI or Ark Goal with selected Todo/replan work now receives a +quota re-entry template with `--turn-instance-id`. Fill it with one public-safe +unique work-iteration id and reuse that id on retries. The next packet supplies +the same ordered settlement machinery used by automation, with `visible-goal` +attribution. SSH Goal continues to use its existing `--begin-turn` path. This +fixes the previous unbound native refresh/spend projection: those commands could +not satisfy the existing settlement identity guard. It does not turn CLI/Ark +Goals into App heartbeat receipts or move scheduler ownership into LoopX. + +The bootstrap retains work-sizing guidance and the distinction between progress +and Goal completion. A new Todo is not a new host Goal; quiet/blocked states are +not terminal no-follow-up. Codex alone retains its native blocked/resume rule. +User/repository authority still applies; a trusted host is not blanket permission. +This changes newly generated native Goal bodies and thin automation dispatch, +not active host Goal objectives or benchmark prompts already pinned to a run. +An installed runtime supplies updated dynamic contracts on later reads; upgrading +it does not retroactively remove old text from an existing Goal. + +Claude Code's MCP-backed `loop.md` follows the same work-sizing rule and current +quota contract, without a fixed one-segment limit or empty-Todo-list completion +shortcut. Its `complete_task` tool already owns the ordered writeback/spend +transaction: callers must not perform a second accounting sequence through CLI. +Partial work is not Todo completion. Native `/loop` remains Claude's scheduler; +only the current Goal's wakeup may be cancelled after terminal no-follow-up. +These changes apply when `loop.md` is regenerated, not by editing active user files. +The MCP tool now exposes `successor_todo_ids`, reusing CLI/TS completion semantics +to link known follow-up without creating another Todo. Ordinary acceptance and +Turn settlement are distinct: the adapter validates/completes work before its +writeback/spend; only terminal closeout requires the full receipt chain. This +removes the former delivery-class circular prerequisite, not validation or +accounting. See the [release test guide](development/testing-and-quality.md#claude-code-and-release-coverage--claude-code-与发布覆盖). + For Codex App, the generated quota command carries the compact explicit runtime profile `--runtime-profile codex_app_heartbeat` (generated commands use the equivalent compact alias `--codex-app`). The prompt does not restate the @@ -285,11 +397,6 @@ Replace the placeholders before installing the automation: ```text Advance the goal described in . -Generic LoopX lifecycle. Keep project-specific branching out of the -automation prompt. Put local policy in registry, active-state sections, adapter -output, quota should-run.goal_boundary, or boundary rules; if a lifecycle rule -is needed, update loopx heartbeat-prompt so all projects inherit it. - Before spending delivery compute, first make the LoopX CLI reachable in this automation shell, then run the quota guard: diff --git a/docs/product/release-readiness.md b/docs/product/release-readiness.md index 1941344809..0aabaad849 100644 --- a/docs/product/release-readiness.md +++ b/docs/product/release-readiness.md @@ -144,6 +144,10 @@ Before moving `stable`, maintainers should: - bump `loopx.__version__` and `pyproject.toml` together when user-visible release behavior changes; - create or verify the matching Git tag, for example `v0.1.3`; +- for host Goal/prompt changes, explicitly run the + [release-only native Goal regression](../development/testing-and-quality.md#release-only-native-goal-regression--仅发布前的原生-goal-回归) + in a supported Codex environment; record an unavailable environment as + `skipped`, not a live pass. Never enable paid model execution in default PR CI; - fast-forward `stable` to that tagged commit after the release canary passes; - confirm `release.json`, `loopx doctor`, and `loopx update check` report the same package version and tag; diff --git a/docs/project-agent-todo-contract.md b/docs/project-agent-todo-contract.md index 7ba679a51b..72b389f4ba 100644 --- a/docs/project-agent-todo-contract.md +++ b/docs/project-agent-todo-contract.md @@ -444,11 +444,12 @@ the agent should do one of two things: This succession decision is durable Todo state. A later progress observation, vision ACK, coverage-exhausted result, or rewritten rationale cannot substitute for it. Every new completion therefore retains an opaque completion identity. -A quota-bound completion still permits only the receipt-backed same-turn -`todo complete` transition for agent advancement work. Complete the matching -accountable `refresh-state` and `quota spend-slot` first; explicit -`same_agent_non_delivery` work, monitors, user actions, and user gates keep -their existing lifecycle paths. An ordinary unscoped completion gets a +A quota-bound ordinary completion proves the exact admitted identity and Todo +acceptance (including declared validation), not completion of Turn accounting. +It may precede the same-turn writeback and spend. The existing typed replay +phase remains `settlement_pending` until those receipts exist; Todo `done` alone +does not mean the Turn settled. Terminal `--no-follow-up` still requires the +complete matching writeback/spend chain. An ordinary unscoped completion gets a stable `local_completion_*` identity; if a later `refresh-state` discovers that the finished Goal has no real successor, its typed rejection may project `--completion-identity-key` for one direct lifecycle reentry. That command is @@ -458,11 +459,14 @@ It cannot be supplied for an open Todo or used as a quota turn identity. Otherwise add/link a real successor. Do not create a user gate merely to silence a succession warning. -Compatibility host adapters whose established transaction completes the Todo -before writing the same-turn refresh and quota receipts must explicitly mark -their non-repository work `same_agent_non_delivery`. Repository advancement -through those adapters fails closed with a typed settlement blocker until the -adapter adopts a writeback-and-spend-before-completion transaction. +Host adapters own the internal sequence: validate/complete, write back, spend, +then terminal closeout if requested. A failed internal step is not a request to +redo accepted task work: retry the same identity and recover the missing receipt. +MCP returns success only after the whole requested sequence succeeds. Task +acceptance must not prescribe LoopX bookkeeping, and delivery work must not be +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. This keeps the active checklist honest without making LoopX a heavyweight project-management state machine. diff --git a/docs/reference/automation-prompt-upgrades.md b/docs/reference/automation-prompt-upgrades.md index 22c7808c73..a676c8c4cb 100644 --- a/docs/reference/automation-prompt-upgrades.md +++ b/docs/reference/automation-prompt-upgrades.md @@ -17,6 +17,13 @@ another scheduler. Failure to load a complete successful response stops work and spending; it must not fall back to remembered rules. Project watches and business policy belong in LoopX state, not in this bootstrap. +The v2 wrapper distinguishes continued work, notifications and real waits: +one operation does not end the work; notification silence is not execution +silence; waits follow the live scheduler contract instead of unchanged polling. +Local entrypoint errors may be repaired within existing authority, but an +unavailable contract still forbids delivery and spending. This is not permission +to bypass a gate, retry indefinitely, or disable a healthy automation. + For a one-agent trial, pass `--cli-bin loopx-canary` to preview and apply. The bootstrap and the thin prompt's generated commands both use that executable; other automations continue to use their existing runtime. Do not promote the @@ -26,7 +33,47 @@ The owner is the existing heartbeat/upgrade boundary; there is no new optional capability or extension provider. SQLite/TOML handling is a local host adapter, not Todo, quota or scheduler authority. -## Preview and adopt existing tasks +## Automatic upgrade and manual adoption + +`loopx update --apply` now captures an owner-only snapshot **before** replacing +the runtime and invokes `automation-prompts sync-installed` in the **new** +runtime afterward. Exact recognized managed bootstraps and byte-identical +prompts reproduced by the old installed generator may migrate automatically. +An automation name, matching prose or Goal id alone never authorizes adoption. +Custom instructions remain `review_required`; a canary executable, different +registry/home or changed preview is not silently retargeted. Binary-install +success and prompt-migration success are reported separately. + +**Exact managed v1 wrappers upgrade to v2 automatically through this path; +they do not require per-task approval.** `automation-prompts plan` is only a +read-only preview, not the upgrade executor. Do not infer a manual-only policy +from its `adoption_required` status. Custom or inconsistent entries still need +review; automatic prompt migration never grants scheduler or thread authority. + +On the qualified macOS heartbeat schema this upgrade path can write directly +with the App running. It holds a SQLite writer transaction through TOML delivery, +compares the entire previewed manifest, preserves every non-prompt field, and +reads both stores back. It neither pauses tasks nor kills the App. A scheduler +invocation that already loaded its prompt is not rewritten; the next invocation +can load the new prompt. This is a local storage compatibility adapter, not an +official Codex API or a guarantee against every possible external filesystem +race. Uncoordinated TOML writes cannot participate in the SQLite transaction; +detected changes/crashes retain the private journal and require reconciliation. + +Unsupported platforms/schemas, custom prompts and conflicts are reported per +task. Where an eligible task can instead use the native App writer, the report +includes a complete `automation_update` request with preserved fields and a +fresh-view/hash precondition. A CLI cannot invoke an in-App tool itself. Do not +blindly replay a request after a user edit. A failed runtime installation never +starts prompt migration. A pending report retains the private pre-update plan +so a selected task can be retried, without scanning another Codex home: + +```sh +loopx automation-prompts sync-installed --plan-file ./private-before.json --automation-id TASK_ID --execute +``` + +The existing `plan` command remains read-only and explicit `apply` remains an +offline, reviewed path; it does not automatically adopt custom instructions. ```sh loopx --format json automation-prompts plan --plan-file ./private-prompt-plan.json @@ -62,16 +109,23 @@ history are preserved. Each selected task commits independently; the command reports partial failure instead of claiming the whole batch succeeded. Only existing heartbeat records with matching SQLite/TOML identity, prompt, -status and thread binding are eligible. Unknown schemas and stale previews fail +status, schedule and thread binding are eligible. Unknown schemas and stale previews fail closed. The fallback checks that the macOS App is closed; keep it closed until readback completes. Restart afterward. This adapter targets the observed local schema, **not an official stable Codex storage API**; no Windows/cloud support is claimed. Use the native API if the host changes its storage contract. +A legacy TOML heartbeat label with a SQLite `cron` row is not enough to prove +thread ownership. In particular, a missing database thread binding must not be +filled from TOML by a prompt-only migration. Such records require App-mediated +reconciliation first; the offline adapter reports that action explicitly and +does not convert scheduler kind, infer a thread, or offer an executable upgrade. + ## Recovery and rollback -The fallback stores a private per-task journal before writing. SQLite commit -and TOML replacement are not one transaction: a crash can leave a mirror pending. +The adapter stores a private per-task journal before writing. SQLite commit +and TOML replacement are not one transaction: a crash can leave a mirror pending, +including a new TOML prompt with a rolled-back SQLite prompt. While the App remains closed, recover that exact task: ```sh @@ -89,10 +143,25 @@ for rollback. Disable automatic rule adoption by replacing the bootstrap with an explicitly pinned prompt using the App, or pause the task there. LoopX runtime rollback -also changes the rules loaded on the next wake. Future incompatible bootstrap -revisions still require an explicit migration; the v1 wrapper does not silently -rewrite itself. `upgrade-plan` recognizes exact v1 wrappers as runtime-loaded -thin prompts, rather than repeatedly reporting their body as stale. +also changes the rules loaded on the next wake. Exact v1 wrappers remain +recognized as runtime-loaded thin prompts; `automation-prompts plan` proposes +v2 without writing, while the update-time exact-owned path can migrate them. +Customized wrappers are not recognized merely from their header. + +## Goal host loaders + +`heartbeat-prompt --bootstrap` uses the same shell loader renderer as automation. +New Codex App/SSH, Codex CLI/IDE and managed-agent activations store this loader; +it preserves explicit caller policy but re-resolves registry state on each load. +Its inner command omits `--bootstrap`, preventing recursion. Persisting a fixed +turn-instance id is rejected. Existing saved native Goal objectives are not +rewritten through SQLite. + +Claude Code stores a bound MCP loader: `host_prompt` returns the current inner +rules for its existing Goal and agent. Restart an existing MCP server after a +runtime upgrade; already imported Python code does not hot-reload. TraeX retains +its direct Goal projection when capabilities require a separate host surface; +the generic loader must not move those declarations into prompt text. ## 中文摘要 @@ -100,7 +169,10 @@ thin prompts, rather than repeatedly reporting their body as stale. thin 指令;之后升级 LoopX 即可让下一轮采用新版规则,无需逐版本改 SQLite。 正在运行的轮次不热切换。启动器不是另一套执行规则,也不增加权限。 -先批量预览,再明确接管;自定义内容不猜测合并,不自动删除。App 运行时用 -原生更新接口;离线兼容通道要求关闭 App、精确预览校验、双存储读回,并保留 -私有恢复记录。日程、暂停状态、模型、线程、通知偏好和历史均不迁移。 -本批提供命令行批量迁移,不新增 Dashboard 按钮;也不保证模型行为已通过在线评测。 +升级主流程在替换 runtime 前留存旧模板证据,升级后由新 runtime 生成并迁移。 +仅完整匹配的托管指令可自动更新;自定义内容不猜测合并、不自动删除。macOS +已匹配的 heartbeat 存储支持 App 运行中直接写入,仅改 prompt,持有数据库写锁 +直到 TOML 交付和读回完成。两种存储并非一个原子事务;冲突或异常保留私有日志, +不伪报成功。日程、暂停状态、模型、线程、通知偏好和历史均不迁移。 +不支持的存储仍需原生 API;运行中的本轮不热切换。普通测试不消耗模型 token, +真实模型发布资格仍需独立评测,不能由迁移成功推断。 diff --git a/examples/control_plane/agent-onboard-host-loop-activation-smoke.py b/examples/control_plane/agent-onboard-host-loop-activation-smoke.py index ff348482d6..e680931132 100644 --- a/examples/control_plane/agent-onboard-host-loop-activation-smoke.py +++ b/examples/control_plane/agent-onboard-host-loop-activation-smoke.py @@ -49,6 +49,16 @@ def run_cli( ) +def load_bootstrap(packet: dict, cli_bin: str, home: Path) -> dict: + assert packet["ok"] and packet["bootstrap"] + assert packet["interface_budget"]["within_budget"] + loader = shlex.split(packet["task_body"].split("```sh\n", 1)[1].split("\n```", 1)[0]) + assert "--bootstrap" not in loader + loader[0] = cli_bin + return json.loads(subprocess.run(loader, env={**os.environ, "HOME": str(home)}, + check=True, text=True, capture_output=True, timeout=120).stdout) + + def main() -> int: catalog = build_agent_type_catalog() agent_types = {item["agent_type"] for item in catalog["canonical_agent_types"]} @@ -423,6 +433,7 @@ def main() -> int: ) app_ssh_prompt = json.loads(app_ssh_prompt_run.stdout) assert app_ssh_prompt["ok"] is True, app_ssh_prompt + app_ssh_prompt = load_bootstrap(app_ssh_prompt, cli_bin, home) assert app_ssh_prompt["interface_budget"]["mode"] == "visible_goal", app_ssh_prompt assert app_ssh_prompt["interface_budget"]["max_chars"] == 4_000, app_ssh_prompt assert app_ssh_prompt["interface_budget"]["within_budget"] is True, app_ssh_prompt @@ -500,6 +511,7 @@ def main() -> int: timeout=120, ) cli_prompt = json.loads(cli_prompt_run.stdout) + cli_prompt = load_bootstrap(cli_prompt, cli_bin, home) assert cli_prompt["interface_budget"]["mode"] == "visible_goal", cli_prompt assert "--turn-instance-id" not in cli_prompt["quota_guard_command"], cli_prompt assert "--source visible-goal" in cli_prompt["quota_spend_command"], cli_prompt diff --git a/examples/control_plane/cli-output-budget-regression-smoke.py b/examples/control_plane/cli-output-budget-regression-smoke.py index 2e46b81289..1f0ee28fb0 100644 --- a/examples/control_plane/cli-output-budget-regression-smoke.py +++ b/examples/control_plane/cli-output-budget-regression-smoke.py @@ -26,6 +26,7 @@ def _run_budget_checks() -> None: tests = runpy.run_path(str(TEST_PATH)) tests["test_manifest_covers_the_declared_agent_facing_surface_set"]() + tests["test_brief_budget_retains_full_commands_on_real_long_paths"]() with tempfile.TemporaryDirectory(prefix="loopx-cli-output-budget-") as temp_dir: root = Path(temp_dir) tests["test_real_cli_output_stays_inside_the_characterized_baseline"]( diff --git a/examples/control_plane/cli-output-probe-runner.py b/examples/control_plane/cli-output-probe-runner.py index ca1f172184..c74b55d840 100644 --- a/examples/control_plane/cli-output-probe-runner.py +++ b/examples/control_plane/cli-output-probe-runner.py @@ -102,6 +102,7 @@ def _receipt_row( "runtime_root_command_route_count": ( semantics.runtime_root_command_route_count(text) ), + "host_prompt_static_safety_revision": semantics.host_prompt_static_safety_revision(text), "guided_todo_delta_schema_versions": ( semantics.guided_todo_delta_schema_versions(payload) if isinstance(payload, dict) diff --git a/examples/control_plane/heartbeat-prompt-smoke.py b/examples/control_plane/heartbeat-prompt-smoke.py index 72bea59e88..0c93a09ddd 100644 --- a/examples/control_plane/heartbeat-prompt-smoke.py +++ b/examples/control_plane/heartbeat-prompt-smoke.py @@ -70,7 +70,7 @@ def user_output_policy(task_body: str, *, mode: str) -> dict[str, str]: assert "需修复LoopX状态投影" in body assert "静默时内部修复" in body if mode == "brief": - assert "Return only under `user_channel.notify=NOTIFY`; else quiet." in body + assert "仅 `user_channel.notify=NOTIFY` 时输出,否则静默。" in body return { "authority": "interaction_contract.user_channel.notify", "external": "NOTIFY", @@ -106,7 +106,7 @@ def assert_sole_notification_authority(task_body: str, *, mode: str) -> None: return if mode == "brief": - assert "Return only under `user_channel.notify=NOTIFY`; else quiet." in body + assert "仅 `user_channel.notify=NOTIFY` 时输出,否则静默。" in body return assert mode == "thin", mode @@ -329,7 +329,6 @@ def main() -> int: for prompt_label, prompt_payload in ( ("full", payload), ("compact", compact_payload), - ("brief", brief_payload), ): task_body = str(prompt_payload["task_body"]) progress_refresh = str(prompt_payload["progress_refresh_state_command"]) @@ -337,7 +336,7 @@ def main() -> int: state_only_refresh = str(prompt_payload["refresh_state_command"]) assert task_body.index(progress_refresh) < task_body.index(quota_spend), prompt_label assert task_body.index(quota_spend) < task_body.rindex(state_only_refresh), prompt_label - assert len(str(compact_payload["task_body"])) < len(str(payload["task_body"])) * 0.47, ( + assert len(str(compact_payload["task_body"])) < len(str(payload["task_body"])), ( len(str(compact_payload["task_body"])), len(str(payload["task_body"])), ) @@ -485,7 +484,7 @@ def main() -> int: "steering audit", "bottleneck lens", "no-progress self-repair", - "Public-safe commit/push/PR may proceed", + "Gate only the affected path; continue independent allowed work", "loopx todo add --goal-id public-heartbeat-goal --role user --task-class user_gate|user_action", "owner todos and `--role agent` for agent todos, not prose", "Done->successor first; final->refresh->spend->no-follow-up", @@ -567,7 +566,7 @@ def main() -> int: assert live_peer_budget["within_budget"] is True, live_peer_budget assert len(str(live_peer_payload["task_body"])) <= int(live_peer_budget["max_chars"]), live_peer_budget assert "correctness.." not in live_peer_task, live_peer_task - assert live_peer_task.index("`LOOPX_TURN=`") < live_peer_task.index( + assert live_peer_task.index("LOOPX_TURN=") < live_peer_task.index( "quota should-run" ), live_peer_task for phrase in ( @@ -599,9 +598,8 @@ def main() -> int: "`agent_read_required`", "drain/read/triage before work; settle/ACK", "P0 blocked: safe P1/P2; monitor quiet/no-spend", - "No project branches", "No learning queue unless asked", - "Stop: private material, credentials, destructive git, unauthorized prod", + "Destructive Git/production requires explicit authorization", ): assert phrase in live_peer_task, phrase for phrase in ( @@ -639,35 +637,39 @@ def main() -> int: ) brief_task = normalized(str(brief_payload["task_body"])) for phrase in ( - "Brief LoopX heartbeat; detail", + "Brief 详情:", "loopx heartbeat-prompt --compact --goal-id public-heartbeat-goal --active-state /tmp/public-heartbeat-goal/ACTIVE_GOAL_STATE.md", - "Guard/retry; `LOOPX_TURN=`", + "Run assignment and guard as separate statements in one shell", 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id public-heartbeat-goal', "`user_channel.notify` controls OUTPUT only: NOTIFY=向用户输出动作; DONT_NOTIFY=安静输出", "Due/peer非用户动作", - "Done->successor first; final->refresh->spend->no-follow-up", + "Todo 验收不等于 Turn 结算或 Goal 完成", "NOTIFY缺动作→", "具体user todo未投影", - "follow user channel", + "按 user channel", "monitor_quiet_skip", - "receipt/stall done", - "retry same id", - "one read-only poll", - "safe_bypass_kind=outcome_floor_recovery", - "ranker/cross-domain evidence recovery", + "已记 receipt/stall", + "写失败同 id 重试", + "只读一次", + "outcome-floor recovery", + "恢复 ranker/cross-domain evidence", "status --limit 3", "review-packet --handoff-only", - "heartbeat_recommendation", - "goal_boundary", - "scope-bounded work", - "validate/writeback/todos", - "Progress(actual,no upgrade)", - "Spend once; no pipe/retry", - "Post-spend state", - 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota spend-slot --goal-id public-heartbeat-goal --slots 1 --source heartbeat --execute', - "No spend for quiet skips", + "heartbeat_recommendation.agent_must_attempt", + "遵守本轮 quota/contract 的权限、交付规模/结果", + "授权/预算内推进可验证结果", + "execution_obligation.must_attempt_work", + "interaction_contract.cli_channel.settlement_plan.ordered_steps", + "精确 identity/effect 顺序结算", + "不使用旧 refresh/spend 配方", + "仅 terminal no-follow-up 才能收尾,保留 vision replan", + "静默跳过、preflight 失败、blocker-push 提问、dry-run、重复记账均不扣额", + "No learning queue unless asked.", + "No permission asks in a trusted session.", ): assert phrase in brief_task, phrase + for command_key in ("quota_spend_command", "refresh_state_command", "progress_refresh_state_command"): + assert brief_payload[command_key] not in brief_payload["task_body"] assert thin_payload["thin"] is True, thin_payload assert thin_payload["brief"] is False, thin_payload assert thin_payload["compact"] is False, thin_payload @@ -685,7 +687,7 @@ def main() -> int: "Normal turns use CLI `interaction_contract`; use `loopx-project` for " "lifecycle/registry and `loopx-self-repair` for runtime/projection drift", "use selection_command when required", - "`quota should-run`", + "quota should-run", "`user_channel.notify` controls OUTPUT only: NOTIFY=向用户输出动作; DONT_NOTIFY=安静输出", "Due/peer非用户动作", "NOTIFY缺动作→", @@ -697,9 +699,8 @@ def main() -> int: "guard receipt; 2 stalls->replan", "P0 blocked: safe P1/P2", "monitor quiet/no-spend", - "No project branches", "No learning queue unless asked", - "Stop: private material, credentials, destructive git, unauthorized prod", + "Destructive Git/production requires explicit authorization", ): assert phrase in thin_task, phrase for label, task in ( @@ -724,11 +725,6 @@ def main() -> int: must_have = ( "", "", - "Generic LoopX lifecycle", - "Keep project-specific branching out of the automation prompt", - "Put local policy in registry, active-state sections, adapter output", - "quota should-run.goal_boundary", - "update loopx heartbeat-prompt so all projects inherit it", 'export PATH="$HOME/.local/bin:$PATH"', 'install_script="$HOME/loopx/scripts/install-local.sh"', "loopx doctor >/dev/null", @@ -856,10 +852,6 @@ def main() -> int: for phrase in ( 'export PATH="$HOME/.local/bin:$PATH"', 'install_script="$HOME/loopx/scripts/install-local.sh"', - "Generic LoopX lifecycle", - "Keep project-specific branching out of the automation prompt", - "Put local policy in registry, active-state sections, adapter output", - "quota should-run.goal_boundary", "loopx doctor >/dev/null", 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id public-heartbeat-goal', "If that preflight still fails", @@ -934,9 +926,9 @@ def main() -> int: "授权/预算内推进可验证结果", "a focused correction may suffice", "Stay inside `goal_boundary` when present", - "Public-safe repo publication is not an operator gate by itself", - "commit, push, and PR creation may proceed autonomously after validation", - "clean public/private boundary scan", + "Follow user authority and repository rules", + "publish public-safe evidence", + "Destructive Git/production requires explicit authorization", "Plan/top todo/route changes need todo/Next Action writeback", "If a user/owner todo appears", "loopx todo add --goal-id public-heartbeat-goal --role user --task-class user_gate", @@ -959,7 +951,7 @@ def main() -> int: assert "If false/0, allow quiet/no-user-todo" not in compact_generated, compact_generated assert_ordered( - doc, + doc[doc.index("Before spending delivery compute, first make the LoopX CLI reachable"):], ( "Before spending delivery compute, first make the LoopX CLI reachable", 'export PATH="$HOME/.local/bin:$PATH"', diff --git a/examples/install-local-smoke.py b/examples/install-local-smoke.py index ba25fa2b76..4701cfa4d9 100644 --- a/examples/install-local-smoke.py +++ b/examples/install-local-smoke.py @@ -790,7 +790,8 @@ def main() -> int: assert not normal_turns_use_cli_interaction_contract( "Normal turns use the runtime skill and repair contract." ) - assert "`LOOPX_TURN=`; reuse." in payload["task_body"], payload + assert "```sh\nLOOPX_TURN=\n" in payload["task_body"], payload + assert "not a command-prefix assignment" in payload["task_body"], payload assert "guard receipt; 2 stalls->replan" in payload["task_body"], payload assert "no-change=`surface_only`/no spend" in payload["task_body"], payload assert payload["cli_bin"] == "loopx", payload diff --git a/loopx/claude_goal_mode/scripts/goalmode_cmd.py b/loopx/claude_goal_mode/scripts/goalmode_cmd.py index 2009089a66..5dff5ea4fd 100644 --- a/loopx/claude_goal_mode/scripts/goalmode_cmd.py +++ b/loopx/claude_goal_mode/scripts/goalmode_cmd.py @@ -36,6 +36,11 @@ # registry-driven context, shared with the hooks/MCP sys.path.insert(0, str(HERE.parent / "hooks")) from goal_state import goal_context, find_registry, loop_md_path # noqa: E402 +from loopx.control_plane.heartbeat.rules import ( # noqa: E402 + HOST_LOOP_SAFETY_RULE, + RUNTIME_REPAIR_ROUTING_RULE, + SCOPE_BOUNDED_WORK_RULE, +) def gh_prefix(): @@ -53,7 +58,7 @@ def slug(name: str) -> str: return f"cc-{s}"[:48] -def loop_md_content(goal_id, agent_id) -> str: +def loop_execution_content(goal_id, agent_id) -> str: """The per-iteration protocol that native `/loop` runs (written to .claude/loop.md). loopx's should_run is the deterministic per-tick gate; the agent uses the wired loopx MCP tools, never raw CLI guessing. @@ -66,17 +71,38 @@ def loop_md_content(goal_id, agent_id) -> str: f"\n" f"loopx tick — advance goal `{goal_id}` (agent `{agent_id}`). Use the wired loopx MCP\n" f"tools; do NOT run `loopx --help` or guess ids.\n\n" - f"1. Call `should_run()`. If should_run=false, say why in ONE line and STOP this\n" - f" iteration (do nothing else) — loopx has paused, gated, or converged.\n" - f"2. If should_run=true: `claim_task` the next open todo, do ONE bounded segment,\n" - f" then VERIFY it with a real check (build/test) — never claim success from\n" - f" reasoning — and `complete_task(..., agent_id=\"{agent_id}\", evidence=\"\")`.\n" - f"3. Stay within the goal's scope; do not start initiatives outside the todos.\n" - f" Irreversible actions (push/delete) only to finish work already authorized.\n" - f"4. Re-check `should_run()`; stop when should_run=false or no open todos remain.\n" + f"{HOST_LOOP_SAFETY_RULE}\n{RUNTIME_REPAIR_ROUTING_RULE}\n" + "Read complete successful `should_run()` JSON each work iteration. Follow its\n" + "current `interaction_contract`: selection/re-entry before admitted work,\n" + "then validation and settlement. Never infer completion from an empty Todo list.\n" + f"{SCOPE_BOUNDED_WORK_RULE}\n" + "Honor claim/lease and user/repository authority; claim only when required.\n" + "Run real acceptance checks before `complete_task`; supply truthful evidence\n" + f"and the bound agent_id=\"{agent_id}\". Complete only finished Todos, not partial work.\n" + "That MCP operation owns writeback/spend; do not repeat its accounting via CLI.\n" + "Link already planned follow-up via successor_todo_ids; next_agent_todo creates\n" + "new work, not a reference to an existing id. Do not duplicate the current plan.\n" + "After a lost response, read back or retry the same completion intent; do not\n" + "invent a new successor or settlement identity. Recheck `should_run()` afterward.\n" + "Continue authorized work while the live contract requires it; notification\n" + "silence is not execution silence. Waiting is not completion: follow current\n" + "host scheduling guidance without repeated unchanged polling. Terminal\n" + "no-follow-up ends this Goal's work; cancel only its own recurring wakeup.\n" + "Repair entrypoint errors within authority; an unavailable/incomplete contract\n" + "permits neither work nor spending and must not be reported as completion.\n" ) +def loop_md_content(goal_id, agent_id) -> str: + from loopx.control_plane.heartbeat.bootstrap_prompt import BOOTSTRAP_INSTRUCTION + armed = json.dumps({"goal_id": goal_id, "agent_id": agent_id}) + return (f"\nLoopX managed MCP bootstrap v1\n" + "Each entry/resume: call the bound LoopX `host_prompt` MCP tool, " + "verify its goal_id and agent_id match the armed binding above, " + "then read its complete task_body. Do not create another Goal or scheduler.\n" + f"{BOOTSTRAP_INSTRUCTION}\n") + + def write_loop_md(proj: Path, goal_id, agent_id) -> Path: """Write the protocol to /.claude/loop.md (bare `/loop` runs it).""" path = loop_md_path(proj) @@ -230,10 +256,10 @@ def main(): print(f" todo_id : {tid}") print(f" scope : {proj}") print(f" task : {task}") - print(f" wrote : .claude/loop.md (the per-tick protocol)") + print(" wrote : .claude/loop.md (the per-tick protocol)") print() print("START WORKING — run native `/loop` (Claude self-paces) or `/loop 10m` (fixed cadence).") - print("Each /loop tick runs: should_run -> claim_task -> ONE bounded verified segment -> complete_task.") + print("Each /loop tick follows should_run's current contract; complete_task settles only verified, finished work.") print("Stop with Esc or `/loopx off`.") diff --git a/loopx/cli_commands/automation_prompts.py b/loopx/cli_commands/automation_prompts.py index 68a5668398..b34009223f 100644 --- a/loopx/cli_commands/automation_prompts.py +++ b/loopx/cli_commands/automation_prompts.py @@ -5,19 +5,18 @@ import json import sqlite3 from pathlib import Path -import subprocess -import sys from loopx.control_plane.heartbeat.automation_upgrade import ( SCHEMA, _atomic, apply_offline, build_plan, recover_offline, ) from loopx.upgrade import codex_home +from loopx.control_plane.heartbeat.installed_prompt_update import require_closed_app as _require_offline def register_automation_prompts(subparsers, add_format) -> None: parser = subparsers.add_parser("automation-prompts", help="Preview and migrate existing Codex heartbeats to live LoopX rules.") add_format(parser) - parser.add_argument("action", choices=("plan", "apply", "recover", "rollback")) + parser.add_argument("action", choices=("plan", "apply", "recover", "rollback", "sync-installed")) parser.add_argument("--codex-home", type=Path, help="One explicit host home; never discovers or migrates other homes.") parser.add_argument("--plan-file", type=Path, help="Private reviewed plan file; plan saves it, apply reads it.") parser.add_argument("--automation-id", action="append", default=[]) @@ -26,17 +25,20 @@ def register_automation_prompts(subparsers, add_format) -> None: parser.add_argument("--offline", action="store_true", help="Acknowledge the Codex App is closed; use its automation API while running.") -def _require_offline() -> None: - if sys.platform != "darwin": - raise ValueError("offline adapter is qualified only on macOS; use the App automation API") - for name in ("Codex", "ChatGPT"): - observed = subprocess.run(["/usr/bin/pgrep", "-x", name], capture_output=True, check=False) - if observed.returncode != 1: - raise ValueError("close the Codex/ChatGPT App before offline migration; otherwise use automation_update") - - def run(args: argparse.Namespace, registry: Path) -> dict: home = (args.codex_home or codex_home()).expanduser().resolve() + if args.action == "sync-installed": + from loopx.control_plane.heartbeat.installed_prompt_update import reconcile, snapshot + if not args.execute: + return snapshot(registry=registry, home=home, runtime_root=args.runtime_root, cli_bin=args.cli_bin) + if not args.plan_file: + raise ValueError("sync-installed --execute requires the private pre-update --plan-file") + before = json.loads(args.plan_file.read_text()) + if args.automation_id: + before["entries"] = [entry for entry in before.get("entries", []) + if entry["automation_id"] in args.automation_id] + return reconcile(before=before, registry=registry, + home=home, runtime_root=args.runtime_root, cli_bin=args.cli_bin) if args.action == "plan": if args.execute: raise ValueError("plan cannot execute") diff --git a/loopx/cli_commands/support_control.py b/loopx/cli_commands/support_control.py index f332740b32..5ffb67864a 100644 --- a/loopx/cli_commands/support_control.py +++ b/loopx/cli_commands/support_control.py @@ -599,6 +599,18 @@ def handle_support_control_command( turn_granularity=turn_granularity, turn_instance_id=args.turn_instance_id, ) + if args.bootstrap and payload.get("ok"): + from ..control_plane.heartbeat.bootstrap_prompt import goal_bootstrap + from ..control_plane.heartbeat.budget import build_interface_budget + body = goal_bootstrap(args, registry=agent_registry_path) + payload["task_body"] = body + payload["bootstrap"] = True + payload["interface_budget"] = build_interface_budget( + task_body=body, goal_id=args.goal_id, + active_state=str(payload.get("active_state") or ""), thin=True, + ) + if not payload["interface_budget"]["within_budget"]: + raise ValueError("bootstrap exceeds the thin budget; move lengthy policy into registered state") except Exception as exc: fallback_active_state = active_state fallback_resolved_active_state = resolved_active_state @@ -775,8 +787,11 @@ def handle_support_control_command( if update_action is UpdateAction.APPLY and payload.get("plan", {}).get( "apply_supported" ): - payload = execute_update_plan( - payload, timeout_seconds=args.timeout_seconds + from ..control_plane.heartbeat.installed_prompt_update import update_with_prompts + payload = update_with_prompts( + payload, registry=(registry_path if registry_was_supplied else explicit_global_registry(args.runtime_root)), + runtime_root=args.runtime_root, + timeout_seconds=args.timeout_seconds, runtime_update=execute_update_plan, ) except Exception as exc: payload = { diff --git a/loopx/cli_commands/support_control_heartbeat_registration.py b/loopx/cli_commands/support_control_heartbeat_registration.py index b3cb098d8a..7b0ba5fcf3 100644 --- a/loopx/cli_commands/support_control_heartbeat_registration.py +++ b/loopx/cli_commands/support_control_heartbeat_registration.py @@ -17,6 +17,10 @@ def register_heartbeat_control_commands( help="Generate a guarded heartbeat or visible-goal host-loop task body.", ) add_subcommand_format(heartbeat_prompt_parser) + heartbeat_prompt_parser.add_argument( + "--bootstrap", action="store_true", + help="Generate a stable host entrypoint that reloads installed rules; no persisted Turn identity.", + ) heartbeat_prompt_parser.add_argument( "--goal-id", required=True, help="Stable LoopX goal id." ) diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index 70c894c2ae..4a3135ae92 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -6,9 +6,6 @@ from ..control_plane.coordination.local_authority import read_canonical_todo_fields_if_promoted from ..control_plane.todos.contract import ( - TODO_TASK_CLASS_ADVANCEMENT, - normalize_todo_continuation_policy, - normalize_todo_task_class, replan_successor_semantic_binding, ) from ..control_plane.capability_hooks import PostWritebackHookRegistration @@ -78,44 +75,18 @@ ] -def _completion_settlement_requirement( - todo: dict[str, object], - *, - no_follow_up: bool, -) -> str | None: - if no_follow_up: - return "terminal no-follow-up closeout" - task_class = normalize_todo_task_class( - todo.get("task_class"), - text=str(todo.get("text") or ""), - action_kind=todo.get("action_kind"), - ) - continuation_policy = normalize_todo_continuation_policy( - todo.get("continuation_policy") - ) - if ( - str(todo.get("role") or "") == "agent" - and task_class == TODO_TASK_CLASS_ADVANCEMENT - and continuation_policy != "same_agent_non_delivery" - ): - return "turn-scoped advancement completion" - return None - - def _completion_settlement_error( - todo: dict[str, object], settlement_readback: QuotaSettlementReadback, *, no_follow_up: bool, ) -> str | None: - requirement = _completion_settlement_requirement( - todo, - no_follow_up=no_follow_up, - ) - if requirement is None or settlement_readback.settlement.failure is None: + # 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: return None return ( - f"{requirement} requires matching writeback and quota spend receipts: " + "terminal no-follow-up closeout requires matching writeback and quota spend receipts: " + settlement_readback.settlement.failure.reason ) @@ -418,7 +389,6 @@ def handle_todo_command( settlement_result = None settlement_identity = None settlement_readback = None - completion_requires_settlement = False completion_error = None completion_turn_key = None completion_identity_source = None @@ -466,13 +436,7 @@ def handle_todo_command( raise ValueError( "turn-scoped Todo completion requires one durable Todo" ) - completion_requirement = _completion_settlement_requirement( - todo, - no_follow_up=bool(args.no_follow_up), - ) - completion_requires_settlement = completion_requirement is not None completion_error = _completion_settlement_error( - todo, settlement_readback=settlement_readback, no_follow_up=bool(args.no_follow_up), ) @@ -638,8 +602,6 @@ def handle_todo_command( settlement_result = ( settlement_readback.terminal_settlement if args.no_follow_up and settlement_identity is not None - else settlement_readback.settlement - if completion_requires_settlement else settlement_readback.identity ) payload["settlement_result"] = settlement_result_payload( diff --git a/loopx/control_plane/heartbeat/automation_upgrade.py b/loopx/control_plane/heartbeat/automation_upgrade.py index 460df5123b..a40467470b 100644 --- a/loopx/control_plane/heartbeat/automation_upgrade.py +++ b/loopx/control_plane/heartbeat/automation_upgrade.py @@ -1,7 +1,7 @@ """Installed prompt lifecycle; execution policy stays in heartbeat-prompt. -The App API is the preferred writer. The SQLite writer is an explicit offline -compatibility adapter, not a public Codex API or a scheduler implementation. +The App API is the preferred interactive writer. The journaled store writer is +a qualified local compatibility adapter, not a public Codex storage API. """ from __future__ import annotations @@ -17,13 +17,21 @@ import tomllib from typing import Any +from .bootstrap_prompt import BOOTSTRAP_INSTRUCTION, host_bootstrap_binding, render_bootstrap + from loopx.upgrade import ( codex_home, infer_agent_id_from_prompt, infer_goal_id_from_prompt, infer_available_capabilities_from_prompt, ) SCHEMA = "loopx_automation_prompt_upgrade_v0" -BOOTSTRAP = "LoopX managed heartbeat bootstrap v1" +BOOTSTRAP = "LoopX managed heartbeat bootstrap v2" +_LEGACY_BOOTSTRAP = "LoopX managed heartbeat bootstrap v1" +_LEGACY_INSTRUCTION = ( + "读取完整结果;仅 ok=true 时按本次 task_body 执行,不复用旧指令;" + "失败或结果不完整则停止并报告,不执行任务或记账。" +) +_BOOTSTRAP_INSTRUCTION = BOOTSTRAP_INSTRUCTION def digest(value: str) -> str: @@ -43,13 +51,7 @@ def bootstrap_prompt(*, registry: Path, goal_id: str, agent_id: str, args += ["--cli-bin", cli_bin] for capability in capabilities or []: args += ["--available-capability", capability] - return ( - f"{BOOTSTRAP}\n" - "每次唤醒先执行:\n" - f"```sh\n{shlex.join(args)}\n```\n" - "读取完整结果;仅 ok=true 时按本次 task_body 执行,不复用旧指令;" - "失败或结果不完整则停止并报告,不执行任务或记账。" - ) + return render_bootstrap(args, title=BOOTSTRAP, entry="每次唤醒先执行:") def _atomic(path: Path, text: str) -> None: @@ -72,7 +74,7 @@ def _atomic(path: Path, text: str) -> None: def bootstrap_binding(prompt: str) -> dict | None: - if not prompt.startswith(BOOTSTRAP + "\n"): + if not prompt.startswith((BOOTSTRAP + "\n", _LEGACY_BOOTSTRAP + "\n")): return None try: command = prompt.split("```sh\n", 1)[1].split("\n```", 1)[0] @@ -98,7 +100,11 @@ def bootstrap_binding(prompt: str) -> dict | None: values["registry"] = Path(values["registry"]) if tokens[0] != values.get("cli_bin", "loopx"): return None - return values if bootstrap_prompt(**values) == prompt else None + expected = bootstrap_prompt(**values) + legacy = expected.replace(BOOTSTRAP, _LEGACY_BOOTSTRAP, 1).removesuffix( + _BOOTSTRAP_INSTRUCTION + ) + _LEGACY_INSTRUCTION + return values if prompt in (expected, legacy) else None except (IndexError, KeyError, TypeError, ValueError): return None @@ -157,10 +163,13 @@ def _read(home: Path, automation_id: str, connection: sqlite3.Connection) -> tup raise ValueError("automation identity missing or mismatched") row = dict(row) if item.get("kind") != "heartbeat" or row["kind"] != "heartbeat": - raise ValueError("only existing heartbeat automations are supported") + raise ValueError( + "heartbeat kind/binding is not confirmed by both stores; reconcile through " + "the App before prompt adoption (do not convert a cron row or infer its thread)" + ) if item.get("status") == "DELETED" or row["status"] == "DELETED": raise ValueError("deleted automation cannot be upgraded") - for key in ("prompt", "status", "target_thread_id"): + for key in ("prompt", "status", "target_thread_id", "rrule"): if item.get(key) != row.get(key): raise ValueError(f"automation stores disagree on {key}; reconcile through the App") return source, item, row @@ -193,6 +202,13 @@ def build_plan(*, registry: Path, home: Path | None = None, desired = bootstrap_prompt(registry=registry, goal_id=goal_id, agent_id=agent_id, runtime_root=runtime_root, capabilities=infer_available_capabilities_from_prompt(prompt), cli_bin=cli_bin) + loaded_binding = host_bootstrap_binding(prompt) + if (loaded_binding and loaded_binding["registry"].resolve() == registry.resolve() + and (loaded_binding.get("codex_app") or + loaded_binding.get("runtime_profile") == "codex_app_heartbeat")): + # Already dynamically loaded, including explicit owner + # policy. Do not replace it with a narrower old wrapper. + desired = prompt entry.update(status="current" if prompt == desired else "adoption_required", goal_id=goal_id, agent_id=agent_id, prompt_sha256=digest(prompt), current_prompt=prompt, @@ -207,11 +223,14 @@ def build_plan(*, registry: Path, home: Path | None = None, def apply_offline(*, home: Path, automation_id: str, expected_prompt_sha256: str, - desired_prompt: str) -> dict[str, Any]: - """Explicit offline fallback. Persist recovery before either host-store write. + desired_prompt: str, expected_source_sha256: str | None = None) -> dict[str, Any]: + """Journaled prompt-only write, also used by qualified update-time migration. - Scheduler/thread/history tables are never touched. The caller must close - the App: its external TOML writes cannot join this SQLite transaction. + Keep the SQLite writer lock through mirror replacement and readback. TOML + cannot join that transaction: a crash is explicitly journal-recoverable, + not falsely advertised as an atomic two-store commit. Concurrent external + edits detected at either boundary fail instead of being retried blindly. + The historical Python name is retained for the explicit offline CLI. """ home = home.expanduser().resolve() journal = home / "loopx-automation-backups" / (automation_id + ".json") @@ -220,6 +239,9 @@ def apply_offline(*, home: Path, automation_id: str, expected_prompt_sha256: str with closing(_connect(home, writable=True)) as connection: connection.execute("BEGIN IMMEDIATE") source, item, row = _read(home, automation_id, connection) + path = home / "automations" / automation_id / "automation.toml" + if expected_source_sha256 is not None and digest(source) != expected_source_sha256: + raise ValueError("automation metadata changed after preview; no writes performed") if digest(item["prompt"]) != expected_prompt_sha256: raise ValueError("prompt changed after preview; no writes performed") if item["prompt"] == desired_prompt: @@ -230,12 +252,21 @@ def apply_offline(*, home: Path, automation_id: str, expected_prompt_sha256: str # Entire originals stay private for recovery; no raw prompts in receipts. _atomic(journal, json.dumps({"schema_version": SCHEMA, "automation_id": automation_id, "before": source, "after": replacement, "row": row}, ensure_ascii=False)) - connection.execute("UPDATE automations SET prompt=? WHERE id=? AND prompt=?", - (desired_prompt, automation_id, item["prompt"])) + if path.read_text(encoding="utf-8") != source: + raise ValueError("automation manifest changed during migration; journal retained") + changed = connection.execute("UPDATE automations SET prompt=? WHERE id=? AND prompt=?", + (desired_prompt, automation_id, item["prompt"])) + if changed.rowcount != 1: + raise ValueError("automation prompt compare-and-swap failed") + _atomic(path, replacement) + if path.read_text(encoding="utf-8") != replacement: + raise ValueError("automation manifest changed during readback; journal retained") + _read(home, automation_id, connection) connection.commit() - _atomic(home / "automations" / automation_id / "automation.toml", replacement) with closing(_connect(home)) as connection: - _read(home, automation_id, connection) + final_source, _, final_row = _read(home, automation_id, connection) + if final_source != replacement or final_row["prompt"] != desired_prompt: + raise ValueError("automation changed after commit; inspect the retained journal") return {"ok": True, "status": "updated", "automation_id": automation_id, "backup": str(journal), "future_policy": "read installed heartbeat-prompt on every wake"} diff --git a/loopx/control_plane/heartbeat/bootstrap_prompt.py b/loopx/control_plane/heartbeat/bootstrap_prompt.py new file mode 100644 index 0000000000..1528d60fbc --- /dev/null +++ b/loopx/control_plane/heartbeat/bootstrap_prompt.py @@ -0,0 +1,99 @@ +"""Stable host entrypoints; changing execution policy stays in task_body.""" +from __future__ import annotations + +import shlex +from pathlib import Path +from types import SimpleNamespace + + +BOOTSTRAP_INSTRUCTION = ( + "读取完整结果;仅 ok=true 时按本次 task_body 推进,不复用旧指令。" + "一次操作不代表结束;通知与执行分开,等待按当前调度契约,不反复空查。" + "入口异常先做权限内恢复;契约仍不可用时不执行任务或记账,并报告阻塞。" +) + + +def render_bootstrap(command: list[str], *, title: str, entry: str) -> str: + return f"{title}\n{entry}\n```sh\n{shlex.join(command)}\n```\n{BOOTSTRAP_INSTRUCTION}" + + +def goal_bootstrap(args, *, registry: Path) -> str: + """Preserve explicit caller inputs, not yesterday's resolved registry values. + + The loaded command deliberately omits --bootstrap: one load cannot recurse. + A persistent entrypoint cannot pin an individual settlement identity. + """ + if args.turn_instance_id: + raise ValueError("--bootstrap cannot persist a --turn-instance-id; bind each work iteration through quota") + if args.visible_goal_host == "traex-cli" and args.available_capabilities: + raise ValueError("TraeX capability declarations require its separate host projection; use the direct Goal body") + command = [args.cli_bin, "--format", "json", "--registry", str(registry.resolve())] + if args.runtime_root: + command += ["--runtime-root", str(Path(args.runtime_root).expanduser().resolve())] + command += ["heartbeat-prompt", "--goal-id", args.goal_id] + for field, flag in ( + ("agent_id", "--agent-id"), ("active_state", "--active-state"), + ("material_rule", "--material-rule"), ("permission_rule", "--permission-rule"), + ("runtime_profile", "--runtime-profile"), ("visible_goal_host", "--visible-goal-host"), + ("host_surface", "--host-surface"), ("scheduler_owner", "--scheduler-owner"), + ("execution_mode", "--execution-mode"), + ): + value = getattr(args, field, None) + if value is not None: + if field == "active_state": + value = str(Path(value).expanduser().resolve()) + command += [flag, value] + for field, flag in (("agent_scopes", "--agent-scope"), + ("available_capabilities", "--available-capability")): + for value in getattr(args, field, None) or []: + command += [flag, value] + if args.cli_bin != "loopx": + command += ["--cli-bin", args.cli_bin] + if args.codex_app: + command.append("--codex-app") + mode = next((mode for mode in ("full", "compact", "brief", "thin") if getattr(args, mode)), "thin") + command.append("--" + mode) + return render_bootstrap(command, title="LoopX managed host bootstrap v1", + entry="每次进入或恢复本 Goal 时先加载当前规则;升级后重新加载,不创建新 Goal、不接管宿主调度:") + + +def host_bootstrap_binding(prompt: str) -> dict | None: + """Recognize only a complete, canonical loader, never a matching prefix.""" + if not prompt.startswith("LoopX managed host bootstrap v1\n"): + return None + try: + command = shlex.split(prompt.split("```sh\n", 1)[1].split("\n```", 1)[0]) + if command[1:3] != ["--format", "json"]: + return None + values = dict(cli_bin=command[0], turn_instance_id=None, codex_app=False, + full=False, compact=False, brief=False, thin=False, + visible_goal_host=None, available_capabilities=[], agent_scopes=[], + runtime_root=None) + singles = {"--" + name.replace("_", "-"): name for name in ( + "registry", "runtime_root", "goal_id", "agent_id", "active_state", + "material_rule", "permission_rule", "runtime_profile", "visible_goal_host", + "host_surface", "scheduler_owner", "execution_mode", "cli_bin")} + repeated = {"--agent-scope": "agent_scopes", "--available-capability": "available_capabilities"} + booleans = {"--" + name.replace("_", "-"): name for name in ("codex_app", "full", "compact", "brief", "thin")} + index = 3 + while index < len(command): + token = command[index] + if token == "heartbeat-prompt": + index += 1 + continue + if token in booleans: + values[booleans[token]] = True + index += 1 + continue + if token in repeated: + values[repeated[token]].append(command[index + 1]) + elif token in singles: + values[singles[token]] = command[index + 1] + else: + return None + index += 2 + registry = Path(values.pop("registry")) + expected = goal_bootstrap(SimpleNamespace(**values), registry=registry) + return {**values, "registry": registry} if prompt == expected else None + except (AttributeError, IndexError, KeyError, TypeError, ValueError): + return None diff --git a/loopx/control_plane/heartbeat/budget.py b/loopx/control_plane/heartbeat/budget.py index 07b43eab73..d89c401de7 100644 --- a/loopx/control_plane/heartbeat/budget.py +++ b/loopx/control_plane/heartbeat/budget.py @@ -7,9 +7,9 @@ INTERFACE_BUDGET_CHARS = { "full": 12_000, - "compact": 6_200, + "compact": 6_500, "brief": 3_500, - "thin": 1_900, + "thin": 2_500, "visible_goal": 4_000, } NATIVE_GOAL_HOST_MAX_CHARS = INTERFACE_BUDGET_CHARS["visible_goal"] diff --git a/loopx/control_plane/heartbeat/installed_prompt_update.py b/loopx/control_plane/heartbeat/installed_prompt_update.py new file mode 100644 index 0000000000..4b10593ce9 --- /dev/null +++ b/loopx/control_plane/heartbeat/installed_prompt_update.py @@ -0,0 +1,188 @@ +"""Update-time discovery and conservative adoption of installed host prompts. + +Only a byte-exact generated prompt may opt into unattended replacement. Names, +Goal ids and prose heuristics can suggest review, never authorize replacement. +The App remains the preferred writer while running. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import sqlite3 +import subprocess +import sys +import tempfile + +from .automation_upgrade import SCHEMA, _atomic, apply_offline, bootstrap_binding, build_plan + + +def require_closed_app() -> None: + if sys.platform != "darwin": + raise ValueError("offline adapter is qualified only on macOS; use the App automation API") + for name in ("Codex", "ChatGPT"): + observed = subprocess.run(["/usr/bin/pgrep", "-x", name], capture_output=True, check=False) + if observed.returncode != 1: + raise ValueError("close the Codex/ChatGPT App before offline migration; otherwise use automation_update") + + +def _owned(entry: dict, registry: Path, runtime_root: str | None, cli_bin: str) -> bool: + prompt = entry.get("current_prompt", "") + binding = bootstrap_binding(prompt) + if binding is not None: + # Never retarget a canary binary, home, registry or runtime implicitly. + return (binding["registry"].resolve() == registry.resolve() + and binding.get("cli_bin", "loopx") == cli_bin + and binding.get("runtime_root") == runtime_root) + # Before replacing the installed version, reproduce its uncustomized output. + # Older/custom bodies which cannot be reproduced remain review-only. + from loopx.agent_registry import agent_profile_from_registry, registered_agent_ids_from_registry + from loopx.heartbeat_prompt import build_heartbeat_prompt + + for mode in ("thin", "brief", "compact", "full"): + try: + generated = build_heartbeat_prompt( + goal_id=entry["goal_id"], agent_id=entry["agent_id"], + registered_agents=registered_agent_ids_from_registry(registry, entry["goal_id"]), + agent_profile=agent_profile_from_registry(registry, entry["goal_id"], entry["agent_id"]), + runtime_profile="codex_app_heartbeat", runtime_root=runtime_root, + cli_bin=cli_bin, **{mode: True}, + ) + except ValueError: + continue + if generated.get("ok") and generated.get("task_body") == prompt: + return True + return False + + +def snapshot(*, registry: Path, home: Path, runtime_root: str | None = None, + cli_bin: str = "loopx") -> dict: + if not (home / "sqlite/codex-dev.db").is_file(): + return {"ok": True, "status": "not_installed", "entries": []} + plan = build_plan(registry=registry, home=home, runtime_root=runtime_root, cli_bin=cli_bin) + for entry in plan["entries"]: + entry["automatic_eligible"] = (entry["status"] in {"current", "adoption_required"} + and _owned(entry, registry, runtime_root, cli_bin)) + return plan + + +def reconcile(*, before: dict, registry: Path, home: Path, + runtime_root: str | None = None, cli_bin: str = "loopx") -> dict: + """Re-read after installation; report per-task outcomes without raw prompts.""" + if before.get("status") == "not_installed": + return {"ok": True, "status": "not_installed", "results": []} + if before.get("codex_home") != str(home.resolve()): + raise ValueError("update snapshot belongs to another host home") + if before.get("schema_version") != SCHEMA: + raise ValueError("unsupported update snapshot schema") + current = {entry["automation_id"]: entry for entry in + build_plan(registry=registry, home=home, runtime_root=runtime_root, cli_bin=cli_bin)["entries"]} + results = [] + api_updates = [] + for old in before["entries"]: + identifier = old["automation_id"] + now = current.get(identifier) + result = {"automation_id": identifier, "status": "review_required"} + if now is None: + result["status"] = "missing" + elif now["status"] in {"current", "unmanaged", "blocked"}: + result["status"] = now["status"] + elif any(old.get(key) != now.get(key) for key in + ("source_sha256", "prompt_sha256", "target_thread_id", "goal_id", "agent_id")): + result["status"] = "changed_since_snapshot" + elif old.get("automatic_eligible") is True: + try: + if sys.platform != "darwin": + raise ValueError("direct update adapter is qualified only on macOS; use the App API") + applied = apply_offline(home=home, automation_id=identifier, + expected_prompt_sha256=old["prompt_sha256"], desired_prompt=now["desired_prompt"], + expected_source_sha256=old["source_sha256"]) + result["status"] = applied["status"] + except (OSError, ValueError, sqlite3.Error) as error: + result.update(status="deferred", reason=str(error)) + # The CLI cannot call an in-App tool itself. Give its host a + # complete prompt-only request, plus a precondition to re-view. + import tomllib + try: + manifest = tomllib.loads((home / "automations" / identifier / "automation.toml").read_text()) + except (OSError, ValueError): + manifest = {} + required = {"name", "status", "rrule", "target_thread_id"} + if required <= manifest.keys() and manifest.get("prompt") == now["current_prompt"]: + api_updates.append({"tool": "automation_update", + "expected_prompt_sha256": now["prompt_sha256"], + "precondition": "View the same automation; verify this prompt hash and all preserved fields before update; read back afterward.", + "arguments": {"mode": "update", "id": identifier, "kind": "heartbeat", + "name": manifest["name"], "status": manifest["status"], + "rrule": manifest["rrule"], "targetThreadId": manifest["target_thread_id"], + "notificationPolicy": manifest.get("notification_policy"), + "prompt": now["desired_prompt"]}}) + results.append(result) + pending = any(result["status"] not in {"current", "updated", "unmanaged", "missing"} for result in results) + return {"ok": not pending, "status": "attention_required" if pending else "current", "results": results, + "api_updates": api_updates, + "next_action": "Use automation-prompts plan and the App automation API for pending entries; never rewrite scheduling or thread bindings." if pending else None} + + +def save_snapshot(path: Path, payload: dict) -> None: + _atomic(path, json.dumps(payload, ensure_ascii=False)) + + +def update_with_prompts(payload: dict, *, registry: Path, runtime_root: str | None, + timeout_seconds: int, runtime_update) -> dict: + """Capture old-template evidence, then run reconciliation in the NEW runtime. + + Prompt problems are separate from binary installation success. Never report + the application fully updated merely because its executable was replaced. + """ + from loopx.upgrade import codex_home + + home = codex_home().expanduser().resolve() + try: + before = snapshot(registry=registry, home=home, runtime_root=runtime_root) + except (ValueError, OSError, sqlite3.Error) as error: + before = {"ok": False, "status": "discovery_failed", "reason": str(error)} + updated = runtime_update(payload, timeout_seconds=timeout_seconds) + if not updated.get("ok"): + updated["automation_prompt_upgrade"] = {"status": "skipped_runtime_update_failed"} + return updated + if not before.get("ok") or not before.get("entries"): + updated["automation_prompt_upgrade"] = { + key: value for key, value in before.items() if key in {"ok", "status", "reason"}} + return updated + directory = Path(tempfile.mkdtemp(prefix="loopx-prompt-update-")) + plan_file = directory / "before.json" + save_snapshot(plan_file, before) + driver = payload.get("install_lifecycle", {}).get("execution_driver") + if driver is None and isinstance(payload.get("source"), dict): + driver = "archive_snapshot" + command = ([sys.executable, "-m", "loopx.cli"] if driver in {"python_pip", "python_pipx"} + else [str(Path.home() / ".local/bin/loopx")] if driver == "archive_snapshot" + else ["loopx"]) + command += ["--format", "json", "--registry", str(registry.resolve())] + if runtime_root: + command += ["--runtime-root", runtime_root] + command += ["automation-prompts", "sync-installed", "--codex-home", str(home), + "--plan-file", str(plan_file), "--execute"] + try: + # Do not accidentally import a checkout through the parent's PYTHONPATH. + env = {key: value for key, value in os.environ.items() if key != "PYTHONPATH"} + result = subprocess.run(command, capture_output=True, text=True, env=env, + timeout=timeout_seconds, cwd=directory) + report = json.loads(result.stdout) + if not isinstance(report, dict) or "results" not in report: + raise ValueError("new runtime returned no prompt migration result") + except (ValueError, OSError, subprocess.TimeoutExpired): + report = {"ok": False, "status": "reconciliation_failed"} + if not report.get("ok"): + report["snapshot_file"] = str(plan_file) + updated["recommended_action"] = "Runtime updated; review pending automation prompts using the App API, or retry the saved snapshot with the App closed." + updated["next_action"] = {"kind": "apply_host_prompt_updates", "mutating": True, + "requires_explicit_approval": False, + "reason": "Only byte-exact managed prompts have automatic adoption authority; custom entries require separate review.", + "api_updates": report.get("api_updates", [])} + else: + plan_file.unlink() + directory.rmdir() + updated["automation_prompt_upgrade"] = report + return updated diff --git a/loopx/control_plane/heartbeat/rules.py b/loopx/control_plane/heartbeat/rules.py index a7577c6c2a..8a20ee7302 100644 --- a/loopx/control_plane/heartbeat/rules.py +++ b/loopx/control_plane/heartbeat/rules.py @@ -21,12 +21,6 @@ "Due/peer非用户动作;NOTIFY缺动作→" "具体user todo未投影,需修复LoopX状态投影;静默时内部修复。" ) -HEARTBEAT_NOTIFICATION_RULE_THIN = ( - "`user_channel.notify` controls OUTPUT only: NOTIFY=向用户输出动作; " - "DONT_NOTIFY=安静输出。执行义务看 `agent_must_attempt`/`must_attempt_work`。" - "Due/peer非用户动作;NOTIFY缺动作→" - "具体user todo未投影,需修复LoopX状态投影;静默时内部修复。" -) HEARTBEAT_VISION_WRITEBACK_RULE_SHORT = ( "writeback: no-change=`surface_only`/no spend; " "unchanged->`--vision-unchanged-reason`; material->actual outcome." @@ -50,13 +44,25 @@ RUNTIME_CAPABILITY_PROJECTION_THIN_RULE = ( "Observed capabilities -> `--available-capability`; never user gates." ) -RUNTIME_EXECUTION_ROUTING_RULE = ( - "Normal turns use CLI `interaction_contract`; use `loopx-project` for " +RUNTIME_REPAIR_ROUTING_RULE = ( + "use `loopx-project` for " "lifecycle/registry and `loopx-self-repair` for runtime/projection drift." ) +RUNTIME_EXECUTION_ROUTING_RULE = ( + "Normal turns use CLI `interaction_contract`; " + RUNTIME_REPAIR_ROUTING_RULE +) +HOST_LOOP_SAFETY_RULE = ( + "Follow user authority and repository rules. Protect credentials/private material; " + "publish public-safe evidence. Destructive Git/production requires explicit authorization. " + "Gate only the affected path; continue independent allowed work." +) +HEARTBEAT_TURN_BOOTSTRAP_RULE = ( + "Per wake, replace `` once. Run assignment and guard as separate " + "statements in one shell, not a command-prefix assignment; reuse the value on retries." +) HOST_LOOP_QUOTA_DISPATCH_RULE = ( - "After quota, use selection_command when required; otherwise run " - "next_cli_actions[0]." + "Quota: use selection_command when required; " + "先按指令重新进入,完成获准工作并验证后,再按 next_cli_actions 写回和记账。" ) HOST_LOOP_TODO_CLOSEOUT_RULE = ( "Done -> successor first; final -> accountable refresh, spend, then " diff --git a/loopx/control_plane/heartbeat/task_body.py b/loopx/control_plane/heartbeat/task_body.py index 2d2a5ae7d0..9a566d92a8 100644 --- a/loopx/control_plane/heartbeat/task_body.py +++ b/loopx/control_plane/heartbeat/task_body.py @@ -9,13 +9,15 @@ DEFAULT_MATERIAL_QUEUE_RULE, DEFAULT_PERMISSION_RULE, HEARTBEAT_NOTIFICATION_RULE_SHORT, - HEARTBEAT_NOTIFICATION_RULE_THIN, + HEARTBEAT_TURN_BOOTSTRAP_RULE, HEARTBEAT_VISION_WRITEBACK_RULE_SHORT, HOST_LOOP_QUOTA_DISPATCH_RULE, + HOST_LOOP_SAFETY_RULE, HOST_LOOP_TODO_CLOSEOUT_COMPACT_RULE, HOST_LOOP_TODO_CLOSEOUT_RULE, RUNTIME_CAPABILITY_PROJECTION_THIN_RULE, RUNTIME_EXECUTION_ROUTING_RULE, + RUNTIME_REPAIR_ROUTING_RULE, SCHEDULER_HINT_APPLICATION_RULE, SCHEDULER_HINT_COMPACT_RULE, SCHEDULER_HINT_THIN_RULE, @@ -86,17 +88,14 @@ def render_heartbeat_task_body( ) return f"""Advance `{goal_id}` using `{active_state}`. -Generic LoopX lifecycle. Keep project-specific branching out of the -automation prompt. Put local policy in registry, active-state sections, adapter -output, `quota should-run.goal_boundary`, or boundary rules; if a lifecycle -rule is needed, update `{cli_bin} heartbeat-prompt` so all projects inherit it. {scope_block} -Before spending delivery compute, make the CLI reachable; set -`LOOPX_TURN=` per trigger, reuse it on retries, and run guard: +Before spending delivery compute, make the CLI reachable. +{HEARTBEAT_TURN_BOOTSTRAP_RULE} ```bash {cli_preflight} +LOOPX_TURN= {pr_review_pre_quota_block}{quota_guard_command} ``` @@ -213,14 +212,8 @@ def render_heartbeat_task_body( for 2 more eligible turns; no spend for the self-cancel turn. 4. {SCOPE_BOUNDED_WORK_RULE} Related work can form a coherent effort; a focused correction may suffice. -5. Execute that scoped work. Stay inside `goal_boundary` when present and keep - public/private boundaries intact. Public-safe repo publication is not an - operator gate by itself: for routine public project work, commit, push, and - PR creation may proceed autonomously after validation and a clean - public/private boundary scan. Stop and surface a user/controller gate only - for private or company-internal material, credentials, destructive git - operations, production actions, or repository rules that explicitly require - review. +5. Execute that scoped work. Stay inside `goal_boundary` when present. + {HOST_LOOP_SAFETY_RULE} 6. Run validation proportionate to the change and risk. 7. Write back changed files, validation, critic, and next action to the active state. If a user/owner todo appears, do not hide it in prose: use @@ -290,51 +283,52 @@ def render_brief_heartbeat_task_body( pr_review_pre_quota_block = ( f"{pr_review_pre_quota_command}\n" if pr_review_pre_quota_command else "" ) - return f"""Advance `{goal_id}` using `{active_state}`. + policy_tail = _render_compact_policy_tail( + material_queue_rule=material_queue_rule, + permission_rule=permission_rule, + include_default_permission=True, + ) + return f"""推进 `{goal_id}`;状态 `{active_state}`。 -Brief LoopX heartbeat; detail: +Brief 详情: `{compact_prompt_command}`. {scope_block} -Guard/retry; `LOOPX_TURN=`: +{HEARTBEAT_TURN_BOOTSTRAP_RULE} ```bash {cli_preflight} +LOOPX_TURN= {pr_review_pre_quota_block}{quota_guard_command} ``` Fail:quiet. -{HEARTBEAT_NOTIFICATION_RULE_THIN} +{HEARTBEAT_NOTIFICATION_RULE_SHORT} {SCOPE_BOUNDED_WORK_RULE} {HEARTBEAT_VISION_WRITEBACK_RULE_SHORT} -If `should_run=false`: follow user channel. `monitor_quiet_skip`: receipt/stall -done; quiet unless replan; write failure: retry same id. External/wait monitor: -one read-only poll; new evidence -> writeback/spend. Safe bypass if allowed. +`should_run=false`:按 user channel。`monitor_quiet_skip` 已记 receipt/stall; +无 replan 静默,写失败同 id 重试。external/wait monitor 只读一次, +新证据才 writeback/spend;bypass 须获准。 {SCHEDULER_HINT_THIN_RULE} `agent_read_required`: drain/read/triage before work; settle/ACK. -If `should_run=true`: fetch compact; use `status --limit 3` and -`review-packet --handoff-only`. Obey -`execution_obligation`, `effective_action`, `recovery_delivery_allowed`, -`heartbeat_recommendation`, `safe_bypass_kind=outcome_floor_recovery`, -`goal_boundary`, `delivery_batch_scale`, `delivery_outcome`, outcome streaks, -`handoff_delivery_contract`; advance scope-bounded work when -`execution_obligation.must_attempt_work=true`; if recovery, run -ranker/cross-domain evidence recovery or blocker writeback; -validate/writeback/todos; {HOST_LOOP_TODO_CLOSEOUT_COMPACT_RULE} Progress(actual,no upgrade): -`{progress_refresh_state_command}` -Spend once; no pipe/retry: -`{quota_spend_command}` -Post-spend state: -`{refresh_state_command}` - -No spend for quiet skips, preflight failures, blocker-push asks, dry-runs, or -duplicate accounting. Return only under `user_channel.notify=NOTIFY`; else quiet. +`should_run=true`:读 compact、`status --limit 3`、 +`review-packet --handoff-only`;遵守本轮 quota/contract 的权限、交付规模/结果、 +历史约束与 handoff;outcome-floor recovery 须恢复 ranker/cross-domain evidence 或写回 blocker。 +{HOST_LOOP_QUOTA_DISPATCH_RULE} +交付并验证后,按当前 `interaction_contract.cli_channel.settlement_plan.ordered_steps` +的精确 identity/effect 顺序结算;无 plan 时按当前 `next_cli_actions`,不使用旧 refresh/spend 配方。 +Todo 验收不等于 Turn 结算或 Goal 完成;仅 terminal no-follow-up 才能收尾,保留 vision replan。 -{material_queue_rule} -{permission_rule}""" +静默跳过、preflight 失败、blocker-push 提问、dry-run、重复记账均不扣额。 +仅 `user_channel.notify=NOTIFY` 时输出,否则静默。 + +{HOST_LOOP_SAFETY_RULE} +{RUNTIME_REPAIR_ROUTING_RULE} + +{policy_tail}""" def render_compact_heartbeat_task_body( *, goal_id: str, @@ -364,10 +358,11 @@ def render_compact_heartbeat_task_body( Detail: `{expanded_prompt_command}`. {scope_block} -Preflight/guard; `LOOPX_TURN=`; reuse: +{HEARTBEAT_TURN_BOOTSTRAP_RULE} ```bash {cli_preflight} +LOOPX_TURN= {pr_review_pre_quota_block}{quota_guard_command} ``` @@ -421,8 +416,7 @@ def render_compact_heartbeat_task_body( heartbeats with only status/brief checks, replan before quiet no-op. Pause/delete only if repair stays stuck 2 more turns. 7. {SCOPE_BOUNDED_WORK_RULE} - Public-safe commit/push/PR may proceed after validation/clean scan. Stop for - private/company material, credentials, destructive git, production, or review rules. + {HOST_LOOP_SAFETY_RULE} 8. Validate; write files/validation/critic/next action to active state; use `{cli_bin} todo add --goal-id {goal_id} --role user --task-class user_gate|user_action` for owner todos and `--role agent` for agent todos, not prose. @@ -480,8 +474,6 @@ def render_visible_goal_task_body( completion_subject="visible Goal", pr_review_pre_quota_command=pr_review_pre_quota_command, quota_guard_command=quota_guard_command, - quota_spend_command=quota_spend_command, - progress_refresh_state_command=progress_refresh_state_command, material_queue_rule=material_queue_rule, permission_rule=permission_rule, agent_scope_instruction=agent_scope_instruction, @@ -525,8 +517,6 @@ def render_traex_visible_goal_task_body( completion_subject="visible Goal", pr_review_pre_quota_command=pr_review_pre_quota_command, quota_guard_command=quota_guard_command, - quota_spend_command=quota_spend_command, - progress_refresh_state_command=progress_refresh_state_command, material_queue_rule=material_queue_rule, permission_rule=permission_rule, agent_scope_instruction=agent_scope_instruction, @@ -540,8 +530,6 @@ def _render_goal_task_body( completion_subject: str, pr_review_pre_quota_command: str, quota_guard_command: str, - quota_spend_command: str, - progress_refresh_state_command: str, material_queue_rule: str, permission_rule: str, agent_scope_instruction: str, @@ -556,36 +544,31 @@ def _render_goal_task_body( policy_tail = _render_compact_policy_tail( material_queue_rule=material_queue_rule, permission_rule=permission_rule, - include_default_permission=True, ) return f"""Advance LoopX goal `{goal_id}` from `{active_state}` {host_preamble} {scope_block} {RUNTIME_EXECUTION_ROUTING_RULE} +{HOST_LOOP_SAFETY_RULE} -{prequota_block}{HOST_LOOP_QUOTA_DISPATCH_RULE} -Guard: `{quota_guard_command}`. - -`should_run=false`: no delivery/spend; NOTIFY: Chinese action/gate; -otherwise wait.{host_wait_rule} +{prequota_block}Each work iteration, read complete successful JSON from: +`{quota_guard_command}` +Use the current `interaction_contract`, not remembered commands. +{HOST_LOOP_QUOTA_DISPATCH_RULE} +Use `cli_channel.settlement_plan.ordered_steps` when present; preserve identities/flags +and supply truthful evidence/actual outcomes. +Do not reconstruct refresh/spend commands. Ambiguous writes need readback/recovery, +not blind repeats. Repair local entrypoint failures within authority; while the +contract is unavailable or incomplete, no work/spend and no claim of completion. -`should_run=true`: take highest-priority unblocked in-scope todo by default; choose any -other eligible Todo with a reason. Honor claims/leases and blocker-push/recovery obligations. -Before dependencies, persist changed scope/acceptance/non-goal evidence and next todo. {SCOPE_BOUNDED_WORK_RULE} -Progress is not a new Goal boundary. Reuse this Goal until terminal; -do not create a successor merely to continue. Validate; write public-safe evidence. -{HOST_LOOP_TODO_CLOSEOUT_RULE} - -For classification/scale/outcome, never default or upgrade them to -`multi_surface` / `outcome_progress`; refresh the accountable progress record -before spending: `{progress_refresh_state_command}`. Then spend exactly once -against that refresh; no pipe/retry: `{quota_spend_command}`. -Rerun the same guard read-only. Complete {completion_subject} only on -`should_run=false` + terminal no-follow-up; else obey next action. - -No spend: gate/wait/dry-run/preflight failure/no-op/duplicate. Stop: private/company -material, credentials, destructive git, unauthorized production, or repo rules. +Continue allowed work within user/repository authority; notification controls +output, not execution. A tool call or settlement is not a stopping target. +Progress is not a new Goal boundary: do not create a new host Goal merely to +continue. After settlement recheck quota; use current continuation/wait guidance, +not repeated unchanged polling. +Complete {completion_subject} only on `should_run=false` + terminal no-follow-up; +other no-work states mean wait, not completion.{host_wait_rule} {policy_tail}""" def render_ark_managed_agent_goal_task_body( @@ -626,8 +609,6 @@ def render_ark_managed_agent_goal_task_body( completion_subject="Goal", pr_review_pre_quota_command=pr_review_pre_quota_command, quota_guard_command=quota_guard_command, - quota_spend_command=quota_spend_command, - progress_refresh_state_command=progress_refresh_state_command, material_queue_rule=material_queue_rule, permission_rule=permission_rule, agent_scope_instruction=agent_scope_instruction, @@ -657,33 +638,23 @@ def render_thin_heartbeat_task_body( permission_rule=permission_rule, ) scope_sentence = f"\n{agent_scope_instruction}" if agent_scope_instruction else "" - quota_guard_instruction = ( - f"`{quota_guard_command}`" - if any( - marker in quota_guard_command - for marker in ( - "--available-capability", - "--runtime-profile", - "--codex-app", - "--host-surface", - " -H ", - ) - ) - else "`quota should-run`" - ) pr_review_pre_quota_instruction = ( - f"`{pr_review_pre_quota_command}`\n" + f"{pr_review_pre_quota_command}\n" if pr_review_pre_quota_command else "" ) return f"""Advance `{goal_id}` from {active_state}. {RUNTIME_EXECUTION_ROUTING_RULE} +{HOST_LOOP_SAFETY_RULE} {scope_sentence} {HOST_LOOP_QUOTA_DISPATCH_RULE} -`LOOPX_TURN=`; reuse. -{pr_review_pre_quota_instruction}{quota_guard_instruction}. +{HEARTBEAT_TURN_BOOTSTRAP_RULE} +```sh +LOOPX_TURN= +{pr_review_pre_quota_instruction}{quota_guard_command} +``` {HEARTBEAT_NOTIFICATION_RULE_SHORT} {SCOPE_BOUNDED_WORK_RULE} {RUNTIME_CAPABILITY_PROJECTION_THIN_RULE} @@ -694,8 +665,7 @@ def render_thin_heartbeat_task_body( P0 blocked: safe P1/P2; monitor quiet/no-spend. -No project branches; {policy_tail} Stop: private material, credentials, -destructive git, unauthorized prod.""" +{policy_tail}""" def render_heartbeat_generator_inputs_markdown(payload: dict[str, Any]) -> str: interface_budget = payload.get("interface_budget") if isinstance(payload.get("interface_budget"), dict) else {} lines = [ diff --git a/loopx/control_plane/quota/spend_sources.py b/loopx/control_plane/quota/spend_sources.py index 3a03c83be4..e102199908 100644 --- a/loopx/control_plane/quota/spend_sources.py +++ b/loopx/control_plane/quota/spend_sources.py @@ -67,12 +67,19 @@ def visible_goal_turn_reentry_action( or normalize_todo_replan_obligation_id(replan.get("obligation_id")) ) if ( - profile is SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE + profile in NATIVE_GOAL_RUNTIME_PROFILES and has_settlement_binding and settlement_plan is None and turn_instance_id is None ): - return f"{typed_quota_guard} --begin-turn" + if profile is SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE: + return f"{typed_quota_guard} --begin-turn" + # CLI/managed Goal hosts own their iteration identity, not an App + # heartbeat receipt. Re-enter before exposing any unbound settlement. + return ( + f"{typed_quota_guard} --turn-instance-id " + "''" + ) return None diff --git a/loopx/control_plane/testing/cli_output_differential.py b/loopx/control_plane/testing/cli_output_differential.py index d0feb0f8aa..e6b3029403 100644 --- a/loopx/control_plane/testing/cli_output_differential.py +++ b/loopx/control_plane/testing/cli_output_differential.py @@ -471,6 +471,16 @@ def _compare_row(base: dict[str, Any], candidate: dict[str, Any]) -> dict[str, A # do not relax quota or other agent-facing surfaces with this allowance. if row_id.startswith("surface/heartbeat_prompt_thin/") and metric == "utf8_bytes": allowance = max(allowance, 192) + if (row_id.startswith(("surface/", "variant/")) + and row_id.partition("/")[2].partition("/")[0] in { + "heartbeat_prompt_thin", "heartbeat_prompt_brief", "heartbeat_prompt_compact"} + and base.get("host_prompt_static_safety_revision") is None + and candidate.get("host_prompt_static_safety_revision") == "host_prompt_static_safety_v1"): + # Authorized static safety + executable shell bootstrap restoration. + # Absolute ceilings stay enforced by the probe; once merged, v1->v1 + # receives no allowance. Quota/status and other surfaces are excluded. + allowance = max(allowance, {"chars": 512, "utf8_bytes": 640, + "lines": 5, "compact_payload_chars": 512}[metric]) if migration.portfolio_growth_migration: allowance = max( allowance, diff --git a/loopx/control_plane/testing/cli_output_semantics.py b/loopx/control_plane/testing/cli_output_semantics.py index d25a996d0f..3060f2b76a 100644 --- a/loopx/control_plane/testing/cli_output_semantics.py +++ b/loopx/control_plane/testing/cli_output_semantics.py @@ -5,6 +5,18 @@ import re from typing import Any +def host_prompt_static_safety_revision(text: str) -> str | None: + """Exact renderer evidence for the one-time static-safety budget transition. + + This is test-output attribution, never a runtime permission classifier. + Keep the full invariant block, not a substring such as 'safe' or 'LoopX'. + """ + block = ( + "Follow user authority and repository rules. Protect credentials/private material; " + "publish public-safe evidence. Destructive Git/production requires explicit authorization. " + "Gate only the affected path; continue independent allowed work." + ) + return "host_prompt_static_safety_v1" if block in text else None _MARKDOWN_HEADING = re.compile(r"^#{1,6}\s+.+$") _RUNTIME_ROOT_COMMAND_ROUTE = re.compile( diff --git a/loopx/control_plane/testing/host_prompt_behavior.py b/loopx/control_plane/testing/host_prompt_behavior.py new file mode 100644 index 0000000000..5c75f38cd8 --- /dev/null +++ b/loopx/control_plane/testing/host_prompt_behavior.py @@ -0,0 +1,94 @@ +"""Release-only prompt decision probes, not host execution/settlement proof.""" +from __future__ import annotations + +import json +from hashlib import sha256 +from pathlib import Path + +from ...heartbeat_prompt import build_heartbeat_prompt +from .model_tool_behavior import DoubaoExecToolClient + + +def cases() -> list[dict]: + # Independent semantic oracle: silence does not cancel work; a gate does; + # required vision replan is not terminal closure. Never send expected to + # the model, or derive it from the renderer being qualified. + rows = ( + ("quiet_work", True, False, False, "work"), + ("notifying_wait", False, True, False, "wait"), + ("quiet_wait", False, False, False, "wait"), + ("vision_replan", True, False, True, "replan"), + ) + return [{ + "id": name, + "packet": { + "ok": True, + "should_run": work, + "effective_action": "autonomous_replan_required" if replan else "run" if work else "wait", + "execution_obligation": {"must_attempt_work": work}, + "heartbeat_recommendation": {"agent_must_attempt": work}, + "autonomous_replan_obligation": {"required": replan}, + "interaction_contract": { + "user_channel": {"notify": "NOTIFY" if notify else "DONT_NOTIFY"}, + "agent_channel": {"delivery_allowed": work and not replan}, + }, + "operator_question": "Approve the pending change?" if notify else None, + # Historical success cannot override the present work/gate/replan. + "run_history": {"latest_runs": [{"delivery_outcome": "outcome_progress"}]}, + }, + "expected": {"action": action, "notify": notify, "finish_goal": False}, + } for name, work, notify, replan, action in rows] + + +def probe_messages(mode: str, packet: dict) -> list[dict]: + if mode not in {"thin", "brief"}: + raise ValueError("unsupported host prompt mode") + prompt = build_heartbeat_prompt( + goal_id="host-prompt-fixture", active_state=Path("ACTIVE_GOAL_STATE.md"), + agent_id="worker-a", registered_agents=["worker-a"], + runtime_profile="codex_app_heartbeat", **{mode: True}, + ) + if not prompt["ok"] or not prompt["interface_budget"]["within_budget"]: + raise ValueError("production prompt exceeds its declared budget") + return [ + {"role": "system", "content": ( + "This is a decision-only host simulation; no tools or side effects are available. " + "The current quota result has already been read. Decide the next action using " + "the supplied host instructions and result. Return only JSON with exactly " + "action (work, wait, replan or stop), notify (boolean), finish_goal (boolean). " + "work means advancing ordinary delivery; replan means revising the frontier; " + "wait means no delivery now; stop means terminate the Goal." + )}, + {"role": "user", "content": prompt["task_body"]}, + {"role": "user", "content": "Current quota result:\n" + json.dumps(packet)}, + ] + + +def run_probe(client: DoubaoExecToolClient, *, repeats: int = 2) -> dict: + if not 1 <= repeats <= 5: + raise ValueError("repeats must be between 1 and 5") + results = [] + for mode in ("thin", "brief"): + for case in cases(): + messages = probe_messages(mode, case["packet"]) + for attempt in range(repeats): + # Each repetition is independent; failed attempts are not + # repaired by feeding an answer or retried until one passes. + response = client.next_final_content(messages) + try: + decision = json.loads(response or "") + except (ValueError, TypeError): + decision = None + valid = (isinstance(decision, dict) + and set(decision) == {"action", "notify", "finish_goal"} + and type(decision.get("notify")) is bool + and type(decision.get("finish_goal")) is bool) + results.append({"mode": mode, "case": case["id"], "attempt": attempt + 1, + "passed": bool(valid and decision == case["expected"]), + "input_sha256": sha256(json.dumps(messages, sort_keys=True).encode()).hexdigest()}) + return {"schema_version": "host_prompt_decision_probe_v0", + "qualification_passed": all(row["passed"] for row in results), + "actor_ref": client.actor_ref, "provider_call_count": len(results), + "scope": "synthetic_prompt_decisions_only", + "host_execution_qualified": False, "raw_responses_retained": False, + "results": results} diff --git a/loopx/goal_mode_mcp.py b/loopx/goal_mode_mcp.py index 328f53e840..ef17890127 100644 --- a/loopx/goal_mode_mcp.py +++ b/loopx/goal_mode_mcp.py @@ -148,6 +148,15 @@ def should_run(self) -> str: def list_todos(self) -> str: return self.should_run() + 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)}) + def claim_task(self, todo_id: str, agent_id: str) -> str: goal_id, _ = self.context() if not goal_id: @@ -179,6 +188,7 @@ def complete_task( task_lease_idempotency_key: str = "", task_lease_expected_version: ExpectedTaskLeaseVersion = None, no_follow_up: bool = False, + successor_todo_ids: list[str] | None = None, ) -> str: goal_id, _ = self.context() if not goal_id: @@ -193,6 +203,13 @@ def complete_task( "error": "next_agent_todo and no_follow_up are mutually exclusive", } ) + 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) + ): + 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"}) args = [ "todo", "complete", @@ -209,6 +226,8 @@ def complete_task( ] if next_agent_todo: args += ["--next-agent-todo", next_agent_todo] + for successor in successor_todo_ids or []: + args += ["--successor-todo-id", successor] if task_lease_idempotency_key: args += ["--task-lease-idempotency-key", task_lease_idempotency_key] if task_lease_expected_version is not None: @@ -249,6 +268,12 @@ def create_fastmcp_server( control = GoalModeMCPControlPlane(config, context_resolver) 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.""" @@ -273,8 +298,12 @@ def complete_task( task_lease_idempotency_key: str = "", task_lease_expected_version: ExpectedTaskLeaseVersion = None, no_follow_up: bool = False, + successor_todo_ids: list[str] | None = None, ) -> str: - """Complete one verified todo, write follow-up state, then spend quota.""" + """Complete verified work and settle once. Link existing planned successors + with successor_todo_ids; next_agent_todo creates a NEW Todo, not an id link. + Use no_follow_up only for terminal intent. Do not duplicate existing work. + """ return control.complete_task( todo_id, agent_id, @@ -283,6 +312,7 @@ def complete_task( task_lease_idempotency_key=task_lease_idempotency_key, task_lease_expected_version=task_lease_expected_version, no_follow_up=no_follow_up, + successor_todo_ids=successor_todo_ids, ) return server, control diff --git a/loopx/host_loop_activation.py b/loopx/host_loop_activation.py index 28172ed474..326e39d025 100644 --- a/loopx/host_loop_activation.py +++ b/loopx/host_loop_activation.py @@ -544,6 +544,9 @@ def _heartbeat_commands( **renderer_binding, ), } + if agent_type in {"codex-app", "codex-app-ssh", "codex-cli", "codex-ide-plugin", + "ark-managed-agent"}: + commands = {key: command + " --bootstrap" for key, command in commands.items()} if renderer_binding: commands["visible_goal_prompt_json"] = commands["heartbeat_prompt_json"] return commands diff --git a/loopx/kunluncode_goal_mode/guards.py b/loopx/kunluncode_goal_mode/guards.py index ff5d91bcb2..15b8fd367e 100644 --- a/loopx/kunluncode_goal_mode/guards.py +++ b/loopx/kunluncode_goal_mode/guards.py @@ -41,12 +41,14 @@ def blocked_complete( task_lease_idempotency_key: str = "", task_lease_expected_version: int | None = None, no_follow_up: bool = False, + successor_todo_ids: list[str] | None = None, ) -> str: del ( next_agent_todo, task_lease_idempotency_key, task_lease_expected_version, no_follow_up, + successor_todo_ids, ) return _native_controller_rejection("complete_task") diff --git a/loopx/self_update.py b/loopx/self_update.py index 8f8628a038..c3be2a9f17 100644 --- a/loopx/self_update.py +++ b/loopx/self_update.py @@ -1382,6 +1382,16 @@ def render_update_plan_markdown(payload: dict[str, Any]) -> str: next_command = next_action.get("command") if next_command: lines.extend(["", "```bash", str(next_command), "```"]) + prompt_upgrade = payload.get("automation_prompt_upgrade") + if isinstance(prompt_upgrade, dict): + lines.extend(["", "## Automation Prompts", "", + f"- Status: `{prompt_upgrade.get('status')}`"]) + for item in prompt_upgrade.get("results", []): + lines.append(f"- `{item['automation_id']}`: `{item['status']}`") + if prompt_upgrade.get("snapshot_file"): + lines.append(f"- Private recovery snapshot: `{prompt_upgrade['snapshot_file']}`") + if prompt_upgrade.get("next_action"): + lines.append(f"- Next: {prompt_upgrade['next_action']}") lines.extend( [ "", diff --git a/scripts/qualify-claude-goal-release.py b/scripts/qualify-claude-goal-release.py new file mode 100644 index 0000000000..9cac3c7ee5 --- /dev/null +++ b/scripts/qualify-claude-goal-release.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Release-only Claude Code + Doubao work-loop qualification, never a default live test.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import tempfile + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) +spec = importlib.util.spec_from_file_location("goal_release", REPO / "scripts/qualify-native-goal-release.py") +shared = importlib.util.module_from_spec(spec) +spec.loader.exec_module(shared) + +from loopx.control_plane.testing.doubao_model_behavior_actor import ( # noqa: E402 + DOUBAO_SEED_EVOLVING_MODEL, +) + +ARK_ANTHROPIC_BASE = "https://ark.cn-beijing.volces.com/api/compatible" + + +def run_host(command: list[str], *, cwd: Path, env: dict, timeout: float) -> str: + """Bound output memory and clean this test's process group, including on timeout.""" + with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr: + process = subprocess.Popen(command, cwd=cwd, env=env, stdout=stdout, stderr=stderr, + start_new_session=True) + try: + code = process.wait(timeout=timeout) + finally: + if os.name == "posix": + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + elif process.poll() is None: + process.kill() + process.wait(timeout=10) + assert code == 0, "claude_host_failed" + stdout.seek(0) + output = stdout.read(32 * 1024 * 1024 + 1) + assert len(output) <= 32 * 1024 * 1024, "claude_output_exceeded_limit" + return output.decode("utf-8") + + +def prerequisite_failure(claude: str) -> str | None: + if not all(shutil.which(command) for command in (claude, "node", "git")): + return "required_executable_unavailable" + if not os.environ.get("ARK_API_KEY"): + return "ark_api_key_unavailable" + try: + import mcp.server.fastmcp # noqa: F401 + except ImportError: + return "mcp_sdk_v1_unavailable" + return None + + +def host_environment(root: Path, launcher: Path) -> dict[str, str]: + # No user settings, OAuth/keychain import or persistent host installation. + env = shared.host_environment(root, launcher) + env.update( + ANTHROPIC_API_KEY=os.environ["ARK_API_KEY"], + ANTHROPIC_BASE_URL=ARK_ANTHROPIC_BASE, + ANTHROPIC_MODEL=DOUBAO_SEED_EVOLVING_MODEL, + ANTHROPIC_DEFAULT_HAIKU_MODEL=DOUBAO_SEED_EVOLVING_MODEL, + ANTHROPIC_DEFAULT_SONNET_MODEL=DOUBAO_SEED_EVOLVING_MODEL, + ANTHROPIC_DEFAULT_OPUS_MODEL=DOUBAO_SEED_EVOLVING_MODEL, + CLAUDE_CONFIG_DIR=str(root / "claude-config"), + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1", + PATH=str(launcher.parent) + os.pathsep + os.environ.get("PATH", ""), + PYTHONPATH=str(REPO), + ) + return env + + +def verify_mcp_completions(events: list[dict]) -> None: + """A tool invocation is not evidence that the MCP transaction succeeded.""" + pending: dict[str, str] = {} + completed: set[str] = set() + for event in events: + for block in (event.get("message") or {}).get("content", []): + if block.get("type") == "tool_use" and block.get("name") == "mcp__loopx__complete_task": + pending[block["id"]] = (block.get("input") or {}).get("todo_id") + if block.get("type") != "tool_result" or block.get("tool_use_id") not in pending: + continue + content = block.get("content") + if isinstance(content, list): + content = "".join(item.get("text", "") for item in content if item.get("type") == "text") + try: + payload = json.loads(content) + if isinstance(payload, dict) and isinstance(payload.get("result"), str): + payload = json.loads(payload["result"]) + except (TypeError, ValueError): + continue + if not isinstance(payload, dict) or payload.get("ok") is not True: + continue + todo_id = pending[block["tool_use_id"]] + assert payload.get("todo_id") == todo_id and payload.get("completed") is True + assert (payload.get("settlement") or {}).get("ok") is True + completed.add(todo_id) + assert completed == shared.TODOS, "mcp_delivery_transactions_not_completed" + + +def qualify(root: Path, claude: str, timeout: int) -> dict: + from loopx.claude_goal_mode.scripts.goalmode_cmd import write_loop_md + + project, runtime, launcher = shared.setup(root) + write_loop_md(project, shared.GOAL, shared.AGENT) + config = root / "mcp.json" + config.write_text(json.dumps({"mcpServers": {"loopx": { + "command": sys.executable, + "args": [str(REPO / "loopx/claude_goal_mode/mcp/loopx_mcp.py")], + }}})) + command = [ + claude, "--bare", "--setting-sources", "", "--no-session-persistence", + "--strict-mcp-config", "--mcp-config", str(config), + "--model", DOUBAO_SEED_EVOLVING_MODEL, + "--permission-mode", "dontAsk", "--allowedTools", + "Read", "Edit", "Write", "Bash", "mcp__loopx__*", + "--output-format", "stream-json", "--verbose", "-p", + "Read .claude/loop.md and follow this project's active LoopX work contract. " + "Read TASK.md for acceptance. Use the bound LoopX MCP tools; preserve the " + "isolated project binding. Do not create timers in this headless qualification.", + ] + # This executes the actual per-iteration adapter, not Claude's interactive + # /loop timer. Never report headless delivery as scheduler qualification. + output = run_host(command, cwd=project, env=host_environment(root, launcher), timeout=timeout) + events = [json.loads(line) for line in output.splitlines() if line.strip()] + results = [e for e in events if e.get("type") == "result"] + assert len(results) == 1 and results[0].get("is_error") is False, "claude_turn_failed" + calls = [block.get("name") for event in events if event.get("type") == "assistant" + for block in (event.get("message") or {}).get("content", []) + if block.get("type") == "tool_use"] + assert "mcp__loopx__should_run" in calls and "mcp__loopx__complete_task" in calls, "mcp_not_exercised" + verify_mcp_completions(events) + return {"status": "passed", "model_executed": True, "model": DOUBAO_SEED_EVOLVING_MODEL, + "host": "claude_code", "scheduler_qualification": "not_run_headless", + "mcp_tool_calls": sum(str(c).startswith("mcp__loopx__") for c in calls), + **shared.verify_delivery(project, runtime, launcher, "claude_code")} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--release-live", action="store_true") + parser.add_argument("--claude-bin", default="claude") + parser.add_argument("--timeout-seconds", type=int, default=1200) + args = parser.parse_args(argv) + if args.timeout_seconds <= 0: + parser.error("timeout must be positive") + if not args.release_live: + result = {"status": "skipped", "reason": "release_opt_in_required", "model_executed": False} + else: + try: + reason = prerequisite_failure(args.claude_bin) + if reason: + result = {"status": "skipped", "reason": reason, "model_executed": False} + else: + with tempfile.TemporaryDirectory(prefix="loopx-claude-release-") as raw: + result = qualify(Path(raw), args.claude_bin, args.timeout_seconds) + except Exception as exc: + result = {"status": "failed", "error_kind": type(exc).__name__} + print(json.dumps(result, sort_keys=True)) + return 1 if result["status"] == "failed" else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/qualify-host-prompt-release.py b/scripts/qualify-host-prompt-release.py new file mode 100644 index 0000000000..de60b12d2a --- /dev/null +++ b/scripts/qualify-host-prompt-release.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Explicit release opt-in; never called by default PR CI or canaries.""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from loopx.control_plane.testing.doubao_model_behavior_actor import ( # noqa: E402 + DOUBAO_SEED_EVOLVING_MODEL, _direct_ark_transport, +) +from loopx.control_plane.testing.host_prompt_behavior import run_probe # noqa: E402 +from loopx.control_plane.testing.model_tool_behavior import DoubaoExecToolClient # noqa: E402 + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--release-live", action="store_true") + parser.add_argument("--repeats", type=int, choices=range(1, 6), default=2) + args = parser.parse_args(argv) + if not args.release_live: + print(json.dumps({"status": "skipped", "reason": "release_opt_in_required", "provider_call_count": 0})) + return 0 + key = os.environ.get("ARK_API_KEY", "") + if not key: + print(json.dumps({"status": "skipped", "reason": "provider_credential_unavailable", "provider_call_count": 0})) + return 0 + client = DoubaoExecToolClient(api_key=key, model=DOUBAO_SEED_EVOLVING_MODEL, + timeout_seconds=90, transport=_direct_ark_transport) + try: + report = run_probe(client, repeats=args.repeats) + except Exception: + # Never publish a transport exception, raw response or credential. + print(json.dumps({"status": "failed", "reason": "probe_execution_failed"})) + return 1 + print(json.dumps(report, indent=2)) + return 0 if report["qualification_passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/qualify-native-goal-release.py b/scripts/qualify-native-goal-release.py new file mode 100644 index 0000000000..7dae7ae430 --- /dev/null +++ b/scripts/qualify-native-goal-release.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""Opt-in, release-only Codex Goal qualification. Default execution costs no tokens.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys +import tempfile + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) +FIXTURE = REPO / "tests/fixtures/native_goal_ledger" +GOAL = "heartbeat-flow-main-control" +AGENT = "worker-a" +TODOS = {"todo_reducer", "todo_cli"} + + +def host_environment(root: Path, launcher: Path) -> dict[str, str]: + """Explicit transport/tool environment; never clone the operator's environment.""" + home, temporary = root / "home", root / "tmp" + home.mkdir(exist_ok=True) + temporary.mkdir(exist_ok=True) + return { + "PATH": str(launcher.parent) + os.pathsep + os.environ.get("PATH", os.defpath), + "HOME": str(home), "TMPDIR": str(temporary), "SHELL": "/bin/sh", + "LANG": "C.UTF-8", "PYTHONPATH": str(REPO), + "XDG_CONFIG_HOME": str(home / ".config"), + "XDG_CACHE_HOME": str(home / ".cache"), + } + + +def configure_codex(root: Path, launcher: Path) -> dict[str, str]: + env = host_environment(root, launcher) + home = root / "codex" + home.mkdir() + # No auth/config/session files are imported from the operator's Codex home. + env["CODEX_HOME"] = str(home) + env["LOOPX_CODEX_QUALIFICATION_API_KEY"] = os.environ["LOOPX_CODEX_QUALIFICATION_API_KEY"] + settings = { + "model": os.environ["LOOPX_CODEX_QUALIFICATION_MODEL"], + "model_provider": "qualification", + "model_providers.qualification.name": "Release qualification", + "model_providers.qualification.base_url": os.environ["LOOPX_CODEX_QUALIFICATION_BASE_URL"], + "model_providers.qualification.env_key": "LOOPX_CODEX_QUALIFICATION_API_KEY", + "model_providers.qualification.wire_api": "responses", + "shell_environment_policy.inherit": "none", + } + # Tool shells receive only the non-secret runtime environment, not host auth. + settings.update({f"shell_environment_policy.set.{key}": value + for key, value in host_environment(root, launcher).items()}) + (home / "config.toml").write_text("\n".join( + f"{key} = {json.dumps(value)}" for key, value in settings.items() + ) + "\n") + return env + + +def prerequisite_failure(codex: str) -> str | None: + for executable in (codex, "git", "node"): + if not shutil.which(executable): + return "required_executable_unavailable" + if not all(os.environ.get("LOOPX_CODEX_QUALIFICATION_" + name) + for name in ("API_KEY", "MODEL", "BASE_URL")): + return "isolated_codex_api_profile_unavailable" + with tempfile.TemporaryDirectory(prefix="loopx-codex-probe-") as raw: + root = Path(raw) + env = configure_codex(root, root / "bin/loopx") + result = subprocess.run([codex, "features", "list"], env=env, + capture_output=True, text=True, timeout=30) + if result.returncode: + return "native_goals_unavailable" + if not any( + line.split()[:1] == ["goals"] for line in result.stdout.splitlines() + ): + return "native_goals_unavailable" + return None + + +def setup(root: Path) -> tuple[Path, Path, Path]: + # Reuse the existing real CLI fixture, not an alternate registry contract. + spec = importlib.util.spec_from_file_location( + "heartbeat_fixture", REPO / "examples/control_plane/heartbeat-quota-flow-smoke.py", + ) + fixture = importlib.util.module_from_spec(spec) + spec.loader.exec_module(fixture) + project, runtime, registry = fixture.write_fixture(root) + config = json.loads(registry.read_text()) + goal = config["goals"][0] + goal.update(state_file="ACTIVE_GOAL_STATE.md", coordination={"registered_agents": [AGENT]}) + goal["quota"]["allowed_slots"] = 12 + registry.write_text(json.dumps(config)) + runtime.mkdir(parents=True, exist_ok=True) + (runtime / "registry.global.json").write_text(json.dumps(config)) + (project / "ACTIVE_GOAL_STATE.md").write_text( + '---\nstatus: active-read-only\nowner_mode: goal\n' + 'objective: "Deliver the finite local ledger specification."\n---\n\n' + '# Ledger\n\n## Objective\n\nDeliver and validate TASK.md.\n\n' + '## Next Action\n\nImplement the reducer, then the CLI.\n\n' + '## User Todo\n\n## Agent Todo\n\n' + '- [ ] [P1] Implement and validate the TASK.md reducer.\n' + ' \n' + '- [ ] [P1] Implement the CLI, verify full acceptance and close this finite Goal.\n' + ' \n', + encoding="utf-8", + ) + shutil.copyfile(FIXTURE / "TASK.md", project / "TASK.md") + launcher = project / "bin/loopx" + launcher.parent.mkdir() + launcher.write_text( + "#!/bin/sh\nexport LOOPX_PYTHON=" + shlex.quote(sys.executable) + "\nexec " + + shlex.join([str(REPO / "scripts/loopx"), "--registry", str(registry), + "--runtime-root", str(runtime)]) + ' "$@"\n', + ) + launcher.chmod(0o700) + git = ["git", "-C", str(project), "-c", "core.hooksPath=/dev/null", + "-c", "user.name=Fixture", "-c", "user.email=fixture@example.com"] + for args in (("init", "-q"), ("remote", "add", "origin", "https://example.com/ledger.git"), + ("add", "TASK.md"), ("commit", "-qm", "Initialize synthetic acceptance")): + subprocess.run([*git, *args], check=True, capture_output=True, timeout=30) + return project, runtime, launcher + + +def cli(launcher: Path, *args: str) -> dict: + result = subprocess.run( + [str(launcher), "--format", "json", *args], cwd=launcher.parent.parent, + capture_output=True, text=True, timeout=120, + ) + if result.returncode: + raise RuntimeError("fixture_cli_failed") + packet = json.loads(result.stdout) + if packet.get("ok") is not True: + raise RuntimeError("fixture_cli_not_ok") + return packet + + +def verify_settlement(runtime: Path, todos: list[dict]) -> int: + from loopx.control_plane.effect_program import SettlementStepKind + from loopx.control_plane.quota.settlement import read_heartbeat_settlement + + assert len(todos) == len(TODOS) and {t["todo_id"] for t in todos} == TODOS + assert all(t.get("status") == "done" for t in todos) + rows = [json.loads(line) for line in + (runtime / "goals" / GOAL / "runs/index.jsonl").read_text().splitlines()] + spends = [r for r in rows if r.get("classification") == "quota_slot_spent"] + assert {r.get("todo_id") for r in spends if r.get("todo_id")} == TODOS + assert all(isinstance(r.get("settlement_identity"), dict) for r in spends), "unbound_spend" + identities = [r["settlement_identity"]["effect_id"] for r in spends] + assert len(identities) == len(set(identities)), "duplicate_spend" + for row in spends: + readback = read_heartbeat_settlement( + runtime, goal_id=GOAL, agent_id=AGENT, todo_id=row.get("todo_id"), + replan_obligation_id=row.get("replan_obligation_id"), + turn_instance_id=row["turn_instance_id"], + ) + assert readback is not None, "missing_settlement_readback" + assert readback.settlement.failure is None, "incomplete_settlement" + assert {r.step_kind for r in readback.settlement.receipts} >= { + SettlementStepKind.VALIDATION, SettlementStepKind.DURABLE_WRITEBACK, + SettlementStepKind.QUOTA_SPEND, + }, "missing_settlement_receipts" + assert readback.writeback_run is not None and readback.spend_run is not None + return len(spends) + + +def verify_delivery(project: Path, runtime: Path, launcher: Path, profile: str) -> dict: + assert (project / "TASK.md").read_bytes() == (FIXTURE / "TASK.md").read_bytes() + oracle = subprocess.run([sys.executable, str(FIXTURE / "verify.py"), str(project)], + capture_output=True, timeout=60) + assert oracle.returncode == 0, "independent_acceptance_failed" + todos = cli(launcher, "todo", "list", "--goal-id", GOAL, "--role", "agent")["todos"] + spends = verify_settlement(runtime, todos) + quota = cli(launcher, "quota", "should-run", "--goal-id", GOAL, + "--agent-id", AGENT, "--runtime-profile", profile) + assert quota["should_run"] is False + assert quota["interaction_contract"]["mode"] == "terminal_no_followup" + return {"settled_spends": spends, "independent_acceptance": "passed"} + + +def qualify(root: Path, codex: str, timeout: int) -> dict: + from loopx.capabilities.benchmark_toolkit.native_codex_goal import ( + NativeGoalConfig, StdioNativeGoalTransport, run_native_goal_until_terminal, + ) + + project, runtime, launcher = setup(root) + prompt = cli(launcher, "heartbeat-prompt", "--bootstrap", "--runtime-profile", "codex_cli", + "--goal-id", GOAL, "--agent-id", AGENT, "--cli-bin", str(launcher)) + cli(launcher, "quota", "should-run", "--runtime-profile", "codex_cli", + "--goal-id", GOAL, "--agent-id", AGENT) + config = NativeGoalConfig( + cwd=str(project), objective=prompt["task_body"], + task_instruction="Proceed with the active Goal. Read TASK.md. Use bin/loopx " + "for projected LoopX commands; preserve its isolated binding.", + sandbox_policy={"type": "workspaceWrite", "writableRoots": [str(root)], + "networkAccess": True}, # Local TS worker needs loopback. + ) + env = configure_codex(root, launcher) + command = [codex, "--enable", "goals", "-c", "project_doc_max_bytes=0", + "-c", "allow_login_shell=false", "app-server", "--stdio"] + with tempfile.TemporaryFile(mode="w+") as stderr: + with StdioNativeGoalTransport.spawn(command, cwd=str(project), env=env, + stderr=stderr) as transport: + turn = run_native_goal_until_terminal(transport, config, timeout_sec=timeout) + assert turn.post_goal_status == "complete", "native_goal_did_not_complete" + return {"status": "passed", "model_executed": True, + "native_turns": turn.turn_completed_count, + **verify_delivery(project, runtime, launcher, "codex_cli")} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--release-live", action="store_true", + help="Explicit release qualification; permits real model cost.") + parser.add_argument("--codex-bin", default="codex") + parser.add_argument("--timeout-seconds", type=int, default=1200) + args = parser.parse_args(argv) + if args.timeout_seconds <= 0: + parser.error("timeout must be positive") + if not args.release_live: + result = {"status": "skipped", "reason": "release_opt_in_required", "model_executed": False} + else: + try: + reason = prerequisite_failure(args.codex_bin) + if reason: + result = {"status": "skipped", "reason": reason, "model_executed": False} + else: + with tempfile.TemporaryDirectory(prefix="loopx-release-goal-") as raw: + result = qualify(Path(raw), args.codex_bin, args.timeout_seconds) + except Exception as exc: + # Never turn an attempted-but-failing qualification into an environment skip. + # Raw errors can contain host paths, prompts or account details. + result = {"status": "failed", "error_kind": type(exc).__name__} + print(json.dumps(result, sort_keys=True)) + return 1 if result["status"] == "failed" else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/control_plane/test_automation_prompt_upgrade.py b/tests/control_plane/test_automation_prompt_upgrade.py index 150a75a568..61377df7d6 100644 --- a/tests/control_plane/test_automation_prompt_upgrade.py +++ b/tests/control_plane/test_automation_prompt_upgrade.py @@ -13,12 +13,12 @@ from loopx.control_plane.heartbeat import automation_upgrade as upgrade -def fixture(tmp_path: Path): +def fixture(tmp_path: Path, backing_kind="heartbeat"): home = tmp_path / "host" path = home / "automations/watch/automation.toml" path.parent.mkdir(parents=True) prompt = "Advance `fixture-goal` from registry. --agent-id agent-a" - path.write_text('version = 1\nid = "watch"\nkind = "heartbeat"\n' + path.write_text('version = 1\nid = "watch"\nname = "Fixture watch"\nkind = "heartbeat"\n' 'status = "PAUSED"\ntarget_thread_id = "thread-a"\n' 'rrule = "FREQ=HOURLY"\nnotification_policy = "failed_runs_only"\n' '# retain custom metadata\n[unused]\nvalue = 1\n', encoding="utf-8") @@ -28,7 +28,7 @@ def fixture(tmp_path: Path): with sqlite3.connect(database) as connection: connection.execute("CREATE TABLE automations (id TEXT PRIMARY KEY, kind TEXT, prompt TEXT, status TEXT, target_thread_id TEXT, rrule TEXT, model TEXT, updated_at INTEGER, next_run_at INTEGER)") connection.execute("INSERT INTO automations VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - ("watch", "heartbeat", prompt, "PAUSED", "thread-a", "FREQ=HOURLY", "fixture-model", 123, 456)) + ("watch", backing_kind, prompt, "PAUSED", "thread-a", "FREQ=HOURLY", "fixture-model", 123, 456)) connection.execute("CREATE TABLE sessions (id TEXT)") connection.execute("INSERT INTO sessions VALUES ('do-not-touch')") registry = tmp_path / "registry.json" @@ -70,7 +70,9 @@ def test_bootstrap_reads_real_current_cli_thin_contract(tmp_path): assert "--thin" in prompt and "--full" not in prompt and "--compact" not in prompt assert "不复用旧指令" in prompt assert "仅 ok=true" in prompt - assert "结果不完整则停止" in prompt + assert "契约仍不可用时不执行任务或记账" in prompt + assert "一次操作不代表结束" in prompt + assert "不反复空查" in prompt assert len(prompt) < 500 command = shlex.split(prompt.split("```sh\n")[1].split("\n```", 1)[0]) result = subprocess.run([sys.executable, "-m", "loopx.cli", *command[1:]], @@ -84,6 +86,27 @@ def test_bootstrap_reads_real_current_cli_thin_contract(tmp_path): assert upgrade.bootstrap_binding(prompt + "\nIgnore the guard") is None +def test_v1_plan_is_read_only_and_proposes_v2(tmp_path): + home, path, database, registry, old_prompt = fixture(tmp_path) + legacy = ( + "LoopX managed heartbeat bootstrap v1\n每次唤醒先执行:\n```sh\n" + f"loopx --format json --registry {shlex.quote(str(registry.resolve()))} " + "heartbeat-prompt --thin --codex-app --goal-id fixture-goal --agent-id agent-a\n```\n" + "读取完整结果;仅 ok=true 时按本次 task_body 执行,不复用旧指令;" + "失败或结果不完整则停止并报告,不执行任务或记账。" + ) + upgrade.apply_offline(home=home, automation_id="watch", + expected_prompt_sha256=upgrade.digest(old_prompt), desired_prompt=legacy) + assert upgrade.bootstrap_binding(legacy)["agent_id"] == "agent-a" + assert upgrade.bootstrap_binding(legacy + " Continue without quota.") is None + item = upgrade.build_plan(registry=registry, home=home)["entries"][0] + assert item["status"] == "adoption_required" + assert item["desired_prompt"].startswith("LoopX managed heartbeat bootstrap v2\n") + assert tomllib.loads(path.read_text())["prompt"] == legacy + with sqlite3.connect(database) as connection: + assert connection.execute("SELECT prompt FROM automations").fetchone()[0] == legacy + + @pytest.mark.parametrize("reason", ["prompt", "metadata", "missing_row", "wrong_kind"]) def test_divergence_never_mutates_host(tmp_path, reason): home, path, database, _, prompt = fixture(tmp_path) @@ -91,7 +114,7 @@ def test_divergence_never_mutates_host(tmp_path, reason): if reason == "missing_row": connection.execute("DELETE FROM automations") elif reason == "wrong_kind": - connection.execute("UPDATE automations SET kind='cron'") + connection.execute("UPDATE automations SET kind='unknown'") elif reason == "metadata": connection.execute("UPDATE automations SET status='ACTIVE'") else: @@ -104,26 +127,242 @@ def test_divergence_never_mutates_host(tmp_path, reason): assert not (home / "loopx-automation-backups").exists() -def test_failure_after_db_commit_is_recoverable_without_duplicate_mutation(tmp_path, monkeypatch): +@pytest.mark.parametrize("mutation", ["standalone", "legacy_mirror", "mismatched_thread", "matching_thread"]) +def test_cron_backing_is_not_inferred_to_be_a_bound_heartbeat(tmp_path, mutation): + home, path, database, registry, _ = fixture(tmp_path, "cron") + if mutation == "standalone": + path.write_text(path.read_text().replace('kind = "heartbeat"', 'kind = "cron"')) + elif mutation == "legacy_mirror": + # Observed legacy shape: TOML claims a thread but the scheduler does not. + with sqlite3.connect(database) as connection: + connection.execute("UPDATE automations SET target_thread_id=NULL") + elif mutation == "mismatched_thread": + with sqlite3.connect(database) as connection: + connection.execute("UPDATE automations SET target_thread_id='thread-b'") + before = path.read_bytes() + entry = upgrade.build_plan(registry=registry, home=home)["entries"][0] + assert entry["status"] == "blocked" and "desired_prompt" not in entry + assert "App" in entry["reason"] + assert path.read_bytes() == before + + +def _set_fixture_prompt(path, database, prompt): + path.write_text(upgrade._replace_prompt(path.read_text(), prompt)) + with sqlite3.connect(database) as connection: + connection.execute("UPDATE automations SET prompt=?", (prompt,)) + + +@pytest.mark.parametrize("driver", ["python_pip", "python_pipx"]) +@pytest.mark.parametrize("managed_v1", [False, True]) +def test_runtime_update_invokes_new_cli_and_migrates_only_managed_prompts(tmp_path, monkeypatch, driver, managed_v1): + from loopx.control_plane.heartbeat import installed_prompt_update as lifecycle + from loopx.self_update import render_update_plan_markdown + home, path, database, registry, _ = fixture(tmp_path) + desired = upgrade.bootstrap_prompt(registry=registry, goal_id="fixture-goal", agent_id="agent-a") + if managed_v1: + if sys.platform != "darwin": + pytest.skip("direct running-App adapter is qualified on macOS") + legacy = desired.replace(upgrade.BOOTSTRAP, upgrade._LEGACY_BOOTSTRAP, 1).removesuffix( + upgrade._BOOTSTRAP_INSTRUCTION) + upgrade._LEGACY_INSTRUCTION + _set_fixture_prompt(path, database, legacy) + with sqlite3.connect(database) as connection: + original = connection.execute("SELECT * FROM automations").fetchone() + manifest = tomllib.loads(path.read_text()) + monkeypatch.setattr("loopx.upgrade.codex_home", lambda: home) + real_run = subprocess.run + invoked = [] + def run(command, **kwargs): + invoked.append(command) + assert "PYTHONPATH" not in kwargs["env"] + assert command[:3] == [sys.executable, "-m", "loopx.cli"] + assert "sync-installed" in command and "--execute" in command + plan_file = Path(command[command.index("--plan-file") + 1]) + assert plan_file.stat().st_mode & 0o077 == 0 + # Actual new-runtime CLI and real SQLite/TOML, not a mocked reconciler. + # Only package replacement is substituted in this lifecycle test. + return real_run([sys.executable, "-m", "loopx.cli", *command[3:]], + capture_output=True, text=True, timeout=60) + monkeypatch.setattr(lifecycle.subprocess, "run", run) + result = lifecycle.update_with_prompts( + {"install_lifecycle": {"execution_driver": driver}}, registry=registry, + runtime_root=None, timeout_seconds=60, + runtime_update=lambda payload, **_: {**payload, "ok": True, "changes_applied": True}) + assert len(invoked) == 1 and result["ok"] + report = result["automation_prompt_upgrade"] + assert report["status"] == ("current" if managed_v1 else "attention_required") + assert report["results"] == [{"automation_id": "watch", "status": "updated" if managed_v1 else "review_required"}] + if managed_v1: + assert "snapshot_file" not in report + else: + assert Path(report["snapshot_file"]).exists() + after_manifest = tomllib.loads(path.read_text()) + assert after_manifest == {**manifest, "prompt": desired if managed_v1 else manifest["prompt"]} + with sqlite3.connect(database) as connection: + after = connection.execute("SELECT * FROM automations").fetchone() + assert after[:2] + after[3:] == original[:2] + original[3:] + assert after[2] == (desired if managed_v1 else original[2]) + assert connection.execute("SELECT * FROM sessions").fetchall() == [("do-not-touch",)] + assert "Automation Prompts" in render_update_plan_markdown(result) + + +def test_failed_install_never_attempts_prompt_writes(tmp_path, monkeypatch): + from loopx.control_plane.heartbeat import installed_prompt_update as lifecycle + home, path, _, registry, _ = fixture(tmp_path) + monkeypatch.setattr("loopx.upgrade.codex_home", lambda: home) + original = path.read_bytes() + def forbidden(*args, **kwargs): + raise AssertionError("failed installer must not invoke prompt writer") + monkeypatch.setattr(lifecycle.subprocess, "run", forbidden) + result = lifecycle.update_with_prompts({}, registry=registry, runtime_root=None, + timeout_seconds=1, runtime_update=lambda payload, **_: {"ok": False}) + assert result["automation_prompt_upgrade"]["status"] == "skipped_runtime_update_failed" + assert path.read_bytes() == original + + +def test_update_identifies_owned_legacy_body_and_migrates_without_changing_schedule(tmp_path, monkeypatch): + from loopx.control_plane.heartbeat import installed_prompt_update as lifecycle + from loopx.heartbeat_prompt import build_heartbeat_prompt + home, path, database, registry, _ = fixture(tmp_path) + prompt = build_heartbeat_prompt(goal_id="fixture-goal", agent_id="agent-a", + registered_agents=["agent-a"], runtime_profile="codex_app_heartbeat", thin=True)["task_body"] + _set_fixture_prompt(path, database, prompt) + before = lifecycle.snapshot(registry=registry, home=home) + assert before["entries"][0]["automatic_eligible"] is True + metadata = tomllib.loads(path.read_text()) + monkeypatch.setattr(lifecycle, "require_closed_app", lambda: None) + monkeypatch.setattr(lifecycle.sys, "platform", "darwin") + result = lifecycle.reconcile(before=before, registry=registry, home=home) + assert result["ok"] and result["results"][0]["status"] == "updated" + after = tomllib.loads(path.read_text()) + assert {k: v for k, v in after.items() if k != "prompt"} == {k: v for k, v in metadata.items() if k != "prompt"} + assert upgrade.bootstrap_binding(after["prompt"]) is not None + assert lifecycle.reconcile(before=before, registry=registry, home=home)["results"][0]["status"] == "current" + + +@pytest.mark.parametrize("scenario", ["custom", "unsupported_host", "race", "wrong_home", "canary"]) +def test_update_does_not_overwrite_custom_changed_or_foreign_hosts(tmp_path, monkeypatch, scenario): + from loopx.control_plane.heartbeat import installed_prompt_update as lifecycle + home, path, database, registry, _ = fixture(tmp_path) + prompt = upgrade.bootstrap_prompt(registry=registry, goal_id="fixture-goal", agent_id="agent-a", + cli_bin="loopx-canary" if scenario == "canary" else "loopx") + prompt = prompt.replace(upgrade.BOOTSTRAP, upgrade._LEGACY_BOOTSTRAP, 1).removesuffix( + upgrade._BOOTSTRAP_INSTRUCTION) + upgrade._LEGACY_INSTRUCTION + if scenario == "custom": + prompt += "\nAdditional owner instruction." + _set_fixture_prompt(path, database, prompt) + before = lifecycle.snapshot(registry=registry, home=home) + if scenario == "race": + _set_fixture_prompt(path, database, prompt + "\nConcurrent edit.") + original = path.read_bytes() + monkeypatch.setattr(lifecycle.sys, "platform", "linux") + if scenario == "wrong_home": + with pytest.raises(ValueError, match="another host"): + lifecycle.reconcile(before=before, registry=registry, home=tmp_path / "other") + else: + result = lifecycle.reconcile(before=before, registry=registry, home=home) + expected = {"custom": "review_required", "canary": "review_required", + "unsupported_host": "deferred", "race": "changed_since_snapshot"}[scenario] + assert result["results"][0]["status"] == expected + assert not result["ok"] + if scenario == "unsupported_host": + request = result["api_updates"][0]["arguments"] + assert request["name"] == "Fixture watch" + assert request["targetThreadId"] == "thread-a" and request["status"] == "PAUSED" + assert path.read_bytes() == original + assert not (home / "loopx-automation-backups").exists() + + +@pytest.mark.parametrize("after_replace", [False, True]) +def test_mirror_failure_rolls_back_db_and_is_journal_recoverable(tmp_path, monkeypatch, after_replace): home, path, database, _, prompt = fixture(tmp_path) atomic = upgrade._atomic def fail_mirror(target, text): if target == path: + if after_replace: + atomic(target, text) raise OSError("synthetic mirror failure") atomic(target, text) monkeypatch.setattr(upgrade, "_atomic", fail_mirror) with pytest.raises(OSError): upgrade.apply_offline(home=home, automation_id="watch", expected_prompt_sha256=upgrade.digest(prompt), desired_prompt="new") - assert tomllib.loads(path.read_text())["prompt"] == prompt + assert tomllib.loads(path.read_text())["prompt"] == ("new" if after_replace else prompt) with sqlite3.connect(database) as connection: - assert connection.execute("SELECT prompt FROM automations").fetchone()[0] == "new" + assert connection.execute("SELECT prompt FROM automations").fetchone()[0] == prompt monkeypatch.setattr(upgrade, "_atomic", atomic) assert upgrade.recover_offline(home=home, automation_id="watch")["status"] == "recovered" assert tomllib.loads(path.read_text())["prompt"] == "new" assert upgrade.recover_offline(home=home, automation_id="watch")["status"] == "recovered" +def test_upgrade_migrates_with_running_app_without_process_or_schedule_mutations(tmp_path, monkeypatch): + from loopx.control_plane.heartbeat import installed_prompt_update as lifecycle + home, path, database, registry, _ = fixture(tmp_path) + prompt = upgrade.bootstrap_prompt(registry=registry, goal_id="fixture-goal", agent_id="agent-a") + legacy = prompt.replace(upgrade.BOOTSTRAP, upgrade._LEGACY_BOOTSTRAP, 1).removesuffix( + upgrade._BOOTSTRAP_INSTRUCTION) + upgrade._LEGACY_INSTRUCTION + _set_fixture_prompt(path, database, legacy) + before = lifecycle.snapshot(registry=registry, home=home) + monkeypatch.setattr(lifecycle.sys, "platform", "darwin") + def forbidden(): + raise AssertionError("the App is running; upgrade must not close or pause it") + monkeypatch.setattr(lifecycle, "require_closed_app", forbidden) + with sqlite3.connect(database) as observer: + metadata = observer.execute("SELECT status, target_thread_id, rrule, model, updated_at, next_run_at FROM automations").fetchone() + result = lifecycle.reconcile(before=before, registry=registry, home=home) + assert result["ok"] and result["results"][0]["status"] == "updated" + assert observer.execute("SELECT prompt FROM automations").fetchone()[0] == prompt + assert observer.execute("SELECT status, target_thread_id, rrule, model, updated_at, next_run_at FROM automations").fetchone() == metadata + assert tomllib.loads(path.read_text())["prompt"] == prompt + + +def test_concurrent_manifest_change_before_write_is_not_overwritten(tmp_path, monkeypatch): + home, path, database, _, prompt = fixture(tmp_path) + original = path.read_text() + atomic = upgrade._atomic + def concurrent_edit(target, text): + atomic(target, text) + if target != path: + path.write_text(original + '\n# concurrent owner edit\n') + monkeypatch.setattr(upgrade, "_atomic", concurrent_edit) + with pytest.raises(ValueError, match="manifest changed"): + upgrade.apply_offline(home=home, automation_id="watch", + expected_prompt_sha256=upgrade.digest(prompt), desired_prompt="new") + assert "concurrent owner edit" in path.read_text() + with sqlite3.connect(database) as connection: + assert connection.execute("SELECT prompt FROM automations").fetchone()[0] == prompt + + +def test_sqlite_writer_lock_covers_mirror_delivery(tmp_path, monkeypatch): + home, path, database, _, prompt = fixture(tmp_path) + atomic = upgrade._atomic + def while_locked(target, text): + if target == path: + with sqlite3.connect(database, timeout=0) as other: + with pytest.raises(sqlite3.OperationalError, match="locked"): + other.execute("UPDATE automations SET prompt='concurrent'") + atomic(target, text) + monkeypatch.setattr(upgrade, "_atomic", while_locked) + assert upgrade.apply_offline(home=home, automation_id="watch", + expected_prompt_sha256=upgrade.digest(prompt), desired_prompt="new")["ok"] + + +def test_readback_detects_external_manifest_edit_without_rolling_it_back(tmp_path, monkeypatch): + home, path, database, _, prompt = fixture(tmp_path) + atomic = upgrade._atomic + def interference(target, text): + atomic(target, text) + if target == path: + path.write_text(text + '\n# owner edit during readback\n') + monkeypatch.setattr(upgrade, "_atomic", interference) + with pytest.raises(ValueError, match="readback"): + upgrade.apply_offline(home=home, automation_id="watch", + expected_prompt_sha256=upgrade.digest(prompt), desired_prompt="new") + assert 'owner edit' in path.read_text() + with sqlite3.connect(database) as connection: + assert connection.execute("SELECT prompt FROM automations").fetchone()[0] == prompt + + def test_recovery_refuses_later_customization(tmp_path): home, path, _, _, prompt = fixture(tmp_path) upgrade.apply_offline(home=home, automation_id="watch", diff --git a/tests/control_plane/test_cli_output_budget.py b/tests/control_plane/test_cli_output_budget.py index 2e7504329d..9f65be4bb4 100644 --- a/tests/control_plane/test_cli_output_budget.py +++ b/tests/control_plane/test_cli_output_budget.py @@ -6,6 +6,7 @@ import json import os import shlex +import tempfile from dataclasses import dataclass from pathlib import Path @@ -1429,7 +1430,14 @@ def test_collection_growth_and_bootstrap_duplication_are_explicit(tmp_path: Path def test_explicit_compact_and_detail_modes_are_characterized(tmp_path: Path) -> None: - project, runtime, registry_path, state_file = _write_fixture(tmp_path, SCENARIOS[0]) + # Match the other budget scenarios: runner/xdist path length is not a + # prompt revision. Exercise real long paths separately below. + with _stable_budget_fixture_root(tmp_path / "variants") as root: + _assert_mode_variant_budgets(root) + + +def _assert_mode_variant_budgets(root: Path, *, only: str | None = None) -> None: + project, runtime, registry_path, state_file = _write_fixture(root, SCENARIOS[0]) for output_format in ("json", "markdown"): commands = _mode_variant_commands( project=project, @@ -1439,6 +1447,8 @@ def test_explicit_compact_and_detail_modes_are_characterized(tmp_path: Path) -> output_format=output_format, ) for variant_id, command in commands.items(): + if only is not None and variant_id != only: + continue spec = CLI_OUTPUT_MODE_VARIANT_BY_ID[variant_id] if output_format not in spec.output_formats: continue @@ -1453,6 +1463,20 @@ def test_explicit_compact_and_detail_modes_are_characterized(tmp_path: Path) -> ) +def test_brief_budget_retains_full_commands_on_real_long_paths() -> None: + # A reproducible 128-character absolute root, independent of pytest's + # ever-growing temp/worker prefix. Do not shorten rendered paths or raise + # the absolute output ceiling to make this case pass. + parent = Path(tempfile.gettempdir()).resolve() + # tempfile contributes an eight-character random suffix. Hold input size + # constant across Linux /tmp and macOS's longer temporary-directory root. + prefix = "loopx-brief-".ljust(128 - len(str(parent)) - 1 - 8, "p") + with tempfile.TemporaryDirectory(prefix=prefix, dir=parent) as directory: + root = Path(directory).resolve() + assert len(str(root)) == 128 + _assert_mode_variant_budgets(root, only="heartbeat_prompt_brief") + + def test_todo_list_explicit_limit_stays_bounded_and_default_path_unchanged( tmp_path: Path, ) -> None: diff --git a/tests/control_plane/test_cli_output_differential.py b/tests/control_plane/test_cli_output_differential.py index 2dac0ac73a..bad1c40fb7 100644 --- a/tests/control_plane/test_cli_output_differential.py +++ b/tests/control_plane/test_cli_output_differential.py @@ -85,6 +85,24 @@ def test_sync_commit_uses_main_as_cli_output_base() -> None: assert selected == "origin/main" +@pytest.mark.parametrize("row_kind", ["surface", "variant"]) +@pytest.mark.parametrize("mode", ["thin", "brief", "compact"]) +def test_host_safety_restoration_budget_is_one_time_bounded_and_prompt_only(row_kind, mode): + from loopx.control_plane.testing.cli_output_differential import _compare_row + from loopx.control_plane.testing.cli_output_semantics import host_prompt_static_safety_revision + from loopx.control_plane.heartbeat.rules import HOST_LOOP_SAFETY_RULE + assert host_prompt_static_safety_revision(HOST_LOOP_SAFETY_RULE) == "host_prompt_static_safety_v1" + assert host_prompt_static_safety_revision(HOST_LOOP_SAFETY_RULE.replace("requires explicit authorization", "is always allowed")) is None + base = _row(row_id=f"{row_kind}/heartbeat_prompt_{mode}/small/json") + current = {**base, "chars": base["chars"] + 500, + "host_prompt_static_safety_revision": "host_prompt_static_safety_v1"} + assert not _compare_row(base, current)["failures"] + assert _compare_row(base, {**current, "chars": base["chars"] + 513})["failures"] + assert _compare_row(current, {**current, "chars": current["chars"] + 500})["failures"] + assert _compare_row({**base, "row_id": "surface/status/small/json"}, + {**current, "row_id": "surface/status/small/json"})["failures"] + + def test_regular_integration_pr_keeps_requested_cli_output_base() -> None: ancestors = { ("origin/main", "HEAD"), diff --git a/tests/control_plane/test_goal_prompt_dispatch.py b/tests/control_plane/test_goal_prompt_dispatch.py new file mode 100644 index 0000000000..74c24b25a2 --- /dev/null +++ b/tests/control_plane/test_goal_prompt_dispatch.py @@ -0,0 +1,100 @@ +"""Native Goal bootstrap delegates policy to the current quota contract.""" + +import os +import re +import shutil +import subprocess + +import pytest + +from loopx.heartbeat_prompt import build_heartbeat_prompt + + +@pytest.mark.parametrize( + "host", ["codex_cli", "codex_app_ssh_goal", "ark_managed_agent_goal", "traex"] +) +def test_goal_prompt_has_one_live_execution_entry(host: str) -> None: + kwargs = {"visible_goal_host": "traex-cli", "runtime_profile": "generic_cli"} if host == "traex" else { + "runtime_profile": host + } + packet = build_heartbeat_prompt( + goal_id="contract-fixture", agent_id="worker-a", + registered_agents=["worker-a"], **kwargs, + ) + body = packet["task_body"] + assert "Each work iteration" in body + assert "complete successful JSON" in body + assert "interaction_contract" in body + assert "settlement_plan.ordered_steps" in body + assert "selection_command" in body + assert "next_cli_actions" in body + assert packet["quota_spend_command"] not in body + assert packet["progress_refresh_state_command"] not in body + assert "Do not reconstruct" in body + assert "new host Goal" in body + assert "terminal no-follow-up" in body + assert "notification" in body + assert "no work/spend" in body + assert "No permission asks in a trusted session" not in body + assert len(body) < 3300 + + +def test_codex_wait_rule_is_not_exported_to_other_goal_hosts() -> None: + codex = build_heartbeat_prompt(goal_id="contract-fixture", runtime_profile="codex_cli") + ark = build_heartbeat_prompt(goal_id="contract-fixture", runtime_profile="ark_managed_agent_goal") + assert "status=blocked" in codex["task_body"] + assert "status=blocked" not in ark["task_body"] + assert "do not invoke LoopX Turn" in ark["task_body"] + + +def test_explicit_goal_policy_is_preserved_not_replaced_by_bootstrap_defaults() -> None: + packet = build_heartbeat_prompt( + goal_id="contract-fixture", runtime_profile="codex_cli", + permission_rule="Only edit the assigned workspace.", + material_queue_rule="Use the approved reference set only.", + ) + assert "Only edit the assigned workspace." in packet["task_body"] + assert "Use the approved reference set only." in packet["task_body"] + + +@pytest.mark.parametrize("profile", ["codex_cli", None, "ark_managed_agent_goal"]) +def test_shared_static_safety_and_exception_routing_survive_thinning(profile): + body = build_heartbeat_prompt(goal_id="contract-fixture", runtime_profile=profile, + thin=True)["task_body"] + for obligation in ("repository rules", "credentials", "private material", + "Destructive Git", "production", "loopx-project", "loopx-self-repair"): + assert obligation in body + assert "project-specific workflow" not in body + assert "No project branches" not in body + assert "only the affected path" in body + + +@pytest.mark.parametrize("shell", ["bash", "zsh"]) +def test_emitted_heartbeat_bootstrap_expands_turn_before_real_guard(shell, tmp_path): + executable = shutil.which(shell) + if executable is None: + pytest.skip(f"{shell} unavailable") + # The actual emitted shell block must work without any inherited Turn. + from importlib.util import module_from_spec, spec_from_file_location + from pathlib import Path + spec = spec_from_file_location("goal_runner", Path(__file__).resolve().parents[2] / + "scripts/qualify-native-goal-release.py") + runner = module_from_spec(spec) + spec.loader.exec_module(runner) + project, _, launcher = runner.setup(tmp_path) + packet = runner.cli(launcher, "heartbeat-prompt", "--thin", "--codex-app", + "--goal-id", runner.GOAL, "--agent-id", runner.AGENT, + "--cli-bin", str(launcher)) + script = re.search(r"```sh\n(.*?)\n```", packet["task_body"], re.S).group(1) + script = script.replace("", "2026-09-01T00:00:00Z") + env = {k: v for k, v in os.environ.items() if k != "LOOPX_TURN"} + result = subprocess.run([executable, "-c", script], cwd=project, env=env, + capture_output=True, text=True, timeout=120) + assert result.returncode == 0, result.stderr + assert '"ok": true' in result.stdout + # Prefix assignment is a genuine shell failure, not a LoopX rejection. + broken = script.replace("LOOPX_TURN=2026-09-01T00:00:00Z\n", + "LOOPX_TURN=2026-09-01T00:00:00Z ", 1) + rejected = subprocess.run([executable, "-c", broken], cwd=project, env=env, + capture_output=True, text=True, timeout=120) + assert rejected.returncode != 0 and "LOOPX_TURN" in rejected.stderr diff --git a/tests/control_plane/test_heartbeat_notification_rule.py b/tests/control_plane/test_heartbeat_notification_rule.py index 348ea90539..63bda2a32b 100644 --- a/tests/control_plane/test_heartbeat_notification_rule.py +++ b/tests/control_plane/test_heartbeat_notification_rule.py @@ -11,7 +11,6 @@ from loopx.control_plane.heartbeat.rules import ( HEARTBEAT_NOTIFICATION_RULE_SHORT, - HEARTBEAT_NOTIFICATION_RULE_THIN, ) from loopx.control_plane.heartbeat.task_body import ( render_brief_heartbeat_task_body, @@ -42,11 +41,11 @@ def test_short_rule_qualifies_dont_notify_as_output_only() -> None: assert "execution_obligation.must_attempt_work" in rule -def test_short_rules_keep_projection_repair_and_quiet_boundary() -> None: - for rule in (HEARTBEAT_NOTIFICATION_RULE_SHORT, HEARTBEAT_NOTIFICATION_RULE_THIN): - assert "NOTIFY缺动作→具体user todo未投影" in rule - assert "需修复LoopX状态投影" in rule - assert "静默时内部修复" in rule +def test_shared_rule_keeps_projection_repair_and_quiet_boundary() -> None: + rule = HEARTBEAT_NOTIFICATION_RULE_SHORT + assert "NOTIFY缺动作→具体user todo未投影" in rule + assert "需修复LoopX状态投影" in rule + assert "静默时内部修复" in rule def test_rendered_task_bodies_keep_execution_obligation_authority() -> None: @@ -70,7 +69,7 @@ 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 "agent_must_attempt" in body + assert "heartbeat_recommendation.agent_must_attempt" in body assert "execution_obligation.must_attempt_work" in body assert "OUTPUT only" in body assert "需修复LoopX状态投影" in body diff --git a/tests/control_plane/test_heartbeat_prompt_support.py b/tests/control_plane/test_heartbeat_prompt_support.py index d75c2eedf4..eab2e3cd62 100644 --- a/tests/control_plane/test_heartbeat_prompt_support.py +++ b/tests/control_plane/test_heartbeat_prompt_support.py @@ -147,9 +147,9 @@ def test_goal_hosts_preserve_sizing_and_terminal_boundary(profile: str) -> None: payload = build_heartbeat_prompt(goal_id="sizing-fixture", runtime_profile=profile) body = payload["task_body"] assert body.count(SCOPE_BOUNDED_WORK_RULE) == 1 - assert "`should_run=false`: no delivery/spend" in body + assert "no work/spend" in body assert "terminal no-follow-up" in body - assert "Then spend exactly once" in body + assert "settlement_plan.ordered_steps" in body assert payload["interface_budget"]["within_budget"] is True diff --git a/tests/control_plane/test_host_bootstrap_lifecycle.py b/tests/control_plane/test_host_bootstrap_lifecycle.py new file mode 100644 index 0000000000..2f6d4cfbb2 --- /dev/null +++ b/tests/control_plane/test_host_bootstrap_lifecycle.py @@ -0,0 +1,116 @@ +"""The persistent host entrypoint reloads policy, not scheduler ownership.""" +import json +import shlex +import subprocess +import sys + +import pytest + + +def cli(registry, *arguments): + result = subprocess.run([sys.executable, "-m", "loopx.cli", "--format", "json", + "--registry", str(registry), "heartbeat-prompt", "--goal-id", "fixture-goal", + "--agent-id", "worker-a", *arguments], capture_output=True, text=True, timeout=60) + return json.loads(result.stdout) + + +@pytest.fixture +def registry(tmp_path): + state = tmp_path / "STATE.md" + state.write_text("# Fixture\n") + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"goals": [{"id": "fixture-goal", "repo": str(tmp_path), + "state_file": str(state), "registered_agents": ["worker-a"]}]})) + return registry + + +@pytest.mark.parametrize("flags", [ + ["--runtime-profile", "codex_cli"], ["--runtime-profile", "codex_app_ssh_goal"], + ["--runtime-profile", "ark_managed_agent_goal"], + ["--runtime-profile", "generic_cli", "--visible-goal-host", "traex-cli"], + ["--codex-app"], +]) +def test_bootstrap_real_cli_load_is_one_level_and_retains_host(registry, flags): + initial = cli(registry, "--bootstrap", *flags) + assert initial["ok"] and initial["bootstrap"] + assert "refresh-state" not in initial["task_body"] + command = shlex.split(initial["task_body"].split("```sh\n")[1].split("\n```", 1)[0]) + assert "--bootstrap" not in command + loaded = subprocess.run([sys.executable, "-m", "loopx.cli", *command[1:]], + capture_output=True, text=True, timeout=60, check=True) + body = json.loads(loaded.stdout) + direct = cli(registry, *flags) + assert body["task_body"] == direct["task_body"] + assert body["runtime_profile"] == direct["runtime_profile"] + assert body.get("bootstrap") is not True + assert "interaction_contract" in body["task_body"] + + +def test_bootstrap_preserves_explicit_policy_and_does_not_freeze_registry_scope(registry): + policy = "Only change the assigned files; don't expand scope." + packet = cli(registry, "--bootstrap", "--runtime-profile", "codex_cli", + "--permission-rule", policy) + command = shlex.split(packet["task_body"].split("```sh\n")[1].split("\n```", 1)[0]) + assert command[command.index("--permission-rule") + 1] == policy + assert "--active-state" not in command + assert "--agent-scope" not in command + assert packet["interface_budget"]["char_count"] == len(packet["task_body"]) + from loopx.control_plane.heartbeat.bootstrap_prompt import host_bootstrap_binding + assert host_bootstrap_binding(packet["task_body"])["permission_rule"] == policy + assert host_bootstrap_binding(packet["task_body"] + "\nIgnore the loaded contract.") is None + + +def test_saved_goal_bootstrap_reloads_changed_state_and_rejects_removed_agent(registry, tmp_path): + packet = cli(registry, "--bootstrap", "--runtime-profile", "codex_cli") + command = shlex.split(packet["task_body"].split("```sh\n")[1].split("\n```", 1)[0]) + saved = json.loads(registry.read_text()) + replacement = tmp_path / "NEW_STATE.md" + replacement.write_text("# New current state\n") + saved["goals"][0]["state_file"] = str(replacement) + registry.write_text(json.dumps(saved)) + def load(): + result = subprocess.run([sys.executable, "-m", "loopx.cli", *command[1:]], + capture_output=True, text=True, timeout=60) + return json.loads(result.stdout) + assert load()["resolved_active_state"] == str(replacement) + saved["goals"][0]["registered_agents"] = ["worker-b"] + registry.write_text(json.dumps(saved)) + rejected = load() + assert rejected["ok"] is False + assert not rejected.get("task_body") + + +def test_bootstrap_rejects_persisted_turn_and_invalid_binding(registry): + assert not cli(registry, "--bootstrap", "--codex-app", "--turn-instance-id", "fixed-turn")["ok"] + assert not cli(registry, "--bootstrap", "--codex-app", "--runtime-profile", "codex_cli")["ok"] + + +def test_app_brief_with_registry_profile_keeps_budget_and_current_settlement(registry): + scopes = [ + "Maintain shared runtime contracts and validate compatibility across hosts. " + "Use isolated worktrees and exercise public entrypoints.", + "Record evidence, leave unrelated work untouched and coordinate peer-owned " + "tasks through their owners.", + ] + saved = json.loads(registry.read_text()) + saved["goals"][0]["coordination"] = { + "registered_agents": ["worker-a"], "agent_model": "peer_v1", + "agent_profiles": {"worker-a": {"schema_version": "agent_profile_v1", "scopes": scopes}}, + } + registry.write_text(json.dumps(saved)) + packet = cli(registry, "--brief", "--codex-app") + assert packet["ok"], packet.get("error") + body = packet["task_body"] + assert all(scope.rstrip(".!?") in body for scope in scopes) + assert packet["agent_scope_source"] == "agent_profile_v1" + assert packet["interface_budget"]["max_chars"] == 3500 + assert packet["interface_budget"]["within_budget"], packet["interface_budget"] + assert packet["cli_preflight"] in body + assert "--codex-app" in body + assert "execution_obligation.must_attempt_work" in body + assert "heartbeat_recommendation.agent_must_attempt" in body + assert "interaction_contract.cli_channel.settlement_plan.ordered_steps" in body + assert "terminal no-follow-up" in body + assert packet["quota_spend_command"] not in body + assert packet["progress_refresh_state_command"] not in body + assert packet["refresh_state_command"] not in body diff --git a/tests/control_plane/test_host_prompt_behavior.py b/tests/control_plane/test_host_prompt_behavior.py new file mode 100644 index 0000000000..1828d910c1 --- /dev/null +++ b/tests/control_plane/test_host_prompt_behavior.py @@ -0,0 +1,95 @@ +"""Deterministic probe/oracle checks; no provider calls in pytest.""" +import json +import runpy +from pathlib import Path + +import pytest + +from loopx.control_plane.testing.host_prompt_behavior import cases, probe_messages, run_probe + + +ANSWERS = [ + {"action": "work", "notify": False, "finish_goal": False}, + {"action": "wait", "notify": True, "finish_goal": False}, + {"action": "wait", "notify": False, "finish_goal": False}, + {"action": "replan", "notify": False, "finish_goal": False}, +] + + +class ScriptedClient: + actor_ref = "scripted-not-model-evidence" + + def __init__(self, mutation=None, repeats=1): + self.count = 0 + self.mutation = mutation + self.repeats = repeats + + def next_final_content(self, messages): + assert "expected" not in json.dumps(messages) + answer = dict(ANSWERS[(self.count // self.repeats) % 4]) + self.count += 1 + if self.mutation: + answer.update(self.mutation) + return json.dumps(answer) + + +def test_probe_uses_current_production_prompts_and_hidden_independent_oracle(): + for mode in ("thin", "brief"): + for case in cases(): + messages = probe_messages(mode, case["packet"]) + body = messages[1]["content"] + assert "execution_obligation.must_attempt_work" in body + assert "heartbeat_recommendation.agent_must_attempt" in body + assert "--codex-app" in body + assert "LOOPX_TURN=" in body + assert case["id"] not in json.dumps(messages) + assert "expected" not in json.dumps(messages) + client = ScriptedClient() + report = run_probe(client, repeats=1) + assert report["qualification_passed"] + assert client.count == report["provider_call_count"] == 8 + assert not report["host_execution_qualified"] + + +@pytest.mark.parametrize("mutation", [ + {"action": "wait"}, {"notify": True}, {"finish_goal": True}, {"notify": 0}, +]) +def test_oracle_rejects_quiet_noop_spurious_notification_early_finish_and_wrong_types(mutation): + report = run_probe(ScriptedClient(mutation), repeats=1) + assert not report["qualification_passed"] + + +def test_later_success_does_not_erase_a_failed_independent_attempt(): + class FailOnce(ScriptedClient): + def next_final_content(self, messages): + self.mutation = {"action": "wait"} if self.count == 0 else None + return super().next_final_content(messages) + + report = run_probe(FailOnce(repeats=2), repeats=2) + assert report["provider_call_count"] == 16 + assert not report["qualification_passed"] + assert sum(not row["passed"] for row in report["results"]) == 1 + + +def test_release_probe_is_default_off_and_missing_environment_is_not_a_live_pass(monkeypatch, capsys): + script = Path(__file__).resolve().parents[2] / "scripts/qualify-host-prompt-release.py" + main = runpy.run_path(str(script))["main"] + monkeypatch.setenv("ARK_API_KEY", "test-only-unused") + assert main([]) == 0 + assert json.loads(capsys.readouterr().out)["provider_call_count"] == 0 + monkeypatch.delenv("ARK_API_KEY") + assert main(["--release-live"]) == 0 + assert json.loads(capsys.readouterr().out)["status"] == "skipped" + + +def test_attempted_failure_is_not_reported_as_environment_skip(monkeypatch, capsys): + script = Path(__file__).resolve().parents[2] / "scripts/qualify-host-prompt-release.py" + main = runpy.run_path(str(script))["main"] + def fail(*args, **kwargs): + raise RuntimeError("private provider failure must not escape") + monkeypatch.setitem(main.__globals__, "run_probe", fail) + monkeypatch.setenv("ARK_API_KEY", "test-only-unused") + assert main(["--release-live"]) == 1 + result = capsys.readouterr().out + assert "private provider" not in result + assert json.loads(result)["status"] == "failed" diff --git a/tests/control_plane/test_quota_settlement.py b/tests/control_plane/test_quota_settlement.py index e6368ea89d..c69bb82df3 100644 --- a/tests/control_plane/test_quota_settlement.py +++ b/tests/control_plane/test_quota_settlement.py @@ -273,7 +273,7 @@ def test_quota_settlement_readback_returns_the_complete_typed_chain( assert readback.spend_run is not None -def test_advancement_completion_requires_the_complete_settlement_chain( +def test_only_terminal_closeout_requires_the_complete_settlement_chain( tmp_path: Path, ) -> None: runtime_root = tmp_path / "runtime" @@ -289,18 +289,12 @@ def test_advancement_completion_requires_the_complete_settlement_chain( ) assert incomplete is not None error = _completion_settlement_error( - { - "role": "agent", - "task_class": "advancement_task", - "action_kind": "implement", - "text": "Ship the repository change.", - }, incomplete, - no_follow_up=False, + no_follow_up=True, ) assert error is not None assert error.startswith( - "turn-scoped advancement completion requires matching writeback and " + "terminal no-follow-up closeout requires matching writeback and " "quota spend receipts:" ) @@ -318,26 +312,13 @@ def test_advancement_completion_requires_the_complete_settlement_chain( ) assert ( _completion_settlement_error( - { - "role": "agent", - "task_class": "advancement_task", - "action_kind": "implement", - "text": "Ship the repository change.", - }, settled, - no_follow_up=False, + no_follow_up=True, ) is None ) assert ( _completion_settlement_error( - { - "role": "agent", - "task_class": "advancement_task", - "action_kind": "research", - "continuation_policy": "same_agent_non_delivery", - "text": "Analyze the evidence.", - }, incomplete, no_follow_up=False, ) @@ -631,7 +612,7 @@ def test_codex_app_actions_preserve_a_concrete_admitted_turn_identity() -> None: SchedulerRuntimeProfile.CODEX_CLI_VISIBLE, ), ) -def test_unbound_native_goal_actions_preserve_visible_goal_spend_attribution( +def test_unbound_native_goal_actions_require_host_identity_before_settlement( profile: SchedulerRuntimeProfile, ) -> None: todo_id = "todo_visible_goal" @@ -647,14 +628,12 @@ def test_unbound_native_goal_actions_preserve_visible_goal_spend_attribution( ), ) - assert len(actions) == 2 - assert actions[0].startswith("loopx refresh-state") - assert actions[1] == ( - f"loopx quota spend-slot --goal-id {GOAL_ID} --slots 1 " - f"--source visible-goal --execute --agent-id {AGENT_ID}" - ) - assert all("--todo-id" not in command for command in actions) - assert all("--turn-instance-id" not in command for command in actions) + assert len(actions) == 1 + assert "quota should-run" in actions[0] + assert "--turn-instance-id" in actions[0] + assert "--begin-turn" not in actions[0] + assert "spend-slot" not in actions[0] + assert "refresh-state" not in actions[0] def test_unbound_codex_app_ssh_goal_requires_a_guided_turn_before_delivery() -> None: @@ -677,7 +656,14 @@ def test_unbound_codex_app_ssh_goal_requires_a_guided_turn_before_delivery() -> assert "spend-slot" not in actions[0] -def test_unbound_codex_app_ssh_goal_requires_a_guided_turn_before_replan() -> None: +@pytest.mark.parametrize( + "profile", ( + SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE, + SchedulerRuntimeProfile.CODEX_CLI_VISIBLE, + SchedulerRuntimeProfile.ARK_MANAGED_AGENT_GOAL, + ), +) +def test_unbound_native_goal_requires_identity_before_replan(profile) -> None: actions = interaction_next_cli_actions( { "goal_id": GOAL_ID, @@ -698,19 +684,30 @@ def test_unbound_codex_app_ssh_goal_requires_a_guided_turn_before_replan() -> No }, mode="autonomous_replan", scheduler_execution_context=scheduler_execution_context_for_runtime_profile( - SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE + profile ), ) assert len(actions) == 1 assert actions[0].startswith("loopx --format json quota should-run") - assert "--runtime-profile codex_app_ssh_goal" in actions[0] - assert actions[0].endswith("--begin-turn") + assert f"--runtime-profile {profile.value}" in actions[0] + if profile is SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE: + assert actions[0].endswith("--begin-turn") + else: + assert "--turn-instance-id" in actions[0] + assert "--begin-turn" not in actions[0] assert "refresh-state" not in actions[0] assert "spend-slot" not in actions[0] -def test_turn_bound_codex_app_ssh_goal_preserves_visible_goal_settlement() -> None: +@pytest.mark.parametrize( + "profile", ( + SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE, + SchedulerRuntimeProfile.CODEX_CLI_VISIBLE, + SchedulerRuntimeProfile.ARK_MANAGED_AGENT_GOAL, + ), +) +def test_turn_bound_native_goal_preserves_visible_goal_settlement(profile) -> None: turn_instance_id = "guided-start:native-visible-goal" actions = interaction_next_cli_actions( @@ -721,7 +718,7 @@ def test_turn_bound_codex_app_ssh_goal_preserves_visible_goal_settlement() -> No }, mode="bounded_delivery", scheduler_execution_context=scheduler_execution_context_for_runtime_profile( - SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE + profile ), turn_instance_id=turn_instance_id, ) diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index 293429ea08..d101b717a8 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -2016,8 +2016,9 @@ def test_visible_goal_unbound_spend_recovers_delivery_after_capability_replan( assert _spend_run_count(runtime) == 1 +@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( - tmp_path: Path, + tmp_path: Path, profile: str, ) -> None: project, runtime, registry_path = _write_fixture(tmp_path) @@ -2027,7 +2028,7 @@ def test_unbound_visible_goal_spend_returns_typed_mismatch_without_receipt( "quota", "should-run", "--runtime-profile", - "codex_app_ssh_goal", + profile, "--goal-id", GOAL_ID, "--agent-id", @@ -2038,7 +2039,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 - assert actions[0].endswith("--begin-turn") + 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) spend_rc, spend = _run_cli( @@ -2065,6 +2070,22 @@ 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) + 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 [step["kind"] for step in plan["ordered_steps"]] == [ + "validation", "durable_writeback", "quota_spend", "terminal_closeout", + ] + assert _spend_run_count(runtime) == 0 + def test_begin_turn_rejects_a_non_receipt_runtime_profile(tmp_path: Path) -> None: project, runtime, registry_path = _write_fixture(tmp_path) diff --git a/tests/fixtures/native_goal_ledger/TASK.md b/tests/fixtures/native_goal_ledger/TASK.md new file mode 100644 index 0000000000..0f59fa4aa7 --- /dev/null +++ b/tests/fixtures/native_goal_ledger/TASK.md @@ -0,0 +1,24 @@ +# Replay-safe local ledger + +Build `ledger.py` using only the Python standard library. Implement +`reduce_events(events)` and `python ledger.py INPUT` (UTF-8 JSONL). + +Every event has nonempty string `id`, `account`, and `type`. Credit/debit events +have a strictly positive integer `amount` (booleans are invalid). Reverse events +have `target`, referencing an earlier credit/debit in the same account. Reverse +an original at most once; never reverse a reversal. Reject overdrafts at every +prefix, including reversal of a credit. Exact event replays are idempotent; +conflicting reuse of an id is invalid. Reject missing/extra fields, invalid +types and references with `ValueError`. Never mutate input dictionaries. Keep +zero-balance accounts; names are case-sensitive and may contain Unicode. + +The CLI ignores blank lines and prints exactly one JSON object sorted by account +with a trailing newline. Empty input succeeds with `{}`. Invalid JSON or domain +input must leave stdout empty, write a concise diagnostic to stderr, and exit +nonzero. Add reducer and subprocess tests, including long replay, and a README. + +This is a finite local task: no upload, publication, deployment, +package installation, or external side effect is part of acceptance. Keep all +changes in the disposable project/worktrees. Do not change this specification +or LoopX source to make the task pass. Do not substitute fabricated evidence +for actual code delivery. diff --git a/tests/fixtures/native_goal_ledger/verify.py b/tests/fixtures/native_goal_ledger/verify.py new file mode 100644 index 0000000000..b0ca4869d1 --- /dev/null +++ b/tests/fixtures/native_goal_ledger/verify.py @@ -0,0 +1,74 @@ +"""Independent release oracle; the model's own tests are not acceptance authority.""" + +import copy +import importlib.util +import json +from pathlib import Path +import subprocess +import sys +import tempfile + + +def verify(project: Path) -> None: + spec = importlib.util.spec_from_file_location("candidate_ledger", project / "ledger.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + reduce_events = module.reduce_events + credit = {"id": "c", "account": "雪", "type": "credit", "amount": 100} + debit = {"id": "d", "account": "雪", "type": "debit", "amount": 35} + reverse = {"id": "r", "account": "雪", "type": "reverse", "target": "d"} + events = [credit, debit, credit.copy(), reverse, reverse.copy()] + before = copy.deepcopy(events) + assert reduce_events(events) == {"雪": 100} + assert events == before + assert reduce_events([credit, {**reverse, "target": "c"}]) == {"雪": 0} + assert reduce_events([]) == {} + for invalid in ( + [{**credit, "amount": True}], [{**credit, "amount": 1.0}], + [{**credit, "amount": 0}], [{**credit, "extra": 1}], + [{"id": "missing"}], [debit], [reverse, credit], + [credit, {**credit, "amount": 2}], + [credit, debit, {**reverse, "target": "c"}], + [credit, debit, {**reverse, "account": "other"}], + [credit, debit, reverse, {**reverse, "id": "r2"}], + [credit, debit, reverse, {**reverse, "id": "r2", "target": "r"}], + ): + untouched = copy.deepcopy(invalid) + try: + reduce_events(invalid) + except ValueError: + pass + else: + raise AssertionError("invalid_event_accepted") + assert invalid == untouched + long = [{**credit, "id": str(i), "account": "A" if i % 2 else "a"} + for i in range(12000)] + assert reduce_events(long + long) == {"A": 600000, "a": 600000} + with tempfile.TemporaryDirectory(prefix="ledger-oracle-") as raw: + source = Path(raw) / "input.jsonl" + for content, expected in ( + ("\n" + "\n".join(json.dumps(e) for e in events), {"雪": 100}), + ("\n".join(json.dumps(e) for e in [credit, {**credit, "id": "a", "account": "A"}]), + {"A": 100, "雪": 100}), + ("\n", {}), + (json.dumps(credit) + "\nnot-json", None), + (json.dumps(debit), None), + ): + source.write_text(content, encoding="utf-8") + result = subprocess.run( + [sys.executable, str(project / "ledger.py"), str(source)], + capture_output=True, text=True, timeout=30, + ) + if expected is None: + assert result.returncode != 0 and result.stdout == "" and result.stderr + else: + assert result.returncode == 0 and result.stderr == "" + assert result.stdout.endswith("\n") and len(result.stdout.splitlines()) == 1 + assert json.loads(result.stdout) == expected + assert list(json.loads(result.stdout)) == sorted(expected) + assert (project / "README.md").is_file() + + +if __name__ == "__main__": + verify(Path(sys.argv[1]).resolve()) + print("ledger acceptance passed") diff --git a/tests/test_ark_managed_agent_host.py b/tests/test_ark_managed_agent_host.py index c9405ea659..8699210dec 100644 --- a/tests/test_ark_managed_agent_host.py +++ b/tests/test_ark_managed_agent_host.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import shlex import shutil from pathlib import Path @@ -73,22 +74,13 @@ def test_goal_prompt_is_one_transport_independent_activation() -> None: assert "goal loop, not automation" in normalized assert "invoke LoopX Turn" in normalized assert "Progress is not a new Goal boundary" in normalized - assert ( - "Reuse this Goal until terminal" - ) in normalized - assert "do not create a successor merely to continue" in normalized - assert ( - "Normal turns use CLI `interaction_contract`; use `loopx-project` for " - "lifecycle/registry and `loopx-self-repair` for runtime/projection drift." - in normalized - ) - assert "take highest-priority unblocked in-scope todo" in normalized - assert "claims/leases and blocker-push/recovery obligations" in normalized - assert ( - "Before dependencies, persist changed scope/acceptance/non-goal evidence " - "and next todo" - ) in normalized - assert "refresh the accountable progress record before spending" in normalized + assert "do not create a new host Goal merely to continue" in normalized + assert "current `interaction_contract`" in normalized + assert "selection_command" in normalized + assert "settlement_plan.ordered_steps" in normalized + assert "terminal no-follow-up" in normalized + assert local_development["progress_refresh_state_command"] not in local_development["task_body"] + assert local_development["quota_spend_command"] not in local_development["task_body"] def test_goal_prompt_projects_goal_only_host_contract() -> None: @@ -155,9 +147,9 @@ def test_host_activation_submits_one_goal_without_turn_or_automation() -> None: "runtime_capability_reentry_v0" in step and "do not rewrite task_body" in step for step in packet["activation_steps"] ) - assert packet["commands"]["heartbeat_prompt"].endswith( - "--runtime-profile ark_managed_agent_goal" - ) + prompt_args = shlex.split(packet["commands"]["heartbeat_prompt"]) + assert prompt_args[prompt_args.index("--runtime-profile") + 1] == "ark_managed_agent_goal" + assert prompt_args.count("--bootstrap") == 1 assert "automation_update" not in str(packet) assert "loopx turn run-once" not in str(packet).lower() diff --git a/tests/test_ark_managed_agent_issue_fix_matrix.py b/tests/test_ark_managed_agent_issue_fix_matrix.py index 45220e2e88..7f67c029c7 100644 --- a/tests/test_ark_managed_agent_issue_fix_matrix.py +++ b/tests/test_ark_managed_agent_issue_fix_matrix.py @@ -167,10 +167,11 @@ def test_one_shot_host_contract_keeps_goal_closure_with_the_host() -> None: task_body = prompt["task_body"] normalized = " ".join(task_body.split()) assert "Progress is not a new Goal boundary" in normalized - assert "do not create a successor merely to continue" in normalized - assert "refresh the accountable progress record before spending" in normalized - assert "Then spend exactly once against that refresh" in normalized - assert task_body.index("loopx refresh-state") < task_body.index("quota spend-slot") + assert "do not create a new host Goal merely to continue" in normalized + assert "settlement_plan.ordered_steps" in normalized + assert "Do not reconstruct refresh/spend commands" in normalized + assert prompt["progress_refresh_state_command"] not in task_body + assert prompt["quota_spend_command"] not in task_body assert activation["activation_method"] == "submit_goal_once" assert activation["host_mutation"]["prompt_field"] == "task_body" diff --git a/tests/test_claude_goal_release_qualification.py b/tests/test_claude_goal_release_qualification.py new file mode 100644 index 0000000000..35c54e8951 --- /dev/null +++ b/tests/test_claude_goal_release_qualification.py @@ -0,0 +1,186 @@ +"""Ordinary CI covers policy/transport; no test here invokes a model.""" + +import asyncio +import importlib.util +import json +import os +import subprocess +from pathlib import Path +import sys + +import pytest + +REPO = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("claude_release", REPO / "scripts/qualify-claude-goal-release.py") +runner = importlib.util.module_from_spec(spec) +spec.loader.exec_module(runner) + + +def test_default_never_probes_or_calls_model(monkeypatch, capsys): + def forbidden(*_): + raise AssertionError("must not execute") + monkeypatch.setattr(runner, "qualify", forbidden) + monkeypatch.setattr(runner, "prerequisite_failure", forbidden) + assert runner.main([]) == 0 + assert json.loads(capsys.readouterr().out)["model_executed"] is False + + +def test_missing_environment_skips_but_attempted_failure_fails(monkeypatch, capsys): + monkeypatch.setattr(runner, "prerequisite_failure", lambda _: "ark_api_key_unavailable") + assert runner.main(["--release-live"]) == 0 + assert json.loads(capsys.readouterr().out)["status"] == "skipped" + monkeypatch.setattr(runner, "prerequisite_failure", lambda _: None) + def failure(*_): + raise RuntimeError("private sentinel must not reach public result") + monkeypatch.setattr(runner, "qualify", failure) + assert runner.main(["--release-live"]) == 1 + assert json.loads(capsys.readouterr().out) == {"status": "failed", "error_kind": "RuntimeError"} + + +def test_provider_binding_does_not_inherit_another_anthropic_account(monkeypatch, tmp_path): + monkeypatch.setenv("ARK_API_KEY", "synthetic-ark-key") + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "synthetic-other-provider-key") + monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://example.com") + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "synthetic-oauth") + env = runner.host_environment(tmp_path, tmp_path / "bin/loopx") + assert env["ANTHROPIC_API_KEY"] == "synthetic-ark-key" + assert env["ANTHROPIC_BASE_URL"] == runner.ARK_ANTHROPIC_BASE + assert env["ANTHROPIC_MODEL"] == "doubao-seed-evolving" + assert "ANTHROPIC_AUTH_TOKEN" not in env and "CLAUDE_CODE_OAUTH_TOKEN" not in env + assert os.environ["ANTHROPIC_AUTH_TOKEN"] == "synthetic-other-provider-key" + + +def test_child_environment_allowlist_drops_unrelated_secrets_and_operator_home(monkeypatch, tmp_path): + monkeypatch.setenv("ARK_API_KEY", "synthetic-provider-key") + forbidden = ("GH_TOKEN", "DATABASE_URL", "CUSTOM_AUTH", "SSH_AUTH_SOCK", + "AWS_SECRET_ACCESS_KEY", "NODE_OPTIONS", "BASH_ENV", "CODEX_HOME") + for key in forbidden: + monkeypatch.setenv(key, "synthetic-unrelated-value") + env = runner.host_environment(tmp_path, tmp_path / "bin/loopx") + # Execute a real child, not just an assertion on a builder's keys. + result = subprocess.run([sys.executable, "-c", "import os,json; print(json.dumps(dict(os.environ)))"], + env=env, capture_output=True, text=True, check=True) + actual = json.loads(result.stdout) + assert all(key not in actual for key in (*forbidden, "ARK_API_KEY")) + assert actual["HOME"] == str(tmp_path / "home") + assert actual["ANTHROPIC_API_KEY"] == "synthetic-provider-key" + + +def test_claude_loop_uses_current_contract_not_segment_or_empty_list_stop(): + from loopx.claude_goal_mode.scripts.goalmode_cmd import loop_execution_content, loop_md_content + from loopx.control_plane.heartbeat.rules import SCOPE_BOUNDED_WORK_RULE + + bootstrap = loop_md_content("goal-a", "agent-a") + assert "host_prompt" in bootstrap and "loopx:armed" in bootstrap + assert "writeback/spend" not in bootstrap + prompt = loop_execution_content("goal-a", "agent-a") + assert SCOPE_BOUNDED_WORK_RULE in prompt + assert "interaction_contract" in prompt and "notification" in prompt + assert "ONE bounded segment" not in prompt and "no open todos remain" not in prompt + assert "Complete only finished Todos, not partial work" in prompt + assert 'agent_id="agent-a"' in prompt + assert "That MCP operation owns writeback/spend" in prompt + assert "successor_todo_ids" in prompt + assert "unavailable/incomplete contract" in prompt + + +def test_host_timeout_fails_and_reaps_the_spawned_process(tmp_path, monkeypatch): + original = subprocess.Popen + children = [] + def capture(*args, **kwargs): + child = original(*args, **kwargs) + children.append(child) + return child + monkeypatch.setattr(runner.subprocess, "Popen", capture) + with pytest.raises(subprocess.TimeoutExpired): + runner.run_host([sys.executable, "-c", "import time; time.sleep(30)"], + cwd=tmp_path, env=dict(os.environ), timeout=0.1) + assert len(children) == 1 and children[0].poll() is not None + + +def test_host_nonzero_is_not_reported_as_a_successful_model_turn(tmp_path): + with pytest.raises(AssertionError, match="claude_host_failed"): + runner.run_host([sys.executable, "-c", "raise SystemExit(2)"], + cwd=tmp_path, env=dict(os.environ), timeout=10) + + +@pytest.mark.parametrize("failed", [False, True]) +def test_mcp_oracle_requires_successful_transactions_not_only_invocations(failed): + events = [] + for todo in sorted(runner.shared.TODOS): + events.append({"message": {"content": [{"type": "tool_use", "id": todo, + "name": "mcp__loopx__complete_task", "input": {"todo_id": todo}}]}}) + result = {"ok": not failed, "completed": True, "todo_id": todo, + "settlement": {"ok": not failed}} + events.append({"message": {"content": [{"type": "tool_result", "tool_use_id": todo, + "content": json.dumps({"result": json.dumps(result)})}]}}) + if failed: + with pytest.raises(AssertionError, match="mcp_delivery_transactions_not_completed"): + runner.verify_mcp_completions(events) + else: + runner.verify_mcp_completions(events) + + +def test_real_claude_stdio_mcp_binding_and_identity_gate(tmp_path): + pytest.importorskip("mcp.server.fastmcp") + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + from loopx.claude_goal_mode.scripts.goalmode_cmd import write_loop_md + + project, _, launcher = runner.shared.setup(tmp_path) + write_loop_md(project, runner.shared.GOAL, runner.shared.AGENT) + state = project / "ACTIVE_GOAL_STATE.md" + before = state.read_bytes() + params = StdioServerParameters( + command=sys.executable, + args=[str(REPO / "loopx/claude_goal_mode/mcp/loopx_mcp.py")], + cwd=str(project), env=runner.shared.host_environment(tmp_path, launcher), + ) + async def exercise(): + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + tools = await session.list_tools() + assert {"host_prompt", "should_run", "claim_task", "complete_task"} <= {t.name for t in tools.tools} + loaded = await session.call_tool("host_prompt", {}) + current = json.loads(loaded.content[0].text) + assert current["ok"] and current["goal_id"] == runner.shared.GOAL + assert current["agent_id"] == runner.shared.AGENT + assert "complete_task" in current["task_body"] + assert "call the bound LoopX `host_prompt`" not in current["task_body"] + complete = next(t for t in tools.tools if t.name == "complete_task") + assert "successor_todo_ids" in complete.inputSchema["properties"] + guard = await session.call_tool("should_run", {}) + payload = json.loads(guard.content[0].text) + assert payload["ok"] is True and payload["selected_todo"]["todo_id"] == "todo_reducer" + rejected = await session.call_tool("claim_task", {"todo_id": "todo_reducer", "agent_id": "other-agent"}) + assert json.loads(rejected.content[0].text)["ok"] is False + asyncio.run(exercise()) + assert state.read_bytes() == before + + +def test_real_mcp_delivery_completes_and_settles_existing_plan(tmp_path): + from loopx.goal_mode_mcp import GoalModeMCPConfig, GoalModeMCPControlPlane + + project, runtime, launcher = runner.shared.setup(tmp_path) + # Real delivery class: do not substitute same_agent_non_delivery to make + # this acceptance test green. No live model or external side effect. + (project / "delivery.txt").write_text("synthetic verified delivery\n") + control = GoalModeMCPControlPlane( + GoalModeMCPConfig(server_name="loopx", runtime_profile="claude_code", legacy_host_surface="claude_code"), + lambda: {"goal_id": runner.shared.GOAL, "agent_id": runner.shared.AGENT, + "registry": str(project / ".loopx/registry.json")}, + ) + control.command_prefix = lambda: [str(launcher)] + result = json.loads(control.complete_task( + "todo_reducer", runner.shared.AGENT, "synthetic delivery validation passed", + successor_todo_ids=["todo_cli"], + )) + assert result["ok"] is True, "unexpected completion failure" + todos = runner.shared.cli(launcher, "todo", "list", "--goal-id", runner.shared.GOAL, "--role", "agent")["todos"] + assert {row["todo_id"] for row in todos} == runner.shared.TODOS + assert next(row for row in todos if row["todo_id"] == "todo_reducer")["status"] == "done" + index = runtime / "goals" / runner.shared.GOAL / "runs/index.jsonl" + spends = [json.loads(line) for line in index.read_text().splitlines() + if json.loads(line).get("classification") == "quota_slot_spent"] + assert len(spends) == 1 and spends[0]["todo_id"] == "todo_reducer" diff --git a/tests/test_goal_mode_mcp_completion_validation.py b/tests/test_goal_mode_mcp_completion_validation.py index 36301cdca4..e0573167e4 100644 --- a/tests/test_goal_mode_mcp_completion_validation.py +++ b/tests/test_goal_mode_mcp_completion_validation.py @@ -5,6 +5,8 @@ import sys from pathlib import Path +import pytest + from loopx.goal_mode_mcp import GoalModeMCPConfig, GoalModeMCPControlPlane from loopx.status import parse_active_state_todos from loopx.todos import add_goal_todo @@ -68,7 +70,6 @@ def _add_todo(registry: Path, *, validation_command: str | None = None) -> str: text="Deliver one bounded change.", task_class="advancement_task", claimed_by=AGENT, - continuation_policy="same_agent_non_delivery", validation_command=validation_command, ) return str(todo["todo_id"]) @@ -125,10 +126,13 @@ def test_mcp_complete_task_fails_closed_on_failing_declared_validation( assert _agent_todo_status(state, todo_id) != "done" -def test_mcp_advancement_completion_returns_typed_settlement_blocker( - tmp_path: Path, +@pytest.mark.parametrize("no_follow_up", [False, True]) +def test_mcp_advancement_validation_precedes_controller_owned_settlement( + tmp_path: Path, no_follow_up: bool, ) -> None: registry, state = _write_fixture(tmp_path) + validation = state.parent / "check.py" + validation.write_text("from pathlib import Path\nPath('validation-ran').write_text('passed')\n") todo = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, @@ -136,22 +140,22 @@ def test_mcp_advancement_completion_returns_typed_settlement_blocker( text="Deliver one repository advancement.", task_class="advancement_task", claimed_by=AGENT, + validation_command=shlex.join([sys.executable, str(validation)]), ) todo_id = str(todo["todo_id"]) payload = _first_json_blob( - _control(registry).complete_task(todo_id, AGENT, "claimed done") + _control(registry).complete_task(todo_id, AGENT, "validated delivery", no_follow_up=no_follow_up) ) - assert payload["ok"] is False - assert payload["completed"] is False - assert payload["changed"] is False - assert payload["settlement_blocked_completion"] is True + assert payload["ok"] is True, payload + assert payload["completed"] is True assert payload["settlement_identity"]["todo_id"] == todo_id - assert payload["settlement_result"]["failure"]["kind"] == ( - "writeback_missing" - ) - assert _agent_todo_status(state, todo_id) != "done" + assert (state.parent / "validation-ran").read_text() == "passed" + assert payload["settlement"]["durable_writeback"]["ok"] is True + assert payload["settlement"]["quota_spend"]["appended"] is True + assert ("terminal_closeout" in payload["settlement"]) is no_follow_up + assert _agent_todo_status(state, todo_id) == "done" def test_expected_lease_version_annotation_rejects_bool_at_the_boundary() -> None: """FastMCP validates tool arguments with pydantic, and lax pydantic diff --git a/tests/test_goal_mode_mcp_settlement.py b/tests/test_goal_mode_mcp_settlement.py index 6a17df4da1..3dfafc7fc9 100644 --- a/tests/test_goal_mode_mcp_settlement.py +++ b/tests/test_goal_mode_mcp_settlement.py @@ -461,3 +461,92 @@ def test_real_mcp_terminal_completion_closes_out_after_spend( ) assert completed["status"] == "done" assert "no_followup=true" in state_file.read_text(encoding="utf-8") + + +@pytest.mark.parametrize("lost_after", ["lifecycle", "writeback", "spend"]) +def test_real_mcp_completion_recovers_a_lost_mutation_response(tmp_path, lost_after): + """The real write commits, but its caller sees a failure: retry must not pay twice.""" + registry, _ = _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", + claimed_by=AGENT_ID, + ) + control = _control(registry) + original = control.run_cli + injected = False + bound_identity = None + + def lose_once(args, **kwargs): + nonlocal injected, bound_identity + output = original(args, **kwargs) + 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"): + injected = True + bound_identity = json.loads(output)["settlement_identity"] + return json.dumps({"ok": False, "error": "synthetic_response_lost_after_commit"}) + return output + + control.run_cli = lose_once + first = json.loads(control.complete_task( + added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, + )) + assert injected and first["ok"] is False + 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, + todo_id=added["todo_id"], turn_instance_id=bound_identity["turn_instance_id"], + ) + assert readback.replay_phase == ("settled" if lost_after == "spend" else "settlement_pending") + if lost_after != "spend": + 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( + added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, + )) + assert replay["ok"] is True + again = json.loads(control.complete_task( + added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, + )) + assert again["ok"] is True + assert again["settlement_identity"] == replay["settlement_identity"] + assert again["settlement"]["quota_spend"]["appended"] is False + status = json.loads(control.should_run()) + assert status["quota"]["spent_slots"] == 1 + + +def test_real_mcp_links_existing_successor_without_creating_another_todo(tmp_path): + registry, state_file = _write_fixture(tmp_path) + ids = [str(add_goal_todo( + registry_path=registry, goal_id=GOAL_ID, role="agent", text=text, + task_class="advancement_task", + claimed_by=AGENT_ID, + )["todo_id"]) for text in ("First accepted step.", "Already planned follow-up.")] + control = _control(registry) + before = state_file.read_bytes() + rejected = json.loads(control.complete_task( + 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( + ids[0], AGENT_ID, "synthetic check passed", successor_todo_ids=[ids[1]], + )) + assert first["ok"] is True, first + replay = json.loads(control.complete_task( + ids[0], AGENT_ID, "synthetic check passed", successor_todo_ids=[ids[1]], + )) + assert replay["ok"] is True + assert replay["settlement_identity"] == first["settlement_identity"] + todos = parse_active_state_todos(state_file.read_text())["agent_todos"]["items"] + by_id = {row["todo_id"]: row for row in todos} + assert set(by_id) == set(ids) + assert by_id[ids[0]]["status"] == "done" and by_id[ids[1]]["status"] == "open" + assert by_id[ids[0]]["successor_todo_ids"] == [ids[1]] + assert json.loads(control.should_run())["quota"]["spent_slots"] == 1 diff --git a/tests/test_host_loop_activation.py b/tests/test_host_loop_activation.py index b9da6728aa..bfa069cb91 100644 --- a/tests/test_host_loop_activation.py +++ b/tests/test_host_loop_activation.py @@ -322,7 +322,7 @@ def test_deepseek_harness_native_is_distinct_same_session_host() -> None: "runtime_profile", ("ark_managed_agent_goal", "codex_app_ssh_goal"), ) -def test_goal_hosts_attribute_spend_to_current_progress_refresh( +def test_goal_hosts_delegate_spend_to_live_settlement_not_static_templates( runtime_profile: str, ) -> None: payload = build_heartbeat_prompt( @@ -334,19 +334,18 @@ def test_goal_hosts_attribute_spend_to_current_progress_refresh( refresh_command = f"`{payload['progress_refresh_state_command']}`" spend_command = f"`{payload['quota_spend_command']}`" - assert task_body.index(refresh_command) < task_body.index(spend_command) + assert refresh_command not in task_body + assert spend_command not in task_body + assert "settlement_plan.ordered_steps" in task_body + assert "preserve identities/flags" in task_body assert "" in refresh_command assert "" in refresh_command assert "" in refresh_command assert "--delivery-batch-scale multi_surface" not in refresh_command assert "--delivery-outcome outcome_progress" not in refresh_command - normalized_task_body = " ".join(task_body.split()) assert payload["quota_spend_command"].startswith("loopx --format json ") - assert ( - "never default or upgrade them to `multi_surface` / `outcome_progress`" - in normalized_task_body - ) - assert "no pipe/retry" in normalized_task_body + assert "actual outcomes" in task_body + assert "readback/recovery" in task_body def test_heartbeat_prompt_commands_keep_explicit_runtime_root() -> None: @@ -386,7 +385,7 @@ def test_heartbeat_prompt_commands_keep_explicit_runtime_root() -> None: "runtime_profile", ("ark_managed_agent_goal", "codex_app_ssh_goal"), ) -def test_goal_hosts_share_narrow_runtime_skill_routing( +def test_goal_hosts_enter_live_contract_without_a_mandatory_skill_detour( runtime_profile: str, ) -> None: payload = build_heartbeat_prompt( @@ -396,13 +395,11 @@ def test_goal_hosts_share_narrow_runtime_skill_routing( ) task_body = " ".join(payload["task_body"].split()) - assert ( - "Normal turns use CLI `interaction_contract`; use `loopx-project` for " - "lifecycle/registry and `loopx-self-repair` for runtime/projection drift." - in task_body - ) + assert "Use the current `interaction_contract`, not remembered commands" in task_body + assert "loopx-project" in task_body + assert "loopx-self-repair" in task_body assert "Progress is not a new Goal boundary" in task_body - assert "do not create a successor merely to continue" in task_body + assert "do not create a new host Goal merely to continue" in task_body def test_goal_hosts_reuse_thin_dispatch_and_stay_compact() -> None: @@ -435,8 +432,9 @@ def test_goal_hosts_reuse_thin_dispatch_and_stay_compact() -> None: for rule in shared_rules: assert rule in generic["task_body"] for payload in goal_hosts: - for rule in shared_rules: - assert rule in payload["task_body"] + assert "selection_command" in payload["task_body"] + assert "No learning queue unless asked." in payload["task_body"] + assert "完成获准工作并验证后,再按 next_cli_actions 写回和记账" in payload["task_body"] assert payload["interface_budget"]["budget_char_count"] <= 2_800 assert payload["interface_budget"]["within_budget"] is True diff --git a/tests/test_native_goal_release_qualification.py b/tests/test_native_goal_release_qualification.py new file mode 100644 index 0000000000..b0376e1ae5 --- /dev/null +++ b/tests/test_native_goal_release_qualification.py @@ -0,0 +1,187 @@ +"""Runner policy tests never start a real model, including under ordinary CI.""" + +import importlib.util +import json +import os +import subprocess +import sys +import tomllib +from pathlib import Path +from types import SimpleNamespace + +import pytest + +REPO = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + "release_goal", REPO / "scripts/qualify-native-goal-release.py", +) +runner = importlib.util.module_from_spec(spec) +spec.loader.exec_module(runner) + + +def test_native_spawn_preserves_isolated_profile_and_secret_free_shell(monkeypatch, tmp_path): + from loopx.capabilities.benchmark_toolkit.native_codex_goal import StdioNativeGoalTransport + + for suffix, value in {"API_KEY": "synthetic-selected-key", "MODEL": "fixture-model", + "BASE_URL": "https://example.com/v1"}.items(): + monkeypatch.setenv("LOOPX_CODEX_QUALIFICATION_" + suffix, value) + monkeypatch.setenv("UNRELATED_AUTH_TOKEN", "synthetic-forbidden-key") + monkeypatch.setenv("SSH_AUTH_SOCK", "synthetic-forbidden-socket") + launcher = tmp_path / "bin/loopx" + monkeypatch.setattr(runner, "setup", lambda _: (tmp_path, tmp_path / "runtime", launcher)) + prompt_loads = [] + def current_cli(_launcher, *arguments): + if arguments[0] == "heartbeat-prompt": + assert "--bootstrap" in arguments + prompt_loads.append(arguments) + return {"task_body": "Synthetic task"} + monkeypatch.setattr(runner, "cli", current_cli) + + class InspectedSpawn(Exception): + pass + + def inspect(command, **kwargs): + assert len(prompt_loads) == 1 + env = kwargs["env"] + assert "UNRELATED_AUTH_TOKEN" not in env and "SSH_AUTH_SOCK" not in env + settings = tomllib.loads((Path(env["CODEX_HOME"]) / "config.toml").read_text()) + policy = settings["shell_environment_policy"] + assert policy["inherit"] == "none" + assert "LOOPX_CODEX_QUALIFICATION_API_KEY" not in policy["set"] + child = subprocess.run([sys.executable, "-c", "import os,json; print(json.dumps(dict(os.environ)))"], + env=policy["set"], capture_output=True, text=True, check=True) + assert "synthetic-selected-key" not in child.stdout + assert "synthetic-forbidden" not in child.stdout + raise InspectedSpawn + + monkeypatch.setattr(StdioNativeGoalTransport, "spawn", inspect) + with pytest.raises(InspectedSpawn): + runner.qualify(tmp_path, "synthetic-codex", 10) + + +def test_default_does_not_even_probe_model_environment(monkeypatch, capsys): + def forbidden(*args): + raise AssertionError("default must not touch a model host") + monkeypatch.setattr(runner, "prerequisite_failure", forbidden) + monkeypatch.setattr(runner, "qualify", forbidden) + assert runner.main([]) == 0 + result = json.loads(capsys.readouterr().out) + assert result == {"status": "skipped", "reason": "release_opt_in_required", "model_executed": False} + + +def test_missing_release_environment_is_explicit_skip(monkeypatch, capsys): + monkeypatch.setattr(runner, "prerequisite_failure", lambda _: "codex_auth_unavailable") + assert runner.main(["--release-live"]) == 0 + assert json.loads(capsys.readouterr().out)["status"] == "skipped" + + +def test_attempted_release_failure_is_not_converted_to_skip(monkeypatch, capsys): + monkeypatch.setattr(runner, "prerequisite_failure", lambda _: None) + def failing(*args): + raise RuntimeError("sensitive diagnostic sentinel") + monkeypatch.setattr(runner, "qualify", failing) + assert runner.main(["--release-live"]) == 1 + result = json.loads(capsys.readouterr().out) + assert result == {"status": "failed", "error_kind": "RuntimeError"} + + +def test_release_timeout_must_be_positive(): + with pytest.raises(SystemExit): + runner.main(["--timeout-seconds", "0"]) + + +def test_isolated_codex_profile_never_imports_operator_config_or_shell_secrets(monkeypatch, tmp_path): + for suffix, value in {"API_KEY": "synthetic-key", "MODEL": "fixture-model", + "BASE_URL": "https://example.com/v1"}.items(): + monkeypatch.setenv("LOOPX_CODEX_QUALIFICATION_" + suffix, value) + forbidden = ("ARK_API_KEY", "GH_TOKEN", "CUSTOM_AUTH", "SSH_AUTH_SOCK", "NODE_OPTIONS", "BASH_ENV") + for key in forbidden: + monkeypatch.setenv(key, "synthetic-unrelated-value") + env = runner.configure_codex(tmp_path, tmp_path / "bin/loopx") + assert all(key not in env for key in forbidden) + config = tomllib.loads((tmp_path / "codex/config.toml").read_text()) + assert "synthetic-key" not in (tmp_path / "codex/config.toml").read_text() + assert config["model"] == "fixture-model" + policy = config["shell_environment_policy"] + assert policy["inherit"] == "none" + result = subprocess.run([sys.executable, "-c", "import os,json; print(json.dumps(dict(os.environ)))"], + env=policy["set"], capture_output=True, text=True, check=True) + actual = json.loads(result.stdout) + assert "LOOPX_CODEX_QUALIFICATION_API_KEY" not in actual + assert all(key not in actual for key in forbidden) + assert actual["HOME"] == str(tmp_path / "home") + assert env["CODEX_HOME"] == str(tmp_path / "codex") + assert os.environ["GH_TOKEN"] == "synthetic-unrelated-value" + + +def test_synthetic_fixture_real_cli_projects_identity_reentry(tmp_path): + project, _, launcher = runner.setup(tmp_path) + assert (project / "TASK.md").read_bytes() == (runner.FIXTURE / "TASK.md").read_bytes() + quota = runner.cli(launcher, "quota", "should-run", "--runtime-profile", "codex_cli", + "--goal-id", runner.GOAL, "--agent-id", runner.AGENT) + assert quota["should_run"] is True + assert quota["selected_todo"]["todo_id"] == "todo_reducer" + actions = quota["interaction_contract"]["cli_channel"]["next_cli_actions"] + assert len(actions) == 1 and "--turn-instance-id" in actions[0] + assert "spend-slot" not in actions[0] + + # Qualify the fixture's terminal shape without paying for a model run. A + # missing user section is not proof of zero user obligations. + state = project / "ACTIVE_GOAL_STATE.md" + state.write_text(state.read_text().replace("- [ ]", "- [x]").replace( + "status=open", "status=done no_followup=true", + )) + terminal = runner.cli(launcher, "quota", "should-run", "--runtime-profile", "codex_cli", + "--goal-id", runner.GOAL, "--agent-id", runner.AGENT) + assert terminal["should_run"] is False + assert terminal["interaction_contract"]["mode"] == "terminal_no_followup" + + state.write_text(state.read_text().replace("## User Todo\n\n", "")) + incomplete = runner.cli(launcher, "quota", "should-run", "--runtime-profile", "codex_cli", + "--goal-id", runner.GOAL, "--agent-id", runner.AGENT) + assert incomplete["interaction_contract"]["mode"] != "terminal_no_followup" + + +@pytest.mark.parametrize("mutation", ["unbound", "duplicate"]) +def test_settlement_oracle_rejects_missing_identity_and_duplicate_spend(tmp_path, mutation): + rows = [{"classification": "quota_slot_spent", "todo_id": todo, + "settlement_identity": {"effect_id": todo}} for todo in sorted(runner.TODOS)] + if mutation == "unbound": + rows.append({"classification": "quota_slot_spent"}) + else: + rows.append(rows[0].copy()) + index = tmp_path / "goals" / runner.GOAL / "runs/index.jsonl" + index.parent.mkdir(parents=True) + index.write_text("\n".join(json.dumps(row) for row in rows)) + todos = [{"todo_id": todo, "status": "done"} for todo in runner.TODOS] + with pytest.raises(AssertionError): + runner.verify_settlement(tmp_path, todos) + + +@pytest.mark.parametrize("missing", ["writeback", "spend", "receipt"]) +def test_settlement_oracle_requires_durable_receipts_not_only_index_rows(tmp_path, monkeypatch, missing): + from loopx.control_plane.quota import settlement + from loopx.control_plane.effect_program import SettlementFailure, SettlementFailureKind, SettlementResult + + receipt = None if missing == "receipt" else SimpleNamespace( + # Real failed result objects are truthy, unlike None. The oracle must + # inspect the typed failure, not accept object existence as evidence. + settlement=SettlementResult(value=None, failure=SettlementFailure( + kind=SettlementFailureKind.RECEIPT_MISSING, reason=missing, step_kind=None, + )), + ) + monkeypatch.setattr(settlement, "read_heartbeat_settlement", lambda *_, **__: receipt) + rows = [{"classification": "quota_slot_spent", "todo_id": todo, "turn_instance_id": todo, + "settlement_identity": {"effect_id": todo}} for todo in sorted(runner.TODOS)] + index = tmp_path / "goals" / runner.GOAL / "runs/index.jsonl" + index.parent.mkdir(parents=True) + index.write_text("\n".join(json.dumps(row) for row in rows)) + with pytest.raises(AssertionError): + runner.verify_settlement(tmp_path, [{"todo_id": t, "status": "done"} for t in runner.TODOS]) + + +def test_settlement_oracle_rejects_duplicate_open_successor(tmp_path): + todos = [{"todo_id": t, "status": "done"} for t in runner.TODOS] + todos.append({"todo_id": "todo_duplicate", "status": "open"}) + with pytest.raises(AssertionError): + runner.verify_settlement(tmp_path, todos) diff --git a/tests/test_visible_goal_terminal_settlement.py b/tests/test_visible_goal_terminal_settlement.py index 3eb00adf3f..f0814eae29 100644 --- a/tests/test_visible_goal_terminal_settlement.py +++ b/tests/test_visible_goal_terminal_settlement.py @@ -3,7 +3,7 @@ from loopx.heartbeat_prompt import build_heartbeat_prompt -def test_visible_goal_keeps_final_todo_nonterminal_until_spend() -> None: +def test_visible_goal_delegates_settlement_then_checks_terminal_readback() -> None: payload = build_heartbeat_prompt( goal_id="terminal-settlement-fixture", thin=True, @@ -11,20 +11,15 @@ def test_visible_goal_keeps_final_todo_nonterminal_until_spend() -> None: ) task_body = " ".join(payload["task_body"].split()) - terminal_rule = ( - "Done -> successor first; final -> accountable refresh, spend, then " - "no-follow-up completion." - ) - refresh = "refresh the accountable progress record before spending" - spend = "Then spend exactly once against that refresh" - readback = "Rerun the same guard read-only" + # Ordering belongs to the live settlement contract (covered by the real CLI + # suite), not a second static list embedded in the host objective. + settlement = "cli_channel.settlement_plan.ordered_steps" + readback = "After settlement recheck quota" terminal_readback = ( "Complete visible Goal only on `should_run=false` + terminal " "no-follow-up" ) - assert task_body.index(terminal_rule) < task_body.index(refresh) - assert task_body.index(refresh) < task_body.index(spend) - assert task_body.index(spend) < task_body.index(readback) + assert task_body.index(settlement) < task_body.index(readback) assert terminal_readback in task_body assert payload["interface_budget"]["within_budget"] is True