Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions docs/reference/automation-prompt-upgrades.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Managed Codex heartbeat prompt upgrades

## Product contract

Default execution remains `heartbeat-prompt --thin`. An adopted automation
stores a small, stable bootstrap instead of a copy of the current execution
rules. At **every wake**, it requests the complete JSON thin prompt from the
installed LoopX, with an explicit registry, Goal, agent and host capability
binding, then follows that prompt. After a normal LoopX upgrade, the next wake
reads the new rules; no per-release Codex database rewrite is needed. An already
running wake keeps its current instructions. This is model-mediated loading,
not a guarantee that a model will obey every instruction.

The bootstrap is a transport wrapper, not a new execution mode. It does not
default to full/compact prompts, contain a parallel work policy, or create
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.

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
canary as the global default merely to test one task.

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

```sh
loopx --format json automation-prompts plan --plan-file ./private-prompt-plan.json
```

The plan contains current and proposed prompts and is **private local data**.
Do not commit, upload, or paste it into public issues. The file is owner-only.
Use repeatable `--automation-id` selectors to narrow the plan. Discovery uses
registered Goal/agent identities only to propose candidates; it never grants
permission to overwrite them. Unrelated, ambiguous and inconsistent host
records are not adopted. Review custom instructions before replacement: move
durable project policy to its LoopX owner, or leave that task unadopted. There
is no automatic prose merge or substring-based permission to replace a prompt.

While the Codex App is running, prefer its `automation_update` interface:
view each selected task, verify its current prompt against the preview, update
only the prompt to `desired_prompt` while preserving every other field, then
read it back. Do not recreate the task or rebind its thread. A single user
request can authorize this reviewed batch; the plan itself does not execute it.

When that API is unavailable, the qualified macOS offline fallback is:

```sh
# Review the plan, then close the Codex App first.
loopx automation-prompts apply --plan-file ./private-prompt-plan.json --offline --execute
```

The same registry/runtime-root and Codex home must be used for preview and
apply. `--codex-home` explicitly selects one home; the command never searches
other homes or copies sessions between them. No new automation is created.
Schedule, pause state, model, notification preferences, thread binding and run
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
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.

## 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.
While the App remains closed, recover that exact task:

```sh
loopx automation-prompts recover --automation-id TASK_ID --offline --execute
# Or restore the previous prompt:
loopx automation-prompts rollback --automation-id TASK_ID --offline --execute
```

Recovery is idempotent. It refuses to overwrite later prompt/metadata edits.
The journal remains under the selected Codex home's `loopx-automation-backups`
directory; do not publish it. Rollback restores only the recorded prompt,
never an entire historical database or session table. Native API migrations
should retain the reviewed previous prompt privately and use that same API
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.

## 中文摘要

默认仍是 thin。一次迁移后,automation 每次唤醒读取已安装 LoopX 的最新
thin 指令;之后升级 LoopX 即可让下一轮采用新版规则,无需逐版本改 SQLite。
正在运行的轮次不热切换。启动器不是另一套执行规则,也不增加权限。

先批量预览,再明确接管;自定义内容不猜测合并,不自动删除。App 运行时用
原生更新接口;离线兼容通道要求关闭 App、精确预览校验、双存储读回,并保留
私有恢复记录。日程、暂停状态、模型、线程、通知偏好和历史均不迁移。
本批提供命令行批量迁移,不新增 Dashboard 按钮;也不保证模型行为已通过在线评测。
88 changes: 88 additions & 0 deletions loopx/cli_commands/automation_prompts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""One reviewed migration; subsequent runtime upgrades need no host-store write."""
from __future__ import annotations

import argparse
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


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("--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=[])
parser.add_argument("--cli-bin", default="loopx", help="Stable installed executable; choose a canary binary for a single-task trial.")
parser.add_argument("--execute", action="store_true")
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 == "plan":
if args.execute:
raise ValueError("plan cannot execute")
payload = build_plan(registry=registry, home=home, runtime_root=args.runtime_root, cli_bin=args.cli_bin)
if args.automation_id:
payload["entries"] = [entry for entry in payload["entries"] if entry["automation_id"] in args.automation_id]
if args.plan_file:
_atomic(args.plan_file.expanduser(), json.dumps(payload, ensure_ascii=False, indent=2))
return payload
if not args.execute or not args.offline:
raise ValueError("use the App API, or close the App and explicitly pass --offline --execute")
_require_offline()
if args.action in ("recover", "rollback"):
if not args.automation_id:
raise ValueError("recovery requires explicit --automation-id")
return {"ok": True, "results": [recover_offline(home=home, automation_id=identifier,
rollback=args.action == "rollback") for identifier in args.automation_id]}
if not args.plan_file:
raise ValueError("apply requires a reviewed --plan-file")
plan = json.loads(args.plan_file.expanduser().read_text(encoding="utf-8"))
if plan.get("schema_version") != SCHEMA or plan.get("codex_home") != str(home):
raise ValueError("plan schema or host-home mismatch")
# Regenerate using current registry/profile inputs: a saved plan is not a
# license to install stale rules or execute arbitrary prompt text.
current = {entry["automation_id"]: entry for entry in build_plan(
registry=registry, home=home, runtime_root=args.runtime_root, cli_bin=args.cli_bin)["entries"]}
results = []
for entry in plan["entries"]:
identifier = entry["automation_id"]
if args.automation_id and identifier not in args.automation_id:
continue
if entry["status"] != "adoption_required":
continue
now = current.get(identifier)
if not now or any(now.get(key) != entry.get(key) for key in
("prompt_sha256", "source_sha256", "desired_prompt", "target_thread_id")):
results.append({"automation_id": identifier, "ok": False, "status": "preview_stale"})
continue
try:
results.append(apply_offline(home=home, automation_id=identifier,
expected_prompt_sha256=entry["prompt_sha256"], desired_prompt=entry["desired_prompt"]))
except (ValueError, OSError, sqlite3.Error) as error:
results.append({"automation_id": identifier, "ok": False, "status": "failed", "reason": str(error)})
return {"ok": all(item["ok"] for item in results), "results": results,
"scope": "per-automation commit; other automations and sessions unchanged"}


def render(payload: dict) -> str:
return "# Automation prompt upgrade\n\n```json\n" + json.dumps(payload, ensure_ascii=False, indent=2) + "\n```"
12 changes: 12 additions & 0 deletions loopx/cli_commands/support_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
AddFormat = Callable[[argparse.ArgumentParser], None]

SUPPORT_CONTROL_COMMANDS = {
"automation-prompts",
"backup-state",
"chat",
"chat-endpoint",
Expand All @@ -108,6 +109,8 @@ def register_support_control_commands(
subparsers: argparse._SubParsersAction,
add_subcommand_format: AddFormat,
) -> None:
from .automation_prompts import register_automation_prompts
register_automation_prompts(subparsers, add_subcommand_format)
register_backup_state_command(subparsers, add_subcommand_format)
register_heartbeat_control_commands(subparsers, add_subcommand_format)

Expand Down Expand Up @@ -501,6 +504,15 @@ def handle_support_control_command(
if args.command not in SUPPORT_CONTROL_COMMANDS:
return None

if args.command == "automation-prompts":
from .automation_prompts import run, render
try:
payload = run(args, registry_path)
except Exception as error:
payload = {"ok": False, "error": str(error)}
print_payload(payload, output_format(args), render)
return 0 if payload.get("ok") else 1

if args.command == "chat-endpoint":
return handle_chat_endpoint_command(
args,
Expand Down
Loading
Loading