diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml
index 966c43a0a9..cd5b2ad444 100644
--- a/.github/workflows/python-tests.yml
+++ b/.github/workflows/python-tests.yml
@@ -507,7 +507,7 @@ jobs:
group: loopx-python-test-shard-${{ matrix.shard }}
queue: max
cancel-in-progress: false
- # All four shards were still progressing at 83-94% when the 45m job
+ # All shards were still progressing at 83-94% when the 45m job
# deadline cancelled run 37916979857. Reserve setup/reporting headroom
# without changing test-level deadlines, collection or worker count.
timeout-minutes: 60
diff --git a/apps/desktop/loopx-control-plane/README.md b/apps/desktop/loopx-control-plane/README.md
index 77ab956618..7dc229897c 100644
--- a/apps/desktop/loopx-control-plane/README.md
+++ b/apps/desktop/loopx-control-plane/README.md
@@ -50,6 +50,13 @@ previous runtime. Core's installation-only doctor qualifies non-editable wheel
fingerprints: a local reuse fence, not publisher or source attestation. Older
CLIs without that readback can use the automatic App-owned fallback.
+Wheel qualification has a separate ten-second subprocess deadline because it
+starts Core, checks the TypeScript runtime and hashes package contents. The
+one-second environment/version probes remain unchanged. A slow valid wheel can
+therefore qualify without asking the user to repair it; invalid or timed-out
+qualification still fails closed, and timed-out process groups are terminated
+and reaped. This does not speed up doctor or certify every cold-start workload.
+
A launch-time `LOOPX_BIN` remains the developer's explicit pin. Development mode
also accepts a source runtime without promoting an installation. A previously
saved path is a discovery candidate, not a permanent version pin. Corrupt
@@ -305,6 +312,43 @@ cd ..
npm run build
```
+On macOS, also qualify a released artifact using the opt-in checks below
+(run from `src-tauri`, replacing the paths with downloaded release artifacts).
+Keep the updater archive's `.sig` file beside it:
+
+```bash
+LOOPX_TEST_APP="/path/to/LoopX.app" cargo test --lib \
+ real_release_backup_restores_damage_and_rejects_tampering -- --ignored
+LOOPX_TEST_SIGNED_ARCHIVE="/path/to/LoopX.app.tar.gz" cargo test \
+ --test signed_bundle -- --ignored
+```
+
+The first check copies the signed App into temporary directories, then exercises
+the production restore path after removing its executable, a sealed runtime
+resource, or the entire target bundle. It also rejects a tampered backup before
+journaling or replacement and checks that the source artifact remains intact.
+The second verifies the updater archive with the shipped public key and rejects
+modified bytes. Neither check launches the App or writes to an installed App or
+user data. These qualify the native recovery and signature boundaries; they do
+not replace packaged UI checks of download failure, restart, or runtime handoff.
+
+### Recover An Interrupted App Update
+
+If replacement leaves the App incomplete and a backup is available, the update
+panel exposes **Restore previous version** directly. Restore it, then choose
+**Restart to finish**. This removes two navigation disclosures from the failure
+path; choosing a healthy installation's rollback remains under Advanced options.
+The native updater still verifies the backup before replacement. An unavailable
+or invalid backup reports its cause without recommending an unavailable runtime
+repair. Restoring the App does not overwrite a separately managed CLI.
+
+| Previous recovery panel | Direct recovery action |
+| --- | --- |
+|  |  |
+
+These packaged-panel captures use synthetic native status for presentation
+comparison; they do not certify an installed update or an executing task's recovery.
+
`npm run build` produces the configured platform bundles under
`src-tauri/target/release/bundle/`. The release workflow builds macOS `.dmg`
and `.app.zip` artifacts on macOS, Windows `.msi` and `.exe` artifacts on
diff --git a/apps/desktop/loopx-control-plane/docs/recovery-after.png b/apps/desktop/loopx-control-plane/docs/recovery-after.png
new file mode 100644
index 0000000000..e9e1266d82
Binary files /dev/null and b/apps/desktop/loopx-control-plane/docs/recovery-after.png differ
diff --git a/apps/desktop/loopx-control-plane/docs/recovery-before.png b/apps/desktop/loopx-control-plane/docs/recovery-before.png
new file mode 100644
index 0000000000..2f4f913e09
Binary files /dev/null and b/apps/desktop/loopx-control-plane/docs/recovery-before.png differ
diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/services.rs b/apps/desktop/loopx-control-plane/src-tauri/src/services.rs
index 25a3c276e7..877f52b70d 100644
--- a/apps/desktop/loopx-control-plane/src-tauri/src/services.rs
+++ b/apps/desktop/loopx-control-plane/src-tauri/src/services.rs
@@ -815,6 +815,11 @@ pub(crate) fn python3_environment() -> (bool, Option) {
}
const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(1);
+// Installation qualification boots Core, checks the TypeScript runtime and
+// hashes the installed package. It is not a version-banner query: a valid cold
+// wheel can exceed one second. Keep that work bounded without weakening Core's
+// qualification or extending the lightweight environment probes.
+const INSTALLATION_PROBE_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) fn timed_output(command: Command) -> Option {
timed_output_with_timeout(command, VERSION_PROBE_TIMEOUT)
@@ -986,7 +991,7 @@ fn runtime_identity_for_executable_with_path(
let mut command = Command::new(canonical);
configure_runtime_environment(&mut command);
command.args(["--format", "json", "doctor", "--installation-only"]);
- let output = timed_output(command)?;
+ let output = timed_output_with_timeout(command, INSTALLATION_PROBE_TIMEOUT)?;
if !output.status.success() {
return None;
}
diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs b/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs
index 8bc670752a..72f0d3b6df 100644
--- a/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs
+++ b/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs
@@ -1,5 +1,69 @@
use super::*;
+#[cfg(unix)]
+fn installation_probe_fixture(body: &str) -> (tempfile::TempDir, PathBuf) {
+ use std::os::unix::fs::PermissionsExt;
+ let root = tempfile::tempdir().expect("fixture directory");
+ let bin = root.path().join("bin");
+ fs::create_dir(&bin).expect("fixture bin");
+ let executable = bin.join("loopx");
+ fs::write(
+ &executable,
+ format!("#!/bin/sh\n[ \"$*\" = \"--format json doctor --installation-only\" ] || exit 2\n{body}\n"),
+ )
+ .expect("fixture executable");
+ fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap();
+ (root, executable)
+}
+
+#[test]
+#[cfg(unix)]
+fn installation_probe_allows_cold_qualification_beyond_version_budget() {
+ let expected = serde_json::json!({
+ "schema_version": "loopx_runtime_identity_v1",
+ "package_version": "1.4.1", "release_id": null, "source_revision": null,
+ "package_fingerprint": format!("sha256:{}", "a".repeat(64)),
+ });
+ let payload = serde_json::json!({
+ "ok": true, "scope": "installation_only", "service_runtime_identity": expected,
+ });
+ let (_root, executable) =
+ installation_probe_fixture(&format!("sleep 1.2\nprintf '%s\\n' '{payload}'"));
+ assert_eq!(
+ runtime_identity_for_executable(executable.to_str().unwrap()),
+ Some(expected),
+ "slow, fully qualified wheels must not be treated as missing runtimes"
+ );
+ let mut banner = Command::new("sh");
+ banner.args(["-c", "sleep 2; printf 'Python 3.12.0\\n'"]);
+ assert!(
+ timed_output(banner).is_none(),
+ "version probes keep their short bound"
+ );
+}
+
+#[test]
+#[cfg(unix)]
+fn installation_probe_still_bounds_and_reaps_hung_qualification() {
+ let (root, executable) = installation_probe_fixture("echo $$ > \"$0.pid\"\nexec sleep 30");
+ let started = Instant::now();
+ assert!(runtime_identity_for_executable(executable.to_str().unwrap()).is_none());
+ assert!(
+ started.elapsed() < Duration::from_secs(13),
+ "qualification must remain bounded"
+ );
+ let pid = fs::read_to_string(root.path().join("bin/loopx.pid")).unwrap();
+ assert!(
+ !Command::new("kill")
+ .args(["-0", pid.trim()])
+ .stderr(Stdio::null())
+ .status()
+ .unwrap()
+ .success(),
+ "timed out probe must be reaped"
+ );
+}
+
#[test]
fn status_readiness_is_explicit_and_fails_closed() {
assert_eq!(ServiceKind::Status.probe_path(), "/?readiness=1");
diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/update_backup.rs b/apps/desktop/loopx-control-plane/src-tauri/src/update_backup.rs
index 2ee7ebce2c..1be2e407ae 100644
--- a/apps/desktop/loopx-control-plane/src-tauri/src/update_backup.rs
+++ b/apps/desktop/loopx-control-plane/src-tauri/src/update_backup.rs
@@ -623,15 +623,71 @@ mod tests {
}
#[test]
+ #[cfg(target_os = "macos")]
#[ignore = "requires LOOPX_TEST_APP from a signed macOS build"]
- fn real_backup_copy_retains_signature() {
+ fn real_release_backup_restores_damage_and_rejects_tampering() {
+ use std::os::unix::fs::MetadataExt;
+
let source = std::env::var("LOOPX_TEST_APP").unwrap();
+ let source = Path::new(&source);
+ let executable_name = declared_bundle_executable(source).unwrap();
+ let resource = "Contents/Resources/runtime/identity.json";
+ let identity = fs::read(source.join(resource)).unwrap();
let dir = tempfile::tempdir().unwrap();
- let copied = dir.path().join("LoopX.app");
- copy(Path::new(&source), &copied).unwrap();
- assert!(copied
- .join("Contents/Resources/runtime/identity.json")
- .is_file());
+ let temporary_root = dir.path().canonicalize().unwrap();
+ let assert_temporary = |path: &Path| {
+ assert!(path.canonicalize().unwrap().starts_with(&temporary_root));
+ };
+ let previous = dir.path().join("previous");
+ let backup = previous.join("LoopX.app");
+ let target = dir.path().join("LoopX.app");
+ let executable = target.join("Contents/MacOS").join(executable_name);
+ let journal = dir.path().join("pending-rollback");
+ copy(source, &backup).unwrap();
+ copy(source, &target).unwrap();
+ assert!(installed_bundle_verifies(&executable));
+
+ // Only disposable copies are damaged. Exercise the same locator,
+ // verified copy and atomic replacement used by the recovery button
+ // with a shipped bundle, including its actual sealed runtime resource.
+ for damaged in [&executable, &target.join(resource), &target] {
+ assert_temporary(damaged);
+ if damaged.is_dir() {
+ fs::remove_dir_all(damaged).unwrap();
+ } else {
+ fs::remove_file(damaged).unwrap();
+ }
+ assert!(!installed_bundle_verifies(&executable));
+ restore_verified_backup(&executable, &previous, || {
+ // The journal callback must precede replacement: the damaged
+ // target must still be observable here.
+ assert!(!installed_bundle_verifies(&executable));
+ fs::write(&journal, "rollback").map_err(|error| error.to_string())
+ })
+ .unwrap();
+ assert_eq!(fs::read_to_string(&journal).unwrap(), "rollback");
+ assert!(installed_bundle_verifies(&executable));
+ assert_eq!(fs::read(target.join(resource)).unwrap(), identity);
+ assert!(signature_verifies(&backup));
+ fs::remove_file(&journal).unwrap();
+ }
+
+ // An invalid release backup must fail before journaling or swapping,
+ // leaving the exact installed directory and its signature intact.
+ let installed_inode = fs::metadata(&target).unwrap().ino();
+ assert_temporary(&backup.join(resource));
+ fs::write(backup.join(resource), "tampered").unwrap();
+ assert_eq!(
+ restore_verified_backup(&executable, &previous, || {
+ panic!("a tampered backup must never reach the commit callback")
+ }),
+ Err("backup_failed".into())
+ );
+ assert_eq!(fs::metadata(&target).unwrap().ino(), installed_inode);
+ assert!(installed_bundle_verifies(&executable));
+ assert!(!journal.exists());
+ assert_eq!(fs::read(source.join(resource)).unwrap(), identity);
+ assert!(signature_verifies(source));
}
#[test]
diff --git a/apps/desktop/loopx-control-plane/static/boot.js b/apps/desktop/loopx-control-plane/static/boot.js
index fe8f06932e..b40b0f05f5 100644
--- a/apps/desktop/loopx-control-plane/static/boot.js
+++ b/apps/desktop/loopx-control-plane/static/boot.js
@@ -116,7 +116,9 @@ const errors = {
app_install_failed: "App 安装未能完成,本次更新未生效;已确认当前版本完好且运行时可用,可直接重启继续使用,或重新检查更新后再试。",
app_install_incomplete: "App 安装中断,且无法确认当前版本是否完整,请勿直接重启。请在恢复与更新面板还原上一版本(或重新安装)后再试。",
runtime_pairing_required: "旧版本未能自动选择可用组件。请检查 App 更新,或通过恢复入口重新连接。",
- backup_failed: "无法备份当前版本,更新已停止。请检查磁盘空间后重试。",
+ backup_unavailable: "没有可验证的 App 备份,未执行恢复。请重新安装完整 App;当前窗口仍可保留。",
+ backup_failed: "App 备份无法保存或未通过完整性校验,替换已停止。请检查磁盘空间与权限,或重新安装完整 App。",
+ rollback_failed: "恢复 App 未完成,请保持窗口打开。可重试恢复;若仍失败,请重新安装完整 App。",
};
function codeText(code, phase) {
if (runtimeExplicit && ["runtime_identity_unavailable", "runtime_selection_explicit"].includes(code)) {
diff --git a/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs b/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs
index 23c4fa8257..490ef2a445 100644
--- a/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs
+++ b/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs
@@ -1,5 +1,5 @@
#!/usr/bin/env node
-// Exercise the shipped delivery view with synthetic status and read-only API fixtures.
+// Exercise owner contract readback and unavailable operation controls with synthetic sources.
import assert from "node:assert/strict";
import {mkdir} from "node:fs/promises";
import { execFileSync, spawn } from "node:child_process";
@@ -39,9 +39,12 @@ try {
const writes = [];
let response = structuredClone(snapshot);
let responseStatus = 200;
+ let authorityStatus = 200;
await page.addInitScript(language => localStorage.setItem("loopx-pw-locale", language), locale);
await page.route("**/api/**", route => {
- if (route.request().method() !== "GET") writes.push(route.request().url());
+ // Model-option discovery is an existing read-only POST. Every effect
+ // endpoint, including the new acceptance operation, remains in this ledger.
+ if (route.request().method() !== "GET" && new URL(route.request().url()).pathname !== "/api/chat/agent-model-options") writes.push(route.request().url());
return route.fulfill({ status: 404, json: { error: "Unavailable in read-only fixture" } });
});
await page.route(url => url.pathname === "/status.json", route => route.fulfill({ json: payload }));
@@ -50,6 +53,11 @@ try {
assert.equal(new URL(route.request().url()).searchParams.get("goal_id"), "release-demo");
return route.fulfill({ status: responseStatus, json: response });
});
+ await page.route("**/api/actions?*", route => route.fulfill({json: {ok: true, schema_version: "loopx_chat_action_list_v1", proposals: []}}));
+ await page.route("**/api/chat/goal-acceptance?*", route => route.fulfill({status: authorityStatus, json: {
+ ok: true, goal_id: response.acceptance.goal_id, provider_revision: "fixture:current",
+ goal_acceptance_contract: response.acceptance.goal_acceptance_contract ?? {enabled: false},
+ }}));
await page.goto(`http://127.0.0.1:${port}/${packaged ? "chat/" : ""}?statusUrl=/status.json`, { waitUntil: "networkidle" });
const navigation = page.getByRole("button", { name: locale === "en" ? "Open Goal navigation" : "打开 Goal 导航" });
if (await navigation.isVisible()) await navigation.click();
@@ -82,12 +90,25 @@ try {
const section = review.locator(".delivery-acceptance-contract");
const refresh = review.getByRole("button", { name: locale === "en" ? "Refresh snapshot" : "刷新快照", exact: true });
const exportButton = review.getByRole("button", { name: locale === "en" ? "Export delivery snapshot" : "导出交付快照", exact: true });
+ const openAdvanced = async () => {
+ const advanced = section.locator(".delivery-check-advanced");
+ if (await advanced.isVisible() && await advanced.getAttribute("open") === null) {
+ await advanced.locator(":scope > summary").focus();
+ await page.keyboard.press("Enter");
+ assert.notEqual(await advanced.getAttribute("open"), null, "Optional diagnostics are keyboard accessible");
+ }
+ };
const refreshSnapshot = async value => {
response = { ...snapshot, acceptance: { ...acceptance, goal_acceptance_contract: value } };
const read = page.waitForResponse(url => url.url().includes("/api/chat/delivery-review?"));
await refresh.click();
await read;
await page.waitForFunction(() => !document.querySelector(".delivery-review-toolbar button")?.disabled);
+ // The native check reader is independent of the delivery-map snapshot.
+ // Reload verifies both read owners; polling/recovery is covered by the
+ // real SQLite/HTTP/CLI journey.
+ if (responseStatus === 200) await page.reload({waitUntil: "networkidle"});
+ await openAdvanced();
};
await review.locator(".delivery-chain").waitFor();
const baseline = await review.innerText();
@@ -98,35 +119,28 @@ try {
assert.equal(await review.innerText(), baseline, "Absent/off contract must preserve the existing view");
}
await refreshSnapshot(contract);
- assert.equal(await section.getAttribute("open"), null, "Contract starts collapsed");
- const summary = section.locator(":scope > summary");
- await summary.focus();
- await page.keyboard.press("Enter");
- assert.notEqual(await section.getAttribute("open"), null, "Disclosure is keyboard accessible");
const text = await section.innerText();
for (const value of [contract.objective, contract.digest, "release-demo", "todo_confirmed", "todo_unbound", "todo_stale", "recovery"]) assert.ok(text.includes(value));
for (const value of locale === "en" ? ["Task association confirmed", "Task association missing", "Task association stale", "Task associations require confirmation", "Outside the current task gate"] : ["任务关联已确认", "任务关联缺失", "任务关联已过期", "任务关联需要确认", "不属于当前任务门禁范围"]) assert.ok(text.includes(value), value);
- assert.equal(await section.locator("button,input,select,textarea").count(), 0, "Readback must offer no mutation controls");
- await section.locator("details > summary").last().click();
- await section.getByText(locale === "en" ? /The local Goal owner/ : /本地 Goal 所有者/).waitFor();
+ await section.getByText(locale === "en" ? /Import an existing JSON contract/ : /导入现有 JSON 合同/).waitFor();
for (const [status, en, zh] of [["unverified", "Artifact checks not verified", "产物检查未验证"], ["failed", "Artifact checks failed", "产物检查失败"], ["stale", "Artifact checks stale", "产物检查已过期"], ["partial", "Task checks passed; Goal-wide verification unknown", "任务检查通过;Goal 整体验证未知"], ["accepted", "Artifact checks passed", "产物检查通过"]]) {
await refreshSnapshot(verifiedContract(status));
- await section.getByText(locale === "en" ? en : zh, { exact: true }).waitFor();
- assert.ok((await section.innerText()).includes(locale === "en" ? "neither automatically approves or completes the Goal" : "均不会自动批准或完成 Goal"));
+ await section.getByText(locale === "en" ? en : zh, { exact: true }).first().waitFor();
+ assert.ok((await section.innerText()).includes(locale === "en" ? "independent review" : "独立复核"));
}
await refreshSnapshot({ ...contract, scope: { kind: "selected_work", todo_ids: contract.tasks.map(task => task.todo_id) } });
await section.getByText(locale === "en" ? "Only explicitly selected tasks" : "仅明确选定的任务", { exact: true }).waitFor();
- await section.locator("dl").first().scrollIntoViewIfNeeded();
+ await section.locator(".delivery-check-advanced dl").scrollIntoViewIfNeeded();
await page.screenshot({ path: process.env.LOOPX_ACCEPTANCE_SCOPE_SCREENSHOT_DIR
? `${process.env.LOOPX_ACCEPTANCE_SCOPE_SCREENSHOT_DIR}/${locale}.png` : `/tmp/loopx-acceptance-scope-${locale}.png` });
const next = { ...verifiedContract("stale"), revision: 8, digest: "b".repeat(64) };
await refreshSnapshot(next);
await section.getByText(next.digest, { exact: true }).waitFor();
assert.equal(await section.getByText(contract.digest, { exact: true }).count(), 0);
- assert.match(await section.locator("dl").first().innerText(), /8/);
+ assert.match(await section.locator(".delivery-check-advanced dl").innerText(), /8/);
await section.locator("details > summary").first().click();
await section.getByText("c".repeat(64), { exact: true }).waitFor();
- assert.match(await section.locator("dl").last().innerText(), /6/);
+ assert.match(await section.locator(".delivery-check-result dl").innerText(), /6/);
assert.equal(await section.evaluate(element => element.scrollWidth > element.clientWidth + 2), false, "Long digests must wrap at narrow widths");
const downloadPromise = page.waitForEvent("download");
await exportButton.click();
@@ -136,16 +150,20 @@ try {
for await (const chunk of stream) markdown += chunk.toString();
assert.ok(markdown.includes(next.digest) && markdown.includes("8") && markdown.includes(contract.objective));
responseStatus = 503;
+ authorityStatus = 503;
await refreshSnapshot(next);
- await section.getByText(locale === "en" ? /Retained snapshot/ : /当前保留旧快照/).waitFor();
+ await section.getByText(locale === "en" ? /Retained snapshot/ : /当前保留旧快照/).first().waitFor();
+ assert.equal(await section.getByRole("button", {name: locale === "en" ? "Check delivery" : "检查交付", exact: true}).count(), 0,
+ "An unavailable current authority must not offer a command from retained state");
assert.equal(await exportButton.isDisabled(), true, "Failed refresh must not export retained contract as current");
responseStatus = 200;
+ authorityStatus = 200;
response = { ...snapshot, acceptance: { ...acceptance, goal_id: "other-goal", goal_acceptance_contract: next } };
const mismatch = page.waitForResponse(url => url.url().includes("/api/chat/delivery-review?"));
await refresh.click();
await mismatch;
await page.waitForFunction(() => !document.querySelector(".delivery-review-toolbar button")?.disabled);
- await section.getByText(locale === "en" ? /Retained snapshot/ : /当前保留旧快照/).waitFor();
+ await section.getByText(locale === "en" ? /Retained snapshot/ : /当前保留旧快照/).first().waitFor();
assert.equal(await section.getByText("other-goal", { exact: true }).count(), 0);
await refreshSnapshot({ enabled: false });
assert.equal(await section.count(), 0, "Disabling removes the previous enabled readback");
diff --git a/apps/presentation/dashboard/smoke/goal-acceptance-contract-smoke.mjs b/apps/presentation/dashboard/smoke/goal-acceptance-contract-smoke.mjs
index c6acf089e3..04015e5eab 100644
--- a/apps/presentation/dashboard/smoke/goal-acceptance-contract-smoke.mjs
+++ b/apps/presentation/dashboard/smoke/goal-acceptance-contract-smoke.mjs
@@ -47,7 +47,15 @@ assert.notEqual(next.acceptance.goal_acceptance_contract.digest, contract.digest
assert.deepEqual(contract, before, "Rendering must not mutate the supplied contract");
console.log("Goal acceptance contract API/export: off parity, association/verification distinction, source/revision and negative cases passed");
-const {projectGoalAcceptanceCriteria} = await import("../node_modules/.cache/delivery-review/data/goal-acceptance-observation.js");
+const {goalAcceptanceDisplayStatus, projectGoalAcceptanceCriteria} = await import("../node_modules/.cache/delivery-review/data/goal-acceptance-observation.js");
+for (const [observation, expected] of Object.entries({current: "accepted", stale: "stale", failed: "failed", recorded: "unverified", unavailable: "unverified"})) {
+ const value = structuredClone(verifiedContract("accepted"));
+ value.criteria[0].oracle = {state: observation};
+ const before = structuredClone(value);
+ assert.equal(goalAcceptanceDisplayStatus(value), expected, "Historical success cannot override current file observations");
+ assert.equal(projectGoalAcceptanceCriteria(value)[0].state, expected);
+ assert.deepEqual(value, before, "Display freshness cannot mutate canonical acceptance");
+}
for (const status of ["stale", "held", "partial"]) {
const value = verifiedContract(status);
value.verification.contract_revision = value.revision;
diff --git a/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs b/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs
index 49447a3e05..83bb04e95f 100644
--- a/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs
+++ b/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs
@@ -133,6 +133,26 @@ try {
await verificationGap.scrollIntoViewIfNeeded();
await page.screenshot({path: resolve(outputDir, "team-verifier-gap-mobile.png"), animations: "disabled"});
await page.setViewportSize({width: 1512, height: 980});
+ // A local pinned check is useful evidence, but cannot authenticate an
+ // independent reviewer. Keep the existing gap alongside the optional receipt.
+ validation = {...currentValidation, oracle_checks: [{criterion_id: "report-format", source_id: "report-checker",
+ source_digest: "sha256:" + "8".repeat(64), checked_at: checkTime,
+ input_versions: [], output_versions: outputVersions}]};
+ await page.route("**/api/chat/sessions/*/loopx", checkedRead);
+ await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click();
+ const correction = evidence.getByRole("region", {name: "纠偏证据路径", exact: true});
+ await correction.getByText("负责人指定的版本固定检查器", {exact: true}).waitFor();
+ await verificationGap.waitFor();
+ await correction.getByText("检查在声明文件的隔离副本上执行;不代表远程复核者身份或异议已解决。", {exact: true}).waitFor();
+ await selectedOutput().waitFor();
+ await verificationGap.scrollIntoViewIfNeeded();
+ await page.screenshot({path: resolve(outputDir, "team-local-checker-gap-desktop.png"), animations: "disabled"});
+ await page.setViewportSize({width: 390, height: 844});
+ assert.ok(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth));
+ await verificationGap.scrollIntoViewIfNeeded();
+ await page.screenshot({path: resolve(outputDir, "team-local-checker-gap-mobile.png"), animations: "disabled"});
+ await page.setViewportSize({width: 1512, height: 980});
+ await page.unroute("**/api/chat/sessions/*/loopx", checkedRead);
// A completed source can retain its output bytes while its dependency
// chain becomes unavailable. Reading an adoption candidate must withdraw
// the earlier correction trace and must never offer a confirmation.
diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts
index cb5b66fba5..989d0b8c81 100644
--- a/apps/presentation/dashboard/src/data/chat.ts
+++ b/apps/presentation/dashboard/src/data/chat.ts
@@ -1,3 +1,4 @@
+import type {OracleEvidence} from "./goal-acceptance-observation";
import { HANDOFF_MODES, EXECUTION_HANDOFF_MODES, type ExecutionHandoffMode } from "../../../../../loopx/control_plane/coordination/handoff_mode_vocabulary.js";
import { normalizeGoalDraft } from "../../../../../loopx/control_plane/collaboration/goal_draft.js";
import { parseTurnStep, type TurnStep } from "./turn-steps.js";
@@ -410,6 +411,7 @@ export class ChatApiError extends Error {
}
export const typedActionKindSchema = z.enum([
+ "goal.acceptance.check",
"goal.create",
"goal.update",
"goal.lifecycle",
@@ -1174,7 +1176,7 @@ export type DelegationReadback = {
artifacts?: Array<{ref: string; sha256: string; text: string}>; error?: string;
validation?: {source: "goal_acceptance" | "todo_validation"; basis_sha256: string;
check_count: number; pinned_file_count: number; checked_at?: string;
- output_versions?: Array<{ref: string; sha256: string}>};
+ output_versions?: Array<{ref: string; sha256: string}>; oracle_checks?: Array};
task_failure?: {stage: "task_postcondition"; status: "failed" | "inconclusive" | "unavailable";
recovery_kind: "repair_required" | "replan_required"; summary: string; validator_kind: string;
turn_key: string; resume_mode: "revalidate_cached_result"};
diff --git a/apps/presentation/dashboard/src/data/delivery-review.ts b/apps/presentation/dashboard/src/data/delivery-review.ts
index bf886b7cd7..3eb32199f2 100644
--- a/apps/presentation/dashboard/src/data/delivery-review.ts
+++ b/apps/presentation/dashboard/src/data/delivery-review.ts
@@ -1,5 +1,5 @@
import { z } from "zod";
-import { goalAcceptanceObservationSchema, type GoalAcceptanceContract } from "./goal-acceptance-observation.js";
+import { goalAcceptanceDisplayStatus, goalAcceptanceObservationSchema, type GoalAcceptanceContract } from "./goal-acceptance-observation.js";
const refsSchema = z.record(z.string(), z.array(z.string()));
const nodeSchema = z.object({
@@ -211,7 +211,7 @@ export function deliveryReviewMarkdown(snapshot: DeliveryReviewSnapshot, labels:
if (task.reason) rows.push(` ${line(task.reason)}`);
if (task.applicable === false) rows.push(` ${copy.notApplicable}`);
}
- rows.push("", `### ${copy.verification}`, copy.verificationState[contract.status]);
+ rows.push("", `### ${copy.verification}`, copy.verificationState[goalAcceptanceDisplayStatus(contract)]);
if (contract.held_todo_ids.length) rows.push(`${copy.heldTasks}: ${line(contract.held_todo_ids.join(", "))}`);
rows.push("", `### ${copy.receipt}`);
const receipt = contract.verification;
diff --git a/apps/presentation/dashboard/src/data/goal-acceptance-actions.ts b/apps/presentation/dashboard/src/data/goal-acceptance-actions.ts
new file mode 100644
index 0000000000..de4f91e505
--- /dev/null
+++ b/apps/presentation/dashboard/src/data/goal-acceptance-actions.ts
@@ -0,0 +1,41 @@
+import {z} from "zod";
+import {goalAcceptanceContractSchema} from "./goal-acceptance-observation";
+import type {TypedActionProposal} from "./chat";
+const responseSchema = z.object({ok: z.literal(true), goal_id: z.string(), provider_revision: z.string(),
+ goal_acceptance_contract: goalAcceptanceContractSchema, checks_passed: z.boolean().optional()});
+export type AcceptanceReadback = z.infer;
+export async function acceptanceRequest(goalId: string, operation?: Record, signal?: AbortSignal): Promise {
+ const url = "/api/chat/goal-acceptance";
+ const response = await fetch(operation ? url : `${url}?goal_id=${encodeURIComponent(goalId)}`, operation
+ ? {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({...operation, goal_id: goalId}), signal} : {signal});
+ if (!response.ok) throw new Error("Acceptance unavailable");
+ const result = responseSchema.parse(await response.json());
+ if (result.goal_id !== goalId) throw new Error("Acceptance source changed");
+ return result;
+}
+
+// Read model only. The existing typed acceptance owner validates the complete
+// document and its command/file bindings again at apply time.
+const preparedDocument = z.object({objective: z.string().min(1),
+ scope: z.discriminatedUnion("kind", [z.object({kind: z.literal("all_advancement")}),
+ z.object({kind: z.literal("selected_work"), todo_ids: z.array(z.string()).min(1)})]),
+ criteria: z.array(z.object({id: z.string(), description: z.string(),
+ validation_argv: z.array(z.string()).min(1),
+ oracle: z.object({output_refs: z.array(z.string())}).passthrough().optional(),
+ }).passthrough()).min(1),
+}).passthrough();
+export function preparedAcceptancePlan(proposals: TypedActionProposal[], goalId: string) {
+ // Never resurrect an older draft after the newest plan was applied, cancelled
+ // or invalidated. Ambiguous creation order requires a fresh Agent proposal.
+ const matching = proposals.filter(proposal => proposal.action_kind === "goal.acceptance.check"
+ && proposal.context.kind === "goal" && proposal.context.goal_id === goalId
+ && proposal.normalized_parameters.goal_id === goalId)
+ .sort((a, b) => b.created_at.localeCompare(a.created_at));
+ const proposal = matching[0];
+ if (!proposal || matching[1]?.created_at === proposal.created_at) return null;
+ if (proposal.permission_classification !== "local_command_execution"
+ || !["preview_ready", "deferred"].includes(proposal.status) || proposal.gate || proposal.error
+ || !proposal.available_transitions.includes("apply")) return null;
+ const parsed = preparedDocument.safeParse(proposal.normalized_parameters.document);
+ return parsed.success ? {proposal, document: parsed.data} : null;
+}
diff --git a/apps/presentation/dashboard/src/data/goal-acceptance-observation.ts b/apps/presentation/dashboard/src/data/goal-acceptance-observation.ts
index 118c0101e7..c2be3ac4ce 100644
--- a/apps/presentation/dashboard/src/data/goal-acceptance-observation.ts
+++ b/apps/presentation/dashboard/src/data/goal-acceptance-observation.ts
@@ -2,7 +2,11 @@ import { z } from "zod";
const nullableText = z.string().nullable();
// Readback only: the Goal owner supplies association and verification states.
-const goalAcceptanceContractSchema = z.discriminatedUnion("enabled", [
+export const oracleEvidenceSchema = z.object({source_id: z.string(), source_digest: z.string(), checked_at: z.string(),
+ input_versions: z.array(z.object({ref: z.string(), sha256: z.string()})),
+ output_versions: z.array(z.object({ref: z.string(), sha256: z.string()}))});
+export type OracleEvidence = z.infer;
+export const goalAcceptanceContractSchema = z.discriminatedUnion("enabled", [
z.object({ enabled: z.literal(false) }),
z.object({
enabled: z.literal(true), revision: z.number().int().positive(), digest: z.string().min(1), objective: z.string(),
@@ -12,7 +16,11 @@ const goalAcceptanceContractSchema = z.discriminatedUnion("enabled", [
]).optional(),
non_goals: z.array(z.string()), held_todo_ids: z.array(z.string()),
status: z.enum(["unverified", "stale", "failed", "partial", "accepted", "held"]),
- criteria: z.array(z.object({ id: z.string(), description: z.string() })),
+ criteria: z.array(z.object({ id: z.string(), description: z.string(), oracle: z.object({
+ kind: z.literal("owner_pinned_oracle"), source_id: z.string(), source_digest: z.string(),
+ input_refs: z.array(z.string()), output_refs: z.array(z.string()),
+ state: z.enum(["unverified", "recorded", "current", "stale", "failed", "unavailable"]),
+ }).optional() })),
tasks: z.array(z.object({
todo_id: z.string(), state: z.enum(["ready", "unbound", "stale"]),
criterion_ids: z.array(z.string()), reason: z.string().optional(),
@@ -21,7 +29,7 @@ const goalAcceptanceContractSchema = z.discriminatedUnion("enabled", [
verification: z.object({
operation_id: z.string(), contract_revision: z.number().int().positive(), contract_digest: z.string(),
todo_id: z.string().nullable(),
- results: z.array(z.object({ criterion_id: z.string(), passed: z.boolean(), exit_code: z.number().int().nullable() })),
+ results: z.array(z.object({ criterion_id: z.string(), passed: z.boolean(), exit_code: z.number().int().nullable(), oracle_evidence: oracleEvidenceSchema.optional() })),
}).nullable(),
}),
]);
@@ -67,6 +75,21 @@ export function projectGoalAcceptanceCriteria(contract: Extract) {
+ if (contract.status !== "accepted" && contract.status !== "partial") return contract.status;
+ const criteria = projectGoalAcceptanceCriteria(contract);
+ if (criteria.some(item => item.state === "failed")) return "failed";
+ if (criteria.some(item => item.state === "stale")) return "stale";
+ if (criteria.some(item => item.state === "unverified")) return "unverified";
+ return contract.status;
+}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css
new file mode 100644
index 0000000000..c41ae101b3
--- /dev/null
+++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css
@@ -0,0 +1,61 @@
+.attached-agent-conversations {margin: 12px 24px;}
+.attached-conversation-entry {font: inherit; padding: 8px 12px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); cursor: pointer;}
+.attached-conversation-heading {display: flex; align-items: center; justify-content: space-between; padding: 16px; gap: 16px; border-bottom: 1px solid var(--pw-line);}
+.attached-conversation-heading h2 {margin: 0; font-size: var(--text-heading);}
+.attached-conversation-heading button {font: inherit; padding: 8px 12px; color: inherit; background: var(--pw-card); border: 1px solid var(--pw-line); border-radius: 6px;}
+.attached-conversation-content {padding: 0 16px 16px; font-size: var(--text-body); min-height: 0; overflow: auto;}
+.attached-conversation-content > p {color: var(--pw-text);}
+.attached-conversation-content label {display: grid; gap: 8px; margin-block: 12px;}
+.attached-conversation-content select, .attached-conversation-content textarea {font: inherit; padding: 8px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); color: var(--pw-text);}
+.attached-conversation-content textarea {min-height: 88px; width: 100%; resize: vertical;}
+.attached-conversation-content button {font: inherit; padding: 8px 12px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); cursor: pointer;}
+.attached-conversation-content button:disabled, .attached-conversation-content button[aria-disabled="true"] {opacity: .5; cursor: default;}
+.attached-conversation-content .attached-conversation-open {border: 0; padding: 0; border-radius: 0; background: transparent; text-decoration: underline; text-underline-offset: 3px;}
+.attached-conversation-messages {max-height: min(40vh, 360px); overflow: auto; overscroll-behavior: contain; margin-block: 16px;}
+.attached-conversation-messages article {padding-block: 16px; border-top: 1px solid var(--pw-line); overflow-wrap: anywhere;}
+.attached-conversation-messages header {display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline;}
+.attached-conversation-messages time {font-size: var(--text-caption); color: var(--pw-muted);}
+.attached-conversation-messages [data-role="user"] {padding-inline: 12px; background: var(--pw-hover); border-radius: 6px;}
+@media (max-width: 640px) {.attached-agent-conversations {margin: 12px;} .attached-conversation-messages {max-height: 48vh;}}
+
+.attached-conversation-messages [data-role="tool"], .attached-conversation-messages [data-role="activity"] {padding-block: 0; color: var(--pw-muted);}
+.attached-conversation-messages details {margin-block: 0;}
+.attached-conversation-messages summary {cursor: pointer; min-height: 44px; align-content: center; color: var(--pw-text);}
+.attached-conversation-messages pre {font: var(--text-body) / var(--leading-prose) var(--font-mono); color: var(--pw-text); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 24rem; overflow: auto;}
+.attached-history-navigation {display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: var(--text-caption);}
+.personal-composer-wrap[hidden] {display: none;}
+.personal-channel-scroll[data-agent-work="true"] {padding-block: 0; overflow: hidden;}
+.personal-channel-scroll[data-agent-work="true"] > .personal-goal-view-panel:not([hidden]) {height: 100%;}
+.goal-agent-work-page {height: 100%; display: flex; flex-direction: column; min-width: 0;}
+.goal-agent-work-page .attached-conversation-heading {padding: 16px 0; flex-wrap: wrap; gap: 12px;}
+.goal-agent-work-page .attached-conversation-heading h2 {overflow-wrap: anywhere;}
+.agent-work-navigation {display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0;}
+.agent-work-navigation select, .agent-work-directory button {font: inherit; border: 1px solid var(--pw-line); background: var(--pw-card); color: var(--pw-text); border-radius: 6px; padding: 8px 12px; min-height: 44px; max-width: 100%;}
+.agent-work-directory {display: grid; gap: 12px; padding-block: 24px;}
+.agent-work-directory button {text-align: left; cursor: pointer;}
+.agent-work-unavailable {padding-block: 24px; color: var(--pw-muted); line-height: var(--leading-prose);}
+.goal-agent-work-page .attached-conversation-content {flex: 1; display: flex; flex-direction: column; padding: 0 0 16px; overflow: auto;}
+.goal-agent-work-page .attached-conversation-content > p {margin-block: 8px;}
+.goal-agent-work-page .attached-conversation-source {font-size: var(--text-caption); color: var(--pw-muted);}
+.goal-agent-work-page .attached-conversation-messages {flex: 1; min-height: 160px; max-height: none; margin-block: 12px;}
+.goal-agent-work-page .attached-conversation-content > button {align-self: flex-start; min-height: 44px;}
+.goal-agent-work-page form {flex-shrink: 0; display: flex; gap: 12px; align-items: flex-end; border-top: 1px solid var(--pw-line); padding-top: 8px;}
+.goal-agent-work-page form label {margin: 0; flex: 1; min-width: 0;}
+.goal-agent-work-page form textarea {min-height: 64px; max-height: 160px;}
+.goal-agent-work-page form button[type="submit"] {background: var(--pw-text); color: var(--pw-card); min-height: 44px;}
+@media (max-width: 640px) {.agent-work-navigation {width: 100%;} .agent-work-navigation select {flex: 1; min-width: 0;} .goal-agent-work-page .attached-conversation-messages {min-height: 120px;}}
+
+.attached-connection-status {display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px 16px; padding-block: 8px;}
+.attached-connection-status p {margin: 0;}
+.attached-connection-status .attached-conversation-open {min-height: 44px; flex-shrink: 0;}
+@media (max-width: 640px) {.goal-agent-work-page .attached-conversation-heading h2 {font-size: var(--text-lead);} .agent-work-navigation {width: auto; flex: 1;} .agent-work-navigation select {max-width: 140px;} .attached-conversation-heading button {padding-inline: 8px;}}
+
+.attached-conversation-messages summary > strong {font-weight: 500;}
+.attached-conversation-messages summary > time {margin-left: 8px;}
+.attached-tool-metadata {font-size: var(--text-caption); margin-top: 8px;}
+.attached-conversation-messages .attached-tool-source {font-size: var(--text-caption);}
+@media (max-width: 640px) {
+ .goal-agent-work-page form {flex-wrap: wrap;}
+ .goal-agent-work-page form label {flex-basis: 100%;}
+ .goal-agent-work-page form button[type="submit"] {margin-left: auto;}
+}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx
new file mode 100644
index 0000000000..8df54a9f03
--- /dev/null
+++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx
@@ -0,0 +1,284 @@
+import {useEffect, useRef, useState} from "react";
+import {AttachedConversationRecord, type ConversationRecord, type ContentPage} from "./attached-conversation-record";
+import "./attached-agent-conversations.css";
+
+type Binding = {agent_id: string; thread_id: string; binding_ref: string};
+type Message = ConversationRecord;
+type Snapshot = {messages?: Message[]; before?: number | null; after?: number; has_more?: boolean; reset?: boolean; source_ref?: string; history_size?: number; revision: string; unchanged?: boolean; connected?: boolean; can_reply?: boolean; activity: {state: string}};
+type Submission = {operation_id: string; text: string; status?: string};
+
+class ConversationRequestError extends Error {
+ constructor(message: string, readonly unsubmittedOperation?: string) {super(message);}
+}
+
+async function request(url: string, options?: RequestInit): Promise {
+ const response = await fetch(url, {...options, signal: AbortSignal.timeout(6_000)});
+ const value = await response.json();
+ if (!response.ok || value.ok !== true) throw new ConversationRequestError(
+ value.error || "Conversation unavailable",
+ response.status === 409 && value.input_not_submitted === true && typeof value.operation_id === "string" ? value.operation_id : undefined,
+ );
+ return value as T;
+}
+
+// A bounded file window may contain only metadata. Advance without making the
+// reader click through empty pages; keep each request and the whole burst bounded.
+async function readVisiblePage(params: URLSearchParams, current: () => boolean): Promise {
+ const forward = params.has("after");
+ let page = await request(`/api/chat/codex-attached?${params}`);
+ const newestBoundary = page.after;
+ const newerAvailable = page.has_more;
+ for (let windows = 1; windows < 8 && current() && !page.unchanged && !page.reset && !page.messages?.length; windows++) {
+ const cursor = forward ? (page.has_more ? page.after : null) : page.before;
+ const previous = params.get(forward ? "after" : "before");
+ if (cursor == null) break;
+ if (previous !== null && (forward ? cursor <= Number(previous) : cursor >= Number(previous))) {
+ throw new Error("History position did not advance; reconnect to read it.");
+ }
+ params.set(forward ? "after" : "before", String(cursor));
+ params.set("source_ref", page.source_ref ?? "");
+ params.delete("revision");
+ const next = await request(`/api/chat/codex-attached?${params}`);
+ if (next.reset || next.source_ref !== page.source_ref) return next;
+ page = next;
+ }
+ // Backward skipping must not move the tail cursor backwards: those bytes
+ // contained no visible records, and polling should resume at the original tail.
+ return forward ? page : {...page, after: newestBoundary, has_more: newerAvailable};
+}
+
+/** Owner-only original conversation. It never changes the selected executor. */
+export function AttachedAgentConversations({goalId, zh, targetAgentId}: {
+ goalId: string; zh: boolean; targetAgentId: string;
+}) {
+ const [discovering, setDiscovering] = useState(true);
+ const [agents, setAgents] = useState([]);
+ const [selected, setSelected] = useState("");
+ const [messages, setMessages] = useState([]);
+ const [snapshot, setSnapshot] = useState(null);
+ const [before, setBefore] = useState(null);
+ const [error, setError] = useState("");
+ const [recoveryError, setRecoveryError] = useState("");
+ const [sendNotice, setSendNotice] = useState("");
+ const [draft, setDraft] = useState("");
+ const [submission, setSubmission] = useState(null);
+ const [sending, setSending] = useState(false);
+ const [loadingOlder, setLoadingOlder] = useState(false);
+ const [opening, setOpening] = useState(false);
+ const [openRequested, setOpenRequested] = useState(false);
+ const [openError, setOpenError] = useState("");
+ const [refresh, setRefresh] = useState(0);
+ const [readingHistory, setReadingHistory] = useState(false);
+ const [unread, setUnread] = useState(false);
+ const following = useRef(true);
+ const after = useRef(null);
+ const historyEpoch = useRef(0);
+ const generation = useRef(0);
+ const receiptGeneration = useRef(0);
+ const revision = useRef("");
+ const history = useRef<{source: string; size: number} | null>(null);
+ const transcript = useRef(null);
+ const binding = agents.find(agent => agent.agent_id === selected);
+ const awaitingReceipt = Boolean(submission?.status && submission.status !== "delivered");
+ const cacheKey = binding ? `loopx-attached-input:${goalId}:${binding.binding_ref}` : "";
+ const query = binding ? new URLSearchParams({goal_id: goalId, agent_id: binding.agent_id, binding_ref: binding.binding_ref}) : null;
+
+ useEffect(() => {
+ let active = true;
+ setAgents([]); setSelected(""); setMessages([]); setSnapshot(null); setError("");
+ setDiscovering(true);
+ void request<{agents: Binding[]}>(`/api/chat/codex-attached?${new URLSearchParams({goal_id: goalId})}`)
+ .then(value => {if (active) {setAgents(value.agents); setSelected(targetAgentId);}})
+ .catch(failure => {if (active) setError(String(failure));})
+ .finally(() => {if (active) setDiscovering(false);});
+ return () => {active = false;};
+ }, [goalId, refresh, targetAgentId]);
+
+ useEffect(() => {
+ const current = ++generation.current;
+ revision.current = "";
+ history.current = null;
+ after.current = null; following.current = true; historyEpoch.current++;
+ setReadingHistory(false); setUnread(false);
+ setOpening(false); setOpenRequested(false); setOpenError("");
+ setSendNotice("");
+ setMessages([]); setSnapshot(null); setBefore(null); setDraft(""); setSubmission(null); setSending(false); setLoadingOlder(false); setError(""); setRecoveryError("");
+ if (!binding) return;
+ try {
+ setDraft(sessionStorage.getItem(`${cacheKey}:draft`) ?? "");
+ const stored = sessionStorage.getItem(cacheKey);
+ if (stored) {
+ const pending = JSON.parse(stored) as Submission;
+ if (!pending || typeof pending.operation_id !== "string" || typeof pending.text !== "string" || !["pending", "delivered"].includes(pending.status ?? "")) throw new Error("Invalid recovery record");
+ if (pending.status === "pending") setDraft(pending.text);
+ setSubmission(pending);
+ if (pending.status === "pending") void readReceipt(pending, current);
+ }
+ } catch {setRecoveryError(zh ? "浏览器回复恢复记录不可读,暂不能安全发送。请先在原 App 核对。" : "Reply recovery is unreadable; check the original App before clearing it.");}
+ let timer: ReturnType;
+ async function read() {
+ const epoch = historyEpoch.current;
+ const params = new URLSearchParams(query!);
+ if (following.current && after.current !== null) params.set("after", String(after.current));
+ if (history.current) params.set("source_ref", history.current.source);
+ if (revision.current) params.set("revision", revision.current);
+ try {
+ const next = await readVisiblePage(params, () => current === generation.current && epoch === historyEpoch.current);
+ if (current !== generation.current || epoch !== historyEpoch.current) return;
+ const follow = !transcript.current || transcript.current.scrollHeight - transcript.current.scrollTop - transcript.current.clientHeight < 64;
+ revision.current = next.revision;
+ if (!next.unchanged) {
+ const reset = next.reset || !history.current || history.current.source !== next.source_ref || (next.history_size ?? 0) < history.current.size;
+ history.current = {source: next.source_ref ?? "", size: next.history_size ?? 0};
+ if (following.current || reset) {
+ after.current = next.after ?? null;
+ setMessages(previous => {
+ const combined = [...new Map([...(reset ? [] : previous), ...next.messages ?? []].map(message => [message.id, message])).values()].sort((a,b) => Number(a.id) - Number(b.id));
+ if (combined.length > 300) setBefore(Number(combined[combined.length - 300].id));
+ return combined.slice(-300);
+ });
+ if (reset) {setBefore(next.before ?? null); following.current = true; setReadingHistory(false); setUnread(false);}
+ } else setUnread(true);
+ }
+ setSnapshot(previous => ({...previous, ...next})); setError("");
+ if (follow && following.current) requestAnimationFrame(() => transcript.current?.scrollTo({top: transcript.current.scrollHeight}));
+ } catch (failure) {if (current === generation.current) {setError(String(failure)); setSnapshot(null);}}
+ finally {if (current === generation.current) timer = setTimeout(read, 1000);}
+ }
+ void read();
+ return () => {generation.current++; clearTimeout(timer);};
+ }, [goalId, binding?.binding_ref]);
+
+ async function openOriginal() {
+ if (!binding || opening) return;
+ const current = generation.current;
+ setOpening(true); setOpenRequested(false); setOpenError("");
+ try {
+ await request<{requested: true}>("/api/chat/codex-attached/open", {method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({goal_id: goalId, agent_id: binding.agent_id, binding_ref: binding.binding_ref})});
+ if (current === generation.current) setOpenRequested(true);
+ } catch (failure) {if (current === generation.current) setOpenError(String(failure));}
+ finally {if (current === generation.current) setOpening(false);}
+ }
+
+ async function older(newer = false) {
+ if ((!newer && before === null) || !query || loadingOlder) return;
+ const current = generation.current;
+ const epoch = ++historyEpoch.current;
+ following.current = false; setReadingHistory(true);
+ setLoadingOlder(true);
+ const params = new URLSearchParams(query);
+ params.set(newer ? "after" : "before", String(newer ? after.current ?? 0 : before));
+ if (history.current) params.set("source_ref", history.current.source);
+ try {
+ const next = await readVisiblePage(params, () => current === generation.current && epoch === historyEpoch.current);
+ if (current === generation.current && epoch === historyEpoch.current) {
+ if (next.reset || !history.current || history.current.source !== next.source_ref || (next.history_size ?? 0) < history.current.size) throw new Error(zh ? "原会话历史已更新,请重新连接。" : "Original history changed; reconnect to read it.");
+ const previousHeight = transcript.current?.scrollHeight ?? 0;
+ setMessages(previous => {
+ const combined = [...new Map([...(next.messages ?? []), ...previous].map(row => [row.id, row])).values()].sort((a,b) => Number(a.id) - Number(b.id));
+ const retained = newer ? combined.slice(-300) : combined.slice(0,300);
+ after.current = newer ? next.after ?? after.current : retained.at(-1)?.end ?? next.after ?? null;
+ setBefore(newer ? Number(retained[0]?.id) || null : next.before ?? null);
+ return retained;
+ });
+ if (!newer) requestAnimationFrame(() => {if (transcript.current) transcript.current.scrollTop += transcript.current.scrollHeight - previousHeight;});
+ if (newer && !next.has_more) {following.current = true; setReadingHistory(false); setUnread(false);}
+ setError("");
+ }
+ } catch (failure) {if (current === generation.current) setError(String(failure));}
+ finally {if (current === generation.current) setLoadingOlder(false);}
+ }
+
+ function pauseHistory() {following.current = false; setReadingHistory(true);}
+ function latest() {
+ historyEpoch.current++; after.current = null; history.current = null; revision.current = "";
+ following.current = true; setReadingHistory(false); setUnread(false); setMessages([]); setBefore(null);
+ }
+ async function loadContent(record: Message, offset: number): Promise {
+ const params = new URLSearchParams(query!);
+ params.set("entry_id", record.id); params.set("content_offset", String(offset));
+ params.set("source_ref", history.current?.source ?? ""); params.set("content_ref", record.content_ref ?? "");
+ return request(`/api/chat/codex-attached?${params}`);
+ }
+
+ async function send() {
+ if (!binding || sending || error || recoveryError || !snapshot?.can_reply || !draft.trim() || awaitingReceipt) return;
+ const current = generation.current;
+ const pending = {operation_id: crypto.randomUUID(), text: draft, status: "pending"};
+ try {sessionStorage.setItem(cacheKey, JSON.stringify(pending));}
+ catch {setError(zh ? "无法保存回复回执,未发送。" : "Cannot save reply recovery; nothing was sent."); return;}
+ receiptGeneration.current++;
+ setSubmission(pending); setSending(true); setSendNotice("");
+ try {
+ const result = await request<{status: string}>("/api/chat/codex-attached", {method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({operation_id: pending.operation_id, text: pending.text, goal_id: goalId, agent_id: binding.agent_id, binding_ref: binding.binding_ref})});
+ if (current === generation.current) {
+ const delivered = {...pending, status: result.status};
+ sessionStorage.setItem(cacheKey, JSON.stringify(delivered)); setSubmission(delivered);
+ if (result.status === "delivered") {sessionStorage.removeItem(`${cacheKey}:draft`); setDraft("");}
+ else void readReceipt(pending, current);
+ revision.current = "";
+ }
+ } catch (failure) {
+ if (current === generation.current) {
+ if (failure instanceof ConversationRequestError && failure.unsubmittedOperation === pending.operation_id) {
+ // Only the owning server can prove no native effect. Keep the draft,
+ // discard this attempt, and require a fresh explicit send when idle.
+ try {
+ sessionStorage.removeItem(cacheKey);
+ receiptGeneration.current++;
+ setSubmission(null); setRecoveryError("");
+ setSnapshot(previous => previous ? {...previous, can_reply: false} : null);
+ setSendNotice(zh ? "Agent 正在忙碌或状态尚未确认;消息未发送,草稿已保留。" : "The Agent is busy or its state is unconfirmed; nothing was sent. Your draft is retained.");
+ } catch {setRecoveryError(zh ? "消息未发送,但浏览器回复恢复记录无法清理。" : "Nothing was sent, but the browser recovery record could not be cleared.");}
+ } else {setError(String(failure)); void readReceipt(pending, current);}
+ }
+ }
+ finally {if (current === generation.current) setSending(false);}
+ }
+ async function readReceipt(pending = submission, current = generation.current) {
+ // Recover from existing evidence only. Never retry the native effect.
+ if (!query || !pending || current !== generation.current) return;
+ const receiptVersion = receiptGeneration.current;
+ const params = new URLSearchParams(query); params.set("operation_id", pending.operation_id);
+ try {
+ const receipt = await request<{status: string}>(`/api/chat/codex-attached?${params}`);
+ if (current === generation.current && receiptVersion === receiptGeneration.current) {
+ const next = {...pending, status: receipt.status};
+ sessionStorage.setItem(cacheKey, JSON.stringify(next)); setSubmission(next);
+ setRecoveryError("");
+ if (receipt.status === "delivered") {sessionStorage.removeItem(`${cacheKey}:draft`); receiptGeneration.current++; setDraft("");}
+ }
+ } catch (failure) {if (current === generation.current && receiptVersion === receiptGeneration.current) setRecoveryError(String(failure));}
+ }
+ function editDraft(text: string) {
+ setDraft(text); setSubmission(null); setSendNotice("");
+ try {sessionStorage.setItem(`${cacheKey}:draft`, text);}
+ catch {setRecoveryError(zh ? "草稿无法保存在此浏览器,请复制保留后重试。" : "This browser cannot retain the draft; copy it before retrying.");}
+ }
+ function nextReply() {
+ try {sessionStorage.removeItem(cacheKey); sessionStorage.removeItem(`${cacheKey}:draft`); receiptGeneration.current++; setSubmission(null); setDraft(""); setError(""); setRecoveryError("");}
+ catch {setError(zh ? "无法清理回复回执。" : "Reply recovery could not be cleared.");}
+ }
+ return
+
+ {discovering ?
{zh ? "正在连接原会话…" : "Connecting to the original conversation…"}
: !binding && !error ?
{zh ? "此 Agent 暂无已绑定的本机 Codex App 原会话。" : "This Agent has no bound local Codex App conversation."}
: null}
+ {recoveryError ?
{recoveryError} {zh ? "已在原 App 核对,清理本页回执" : "Checked in original App; clear this receipt"}
: null}
+ {error ?
{error} setRefresh(value => value + 1)}>{zh ? "重新连接" : "Reconnect"}
: null}
+ {sendNotice ?
{sendNotice}
: null}
+ {binding ? <>
+
{zh ? "Codex App · 已保存消息与工具记录自动更新" : "Codex App · saved messages and tools update automatically"}
{!snapshot ? (zh ? "正在连接或连接不可用" : "Connecting or unavailable") : snapshot.can_reply ? (zh ? "原会话可回复" : "Original conversation accepts replies") : snapshot.activity.state === "turn_open" ? (zh ? "原会话有未结束执行;可在原 App 中追加输入" : "The original conversation has an unfinished turn; add input in the original App") : (zh ? "历史可读;回复连接或执行状态尚未确认" : "History available; reply connection or execution state unconfirmed")}
+
void openOriginal()}>{zh ? "在 Codex App 中打开" : "Open in Codex App"}
+ {openRequested ?
{zh ? "已请求打开原会话,请在 Codex App 中查看。" : "Requested opening the original conversation; check Codex App."}
: null}
+ {openError ?
{openError}
: null}
+ {before !== null ?
void older()}>{zh ? "读取更早的消息" : "Read earlier messages"} : null}
+ {readingHistory ?
{zh ? "正在回看历史" : "Reading history"}{unread ? (zh ? " · 有新活动" : " · new activity") : ""} void older(true)}>{zh ? "较新记录" : "Newer records"} {zh ? "回到最新" : "Back to latest"}
: null}
+
{if (event.deltaY < 0) pauseHistory();}} onTouchMove={pauseHistory} onClick={pauseHistory} onKeyDown={event => {if (["ArrowUp", "PageUp", "Home"].includes(event.key)) pauseHistory();}} aria-label={zh ? "原会话消息" : "Original conversation messages"}>
+ {messages.map(message =>
)}
+ {!messages.length && snapshot ? {zh ? "当前有界历史窗口内没有可显示的消息,可读取更早记录。" : "No displayable messages in this bounded window; read earlier records."}
: null}
+
+ {submission?.status ?
{submission.status === "delivered" ? (zh ? "已由原 App 接纳;等待 Agent 结果。" : "Accepted by the original App; awaiting the Agent result.") : (zh ? "投递尚未确认,请先在原 App 核对,不会自动重发。" : "Delivery unconfirmed; check the original App. No automatic resend.")} {submission.status === "pending" ? void readReceipt()}>{zh ? "重读投递回执" : "Recheck delivery receipt"} : null}{awaitingReceipt ? {zh ? "已核对,写下一条" : "Checked; write another"} : null}
: null}
+ {!awaitingReceipt ?
: null}
+ > : null}
+
+ ;
+}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attached-conversation-record.tsx b/apps/presentation/dashboard/src/features/personal-workspace/attached-conversation-record.tsx
new file mode 100644
index 0000000000..42f8179fb4
--- /dev/null
+++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-conversation-record.tsx
@@ -0,0 +1,73 @@
+import {useState} from "react";
+import {MarkdownText} from "./markdown";
+
+/** Provider-local read model; these records grant no execution authority. */
+export type ConversationRecord = {
+ id: string; end: number; role: "user" | "assistant" | "tool" | "activity";
+ kind: string; label: string; text: string; truncated: boolean;
+ timestamp?: string | null; content_ref?: string; redacted?: boolean; unavailable?: string;
+};
+export type ContentPage = {text: string; next_offset: number; has_more: boolean};
+
+function ToolContent({text, complete, zh}: {text: string; complete: boolean; zh: boolean}) {
+ // Presentation only: render the saved shell envelope's output as readable text.
+ // Keep every source field accessible; an exit code is not task acceptance.
+ if (complete) {
+ try {
+ const value: unknown = JSON.parse(text);
+ if (value && typeof value === "object" && !Array.isArray(value) &&
+ "output" in value && typeof value.output === "string" &&
+ "wall_time_seconds" in value && typeof value.wall_time_seconds === "number" &&
+ "exit_code" in value && (value.exit_code === null || typeof value.exit_code === "number")) {
+ return <>
+ {value.exit_code === null ? (zh ? "尚无退出码" : "No exit code yet") : `${zh ? "退出码" : "Exit code"} ${value.exit_code}`} · {value.wall_time_seconds} s
+ {value.output}
+ {zh ? "原始记录字段" : "Original record fields"} {text}
+ >;
+ }
+ } catch { /* Plain text and partial/unknown formats remain lossless. */ }
+ }
+ return {text} ;
+}
+
+export function AttachedConversationRecord({record, agent, zh, load}: {
+ record: ConversationRecord; agent: string; zh: boolean;
+ load: (record: ConversationRecord, offset: number) => Promise;
+}) {
+ const [text, setText] = useState(record.text);
+ const [more, setMore] = useState(record.truncated);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [offset, setOffset] = useState(Array.from(record.text).length);
+ const labels: Record = zh ? {
+ task_started: "开始执行", task_complete: "本轮结束", turn_aborted: "执行中断", error: "执行报错",
+ function_call: "工具调用", custom_tool_call: "工具调用",
+ function_call_output: "工具结果", custom_tool_call_output: "工具结果",
+ } : {
+ task_started: "Turn started", task_complete: "Turn ended", turn_aborted: "Turn interrupted", error: "Execution error",
+ function_call: "Tool call", custom_tool_call: "Tool call",
+ function_call_output: "Tool result", custom_tool_call_output: "Tool result",
+ };
+ const title = record.role === "user" ? (zh ? "你" : "You") : record.role === "assistant" ? agent :
+ `${labels[record.kind] ?? (zh ? "记录暂不可读" : "Record unavailable")}${record.label && record.label !== record.kind ? ` · ${record.label}` : ""}`;
+ async function continueReading() {
+ if (loading) return;
+ setLoading(true); setError("");
+ try {
+ const page = await load(record, offset);
+ setText(previous => previous + page.text); setOffset(page.next_offset); setMore(page.has_more);
+ } catch (failure) {setError(String(failure));}
+ finally {setLoading(false);}
+ }
+ const content = <>
+ {record.redacted ? {zh ? "此段包含疑似凭据,内容已隐藏。" : "This content contains credential-shaped data and has been withheld."}
: record.role === "tool" ? : }
+ {more ? void continueReading()}>{loading ? (zh ? "正在读取…" : "Loading…") : (zh ? "继续读取完整内容" : "Read more of this record")} : null}
+ {error ? {error}
: null}
+ >;
+ const heading = <>{title} {record.timestamp ? {new Date(record.timestamp).toLocaleString()} : null}>;
+ return
+ {record.role !== "tool" ? : null}
+ {record.unavailable ? {zh ? "源记录无法安全读取;此处保留缺口,未将其当作完成。" : "This source record could not be read safely. The gap is retained, not treated as completion."}
:
+ record.role === "tool" || text.length > 1800 ? {record.role === "tool" ? heading : (zh ? "查看内容" : "Show content")}{more ? {zh ? " · 可继续加载" : " · more available"} : null} {content} : content}
+ ;
+}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx
index b7e08c9800..7042a7764a 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx
@@ -10,6 +10,7 @@ import { useEffect, useState } from "react";
export function ChannelHeader({
agents,
+ agentWorkActive = false,
conversationScope,
goalLifecycleBusy,
managerChannelBinding,
@@ -40,6 +41,7 @@ export function ChannelHeader({
workspaceGrantLabel,
}: {
agents: WorkspaceAgentOption[];
+ agentWorkActive?: boolean;
/** Steward conversation scope; absent when the host grants no workspace. */
conversationScope?: { options: { disabled?: boolean; label: string; value: string }[]; value: string; onChange: (value: string) => void } | null;
goalLifecycleBusy?: boolean;
@@ -77,7 +79,7 @@ export function ChannelHeader({
const [modelDiscovery, setModelDiscovery] = useState(null);
const [modelDiscoveryState, setModelDiscoveryState] = useState<"idle" | "loading" | "error">("idle");
useEffect(() => {
- if (readOnlySourceLabel) return;
+ if (readOnlySourceLabel || agentWorkActive) return;
const controller = new AbortController();
setModelDiscovery(null);
setModelDiscoveryState("loading");
@@ -85,7 +87,7 @@ export function ChannelHeader({
.then((result) => { if (!controller.signal.aborted) { setModelDiscovery(result); setModelDiscoveryState("idle"); } })
.catch(() => { if (!controller.signal.aborted) setModelDiscoveryState("error"); });
return () => controller.abort();
- }, [readOnlySourceLabel, selectedAgentId]);
+ }, [readOnlySourceLabel, selectedAgentId, agentWorkActive]);
const model = modelDiscovery?.model;
const selectedModel = selectedAgentModelId === undefined ? model?.selected || model?.options[0]?.value || "" : selectedAgentModelId;
const modelControl = model && model.options.length ? (
@@ -174,7 +176,7 @@ export function ChannelHeader({
: "header.managerAllocationConfigured")
: null;
- const runtimeControl = readOnlySourceLabel ? (
+ const runtimeControl = agentWorkActive ? null : readOnlySourceLabel ? (
{readOnlySourceLabel}{t("common.readOnly")}
) : <>
div, .delivery-chain-toolbar > div, .delivery-source-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.delivery-review-toolbar button, .delivery-source-actions button { display: inline-flex; gap: 8px; align-items: center; }
@@ -67,6 +67,18 @@
.delivery-relations p { padding-top: 8px; color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); }
.delivery-empty { padding: 24px; }
.delivery-acceptance-contract { min-width: 0; border: 1px solid var(--pw-line); border-radius: 12px; background: var(--pw-card); }
+.delivery-acceptance-contract > .delivery-acceptance-content { padding: 20px; }
+.delivery-check-heading { display: flex; align-items: start; justify-content: space-between; gap: 16px; }
+.delivery-check-heading h3 { margin: 0 0 4px; }
+.delivery-check-heading p, .delivery-check-boundary { color: var(--pw-muted); }
+.delivery-acceptance-contract .delivery-check-primary { background: var(--pw-text); color: var(--pw-card); flex-shrink: 0; }
+.delivery-check-result > ul { display: grid; gap: 12px; }
+.delivery-check-result li > span + span { display: block; color: var(--pw-muted); font-size: var(--text-caption); }
+.delivery-check-plan, .delivery-check-result { display: grid; gap: 12px; }
+@media (max-width: 640px) {
+ .delivery-check-heading { flex-direction: column; }
+ .delivery-check-primary { width: 100%; }
+}
.delivery-acceptance-contract summary { min-height: 44px; padding: 12px 16px; cursor: pointer; font-weight: 500; }
.delivery-acceptance-content { display: grid; gap: 16px; padding: 0 16px 16px; font-size: var(--text-body); line-height: var(--leading-body); }
.delivery-acceptance-content ul { margin: 0; padding-left: 20px; }
@@ -95,3 +107,10 @@
.delivery-notice summary { cursor: pointer; min-height: 24px; }
+
+.delivery-acceptance-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
+.delivery-acceptance-controls label, .delivery-acceptance-controls details { flex-basis: 100%; min-width: 0; }
+.delivery-acceptance-controls input { display: block; max-width: 100%; margin-block-start: 8px; }
+.delivery-acceptance-controls pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 20rem; overflow: auto; }
+.delivery-oracle-evidence { margin-block: 12px; overflow-wrap: anywhere; }
+.delivery-oracle-evidence code { overflow-wrap: anywhere; word-break: break-word; }
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx
index dc6ebbc22a..67c7980c4c 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx
@@ -57,7 +57,7 @@ export function DeliveryReview(props: DeliveryReviewProps) {
return ;
}
-export function DeliveryReviewContent({ goal, items, userTodos, onSelect, review, hideWorkMap = false }: DeliveryReviewProps & {
+export function DeliveryReviewContent({ goal, items, userTodos, onSelect, review, active, hideWorkMap = false }: DeliveryReviewProps & {
review: DeliveryReviewRead; hideWorkMap?: boolean;
}) {
const { locale } = useWorkspaceI18n();
@@ -154,7 +154,7 @@ export function DeliveryReviewContent({ goal, items, userTodos, onSelect, review
> : {copy.noGraph}
}
>}
-
+
;
}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/desktop-update.tsx b/apps/presentation/dashboard/src/features/personal-workspace/desktop-update.tsx
index e143521fa6..2a5da0a9ee 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/desktop-update.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/desktop-update.tsx
@@ -11,7 +11,10 @@ const workingPhases: Phase[] = ["checking", "connecting", "downloading", "instal
const updateErrors: Record = {
app_install_failed: ["App 更新未完成,已确认当前版本完好。可继续使用或重新检查更新。", "The App update failed; the current version was verified intact. Keep using it or check for updates again."],
- app_install_incomplete: ["App 安装未完成,当前版本可能不完整。请保持窗口打开,在「高级选项」恢复上个版本,或重新安装完整 App。", "App installation is incomplete; the current version may be damaged. Keep this window open and restore the previous version in Advanced options, or reinstall the complete App."],
+ app_install_incomplete: ["App 安装未完成,当前版本可能不完整。请保持窗口打开,恢复上个版本后重启;没有可用备份时,请重新安装完整 App。", "App installation is incomplete; the current version may be damaged. Keep this window open, restore the previous version, then restart. If no backup is available, reinstall the complete App."],
+ backup_unavailable: ["没有可验证的 App 备份,未执行恢复。请重新安装完整 App;当前窗口仍可保留。", "No verified App backup is available; restoration did not run. Reinstall the complete App; you can keep this window open."],
+ backup_failed: ["App 备份无法保存或未通过完整性校验,替换已停止。请检查磁盘空间与权限,或重新安装完整 App。", "The App backup could not be saved or failed integrity verification; replacement stopped. Check disk space and permissions, or reinstall the complete App."],
+ rollback_failed: ["恢复 App 未完成,请保持窗口打开。可重试恢复;若仍失败,请重新安装完整 App。", "App restoration did not finish. Keep this window open and retry restoration; if it still fails, reinstall the complete App."],
runtime_bundle_missing: ["App 缺少配套运行时文件。请恢复上个版本或重新安装完整 App。", "The App's bundled runtime files are missing. Restore the previous version or reinstall the complete App."],
runtime_selection_explicit: ["当前运行时由其他安装方式或启动参数管理。请通过原安装方式维护它;App 不会覆盖该安装。", "This runtime is managed by another installer or launch override. Maintain it through that owner; the App will not overwrite it."],
desktop_status_unavailable: ["无法读取 App 更新状态。请重启 App 后再试;若仍失败,请重新安装最新 App。", "Cannot read the App update status. Restart the App; if this persists, reinstall the latest App."],
@@ -41,6 +44,7 @@ export function DesktopUpdate() {
const invoke = (window as DesktopWindow).__TAURI__?.core.invoke;
const working = workingPhases.includes(state.phase);
const repairAvailable = runtimeSelection?.explicit !== true && runtimeSelection?.bundled_repair_available !== false;
+ const restoreInPlace = state.phase === "error" && state.details?.code === "app_install_incomplete" && rollbackAvailable;
function readStatus(value: UpdateStatus, includeState = true) {
setVersion(value.app_version);
@@ -141,7 +145,9 @@ export function DesktopUpdate() {
installing_runtime: zh ? "正在安装匹配的运行时,请稍候…" : "Installing the matching runtime…",
restart_required: zh ? "重启后将自动完成运行时安装与服务连接。" : "Restart to finish runtime installation and reconnect services.",
ready: zh ? "更新完成,服务已就绪。" : "Update completed; services are ready.",
- error: updateErrors[state.details?.code ?? ""]?.[zh ? 0 : 1] ?? (zh ? "更新未完成。请重试;启动失败可尝试修复当前版本。" : "Update incomplete. Retry; repair this version if startup fails."),
+ error: updateErrors[state.details?.code ?? ""]?.[zh ? 0 : 1] ?? (repairAvailable
+ ? (zh ? "更新未完成。请重试;启动失败可尝试修复当前版本。" : "Update incomplete. Retry; repair this version if startup fails.")
+ : (zh ? "更新未完成。请重新检查更新;若仍失败,请重新安装完整 App。" : "Update incomplete. Check for updates again; if it still fails, reinstall the complete App.")),
};
const label = working ? (zh ? "正在更新…" : "Updating…") : state.phase === "available" ? (zh ? "有可用更新" : "Update available") : state.phase === "restart_required" ? (zh ? "重启完成更新" : "Restart to finish") : state.phase === "error" ? (zh ? "更新需重试" : "Retry update") : (zh ? "更新 LoopX" : "Update LoopX");
return
@@ -160,6 +166,7 @@ export function DesktopUpdate() {
{state.phase === "downloading" && state.details?.total ?
: null}
{invoke ?
{state.phase === "restart_required" ?
void run("restart")}>{zh ? "重启完成更新" : "Restart to finish"} : <>
+ {restoreInPlace ?
void run("rollback")}>{zh ? "恢复上个版本" : "Restore previous version"} : null}
void run("check")}>{zh ? "检查更新" : "Check for updates"}
{state.phase === "available" ?
void run("apply")}>{zh ? "更新并准备重启" : "Install update"} : null}
>}
@@ -180,7 +187,7 @@ export function DesktopUpdate() {
void run("forget_runtime_selection")}>{zh ? "清除记住的运行时选择" : "Forget runtime choice"}
> : null}
> : null}
- {invoke && rollbackAvailable ?
{zh ? "恢复上个版本" : "Restore previous version"} {zh ? "将恢复已保留的 App 和它的运行时,需要重启。" : "Restore the retained App and its runtime, then restart."}
void run("rollback")}>{zh ? "确认恢复上版" : "Restore previous version"} : null}
+ {invoke && rollbackAvailable && !restoreInPlace ?
{zh ? "恢复上个版本" : "Restore previous version"} {zh ? "恢复保留的 App 后需要重启。单独管理的 CLI 不会被覆盖。" : "Restore the retained App, then restart. A separately managed CLI will not be overwritten."}
void run("rollback")}>{zh ? "确认恢复上版" : "Restore previous version"} : null}
;
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-acceptance-contract.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-acceptance-contract.tsx
index f833b74ed2..28b983ae37 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/goal-acceptance-contract.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-acceptance-contract.tsx
@@ -1,55 +1,151 @@
-import type { GoalAcceptanceContract } from "../../data/goal-acceptance-observation";
-import type { GoalAcceptanceContractCopy } from "./delivery-review-copy";
+import {useEffect, useRef, useState} from "react";
+import {useQuery, useQueryClient} from "@tanstack/react-query";
+import {acceptanceRequest, preparedAcceptancePlan} from "../../data/goal-acceptance-actions";
+import {applyTypedAction} from "../../data/chat";
+import {useTypedActionReadback} from "../../data/use-typed-action-readback";
+import {useWorkspaceI18n} from "./i18n";
+import {GoalOracleEvidence} from "./goal-oracle-evidence";
+import {goalAcceptanceDisplayStatus, projectGoalAcceptanceCriteria, type GoalAcceptanceContract} from "../../data/goal-acceptance-observation";
+import type {GoalAcceptanceContractCopy} from "./delivery-review-copy";
-/** Present owner-projected states without deriving acceptance or task readiness. */
-export function GoalAcceptanceContractSection({ goalId, contract, copy, current }: {
- goalId: string; contract?: GoalAcceptanceContract; copy: GoalAcceptanceContractCopy; current: boolean;
+/** The Agent prepares; the owner decides the described effect in context.
+ * Proposal receipts are historical. Only native readback supplies check state. */
+export function GoalAcceptanceContractSection({goalId, contract: retained, copy, current, canManage = current, management = false}: {
+ goalId: string; contract?: GoalAcceptanceContract; copy: GoalAcceptanceContractCopy; current: boolean; canManage?: boolean; management?: boolean;
}) {
- if (contract?.enabled !== true) return null;
- const inspectCommand = `loopx --format json goal-acceptance inspect --goal-id '${goalId.replace(/'/g, "'\\''")}'`;
- return
- {copy.title}
+ const zh = useWorkspaceI18n().locale === "zh-CN";
+ const client = useQueryClient();
+ const queryKey = ["goal-acceptance-readback", goalId];
+ const read = useQuery({queryKey, queryFn: ({signal}) => acceptanceRequest(goalId, undefined, signal),
+ enabled: canManage, retry: false, refetchOnWindowFocus: "always",
+ refetchInterval: 15_000, refetchIntervalInBackground: false});
+ const actions = useTypedActionReadback(!canManage, goalId);
+ const plan = actions.isError ? null : preparedAcceptancePlan(actions.data ?? [], goalId);
+ const fresh = canManage && !read.isError && read.data?.goal_id === goalId;
+ const contract = fresh ? read.data!.goal_acceptance_contract : retained;
+ const enabled = contract?.enabled === true;
+ const criteria = enabled ? projectGoalAcceptanceCriteria(contract) : [];
+ const [document, setDocument] = useState | null>(null);
+ const [filename, setFilename] = useState("");
+ const [busy, setBusy] = useState(false), [error, setError] = useState("");
+ const [notice, setNotice] = useState("");
+ const generation = useRef(0), flight = useRef(false);
+ const source = useRef(goalId); source.current = goalId;
+ useEffect(() => {
+ generation.current++; flight.current = false;
+ setDocument(null); setFilename(""); setError(""); setNotice(""); setBusy(false);
+ return () => {generation.current++;};
+ }, [goalId, canManage]);
+ async function operate(action: "adopt" | "configure" | "verify" | "disable") {
+ if (!canManage || flight.current || !fresh || action === "adopt" && !plan || action === "configure" && !document) return;
+ const token = ++generation.current, target = goalId;
+ flight.current = true; setBusy(true); setError(""); setNotice("");
+ try {
+ await client.cancelQueries({queryKey, exact: true});
+ if (source.current !== target || token !== generation.current) return;
+ if (action === "adopt") {
+ const result = await applyTypedAction(plan!.proposal.proposal_id);
+ await actions.acceptProposal(result.proposal);
+ if (result.proposal.status !== "applied") throw new Error("The prepared plan changed");
+ } else {
+ await acceptanceRequest(target, {action, expected_provider_revision: read.data!.provider_revision,
+ ...(action !== "verify" ? {operation_id: `acceptance-ui:${crypto.randomUUID()}`} : {}),
+ ...(action === "configure" ? {document} : {})});
+ }
+ // Never turn a stored action receipt into current acceptance.
+ const observed = await acceptanceRequest(target);
+ if (source.current !== target || token !== generation.current) return;
+ client.setQueryData(queryKey, observed);
+ if (action === "configure") {setDocument(null); setFilename("");}
+ setNotice(action === "disable" ? (zh ? "已停用检查,历史记录仍保留。" : "Checks disabled; history is retained.")
+ : action === "configure" ? (zh ? "已保存,尚未运行检查。" : "Saved; checks have not run.")
+ : "");
+ } catch {
+ if (source.current === target && token === generation.current) {
+ setError(zh ? "操作结果尚未确认。先刷新结果;不会自动重跑本机命令。" : "The result is unconfirmed. Refresh the result first; local commands will not be retried automatically.");
+ // A read-only reconciliation may recover a committed native result.
+ await read.refetch();
+ await actions.refetch();
+ }
+ } finally {if (token === generation.current) {flight.current = false; setBusy(false);}}
+ }
+ if (!management && !enabled && !plan) return null;
+ const readOnly = !canManage;
+ return
-
{copy.boundary}
- {!current ?
{copy.retained}
: null}
-
-
{copy.source} {goalId}
-
{copy.scope} {contract.scope?.kind === "selected_work" ? copy.selectedWork : copy.allWork}
-
{copy.revision} {contract.revision}
-
{copy.digest} {contract.digest}
-
-
{copy.objective} {contract.objective || copy.unknown}
- {contract.non_goals.length ? <>
{copy.nonGoals} {contract.non_goals.map((item, index) => {item} )} > : null}
-
{copy.criteria}
- {contract.criteria.length ?
{contract.criteria.map(criterion => {criterion.id} · {criterion.description} )} :
{copy.noCriteria}
}
-
{copy.tasks}
- {contract.tasks.length ?
{contract.tasks.map(task =>
- {task.todo_id} · {copy.taskState[task.state]}
- {copy.criteria}: {task.criterion_ids.length ? task.criterion_ids.join(", ") : copy.unknown}
- {task.applicable === false ? {copy.notApplicable}
: null}
- {task.reason ? {task.reason}
: null}
- )} :
{copy.noTasks}
}
-
{copy.verification}
-
{copy.verificationState[contract.status]}
- {contract.held_todo_ids.length ?
{copy.heldTasks}: {contract.held_todo_ids.join(", ")}
: null}
-
{copy.receipt}
- {!contract.verification ? {copy.unknown}
: <>
- {copy.receiptNote}
-
-
{copy.operation} {contract.verification.operation_id}
-
{copy.revision} {contract.verification.contract_revision}
-
{copy.digest} {contract.verification.contract_digest}
-
{copy.verificationScope} {contract.verification.todo_id ?? copy.allCriteria}
-
- {contract.verification.results.map(result =>
- {result.criterion_id} · {result.passed ? copy.passed : copy.failed} · {copy.exitCode}: {result.exit_code ?? copy.unknown}
- )}
- >}
-
-
{copy.help} {copy.guidance}
- {inspectCommand}
loopx goal-acceptance --help
- {copy.guide}
-
+
+ {plan ?
+
{plan.document.scope.kind === "all_advancement" ? copy.allWork
+ : (zh ? `仅覆盖方案选定的 ${plan.document.scope.todo_ids.length} 项任务` : `Covers only ${plan.document.scope.todo_ids.length} selected tasks`)}
+
{plan.document.criteria.map(item => {item.description} )}
+ {plan.document.criteria.some(item => item.oracle?.output_refs.length) ?
{zh ? "交付物" : "Delivery"}: {Array.from(new Set(plan.document.criteria.flatMap(item => item.oracle?.output_refs ?? []))).join(" · ")}
: null}
+
{zh ? "将保存这份方案、更新相关任务的验收关联,并在本机运行以下检查。准备方案本身不会执行命令。" : "This saves the plan, updates its task associations and runs the following checks locally. Preparation itself executes no commands."}
+
{zh ? "检查内容与命令" : "Checks and commands"}
+ {plan.document.criteria.map(item => {item.description}
{item.validation_argv.map(arg => JSON.stringify(arg)).join(" ")}
+ {item.oracle?.output_refs.map(ref =>
{zh ? "交付物" : "Delivery"}: {ref}
)}
)}
+
+
: null}
+ {enabled ?
+
{fresh ? copy.verificationState[goalAcceptanceDisplayStatus(contract)] : copy.retained}
+
{criteria.map(item => {item.description}
+ {item.oracle ? {({current: zh ? "当前版本已核对" : "Current versions checked", recorded: zh ? "历史结果,当前版本未核对" : "Recorded; current versions not checked", stale: zh ? "来源或版本已变化,请重新检查" : "Source or versions changed; check again", failed: zh ? "检查失败;修复交付物后可再次检查" : "Checks failed; repair the delivery before checking again", unverified: zh ? "尚未检查" : "Not checked", unavailable: zh ? "证据不可用" : "Evidence unavailable"})[fresh ? item.oracle.state : "recorded"]}
+ : {fresh ? copy.verificationState[item.state] : copy.retained} }
+ )}
+
{copy.boundary}
+
{zh ? "检查记录与版本" : "Check record and versions"}
+ {!contract.verification ? {zh ? "尚无检查记录。" : "No check record yet."}
: <>
+ {copy.receiptNote}
+
+
{copy.operation} {contract.verification.operation_id}
+
{copy.revision} {contract.verification.contract_revision}
+
{copy.digest} {contract.verification.contract_digest}
+
{copy.verificationScope} {contract.verification.todo_id ?? copy.allCriteria}
+
+ {contract.verification.results.map(result =>
+
{contract.criteria.find(item => item.id === result.criterion_id)?.description ?? result.criterion_id} · {result.passed ? copy.passed : copy.failed}
+
{copy.exitCode}: {result.exit_code ?? copy.unknown}
+ {result.oracle_evidence ?
: null}
+ )}
+ >}
+
+
: null}
+ {readOnly ?
{zh ? "当前来源只读;返回本机后可检查交付。" : "This source is read-only. Return to the local workspace to check."}
: null}
+ {read.isError || actions.isError || error ?
{error || (zh ? "检查方案或结果暂时不可用。刷新只读取信息。" : "The plan or result is unavailable. Refresh only reads information.")}
+
{setError(""); void read.refetch(); void actions.refetch();}}>{zh ? "刷新结果" : "Refresh result"} : null}
+ {notice ?
{notice}
: null}
+ {management || enabled ?
{zh ? "高级配置与诊断" : "Advanced configuration and diagnostics"}
+ {copy.guidance}
+
+
{setError(""); void read.refetch(); void actions.refetch();}}>{zh ? "刷新结果" : "Refresh result"}
+
{zh ? "导入现有 JSON 合同(可选)" : "Import an existing JSON contract (optional)"} {const file = event.target.files?.[0]; const token = ++generation.current; setDocument(null); setFilename("");
+ if (!file) return; if (file.size > 262144) {setError(zh ? "文件超过 256 KB。" : "File exceeds 256 KB."); return;}
+ void file.text().then(text => {if (token !== generation.current) return; const parsed: unknown = JSON.parse(text);
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Expected document");
+ setDocument(parsed as Record); setFilename(file.name); setError("");
+ }).catch(() => {if (token === generation.current) setError(zh ? "请选择有效的 JSON 对象。" : "Choose a valid JSON object.");});
+ }}/>
+ {document ?
{zh ? "待保存配置" : "Configuration to save"} · {filename} {JSON.stringify(document, null, 2)}
+ {zh ? "保存会更新所列任务的关联,不会运行检查。" : "Saving updates the listed task associations and does not run checks."}
: null}
+
void operate("configure")}>{zh ? "保存配置" : "Save configuration"}
+
void operate("disable")}>{zh ? "停用检查" : "Disable checks"}
+
+
{copy.source} {goalId}
+ {enabled ? <>
{copy.scope} {contract.scope?.kind === "selected_work" ? copy.selectedWork : copy.allWork}
+
{copy.revision} {contract.revision}
{copy.digest} {contract.digest} > : null}
+
+ {enabled ? <>{contract.non_goals.join(" · ")}
{copy.tasks}
+
+ {contract.held_todo_ids.length ? {copy.heldTasks}: {contract.held_todo_ids.join(", ")}
: null}> : null}
+ {copy.guide}
+ : null}
- ;
+ ;
}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-agent-work-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-agent-work-page.tsx
new file mode 100644
index 0000000000..ee3a4ea6f4
--- /dev/null
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-agent-work-page.tsx
@@ -0,0 +1,41 @@
+import {useEffect, useRef} from "react";
+import {AttachedAgentConversations} from "./attached-agent-conversations";
+import {useWorkspaceI18n} from "./i18n";
+import type {WorkspaceGoal} from "./personal-workspace-model";
+
+/** Navigation uses the Goal directory; only the native provider can authorize a conversation. */
+export function GoalAgentWorkPage({goal, agentId, readOnly, onSelect, onClose}: {
+ goal: WorkspaceGoal; agentId: string; readOnly: boolean;
+ onSelect: (agentId: string) => void; onClose: () => void;
+}) {
+ const zh = useWorkspaceI18n().locale === "zh-CN";
+ const heading = useRef
(null);
+ useEffect(() => {heading.current?.focus({preventScroll: true});}, [agentId]);
+ const ids = [...new Set(goal.registeredAgentIds ?? [])];
+ const lane = goal.agentLanes?.find(item => item.agentId === agentId);
+ const registered = ids.includes(agentId);
+ const hosts = lane?.hostSurfaces ?? [];
+ const supported = hosts.includes("codex-app");
+ const title = zh ? "工作记录" : "Work conversation";
+ return
+
+ {!agentId ?
+ {ids.map(id =>
onSelect(id)}>{goal.agentLanes?.find(item => item.agentId === id)?.label ?? id} )}
+ {!ids.length ?
{zh ? "此 Goal 尚无已注册的 Agent。" : "This Goal has no registered Agents."}
: null}
+
: readOnly ? {zh ? "此来源仅提供状态快照,无法在这里读取或回复原会话。" : "This source provides a status snapshot; original conversations cannot be read or replied to here."}
+ : !registered ? {zh ? "此 Agent 不在当前 Goal 的已注册列表中,请选择列表中的 Agent。" : "This Agent is not registered in the current Goal. Choose an Agent from its directory."}
+ : !supported ? {hosts.length
+ ? (zh ? `来源:${hosts.join("、")}。此宿主的工作记录与回复尚未接入。` : `Source: ${hosts.join(", ")}. Work records and replies from this host are not connected yet.`)
+ : (zh ? "此 Agent 尚无已绑定的会话来源。" : "This Agent has no bound conversation source yet.")}
+ : }
+ ;
+}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-oracle-evidence.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-oracle-evidence.tsx
new file mode 100644
index 0000000000..c839175b64
--- /dev/null
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-oracle-evidence.tsx
@@ -0,0 +1,15 @@
+import type {OracleEvidence} from "../../data/goal-acceptance-observation";
+export function GoalOracleEvidence({evidence, zh}: {evidence: OracleEvidence; zh: boolean}) {
+ return
+
{zh ? "负责人指定的版本固定检查器" : "Owner-pinned checker"} · {evidence.source_id}
+
{evidence.checked_at}
+
{zh ? "检查在声明文件的隔离副本上执行;不代表远程复核者身份或异议已解决。" : "Checks ran on copies of the declared files. This does not authenticate a remote reviewer or resolve an objection."}
+
{zh ? "查看来源与实际检查版本" : "Source and tested versions"}
+ {zh ? "来源摘要" : "Source digest"} · {evidence.source_digest}
+ {(["input", "output"] as const).map(role =>
+
{role === "input" ? (zh ? "输入版本" : "Input versions") : (zh ? "输出版本" : "Output versions")}
+
{evidence[`${role}_versions`].map(row => {row.ref} · {row.sha256} )}
+
)}
+
+
;
+}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx
index 8f4724555c..c12e4825f9 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx
@@ -9,12 +9,13 @@ import type {GoalWorkspaceDigest} from "../../../../../../loopx/control_plane/pr
import { GoalAcceptanceObservationCard } from "./goal-acceptance-observation-card";
import type { WorkspaceActionPreview, WorkspaceActionPreviewRequest, WorkspaceDrawerSelection, WorkspaceGoal, WorkspaceGoalTab, WorkspaceModel, WorkspaceTimelineItem } from "./personal-workspace-model";
import { useWorkspaceI18n } from "./i18n";
-import {projectGoalAcceptanceCriteria} from "../../data/goal-acceptance-observation";
+import {goalAcceptanceDisplayStatus, projectGoalAcceptanceCriteria} from "../../data/goal-acceptance-observation";
import "./goal-overview.css";
export const goalOverviewAttentionId = "goal-overview-attention";
-export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenDetails, onSelect, onView, selectedTodoId, board, results, proposals, onPreview, historyRefreshRevision, review, digest, railOpen, onCloseRail }: {
+export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenDetails, onSelect, onView, selectedTodoId, board, results, proposals, onPreview, historyRefreshRevision, review, digest, railOpen, onCloseRail, onOpenAgentConversation }: {
+ onOpenAgentConversation?: (agentId: string) => void;
railOpen: boolean; onCloseRail: () => void;
review: DeliveryReviewRead; digest: GoalWorkspaceDigest;
active: boolean; goal: WorkspaceGoal; items: WorkspaceTimelineItem[]; userTodos: WorkspaceModel["userTodos"];
@@ -62,7 +63,7 @@ export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenD
{contract ? copy.direction : copy.current}
- {contract ? {acceptanceCopy.verificationState[contract.status]} : null}
+ {contract ? {acceptanceCopy.verificationState[goalAcceptanceDisplayStatus(contract)]} : null}
{copy.details}
setDirectionExpanded(value => !value)}>
@@ -106,7 +107,7 @@ export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenD
{moreAttention ? onView("tasks")}>{copy.tasks}{totalAttention !== null ? ` (${totalAttention})` : ""} : null}
: null}
- {copy.remote}
: }/>
{readOnly ? <>{copy.remote}
>
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-episode.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-episode.tsx
index ec1468438a..356350697b 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-episode.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-episode.tsx
@@ -1,3 +1,4 @@
+import {GoalOracleEvidence} from "./goal-oracle-evidence";
import {useEffect, useRef, useState} from "react";
import {readLoopXTeamWork, type DelegationAdoption, type DelegationDependency, type DelegationReadback} from "../../data/chat";
@@ -101,6 +102,8 @@ export function GoalTeamEpisode({sessionId, result, zh, onInspect, onObservation
onInspect(episode.response.link.operation_id)}>{zh ? "阅读回应与证据" : "Read response and evidence"}
03 · {zh ? "修订产物 · 当前验收有效" : "Revised output · currently accepted"} {result.agent_id}
{zh ? "与原始版本的正文对照见下方" : "Compare with the original below"}
+ {result.validation?.oracle_checks?.map(check =>
+ )}
{zh ? "独立验收 · 证据未提供" : "Independent verification · evidence not provided"}
{zh ? "当前读回未提供独立验收者与指定版本回执。" : "This readback does not provide an independent verifier and an exact-version receipt."}
04 · {episode.downstream ? (zh ? "后续结果 · 当前验收与采用记录有效" : "Downstream result · acceptance and adoption current")
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx
index 3e62d9ff04..aa85b7f6ff 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx
@@ -1,3 +1,4 @@
+import {GoalOracleEvidence} from "./goal-oracle-evidence";
import {GoalTeamLineage} from "./goal-team-lineage";
import {GoalTeamEpisode} from "./goal-team-episode";
import {GoalTeamComparison} from "./goal-team-comparison";
@@ -111,12 +112,12 @@ export function GoalTeamEvidence({sessionId, operationId, zh, canMessage, ingres
}
return
{zh ? "执行证据" : "Execution evidence"}
- void read()}>{zh ? "重新读取证据" : "Recheck evidence"}
- {busy ? {zh ? "正在核验绑定、验收与文件…" : "Checking bindings, acceptance and files…"}
: null}
+ void read()}>{zh ? "重新检查交付" : "Check delivery again"}
+ {busy ? {zh ? "正在运行已配置的检查并核对产物版本…" : "Running configured checks and observing artifact versions…"}
: null}
{error ? {zh ? "无法核验,已清除上次证据。" : "Cannot verify; previous evidence cleared."} {error}
: null}
{result ? <>
{result.agent_id} · {delegationStateLabel(result, zh)} {" · "}{observedAt}
- {zh ? "按需读取的当前观察,不是持续在线状态;验收不代表协调员已采用。" : "An on-demand observation, not continuous liveness; acceptance does not establish coordinator adoption."}
+ {zh ? "本次观察会运行已配置的检查,不代表持续在线;验收不代表协调员已采用。" : "This observation runs the configured checks, not continuous liveness; acceptance does not establish coordinator adoption."}
{hasCorrectionPath ? {zh ? "版本与采用关系详情" : "Version and adoption details"}
@@ -148,6 +149,7 @@ export function GoalTeamEvidence({sessionId, operationId, zh, canMessage, ingres
{zh ? "检查前后保持一致的产物版本" : "Output versions unchanged across these checks"}
{checkedOutputs!.map(row => {row.ref}{" · "}{row.sha256}
)}
> : {zh ? "未提供与当前产物匹配的检查时间及版本记录。" : "Check time and version records matching this report are unavailable."}
}
+ {result.validation.oracle_checks?.map(check => )}
{result.validation.basis_sha256}
> : {zh ? "此运行时未提供验收依据标识。" : "This runtime did not provide the validation basis identity."}
}
: null}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-workspace-work.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-workspace-work.css
index 2ec9d02d7f..34979a3cc6 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/goal-workspace-work.css
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-workspace-work.css
@@ -8,7 +8,7 @@
.goal-work-lenses button[aria-pressed="true"], .goal-work-filters button[aria-pressed="true"], .goal-all-work > [role="group"] button[aria-pressed="true"] { color: var(--pw-text); background: var(--pw-hover); border-color: var(--pw-line); }
.goal-owner-group { margin-top: 20px; }
.goal-owner-group:first-child { margin-top: 0; }
-.goal-owner-group > header { display: flex; gap: 12px; align-items: baseline; padding: 8px 0; border-bottom: 1px solid var(--pw-line-strong); }
+.goal-owner-group > header { display: flex; flex-wrap: wrap; gap: 12px; align-items: baseline; padding: 8px 0; border-bottom: 1px solid var(--pw-line-strong); }
.goal-owner-group h3 { font-size: var(--text-body); line-height: var(--leading-body); font-weight: 600; }
.goal-owner-group > header > span { color: var(--pw-muted); font-size: var(--text-caption); }
.goal-overview .goal-work-row { width: 100%; display: grid; grid-template-columns: 16px minmax(0, 1fr) auto 36px; gap: 10px; border: 0; border-bottom: 1px solid var(--pw-line); border-radius: 0; background: transparent; padding: 12px 0; }
@@ -75,3 +75,5 @@
@media (max-width: 640px) {
.goal-overview .goal-direction-toggle { display: inline-flex; justify-self: start; min-height: 32px; border: 0; padding: 4px 0; background: transparent; color: var(--pw-muted); font-size: var(--text-caption); }
}
+
+.goal-owner-group > header .goal-owner-conversation { margin-left: auto; padding: 6px 8px; min-height: 44px; border: 0; background: transparent; color: var(--pw-text); font: inherit; font-size: var(--text-caption); text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-workspace-work.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-workspace-work.tsx
index 922f8626aa..190362192d 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/goal-workspace-work.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-workspace-work.tsx
@@ -62,7 +62,8 @@ function AddTask({goal, proposals, onPreview, onSelect}: {
}
export function GoalWorkspaceWork({goal, digest, items, userTodos, selectedTodoId, onSelect, board, map, results,
- readOnly, active, historyRefreshRevision, proposals, onPreview, railOpen, onCloseRail}: {
+ readOnly, active, historyRefreshRevision, proposals, onPreview, railOpen, onCloseRail, onOpenAgentConversation}: {
+ onOpenAgentConversation?: (agentId: string) => void;
railOpen: boolean; onCloseRail: () => void;
goal: WorkspaceGoal; digest: GoalWorkspaceDigest; items: WorkspaceTimelineItem[]; userTodos: WorkspaceModel["userTodos"];
selectedTodoId: string | null; onSelect: (selection: WorkspaceDrawerSelection) => void;
@@ -143,7 +144,7 @@ export function GoalWorkspaceWork({goal, digest, items, userTodos, selectedTodoI
)}{!activity.length ? {zh ? "当前时间线尚无已完成或返回的记录。" : "No completed work or returned results in this timeline."}
: null}}
{zh ? "统计仅覆盖此 Goal;任务与历史可能分批载入。" : "Counts cover this Goal; tasks and history may be loaded in bounded pages."}{new Date(digest.observedAt).toLocaleTimeString(locale)}
;
- return
+ return <>
{zh ? "当前工作" : "Now"}
@@ -156,7 +157,9 @@ export function GoalWorkspaceWork({goal, digest, items, userTodos, selectedTodoI
{digest.work.coverage !== "complete" ? {zh ? `当前列出 ${digest.work.listedOpen} 项;总数或覆盖范围不完整。` : `${digest.work.listedOpen} items listed; total or coverage is incomplete.`}
: null}
{digest.work.groups.map(group =>
- {ownerName(group.ownerId)} {group.todoIds.length} {zh ? "项任务" : "tasks"} · {group.todoIds.filter(id => rows.get(id)?.displayClass === group.displayClass).length} {goalWorkClassLabel(group.displayClass, zh)}
+ {ownerName(group.ownerId)} {group.todoIds.length} {zh ? "项任务" : "tasks"} · {group.todoIds.filter(id => rows.get(id)?.displayClass === group.displayClass).length} {goalWorkClassLabel(group.displayClass, zh)}
+ {!readOnly && group.ownerId && onOpenAgentConversation ? onOpenAgentConversation(group.ownerId!)}>{zh ? "工作记录" : "Work conversation"} : null}
+
{group.todoIds.map(id => task(id))}
)}
{!digest.work.groups.length ?
{digest.work.coverage === "complete" ? (zh ? "当前没有可列出的进行中任务。延后与完成记录在全部工作中。" : "No current work to list. Deferred and completed work remain in All work.") : (zh ? "当前快照没有可列出的任务。" : "No work is listed in this snapshot.")}
: null}
@@ -170,5 +173,6 @@ export function GoalWorkspaceWork({goal, digest, items, userTodos, selectedTodoI
{zh ? "关闭全部工作" : "Close all work"}
{rail}
: rail}
-
;
+
+ >;
}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx
index f8232266b5..4db768d258 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx
@@ -1017,6 +1017,7 @@ const en = {
"runs.queued": "Scheduled",
"runs.ready": "Ready",
"runs.resumeFailed": "Recovery failed",
+ "feedback.turnRestarted": "LoopX restarted before this turn finished. Your conversation is saved; this turn was not retried automatically. You can continue with a follow-up here.",
"runs.running": "Running",
"runs.unknown": "Unknown",
"runs.waiting": "Waiting",
@@ -1356,6 +1357,7 @@ const en = {
"proposal.kind.goal.create": "New Goal",
"proposal.kind.goal.update": "Goal update",
"proposal.kind.goal.lifecycle": "Goal lifecycle",
+ "proposal.kind.goal.acceptance.check": "Delivery check",
"proposal.kind.todo.create": "New Task",
"proposal.kind.todo.update": "Task update",
"proposal.kind.agent.bind": "Agent binding",
@@ -1396,6 +1398,28 @@ const en = {
"brief.completedEmpty": "No completed work yet",
"brief.completedTotal": "{count} completed in total",
"home.allGoals": "All Goals",
+ "home.firstGoal.title": "No Goals yet",
+ "home.firstGoal.lead": "A Goal is one outcome you want; the Agent keeps working toward it until you stop it. Describe the outcome in your own words. The LoopX Manager drafts the Goal and nothing is created until you confirm.",
+ "home.firstGoal.greeting": "Let’s start with your first Goal.",
+ "home.firstGoal.create": "Create a Goal",
+ "home.firstGoal.examplesTitle": "Or start from an example. It only fills the message box; nothing is sent until you press send.",
+ "home.firstGoal.example.research": "Help me shape a goal to research Microsoft’s cash flow over the last three fiscal years from its official reports.",
+ "home.firstGoal.example.issue": "Help me shape a goal to turn one open issue in a repository I choose into a reviewed patch.",
+ "home.firstGoal.example.monitor": "Help me shape a goal to watch a public web page every weekday and report only when its content changes.",
+ "home.firstGoal.diagnostics": "Status source details",
+ "home.health.payloadNotOk": "The status source reports that it is not healthy.",
+ "home.health.contractFailed": "Control-plane contract checks failed: {errors} error(s), {warnings} warning(s).",
+ "home.health.contractFailedDetail": "Control-plane contract checks failed: {detail}",
+ "home.health.contractFailedUnknown": "Control-plane contract checks failed.",
+ "home.health.registryHigh": "Registry checks found {count} high-severity finding(s).",
+ "home.health.diagnostics": "Diagnostics",
+ "home.health.sourceUnreadable": "The status source could not be read. What is shown is the last successful read.",
+ "home.health.sourceUnreadableDetail": "Latest read failed: {error}",
+ "home.health.retry": "Retry",
+ "home.health.allClear": "All control-plane contract and registry checks passed.",
+ "home.health.checked": "Status checks completed.",
+ "home.health.concerns": "{count} concern(s) found.",
+ "home.health.freshness": "{count} decision state(s) are stale.",
"state.stopped": "Stopped",
"state.waiting": "Waiting",
"tasks.blocked": "Blocked",
@@ -2500,6 +2524,7 @@ const zhCN: Record
= {
"runs.queued": "已安排",
"runs.ready": "可继续",
"runs.resumeFailed": "恢复失败",
+ "feedback.turnRestarted": "LoopX 重启中断了这一轮。对话已保留,本轮没有自动重跑;你可以在这里继续发送消息。",
"runs.running": "执行中",
"runs.unknown": "状态未知",
"runs.waiting": "等待条件",
@@ -2839,6 +2864,7 @@ const zhCN: Record = {
"proposal.kind.goal.create": "新 Goal",
"proposal.kind.goal.update": "更新 Goal",
"proposal.kind.goal.lifecycle": "Goal 启停",
+ "proposal.kind.goal.acceptance.check": "交付检查",
"proposal.kind.todo.create": "新任务",
"proposal.kind.todo.update": "更新任务",
"proposal.kind.agent.bind": "绑定 Agent",
@@ -2879,6 +2905,28 @@ const zhCN: Record = {
"brief.completedEmpty": "还没有完成的工作",
"brief.completedTotal": "累计完成 {count} 项",
"home.allGoals": "全部 Goal",
+ "home.firstGoal.title": "还没有 Goal",
+ "home.firstGoal.lead": "一个 Goal 就是你想要的一个结果,Agent 会持续推进它直到你叫停。用你自己的话描述想要的结果,LoopX 管家会先整理成 Goal 草稿,你确认之前不会创建任何东西。",
+ "home.firstGoal.greeting": "我们从第一个 Goal 开始。",
+ "home.firstGoal.create": "创建 Goal",
+ "home.firstGoal.examplesTitle": "也可以从一个例子开始。点选只会填入输入框,按发送前不会发出任何内容。",
+ "home.firstGoal.example.research": "帮我整理一个目标:依据官方财报研究微软最近三个财年的现金流。",
+ "home.firstGoal.example.issue": "帮我整理一个目标:把我指定仓库里的一个未关闭 issue 做成一个经过评审的补丁。",
+ "home.firstGoal.example.monitor": "帮我整理一个目标:每个工作日查看一个公开网页,只有内容变化时才向我汇报。",
+ "home.firstGoal.diagnostics": "状态来源详情",
+ "home.health.payloadNotOk": "状态来源报告自身不健康。",
+ "home.health.contractFailed": "控制面契约检查未通过:{errors} 项错误,{warnings} 项警告。",
+ "home.health.contractFailedDetail": "控制面契约检查未通过:{detail}",
+ "home.health.contractFailedUnknown": "控制面契约检查未通过。",
+ "home.health.registryHigh": "注册表检查发现 {count} 项高危问题。",
+ "home.health.diagnostics": "诊断信息",
+ "home.health.sourceUnreadable": "状态来源读取失败,当前显示的是上一次成功读取的内容。",
+ "home.health.sourceUnreadableDetail": "最近一次读取失败:{error}",
+ "home.health.retry": "重试",
+ "home.health.allClear": "所有控制面契约与注册表检查均正常。",
+ "home.health.checked": "状态检查已完成。",
+ "home.health.concerns": "发现 {count} 项需要关注。",
+ "home.health.freshness": "{count} 项决策状态已过期。",
"state.stopped": "已停止",
"state.waiting": "等待条件",
"tasks.blocked": "受阻",
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs
index 7fc03c2055..2efa09d6c0 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs
+++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs
@@ -585,4 +585,19 @@ assert.match(
"The Goal header prefers the current projected Todo over stale recommended_action strings",
);
+// First-Goal home: a healthy source with no registered Goal is a designed
+// state, not empty lanes, and health issues are owner-language sentences.
+assert.match(model, /kind: "no_goals"/, "The home carries a typed first-Goal classification");
+assert.match(dashboard, /const onboarding: WorkspaceHomeOnboarding \| undefined = isHealthy/, "Only a healthy source classifies as a first-Goal workspace");
+assert.match(dashboard, /if \(!sourceError\) return loadedModel;[\s\S]*?onboarding: undefined,[\s\S]*?retryable: true,/, "A failed current read withdraws the first-Goal state and shows a retryable failure");
+assert.match(dashboard, /onRefresh=\{async \(readScope = "all"\) => \{[\s\S]*?rejectOnError: true[\s\S]*?return \{ ok: false, error: formatStatusError\(error\) \}/, "A refresh reports its failure to the page instead of swallowing it");
+assert.match(page, /if \(result && result\.ok === false\) \{\n\s*setRefreshState\("error"\);/, "The header reports a failed refresh and never an update");
+assert.doesNotMatch(dashboard, /payload\.ok === false|契约检查未通过|系统健康关注点/, "Health issues are localized owner language, not protocol fragments");
+assert.match(page, /data-testid="personal-first-goal"/, "The home renders the first-Goal state");
+assert.match(page, /onSuggestRequest\(t\(`home\.firstGoal\.example\./, "Example requests only fill the composer");
+assert.match(page, /personal-first-goal-diagnostics/, "Status source details stay in a collapsed disclosure");
+for (const key of ["home.firstGoal.title", "home.firstGoal.lead", "home.firstGoal.create", "home.health.contractFailed", "home.health.concerns"]) {
+ assert.equal((i18n.match(new RegExp(`"${key.replace(/\./g, "\\.")}":`, "g")) ?? []).length, 2, `${key} exists in both locales`);
+}
+
console.log("personal workspace drawer contract smoke passed");
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts
index 3611611929..8569de6346 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts
+++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts
@@ -167,6 +167,7 @@ export type WorkspaceGoal = {
label: string;
lastActivityAt?: string | null;
state?: string | null;
+ hostSurfaces?: string[];
}>;
/** Canonical registered identities; discovered task claimants do not grant binding authority. */
registeredAgentIds?: string[];
@@ -321,6 +322,7 @@ export type WorkspaceActionPreview = {
| "goal.create"
| "goal.update"
| "goal.lifecycle"
+ | "goal.acceptance.check"
| "todo.create"
| "todo.update"
| "agent.bind"
@@ -434,12 +436,31 @@ export type WorkspaceGoalNotification = {
};
export type WorkspaceSystemHealth = {
+ /** Status-source contract lines for a collapsed disclosure; never primary copy. */
+ diagnostics?: string[];
freshnessWarning?: string | null;
issues: string[];
ok: boolean;
+ /** The current read of the source failed; a retry can clear it. */
+ retryable?: boolean;
summary: string;
};
+/** Outcome of a workspace refresh; a failed read never rejects, it reports. */
+export type WorkspaceRefreshResult = { ok: true } | { ok: false; error: string };
+
+/**
+ * Typed first-Goal classification of the manager home. `no_goals` means the
+ * status source reports zero registered Goals and no execution history, so
+ * the home shows its first-Goal state instead of empty lanes. `diagnostics`
+ * carries the status source's own contract lines for a collapsed disclosure;
+ * it is never primary copy.
+ */
+export type WorkspaceHomeOnboarding = {
+ kind: "no_goals";
+ diagnostics: string[];
+};
+
export type WorkspaceModel = {
blockingTodoCount: number;
goalNotifications?: WorkspaceGoalNotification[];
@@ -449,6 +470,7 @@ export type WorkspaceModel = {
error?: string | null;
loading: boolean;
};
+ onboarding?: WorkspaceHomeOnboarding;
systemHealth?: WorkspaceSystemHealth;
timeline?: WorkspaceTimelineItem[];
attentionHistory?: WorkspaceAttention[];
@@ -490,6 +512,7 @@ export type PersonalHomeCompatibleModel = {
goalNotifications?: WorkspaceGoalNotification[];
goals: WorkspaceGoal[];
openUserTodoCount: number;
+ onboarding?: WorkspaceHomeOnboarding;
systemHealth?: WorkspaceSystemHealth;
attentionHistory?: WorkspaceAttention[];
userTodos: WorkspaceAttention[];
@@ -545,7 +568,7 @@ export type PersonalWorkspaceCallbacks = {
* names the Goals the action touched, so a peer's snapshot is not re-read for it. */
onReconcileStatus?: (options?: { invalidateGoalIds?: string[] }) => void | Promise;
/** Full refresh by default; error recovery can explicitly read missing Goals only. */
- onRefresh?: (scope?: WorkspaceReadScope) => void | Promise;
+ onRefresh?: (scope?: WorkspaceReadScope) => void | WorkspaceRefreshResult | Promise;
onRetryGoalArchive?: () => void | Promise;
onPreviewAction?: (request: WorkspaceActionPreviewRequest) => WorkspaceActionPreview | Promise;
onRequestGoalCreate?: () => WorkspaceActionPreview | Promise | void;
@@ -568,6 +591,7 @@ export type PersonalWorkspaceCallbacks = {
onSelectGoal?: (goalId: string | null, view: WorkspaceGoalTab) => void;
onSelectTodo?: (todoId: string | null) => void;
onSelectView?: (view: WorkspaceGoalTab) => void;
+ onSelectWorkAgent?: (agentId: string | null) => void;
onOpenNotificationSettings?: (goalId?: string) => void;
onFetchNotificationTargets?: () => Promise>;
onSetupGoalChannel?: (options: { execute: boolean; goalId: string; target: string }) => Promise<{ ok: boolean; blocker?: string; public_summary?: string; status?: string }>;
@@ -596,6 +620,7 @@ export function normalizePersonalHomeModel(model: PersonalHomeCompatibleModel):
goalNotifications: model.goalNotifications,
goals: model.goals,
openUserTodoCount: model.openUserTodoCount,
+ onboarding: model.onboarding,
systemHealth: model.systemHealth,
attentionHistory: model.attentionHistory,
userTodos: model.userTodos,
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx
index 7184d1fd5f..d982251735 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx
@@ -51,6 +51,7 @@ import { MessageActivity } from "./message-activity";
import { ChannelTimeline } from "./channel-timeline";
import { ContextDrawer } from "./context-drawer";
import { monitorScheduleReadback } from "./monitor-readback";
+import {GoalAgentWorkPage} from "./goal-agent-work-page";
import { GoalSidebar } from "./goal-sidebar";
import { GoalTasksView } from "./goal-tasks-view";
import { GoalOverview, goalOverviewAttentionId } from "./goal-overview";
@@ -72,6 +73,7 @@ import type {
WorkspaceImageAttachment,
WorkspaceModel,
WorkspaceRun,
+ WorkspaceHomeOnboarding,
WorkspaceSystemHealth,
WorkspaceConversationDirectory,
WorkspaceTimelineItem,
@@ -119,6 +121,10 @@ function ManagerHomeBoard({
goals,
onSelectGoal,
onRetry,
+ onRetrySource,
+ onboarding,
+ onRequestGoalCreate,
+ onSuggestRequest,
systemHealth,
operations,
onSelectOperation,
@@ -127,6 +133,11 @@ function ManagerHomeBoard({
goals: WorkspaceGoal[];
onSelectGoal: (goalId: string) => void;
onRetry?: () => void;
+ /** Re-read the whole source after its latest read failed. */
+ onRetrySource?: () => void;
+ onboarding?: WorkspaceHomeOnboarding;
+ onRequestGoalCreate?: () => void;
+ onSuggestRequest?: (text: string) => void;
systemHealth?: WorkspaceSystemHealth;
operations: WorkspaceActionPreview[];
onSelectOperation: (proposal: WorkspaceActionPreview) => void;
@@ -175,8 +186,44 @@ function ManagerHomeBoard({
))}
) : null}
+ {systemHealth.diagnostics?.length ? (
+
+ {t("home.health.diagnostics")}
+ {systemHealth.diagnostics.map((line, idx) => {line} )}
+
+ ) : null}
+ {systemHealth.retryable && onRetrySource ? (
+ {t("home.health.retry")}
+ ) : null}
) : null}
+ {onboarding?.kind === "no_goals" && goals.length === 0 ? (
+
+ {t("home.firstGoal.title")}
+ {t("home.firstGoal.lead")}
+ {onRequestGoalCreate ? (
+
+
{t("home.firstGoal.create")}
+
+ ) : null}
+ {onSuggestRequest ? (
+
+ {t("home.firstGoal.examplesTitle")}
+ {(["research", "issue", "monitor"] as const).map((example) => (
+
+ onSuggestRequest(t(`home.firstGoal.example.${example}`))} type="button">{t(`home.firstGoal.example.${example}`)}
+
+ ))}
+
+ ) : null}
+ {onboarding.diagnostics.length ? (
+
+ {t("home.firstGoal.diagnostics")}
+ {onboarding.diagnostics.map((line, idx) => {line} )}
+
+ ) : null}
+
+ ) : null}
{currentGoals.some((goal) => goal.loadState) ?
{t("startup.progress", { loaded: currentGoals.filter((goal) => !goal.loadState).length, total: currentGoals.length })}
{failedCount ? {t("startup.failedCount", { count: failedCount })}
@@ -185,7 +232,7 @@ function ManagerHomeBoard({
: null}
{currentGoals.some((goal) => !goal.loadState) ?
: null}
-
{t("home.allGoals")}
+ {onboarding?.kind === "no_goals" && goals.length === 0 ? null :
{t("home.allGoals")} }
{activeHomeLanes.filter((lane) => active[lane.key].length > 0).map((lane) => (
@@ -813,6 +860,7 @@ export function PersonalWorkspacePage({
goalArchiveLoadState = { error: null, phase: "ready" },
selectedView: controlledView,
selectedTodoId: routedTodoId,
+ selectedWorkAgentId,
managerChannelBinding,
managerRuntime,
model,
@@ -838,6 +886,7 @@ export function PersonalWorkspacePage({
goalArchiveLoadState?: WorkspaceGoalArchiveLoadState;
selectedView?: WorkspaceGoalTab;
selectedTodoId?: string | null;
+ selectedWorkAgentId?: string | null;
managerChannelBinding?: ManagerChannelBinding | null;
managerRuntime?: ManagerRuntimeSessionReadback | null;
model: WorkspaceModel;
@@ -877,6 +926,20 @@ export function PersonalWorkspacePage({
const [proposals, setProposals] = useState>({});
const [localView, setLocalView] = useState(controlledGoalId ? "chat" : "overview");
const selectedGoalTab = controlledView ?? localView;
+ const agentWorkActive = Boolean(controlledGoalId === undefined ? localGoalId : controlledGoalId) && selectedGoalTab === "chat" && selectedWorkAgentId != null;
+ const workReturn = useRef<{scope: string; agentId: string} | null>(null);
+ useEffect(() => {
+ if (agentWorkActive) {workReturn.current = {scope: selectionScope, agentId: selectedWorkAgentId!}; return;}
+ const previous = workReturn.current;
+ workReturn.current = null;
+ if (!previous || previous.scope !== selectionScope || selectedGoalTab !== "overview") return;
+ const frame = requestAnimationFrame(() => {
+ const entry = document.querySelector(`.goal-owner-conversation[data-agent-id="${CSS.escape(previous.agentId)}"]`)
+ ?? document.querySelector('.personal-goal-tabs button[aria-current="page"]');
+ entry?.focus({preventScroll: true});
+ });
+ return () => cancelAnimationFrame(frame);
+ }, [agentWorkActive, selectionScope, selectedWorkAgentId, selectedGoalTab]);
const selectedWorkspaceRef = workspaceConversations?.selectedRef ?? null;
const selectedWorkspaceProject = workspaceConversations?.projects?.find((project) => project.project_ref === selectedWorkspaceRef) ?? null;
// A workspace scope exists only inside the steward conversation tab.
@@ -1138,7 +1201,7 @@ export function PersonalWorkspacePage({
|| managerChannelProposalIds.includes(item.proposal.previewId)))),
[items, managerSessionProposalIds, managerChannelProposalIds],
);
- const conversationOpen = selectedGoal ? selectedGoalTab === "chat" : managerChatOpen;
+ const conversationOpen = !agentWorkActive && (selectedGoal ? selectedGoalTab === "chat" : managerChatOpen);
const conversationMessages = selectedGoal ? goalMessages : managerMessages;
const latestMessage = conversationMessages.at(-1);
const latestMessageTextLength = latestMessage?.text.length ?? 0;
@@ -1295,6 +1358,8 @@ export function PersonalWorkspacePage({
const stored = actionReadback.data.filter(proposal => selectedGoalId || proposal.context.kind === "manager"
|| (proposal.action_kind === "operation.execute" && knownGoals.has(String(proposal.normalized_parameters.goal_id))));
const restoreable = stored
+ // Acceptance plans are reviewed beside the delivery they check.
+ .filter((proposal) => proposal.action_kind !== "goal.acceptance.check")
.filter((proposal) => ["preview_ready", "gated", "deferred", "applying"].includes(proposal.status)
|| compileActionReviewPlan(proposal).retryOriginal === true
|| (proposal.action_kind === "team.plan" && proposal.status === "applied")
@@ -2009,9 +2074,13 @@ export function PersonalWorkspacePage({
}
setRefreshState("loading");
try {
- await callbacks.onRefresh();
- setHistoryRefreshRevision((revision) => revision + 1);
- setRefreshState("done");
+ const result = await callbacks.onRefresh();
+ if (result && result.ok === false) {
+ setRefreshState("error");
+ } else {
+ setHistoryRefreshRevision((revision) => revision + 1);
+ setRefreshState("done");
+ }
} catch {
setRefreshState("error");
}
@@ -2120,6 +2189,7 @@ export function PersonalWorkspacePage({
{
setActiveSessionRun(null);
@@ -2174,16 +2244,16 @@ export function PersonalWorkspacePage({
: undefined}
resumeReviewed={!callbacks.onExecuteGoalLifecycle}
/>
- {selectedGoal && !selectedGoal.loadState ?
+ {selectedGoal && !selectedGoal.loadState && !agentWorkActive ?
: selectedGoal?.loadState === "loading" ? : null}
{!readOnly ? openSettings({ kind: "settings", tab: "machine" })} /> : null}
- {selectedGoalId && selectedGoalTab === "chat" && !readOnly && selectedAgentId === "codex" && callbacks.onStartLoopX ? callbacks.onPrepareLoopX!(selectedAgentId, selectedGoalId)}
key={`${selectedGoalId}:${selectedAgentId}`} sessionId={conversationSessionId} onChange={setLoopxMode}
onExecute={(operation, settings) => callbacks.onStartLoopX?.(operation, selectedAgentId, selectedGoalId, settings)}
/> : null}
- {
if (!conversationOpen) return;
const { scrollHeight, scrollTop, clientHeight } = event.currentTarget;
@@ -2191,7 +2261,8 @@ export function PersonalWorkspacePage({
followConversationRef.current = nearBottom;
setShowLatestMessage(!nearBottom);
}}>
- {selectedGoalId && selectedGoalTab === "chat" && !readOnly && selectedAgentId === "codex" && conversationSessionId && loopxMode?.settings.execution_config && loopxMode.settings.agent_id ?
callbacks.onSelectWorkAgent?.("")}>{locale === "zh-CN" ? "Agent 工作记录" : "Agent work conversations"} : null}
+ {selectedGoalId && selectedGoalTab === "chat" && !agentWorkActive && !readOnly && selectedAgentId === "codex" && conversationSessionId && loopxMode?.settings.execution_config && loopxMode.settings.agent_id ?
@@ -2208,7 +2279,7 @@ export function PersonalWorkspacePage({
{!selectedGoal && !managerChatOpen ? (
- {t("brief.title")} · {new Intl.DateTimeFormat(locale, { month: "long", day: "numeric", weekday: "short" }).format(new Date())} {t("home.greeting")} {model.goals.some((goal) => goal.activationState === "active" && goal.loadState) ? t("startup.partial") : <>{t("home.waitingCount", { count: managerNeedsYouCount })} {managerBlockingCount > 0 ? t("home.blockingSummary", { count: managerBlockingCount }) : null} {!actionReadback.isError && homeOperations.length ? t("home.operationSummary", {count: homeOperations.length}) : null}>}
+ {t("brief.title")} · {new Intl.DateTimeFormat(locale, { month: "long", day: "numeric", weekday: "short" }).format(new Date())} {t("home.greeting")} {model.goals.some((goal) => goal.activationState === "active" && goal.loadState) ? t("startup.partial") : model.onboarding?.kind === "no_goals" && model.goals.length === 0 ? t("home.firstGoal.greeting") : <>{t("home.waitingCount", { count: managerNeedsYouCount })} {managerBlockingCount > 0 ? t("home.blockingSummary", { count: managerBlockingCount }) : null} {!actionReadback.isError && homeOperations.length ? t("home.operationSummary", {count: homeOperations.length}) : null}>}
) : null}
{selectedGoal && !selectedGoal.loadState && readOnly && routedTodoId
@@ -2224,14 +2295,15 @@ export function PersonalWorkspacePage({
) : selectedGoal ? (
setRailScope(null)} digest={workspaceDigest} review={workspaceReview} active={!settingsOpen && selectedGoalTab === "overview"} goal={selectedGoal} items={items} userTodos={model.userTodos} readOnly={readOnly}
+ overview: setRailScope(null)} digest={workspaceDigest} review={workspaceReview} active={!settingsOpen && selectedGoalTab === "overview"} goal={selectedGoal} items={items} userTodos={model.userTodos} readOnly={readOnly}
onOpenDetails={() => setSelection({ kind: "goal", item: selectedGoal })} onSelect={setSelection} onView={setSelectedGoalTab}
selectedTodoId={drawerSelection?.kind === "todo" ? drawerSelection.item.todoId : null}
board={taskBoard(!settingsOpen && selectedGoalTab === "overview")} results={goalResults(!settingsOpen && selectedGoalTab === "overview")}
historyRefreshRevision={historyRefreshRevision} proposals={proposals} onPreview={createPreview} />,
tasks: taskBoard(!settingsOpen && selectedGoalTab === "tasks"),
files: goalResults(selectedGoalTab === "files" && !settingsOpen),
- chat: (<>
+ chat: agentWorkActive ? callbacks.onSelectWorkAgent?.(id)} onClose={() => callbacks.onSelectWorkAgent?.(null)}/> : (<>
{selectedGoal && activeSessionRun?.goalId === selectedGoal.goalId ? (
setActiveSessionRun(null)}
@@ -2251,7 +2323,8 @@ export function PersonalWorkspacePage({
>),
}} />
) : !managerChatOpen ? (
- void callbacks.onRefresh?.("missing")} onSelectGoal={selectGoal} systemHealth={model.systemHealth}
+ void callbacks.onRefresh?.("missing")} onRetrySource={() => void refreshWorkspace()} onSelectGoal={selectGoal} systemHealth={model.systemHealth}
+ onboarding={model.onboarding} onRequestGoalCreate={readOnly ? undefined : requestGoalCreate} onSuggestRequest={readOnly ? undefined : suggestReply}
operations={actionReadback.isError ? [] : homeOperations} onSelectOperation={proposal => setSelection({kind: "proposal", item: proposal})}
onViewAllOperations={() => setSelectedGoalTab("chat")} />
) : (
@@ -2269,7 +2342,7 @@ export function PersonalWorkspacePage({
onOpenGoalEvidence={(goalId) => { selectGoal(goalId, "chat"); }} />
)}
-
+
{!readOnly && actionReadback.isError ?
{t("proposal.readbackUnavailable")} void actionReadback.refetch()}>{t("proposal.readbackRetry")}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css
index 22181b0d3e..d4172394a9 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css
+++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css
@@ -312,6 +312,26 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
.personal-system-health-header { display: flex; align-items: center; gap: 8px; font-size: var(--text-body); line-height: var(--leading-body); font-weight: 600; }
.personal-system-health-header small { color: #b91c1c; font-size: var(--text-caption); line-height: var(--leading-caption); font-weight: 400; }
.personal-system-health-issues { margin: 6px 0 0 24px; padding: 0; font-size: var(--text-caption); line-height: var(--leading-caption); color: #b91c1c; }
+.personal-first-goal { display: grid; gap: 16px; max-width: 640px; min-width: 0; padding: 8px 0 24px; border-bottom: 1px solid var(--pw-line); }
+.personal-first-goal h2 { margin: 0; color: var(--pw-text); font-size: var(--text-heading); line-height: var(--leading-heading); letter-spacing: -0.01em; }
+.personal-first-goal > p { margin: 0; color: var(--pw-muted); font-size: var(--text-body); line-height: var(--leading-body); }
+.personal-first-goal-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
+.personal-first-goal-create { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 16px; border: 0; border-radius: 10px; background: var(--pw-blue); color: var(--pw-card); font-size: var(--text-body); font-weight: 600; cursor: pointer; }
+.personal-first-goal-create:hover { background: var(--pw-blue-ink); }
+.personal-first-goal-create:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 2px; }
+.personal-first-goal-examples { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
+.personal-first-goal-examples > li:first-child { color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); }
+.personal-first-goal-examples button { display: block; width: 100%; min-height: 44px; padding: 10px 0; border: 0; border-top: 1px solid var(--pw-line); background: transparent; color: var(--pw-text); font-size: var(--text-body); line-height: var(--leading-body); text-align: left; cursor: pointer; }
+.personal-first-goal-examples button:hover { color: var(--pw-blue-ink); }
+.personal-first-goal-examples button:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: -2px; }
+.personal-first-goal-diagnostics { color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); }
+.personal-first-goal-diagnostics summary { cursor: pointer; }
+.personal-first-goal-diagnostics ul { margin: 6px 0 0 16px; padding: 0; }
+.personal-system-health-retry { margin-top: 10px; min-height: 36px; padding: 0 14px; border: 1px solid var(--pw-red); border-radius: 8px; background: var(--pw-card); color: var(--pw-red); font-size: var(--text-caption); font-weight: 600; cursor: pointer; }
+.personal-system-health-retry:focus-visible { outline: 2px solid var(--pw-red); outline-offset: 2px; }
+.personal-system-health-diagnostics { margin-top: 6px; font-size: var(--text-caption); line-height: var(--leading-caption); }
+.personal-system-health-diagnostics summary { cursor: pointer; }
+.personal-system-health-diagnostics ul { margin: 4px 0 0 16px; padding: 0; }
.personal-home-lane { display: flex; flex-direction: column; min-width: 0; min-height: 100%; border: 1px solid var(--pw-line); border-radius: 16px; background: #fff; box-shadow: 0 1px 4px rgb(30 28 20 / 4%); }
.personal-manager-greeting > span { display: grid; flex: none; place-items: center; width: 44px; height: 44px; border-radius: 12px; background: var(--pw-blue-soft); color: var(--pw-blue); }
.personal-manager-greeting div { display: grid; gap: 4px; min-width: 0; }
@@ -1857,6 +1877,9 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
.personal-workspace-shell[data-pw-theme="brutal"] .personal-manager-conversation-messages article.is-user { background: #8fdcff; }
.personal-workspace-shell[data-pw-theme="brutal"] .personal-quick-prompts button { border: 2px solid #141414; background: #fff; color: #141414; box-shadow: 2px 2px 0 #141414; }
.personal-workspace-shell[data-pw-theme="brutal"] .personal-quick-prompts button:hover { background: #ffe23f; border-color: #141414; color: #141414; }
+.personal-workspace-shell[data-pw-theme="brutal"] .personal-first-goal-create { border: 2px solid #141414; border-radius: 4px; background: #ffe23f; color: #141414; box-shadow: 3px 3px 0 #141414; }
+.personal-workspace-shell[data-pw-theme="brutal"] .personal-first-goal-create:hover { background: #ffd91a; }
+.personal-workspace-shell[data-pw-theme="brutal"] .personal-first-goal-examples button { border-top: 1.5px solid #141414; }
.personal-workspace-shell[data-pw-theme="brutal"] .personal-channel-composer { border: 2px solid #141414; border-radius: 6px; box-shadow: 3px 3px 0 #141414; }
.personal-workspace-shell[data-pw-theme="brutal"] .personal-channel-composer:focus-within { border-color: #141414; box-shadow: 5px 5px 0 #141414; }
.personal-workspace-shell[data-pw-theme="brutal"] .personal-channel-composer textarea { border-left: 2px solid #141414; }
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx
index a166d462b1..7886491a2a 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx
@@ -1,3 +1,5 @@
+import {GoalAcceptanceContractSection} from "./goal-acceptance-contract";
+import {goalAcceptanceContractCopy} from "./delivery-review-copy";
import { useEffect, useRef, useState } from "react";
import { ArrowLeft, Bot, Check, Clock3, KeyRound, Languages, Palette, ServerCog, Settings2 } from "lucide-react";
@@ -233,6 +235,7 @@ export function WorkspaceSettingsPage({
}} />
+
: null}
{tab === "capabilities" && (capabilityScope === "machine" || capabilityGoalId) ?
: null}
{tab === "cadence" && selectedGoal ?
: null}
diff --git a/apps/presentation/dashboard/src/router.tsx b/apps/presentation/dashboard/src/router.tsx
index 8dd1d230a9..1b1cb9282c 100644
--- a/apps/presentation/dashboard/src/router.tsx
+++ b/apps/presentation/dashboard/src/router.tsx
@@ -16,6 +16,8 @@ import { AnswerReportPage } from "./features/personal-workspace/answer-report-pa
const searchSchema = z.object({
goalId: z.string().optional().default(""),
+ // Stable Goal/Agent working conversation; empty string opens the directory.
+ workAgent: z.string().regex(/^[A-Za-z0-9._:-]{0,200}$/).optional().catch(undefined),
todo: z.string().regex(/^[A-Za-z0-9._:-]{1,200}$/).optional().catch(undefined),
// Scopes the steward conversation to a host-granted workspace; selecting a Goal clears it.
workspace: z.string().regex(/^[A-Za-z0-9]{1,80}$/).optional().catch(undefined),
diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx
index 0f33b03701..5877abf0dd 100644
--- a/apps/presentation/dashboard/src/views/dashboard-page.tsx
+++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx
@@ -108,6 +108,8 @@ import {
type WorkspaceGoalTab,
type WorkspaceGoalUsage,
type WorkspaceImageAttachment,
+ type WorkspaceHomeOnboarding,
+ type WorkspaceRefreshResult,
type WorkspaceSystemHealth,
type WorkspaceTimelineItem,
type WorkspaceWorker,
@@ -504,6 +506,7 @@ type PersonalHomeModel = {
goalNotifications?: WorkspaceGoalNotification[];
goals: PersonalGoalItem[];
openUserTodoCount: number;
+ onboarding?: WorkspaceHomeOnboarding;
systemHealth?: WorkspaceSystemHealth;
attentionHistory?: PersonalNeedsYouItem[];
userTodos: PersonalNeedsYouItem[];
@@ -1013,16 +1016,19 @@ function buildPersonalHomeModel(
(right.lastActivity ?? "").localeCompare(left.lastActivity ?? ""),
);
const knownAgentIds = new Set(sortedGoalAgentRows.map((agent) => agent.agentId));
+ const agentHostSurfaces = (id: string) => Array.from(new Set((goal.coordination?.thread_agent_bindings ?? [])
+ .flatMap(binding => binding.agent_id === id && binding.host_surface ? [binding.host_surface] : [])));
const goalAgentLanes = [
...sortedGoalAgentRows.map((agent) => ({
agentId: agent.agentId,
label: agent.agentId,
lastActivityAt: agent.lastActivity,
state: agent.status.label,
+ hostSurfaces: agentHostSurfaces(agent.agentId),
})),
...registeredAgentIds
.filter((agentId) => !knownAgentIds.has(agentId))
- .map((agentId) => ({ agentId, label: agentId, lastActivityAt: null, state: "registered" })),
+ .map((agentId) => ({ agentId, label: agentId, lastActivityAt: null, state: "registered", hostSurfaces: agentHostSurfaces(agentId) })),
];
const agentRow = sortedGoalAgentRows[0];
const goalAgentTodos = mergePersonalAgentTodos(personalAgentTodos(row), sortedGoalAgentRows, row);
@@ -1105,20 +1111,23 @@ function buildPersonalHomeModel(
}];
});
+ // Owner language first: each issue is a localized sentence. The status
+ // source's own contract lines stay available to a collapsed disclosure.
const systemHealthIssues: string[] = [];
if (!payload.ok) {
- systemHealthIssues.push("状态载荷未标记为正常 (payload.ok === false)");
+ systemHealthIssues.push(t("home.health.payloadNotOk"));
}
if (payload.contract && !payload.contract.ok) {
const summary = payload.contract.summary;
- const detail = summary
- ? `${summary.errors} 项错误 / ${summary.warnings} 项警告`
- : (payload.contract.errors?.[0] || "请检查控制面契约");
- systemHealthIssues.push(`契约检查未通过: ${detail}`);
+ systemHealthIssues.push(summary
+ ? t("home.health.contractFailed", { errors: summary.errors, warnings: summary.warnings })
+ : payload.contract.errors?.[0]
+ ? t("home.health.contractFailedDetail", { detail: payload.contract.errors[0] })
+ : t("home.health.contractFailedUnknown"));
}
if (payload.global_registry) {
if (!payload.global_registry.ok) {
- systemHealthIssues.push(`注册表状态异常: ${payload.global_registry.summary.high} 项高危`);
+ systemHealthIssues.push(t("home.health.registryHigh", { count: payload.global_registry.summary.high }));
}
for (const finding of payload.global_registry.findings || []) {
if (finding.severity === "high") {
@@ -1127,17 +1136,24 @@ function buildPersonalHomeModel(
}
}
const freshnessWarning = payload.decision_freshness_summary?.summary?.stale_count
- ? `${payload.decision_freshness_summary.summary.stale_count} 项决策状态已过期`
+ ? t("home.health.freshness", { count: payload.decision_freshness_summary.summary.stale_count })
: null;
const isHealthy = systemHealthIssues.length === 0 && !freshnessWarning;
const systemHealth: WorkspaceSystemHealth = {
ok: isHealthy,
summary: isHealthy
- ? "所有控制面契约与注册表检查均正常"
- : `发现 ${systemHealthIssues.length + (freshnessWarning ? 1 : 0)} 项系统健康关注点`,
+ ? t("home.health.allClear")
+ : t("home.health.concerns", { count: systemHealthIssues.length + (freshnessWarning ? 1 : 0) }),
issues: systemHealthIssues,
freshnessWarning,
+ diagnostics: [...(payload.contract?.errors ?? []), ...(payload.contract?.warnings ?? [])],
};
+ // A healthy source with no registered Goal and no execution history is a
+ // first-Goal workspace, not a failure. Any health issue keeps its banner.
+ const onboarding: WorkspaceHomeOnboarding | undefined = isHealthy
+ && payload.goal_count === 0 && goals.length === 0 && (payload.run_history?.goals ?? []).length === 0
+ ? { kind: "no_goals", diagnostics: [...(payload.contract?.warnings ?? []), ...(payload.contract?.checks ?? [])] }
+ : undefined;
return {
blockingTodoCount: allUserTodos.filter((todo) => todo.blocking).length,
@@ -1159,6 +1175,7 @@ function buildPersonalHomeModel(
})),
goals,
openUserTodoCount: allUserTodos.length,
+ onboarding,
systemHealth,
attentionHistory: [...attentionHistory, ...pendingOperatorGates],
userTodos: allUserTodos,
@@ -1174,8 +1191,11 @@ function buildPersonalHomeModel(
}
function PersonalGoalHome({
goalArchiveLoadState,
+ sourceError,
selectedView,
selectedTodoId,
+ selectedWorkAgentId,
+ onSelectWorkAgent,
onSelectTodo,
onSelectView,
onGoalActivationStateChange,
@@ -1194,8 +1214,12 @@ function PersonalGoalHome({
theme,
}: {
goalArchiveLoadState: WorkspaceGoalArchiveLoadState;
+ /** The current source's latest read failed; the retained payload is stale. */
+ sourceError: string | null;
selectedView?: WorkspaceGoalTab;
selectedTodoId?: string | null;
+ selectedWorkAgentId?: string | null;
+ onSelectWorkAgent: (agentId: string | null) => void;
onSelectTodo: (todoId: string | null) => void;
onSelectView: (view: WorkspaceGoalTab) => void;
onGoalActivationStateChange: (goalId: string, activationState: "active" | "stopped") => void;
@@ -1204,7 +1228,7 @@ function PersonalGoalHome({
onSelectWorkspace: (projectRef: string | null) => void;
selectedWorkspaceRef: string | null;
onReconcileStatus: (options?: { invalidateGoalIds?: string[] }) => void | Promise
;
- onRefresh: (scope?: WorkspaceReadScope) => void | Promise;
+ onRefresh: (scope?: WorkspaceReadScope) => Promise;
onRetryGoalArchive: () => void | Promise;
payload: StatusPayload;
progress: WorkspaceProgress | null;
@@ -1235,7 +1259,7 @@ function PersonalGoalHome({
const [managerRuntime, setManagerRuntime] = useState(null);
const [managerChannelBinding, setManagerChannelBinding] = useState(null);
const [capabilityRevision, setCapabilityRevision] = useState(0);
- const model = useMemo(() => {
+ const loadedModel = useMemo(() => {
const base = buildPersonalHomeModel(payload, rows, t, goalSubagentConfigurationEnabled);
if (!progress) return base;
const models = Object.values(progress.snapshots).map((snapshot) => buildPersonalHomeModel(
@@ -1256,13 +1280,32 @@ function PersonalGoalHome({
openUserTodoCount: userTodos.length, blockingTodoCount: userTodos.filter((todo) => todo.blocking).length,
workers: [...new Map(models.flatMap((item) => item.workers ?? []).map((worker) => [worker.agentId, worker])).values()],
goalNotifications: models.flatMap((item) => item.goalNotifications ?? []),
+ onboarding: incomplete ? undefined : base.onboarding,
systemHealth: incomplete || models.length === 0 ? undefined : {
ok: models.every((item) => item.systemHealth?.ok), issues,
- summary: issues.length ? `发现 ${issues.length} 项系统健康关注点` : "状态检查已完成",
+ summary: issues.length ? t("home.health.concerns", { count: issues.length }) : t("home.health.checked"),
freshnessWarning: models.map((item) => item.systemHealth?.freshnessWarning).filter(Boolean).join(";") || null,
+ diagnostics: [...new Set(models.flatMap((item) => item.systemHealth?.diagnostics ?? []))],
},
};
}, [payload, rows, progress, goalSubagentConfigurationEnabled, t]);
+ // A retained payload is not a current fact once the source's latest read
+ // failed: the first-Goal invitation is withdrawn and the failure is shown
+ // with a retry, instead of an empty workspace that claims to be updated.
+ const model = useMemo(() => {
+ if (!sourceError) return loadedModel;
+ return {
+ ...loadedModel,
+ onboarding: undefined,
+ systemHealth: {
+ ok: false, retryable: true,
+ summary: t("home.health.sourceUnreadable"),
+ issues: [t("home.health.sourceUnreadableDetail", { error: sourceError })],
+ freshnessWarning: null,
+ diagnostics: loadedModel.systemHealth?.diagnostics ?? [],
+ },
+ };
+ }, [sourceError, loadedModel, t]);
// A workspace conversation has no Goal, so no Goal projection may apply to it.
const selectedGoal = selectedWorkspaceRef ? null : model.goals.find((goal) => goal.goalId === selectedGoalId) ?? null;
const workspaceProjects = useChatProjects(readOnly);
@@ -1776,7 +1819,10 @@ function PersonalGoalHome({
pending: false,
reconnect: error instanceof ChatApiError && error.payload.reconnectable === true,
sourceLabel: "LoopX Chat 本地后端",
- text: interrupted ? [streamedText.trim(), "已中断。你可以在当前会话继续发送消息。"].filter(Boolean).join("\n\n") : error instanceof Error ? error.message : "无法恢复进行中的 Agent 回合。",
+ text: interrupted ? [streamedText.trim(), "已中断。你可以在当前会话继续发送消息。"].filter(Boolean).join("\n\n")
+ : error instanceof ChatApiError && error.payload.error_code === "server_restarted"
+ ? t("feedback.turnRestarted")
+ : error instanceof Error ? error.message : "无法恢复进行中的 Agent 回合。",
});
} finally {
releaseObservation();
@@ -2368,7 +2414,9 @@ function PersonalGoalHome({
pending: false,
reconnect: payloadError?.reconnectable === true,
sourceLabel: "LoopX Chat 本地后端",
- text: payloadError?.error_code === "resume_failed"
+ text: payloadError?.error_code === "server_restarted"
+ ? t("feedback.turnRestarted")
+ : payloadError?.error_code === "resume_failed"
? `原 ${answerIdentityLabel(targetContextId, selectedRoute.label)} 会话无法恢复。本地历史已经保留,请在运行详情里选择“重试恢复”或“开始新 Session”。`
: payloadError?.delivery_state === "not_delivered" && payloadError.turn_replay_safe === true && !submittedTurnId
? `${locale === "zh-CN" ? "请求未提交;草稿和图片已保留,可以修改后重新发送。" : "Request not submitted. Draft and images retained; edit and send again."}\n\n${error instanceof Error ? error.message : ""}`
@@ -2925,14 +2973,16 @@ function PersonalGoalHome({
URL.revokeObjectURL(url);
},
onRefresh: async (scope) => {
- await onRefresh(scope);
+ const result = await onRefresh(scope);
setCapabilityRevision((revision) => revision + 1);
+ return result;
},
onRetryResumeRun: retryManagerSession,
onSelectAgent: chooseAgent,
onSelectAgentModel: chooseAgentModel,
onSelectGoal: (goalId, view) => onSelectGoal(goalId ?? "", view),
onSelectView,
+ onSelectWorkAgent,
onSelectTodo,
onSendMessage: async (message, agentId, targetContextId, attachments) => sendManagerQuestion(message, { agentId, contextId: targetContextId, attachments }),
onPrepareLoopX: (agentId, goalId) => prepareGoalConversation(goalId, agentId),
@@ -2942,6 +2992,7 @@ function PersonalGoalHome({
goalArchiveLoadState={goalArchiveLoadState}
selectedView={selectedView}
selectedTodoId={selectedTodoId}
+ selectedWorkAgentId={selectedWorkAgentId}
managerChannelBinding={managerChannelBinding}
managerRuntime={managerRuntime}
conversationSessionId={runtimeBindings[contextId]?.sessionId}
@@ -3164,6 +3215,7 @@ export function DashboardPage() {
search: (current) => ({
...current,
statusUrl: url,
+ workAgent: "statusUrl" in current && (!current.statusUrl || current.statusUrl === url) ? current.workAgent : undefined,
}),
});
}
@@ -3417,6 +3469,7 @@ export function DashboardPage() {
...current,
goalId,
workspace: undefined,
+ workAgent: undefined,
todo: undefined,
view: view === "chat" ? "conversation" : view,
}),
@@ -3424,7 +3477,7 @@ export function DashboardPage() {
}
function selectWorkspace(projectRef: string | null) {
- void navigate({ search: (current) => ({ ...current, goalId: "", todo: undefined, workspace: projectRef ?? undefined, view: "conversation" }) });
+ void navigate({ search: (current) => ({ ...current, goalId: "", todo: undefined, workAgent: undefined, workspace: projectRef ?? undefined, view: "conversation" }) });
}
if (statusRequestActive) {
@@ -3443,7 +3496,14 @@ export function DashboardPage() {
return (
{
+ const goalId = search.goalId, statusUrl = search.statusUrl;
+ void navigate({search: current => "goalId" in current && current.goalId === goalId && current.statusUrl === statusUrl
+ ? {...current, todo: undefined, workAgent: agentId ?? undefined, view: agentId === null ? "overview" : "conversation"} : current});
+ }}
onSelectTodo={(todoId) => {
const goalId = search.goalId, statusUrl = search.statusUrl;
void navigate({search: current => "goalId" in current && current.goalId === goalId && current.statusUrl === statusUrl
@@ -3451,7 +3511,7 @@ export function DashboardPage() {
}}
selectedView={search.view === "conversation" ? "chat" : search.goalId ? search.view ?? "chat" : "overview"}
onSelectView={(view) => {
- void navigate({ search: current => ({ ...current, view: view === "chat" ? "conversation" : view }) });
+ void navigate({ search: current => ({ ...current, workAgent: undefined, view: view === "chat" ? "conversation" : view }) });
}}
onGoalActivationStateChange={(goalId, activationState) => {
statusRequestFenceRef.current.projectionRevision += 1;
@@ -3474,7 +3534,14 @@ export function DashboardPage() {
{ rejectOnError: true, background: true, invalidateGoalIds: options?.invalidateGoalIds, readScope: "missing" },
)}
onRetryGoalArchive={retryGoalArchive}
- onRefresh={(readScope = "all") => loadFromUrl(source.kind === "url" ? source.label : (statusUrl || defaultGlobalStatusUrl), { readScope })}
+ onRefresh={async (readScope = "all") => {
+ try {
+ await loadFromUrl(source.kind === "url" ? source.label : (statusUrl || defaultGlobalStatusUrl), { readScope, rejectOnError: true });
+ return { ok: true } as const;
+ } catch (error) {
+ return { ok: false, error: formatStatusError(error) } as const;
+ }
+ }}
payload={payload}
progress={progress}
rows={goalRows}
diff --git a/benchmark/LHTB/run.sh b/benchmark/LHTB/run.sh
index 5ed384b50c..e816f65739 100755
--- a/benchmark/LHTB/run.sh
+++ b/benchmark/LHTB/run.sh
@@ -48,7 +48,7 @@ LOOPX_SRC_DIR="${LOOPX_SRC_DIR:-$LOOPX_ROOT}"
export PYTHONPATH="$LOOPX_SRC_DIR${PYTHONPATH:+:$PYTHONPATH}"
LOOPX_EXECUTION_MODE="${LOOPX_EXECUTION_MODE:-heartbeat}"
LOOPX_TASK_ENTRY="${LOOPX_TASK_ENTRY:-}"
-LOOPX_PLANNING_TIMEOUT_SEC="${LOOPX_PLANNING_TIMEOUT_SEC:-300}"
+[[ -z "${LOOPX_PLANNING_TIMEOUT_SEC:-}" ]] || die "Remove LOOPX_PLANNING_TIMEOUT_SEC; planning shares LOOPX_SCHEDULER_TIMEOUT_SEC with execution"
LOOPX_ITERATION_CONTEXT="${LOOPX_ITERATION_CONTEXT:-fresh}"
LOOPX_VALIDATION_COMMAND_JSON="${LOOPX_VALIDATION_COMMAND_JSON:-[]}"
SHARED_CODEX_AGENT_DIR="$LOOPX_SRC_DIR/benchmark/runtime"
@@ -140,7 +140,6 @@ fi
--timeout "$AGENT_TIMEOUT_SEC" \
--execution-mode "$LOOPX_EXECUTION_MODE" \
"${task_entry_args[@]}" \
- --planning-timeout "$LOOPX_PLANNING_TIMEOUT_SEC" \
--iteration-context "$LOOPX_ITERATION_CONTEXT" \
--validation-command-json "$LOOPX_VALIDATION_COMMAND_JSON" \
"${turn_timeout_args[@]}" \
diff --git a/benchmark/README.md b/benchmark/README.md
index 05d3c0629a..5c138195b3 100644
--- a/benchmark/README.md
+++ b/benchmark/README.md
@@ -114,6 +114,25 @@ preserved for inspection; the companion notice governs their current evidence
status. Neither archive category is the architecture for new work or grants
runner, verifier, submission, or publication authority.
+## Matched model provider routing
+
+For a custom Responses-compatible model endpoint, set `OPENAI_BASE_URL` and
+`OPENAI_API_KEY` in the private runner environment before invoking
+`python -m benchmark.edgebench.run` for either arm. The native SForge config,
+official Codex home and LoopX worker use the same endpoint and environment-key
+credential contract. A base URL without a key fails before creating the attempt;
+the custom route does not copy or fall back to `CODEX_AUTH_JSON_PATH`. Credentials
+are not embedded in Codex settings or the logged scheduler command. The operator
+still owns endpoint access, actual model/tool qualification and private evidence.
+
+With `OPENAI_BASE_URL` unset, login-file behavior is unchanged, including the
+required `CODEX_AUTH_JSON_PATH`; an ambient API key alone does not select the
+custom route. Change routes only between fresh attempts. Checkpoint continuation
+must retain the captured endpoint/configuration and supply fresh credentials
+externally. This routing repair enables matched experiments under the
+[long-horizon research contract](../docs/architecture/rfcs/long-horizon-harness-benchmark-research-program-v0.md);
+it establishes neither sustained operation nor a solver-quality improvement.
+
## Inert checkpoint capture
This is a developer prerequisite for the [research checkpoint contract](../docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md#77-research-state-branching-and-matched-continuation),
@@ -870,6 +889,34 @@ returns. Preparation and context-reading cost belong in the comparison. Omit all
restore flags for the unchanged cold entry; never reuse an ambiguous trial
folder as a new attempt.
+Research files outside the task workspace need explicit capture **and** explicit
+receiver selection. Add each required directory or file to the source checkpoint
+request's existing `components` array before capture, for example
+`{"id": "research_trials", "source_path": "/tmp/research-trials"}`. Create that
+owned directory before the first capture and keep durable experiment inputs,
+candidates and reports there (or inside the task workspace). Do not capture all
+of `/tmp`, credentials, or unrelated files. The archive cannot discover a missing
+dependency from a conversation reference.
+
+At continuation, inspect the sealed `worker_file_snapshot.components` paths and
+pass `--restore-research-component research_trials`; repeat the option for each
+selected component. Only recorded files below `/tmp/` or `/home/workspace/`,
+separate from the full task workspace, are eligible. The receiver restores the
+original guest path, requires an absent target and an existing physical parent,
+and verifies copied bytes/modes through the artifact owner before model work.
+Unknown/duplicate IDs, overlapping roots, occupied targets and symlink parents
+reject. Selection does not grant control-plane authority; caller-declared files
+are private artifacts and are not automatically filtered for credentials.
+
+Omitting the option preserves workspace-only restoration; unselected components
+remain inert in the archive. A point that never captured a dependency cannot be
+repaired by adding a later copy. Qualify the actual experiment's required files
+in a receiver before describing its research state as complete. An interrupted
+copy leaves a failed attempt to inspect; do not reuse that receiver or overwrite
+its workspace backup. Disable this opt-in by omitting the selection on a new
+attempt; preserve previous evidence. This provider CLI transport does not close
+the generic checkpoint frontend or automatic dependency-discovery gaps.
+
The default continuation retains the captured remaining time. For a separately
authorized longer study, explicitly add `--restore-phase-extension-seconds N`
and set `--timeout` large enough for the extended continuation. For example,
diff --git a/benchmark/edgebench/checkpoint_worker.py b/benchmark/edgebench/checkpoint_worker.py
index 731ee9f46e..183cfefb98 100644
--- a/benchmark/edgebench/checkpoint_worker.py
+++ b/benchmark/edgebench/checkpoint_worker.py
@@ -19,6 +19,7 @@
from loopx.control_plane.effect_runtime import effect_runtime_result
from loopx.file_lock import exclusive_file_lock
from benchmark.runtime.checkpoint import _participant_id
+from benchmark.runtime.checkpoint_handoff import controller_completion_deadline
from .checkpoint_controller import _controller, _physical, _remaining, _phase_budget, capture
@@ -264,7 +265,9 @@ def capture_worker(controller_file: Path, *, token: str, capture_id: str,
# original phase owner and its captured physical clock.
remaining_phase = _phase_budget(phase.read_bytes(), {
"captured_at_epoch": time.time()})["remaining_seconds"]
- deadline = time.monotonic() + remaining_phase
+ completion = controller_completion_deadline(
+ prepared["deadline_epoch"], time.time() + remaining_phase)
+ deadline = time.monotonic() + completion - time.time()
sealing_epoch_ms = (time.time() + _remaining(deadline)) * 1000
native_component = _participant_id(components, "native_capsule")
components.append({"id": native_component, "source_path": str(stage / "native")})
diff --git a/benchmark/edgebench/run.py b/benchmark/edgebench/run.py
index 8685519f3f..961a53459c 100644
--- a/benchmark/edgebench/run.py
+++ b/benchmark/edgebench/run.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import argparse
+from dataclasses import replace
import hashlib
import json
import logging
@@ -130,6 +131,8 @@ def main(argv=None):
parser.add_argument("--restore-goal")
parser.add_argument("--restore-agent")
parser.add_argument("--restore-source-todo")
+ parser.add_argument("--restore-research-component", action="append", default=[],
+ help="Opt-in captured file component ID to restore at its original research path; repeat per component")
parser.add_argument("--restore-phase-extension-seconds", type=int, default=0,
help="Explicit authorized extra continuation time; omitted preserves the checkpoint's remaining phase")
parser.add_argument("--eval-interval", type=int,
@@ -139,6 +142,10 @@ def main(argv=None):
parser.add_argument("--judge-url", required=True)
parser.add_argument("--api-proxy-url", help="Operator-owned, OpenAI-only CONNECT proxy")
args = parser.parse_args(argv)
+ api_base_url = os.environ.get("OPENAI_BASE_URL") or None
+ api_key = os.environ.get("OPENAI_API_KEY") if api_base_url else None
+ if api_base_url and not api_key:
+ parser.error("OPENAI_BASE_URL requires OPENAI_API_KEY; no login fallback")
args.timeout = _task_default(args.task, "timeout_seconds", args.timeout, DEFAULT_TIMEOUT_SECONDS)
args.eval_interval = (0 if args.feedback == "native" and args.eval_interval is None else
_task_default(args.task, "eval_interval_seconds", args.eval_interval, 300))
@@ -171,6 +178,8 @@ def main(argv=None):
if (not 0 <= args.restore_phase_extension_seconds < 2 ** 53 or
(args.restore_phase_extension_seconds and not args.restore_checkpoint)):
parser.error("A non-negative phase extension requires an explicit warm checkpoint continuation")
+ if args.restore_research_component and not args.restore_checkpoint:
+ parser.error("Research component restoration requires an explicit warm checkpoint continuation")
if args.restore_checkpoint and (not handoff or args.eval_interval != 0 or args.replan_after_todos is not None):
parser.error("Warm continuation requires full native checkpoint capture, no periodic evaluation and effective-Turn cadence")
# One directory is one attempt: never reuse native registration or overwrite
@@ -217,6 +226,7 @@ def main(argv=None):
workspace_instructions = native_task_instructions(task.submit_paths, **native_policy)
task_objective = native_task_objective()
config = SForgeConfig(
+ agent_api_base_url=api_base_url, agent_api_key=api_key,
agent_model=args.model, agent_effort=args.effort,
agent_timeout=args.timeout, log_dir=args.log_dir, tasks_dir=args.tasks_dir,
work_cpu_limit=4, work_mem_limit="16g",
@@ -248,7 +258,8 @@ def main(argv=None):
# Validate session/workspace participants before native registration.
candidate = WarmStart(args.restore_checkpoint, args.restore_sha256, args.restore_config,
args.restore_goal, args.restore_agent, args.restore_source_todo,
- int(time.time()) + args.timeout, args.replan_after_turns or 6)
+ int(time.time()) + args.timeout, args.replan_after_turns or 6,
+ tuple(args.restore_research_component))
candidate.verify(task.cwd)
warm_start = candidate
agent = SForgeWorker(config, profile=args.worker, cwd=task.cwd,
@@ -286,7 +297,7 @@ def main(argv=None):
from benchmark.edgebench.checkpoint_supervisor import CheckpointSupervisor
checkpoint = CheckpointSupervisor(args.checkpoint_controller_file, trial / "checkpoints")
backend = RecordingDockerBackend(log_dir=trial / "collected", logger=logger,
- oauth_proxy=bool(args.api_proxy_url),
+ oauth_proxy=bool(args.api_proxy_url or api_base_url),
blind_api_endpoint=blind_endpoint, feedback=feedback,
**({"checkpoint": checkpoint} if checkpoint is not None else {}))
for image in (task.work_image_key, task.judge_image_key):
@@ -297,8 +308,7 @@ def main(argv=None):
expected_sha256=snapshot["native_capsule_sha256"], run_id=args.run_id,
phase_deadline_file=clock, phase_extension_seconds=args.restore_phase_extension_seconds)
epoch = int(clock.read_text())
- warm_start = WarmStart(candidate.root, candidate.sha256, candidate.config,
- candidate.goal_id, candidate.agent_id, candidate.source_todo_id, epoch, candidate.normal_cadence)
+ warm_start = replace(candidate, deadline_epoch=epoch)
reference = json.loads((capsule / "checkpoint.json").read_bytes())["point_metadata"]["native_reference"]
restored_registration = RestoredRegistration(reference["checkpoint_id"],
reference["manifest_sha256"], epoch, capsule / "components/native_judge/manifest.json")
diff --git a/benchmark/runtime/checkpoint.py b/benchmark/runtime/checkpoint.py
index 69171521f0..353e862a98 100644
--- a/benchmark/runtime/checkpoint.py
+++ b/benchmark/runtime/checkpoint.py
@@ -11,7 +11,7 @@
import math
import subprocess
import time
-from contextlib import contextmanager
+from contextlib import ExitStack, contextmanager
from pathlib import Path, PurePosixPath
from typing import Any
@@ -323,68 +323,74 @@ def capture_after_wake(env: dict[str, str], receipt: dict[str, Any], *,
# caller IDs intact and bind the witness to the allocated archive.
metadata["evaluation_opportunity"] = {"component_id": opportunity_id,
"incumbent_component_id": incumbent_id}
- if request.get("native_session"):
- from loopx.hosts.codex_checkpoint import native_session_checkpoint
- from .codex import Execution
- from .sessions import BenchmarkSessionWake
+ # Share the inspection process, not its observations: reread the full
+ # native history after the byte probe. Closing can checkpoint native SQLite,
+ # so keep the reader alive through sealing/the controller writer barrier.
+ with ExitStack() as readers:
+ if request.get("native_session"):
+ from loopx.hosts.codex_checkpoint import native_checkpoint_reader, native_session_checkpoint
+ from .codex import Execution
+ from .sessions import BenchmarkSessionWake
- session = receipt.get("session") or {}
- components, source_wake = native_binding_participant(env, components, metadata)
- native = native_session_checkpoint(env, session.get("session_id", ""),
- timeout=max(.1, deadline - time.time()))
- # Native names identify provider files, not reserved artifact IDs.
- # Allocate in the same namespace as caller and other participants;
- # retain the provider observation unchanged for the stability reread.
- native_ids = {}
- for entry in native["components"]:
- component_id = _participant_id(components, entry["id"])
- native_ids[entry["id"]] = component_id
- components = [*components, {**entry, "id": component_id}]
- metadata["native_session"] = {**native["metadata"], "store_members": [
- {**member, "id": native_ids[member["id"]]}
- for member in native["metadata"]["store_members"]]}
- probe = effect_runtime_result("runtime.checkpoint_artifacts", {
- "action": "probe", "components": components,
- "deadline_epoch_ms": deadline * 1000,
- }, timeout=max(.1, deadline - time.time()), retry_safe=False)
- if request.get("native_session"):
- # Native readback precedes the byte witness. A completed later Turn
- # in that gap must not be sealed under the earlier cursor. Re-read
- # after the probe; capture's existing witness comparison then guards
- # source writes during this recheck and through atomic publication.
- confirmed = native_session_checkpoint(env, session.get("session_id", ""),
- timeout=max(.1, deadline - time.time()))
- if confirmed != native:
- raise ValueError("native checkpoint cursor or participant basis moved before sealing")
- confirmed_wake = BenchmarkSessionWake(env, Execution(context="resume",
- sandbox=env.get("LOOPX_CODEX_SANDBOX", "danger-full-access")), {})
- if ((confirmed_wake.session_id, confirmed_wake.goal_ref, confirmed_wake.digest) !=
- (source_wake.session_id, source_wake.goal_ref, source_wake.digest)):
- raise ValueError("native checkpoint source session binding moved before sealing")
- if request.get("loopx_state"):
- verify_research_participants(env, components, metadata, research, probe["source_witness"], deadline)
- if opportunity is not None and opportunity["native_incumbent"] is not None:
- selected = opportunity["native_incumbent"]
- witness = probe["source_witness"].get(metadata["evaluation_opportunity"]["incumbent_component_id"], [])
- if (len(witness) != 1 or witness[0].get("path") != "" or witness[0].get("kind") != "file" or
- witness[0].get("sha256") != selected["archive_sha256"] or
- witness[0].get("size") != selected["archive_bytes"]):
- raise ValueError("native incumbent participant differs from its captured result")
- destination.parent.mkdir(parents=True, exist_ok=True)
- if request.get("controller_handoff"):
- if research is None:
- raise ValueError("controller checkpoint requires complete canonical research context")
- from .checkpoint_handoff import await_controller_capture
- captured = await_controller_capture(root, components, metadata, probe, deadline)
- receipt["checkpoint"] = {"captured": True, **captured}
- return
- captured = effect_runtime_result("runtime.checkpoint_artifacts", {
- "action": "capture", "destination": str(destination),
- "components": components, "point_metadata": metadata,
- "expected_source_sha256": probe["source_sha256"],
- "deadline_epoch_ms": deadline * 1000,
- }, timeout=max(.1, deadline - time.time()), retry_safe=False)
- receipt["checkpoint"] = {"captured": True, "destination": str(destination), **captured}
+ session = receipt.get("session") or {}
+ components, source_wake = native_binding_participant(env, components, metadata)
+ reader = readers.enter_context(native_checkpoint_reader(env, timeout=max(.1, deadline - time.time())))
+ native = native_session_checkpoint(env, session.get("session_id", ""),
+ timeout=max(.1, deadline - time.time()), transport=reader)
+ # Native names identify provider files, not reserved artifact IDs.
+ # Allocate in the same namespace as caller and other participants;
+ # retain the provider observation unchanged for the stability reread.
+ native_ids = {}
+ for entry in native["components"]:
+ component_id = _participant_id(components, entry["id"])
+ native_ids[entry["id"]] = component_id
+ components = [*components, {**entry, "id": component_id}]
+ metadata["native_session"] = {**native["metadata"], "store_members": [
+ {**member, "id": native_ids[member["id"]]}
+ for member in native["metadata"]["store_members"]]}
+ probe = effect_runtime_result("runtime.checkpoint_artifacts", {
+ "action": "probe", "components": components,
+ "deadline_epoch_ms": deadline * 1000,
+ }, timeout=max(.1, deadline - time.time()), retry_safe=False)
+ if request.get("native_session"):
+ # Native readback precedes the byte witness. A completed later Turn
+ # in that gap must not be sealed under the earlier cursor. Re-read
+ # after the probe; capture's existing witness comparison then guards
+ # source writes during this recheck and through atomic publication.
+ confirmed = native_session_checkpoint(env, session.get("session_id", ""),
+ timeout=max(.1, deadline - time.time()), transport=reader)
+ if confirmed != native:
+ raise ValueError("native checkpoint cursor or participant basis moved before sealing")
+ confirmed_wake = BenchmarkSessionWake(env, Execution(context="resume",
+ sandbox=env.get("LOOPX_CODEX_SANDBOX", "danger-full-access")), {})
+ if ((confirmed_wake.session_id, confirmed_wake.goal_ref, confirmed_wake.digest) !=
+ (source_wake.session_id, source_wake.goal_ref, source_wake.digest)):
+ raise ValueError("native checkpoint source session binding moved before sealing")
+ if request.get("loopx_state"):
+ verify_research_participants(env, components, metadata, research, probe["source_witness"], deadline)
+ if opportunity is not None and opportunity["native_incumbent"] is not None:
+ selected = opportunity["native_incumbent"]
+ witness = probe["source_witness"].get(metadata["evaluation_opportunity"]["incumbent_component_id"], [])
+ if (len(witness) != 1 or witness[0].get("path") != "" or witness[0].get("kind") != "file" or
+ witness[0].get("sha256") != selected["archive_sha256"] or
+ witness[0].get("size") != selected["archive_bytes"]):
+ raise ValueError("native incumbent participant differs from its captured result")
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ if request.get("controller_handoff"):
+ if research is None:
+ raise ValueError("controller checkpoint requires complete canonical research context")
+ from .checkpoint_handoff import await_controller_capture
+ captured = await_controller_capture(root, components, metadata, probe, deadline,
+ phase_deadline=float(phase_deadline) if phase_deadline is not None else None)
+ receipt["checkpoint"] = {"captured": True, **captured}
+ return
+ captured = effect_runtime_result("runtime.checkpoint_artifacts", {
+ "action": "capture", "destination": str(destination),
+ "components": components, "point_metadata": metadata,
+ "expected_source_sha256": probe["source_sha256"],
+ "deadline_epoch_ms": deadline * 1000,
+ }, timeout=max(.1, deadline - time.time()), retry_safe=False)
+ receipt["checkpoint"] = {"captured": True, "destination": str(destination), **captured}
except Exception as error:
# Capture failure is explicit but cannot rewrite an already settled work
# outcome or replay its model/evaluation. Keep the source trial running.
diff --git a/benchmark/runtime/checkpoint_handoff.py b/benchmark/runtime/checkpoint_handoff.py
index 753ab6fc95..1f9458e751 100644
--- a/benchmark/runtime/checkpoint_handoff.py
+++ b/benchmark/runtime/checkpoint_handoff.py
@@ -7,12 +7,34 @@
import hashlib
import json
+import math
import os
import time
import uuid
-def await_controller_capture(root, components, metadata, probe, deadline):
+CONTROLLER_SEAL_SECONDS = 30
+
+
+def controller_completion_deadline(capture_deadline, phase_deadline=None):
+ """Bound private sealing separately without extending live-source capture.
+
+ This is the benchmark mailbox's transport budget, not a new trial budget or
+ artifact admission rule. Both peers use the original absolute capture clock;
+ starting or retrying a reader cannot renew the completion allowance.
+ """
+ if not math.isfinite(capture_deadline):
+ raise ValueError("controller capture deadline must be finite")
+ completion = capture_deadline + CONTROLLER_SEAL_SECONDS
+ if phase_deadline is not None:
+ if not math.isfinite(phase_deadline):
+ raise ValueError("controller phase deadline must be finite")
+ completion = min(completion, phase_deadline)
+ return completion
+
+
+def await_controller_capture(root, components, metadata, probe, deadline, *, phase_deadline=None):
+ completion_deadline = controller_completion_deadline(deadline, phase_deadline)
directory = root.parent / "checkpoint-controller"
directory.mkdir(mode=0o700, exist_ok=True)
if directory.resolve() != directory:
@@ -38,7 +60,11 @@ def await_controller_capture(root, components, metadata, probe, deadline):
finally:
pending.unlink()
try:
- while time.time() < deadline:
+ # The request still carries the original capture deadline. Only the
+ # controller can attest that its barrier/native capture finished in it.
+ # Its sealed reply can arrive after source release, within this separate
+ # bounded window. Do not silently discard that successful publication.
+ while time.time() < completion_deadline:
if reply.exists():
if reply.is_symlink():
raise ValueError("checkpoint controller reply must be physical")
@@ -49,7 +75,7 @@ def await_controller_capture(root, components, metadata, probe, deadline):
raise ValueError("controller checkpoint failed: " + str(result.get("error_kind")))
return result["capture"]
time.sleep(.05)
- raise TimeoutError("controller did not capture before the original checkpoint deadline")
+ raise TimeoutError("controller did not publish a sealed checkpoint before the completion deadline")
finally:
# The controller must reread this exact file under its pause barrier.
# Once absent, a late controller cannot capture a resumed source.
diff --git a/benchmark/runtime/checkpoint_wake.py b/benchmark/runtime/checkpoint_wake.py
index 48387cafbd..88ac4dcadd 100644
--- a/benchmark/runtime/checkpoint_wake.py
+++ b/benchmark/runtime/checkpoint_wake.py
@@ -114,55 +114,62 @@ def _capture(self, held, deadline):
components, research = research_participants(self.env, components, metadata, destination, evidence, window)
if research is None:
raise ValueError("controller checkpoint requires complete canonical research context")
- if self.request.get("native_session"):
- from loopx.hosts.codex_checkpoint import native_session_checkpoint
- from .checkpoint import native_binding_participant
- components, source_wake = native_binding_participant(self.env, components, metadata)
- guard = {"cli": self.env["LOOPX_CLI"], "workspace": self.env["LOOPX_PROJECT"],
- "registry": self.env["LOOPX_REGISTRY"], "runtime_root": self.env["LOOPX_RUNTIME_ROOT"],
- "goal_id": self.env["LOOPX_GOAL_ID"], "agent_id": self.env["LOOPX_AGENT_ID"],
- "turn_instance_id": held["quota"]["heartbeat_receipt"]["turn_instance_id"],
- "capture_root": str(self.capture_root), "socket_path": self.socket_path,
- "decision_sha256": held["sha256"]}
- native = native_session_checkpoint(self.env, self.receipt["session"]["session_id"],
- timeout=max(.1, window - time.time()), interrupted_guard=guard)
- allocated = {}
- for entry in native["components"]:
- component_id = _participant_id(components, entry["id"])
- allocated[entry["id"]] = component_id
- components.append({**entry, "id": component_id})
- decision_id = _participant_id(components, "held_decision")
- components.append({"id": decision_id, "source_path": str(held["path"])})
- metadata["native_session"] = {**native["metadata"], "store_members": [
- {**member, "id": allocated[member["id"]]} for member in native["metadata"]["store_members"]]}
- metadata["held_decision_component_id"] = decision_id
- probe = effect_runtime_result("runtime.checkpoint_artifacts", {
- "action": "probe", "components": components, "deadline_epoch_ms": window * 1000,
- }, timeout=max(.1, window - time.time()), retry_safe=False)
- if native is not None:
- confirmed = native_session_checkpoint(self.env, self.receipt["session"]["session_id"],
- timeout=max(.1, window - time.time()), interrupted_guard=guard)
- from .sessions import BenchmarkSessionWake
- from .codex import Execution
- confirmed_wake = BenchmarkSessionWake(self.env, Execution(context="resume",
- sandbox=self.env.get("LOOPX_CODEX_SANDBOX", "danger-full-access")), {})
- if ((confirmed_wake.session_id, confirmed_wake.goal_ref, confirmed_wake.digest) !=
- (source_wake.session_id, source_wake.goal_ref, source_wake.digest)):
- raise ValueError("interrupted source session binding moved before sealing")
- if confirmed != native:
- raise ValueError("interrupted native cursor moved before sealing")
- if self.request.get("loopx_state"):
- from .checkpoint import verify_research_participants
- verify_research_participants(self.env, components, metadata, research, probe["source_witness"], window)
- if self.request.get("controller_handoff"):
- from .checkpoint_handoff import await_controller_capture
- return await_controller_capture(self.root, components, metadata, probe, window)
- self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
- return effect_runtime_result("runtime.checkpoint_artifacts", {
- "action": "capture", "components": components, "destination": str(destination),
- "expected_source_sha256": probe["source_sha256"], "point_metadata": metadata,
- "deadline_epoch_ms": window * 1000,
- }, timeout=max(.1, window - time.time()), retry_safe=False)
+ # Reuse the native reader across both full observations, including
+ # interrupted-guard validation. Close after sealing/the writer barrier:
+ # native shutdown can otherwise change the probed SQLite files.
+ with ExitStack() as readers:
+ if self.request.get("native_session"):
+ from loopx.hosts.codex_checkpoint import native_checkpoint_reader, native_session_checkpoint
+ from .checkpoint import native_binding_participant
+ components, source_wake = native_binding_participant(self.env, components, metadata)
+ guard = {"cli": self.env["LOOPX_CLI"], "workspace": self.env["LOOPX_PROJECT"],
+ "registry": self.env["LOOPX_REGISTRY"], "runtime_root": self.env["LOOPX_RUNTIME_ROOT"],
+ "goal_id": self.env["LOOPX_GOAL_ID"], "agent_id": self.env["LOOPX_AGENT_ID"],
+ "turn_instance_id": held["quota"]["heartbeat_receipt"]["turn_instance_id"],
+ "capture_root": str(self.capture_root), "socket_path": self.socket_path,
+ "decision_sha256": held["sha256"]}
+ reader = readers.enter_context(native_checkpoint_reader(self.env, timeout=max(.1, window - time.time())))
+ native = native_session_checkpoint(self.env, self.receipt["session"]["session_id"],
+ timeout=max(.1, window - time.time()), interrupted_guard=guard, transport=reader)
+ allocated = {}
+ for entry in native["components"]:
+ component_id = _participant_id(components, entry["id"])
+ allocated[entry["id"]] = component_id
+ components.append({**entry, "id": component_id})
+ decision_id = _participant_id(components, "held_decision")
+ components.append({"id": decision_id, "source_path": str(held["path"])})
+ metadata["native_session"] = {**native["metadata"], "store_members": [
+ {**member, "id": allocated[member["id"]]} for member in native["metadata"]["store_members"]]}
+ metadata["held_decision_component_id"] = decision_id
+ probe = effect_runtime_result("runtime.checkpoint_artifacts", {
+ "action": "probe", "components": components, "deadline_epoch_ms": window * 1000,
+ }, timeout=max(.1, window - time.time()), retry_safe=False)
+ if native is not None:
+ confirmed = native_session_checkpoint(self.env, self.receipt["session"]["session_id"],
+ timeout=max(.1, window - time.time()), interrupted_guard=guard, transport=reader)
+ from .sessions import BenchmarkSessionWake
+ from .codex import Execution
+ confirmed_wake = BenchmarkSessionWake(self.env, Execution(context="resume",
+ sandbox=self.env.get("LOOPX_CODEX_SANDBOX", "danger-full-access")), {})
+ if ((confirmed_wake.session_id, confirmed_wake.goal_ref, confirmed_wake.digest) !=
+ (source_wake.session_id, source_wake.goal_ref, source_wake.digest)):
+ raise ValueError("interrupted source session binding moved before sealing")
+ if confirmed != native:
+ raise ValueError("interrupted native cursor moved before sealing")
+ if self.request.get("loopx_state"):
+ from .checkpoint import verify_research_participants
+ verify_research_participants(self.env, components, metadata, research, probe["source_witness"], window)
+ if self.request.get("controller_handoff"):
+ from .checkpoint_handoff import await_controller_capture
+ return await_controller_capture(self.root, components, metadata, probe, window,
+ phase_deadline=time.time() + deadline - time.monotonic()
+ if deadline != float("inf") else None)
+ self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
+ return effect_runtime_result("runtime.checkpoint_artifacts", {
+ "action": "capture", "components": components, "destination": str(destination),
+ "expected_source_sha256": probe["source_sha256"], "point_metadata": metadata,
+ "deadline_epoch_ms": window * 1000,
+ }, timeout=max(.1, window - time.time()), retry_safe=False)
def run(self, execution, body, wake, session_wake, stdout, stderr, timeout):
from .worker import child_process, native_command
diff --git a/benchmark/runtime/sforge.py b/benchmark/runtime/sforge.py
index 11b5217157..de74da019d 100644
--- a/benchmark/runtime/sforge.py
+++ b/benchmark/runtime/sforge.py
@@ -109,7 +109,9 @@ def __init__(self, config, *, profile: str, cwd: str,
raise ValueError("Worker budget must exceed the 160s startup/settlement reserve")
if not config.agent_model or not config.agent_effort:
raise ValueError("Explicit model and reasoning effort are required")
- if not os.environ.get("CODEX_AUTH_JSON_PATH"):
+ if config.agent_api_base_url and not config.agent_api_key:
+ raise ValueError("Custom model provider requires an API credential")
+ if not config.agent_api_base_url and not os.environ.get("CODEX_AUTH_JSON_PATH"):
raise ValueError("Set CODEX_AUTH_JSON_PATH to the trial credential source")
if task_entry == "loopx-planned" and not profile.startswith("heartbeat-"):
raise ValueError("loopx-planned requires a heartbeat profile")
@@ -208,6 +210,12 @@ def _install_worker(self, backend, handle, log_dir, logger):
effort = {"max": "xhigh"}.get(self._config.agent_effort, self._config.agent_effort)
common = dict(logs_dir=log_dir / "worker", model_name=self._config.agent_model,
reasoning_effort=effort)
+ if self._config.agent_api_base_url:
+ common["extra_env"] = {
+ "OPENAI_BASE_URL": self._config.agent_api_base_url,
+ "OPENAI_API_KEY": self._config.agent_api_key,
+ "CODEX_AUTH_JSON_PATH": "",
+ }
if self.profile in {"official", "single"}:
asyncio.run(CodexOffline(**common).install(self.environment))
result = backend.exec_run(handle, "mkdir -p /home/agent/.codex", user="agent")
@@ -215,18 +223,23 @@ def _install_worker(self, backend, handle, log_dir, logger):
raise RuntimeError("Could not create isolated Codex home")
with tempfile.TemporaryDirectory(prefix="benchmark-auth-") as directory:
home = Path(directory)
- shutil.copyfile(os.environ["CODEX_AUTH_JSON_PATH"], home / "auth.json")
- (home / "auth.json").chmod(0o600)
+ if not self._config.agent_api_base_url:
+ shutil.copyfile(os.environ["CODEX_AUTH_JSON_PATH"], home / "auth.json")
+ (home / "auth.json").chmod(0o600)
prepare_codex_home(
home, execution=Execution(mode="plain"), workspace=Path(self.cwd),
model=self._config.agent_model, effort=effort,
- base_url="", api_key="", wire_api="responses", skills=None,
+ base_url=self._config.agent_api_base_url or "",
+ api_key=self._config.agent_api_key or "", wire_api="responses", skills=None,
)
for name in ("auth.json", "config.toml"):
+ if not (home / name).exists():
+ continue
backend.copy_to_container(handle, home / name,
PurePosixPath(f"/home/agent/.codex/{name}"))
result = backend.exec_run(handle, ["/bin/bash", "-c",
- "chown -R agent:agent /home/agent/.codex && chmod 0600 /home/agent/.codex/auth.json"],
+ "chown -R agent:agent /home/agent/.codex && "
+ "{ test ! -f /home/agent/.codex/auth.json || chmod 0600 /home/agent/.codex/auth.json; }"],
user="root")
if result.exit_code:
raise RuntimeError("Could not protect trial credentials")
@@ -352,6 +365,7 @@ def format_run_cmd(self, prompt_path, *, model=None, cwd="", internet=True, resu
asyncio.run(self.runtime._write_task_document(self.environment, result.stdout))
if self.warm_start is not None:
self._warm_command("context")
+ if self.warm_start is not None and not self._config.agent_api_base_url:
auth = Path(os.environ["CODEX_AUTH_JSON_PATH"]).resolve()
asyncio.run(self.environment.upload_file(auth, f"{_CODEX_HOME}/auth.json"))
protected = self.backend.exec_run(self.handle, ["/bin/bash", "-c",
@@ -377,6 +391,10 @@ def format_run_cmd(self, prompt_path, *, model=None, cwd="", internet=True, resu
clock = (f"test ! -e {deadline}; printf '%s\\n' {self.warm_start.deadline_epoch} > {deadline}; ")
else:
clock = f"test -f {deadline} || echo $(( $(date +%s) + {self.timeout_seconds} )) > {deadline}; "
+ if self._config.agent_api_base_url:
+ # SForge injects the credential through the container execution
+ # environment. Do not duplicate it in the logged shell command.
+ env.pop("OPENAI_API_KEY", None)
exports = " ".join(f"{key}={shlex.quote(value)}" for key, value in env.items())
return self._execution_command(
f"set -eu; {clock}"
diff --git a/benchmark/runtime/sforge_warm_start.py b/benchmark/runtime/sforge_warm_start.py
index cc52a41dd2..3e9b139c3e 100644
--- a/benchmark/runtime/sforge_warm_start.py
+++ b/benchmark/runtime/sforge_warm_start.py
@@ -31,6 +31,7 @@ class WarmStart:
source_todo_id: str
deadline_epoch: int
normal_cadence: int = 6
+ research_component_ids: tuple[str, ...] = ()
def verify(self, workspace: str) -> dict:
if (type(self.normal_cadence) is not int or not 1 <= self.normal_cadence <= 6
@@ -54,6 +55,7 @@ def verify(self, workspace: str) -> dict:
if c["source_path"] == workspace]
if len(roots) != 1 or not (self.root / "components" / roots[0]["id"]).is_dir():
raise ValueError("Warm start requires one complete captured workspace")
+ _research_components(self.root, metadata, Path(workspace), self.research_component_ids)
context = metadata["research_context"]
context_path = self.root / "components" / context["component_id"] / context["member_path"]
if not context_path.resolve(strict=True).is_relative_to(self.root / "components"):
@@ -77,6 +79,8 @@ async def stage(self, environment, workspace: str):
goal_id=self.goal_id, agent_id=self.agent_id,
source_todo_id=self.source_todo_id, deadline_epoch=self.deadline_epoch,
normal_cadence=self.normal_cadence)
+ if self.research_component_ids:
+ payload["research_component_ids"] = list(self.research_component_ids)
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "input.json"
path.write_text(json.dumps(payload))
@@ -91,12 +95,61 @@ async def stage(self, environment, workspace: str):
raise RuntimeError("Could not stage the verified checkpoint participants")
+# Provider-owned data locations; selecting a component never grants permission
+# to restore a home, executable installation, registry or runtime authority.
+_RESEARCH_ROOTS = (Path("/tmp"), Path("/home/workspace"))
+
+
+def _research_components(unit: Path, metadata: dict, workspace: Path, selected) -> list[tuple[str, Path, Path]]:
+ """Bind explicit receiver selection to sealed worker files, not prose refs.
+
+ Capture already accepts multiple components. This transport never discovers
+ dependencies or assumes that all captured files are research data.
+ """
+ if (not isinstance(selected, (tuple, list)) or
+ any(not isinstance(key, str) for key in selected) or len(set(selected)) != len(selected)):
+ raise ValueError("Research component selection must contain distinct captured IDs")
+ result = []
+ files = metadata["worker_file_snapshot"]["components"]
+ for key in selected:
+ matches = [entry for entry in files if entry["id"] == key]
+ if len(matches) != 1 or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_.-]{0,63}", key):
+ raise ValueError("Selected research component is absent from the captured worker files")
+ target = Path(matches[0]["source_path"])
+ if (not target.is_absolute() or ".." in target.parts or str(target) != matches[0]["source_path"] or
+ not any(target != root and target.is_relative_to(root) for root in _RESEARCH_ROOTS) or
+ target.is_relative_to(workspace) or workspace.is_relative_to(target) or
+ target.is_relative_to(unit) or unit.is_relative_to(target) or
+ target == workspace.with_name(workspace.name + ".before-checkpoint")):
+ raise ValueError("Research component must be separate data below a provider research root")
+ if any(target.is_relative_to(old) or old.is_relative_to(target) for _, _, old in result):
+ raise ValueError("Selected research components overlap")
+ source = unit / "components" / key
+ if not source.exists() or source.is_symlink():
+ raise ValueError("Selected research component has no verified artifact bytes")
+ result.append((key, source, target))
+ return result
+
+
+def _check_research_destinations(entries):
+ # Check every destination before touching the workspace. Receiver admission
+ # owns this fresh, quiescent environment; concurrent writers are unsupported.
+ for _, _, target in entries:
+ if target.exists() or target.is_symlink():
+ raise ValueError("Research destination already exists; preserve it and use a fresh receiver")
+ if not target.parent.is_dir() or target.resolve() != target:
+ raise ValueError("Research destination requires an existing physical parent")
+
+
def restore_workspace(declaration: dict, workspace: Path):
"""Materialize one isolated receiver workspace; never import its registry."""
unit = Path(declaration["root"])
effect_runtime_result("runtime.checkpoint_artifacts", {
"action": "verify", "root": str(unit), "expected_sha256": declaration["sha256"]})
- metadata = json.loads((unit / "checkpoint.json").read_bytes())["point_metadata"]
+ manifest = json.loads((unit / "checkpoint.json").read_bytes())
+ metadata = manifest["point_metadata"]
+ research = _research_components(unit, metadata, workspace, declaration.get("research_component_ids", ()))
+ _check_research_destinations(research)
if str(workspace) != metadata["native_session"]["guest_workspace"]:
raise ValueError("Captured and destination workspace paths differ")
if (workspace.resolve() != workspace or workspace == Path("/")
@@ -118,6 +171,20 @@ def restore_workspace(declaration: dict, workspace: Path):
# tree; a partial attempt still cannot be reused.
shutil.move(str(workspace), str(original))
shutil.copytree(source, workspace, symlinks=True)
+ for _, captured, target in research:
+ if captured.is_dir():
+ shutil.copytree(captured, target)
+ else:
+ # Exclusive creation refuses a late collision; leave partial output
+ # for diagnosis. The workspace backup fences the entire attempt.
+ with target.open("xb") as output, captured.open("rb") as input_file:
+ shutil.copyfileobj(input_file, output)
+ shutil.copystat(captured, target)
+ if research:
+ copied = effect_runtime_result("runtime.checkpoint_artifacts", {"action": "probe",
+ "components": [{"id": key, "source_path": str(target)} for key, _, target in research]})
+ if any(copied["source_witness"][key] != manifest["artifact_witness"][key] for key, _, _ in research):
+ raise ValueError("Restored research files differ from the sealed artifacts; preserve this failed receiver")
def _prepare_native_skills(home: Path, installed: Path) -> None:
diff --git a/benchmark/tests/test_checkpoint_handoff.py b/benchmark/tests/test_checkpoint_handoff.py
index d5107898ad..5c198d800c 100644
--- a/benchmark/tests/test_checkpoint_handoff.py
+++ b/benchmark/tests/test_checkpoint_handoff.py
@@ -42,7 +42,7 @@ def controller():
assert await_controller_capture(*args)["sha256"] == "retained"
else:
with pytest.raises(TimeoutError if response == "timeout" else ValueError):
- await_controller_capture(*args)
+ await_controller_capture(*args, phase_deadline=args[-1])
finally:
thread.join(3)
assert not thread.is_alive() and not ready.exists() and len(observed) == 1
@@ -59,6 +59,49 @@ def test_surviving_request_is_not_overwritten(tmp_path):
assert ready.read_bytes() == b"original uncertain request"
+@pytest.mark.parametrize("response", ["captured", "failed", "foreign", "missing", "phase_expired"])
+def test_sealed_reply_after_capture_deadline_is_bounded_and_bound(tmp_path, monkeypatch, response):
+ from types import SimpleNamespace
+ from benchmark.runtime import checkpoint_handoff as handoff
+
+ clock = [100.0]
+ mailbox = tmp_path / "checkpoint-controller"
+ ready = mailbox / "ready.json"
+ published = []
+ def advance(seconds):
+ clock[0] += seconds
+ if clock[0] < 100.25 or published or response in {"missing", "phase_expired"}:
+ return
+ raw = ready.read_bytes()
+ request = json.loads(raw)
+ # The extra completion time must not leak into the writer barrier.
+ assert request["deadline_epoch"] == 100.1
+ result = {"request_sha256": "foreign" if response == "foreign" else hashlib.sha256(raw).hexdigest(),
+ "ok": response == "captured", "error_kind": "SealingFailed",
+ "capture": {"sha256": "sealed-original", "research_point_qualified": False}}
+ (mailbox / (request["capture_id"] + ".json")).write_text(json.dumps(result))
+ published.append(True)
+ monkeypatch.setattr(handoff, "time", SimpleNamespace(time=lambda: clock[0], sleep=advance))
+ monkeypatch.setattr(handoff, "CONTROLLER_SEAL_SECONDS", .5)
+ args = (tmp_path / "checkpoints", [], {}, {"source_sha256": "prepared", "source_witness": {}}, 100.1)
+ phase = 100.2 if response == "phase_expired" else 1000
+ if response == "captured":
+ assert handoff.await_controller_capture(*args, phase_deadline=phase)["sha256"] == "sealed-original"
+ else:
+ with pytest.raises(TimeoutError if response in {"missing", "phase_expired"} else ValueError):
+ handoff.await_controller_capture(*args, phase_deadline=phase)
+ assert 100.1 < clock[0] < (100.3 if response == "phase_expired" else 100.7)
+ assert not ready.exists()
+
+
+@pytest.mark.parametrize("capture,phase", [(float("nan"), None), (float("inf"), None),
+ (100, float("nan")), (100, float("inf"))])
+def test_controller_completion_requires_finite_clocks(capture, phase):
+ from benchmark.runtime.checkpoint_handoff import controller_completion_deadline
+ with pytest.raises(ValueError, match="finite"):
+ controller_completion_deadline(capture, phase)
+
+
@pytest.mark.skipif(sys.platform != "linux", reason="Qualifies the Linux writer process group")
@pytest.mark.parametrize("use_selector,closeout,settlement_only", [
(False, False, False), (True, False, False), (True, True, False), (True, False, True),
@@ -216,3 +259,69 @@ def test_closeout_does_not_relax_settlement_identity(field, value):
with pytest.raises(EffectRuntimeRemoteError):
effect_runtime_result("runtime.checkpoint_artifacts", {"action": "select_replan", "quota": quota,
"goal_id": "source", "agent_id": "agent", "turn_instance_id": "turn"})
+
+
+@pytest.mark.parametrize("fault", [None, "first_read", "probe", "cursor_moved", "capture"])
+def test_interrupted_capture_keeps_reader_through_publication_and_cleans_up(tmp_path, monkeypatch, fault):
+ from contextlib import contextmanager
+ from types import SimpleNamespace
+ from benchmark.runtime.checkpoint_wake import CheckpointWake
+
+ env = {"LOOPX_WAKE_LOG_DIR": str(tmp_path / "wakes"), "LOOPX_CLI": "loopx",
+ "LOOPX_PROJECT": str(tmp_path), "LOOPX_REGISTRY": str(tmp_path / "registry"),
+ "LOOPX_RUNTIME_ROOT": str(tmp_path / "runtime"), "LOOPX_GOAL_ID": "goal",
+ "LOOPX_AGENT_ID": "agent"}
+ hook = CheckpointWake(env, {"turn_id": "turn", "session": {"session_id": "session"}})
+ hook.raw = b"synthetic request"
+ code = tmp_path / "candidate"
+ code.write_text("retained")
+ hook.request = {"components": [{"id": "code", "source_path": str(code)}],
+ "point_metadata": {}, "native_session": True}
+ held = {"path": tmp_path / "held", "sha256": "guard-digest",
+ "intent": {"obligation_id": "replan-fixture"},
+ "quota": {"heartbeat_receipt": {"turn_instance_id": "turn"}}}
+ held["path"].write_text("original guard")
+ binding = SimpleNamespace(session_id="session", goal_ref="goal", digest="profile")
+ monkeypatch.setattr("benchmark.runtime.checkpoint.native_binding_participant",
+ lambda env, components, metadata: (components, binding))
+ monkeypatch.setattr("benchmark.runtime.sessions.BenchmarkSessionWake", lambda *a, **k: binding)
+ events = []
+ token = object()
+ @contextmanager
+ def reader(*args, **kwargs):
+ events.append("open")
+ try:
+ yield token
+ finally:
+ events.append("closed")
+ def read(*args, transport, interrupted_guard, **kwargs):
+ assert transport is token and interrupted_guard["decision_sha256"] == held["sha256"]
+ events.append("read")
+ if fault == "first_read":
+ raise ValueError("synthetic read failure")
+ return {"components": [], "metadata": {"store_members": [],
+ "visible_history_sha256": "later" if fault == "cursor_moved" and events.count("read") == 2 else "original"}}
+ monkeypatch.setattr("loopx.hosts.codex_checkpoint.native_checkpoint_reader", reader)
+ monkeypatch.setattr("loopx.hosts.codex_checkpoint.native_session_checkpoint", read)
+ def artifact(method, params, **kwargs):
+ assert params["deadline_epoch_ms"] <= phase_deadline * 1000 + 1
+ events.append(params["action"])
+ if params["action"] == "probe":
+ if fault == "probe":
+ raise ValueError("synthetic probe failure")
+ return {"source_sha256": "witness"}
+ assert "closed" not in events
+ if fault == "capture":
+ raise ValueError("synthetic capture failure")
+ assert params["expected_source_sha256"] == "witness"
+ return {"sha256": "sealed"}
+ monkeypatch.setattr("benchmark.runtime.checkpoint_wake.effect_runtime_result", artifact)
+ phase_deadline = time.time() + 5
+ if fault:
+ with pytest.raises(ValueError, match="failure|cursor moved"):
+ hook._capture(held, time.monotonic() + 5)
+ assert events[-1] == "closed"
+ assert ("capture" in events) is (fault == "capture")
+ else:
+ assert hook._capture(held, time.monotonic() + 5)["sha256"] == "sealed"
+ assert events == ["open", "read", "probe", "read", "capture", "closed"]
diff --git a/benchmark/tests/test_checkpoint_worker.py b/benchmark/tests/test_checkpoint_worker.py
index 8b3fe45f13..c050493280 100644
--- a/benchmark/tests/test_checkpoint_worker.py
+++ b/benchmark/tests/test_checkpoint_worker.py
@@ -214,6 +214,8 @@ def test_post_release_sealing_preserves_captured_bytes_and_original_phase(
unit = tmp_path / "point"
def delayed_seal(method, params, **kwargs):
if params.get("action") == "capture" and params.get("destination") == str(unit):
+ if seal_within_phase:
+ assert params["deadline_epoch_ms"] <= min(held_deadline + 30, phase_epoch) * 1000 + 1
container.reload()
assert container.attrs["State"]["Running"] and not container.attrs["State"]["Paused"]
before = counter(container)
diff --git a/benchmark/tests/test_lhtb_preflight.py b/benchmark/tests/test_lhtb_preflight.py
index f4b77b043d..ba4eb5b8bd 100644
--- a/benchmark/tests/test_lhtb_preflight.py
+++ b/benchmark/tests/test_lhtb_preflight.py
@@ -1,8 +1,11 @@
"""The preflight accepts only cadence values supported by the owning CLI."""
from __future__ import annotations
+import os
+import re
import subprocess
import sys
+from pathlib import Path
import pytest
@@ -56,3 +59,49 @@ def test_config_renderer_removes_only_legacy_null_planning_limit(tmp_path, monke
assert "planning_timeout_sec" not in kwargs
assert kwargs["task_entry"] == "loopx-planned"
assert kwargs["scheduler_timeout_sec"] == 5080
+
+
+def _render_config_flags(run_sh: str) -> set[str]:
+ """Collect every option run.sh hands to render_config.py, including argv arrays."""
+ lines = run_sh.splitlines()
+ start = next(i for i, line in enumerate(lines) if 'scripts/render_config.py"' in line)
+ command = []
+ for line in lines[start:]:
+ command.append(line)
+ if not line.rstrip().endswith("\\"):
+ break
+ text = "\n".join(command)
+ flags = set(re.findall(r"(--[a-z][a-z-]*)", text))
+ for array in re.findall(r'"\$\{(\w+)\[@\]\}"', text):
+ flags.update(re.findall(rf"{array}=\((--[a-z][a-z-]*)", run_sh))
+ return flags
+
+
+def test_run_sh_passes_only_options_the_renderer_accepts():
+ lhtb = Path(__file__).resolve().parents[1] / "LHTB"
+ help_result = subprocess.run(
+ [sys.executable, "-m", "benchmark.LHTB.scripts.render_config", "--help"],
+ cwd=lhtb.parents[1], capture_output=True, text=True, check=True, timeout=30,
+ )
+ accepted = set(re.findall(r"(--[a-z][a-z-]*)", help_result.stdout))
+ passed = _render_config_flags((lhtb / "run.sh").read_text())
+ assert passed and passed <= accepted, passed - accepted
+
+
+def test_run_sh_rejects_the_retired_planning_cap(tmp_path):
+ run_sh = Path(__file__).resolve().parents[1] / "LHTB" / "run.sh"
+ (tmp_path / "upstream" / "tasks").mkdir(parents=True)
+ venv_bin = tmp_path / ".venv" / "bin"
+ venv_bin.mkdir(parents=True)
+ for name in ("harbor", "python"):
+ (venv_bin / name).write_text("#!/bin/sh\nexit 0\n")
+ (venv_bin / name).chmod(0o755)
+ result = subprocess.run(
+ ["bash", str(run_sh), "preflight"],
+ capture_output=True, text=True, timeout=30,
+ env={"PATH": os.environ["PATH"], "LHTB_ROOT": str(tmp_path),
+ "OPENAI_BASE_URL": "http://192.0.2.1:8000/v1", "OPENAI_API_KEY": "x",
+ "LOOPX_PLANNING_TIMEOUT_SEC": "300"},
+ )
+ assert result.returncode == 1
+ assert "Remove LOOPX_PLANNING_TIMEOUT_SEC" in result.stderr
diff --git a/benchmark/tests/test_sforge_runtime.py b/benchmark/tests/test_sforge_runtime.py
index 7c0b0063cf..dfab1ac9fd 100644
--- a/benchmark/tests/test_sforge_runtime.py
+++ b/benchmark/tests/test_sforge_runtime.py
@@ -847,3 +847,108 @@ async def execute(command, **kwargs):
worker._warm_command("context")
assert json.loads((tmp_path / "checkpoint-context.json").read_text()) == {"model_calls": 0}
assert len(calls) == 2
+
+
+@pytest.mark.parametrize('profile', ['official', 'single', 'native-goal', 'heartbeat-resume'])
+def test_custom_provider_uses_environment_credential_without_copying_login(tmp_path, monkeypatch, profile):
+ pytest.importorskip('sforge')
+ pytest.importorskip('harbor')
+ import tomllib
+ from types import SimpleNamespace
+ from sforge.harness.agent.codex import CodexAgent
+ from sforge.harness.config import SForgeConfig
+ from sforge.harness.run_agent import _build_agent_env
+ from benchmark.runtime.sforge import SForgeWorker, CodexOffline, BenchmarkCodex
+
+ # An ambient, unusable login must neither be needed nor staged on this route.
+ monkeypatch.setenv('CODEX_AUTH_JSON_PATH', str(tmp_path / 'must-not-read-auth.json'))
+ monkeypatch.setenv('OPENAI_API_KEY', 'wrong-ambient-key')
+ monkeypatch.setenv('OPENAI_BASE_URL', 'https://wrong.example/v1')
+ async def installed(self, environment):
+ pass
+ monkeypatch.setattr(CodexOffline, 'install', installed)
+ monkeypatch.setattr(BenchmarkCodex, 'install', installed)
+ monkeypatch.setattr(CodexAgent, 'install_stop_hook', lambda *args: None)
+ copied = {}
+ def copy(handle, source, target):
+ copied[str(target)] = source.read_text()
+ backend = SimpleNamespace(exec_run=lambda *a, **k: SimpleNamespace(exit_code=0),
+ copy_to_container=copy)
+ config = SForgeConfig(agent_model='fixture', agent_effort='xhigh',
+ agent_api_base_url='http://127.0.0.1:9876/v1',
+ agent_api_key='synthetic-provider-secret')
+ worker = SForgeWorker(config, profile=profile, cwd='/task')
+ worker.install_stop_hook(backend, None, tmp_path, None)
+ native_env = _build_agent_env(worker)
+ assert native_env['OPENAI_API_KEY'] == config.agent_api_key
+ assert native_env['OPENAI_BASE_URL'] == config.agent_api_base_url
+ assert not any(name.endswith('/auth.json') for name in copied)
+ if profile in {'official', 'single'}:
+ settings = tomllib.loads(copied['/home/agent/.codex/config.toml'])
+ assert settings['model_provider'] == 'harbor'
+ assert settings['model_providers']['harbor']['env_key'] == 'OPENAI_API_KEY'
+ assert settings['model_providers']['harbor']['base_url'] == config.agent_api_base_url
+ else:
+ assert worker.runtime._get_env('CODEX_AUTH_JSON_PATH') == ''
+ env = worker.runtime._worker_env(cwd='/task')
+ assert env['OPENAI_API_KEY'] == config.agent_api_key
+ assert env['OPENAI_BASE_URL'] == config.agent_api_base_url
+ worker.prepared = True
+ command = worker.format_run_cmd('/task/prompt.md')
+ assert config.agent_api_key not in command
+ assert all(config.agent_api_key not in contents for contents in copied.values())
+ config.agent_api_key = None
+ with pytest.raises(ValueError, match='API credential'):
+ SForgeWorker(config, profile=profile, cwd='/task')
+
+
+def test_custom_provider_without_key_fails_before_creating_attempt(tmp_path, monkeypatch):
+ pytest.importorskip('sforge')
+ pytest.importorskip('harbor')
+ from benchmark.edgebench.run import main
+ monkeypatch.setenv('OPENAI_BASE_URL', 'http://127.0.0.1:9876/v1')
+ monkeypatch.delenv('OPENAI_API_KEY', raising=False)
+ with pytest.raises(SystemExit, match='2'):
+ main(['--task', 'fixture', '--tasks-dir', str(tmp_path), '--log-dir', str(tmp_path),
+ '--run-id', 'missing-key', '--worker', 'official', '--model', 'fixture',
+ '--effort', 'xhigh', '--judge-url', 'http://127.0.0.1:9999'])
+ assert not (tmp_path / 'runs').exists()
+
+
+@pytest.mark.parametrize('custom', [False, True])
+def test_edgebench_cli_forwards_only_explicit_provider_pair(tmp_path, monkeypatch, custom):
+ pytest.importorskip('sforge')
+ pytest.importorskip('harbor')
+ from types import SimpleNamespace
+ from benchmark.edgebench import run
+ monkeypatch.setenv('LOOPX_SRC_DIR', str(tmp_path))
+ monkeypatch.setenv('LOOPX_EXPECTED_COMMIT', 'fixture')
+ monkeypatch.setenv('CODEX_AUTH_JSON_PATH', '/synthetic-login')
+ monkeypatch.setenv('OPENAI_API_KEY', 'synthetic-provider-secret')
+ if custom:
+ monkeypatch.setenv('OPENAI_BASE_URL', 'http://127.0.0.1:9876/v1')
+ else:
+ monkeypatch.delenv('OPENAI_BASE_URL', raising=False)
+ (tmp_path / 'fixture.json').write_text('{}')
+ monkeypatch.setattr(run, 'source_pins', lambda *a: ('fixture', 'fixture'))
+ monkeypatch.setattr(run, 'load_benchmark', lambda *a: None)
+ monkeypatch.setattr(run, 'make_task_spec', lambda *a: SimpleNamespace(
+ cwd='/task', work_image_key='work', judge_image_key='judge', internet=False,
+ game_mode=False, submit_paths=['solver.py'], work=SimpleNamespace(agent_query='fixture task'),
+ judge=SimpleNamespace(selection='score_first', score_direction='maximize')))
+ def backend(**kwargs):
+ assert kwargs['oauth_proxy'] is custom
+ return SimpleNamespace(image_exists=lambda image: True)
+ monkeypatch.setattr(run, 'RecordingDockerBackend', backend)
+ def stop_before_solver(**kwargs):
+ config = kwargs['config']
+ assert config.agent_api_base_url == ('http://127.0.0.1:9876/v1' if custom else None)
+ assert config.agent_api_key == ('synthetic-provider-secret' if custom else None)
+ raise RuntimeError('synthetic launch boundary')
+ monkeypatch.setattr(run, 'run_agent', stop_before_solver)
+ with pytest.raises(RuntimeError, match='synthetic launch boundary'):
+ run.main(['--task', 'fixture', '--tasks-dir', str(tmp_path), '--log-dir', str(tmp_path),
+ '--run-id', 'route', '--worker', 'official', '--model', 'fixture',
+ '--effort', 'xhigh', '--judge-url', 'http://127.0.0.1:9999'])
+ receipt = (tmp_path / 'runs/route/fixture/runtime-receipt.json').read_text()
+ assert 'synthetic-provider-secret' not in receipt
diff --git a/benchmark/tests/test_sforge_warm_start.py b/benchmark/tests/test_sforge_warm_start.py
index c4ceaafd94..cefb46dad7 100644
--- a/benchmark/tests/test_sforge_warm_start.py
+++ b/benchmark/tests/test_sforge_warm_start.py
@@ -237,3 +237,166 @@ def at_source_check(*args):
with pytest.raises(FileExistsError):
entry.main(argv)
assert not native_run.exists()
+
+
+@pytest.fixture
+def research_point(point, tmp_path, monkeypatch, request):
+ start, workspace = point
+ research = tmp_path / "retained-experiments"
+ if getattr(request, "param", "directory") == "file":
+ research.write_text("retained observations")
+ research.chmod(0o600)
+ else:
+ research.mkdir()
+ (research / "candidate.py").write_text("def predict(x): return x * 7\n")
+ (research / "observations.json").write_text('[2, 3, 5]')
+ (research / "candidate.py").chmod(0o600)
+ manifest = json.loads((start.root / "checkpoint.json").read_bytes())
+ components = manifest["components"] + [{"id": "experiments", "source_path": str(research)}]
+ metadata = manifest["point_metadata"]
+ metadata["worker_file_snapshot"]["components"].append(components[-1])
+ probe = effect_runtime_result("runtime.checkpoint_artifacts", {"action": "probe", "components": components})
+ unit = tmp_path / "research-point"
+ result = effect_runtime_result("runtime.checkpoint_artifacts", {"action": "capture", "components": components,
+ "point_metadata": metadata, "expected_source_sha256": probe["source_sha256"], "destination": str(unit)})
+ # Only the fixture source; the receiver must use sealed bytes.
+ if research.is_dir():
+ shutil.rmtree(research)
+ else:
+ research.unlink()
+ monkeypatch.setattr(warm, "_RESEARCH_ROOTS", (tmp_path,))
+ return replace(start, root=unit, sha256=result["sha256"], research_component_ids=("experiments",)), workspace, research
+
+
+def test_explicit_research_files_reproduce_the_retained_experiment(research_point):
+ start, workspace, research = research_point
+ assert start.verify(str(workspace))
+ warm.restore_workspace({"root": str(start.root), "sha256": start.sha256,
+ "research_component_ids": ["experiments"]}, workspace)
+ scope = {}
+ exec((research / "candidate.py").read_text(), scope)
+ assert [scope["predict"](x) for x in json.loads((research / "observations.json").read_text())] == [14, 21, 35]
+ assert (research / "candidate.py").stat().st_mode & 0o777 == 0o600
+ # The original unit is still valid and replay cannot overwrite receiver work.
+ effect_runtime_result("runtime.checkpoint_artifacts", {"action": "verify", "root": str(start.root),
+ "expected_sha256": start.sha256})
+ with pytest.raises(ValueError, match="already exists"):
+ warm.restore_workspace({"root": str(start.root), "sha256": start.sha256,
+ "research_component_ids": ["experiments"]}, workspace)
+
+
+def test_unselected_research_files_remain_inert(research_point):
+ start, workspace, research = research_point
+ warm.restore_workspace({"root": str(start.root), "sha256": start.sha256}, workspace)
+ assert not research.exists()
+
+
+@pytest.mark.parametrize("selected", [("absent",), ("workspace",), ("context",),
+ ("experiments", "experiments"), (None,), "experiments"])
+def test_invalid_research_selection_rejects_before_workspace_changes(research_point, selected):
+ start, workspace, research = research_point
+ with pytest.raises(ValueError):
+ replace(start, research_component_ids=selected).verify(str(workspace))
+ with pytest.raises(ValueError):
+ warm.restore_workspace({"root": str(start.root), "sha256": start.sha256,
+ "research_component_ids": selected}, workspace)
+ assert (workspace / "candidate").read_text() == "retained candidate"
+ assert not workspace.with_name("workspace.before-checkpoint").exists() and not research.exists()
+
+
+@pytest.mark.parametrize("collision", ["file", "directory", "symlink", "missing-parent", "parent-symlink"])
+def test_research_destination_conflicts_preserve_receiver(research_point, collision, tmp_path):
+ start, workspace, research = research_point
+ if collision == "file":
+ research.write_text("unrelated")
+ elif collision == "directory":
+ research.mkdir()
+ elif collision == "symlink":
+ research.symlink_to(tmp_path / "missing")
+ else:
+ # Exercise destination validation directly: the sealed source path must
+ # never be remapped just to make a receiver pass.
+ parent = tmp_path / "new-parent"
+ if collision == "parent-symlink":
+ parent.symlink_to(tmp_path, target_is_directory=True)
+ with pytest.raises(ValueError, match="physical parent"):
+ warm._check_research_destinations([("experiments", start.root, parent / "new-file")])
+ return
+ with pytest.raises(ValueError, match="already exists"):
+ warm.restore_workspace({"root": str(start.root), "sha256": start.sha256,
+ "research_component_ids": ["experiments"]}, workspace)
+ assert not workspace.with_name("workspace.before-checkpoint").exists()
+ if collision == "file":
+ assert research.read_text() == "unrelated"
+
+
+def test_research_data_cannot_target_provider_control_or_unqualified_roots(research_point, monkeypatch):
+ start, workspace, _ = research_point
+ metadata = json.loads((start.root / "checkpoint.json").read_bytes())["point_metadata"]
+ monkeypatch.setattr(warm, "_RESEARCH_ROOTS", (warm.Path("/tmp"), warm.Path("/home/workspace")))
+ entry = metadata["worker_file_snapshot"]["components"][-1]
+ for path in ("/", "/tmp", "/home/workspace", "/etc/config", warm._CONTROL + "/registry.json",
+ warm._CODEX_HOME, "/logs/agent", "/tmp/a/../b"):
+ entry["source_path"] = path
+ with pytest.raises(ValueError, match="separate data"):
+ warm._research_components(start.root, metadata, workspace, ("experiments",))
+
+
+def test_research_copy_mismatch_is_an_unusable_attempt(research_point, monkeypatch):
+ start, workspace, research = research_point
+ copy = shutil.copytree
+ def corrupt(source, destination, *args, **kwargs):
+ result = copy(source, destination, *args, **kwargs)
+ if destination == research:
+ (research / "observations.json").write_text('[999]')
+ return result
+ monkeypatch.setattr(shutil, "copytree", corrupt)
+ with pytest.raises(ValueError, match="differ from the sealed"):
+ warm.restore_workspace({"root": str(start.root), "sha256": start.sha256,
+ "research_component_ids": ["experiments"]}, workspace)
+ assert workspace.with_name("workspace.before-checkpoint").is_dir()
+
+
+def test_research_selection_survives_staging(research_point, monkeypatch, tmp_path):
+ start, workspace, _ = research_point
+ control = tmp_path / "control"
+ control.mkdir()
+ monkeypatch.setattr(warm, "_CONTROL", str(control))
+ class Environment:
+ async def upload_dir(self, source, target):
+ shutil.copytree(source, target)
+ async def upload_file(self, source, target):
+ shutil.copyfile(source, target)
+ async def exec(self, command, **kwargs):
+ return SimpleNamespace(return_code=0)
+ asyncio.run(start.stage(Environment(), str(workspace)))
+ declaration = json.loads((control / "warm-start.json").read_bytes())
+ assert declaration["research_component_ids"] == ["experiments"]
+
+
+def test_research_option_without_warm_entry_rejects_before_registration(tmp_path):
+ pytest.importorskip("harbor")
+ from benchmark.edgebench import run as entry
+ with pytest.raises(SystemExit) as rejected:
+ entry.main(["--task", "fixture", "--tasks-dir", str(tmp_path), "--log-dir", str(tmp_path),
+ "--run-id", "unadmitted", "--worker", "heartbeat-explore", "--model", "fixture",
+ "--effort", "high", "--judge-url", "http://localhost:1",
+ "--restore-research-component", "experiments"])
+ assert rejected.value.code == 2 and not list(tmp_path.iterdir())
+
+
+@pytest.mark.parametrize("research_point", ["file"], indirect=True)
+def test_standalone_research_file_keeps_bytes_and_mode(research_point):
+ start, workspace, research = research_point
+ warm.restore_workspace({"root": str(start.root), "sha256": start.sha256,
+ "research_component_ids": ["experiments"]}, workspace)
+ assert research.read_text() == "retained observations"
+ assert research.stat().st_mode & 0o777 == 0o600
+
+
+def test_workspace_backup_cannot_be_a_research_destination(research_point):
+ start, workspace, _ = research_point
+ metadata = json.loads((start.root / "checkpoint.json").read_bytes())["point_metadata"]
+ metadata["worker_file_snapshot"]["components"][-1]["source_path"] = str(workspace.with_name("workspace.before-checkpoint"))
+ with pytest.raises(ValueError, match="separate data"):
+ warm._research_components(start.root, metadata, workspace, ("experiments",))
diff --git a/docs/architecture/rfcs/complete-state-recovery-v0.md b/docs/architecture/rfcs/complete-state-recovery-v0.md
index 0cb9cab292..066dcaad8e 100644
--- a/docs/architecture/rfcs/complete-state-recovery-v0.md
+++ b/docs/architecture/rfcs/complete-state-recovery-v0.md
@@ -778,7 +778,7 @@ Those measurements are historical evidence, not measurements of a later merge.
| Existing transport limits: 64 MiB private snapshot only for the two manifest checks; normal RPC and worker input 2 MiB; owner stdout 2 Mi characters; saved-audit input 16 MiB | Bound exchanges and historical report decoding without widening ordinary transport | Large-manifest transport and audit-integrity tests cover the path. These independent limits are not additive capacity or proof that every maximum-size audit is publishable. |
| Free destination space at least copied regular-file bytes plus 16 MiB before copying | Refuse a known-insufficient destination before publication | Source check plus failure-cleanup tests; concurrent disk consumption and a successful worst-case space reservation are not guaranteed. |
-Source owners: [`state_recovery.py`](../../../loopx/state_recovery.py),
+Source owners: [`state_recovery_io.py`](../../../loopx/control_plane/state_recovery_io.py),
[`state_recovery.ts`](../../../loopx/control_plane/state_recovery.ts), and
[`state_recovery_worker.ts`](../../../loopx/control_plane/state_recovery_worker.ts).
Existing [Python recovery cases](../../../tests/test_state_recovery.py) and
diff --git a/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md b/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md
index 7e2c5382fa..224b3bab4f 100644
--- a/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md
+++ b/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md
@@ -668,7 +668,7 @@ provider、捕获一致性、身份接纳或 M2/M3 重新启用。同条评论
| 既有 transport:仅两项清单检查用 64 MiB private snapshot;普通 RPC 和 worker 输入 2 MiB;owner stdout 2 Mi 字符;历史 audit 输入 16 MiB | 限制传输和历史报告解码,不扩大普通 transport | 大清单 transport 与 audit 完整性测试覆盖路径。独立上限不能相加,也不保证任意最大 audit 均可发布。 |
| 复制前目标剩余空间至少为普通文件复制字节数加 16 MiB | 发布前拒绝已知空间不足 | 有源码检查和失败清理测试;不保证抵御并发磁盘消耗,也不证明最坏空间预留成功。 |
-源码 owner 为 [`state_recovery.py`](../../../loopx/state_recovery.py)、
+源码 owner 为 [`state_recovery_io.py`](../../../loopx/control_plane/state_recovery_io.py)、
[`state_recovery.ts`](../../../loopx/control_plane/state_recovery.ts) 和
[`state_recovery_worker.ts`](../../../loopx/control_plane/state_recovery_worker.ts)。
既有 [Python 恢复用例](../../../tests/test_state_recovery.py)及
diff --git a/docs/architecture/rfcs/desktop-execution-frontends-v0.md b/docs/architecture/rfcs/desktop-execution-frontends-v0.md
index 0aab6384d4..11bd960e17 100644
--- a/docs/architecture/rfcs/desktop-execution-frontends-v0.md
+++ b/docs/architecture/rfcs/desktop-execution-frontends-v0.md
@@ -1022,6 +1022,39 @@ authoritative or require the document provider to become a task database.
This is the short path for adopting Desktop around work that is already
running.
+Bounded provider checkpoint: the local Goal Chat can inspect a registered
+Codex App Agent's original saved conversation and offer an idle-only plain-text
+reply through the existing Desktop owner. It keeps bounded source windows and
+one-second saved-record observations instead of resuming another app-server.
+The provider view includes visible messages, function/custom-tool input and
+results, and explicit turn lifecycle records. Long text can be continued in
+place; backward/forward paging and append cursors preserve history across
+bursts. Bounded empty-window catch-up avoids repeated clicks through metadata;
+folded tool rows decode native text blocks and expose readable shell output while
+retaining source fields. Reading old records pauses insertion, with a path back to latest.
+Hidden reasoning/configuration are excluded and credential-shaped fields are
+withheld. Unsupported, malformed, non-text and over-limit source records remain
+visible coverage gaps. The target is a stable working conversation for every
+Goal/Agent. The Goal directory now opens a stable Agent route, preserving reload
+and Back/Forward and showing unbound or unsupported hosts explicitly. The
+ordinary Goal composer/executor is absent in this view. This first provider does
+not establish other-provider coverage or complete native activity/stream parity.
+See the [provider boundary and operation](../../integrations/session-runtime-control-plane-adapter.md#experimental-owner-local-codex-app-conversations).
+This is an experimental provider proposal, not full Slice B acceptance:
+token deltas, busy-turn steering/queueing, approval callbacks, native live-send
+qualification, Windows transport and installed adoption remain separate gaps.
+Its bounded user task is following up with a specific Agent while reviewing
+Goal work. Confirmed input leaves the composer ready without a redundant
+acknowledgement. Interrupted responses recover from existing receipts without
+resubmitting; genuinely uncertain input still requires recovery before another send.
+The overview's owner entry opens that exact Agent with one navigation action,
+without repeating Agent selection; an absent binding stays unavailable. The
+stable page reuses the existing native provider and authority. This reduces the
+local overview path; evaluate the remaining working-session acceptance
+integration against selecting the original conversation in Codex App, including
+target discovery, correction and result readback; transport success alone does
+not establish a shorter task path, independent acceptance or G1 completion.
+
### Slice C: managed reference vertical
1. a provider-neutral managed-runtime and managed-provider interface;
diff --git a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md
index 4e4a2ba300..da9fcfaff9 100644
--- a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md
+++ b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md
@@ -619,6 +619,16 @@ with invented evidence. The field never qualifies execution, scientific novelty,
utility, effective-Turn counts or acceptance; these remain independent checks.
No new scheduler, selection rule, quota debit or per-result settlement is added.
+The history reader covers both legacy Todo-bound decisions and autonomous
+replan decisions. For an autonomous decision, the recorded Goal, Agent, Turn,
+obligation, settlement identity and accepted acknowledgement must agree; the
+derived decision reference includes that obligation instead of inventing a
+Todo. The existing settlement owner defines the binding, while the Python
+codec only transports its facts. Enabled reviews therefore receive the prior
+decision through the same `previous_review` requirement. Feature-off context
+and legacy decision references remain unchanged. This repairs feedback
+delivery; execution quality and goal benefit still require separate evidence.
+
#### An iterative strategy and engineering boundary
The first strategy is **model-guided, evidence-conditioned work planning**.
@@ -932,8 +942,19 @@ checks the session's actual workspace and current source binding/profile, and re
probe. A later source Turn in the read-to-probe gap is rejected; the artifact
owner's witness checks cover subsequent writes. It seals rollout/index/paged
stores and verifies native forks at fixed guest paths. These source-basis checks
-are qualified separately from research utility. An explicit source-checkout
-controller now prepares a verified fork through the existing registry/session
+are qualified separately from research utility.
+
+Both completed-wake and interrupted-replan benchmark capture reuse the existing
+native inspection process across the two full reads and keep it open through
+artifact sealing or controller handoff. Native shutdown can checkpoint SQLite;
+closing it between the byte probe and sealing can invalidate the source witness.
+This changes inspection lifetime only: history pagination, cursor/profile
+freshness, byte witnesses, optional activation and the original capture/phase
+deadlines remain unchanged. Reduced preflight cost does not establish sustained
+capture reliability for growing histories or qualify a later receiver point.
+
+An explicit source-checkout controller now prepares a verified fork through the
+existing registry/session
owners, with distinct registered Goal/Agent identities and a matching captured
session profile. The registry fence guards configuration through the binding write;
existing lifetime semantics remain. The lifecycle-only source-session profile
@@ -996,7 +1017,13 @@ The automatic supervisor keeps the prepared deadline for the live-source
barrier and native capture. After successful source release it seals only its
private exported files under the captured original Host phase deadline, which
also bounds typed artifact publication. Later source work cannot enter that
-point. This separates source hold time from final sealing; it grants no trial
+point. The worker reply wait and controller publication share a completion
+deadline of the original capture deadline plus at most 30 seconds, capped by
+that phase. This closes the mismatch where the controller sealed successfully
+after release but the worker already reported capture failure. It also bounds
+an absent controller or stalled sealing without waiting out the whole trial.
+The original 30-second preparation/live-capture deadline is unchanged; the
+extra allowance never admits a late source/native capture. This grants no trial
time, cooldown or opportunities. Direct callers retain their total timeout.
Warm continuation preserves its captured remaining phase by default. An
@@ -1024,6 +1051,18 @@ call inward and add evaluator/budget participants. Capture and adoption are
separate explicit operations. This CLI/Host stage does not complete the packaged
frontend or disaster-replacement acceptance.
+File coverage and knowledge adoption are separate acceptance dimensions. An
+integrity-verified archive proves its declared roots, not the availability of
+every candidate, data file or report mentioned in native history. The SForge
+receiver now offers explicit selection of captured research components outside
+the workspace, with fresh data destinations and post-copy byte/mode verification.
+Source capture still requires explicit roots; neither path inference nor later
+salvage completes an earlier point. Validate a retained experiment requiring
+those files before claiming usable research-state continuity. Existing points,
+active runs, default workspace-only restoration, budgets and feedback are
+unchanged. This closes a provider transport omission; automatic dependency
+coverage and the generic frontend remain open.
+
Retaining historical research files alone does not deliver them to a current
typed review. Explicit warm-context adoption now seeds the fresh Goal's Explore
knowledge log from the verified source, preserving scoped findings, counterevidence
diff --git a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md
index 946e187dd1..c2fb85859e 100644
--- a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md
+++ b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md
@@ -532,6 +532,13 @@ replan 复查这个向量,不成为新的权限 owner。在授权目标内改
替代证据。此字段不资格化执行、科学创新、收益、有效 Turn 计数或验收,后者仍
独立验证;不增加 scheduler、选择规则、quota 消耗或逐结果结算。
+历史读取同时覆盖旧 Todo 绑定决策和自主 replan 决策。自主决策的 Goal、Agent、
+Turn、义务、settlement identity 与已接受的回执必须一致;派生决策引用包含该
+义务,不虚构 Todo。绑定由现有 settlement owner 定义,Python codec 只传输
+事实。启用策略后,上一决策进入同一个 `previous_review` 要求;关闭策略的
+context 和历史决策引用保持兼容。这里修复的是反馈送达,执行质量和目标收益
+仍需独立证据。
+
#### 可迭代策略与工程边界
第一版策略是**模型依据证据规划完整工作**:显式选择 grow(扩大有支持的路线)、
@@ -755,7 +762,14 @@ quota guard,在模型调用前捕获 wake 开始已投影的准入 replan,
Codex 读取完整历史、核对会话真实工作区及当前 source binding/profile,并在字节 probe 后复查。读取
到 probe 之间出现的后续 source Turn 会拒绝;之后的写入由产物 witness 校验
拦截。它封存 rollout/索引/分页库并验证固定 guest 路径下的原生分叉;来源基准
-校验不等于研究收益。显式 source-checkout controller 现可通过既有 registry/
+校验不等于研究收益。已结算 wake 与中途 replan 的 benchmark 捕获共用既有
+native 读取进程,执行两次完整读取,并保持到产物封存或 controller handoff
+完成;native 退出可能 checkpoint SQLite,不能在字节 probe 与封存之间退出,
+使自身校验基准失效。这只调整读取进程的生命周期,保持分页历史、cursor/profile
+新鲜度、字节 witness、显式开启及原捕获/phase deadline。预检成本下降尚不证明
+增长历史下的持续捕获可靠性,也不等于后继点 receiver 验收。
+
+显式 source-checkout controller 现可通过既有 registry/
session owner 准备已验证 fork,要求不同且已注册的 Goal/Agent 身份与匹配的
捕获 session profile。Registry fence 保护配置复查到绑定写入,保持原有 profile
的 lifetime 语义;lifecycle-only source-session profile 不能运行 governed 工作,
@@ -804,7 +818,10 @@ binding 已提交但控制器响应丢失时,重复原命令会核对原凭据
自动 supervisor 的准备截止时间约束源屏障和原生捕获。成功恢复源运行后,仅对
controller 私有导出副本继续封存,沿用捕获的原 Host phase 截止时间并传给类型化
-产物发布 owner。源后续工作不会进入该点;不会补发预算、冷却或机会。直接调用
+产物发布 owner。worker 回执等待与 controller 发布共用完成期限:原捕获截止时间
+之后最多再给 30 秒,并受原 phase 限制。这样既接得住释放后成功封存的回执,也
+不会因 controller 缺失或封存停滞而等完整段试验。原 30 秒准备/源捕获窗口不变,
+这段额外时间不能接纳迟到的源/原生捕获。源后续工作不会进入该点;不会补发预算、冷却或机会。直接调用
仍默认保留整项 timeout。该调整不证明任意规模的捕获时限或完整科学接续收益。
Warm 接续默认保留捕获的剩余 phase。显式获得 operator 授权的追加时长属于新的
@@ -831,6 +848,14 @@ worker 后再发布惰性联合产物。真实持续写入容器与 native HTTP
provider;benchmark adapter 向内调用,并添加 evaluator/budget 参与者。捕获与
采用是分别显式授权的操作。此 CLI/Host 阶段不代表打包前端或灾难替换验收完成。
+文件覆盖与知识采用是两个独立验收维度。通过完整性校验的归档只能证明其声明根,
+不能证明原生历史提及的每个候选、数据文件和报告都可用。SForge 接收端现支持显式
+选择已捕获的工作区外研究组件,要求全新数据目标,并在复制后校验字节与模式。
+源捕获仍需声明根;路径推断或后续抢救都不能补全旧时点。应在接收端执行依赖这些
+文件的保留实验,再声称研究状态可用地接续。旧点、活跃运行、默认只恢复工作区、
+预算及反馈保持不变。本阶段补齐 provider 文件传输遗漏;自动依赖覆盖与通用前端
+仍未完成。
+
仅保存历史研究文件不等价于交付给当前类型化复盘。显式 warm 上下文采用现将
已验证来源的 Explore 知识写入新 Goal 的普通知识日志,保留 findings 的适用
范围、反证、拓扑及 checkpoint/event 来源。既有日志 owner 约束原序前缀恢复,
diff --git a/docs/architecture/rfcs/live-team-workspace-v0.md b/docs/architecture/rfcs/live-team-workspace-v0.md
index 5d65447e80..e2289ac128 100644
--- a/docs/architecture/rfcs/live-team-workspace-v0.md
+++ b/docs/architecture/rfcs/live-team-workspace-v0.md
@@ -401,6 +401,33 @@ Implementation checkpoint ([#5587](https://github.com/loopx-project/loopx/pull/5
This remains a presentation/readback slice. `responds_to` does not certify an objection. Current native acceptance validates the canonical task and configured validators, but its readback does not expose an independent verifier identity and exact-version receipt. The revision therefore shows that missing evidence explicitly, even beside a valid requester adoption. The evidence reader separately exposes the current validation source, definition digest and check/file-pin counts from the existing typed validation plan. CLI and HTTP readback carry the same path-free observation after the original checks pass; failed validation or changed pins withdraw it with the report. This rule identity is not a persisted success or independent-verifier receipt. The frontend must consume such evidence from the existing acceptance owner when available; it cannot infer it from the reviewer name, relationship or artifact hash.
+The acceptance owner also supports an explicit optional owner-pinned oracle:
+the trusted local host executes its pinned source against copies of declared
+inputs/outputs and records their exact versions. Current Team evidence consumes
+only matching source/contract/work and output observations; stale or absent
+receipts remain unavailable. Existing Goal settings and acceptance details offer
+real save/check/readback/disable operations. This source identity is an
+owner-approved checker, not an authenticated remote verifier; snapshots do not
+attest the interpreter or unpinned imports. Ordinary validators retain their
+previous behavior. This closes the bounded configured-source/version producer
+and local operator-journey gap; it does not qualify independent authorship,
+semantic resolution, the real correction/adoption episode or full L1. See the
+[operation and rollback guide](../../reference/goal-acceptance-observations.md#owner-pinned-oracle-and-exact-versions).
+
+The proposed local journey now lets a registered Agent prepare that exact check
+plan through the existing typed-action store. Preparation changes neither the
+contract nor the outputs. The owner sees its objective, coverage, outputs and
+local command effect beside the delivery, then adopts and checks in one action;
+JSON import and revision diagnostics remain optional. This is one additional
+owner decision after preparation, not proof that every original request takes
+one interaction. Proposal replay and lost responses use existing native receipts;
+an uncertain execution never automatically reruns commands. Fresh readback
+withdraws historical success when source/output evidence changes, and an applied
+new plan does not revive an older draft. File/SQLite execution, isolated
+PostgreSQL acceptance ownership and packaged SQLite/HTTP/CLI recovery validate
+this author proposal. Scripted Agent preparation does not qualify live model
+preparation, installed App adoption, independent authorship or full L1.
+
The current host-check observation additionally names its UTC completion time and the declared output versions that remained unchanged before and after those checks. A changed output withdraws even the first return; the evidence detail displays matching records and leaves older or mismatched records unavailable. Stability reuses the acceptance owner's task work digest plus current claim/lifecycle and selected rules/pins/workspace: unrelated canonical commits and observation metadata do not revoke a stable check, while changed work, authorization or validators still reject it. This replaces whole-Goal revision comparison only on this read path; mutation CAS remains intact. The delegation TS owner and read-only Chat/CLI projection add no verifier configuration or independent semantic verdict, and do not close Section 9's independent exact-version acceptance gap.
Packaged desktop, 390px, keyboard/reduced-motion and pagination checks cover these returns and recovery. An isolated production SQLite/HTTP/CLI fixture also rejects changed output, withdraws its list acceptance and restores the original version without launching additional work. [Public-safe fixture views](../../reference/local-delegation.md#inspect-accepted-evidence-and-return) make these states reviewable. These checks do not establish a real objection, independent semantic acceptance, installed native behavior or L1 completion. The existing real correction episode must still be exercised through the packaged UI and independently read back with Section 9's missing/lost-observation cases.
diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md
index a505028a8b..33fa43a7a1 100644
--- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md
+++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md
@@ -1714,6 +1714,14 @@ only the reduction call. Python translates the result into the legacy CLI shape.
It does not call a series of TypeScript leaf helpers or retain parallel enums
and reducers.
+Explicit `turn run-once --resume-turn-key` now loads the original journal plan
+before fresh selection. It retains actor/Goal-lifetime admission, the existing
+TypeScript recovery decision and execution path, and current checks for any
+remaining effects. It no longer constructs and discards a fresh decision,
+TurnEnvelope or Turn-start context/sync/recall sequence. New Turns keep their
+shared fresh decision owner. This removes redundant Python composition from
+recovery; it does not retire the Python CLI or complete the remaining cutovers.
+
The same PR must delete the Python semantic path it replaces. A new TypeScript
module is not migration progress by itself: the payoff is fewer semantic
owners, fewer cross-runtime round trips, and a facade with a credible deletion
diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md
index a4f97c2a68..11728e3657 100644
--- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md
+++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md
@@ -1156,6 +1156,12 @@ TypeScript 完成最终 reduction 并返回 typed result。没有待执行 provi
只需一次 reduction。Python 只把结果投影为旧 CLI shape,不再串行调用一组
TypeScript leaf helper,也不保留平行的 enum 和 reducer。
+显式 `turn run-once --resume-turn-key` 现在先加载原 journal plan,再进入原恢复路径,
+保留 actor/Goal 生命周期准入、既有 TypeScript 恢复判定,以及剩余效果的当前校验。
+它不再构造并丢弃 fresh decision、TurnEnvelope 或新一轮 Turn-start 的上下文、同步与
+recall。新 Turn 仍使用共享的 fresh decision owner。本批删除恢复中的冗余 Python
+编排,不代表 Python CLI 已退役,也不代表其余 cutover 已完成。
+
同一 PR 必须删除被它替代的 Python 语义路径。仅新增 TypeScript module 不等于取得
迁移进展;真正的兑现是语义 owner 更少、跨 runtime round trip 更少,并且 facade
有可信的删除条件。
diff --git a/docs/assets/personal-workspace/first-goal-home-desktop.png b/docs/assets/personal-workspace/first-goal-home-desktop.png
new file mode 100644
index 0000000000..8a159bffc9
Binary files /dev/null and b/docs/assets/personal-workspace/first-goal-home-desktop.png differ
diff --git a/docs/development/design.md b/docs/development/design.md
index 02214919a9..de6a453625 100644
--- a/docs/development/design.md
+++ b/docs/development/design.md
@@ -48,6 +48,23 @@ Judge the whole viewport, including information repeated across components.
Before implementation, inventory the visible elements and their value. Keep
conversation, results, and decisions prominent; consolidate routine activity;
offer one-step access to relevant controls and details without a chain of pages.
+Start from the owner's original task and minimize the whole job they must do.
+Agent preparation should absorb discovery, identifiers, formatting and execution
+detail within existing authority. The owner supplies missing intent and real
+decisions. Compare a smaller lawful route before justifying individual controls:
+a sequence of locally useful fields can still assign the wrong work to the user.
+Reuse prior scoped authorization; explain any remaining command effect at its
+decision point. Advanced configuration must remain optional for an ordinary task
+when an Agent can prepare the same reviewable plan.
+Count mandatory interaction rounds from the original intent to a usable result,
+including preparation outside the visible page. One round is one user statement,
+choice or confirmation required to continue; merging buttons does not merge
+decisions. Most ordinary capabilities should complete in one round. With intent
+and scoped authorization already present, aim for zero additional rounds.
+Two or more rounds need a concrete missing fact, consequential choice or authority
+boundary for each extra round. High value may justify deeper collaboration, but
+does not itself justify mandatory friction. Keep voluntary exploration separate,
+and record navigation, copying, waiting and recovery effort alongside round count.
Do not collapse failures, missing authority, uncertain observations, or actions
requiring judgment into a reassuring success summary. Read models select facts;
they must not invent execution, acceptance, or completion from prose or counts.
diff --git a/docs/development/frontend-delivery.md b/docs/development/frontend-delivery.md
index c15c03f92d..1ec466480a 100644
--- a/docs/development/frontend-delivery.md
+++ b/docs/development/frontend-delivery.md
@@ -32,11 +32,25 @@ frontend-only decisions or a setting for a capability with no persistent policy.
Carry the same task through these steps:
1. **Compare the journey.** Write the current and proposed user steps in the
- existing before/after evidence. Count required navigation, information the
- user must repeat, questions, confirmations and recovery effort. Explain the
- information, authority or unavoidable prerequisite supplied by each required
- step; remove a step that supplies none. This is a comparison, not a universal
- click limit. Keep necessary scoped consent and explicit effect confirmation;
+ existing before/after evidence, anchored to the original request rather than
+ a configuration task invented from the proposed implementation. Compare
+ no change and a smaller lawful route, including preparation the Agent can
+ already perform. Separate missing intent or owner-only authorization from
+ lookup, identifiers, formatting and other preparation. Count total navigation,
+ repeated information, questions, confirmations and recovery effort. Justifying
+ each control individually cannot establish that this whole human workflow is
+ necessary. Default to one required interaction from the original intent to a usable
+ result, or zero additional interactions when the intent and authority already
+ exist. Account for every required statement, choice or confirmation, including
+ preparation outside the UI; one final button does not make a multi-round task
+ one-round. Justify each additional round by a missing fact, consequential
+ choice or authority boundary, not feature value alone. Record navigation,
+ copying, waiting and recovery separately; voluntary refinement does not raise
+ the ordinary-path budget. These are design defaults, not a click-count gate.
+ Remove work the Agent can prepare under existing authority and
+ reuse prior scoped authorization. This is a comparison, not a universal click
+ limit or a ban on legitimate technical configuration. Keep necessary scoped
+ consent and explicit effect confirmation;
make optional refinement optional. A complete conversational draft should not
require a second form just to repeat the same facts.
2. **Choose the owner and composition.** Reuse the existing typed state and
@@ -61,7 +75,9 @@ Carry the same task through these steps:
browser, installed App and real model each prove different things.
5. **Review the task before the diff narrative.** The review capability's
`problem_context.outcome_impact.user_experience` references the same journey
- and validation, then asks what remains wrong even if every test passes.
+ and validation. Independently check that the author's task matches the original
+ request and challenge the full human workload against the smaller lawful
+ route, then ask what remains wrong even if every test passes.
Reused evidence needs its revision and unchanged assumptions. A material
unsupported claim remains unproven. A necessary confirmation can be accepted
with its authority basis; green CI cannot justify redundant confirmation.
diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md
index 279516e9e6..c51dd851e9 100644
--- a/docs/development/testing-and-quality.md
+++ b/docs/development/testing-and-quality.md
@@ -122,7 +122,7 @@ shell;不能因为过滤了 `CI` 就恢复采集。不能按系统、安装渠
`python-tests.yml` publishes `merge-gate` for every open pull request. Code,
workflow, policy and unknown paths require the existing `pytest` aggregate
-(including TypeScript checks and all four Python shards), Stage 2C correctness
+(including TypeScript checks and all six Python shards), Stage 2C correctness
aggregate, and Windows tests to succeed. Failed, cancelled, missing or
unexpectedly skipped results cannot pass the gate. The client-only exception
below retains common checks and substitutes packaged Dashboard qualification
@@ -223,8 +223,8 @@ The [job exemption policy](ci-impact-selection.md) additionally permits pure
Dashboard-client changes to skip backend Python/Windows and Stage2c, while
requiring frontend type-check/build, scoped coverage and real packaged Dashboard
browser smoke. Backend-only TS/lint/minimum-Node jobs are also exempt.
-Mixed, prompt, dependency and unknown changes stay full. Full Python runs four
-complete shards and combines all four coverage files. The `ci:full` label forces
+Mixed, prompt, dependency and unknown changes stay full. Full Python runs six
+complete shards and combines all six coverage files. The `ci:full` label forces
full qualification; main stays full. No selected-only report impersonates full
coverage. 新的前端豁免由目标分支已审阅的策略控制;CI 自身变更仍全量验证。
@@ -426,6 +426,17 @@ Effect 服务先排空已接纳的 handler,再退出。启用编译缓存时
继续普通清理。一秒额度不涵盖定位文件锁、存储等待或操作系统调度;故障注入不能
代替原生平台资格验证,V8 CPU 样本也不测量内核 IO。
+Windows mutation-lock acquisition retries `EPERM` from exclusive creation only
+after observing contention, within the existing lock timeout. This covers a
+[pending-delete pathname](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-deletefilew#remarks).
+Initial permission denial, errors after creation and other platforms retain
+their failure behavior; persistent denial preserves its original error.
+Injected filesystem failures complement actual Windows execution, not replace it.
+
+Windows 文件锁只在已观察到争用后,对独占创建返回的 `EPERM` 使用原锁等待额度重试。
+初次权限拒绝、创建后的错误和其它平台仍直接失败;持续拒绝保留原错误。
+故障注入验证重试边界,原生 Windows 运行仍须单独验收。
+
Smoke cleanup owns every valid runtime locator in its newly created private
temporary namespace, including runtimes loaded by temporary base checkouts.
It uses the existing authenticated shutdown protocol, then observes actual
@@ -699,7 +710,7 @@ An explicitly loaded CI observer also streams pytest-owned phase durations from
the xdist controller. Read the downloaded log with
`node scripts/ci/pytest_timing.ts JOB_LOG`; incomplete evidence remains incomplete
and does not replace JUnit, coverage or the required gate. See the
-[timing observation boundary](ci-impact-selection.md#four-complete-python-shards).
+[timing observation boundary](ci-impact-selection.md#six-complete-python-shards).
Full Python shards also opt into `--ci-stall-after 120` on the existing pytest
observer: a long-running test attempts to print its executing thread's Python stack without
terminating it or changing its result. This cooperative diagnostic needs the
@@ -730,7 +741,7 @@ Linux 全套测试分到六台 hosted runner,每台保留两个 xdist worker
CI 同时在 xdist controller 流式记录 pytest 原生 phase 耗时。下载日志后用
`node scripts/ci/pytest_timing.ts JOB_LOG` 回读;不完整证据保持不完整,不能替代
JUnit、coverage 或必过门禁。归属与测量边界见
-[分片耗时记录](ci-impact-selection.md#four-complete-python-shards)。
+[分片耗时记录](ci-impact-selection.md#six-complete-python-shards)。
全量 Python 分片在既有 pytest observer 上显式启用 `--ci-stall-after 120`:测试
超过该时长就尝试输出其执行线程的 Python 栈,不终止测试或改变结果。此协作式诊断
需要解释器调度观察线程,无法诊断始终占有 GIL 的原生代码。栈用于诊断,不能只凭
diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md
index 311898c668..0ef3df1156 100644
--- a/docs/guides/getting-started.md
+++ b/docs/guides/getting-started.md
@@ -1055,9 +1055,12 @@ See [heartbeat automation prompt](../heartbeat-automation-prompt.md) and
## Dashboard
-Dashboard status is an experimental operator preview. The CLI and
-`loopx status` remain the canonical daily workflow; the React dashboard
-is useful for demos, public-safe fixtures, and local inspection.
+The Personal Workspace started by `loopx dashboard` is the supported
+browser/PWA entry for everyday use, as the README states: one local service
+serves the packaged UI, the status projection and Agent Chat. `loopx status`
+and the other CLI commands read the same state and remain the readback path
+for every claimed journey, for scripting and for remote hosts; neither surface
+is a preview of the other.
Start the installed Personal Workspace with one command:
diff --git a/docs/guides/personal-workspace-user-guide.md b/docs/guides/personal-workspace-user-guide.md
index 0fefa252cd..f0f95c9fbf 100644
--- a/docs/guides/personal-workspace-user-guide.md
+++ b/docs/guides/personal-workspace-user-guide.md
@@ -69,6 +69,23 @@ graph TD

+### 首次打开 / First open
+
+
+
+还没有任何 Goal 时,总览不显示空泳道或健康告警,而是一个「还没有 Goal」状态:一句话说明
+Goal 是什么、一个 **创建 Goal** 按钮(打开与侧边栏 `+` 相同的创建表单),以及三条示例
+请求。点选示例只会填入输入框,按发送前不会发出任何内容。状态来源自身的检查行(例如
+本机尚未建立注册表)折叠在「状态来源详情」里。只有状态来源真的不健康(例如注册表损坏)
+时才显示健康告警,并以可读句子描述问题,原始检查行同样折叠在「诊断信息」里。
+
+When no Goal exists yet, the overview shows a first-Goal state instead of empty lanes or a
+health banner: what a Goal is, a **Create a Goal** button (the same form as the sidebar `+`),
+and three example requests that only fill the composer. The status source's own check
+lines (such as "no registry yet on this machine") stay collapsed under "Status source
+details". A health banner appears only when the source is actually unhealthy, in plain
+sentences, with the raw check lines collapsed under "Diagnostics".
+
### 核心功能区
1. **「你不在的时候」离线概览**:
- 聚合展示你离开期间所有 Agent 的运行结果:`已完成` 数量、`异常/失败` 数量以及当前 **`等你确认`** 的阻塞项。
diff --git a/docs/integrations/ego-source-reader.md b/docs/integrations/ego-source-reader.md
index 9fa9c90c3a..b6df3a2524 100644
--- a/docs/integrations/ego-source-reader.md
+++ b/docs/integrations/ego-source-reader.md
@@ -118,8 +118,24 @@ The tool accepts an HTTPS URL, checks the configured origin policy before naviga
and uses WHATWG URL normalization for the target before checking the exact
resulting URL before DOM extraction. Equivalent dot segments and query escaping
do not count as redirects. The fixed operator-owned script supplies the canonical
-target; results retain both requested and observed URLs. Redirects and Page
-races fail closed. Input cannot select code, executables, Page labels or browser
+target; successful results retain both requested and observed URLs. Redirects and Page
+races still fail closed before source text, title, image metadata or pixels are read.
+For a same-origin mismatch, `source_url_changed` now includes `observed_url`,
+validated against the original request and canonical target. Cross-origin
+destinations are omitted, even in all-origin mode. Legacy error receipts without
+location metadata remain supported. No redirected content or automatic second
+read is returned. Check whether the observed location is the intended source
+before making a separate authorized read; a login or verification page is not
+the requested article. This diagnosis does not override an access denial or
+authorize a different browser, login, takeover or wider host permissions.
+
+`source_url_changed` proves that this call reached the browser's URL check;
+it is not a disconnected-bootstrap error. A separate model-shell bootstrap
+failure does not invalidate a working configured MCP reader. Keep those paths
+distinct, and do not infer that opening the app or enabling Full Access is
+required. Original host and site permission stops still apply.
+
+Input cannot select code, executables, Page labels or browser
commands. Calls within one process reject concurrent reads. Execution times out
after 30 seconds; returned text is limited to 100,000 characters. Errors omit
raw browser diagnostics; use local provider logs for diagnosis.
diff --git a/docs/integrations/session-runtime-control-plane-adapter.md b/docs/integrations/session-runtime-control-plane-adapter.md
index c2425c5e9c..9cb7419d5a 100644
--- a/docs/integrations/session-runtime-control-plane-adapter.md
+++ b/docs/integrations/session-runtime-control-plane-adapter.md
@@ -96,6 +96,127 @@ LoopX says a goal is still blocked, the projection must explain the
reconcile rule: missing gate, missing validation, failed outcome, stale
artifact, or human decision not yet recorded.
+## Experimental owner-local Codex App conversations
+
+This is a follow-up path for an owner already reviewing a Goal: read a specific
+Agent's result, give it a correction and keep the original conversation. It is
+not the general Goal entrypoint or a replacement for the native App. The useful
+product comparison is the complete task, including finding the right Agent,
+understanding its result and recovering an interrupted reply. An additional
+chat window or successful transport alone does not establish a better experience
+than working in Codex App. Goal progress and acceptance still come from their
+existing owners, never from the transcript or an input acknowledgement.
+
+In the Goal overview's **By owner** view, **Work conversation** opens that owner's
+stable working page directly. The owner is already selected; a
+missing binding is shown as unavailable and never selects another Agent instead.
+Read-only workspaces do not expose this control. The Goal **Chat** view also
+offers **Agent work conversations** with the complete registered Agent directory.
+The URL retains the selected Goal and `workAgent` across reload and browser
+Back/Forward. Goal or source changes clear the Agent route. Unbound and unsupported
+hosts remain visible with their specific limitation; discovery never silently
+substitutes a different Agent. The native provider still authorizes each read
+and reply independently. The page hides the ordinary Goal composer and executor
+selector so a reply has one explicit recipient.
+
+Supported entries read the original saved work records and send a plain-text reply
+when that conversation is observed idle. Leaving the Agent page stops observation;
+the original App keeps its executor, conversation, model and approval settings.
+There is no separate Agent process, automatic resume/fork or permission change.
+
+This is an experimental Desktop provider within the existing native Chat
+boundary, not a second generic session authority. Binding selection reuses
+`thread_agent_binding`; activity reuses the existing Codex host observer. The
+provider reads the uniquely matching local Codex home and bounded read-only
+SQLite/JSONL windows. The history view now includes user/assistant visible
+messages, function/custom-tool calls and results, and explicit turn start/end,
+interruption and error records. This intentionally extends the former text-only
+default. System/developer instructions, analysis/reasoning, token counters and
+duplicate message mirrors are excluded; authentication files are never read.
+The provider does not infer a successful task, a retry or current execution from
+a tool result or a recorded turn end. Current activity retains its own observer.
+
+Tool records use a single expandable row; long text continues in the same view
+with explicit content reads. Native text-only output blocks are decoded before
+pagination so Unicode offsets remain lossless. Structured shell output shows its
+output and recorded exit code, with every original field available in a disclosure;
+unknown formats remain verbatim. Neither a rendered exit code nor readable output
+establishes task acceptance. Native record positions order and deduplicate pages, including
+bursts larger than a page between observations and incomplete appends. Older and
+newer pages remain reachable when the visible window reaches 300 records.
+Looking back pauses automatic insertion; new activity is disclosed without
+moving the reader. Source and content fingerprints reject stale detail reads;
+history replacement resets the visible window instead of mixing conversations.
+Pages contain at most 100 records and scan approximately 2 MiB, allowing a whole
+larger record at a boundary. The reader skips up to eight metadata-only windows
+per read gesture or observation; it stops on visible work, the source boundary,
+a changed source or navigation away. Exhausting that bounded burst leaves the
+history controls available; it never scans the full rollout without a bound. Detail responses carry up to 24,000 characters.
+Records above the 8 MiB reader limit, malformed records and unsupported response
+types retain an explicit unavailable position. Non-text attachments are disclosed
+as unavailable. These limits remain coverage gaps, not a claim of complete native
+event parity. Credential-shaped content is withheld through the shared detector;
+that detector is not a guarantee of identifying every possible secret.
+
+Original content remains private to the local owner. One-second observation of
+saved records is **not token streaming**. History is neither copied into LoopX
+run history nor made available to a shared sink. Stable Agent routing is shared
+by the Goal directory; other providers and native approval/stream parity remain
+within the working-session acceptance. The Codex provider does not close those gaps.
+
+Reply uses Codex Desktop's version-sensitive follower IPC, requires its original
+owner to advertise support for external input, and retains the original thread
+settings. It does not use the app-tools pipe or impersonate a model tool caller.
+Busy, unknown, ambiguous, rebound and disconnected conversations cannot send
+through this surface. Continue such work in the original App. Tool approvals,
+questions and active-turn steering remain there; this stage does not claim the
+complete attached-session acceptance or Lark parity.
+
+Input receipts contain an operation id, binding, content fingerprint and native
+acknowledgement, without copying the message body. The same operation cannot be
+sent twice. A confirmed submission clears the draft and leaves the next reply
+composer available, including after reload; it does not require a second human
+acknowledgement. Sending again still requires an explicit new message and a fresh
+idle observation. If the Agent becomes busy before dispatch, the API returns
+`input_not_submitted: true` with the rejected operation id. The page retains the
+draft and restores the composer directly, including across reload, without an
+original-App check or automatic resend. This proof applies only before a receipt
+or native input exists; a generic conflict, missing receipt or transport failure
+does not establish it. After an interrupted response or reopening with a pending
+submission, the page automatically reads its existing receipt once. A recorded
+acknowledgement restores the composer without another user step; genuinely
+unconfirmed delivery remains pending and never silently retries a native effect.
+Check the original App before discarding an uncertain receipt. Late reads cannot
+restore a discarded operation over a new draft. The UI keeps only its reply recovery
+record and unsent draft in the current browser session, scoped to the exact Goal
+and binding; switching Agents never carries text to another recipient. Replies are explicit local owner actions;
+a registered binding or observer does not authorize an Agent to message peers.
+
+The local presentation API is `/api/chat/codex-attached`: GET requires the
+registered Goal, then the exact Agent/binding for history or a receipt; POST
+requires that same binding, one operation id and text. It cannot select arbitrary
+host threads, paths, RPC methods, approvals, models or permissions. Existing
+loopback Origin checks still protect effects. Native send is unavailable on
+Windows in this stage. **Open in Codex App** uses an explicit same-origin POST to
+`/api/chat/codex-attached/open` with only Goal, Agent and binding. The provider
+rechecks the accepted binding and local conversation, then requests macOS open
+the fixed Codex bundle with that conversation's canonical UUID. It cannot launch
+arbitrary URLs or commands, submit input or bypass the desktop navigation fence.
+The UI reports OS dispatch, not proof that the App displayed the thread; errors
+are visible and retry requires another explicit click. Other hosts report this
+operation unavailable. Leave the Agent page to stop reads; rolling back the runtime
+removes this UI/provider without editing native sessions, credentials or App
+startup configuration. Do not expose this owner-only API through a shared sink.
+
+Source research: official [app-server transports and history](https://learn.chatgpt.com/docs/app-server),
+[codex-monitor](https://github.com/lucianlamp/codex-monitor) (loaded owner selection),
+[CodexMonitor](https://github.com/Dimillian/CodexMonitor) (a separately owned
+app-server UI), [codex-bridge](https://github.com/hitsmaxft/codex-bridge) (writer
+ownership and explicit Desktop interposition), and
+[codex-web's follower IPC](https://github.com/i-am-BT/codex-web/blob/c401ea7f33cbfff227e154b8d817b150d39256fc/desktop-ipc-client.mjs).
+The provider is independently implemented; none of these projects is installed,
+vendored or allowed to replace the owner's App/runtime automatically.
+
## Adapter Phases
### Phase 1: Read-Only Projection
diff --git a/docs/project-agent-todo-contract.md b/docs/project-agent-todo-contract.md
index feb771b1d2..4bdfc85aff 100644
--- a/docs/project-agent-todo-contract.md
+++ b/docs/project-agent-todo-contract.md
@@ -29,8 +29,11 @@ sync catch up.
## Priority intent
-Priority is an explicit scheduling field, with `P0` highest and `P4` lowest.
-Create or edit it independently of the task description:
+Priority is scheduling intent, with `P0` highest and `P4` lowest. New Todos
+default to **P1** when neither a priority parameter nor a legacy prefix is
+supplied. Reserve P0 for explicitly urgent work; routine creation must not
+compete with it just because the author omitted a field. Create or edit
+priority independently of the task description:
```bash
loopx todo add --goal-id --role agent --priority P1 --text ''
@@ -42,10 +45,9 @@ Pass the registered `--agent-id` on updates when the Goal has multiple Agents.
The existing authoring, ownership, lease and review requirements still apply.
Chat `todo.create` accepts `priority`; reviewed `todo.update` edits accept
`priority` or `clear_priority`. The task management panel uses that reviewed
-update path for its priority selector. Priority never grants eligibility,
-capabilities, a claim or quota; missing priority is allowed and sorts after P4.
-The generated agent authoring hint shows an explicit P1 example, not a global
-default or a prerequisite for same-turn binding.
+update path for its priority selector. CLI, Chat and canonical creation use the
+same typed default. Priority never grants eligibility, capabilities, a claim,
+quota, or permission to bypass a gate.
Omitting priority from an ordinary text edit preserves the current priority.
Use `--clear-priority` to remove it. Legacy `--text '[P2] Task'` remains supported;
@@ -54,9 +56,24 @@ conflicting prefix are rejected before writing, as are simultaneous set/clear
instructions. Words such as `P0` inside ordinary prose have no scheduling meaning.
Historical decorated prefixes such as `[P2-review]` read as P2; an edit renders
the normalized `[P2]` prefix. P3/P4 participate in ordering and repair suggestions
-without being silently promoted to P1. Existing repair-suggestion and historical
-event-replay defaults retain their own contracts; they do not impose a default
-on new unprioritized Todos.
+without being silently promoted to P1.
+
+Direct `complete`/`supersede` continuations retain the predecessor's declared
+priority unless the successor explicitly supplies a different legacy prefix.
+Monitor discoveries are independent work: `quota monitor-poll` accepts
+`--next-agent-priority` and `--next-user-priority`, each tied to its corresponding
+successor. Omission uses the new-Todo default, not the monitor's priority. A P0
+watch therefore creates an ordinary P1 follow-up unless the caller declares
+urgency. The successor record and receipt expose the resulting priority.
+
+**Default change and compatibility:** previously omitted creation priority was
+unranked; new CLI, Chat, canonical and monitor-created Todos now receive P1.
+Existing unranked Todos and explicit clears remain unranked and sort after P4.
+Ordinary edits preserve them. Creation defaults apply after original receipt
+lookup, so upgrading and retrying an old operation does not reprioritize its
+result. Historical event replay and repair-suggestion policies retain their own
+contracts. To choose another priority, pass it explicitly; to make an existing
+Todo unranked, use the reviewed edit or `--clear-priority`.
`todos/priority.ts` owns authoring intent and ordering. The generated coordination
contract supplies its vocabulary and legacy grammar to Python read adapters.
diff --git a/docs/quota-allocation.md b/docs/quota-allocation.md
index 411513d291..7914d24089 100644
--- a/docs/quota-allocation.md
+++ b/docs/quota-allocation.md
@@ -943,7 +943,11 @@ follow-up instead of staying as an opaque watch. A user follow-up must declare
a blocking owner decision and `user_action` for a visible reminder that must
not block the bound agent lane. Omitting the task class fails before writeback.
The monitor remains an observe-only `continuous_monitor`; `--next-agent-todo`
-creates a separate runnable `advancement_task`. For the corresponding
+creates a separate runnable `advancement_task`. New successors default to P1
+independently of the monitor. Use `--next-agent-priority` and
+`--next-user-priority` to declare urgency for each successor; each option
+requires its corresponding Todo. Conflicting priority and legacy text prefix
+are rejected before observation writeback. For the corresponding
generation-fenced waiting-Todo recipe, see
[Project Agent Todo Contract](project-agent-todo-contract.md#machine-readable-resume-conditions):
@@ -952,7 +956,8 @@ loopx quota monitor-poll --goal-id \
--target-key --result-hash --material-change \
--next-user-todo "" \
--next-user-task-class user_action \
- --next-agent-todo "" --execute
+ --next-agent-todo "" --next-action-kind validate \
+ --next-agent-priority P1 --next-user-priority P2 --execute
```
The command appends a `quota_monitor_poll` run record, does not mutate the
diff --git a/docs/reference/goal-acceptance-observations.md b/docs/reference/goal-acceptance-observations.md
index e0c9d83730..90f5329794 100644
--- a/docs/reference/goal-acceptance-observations.md
+++ b/docs/reference/goal-acceptance-observations.md
@@ -81,9 +81,49 @@ promote a provider or fall back to legacy Markdown when canonical reads fail.
The owner makes three explicit decisions: the objective and its acceptance
criteria, which existing advancement tasks serve those criteria, and whether to
enable this governance. The existing Goal authority owns the contract; the
-Dashboard only reads it. No new capability editor or provider is introduced.
+Dashboard presents its checked result and uses the same owner for authorized changes. No new capability editor or provider is introduced.
-From the Goal's delivery workspace, inspect the current provider revision:
+For an ordinary delivery, the Agent prepares the technical plan from the stated
+outcome and existing task/file context. The local owner sees its objective,
+criteria, coverage and command effect beside the delivery, then uses **Use plan
+and check** once. A configured contract needs only **Check delivery**. The
+advanced JSON import remains available for technical migration; it is not a
+prerequisite for this path.
+
+Agent preparation uses the existing typed action preview store:
+
+```bash
+loopx --format json goal-acceptance prepare --goal-id example-goal \
+ --agent-id example-agent --document acceptance.json \
+ --operation-id prepare-delivery-check --execute
+```
+
+Here `--execute` persists only the reviewable proposal. Omit it for a read-only
+validation preview. Neither form configures the Goal, approves governance or runs
+commands. The local owner's action applies the described configuration and
+executes its bounded checks; this is the existing trusted local owner surface,
+not an authenticated remote owner decision. Use runtime command names and safe
+relative file references in proposals, rather than machine-specific paths.
+The original operation identity and native receipts recover a lost response.
+Concurrent or repeated apply does not rerun a consumed intent; if a started check
+has no matching native receipt, the result stays uncertain until explicitly
+reconciled. Readback never runs checks. A stored successful receipt cannot
+certify changed files, independent reviewer identity, adoption or Goal completion.
+A later finished/cancelled plan does not resurrect an older draft.
+
+Count the complete task: with an Agent-prepared plan already delivered, the owner
+has one required decision. If the original request still lacks acceptance intent
+or authority, that missing decision is an additional round; preparing a proposal
+alone does not establish a one-round cold-start or live-model experience.
+No-plan/default-off behavior remains unchanged, and no Lark action is introduced.
+
+中文:普通路径由 Agent 根据目标与既有上下文准备方案,负责人在交付处核对要求、覆盖范围和
+执行影响,一次“采用并检查”;已有配置直接“检查交付”。JSON 导入仅保留为高级迁移入口。
+`prepare --execute` 只保存可审阅方案,不修改 Goal 规则、不执行检查。丢失回执先对账,
+不能自动重跑;历史通过不能认证变化后的版本。交互次数从原始意图算起,前置缺失的意图或
+授权不能从计数中省略。此入口沿用可信本机所有者边界,不证明远程身份、独立复核或实际采用。
+
+For advanced CLI configuration, inspect the current provider revision:
```bash
loopx --format json goal-acceptance inspect --goal-id example-goal
@@ -126,8 +166,8 @@ owner-approved scope change.
Unselected work retains its ordinary validation, claims, leases, permissions and
continuation checks. Selected work cannot edit role/class/status to escape its
contract. Changes to unrelated work do not stale a selected-work verification;
-changes to selected work do. The dashboard's existing read-only acceptance detail,
-Markdown export and CLI expose coverage without granting new configuration power.
+changes to selected work do. The dashboard's acceptance detail, Markdown export and CLI expose coverage.
+Configuration continues through the same owner-authorized CAS transition.
No Lark configuration operation is introduced.
**恢复与范围 / Recovery and coverage.** Missing/stale associations trigger scoped
@@ -450,3 +490,117 @@ TS 校验整个候选工作声明的摘要必须等于 owner 当初确认的摘
必须提供真实的原文本/等待条件。若不知道原声明、改动涉及其他字段,或后续兼容修订
使完整摘要无法精确匹配,应请 owner 审核并显式重新绑定。不能猜测旧值、伪造完成,
也不能先取得 stale 工作的租约来绕过这条边界。
+
+
+## Owner-pinned oracle and exact versions
+
+In **Goal settings → Task ownership → Goal acceptance contract**, the local owner
+can import a complete approved JSON document, review its scope and command,
+save it, run the configured checks, read back the result, or disable the contract.
+Existing configured contracts offer the same operations in **Delivery & evidence**.
+Saving does not run a command. Reading does not run checks. Failed or uncertain
+operations retain a readback/retry path; an uncertain check is never replayed
+automatically. These actions use the existing acceptance owner and provider CAS.
+Agent callers still cannot configure the owner's contract or submit a verdict.
+
+An optional criterion `oracle` declares an **owner-pinned checker**, rather than
+inferring independent acceptance from a worker's name, report or artifact hash:
+
+```json
+{
+ "kind": "owner_pinned_oracle",
+ "source_id": "export-checker",
+ "input_refs": ["expected.txt"],
+ "output_refs": ["result.txt"]
+}
+```
+
+Add this object to a criterion in the existing document format. That criterion
+must explicitly pin the invoked checker in `validation_files`, with its actual
+SHA-256, and invoke that file directly or as Python's immediate script argument.
+The supported interpreter names are `python`, `python3`, and versioned
+`python3.`, optionally ending in `.exe`; absolute host paths to those
+interpreters are allowed. Relative interpreter wrappers must themselves be
+pinned and directly invoked; they cannot borrow Python's script-argument rule.
+For example, use
+`validation_argv: ["python3", "checks/export.py"]` with a pin for
+`checks/export.py`. Interpreter options before the script (including `-c`, `-m`
+and `--`) are unsupported. Arbitrary commands such as `true checks/export.py`
+or `cat checks/export.py` cannot attest execution of that pinned source and are
+rejected before configuration is written. Script arguments after the file are
+allowed. Other runtimes can use a directly executed pinned entry point with a
+relative path containing `/` (for example `checks/export`), not a bare name
+resolved through `PATH`. This grammar does not authenticate the local interpreter
+binary, which remains part of the trusted host environment.
+Declare every input/output file whose version the receipt is meant to cover;
+there must be at least one output. Source, input and output roles are distinct.
+All refs are bounded repository-relative files; symlinks and unavailable files
+fail closed. Ordinary criteria without `oracle` retain their previous execution,
+canonical document bytes and result shape.
+
+The host copies the pinned source and declared inputs/outputs into a temporary
+workspace, verifies their bytes, executes the approved command there, and checks
+those copies and the original workspace again before returning evidence. The
+successful criterion result contains `oracle_evidence`: source identity and
+digest, UTC check time, and exact input/output SHA-256 versions. A changed or
+missing source/output, failed check or timeout does not produce a success
+receipt. Completion still has its existing fresh checks and transaction; an
+ordinary completion receipt is not substituted for this explicit oracle record.
+
+This is file-snapshot isolation, **not an OS sandbox**. The local interpreter,
+environment, external services and unpinned imports remain trusted host inputs.
+The owner must approve the checker's meaning, provenance and any effects before
+running it. The source label is not an authenticated remote reviewer's identity;
+file hashes do not prove independent authorship. The checker record does not
+resolve an objection, establish requester adoption, or complete a Goal.
+
+`goal-acceptance inspect` observes current files without executing checks. It
+shows `current` only when the current contract/work, pinned source and declared
+versions match the stored receipt. Changed bytes show `stale`; unavailable files
+or missing receipts cannot become current success. Raw canonical/status snapshots
+without host file observations show `recorded`, which does not certify current
+bytes. Delegation evidence includes the checker only when the acceptance owner
+observed it current and its tested outputs cover that execution's exact outputs.
+Requester adoption remains a separate record.
+
+The same local-owner journey is available through the existing CLI:
+
+```sh
+loopx --format json goal-acceptance inspect --goal-id GOAL
+loopx --format json goal-acceptance configure --goal-id GOAL --document owner-contract.json --expected-provider-revision REVISION --execute
+loopx --format json goal-acceptance verify --goal-id GOAL --execute
+loopx --format json goal-acceptance inspect --goal-id GOAL
+loopx --format json goal-acceptance disable --goal-id GOAL --expected-provider-revision CURRENT_REVISION --execute
+```
+
+Use revision tokens from fresh inspect results. Disable withdraws the current
+contract and current oracle projection while retaining historical evidence;
+removing every `oracle` through an explicit owner reconfiguration restores
+ordinary criterion behavior and withdraws the current oracle receipt. The immutable
+operation journal retains its historical evidence; ordinary checks must run again.
+Before downgrading to a runtime that predates this optional field, use the newer
+runtime to configure that ordinary document with a fresh revision, inspect the
+result, then optionally disable. Disable alone retains the newer document and is
+not a backward-format migration. This enables no worker, model, transport, budget
+or permission.
+Local HTTP operations share the same owner through `/api/chat/goal-acceptance`;
+remote/missing authority and stale revisions remain explicit failures.
+
+The packaged regression runs Agent preparation and the in-place owner decision
+against a disposable real SQLite/HTTP/CLI source, including changed output,
+failure/recovery, optional configuration, disable, 390px and keyboard/reduced-motion:
+
+```sh
+uv run --extra test python scripts/chat_bundle.py build --install
+LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE="$PWD/apps/presentation/dashboard/node_modules/playwright" uv run --extra test node examples/personal-workspace-browser/goal-acceptance-oracle.mjs
+```
+
+Workspace discovery in this regression is synthetic; it does not establish
+installed-App adoption or the live L1 correction episode.
+
+
+Synthetic packaged views of the actual local-owner interaction (not an installed
+App or live team): prepared plan on [desktop](../../examples/personal-workspace-browser/oracle-controls-desktop.png)
+and [390px](../../examples/personal-workspace-browser/oracle-controls-mobile.png);
+current result on [desktop](../../examples/personal-workspace-browser/oracle-current-desktop.png)
+and [390px](../../examples/personal-workspace-browser/oracle-current-mobile.png).
diff --git a/docs/reference/local-delegation.md b/docs/reference/local-delegation.md
index 532990052f..580e2f4719 100644
--- a/docs/reference/local-delegation.md
+++ b/docs/reference/local-delegation.md
@@ -1377,3 +1377,16 @@ plan/inspect 的 `operation_callback_managed_wake_configured` 仅证明配置,
移除启动配置或原 requester grant 在下一 callback 生效,已开始工作须走原停止/
对账流程;丢失 ACK 不自动重启。Dashboard 沿用共享 frame 展示状态,但尚不能编辑
此 grant;前端配置与真实原来源结果送达仍为部分交付,不能据合成测试称交易闭环。
+
+
+### Owner-pinned checker evidence
+
+Delegation evidence can include the acceptance owner's optional
+[owner-pinned oracle record](goal-acceptance-observations.md#owner-pinned-oracle-and-exact-versions).
+It names the source, check time and tested input/output versions only when the
+current owner contract and files still match and every returned output is covered
+at its exact version. Unconfigured, missing, changed or failed evidence stays
+unavailable; a current worker check or requester adoption cannot manufacture it.
+The Team evidence detail and correction trace show the same CLI/HTTP observation.
+This record attests a configured checker on declared snapshots; it does not
+identify a remote human/Agent verifier or prove that an objection was resolved.
diff --git a/docs/reference/protocols/host-integration-surface-v0.md b/docs/reference/protocols/host-integration-surface-v0.md
index 46601870f6..0e489f1d6b 100644
--- a/docs/reference/protocols/host-integration-surface-v0.md
+++ b/docs/reference/protocols/host-integration-surface-v0.md
@@ -30,17 +30,24 @@ before ordinary chat, then hands off to the same CLI-backed lifecycle.
## Codex Ordinary Project Git Writes
An ordinary project with the Core `workspace_write` grant adds its physical
-`.git` directory to the native workspace policy's explicit write roots. This
-allows authorized fetch, branch and commit operations without switching to
-full access. Start and resume require native readback of the metadata write
-root; subsequent Turns retain the same thread policy. Existing host network,
-temporary-directory settings and approved write roots are preserved.
+`.git` directory to the native workspace policy's explicit write roots. When
+both `.codex` and `.codex/skills` are physical directories, it also adds only
+the project skills subtree, so an authorized checkout or merge can update
+repository skills. Codex otherwise protects `.codex` recursively even inside
+a writable workspace. Project configuration, other protected directories and
+the account's native store gain no write root.
+
+Start and resume require native readback of every added root; subsequent Turns
+retain the same thread policy. Existing host network, temporary-directory
+settings and approved write roots are preserved. This changes the ordinary
+project default for project skills; it does not select full access.
Read-only projects and workspace-only community isolation keep their existing
policies. A symlinked `.git` or a linked worktree's external Git directory gains
-no extra write root. The ordinary project's account, tools, network policy and
-native store remain under their existing owners; this is filesystem execution
-support, not authorization to publish or perform destructive Git operations.
+no extra write root. Symlinked `.codex` or `skills` directories also gain no
+additional root. The ordinary project's account, tools and network policy
+remain under their existing owners; this is filesystem execution support,
+not authorization to publish or perform destructive Git operations.
## Thin Hook Activation
diff --git a/docs/reference/protocols/loopx-turn-v0.md b/docs/reference/protocols/loopx-turn-v0.md
index 91f0c1af8a..dea27b7a7c 100644
--- a/docs/reference/protocols/loopx-turn-v0.md
+++ b/docs/reference/protocols/loopx-turn-v0.md
@@ -895,6 +895,18 @@ an explicit replay or failed-Turn recovery. The context policy controls Host
memory, while the TurnEnvelope and durable LoopX frontier remain the sole
authority for work selection and continuation.
+`run-once --resume-turn-key` loads that original plan before fresh selection or
+Turn-start context, inbox synchronization and recall. It does not mint a new
+TurnEnvelope for recovery. The existing actor/Goal-lifetime checks, typed
+journal/session recovery decision, provider revalidation and post-writeback
+recovery still apply; `--retry-failed-turn` retains its existing bounded policy.
+A new invocation without a resume key continues through fresh admission.
+
+中文:显式 resume 先读原计划,不为恢复另建一次选任务、TurnEnvelope 或 Turn-start
+上下文、inbox 同步和 recall。原身份/Goal 生命周期、journal/Session 恢复、provider
+当前校验与写回后恢复流程保持;失败重试仍受既有有界策略约束。无 resume key 的新轮次
+继续走完整 fresh 准入。
+
## Adapter Requirements
An external host adapter must provide:
diff --git a/docs/reference/research-checkpoint.md b/docs/reference/research-checkpoint.md
index a274504cf7..831c88ae86 100644
--- a/docs/reference/research-checkpoint.md
+++ b/docs/reference/research-checkpoint.md
@@ -44,6 +44,17 @@ history and qualified native stores. The native inventory excludes credential
files; arbitrary caller-declared artifacts remain private and are not privacy
certified. A new destination is required; capture never overwrites a point.
+The inventory covers only declared file roots plus the automatically owned
+session and LoopX state participants. A conversation mentioning an external
+candidate or report does not capture that file. Declare every needed research
+directory before capture, keep its source quiescent, and verify its bytes at the
+receiver. Never broaden capture to an entire temporary or credential directory
+just to obtain apparent coverage. Missing historical bytes cannot be supplied
+from a later state under the original point identity. Domain receivers must
+explicitly materialize their selected research components; the
+[SForge warm transport](../../benchmark/README.md#explicit-warm-worker-composition-qualification-in-progress)
+provides that selection for its data paths.
+
Verify the returned digest before preparing a receiver:
```sh
diff --git a/examples/claude-install-no-system-mutation-smoke.py b/examples/claude-install-no-system-mutation-smoke.py
index 5c02292043..4c50d399aa 100644
--- a/examples/claude-install-no-system-mutation-smoke.py
+++ b/examples/claude-install-no-system-mutation-smoke.py
@@ -8,13 +8,21 @@
2. `install_mcp` must NOT remove a legacy `goal-harness` MCP entry by default;
that is gated behind `migrate_goal_harness=True`. It still replaces our own
`loopx` entry.
+ 3. The `--harden` refusal must narrate the install that really happened: the
+ /loopx command is written, and the MCP state is the registration receipt,
+ never a claim that a missing `claude` CLI or a failed `claude mcp add`
+ installed a server.
Loads install.py as a module and records subprocess calls (no real venv/pip/claude
runs, no filesystem side effects outside a temp dir).
"""
from __future__ import annotations
+import contextlib
import importlib.util
+import io
+import json
+import subprocess
import sys
import tempfile
from pathlib import Path
@@ -156,11 +164,220 @@ def run(cmd, *a, **k):
f"connector must run installer from the target project cwd:\n{calls}"
+def run_installer(project_dir, settings_bytes):
+ """Run the shipped `--harden` install against one project scope."""
+ claude_dir = Path(project_dir) / ".claude"
+ claude_dir.mkdir(parents=True, exist_ok=True)
+ settings = claude_dir / "settings.json"
+ if settings_bytes is not None:
+ settings.write_bytes(settings_bytes)
+ completed = subprocess.run(
+ [
+ sys.executable,
+ str(INSTALLER),
+ "--scope",
+ "project",
+ "--project",
+ str(project_dir),
+ "--harden",
+ "--skip-mcp",
+ ],
+ capture_output=True,
+ text=True,
+ # The child writes its own narration with the host console codec, which
+ # is not UTF-8 on a cp936 Windows console; without `errors` the reader
+ # thread dies and `stdout` comes back as None.
+ encoding="utf-8",
+ errors="replace",
+ )
+ return completed, settings
+
+
+def test_harden_refuses_a_settings_file_it_cannot_read():
+ """Review P1: the merge rewrites the whole file, so an unreadable one is
+ refused instead of being replaced by the loopx block alone.
+
+ `add_hardening` used to fall back to `cur = {}` on any read failure and then
+ write the merged result back, so a settings.json that does not parse - a
+ UTF-8 BOM from Notepad, a hand-added `//` comment or trailing comma, a torn
+ tail from an interrupted write - lost `permissions` (including `deny`
+ rules), `env`, `model`, other hooks and MCP approvals, at exit status 0 and
+ with no backup. The file belongs to the user, so the install must fail
+ closed and leave it byte-identical.
+ """
+ torn = b'{"model": "opus", "permissions": {"deny": ["Read(~/.ssh/**)"]}, "hooks"'
+ with tempfile.TemporaryDirectory(prefix="loopx-harden-") as d:
+ completed, settings = run_installer(d, torn)
+ assert completed.returncode != 0, \
+ f"an unreadable settings.json must fail the install:\n{completed.stdout}"
+ assert settings.read_bytes() == torn, "settings.json must be left untouched"
+ # The refusal is scoped to the hardening step: the rest of the adapter
+ # must still be installed, so a refusal is not a failed install.
+ assert (Path(d) / ".claude" / "commands" / "loopx.md").exists(), \
+ "the /loopx command must still be installed"
+
+ bom = "\ufeff".encode("utf-8") + b'{"model": "opus"}'
+ with tempfile.TemporaryDirectory(prefix="loopx-harden-") as d:
+ completed, settings = run_installer(d, bom)
+ assert completed.returncode != 0, \
+ f"a BOM-prefixed settings.json must fail the install:\n{completed.stdout}"
+ assert settings.read_bytes() == bom, "settings.json must be left untouched"
+
+
+def test_harden_still_merges_a_readable_settings_file():
+ original = {
+ "permissions": {"deny": ["Read(~/.ssh/**)"]},
+ "env": {"ANTHROPIC_LOG": "1"},
+ "model": "opus",
+ }
+ with tempfile.TemporaryDirectory(prefix="loopx-harden-") as d:
+ completed, settings = run_installer(
+ d, json.dumps(original).encode("utf-8")
+ )
+ assert completed.returncode == 0, f"--harden failed:\n{completed.stderr}"
+ written = json.loads(settings.read_text(encoding="utf-8"))
+ for key, value in original.items():
+ assert written[key] == value, f"{key} must survive the merge:\n{written}"
+ assert written["statusLine"]["type"] == "command"
+
+
+TORN_SETTINGS = b'{"model": "opus", "permissions": {"deny": ["Read(~/.ssh/**)"]}, "hooks"'
+
+
+def run_main(project_dir, settings_bytes, argv_extra=(), claude_path="/usr/bin/claude",
+ add_rc=0):
+ """Drive install.py's own `main()` for one MCP outcome.
+
+ The real `main` -> `install_mcp` -> refusal path, with only these replaced:
+ the venv/pip provisioning (never touched here), the `claude` PATH lookup, and
+ the `claude` subprocess. A missing CLI and a failed `claude mcp add` are the
+ two real paths that leave the server unregistered, so the refusal message has
+ to report them as such.
+ """
+ inst = load_install()
+ claude_dir = Path(project_dir) / ".claude"
+ claude_dir.mkdir(parents=True, exist_ok=True)
+ settings = claude_dir / "settings.json"
+ settings.write_bytes(settings_bytes)
+ calls = []
+
+ def fake_run(cmd, *a, **k):
+ flat_cmd = flat(cmd)
+ calls.append(flat_cmd)
+ rc = add_rc if "mcp add" in flat_cmd else 1 # 1: no legacy goal-harness
+ return SimpleNamespace(returncode=rc, stdout="", stderr="")
+
+ inst.provision_mcp_python = lambda dry, allow_system_pip=False: sys.executable
+ inst.shutil = SimpleNamespace(which=lambda name: claude_path if name == "claude" else None)
+ inst.subprocess = SimpleNamespace(run=fake_run)
+ out, err = io.StringIO(), io.StringIO()
+ argv_original = sys.argv
+ sys.argv = [
+ "install.py", "--scope", "project", "--project", str(project_dir),
+ "--harden", *argv_extra,
+ ]
+ try:
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
+ rc = inst.main()
+ finally:
+ sys.argv = argv_original
+ return rc, settings, calls, out.getvalue(), err.getvalue()
+
+
+def test_refusal_reports_a_missing_claude_cli_instead_of_claiming_an_install():
+ """The refusal must not turn an MCP *attempt* into an MCP *install*.
+
+ `install_mcp` prints the manual `claude mcp add` command and returns when
+ `claude` is not on PATH. A refusal message that says the MCP server "was
+ installed" would let that user skip the manual registration the message just
+ told them to run.
+ """
+ with tempfile.TemporaryDirectory(prefix="loopx-harden-") as d:
+ rc, settings, calls, out, err = run_main(d, TORN_SETTINGS, claude_path=None)
+ assert rc == 2, f"a refused --harden must exit 2:\n{err}"
+ assert settings.read_bytes() == TORN_SETTINGS, "settings.json must be untouched"
+ assert "add the MCP server manually" in out, \
+ f"the manual registration command must still be printed:\n{out}"
+ assert "the MCP server was not registered" in err, \
+ f"the refusal must report the real MCP state:\n{err}"
+ assert "were installed" not in err, \
+ f"no MCP success claim without a successful registration:\n{err}"
+ assert "the /loopx command was installed" in err, \
+ f"the command really was written and may be reported:\n{err}"
+ assert (Path(d) / ".claude" / "commands" / "loopx.md").exists()
+ assert not calls, f"no claude call is possible without the CLI:\n{calls}"
+
+
+def test_refusal_reports_a_failed_mcp_registration():
+ """`claude mcp add` exiting non-zero leaves the server unregistered too."""
+ with tempfile.TemporaryDirectory(prefix="loopx-harden-") as d:
+ rc, settings, calls, out, err = run_main(d, TORN_SETTINGS, add_rc=1)
+ assert rc == 2, f"a refused --harden must exit 2:\n{err}"
+ assert settings.read_bytes() == TORN_SETTINGS, "settings.json must be untouched"
+ assert any("mcp add" in c for c in calls), f"the add must be attempted:\n{calls}"
+ assert "mcp add failed; add manually" in out, \
+ f"the manual registration command must still be printed:\n{out}"
+ assert "the MCP server was not registered" in err, \
+ f"the refusal must report the real MCP state:\n{err}"
+ assert "were installed" not in err, \
+ f"no MCP success claim after a failed registration:\n{err}"
+
+
+def test_refusal_reports_a_registration_that_did_succeed():
+ """The receipt is read both ways: a real registration is reported as one."""
+ with tempfile.TemporaryDirectory(prefix="loopx-harden-") as d:
+ rc, settings, calls, _out, err = run_main(d, TORN_SETTINGS, add_rc=0)
+ assert rc == 2, f"a refused --harden must exit 2:\n{err}"
+ assert settings.read_bytes() == TORN_SETTINGS, "settings.json must be untouched"
+ assert any("mcp add" in c for c in calls), f"the add must be attempted:\n{calls}"
+ assert "the MCP server was registered" in err, \
+ f"a successful registration must be reported as one:\n{err}"
+
+
+def test_refusal_under_dry_run_claims_nothing_was_written():
+ """A dry run writes neither the command nor the settings file."""
+ with tempfile.TemporaryDirectory(prefix="loopx-harden-") as d:
+ rc, settings, _calls, _out, err = run_main(
+ d, TORN_SETTINGS, argv_extra=("--dry-run",), add_rc=0
+ )
+ assert rc == 2, f"a refused --harden must exit 2:\n{err}"
+ assert settings.read_bytes() == TORN_SETTINGS, "settings.json must be untouched"
+ assert "nothing was written (dry-run)" in err, \
+ f"the refusal must say nothing was written:\n{err}"
+ assert "command was installed" not in err, \
+ f"a dry run installs nothing and must claim nothing:\n{err}"
+ assert not (Path(d) / ".claude" / "commands" / "loopx.md").exists(), \
+ "a dry run must not write the command"
+
+
+def test_refusal_reports_a_skipped_mcp_step():
+ """`--skip-mcp` is neither an install nor a registration failure."""
+ with tempfile.TemporaryDirectory(prefix="loopx-harden-") as d:
+ rc, settings, calls, _out, err = run_main(
+ d, TORN_SETTINGS, argv_extra=("--skip-mcp",)
+ )
+ assert rc == 2, f"a refused --harden must exit 2:\n{err}"
+ assert settings.read_bytes() == TORN_SETTINGS, "settings.json must be untouched"
+ assert "the MCP server was skipped (--skip-mcp)" in err, \
+ f"a skipped MCP step must be reported as skipped:\n{err}"
+ assert "were installed" not in err, \
+ f"no MCP success claim for a skipped step:\n{err}"
+ assert not calls, f"--skip-mcp must not call claude:\n{calls}"
+ assert (Path(d) / ".claude" / "commands" / "loopx.md").exists()
+
+
def main() -> int:
test_no_break_system_packages_by_default()
test_does_not_remove_goal_harness_by_default()
test_project_scope_uses_project_working_directory()
test_connect_runs_installer_from_project_working_directory()
+ test_harden_refuses_a_settings_file_it_cannot_read()
+ test_harden_still_merges_a_readable_settings_file()
+ test_refusal_reports_a_missing_claude_cli_instead_of_claiming_an_install()
+ test_refusal_reports_a_failed_mcp_registration()
+ test_refusal_reports_a_registration_that_did_succeed()
+ test_refusal_under_dry_run_claims_nothing_was_written()
+ test_refusal_reports_a_skipped_mcp_step()
print("claude-install-no-system-mutation-smoke ok")
return 0
diff --git a/examples/desktop-update-browser-smoke.mjs b/examples/desktop-update-browser-smoke.mjs
index 43dc5df31f..0607993c11 100644
--- a/examples/desktop-update-browser-smoke.mjs
+++ b/examples/desktop-update-browser-smoke.mjs
@@ -196,11 +196,14 @@ try {
// Preserve the native recovery code; a runtime-only repair cannot restore it.
installFailureCode = "app_install_incomplete";
nativeRuntimeSelection = { explicit: false, remembered: true, bundled_repair_available: false };
+ await page.getByText("高级选项", { exact: true }).click();
await page.getByRole("button", { name: "检查更新", exact: true }).click();
await page.getByRole("button", { name: "更新并准备重启", exact: true }).click();
await page.getByText(/App 安装未完成,当前版本可能不完整/).waitFor({ timeout: 2000 });
+ assert.equal(await page.getByRole("button", { name: "恢复上个版本", exact: true }).isVisible(), true, "an incomplete App exposes recovery without opening advanced controls");
+ assert.equal(await page.getByRole("button", { name: "确认恢复上版", exact: true }).count(), 0, "do not duplicate the same recovery action behind a second disclosure");
+ await page.getByText("高级选项", { exact: true }).click();
assert.equal(await page.getByRole("button", { name: "修复当前版本", exact: true }).isEnabled(), false);
- assert.equal(await page.getByRole("button", { name: "确认恢复上版", exact: true }).isEnabled(), true);
await page.getByText(/当前 App 无法重装运行时/).waitFor();
assert.equal(await page.locator(".personal-update-panel").getByRole("button", { name: "重启完成更新", exact: true }).count(), 0);
await page.screenshot({ path: resolve(output, "update-incomplete-recovery.png") });
@@ -224,6 +227,22 @@ try {
checkUpToDate = false;
fastInstallFailure = false;
+ // Restore failures retain the native cause instead of suggesting a Repair
+ // action that this independently managed runtime cannot perform.
+ nativeRuntimeSelection = { explicit: true, bundled_repair_available: false };
+ for (const [code, message] of [
+ ["backup_unavailable", /没有可验证的 App 备份,未执行恢复/],
+ ["backup_failed", /App 备份无法保存或未通过完整性校验/],
+ ["rollback_failed", /恢复 App 未完成,请保持窗口打开/],
+ ]) {
+ nativeState = { phase: "error", details: { code } };
+ const beforeRead = calls.length;
+ await page.reload();
+ await page.getByRole("button", { name: "更新需重试", exact: true }).click();
+ await page.getByRole("status").filter({ hasText: message }).waitFor();
+ assert.equal(calls.length, beforeRead, "reading a failure must not repeat recovery effects");
+ }
+
// The native owner decides whether Repair can maintain this installation.
// The workspace consumes that projection rather than inferring from a path.
nativeState = { phase: "ready", details: {} };
diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs
index 14348a3e96..4c5efde221 100644
--- a/examples/personal-workspace-browser-smoke.mjs
+++ b/examples/personal-workspace-browser-smoke.mjs
@@ -5,6 +5,7 @@ import {goalWorkspaceCreateScenario} from "./personal-workspace-browser/goal-wor
import {agentModelSelectionScenario} from "./personal-workspace-browser/agent-model-selection.mjs";
import {goalWorkspaceM2Scenario} from "./personal-workspace-browser/goal-workspace-m2.mjs";
import {goalWorkspaceSelectionScenario} from "./personal-workspace-browser/goal-workspace-selection.mjs";
+import {firstGoalHomeScenario} from "./personal-workspace-browser/first-goal-home.mjs";
import { replanCadenceScenario } from "./personal-workspace-browser/replan-cadence.mjs";
import {nativeChildActivityScenario} from "./personal-workspace-browser/native-child-activity.mjs";
import {privateStewardScopeScenario} from "./personal-workspace-browser/private-steward-scope.mjs";
@@ -109,6 +110,7 @@ scenarioCatalog.push(zcodeGoalScenario);
scenarioCatalog.push(goalWorkspaceM2Scenario);
scenarioCatalog.push(goalWorkspaceCreateScenario);
scenarioCatalog.push(goalWorkspaceSelectionScenario);
+scenarioCatalog.push(firstGoalHomeScenario);
const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO;
scenarioCatalog.push(agentModelSelectionScenario);
const scenarios = requestedScenario
diff --git a/examples/personal-workspace-browser/agent-work-after-desktop.png b/examples/personal-workspace-browser/agent-work-after-desktop.png
new file mode 100644
index 0000000000..e38c38f8d7
Binary files /dev/null and b/examples/personal-workspace-browser/agent-work-after-desktop.png differ
diff --git a/examples/personal-workspace-browser/agent-work-after-phone.png b/examples/personal-workspace-browser/agent-work-after-phone.png
new file mode 100644
index 0000000000..75796fb395
Binary files /dev/null and b/examples/personal-workspace-browser/agent-work-after-phone.png differ
diff --git a/examples/personal-workspace-browser/agent-work-before-desktop.png b/examples/personal-workspace-browser/agent-work-before-desktop.png
new file mode 100644
index 0000000000..182fccf21b
Binary files /dev/null and b/examples/personal-workspace-browser/agent-work-before-desktop.png differ
diff --git a/examples/personal-workspace-browser/agent-work-overview-after.png b/examples/personal-workspace-browser/agent-work-overview-after.png
new file mode 100644
index 0000000000..2b2f53474f
Binary files /dev/null and b/examples/personal-workspace-browser/agent-work-overview-after.png differ
diff --git a/examples/personal-workspace-browser/agent-work-overview-before.png b/examples/personal-workspace-browser/agent-work-overview-before.png
new file mode 100644
index 0000000000..0ed44f48c2
Binary files /dev/null and b/examples/personal-workspace-browser/agent-work-overview-before.png differ
diff --git a/examples/personal-workspace-browser/agent-work-tool-result.png b/examples/personal-workspace-browser/agent-work-tool-result.png
new file mode 100644
index 0000000000..a2e56e9f0c
Binary files /dev/null and b/examples/personal-workspace-browser/agent-work-tool-result.png differ
diff --git a/examples/personal-workspace-browser/attached-host-follow-up.mjs b/examples/personal-workspace-browser/attached-host-follow-up.mjs
index 039d93d743..40557adcd9 100644
--- a/examples/personal-workspace-browser/attached-host-follow-up.mjs
+++ b/examples/personal-workspace-browser/attached-host-follow-up.mjs
@@ -1,3 +1,4 @@
+import { runCodexAttached } from "./codex-attached.mjs";
import { openWorkspacePage } from "./scenario-context.mjs";
// An attached_host Session queues a follow-up while its host runs a Turn
@@ -21,6 +22,7 @@ function runningSession(goalId, mode) {
export const attachedHostFollowUpScenario = {
id: "attached-host-follow-up",
async run({ browser, collectCoverage, url }) {
+ await runCodexAttached(browser);
const heldStreams = [];
const posts = [];
const context = await openWorkspacePage(browser, url, {
diff --git a/examples/personal-workspace-browser/codex-attached.mjs b/examples/personal-workspace-browser/codex-attached.mjs
new file mode 100644
index 0000000000..cabe62c3a4
--- /dev/null
+++ b/examples/personal-workspace-browser/codex-attached.mjs
@@ -0,0 +1,361 @@
+import assert from 'node:assert/strict';
+import {spawn} from 'node:child_process';
+import {once} from 'node:events';
+import {resolve} from 'node:path';
+import {createInterface} from 'node:readline';
+import {repoRoot, outputDir} from './fixture.mjs';
+import {openWorkspacePage} from './scenario-context.mjs';
+import {testPythonCommand} from '../../scripts/test-python.mjs';
+
+export async function runCodexAttached(browser) {
+ if (process.platform === "win32") return; // Windows has no qualified Desktop follower transport.
+ await runConversation(browser, false);
+ await runConversation(browser, true);
+}
+
+async function runConversation(browser, uncertain) {
+ const fixturePath = resolve(repoRoot, 'examples/personal-workspace-browser/codex_attached_fixture.py');
+ const python = testPythonCommand(`import runpy, sys\nsys.argv = [${JSON.stringify(fixturePath)}, ${JSON.stringify(uncertain ? '--uncertain' : '--confirmed')}]\nrunpy.run_path(${JSON.stringify(fixturePath)}, run_name="__main__")`, {repoRoot});
+ const child = spawn(python.command, python.args, {cwd: repoRoot, env: process.env, stdio: ['ignore','pipe','pipe']});
+ let stderr = '';
+ child.stderr.on('data', value => {stderr += value;});
+ const lines = createInterface({input: child.stdout});
+ let context, timer;
+ try {
+ const [line] = await Promise.race([once(lines,'line'), once(child,'exit').then(() => {throw new Error(stderr);}), new Promise((_, reject) => {timer = setTimeout(() => reject(new Error('Native fixture did not start')), 30_000);})]);
+ clearTimeout(timer);
+ const {url} = JSON.parse(line);
+ context = await openWorkspacePage(browser, url, {gotoWaitUntil: 'domcontentloaded', apiOptions: {fixturePort: new URL(url).port}, beforeGoto: async (api, page) => {
+ api.transformStatus = fixture => {
+ const directoryGoal = fixture.run_history.goals.find(goal => goal.id === 'product-release');
+ directoryGoal.coordination = {registered_agents:['worker','other-worker','remote-worker'], thread_agent_bindings:[
+ {agent_id:'worker',host_surface:'codex-app'}, {agent_id:'remote-worker',host_surface:'cursor'},
+ ]};
+ let goal = fixture.attention_queue.items.find(item => item.goal_id === 'product-release');
+ if (!goal) {
+ goal = {goal_id:'product-release',status:'active',waiting_on:'worker',severity:'info',recommended_action:'Review worker result',
+ project_asset:{owner:'worker',gate:'none',next_action:'Review worker result',stop_condition:'Results reviewed'}};
+ fixture.attention_queue.items.push(goal);
+ }
+ const todos = ['worker', 'other-worker'].map((owner, index) => ({index, done:false, status:'open', role:'agent', task_class:'advancement_task', todo_id:`owner-task-${index}`, text:`Review ${owner} result`, claimed_by:owner}));
+ goal.agent_todos = {items:todos, open_count:2, total_count:2, done_count:0};
+ goal.project_asset = {...goal.project_asset, agent_todos:{items:todos, open:2, done:0, total:2}};
+ };
+ // Unrelated Goal/status APIs use the public synthetic workspace. The
+ // original-conversation route always reaches the real production owner.
+ await page.route('**/api/chat/codex-attached**', route => route.continue());
+ }});
+ const {page} = context;
+ await page.locator('.personal-goal-link').filter({hasText:'Product Release'}).click();
+ if (!uncertain) {
+ await page.getByRole('navigation',{name:'Goal 视图'}).getByRole('button',{name:'概览',exact:true}).click();
+ // Start with the owner of the work in view. Never choose another bound
+ // Agent merely because the requested owner has no native conversation.
+ const other = page.getByRole('button',{name:'与 other-worker 对话',exact:true});
+ await other.click();
+ const unavailable = page.getByRole('region',{name:'Agent 工作会话',exact:true});
+ await unavailable.getByText('此 Agent 尚无已绑定的会话来源。',{exact:true}).waitFor();
+ assert.equal(await unavailable.getByText('Original result',{exact:true}).count(),0);
+ assert.equal(await unavailable.getByRole('textbox').count(),0);
+ await page.goBack();
+ await other.waitFor();
+ assert.ok(await other.evaluate(node => node === document.activeElement));
+ const entry = page.getByRole('button',{name:'与 worker 对话',exact:true});
+ await entry.click();
+ const owner = page.getByRole('region',{name:'Agent 工作会话',exact:true});
+ await owner.getByText('Original result',{exact:true}).waitFor();
+ assert.equal(await owner.getByRole('combobox',{name:'查看 Agent'}).inputValue(),'worker', 'The owner was already selected by the entry');
+ assert.equal(new URL(page.url()).searchParams.get('workAgent'),'worker');
+ await page.reload();
+ await owner.getByText('Original result',{exact:true}).waitFor();
+ assert.equal((await (await page.request.get(new URL('/fixture/inputs',url).href)).json()).count,0);
+ await owner.getByRole('textbox',{name:'回复 worker',exact:true}).fill('保留在 worker 的草稿');
+ await page.reload();
+ await owner.getByText('Original result',{exact:true}).waitFor();
+ assert.equal(await owner.getByRole('textbox',{name:'回复 worker',exact:true}).inputValue(),'保留在 worker 的草稿');
+ assert.equal(await page.getByRole('textbox',{name:'向 LoopX 发送消息',exact:true}).count(),0);
+ assert.equal(await page.getByRole('combobox',{name:'选择 Chat Runtime',exact:true}).count(),0);
+ await page.locator('.personal-goal-link').filter({hasText:'Research Monitor'}).click();
+ assert.equal(new URL(page.url()).searchParams.has('workAgent'),false);
+ assert.equal(await owner.count(),0);
+ await page.goBack();
+ await owner.getByText('Original result',{exact:true}).waitFor();
+ assert.equal(await owner.getByRole('textbox',{name:'回复 worker',exact:true}).inputValue(),'保留在 worker 的草稿');
+ await owner.getByRole('textbox',{name:'回复 worker',exact:true}).fill('');
+ await owner.getByRole('combobox',{name:'查看 Agent'}).selectOption('remote-worker');
+ await owner.getByText('来源:cursor。此宿主的工作记录与回复尚未接入。',{exact:true}).waitFor();
+ assert.equal(await owner.getByRole('textbox').count(),0);
+ await page.screenshot({path:resolve(outputDir,'attached-agent-unsupported.png'),animations:'disabled'});
+ await page.goBack();
+ await owner.getByText('Original result',{exact:true}).waitFor();
+ await page.goForward();
+ await owner.getByText('来源:cursor。此宿主的工作记录与回复尚未接入。',{exact:true}).waitFor();
+ await owner.getByRole('button',{name:'返回概览',exact:true}).click();
+ await entry.waitFor();
+ const unknown = new URL(page.url()); unknown.searchParams.set('view','conversation'); unknown.searchParams.set('workAgent','missing-agent');
+ await page.goto(unknown.href);
+ await owner.getByText('此 Agent 不在当前 Goal 的已注册列表中,请选择列表中的 Agent。',{exact:true}).waitFor();
+ assert.equal(await owner.getByRole('textbox').count(),0);
+ await owner.getByRole('button',{name:'返回概览',exact:true}).click();
+ }
+ await page.getByRole('navigation',{name:'Goal 视图'}).getByRole('button',{name:'对话',exact:true}).click();
+ const panel = page.getByRole('region', {name:'Agent 工作会话',exact:true});
+ await page.screenshot({path:resolve(outputDir,'attached-codex-entry-desktop.png'),animations:'disabled'});
+ await page.getByRole('button',{name:'Agent 工作记录',exact:true}).click();
+ assert.equal(await panel.locator('.agent-work-directory button').count(),3);
+ await panel.getByRole('button',{name:'worker',exact:true}).click();
+ await panel.getByText('Original result',{exact:true}).waitFor();
+ await panel.getByText('原会话可回复',{exact:true}).waitFor();
+ const inputCount = async () => (await (await page.request.get(new URL('/fixture/inputs',url).href)).json()).count;
+ const composer = panel.getByRole('textbox', {name:'回复 worker',exact:true});
+ const send = panel.getByRole('button', {name:'发送到原会话',exact:true});
+ const sentOperations = [];
+ page.on('request', request => {
+ if (request.method() === 'POST' && new URL(request.url()).pathname === '/api/chat/codex-attached') sentOperations.push(request.postDataJSON().operation_id);
+ });
+ if (uncertain) {
+ const unrelatedRejection = async route => {
+ const response = await route.fetch();
+ assert.equal((await response.json()).status,'pending');
+ // Even an explicit non-submission proof cannot clear another operation.
+ await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({ok:false,error:'Unrelated operation rejected',input_not_submitted:true,operation_id:'different-operation'})});
+ };
+ await page.route('**/api/chat/codex-attached',unrelatedRejection);
+ await composer.fill('核对一次投递,不要自动重发。');
+ const submitted = page.waitForResponse(response => response.request().method() === 'POST' && new URL(response.url()).pathname === '/api/chat/codex-attached');
+ await send.click();
+ await submitted;
+ await page.unroute('**/api/chat/codex-attached',unrelatedRejection);
+ await panel.getByText('投递尚未确认,请先在原 App 核对,不会自动重发。',{exact:false}).waitFor();
+ assert.equal(await inputCount(),1);
+ await page.reload();
+ await panel.getByRole('button',{name:'重读投递回执',exact:true}).click();
+ assert.equal(await composer.count(),0, 'Unconfirmed input must not expose another send');
+ assert.equal(await inputCount(),1, 'Reload and receipt reads must not repeat the effect');
+ assert.equal(sentOperations.length,1);
+ await panel.getByRole('button',{name:'已核对,写下一条',exact:true}).waitFor();
+ await page.screenshot({path:resolve(outputDir,'attached-codex-pending-desktop.png'),animations:'disabled'});
+ let releaseReceipt;
+ const holdReceipt = new Promise(resolve => {releaseReceipt = resolve;});
+ let receiptStarted;
+ const readingReceipt = new Promise(resolve => {receiptStarted = resolve;});
+ await page.route('**/api/chat/codex-attached?*operation_id=*', async route => {
+ const response = await route.fetch();
+ receiptStarted();
+ await holdReceipt;
+ await route.fulfill({response});
+ });
+ await panel.getByRole('button',{name:'重读投递回执',exact:true}).click();
+ await readingReceipt;
+ await panel.getByRole('button',{name:'已核对,写下一条',exact:true}).click();
+ await composer.fill('这是核对后新写的草稿。');
+ const lateReceipt = page.waitForResponse(response => new URL(response.url()).searchParams.has('operation_id'));
+ releaseReceipt();
+ await lateReceipt;
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
+ assert.equal(await composer.inputValue({timeout:2000}),'这是核对后新写的草稿。', 'Late receipt reads must not revive a discarded operation');
+ assert.equal(await inputCount(),1);
+ return;
+ }
+ assert.equal((await (await page.request.get(new URL('/fixture/opened',url).href)).json()).count,0);
+ const openOriginal = panel.getByRole('button',{name:'在 Codex App 中打开',exact:true});
+ await page.request.post(new URL('/fixture/fail-open',url).href);
+ await openOriginal.focus();
+ await page.keyboard.press('Enter');
+ await panel.getByRole('alert').filter({hasText:'Codex App could not be opened'}).waitFor();
+ await page.screenshot({path:resolve(outputDir,'attached-codex-open-error-desktop.png'),animations:'disabled'});
+ assert.equal((await (await page.request.get(new URL('/fixture/opened',url).href)).json()).count,0);
+ await openOriginal.click();
+ await panel.getByText('已请求打开原会话,请在 Codex App 中查看。',{exact:true}).waitFor();
+ assert.equal((await (await page.request.get(new URL('/fixture/opened',url).href)).json()).count,1);
+ assert.equal(await panel.getByRole('alert').count(),0);
+ assert.equal(await panel.locator('a[href^="codex:"]').count(),0);
+ assert.equal(await panel.evaluate(node => node.contains(document.activeElement)), true);
+ await panel.getByRole('button',{name:'返回概览',exact:true}).click();
+ assert.equal(await panel.isVisible(), false);
+ await page.goBack();
+ await panel.getByText('Original result',{exact:true}).waitFor();
+ await page.request.post(new URL('/fixture/append',url).href);
+ await panel.getByText('原 Agent 的新结果:已保留原会话。',{exact:true}).waitFor();
+ await panel.getByLabel('回复 worker',{exact:true}).fill('请沿用当前原会话补充验证。');
+ await panel.getByRole('button',{name:'发送到原会话',exact:true}).click();
+ await panel.getByText('已由原 App 接纳;等待 Agent 结果。',{exact:false}).waitFor();
+ await panel.locator('.attached-conversation-messages').getByText('请沿用当前原会话补充验证。',{exact:true}).waitFor();
+ assert.equal(await composer.count(),1, 'A confirmed reply must leave the next composer available');
+ assert.equal(await composer.inputValue(),'');
+ assert.equal(await panel.getByRole('button',{name:'已核对,写下一条',exact:true}).count(),0);
+ await page.reload();
+ await panel.getByText('已由原 App 接纳;等待 Agent 结果。',{exact:false}).waitFor();
+ assert.equal(await composer.inputValue(),'', 'Reload must not turn a confirmed message back into a draft');
+ assert.equal(await inputCount(),1, 'Reload must not submit anything');
+ await page.screenshot({path:resolve(outputDir,'attached-codex-conversation-desktop.png'),animations:'disabled'});
+ await page.setViewportSize({width:390,height:844});
+ await page.emulateMedia({reducedMotion:'reduce'});
+ await panel.getByRole('heading',{name:'工作记录',exact:true}).scrollIntoViewIfNeeded();
+ assert.ok(await panel.boundingBox());
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),true);
+ await page.screenshot({path:resolve(outputDir,'attached-codex-conversation-phone.png'),animations:'disabled'});
+ // The host may become busy between the idle snapshot and the explicit send.
+ const busyRace = async route => {
+ await page.request.post(new URL('/fixture/busy',url).href);
+ const response = await route.fetch();
+ const rejected = await response.json();
+ assert.equal(response.status(),409);
+ assert.equal(rejected.input_not_submitted,true);
+ assert.equal(rejected.operation_id,route.request().postDataJSON().operation_id);
+ await route.fulfill({response});
+ };
+ await page.route('**/api/chat/codex-attached',busyRace);
+ await composer.fill('继续检查这份结果。');
+ await send.click();
+ await panel.getByText('Agent 正在忙碌或状态尚未确认;消息未发送,草稿已保留。',{exact:true}).waitFor();
+ await page.unroute('**/api/chat/codex-attached',busyRace);
+ assert.equal(await inputCount(),1, 'A pre-dispatch rejection creates no native input');
+ assert.equal(await composer.inputValue(),'继续检查这份结果。');
+ assert.equal(await panel.getByRole('button',{name:'已核对,写下一条',exact:true}).count(),0);
+ assert.equal(await page.evaluate(() => Object.keys(sessionStorage).filter(key => key.startsWith('loopx-attached-input:') && !key.endsWith(':draft')).length),0);
+ await page.screenshot({path:resolve(outputDir,'attached-codex-busy-recovery-phone.png'),animations:'disabled'});
+ await page.reload();
+ await panel.getByText('原会话有未结束执行;可在原 App 中追加输入',{exact:true}).waitFor();
+ assert.equal(await composer.inputValue(),'继续检查这份结果。', 'Rejected input remains a draft across reload');
+ assert.equal(await inputCount(),1, 'Reload must not retry the rejected input');
+ assert.equal(await send.isDisabled(),true, 'Keeping the composer must not bypass host activity');
+ await page.request.post(new URL('/fixture/append',url).href);
+ await panel.getByText('原会话可回复',{exact:true}).waitFor();
+ await send.focus();
+ await page.keyboard.press('Enter');
+ await panel.getByText('已由原 App 接纳;等待 Agent 结果。',{exact:false}).waitFor();
+ assert.equal(await inputCount(),2);
+ assert.equal(sentOperations.length,3);
+ assert.equal(new Set(sentOperations).size,3, 'Each explicit new reply needs its own operation');
+ assert.equal(await composer.inputValue(),'');
+ let receiptReadUnavailable = true;
+ await page.route('**/api/chat/codex-attached**', async route => {
+ if (new URL(route.request().url()).pathname !== '/api/chat/codex-attached') return route.continue();
+ if (receiptReadUnavailable && new URL(route.request().url()).searchParams.has('operation_id')) return route.abort('failed');
+ if (route.request().method() !== 'POST') return route.continue();
+ const response = await route.fetch();
+ assert.equal((await response.json()).status, 'delivered');
+ // The original owner and receipt store accepted the input, but the
+ // browser lost the HTTP acknowledgement. Recovery must only read it.
+ await route.abort('failed');
+ });
+ await composer.fill('响应丢失后沿用原回执恢复。');
+ const lostResponse = page.waitForEvent('requestfailed', request => request.method() === 'POST');
+ await send.click();
+ await lostResponse;
+ assert.equal(await inputCount(),3);
+ assert.equal(await page.evaluate(() => JSON.parse(Object.entries(sessionStorage).find(([key]) => key.startsWith('loopx-attached-input:') && !key.endsWith(':draft'))[1]).status),'pending');
+ receiptReadUnavailable = false;
+ await page.reload();
+ await panel.getByText('已由原 App 接纳;等待 Agent 结果。',{exact:false}).waitFor({timeout:5000});
+ assert.equal(await composer.inputValue(),'');
+ assert.equal(await inputCount(),3, 'Automatic recovery must not submit another input');
+ assert.equal(sentOperations.length,4);
+ await composer.fill('本页也自动读取成功回执。');
+ await send.click();
+ await panel.getByText('已由原 App 接纳;等待 Agent 结果。',{exact:false}).waitFor();
+ assert.equal(await composer.inputValue(),'');
+ assert.equal(await inputCount(),4, 'In-page recovery must only read the receipt');
+ assert.equal(new Set(sentOperations).size,5);
+ await page.setViewportSize({width:1512,height:982});
+ await page.getByRole('navigation',{name:'Goal 视图'}).getByRole('button',{name:'概览',exact:true}).click();
+ await page.getByRole('button',{name:'与 worker 对话',exact:true}).click();
+ const direct = page.getByRole('region',{name:'Agent 工作会话',exact:true});
+ await direct.getByText('原会话可回复',{exact:true}).waitFor();
+ await direct.getByRole('textbox',{name:'回复 worker',exact:true}).fill('从负责人的工作直接追问。');
+ await direct.getByRole('button',{name:'发送到原会话',exact:true}).click();
+ await direct.locator('.attached-conversation-messages').getByText('从负责人的工作直接追问。',{exact:true}).waitFor();
+ assert.equal(await inputCount(),5);
+ assert.equal(new Set(sentOperations).size,6);
+ assert.equal(await direct.getByRole('textbox',{name:'回复 worker',exact:true}).inputValue(),'');
+ await page.screenshot({path:resolve(outputDir,'attached-codex-owner-conversation.png'),animations:'disabled'});
+ await page.request.post(new URL('/fixture/work-records',url).href);
+ await direct.getByText('工作结果已返回。',{exact:true}).waitFor();
+ const toolResult = direct.locator('article[data-role="tool"]').filter({hasText:'工具结果'});
+ await toolResult.locator('summary').click();
+ assert.equal(await toolResult.getByText('COMPLETE TOOL RESULT',{exact:false}).count(),0);
+ await toolResult.getByRole('button',{name:'继续读取完整内容',exact:true}).click();
+ await toolResult.getByText('COMPLETE TOOL RESULT',{exact:false}).waitFor();
+ assert.equal(await toolResult.getByRole('button',{name:'继续读取完整内容',exact:true}).count(),0);
+ // Unicode detail offsets must count source characters, not JS UTF-16 units.
+ assert.equal(await toolResult.locator('pre').textContent(),'Validation output\n' + '🧭验证结果\n'.repeat(6000) + 'COMPLETE TOOL RESULT');
+ await toolResult.locator('summary').click();
+ await page.screenshot({path:resolve(outputDir,'attached-codex-work-timeline.png'),animations:'disabled'});
+ if (await direct.getByRole('button',{name:'回到最新',exact:true}).count()) {
+ await direct.getByRole('button',{name:'回到最新',exact:true}).click();
+ await direct.getByText('工作结果已返回。',{exact:true}).waitFor();
+ }
+ await page.request.post(new URL('/fixture/burst',url).href);
+ await direct.getByText('Activity 329',{exact:true}).waitFor();
+ assert.ok(await direct.locator('article').count() <= 300, 'The visible history is bounded without making older records unreachable');
+ await direct.getByRole('button',{name:'读取更早的消息',exact:true}).click();
+ await direct.getByText('Original request',{exact:true}).waitFor();
+ assert.equal(await inputCount(),5, 'History and tool details never execute another input');
+ await page.request.post(new URL('/fixture/append',url).href);
+ await direct.getByText('正在回看历史 · 有新活动',{exact:true}).waitFor();
+ await direct.getByRole('button',{name:'较新记录',exact:true}).click();
+ await direct.getByText('Activity 329',{exact:true}).waitFor();
+ // Following history remains continuous across multi-megabyte invisible metadata.
+ await page.reload();
+ await direct.getByText('Activity 329',{exact:true}).waitFor();
+ await page.request.post(new URL('/fixture/metadata-gap',url).href);
+ await page.request.post(new URL('/fixture/tool-envelope',url).href);
+ const envelope = direct.locator('article[data-role="tool"]').filter({hasText:'退出码 1'});
+ await envelope.locator('summary').first().focus();
+ await page.keyboard.press('Enter');
+ await envelope.getByText('Readable shell result\nSecond line',{exact:true}).waitFor();
+ assert.equal(await envelope.locator('pre').first().textContent(),'Readable shell result\nSecond line');
+ await envelope.getByText('原始记录字段',{exact:true}).click();
+ assert.match(await envelope.locator('.attached-tool-source pre').textContent(), /synthetic/);
+ assert.equal(await inputCount(),5);
+ await page.screenshot({path:resolve(outputDir,'attached-codex-tool-readable.png'),animations:'disabled'});
+ // Reopening at an invisible tail finds prior visible work without an empty
+ // page/another click. Reading older crosses the same gap in one action.
+ await page.request.post(new URL('/fixture/metadata-gap',url).href);
+ await page.reload();
+ await panel.getByRole('textbox',{name:'回复 worker',exact:true}).waitFor();
+ await panel.locator('article[data-role="tool"] summary').first().waitFor();
+ await panel.getByRole('button',{name:'读取更早的消息',exact:true}).click();
+ await panel.getByText('Activity 329',{exact:true}).waitFor();
+ assert.equal(await send.isDisabled(),true, 'History readback must not infer idle from old events outside the observer window');
+ // A pending invisible window must not keep scanning or reinsert the old
+ // Agent's content after navigation away. Hold the real backend response.
+ let releaseWindow, windowStarted;
+ const heldWindow = new Promise(resolve => {releaseWindow = resolve;});
+ const windowRead = new Promise(resolve => {windowStarted = resolve;});
+ let historyReads = 0;
+ const delayedWindow = async route => {
+ const query = new URL(route.request().url()).searchParams;
+ if (query.get('agent_id') !== 'worker' || query.has('operation_id')) return route.continue();
+ historyReads++;
+ const response = await route.fetch();
+ const value = await response.json();
+ if (value.messages?.length === 0 && value.before) {windowStarted(); await heldWindow;}
+ await route.fulfill({response});
+ };
+ await page.route('**/api/chat/codex-attached?*',delayedWindow);
+ await page.reload();
+ await windowRead;
+ await panel.getByRole('combobox',{name:'查看 Agent'}).selectOption('other-worker');
+ await panel.getByText('此 Agent 尚无已绑定的会话来源。',{exact:true}).waitFor();
+ const lateWindow = page.waitForResponse(response => new URL(response.url()).searchParams.get('agent_id') === 'worker');
+ releaseWindow(); await lateWindow;
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
+ assert.equal(historyReads,1, 'Switching Agent cancels the remaining empty-window chain');
+ assert.equal(await panel.locator('article').count(),0);
+ await page.unroute('**/api/chat/codex-attached?*',delayedWindow);
+ await panel.getByRole('combobox',{name:'查看 Agent'}).selectOption('worker');
+ await panel.locator('article[data-role="tool"] summary').first().waitFor();
+ await page.request.post(new URL('/fixture/rebind',url).href);
+ await panel.getByRole('alert').waitFor();
+ await openOriginal.click();
+ await panel.getByRole('alert').filter({hasText:'binding is invalid or changed'}).waitFor();
+ assert.equal((await (await page.request.get(new URL('/fixture/opened',url).href)).json()).count,1);
+ assert.equal(await panel.getByRole('button',{name:'发送到原会话',exact:true}).isDisabled(),true);
+ } finally {
+ clearTimeout(timer); lines.close();
+ if (context) await context.close();
+ if (child.exitCode === null) {child.kill(); await once(child,'exit');}
+ }
+}
diff --git a/examples/personal-workspace-browser/codex_attached_fixture.py b/examples/personal-workspace-browser/codex_attached_fixture.py
new file mode 100644
index 0000000000..cf874030b7
--- /dev/null
+++ b/examples/personal-workspace-browser/codex_attached_fixture.py
@@ -0,0 +1,210 @@
+"""Disposable original-owner protocol peer shared with the HTTP regression."""
+
+import json
+from pathlib import Path
+import sys
+import signal
+import subprocess
+import tempfile
+import threading
+
+from contextlib import ExitStack
+from types import SimpleNamespace
+from unittest.mock import patch
+
+from loopx.chat_server import ChatRequestHandler, default_chat_assets_dir
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from codex_attached_peer import append_message, fixture, provider
+
+stop = threading.Event()
+signal.signal(signal.SIGTERM, lambda *_: stop.set())
+with (
+ tempfile.TemporaryDirectory(prefix="lx-ui-", dir="/tmp") as directory,
+ ExitStack() as patches,
+):
+ with fixture(
+ Path(directory), goal_id="product-release", uncertain="--uncertain" in sys.argv
+ ) as (
+ request,
+ rollout,
+ _methods,
+ _inputs,
+ registry,
+ _home,
+ ):
+ # The directory includes Agents without this provider or without a binding.
+ value = json.loads(registry.read_text())
+ coordination = value["goals"][0]["coordination"]
+ coordination["registered_agents"] += ["other-worker", "remote-worker"]
+ coordination["thread_agent_bindings"].append(
+ {
+ "agent_id": "remote-worker",
+ "host_surface": "cursor",
+ "thread_id": "synthetic-cursor-thread",
+ }
+ )
+ registry.write_text(json.dumps(value))
+ launches = []
+ patches.enter_context(
+ patch.object(provider, "sys", SimpleNamespace(platform="darwin"))
+ )
+ fail_next_open = False
+
+ def open_spy(argv, **options):
+ global fail_next_open
+ if fail_next_open:
+ fail_next_open = False
+ raise OSError("synthetic unavailable App")
+ launches.append((argv, options))
+
+ patches.enter_context(
+ patch.object(
+ provider,
+ "subprocess",
+ SimpleNamespace(
+ run=open_spy, SubprocessError=subprocess.SubprocessError
+ ),
+ )
+ )
+
+ class Handler(ChatRequestHandler):
+ def do_GET(self):
+ if self.path == "/fixture/opened":
+ self._send_json({"ok": True, "count": len(launches)})
+ elif self.path == "/fixture/inputs":
+ self._send_json({"ok": True, "count": len(_inputs)})
+ else:
+ super().do_GET()
+
+ def do_POST(self):
+ global fail_next_open
+ if self.path == "/fixture/work-records":
+ records = [
+ {
+ "type": "function_call",
+ "name": "validate",
+ "call_id": "work-1",
+ "arguments": "Check the release artifact",
+ },
+ {
+ "type": "function_call_output",
+ "call_id": "work-1",
+ "output": [
+ {
+ "type": "text",
+ "text": "Validation output\n"
+ + "🧭验证结果\n" * 6000
+ + "COMPLETE TOOL RESULT",
+ }
+ ],
+ },
+ ]
+ with rollout.open("a") as stream:
+ for item in records:
+ stream.write(
+ json.dumps({"type": "response_item", "payload": item})
+ + "\n"
+ )
+ append_message(rollout, "assistant", "工作结果已返回。")
+ self._send_json({"ok": True})
+ elif self.path == "/fixture/metadata-gap":
+ # A real rollout page with no displayable rows, between two
+ # visible records. Boundaries belong to the production reader.
+ with rollout.open("a") as stream:
+ for _ in range(5):
+ stream.write(
+ json.dumps(
+ {
+ "type": "response_item",
+ "payload": {
+ "type": "reasoning",
+ "encrypted_content": "x" * (1024 * 1024),
+ },
+ }
+ )
+ + "\n"
+ )
+ self._send_json({"ok": True})
+ elif self.path == "/fixture/tool-envelope":
+ with rollout.open("a") as stream:
+ stream.write(
+ json.dumps(
+ {
+ "type": "response_item",
+ "payload": {
+ "type": "custom_tool_call_output",
+ "output": [
+ {
+ "type": "text",
+ "text": json.dumps(
+ {
+ "output": "Readable shell result\nSecond line",
+ "exit_code": 1,
+ "wall_time_seconds": 0.25,
+ "chunk_id": "synthetic",
+ }
+ ),
+ }
+ ],
+ },
+ }
+ )
+ + "\n"
+ )
+ self._send_json({"ok": True})
+ elif self.path == "/fixture/burst":
+ for index in range(330):
+ append_message(rollout, "assistant", f"Activity {index}")
+ self._send_json({"ok": True})
+ elif self.path == "/fixture/append":
+ append_message(
+ rollout, "assistant", "原 Agent 的新结果:已保留原会话。"
+ )
+ with rollout.open("a") as stream:
+ stream.write(
+ json.dumps(
+ {
+ "type": "event_msg",
+ "timestamp": "2026-01-01T00:00:03Z",
+ "payload": {"type": "task_complete"},
+ }
+ )
+ + "\n"
+ )
+ self._send_json({"ok": True})
+ elif self.path == "/fixture/busy":
+ with rollout.open("a") as stream:
+ stream.write(
+ json.dumps(
+ {
+ "type": "event_msg",
+ "timestamp": "2026-01-01T00:00:02Z",
+ "payload": {"type": "task_started"},
+ }
+ )
+ + "\n"
+ )
+ self._send_json({"ok": True})
+ elif self.path == "/fixture/rebind":
+ value = json.loads(registry.read_text())
+ value["goals"][0]["coordination"]["thread_agent_bindings"][0][
+ "thread_id"
+ ] = "22222222-2222-4222-8222-222222222222"
+ registry.write_text(json.dumps(value))
+ self._send_json({"ok": True})
+ elif self.path == "/fixture/fail-open":
+ fail_next_open = True
+ self._send_json({"ok": True})
+ else:
+ super().do_POST()
+
+ request.server.RequestHandlerClass = Handler
+ request.server.assets_dir = default_chat_assets_dir()
+ print(
+ json.dumps(
+ {"url": f"http://127.0.0.1:{request.server.server_address[1]}/chat/"}
+ ),
+ flush=True,
+ )
+ stop.wait()
diff --git a/examples/personal-workspace-browser/codex_attached_peer.py b/examples/personal-workspace-browser/codex_attached_peer.py
new file mode 100644
index 0000000000..a918099549
--- /dev/null
+++ b/examples/personal-workspace-browser/codex_attached_peer.py
@@ -0,0 +1,224 @@
+"""Disposable local Desktop owner for HTTP and installed-package UI validation.
+
+This is a standard-library fixture, not a production transport or executor.
+"""
+
+from contextlib import contextmanager
+import http.client
+import json
+import os
+from pathlib import Path
+import socket
+import sqlite3
+import struct
+import tempfile
+import threading
+import unittest
+from unittest.mock import patch
+
+from loopx.chat_server import ChatHTTPServer, ChatRequestHandler
+from loopx.capabilities.native_chat import codex_attached as provider
+
+THREAD = "11111111-1111-4111-8111-111111111111"
+
+
+def frame(connection):
+ def take(size):
+ value = b""
+ while len(value) < size:
+ data = connection.recv(size - len(value))
+ if not data:
+ raise EOFError
+ value += data
+ return value
+
+ size = struct.unpack(" { api.transformStatus = (fixture) => emptyWorkspace(fixture); } });
+ const { api, page } = session;
+ // "Sends nothing" means no Turn is created. Session admission on focus is
+ // not a send, so only turn creation and steering count.
+ const turnRequests = [];
+ page.on("request", (request) => {
+ if (request.method() === "POST" && /\/api\/chat\/sessions\/[^/]+\/turns/.test(request.url())) turnRequests.push(request.url());
+ });
+ try {
+ const firstGoal = page.getByTestId("personal-first-goal");
+ await firstGoal.waitFor({ state: "visible", timeout: 15_000 });
+ assert.equal(await page.locator(".personal-system-health-banner").count(), 0, "A missing default registry is not a health concern");
+ assert.equal(await page.locator(".personal-home-section-title").count(), 0, "No empty 'All Goals' heading in the first-Goal state");
+ assert.match(await firstGoal.locator("h2").innerText(), /还没有 Goal/);
+ assert.match(await page.locator(".personal-manager-greeting p").innerText(), /我们从第一个 Goal 开始/);
+ // Status source details stay collapsed and carry the source's own warning.
+ const details = firstGoal.locator("details.personal-first-goal-diagnostics");
+ assert.equal(await details.getAttribute("open"), null, "Diagnostics are collapsed by default");
+ assert.match((await details.locator("li").first().textContent()) ?? "", /registry file does not exist/);
+ await page.screenshot({ path: resolve(outputDir, "first-goal-home-desktop.png"), animations: "disabled" });
+
+ // An example request only fills the composer; nothing is sent.
+ const composer = page.locator(".personal-channel-composer textarea");
+ const example = firstGoal.locator(".personal-first-goal-examples button").first();
+ const exampleText = (await example.innerText()).trim();
+ await example.click();
+ assert.equal((await composer.inputValue()).trim(), exampleText, "The example fills the composer verbatim");
+ assert.equal(turnRequests.length, 0, `Selecting an example sends nothing: ${turnRequests.join(", ")}`);
+ await page.keyboard.press("Escape");
+
+ // Create a Goal opens the existing typed Goal form in place.
+ await firstGoal.locator(".personal-first-goal-create").click();
+ const form = page.locator("dialog.personal-action-form");
+ await form.waitFor({ state: "visible", timeout: 10_000 });
+ await page.screenshot({ path: resolve(outputDir, "first-goal-home-create.png"), animations: "disabled" });
+ await page.keyboard.press("Escape");
+ await form.waitFor({ state: "hidden", timeout: 10_000 });
+
+ // A warm refresh that fails is not an empty workspace: both status reads
+ // answer HTTP 500, the invitation is withdrawn, the failure is shown with
+ // a retry, and the header never reports an update. Repairing the source
+ // and retrying restores the first-Goal state.
+ api.statusFailure = 500;
+ const refresh = page.getByRole("button", { name: "刷新状态", exact: true });
+ await refresh.click();
+ const sourceRetry = page.getByTestId("personal-source-retry");
+ await sourceRetry.waitFor({ state: "visible", timeout: 15_000 });
+ assert.equal(await firstGoal.count(), 0, "A failed read withdraws the first-Goal invitation");
+ const failedBanner = page.locator(".personal-system-health-banner");
+ assert.match(await failedBanner.innerText(), /状态来源读取失败/, "The failure is shown in owner language");
+ assert.match(await failedBanner.innerText(), /最近一次读取失败:/);
+ const headerStatus = page.locator(".personal-channel-title, header").first();
+ await page.locator("small", { hasText: "刷新失败" }).first().waitFor({ state: "visible", timeout: 10_000 });
+ assert.equal(await page.locator("small", { hasText: "刚刚更新" }).count(), 0, "A failed refresh never reports an update");
+ void headerStatus;
+ await page.screenshot({ path: resolve(outputDir, "first-goal-home-source-failed.png"), animations: "disabled" });
+ api.statusFailure = null;
+ await sourceRetry.click();
+ await firstGoal.waitFor({ state: "visible", timeout: 15_000 });
+ assert.equal(await page.locator(".personal-system-health-banner").count(), 0, "A repaired source clears the failure");
+ await page.locator("small", { hasText: "刚刚更新" }).first().waitFor({ state: "visible", timeout: 10_000 });
+
+ // English copy and the phone composition.
+ await page.evaluate(() => localStorage.setItem("loopx-pw-locale", "en"));
+ await page.reload({ waitUntil: "networkidle" });
+ await firstGoal.waitFor({ state: "visible", timeout: 15_000 });
+ assert.match(await firstGoal.locator("h2").innerText(), /No Goals yet/);
+ await page.screenshot({ path: resolve(outputDir, "first-goal-home-en.png"), animations: "disabled" });
+ await page.evaluate(() => localStorage.removeItem("loopx-pw-locale"));
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.reload({ waitUntil: "networkidle" });
+ await firstGoal.waitFor({ state: "visible", timeout: 15_000 });
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true, "No horizontal overflow on a phone");
+ await page.screenshot({ path: resolve(outputDir, "first-goal-home-phone.png"), animations: "disabled" });
+ coverageEntries.push(...await session.close());
+ } catch (error) {
+ await session.close().catch(() => {});
+ throw error;
+ }
+
+ // Negative: an unhealthy source with zero Goals keeps its banner and does
+ // not present the first-Goal invitation.
+ const unhealthy = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width: 1440, height: 900 },
+ beforeGoto: (api) => { api.transformStatus = (fixture) => emptyWorkspace(fixture, { healthy: false }); } });
+ try {
+ const banner = unhealthy.page.locator(".personal-system-health-banner");
+ await banner.waitFor({ state: "visible", timeout: 15_000 });
+ const bannerText = await banner.innerText();
+ assert.match(bannerText, /控制面契约检查未通过:1 项错误,0 项警告/, "Health issues read as owner language");
+ assert.doesNotMatch(bannerText, /payload\.ok/, "No protocol fragment in primary copy");
+ assert.equal(await banner.locator("details.personal-system-health-diagnostics").getAttribute("open"), null, "Source lines stay collapsed");
+ assert.match((await banner.locator("details li").first().textContent()) ?? "", /registry file is not valid JSON/);
+ assert.equal(await unhealthy.page.getByTestId("personal-first-goal").count(), 0, "An unhealthy source does not invite a first Goal");
+ await unhealthy.page.screenshot({ path: resolve(outputDir, "first-goal-home-unhealthy.png"), animations: "disabled" });
+ coverageEntries.push(...await unhealthy.close());
+ } catch (error) {
+ await unhealthy.close().catch(() => {});
+ throw error;
+ }
+ return { coverageEntries };
+ },
+};
diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs
index 4968b5416e..117aa6a8b8 100644
--- a/examples/personal-workspace-browser/fixture.mjs
+++ b/examples/personal-workspace-browser/fixture.mjs
@@ -446,6 +446,8 @@ export async function installApi(page, { fixturePort = port, goalSubagentConfigu
failNextLifecyclePreview: false,
failNextActionPreview: false,
failNextStatusRequest: false,
+ /** When set, every status read answers this HTTP status until cleared: a source that cannot be projected. */
+ statusFailure: null,
freezeGoalSubagentStatusProjection: false,
goalSubagentConfigurationEnabled,
goalActivationStates: new Map([
@@ -503,6 +505,10 @@ export async function installApi(page, { fixturePort = port, goalSubagentConfigu
};
await page.route(`http://127.0.0.1:${fixturePort}/status.json*`, async (route) => {
state.statusRequestCount += 1;
+ if (state.statusFailure) {
+ await route.fulfill({ contentType: "application/json", json: { ok: false, error: "LoopX status could not be projected for the workspace." }, status: state.statusFailure });
+ return;
+ }
const requestUrl = new URL(route.request().url());
const requestedGoalId = requestUrl.searchParams.get("goal_id");
if (progressiveWorkspace && requestUrl.searchParams.get("view") === "workspace-directory") {
diff --git a/examples/personal-workspace-browser/goal-acceptance-oracle-backend.py b/examples/personal-workspace-browser/goal-acceptance-oracle-backend.py
new file mode 100644
index 0000000000..fa7fbc166b
--- /dev/null
+++ b/examples/personal-workspace-browser/goal-acceptance-oracle-backend.py
@@ -0,0 +1,111 @@
+"""Disposable real SQLite acceptance owner for the packaged browser journey."""
+
+import hashlib
+import json
+from pathlib import Path
+import sys
+
+from loopx.chat_server import ChatHTTPServer, ChatRequestHandler
+from loopx.chat_action_store import ChatActionStore
+from loopx.chat_actions import ChatActionService
+from loopx.control_plane.coordination.runtime_shadow import (
+ build_todo_runtime_shadow_projection,
+)
+from loopx.control_plane.testing.canary_harness import write_fixture_registry
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tests" / "control_plane"))
+from canonical_authority_fixture import initialize_canonical_authority
+
+root = Path(sys.argv[1])
+project = root / "project"
+project.mkdir()
+state = project / "state.md"
+state.write_text(
+ "---\nstatus: active\n---\n# Synthetic acceptance workspace\n## Agent Todo\n"
+)
+registry, runtime = root / "registry.json", root / "runtime"
+write_fixture_registry(
+ project=project,
+ registry_path=registry,
+ runtime_root=runtime,
+ goal_id="multi-agent-projection",
+ domain="acceptance",
+ adapter_kind="generic_project_goal_v0",
+ state_file=str(state),
+ registered_agents=["agent-a"],
+)
+todo = {
+ "schema_version": "todo_item_v0",
+ "todo_id": "todo_work",
+ "role": "agent",
+ "text": "Produce the expected export",
+ "status": "open",
+ "done": False,
+ "task_class": "advancement_task",
+ "action_kind": "implement",
+ "index": 1,
+ "source_section": "Agent Todo",
+ "archive_state": "active",
+ "claimed_by": "agent-a",
+}
+initialize_canonical_authority(
+ runtime,
+ "multi-agent-projection",
+ build_todo_runtime_shadow_projection(
+ goal_id="multi-agent-projection", todos=[todo], handoff_mode="soft_claim"
+ ),
+ state_path=state,
+ provider="sqlite",
+)
+checker = project / "check.py"
+checker.write_text(
+ "from pathlib import Path\nassert Path('result.txt').read_text() == Path('expected.txt').read_text()\n"
+)
+(project / "expected.txt").write_text("usable export")
+(project / "result.txt").write_text("usable export")
+document = {
+ "scope": {"kind": "selected_work", "todo_ids": ["todo_work"]},
+ "objective": "Validate the exact export version",
+ "non_goals": [
+ "This synthetic fixture does not run a model or certify live adoption"
+ ],
+ "criteria": [
+ {
+ "id": "export",
+ "description": "The export matches the expected input",
+ "validation_argv": ["python3", "check.py"],
+ "validation_timeout_seconds": 5,
+ "validation_files": [
+ {
+ "path": "check.py",
+ "sha256": hashlib.sha256(checker.read_bytes()).hexdigest(),
+ }
+ ],
+ "oracle": {
+ "kind": "owner_pinned_oracle",
+ "source_id": "export-oracle",
+ "input_refs": ["expected.txt"],
+ "output_refs": ["result.txt"],
+ },
+ }
+ ],
+ "bindings": [{"todo_id": "todo_work", "criterion_ids": ["export"]}],
+}
+(root / "owner-contract.json").write_text(json.dumps(document))
+server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler)
+server.registry_path, server.runtime_root = registry, runtime
+server.runtime_root_override, server.verbose = str(runtime), False
+server.selected_goal_id, server.scan_roots, server.limit = None, [], 20
+server.goal_subagent_configuration_enabled = False
+server.action_store = ChatActionStore(runtime / "chat" / "actions")
+server.action_service = ChatActionService(store=server.action_store, registry_path=registry)
+print(
+ json.dumps(
+ {"port": server.server_port, "registry": str(registry), "runtime": str(runtime)}
+ ),
+ flush=True,
+)
+try:
+ server.serve_forever()
+finally:
+ server.server_close()
diff --git a/examples/personal-workspace-browser/goal-acceptance-oracle.mjs b/examples/personal-workspace-browser/goal-acceptance-oracle.mjs
new file mode 100644
index 0000000000..69994e5ea8
--- /dev/null
+++ b/examples/personal-workspace-browser/goal-acceptance-oracle.mjs
@@ -0,0 +1,119 @@
+// Packaged settings and evidence with a real disposable SQLite/HTTP/CLI owner.
+import assert from "node:assert/strict";
+import {spawn, execFileSync} from "node:child_process";
+import {createInterface} from "node:readline";
+import {mkdtemp, mkdir, readFile, rm, writeFile} from "node:fs/promises";
+import {tmpdir} from "node:os";
+import {resolve} from "node:path";
+import {resolveTestPython} from "../../scripts/test-python.mjs";
+import {launchBrowser, loadPlaywright, waitForHttp} from "../dashboard-browser-smoke-support.mjs";
+import {fetchWorkspaceBackend, openWorkspacePage} from "./scenario-context.mjs";
+import {outputDir, packaged, port, repoRoot, startServer} from "./fixture.mjs";
+
+const root = await mkdtemp(resolve(tmpdir(), "loopx-oracle-browser-"));
+const child = spawn(resolveTestPython(), ["-u", "examples/personal-workspace-browser/goal-acceptance-oracle-backend.py", root], {cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"]});
+let diagnostic = "";
+child.stderr.on("data", chunk => diagnostic = (diagnostic + chunk).slice(-4000));
+const lines = createInterface({input: child.stdout});
+let browser, server, context;
+try {
+ const authority = await new Promise((accept, reject) => {
+ const timer = setTimeout(() => reject(Error(`Oracle fixture did not start: ${diagnostic}`)), 20000);
+ child.once("exit", () => {clearTimeout(timer); reject(Error(`Oracle fixture exited: ${diagnostic}`));});
+ lines.once("line", line => {clearTimeout(timer); accept(JSON.parse(line));});
+ });
+ const backend = `http://127.0.0.1:${authority.port}`;
+ server = await startServer();
+ const url = `http://127.0.0.1:${port}/${packaged ? "chat/" : ""}?statusUrl=/status.json`;
+ await waitForHttp(url);
+ browser = await launchBrowser(loadPlaywright().chromium);
+ context = await openWorkspacePage(browser, url, {beforeGoto: async (_api, page) => {
+ await page.addInitScript(() => localStorage.setItem("loopx-pw-locale", "zh-CN"));
+ await page.route("**/api/chat/goal-acceptance**", async route => route.fulfill({response: await fetchWorkspaceBackend(route, backend)}));
+ await page.route("**/api/actions**", async route => route.fulfill({response: await fetchWorkspaceBackend(route, backend)}));
+ await page.route("**/api/chat/delivery-review**", async route => route.fulfill({response: await fetchWorkspaceBackend(route, backend)}));
+ }});
+ const {page} = context;
+ const cli = (...args) => JSON.parse(execFileSync(resolveTestPython(), ["-m", "loopx.cli", "--format", "json", "--registry", authority.registry, "--runtime-root", authority.runtime,
+ "goal-acceptance", ...args, "--goal-id", "multi-agent-projection"], {cwd: repoRoot, encoding: "utf8"}));
+ const read = () => cli("inspect");
+ assert.equal(read().goal_acceptance_contract.enabled, false);
+ await page.locator(".personal-goal-link", {hasText: "Multi Agent Projection"}).click();
+ await page.getByRole("button", {name: "概览", exact: true}).click();
+ // The Agent does the technical preparation. The owner does not enter settings,
+ // inspect revisions, upload JSON or confirm the same facts in a second form.
+ cli("prepare", "--agent-id", "agent-a", "--document", resolve(root, "owner-contract.json"),
+ "--operation-id", "older-export-plan", "--execute");
+ const prepared = cli("prepare", "--agent-id", "agent-a", "--document", resolve(root, "owner-contract.json"),
+ "--operation-id", "prepare-export-check", "--execute");
+ assert.equal(prepared.executed, false);
+ assert.equal(read().goal_acceptance_contract.enabled, false, "preparation is not owner approval");
+ const panel = page.locator(".delivery-acceptance-contract");
+ const adopt = panel.getByRole("button", {name: "采用并检查", exact: true});
+ await adopt.waitFor();
+ await adopt.scrollIntoViewIfNeeded();
+ await mkdir(outputDir, {recursive: true});
+ await page.screenshot({path: resolve(outputDir, "oracle-plan-desktop.png"), animations: "disabled"});
+ await page.setViewportSize({width: 390, height: 844});
+ await adopt.scrollIntoViewIfNeeded();
+ await page.screenshot({path: resolve(outputDir, "oracle-plan-mobile.png"), animations: "disabled"});
+ await page.setViewportSize({width: 1512, height: 982});
+ let applyCount = 0;
+ page.on("request", req => {if (req.method() === "POST" && /\/api\/actions\/[^/]+\/apply$/.test(new URL(req.url()).pathname)) applyCount++;});
+ await adopt.focus(); await page.keyboard.press("Enter");
+ await panel.getByText("当前版本已核对", {exact: true}).waitFor();
+ assert.equal(applyCount, 1, "one owner action adopts and checks the prepared plan");
+ assert.equal(read().goal_acceptance_contract.criteria[0].oracle.state, "current");
+ assert.equal(await panel.locator('input[type="file"]').isVisible(), false, "technical import is optional");
+ await panel.scrollIntoViewIfNeeded();
+ await page.screenshot({path: resolve(outputDir, "oracle-current-desktop.png"), animations: "disabled"});
+ await page.setViewportSize({width: 390, height: 844});
+ await page.emulateMedia({reducedMotion: "reduce"});
+ await panel.scrollIntoViewIfNeeded();
+ await page.screenshot({path: resolve(outputDir, "oracle-current-mobile.png"), animations: "disabled"});
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
+ const run = panel.getByRole("button", {name: "检查交付", exact: true});
+ await writeFile(resolve(root, "project/result.txt"), "new version without verification");
+ // Read-only observation invalidates the old green state without rerunning.
+ await panel.getByText("来源或版本已变化,请重新检查", {exact: true}).waitFor({timeout: 25000});
+ assert.equal(await panel.getByText("产物检查通过", {exact: true}).count(), 0);
+ const oldOperation = read().goal_acceptance_contract.verification.operation_id;
+ assert.equal(applyCount, 1);
+ await run.click();
+ await panel.getByText("检查失败;修复交付物后可再次检查", {exact: true}).waitFor();
+ assert.notEqual(read().goal_acceptance_contract.verification.operation_id, oldOperation);
+ await writeFile(resolve(root, "project/result.txt"), "usable export");
+ await run.click(); await panel.getByText("当前版本已核对", {exact: true}).waitFor();
+ // Advanced migration remains available; a rejected configuration cannot erase
+ // the working contract. Recovery reads, rather than automatically executes.
+ await panel.getByText("高级配置与诊断", {exact: true}).click();
+ const initial = read();
+ const invalid = JSON.parse(await readFile(resolve(root, "owner-contract.json"), "utf8"));
+ invalid.criteria[0].validation_argv = ["/usr/bin/true", "check.py"];
+ await writeFile(resolve(root, "invalid-contract.json"), JSON.stringify(invalid));
+ await panel.locator('input[type="file"]').setInputFiles(resolve(root, "invalid-contract.json"));
+ const rejectedSave = page.waitForResponse(response => response.url().includes("/api/chat/goal-acceptance") && response.request().method() === "POST");
+ await panel.getByRole("button", {name: "保存配置", exact: true}).click();
+ assert.equal((await rejectedSave).status(), 409);
+ await panel.getByRole("alert").waitFor();
+ assert.deepEqual(read(), initial);
+ await panel.getByRole("alert").getByRole("button", {name: "刷新结果", exact: true}).click();
+ await panel.getByRole("button", {name: "停用检查", exact: true}).click();
+ await panel.waitFor({state: "hidden"});
+ assert.equal(read().goal_acceptance_contract.enabled, false);
+ const expected = "Failed to load resource: the server responded with a status of 409 (Conflict)";
+ const at = context.errors.indexOf(expected); if (at >= 0) context.errors.splice(at, 1);
+ assert.deepEqual(context.errors.filter(value => !value.includes("404")), []);
+ console.log(JSON.stringify({status: "PASS", boundary: "Packaged UI with actual disposable SQLite/HTTP/CLI; Agent preparation is scripted, not live model or installed App evidence", checks: ["automatic prepared-plan discovery", "one contextual owner action", "no mandatory JSON/revision form", "exact native readback", "changed output clears green without execution", "failure/recovery", "advanced import rejection", "disable/default-off", "390px", "keyboard/reduced-motion"]}));
+} catch (error) {
+ if (context?.page) {
+ await mkdir(outputDir, {recursive: true});
+ await context.page.screenshot({path: resolve(outputDir, "oracle-failure.png"), animations: "disabled"});
+ console.error(JSON.stringify({errors: context.errors, body: (await context.page.locator("body").innerText()).slice(-6000)}));
+ }
+ throw error;
+} finally {
+ await context?.close(); await browser?.close(); server?.kill("SIGTERM");
+ lines.close(); child.kill("SIGTERM");
+ await rm(root, {recursive: true, force: true});
+}
diff --git a/examples/personal-workspace-browser/goal-workspace-create.mjs b/examples/personal-workspace-browser/goal-workspace-create.mjs
index 67b53b6e6a..30b044f8c2 100644
--- a/examples/personal-workspace-browser/goal-workspace-create.mjs
+++ b/examples/personal-workspace-browser/goal-workspace-create.mjs
@@ -41,6 +41,7 @@ export const goalWorkspaceCreateScenario = {
await task.fill(text);await now.getByRole('button',{name:'检查任务',exact:true}).click();
const drawer=page.locator('.personal-context-drawer');await drawer.waitFor();
assert.equal(requests.length,1);assert.equal(requests[0].normalized_parameters.text,text);
+ assert.equal(requests[0].normalized_parameters.priority,undefined,'Ordinary creation leaves priority to the Todo owner');
await drawer.locator('.personal-primary-action').click();
await drawer.getByText(/失败/).first().waitFor();
await page.locator('.personal-drawer-close').click();assert.equal(await task.inputValue(),text);
@@ -53,9 +54,11 @@ export const goalWorkspaceCreateScenario = {
await now.locator('.goal-work-row').filter({hasText:text}).waitFor();
assert.equal(await task.inputValue(),'');
const {stdout}=await promisify(execFile)(python,['-c','from loopx.entrypoint import main; main()','--format','json','--registry',ready.registry,'todo','list','--goal-id','progress-projection','--role','agent'],{cwd:repoRoot,env:{...process.env,PYTHONPATH:repoRoot,LOOPX_USAGE_PING:'0'},timeout:30000});
- const result=JSON.parse(stdout);assert.equal(result.todos.filter(todo=>todo.text===text).length,1,'CLI must read exactly one created task');
+ const result=JSON.parse(stdout),created=result.todos.filter(todo=>todo.title===text);
+ assert.equal(created.length,1,'CLI must read exactly one created task');
+ assert.equal(created[0].priority,'P1','The real Todo owner defaults omitted creation priority');
await page.screenshot({path:resolve(outputDir,'goal-workspace-created.png'),animations:'disabled'});
- return {coverageEntries:[],note:'Packaged inline Add task -> real action HTTP/File Todo owner -> refreshed row and independent CLI readback. Failed apply retains draft; recovery creates once. No model execution.'};
+ return {coverageEntries:[],note:'Packaged inline Add task -> real action HTTP/File Todo owner -> refreshed row and independent CLI readback with default P1. Failed apply retains draft; recovery creates once. No model execution.'};
} catch(error){if(diagnostics)process.stderr.write(diagnostics);if(context)await context.page.screenshot({path:resolve(outputDir,'goal-workspace-create-failed.png')});throw error;}
finally {clearTimeout(timer);await context?.close();lines.close();if(child.exitCode===null&&child.signalCode===null){child.kill('SIGTERM');const kill=setTimeout(()=>child.kill('SIGKILL'),5000);try{await closed;}finally{clearTimeout(kill);}}await rm(root,{recursive:true,force:true});}
},
diff --git a/examples/personal-workspace-browser/goal-workspace-m2.mjs b/examples/personal-workspace-browser/goal-workspace-m2.mjs
index 74098aaae6..0b1d426dd6 100644
--- a/examples/personal-workspace-browser/goal-workspace-m2.mjs
+++ b/examples/personal-workspace-browser/goal-workspace-m2.mjs
@@ -171,7 +171,7 @@ async function verifyRemoteSelection(browser, url) {
await page.route("http://127.0.0.1:8978/status.json*", route => route.fulfill({json:snapshot}));
page.on("request",request => {
const parsed = new URL(request.url());
- if (parsed.port === new URL(url).port && /\/api\/chat\/(todo\/detail|completed-todos|delivery-review|goal-results)/.test(parsed.pathname)) localOwnerReads.push(parsed.pathname);
+ if (parsed.port === new URL(url).port && /\/api\/chat\/(todo\/detail|completed-todos|delivery-review|goal-results|codex-attached)/.test(parsed.pathname)) localOwnerReads.push(parsed.pathname);
});
}});
try {
@@ -180,8 +180,14 @@ async function verifyRemoteSelection(browser, url) {
assert.equal(await page.locator(".personal-context-drawer").count(),0,"An unknown remote Todo must not acquire a local request reader");
await page.getByRole("status").filter({hasText:"链接中的任务未出现在此来源的当前快照中"}).waitFor();
assert.equal(await page.getByRole("button",{name:"添加任务",exact:true}).count(),0);
+ assert.equal(await page.locator('.goal-owner-conversation').count(),0,"A remote snapshot cannot open a local Agent conversation");
await page.getByRole("region",{name:"当前工作",exact:true}).getByRole("button",{name:"关系图",exact:true}).click();
assert.equal(await page.locator(".work-map").count(),0,"Remote selection cannot reuse a local map");
+ target.searchParams.set("view", "conversation");
+ target.searchParams.set("workAgent", "worker");
+ await page.goto(target.href);
+ await page.getByRole("status").filter({hasText:"此来源仅提供状态快照,无法在这里读取或回复原会话。"}).waitFor();
+ assert.equal(await page.getByRole("region", {name:"Agent 工作会话",exact:true}).getByRole("textbox").count(),0);
assert.deepEqual(localOwnerReads,[],"Remote deep link caused local owner reads");
} finally {await session.close();}
}
diff --git a/examples/personal-workspace-browser/oracle-controls-desktop.png b/examples/personal-workspace-browser/oracle-controls-desktop.png
new file mode 100644
index 0000000000..77728f841a
Binary files /dev/null and b/examples/personal-workspace-browser/oracle-controls-desktop.png differ
diff --git a/examples/personal-workspace-browser/oracle-controls-mobile.png b/examples/personal-workspace-browser/oracle-controls-mobile.png
new file mode 100644
index 0000000000..f036bc0982
Binary files /dev/null and b/examples/personal-workspace-browser/oracle-controls-mobile.png differ
diff --git a/examples/personal-workspace-browser/oracle-current-desktop.png b/examples/personal-workspace-browser/oracle-current-desktop.png
new file mode 100644
index 0000000000..5fe584601e
Binary files /dev/null and b/examples/personal-workspace-browser/oracle-current-desktop.png differ
diff --git a/examples/personal-workspace-browser/oracle-current-mobile.png b/examples/personal-workspace-browser/oracle-current-mobile.png
new file mode 100644
index 0000000000..24764acede
Binary files /dev/null and b/examples/personal-workspace-browser/oracle-current-mobile.png differ
diff --git a/examples/personal-workspace-browser/screenshots/codex-attached/README.md b/examples/personal-workspace-browser/screenshots/codex-attached/README.md
new file mode 100644
index 0000000000..6bb5692318
--- /dev/null
+++ b/examples/personal-workspace-browser/screenshots/codex-attached/README.md
@@ -0,0 +1,19 @@
+# Original Codex App conversation UI
+
+Public synthetic visual evidence from the packaged browser regression. All Goals,
+Agent messages and native owner acknowledgements are disposable fixtures. These
+images do not depict a real account, installation or native live send.
+
+Reproduce from the repository root after building the Chat frontend:
+
+```sh
+LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 \
+LOOPX_PERSONAL_WORKSPACE_SCENARIO=attached-host-follow-up \
+node examples/personal-workspace-browser-smoke.mjs
+```
+
+The scenario writes current screenshots under `output/playwright/personal-workspace`.
+The checked-in images document the proposed entry, desktop conversation, narrow
+conversation, explicit open-failure state and unconfirmed delivery; they are not
+golden pixel oracles. Confirmed delivery keeps an empty composer available;
+unconfirmed delivery exposes receipt recovery and never automatically resends.
diff --git a/examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-conversation-desktop.png b/examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-conversation-desktop.png
new file mode 100644
index 0000000000..56aae62a18
Binary files /dev/null and b/examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-conversation-desktop.png differ
diff --git a/examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-conversation-phone.png b/examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-conversation-phone.png
new file mode 100644
index 0000000000..b3a5895461
Binary files /dev/null and b/examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-conversation-phone.png differ
diff --git a/examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-entry-desktop.png b/examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-entry-desktop.png
new file mode 100644
index 0000000000..f8b6b89c98
Binary files /dev/null and b/examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-entry-desktop.png differ
diff --git a/examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-open-error-desktop.png b/examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-open-error-desktop.png
new file mode 100644
index 0000000000..c1c61d9439
Binary files /dev/null and b/examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-open-error-desktop.png differ
diff --git a/examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-pending-desktop.png b/examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-pending-desktop.png
new file mode 100644
index 0000000000..8e01ef186b
Binary files /dev/null and b/examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-pending-desktop.png differ
diff --git a/examples/personal-workspace-browser/showcase-workspace.mjs b/examples/personal-workspace-browser/showcase-workspace.mjs
index 2a8ebbfa8d..47f64a0866 100644
--- a/examples/personal-workspace-browser/showcase-workspace.mjs
+++ b/examples/personal-workspace-browser/showcase-workspace.mjs
@@ -95,6 +95,13 @@ function deliverySnapshot() {
/** Installs the showcase status, usage and delivery reads. `state` stays live: change it, then refresh. */
export async function installShowcaseWorkspace(api, page, state) {
+ // The check panel refreshes its owning read independently of the map. This
+ // visual fixture supplies that same synthetic contract; real execution is
+ // qualified by goal-acceptance-oracle.mjs against SQLite/HTTP/CLI.
+ await page.route("**/api/chat/goal-acceptance?*", route => route.fulfill({
+ contentType: "application/json", json: {ok: true, goal_id: goalId,
+ provider_revision: "showcase:3", goal_acceptance_contract: acceptance.goal_acceptance_contract},
+ }));
await page.route("**/api/chat/delivery-review?*", (route) => state.deliveryFails
? route.fulfill({ status: 503, contentType: "application/json", json: { ok: false, error: "temporarily unavailable" } })
: route.fulfill({ contentType: "application/json", json: deliverySnapshot() }));
diff --git a/examples/personal-workspace-browser/team-evidence.mjs b/examples/personal-workspace-browser/team-evidence.mjs
index 509f83d925..c6a2646daf 100644
--- a/examples/personal-workspace-browser/team-evidence.mjs
+++ b/examples/personal-workspace-browser/team-evidence.mjs
@@ -158,7 +158,7 @@ export const teamEvidenceScenario = {
await evidence.getByText(/验收不代表协调员已采用/).waitFor();
await evidence.getByText("尚无请求方采用记录。", {exact: true}).waitFor();
mode.fixtureAdoptionState = "current";
- await evidence.getByRole("button", {name: "重新读取证据", exact: true}).click();
+ await evidence.getByRole("button", {name: "重新检查交付", exact: true}).click();
await evidence.getByText("已记录采用 · 后续结果验收有效", {exact: true}).waitFor();
await evidence.getByRole("button", {name: "查看后续结果", exact: true}).click();
await evidence.getByLabel("证据内容: synthesis.json").waitFor();
@@ -204,20 +204,20 @@ export const teamEvidenceScenario = {
await evidence.getByRole("button", {name: "accepted-analysis", exact: true}).first().click();
await content.waitFor();
mode.fixtureAdoptionState = "unavailable";
- await evidence.getByRole("button", {name: "重新读取证据", exact: true}).click();
+ await evidence.getByRole("button", {name: "重新检查交付", exact: true}).click();
await evidence.getByText("采用证据已失效或无法核验", {exact: true}).waitFor();
assert.equal(await evidence.getByText("已记录采用 · 后续结果验收有效", {exact: true}).count(), 0);
mode.fixtureCorrectionEpisode = true;
- await evidence.getByRole("button", {name: "重新读取证据", exact: true}).click();
+ await evidence.getByRole("button", {name: "重新检查交付", exact: true}).click();
const episode = evidence.getByRole("region", {name: "纠偏证据路径"});
await episode.getByRole("button", {name: "核验关联执行", exact: true}).click();
await episode.getByText(/采用证据无法核验/).waitFor();
delete mode.fixtureAdoptionState;
- await evidence.getByRole("button", {name: "重新读取证据", exact: true}).click();
+ await evidence.getByRole("button", {name: "重新检查交付", exact: true}).click();
await episode.getByRole("button", {name: "核验关联执行", exact: true}).click();
await episode.getByText(/尚无请求方采用/).waitFor();
mode.fixtureAdoptionState = "current";
- await evidence.getByRole("button", {name: "重新读取证据", exact: true}).click();
+ await evidence.getByRole("button", {name: "重新检查交付", exact: true}).click();
assert.equal(await episode.getByText("independent-reviewer").count(), 0, "The path is not inferred before a deliberate read");
await episode.getByRole("button", {name: "核验关联执行", exact: true}).click();
await episode.getByText("independent-reviewer", {exact: true}).waitFor();
@@ -243,7 +243,7 @@ export const teamEvidenceScenario = {
assert.equal(await evidence.getByText("已记录采用 · 后续结果验收有效", {exact: true}).count(), 0,
"Rejected core evidence cannot retain accepted adoption details");
await page.unroute("**/api/chat/sessions/*/loopx", rejectedSource);
- await evidence.getByRole("button", {name: "重新读取证据", exact: true}).click();
+ await evidence.getByRole("button", {name: "重新检查交付", exact: true}).click();
await episode.getByRole("button", {name: "核验关联执行", exact: true}).click();
await episode.getByText("independent-reviewer", {exact: true}).waitFor();
let revisionRechecks = 0;
@@ -265,14 +265,14 @@ export const teamEvidenceScenario = {
assert.equal(await content.count(), 0, "A changed revision withdraws the earlier report");
assert.equal(await evidence.getByText("newer revision").count(), 0);
await page.unroute("**/api/chat/sessions/*/loopx", changedRevision);
- await evidence.getByRole("button", {name: "重新读取证据", exact: true}).click();
+ await evidence.getByRole("button", {name: "重新检查交付", exact: true}).click();
await content.waitFor();
assert.equal(api.turnRequests.length, 0, "Evidence reading must not start a model");
await page.screenshot({path: resolve(outputDir, "team-evidence-desktop.png"), animations: "disabled"});
// Reading a consumer is not adoption. Only the explicit owner decision
// writes; a lost post-commit acknowledgement reconciles without rewriting.
delete mode.fixtureAdoptionState;
- await evidence.getByRole("button", {name: "重新读取证据", exact: true}).click();
+ await evidence.getByRole("button", {name: "重新检查交付", exact: true}).click();
await content.waitFor();
await evidence.locator(".goal-team-lineage-detail > summary").click();
const adoption = evidence.locator(".goal-team-adoption");
@@ -384,7 +384,7 @@ export const teamEvidenceScenario = {
}
return route.fallback();
});
- await evidence.getByRole("button", {name: "重新读取证据", exact: true}).click();
+ await evidence.getByRole("button", {name: "重新检查交付", exact: true}).click();
await evidence.getByRole("alert").filter({hasText: "已清除上次证据"}).waitFor();
assert.equal(await content.count(), 0);
assert.equal(await episode.count(), 0, "Lost observation removes the correction path");
diff --git a/examples/repository-hygiene-smoke.py b/examples/repository-hygiene-smoke.py
index cf6dbd747e..39f8e13d02 100644
--- a/examples/repository-hygiene-smoke.py
+++ b/examples/repository-hygiene-smoke.py
@@ -186,6 +186,12 @@ def validate_public_private_boundary() -> None:
if hits:
detail = "\n".join(hits)
raise AssertionError(f"public/private boundary violations:\n{detail}")
+ # `ok` only reports what the scan read. A directory it could not list is an
+ # unscanned input, so this smoke must not pass on it either.
+ unreadable_directories = list(boundary.get("unreadable_directories") or [])
+ if unreadable_directories:
+ detail = "\n".join(unreadable_directories)
+ raise AssertionError(f"public/private boundary scan could not list:\n{detail}")
if not boundary.get("ok"):
raise AssertionError(
f"public/private boundary scan failed: {boundary.get('ok')}"
diff --git a/examples/semantic-vocabulary-drift-smoke.py b/examples/semantic-vocabulary-drift-smoke.py
index b7eb6f3d3f..f24251c8ec 100755
--- a/examples/semantic-vocabulary-drift-smoke.py
+++ b/examples/semantic-vocabulary-drift-smoke.py
@@ -192,7 +192,7 @@
# silently; that is the gap the anchor exists to close.
COVERAGE_ANCHOR = {
"vocabularies": 28,
- "owner_symbols": 53,
+ "owner_symbols": 54,
"literal_scan_fields": 1,
"projections": 1,
"relations": 9,
diff --git a/loopx/canary/premerge.py b/loopx/canary/premerge.py
index ee7b4b66c3..a0f2d93ae8 100644
--- a/loopx/canary/premerge.py
+++ b/loopx/canary/premerge.py
@@ -200,22 +200,32 @@ def _public_boundary_changed_files_run(
else:
boundary = scan_public_boundary(existing_paths, registry={})
hits = [str(item) for item in boundary.get("hits") or []]
+ # A directory this scan could not list is an unscanned input, not a
+ # clean one, so the check fails closed like a recorded hit.
+ unreadable_directories = [
+ str(item) for item in boundary.get("unreadable_directories") or []
+ ]
check.update(
{
- "status": "passed" if boundary.get("ok") else "failed",
- "ok": bool(boundary.get("ok")),
+ "status": (
+ "passed"
+ if boundary.get("ok") and not unreadable_directories
+ else "failed"
+ ),
+ "ok": bool(boundary.get("ok")) and not unreadable_directories,
"duration_seconds": round(time.monotonic() - started, 3),
"stdout_tail": (
f"public boundary scan clean: {boundary.get('scanned_files')} files"
- if boundary.get("ok")
+ if boundary.get("ok") and not unreadable_directories
else ""
),
- "stderr_tail": "\n".join(hits)[-800:] if hits else "",
+ "stderr_tail": "\n".join(hits + unreadable_directories)[-800:],
"boundary": {
"scanned_files": boundary.get("scanned_files"),
"skipped_private_state_files": boundary.get("skipped_private_state_files"),
"allowed_hits": boundary.get("allowed_hits"),
"hit_count": len(hits),
+ "unreadable_directory_count": len(unreadable_directories),
},
}
)
diff --git a/loopx/capabilities/native_chat/codex_attached.py b/loopx/capabilities/native_chat/codex_attached.py
new file mode 100644
index 0000000000..e2f1e10d18
--- /dev/null
+++ b/loopx/capabilities/native_chat/codex_attached.py
@@ -0,0 +1,267 @@
+"""Owner-local Codex Desktop transport. Never starts/resumes an executor.
+
+The Desktop follower IPC is private and version-sensitive. Protocol discovery
+comes from codex-web; this independent adapter implements only discovery and
+owner-routed input. History stays in the host store, read in bounded windows.
+"""
+
+from __future__ import annotations
+
+from contextlib import closing
+import hashlib
+import json
+import os
+from pathlib import Path
+import socket
+import sqlite3
+import stat
+import struct
+import subprocess
+import sys
+import time
+from typing import Any
+from uuid import UUID, uuid4
+
+from ...codex_app_thread_activity import codex_homes, observe_codex_threads
+from .codex_history import (
+ history_revision as history_revision,
+ read_messages as read_messages,
+)
+
+MAX_FRAME_BYTES = 8 * 1024 * 1024
+
+
+class DesktopUnavailable(RuntimeError):
+ pass
+
+
+class DesktopUncertain(RuntimeError):
+ """A submitted input has no acknowledgement; never retry automatically."""
+
+
+def open_conversation(thread_id: str) -> None:
+ """Request OS navigation to an already validated binding, without host input."""
+ if str(UUID(thread_id)) != thread_id:
+ raise ValueError("Conversation identity is not canonical")
+ if sys.platform != "darwin":
+ raise DesktopUnavailable("Opening Codex App is not supported on this host")
+ try:
+ subprocess.run(
+ ["/usr/bin/open", "-b", "com.openai.codex", f"codex://threads/{thread_id}"],
+ check=True,
+ timeout=3,
+ capture_output=True,
+ )
+ except (OSError, subprocess.SubprocessError) as exc:
+ raise DesktopUnavailable("Codex App could not be opened") from exc
+
+
+class DesktopClient:
+ def __init__(self, home: Path):
+ self.path = home / "ipc" / "ipc.sock"
+ self.socket: socket.socket | None = None
+ self.client_id = "initializing-client"
+ self.deadline = 0.0
+
+ def __enter__(self) -> DesktopClient:
+ if os.name != "posix" or not hasattr(socket, "AF_UNIX"):
+ raise DesktopUnavailable(
+ "This host does not expose the local Desktop transport"
+ )
+ try:
+ info = self.path.lstat()
+ if (
+ not stat.S_ISSOCK(info.st_mode)
+ or info.st_uid != os.getuid()
+ or info.st_mode & 0o077
+ ):
+ raise DesktopUnavailable(
+ "Desktop connection is not private to this user"
+ )
+ self.socket = socket.socket(socket.AF_UNIX)
+ self.socket.settimeout(3)
+ self.socket.connect(str(self.path))
+ result = self.call("initialize", {"clientType": "loopx"}, version=0)
+ self.client_id = result["result"]["clientId"]
+ if not isinstance(self.client_id, str) or not self.client_id:
+ raise DesktopUnavailable("Desktop client identity is unsupported")
+ return self
+ except (OSError, KeyError, TypeError, ValueError, DesktopUnavailable) as exc:
+ self.__exit__(None, None, None)
+ raise DesktopUnavailable("Codex App connection is unavailable") from exc
+
+ def __exit__(self, *_args: Any) -> None:
+ if self.socket is not None:
+ self.socket.close()
+
+ def _send(self, value: dict[str, Any]) -> None:
+ assert self.socket is not None
+ raw = json.dumps(value).encode()
+ if len(raw) > MAX_FRAME_BYTES:
+ raise DesktopUnavailable("Desktop input exceeds the bounded frame")
+ self.socket.sendall(struct.pack(" bytes:
+ assert self.socket is not None
+ out = bytearray()
+ while len(out) < length:
+ remaining = self.deadline - time.monotonic()
+ if remaining <= 0:
+ raise DesktopUnavailable("Desktop response timed out")
+ self.socket.settimeout(remaining)
+ chunk = self.socket.recv(length - len(out))
+ if not chunk:
+ raise DesktopUnavailable("Desktop disconnected")
+ out.extend(chunk)
+ return bytes(out)
+
+ def call(
+ self,
+ method: str,
+ params: dict[str, Any],
+ *,
+ version: int,
+ target: str | None = None,
+ ) -> dict[str, Any]:
+ self.deadline = time.monotonic() + 3
+ request_id = str(uuid4())
+ self._send(
+ {
+ "type": "request",
+ "requestId": request_id,
+ "sourceClientId": self.client_id,
+ "method": method,
+ "version": version,
+ "params": params,
+ "timeoutMs": 2500,
+ **({"targetClientId": target} if target else {}),
+ }
+ )
+ for _ in range(128):
+ length = struct.unpack(" str:
+ value = self.call(
+ "thread-owner-discovery",
+ {"hostId": "local", "conversationId": thread_id},
+ version=1,
+ )
+ owner = value.get("handledByClientId")
+ result = value.get("result")
+ if (
+ not isinstance(owner, str)
+ or not 0 < len(owner) <= 160
+ or not isinstance(result, dict)
+ or result.get("supportsUntrustedAppInput") is not True
+ ):
+ raise DesktopUnavailable(
+ "The original App owner does not support external input"
+ )
+ return owner
+
+ def send(self, thread_id: str, text: str, owner: str) -> str:
+ # The host keeps model, tool, sandbox and approval settings. No resume,
+ # fork, steer, permission decision or second app-server is available.
+ try:
+ value = self.call(
+ "thread-follower-start-turn",
+ {
+ "conversationId": thread_id,
+ "turnStart": {
+ "request": {
+ "threadId": thread_id,
+ "input": [
+ {"type": "text", "text": text, "text_elements": []}
+ ],
+ },
+ "context": {
+ "attachments": [],
+ "commentAttachments": [],
+ "inheritThreadSettings": True,
+ "useAppServerPermissionDefault": True,
+ },
+ },
+ },
+ version=2,
+ target=owner,
+ )
+ if value.get("handledByClientId") != owner:
+ raise DesktopUncertain("Delivery owner was not confirmed")
+ result = value.get("result")
+ result = result.get("result") if isinstance(result, dict) else None
+ turn = result.get("turn") if isinstance(result, dict) else None
+ turn_id = turn.get("id") if isinstance(turn, dict) else None
+ if not isinstance(turn_id, str) or not turn_id:
+ raise DesktopUncertain(
+ "Desktop acknowledgement is incomplete; check the original conversation before sending again"
+ )
+ return turn_id
+ except (OSError, ValueError, DesktopUnavailable) as exc:
+ raise DesktopUncertain(
+ "Delivery is unconfirmed; check the original conversation before sending again"
+ ) from exc
+
+
+def locate_thread(thread_id: str, homes: list[Path] | None = None) -> tuple[Path, Path]:
+ matches: list[tuple[Path, Path]] = []
+ for home in homes if homes is not None else codex_homes():
+ versions = [
+ (int(p.stem.split("_")[-1]), p)
+ for p in home.glob("state_*.sqlite")
+ if p.stem.split("_")[-1].isdigit()
+ ]
+ if not versions:
+ continue
+ try:
+ with closing(
+ sqlite3.connect(
+ max(versions)[1].as_uri() + "?mode=ro", uri=True, timeout=0.3
+ )
+ ) as connection:
+ row = connection.execute(
+ "SELECT rollout_path, archived FROM threads WHERE id=?",
+ (thread_id,),
+ ).fetchone()
+ if not row or row[1]:
+ continue
+ path = Path(row[0]).resolve(strict=True)
+ # The host DB is not permission to read an arbitrary local file.
+ if not any(
+ path.is_relative_to((home / folder).resolve())
+ for folder in ["sessions", "archived_sessions"]
+ ):
+ continue
+ matches.append((home, path))
+ except (OSError, sqlite3.Error, TypeError, ValueError):
+ continue
+ if len(matches) != 1:
+ raise DesktopUnavailable(
+ "The bound conversation is missing or ambiguous on this host"
+ )
+ return matches[0]
+
+
+def activity(home: Path, thread_id: str) -> dict[str, Any]:
+ return observe_codex_threads([thread_id], homes=[home])[thread_id].to_payload()
+
+
+def input_fingerprint(binding: str, text: str) -> str:
+ return hashlib.sha256((binding + "\0" + text).encode()).hexdigest()
diff --git a/loopx/capabilities/native_chat/codex_history.py b/loopx/capabilities/native_chat/codex_history.py
new file mode 100644
index 0000000000..0734e1e1b6
--- /dev/null
+++ b/loopx/capabilities/native_chat/codex_history.py
@@ -0,0 +1,279 @@
+"""Bounded, owner-local views of the Codex rollout, never an execution source.
+
+Native record names stay provider-local. Only visible conversation channels,
+tool input/output and explicit lifecycle events are projected; internal model
+reasoning and configuration are not conversation content.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import mmap
+import os
+from pathlib import Path
+from typing import Any
+
+from ...public_safe_text import CATEGORY_CREDENTIAL, classify_private_text
+
+WINDOW_BYTES = 2 * 1024 * 1024
+RECORD_BYTES = 8 * 1024 * 1024
+CONTENT_CHARS = 24000
+PAGE_ROWS = 100
+
+
+def history_revision(stamp: os.stat_result) -> str:
+ return f"{stamp.st_dev}:{stamp.st_ino}:{stamp.st_size}:{stamp.st_mtime_ns}"
+
+
+def source_ref(stamp: os.stat_result) -> str:
+ return hashlib.sha256(f"{stamp.st_dev}:{stamp.st_ino}".encode()).hexdigest()
+
+
+def _display_record(raw: bytes) -> dict[str, Any] | None:
+ record = json.loads(raw)
+ item = record.get("payload", {})
+ if not isinstance(item, dict):
+ return None
+ kind = item.get("type")
+ text = ""
+ role = "activity"
+ label = kind
+ if record.get("type") == "response_item":
+ if kind == "message":
+ message_role = item.get("role")
+ if message_role not in {"user", "assistant"} or item.get("channel") not in {
+ None,
+ "commentary",
+ "final",
+ }:
+ return None
+ role = str(message_role)
+ text = "\n".join(
+ part["text"]
+ for part in item.get("content", [])
+ if isinstance(part, dict)
+ and part.get("type") in {"input_text", "output_text"}
+ and isinstance(part.get("text"), str)
+ )
+ # Attachments are not silently represented as a complete text message.
+ if any(
+ isinstance(part, dict)
+ and part.get("type")
+ not in {
+ "input_text",
+ "output_text",
+ }
+ for part in item.get("content", [])
+ ):
+ text += "\n[Non-text content is not available in this view.]"
+ elif kind in {"function_call", "custom_tool_call"}:
+ role = "tool"
+ label = item.get("name", kind)
+ text = item.get("arguments" if kind == "function_call" else "input", "")
+ elif kind in {"function_call_output", "custom_tool_call_output"}:
+ role = "tool"
+ text = item.get("output", "")
+ # Codex custom tools can persist a list of text blocks. Decode only
+ # that exact shape before pagination so Unicode offsets remain stable.
+ # Preserve unfamiliar blocks/metadata as JSON rather than drop facts.
+ if (
+ isinstance(text, list)
+ and text
+ and all(
+ isinstance(part, dict)
+ and set(part) == {"type", "text"}
+ and part["type"] == "text"
+ and isinstance(part["text"], str)
+ for part in text
+ )
+ ):
+ text = "\n".join(part["text"] for part in text)
+ elif kind == "reasoning":
+ return None # Never expose reasoning/encrypted_content.
+ else:
+ return {
+ "role": "activity",
+ "kind": "unavailable",
+ "label": "unavailable",
+ "text": "",
+ "unavailable": "unsupported_record_type",
+ }
+ elif record.get("type") == "event_msg":
+ if kind not in {"task_started", "task_complete", "turn_aborted", "error"}:
+ return None # Message mirrors and token counters are not new activity.
+ text = item.get("message", "") if kind == "error" else ""
+ else:
+ return None
+ if not isinstance(text, str):
+ text = json.dumps(text, ensure_ascii=False, indent=2)
+ call_id = str(item.get("call_id", ""))[:160]
+ redacted = (
+ classify_private_text(
+ text + "\n" + str(label) + "\n" + call_id,
+ categories=frozenset({CATEGORY_CREDENTIAL}),
+ include_compound_field_assignment=True,
+ )
+ is not None
+ )
+ # The detector identifies shapes, not byte-complete values. Withhold the
+ # whole field instead of accidentally leaking a suffix through partial masking.
+ if redacted:
+ text = "[Content withheld because it contains credential-shaped data.]"
+ label = kind
+ call_id = ""
+ return {
+ "role": role,
+ "kind": kind,
+ "label": str(label)[:160],
+ "text": text,
+ "redacted": redacted,
+ "timestamp": record.get("timestamp"),
+ "call_id": call_id,
+ }
+
+
+def _entry(data: mmap.mmap, start: int, end: int) -> dict[str, Any] | None:
+ if end - start > RECORD_BYTES:
+ # Preserve the position and disclosure, rather than silently dropping a
+ # record that exceeds the host frame bound. No raw bytes leave this path.
+ return {
+ "id": str(start),
+ "end": end,
+ "role": "activity",
+ "kind": "unavailable",
+ "label": "unavailable",
+ "text": "",
+ "unavailable": "record_exceeds_read_limit",
+ "truncated": False,
+ }
+ raw = data[start:end]
+ try:
+ value = _display_record(raw)
+ except (ValueError, TypeError, AttributeError):
+ return {
+ "id": str(start),
+ "end": end,
+ "role": "activity",
+ "kind": "unavailable",
+ "label": "unavailable",
+ "text": "",
+ "unavailable": "record_unreadable",
+ "truncated": False,
+ }
+ if value is None:
+ return None
+ return {
+ **value,
+ "id": str(start),
+ "end": end,
+ "content_ref": hashlib.sha256(raw).hexdigest(),
+ "text": value["text"][:CONTENT_CHARS],
+ "truncated": len(value["text"]) > CONTENT_CHARS,
+ }
+
+
+def read_messages(
+ path: Path,
+ *,
+ before: int | None = None,
+ after: int | None = None,
+ expected_source: str | None = None,
+) -> dict[str, Any]:
+ if before is not None and after is not None:
+ raise ValueError("Choose one history direction")
+ if any(position is not None and position < 0 for position in (before, after)):
+ raise ValueError("History position must be nonnegative")
+ with path.open("rb") as stream:
+ stamp = os.fstat(stream.fileno())
+ size = stamp.st_size
+ source = source_ref(stamp)
+ reset = expected_source is not None and expected_source != source
+ reset = reset or (after is not None and after > size)
+ if reset:
+ before = after = None
+ rows: list[dict[str, Any]] = []
+ lower = upper = 0
+ more = False
+ if size:
+ with mmap.mmap(stream.fileno(), size, access=mmap.ACCESS_READ) as data:
+ # An incomplete append is retried, including a record longer than
+ # the normal page window. Cursors only point to complete lines.
+ complete = data.rfind(b"\n") + 1
+ if after is not None:
+ if after > complete or (after and data[after - 1 : after] != b"\n"):
+ raise ValueError("History cursor is not a record boundary")
+ lower = upper = after
+ while upper < complete and len(rows) < PAGE_ROWS:
+ end = data.find(b"\n", upper, complete) + 1
+ row = _entry(data, upper, end)
+ upper = end
+ if row is not None:
+ rows.append(row)
+ if upper - lower >= WINDOW_BYTES:
+ break
+ more = upper < complete
+ else:
+ upper = min(before, complete) if before is not None else complete
+ if upper and data[upper - 1 : upper] != b"\n":
+ raise ValueError("History cursor is not a record boundary")
+ lower = upper
+ while lower > 0 and len(rows) < PAGE_ROWS:
+ start = data.rfind(b"\n", 0, lower - 1) + 1
+ row = _entry(data, start, lower)
+ lower = start
+ if row is not None:
+ rows.append(row)
+ if upper - lower >= WINDOW_BYTES:
+ break
+ rows.reverse()
+ more = upper < complete
+ return {
+ "messages": rows,
+ "before": lower or None,
+ "after": upper,
+ "has_more": more,
+ "reset": reset,
+ "revision": history_revision(stamp),
+ "source_ref": source,
+ "history_size": size,
+ "bounded": bool(lower or more),
+ }
+
+
+def read_content(
+ path: Path,
+ *,
+ entry_id: int,
+ offset: int,
+ expected_source: str,
+ content_ref: str,
+) -> dict[str, Any]:
+ if entry_id < 0 or offset < 0:
+ raise ValueError("Content position must be nonnegative")
+ with path.open("rb") as stream:
+ stamp = os.fstat(stream.fileno())
+ if source_ref(stamp) != expected_source or entry_id >= stamp.st_size:
+ raise ValueError("History source changed")
+ if entry_id:
+ stream.seek(entry_id - 1)
+ if stream.read(1) != b"\n":
+ raise ValueError("Content cursor is not a record boundary")
+ stream.seek(entry_id)
+ raw = stream.readline(RECORD_BYTES + 1)
+ if (
+ len(raw) > RECORD_BYTES
+ or not raw.endswith(b"\n")
+ or hashlib.sha256(raw).hexdigest() != content_ref
+ ):
+ raise ValueError("Content changed or is unavailable")
+ value = _display_record(raw)
+ if value is None or value.get("unavailable") or offset > len(value["text"]):
+ raise ValueError("Content is unavailable")
+ end = min(offset + CONTENT_CHARS, len(value["text"]))
+ return {
+ "text": value["text"][offset:end],
+ "next_offset": end,
+ "has_more": end < len(value["text"]),
+ "redacted": value["redacted"],
+ }
diff --git a/loopx/capabilities/pr_review_queue/review_contract.py b/loopx/capabilities/pr_review_queue/review_contract.py
index dcc18b4edc..9b0646839a 100644
--- a/loopx/capabilities/pr_review_queue/review_contract.py
+++ b/loopx/capabilities/pr_review_queue/review_contract.py
@@ -152,12 +152,24 @@
"For user_experience, compare the real affected CLI, UI or messaging journey: setup and "
"repeated intervention, truthful state/readback, actionable failure, correction/cancel and "
"recovery. Inspect existing companion surfaces; a backend success is not a usable journey. "
- "For an affected user journey, establish the ordinary task and expected result before "
- "reading the implementation path. In existing before_after and walkthrough evidence, "
+ "For an affected user journey, derive the ordinary task and expected result from the "
+ "original request or accepted product contract, independently of the author's proposed "
+ "workflow. Do not substitute configuring the feature for the user's actual task. "
+ "Challenge whether the entire human job is necessary: compare no change and a smaller "
+ "lawful alternative, including work an Agent can prepare with existing context and authority, "
+ "before justifying individual controls. In existing before_after and walkthrough evidence, "
"compare required navigation, re-entry of known information, clarification, confirmation "
"and recovery steps with the shortest valid path through the existing authority boundary. "
- "Explain what new information or authority each mandatory step supplies; a form that "
- "works can still be redundant. Optional editing is not a mandatory decision. Do not "
+ "Account for total human preparation, not only visible clicks. Reuse known information "
+ "and prior scoped authorization; explain only genuinely missing intent or authority. "
+ "Compare required interaction rounds from original intent to usable result: one round "
+ "is the ordinary design default, zero additional rounds when intent and authorization "
+ "already exist. Count required statements, choices and confirmations, including off-screen "
+ "preparation. Each extra round needs a missing fact, consequential decision or authority "
+ "boundary; high feature value alone is insufficient. Account separately for navigation, "
+ "copying, waiting, voluntary exploration and recovery; this is not a universal click gate. "
+ "A local rationale for every control does not establish that the whole workflow is needed. "
+ "Optional technical editing remains valid when it serves an actual user task. Do not "
"remove necessary scoped consent or correctness checks to minimize clicks. "
"For visible changes inspect the rendered whole viewport with representative populated "
"data, not only component presence: target/owner, hierarchy, truthful freshness/order "
diff --git a/loopx/chat_action_normalization.py b/loopx/chat_action_normalization.py
index a6acab1b6d..9a99646087 100644
--- a/loopx/chat_action_normalization.py
+++ b/loopx/chat_action_normalization.py
@@ -19,6 +19,15 @@
class ChatActionNormalizationMixin:
"""Normalize typed action inputs without owning effects or persistence."""
+ def _goal(self, goal_id: str) -> dict[str, Any]:
+ raise NotImplementedError
+
+ @staticmethod
+ def _allowed_parameters(
+ parameters: Mapping[str, Any], *, allowed: set[str],
+ ) -> dict[str, Any]:
+ raise NotImplementedError
+
def _normalize(
self, action_kind: str, parameters: Mapping[str, Any]
) -> dict[str, Any]:
@@ -32,6 +41,18 @@ def _normalize(
_text,
)
+ if action_kind == "goal.acceptance.check":
+ values = self._allowed_parameters(parameters, allowed={"goal_id", "document", "prepared_by"})
+ goal_id = _opaque(values.get("goal_id"), field="goal_id")
+ goal = self._goal(goal_id)
+ document = values.get("document")
+ if not isinstance(document, Mapping):
+ raise ValueError("a prepared check requires an acceptance document")
+ prepared_by = values.get("prepared_by")
+ if prepared_by is not None:
+ if prepared_by not in registered_agent_ids_for_goal(goal):
+ raise ValueError("the preparing Agent must be registered for this Goal")
+ return {"goal_id": goal_id, "document": dict(document), "prepared_by": prepared_by}
if action_kind == "operation.execute":
values = self._allowed_parameters(
parameters,
diff --git a/loopx/chat_action_store.py b/loopx/chat_action_store.py
index bf2a7f98af..86694ea885 100644
--- a/loopx/chat_action_store.py
+++ b/loopx/chat_action_store.py
@@ -21,6 +21,7 @@
CHAT_ACTION_PROPOSAL_SCHEMA_VERSION = "loopx_chat_action_proposal_v1"
ACTION_KINDS = {
+ "goal.acceptance.check",
"goal.create",
"goal.update",
"goal.lifecycle",
diff --git a/loopx/chat_actions.py b/loopx/chat_actions.py
index 455a27e2cd..6b97a3bf34 100644
--- a/loopx/chat_actions.py
+++ b/loopx/chat_actions.py
@@ -18,6 +18,7 @@
from .chat_monitor_actions import ChatMonitorActionMixin
from .chat_store import ChatSessionStore
from .chat_todo_actions import ChatTodoActionMixin
+from .control_plane.goals.acceptance_actions import GoalAcceptanceActionMixin
from .configure_goal import configure_goal
from .control_plane.goals.configure_goal_service import (
bind_goal_agent_with_global_sync,
@@ -39,6 +40,7 @@
CHAT_ACTION_RESPONSE_SCHEMA_VERSION = "loopx_chat_action_response_v1"
SUPPORTED_ACTION_KINDS = {
+ "goal.acceptance.check",
"todo.create",
"todo.update",
"run.correct",
@@ -214,6 +216,7 @@ def _monitor_text(parameters: Mapping[str, Any]) -> str | None:
class ChatActionService(
+ GoalAcceptanceActionMixin,
ChatActionNormalizationMixin,
ChatGoalLifecycleActionMixin,
ChatMonitorActionMixin,
@@ -1194,7 +1197,13 @@ def preview(self, request: Mapping[str, Any]) -> dict[str, Any]:
normalized["workspace_ref"] = workspace_binding["workspace_ref"]
else:
eligibility = None
- if action_kind == "operation.execute":
+ if action_kind == "goal.acceptance.check":
+ if context.get("kind") != "goal" or context.get("goal_id") != normalized["goal_id"]:
+ raise ValueError("check plan context must match its target Goal")
+ fingerprint, _ = self._acceptance_preview(normalized)
+ evidence = ["The existing typed acceptance owner validated this plan without changing configuration or executing commands."]
+ permission = "local_command_execution"
+ elif action_kind == "operation.execute":
fingerprint = _digest(normalized)
evidence = [
"The provider-neutral operation envelope and immutable digests validated.",
@@ -1449,6 +1458,8 @@ def apply(self, proposal_id: str, *, steward_context: dict[str, Any] | None = No
parameters = proposal.get("normalized_parameters")
if not isinstance(parameters, dict):
raise ValueError("typed Chat action proposal is malformed")
+ if action_kind == "goal.acceptance.check":
+ return self._apply_acceptance_check(proposal_id)
if action_kind == "goal.create":
return self._apply_goal_create(proposal_id, proposal, parameters)
if action_kind == "goal.lifecycle":
diff --git a/loopx/chat_agent.py b/loopx/chat_agent.py
index f8aade401a..45ef9b772b 100644
--- a/loopx/chat_agent.py
+++ b/loopx/chat_agent.py
@@ -753,6 +753,14 @@ def start(
owner_git_write = (project_context is not None and not permissions_profile
and selected_sandbox == "workspace-write"
and (root / ".git").is_dir() and not (root / ".git").is_symlink())
+ owner_git_write_roots = [str(root / ".git")] if owner_git_write else []
+ # Repository skills are project content, but Codex protects all of
+ # .codex recursively by default. Map only the physical skills subtree
+ # for ordinary project writes; never open project config or follow a
+ # linked instruction directory into another workspace/account.
+ if owner_git_write and all(path.is_dir() and not path.is_symlink()
+ for path in (root / ".codex", root / ".codex" / "skills")):
+ owner_git_write_roots.append(str(root / ".codex" / "skills"))
# Pin the host store explicitly, including compatibility retries. Never
# redirect an existing thread by inheriting a different launch context.
base_home = (
@@ -888,10 +896,8 @@ def start(
writable = settings.get("writable_roots", [])
if not isinstance(writable, list) or any(not isinstance(path, str) for path in writable):
raise session._runtime_error("Invalid native workspace write roots.")
- # Only physical, in-workspace metadata gains a write root.
- # Do not follow .git links or a linked worktree's gitdir.
host_config = {**(host_config or {}), "sandbox_workspace_write": {
- **settings, "writable_roots": list(dict.fromkeys([*writable, str(root / ".git")]))}}
+ **settings, "writable_roots": list(dict.fromkeys([*writable, *owner_git_write_roots]))}}
if permissions_profile:
host_config = codex_context.disable_mcp_servers(effective, host_config or {})
try:
@@ -982,8 +988,9 @@ def start(
applied = thread_result.get("sandbox")
applied_roots = applied.get("writableRoots") if isinstance(applied, dict) else None
if (not isinstance(applied, dict) or applied.get("type") != "workspaceWrite"
- or not isinstance(applied_roots, list) or str(root / ".git") not in applied_roots):
- raise session._runtime_error("Codex did not apply the project Git write root.")
+ or not isinstance(applied_roots, list)
+ or any(path not in applied_roots for path in owner_git_write_roots)):
+ raise session._runtime_error("Codex did not apply the project Git write roots.")
if not session.thread_id:
raise session._runtime_error(
"Codex app-server did not return a thread id."
diff --git a/loopx/chat_runtime.py b/loopx/chat_runtime.py
index 30a8176e09..5e6d25418f 100644
--- a/loopx/chat_runtime.py
+++ b/loopx/chat_runtime.py
@@ -933,7 +933,9 @@ def _ensure_adapter_locked(
expected_statuses={"queued", "starting", "running", "interrupting"},
status="failed",
error_code="server_restarted",
- error="LoopX Chat restarted before this turn completed.",
+ error=("LoopX Chat restarted before this turn finished. "
+ "Your conversation is saved; this turn was not retried automatically. "
+ "You can continue with a follow-up in this session."),
completed_at=utc_now(),
)
if failed is not None:
@@ -941,7 +943,7 @@ def _ensure_adapter_locked(
session_id,
str(active_turn_id),
kind="turn.failed",
- payload={"error_code": "server_restarted"},
+ payload={"error_code": "server_restarted", "message": failed["error"]},
)
try:
stored_messages = self.store.messages(session_id)
diff --git a/loopx/chat_server.py b/loopx/chat_server.py
index 42ea426056..488818eb06 100644
--- a/loopx/chat_server.py
+++ b/loopx/chat_server.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+from .control_plane.goals.acceptance_http import GoalAcceptanceRequestMixin
+
import json
import mimetypes
import re
@@ -481,6 +483,7 @@ def server_close(self) -> None:
class ChatRequestHandler(
+ GoalAcceptanceRequestMixin,
ZCodeGoalRequestMixin,
PrivateConversationRequestMixin,
CompletedTodoRequestMixin,
@@ -1533,6 +1536,7 @@ def do_GET(self) -> None:
**self._configuration_get_routes(),
DEFAULT_CHAT_STATUS_PATH: self._status,
"/api/chat/delivery-review": self._delivery_review,
+ "/api/chat/goal-acceptance": self._goal_acceptance_inspect,
SSH_HOST_CATALOG_PATH: self._ssh_hosts,
}
if path in get_dispatch:
@@ -1573,6 +1577,7 @@ def do_POST(self) -> None:
CHAT_ATTACH_SESSION_PATH: self._attach_session,
CHAT_PROJECTION_MESSAGES_PATH: self._record_projection_exchange,
CHAT_ACTION_PREVIEW_PATH: self._action_preview,
+ "/api/chat/goal-acceptance": self._goal_acceptance_update,
CHAT_TODO_DRY_RUN_PATH: lambda: self._todo(apply=False),
CHAT_TODO_APPLY_PATH: lambda: self._todo(apply=True),
CHAT_GOAL_CHANNEL_SETUP_PATH: self._goal_channel_setup,
diff --git a/loopx/chat_status_api.py b/loopx/chat_status_api.py
index 861eec2d0c..9f7dbb3276 100644
--- a/loopx/chat_status_api.py
+++ b/loopx/chat_status_api.py
@@ -5,6 +5,7 @@
import errno
import json
from datetime import datetime, timezone
+from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse
@@ -73,6 +74,21 @@ def _goal_task_map(server: Any, goal_id: str) -> dict[str, Any] | None:
)
+def _discovery_registry(registry_path: Path) -> dict[str, Any]:
+ """Read the registry for the workspace directory; a missing file is an empty workspace.
+
+ A fresh install has no registry until the first Goal is created. Discovery
+ reports that as zero registered Goals rather than a projection failure, so
+ the home page can show its first-Goal state. Unreadable or incompatible
+ registries still raise.
+ """
+
+ try:
+ return load_project_registry(registry_path)
+ except FileNotFoundError:
+ return {}
+
+
def _status_access_denied(error: BaseException) -> bool:
"""Recognize permission causes without reclassifying unrelated I/O failures."""
current: BaseException | None = error
@@ -158,7 +174,7 @@ def _status(self, *, delivery_review: bool = False) -> None:
if views is not None or requested_goals is not None or delivery_review:
# Discovery reads only metadata. Full status/runtime reads keep
# their existing lifecycle-only profile refusal.
- registry = (load_project_registry(self.server.registry_path) if views == ["workspace-directory"]
+ registry = (_discovery_registry(self.server.registry_path) if views == ["workspace-directory"]
else load_registry(self.server.registry_path))
if goal_id and goal_id not in {
str(g.get("id")) for g in registry_goals(registry)
@@ -173,6 +189,10 @@ def _status(self, *, delivery_review: bool = False) -> None:
return
projection = collect_status(
registry_path=self.server.registry_path,
+ # The Chat service already starts on an absent registry as an
+ # empty workspace (serve_chat); its status reports the same
+ # fact as a warning, so a fresh install is not a failed source.
+ allow_missing_registry=True,
runtime_root_override=self.server.runtime_root_override,
scan_roots=self.server.scan_roots,
limit=self.server.limit,
diff --git a/loopx/claude_goal_mode/scripts/install.py b/loopx/claude_goal_mode/scripts/install.py
index d8ddd21131..c51a8ba20b 100644
--- a/loopx/claude_goal_mode/scripts/install.py
+++ b/loopx/claude_goal_mode/scripts/install.py
@@ -31,6 +31,14 @@
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
MCP_VENV = Path.home() / ".local" / "share" / "loopx" / "mcp-venv"
+# This script is run by path (`python /loopx/claude_goal_mode/scripts/
+# install.py`), so the package root is not on `sys.path` by default.
+REPO_ROOT = Path(__file__).resolve().parents[3]
+if str(REPO_ROOT) not in sys.path:
+ sys.path.insert(0, str(REPO_ROOT))
+
+from loopx.file_lock import write_json_atomic # noqa: E402
+
def _p(*parts) -> str:
return str(PLUGIN_ROOT.joinpath(*parts)).replace("\\", "/")
@@ -258,12 +266,17 @@ def install_mcp(
scope: str,
migrate_goal_harness: bool = False,
project_dir: Path | None = None,
-):
+) -> bool:
"""Register the loopx MCP server at the chosen scope via `claude mcp add`.
We replace only OUR `loopx` entry. A legacy/user-owned `goal-harness` MCP is
left untouched by default — we don't delete a server we don't own. If one
- exists we print a manual cleanup hint; `--migrate-goal-harness` removes it."""
+ exists we print a manual cleanup hint; `--migrate-goal-harness` removes it.
+
+ Returns whether the `loopx` entry is registered afterwards: True only when
+ `claude mcp add` reported success. A missing `claude` CLI and a failed add
+ both leave the server unregistered and print the manual command instead, so
+ a caller that narrates the install must read that receipt, not the attempt."""
if scope == "project" and project_dir is None:
raise ValueError("project_dir is required for project-scoped MCP registration")
cwd_run = str(project_dir) if scope == "project" else None
@@ -272,10 +285,10 @@ def install_mcp(
add = ["mcp", "add", "--scope", scope, "loopx", "--", py, mcp_path]
if not claude:
print("[mcp] claude CLI not on PATH — add the MCP server manually:\n claude " + " ".join(add))
- return
+ return False
print(f"[mcp] claude mcp add --scope {scope} loopx -- {py} ")
if dry:
- return
+ return False
subprocess.run(
[claude, "mcp", "remove", "--scope", scope, "loopx"],
capture_output=True,
@@ -309,6 +322,8 @@ def install_mcp(
)
if r.returncode != 0:
print(" (mcp add failed; add manually:\n claude " + " ".join(add) + "\n " + (r.stdout + r.stderr)[:200] + ")")
+ return False
+ return True
# ---- /loopx command ----------------------------------------------------------
@@ -351,15 +366,35 @@ def install_command(dry: bool, cmd_dir: Path):
path.write_text(command_md(), encoding="utf-8")
+class HardenSettingsUnreadable(RuntimeError):
+ """`--harden` refuses to rewrite a settings file it cannot read as JSON."""
+
+
def add_hardening(dry: bool, settings_path: Path):
"""Deep-merge the optional hook + statusline into settings_path, preserving
- existing config. NEVER deletes permission rules."""
+ existing config. NEVER deletes permission rules.
+
+ The merge rewrites the whole file, so a settings file that exists but does
+ not parse is refused instead of replaced: substituting `{}` for it would
+ drop every key the user owns - permission rules, `env`, `model`, other
+ hooks, MCP approvals - leaving only the loopx block, at exit status 0. The
+ file is user-owned (LoopX did not create it), which is why the sibling
+ merge in `loopx/slash_command_install.py` fails closed on the same input.
+ """
cur = {}
if settings_path.exists():
try:
cur = json.loads(settings_path.read_text(encoding="utf-8"))
- except Exception:
- cur = {}
+ except (OSError, ValueError) as exc:
+ raise HardenSettingsUnreadable(
+ f"{settings_path} exists but is not readable as JSON ({exc}); refusing to "
+ "rewrite it. Fix or move that file, then re-run with --harden."
+ ) from exc
+ if not isinstance(cur, dict):
+ raise HardenSettingsUnreadable(
+ f"{settings_path} exists but is not a JSON object; refusing to rewrite it. "
+ "Fix or move that file, then re-run with --harden."
+ )
# prune prior loopx PreToolUse entries (avoid stacking on reinstall)
for grp in (cur.get("hooks", {}) or {}).get("PreToolUse", []) or []:
grp["hooks"] = [h for h in grp.get("hooks", []) if "goal_policy.py" not in str(h.get("command", ""))]
@@ -368,8 +403,11 @@ def add_hardening(dry: bool, settings_path: Path):
merged = deep_merge(cur, hardening_block())
print(f"[harden] PreToolUse should_run gate + statusline -> {settings_path}")
if not dry:
- settings_path.parent.mkdir(parents=True, exist_ok=True)
- settings_path.write_text(json.dumps(merged, indent=2), encoding="utf-8")
+ # One replace, not a truncate-in-place: an interrupted install must not
+ # leave the user's settings.json as a partial document. The destination
+ # is resolved first so a dotfiles-managed symlink keeps pointing at its
+ # target instead of being replaced by the rename.
+ write_json_atomic(settings_path.resolve(), merged, preserve_mode=True)
def warn_legacy_perms(settings_path: Path):
@@ -417,11 +455,12 @@ def main():
print(" available in ALL your Claude Code projects." +
(" --harden adds USER-LEVEL, matcher-wide hooks too." if a.harden else ""))
+ mcp_registered = False
if a.skip_mcp:
print("[mcp] skipped (--skip-mcp)")
else:
mcp_python = provision_mcp_python(dry, a.allow_system_pip)
- install_mcp(
+ mcp_registered = install_mcp(
dry,
mcp_python,
a.scope,
@@ -432,7 +471,38 @@ def main():
settings_path = claude_dir / "settings.json"
if a.harden:
- add_hardening(dry, settings_path)
+ try:
+ add_hardening(dry, settings_path)
+ except HardenSettingsUnreadable as exc:
+ print(f"[harden] {exc}", file=sys.stderr)
+ outcome = (
+ "nothing was written (dry-run)"
+ if dry
+ else "the settings file is unchanged"
+ )
+ print(
+ f"[harden] the optional should_run gate + statusline were skipped; {outcome}.",
+ file=sys.stderr,
+ )
+ if not dry:
+ # The rest of the install did run, so name only what happened:
+ # the /loopx command was written just above, and the MCP state is
+ # the registration receipt `install_mcp` returned - not the
+ # attempt. A missing CLI or a failed `claude mcp add` leaves the
+ # server unregistered, and reporting that attempt as an install
+ # would let a user skip the manual command printed above.
+ if a.skip_mcp:
+ mcp_state = "the MCP server was skipped (--skip-mcp)"
+ elif mcp_registered:
+ mcp_state = "the MCP server was registered"
+ else:
+ mcp_state = ("the MCP server was not registered — run the "
+ "`claude mcp add` command printed above")
+ print(
+ f"[harden] the /loopx command was installed; {mcp_state}.",
+ file=sys.stderr,
+ )
+ return 2
else:
print("[harden] skipped (default): no hooks/statusline installed. Add later with --harden.")
warn_legacy_perms(settings_path)
@@ -442,7 +512,8 @@ def main():
print("Restart any open Claude Code session so the command/MCP load.")
if dry:
print("(dry-run: nothing written)")
+ return 0
if __name__ == "__main__":
- main()
+ raise SystemExit(main())
diff --git a/loopx/cli_commands/goal_acceptance.py b/loopx/cli_commands/goal_acceptance.py
index 6212f783d7..130e0e92f5 100644
--- a/loopx/cli_commands/goal_acceptance.py
+++ b/loopx/cli_commands/goal_acceptance.py
@@ -21,16 +21,16 @@ def register_goal_acceptance_command(subparsers: Any, add_format: Any) -> None:
help="Configure, inspect or verify a versioned Goal acceptance basis.",
)
add_format(parser)
- parser.add_argument("action", choices=("inspect", "configure", "verify", "disable"))
+ parser.add_argument("action", choices=("inspect", "prepare", "configure", "verify", "disable"))
parser.add_argument("--goal-id", required=True)
parser.add_argument(
"--agent-id",
- help="Registered caller; Agent callers may inspect/verify but cannot change the owner's contract.",
+ help="Registered caller; Agents may inspect, prepare or verify, but preparation never approves or executes a plan.",
)
parser.add_argument(
"--document",
type=Path,
- help="Owner-approved JSON with explicit scope: selected_work plus todo_ids, or all_advancement. Used only by configure.",
+ help="Prepared check plan with explicit scope; prepare validates it without approval/execution, configure requires the owner.",
)
parser.add_argument(
"--expected-provider-revision",
@@ -42,7 +42,7 @@ def register_goal_acceptance_command(subparsers: Any, add_format: Any) -> None:
parser.add_argument(
"--execute",
action="store_true",
- help="Apply configuration or execute validation; otherwise preview only.",
+ help="With prepare, save a reviewable plan only; with configure/disable/verify, apply the named operation. Otherwise preview only.",
)
@@ -89,7 +89,18 @@ def handle_goal_acceptance_command(
"runtime_root": runtime_root_arg,
"agent_id": args.agent_id,
}
- if args.action in {"configure", "disable"}:
+ if args.action == "prepare":
+ from ..control_plane.goals.acceptance_actions import prepare_acceptance_check
+ if args.document is None or not args.operation_id:
+ raise ValueError("prepare requires --document and a stable --operation-id")
+ if args.expected_provider_revision:
+ raise ValueError("prepare reads the current basis itself")
+ document = json.loads(args.document.read_text(encoding="utf-8"))
+ if not isinstance(document, dict):
+ raise ValueError("acceptance document must be a JSON object")
+ payload = prepare_acceptance_check(**common, document=document,
+ operation_id=args.operation_id, execute=args.execute)
+ elif args.action in {"configure", "disable"}:
if not args.expected_provider_revision:
raise ValueError(
"configure/disable requires --expected-provider-revision from inspect"
diff --git a/loopx/cli_commands/quota_monitor_poll.py b/loopx/cli_commands/quota_monitor_poll.py
index 95bd8639de..245cea7f7b 100644
--- a/loopx/cli_commands/quota_monitor_poll.py
+++ b/loopx/cli_commands/quota_monitor_poll.py
@@ -78,6 +78,8 @@ def record_quota_monitor_poll_for_cli(
cadence=args.cadence,
next_due_at=args.next_due_at,
next_agent_todo=args.next_agent_todo,
+ next_agent_priority=getattr(args, "next_agent_priority", None),
+ next_user_priority=getattr(args, "next_user_priority", None),
next_action_kind=args.next_action_kind,
next_task_repository=args.next_task_repository,
next_required_capabilities=args.next_required_capabilities,
diff --git a/loopx/cli_commands/quota_request.py b/loopx/cli_commands/quota_request.py
index a807644451..1ce8cf0fdd 100644
--- a/loopx/cli_commands/quota_request.py
+++ b/loopx/cli_commands/quota_request.py
@@ -105,10 +105,19 @@ def register_quota_monitor_poll_request_arguments(
help=("For an executing, turn-scoped monitor-poll with --todo-id, resolve the canonical "
"lease proof or replay the exact prior transaction proof. Does not acquire or renew a lease."))
quota_parser.add_argument("--next-claimed-by", help="Registered agent id to claim the `--next-agent-todo` follow-up.")
+ for role in ("agent", "user"):
+ quota_parser.add_argument(
+ f"--next-{role}-priority", choices=["P0", "P1", "P2", "P3", "P4"],
+ help=f"Priority of --next-{role}-todo; defaults to P1, independently of the monitor.",
+ )
def validate_quota_command_request(args: argparse.Namespace) -> None:
command = args.quota_command
+ if command != "monitor-poll" and any(
+ getattr(args, f"next_{role}_priority", None) is not None for role in ("agent", "user")
+ ):
+ raise QuotaCommandValidationError("successor priority requires quota monitor-poll")
lease_key = getattr(args, "task_lease_idempotency_key", None)
lease_version = getattr(args, "task_lease_expected_version", None)
use_current_lease = bool(getattr(args, "use_current_task_lease", False))
diff --git a/loopx/cli_commands/support_control_backup.py b/loopx/cli_commands/support_control_backup.py
index 73c12fa7ed..57b6cb7ede 100644
--- a/loopx/cli_commands/support_control_backup.py
+++ b/loopx/cli_commands/support_control_backup.py
@@ -88,7 +88,7 @@ def handle_backup_state_command(
output_format: FormatSelector,
) -> int:
if getattr(args, "backup_action", None) == "verify":
- from ..state_recovery import StateRecoveryError, render_state_recovery_markdown, verify_state_backup
+ from ..control_plane.state_recovery_io import StateRecoveryError, render_state_recovery_markdown, verify_state_backup
try:
if not all(getattr(args, key, None) for key in ("archive", "manifest", "destination")):
diff --git a/loopx/cli_commands/todo_registration.py b/loopx/cli_commands/todo_registration.py
index 326bedf7d6..ef02ecc0db 100644
--- a/loopx/cli_commands/todo_registration.py
+++ b/loopx/cli_commands/todo_registration.py
@@ -54,7 +54,7 @@ def register_todo_command(
todo_parser.add_argument("--goal-id", required=True, help="Goal id whose active state should receive the todo.")
todo_parser.add_argument("--role", choices=["user", "agent"], help="Todo owner. Required for add; optional todo_id search scope for lifecycle commands. Defaults to agent for archive-completed.")
todo_parser.add_argument("--text", help="Todo text. Required for add; keep it short and public-safe enough for local status.")
- todo_parser.add_argument("--priority", choices=["P0", "P1", "P2", "P3", "P4"], help="For add/update, declare Todo priority independently of text; omission retains the current value.")
+ todo_parser.add_argument("--priority", choices=["P0", "P1", "P2", "P3", "P4"], help="Declare Todo priority independently of text; add defaults to P1, update omission retains the current value.")
todo_parser.add_argument("--clear-priority", action="store_true", help="For update, explicitly remove priority; cannot be combined with --priority.")
todo_parser.add_argument("--todo-id", help="Structured todo id from status/quota, such as todo_ab12cd34ef56.")
todo_parser.add_argument(
diff --git a/loopx/cli_commands/turn.py b/loopx/cli_commands/turn.py
index 5656129fc3..2469dc935b 100644
--- a/loopx/cli_commands/turn.py
+++ b/loopx/cli_commands/turn.py
@@ -108,143 +108,188 @@ def handle_turn_command(
raise ValueError(
"--operation-source-route-json requires --codex-operation-tools for codex-cli or --dsh-operation-module for dsh"
)
- # Planning and dry-run execution inspect existing admitted intents.
- # Only an executing wake may sync inboxes or reserve a calendar window.
- turn_start_hook_dispatch = {}
- if args.turn_command == "run-once" and args.execute:
- from ..capabilities.agent_turn_recall import run_configured_agent_turn_recall_fail_open
- from ..capabilities.periodic_report.cadence_runtime import extend_cadence_turn_start_dispatch
- from .lark_inbox import dispatch_goal_lark_turn_start_hooks
-
- turn_start_hook_dispatch = dispatch_goal_lark_turn_start_hooks(
+ decision: dict[str, Any] | None = None
+ # A resumed Turn already chose its transaction. New work and context
+ # belong to the next Turn, not to recovery of this journal.
+ if args.turn_command == "run-once" and args.resume_turn_key:
+ resume_requested, _session_binding = resolve_turn_resume_session_binding(args)
+ if args.turn_instance_id:
+ raise ValueError(
+ "--resume-turn-key cannot be combined with --turn-instance-id"
+ )
+ if resume_requested:
+ raise ValueError(
+ "--resume-turn-key cannot be combined with host session identity flags"
+ )
+ payload = load_loopx_turn_plan_from_journal(
+ runtime_root,
+ goal_id=args.goal_id,
+ turn_key=args.resume_turn_key,
+ )
+ raw_envelope = payload.get("turn_envelope")
+ envelope = raw_envelope if isinstance(raw_envelope, dict) else {}
+ if envelope.get("agent_id") != args.agent_id:
+ raise ValueError(
+ "LoopX Turn resume journal belongs to another agent"
+ )
+ goal_admission = FirstPartyHostGoalAdmission.for_plan(
registry_path=registry_path,
- runtime_root_arg=runtime_root,
goal_id=args.goal_id,
- agent_id=args.agent_id,
+ planned_goal_ref=payload.get("goal_ref"),
)
- turn_start_hook_dispatch = extend_cadence_turn_start_dispatch(
- turn_start_hook_dispatch, registry_path=registry_path, runtime_root=runtime_root,
- goal_id=args.goal_id, agent_id=args.agent_id)
- from ..control_plane.agents.capability_memory import (
- extend_turn_start_dispatch as extend_capability_memory_dispatch,
+ strict_goal_admission = goal_admission if goal_admission.enabled else None
+ if strict_goal_admission is not None:
+ strict_goal_admission.require_current()
+ resumed_goal_ref = payload.get("goal_ref")
+ goal_ref = (
+ dict(resumed_goal_ref)
+ if isinstance(resumed_goal_ref, Mapping)
+ else None
)
+ from .lark_inbox import build_lark_operator_inbox_urgency_projector
- turn_start_hook_dispatch = extend_capability_memory_dispatch(
- turn_start_hook_dispatch,
- registry_path=registry_path,
- runtime_root=runtime_root,
- goal_id=args.goal_id,
- agent_id=args.agent_id,
- available=args.available_capabilities,
+ operator_inbox_urgency_projector = build_lark_operator_inbox_urgency_projector(
+ runtime_root_arg=runtime_root,
)
- from ..capabilities.semantic_preference.agent_preferences import extend_turn_start_dispatch as extend_preferences
- turn_start_hook_dispatch = extend_preferences(turn_start_hook_dispatch, runtime_root=runtime_root,
- registry_path=registry_path, goal_id=args.goal_id, agent_id=args.agent_id)
- from ..capabilities.explore.turn_context import extend_turn_start_dispatch as extend_explore
- turn_start_hook_dispatch = extend_explore(turn_start_hook_dispatch,
- registry_path=registry_path, runtime_root=runtime_root,
- goal_id=args.goal_id, agent_id=args.agent_id)
- # `run-once` and `managed-step` must resolve the same governing decision
- # from the same live status, scheduler context and capability hooks, so
- # this Turn takes all of them -- and its later settle-against inputs --
- # from the shared decision owner instead of rebuilding them per command.
- decision_owner = build_fresh_turn_decision_owner(
- args,
- registry_path=registry_path,
- runtime_root=runtime_root,
- runtime_root_arg=runtime_root_arg,
- turn_start_hook_dispatch=turn_start_hook_dispatch,
- goal_ref=goal_ref,
- )
- operator_inbox_urgency_projector = decision_owner.operator_inbox_urgency_projector
- scheduler_context = decision_owner.scheduler_execution_context
- decision = decision_owner.resolve()
- resume_requested, session_binding = resolve_turn_resume_session_binding(args)
- turn_envelope = build_turn_envelope(
- decision,
- scheduler_execution_context=scheduler_context,
- )
- # Operation-equipped app-server sessions retain their Todo-bound
- # approval/handoff contract; this option controls exec conversation reuse.
- session_scope = (args.session_scope if args.host == "codex-cli"
- and not getattr(args, "codex_operation_tools", False) else "todo")
- if (
- args.turn_command == "run-once"
- and (args.host == "codex-cli" or dsh_operation_module)
- and not resume_requested
- and not args.resume_turn_key
- and args.iteration_context != "fresh"
- and turn_envelope.get("effective_action") != EffectiveAction.GOVERNED_CAPABILITY_INTENT.value
- ):
- from ..control_plane.turn_driver.codex_cli import codex_cli_session_binding
+ operator_environ = operator_provider_environ()
+ else:
+ # Planning and dry-run execution inspect existing admitted intents.
+ # Only an executing wake may sync inboxes or reserve a calendar window.
+ turn_start_hook_dispatch = {}
+ if args.turn_command == "run-once" and args.execute:
+ from ..capabilities.agent_turn_recall import run_configured_agent_turn_recall_fail_open
+ from ..capabilities.periodic_report.cadence_runtime import extend_cadence_turn_start_dispatch
+ from .lark_inbox import dispatch_goal_lark_turn_start_hooks
- session_binding = (
- codex_cli_session_binding(
- runtime_root,
- turn_envelope,
- goal_admission=strict_goal_admission,
- session_scope=session_scope,
- host=args.host,
+ turn_start_hook_dispatch = dispatch_goal_lark_turn_start_hooks(
+ registry_path=registry_path,
+ runtime_root_arg=runtime_root,
+ goal_id=args.goal_id,
+ agent_id=args.agent_id,
)
- if strict_goal_admission is not None
- else codex_cli_session_binding(runtime_root, turn_envelope, session_scope=session_scope, host=args.host)
- )
- payload = build_loopx_turn_plan(
- turn_envelope,
- host=args.host,
- execution_mode=args.execution_mode,
- scheduler_owner=args.scheduler_owner,
- session_binding=session_binding,
- turn_instance_id=args.turn_instance_id,
- iteration_context_policy=args.iteration_context,
- session_scope=session_scope,
- goal_ref=goal_ref,
- )
- # Resolve machine authentication once for the readback and host launch.
- operator_environ = operator_provider_environ()
- payload["managed_executor"] = managed_executor_cli_binding(args, environ=operator_environ)
- if (
- args.turn_command == "run-once"
- and args.execute
- and (payload.get("route") or {}).get("would_invoke_host") is True
- ):
- transaction = payload.get("transaction")
- transaction = transaction if isinstance(transaction, Mapping) else {}
- turn_instance_id = str(
- transaction.get("turn_instance_id")
- or transaction.get("turn_key")
- or ""
- )
- payload["reward_memory_recall"] = (
- run_configured_agent_turn_recall_fail_open(
+ turn_start_hook_dispatch = extend_cadence_turn_start_dispatch(
+ turn_start_hook_dispatch, registry_path=registry_path, runtime_root=runtime_root,
+ goal_id=args.goal_id, agent_id=args.agent_id)
+ from ..control_plane.agents.capability_memory import (
+ extend_turn_start_dispatch as extend_capability_memory_dispatch,
+ )
+
+ turn_start_hook_dispatch = extend_capability_memory_dispatch(
+ turn_start_hook_dispatch,
registry_path=registry_path,
+ runtime_root=runtime_root,
goal_id=args.goal_id,
agent_id=args.agent_id,
- quota_decision=decision,
- turn_instance_id=turn_instance_id,
- execute=True,
+ available=args.available_capabilities,
+ )
+ from ..capabilities.semantic_preference.agent_preferences import extend_turn_start_dispatch as extend_preferences
+ turn_start_hook_dispatch = extend_preferences(turn_start_hook_dispatch, runtime_root=runtime_root,
+ registry_path=registry_path, goal_id=args.goal_id, agent_id=args.agent_id)
+ from ..capabilities.explore.turn_context import extend_turn_start_dispatch as extend_explore
+ turn_start_hook_dispatch = extend_explore(turn_start_hook_dispatch,
+ registry_path=registry_path, runtime_root=runtime_root,
+ goal_id=args.goal_id, agent_id=args.agent_id)
+ # `run-once` and `managed-step` must resolve the same governing decision
+ # from the same live status, scheduler context and capability hooks, so
+ # this Turn takes all of them -- and its later settle-against inputs --
+ # from the shared decision owner instead of rebuilding them per command.
+ decision_owner = build_fresh_turn_decision_owner(
+ args,
+ registry_path=registry_path,
+ runtime_root=runtime_root,
+ runtime_root_arg=runtime_root_arg,
+ turn_start_hook_dispatch=turn_start_hook_dispatch,
+ goal_ref=goal_ref,
+ )
+ operator_inbox_urgency_projector = decision_owner.operator_inbox_urgency_projector
+ scheduler_context = decision_owner.scheduler_execution_context
+ decision = decision_owner.resolve()
+ resume_requested, session_binding = resolve_turn_resume_session_binding(args)
+ turn_envelope = build_turn_envelope(
+ decision,
+ scheduler_execution_context=scheduler_context,
+ )
+ # Operation-equipped app-server sessions retain their Todo-bound
+ # approval/handoff contract; this option controls exec conversation reuse.
+ session_scope = (args.session_scope if args.host == "codex-cli"
+ and not getattr(args, "codex_operation_tools", False) else "todo")
+ if (
+ args.turn_command == "run-once"
+ and (args.host == "codex-cli" or dsh_operation_module)
+ and not resume_requested
+ and not args.resume_turn_key
+ and args.iteration_context != "fresh"
+ and turn_envelope.get("effective_action") != EffectiveAction.GOVERNED_CAPABILITY_INTENT.value
+ ):
+ from ..control_plane.turn_driver.codex_cli import codex_cli_session_binding
+
+ session_binding = (
+ codex_cli_session_binding(
+ runtime_root,
+ turn_envelope,
+ goal_admission=strict_goal_admission,
+ session_scope=session_scope,
+ host=args.host,
+ )
+ if strict_goal_admission is not None
+ else codex_cli_session_binding(runtime_root, turn_envelope, session_scope=session_scope, host=args.host)
)
+ payload = build_loopx_turn_plan(
+ turn_envelope,
+ host=args.host,
+ execution_mode=args.execution_mode,
+ scheduler_owner=args.scheduler_owner,
+ session_binding=session_binding,
+ turn_instance_id=args.turn_instance_id,
+ iteration_context_policy=args.iteration_context,
+ session_scope=session_scope,
+ goal_ref=goal_ref,
)
- capability_action = payload.get("capability_action")
- if isinstance(capability_action, dict):
- # Bind the adapter handoff to this invocation, while leaving the
- # signed, provider-neutral intent untouched. This only projects
- # argv; the Turn driver never executes a projected shell string.
- command = shlex.split(capability_action["intent"]["command"])
- capability_action["command_argv"] = [command[0], "--registry", str(registry_path),
- "--runtime-root", str(runtime_root), *command[1:]]
- capability_action["command"] = shlex.join(capability_action["command_argv"])
- if turn_start_hook_dispatch.get("registered_count") or (
- turn_start_hook_dispatch.get("failures")
- ):
- payload["turn_start_capability_hook_dispatch"] = turn_start_hook_dispatch
- if any(isinstance(result, Mapping) and result.get("local_private_state_mutated") is True
- for result in turn_start_hook_dispatch.get("results", [])):
- # The pure plan builder has no effects, but live preflight
- # hooks may journal an inbox or calendar admission. Disclose
- # that write without claiming a host turn or report ran.
- payload["effects"]["state_written"] = True
- payload["boundary"]["read_only"] = False
+ # Resolve machine authentication once for the readback and host launch.
+ operator_environ = operator_provider_environ()
+ payload["managed_executor"] = managed_executor_cli_binding(args, environ=operator_environ)
+ if (
+ args.turn_command == "run-once"
+ and args.execute
+ and (payload.get("route") or {}).get("would_invoke_host") is True
+ ):
+ transaction = payload.get("transaction")
+ transaction = transaction if isinstance(transaction, Mapping) else {}
+ turn_instance_id = str(
+ transaction.get("turn_instance_id")
+ or transaction.get("turn_key")
+ or ""
+ )
+ payload["reward_memory_recall"] = (
+ run_configured_agent_turn_recall_fail_open(
+ registry_path=registry_path,
+ goal_id=args.goal_id,
+ agent_id=args.agent_id,
+ quota_decision=decision,
+ turn_instance_id=turn_instance_id,
+ execute=True,
+ )
+ )
+ capability_action = payload.get("capability_action")
+ if isinstance(capability_action, dict):
+ # Bind the adapter handoff to this invocation, while leaving the
+ # signed, provider-neutral intent untouched. This only projects
+ # argv; the Turn driver never executes a projected shell string.
+ command = shlex.split(capability_action["intent"]["command"])
+ capability_action["command_argv"] = [command[0], "--registry", str(registry_path),
+ "--runtime-root", str(runtime_root), *command[1:]]
+ capability_action["command"] = shlex.join(capability_action["command_argv"])
+ if turn_start_hook_dispatch.get("registered_count") or (
+ turn_start_hook_dispatch.get("failures")
+ ):
+ payload["turn_start_capability_hook_dispatch"] = turn_start_hook_dispatch
+ if any(isinstance(result, Mapping) and result.get("local_private_state_mutated") is True
+ for result in turn_start_hook_dispatch.get("results", [])):
+ # The pure plan builder has no effects, but live preflight
+ # hooks may journal an inbox or calendar admission. Disclose
+ # that write without claiming a host turn or report ran.
+ payload["effects"]["state_written"] = True
+ payload["boundary"]["read_only"] = False
if args.turn_command == "plan":
if not args.include_transaction_detail:
payload.pop("session", None)
@@ -259,40 +304,6 @@ def handle_turn_command(
if isinstance(transaction, dict):
transaction.pop("settlement_plan", None)
elif args.turn_command == "run-once":
- if args.resume_turn_key:
- if args.turn_instance_id:
- raise ValueError(
- "--resume-turn-key cannot be combined with --turn-instance-id"
- )
- if resume_requested:
- raise ValueError(
- "--resume-turn-key cannot be combined with host session identity flags"
- )
- payload = load_loopx_turn_plan_from_journal(
- runtime_root,
- goal_id=args.goal_id,
- turn_key=args.resume_turn_key,
- )
- raw_envelope = payload.get("turn_envelope")
- envelope = raw_envelope if isinstance(raw_envelope, dict) else {}
- if envelope.get("agent_id") != args.agent_id:
- raise ValueError(
- "LoopX Turn resume journal belongs to another agent"
- )
- goal_admission = FirstPartyHostGoalAdmission.for_plan(
- registry_path=registry_path,
- goal_id=args.goal_id,
- planned_goal_ref=payload.get("goal_ref"),
- )
- strict_goal_admission = goal_admission if goal_admission.enabled else None
- if strict_goal_admission is not None:
- strict_goal_admission.require_current()
- resumed_goal_ref = payload.get("goal_ref")
- goal_ref = (
- dict(resumed_goal_ref)
- if isinstance(resumed_goal_ref, Mapping)
- else None
- )
if payload.get("route", {}).get("kind") == "capability_action_required":
# The normal host transaction forbids Core mutations. A
# capability may prepare artifacts and require authored input;
@@ -307,7 +318,7 @@ def handle_turn_command(
payload = execute_turn_run_once(
args,
payload=payload,
- decision=None if args.resume_turn_key else decision,
+ decision=decision,
registry_path=registry_path,
runtime_root=runtime_root,
runtime_root_arg=runtime_root_arg,
diff --git a/loopx/contract.py b/loopx/contract.py
index 4f14268ef6..19b0c0ad43 100644
--- a/loopx/contract.py
+++ b/loopx/contract.py
@@ -4,7 +4,7 @@
import os
import re
import subprocess
-from collections.abc import Iterator
+from collections.abc import Iterator, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -866,12 +866,102 @@ def _tracked_scan_files(scan_root: Path) -> list[Path]:
return files
-def iter_scan_files(scan_root: Path) -> list[Path]:
+def _directory_reason(error: OSError) -> str:
+ """Keep the reason short and free of the directory's own text.
+
+ `strerror` is the host's message and every exercised `os.walk`/`os.scandir`
+ failure carries one. Falling back to `str(error)` would append the absolute
+ path, so the fallback is the error class instead.
+ """
+ reason = str(error.strerror) if error.strerror else type(error).__name__
+ return reason[:120]
+
+
+def _record_unreadable_directory(
+ error: OSError,
+ scan_root: Path,
+ unreadable_directories: list[tuple[Path, str]],
+) -> None:
+ """Keep a directory whose contents could not be listed visible to the caller.
+
+ ``os.walk`` swallows a listing failure by default, so a scan that cannot
+ enter a directory contributes no file and reports the same clean boundary
+ as a tree that has nothing to find. The scan owner must be able to fail
+ closed on that, exactly like an unreadable file.
+ """
+ directory = Path(error.filename) if error.filename else scan_root
+ unreadable_directories.append((directory, _directory_reason(error)))
+
+
+def _unreadable_directory_label(
+ directory: Path, scan_roots: Mapping[Path, Path]
+) -> str:
+ """Name a directory relative to the outermost requested root that contains it.
+
+ Every requested root is a candidate, so a walk that started at an outer root
+ and one that started at the directory itself agree on the directory's name
+ instead of reporting the same directory twice under two labels.
+ """
+ containing = [root for root in scan_roots if directory.is_relative_to(root)]
+ if not containing:
+ return str(directory)
+ root = min(containing, key=lambda candidate: (len(candidate.parts), str(candidate)))
+ return str(directory.relative_to(scan_roots[root])) or "."
+
+
+def _unreadable_directory_labels(
+ unreadable_directories: list[tuple[Path, str]],
+ scan_roots: Mapping[Path, Path],
+) -> list[tuple[str, str]]:
+ """Pair each unreadable directory with the label its scan root renders.
+
+ The absolute directory stays the identity, so a repeated request for one
+ root and two distinct roots are told apart before the labels collapse.
+ """
+ return [
+ (_unreadable_directory_label(directory, scan_roots), reason)
+ for directory, reason in dict.fromkeys(unreadable_directories)
+ ]
+
+
+def _render_unreadable_directories(
+ unreadable_directories: list[tuple[str, str]],
+) -> list[str]:
+ """Render each unreadable directory once, numbering colliding labels."""
+ totals: dict[str, int] = {}
+ for label, _ in unreadable_directories:
+ totals[label] = totals.get(label, 0) + 1
+ seen: dict[str, int] = {}
+ rendered: list[str] = []
+ for label, reason in unreadable_directories:
+ seen[label] = seen.get(label, 0) + 1
+ suffix = f" ({seen[label]})" if totals[label] > 1 and seen[label] > 1 else ""
+ rendered.append(f"{label}{suffix}: {reason}")
+ return rendered
+
+
+def _probe_directory_listing(
+ directory: Path, unreadable_directories: list[tuple[Path, str]]
+) -> None:
+ try:
+ with os.scandir(directory):
+ pass
+ except OSError as error:
+ _record_unreadable_directory(error, directory, unreadable_directories)
+
+
+def iter_scan_files(
+ scan_root: Path, *, unreadable_directories: list[tuple[Path, str]] | None = None
+) -> list[Path]:
scan_root = scan_root.resolve()
if scan_root.is_file():
return [scan_root]
files: list[Path] = []
+ unreadable = unreadable_directories if unreadable_directories is not None else []
tracked_files = _tracked_scan_files(scan_root)
+ # The root is probed even when dependency pruning skips the walk, so an
+ # unreadable scan target cannot pass because of how it was named.
+ _probe_directory_listing(scan_root, unreadable)
root_parts = set(scan_root.parts)
# Dependency pruning never overrides tracked repository ownership.
if (
@@ -880,7 +970,10 @@ def iter_scan_files(scan_root: Path) -> list[Path]:
):
return sorted(tracked_files)
- for dir_path, dir_names, file_names in os.walk(scan_root):
+ for dir_path, dir_names, file_names in os.walk(
+ scan_root,
+ onerror=lambda error: _record_unreadable_directory(error, scan_root, unreadable),
+ ):
current_dir = Path(dir_path)
dir_names[:] = [
name
@@ -906,15 +999,24 @@ def iter_scan_files(scan_root: Path) -> list[Path]:
def scan_public_boundary(
scan_roots: list[Path], *, registry: dict[str, Any] | None = None
) -> dict[str, Any]:
+ """Scan the requested trees and report what was found, and what was not read.
+
+ ``ok`` answers only "no private material was found in the files this scan
+ read", so a caller that must fail closed has to act on ``missing_scan_roots``
+ and ``unreadable_directories`` as well; ``check_contract`` does that for the
+ shipped CLI entry points.
+ """
hits: list[str] = []
allowed_hits: list[str] = []
private_state_git_warnings: list[str] = []
skipped_private_state_files: list[str] = []
credential_reference_hits: list[str] = []
unreadable_files: list[str] = []
+ unreadable_directories: list[tuple[Path, str]] = []
missing_scan_roots: list[str] = []
files: list[Path] = []
file_roots: dict[Path, Path] = {}
+ directory_roots: dict[Path, Path] = {}
private_files: set[Path] = set()
for scan_root in scan_roots:
resolved_scan_root = scan_root.resolve()
@@ -924,6 +1026,10 @@ def scan_public_boundary(
missing_scan_roots.append(str(scan_root))
continue
display_root = resolved_scan_root.parent if resolved_scan_root.is_file() else resolved_scan_root
+ # `file_roots` renders a file relative to the root that asked for it and
+ # is read for every hit, so an unreadable directory is labelled from its
+ # own map instead of adding a non-file key to that one.
+ directory_roots[resolved_scan_root] = display_root
classification_root = display_root
if set(resolved_scan_root.parts) & LOCAL_PRIVATE_STATE_PARTS:
# A narrow explicit input cannot erase private ancestry. A real
@@ -932,7 +1038,9 @@ def scan_public_boundary(
classification_root = (
_git_worktree_root(resolved_scan_root) or Path(resolved_scan_root.anchor)
)
- for file_path in iter_scan_files(resolved_scan_root):
+ for file_path in iter_scan_files(
+ resolved_scan_root, unreadable_directories=unreadable_directories
+ ):
files.append(file_path)
if _is_local_private_state_path(file_path, classification_root):
private_files.add(file_path)
@@ -1035,6 +1143,11 @@ def public_files() -> Iterator[Path]:
"skipped_private_state_files": skipped_private_state_files,
"credential_reference_hits": credential_reference_hits,
"unreadable_files": unreadable_files,
+ # Identity is the absolute directory, so two roots that each hold an
+ # unlistable `notes/` are both reported; colliding labels are numbered.
+ "unreadable_directories": _render_unreadable_directories(
+ _unreadable_directory_labels(unreadable_directories, directory_roots)
+ ),
"missing_scan_roots": missing_scan_roots,
"allowed_hits": allowed_hits,
"private_state_git_warnings": private_state_git_warnings,
@@ -1203,18 +1316,27 @@ def add_global_error(code: str, message: str) -> None:
if include_public_boundary_scan:
boundary = scan_public_boundary(scan_roots, registry=registry)
missing_scan_roots = [str(item) for item in boundary.get("missing_scan_roots") or []]
+ unreadable_directories = [
+ str(item) for item in boundary.get("unreadable_directories") or []
+ ]
public_boundary_scan = {
"state": "completed",
"ok": bool(boundary.get("ok")) and not missing_scan_roots,
"scanned_files": int(boundary.get("scanned_files") or 0),
"missing_scan_roots": missing_scan_roots,
+ "unreadable_directories": unreadable_directories,
}
for missing_root in missing_scan_roots:
add_global_error(
"public_boundary_scan_root_missing",
f"scan root does not exist: {missing_root}",
)
- if boundary.get("ok") and not missing_scan_roots:
+ for unreadable_directory in unreadable_directories:
+ add_global_error(
+ "public_boundary_scan_directory_unreadable",
+ f"scan directory could not be listed: {unreadable_directory}",
+ )
+ if boundary.get("ok") and not missing_scan_roots and not unreadable_directories:
checks.append(f"public boundary scan clean: {boundary.get('scanned_files')} files")
else:
for hit in boundary.get("hits") or []:
diff --git a/loopx/control_plane/collaboration/delegation.ts b/loopx/control_plane/collaboration/delegation.ts
index 7fcc06aa2e..0c0c5d5a34 100644
--- a/loopx/control_plane/collaboration/delegation.ts
+++ b/loopx/control_plane/collaboration/delegation.ts
@@ -62,7 +62,8 @@ export function delegationValidationPlan(params: JsonObject): JsonObject {
basis_sha256: canonicalAuthoritySha256({todo_id: todo.todo_id, requirements, effects}),
check_count: effects.length,
pinned_file_count: effects.reduce((count, effect) => count
- + (Array.isArray(effect.validation_files) ? effect.validation_files.length : 0), 0)};
+ + (Array.isArray(effect.validation_files) ? effect.validation_files.length : 0), 0),
+ ...currentOracleChecks(basis, effects)};
// Reuse the acceptance owner's work classification. Scheduling/progress
// metadata and unrelated provider commits are not a new task declaration;
// current claim/lifecycle still fence this particular validation attempt.
@@ -73,6 +74,20 @@ export function delegationValidationPlan(params: JsonObject): JsonObject {
effects, canonical_done: todo.done === true && todo.status === "done"};
}
+/** Consume only the acceptance owner's current exact-source observation. */
+function currentOracleChecks(basis: JsonObject, effects: JsonObject[]): JsonObject {
+ const contract = basis.goal_acceptance_contract as JsonObject | undefined;
+ if (!contract || !Array.isArray(contract.criteria) || !contract.criteria.some(raw => (raw as JsonObject).oracle)) return {};
+ const receipt = contract.verification as JsonObject | null;
+ const checks = (contract.criteria as JsonObject[]).flatMap(criterion => {
+ const oracle = criterion.oracle as JsonObject | undefined;
+ const result = (receipt?.results as JsonObject[] | undefined)?.find(row => row.criterion_id === criterion.id);
+ return oracle?.state === "current" && effects.some(effect => effect.criterion_id === criterion.id)
+ && result?.oracle_evidence ? [{criterion_id: criterion.id, ...result.oracle_evidence as JsonObject}] : [];
+ });
+ return {oracle_checks: checks};
+}
+
/** Fresh host check observation, not an independent-verifier or persisted receipt.
* Host IO brackets the actual checks; only stable declared output versions return. */
export function delegationCheckedArtifacts(params: JsonObject): JsonObject {
@@ -113,7 +128,12 @@ export function delegationCheckedArtifacts(params: JsonObject): JsonObject {
"delegation output changed during validation; recheck the original output");
return {source: observation.source, basis_sha256: observation.basis_sha256,
check_count: observation.check_count, pinned_file_count: observation.pinned_file_count,
- checked_at: params.checked_at, output_versions: after};
+ checked_at: params.checked_at, output_versions: after,
+ ...(Array.isArray(observation.oracle_checks) ? {oracle_checks: observation.oracle_checks.filter(raw => {
+ const check = requireJsonObject(raw, "oracle execution evidence");
+ return Array.isArray(check.output_versions) && after.every(output => (check.output_versions as JsonObject[])
+ .some(row => row.ref === output.ref && row.sha256 === output.sha256));
+ })} : {})};
}
export function selectDelegationBinding(params: JsonObject): JsonObject {
const config = requireJsonObject(params.config, "delegation configuration");
diff --git a/loopx/control_plane/collaboration/delegation_validation.py b/loopx/control_plane/collaboration/delegation_validation.py
index 35587ca79b..ee4c0c843a 100644
--- a/loopx/control_plane/collaboration/delegation_validation.py
+++ b/loopx/control_plane/collaboration/delegation_validation.py
@@ -29,6 +29,7 @@ def capture(service, binding: dict) -> dict:
basis = inspect_goal_acceptance(
registry_path=service.registry, runtime_root=str(service.root),
goal_id=service.goal_id, agent_id=binding["agent_id"], todo_id=binding["todo_id"],
+ validation_workspace_path=Path(binding["workspace"]),
)
todo = basis.get("todo")
if not isinstance(todo, dict):
diff --git a/loopx/control_plane/coordination/todo_create.ts b/loopx/control_plane/coordination/todo_create.ts
index dd7abb35ef..a7d1d98446 100644
--- a/loopx/control_plane/coordination/todo_create.ts
+++ b/loopx/control_plane/coordination/todo_create.ts
@@ -1,4 +1,4 @@
-import {planTodoPriority} from "../todos/priority.ts";
+import {planTodoCreationPriority} from "../todos/priority.ts";
import {AUTHORITY_SOURCE_CHANGED, uncheckedAuthoritySource, type AuthoritySourceCheck} from "./authority_source.ts";
import type { JsonObject } from "../effect_program.ts";
import {CoordinationCommandReceipt} from "./command_receipt.ts";
@@ -142,8 +142,8 @@ function createCandidate(
input: CoordinationTodoCreateInput,
readModelSchema: unknown,
): JsonObject {
- const priority = Object.hasOwn(input.todo, "priority")
- ? planTodoPriority({}, {text: input.todo.text, priority: input.todo.priority}) : {};
+ const priority = planTodoCreationPriority({text: input.todo.text,
+ ...(Object.hasOwn(input.todo, "priority") ? {priority: input.todo.priority} : {})});
const candidate: JsonObject = {
...input.todo,
...priority,
diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts
index 801c40beca..b883eb7f65 100644
--- a/loopx/control_plane/effect_runtime_handlers.ts
+++ b/loopx/control_plane/effect_runtime_handlers.ts
@@ -374,6 +374,8 @@ export function createEffectRuntimeHandlers(
["goal.source_session.turn_effect.gate", lazyHandler(() => import("./goals/source_session_lifetime.ts"), ({decideSourceTurnEffectGate}) => decideSourceTurnEffectGate)],
["goal.first_party_host_runtime.decide", lazyHandler(() => import("./goals/first_party_host_runtime.ts"), ({decideFirstPartyHostRuntime}) => decideFirstPartyHostRuntime)],
["goal.chat_session.lifecycle.decide", lazyHandler(() => import("./goals/chat_session_lifecycle.ts"), ({decideChatSessionLifecycle}) => decideChatSessionLifecycle)],
+ ["goal.acceptance.oracle.plan", lazyHandler(() => import("./goals/acceptance_oracle.ts"), ({planAcceptanceOracle}) => planAcceptanceOracle)],
+ ["goal.acceptance.oracle.observe", lazyHandler(() => import("./goals/acceptance_oracle.ts"), ({observeAcceptanceOracles}) => observeAcceptanceOracles)],
["goal.acceptance.inspect", lazyHandler(() => import("./goals/acceptance_authority.ts"), ({inspectLocalGoalAcceptance}) => inspectLocalGoalAcceptance)],
["goal.acceptance.configure", lazyHandler(() => import("./goals/acceptance_authority.ts"), ({commitLocalGoalAcceptance}) => commitLocalGoalAcceptance)],
["goal.acceptance.verify.commit", lazyHandler(() => import("./goals/acceptance_authority.ts"), ({commitLocalGoalAcceptanceVerification}) => commitLocalGoalAcceptanceVerification)],
diff --git a/loopx/control_plane/effect_runtime_io.ts b/loopx/control_plane/effect_runtime_io.ts
index a8f4b14dc4..33da3223b0 100644
--- a/loopx/control_plane/effect_runtime_io.ts
+++ b/loopx/control_plane/effect_runtime_io.ts
@@ -321,9 +321,12 @@ export async function acquireFileMutationLock(
const token = randomUUID();
const deadline = Date.now() + timeoutMs;
let retriedAfterReclaim = false;
+ let contended = false;
while (true) {
+ let opened = false;
try {
const handle = await open(lockPath, "wx", 0o600);
+ opened = true;
let identity: CreatedFileIdentity | null = null;
try {
try {
@@ -364,17 +367,26 @@ export async function acquireFileMutationLock(
}
return { targetPath, lockPath, token };
} catch (error) {
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
- const reclaimed = await reclaimStaleMutationLock(lockPath);
- // Removing a dead owner is progress, not waiting for a live owner. Allow
- // one immediate acquisition even for a zero-wait caller. Bound the retry
- // so repeated replacement cannot extend that caller's lock budget.
- if (reclaimed && !retriedAfterReclaim) {
- retriedAfterReclaim = true;
- continue;
- }
- if (Date.now() >= deadline) {
- throw new EffectRuntimeLockTimeoutError();
+ const code = (error as NodeJS.ErrnoException).code;
+ // Windows may deny exclusive creation while a contended lock is pending
+ // deletion. Retry only the unopened pathname within the original budget;
+ // initial permission errors and failures after creation remain fatal.
+ if (!opened && contended && process.platform === "win32" && code === "EPERM") {
+ if (Date.now() >= deadline) throw error;
+ } else {
+ if (code !== "EEXIST") throw error;
+ if (!opened) contended = true;
+ const reclaimed = await reclaimStaleMutationLock(lockPath);
+ // Removing a dead owner is progress, not waiting for a live owner. Allow
+ // one immediate acquisition even for a zero-wait caller. Bound the retry
+ // so repeated replacement cannot extend that caller's lock budget.
+ if (reclaimed && !retriedAfterReclaim) {
+ retriedAfterReclaim = true;
+ continue;
+ }
+ if (Date.now() >= deadline) {
+ throw new EffectRuntimeLockTimeoutError();
+ }
}
await new Promise((resolve) =>
setTimeout(
diff --git a/loopx/control_plane/goals/acceptance.py b/loopx/control_plane/goals/acceptance.py
index fda2747b36..98d6c78898 100644
--- a/loopx/control_plane/goals/acceptance.py
+++ b/loopx/control_plane/goals/acceptance.py
@@ -8,6 +8,7 @@
from __future__ import annotations
import hashlib
+from datetime import UTC, datetime
from collections.abc import Mapping
from pathlib import Path
from typing import Any
@@ -25,6 +26,7 @@
run_declared_completion_validation_effect,
)
from .goal_ref_validation import exact_goal_ref
+from .acceptance_oracle_io import oracle_versions, run_snapshot_oracle
_INSPECT_METHOD = "goal.acceptance.inspect"
@@ -61,11 +63,8 @@ def _result(
*,
timeout: float | None = None,
) -> dict[str, Any]:
- value = effect_runtime_result(
- method,
- dict(request),
- **({"timeout": timeout} if timeout is not None else {}),
- )
+ value = (effect_runtime_result(method, dict(request)) if timeout is None
+ else effect_runtime_result(method, dict(request), timeout=timeout))
if not isinstance(value, dict):
raise TypeError("Goal acceptance authority returned an invalid result")
if value.get("status") not in {
@@ -117,13 +116,35 @@ def inspect_goal_acceptance(
runtime_root: str | None = None,
agent_id: str | None = None,
todo_id: str | None = None,
+ delivery_workspace: Mapping[str, Any] | None = None,
+ validation_workspace_path: Path | None = None,
) -> dict[str, Any]:
"""Read one canonical basis; command declarations stay inside the host."""
- return _result(
+ basis = _result(
_INSPECT_METHOD,
{**_routing(registry_path, goal_id, runtime_root, agent_id),
**({"todo_id": todo_id} if todo_id is not None else {})},
)
+ observations = []
+ for criterion in (basis.get("contract") or {}).get("criteria", []):
+ oracle = criterion.get("oracle")
+ if oracle is None:
+ continue
+ workspace, failure = _resolve_completion_validation_workspace(
+ registry_path=registry_path, goal_id=goal_id,
+ task_repository=(basis.get("todo") or {}).get("task_repository"),
+ delivery_workspace=delivery_workspace, validation_workspace_path=validation_workspace_path,
+ label=criterion["id"],
+ )
+ versions = None if failure else oracle_versions(workspace, oracle)
+ observations.append({"criterion_id": criterion["id"], "available": versions is not None,
+ "source_current": _acceptance_pins_match(criterion["validation_files"], workspace),
+ **(versions or {})})
+ if observations:
+ basis["goal_acceptance_contract"] = effect_runtime_result("goal.acceptance.oracle.observe", {
+ "contract": basis["goal_acceptance_contract"], "observations": observations,
+ })
+ return basis
def _criterion_effects(criteria: list[dict[str, Any]]) -> list[dict[str, Any]]:
@@ -135,6 +156,7 @@ def _criterion_effects(criteria: list[dict[str, Any]]) -> list[dict[str, Any]]:
"validation_label": f"Goal acceptance: {row['id']}",
"validation_timeout_seconds": row.get("validation_timeout_seconds", 29),
"validation_files": row.get("validation_files", []),
+ **({"oracle": row["oracle"]} if "oracle" in row else {}),
}
for row in criteria
]
@@ -257,12 +279,13 @@ def run_goal_acceptance_effects(
"exit_code": exit_code
if isinstance(exit_code, int) and 0 <= exit_code <= 255
else None,
+ **({"oracle_evidence": receipt["oracle_evidence"]} if "oracle_evidence" in receipt else {}),
}
)
return results
-def _acceptance_pins_match(pins: list, workspace: Path | None) -> bool:
+def _acceptance_pins_match(pins: list[dict[str, Any]], workspace: Path | None) -> bool:
if not pins:
return True
if workspace is None:
@@ -309,7 +332,7 @@ def run_goal_acceptance_validation_effect(
raise TypeError("acceptance validation_files must be an array")
label = str(effect.get("validation_label") or "Goal acceptance")
workspace = None
- if pins:
+ if pins or effect.get("oracle"):
workspace, failure = _resolve_completion_validation_workspace(
registry_path=registry_path,
goal_id=goal_id,
@@ -336,16 +359,36 @@ def stale_receipt() -> dict[str, Any]:
if not _acceptance_pins_match(pins, workspace):
return stale_receipt()
- receipt = run_declared_completion_validation_effect(
- effect={
- key: value for key, value in effect.items() if key != "validation_files"
- },
- registry_path=registry_path,
- goal_id=goal_id,
- delivery_workspace=delivery_workspace,
- validation_workspace_path=validation_workspace_path,
- )
- return receipt if _acceptance_pins_match(pins, workspace) else stale_receipt()
+ oracle = effect.get("oracle")
+ plan = effect_runtime_result("goal.acceptance.oracle.plan", {"criterion": dict(effect)}) if oracle else None
+ before = oracle_versions(workspace, oracle) if oracle else None
+ if oracle and before is None:
+ return stale_receipt()
+ if oracle:
+ if workspace is None or before is None or plan is None:
+ return stale_receipt()
+ try:
+ receipt, stable_snapshot = run_snapshot_oracle(workspace=workspace, criterion=dict(effect), before=before)
+ except OSError:
+ return stale_receipt()
+ if not stable_snapshot:
+ return stale_receipt()
+ else:
+ receipt = run_declared_completion_validation_effect(
+ effect={key: value for key, value in effect.items() if key != "validation_files"},
+ registry_path=registry_path, goal_id=goal_id,
+ delivery_workspace=delivery_workspace, validation_workspace_path=validation_workspace_path,
+ )
+ if not _acceptance_pins_match(pins, workspace):
+ return stale_receipt()
+ if oracle:
+ after = oracle_versions(workspace, oracle)
+ if after is None or after != before:
+ return stale_receipt()
+ if receipt["passed"] and plan is not None:
+ receipt["oracle_evidence"] = {"source_id": plan["source_id"], "source_digest": plan["source_digest"],
+ "checked_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), **after}
+ return receipt
def public_goal_acceptance(value: dict[str, Any]) -> dict[str, Any]:
@@ -371,10 +414,14 @@ def verify_goal_acceptance(
runtime_root: str | None = None,
agent_id: str | None = None,
execute: bool = False,
+ expected_provider_revision: str | None = None,
+ operation_id: str | None = None,
) -> dict[str, Any]:
"""Run the configured acceptance checks against a frozen canonical basis."""
route = _routing(registry_path, goal_id, runtime_root, agent_id)
- basis = _result(_INSPECT_METHOD, route)
+ basis = inspect_goal_acceptance(registry_path=registry_path, goal_id=goal_id, runtime_root=runtime_root, agent_id=agent_id)
+ if expected_provider_revision is not None and basis["provider_revision"] != expected_provider_revision:
+ raise ValueError("Acceptance changed; reload the current contract before checking.")
contract = basis.get("contract")
if contract is None:
raise ValueError("Goal acceptance is not enabled")
@@ -395,14 +442,19 @@ def verify_goal_acceptance(
**route,
"expected_provider_revision": basis["provider_revision"],
"actor_agent_id": None,
- "operation_id": f"goal-acceptance-verify:{uuid4().hex}",
+ "operation_id": operation_id or f"goal-acceptance-verify:{uuid4().hex}",
"contract_digest": basis["goal_acceptance_contract"]["digest"],
"revision": basis["goal_acceptance_contract"]["revision"],
"results": receipts,
},
)
+ public_result = public_goal_acceptance(result)
+ if any("oracle" in criterion for criterion in criteria):
+ observed = inspect_goal_acceptance(registry_path=registry_path, goal_id=goal_id,
+ runtime_root=runtime_root, agent_id=agent_id)
+ public_result["goal_acceptance_contract"] = observed["goal_acceptance_contract"]
return {
- **public_goal_acceptance(result),
+ **public_result,
"executed": True,
"checks_passed": all(row.get("passed") is True for row in receipts),
"acceptance_ready": result.get("goal_acceptance_contract", {}).get("status")
diff --git a/loopx/control_plane/goals/acceptance_actions.py b/loopx/control_plane/goals/acceptance_actions.py
new file mode 100644
index 0000000000..699bbeb674
--- /dev/null
+++ b/loopx/control_plane/goals/acceptance_actions.py
@@ -0,0 +1,136 @@
+"""Local Chat-action transport for an Agent-prepared acceptance check.
+
+Reuse ChatActionStore's proposal/checkpoint owner and the TS acceptance owner.
+The per-proposal host lock and started checkpoint fence command execution;
+preparation only validates a document and cannot configure or run it.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, TypedDict
+from uuid import uuid4
+
+from ...chat_action_store import ActionConflictError, ChatActionStore
+from ...file_lock import exclusive_file_lock
+from .acceptance import (
+ configure_goal_acceptance, inspect_goal_acceptance, public_goal_acceptance,
+ verify_goal_acceptance,
+)
+
+
+class _AcceptanceRoute(TypedDict):
+ registry_path: Path
+ runtime_root: str
+ goal_id: str
+
+
+def prepare_acceptance_check(*, registry_path: Path, runtime_root: str | None,
+ goal_id: str, agent_id: str | None,
+ document: dict[str, Any], operation_id: str,
+ execute: bool) -> dict[str, Any]:
+ from ...chat_actions import ChatActionService
+ from ...paths import effective_runtime_root
+
+ root = effective_runtime_root(registry_path, runtime_root)
+ if not execute:
+ route: _AcceptanceRoute = {
+ "registry_path": registry_path, "runtime_root": str(root), "goal_id": goal_id,
+ }
+ before = inspect_goal_acceptance(**route, agent_id=agent_id)
+ preview = configure_goal_acceptance(**route, document=document,
+ expected_provider_revision=before["provider_revision"],
+ operation_id=f"acceptance-preview:{uuid4().hex}", execute=False)
+ return {**public_goal_acceptance(preview), "prepared": False, "executed": False}
+ service = ChatActionService(store=ChatActionStore(root / "chat" / "actions"),
+ registry_path=registry_path)
+ parameters = service._normalize("goal.acceptance.check", {
+ "goal_id": goal_id, "document": document, "prepared_by": agent_id})
+ proposal = service.preview({"action_kind": "goal.acceptance.check",
+ "summary": str(document.get("objective") or "Check the delivery"),
+ "idempotency_key": operation_id, "context": {"kind": "goal", "goal_id": goal_id},
+ "normalized_parameters": parameters})
+ return {"ok": True, "goal_id": goal_id, "prepared": True, "executed": False,
+ "proposal": proposal}
+
+
+class GoalAcceptanceActionMixin:
+ store: ChatActionStore
+ registry_path: Path
+
+ def _acceptance_route(self, goal_id: str) -> _AcceptanceRoute:
+ # ChatHTTPServer and the prepare CLI both use runtime/chat/actions.
+ return {"registry_path": self.registry_path, "goal_id": goal_id,
+ "runtime_root": str(self.store.root.parent.parent)}
+
+ def _acceptance_preview(self, parameters: dict[str, Any]) -> tuple[str, dict[str, Any]]:
+ route = self._acceptance_route(parameters["goal_id"])
+ before = inspect_goal_acceptance(**route)
+ preview = configure_goal_acceptance(
+ **route, document=parameters["document"],
+ expected_provider_revision=before["provider_revision"],
+ operation_id=f"acceptance-preview:{uuid4().hex}", execute=False,
+ )
+ if preview.get("status") != "planned":
+ raise ValueError("The current Goal cannot prepare this check plan")
+ return before["provider_revision"], preview
+
+ def _apply_acceptance_check(self, proposal_id: str) -> dict[str, Any]:
+ # The existing generic start_apply is replayable. Serialize this host
+ # command boundary so concurrent tabs/processes consume one intent.
+ with exclusive_file_lock(self.store.root / f"{proposal_id}.check",
+ agent_id="loopx-chat", operation="acceptance_check"):
+ proposal = self.store.load(proposal_id)
+ if proposal is None:
+ raise KeyError("Check plan was not found")
+ if proposal["status"] == "applied":
+ return {"proposal": proposal, "turn": None}
+ parameters = proposal["normalized_parameters"]
+ route = self._acceptance_route(parameters["goal_id"])
+ steps = (proposal.get("checkpoint") or {}).get("steps", {})
+ configured = steps.get("acceptance_configured")
+ operation_id = f"acceptance-check:{proposal_id}"
+ if configured is None:
+ expected = proposal["expected_state_fingerprint"]
+ try:
+ # Its native idempotency receipt also recovers a lost
+ # configuration response before the Chat checkpoint.
+ saved = configure_goal_acceptance(
+ **route, document=parameters["document"],
+ expected_provider_revision=expected,
+ operation_id=f"acceptance-configure:{proposal_id}", execute=True,
+ )
+ except ValueError:
+ current = inspect_goal_acceptance(**route)
+ if current["provider_revision"] != expected:
+ stored = self.store.apply(proposal_id,
+ current_state_fingerprint=current["provider_revision"], receipt={})
+ return {"proposal": stored, "turn": None}
+ raise
+ configured = {"provider_revision": saved["provider_revision"],
+ "digest": saved["goal_acceptance_contract"]["digest"]}
+ self.store.save_checkpoint(proposal_id, step="acceptance_configured", receipt=configured)
+ current = inspect_goal_acceptance(**route)
+ contract = current["goal_acceptance_contract"]
+ observed = contract.get("verification") or {}
+ if steps.get("acceptance_check_started"):
+ # A native committed receipt may recover a lost HTTP result.
+ # Missing receipt is uncertainty, never permission to rerun.
+ if observed.get("operation_id") != operation_id:
+ raise ActionConflictError("The previous check result is unconfirmed. Read current evidence before explicitly running another check.")
+ result = {**public_goal_acceptance(current), "executed": True,
+ "checks_passed": all(row["passed"] for row in observed["results"])}
+ else:
+ if (current["provider_revision"] != configured["provider_revision"]
+ or contract.get("digest") != configured["digest"]):
+ raise ActionConflictError("The check basis changed. Prepare a current plan before executing.")
+ self.store.save_checkpoint(proposal_id, step="acceptance_check_started",
+ receipt={"operation_id": operation_id})
+ result = verify_goal_acceptance(**route, execute=True,
+ expected_provider_revision=configured["provider_revision"], operation_id=operation_id)
+ receipt = {"receipt_id": f"acceptance-{proposal_id}", "outcome": "acceptance_checked",
+ "projection_verified": True, "goal_id": parameters["goal_id"],
+ "operation_id": operation_id, "checks_passed": result["checks_passed"]}
+ stored = self.store.apply(proposal_id,
+ current_state_fingerprint=proposal["expected_state_fingerprint"], receipt=receipt)
+ return {"proposal": stored, "turn": None}
diff --git a/loopx/control_plane/goals/acceptance_authority.ts b/loopx/control_plane/goals/acceptance_authority.ts
index e62be27ee7..32a360cef9 100644
--- a/loopx/control_plane/goals/acceptance_authority.ts
+++ b/loopx/control_plane/goals/acceptance_authority.ts
@@ -19,6 +19,8 @@ import {GOAL_ACCEPTANCE_LIFECYCLE_SCHEMA, parseGoalAcceptanceLifecycleTransition
readGoalAcceptanceLifecycle, sameExactGoalRef, type GoalAcceptanceLifecycle,
type GoalAcceptanceLifecycleTransition, type WireExactGoalRef} from "./acceptance_lifecycle.ts";
+import {validateOracleResults} from "./acceptance_oracle.ts";
+
const RESULT_SCHEMA = "loopx_goal_acceptance_result_v0";
const RECEIPT_SCHEMA = "loopx_goal_acceptance_operation_v0";
const LIFECYCLE_RECEIPT_SCHEMA = "loopx_goal_acceptance_lifecycle_operation_v0";
@@ -182,8 +184,15 @@ export async function configureGoalAcceptance(store: AuthorityStore, value: Json
"acceptance binding must reference existing Agent advancement work");
return {...binding, todo_semantic_digest: goalAcceptanceTodoDigest(todo), revision, confirmed_by: "owner" as const};
});
+ // Removing the optional oracle withdraws its current receipt. The immutable
+ // operation journal retains that evidence; older readers can then consume
+ // the ordinary contract without understanding the optional result fields.
+ const previousVerification = previous?.verification ?? null;
+ const verification = !document.criteria.some(criterion => criterion.oracle !== undefined)
+ && previousVerification?.results.some(result => result.oracle_evidence !== undefined)
+ ? null : previousVerification;
const fields = {enabled: true, revision, digest: canonicalAuthoritySha256(document),
- document, bindings, verification: previous?.verification ?? null};
+ document, bindings, verification};
const goalRef = requestGoalRef(request);
state = goalRef === null
? {schema_version: GOAL_ACCEPTANCE_SCHEMA, ...fields}
@@ -222,6 +231,7 @@ export async function commitGoalAcceptanceVerification(store: AuthorityStore, va
criterionIds = task.criterion_ids;
}
normalizeAcceptanceResults(results, criterionIds);
+ validateOracleResults(state.document.criteria, results);
const verification: AcceptanceVerification = {operation_id: String(request.operation_id),
contract_revision: state.revision, contract_digest: state.digest, todo_id: todoId,
work_digest: goalAcceptanceWorkDigest(head.head, goalId, state.document.scope), results};
diff --git a/loopx/control_plane/goals/acceptance_contract.ts b/loopx/control_plane/goals/acceptance_contract.ts
index 0ece3e8d36..f0609ccce0 100644
--- a/loopx/control_plane/goals/acceptance_contract.ts
+++ b/loopx/control_plane/goals/acceptance_contract.ts
@@ -10,6 +10,8 @@ import { BARE_SHA256_PATTERN } from "../content_digest.ts";
import {parseWireExactGoalRef, readGoalAcceptanceLifecycle, sameExactGoalRef, type GoalAcceptanceLifecycle,
type WireExactGoalRef} from "./acceptance_lifecycle.ts";
+import {normalizeAcceptanceOracle, normalizeOracleEvidence, projectAcceptanceOracle, validateOracleResults, type AcceptanceOracle} from "./acceptance_oracle.ts";
+
export const GOAL_ACCEPTANCE_SCHEMA = "loopx_goal_acceptance_v0";
export const GOAL_ACCEPTANCE_OWNED_SCHEMA = "loopx_goal_acceptance_v1";
export interface AcceptanceCriterion extends JsonObject {
@@ -18,6 +20,7 @@ export interface AcceptanceCriterion extends JsonObject {
validation_argv: string[];
validation_timeout_seconds: number;
validation_files: {path: string; sha256: string}[];
+ oracle?: AcceptanceOracle;
}
export type AcceptanceScope =
| {kind: "all_advancement"}
@@ -97,6 +100,7 @@ export function acceptanceValidationEffects(requirements: AcceptanceCompletionRe
kind: "caller_validation", validation_command: null, validation_argv: criterion.validation_argv,
validation_label: criterion.id, validation_timeout_seconds: criterion.validation_timeout_seconds,
...(criterion.validation_files == null ? {} : {validation_files: criterion.validation_files}),
+ ...(criterion.oracle === undefined ? {} : {oracle: criterion.oracle}),
task_repository: todo.task_repository ?? null,
}}));
}
@@ -170,7 +174,7 @@ export function normalizeGoalAcceptanceDocument(value: unknown): AcceptanceDocum
acceptanceRequire(canonicalAuthorityBytes(raw).length <= 262144, "acceptance document exceeds byte limit");
const criteria = list(raw.criteria, "acceptance criteria", 64, 1).map(value => {
const item = canonicalAuthorityObject(value, "acceptance criterion");
- acceptanceKeys(item, ["id", "description", "validation_argv"], ["validation_timeout_seconds", "validation_files"]);
+ acceptanceKeys(item, ["id", "description", "validation_argv"], ["validation_timeout_seconds", "validation_files", "oracle"]);
const argv = list(item.validation_argv, "validation argv", 128, 1).map(arg => {
acceptanceRequire(typeof arg === "string" && arg.length > 0 && arg.length <= 8192 && !arg.includes("\0"), "validation argv contains an invalid argument");
return arg;
@@ -178,9 +182,10 @@ export function normalizeGoalAcceptanceDocument(value: unknown): AcceptanceDocum
acceptanceRequire(argv[0].trim().length > 0, "validation argv requires an executable");
const timeout = item.validation_timeout_seconds ?? 5;
acceptanceRequire(Number.isSafeInteger(timeout) && Number(timeout) >= 1 && Number(timeout) <= 25, "validation timeout must be 1..25 seconds");
- return {id: id(item.id), description: acceptanceText(item.description, "criterion description"),
+ const criterion = {id: id(item.id), description: acceptanceText(item.description, "criterion description"),
validation_argv: argv, validation_timeout_seconds: Number(timeout),
validation_files: validationFiles(item.validation_files === undefined ? [] : item.validation_files)};
+ return {...criterion, ...(item.oracle === undefined ? {} : {oracle: normalizeAcceptanceOracle(item.oracle, criterion)})};
}).sort((a, b) => authorityUnicodeCompare(a.id, b.id));
acceptanceRequire(criteria.reduce((total, item) => total + item.validation_timeout_seconds, 0) <= 25,
"acceptance validation timeouts must total at most 25 seconds (default 5 seconds per criterion); lower the timeouts or run longer evaluations outside the completion wrapper");
@@ -370,6 +375,7 @@ export function readGoalAcceptanceAuthority(head: JsonObject, goalId: string): G
}
verification = {...receipt, operation_id: operationId, todo_id: todoId,
results: normalizeAcceptanceResults(receipt.results, expectedIds)} as AcceptanceVerification;
+ if (receipt.contract_revision === state.revision) validateOracleResults(document.criteria, verification.results);
}
if (lifecycle === null) {
const legacyState: LegacyAcceptanceState = {
@@ -441,14 +447,15 @@ export function normalizeAcceptanceResults(value: unknown, expectedIds?: readonl
// Existing caller-validation runner metadata is accepted at the host seam
// but deliberately not persisted or projected (labels can contain context).
acceptanceKeys(item, ["criterion_id", "passed", "exit_code"], ["schema_version", "command_label", "status", "summary",
- "stdout_captured", "stderr_captured", "local_path_captured"]);
+ "stdout_captured", "stderr_captured", "local_path_captured", "oracle_evidence"]);
for (const key of ["stdout_captured", "stderr_captured", "local_path_captured"]) {
acceptanceRequire(item[key] === undefined || item[key] === false, "validation result must not capture private output or paths");
}
acceptanceRequire(typeof item.passed === "boolean" && (item.exit_code === null ||
(Number.isSafeInteger(item.exit_code) && Number(item.exit_code) >= 0 && Number(item.exit_code) <= 255)), "invalid verification result");
acceptanceRequire(item.passed === (item.exit_code === 0), "criterion pass/fail must agree with exit code zero");
- return {criterion_id: id(item.criterion_id), passed: item.passed, exit_code: item.exit_code as number | null};
+ return {criterion_id: id(item.criterion_id), passed: item.passed, exit_code: item.exit_code as number | null,
+ ...(item.oracle_evidence === undefined ? {} : {oracle_evidence: normalizeOracleEvidence(item.oracle_evidence)})};
}).sort((a, b) => authorityUnicodeCompare(a.criterion_id, b.criterion_id));
const ids = unique(results.map(item => item.criterion_id), "verification results");
if (expectedIds) acceptanceRequire(canonicalAuthoritySha256(ids) === canonicalAuthoritySha256([...expectedIds].sort(authorityUnicodeCompare)),
@@ -484,7 +491,8 @@ export function projectGoalAcceptance(head: JsonObject, goalId: string): JsonObj
if (held.length) status = "held";
return {...exactProjection, ...(state.document.scope === undefined ? {} : {scope: state.document.scope}),
enabled: true, revision: state.revision, digest: state.digest, objective: state.document.objective,
- non_goals: state.document.non_goals, criteria: state.document.criteria.map(({id, description}) => ({id, description})),
+ non_goals: state.document.non_goals, criteria: state.document.criteria.map(criterion => ({id: criterion.id, description: criterion.description,
+ ...(criterion.oracle === undefined ? {} : {oracle: projectAcceptanceOracle(criterion, receipt?.results.find(row => row.criterion_id === criterion.id), status === "stale" || status === "held")})})),
tasks, held_todo_ids: held.map(task => task.todo_id), status,
verification: receipt ? {operation_id: receipt.operation_id, contract_revision: receipt.contract_revision,
contract_digest: receipt.contract_digest, todo_id: receipt.todo_id, results: receipt.results} : null};
diff --git a/loopx/control_plane/goals/acceptance_http.py b/loopx/control_plane/goals/acceptance_http.py
new file mode 100644
index 0000000000..8163c3a7d9
--- /dev/null
+++ b/loopx/control_plane/goals/acceptance_http.py
@@ -0,0 +1,130 @@
+"""Loopback operator transport for the existing acceptance owner.
+
+TypeScript retains configuration, evidence and CAS decisions. This adapter only
+parses the local HTTP request and invokes the same host IO as the CLI.
+"""
+
+from __future__ import annotations
+from pathlib import Path
+from typing import Any, Protocol
+from urllib.parse import parse_qs, urlparse
+
+
+class _AcceptanceServer(Protocol):
+ registry_path: Path
+ runtime_root: Path
+
+
+class GoalAcceptanceRequestMixin:
+ server: _AcceptanceServer
+ path: str
+
+ def _read_json(self) -> dict[str, Any]:
+ raise NotImplementedError
+
+ def _send_json(self, payload: dict[str, Any], *, status: int = 200) -> None:
+ raise NotImplementedError
+
+ def _send_error(self, message: str, *, status: int, error_code: str) -> None:
+ raise NotImplementedError
+
+ def _goal_acceptance_inspect(self) -> None:
+ from .acceptance import inspect_goal_acceptance, public_goal_acceptance
+
+ try:
+ query = parse_qs(urlparse(self.path).query, keep_blank_values=True)
+ if (
+ set(query) != {"goal_id"}
+ or len(query["goal_id"]) != 1
+ or not query["goal_id"][0].strip()
+ ):
+ raise ValueError("one goal_id is required")
+ result = public_goal_acceptance(
+ inspect_goal_acceptance(
+ registry_path=self.server.registry_path,
+ runtime_root=str(self.server.runtime_root),
+ goal_id=query["goal_id"][0],
+ )
+ )
+ self._send_json({**result, "goal_id": query["goal_id"][0]})
+ except (OSError, TypeError, ValueError, RuntimeError):
+ self._send_error(
+ "Acceptance is unavailable for this Goal; reload after its local authority is available.",
+ status=409,
+ error_code="goal_acceptance_unavailable",
+ )
+
+ def _goal_acceptance_update(self) -> None:
+ from .acceptance import (
+ configure_goal_acceptance,
+ public_goal_acceptance,
+ verify_goal_acceptance,
+ )
+
+ try:
+ body = self._read_json()
+ if set(body) - {
+ "goal_id",
+ "action",
+ "document",
+ "expected_provider_revision",
+ "operation_id",
+ }:
+ raise ValueError("unknown acceptance request fields")
+ goal_id, action = body.get("goal_id"), body.get("action")
+ revision = body.get("expected_provider_revision")
+ if (
+ not isinstance(goal_id, str)
+ or not goal_id.strip()
+ or not isinstance(revision, str)
+ or not revision
+ ):
+ raise ValueError("Goal and current provider revision are required")
+ common: dict[str, Any] = {
+ "registry_path": self.server.registry_path,
+ "runtime_root": str(self.server.runtime_root),
+ "goal_id": goal_id,
+ "execute": True,
+ }
+ if action == "verify":
+ if set(body) - {"goal_id", "action", "expected_provider_revision"}:
+ raise ValueError("check does not accept declarations or results")
+ result = verify_goal_acceptance(
+ **common, expected_provider_revision=revision
+ )
+ elif action in {"configure", "disable"}:
+ document = body.get("document")
+ if (
+ action == "configure"
+ and not isinstance(document, dict)
+ or action == "disable"
+ and document is not None
+ ):
+ raise ValueError(
+ "configuration requires a document; disable requires no document"
+ )
+ operation_id = body.get("operation_id")
+ if not isinstance(operation_id, str) or not operation_id:
+ raise ValueError(
+ "configuration requires a stable operation identity"
+ )
+ result = public_goal_acceptance(
+ configure_goal_acceptance(
+ **common,
+ document=document,
+ disable=action == "disable",
+ expected_provider_revision=revision,
+ operation_id=operation_id,
+ )
+ )
+ else:
+ raise ValueError("unknown acceptance operation")
+ self._send_json({**result, "goal_id": goal_id})
+ except (OSError, TypeError, ValueError, RuntimeError):
+ # An uncertain check is never automatically replayed. The caller
+ # can inspect the current receipt before explicitly running again.
+ self._send_error(
+ "Acceptance could not be applied or checked. Read back the current contract and receipt before retrying.",
+ status=409,
+ error_code="goal_acceptance_operation_failed",
+ )
diff --git a/loopx/control_plane/goals/acceptance_oracle.ts b/loopx/control_plane/goals/acceptance_oracle.ts
new file mode 100644
index 0000000000..af8d4121b5
--- /dev/null
+++ b/loopx/control_plane/goals/acceptance_oracle.ts
@@ -0,0 +1,135 @@
+/** Optional provenance for an owner-pinned checker, never an Agent's verdict.
+ * The local host executes the configured checker and observes file bytes. This
+ * does not authenticate a remote reviewer or attest unpinned dependencies. */
+import type {JsonObject} from "../effect_program.ts";
+import {AuthorityStoreProtocolError, canonicalAuthorityObject, canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts";
+import {BARE_SHA256_PATTERN} from "../content_digest.ts";
+
+export type AcceptanceOracleState = "unverified" | "recorded" | "current" | "stale" | "failed" | "unavailable";
+
+function requireThat(value: unknown, message: string): asserts value {
+ if (!value) throw new AuthorityStoreProtocolError(message);
+}
+function keys(row: JsonObject, fields: string[]): void {
+ requireThat(Object.keys(row).length === fields.length && fields.every(key => Object.hasOwn(row, key)), "invalid acceptance oracle fields");
+}
+export function acceptanceArtifactRef(value: unknown): string {
+ requireThat(typeof value === "string" && value.length <= 1024 && /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(value)
+ && value.split("/").every(part => part !== "." && part !== ".."), "oracle files must be safe repository-relative refs");
+ return value;
+}
+function refs(value: unknown, required: boolean): string[] {
+ requireThat(Array.isArray(value) && value.length >= (required ? 1 : 0) && value.length <= 20, "oracle needs bounded explicit file refs");
+ const result = value.map(acceptanceArtifactRef).sort();
+ requireThat(new Set(result).size === result.length, "duplicate oracle file ref");
+ return result;
+}
+export interface AcceptanceOracle extends JsonObject {
+ kind: "owner_pinned_oracle";
+ source_id: string;
+ input_refs: string[];
+ output_refs: string[];
+}
+export function normalizeAcceptanceOracle(value: unknown, criterion: JsonObject): AcceptanceOracle {
+ const row = canonicalAuthorityObject(value, "acceptance oracle");
+ keys(row, ["kind", "source_id", "input_refs", "output_refs"]);
+ requireThat(row.kind === "owner_pinned_oracle" && typeof row.source_id === "string"
+ && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(row.source_id), "owner-pinned oracle source identity required");
+ const input_refs = refs(row.input_refs, false), output_refs = refs(row.output_refs, true);
+ requireThat(!input_refs.some(ref => output_refs.includes(ref)), "oracle input and output roles must be distinct");
+ const pins = criterion.validation_files as {path: string; sha256: string}[];
+ const argv = criterion.validation_argv as string[];
+ requireThat(Array.isArray(pins) && pins.length > 0 && Array.isArray(argv),
+ "oracle requires an explicitly pinned checker");
+ // This is the supported invocation grammar, not interpreter attestation.
+ // Arbitrary commands may ignore argv[1]; only Python's immediate script
+ // position has that meaning here. Other runtimes can execute a pinned entry
+ // point directly. The interpreter/environment remain trusted host inputs.
+ const executableName = argv[0]?.split(/[\\/]/).at(-1) ?? "";
+ const hostInterpreterPath = executableName === argv[0] || /^(?:\/|[A-Za-z]:[\\/])/.test(argv[0] ?? "");
+ const pythonScript = hostInterpreterPath && /^python(?:3(?:\.\d+)?)?(?:\.exe)?$/.test(executableName)
+ && typeof argv[1] === "string" && !argv[1].startsWith("-")
+ && pins.some(pin => pin.path === argv[1]);
+ const directSource = typeof argv[0] === "string" && argv[0].includes("/")
+ && pins.some(pin => pin.path === argv[0]);
+ requireThat(directSource || pythonScript,
+ "oracle requires a pinned relative executable path containing '/' or a pinned script immediately after a supported Python interpreter; arbitrary commands and interpreter options are unsupported");
+ requireThat(!pins.some(pin => input_refs.includes(pin.path) || output_refs.includes(pin.path)),
+ "oracle source and checked artifacts must be distinct");
+ return {kind: "owner_pinned_oracle", source_id: row.source_id, input_refs, output_refs};
+}
+export function acceptanceOracleBasis(criterion: JsonObject): string {
+ return canonicalAuthoritySha256({oracle: criterion.oracle, validation_argv: criterion.validation_argv,
+ validation_files: criterion.validation_files, validation_timeout_seconds: criterion.validation_timeout_seconds});
+}
+export interface OracleVersion extends JsonObject {ref: string; sha256: string}
+export function normalizeOracleVersions(value: unknown): OracleVersion[] {
+ requireThat(Array.isArray(value) && value.length <= 20, "bounded oracle versions required");
+ const result = value.map(item => {
+ const row = canonicalAuthorityObject(item, "oracle artifact version"); keys(row, ["ref", "sha256"]);
+ requireThat(typeof row.sha256 === "string" && BARE_SHA256_PATTERN.test(row.sha256), "oracle artifact version requires SHA-256");
+ return {ref: acceptanceArtifactRef(row.ref), sha256: row.sha256};
+ }).sort((a,b) => a.ref < b.ref ? -1 : a.ref > b.ref ? 1 : 0);
+ requireThat(new Set(result.map(row => row.ref)).size === result.length, "duplicate oracle artifact version");
+ return result;
+}
+export function normalizeOracleEvidence(value: unknown): JsonObject {
+ const row = canonicalAuthorityObject(value, "oracle execution evidence");
+ keys(row, ["source_id", "source_digest", "checked_at", "input_versions", "output_versions"]);
+ requireThat(typeof row.source_id === "string" && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(row.source_id)
+ && typeof row.source_digest === "string" && BARE_SHA256_PATTERN.test(row.source_digest), "oracle evidence source required");
+ requireThat(typeof row.checked_at === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/.test(row.checked_at)
+ && Number.isFinite(Date.parse(row.checked_at)), "oracle check time required");
+ return {...row, input_versions: normalizeOracleVersions(row.input_versions), output_versions: normalizeOracleVersions(row.output_versions)};
+}
+export function validateOracleResults(criteria: JsonObject[], results: JsonObject[]): void {
+ for (const result of results) {
+ const criterion = criteria.find(row => row.id === result.criterion_id);
+ if (result.oracle_evidence === undefined) {
+ requireThat(result.passed !== true || criterion?.oracle === undefined, "successful oracle check omitted execution evidence");
+ continue;
+ }
+ requireThat(criterion?.oracle !== undefined && result.passed === true, "unconfigured or failed check cannot attest oracle evidence");
+ const oracle = criterion.oracle as AcceptanceOracle, evidence = normalizeOracleEvidence(result.oracle_evidence);
+ requireThat(evidence.source_id === oracle.source_id && evidence.source_digest === acceptanceOracleBasis(criterion), "oracle evidence source changed");
+ for (const role of ["input", "output"]) {
+ const versions = evidence[`${role}_versions`] as OracleVersion[];
+ requireThat(canonicalAuthoritySha256(versions.map(row => row.ref)) === canonicalAuthoritySha256(oracle[`${role}_refs`]), "oracle evidence omitted or added a checked artifact");
+ }
+ }
+}
+/** Raw canonical projections say recorded, never current, without host IO. */
+export function projectAcceptanceOracle(criterion: JsonObject, result: JsonObject | undefined, stale: boolean): JsonObject {
+ const oracle = criterion.oracle as AcceptanceOracle;
+ return {kind: oracle.kind, source_id: oracle.source_id, source_digest: acceptanceOracleBasis(criterion),
+ input_refs: oracle.input_refs, output_refs: oracle.output_refs,
+ state: stale ? "stale" : !result ? "unverified" : result.passed === false ? "failed" : result.oracle_evidence ? "recorded" : "unavailable"};
+}
+/** Host observations only; a read never runs checks, certifies a new version or
+ * changes canonical acceptance/completion. Unavailable facts are not success. */
+export function observeAcceptanceOracles(params: JsonObject): JsonObject {
+ const contract = canonicalAuthorityObject(params.contract, "acceptance projection");
+ if (contract.enabled !== true) return contract;
+ requireThat(Array.isArray(contract.criteria) && Array.isArray(params.observations), "oracle observations required");
+ const receipt = contract.verification as JsonObject | null;
+ const results = receipt?.results as JsonObject[] | undefined;
+ return {...contract, criteria: (contract.criteria as JsonObject[]).map(criterion => {
+ if (!criterion.oracle) return criterion;
+ const oracle = criterion.oracle as JsonObject;
+ if (oracle.state !== "recorded") return criterion;
+ const evidence = results?.find(row => row.criterion_id === criterion.id)?.oracle_evidence as JsonObject | undefined;
+ const facts = (params.observations as JsonObject[]).find(row => row.criterion_id === criterion.id);
+ let state: AcceptanceOracleState = "unavailable";
+ if (facts?.available === true && evidence) {
+ state = facts.source_current === true && ["input", "output"].every(role =>
+ canonicalAuthoritySha256(normalizeOracleVersions(facts[`${role}_versions`])) === canonicalAuthoritySha256(evidence[`${role}_versions`])) ? "current" : "stale";
+ }
+ return {...criterion, oracle: {...oracle, state}};
+ })};
+}
+
+export function planAcceptanceOracle(params: JsonObject): JsonObject {
+ const criterion = canonicalAuthorityObject(params.criterion, "oracle criterion");
+ const oracle = normalizeAcceptanceOracle(criterion.oracle, criterion);
+ return {...oracle, source_digest: acceptanceOracleBasis({...criterion, oracle})};
+}
diff --git a/loopx/control_plane/goals/acceptance_oracle_io.py b/loopx/control_plane/goals/acceptance_oracle_io.py
new file mode 100644
index 0000000000..c49965dd25
--- /dev/null
+++ b/loopx/control_plane/goals/acceptance_oracle_io.py
@@ -0,0 +1,105 @@
+"""Host filesystem/process IO for the TypeScript acceptance oracle owner.
+
+No configuration, evidence classification or acceptance decision is owned here.
+"""
+
+from __future__ import annotations
+
+import hashlib
+from pathlib import Path
+from typing import Any
+
+
+def oracle_file_versions(
+ workspace: Path | None, refs: list[str]
+) -> list[dict[str, str]] | None:
+ if workspace is None:
+ return None
+ versions = []
+ try:
+ for ref in sorted(refs):
+ path = workspace
+ for part in Path(ref).parts:
+ path = path / part
+ if path.is_symlink():
+ return None
+ if not path.is_file():
+ return None
+ digest = hashlib.sha256()
+ with path.open("rb") as source:
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
+ digest.update(chunk)
+ versions.append({"ref": ref, "sha256": digest.hexdigest()})
+ except OSError:
+ return None
+ return versions
+
+
+def oracle_versions(
+ workspace: Path | None, oracle: dict[str, Any]
+) -> dict[str, Any] | None:
+ inputs = oracle_file_versions(workspace, oracle["input_refs"])
+ outputs = oracle_file_versions(workspace, oracle["output_refs"])
+ if inputs is None or outputs is None:
+ return None
+ return {"input_versions": inputs, "output_versions": outputs}
+
+
+def run_snapshot_oracle(
+ *, workspace: Path, criterion: dict[str, Any], before: dict[str, Any]
+) -> tuple[dict[str, Any], bool]:
+ """Run the approved checker against copies of precisely the declared files.
+
+ CWD isolation freezes the bytes seen through the declared relative refs; it
+ is not an OS sandbox or an attestation of imports/environment/network access.
+ """
+ import shutil
+ import subprocess
+ from tempfile import TemporaryDirectory
+ from ..runtime.validation_command import run_caller_validation
+
+ oracle = criterion["oracle"]
+ pins = criterion["validation_files"]
+ with TemporaryDirectory(prefix="loopx-acceptance-oracle-") as directory:
+ snapshot = Path(directory)
+ for ref in {
+ *(pin["path"] for pin in pins),
+ *oracle["input_refs"],
+ *oracle["output_refs"],
+ }:
+ target = snapshot / ref
+ target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(workspace / ref, target)
+
+ def pins_current() -> bool:
+ versions = oracle_file_versions(snapshot, [pin["path"] for pin in pins])
+ return versions is not None and {
+ row["ref"]: row["sha256"] for row in versions
+ } == {pin["path"]: pin["sha256"] for pin in pins}
+
+ stable = pins_current() and oracle_versions(snapshot, oracle) == before
+ if not stable:
+ return {}, False
+ try:
+ receipt = run_caller_validation(
+ snapshot,
+ validation_argv=criterion["validation_argv"],
+ validation_label=criterion["validation_label"],
+ timeout_seconds=criterion["validation_timeout_seconds"],
+ )
+ except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError) as exc:
+ receipt = {
+ "schema_version": "issue_fix_validation_command_v0",
+ "command_label": criterion["validation_label"],
+ "passed": False,
+ "exit_code": None,
+ "status": "timeout"
+ if isinstance(exc, subprocess.TimeoutExpired)
+ else "command_not_run",
+ "stdout_captured": False,
+ "stderr_captured": False,
+ "local_path_captured": False,
+ }
+ return dict(receipt), pins_current() and oracle_versions(
+ snapshot, oracle
+ ) == before
diff --git a/loopx/control_plane/goals/goal_frontier/__init__.py b/loopx/control_plane/goals/goal_frontier/__init__.py
index db8d118472..ea8943eea8 100644
--- a/loopx/control_plane/goals/goal_frontier/__init__.py
+++ b/loopx/control_plane/goals/goal_frontier/__init__.py
@@ -1736,10 +1736,13 @@ def build_goal_frontier_projection_context_from_status(
guard_recorded_at=receipt_bound_guard_recorded_at, agent_id=agent_id,
items=agent_todo_source_items or [],
derive=lambda summary: derive_goal_frontier_replan_obligation_from_summaries(
- user_todo_summary=user_todo_summary, agent_todo_summary=summary,
+ user_todo_summary=None, agent_todo_summary=summary,
work_lane_contract=work_lane_contract, agent_id=agent_id,
existing_replan_obligation=None, agent_todo_source_items=agent_todo_source_items,
- _frontier_counts=frontier_counts,
+ # Reconstruct the admitted closed-row source, not the current
+ # frontier: independent agent work or user gates may have arrived
+ # after admission. This candidate still requires the original
+ # obligation digest; live gates retain authority over new work.
),
)
diff --git a/loopx/control_plane/presentation/action_review_plan.ts b/loopx/control_plane/presentation/action_review_plan.ts
index 5091b2d0fc..8196e8c147 100644
--- a/loopx/control_plane/presentation/action_review_plan.ts
+++ b/loopx/control_plane/presentation/action_review_plan.ts
@@ -591,6 +591,16 @@ export function compileActionReviewPlan(proposalValue: unknown, nowMs?: number):
if (proposal.status !== "preview_ready" && proposal.status !== "deferred") return held("inactive", "inactive_proposal");
const reviewed = (reason: ActionReviewReason, canApply = true): ActionReviewPlan =>
finish({ interaction: "review", reason, canApply });
+ if (proposal.action_kind === "goal.acceptance.check") {
+ const context = objectValue(proposal.context);
+ const goalId = textValue(parameters?.goal_id);
+ if (!goalId || context?.kind !== "goal" || context.goal_id !== goalId
+ || !objectValue(parameters?.document) || !identity.proposalId || !identity.sourceFingerprint) {
+ return held("refresh", "incomplete_proposal");
+ }
+ return reviewed(proposal.permission_classification === "local_command_execution"
+ ? "action_review" : "unknown_permission", proposal.permission_classification === "local_command_execution");
+ }
if (proposal.action_kind !== "goal.lifecycle") {
return reviewed(proposal.permission_classification === "protected" ? "protected_action" : "action_review");
}
diff --git a/loopx/control_plane/quota/live_decision.py b/loopx/control_plane/quota/live_decision.py
index a9762136c2..a402050247 100644
--- a/loopx/control_plane/quota/live_decision.py
+++ b/loopx/control_plane/quota/live_decision.py
@@ -35,6 +35,7 @@
from .unsettled_host_turn import (
apply_unsettled_host_turn_recovery_if_required,
apply_receipt_bound_wait_recovery,
+ restore_receipt_bound_todo_source,
)
@@ -586,6 +587,9 @@ def build_live_quota_should_run_decision(
receipt_bound_replay_phase = (
settlement_readback.replay_phase if settlement_readback else None
)
+ receipt_bound_guard_fields = _receipt_bound_guard_fields(
+ settlement_readback, receipt_bound_replan_obligation_id,
+ )
fresh_operator_inbox_read = _fresh_operator_inbox_read_required(
turn_start_hook_dispatch
)
@@ -612,6 +616,12 @@ def build_live_quota_should_run_decision(
for item in queue.get("items") or []
],
}
+ decision_status_payload = restore_receipt_bound_todo_source(
+ decision_status_payload, registry_path=registry_path, runtime_root=runtime_root,
+ goal_id=goal_id, todo_id=receipt_bound_todo_id,
+ replay_phase=receipt_bound_replay_phase,
+ replan_guard_scoped=receipt_bound_guard_fields["receipt_bound_replan_guard_scoped"],
+ )
decision_status_payload = overlay_active_turn_retries(
decision_status_payload,
goal_id=goal_id,
@@ -648,9 +658,7 @@ def build_live_quota_should_run_decision(
receipt_bound_monitor_phase=receipt_bound_monitor_phase,
receipt_bound_replay_phase=receipt_bound_replay_phase,
receipt_bound_replan_obligation_id=receipt_bound_replan_obligation_id,
- **_receipt_bound_guard_fields(
- settlement_readback, receipt_bound_replan_obligation_id,
- ),
+ **receipt_bound_guard_fields,
turn_instance_id=turn_instance_id,
runtime_root=runtime_root,
workspace_path=workspace_path,
diff --git a/loopx/control_plane/quota/monitor_poll.py b/loopx/control_plane/quota/monitor_poll.py
index 97208c0c9c..7de8b03443 100644
--- a/loopx/control_plane/quota/monitor_poll.py
+++ b/loopx/control_plane/quota/monitor_poll.py
@@ -266,6 +266,8 @@ def _observation_packet(
next_user_todo: str | None,
next_user_task_class: str | None,
next_claimed_by: str | None,
+ next_agent_priority: str | None = None,
+ next_user_priority: str | None = None,
task_lease_idempotency_key: str | None = None,
task_lease_expected_version: int | None = None,
) -> dict[str, Any]:
@@ -293,6 +295,8 @@ def _observation_packet(
"next_user_todo": next_user_todo,
"next_user_task_class": next_user_task_class,
"next_claimed_by": next_claimed_by,
+ **({"next_agent_priority": next_agent_priority} if next_agent_priority is not None else {}),
+ **({"next_user_priority": next_user_priority} if next_user_priority is not None else {}),
}
@@ -730,6 +734,8 @@ def _provider_writeback(
next_user_todo=plan.get("next_user_todo"),
next_user_task_class=plan.get("next_user_task_class"),
next_claimed_by=plan.get("next_claimed_by"),
+ next_agent_priority=plan.get("next_agent_priority"),
+ next_user_priority=plan.get("next_user_priority"),
agent_id=plan.get("agent_id"),
task_lease_idempotency_key=(plan.get("lease_proof") or {}).get("idempotency_key"),
task_lease_expected_version=(plan.get("lease_proof") or {}).get("expected_version"),
@@ -769,6 +775,8 @@ def record_quota_monitor_poll_for_decision(
next_user_todo: str | None = None,
next_user_task_class: str | None = None,
next_claimed_by: str | None = None,
+ next_agent_priority: str | None = None,
+ next_user_priority: str | None = None,
task_lease_idempotency_key: str | None = None,
task_lease_expected_version: int | None = None,
use_current_task_lease: bool = False,
@@ -863,6 +871,8 @@ def record_quota_monitor_poll_for_decision(
next_user_todo=next_user_todo,
next_user_task_class=next_user_task_class,
next_claimed_by=next_claimed_by,
+ next_agent_priority=next_agent_priority,
+ next_user_priority=next_user_priority,
task_lease_idempotency_key=task_lease_idempotency_key,
task_lease_expected_version=task_lease_expected_version,
)
diff --git a/loopx/control_plane/quota/monitor_poll_commit.ts b/loopx/control_plane/quota/monitor_poll_commit.ts
index 08317ca4cc..8342f9c58d 100644
--- a/loopx/control_plane/quota/monitor_poll_commit.ts
+++ b/loopx/control_plane/quota/monitor_poll_commit.ts
@@ -5,7 +5,8 @@ import { AgentScopeFrontierAction } from "../agents/agent_scope_frontier.generat
import { createHash } from "node:crypto";
import { access, readFile, rm } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
-import { monitorSuccessorIntent, monitorSuccessorRoute } from "../scheduler/monitor_successor.ts";
+import {planTodoCreationPriority, readTodoPriority} from "../todos/priority.ts";
+import { monitorSuccessorIntent, monitorSuccessorRoute, monitorSuccessorPriorities } from "../scheduler/monitor_successor.ts";
import { normalizeTodoCapabilities } from "../todos/work_requirements.ts";
import { parseProjectionDelivery } from "../todos/projection_delivery.ts";
import { isMonitorUnavailableWait } from "./blocked_wait.ts";
@@ -366,6 +367,7 @@ function observationObject(value: unknown): MonitorObservation {
observation.next_due_at,
"observation.next_due_at",
)?.trim() ?? null,
+ ...monitorSuccessorPriorities(observation),
next_agent_todo: optionalString(
observation.next_agent_todo,
"observation.next_agent_todo",
@@ -961,6 +963,7 @@ function providerPlanFor(request: MonitorRequest, gateGuard = Boolean(
cadence: request.observation.cadence,
next_due_at: request.observation.next_due_at,
reason_summary: request.observation.reason_summary,
+ ...monitorSuccessorPriorities(request.observation, true),
next_agent_todo: request.observation.next_agent_todo,
next_action_kind: request.observation.next_action_kind,
next_task_repository: request.observation.next_task_repository,
@@ -1017,6 +1020,7 @@ function providerPlanObject(value: unknown): MonitorProviderPlan {
cadence: optionalString(plan.cadence, "provider_plan.cadence")?.trim() ?? null,
next_due_at: optionalString(plan.next_due_at, "provider_plan.next_due_at")?.trim() ?? null,
reason_summary: optionalString(plan.reason_summary, "provider_plan.reason_summary"),
+ ...monitorSuccessorPriorities(plan),
next_agent_todo: optionalString(plan.next_agent_todo, "provider_plan.next_agent_todo"),
next_action_kind: optionalString(plan.next_action_kind, "provider_plan.next_action_kind")
?.trim() ?? null,
@@ -1092,6 +1096,19 @@ function requireProviderTodoText(
requireProviderMatch(actual, compactExpected, label);
}
+function requireProviderSuccessorPriority(
+ receipt: JsonObject, nextTodo: JsonObject, plan: MonitorProviderPlan, role: "agent" | "user",
+): void {
+ const text = String(plan[`next_${role}_todo`]);
+ const key = `next_${role}_priority`;
+ const explicit = Object.hasOwn(plan, key);
+ const expected = planTodoCreationPriority({text, ...(explicit ? {priority: plan[key]} : {})});
+ if (!explicit && nextTodo.todo === text) return; // Historical provider plan and original receipt.
+ requireProviderTodoText(nextTodo.todo, String(expected.text), `${role} next_todo text`);
+ requireProviderMatch(readTodoPriority(receipt), expected.priority, `${role} successor priority`);
+ requireProviderMatch(readTodoPriority(nextTodo), expected.priority, `${role} next_todo priority`);
+}
+
function requireCanonicalSuccessorRoute(
value: JsonObject,
expected: JsonObject,
@@ -1180,11 +1197,7 @@ function validateSuccessorReceipts(
todoId,
"agent next_todo unblocks_todo_id",
);
- requireProviderTodoText(
- nextTodo.todo,
- plan.next_agent_todo,
- "agent next_todo text",
- );
+ requireProviderSuccessorPriority(receipt, nextTodo, plan, "agent");
requireProviderMatch(
requiredProviderTodoId(receipt.todo_id, "agent successor todo_id"),
requiredProviderTodoId(nextTodo.todo_id, "agent next_todo todo_id"),
@@ -1208,11 +1221,7 @@ function validateSuccessorReceipts(
plan.next_user_task_class,
"user next_todo task_class",
);
- requireProviderTodoText(
- nextTodo.todo,
- plan.next_user_todo,
- "user next_todo text",
- );
+ requireProviderSuccessorPriority(receipt, nextTodo, plan, "user");
requireProviderMatch(
requiredProviderTodoId(receipt.todo_id, "user successor todo_id"),
requiredProviderTodoId(nextTodo.todo_id, "user next_todo todo_id"),
@@ -2109,6 +2118,7 @@ function validateNoEffect(receipt: JsonObject | null, plan: MonitorProviderPlan)
"next_agent_todo", "next_action_kind", "next_task_repository", "next_required_capabilities",
"next_continuation_policy", "next_target_key", "next_claimed_by", "next_user_todo", "next_user_task_class",
].map(key => [key, plan[key]]));
+ Object.assign(intent, monitorSuccessorPriorities(plan));
const hash = monitorPollRequestHash({goal_id: plan.goal_id, actor_agent_id: plan.agent_id,
dry_run: !plan.execute, observation, intent, lease_proof: plan.lease_proof});
if (receipt?.schema_version !== "loopx_coordination_monitor_poll_result_v0" ||
diff --git a/loopx/control_plane/quota/unsettled_host_turn.py b/loopx/control_plane/quota/unsettled_host_turn.py
index 954b80ba71..139c642ed7 100644
--- a/loopx/control_plane/quota/unsettled_host_turn.py
+++ b/loopx/control_plane/quota/unsettled_host_turn.py
@@ -29,6 +29,7 @@
HeartbeatReceiptIdentityConflictError,
)
from .accounting_admission import quota_accounting_admission
+from .effect_program import ReceiptBoundReplayPhase
UNSETTLED_HOST_TURN_RECOVERY_SCHEMA_VERSION = "unsettled_host_turn_recovery_v0"
@@ -99,6 +100,63 @@ def _todo_binding_facts(item: Mapping[str, Any] | None) -> dict[str, Any] | None
}
+def restore_receipt_bound_todo_source(
+ payload: dict[str, Any], *, registry_path: Path, runtime_root: Path,
+ goal_id: str, todo_id: str | None, replay_phase: ReceiptBoundReplayPhase | None,
+ replan_guard_scoped: bool = False,
+) -> dict[str, Any]:
+ """Complete a display snapshot from the original binding's provider reader.
+
+ Display lane caps cannot decide whether a committed Todo still exists.
+ This only transports provider facts to the existing decision owner; it
+ neither changes lifecycle nor substitutes a new settlement binding.
+ """
+ from ..todos.quota_summary import select_planning_inventory_source_items
+
+ if replan_guard_scoped and replay_phase is ReceiptBoundReplayPhase.OPEN:
+ # A replan receipt names an obligation, not a Todo. Its lifecycle
+ # source may be absent from every bounded display lane; the typed
+ # owner still validates the original source identity and timestamps.
+ from ..todos.list_readback import list_goal_todos
+
+ source = list_goal_todos(
+ registry_path=registry_path, runtime_root_arg=str(runtime_root), goal_id=goal_id,
+ )
+ todo_fields = {key: source[key] for key in ("user_todos", "agent_todos")}
+ queue = payload.get("attention_queue") or {}
+ return {**payload, "attention_queue": {**queue, "items": [
+ {**row, **todo_fields,
+ "project_asset": {**(row.get("project_asset") or {}), **todo_fields}}
+ if row.get("goal_id") == goal_id else row
+ for row in queue.get("items") or []
+ ]}}
+ if not todo_id or replay_phase is ReceiptBoundReplayPhase.SETTLED:
+ return payload
+ queue = payload.get("attention_queue") or {}
+ rows = list(queue.get("items") or [])
+ for index, row in enumerate(rows):
+ if row.get("goal_id") != goal_id:
+ continue
+ asset = row.get("project_asset") or {}
+ source = select_planning_inventory_source_items(
+ row.get("agent_todos"), asset.get("agent_todos"), include_terminal=True,
+ )
+ if any(item.get("todo_id") == todo_id for item in source):
+ return payload
+ bound = _bound_todo_item(registry_path=registry_path, runtime_root=runtime_root,
+ goal_id=goal_id, todo_id=todo_id)
+ if bound is None:
+ raise ValueError("The original receipt-bound Todo cannot be read; recover its lifecycle before selecting or settling work")
+ def restored(summary: Mapping[str, Any] | None) -> dict[str, Any]:
+ current = dict(summary or {})
+ current["items"] = [*(current.get("items") or []), bound]
+ return current
+ rows[index] = {**row, "agent_todos": restored(row.get("agent_todos")),
+ "project_asset": {**asset, "agent_todos": restored(asset.get("agent_todos"))}}
+ return {**payload, "attention_queue": {**queue, "items": rows}}
+ return payload
+
+
def _prior_closeout_preflight(
*,
runtime_root: Path,
diff --git a/loopx/control_plane/scheduler/monitor_batch.ts b/loopx/control_plane/scheduler/monitor_batch.ts
index 341a311bc4..af4434d4c8 100644
--- a/loopx/control_plane/scheduler/monitor_batch.ts
+++ b/loopx/control_plane/scheduler/monitor_batch.ts
@@ -47,7 +47,8 @@ export function normalizeMonitorPollFields(observation: unknown, intent: unknown
const observationFields = new Set(["todo_id", "target_key", "generated_at", "result_hash", "material_change", "cadence", "next_due_at", "reason_summary"]);
for (const key of Object.keys(fields.observation)) if (!observationFields.has(key)) throw new EffectRuntimeRequestError(`unsupported Monitor observation field: ${key}`);
const intentFields = new Set(["next_agent_todo", "next_action_kind", "next_task_repository", "next_required_capabilities",
- "next_continuation_policy", "next_target_key", "next_claimed_by", "next_user_todo", "next_user_task_class"]);
+ "next_continuation_policy", "next_target_key", "next_claimed_by", "next_user_todo", "next_user_task_class",
+ "next_agent_priority", "next_user_priority"]);
for (const key of Object.keys(fields.intent)) if (!intentFields.has(key)) throw new EffectRuntimeRequestError(`unsupported Monitor successor field: ${key}`);
return fields;
}
@@ -102,6 +103,8 @@ function planWriteback(input: MonitorBatchInput) {
const todo: JsonObject = {schema_version: TODO_DOMAIN_ITEM_SCHEMA, todo_id: id, role, text,
status: "open", done: false, archive_state: "active",
task_class: role === "agent" ? "advancement_task" : intent.next_user_task_class};
+ const priorityKey = `next_${role}_priority`;
+ if (Object.hasOwn(intent, priorityKey)) todo.priority = intent[priorityKey];
if (role === "agent") {
Object.assign(todo, Object.fromEntries(Object.entries(route).filter(([, value]) => value !== null)),
{unblocks_todo_id: monitor.todo_id});
@@ -126,7 +129,7 @@ function planWriteback(input: MonitorBatchInput) {
if (created.status === "planned") mutations.push({kind: "todo_upsert", todo: record});
plannedTodos.set(String(record.todo_id), record);
}
- const receiptFields = ["todo_id", "role", "task_class", "action_kind", "task_repository",
+ const receiptFields = ["todo_id", "role", "priority", "task_class", "action_kind", "task_repository",
"continuation_policy", "required_capabilities", "claimed_by", "unblocks_todo_id", "target_key"];
const writeback: JsonObject = {schema_version: "monitor_poll_todo_writeback_v0", dry_run: input.dry_run,
goal_id: input.goal_id, todo_id: monitor.todo_id, monitor_effect_id: input.operation_id,
diff --git a/loopx/control_plane/scheduler/monitor_poll_writeback.py b/loopx/control_plane/scheduler/monitor_poll_writeback.py
index 41a6c651de..2629e94b4f 100644
--- a/loopx/control_plane/scheduler/monitor_poll_writeback.py
+++ b/loopx/control_plane/scheduler/monitor_poll_writeback.py
@@ -78,6 +78,8 @@ def write_monitor_poll_todo_state(
next_user_todo: str | None = None,
next_user_task_class: str | None = None,
next_claimed_by: str | None = None,
+ next_agent_priority: str | None = None,
+ next_user_priority: str | None = None,
agent_id: str | None = None,
task_lease_idempotency_key: str | None = None,
task_lease_expected_version: int | None = None,
@@ -104,6 +106,11 @@ def write_monitor_poll_todo_state(
"next_continuation_policy": next_continuation_policy,
"next_target_key": next_target_key, "next_claimed_by": next_claimed_by,
"next_user_todo": next_user_todo, "next_user_task_class": next_user_task_class}
+ # Omitted additive fields must retain old request hashes during replay.
+ if next_agent_priority is not None:
+ intent["next_agent_priority"] = next_agent_priority
+ if next_user_priority is not None:
+ intent["next_user_priority"] = next_user_priority
request = {"schema_version": "loopx_monitor_batch_plan_request_v0",
"goal_id": goal_id, "operation_id": monitor_effect_id or f"monitor-poll:{goal_id}:{uuid4().hex}",
"actor_agent_id": agent_id, "dry_run": not execute,
diff --git a/loopx/control_plane/scheduler/monitor_successor.ts b/loopx/control_plane/scheduler/monitor_successor.ts
index 9ef197496e..e223b2704d 100644
--- a/loopx/control_plane/scheduler/monitor_successor.ts
+++ b/loopx/control_plane/scheduler/monitor_successor.ts
@@ -6,6 +6,7 @@ import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts";
import { optionalNonEmptyString, requireBoolean, requireJsonObject } from "../runtime_decode.ts";
import { normalizeTodoAgent, stripPythonWhitespace } from "../coordination/todo_agents.ts";
import { normalizeTodoRepository, normalizeTodoCapabilities } from "../todos/work_requirements.ts";
+import {planTodoCreationPriority} from "../todos/priority.ts";
export const MONITOR_SUCCESSOR_REQUEST_SCHEMA = "loopx_monitor_successor_plan_request_v0";
export const MONITOR_SUCCESSOR_RESULT_SCHEMA = "loopx_monitor_successor_plan_result_v0";
@@ -62,6 +63,20 @@ export interface MonitorSuccessorIntent extends JsonObject {
next_user_task_class: "user_action" | "user_gate" | null;
}
+/** Optional authoring fields stay absent in historical observation identities. */
+export function monitorSuccessorPriorities(input: JsonObject, defaultOmitted = false): JsonObject {
+ const priorities: JsonObject = {};
+ for (const [role, todo] of [["agent", text(input.next_agent_todo, "next_agent_todo")], ["user", text(input.next_user_todo, "next_user_todo")]] as const) {
+ const key = `next_${role}_priority`;
+ const supplied = Object.hasOwn(input, key);
+ if (!supplied && (!defaultOmitted || !todo)) continue;
+ if (!todo) throw new EffectRuntimeRequestError(`--next-${role}-priority requires --next-${role}-todo`);
+ // The Todo owner validates values and conflicting legacy prefixes.
+ priorities[key] = planTodoCreationPriority({text: todo, ...(supplied ? {priority: input[key]} : {})}).priority;
+ }
+ return priorities;
+}
+
export function monitorSuccessorIntent(value: unknown): MonitorSuccessorIntent {
const input = requireJsonObject(value, "monitor successor intent");
const material = requireBoolean(input.material_change, "material_change");
@@ -99,7 +114,8 @@ export function monitorSuccessorIntent(value: unknown): MonitorSuccessorIntent {
try { owner = normalizeTodoAgent(claim, "next_claimed_by"); }
catch { throw new EffectRuntimeRequestError("--next-claimed-by must be a public-safe agent id"); }
}
- return {next_agent_todo: agentTodo, next_action_kind: action,
+ const priorities = monitorSuccessorPriorities(input);
+ return {...priorities, next_agent_todo: agentTodo, next_action_kind: action,
next_task_repository: repo, next_required_capabilities: capabilities,
next_continuation_policy: policy, next_target_key: target, next_claimed_by: owner,
next_user_todo: userTodo, next_user_task_class: userClass};
diff --git a/loopx/state_recovery.py b/loopx/control_plane/state_recovery_io.py
similarity index 99%
rename from loopx/state_recovery.py
rename to loopx/control_plane/state_recovery_io.py
index 8afa6fc959..60d56f6be9 100644
--- a/loopx/state_recovery.py
+++ b/loopx/control_plane/state_recovery_io.py
@@ -23,7 +23,7 @@
import zlib
from typing import Any, BinaryIO, Iterator, NoReturn
-from .control_plane.effect_runtime import _node_executable, effect_runtime_result, effect_runtime_request_scope
+from .effect_runtime import _node_executable, effect_runtime_result, effect_runtime_request_scope
MAX_ARCHIVE_BYTES = 128 * 1024 * 1024
MAX_EXPANDED_BYTES = 512 * 1024 * 1024
@@ -87,7 +87,7 @@ def _runtime(method: str, request: dict[str, Any]) -> dict[str, Any]:
def _audit_owners(request: dict[str, Any]) -> dict[str, Any]:
command = [_node_executable(), "--no-warnings", "--max-old-space-size=512",
"--experimental-sqlite", "--experimental-strip-types",
- str(Path(__file__).parent / "control_plane/state_recovery_worker.ts")]
+ str(Path(__file__).with_name("state_recovery_worker.ts"))]
try:
completed = subprocess.run(command, input=json.dumps(request), text=True, encoding="utf-8",
capture_output=True, timeout=_OWNER_TIMEOUT_SECONDS, check=False)
diff --git a/loopx/control_plane/status/collection.py b/loopx/control_plane/status/collection.py
index e745db37ca..89df438f2d 100644
--- a/loopx/control_plane/status/collection.py
+++ b/loopx/control_plane/status/collection.py
@@ -85,6 +85,7 @@ def collect_status(
include_zcode_goal_eligibility: bool = False,
activation_state_filter: GoalActivationState | str | None = None,
agent_lane_id: str | None = None,
+ allow_missing_registry: bool = False,
) -> dict[str, Any]:
display_limit = max(0, limit)
control_plane_limit = max(
@@ -134,6 +135,7 @@ def collect_status(
runtime_root_override=str(runtime_root),
scan_roots=scan_roots,
limit=limit,
+ allow_missing_registry=allow_missing_registry,
goal_id_filter=goal_filter,
include_public_boundary_scan=include_public_boundary_scan,
activation_state_filter=activation_filter,
diff --git a/loopx/control_plane/todos/mutation_response.py b/loopx/control_plane/todos/mutation_response.py
index 3b8e5da54e..2875c62cc1 100644
--- a/loopx/control_plane/todos/mutation_response.py
+++ b/loopx/control_plane/todos/mutation_response.py
@@ -19,6 +19,7 @@
from .completion_state import (
normalize_todo_completion_continuation, normalize_todo_completion_recovery,
)
+from .todo_semantics import todo_priority_label
def serialize_added_todo_payload(
*, add_result: Mapping[str, Any], goal_id: str, role: str, todo_text: str,
@@ -41,6 +42,8 @@ def serialize_added_todo_payload(
"role": role,
"section": add_result.get("section"),
"todo": todo_text,
+ "priority": todo_priority_label({"text": todo_text,
+ **({"priority": add_result["priority"]} if "priority" in add_result else {})}),
"todo_id": add_result.get("todo_id"),
"status": add_result.get("status"),
"task_class": add_result.get("task_class"),
diff --git a/loopx/control_plane/todos/priority.ts b/loopx/control_plane/todos/priority.ts
index c61a7cce79..215c4c45af 100644
--- a/loopx/control_plane/todos/priority.ts
+++ b/loopx/control_plane/todos/priority.ts
@@ -9,6 +9,7 @@ import {compactPythonWhitespace} from "../coordination/todo_agents.ts";
const contract = COORDINATION_STATE_CONTRACT.todo_priority;
export const TODO_PRIORITIES = contract.values;
export type TodoPriority = typeof TODO_PRIORITIES[number];
+export const DEFAULT_TODO_PRIORITY: TodoPriority = "P1";
const LEGACY_PREFIX = new RegExp(contract.legacy_prefix_pattern, "iu");
const LEGACY_LABEL = new RegExp(contract.legacy_label_pattern, "iu");
@@ -68,8 +69,19 @@ export function planTodoPriority(todo: JsonObject, intent: JsonObject): JsonObje
title: parsed.title, priority};
}
+/** Default only a fresh creation, after receipt lookup. Reads, edits and
+ * deliberate predecessor inheritance retain their original priority. */
+export function planTodoCreationPriority(intent: JsonObject): JsonObject {
+ return planTodoPriority({priority: DEFAULT_TODO_PRIORITY}, intent);
+}
+
export function evaluateTodoPriority(value: unknown): JsonObject {
const request = requireJsonObject(value, "Todo priority request");
if (request.schema_version !== "todo_priority_request_v0") throw new EffectRuntimeRequestError("Todo priority schema mismatch");
- return planTodoPriority(requireJsonObject(request.todo, "Todo"), requireJsonObject(request.intent, "priority intent"));
+ if (request.create !== undefined && typeof request.create !== "boolean") {
+ throw new EffectRuntimeRequestError("create must be a boolean");
+ }
+ const todo = requireJsonObject(request.todo, "Todo");
+ const intent = requireJsonObject(request.intent, "priority intent");
+ return request.create === true ? planTodoCreationPriority(intent) : planTodoPriority(todo, intent);
}
diff --git a/loopx/control_plane/todos/text.py b/loopx/control_plane/todos/text.py
index df286c942d..9459d40a96 100644
--- a/loopx/control_plane/todos/text.py
+++ b/loopx/control_plane/todos/text.py
@@ -12,10 +12,13 @@ def normalize_new_todo(text: str) -> str:
return compact
-def plan_todo_priority(todo: dict[str, Any], intent: dict[str, Any]) -> dict[str, Any]:
+def plan_todo_priority(
+ todo: dict[str, Any], intent: dict[str, Any], *, create: bool = False,
+) -> dict[str, Any]:
try:
result = effect_runtime_result("todo.priority.plan", {
"schema_version": "todo_priority_request_v0", "todo": todo, "intent": intent,
+ **({"create": True} if create else {}),
})
except EffectRuntimeRejected as exc:
raise ValueError(str(exc)) from None
diff --git a/loopx/control_plane/work_items/replan_context.ts b/loopx/control_plane/work_items/replan_context.ts
index 42b090892e..17c61ceefe 100644
--- a/loopx/control_plane/work_items/replan_context.ts
+++ b/loopx/control_plane/work_items/replan_context.ts
@@ -1,6 +1,7 @@
/** Bounded evidence for the existing replan boundary; no settlement authority. */
import {createHash} from "node:crypto";
-import type {JsonObject} from "../effect_program.ts";
+import {settlementIdentityPayload, SETTLEMENT_IDENTITY_SCHEMA_VERSION,
+ SCOPED_SETTLEMENT_IDENTITY_SCHEMA_VERSION, type JsonObject} from "../effect_program.ts";
import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts";
import {BARE_SHA256_PATTERN} from "../content_digest.ts";
import {requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts";
@@ -104,16 +105,33 @@ function coreGoal(value: unknown): JsonObject {
objective_missing: objective === null, acceptance_contract: acceptance};
}
+/** Qualify recorded bindings using the settlement owner, without settling work.
+ * Keep historical Todo evidence eligible under its existing read contract.
+ */
+function decisionBindingMatches(row: JsonObject, source: JsonObject, delta: JsonObject | null): boolean {
+ const identity = objectOrNull(source.settlement_identity);
+ if (!identity || identity.turn_instance_id !== source.turn_instance_id ||
+ typeof identity.turn_instance_id !== "string" || !ID.test(identity.turn_instance_id)) return false;
+ if (identity.schema_version === SETTLEMENT_IDENTITY_SCHEMA_VERSION) {
+ return ["goal_id", "agent_id", "todo_id"].every(key => identity[key] === row[key]);
+ }
+ if (identity.schema_version !== SCOPED_SETTLEMENT_IDENTITY_SCHEMA_VERSION ||
+ typeof row.goal_id !== "string" || typeof row.agent_id !== "string" ||
+ [row.todo_id, source.todo_id, identity.todo_id].some(value => value != null && value !== "") ||
+ ["goal_id", "agent_id"].some(key => source[key] !== row[key]) ||
+ typeof source.replan_obligation_id !== "string" || !ID.test(source.replan_obligation_id) ||
+ delta?.obligation_id !== source.replan_obligation_id) return false;
+ const expected = settlementIdentityPayload({goal_id: row.goal_id, agent_id: row.agent_id,
+ turn_instance_id: identity.turn_instance_id, replan_obligation_id: source.replan_obligation_id});
+ return Object.entries(expected).every(([key, value]) => identity[key] === value);
+}
+
function acceptedDecisionSource(row: JsonObject): boolean {
const source = objectOrNull(row._source_facts);
- const identity = objectOrNull(source?.settlement_identity);
const vision = objectOrNull(source?.agent_vision);
const ack = objectOrNull(source?.autonomous_replan_ack);
const delta = objectOrNull(ack?.semantic_delta);
- return !!source && identity?.schema_version === "quota_settlement_identity_v0" &&
- ["goal_id", "agent_id", "todo_id"].every(key => identity[key] === row[key]) &&
- identity.turn_instance_id === source.turn_instance_id &&
- typeof identity.turn_instance_id === "string" && ID.test(identity.turn_instance_id) &&
+ return !!source && decisionBindingMatches(row, source, delta) &&
!objectOrNull(source.quota_spend_commit) &&
vision?.schema_version === "goal_vision_replan_contract_v0" && vision.agent_id === row.agent_id &&
(vision.goal_id == null || vision.goal_id === row.goal_id) &&
@@ -137,8 +155,10 @@ function recordedDecision(row: JsonObject): JsonObject | null {
let stride: JsonObject | null;
try { stride = normalizeRecordedStrideDecision(objectOrNull(source.agent_vision)?.stride_decision); }
catch { return null; }
+ const autonomous = objectOrNull(source.settlement_identity)?.schema_version === SCOPED_SETTLEMENT_IDENTITY_SCHEMA_VERSION;
const decision = {history_evidence_ref: "replan-evidence-" + digest(publicEvidenceRow(row)),
- generated_at: row.generated_at, todo_id: row.todo_id, turn_instance_id: source.turn_instance_id,
+ generated_at: row.generated_at, todo_id: autonomous ? null : row.todo_id, turn_instance_id: source.turn_instance_id,
+ ...(autonomous ? {replan_obligation_id: source.replan_obligation_id} : {}),
goal_ref: ref ? {goal_id: ref.goal_id, goal_instance_id: ref.goal_instance_id} : null,
path_delta: path, ...(stride ? {stride_decision: stride} : {})};
// Existing history refs keep their identity. This derived ref additionally
@@ -235,6 +255,10 @@ export function validateReplanContext(value: unknown): JsonObject {
identifier(previous.evidence_ref, "previous_decision.evidence_ref");
identifier(previous.history_evidence_ref, "previous_decision.history_evidence_ref");
identifier(previous.turn_instance_id, "previous_decision.turn_instance_id");
+ if (previous.replan_obligation_id !== undefined) {
+ identifier(previous.replan_obligation_id, "previous_decision.replan_obligation_id");
+ if (previous.todo_id !== null) throw new EffectRuntimeRequestError("previous decision cannot bind both Todo and autonomous replan");
+ }
normalizeGoalPathDelta(previous.path_delta);
normalizeRecordedStrideDecision(previous.stride_decision);
}
diff --git a/loopx/control_plane/work_items/replan_context_codec.py b/loopx/control_plane/work_items/replan_context_codec.py
index 6293e3708f..38839fb3e2 100644
--- a/loopx/control_plane/work_items/replan_context_codec.py
+++ b/loopx/control_plane/work_items/replan_context_codec.py
@@ -91,8 +91,10 @@ def replan_evidence_rows(runs: Iterable[Mapping[str, Any]], *, goal_id: str, age
source["goal_ref"] = run.get("goal_ref")
source["agent_vision"] = run.get("agent_vision")
source["autonomous_replan_ack"] = run.get("autonomous_replan_ack")
+ source["replan_obligation_id"] = run.get("replan_obligation_id")
for key, fields in (("settlement_identity", (
- "schema_version", "goal_id", "agent_id", "todo_id", "turn_instance_id", "effect_id")),
+ "schema_version", "goal_id", "agent_id", "todo_id", "turn_instance_id", "effect_id",
+ *(("binding_kind", "binding_id", "replan_obligation_id") if include_decisions else ()))),
("quota_spend_commit", ("schema_version", "effect_id"))):
value = run.get(key)
source[key] = {field: value.get(field) for field in fields} if isinstance(value, Mapping) else None
diff --git a/loopx/extensions/ego_source_reader.py b/loopx/extensions/ego_source_reader.py
index fe824ef3db..10cf34c64b 100644
--- a/loopx/extensions/ego_source_reader.py
+++ b/loopx/extensions/ego_source_reader.py
@@ -271,15 +271,25 @@ def _navigation(config: ReaderConfig, url: str, *, image: bool = False) -> str:
)
+def _url_fence(current: str, requested: str) -> str:
+ # Report location metadata only for the original origin. Never read DOM or
+ # disclose a cross-origin destination (which may contain login credentials).
+ return (
+ f"if({current}.href!=={requested})return {{error:'source_url_changed',"
+ f"...({current}.origin===new URL({requested}).origin?"
+ f"{{observed_url:{current}.href}}:{{}})}};"
+ )
+
+
def _script(config: ReaderConfig, url: str) -> str:
# No caller-selected code, executable, browser, Page or CLI arguments.
return (
_navigation(config, url) +
"const r=await p.evaluate((request)=>{"
- "const current=new URL(location.href);"
+ "const current=new URL(location.href);" +
# Fence before reading DOM, atomically with extraction. A raced Page or
# redirect returns no content, even within another authorized origin.
- "if(current.href!==request.url)return {error:'source_url_changed'};"
+ _url_fence("current", "request.url") +
"if(!request.ready)return {error:'source_content_not_ready'};"
"const text=document.body?.innerText||'';"
"return {url:current.href,title:document.title,"
@@ -306,6 +316,22 @@ def _result_url(value: dict[str, object], requested: str) -> str:
return final
+def _changed_url_result(value: dict[str, object], requested: str) -> dict[str, object]:
+ result: dict[str, object] = {"ok": False, "error": "source_url_changed"}
+ if "observed_url" not in value:
+ return result
+ observed = value["observed_url"]
+ if not isinstance(observed, str):
+ raise ValueError("URL string required")
+ final, origin = _url(observed)
+ # Reuse the fixed-script provenance contract: WHATWG, not Python, owns
+ # target normalization (dot segments and query escaping included).
+ target = _result_url({**value, "url": value["canonical_url"]}, requested)
+ if final != observed or final == target or origin != _url(target)[1]:
+ raise ValueError("invalid changed URL provenance")
+ return {**result, "observed_url": final}
+
+
def _result(stdout: str, stderr: str, url: str) -> dict[str, object]:
values = [line[len(MARKER):] for line in (stdout + "\n" + stderr).splitlines()
if line.startswith(MARKER)]
@@ -315,7 +341,9 @@ def _result(stdout: str, stderr: str, url: str) -> dict[str, object]:
value = json.loads(values[0])
if not isinstance(value, dict):
raise ValueError("object required")
- if value.get("error") in {"source_url_changed", "source_content_not_ready", "source_reader_not_agent_owned"}:
+ if value.get("error") == "source_url_changed":
+ return _changed_url_result(value, url)
+ if value.get("error") in {"source_content_not_ready", "source_reader_not_agent_owned"}:
return {"ok": False, "error": value["error"]}
final = _result_url(value, url)
text, title = value["text"], value["title"]
@@ -362,24 +390,26 @@ def _image_script(config: ReaderConfig, url: str, index: int, path: str) -> str:
_navigation(config, url, image=True) +
f"const request={request};"
"const initial=await p.evaluate((r)=>{"
- "const u=new URL(location.href);"
- "if(u.href!==r.url)return {error:'source_url_changed'};"
+ "const u=new URL(location.href);" +
+ _url_fence("u", "r.url") +
"if(!r.ready)return {error:'source_content_not_ready'};"
"const im=document.images[r.index];if(!im)return {error:'source_image_unavailable'};"
"im.scrollIntoView({block:'center'});return {ok:true};},request);"
- "if(initial.error){console.log('LOOPX_PUBLIC_SOURCE:'+JSON.stringify(initial));}else{"
+ "if(initial.error){console.log('LOOPX_PUBLIC_SOURCE:'+JSON.stringify({...initial,"
+ "requested_url:requestedUrl,canonical_url:target.href}));}else{"
"await p.waitForFunction((r)=>{const u=new URL(location.href);"
"if(u.href!==r.url)return true;const im=document.images[r.index];"
"return im&&im.complete&&im.naturalWidth>1&&im.naturalHeight>1;},"
"request,{timeout:10000});"
- "const before=await p.evaluate((r)=>{const u=new URL(location.href);"
- "if(u.href!==r.url)return {error:'source_url_changed'};"
+ "const before=await p.evaluate((r)=>{const u=new URL(location.href);" +
+ _url_fence("u", "r.url") +
"const im=document.images[r.index];const b=im.getBoundingClientRect();"
"if(b.width<2||b.height<2||b.width>r.edge||b.height>r.edge)"
"return {error:'source_image_bounds_unsupported'};"
"return {url:u.href,index:r.index,alt:im.alt.slice(0,512),src:im.currentSrc,"
"clip:{x:b.left+scrollX,y:b.top+scrollY,width:b.width,height:b.height}};},request);"
- "if(before.error){console.log('LOOPX_PUBLIC_SOURCE:'+JSON.stringify(before));}else{"
+ "if(before.error){console.log('LOOPX_PUBLIC_SOURCE:'+JSON.stringify({...before,"
+ "requested_url:requestedUrl,canonical_url:target.href}));}else{"
f"await p.screenshot({{path:{json.dumps(path)},fullPage:true,clip:before.clip}});"
"const stable=await p.evaluate((r)=>{const u=new URL(location.href);"
"if(u.href!==r.url)return false;"
@@ -402,7 +432,9 @@ def _image_result(stdout: str, stderr: str, url: str, index: int, path: str) ->
value = json.loads(values[0])
if not isinstance(value, dict):
raise ValueError("object required")
- if value.get("error") in {"source_url_changed", "source_content_not_ready", "source_reader_not_agent_owned", "source_image_unavailable",
+ if value.get("error") == "source_url_changed":
+ return _changed_url_result(value, url)
+ if value.get("error") in {"source_content_not_ready", "source_reader_not_agent_owned", "source_image_unavailable",
"source_image_bounds_unsupported"}:
return {"ok": False, "error": value["error"]}
final = _result_url(value, url)
@@ -480,6 +512,10 @@ def read_public_url(url: str) -> dict[str, object]:
Images are metadata only here. Use read_public_image for actual pixels.
A verification wall or truncation is not a complete source read.
+ source_url_changed means navigation reached another URL, not that Ego is
+ disconnected. An observed_url is same-origin location metadata, not content
+ or permission: verify it is the intended source before a separate read.
+ Do not retry an access denial through another browser or broader permissions.
If source_reader_not_agent_owned, preserve the Page and resume this tool
once browser control returns. No extra chat confirmation is required.
"""
diff --git a/loopx/extensions/lark/private_conversations.py b/loopx/extensions/lark/private_conversations.py
index e9fde972cd..d13317b7af 100644
--- a/loopx/extensions/lark/private_conversations.py
+++ b/loopx/extensions/lark/private_conversations.py
@@ -119,16 +119,21 @@ def rendered() -> Mapping[str, Any]:
"--as", "bot", "--no-reactions", "--format", "json"]))
group = selected["binding"].get("audience") == "group"
+ observation = None
if group:
# Both projections read this exact message under the same App.
# The root is read only after canonical ancestry checks below.
# Outbound provenance needs no display rendering. Admission
# retains that projection unchanged for text and media inputs.
if render_content:
- with ThreadPoolExecutor(max_workers=2) as reads:
+ # The fresh Bot identity and exact message projections
+ # are independent reads. Join all three before checking
+ # the root mention; Core still rechecks under its fences.
+ with ThreadPoolExecutor(max_workers=3) as reads:
current = reads.submit(self._group_source_message, record["profile"], event["message_id"])
display = reads.submit(rendered)
- canonical, result = current.result(), display.result()
+ identity = reads.submit(self.bindings.observe, record["profile"])
+ canonical, result, observation = current.result(), display.result(), identity.result()
message = _message(json_payload(result), event["message_id"])
else:
canonical = self._group_source_message(record["profile"], event["message_id"])
@@ -171,7 +176,8 @@ def rendered() -> Mapping[str, Any]:
original = (message if root == event["message_id"] else
self._group_source_message(record["profile"], root))
from .event_inbox import lark_event_mentions_bot
- observation = self.bindings.observe(record["profile"])
+ if observation is None:
+ observation = self.bindings.observe(record["profile"])
if (not original or original.get("chat_id") != event["chat_id"]
or original.get("sender", {}).get("sender_type") != "user"
or original.get("parent_id") or original.get("root_id") not in (None, "", root)
diff --git a/loopx/opencode2_goal_mode/opencode2-goal-worker.mjs b/loopx/opencode2_goal_mode/opencode2-goal-worker.mjs
index bdf20f1557..cec9de63f8 100644
--- a/loopx/opencode2_goal_mode/opencode2-goal-worker.mjs
+++ b/loopx/opencode2_goal_mode/opencode2-goal-worker.mjs
@@ -331,8 +331,8 @@ export function parseExecFailure(error) {
// stderr payload. Normalize the most useful part.
const stderr = String(error?.stderr || "")
let status = null
- const statusMatch = /status[ =:]+(\d{3})/i.exec(stderr)
- if (statusMatch) status = Number(statusMatch[1])
+ const statusMatch = /(?:status[ =:]+(\d{3})|HTTP(?:\/[\d.]+)?\s+(\d{3}))/i.exec(stderr)
+ if (statusMatch) status = Number(statusMatch[1] || statusMatch[2])
const messageMatch = /(?:error|message)[ =:]+"?([^"\n]{0,180})/i.exec(stderr)
return {
code: error?.code || null,
@@ -344,6 +344,11 @@ export function parseExecFailure(error) {
export function createApiTransport({ bin = process.env.OPENCODE2_BIN || "opencode2", execFileImpl = execFile } = {}) {
+ const waitPaths = [
+ (sessionID) => `/api/session/${sessionID}/wait`,
+ (sessionID) => `/api/experimental/session/${sessionID}/wait`,
+ ]
+ let waitPathIndex = 0
const call = async (method, apiPath, body = null, timeoutMs = 60_000) => {
const args = ["api", method, apiPath]
if (body !== null) args.push("-d", JSON.stringify(body))
@@ -387,15 +392,21 @@ export function createApiTransport({ bin = process.env.OPENCODE2_BIN || "opencod
return response?.data || null
},
async waitForSession(sessionID, { timeoutMs = 60_000 } = {}) {
- try {
- await call("post", `/api/session/${sessionID}/wait`, null, timeoutMs)
- return true
- } catch (error) {
- // A chunk timeout means the turn is still running; the caller
- // verifies the message tail and re-issues the wait.
- if (error?.killed === true || error?.code === "ETIMEDOUT") return false
- throw error
+ let lastNotFound = null
+ for (let index = waitPathIndex; index < waitPaths.length; index += 1) {
+ try {
+ await call("post", waitPaths[index](sessionID), null, timeoutMs)
+ waitPathIndex = index
+ return true
+ } catch (error) {
+ // A chunk timeout means the turn is still running; the caller
+ // verifies the message tail and re-issues the wait.
+ if (error?.killed === true || error?.code === "ETIMEDOUT") return false
+ if (parseExecFailure(error).status !== 404) throw error
+ lastNotFound = error
+ }
}
+ throw lastNotFound
},
async sendPrompt(sessionID, text) {
const response = await call("post", `/api/session/${sessionID}/prompt`, {
diff --git a/loopx/presentation/codex_attached_api.py b/loopx/presentation/codex_attached_api.py
new file mode 100644
index 0000000000..0fffbf536c
--- /dev/null
+++ b/loopx/presentation/codex_attached_api.py
@@ -0,0 +1,354 @@
+"""Owner-only projection and explicit input for registered Desktop conversations."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Protocol, cast
+from urllib.parse import parse_qs, urlparse
+
+from ..agent_registry import registered_agent_ids_for_goal
+from ..attached_session import load_attached_session_registry
+from ..chat_attachments import CHAT_JSON_MAX_BYTES
+from ..capabilities.native_chat.codex_attached import (
+ DesktopClient,
+ DesktopUnavailable,
+ DesktopUncertain,
+ activity,
+ history_revision,
+ input_fingerprint,
+ locate_thread,
+ open_conversation,
+ read_messages,
+)
+from ..capabilities.native_chat.codex_history import read_content
+from ..file_lock import exclusive_file_lock
+from ..registry import atomic_write_json, find_registry_goal
+from ..thread_agent_binding import collect_accepted_bindings
+from .goal_source_routing import source_registry_for_goal
+
+CODEX_ATTACHED_PATH = "/api/chat/codex-attached"
+CODEX_ATTACHED_OPEN_PATH = f"{CODEX_ATTACHED_PATH}/open"
+
+
+def receipt_readback(receipt: dict[str, Any]) -> dict[str, Any]:
+ # Content hashes are private journal bookkeeping, not presentation data.
+ return {
+ "ok": True,
+ **{
+ key: receipt[key]
+ for key in ["operation_id", "status", "turn_id"]
+ if key in receipt
+ },
+ }
+
+
+def bound_conversations(goal: dict[str, Any]) -> list[dict[str, str]]:
+ registered = registered_agent_ids_for_goal(goal)
+ rows = [
+ row
+ for row in collect_accepted_bindings([goal])
+ if row["host_surface"] == "codex-app" and row["agent_id"] in registered
+ ]
+ return [
+ {
+ **row,
+ "binding_ref": hashlib.sha256(
+ json.dumps([goal.get("goal_instance_id"), row], sort_keys=True).encode()
+ ).hexdigest(),
+ }
+ for row in rows
+ ]
+
+
+def current_binding(goal: dict[str, Any], agent: str, expected: str) -> dict[str, str]:
+ rows = [row for row in bound_conversations(goal) if row["agent_id"] == agent]
+ if len(rows) != 1 or rows[0]["binding_ref"] != expected:
+ raise ValueError(
+ "The Agent conversation binding changed or is ambiguous. Choose it again."
+ )
+ return rows[0]
+
+
+class _AttachedHTTP(Protocol):
+ """HTTP operations supplied by the composed handler, not mixin overrides."""
+
+ def _send_json(self, payload: dict[str, Any], *, status: int = 200) -> None: ...
+
+ def _send_error(self, message: str, *, status: int = 400) -> None: ...
+
+ def _read_json(self, *, max_bytes: int = CHAT_JSON_MAX_BYTES) -> dict[str, Any]: ...
+
+
+if TYPE_CHECKING:
+ from ..chat_server import ChatRequestHandler
+
+ # Check the real composition as well as the adapter's local call sites.
+ _attached_http_contract: type[_AttachedHTTP] = ChatRequestHandler
+
+
+class CodexAttachedRequestMixin:
+ server: Any
+ path: str
+
+ def _codex_goal(self, goal_id: str) -> dict[str, Any]:
+ if not goal_id or len(goal_id) > 160:
+ raise ValueError("Choose one registered Goal")
+ selected = getattr(self.server, "selected_goal_id", None)
+ if selected and selected != goal_id:
+ raise ValueError("Goal is outside this workspace")
+ source = source_registry_for_goal(self.server.registry_path, goal_id)
+ goal = find_registry_goal(load_attached_session_registry(source), goal_id)
+ if goal is None:
+ raise ValueError("Goal is not available")
+ return goal
+
+ def _input_receipt_path(
+ self, goal_id: str, agent_id: str, operation_id: str
+ ) -> Path:
+ if not 8 <= len(operation_id) <= 100:
+ raise ValueError("Reply operation is invalid")
+ key = hashlib.sha256(
+ json.dumps([goal_id, agent_id, operation_id]).encode()
+ ).hexdigest()
+ return (
+ Path(self.server.runtime_root)
+ / "native_chat"
+ / "attached-input-receipts"
+ / f"{key}.json"
+ )
+
+ def _codex_attached_read(self) -> None:
+ http = cast(_AttachedHTTP, self)
+ try:
+ query = parse_qs(urlparse(self.path).query, keep_blank_values=True)
+ if set(query) - {
+ "goal_id",
+ "agent_id",
+ "binding_ref",
+ "before",
+ "revision",
+ "operation_id",
+ "after",
+ "source_ref",
+ "entry_id",
+ "content_offset",
+ "content_ref",
+ } or any(len(v) != 1 for v in query.values()):
+ raise ValueError("Conversation query is invalid")
+ goal = self._codex_goal(query.get("goal_id", [""])[0])
+ if "agent_id" not in query:
+ http._send_json({"ok": True, "agents": bound_conversations(goal)})
+ return
+ binding = current_binding(
+ goal, query["agent_id"][0], query.get("binding_ref", [""])[0]
+ )
+ if "operation_id" in query:
+ receipt_path = self._input_receipt_path(
+ query["goal_id"][0], query["agent_id"][0], query["operation_id"][0]
+ )
+ if not receipt_path.exists():
+ http._send_error(
+ "No delivery receipt is recorded for this input.", status=404
+ )
+ return
+ receipt = json.loads(receipt_path.read_text())
+ if receipt.get("binding_ref") != binding["binding_ref"]:
+ raise ValueError("Delivery belongs to an older binding")
+ http._send_json(receipt_readback(receipt))
+ return
+ home, path = locate_thread(binding["thread_id"])
+ if "entry_id" in query:
+ content = read_content(
+ path,
+ entry_id=int(query["entry_id"][0]),
+ offset=int(query.get("content_offset", ["0"])[0]),
+ expected_source=query.get("source_ref", [""])[0],
+ content_ref=query.get("content_ref", [""])[0],
+ )
+ current_binding(
+ self._codex_goal(query["goal_id"][0]),
+ query["agent_id"][0],
+ binding["binding_ref"],
+ )
+ http._send_json({"ok": True, **content})
+ return
+ before = int(query["before"][0]) if "before" in query else None
+ after = int(query["after"][0]) if "after" in query else None
+ stamp = path.stat()
+ revision = history_revision(stamp)
+ state = activity(home, binding["thread_id"])
+ connected = False
+ try:
+ with DesktopClient(home) as client:
+ connected = bool(client.owner(binding["thread_id"]))
+ except (OSError, ValueError, DesktopUnavailable):
+ pass # Persisted history remains readable when Desktop closes.
+ can_reply = connected and state["state"] == "idle"
+ # Do not repeatedly reparse megabytes when no host append changed.
+ if (
+ before is None
+ and query.get("revision", [""])[0] == revision
+ and (after is None or after >= stamp.st_size)
+ ):
+ current_binding(
+ self._codex_goal(query["goal_id"][0]),
+ query["agent_id"][0],
+ binding["binding_ref"],
+ )
+ http._send_json(
+ {
+ "ok": True,
+ "unchanged": True,
+ "revision": revision,
+ "activity": state,
+ "connected": connected,
+ "can_reply": can_reply,
+ }
+ )
+ return
+ view = read_messages(
+ path,
+ before=before,
+ after=after,
+ expected_source=query.get("source_ref", [None])[0],
+ )
+ current_binding(
+ self._codex_goal(query["goal_id"][0]),
+ query["agent_id"][0],
+ binding["binding_ref"],
+ )
+ http._send_json(
+ {
+ "ok": True,
+ **binding,
+ **view,
+ "activity": state,
+ "connected": connected,
+ "can_reply": can_reply,
+ }
+ )
+ except (ValueError, KeyError):
+ http._send_error(
+ "Conversation binding is unavailable or changed. Choose the Agent again.",
+ status=409,
+ )
+ except (OSError, DesktopUnavailable):
+ http._send_error(
+ "Original Codex conversation is unavailable on this host.", status=503
+ )
+
+ def _codex_attached_open(self) -> None:
+ http = cast(_AttachedHTTP, self)
+ try:
+ body = http._read_json(max_bytes=4096)
+ if set(body) != {"goal_id", "agent_id", "binding_ref"} or any(
+ not isinstance(value, str) for value in body.values()
+ ):
+ raise ValueError("Conversation fields are invalid")
+ binding = current_binding(
+ self._codex_goal(body["goal_id"]),
+ body["agent_id"],
+ body["binding_ref"],
+ )
+ locate_thread(binding["thread_id"])
+ current_binding(
+ self._codex_goal(body["goal_id"]),
+ body["agent_id"],
+ body["binding_ref"],
+ )
+ open_conversation(binding["thread_id"])
+ # OS dispatch is not proof of a displayed conversation or a new turn.
+ http._send_json({"ok": True, "requested": True})
+ except (ValueError, KeyError, TypeError):
+ http._send_error(
+ "Conversation binding is invalid or changed. Choose the Agent again.",
+ status=409,
+ )
+ except (OSError, DesktopUnavailable):
+ http._send_error(
+ "Codex App could not be opened on this host. Check it is installed, then try again.",
+ status=503,
+ )
+
+ def _codex_attached_send(self) -> None:
+ http = cast(_AttachedHTTP, self)
+ try:
+ # 24,000 characters also fit when a caller JSON-escapes Unicode.
+ body = http._read_json(max_bytes=160 * 1024)
+ if set(body) != {
+ "goal_id",
+ "agent_id",
+ "binding_ref",
+ "operation_id",
+ "text",
+ } or any(not isinstance(value, str) for value in body.values()):
+ raise ValueError("Reply fields are invalid")
+ if (
+ not body["text"].strip()
+ or len(body["text"]) > 24000
+ or not 8 <= len(body["operation_id"]) <= 100
+ ):
+ raise ValueError("Reply is empty or exceeds the input limit")
+ goal = self._codex_goal(body["goal_id"])
+ binding = current_binding(goal, body["agent_id"], body["binding_ref"])
+ home, _ = locate_thread(binding["thread_id"])
+ path = self._input_receipt_path(
+ body["goal_id"], body["agent_id"], body["operation_id"]
+ )
+ fingerprint = input_fingerprint(body["binding_ref"], body["text"])
+ with exclusive_file_lock(path):
+ if path.exists():
+ receipt = json.loads(path.read_text())
+ if receipt["fingerprint"] != fingerprint:
+ raise ValueError("Operation id belongs to a different input")
+ http._send_json(receipt_readback(receipt))
+ return
+ if activity(home, binding["thread_id"])["state"] != "idle":
+ # No receipt or native input exists for this operation. Do not
+ # extend this proof to generic conflicts or transport errors.
+ http._send_json(
+ {
+ "ok": False,
+ "error": "The Agent is busy or its state is unknown. Nothing was sent; your draft is retained.",
+ "input_not_submitted": True,
+ "operation_id": body["operation_id"],
+ },
+ status=409,
+ )
+ return
+ with DesktopClient(home) as client:
+ owner = client.owner(binding["thread_id"])
+ # Recheck the authoritative binding after discovery, before effect.
+ current_binding(
+ self._codex_goal(body["goal_id"]),
+ body["agent_id"],
+ body["binding_ref"],
+ )
+ receipt = {
+ "operation_id": body["operation_id"],
+ "fingerprint": fingerprint,
+ "binding_ref": binding["binding_ref"],
+ "status": "pending",
+ }
+ atomic_write_json(path, receipt)
+ try:
+ receipt["turn_id"] = client.send(
+ binding["thread_id"], body["text"], owner
+ )
+ receipt["status"] = "delivered"
+ atomic_write_json(path, receipt)
+ except DesktopUncertain:
+ pass # Pending is durable; retrying this id never resends.
+ http._send_json(receipt_readback(receipt))
+ except (ValueError, KeyError, TypeError):
+ http._send_error(
+ "Reply or conversation binding is invalid. Choose the Agent again.",
+ status=409,
+ )
+ except (OSError, DesktopUnavailable):
+ http._send_error(
+ "Reply was not confirmed. Check the original App before trying again.",
+ status=503,
+ )
diff --git a/loopx/presentation/configuration_api.py b/loopx/presentation/configuration_api.py
index 385e385852..94b14f9367 100644
--- a/loopx/presentation/configuration_api.py
+++ b/loopx/presentation/configuration_api.py
@@ -2,6 +2,7 @@
from collections.abc import Callable
+from . import codex_attached_api as attached_api
from . import workspace_preferences_api as preferences_api
from . import configuration_backup_api as backup_api
from . import state_recovery_api as recovery_api
@@ -18,6 +19,7 @@
class ChatConfigurationRequestMixin(
+ attached_api.CodexAttachedRequestMixin,
request_api.RequestControlMixin,
preferences_api.WorkspacePreferencesRequestMixin,
amendment_api.GoalAmendmentRequestMixin,
@@ -36,6 +38,7 @@ class ChatConfigurationRequestMixin(
def _configuration_get_routes(self) -> dict[str, Callable[[], None]]:
return {
+ attached_api.CODEX_ATTACHED_PATH: self._codex_attached_read,
request_api.REQUEST_CONTROL_PATH: self._request_control_read,
preferences_api.SIDEBAR_GOAL_ORDER_PATH: self._sidebar_order_read,
amendment_api.CHAT_GOAL_AMENDMENT_PATH: self._amendment_inspect,
@@ -53,6 +56,8 @@ def _configuration_get_routes(self) -> dict[str, Callable[[], None]]:
def _configuration_post_routes(self) -> dict[str, Callable[[], None]]:
return {
+ attached_api.CODEX_ATTACHED_PATH: self._codex_attached_send,
+ attached_api.CODEX_ATTACHED_OPEN_PATH: self._codex_attached_open,
request_api.REQUEST_CONTROL_PATH: self._request_control_write,
preferences_api.SIDEBAR_GOAL_ORDER_PATH: self._sidebar_order_save,
amendment_api.CHAT_GOAL_AMENDMENT_PATH: self._amendment_configure,
diff --git a/loopx/presentation/state_recovery_api.py b/loopx/presentation/state_recovery_api.py
index 9f596399d9..492ee6a560 100644
--- a/loopx/presentation/state_recovery_api.py
+++ b/loopx/presentation/state_recovery_api.py
@@ -10,7 +10,7 @@
import time
from uuid import uuid4
-from ..state_recovery import (
+from ..control_plane.state_recovery_io import (
MAX_ARCHIVE_BYTES, MAX_MANIFEST_BYTES, StateRecoveryError,
read_state_recovery_audit, verify_state_backup,
)
diff --git a/loopx/quota.py b/loopx/quota.py
index 829dd7d406..2c04e259f5 100644
--- a/loopx/quota.py
+++ b/loopx/quota.py
@@ -1159,6 +1159,8 @@ def record_quota_monitor_poll(
next_user_todo: str | None = None,
next_user_task_class: str | None = None,
next_claimed_by: str | None = None,
+ next_agent_priority: str | None = None,
+ next_user_priority: str | None = None,
task_lease_idempotency_key: str | None = None,
task_lease_expected_version: int | None = None,
use_current_task_lease: bool = False,
@@ -1277,6 +1279,8 @@ def should_run(current_status: dict[str, Any]) -> dict[str, Any]:
next_user_todo=next_user_todo,
next_user_task_class=next_user_task_class,
next_claimed_by=next_claimed_by,
+ next_agent_priority=next_agent_priority,
+ next_user_priority=next_user_priority,
task_lease_idempotency_key=task_lease_idempotency_key,
task_lease_expected_version=task_lease_expected_version,
use_current_task_lease=use_current_task_lease,
diff --git a/loopx/runtime.py b/loopx/runtime.py
index f61c73af61..210250b014 100644
--- a/loopx/runtime.py
+++ b/loopx/runtime.py
@@ -50,7 +50,12 @@ def _archive_source_registry_paths(
registry_path: Path,
goal_id: str,
) -> tuple[Path, ...]:
+ from .control_plane.projects.registry_codec import SOURCE_SESSION_PROFILE_ID
+
paths: set[Path] = set()
+ resolved_registry_path = registry_path.expanduser().resolve()
+ if registry.get("profile_id") == SOURCE_SESSION_PROFILE_ID:
+ paths.add(resolved_registry_path)
for goal in registry_goals(registry):
if str(goal.get("id")) != goal_id:
continue
@@ -59,11 +64,47 @@ def _archive_source_registry_paths(
continue
path = Path(raw_path).expanduser()
if not path.is_absolute():
- path = registry_path.expanduser().resolve().parent / path
+ path = resolved_registry_path.parent / path
paths.add(path.resolve())
return tuple(sorted(paths, key=str))
+def _archive_source_goal_refs(
+ source_registry_paths: tuple[Path, ...],
+ *,
+ goal_id: str,
+) -> tuple[tuple[Path, str, str], ...]:
+ from .control_plane.goals.source_session_registry_state import current_goal_ref
+ from .control_plane.projects.registry_codec import (
+ SOURCE_SESSION_PROFILE_ID,
+ load_project_registry,
+ )
+
+ refs: list[tuple[Path, str, str]] = []
+ for source_registry_path in source_registry_paths:
+ source_registry = load_project_registry(source_registry_path)
+ if source_registry.get("profile_id") != SOURCE_SESSION_PROFILE_ID:
+ continue
+ if not any(
+ str(goal.get("id")) == goal_id
+ for goal in registry_goals(source_registry)
+ ):
+ continue
+ goal_ref, _goal = current_goal_ref(
+ source_registry,
+ goal_id=goal_id,
+ require_active=False,
+ )
+ refs.append(
+ (
+ source_registry_path,
+ goal_ref["goal_id"],
+ goal_ref["goal_instance_id"],
+ )
+ )
+ return tuple(refs)
+
+
def archive_runtime_goal(
*,
registry_path: Path,
@@ -115,6 +156,15 @@ def archive_runtime_goal(
from .control_plane.goals.source_session_registry_state import guard_path
from .file_lock import exclusive_cross_runtime_file_lock
+ source_registry_paths = _archive_source_registry_paths(
+ registry,
+ registry_path=registry_path,
+ goal_id=safe_goal_id,
+ )
+ source_goal_refs = _archive_source_goal_refs(
+ source_registry_paths,
+ goal_id=safe_goal_id,
+ )
with ExitStack() as locks:
resolved_runtime_root = runtime_root.resolve()
locks.enter_context(
@@ -129,11 +179,7 @@ def archive_runtime_goal(
)
# Exact writers enter source before maintenance. Match that order so
# archive cannot overtake a writer between its admission locks.
- for source_registry in _archive_source_registry_paths(
- registry,
- registry_path=registry_path,
- goal_id=safe_goal_id,
- ):
+ for source_registry in source_registry_paths:
locks.enter_context(
exclusive_cross_runtime_file_lock(
guard_path(source_registry, safe_goal_id),
@@ -157,6 +203,21 @@ def archive_runtime_goal(
)
)
locked_registry = load_registry(registry_path)
+ locked_source_registry_paths = _archive_source_registry_paths(
+ locked_registry,
+ registry_path=registry_path,
+ goal_id=safe_goal_id,
+ )
+ if locked_source_registry_paths != source_registry_paths:
+ raise ValueError("source registry route changed during archive preflight")
+ if (
+ _archive_source_goal_refs(
+ locked_source_registry_paths,
+ goal_id=safe_goal_id,
+ )
+ != source_goal_refs
+ ):
+ raise ValueError("goal instance changed during archive preflight")
registry_member = safe_goal_id in {
str(goal.get("id")) for goal in registry_goals(locked_registry)
}
diff --git a/loopx/semantics/vocabulary_v0.json b/loopx/semantics/vocabulary_v0.json
index dcfc15d8ce..443f29d648 100644
--- a/loopx/semantics/vocabulary_v0.json
+++ b/loopx/semantics/vocabulary_v0.json
@@ -171,7 +171,7 @@
},
"coverage_floor": {
"vocabularies": 28,
- "owner_symbols": 53,
+ "owner_symbols": 54,
"literal_scan_fields": 1,
"projections": 1,
"relations": 9,
diff --git a/loopx/status.py b/loopx/status.py
index d5642c0e49..c505bbc574 100644
--- a/loopx/status.py
+++ b/loopx/status.py
@@ -1362,10 +1362,12 @@ def collect_status(
include_zcode_goal_eligibility: bool = False,
activation_state_filter: str | None = None,
agent_lane_id: str | None = None,
+ allow_missing_registry: bool = False,
) -> dict[str, Any]:
with effect_runtime_request_scope():
return _collect_status_read_model(
registry_path=registry_path,
+ allow_missing_registry=allow_missing_registry,
runtime_root_override=runtime_root_override,
scan_roots=scan_roots,
limit=limit,
diff --git a/packages/dsh-loopx-plugin/pnpm-lock.yaml b/packages/dsh-loopx-plugin/pnpm-lock.yaml
index 7e6770a674..6ea9dee90e 100644
--- a/packages/dsh-loopx-plugin/pnpm-lock.yaml
+++ b/packages/dsh-loopx-plugin/pnpm-lock.yaml
@@ -4046,8 +4046,8 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
- js-yaml@4.3.1:
- resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
+ js-yaml@4.3.2:
+ resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
hasBin: true
jsbi@4.3.2:
@@ -5237,7 +5237,7 @@ snapshots:
'@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5)
'@deepseek-ai/cordis-plugin-loader': 1.0.5(@deepseek-ai/cordis@4.0.4)(node-addon-require-builtin@0.1.7)
'@deepseek-ai/cosmokit': 1.8.5
- js-yaml: 4.3.1
+ js-yaml: 4.3.2
'@deepseek-ai/cordis-plugin-loader@1.0.5(@deepseek-ai/cordis@4.0.2(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5))(node-addon-require-builtin@0.1.7)':
dependencies:
@@ -5418,7 +5418,7 @@ snapshots:
'@deepseek-ai/dsh-tools': 0.2.0-rc.2(1f39abe8eddf42052998cdb1b78dcb36)
'@deepseek-ai/dsh-typert-protocol': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.2(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5))
'@deepseek-ai/schemastery': 3.18.4
- js-yaml: 4.3.1
+ js-yaml: 4.3.2
zod: 4.4.3
transitivePeerDependencies:
- '@deepseek-ai/cordis-plugin-group'
@@ -5441,7 +5441,7 @@ snapshots:
'@deepseek-ai/dsh-tools': 0.2.0-rc.2(1f39abe8eddf42052998cdb1b78dcb36)
'@deepseek-ai/dsh-typert-protocol': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.2(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5))
'@deepseek-ai/schemastery': 3.18.4
- js-yaml: 4.3.1
+ js-yaml: 4.3.2
zod: 4.4.3
transitivePeerDependencies:
- '@deepseek-ai/cordis-plugin-group'
@@ -5711,7 +5711,7 @@ snapshots:
'@deepseek-ai/dsh-system-prompt': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.2(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5))(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.2(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5)))(@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.2(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5)))(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.2(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5))(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.2(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5))))
'@eslint-community/regexpp': 4.12.2
ajv: 8.20.0
- js-yaml: 4.3.1
+ js-yaml: 4.3.2
node-addon-require-builtin: 0.1.7
resolve.exports: 2.0.3
semver: 7.8.5
@@ -5729,7 +5729,7 @@ snapshots:
'@deepseek-ai/dsh-system-prompt': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.2(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5))(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.2(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5)))(@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.2(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5)))(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.2(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5))(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.2(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5))))
'@eslint-community/regexpp': 4.12.2
ajv: 8.20.0
- js-yaml: 4.3.1
+ js-yaml: 4.3.2
node-addon-require-builtin: 0.1.7
resolve.exports: 2.0.3
semver: 7.8.5
@@ -5747,7 +5747,7 @@ snapshots:
'@deepseek-ai/dsh-system-prompt': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.2(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5)))(@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.2(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5))(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.2(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5))))
'@eslint-community/regexpp': 4.12.2
ajv: 8.20.0
- js-yaml: 4.3.1
+ js-yaml: 4.3.2
node-addon-require-builtin: 0.1.7
resolve.exports: 2.0.3
semver: 7.8.5
@@ -6457,7 +6457,7 @@ snapshots:
'@deepseek-ai/cordis-plugin-loader': 1.0.5(@deepseek-ai/cordis@4.0.4)(node-addon-require-builtin@0.1.7)
'@deepseek-ai/dsh-app-boot': 0.2.0-rc.2(b23594b371df0098dcaf2a6699dcc581)
'@deepseek-ai/dsh-atomic-write': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.2(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5))
- js-yaml: 4.3.1
+ js-yaml: 4.3.2
yaml: 2.9.0
optionalDependencies:
'@deepseek-ai/dsh-hmr': 0.2.0-rc.2(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5)(@deepseek-ai/cordis-plugin-timer@1.1.6(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-app-boot@0.2.0-rc.2(b23594b371df0098dcaf2a6699dcc581))(@deepseek-ai/dsh-cmdline@0.2.0-rc.2(@deepseek-ai/cordis-plugin-loader@1.0.5)(@deepseek-ai/cordis@4.0.4))
@@ -6469,7 +6469,7 @@ snapshots:
'@deepseek-ai/cordis-plugin-loader': 1.0.5(@deepseek-ai/cordis@4.0.4)(node-addon-require-builtin@0.1.7)
'@deepseek-ai/dsh-app-boot': 0.2.0-rc.2(b23594b371df0098dcaf2a6699dcc581)
'@deepseek-ai/dsh-atomic-write': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)
- js-yaml: 4.3.1
+ js-yaml: 4.3.2
yaml: 2.9.0
optionalDependencies:
'@deepseek-ai/dsh-hmr': 0.2.0-rc.2(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5)(@deepseek-ai/cordis-plugin-timer@1.1.6(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-app-boot@0.2.0-rc.2(b23594b371df0098dcaf2a6699dcc581))(@deepseek-ai/dsh-cmdline@0.2.0-rc.2(@deepseek-ai/cordis-plugin-loader@1.0.5)(@deepseek-ai/cordis@4.0.4))
@@ -9628,7 +9628,7 @@ snapshots:
'@deepseek-ai/dsh-workflow-ptc': 0.2.0-rc.2(ff2e645c4e91039efcd82d814e3eaeed)
'@deepseek-ai/schemastery': 3.18.4
commander: 15.0.0
- js-yaml: 4.3.1
+ js-yaml: 4.3.2
node-addon-require-builtin: 0.1.7
transitivePeerDependencies:
- '@deepseek-ai/cordis-plugin-group'
@@ -11036,7 +11036,7 @@ snapshots:
js-tokens@4.0.0: {}
- js-yaml@4.3.1:
+ js-yaml@4.3.2:
dependencies:
argparse: 2.0.1
diff --git a/packages/dsh-loopx-plugin/tests/admission-closeout.integration.spec.ts b/packages/dsh-loopx-plugin/tests/admission-closeout.integration.spec.ts
index a16ad78ea5..3ba52f18fd 100644
--- a/packages/dsh-loopx-plugin/tests/admission-closeout.integration.spec.ts
+++ b/packages/dsh-loopx-plugin/tests/admission-closeout.integration.spec.ts
@@ -105,6 +105,7 @@ async function fixture(): Promise {
'---',
'status: active-read-only',
'owner_mode: goal',
+ 'handoff_mode: soft_claim',
'objective: "Validate one DSH continuation settlement."',
'updated_at: 2026-01-01T00:00:00+00:00',
'---',
@@ -152,6 +153,30 @@ async function fixture(): Promise {
quota: { compute: 1, window_hours: 24, allowed_slots: 2 },
}],
}, null, 2)}\n`, 'utf8')
+ // The closeout journey starts on supported authority. Markdown remains a
+ // projection; this test must not depend on the retired ordinary writer.
+ const canonical = await runFile(await integrationPython, ['-c', [
+ 'import sys',
+ 'from pathlib import Path',
+ 'sys.path.insert(0, str(Path(sys.argv[1]) / "tests/control_plane"))',
+ 'from canonical_authority_fixture import initialize_canonical_authority',
+ 'from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection',
+ 'projection = build_todo_runtime_shadow_projection(',
+ ' goal_id=sys.argv[3], handoff_mode="soft_claim", todos=[{',
+ ' "schema_version": "todo_item_v0", "todo_id": sys.argv[5],',
+ ' "role": "agent", "status": "open", "done": False,',
+ ' "text": "[P1] Validate and settle the selected delivery.",',
+ ' "title": "Validate and settle the selected delivery.", "priority": "P1",',
+ ' "archive_state": "active", "source_section": "Agent Todo", "index": 1,',
+ ' "task_class": "advancement_task", "action_kind": "validate",',
+ ' }])',
+ 'initialize_canonical_authority(Path(sys.argv[2]), sys.argv[3], projection,',
+ ' state_path=Path(sys.argv[4]), provider="file")',
+ ].join('\n'), repoRoot, runtime, goalId, state, todoId], {
+ cwd: repoRoot,
+ env: { ...process.env, PYTHONPATH: repoRoot },
+ })
+ expect(canonical.exitCode, canonical.stderr).toBe(0)
expect((await runFile('git', ['init', '--quiet'], { cwd: project })).exitCode).toBe(0)
expect((await runFile('git', [
'remote', 'add', 'origin',
diff --git a/scripts/ci/test_review_gate.py b/scripts/ci/test_review_gate.py
index 7afface007..9efe533523 100644
--- a/scripts/ci/test_review_gate.py
+++ b/scripts/ci/test_review_gate.py
@@ -58,7 +58,7 @@ def test_closed_pr_cancels_same_lane_and_skips_qualification(self):
def expression(value, action, *, number=17, event="pull_request"):
ref = "refs/heads/main" if action == "closed" or event != "pull_request" else f"refs/pull/{number}/merge"
github = SimpleNamespace(event_name=event, ref=ref, event=SimpleNamespace(
- action=action, number=number, pull_request=None))
+ action=action, number=number, pull_request=SimpleNamespace(number=number)))
return eval(value.strip().removeprefix("${{").removesuffix("}}").strip()
.replace("&&", "and").replace("||", "or").replace("!cancelled()", "not cancelled()"),
{"__builtins__": {}}, {"github": github, "always": lambda: True,
diff --git a/tests/architecture/test_semantic_vocabulary_drift.py b/tests/architecture/test_semantic_vocabulary_drift.py
index e768d8da4f..8dd97081b9 100644
--- a/tests/architecture/test_semantic_vocabulary_drift.py
+++ b/tests/architecture/test_semantic_vocabulary_drift.py
@@ -970,7 +970,7 @@ def test_native_lifecycle_relation_keeps_the_live_owner_and_verification_boundar
smoke = runpy.run_path(str(SMOKE))
registry = smoke['load_registry']()
assert registry['coverage_floor']['vocabularies'] == 28
- assert registry['coverage_floor']['owner_symbols'] == 53
+ assert registry['coverage_floor']['owner_symbols'] == 54
assert registry['coverage_floor']['relations'] == 9
relation = next(row for row in registry['relations']['subsets']
if row['name'] == 'task_lease_lifecycle_decision_operation')
diff --git a/tests/canary/test_premerge_public_boundary_run.py b/tests/canary/test_premerge_public_boundary_run.py
new file mode 100644
index 0000000000..37804881ae
--- /dev/null
+++ b/tests/canary/test_premerge_public_boundary_run.py
@@ -0,0 +1,80 @@
+"""The premerge boundary check fails on an input the scan could not read.
+
+`_public_boundary_changed_files_run` reads `scan_public_boundary`'s result, so a
+directory the scan could not list has to fail this check the way a recorded hit
+does. The check is reached with directory inputs, which a `git diff` change set
+does not normally carry, so the payload is driven directly here.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from loopx.canary.premerge import _public_boundary_changed_files_run
+
+
+def _boundary(**overrides: object) -> dict[str, object]:
+ payload: dict[str, object] = {
+ "ok": True,
+ "scanned_files": 3,
+ "hits": [],
+ "skipped_private_state_files": [],
+ "allowed_hits": [],
+ "unreadable_directories": [],
+ }
+ payload.update(overrides)
+ return payload
+
+
+def test_unreadable_directory_fails_the_premerge_boundary_check(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ (tmp_path / "tracked.txt").write_text("ordinary\n", encoding="utf-8")
+ monkeypatch.setattr(
+ "loopx.canary.premerge.scan_public_boundary",
+ lambda *_, **__: _boundary(unreadable_directories=["notes: Permission denied"]),
+ )
+
+ run = _public_boundary_changed_files_run(
+ changed_files=["tracked.txt"],
+ execute=True,
+ timeout_seconds=30.0,
+ repo_root=tmp_path,
+ )
+
+ check = run["selected_checks"][0]
+ assert check["ok"] is False
+ assert check["status"] == "failed"
+ assert check["stdout_tail"] == ""
+ assert check["stderr_tail"] == "notes: Permission denied"
+ assert check["boundary"]["unreadable_directory_count"] == 1
+ assert run["ok"] is False
+ assert run["failure_count"] == 1
+
+
+def test_clean_boundary_passes_the_premerge_boundary_check(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ (tmp_path / "tracked.txt").write_text("ordinary\n", encoding="utf-8")
+ monkeypatch.setattr(
+ "loopx.canary.premerge.scan_public_boundary",
+ lambda *_, **__: _boundary(),
+ )
+
+ run = _public_boundary_changed_files_run(
+ changed_files=["tracked.txt"],
+ execute=True,
+ timeout_seconds=30.0,
+ repo_root=tmp_path,
+ )
+
+ check = run["selected_checks"][0]
+ assert check["ok"] is True
+ assert check["status"] == "passed"
+ assert check["stdout_tail"] == "public boundary scan clean: 3 files"
+ assert check["boundary"]["unreadable_directory_count"] == 0
+ assert run["ok"] is True
diff --git a/tests/capabilities/test_pr_review_contract.py b/tests/capabilities/test_pr_review_contract.py
index 2cbfead0f7..f42c50284f 100644
--- a/tests/capabilities/test_pr_review_contract.py
+++ b/tests/capabilities/test_pr_review_contract.py
@@ -677,6 +677,25 @@ def test_motivation_publishes_existing_problem_context_before_implementation():
assert "not truth or comprehension" in publication["rule"]
+def test_experience_review_challenges_total_work_without_erasing_consent():
+ # Agent-consumed prose is behavior: preserve both the reduction obligation
+ # and its legitimate-consent/configuration countercases in the shared packet.
+ contract = build_agent_response_contract()["review_execution_contract"]
+ context = next(row for row in contract["evidence_requirements"]
+ if row["evidence_id"] == "problem_context")
+ impact = context["outcome_impact"]
+ assert impact["applicable_fields"] == ["before_after", "evidence_refs"]
+ rule = impact["rule"]
+ for obligation in ("original request", "entire human job", "smaller lawful alternative",
+ "zero additional rounds", "off-screen preparation",
+ "high feature value alone is insufficient", "not a universal click gate",
+ "Optional technical editing remains valid", "necessary scoped consent"):
+ assert obligation in rule
+ direct = build_review_execution_contract()
+ assert next(row for row in direct["evidence_requirements"]
+ if row["evidence_id"] == "problem_context")["outcome_impact"] == impact
+
+
def test_review_packet_and_template_share_optional_visual_explanation(capsys):
fixture = Path(__file__).resolve().parents[2] / "examples/fixtures/pr-review.public.json"
assert cli_main(["--format", "json", "pr-review", "--fixture", str(fixture)]) == 0
diff --git a/tests/control_plane/test_deferred_original_turn_binding.py b/tests/control_plane/test_deferred_original_turn_binding.py
new file mode 100644
index 0000000000..fdc2e3a13d
--- /dev/null
+++ b/tests/control_plane/test_deferred_original_turn_binding.py
@@ -0,0 +1,96 @@
+"""A compact candidate view cannot replace an admitted Turn's identity."""
+import copy
+import json
+from pathlib import Path
+
+import pytest
+
+from tests.control_plane.test_quota_settlement_cli import (
+ AGENT_ID,
+ GOAL_ID,
+ TODO_ID,
+ _configure_autonomous_replan_fixture,
+ _run_cli,
+ _write_fixture,
+)
+
+
+@pytest.mark.parametrize("hidden_count", [0, 60])
+@pytest.mark.parametrize("checked", [False, True])
+@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"])
+def test_deferred_replay_keeps_original_binding_during_replan(
+ tmp_path: Path, hidden_count: int, checked: bool, provider: str,
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture,
+) -> None:
+ if provider == "sqlite":
+ from canonical_authority_fixture import isolate_sqlite_runtime
+
+ isolate_sqlite_runtime(tmp_path, monkeypatch)
+ project, runtime, registry = _write_fixture(tmp_path)
+ turn = "turn-original-deferred"
+ guard = ("quota", "should-run", "--runtime-profile", "codex_app_ssh_goal",
+ "--goal-id", GOAL_ID, "--agent-id", AGENT_ID,
+ "--turn-instance-id", turn, "--todo-id", TODO_ID)
+ rc, original = _run_cli(registry, runtime, *guard)
+ assert rc == 0, original
+ identity = original["heartbeat_receipt"]["settlement_identity"]
+ assert identity["todo_id"] == TODO_ID
+
+ state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md"
+ text = state.read_text().replace("status: active-read-only", "status: active")
+ # A new replan is legitimately due, but does not own the old Turn.
+ _configure_autonomous_replan_fixture(project, runtime, registry)
+ text = text.replace(f"todo_id={TODO_ID} status=open",
+ f"todo_id={TODO_ID} status=deferred resume_when=todo_done:todo_prior")
+ if checked:
+ text = text.replace("- [ ] [P1] Validate", "- [x] [P1] Validate")
+ text += ("\n- [x] [P1] Prior dependency is complete.\n"
+ f" \n")
+ rows = "".join(f"- [ ] [P0] Independent waiting candidate {i}.\n"
+ f" \n"
+ for i in range(hidden_count))
+ text = text.replace("## Agent Todo\n\n", "## Agent Todo\n\n" + rows)
+ state.write_text(text)
+ if provider != "legacy":
+ from canonical_authority_fixture import initialize_canonical_authority
+ from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection
+ from loopx.control_plane.todos.active_state_todo_parser import parse_active_state_todos
+
+ todos = parse_active_state_todos(text, item_limit=None)["agent_todos"]["items"]
+ initialize_canonical_authority(runtime, GOAL_ID,
+ build_todo_runtime_shadow_projection(goal_id=GOAL_ID,
+ todos=todos, handoff_mode="soft_claim", leases=[]),
+ state_path=state, provider=provider)
+
+ from loopx.cli import main
+ from loopx.cli_commands import quota
+
+ live = quota.build_live_quota_should_run_decision
+
+ def compact_status(status, **kwargs):
+ # The display snapshot can omit the original row; the provider still
+ # owns it. Exercise the real reader, router, and receipt replay.
+ def compact(value):
+ if isinstance(value, list):
+ return [compact(row) for row in value
+ if not isinstance(row, dict) or row.get("todo_id") != TODO_ID]
+ if isinstance(value, dict):
+ return {key: compact(row) for key, row in value.items()}
+ return value
+ return live(compact(copy.deepcopy(status)) if hidden_count else status, **kwargs)
+
+ monkeypatch.setattr(quota, "build_live_quota_should_run_decision", compact_status)
+ capsys.readouterr()
+ rc = main(["--format", "json", "--registry", str(registry),
+ "--runtime-root", str(runtime), *guard, "--scan-path", str(project)])
+ replay = json.loads(capsys.readouterr().out)
+ assert rc == 0, replay
+ assert replay["heartbeat_receipt"]["settlement_identity"] == identity
+ channel = replay["interaction_contract"]["cli_channel"]
+ plan = channel.get("settlement_plan")
+ assert plan is None or plan["identity"]["todo_id"] == TODO_ID, channel
+ assert replay["should_run"] is False, replay.get("effective_action")
+ assert replay["normal_delivery_allowed"] is False
+ assert channel["spend_after_validation"] is False
diff --git a/tests/control_plane/test_effect_runtime_integration.py b/tests/control_plane/test_effect_runtime_integration.py
index 830594738e..42e737ed7f 100644
--- a/tests/control_plane/test_effect_runtime_integration.py
+++ b/tests/control_plane/test_effect_runtime_integration.py
@@ -637,10 +637,12 @@ def test_identical_release_does_not_reuse_a_retired_lazy_loader(
effect_runtime.restart_effect_runtime()
+@pytest.mark.parametrize("cleanup_delay", [0, 0.6], ids=["serving", "idle-retired"])
def test_runtime_decode_change_rotates_identity_and_starts_replacement(
tmp_path: Path,
monkeypatch,
request,
+ cleanup_delay: float,
) -> None:
staged_root, captures = observe_copied_runtime(tmp_path, monkeypatch, request)
runtime_dir = tmp_path / "runtime"
@@ -661,9 +663,20 @@ def test_runtime_decode_change_rotates_identity_and_starts_replacement(
assert original_info != replacement_info
assert replacement_info.exists()
+ replacement_locator = effect_runtime._read_info(
+ replacement_info, fingerprint=replacement_fingerprint,
+ )
+ assert replacement_locator is not None
+ runtime_count = len(captures)
+ time.sleep(cleanup_delay)
shutdown_started = time.monotonic()
- effect_runtime.effect_runtime_result("runtime.shutdown", {}, retry_safe=False)
+ # Cleanup owns this exact process. Rediscovery can race its 250 ms idle
+ # retirement or start a third runtime merely to shut that new one down.
+ effect_runtime._stop_effect_runtime(
+ replacement_info, replacement_locator, timeout=2, wait_for_process_exit=True,
+ )
shutdown_acknowledged = time.monotonic()
+ assert len(captures) == runtime_count, "cleanup must not start another runtime"
deadline = time.monotonic() + 2
while (
original_info.exists() or replacement_info.exists()
diff --git a/tests/control_plane/test_effective_turn_replan.py b/tests/control_plane/test_effective_turn_replan.py
index 8c34d5290f..2bd174c0ae 100644
--- a/tests/control_plane/test_effective_turn_replan.py
+++ b/tests/control_plane/test_effective_turn_replan.py
@@ -312,7 +312,7 @@ def test_due_stride_review_survives_successor_transition_until_decision(
isolate_sqlite_runtime(tmp_path, monkeypatch)
project, runtime, registry = _write_fixture(tmp_path)
data = json.loads(registry.read_text())
- data["goals"][0]["quota"]["allowed_slots"] = 4
+ data["goals"][0]["quota"]["allowed_slots"] = 8
registry.write_text(json.dumps(data))
rc, listed = _run_cli(registry, runtime, "todo", "list", "--goal-id", GOAL_ID)
assert rc == 0, listed
@@ -322,7 +322,7 @@ def test_due_stride_review_survives_successor_transition_until_decision(
def call(*args: str, expected_code: int = 0) -> dict:
code, result = _run_cli(registry, runtime, *args, cwd=project)
- assert code == expected_code, result
+ assert code == expected_code, json.dumps(result, indent=2)
return result
call("configure-goal", "--goal-id", GOAL_ID, "--execution-replan-after-turns", "2",
@@ -439,6 +439,70 @@ def call(*args: str, expected_code: int = 0) -> dict:
assert accepted["autonomous_replan_ack"]["semantic_delta"]["accepted"] is True
persisted = json.loads(Path(accepted["json_path"]).read_text())
assert persisted["agent_vision"]["stride_decision"] == decision
+ assert persisted["settlement_identity"]["schema_version"] == "quota_settlement_identity_v1"
+ assert persisted["replan_obligation_id"] == obligation["obligation_id"]
+ call("quota", "spend-slot", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID,
+ "--replan-obligation-id", obligation["obligation_id"], "--turn-instance-id", "stride-due-review",
+ "--slots", "1", "--source", "heartbeat", "--execute")
+
+ # Continue through real authority/history, rather than injecting a crafted
+ # context. The next periodic review must see the accepted autonomous decision.
+ for index in range(2):
+ turn = f"stride-followthrough-{index}"
+ call("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID,
+ "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", turn,
+ "--scan-path", str(project))
+ call("refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID,
+ "--todo-id", TODO_ID, "--turn-instance-id", turn,
+ "--classification", "validated_progress", "--delivery-batch-scale", "implementation",
+ "--delivery-outcome", "outcome_progress", "--delivery-boundary", "in_flight_continuation",
+ "--no-global-sync", "--suppress-external-sinks")
+ call("quota", "spend-slot", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID,
+ "--todo-id", TODO_ID, "--turn-instance-id", turn,
+ "--slots", "1", "--source", "heartbeat", "--execute")
+ next_guard = call("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID,
+ "--agent-id", AGENT_ID,
+ "--turn-instance-id", "stride-next-review", "--scan-path", str(project))
+ next_obligation = next_guard["autonomous_replan_obligation"]
+ next_successor = call("todo", "add", "--goal-id", GOAL_ID, "--role", "agent",
+ "--claimed-by", AGENT_ID, "--text", "Resolve the remaining source uncertainty",
+ "--task-class", "advancement_task", "--action-kind", "validate",
+ "--target-key", "source-uncertainty", "--replan-obligation-id", next_obligation["obligation_id"])
+ next_guard = call("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID,
+ "--agent-id", AGENT_ID, "--todo-id", next_successor["todo_id"],
+ "--turn-instance-id", "stride-next-review", "--scan-path", str(project))
+ next_obligation = next_guard["autonomous_replan_obligation"]
+ next_control = next_guard["replan_action_packet"]["stride_control"]
+ follow = next_obligation["replan_context"]["decision_followthrough"]
+ previous = follow["previous_decision"]
+ assert previous["todo_id"] is None
+ assert previous["replan_obligation_id"] == obligation["obligation_id"]
+ assert previous["stride_decision"] == decision
+ assert follow["execution_and_goal_gain"] == "unknown"
+ assert next_control["previous_decision_ref"] == previous["evidence_ref"]
+ resolved = call("history", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID,
+ "--evidence-ref", previous["evidence_ref"])
+ assert resolved["evidence"]["stride_decision"] == decision
+ next_decision = {**decision, "reference_context_id": next_control["reference_context_id"],
+ "reference_obligation_id": next_obligation["obligation_id"]}
+ refresh_args = ("refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID,
+ "--replan-obligation-id", next_obligation["obligation_id"], "--turn-instance-id", "stride-next-review",
+ "--agent-vision-json", str(decision_path), "--classification", "bounded_replan_progress",
+ "--delivery-batch-scale", "implementation", "--delivery-outcome", "outcome_progress",
+ "--no-global-sync", "--suppress-external-sinks")
+ decision_path.write_text(json.dumps({**vision_packet, "stride_decision": next_decision}))
+ missing_review = call(*refresh_args, expected_code=1)
+ assert missing_review.get("appended") is not True
+ assert "previous" in missing_review["error"].lower()
+ next_decision["previous_review"] = {"reference_decision_ref": previous["evidence_ref"],
+ "adoption": "unknown", "information_gain": "unknown", "goal_gain": "unknown",
+ "evidence_refs": [previous["history_evidence_ref"]],
+ "lesson": "The decision is recorded; subsequent work does not yet prove target gain."}
+ decision_path.write_text(json.dumps({**vision_packet, "stride_decision": next_decision}))
+ next_accepted = call(*refresh_args)
+ assert next_accepted["autonomous_replan_ack"]["semantic_delta"]["accepted"] is True
+ replay = call(*refresh_args)
+ assert replay.get("appended") is not True
@pytest.mark.parametrize("invalid", [0, 7, True, 2.5, "2"])
diff --git a/tests/control_plane/test_goal_acceptance_cli.py b/tests/control_plane/test_goal_acceptance_cli.py
index 7b61c6172e..7bd28cc3d8 100644
--- a/tests/control_plane/test_goal_acceptance_cli.py
+++ b/tests/control_plane/test_goal_acceptance_cli.py
@@ -128,6 +128,8 @@ def test_owner_configure_validation_failure_success_and_disable(acceptance_goal)
(project / "artifact.txt").write_text("accepted")
code, passed = cli("verify", "--agent-id", "agent-a", "--execute")
assert code == 0 and passed["checks_passed"] is True, passed
+ assert passed["status"] == "applied" and passed["goal_id"] == "goal-acceptance"
+ assert all("oracle" not in row for row in passed["goal_acceptance_contract"]["criteria"])
observed = cli("inspect")[1]
assert observed["goal_acceptance_contract"]["status"] == "accepted"
assert "validation_argv" not in json.dumps(observed)
diff --git a/tests/control_plane/test_goal_acceptance_oracle.py b/tests/control_plane/test_goal_acceptance_oracle.py
new file mode 100644
index 0000000000..1cf6d6efdf
--- /dev/null
+++ b/tests/control_plane/test_goal_acceptance_oracle.py
@@ -0,0 +1,376 @@
+"""Real File/SQLite oracle and owner HTTP journeys; no model or active Goal."""
+
+import hashlib
+import json
+import shutil
+import sys
+import pytest
+from test_goal_acceptance_cli import acceptance_goal as acceptance_goal
+
+
+def test_owner_pinned_oracle_binds_actual_versions_and_withdraws_stale_evidence(
+ acceptance_goal,
+):
+ project, document, cli, _ = acceptance_goal
+ checker = project / "check.py"
+ checker.write_text(
+ "from pathlib import Path\nassert Path('artifact.txt').read_text() == Path('input.txt').read_text()\n"
+ )
+ (project / "input.txt").write_text("accepted")
+ (project / "artifact.txt").write_text("accepted")
+ raw = json.loads(document.read_text())
+ row = raw["criteria"][0]
+ row["validation_argv"] = [sys.executable, "check.py"]
+ row["validation_files"] = [
+ {"path": "check.py", "sha256": hashlib.sha256(checker.read_bytes()).hexdigest()}
+ ]
+ row["oracle"] = {
+ "kind": "owner_pinned_oracle",
+ "source_id": "export-checker",
+ "input_refs": ["input.txt"],
+ "output_refs": ["artifact.txt"],
+ }
+ document.write_text(json.dumps(raw))
+ initial = cli("inspect")[1]
+ code, configured = cli(
+ "configure",
+ "--document",
+ str(document),
+ "--expected-provider-revision",
+ initial["provider_revision"],
+ "--execute",
+ )
+ assert code == 0, configured
+ assert (
+ configured["goal_acceptance_contract"]["criteria"][0]["oracle"]["state"]
+ == "unverified"
+ )
+ code, checked = cli("verify", "--execute")
+ assert code == 0, checked
+ receipt = checked["goal_acceptance_contract"]["verification"]["results"][0][
+ "oracle_evidence"
+ ]
+ assert receipt["source_id"] == "export-checker"
+ assert receipt["input_versions"] == [
+ {"ref": "input.txt", "sha256": hashlib.sha256(b"accepted").hexdigest()}
+ ]
+ assert receipt["output_versions"] == [
+ {"ref": "artifact.txt", "sha256": hashlib.sha256(b"accepted").hexdigest()}
+ ]
+ assert receipt["checked_at"].endswith("Z")
+ current = cli("inspect")[1]["goal_acceptance_contract"]
+ assert current["criteria"][0]["oracle"]["state"] == "current"
+ (project / "artifact.txt").write_text("a later unverified version")
+ stale = cli("inspect")[1]["goal_acceptance_contract"]
+ assert stale["criteria"][0]["oracle"]["state"] == "stale"
+ assert stale["verification"]["results"][0]["oracle_evidence"] == receipt
+ (project / "artifact.txt").write_text("accepted")
+ checker.write_text("raise SystemExit(0)\n")
+ assert (
+ cli("inspect")[1]["goal_acceptance_contract"]["criteria"][0]["oracle"]["state"]
+ == "stale"
+ )
+ code, rejected = cli("verify", "--execute")
+ assert code == 1 and rejected["checks_passed"] is False
+
+
+def test_oracle_http_owner_journey_uses_same_real_authority(acceptance_goal):
+ import http.client
+ import threading
+ from loopx.chat_server import ChatHTTPServer, ChatRequestHandler
+ from loopx.paths import effective_runtime_root
+
+ project, document, cli, _ = acceptance_goal
+ # Fixture's source registry is adjacent to the document; this is an isolated
+ # File/SQLite provider, never the caller's active Goal.
+ registry = document.parent / "registry.json"
+ server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler)
+ server.verbose = False
+ server.registry_path = registry
+ server.runtime_root = effective_runtime_root(registry, None)
+ server.runtime_root_override = str(server.runtime_root)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+
+ def request(body=None, *, origin=None):
+ connection = http.client.HTTPConnection(*server.server_address, timeout=60)
+ try:
+ connection.request(
+ "GET" if body is None else "POST",
+ "/api/chat/goal-acceptance?goal_id=goal-acceptance"
+ if body is None
+ else "/api/chat/goal-acceptance",
+ None
+ if body is None
+ else json.dumps({"goal_id": "goal-acceptance", **body}),
+ {
+ "Content-Type": "application/json",
+ "Origin": origin or f"http://127.0.0.1:{server.server_port}",
+ },
+ )
+ result = connection.getresponse()
+ return result.status, json.loads(result.read())
+ finally:
+ connection.close()
+
+ try:
+ status, before = request()
+ assert status == 200 and before["goal_acceptance_contract"] == {
+ "enabled": False
+ }
+ checker = project / "check.py"
+ checker.write_text(
+ "from pathlib import Path\nassert Path('artifact.txt').read_text() == 'accepted'\n"
+ )
+ owner_document = json.loads(document.read_text())
+ owner_document["criteria"][0].update(
+ validation_argv=[sys.executable, "check.py"],
+ validation_files=[
+ {
+ "path": "check.py",
+ "sha256": hashlib.sha256(checker.read_bytes()).hexdigest(),
+ }
+ ],
+ oracle={
+ "kind": "owner_pinned_oracle",
+ "source_id": "http-owner-checker",
+ "input_refs": [],
+ "output_refs": ["artifact.txt"],
+ },
+ )
+ config = {
+ "action": "configure",
+ "document": owner_document,
+ "expected_provider_revision": before["provider_revision"],
+ "operation_id": "http-contract",
+ }
+ status, saved = request(config)
+ assert status == 200 and saved["goal_id"] == "goal-acceptance"
+ assert (
+ saved["goal_acceptance_contract"]
+ == cli("inspect")[1]["goal_acceptance_contract"]
+ )
+ assert "validation_argv" not in json.dumps(saved)
+ assert request({**config, "operation_id": "stale-config"})[0] == 409
+ assert request({**config, "actor_agent_id": "agent-a"})[0] == 409
+ assert (
+ request(
+ {
+ "action": "verify",
+ "expected_provider_revision": saved["provider_revision"],
+ "results": [{"passed": True}],
+ }
+ )[0]
+ == 409
+ )
+ assert (
+ request(
+ {
+ "action": "verify",
+ "expected_provider_revision": saved["provider_revision"],
+ },
+ origin="https://foreign.example",
+ )[0]
+ == 403
+ )
+ (project / "artifact.txt").write_text("accepted")
+ status, checked = request(
+ {
+ "action": "verify",
+ "expected_provider_revision": saved["provider_revision"],
+ }
+ )
+ assert status == 200 and checked["checks_passed"] is True, checked
+ assert (
+ checked["goal_acceptance_contract"]["criteria"][0]["oracle"]["state"]
+ == "current"
+ )
+ assert (
+ checked["goal_acceptance_contract"]
+ == cli("inspect")[1]["goal_acceptance_contract"]
+ )
+ current = request()[1]
+ status, disabled = request(
+ {
+ "action": "disable",
+ "expected_provider_revision": current["provider_revision"],
+ "operation_id": "http-disable",
+ }
+ )
+ assert status == 200 and disabled["goal_acceptance_contract"] == {
+ "enabled": False
+ }
+ assert cli("inspect")[1]["goal_acceptance_contract"] == {"enabled": False}
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join(timeout=5)
+
+
+@pytest.mark.parametrize(
+ "change",
+ ["snapshot_output", "live_input", "live_output", "missing_source", "timeout"],
+)
+def test_oracle_rejects_changed_or_missing_files_and_timeout(acceptance_goal, change):
+ project, document, cli, _ = acceptance_goal
+ (project / "input.txt").write_text("expected")
+ (project / "artifact.txt").write_text("expected")
+ live = project / ("input.txt" if change == "live_input" else "artifact.txt")
+ script = "from pathlib import Path\n"
+ if change == "snapshot_output":
+ script += "Path('artifact.txt').write_text('changed by checker')\n"
+ elif change.startswith("live_"):
+ script += f"Path({str(live)!r}).write_text('changed while checker ran')\n"
+ elif change == "timeout":
+ script += "import time\ntime.sleep(5)\n"
+ else:
+ script += "Path('artifact.txt').read_text()\n"
+ checker = project / "check.py"
+ checker.write_text(script)
+ doc = json.loads(document.read_text())
+ criterion = doc["criteria"][0]
+ criterion.update(
+ validation_argv=[sys.executable, "check.py"],
+ validation_timeout_seconds=1,
+ validation_files=[
+ {
+ "path": "check.py",
+ "sha256": hashlib.sha256(checker.read_bytes()).hexdigest(),
+ }
+ ],
+ oracle={
+ "kind": "owner_pinned_oracle",
+ "source_id": "owner-checker",
+ "input_refs": ["input.txt"],
+ "output_refs": ["artifact.txt"],
+ },
+ )
+ document.write_text(json.dumps(doc))
+ before = cli("inspect")[1]
+ code, result = cli(
+ "configure",
+ "--document",
+ str(document),
+ "--expected-provider-revision",
+ before["provider_revision"],
+ "--execute",
+ )
+ assert code == 0, result
+ if change == "missing_source":
+ checker.unlink()
+ code, result = cli("verify", "--execute")
+ assert code == 1 and result["checks_passed"] is False, result
+ check = result["goal_acceptance_contract"]["verification"]["results"][0]
+ assert check["passed"] is False and "oracle_evidence" not in check
+ assert (
+ cli("inspect")[1]["goal_acceptance_contract"]["criteria"][0]["oracle"]["state"]
+ != "current"
+ )
+
+
+def test_pinned_file_argument_is_not_an_executed_checker(acceptance_goal):
+ non_interpreter = shutil.which("true")
+ if non_interpreter is None:
+ pytest.skip("POSIX true executable is required for this invocation regression")
+ project, document, cli, _ = acceptance_goal
+ checker = project / "reject.py"
+ checker.write_text("raise SystemExit(17)\n")
+ (project / "artifact.txt").write_text("unchanged output")
+ raw = json.loads(document.read_text())
+ criterion = raw["criteria"][0]
+ criterion.update(
+ validation_argv=[sys.executable, "reject.py"],
+ validation_files=[
+ {
+ "path": "reject.py",
+ "sha256": hashlib.sha256(checker.read_bytes()).hexdigest(),
+ }
+ ],
+ oracle={
+ "kind": "owner_pinned_oracle",
+ "source_id": "rejecting-checker",
+ "input_refs": [],
+ "output_refs": ["artifact.txt"],
+ },
+ )
+ document.write_text(json.dumps(raw))
+ before = cli("inspect")[1]
+ code, configured = cli(
+ "configure",
+ "--document",
+ str(document),
+ "--expected-provider-revision",
+ before["provider_revision"],
+ "--execute",
+ )
+ assert code == 0, configured
+ code, rejected = cli("verify", "--execute")
+ assert code == 1 and rejected["checks_passed"] is False, rejected
+ result = rejected["goal_acceptance_contract"]["verification"]["results"][0]
+ assert result["exit_code"] == 17 and "oracle_evidence" not in result
+ before = cli("inspect")[1]
+ criterion["validation_argv"] = [non_interpreter, "reject.py"]
+ document.write_text(json.dumps(raw))
+ code, refused = cli(
+ "configure",
+ "--document",
+ str(document),
+ "--expected-provider-revision",
+ before["provider_revision"],
+ "--execute",
+ )
+ assert code == 1, refused
+ assert refused["error"] == "goal_acceptance_invalid_request"
+ assert cli("inspect")[1] == before
+
+
+def test_pinned_direct_executable_checker_runs_from_snapshot(acceptance_goal):
+ if sys.platform == "win32":
+ pytest.skip(
+ "Direct POSIX shebang execution is not a Windows invocation contract"
+ )
+ project, document, cli, _ = acceptance_goal
+ checker = project / "checks" / "verify"
+ checker.parent.mkdir()
+ checker.write_text(
+ f"#!{sys.executable}\nfrom pathlib import Path\nassert Path('artifact.txt').read_text() == 'accepted'\n"
+ )
+ checker.chmod(0o755)
+ (project / "artifact.txt").write_text("accepted")
+ raw = json.loads(document.read_text())
+ raw["criteria"][0].update(
+ validation_argv=["checks/verify"],
+ validation_files=[
+ {
+ "path": "checks/verify",
+ "sha256": hashlib.sha256(checker.read_bytes()).hexdigest(),
+ }
+ ],
+ oracle={
+ "kind": "owner_pinned_oracle",
+ "source_id": "direct-checker",
+ "input_refs": [],
+ "output_refs": ["artifact.txt"],
+ },
+ )
+ document.write_text(json.dumps(raw))
+ before = cli("inspect")[1]
+ code, configured = cli(
+ "configure",
+ "--document",
+ str(document),
+ "--expected-provider-revision",
+ before["provider_revision"],
+ "--execute",
+ )
+ assert code == 0, configured
+ code, checked = cli("verify", "--execute")
+ assert code == 0 and checked["checks_passed"] is True, checked
+ assert (
+ "oracle_evidence"
+ in checked["goal_acceptance_contract"]["verification"]["results"][0]
+ )
+ assert (
+ cli("inspect")[1]["goal_acceptance_contract"]["criteria"][0]["oracle"]["state"]
+ == "current"
+ )
diff --git a/tests/control_plane/test_goal_acceptance_prepared_action.py b/tests/control_plane/test_goal_acceptance_prepared_action.py
new file mode 100644
index 0000000000..265853dc1a
--- /dev/null
+++ b/tests/control_plane/test_goal_acceptance_prepared_action.py
@@ -0,0 +1,141 @@
+"""Agent preparation -> one local-owner check; real File/SQLite authority."""
+
+import json
+from concurrent.futures import ThreadPoolExecutor
+
+import pytest
+
+from loopx.chat_actions import ChatActionService
+from loopx.chat_action_store import ActionConflictError, ChatActionStore
+from loopx.control_plane.goals import acceptance_actions
+from test_goal_acceptance_cli import acceptance_goal as acceptance_goal
+
+
+def prepare(acceptance_goal):
+ project, document, cli, _ = acceptance_goal
+ raw = json.loads(document.read_text())
+ # The Agent prepares the public-safe runtime alias, never a machine path.
+ raw["criteria"][0]["validation_argv"] = ["python3", "check.py"]
+ (project / "check.py").write_text(
+ "from pathlib import Path\n"
+ "p=Path('runs.txt')\np.write_text(p.read_text()+'x' if p.exists() else 'x')\n"
+ "assert Path('artifact.txt').read_text() == 'accepted'\n"
+ )
+ document.write_text(json.dumps(raw))
+ (project / "artifact.txt").write_text("accepted")
+ code, result = cli("prepare", "--agent-id", "agent-a", "--document", str(document),
+ "--operation-id", "prepare-check", "--execute")
+ assert code == 0, result
+ root = project.parent
+ service = ChatActionService(store=ChatActionStore(root / "runtime/chat/actions"),
+ registry_path=root / "registry.json")
+ return project, cli, service, result["proposal"]
+
+
+def test_agent_preparation_does_not_approve_or_execute(acceptance_goal):
+ project, cli, service, proposal = prepare(acceptance_goal)
+ assert cli("inspect")[1]["goal_acceptance_contract"] == {"enabled": False}
+ assert not (project / "runs.txt").exists()
+ assert proposal["permission_classification"] == "local_command_execution"
+ result = service.apply(proposal["proposal_id"])
+ assert result["proposal"]["status"] == "applied"
+ assert result["proposal"]["receipt"]["checks_passed"] is True
+ assert (project / "runs.txt").read_text() == "x"
+ assert cli("inspect")[1]["goal_acceptance_contract"]["status"] == "accepted"
+ assert service.apply(proposal["proposal_id"]) == result
+ assert (project / "runs.txt").read_text() == "x"
+
+
+def test_concurrent_apply_consumes_one_prepared_intent(acceptance_goal):
+ project, _, service, proposal = prepare(acceptance_goal)
+ with ThreadPoolExecutor(max_workers=2) as pool:
+ results = list(pool.map(lambda _: service.apply(proposal["proposal_id"]), range(2)))
+ assert all(r["proposal"]["status"] == "applied" for r in results)
+ assert (project / "runs.txt").read_text() == "x"
+
+
+@pytest.mark.parametrize("lost_step", ["acceptance_configured", "native_check_response"])
+def test_lost_response_recovers_native_receipt_without_rerunning(
+ acceptance_goal, monkeypatch, lost_step,
+):
+ project, _, service, proposal = prepare(acceptance_goal)
+ original_save = service.store.save_checkpoint
+ original_verify = acceptance_actions.verify_goal_acceptance
+ if lost_step == "acceptance_configured":
+ def lose_configuration(*args, **kwargs):
+ if kwargs["step"] == lost_step:
+ raise RuntimeError("lost configuration response")
+ return original_save(*args, **kwargs)
+ monkeypatch.setattr(service.store, "save_checkpoint", lose_configuration)
+ else:
+ def lose_native_receipt(**kwargs):
+ original_verify(**kwargs)
+ raise RuntimeError("lost check response")
+ monkeypatch.setattr(acceptance_actions, "verify_goal_acceptance", lose_native_receipt)
+ with pytest.raises(RuntimeError, match="lost"):
+ service.apply(proposal["proposal_id"])
+ monkeypatch.setattr(service.store, "save_checkpoint", original_save)
+ monkeypatch.setattr(acceptance_actions, "verify_goal_acceptance", original_verify)
+ result = service.apply(proposal["proposal_id"])
+ assert result["proposal"]["status"] == "applied"
+ assert (project / "runs.txt").read_text() == "x"
+
+
+def test_missing_native_receipt_does_not_authorize_automatic_retry(acceptance_goal, monkeypatch):
+ project, cli, service, proposal = prepare(acceptance_goal)
+ def interrupted(**_kwargs):
+ raise RuntimeError("host interrupted after execution fence")
+ monkeypatch.setattr(acceptance_actions, "verify_goal_acceptance", interrupted)
+ with pytest.raises(RuntimeError):
+ service.apply(proposal["proposal_id"])
+ with pytest.raises(ActionConflictError, match="unconfirmed"):
+ service.apply(proposal["proposal_id"])
+ assert not (project / "runs.txt").exists()
+ assert cli("inspect")[1]["goal_acceptance_contract"]["status"] == "unverified"
+
+
+def test_stale_prepared_plan_cannot_replace_new_owner_configuration(acceptance_goal):
+ project, document, cli, _ = acceptance_goal
+ _, _, service, proposal = prepare(acceptance_goal)
+ before = cli("inspect")[1]
+ code, configured = cli("configure", "--document", str(document),
+ "--expected-provider-revision", before["provider_revision"],
+ "--operation-id", "another-owner-change", "--execute")
+ assert code == 0, configured
+ result = service.apply(proposal["proposal_id"])
+ assert result["proposal"]["status"] == "stale"
+ assert not (project / "runs.txt").exists()
+
+
+def test_prepare_dry_run_and_unknown_agent_do_not_write(acceptance_goal):
+ project, document, cli, _ = acceptance_goal
+ before = cli("inspect")[1]
+ code, preview = cli("prepare", "--agent-id", "agent-a", "--document", str(document),
+ "--operation-id", "read-only-preparation")
+ assert code == 0 and preview["executed"] is False and preview["prepared"] is False
+ assert cli("inspect")[1] == before
+ assert not (project.parent / "runtime/chat/actions").exists()
+ code, rejected = cli("prepare", "--agent-id", "unknown", "--document", str(document),
+ "--operation-id", "unknown-preparation", "--execute")
+ assert code != 0 and rejected["ok"] is False
+ assert cli("inspect")[1] == before
+
+
+def test_check_failure_is_recorded_without_goal_completion(acceptance_goal):
+ project, cli, service, proposal = prepare(acceptance_goal)
+ (project / "artifact.txt").write_text("not accepted")
+ applied = service.apply(proposal["proposal_id"])["proposal"]
+ assert applied["status"] == "applied"
+ assert applied["receipt"]["checks_passed"] is False
+ assert cli("inspect")[1]["goal_acceptance_contract"]["status"] == "failed"
+ assert (project / "runs.txt").read_text() == "x"
+
+
+def test_prepared_plan_cannot_claim_another_goal_context(acceptance_goal):
+ project, cli, service, proposal = prepare(acceptance_goal)
+ with pytest.raises(ValueError, match="context must match"):
+ service.preview({"action_kind": "goal.acceptance.check", "summary": "Wrong context",
+ "idempotency_key": "wrong-context", "context": {"kind": "goal", "goal_id": "other"},
+ "normalized_parameters": proposal["normalized_parameters"]})
+ assert cli("inspect")[1]["goal_acceptance_contract"]["enabled"] is False
+ assert not (project / "runs.txt").exists()
diff --git a/tests/control_plane/test_lifecycle_guard_reentry.py b/tests/control_plane/test_lifecycle_guard_reentry.py
new file mode 100644
index 0000000000..ffae88862c
--- /dev/null
+++ b/tests/control_plane/test_lifecycle_guard_reentry.py
@@ -0,0 +1,110 @@
+"""A bounded display must not strand an admitted lifecycle recovery."""
+from __future__ import annotations
+
+import json
+from hashlib import sha256
+from pathlib import Path
+
+import pytest
+
+from test_replan_successor_guard_reentry import AGENT, GOAL, _fixture, _guard
+
+
+@pytest.mark.parametrize("provider", ["file", "sqlite"])
+@pytest.mark.parametrize("later_user_gate", [False, True])
+def test_hidden_completed_source_recovers_its_original_guard_after_new_work(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, later_user_gate: bool,
+) -> None:
+ original_id = "todo_original_lifecycle"
+ completion_key = "local_completion_" + sha256(
+ f"loopx-local-todo-completion-v0\0{GOAL}\0{original_id}".encode()
+ ).hexdigest()[:32]
+ completed = {
+ "schema_version": "todo_item_v0", "archive_state": "active", "source_section": "Agent Todo",
+ "todo_id": original_id, "role": "agent", "index": 1,
+ "text": "Record the independently accepted maintenance outcome.",
+ "status": "done", "done": True, "task_class": "advancement_task",
+ "claimed_by": AGENT, "last_actor_agent_id": AGENT,
+ "note": "The finite maintenance outcome was independently accepted.",
+ "completed_at": "2026-08-01T00:00:00Z", "updated_at": "2026-08-01T00:00:00Z",
+ "completion_turn_key": completion_key, "completion_continuation": "active_goal",
+ }
+ # Enough newer terminal rows to move the original outside display budgets.
+ recent = [{**completed, "todo_id": f"todo_later_{i}", "index": i + 2,
+ "text": f"Accepted independent source {i}.", "no_followup": True,
+ "completion_continuation": "no_followup", "completion_turn_key": f"later-{i}",
+ "completed_at": "2026-08-02T00:00:00Z", "updated_at": "2026-08-02T00:00:00Z"}
+ for i in range(24)]
+ watch = {"schema_version": "todo_item_v0", "archive_state": "active", "source_section": "Agent Todo",
+ "todo_id": "todo_future_watch", "role": "agent", "index": 30,
+ "text": "Observe the pending independent source.", "status": "open", "done": False,
+ "task_class": "continuous_monitor", "claimed_by": AGENT,
+ "action_kind": "monitor", "target_key": "independent-source",
+ "cadence": "30m", "next_due_at": "2099-01-01T00:00:00Z",
+ "expires_at": "2099-01-02T00:00:00Z"}
+ call, runtime, index = _fixture(tmp_path, monkeypatch, provider,
+ initial_todos=[completed, *recent, watch])
+ index.write_text("")
+ turn = "turn-admitted-lifecycle-recovery"
+ admitted = _guard(call, turn)
+ identity = admitted["heartbeat_receipt"]["settlement_identity"]
+ obligation = admitted["autonomous_replan_obligation"]
+ assert obligation["resolution_mode"] == "todo_lifecycle_settlement"
+ assert [row["todo_id"] for row in obligation["triggers"]] == [original_id]
+
+ closed = call("todo", "complete", "--goal-id", GOAL, "--todo-id", original_id,
+ "--agent-id", AGENT, "--completion-identity-key", completion_key,
+ "--no-follow-up", "--note", "The finite maintenance outcome was independently accepted.")
+ assert closed["completion_recovery"] == "lifecycle_reentry_terminal_closeout"
+ # A later human request owns a different live frontier, not this old review.
+ later = call("todo", "add", "--goal-id", GOAL, "--role", "agent",
+ "--claimed-by", AGENT, "--text", "Validate the newly requested independent source.",
+ "--task-class", "advancement_task", "--action-kind", "validate",
+ "--target-key", "new-independent-source")
+ user_gate = None
+ if later_user_gate:
+ user_gate = call("todo", "add", "--goal-id", GOAL, "--role", "user",
+ "--agent-id", AGENT, "--task-class", "user_gate", "--global-gate",
+ "--text", "Authorize the separate newly requested source.")
+
+ recovered = _guard(call, turn)
+ assert recovered["heartbeat_receipt"]["settlement_identity"] == identity
+ assert recovered.get("selected_todo") is None
+ assert recovered["autonomous_replan_obligation"]["resolution_mode"] == "receipt_bound_replan_settlement"
+ assert recovered["replan_action_packet"]["decision"] == "vision_outcome_checkpoint_required"
+ assert recovered["replan_action_packet"]["semantic_delta"] is None
+ denied = call("quota", "spend-slot", "--goal-id", GOAL, "--agent-id", AGENT,
+ "--codex-app", "--turn-instance-id", turn, "--replan-obligation-id",
+ obligation["obligation_id"], "--source", "heartbeat", "--execute", expected_code=1)
+ assert denied.get("appended") is not True
+
+ vision = tmp_path / "accepted-review.json"
+ vision.write_text(json.dumps({"schema_version": "goal_vision_replan_contract_v0",
+ "state": "vision_patch_proposed", "vision_patch": {
+ "acceptance_summary": "Finite maintenance is verified; the independent new source remains open."},
+ "path_delta": {"schema_version": "goal_path_delta_v0", "outcome": "continue",
+ "prior_assumption": "Finite maintenance needed a terminal disposition.",
+ "observed_reality": "Its original outcome and no-follow-up were verified; new work remains independent.",
+ "retained": ["The newly requested source requires its own admission."],
+ "evidence_refs": ["evidence:accepted-maintenance"]}}))
+ refreshed = call("refresh-state", "--goal-id", GOAL, "--agent-id", AGENT,
+ "--turn-instance-id", turn, "--replan-obligation-id", obligation["obligation_id"],
+ "--classification", "verified_lifecycle_outcome", "--delivery-batch-scale", "single_surface",
+ "--delivery-outcome", "outcome_progress", "--agent-vision-json", str(vision),
+ "--no-global-sync", "--suppress-external-sinks")
+ assert refreshed["settlement_progress"]["state"] == "spend_required"
+ spend = ("quota", "spend-slot", "--goal-id", GOAL, "--agent-id", AGENT,
+ "--codex-app", "--turn-instance-id", turn, "--replan-obligation-id",
+ obligation["obligation_id"], "--source", "heartbeat", "--execute")
+ assert call(*spend)["settlement_progress"]["state"] == "settled"
+ assert call(*spend)["appended"] is False
+ assert call("todo", "list", "--goal-id", GOAL, "--todo-id", later["todo_id"])["todo"]["status"] == "open"
+ if user_gate:
+ assert call("todo", "list", "--goal-id", GOAL, "--todo-id", user_gate["todo_id"])["todo"]["status"] == "open"
+ fresh = _guard(call, "turn-independent-source-after-lifecycle")
+ interaction = fresh["interaction_contract"]
+ assert interaction["agent_channel"]["delivery_allowed"] is False
+ assert interaction["agent_channel"]["must_attempt"] is False
+ assert interaction["response_plan"]["decision"] == "ask_user"
+ assert len([row for row in index.read_text().splitlines()
+ if json.loads(row).get("classification") == "quota_slot_spent"]) == 1
diff --git a/tests/control_plane/test_monitor_poll_transaction_recovery.py b/tests/control_plane/test_monitor_poll_transaction_recovery.py
index d54f27693f..8931387ac0 100644
--- a/tests/control_plane/test_monitor_poll_transaction_recovery.py
+++ b/tests/control_plane/test_monitor_poll_transaction_recovery.py
@@ -233,6 +233,8 @@ def test_original_receipt_replays_before_fresh_admission_without_effects(
{"next_user_todo": "Request a different review"},
{"agent_id": "another-agent"},
{"result_hash": "a-different-observation"},
+ {"next_agent_priority": "P0"},
+ {"next_user_priority": "P3"},
])
def test_historical_receipt_rejects_changed_identity(tmp_path: Path, promoted, change) -> None:
registry, runtime, state = _fixture(tmp_path)
diff --git a/tests/control_plane/test_monitor_successor_validation.py b/tests/control_plane/test_monitor_successor_validation.py
index b8509c822e..beeead5da4 100644
--- a/tests/control_plane/test_monitor_successor_validation.py
+++ b/tests/control_plane/test_monitor_successor_validation.py
@@ -10,6 +10,9 @@
{"next_required_capabilities": ["filesystem-write", "not/a/capability"]},
{"next_claimed_by": "invalid/actor"},
{"material_change": False},
+ {"next_agent_priority": "P5"},
+ {"next_agent_priority": "P0", "next_agent_todo": "[P1] Conflicting task"},
+ {"next_user_priority": "P0"},
])
def test_invalid_successor_intent_does_not_partially_update_monitor(tmp_path, invalid):
registry, runtime, state = _write_fixture(tmp_path)
@@ -19,8 +22,8 @@ def test_invalid_successor_intent_does_not_partially_update_monitor(tmp_path, in
write_monitor_poll_todo_state(registry_path=registry, runtime_root=runtime,
goal_id=GOAL_ID, todo_id=monitor["todo_id"], execute=True,
generated_at="2026-09-09T12:00:00Z", result_hash="revision-a", agent_id=AGENT_ID,
- next_agent_todo="Validate the material change.", next_action_kind="validate",
- **{"material_change": True, **invalid})
+ next_action_kind="validate",
+ **{"material_change": True, "next_agent_todo": "Validate the material change.", **invalid})
assert state.read_bytes() == before
@@ -41,3 +44,54 @@ def test_public_monitor_poll_accepts_repository_and_action_aliases(tmp_path):
assert successor["required_capabilities"] == ["file__write"]
assert successor["task_class"] == "advancement_task"
assert successor["unblocks_todo_id"] == monitor["todo_id"]
+
+
+@pytest.mark.parametrize("provider", ["file", "sqlite"])
+@pytest.mark.parametrize("explicit", [False, True])
+def test_monitor_priority_cli_and_readback(tmp_path, monkeypatch, provider, explicit):
+ from canonical_authority_fixture import isolate_sqlite_runtime
+ from test_native_monitor_poll import _canonical
+ from loopx.todos import update_goal_todo, list_goal_todos
+
+ isolate_sqlite_runtime(tmp_path, monkeypatch)
+ registry, runtime, _, monitor = _canonical(tmp_path, provider=provider)
+ update_goal_todo(registry_path=registry, runtime_root_arg=str(runtime), goal_id=GOAL_ID,
+ todo_id=monitor["todo_id"], agent_id=AGENT_ID, priority="P0")
+ flags = ["--next-agent-priority", "P2", "--next-user-priority", "P3"] if explicit else []
+ result = run_json_cli("quota", "monitor-poll", "--goal-id", GOAL_ID,
+ "--agent-id", AGENT_ID, "--runtime-profile", "generic_cli",
+ "--todo-id", monitor["todo_id"], "--result-hash", "priority-change", "--material-change",
+ "--next-agent-todo", "Validate the transition", "--next-action-kind", "validate",
+ "--next-user-todo", "Review the result", "--next-user-task-class", "user_action",
+ *flags, "--execute", registry_path=registry, runtime_root=runtime)
+ writeback = result["todo_writeback"]
+ expected = {"agent": "P2", "user": "P3"} if explicit else {"agent": "P1", "user": "P1"}
+ for created, receipt in zip(writeback["next_todos"], writeback["successor_receipts"], strict=True):
+ priority = expected[created["role"]]
+ assert created["priority"] == receipt["priority"] == priority
+ current = list_goal_todos(registry_path=registry, runtime_root_arg=str(runtime),
+ goal_id=GOAL_ID, role=created["role"])["todos"]
+ assert next(row for row in current if row["todo_id"] == created["todo_id"])["priority"] == priority
+
+
+@pytest.mark.parametrize("provider", ["file", "sqlite"])
+def test_monitor_priority_retry_keeps_original_intent(tmp_path, monkeypatch, provider):
+ from canonical_authority_fixture import isolate_sqlite_runtime
+ from test_native_monitor_poll import _canonical
+
+ isolate_sqlite_runtime(tmp_path, monkeypatch)
+ registry, runtime, _, monitor = _canonical(tmp_path, provider=provider)
+ args = dict(registry_path=registry, runtime_root=runtime, goal_id=GOAL_ID,
+ todo_id=monitor["todo_id"], agent_id=AGENT_ID, execute=True,
+ generated_at="2026-09-09T12:00:00Z", result_hash="priority-a", material_change=True,
+ monitor_effect_id="priority-once", next_agent_todo="Repair transition",
+ next_action_kind="repair", next_agent_priority="P0")
+ first = write_monitor_poll_todo_state(**args)
+ replay = write_monitor_poll_todo_state(**args)
+ assert replay["provider_replayed"] is True
+ fields = ("todo_id", "role", "todo", "priority", "action_kind", "target_key")
+ assert [{k: row.get(k) for k in fields} for row in replay["next_todos"]] == [
+ {k: row.get(k) for k in fields} for row in first["next_todos"]]
+ assert first["next_todos"][0]["priority"] == "P0"
+ with pytest.raises((ValueError, RuntimeError), match="identity|mismatch|different coordination request"):
+ write_monitor_poll_todo_state(**{**args, "next_agent_priority": "P4"})
diff --git a/tests/control_plane/test_replan_successor_guard_reentry.py b/tests/control_plane/test_replan_successor_guard_reentry.py
index 7b6317c5f3..4b9293f24e 100644
--- a/tests/control_plane/test_replan_successor_guard_reentry.py
+++ b/tests/control_plane/test_replan_successor_guard_reentry.py
@@ -24,7 +24,7 @@
ROOT = Path(loopx.__file__).resolve().parents[1]
-def _fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, *, later_vision: bool = False, future_monitor: bool = True):
+def _fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, *, later_vision: bool = False, future_monitor: bool = True, initial_todos: list[dict] | None = None):
if provider == "sqlite":
isolate_sqlite_runtime(tmp_path, monkeypatch)
project, runtime = tmp_path / "project", tmp_path / "runtime"
@@ -67,7 +67,7 @@ def _fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, *,
"coordination": {"agent_model": "peer_v1", "registered_agents": [AGENT, "different-agent"]},
}]}))
initialize_canonical_authority(runtime, GOAL, build_todo_runtime_shadow_projection(
- goal_id=GOAL, todos=[], handoff_mode="soft_claim", leases=[],
+ goal_id=GOAL, todos=initial_todos or [], handoff_mode="soft_claim", leases=[],
), state_path=state, provider=provider)
def call(*args: str, expected_code: int = 0) -> dict:
diff --git a/tests/control_plane/test_research_checkpoint_capture.py b/tests/control_plane/test_research_checkpoint_capture.py
index 86ea23ae7b..bfaf87bd51 100644
--- a/tests/control_plane/test_research_checkpoint_capture.py
+++ b/tests/control_plane/test_research_checkpoint_capture.py
@@ -253,7 +253,7 @@ def test_real_native_cursor_must_match_sealed_store_basis(tmp_path, monkeypatch,
project, runtime, registry = cli._write_fixture(tmp_path)
binding = ("--agent-id", cli.AGENT_ID, "--todo-id", cli.TODO_ID, "--turn-instance-id", cli.TURN_ID)
def run(*args):
- code, result = cli._run_cli(registry, runtime, *args)
+ code, result = cli._run_cli(registry, runtime, *args, cwd=project)
assert code == 0, result
run("quota", "should-run", "--goal-id", cli.GOAL_ID, "--codex-app", *binding)
vision = tmp_path / "vision.json"
@@ -287,6 +287,15 @@ def run(*args):
goal_ref=source_wake.goal_ref, session_profile_digest=source_wake.digest))
receipt = {"ok": True, "stage": "execute", "mode": "heartbeat", "turn_id": cli.TURN_ID,
"session": {"session_id": source}}
+ from contextlib import contextmanager
+ from loopx.hosts.codex_checkpoint import native_checkpoint_reader
+ inspections = []
+ @contextmanager
+ def observed_reader(*args, **kwargs):
+ with native_checkpoint_reader(*args, **kwargs) as reader:
+ inspections.append(reader)
+ yield reader
+ monkeypatch.setattr("loopx.hosts.codex_checkpoint.native_checkpoint_reader", observed_reader)
injected = []
def between_read_and_probe(method, params, **kwargs):
if source_moves and params["action"] == mutation_stage and not injected:
@@ -300,6 +309,10 @@ def between_read_and_probe(method, params, **kwargs):
return effect_runtime_result(method, params, **kwargs)
monkeypatch.setattr("benchmark.runtime.checkpoint.effect_runtime_result", between_read_and_probe)
capture_after_wake(env, receipt)
+ # Both full observations use one owned reader, also on rejection. The
+ # real later-Turn mutation above must remain visible through that reader.
+ assert len(inspections) == 1
+ assert inspections[0].process.poll() is not None
assert receipt["ok"] and candidate.read_text() == "source_candidate = 7\n"
assert len(calls) == (2 if source_moves is True else 1) # Static local streams only; no implicit new work.
if source_moves:
diff --git a/tests/control_plane/test_todo_priority_authoring.py b/tests/control_plane/test_todo_priority_authoring.py
index 569c9fdfa3..394142f5cd 100644
--- a/tests/control_plane/test_todo_priority_authoring.py
+++ b/tests/control_plane/test_todo_priority_authoring.py
@@ -70,8 +70,18 @@ def rows(value):
cli("todo", "update", "--todo-id", todo_id, "--clear-priority")
assert read(todo_id).get("priority") is None
assert "[P4] New task title" not in state.read_text()
- unranked = cli("todo", "add", "--role", "agent", "--text", "Investigate P0 prose without declaring priority")
- assert read(unranked["todo_id"]).get("priority") is None
+ ordinary = cli("todo", "add", "--role", "agent", "--text", "Investigate P0 prose without declaring priority")
+ assert read(ordinary["todo_id"])["priority"] == "P1"
selected = cli("quota", "should-run", "--codex-app", "--agent-id", AGENT_ID,
- "--todo-id", unranked["todo_id"], "--turn-instance-id", "priority-selection", "--scan-path", str(project))
+ "--todo-id", ordinary["todo_id"], "--turn-instance-id", "priority-selection", "--scan-path", str(project))
assert selected["should_run"] is True
+ cli("todo", "update", "--todo-id", todo_id, "--text", "Still explicitly unranked")
+ assert read(todo_id).get("priority") is None
+ create = ["todo", "add", "--role", "agent", "--text", "Default priority receipt",
+ "--operation-id", "default-priority-once"]
+ first = cli(*create)
+ assert read(first["todo_id"])["priority"] == "P1"
+ cli("todo", "update", "--todo-id", first["todo_id"], "--priority", "P4")
+ assert cli(*create)["status"] == "replayed"
+ assert read(first["todo_id"])["priority"] == "P4"
+ cli(*create, "--priority", "P0", success=False)
diff --git a/tests/control_plane/test_turn_resume_entry.py b/tests/control_plane/test_turn_resume_entry.py
new file mode 100644
index 0000000000..d516496484
--- /dev/null
+++ b/tests/control_plane/test_turn_resume_entry.py
@@ -0,0 +1,162 @@
+"""Original Turn recovery uses its journal, independently of a fresh frontier."""
+from __future__ import annotations
+
+import contextlib
+import io
+import json
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+import pytest
+
+from loopx.cli import main
+from loopx.control_plane import effect_runtime
+from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted
+from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection
+from loopx.control_plane.todos.active_state_todo_parser import parse_active_state_todos
+from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority
+from tests.test_loopx_turn_driver import (
+ _completion_host_and_validation_scripts,
+ _turn_run_once_completion_argv,
+ _write_live_fixture,
+)
+
+REPO = Path(__file__).resolve().parents[2]
+GOAL = "loopx-turn-fixture"
+
+
+@pytest.fixture
+def owned_runtime(tmp_path, monkeypatch):
+ for name in ("TMPDIR", "TEMP", "TMP"):
+ monkeypatch.setenv(name, str(tmp_path))
+ monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
+ yield
+ assert effect_runtime._runtime_dir().resolve().is_relative_to(tmp_path.resolve())
+ effect_runtime.restart_effect_runtime(wait_for_exit=True)
+
+
+def _invoke(argv):
+ result = subprocess.run(
+ [sys.executable, "-m", "loopx.entrypoint", *argv],
+ cwd=REPO, text=True, capture_output=True, timeout=60,
+ )
+ assert not result.stderr, result.stderr
+ return result.returncode, json.loads(result.stdout)
+
+
+def _committed_turn(root, provider):
+ project, runtime, registry = _write_live_fixture(root)
+ state = project / ".codex/goals" / GOAL / "ACTIVE_GOAL_STATE.md"
+ todos = parse_active_state_todos(state.read_text(), item_limit=None)["agent_todos"]["items"]
+ initialize_canonical_authority(
+ runtime, GOAL, build_todo_runtime_shadow_projection(goal_id=GOAL, todos=todos, handoff_mode="soft_claim"),
+ state_path=state, provider=provider,
+ )
+ host, validator = _completion_host_and_validation_scripts()
+ host = host.replace("request = json.load(sys.stdin)",
+ 'request = json.load(sys.stdin)\npathlib.Path("host-calls").open("a").write("host\\n")')
+ argv = _turn_run_once_completion_argv(project, runtime, registry, host, validator)
+ code, completed = _invoke(argv)
+ assert code == 0 and completed["status"] == "committed", completed
+ return argv, project, runtime, registry, completed["resume_turn_key"]
+
+
+def _grow_backlog(runtime, count):
+ todos = build_todo_runtime_shadow_projection(goal_id=GOAL, todos=[{
+ "schema_version": "todo_item_v0", "todo_id": f"todo_backlog_{index:05d}",
+ "index": index + 2, "role": "agent", "status": "open", "done": False,
+ "text": f"[P2] Additional work {index}", "priority": "P2",
+ "claimed_by": "codex-fixture", "task_class": "advancement_task",
+ "archive_state": "active", "source_section": "Agent Todo",
+ } for index in range(count)])["todos"]
+ # Exercise real provider CAS while retaining the original completion and receipts.
+ script = (
+ f"import {{openLocalAuthorityStore}} from {json.dumps((REPO / 'loopx/control_plane/coordination/local_authority_provider.ts').as_uri())};"
+ f"import {{reduceCoordinationProjection}} from {json.dumps((REPO / 'loopx/control_plane/coordination/coordination_projection.ts').as_uri())};"
+ "let raw='';for await(const chunk of process.stdin)raw+=chunk;const v=JSON.parse(raw);"
+ "const store=await openLocalAuthorityStore(v.root,v.goal);const current=await store.loadAuthority();"
+ "if(current.status!=='loaded')throw Error('fixture missing');"
+ "const next=reduceCoordinationProjection(current.head,v.goal,v.todos.map(todo=>({kind:'todo_upsert',todo})));"
+ "const result=await store.commitAuthority({expected_provider_revision:current.provider_revision,"
+ "operation_id:'fixture-backlog-growth',events:[],next_projection:next,receipts:[]});"
+ "if(result.status!=='applied')throw Error(JSON.stringify(result));"
+ )
+ subprocess.run(
+ ["node", "--no-warnings", "--experimental-strip-types", "--input-type=module", "-e", script],
+ input=json.dumps({"root": str(runtime), "goal": GOAL, "todos": todos}),
+ text=True, capture_output=True, check=True, timeout=45,
+ )
+
+
+@pytest.mark.parametrize("provider", ["file", "sqlite"])
+def test_committed_replay_survives_frontier_growth_beyond_fresh_request_limit(tmp_path, owned_runtime, provider):
+ argv, project, runtime, _registry, key = _committed_turn(tmp_path, provider)
+ journal = runtime / "goals" / GOAL / "turns" / (key.removeprefix("sha256:") + ".json")
+ ledger = runtime / "goals" / GOAL / "runs/index.jsonl"
+ original_journal, original_ledger = journal.read_bytes(), ledger.read_bytes()
+ _grow_backlog(runtime, 2000)
+
+ code, replay = _invoke([*argv, "--resume-turn-key", key])
+
+ assert code == 0, replay
+ assert replay["status"] == "committed" and replay["replayed"] is True
+ assert not any(replay["effects"].values())
+ assert (project / "host-calls").read_text() == "host\n"
+ assert journal.read_bytes() == original_journal
+ assert ledger.read_bytes() == original_ledger
+ source = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL)
+ assert source is not None
+ assert len(source["todos"]) == 2001
+ assert next(todo for todo in source["todos"] if todo["todo_id"] == "todo_fixture0001")["status"] == "done"
+
+
+def test_original_replay_does_not_sync_a_new_inbox_but_fresh_turn_still_does(tmp_path, owned_runtime, monkeypatch):
+ argv, project, runtime, registry, key = _committed_turn(tmp_path, "file")
+ code, installed = _invoke([
+ "--runtime-root", str(runtime), "--format", "json",
+ "extension", "install", "--bundled", "loopx-lark", "--execute",
+ ])
+ assert code == 0, installed
+ registered = json.loads(registry.read_text())
+ registered["goals"][0].setdefault("control_plane", {})["lark_event_inbox"] = {
+ "enabled": True, "config_path": ".loopx/config/inbox.json",
+ }
+ registry.write_text(json.dumps(registered))
+ config = project / ".loopx/config/inbox.json"
+ config.parent.mkdir(parents=True, exist_ok=True)
+ config.write_text(json.dumps({
+ "schema_version": "lark_event_inbox_config_v0", "enabled": True,
+ "inbox_dir": ".loopx/inbox/requirements", "capture_scope": "configured_chat_all",
+ "topic_root_message_id": "om_fixture_root",
+ "material_review": {"enabled": False, "drain_limit": 20},
+ "reply": {
+ "enabled": True, "sender_profile": "fixture-bot", "sender_identity": "bot",
+ "bot_display_name": "Fixture Bot", "chat_id": "oc_fixture",
+ },
+ }))
+ synchronized = project / "new-inbox-sync"
+
+ def external_inbox_sync(**_kwargs):
+ # Substitute provider I/O; retain real registration, activation and dispatch.
+ synchronized.write_text("new inbox read")
+ return {
+ "ok": True, "status": "empty", "observation_count": 0,
+ "agent_read_required": False, "external_reads_performed": True,
+ "external_writes_performed": False, "local_private_state_mutated": False,
+ "error_code": None,
+ }
+
+ monkeypatch.setattr("loopx.cli_commands.lark_inbox.sync_lark_turn_start_inbox", external_inbox_sync)
+ output = io.StringIO()
+ with contextlib.redirect_stdout(output):
+ code = main([*argv, "--resume-turn-key", key])
+ replay = json.loads(output.getvalue())
+ assert code == 0 and replay["replayed"] is True, replay
+ assert not synchronized.exists()
+ assert not any(replay["effects"].values())
+ assert (project / "host-calls").read_text() == "host\n"
+ with contextlib.redirect_stdout(io.StringIO()):
+ main(argv)
+ assert synchronized.read_text() == "new inbox read"
diff --git a/tests/control_plane_ts/content_digest_single_owner.test.ts b/tests/control_plane_ts/content_digest_single_owner.test.ts
index 5d36220968..89abeb040f 100644
--- a/tests/control_plane_ts/content_digest_single_owner.test.ts
+++ b/tests/control_plane_ts/content_digest_single_owner.test.ts
@@ -124,6 +124,7 @@ const CANONICAL_CONSUMERS = [
"control_plane/effect_runtime_snapshot.ts",
"control_plane/goals/acceptance_authority.ts",
"control_plane/goals/acceptance_contract.ts",
+ "control_plane/goals/acceptance_oracle.ts",
"control_plane/goals/direction_authority.ts",
"control_plane/goals/goal_amendment_authority.ts",
"control_plane/goals/goal_amendment_proposal.ts",
diff --git a/tests/control_plane_ts/effect_runtime_io.test.ts b/tests/control_plane_ts/effect_runtime_io.test.ts
index 46fd3fb1f6..31bcc94441 100644
--- a/tests/control_plane_ts/effect_runtime_io.test.ts
+++ b/tests/control_plane_ts/effect_runtime_io.test.ts
@@ -20,6 +20,77 @@ async function workspace(t: TestContext): Promise {
return root;
}
+for (const scenario of [
+ {name: "Windows replacement open recovers", platform: "win32", contended: true, persistent: false, writeFault: false, timeout: 500, code: "EPERM", expected: null},
+ {name: "Windows initial permission denial fails", platform: "win32", contended: false, persistent: true, writeFault: false, timeout: 500, code: "EPERM", expected: "EPERM"},
+ {name: "Windows persistent replacement denial keeps its error", platform: "win32", contended: true, persistent: true, writeFault: false, timeout: 250, code: "EPERM", expected: "EPERM"},
+ {name: "non-Windows replacement denial fails", platform: "linux", contended: true, persistent: false, writeFault: false, timeout: 500, code: "EPERM", expected: "EPERM"},
+ {name: "unrelated Windows open failure fails", platform: "win32", contended: true, persistent: false, writeFault: false, timeout: 500, code: "EIO", expected: "EIO"},
+ {name: "Windows failure after creation is not retried", platform: "win32", contended: true, persistent: false, writeFault: true, timeout: 500, code: "EPERM", expected: "EPERM"},
+ {name: "zero-wait live contention does not gain a retry", platform: "win32", contended: true, persistent: false, writeFault: false, timeout: 0, code: "EPERM", expected: "mutation_lock_timeout"},
+]) {
+ test(scenario.name, async t => {
+ const root = await workspace(t), target = join(root, "state");
+ // Only the selected open/write failure and platform predicate are injected.
+ // All lock bytes, handles, identities, live-owner checks and release use
+ // the production filesystem owner in an isolated child.
+ const script = `
+ import assert from "node:assert/strict";
+ import * as fs from "node:fs/promises";
+ import {mock} from "node:test";
+ const [module, target, scenario] = JSON.parse(process.argv[1]);
+ const path = target + ".ts-effect.lock";
+ let attempts = 0;
+ if (scenario.contended) {
+ await fs.writeFile(path, JSON.stringify({pid: process.pid, token: "live-holder"}));
+ }
+ const {default: ignored, ...namedExports} = fs;
+ mock.module("node:fs/promises", {namedExports: {...namedExports,
+ open: async (file, flags, ...args) => {
+ if (file !== path || flags !== "wx") return fs.open(file, flags, ...args);
+ attempts++;
+ if (scenario.contended && attempts === 1) return fs.open(file, flags, ...args);
+ if (scenario.contended && attempts === 2) await fs.rm(path);
+ const fault = Object.assign(new Error("controlled filesystem failure"),
+ {code: scenario.code, syscall: scenario.writeFault ? "write" : "open"});
+ if (!scenario.writeFault && (scenario.persistent || attempts === 2)) throw fault;
+ const handle = await fs.open(file, flags, ...args);
+ if (scenario.writeFault) handle.writeFile = async () => { throw fault; };
+ return handle;
+ },
+ }});
+ const io = await import(module);
+ Object.defineProperty(process, "platform", {value: scenario.platform});
+ let code = null;
+ const started = Date.now(); // Match the acquisition deadline's clock.
+ try {
+ const lock = await io.acquireFileMutationLock(target, process.pid, scenario.timeout);
+ assert.equal((await io.mutationLockOwner(target)).token, lock.token);
+ assert.equal(await io.releaseFileMutationLock(target, lock.token), true);
+ await assert.rejects(fs.stat(path), {code: "ENOENT"});
+ } catch (error) { code = error.code ?? String(error); }
+ if (scenario.writeFault) await assert.rejects(fs.stat(path), {code: "ENOENT"});
+ console.log(JSON.stringify({code, attempts, elapsed: Date.now() - started}));
+ `;
+ const result = spawnSync(process.execPath, [
+ "--no-warnings", "--experimental-test-module-mocks", "--experimental-strip-types",
+ "--input-type=module", "-e", script,
+ JSON.stringify([new URL("../../loopx/control_plane/effect_runtime_io.ts", import.meta.url).href,
+ target, scenario]),
+ ], {encoding: "utf8", timeout: 10_000});
+ assert.equal(result.status, 0, result.stderr);
+ const observed = JSON.parse(result.stdout.trim());
+ assert.equal(observed.code, scenario.expected);
+ if (scenario.expected === null) assert.equal(observed.attempts, 3);
+ else if (!scenario.contended || scenario.timeout === 0) assert.equal(observed.attempts, 1);
+ else if (scenario.persistent) {
+ assert.ok(observed.attempts > 2);
+ assert.ok(observed.elapsed >= scenario.timeout);
+ assert.ok(observed.elapsed < 2_000, "permission denial must keep a bounded wait");
+ } else assert.equal(observed.attempts, 2);
+ });
+}
+
test("JSONL append starts a new row after an unterminated tail", async t => {
const root = await workspace(t);
const path = join(root, "index.jsonl");
diff --git a/tests/control_plane_ts/goal_acceptance_authority.test.ts b/tests/control_plane_ts/goal_acceptance_authority.test.ts
index 31e4fac292..28501f092c 100644
--- a/tests/control_plane_ts/goal_acceptance_authority.test.ts
+++ b/tests/control_plane_ts/goal_acceptance_authority.test.ts
@@ -26,6 +26,8 @@ import {commitGoalAcceptanceVerification, commitLocalGoalAcceptance, commitLocal
import {decodeCompletionValidationRevision, planCompletionValidationRevision}
from "../../loopx/control_plane/todos/completion_validation_revision.ts";
+import {acceptanceOracleBasis} from "../../loopx/control_plane/goals/acceptance_oracle.ts";
+
const goal = "goal-acceptance-test";
const goalA = {goal_id: goal, goal_instance_id: "ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"};
const goalB = {goal_id: goal, goal_instance_id: "ginst_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"};
@@ -231,6 +233,35 @@ async function fixture(t: TestContext, provider: typeof providers[number]): Prom
for (const provider of providers) {
const options = {skip: (provider === "postgresql" && !process.env.LOOPX_TEST_POSTGRES_URL) ||
(provider === "sqlite" && !sqliteQualified)};
+ test(`${provider}: optional oracle source and exact versions survive canonical commit and revoke on changed work`, options, async t => {
+ const store = await fixture(t, provider); await seed(store);
+ const doc = normalizeGoalAcceptanceDocument({scope: {kind: "selected_work", todo_ids: ["todo_first"]}, objective: "Check exact output", non_goals: [],
+ bindings: [{todo_id: "todo_first", criterion_ids: ["oracle"]}], criteria: [{id: "oracle", description: "Owner-approved checker",
+ validation_argv: ["python3", "check.py"], validation_files: [{path: "check.py", sha256: "a".repeat(64)}],
+ oracle: {kind: "owner_pinned_oracle", source_id: "owner-checker", input_refs: [], output_refs: ["output.txt"]}}]});
+ assert.equal((await configureGoalAcceptance(store, await configureRequest(store, {document: doc}))).status, "applied");
+ const beforeInvalidInvocation = await head(store);
+ await assert.rejects(configureGoalAcceptance(store, await configureRequest(store, {document: {
+ ...doc, criteria: [{...doc.criteria[0], validation_argv: ["/usr/bin/true", "check.py"]}],
+ }})), /oracle requires/);
+ assert.deepEqual(await head(store), beforeInvalidInvocation, "rejected source invocation must not write canonical state");
+ const evidence = {source_id: "owner-checker", source_digest: acceptanceOracleBasis(doc.criteria[0]), checked_at: "2026-01-01T00:00:00Z",
+ input_versions: [], output_versions: [{ref: "output.txt", sha256: "b".repeat(64)}]};
+ const results = [{criterion_id: "oracle", passed: true, exit_code: 0, oracle_evidence: evidence}];
+ await assert.rejects(commitGoalAcceptanceVerification(store, await verifyRequest(store, {actor_agent_id: "worker", results})));
+ assert.equal((await commitGoalAcceptanceVerification(store, await verifyRequest(store, {results}))).status, "applied");
+ const view = (await inspectGoalAcceptance(store, goal)).goal_acceptance_contract as JsonObject;
+ assert.deepEqual(((view.verification as JsonObject).results as JsonObject[])[0].oracle_evidence, evidence);
+ assert.equal((((view.criteria as JsonObject[])[0]).oracle as JsonObject).state, "recorded", "canonical rows alone do not attest current filesystem bytes");
+ await update(store, "todo_first", {text: "Changed output requirement"});
+ const stale = (await inspectGoalAcceptance(store, goal)).goal_acceptance_contract as JsonObject;
+ assert.equal((((stale.criteria as JsonObject[])[0]).oracle as JsonObject).state, "stale");
+ const ordinary = {...doc, criteria: doc.criteria.map(({oracle: _oracle, ...criterion}) => criterion)};
+ assert.equal((await configureGoalAcceptance(store, await configureRequest(store, {document: ordinary}))).status, "applied");
+ const rolledBack = (await inspectGoalAcceptance(store, goal)).goal_acceptance_contract as JsonObject;
+ assert.equal(rolledBack.verification, null, "ordinary rollback cannot reuse an oracle success");
+ assert.equal(Object.hasOwn((rolledBack.criteria as JsonObject[])[0], "oracle"), false);
+ });
test(`${provider}: first exact acceptance mutation binds the current instance atomically`, options, async t => {
const store = await fixture(t, provider); await seed(store);
const request = await configureRequest(store, {goal_ref: goalA});
diff --git a/tests/control_plane_ts/goal_acceptance_oracle.test.ts b/tests/control_plane_ts/goal_acceptance_oracle.test.ts
new file mode 100644
index 0000000000..033e76ca6c
--- /dev/null
+++ b/tests/control_plane_ts/goal_acceptance_oracle.test.ts
@@ -0,0 +1,55 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {normalizeGoalAcceptanceDocument, normalizeAcceptanceResults} from "../../loopx/control_plane/goals/acceptance_contract.ts";
+import {acceptanceOracleBasis, validateOracleResults, observeAcceptanceOracles} from "../../loopx/control_plane/goals/acceptance_oracle.ts";
+const document = () => normalizeGoalAcceptanceDocument({scope: {kind: "selected_work", todo_ids: ["todo_work"]}, objective: "Compare exact output with its expected input", non_goals: [], bindings: [{todo_id: "todo_work", criterion_ids: ["check"]}], criteria: [{id: "check", description: "Owner supplied checker", validation_argv: ["python3", "check.py"], validation_timeout_seconds: 5, validation_files: [{path: "check.py", sha256: "a".repeat(64)}], oracle: {kind: "owner_pinned_oracle", source_id: "owner-checker", input_refs: ["input.txt"], output_refs: ["output.txt"]}}]});
+test("oracle requires a pinned invoked source and distinct explicit artifact roles", () => {
+ for (const change of [
+ {validation_files: []}, {validation_argv: ["python3", "-c", "pass"]},
+ {validation_argv: ["check.py"]},
+ {validation_argv: ["/usr/bin/true", "check.py"]},
+ {validation_argv: ["/usr/bin/cat", "check.py"]},
+ {validation_argv: ["not-python3", "check.py"]},
+ {validation_argv: ["tools/python3", "check.py"]},
+ {validation_argv: ["python3", "--", "check.py"]},
+ {validation_argv: ["python3", "-c", "pass"], validation_files: [{path: "-c", sha256: "a".repeat(64)}]},
+ {oracle: {kind: "owner_pinned_oracle", source_id: "owner-checker", input_refs: ["output.txt"], output_refs: ["output.txt"]}},
+ {oracle: {kind: "owner_pinned_oracle", source_id: "owner-checker", input_refs: [], output_refs: ["../output.txt"]}},
+ ]) {
+ const doc = document();
+ assert.throws(() => normalizeGoalAcceptanceDocument({...doc, criteria: [{...doc.criteria[0], ...change}]}));
+ }
+});
+test("oracle admits explicit Python script forms and pinned direct executables", () => {
+ const doc = document(), criterion = doc.criteria[0];
+ for (const executable of ["python", "python3", "/opt/runtime/bin/python3.13", "C:\\Python313\\python.exe"]) {
+ assert.doesNotThrow(() => normalizeGoalAcceptanceDocument({...doc, criteria: [{...criterion, validation_argv: [executable, "check.py", "--script-argument"]}]}));
+ }
+ assert.doesNotThrow(() => normalizeGoalAcceptanceDocument({...doc, criteria: [{...criterion,
+ validation_argv: ["checks/verify"], validation_files: [{path: "checks/verify", sha256: "a".repeat(64)}]}]}));
+});
+test("ordinary criteria keep their command and canonical shape outside oracle invocation rules", () => {
+ const doc = document(), {oracle: _oracle, ...criterion} = doc.criteria[0];
+ const ordinary = {...doc, criteria: [{...criterion, validation_argv: ["/usr/bin/true", "check.py"]}]};
+ assert.deepEqual(normalizeGoalAcceptanceDocument(ordinary), ordinary);
+});
+test("success cannot be supplied without the exact configured oracle and checked refs", () => {
+ const criterion = document().criteria[0];
+ const evidence = {source_id: "owner-checker", source_digest: acceptanceOracleBasis(criterion), checked_at: "2026-01-01T00:00:00Z",
+ input_versions: [{ref: "input.txt", sha256: "b".repeat(64)}], output_versions: [{ref: "output.txt", sha256: "c".repeat(64)}]};
+ const result = {criterion_id: "check", passed: true, exit_code: 0};
+ assert.throws(() => validateOracleResults([criterion], [result]));
+ for (const patch of [{source_id: "self-asserted-worker"}, {source_digest: "d".repeat(64)}, {output_versions: []}]) {
+ assert.throws(() => validateOracleResults([criterion], [{...result, oracle_evidence: {...evidence, ...patch}}]));
+ }
+ const results = normalizeAcceptanceResults([{...result, oracle_evidence: evidence}]);
+ assert.doesNotThrow(() => validateOracleResults([criterion], results));
+ assert.throws(() => validateOracleResults([{...criterion, oracle: undefined}], results));
+ const contract = {enabled: true, criteria: [{id: "check", oracle: {state: "recorded"}}], verification: {results}};
+ assert.equal((observeAcceptanceOracles({contract, observations: []}).criteria as any[])[0].oracle.state, "unavailable");
+ for (const facts of [
+ {source_current: false, ...evidence},
+ {source_current: true, ...evidence, output_versions: [{ref: "output.txt", sha256: "d".repeat(64)}]},
+ ]) assert.equal((observeAcceptanceOracles({contract, observations: [{criterion_id: "check", available: true, ...facts}]}).criteria as any[])[0].oracle.state, "stale");
+ assert.equal((observeAcceptanceOracles({contract, observations: [{criterion_id: "check", available: true, source_current: true, ...evidence}]}).criteria as any[])[0].oracle.state, "current");
+});
diff --git a/tests/control_plane_ts/goal_acceptance_runtime.test.ts b/tests/control_plane_ts/goal_acceptance_runtime.test.ts
index a49dab559a..a7d937dff0 100644
--- a/tests/control_plane_ts/goal_acceptance_runtime.test.ts
+++ b/tests/control_plane_ts/goal_acceptance_runtime.test.ts
@@ -1,3 +1,4 @@
+import {compileActionReviewPlan} from "../../loopx/control_plane/presentation/action_review_plan.ts";
import assert from "node:assert/strict";
import {randomUUID} from "node:crypto";
import {mkdtemp, rm} from "node:fs/promises";
@@ -401,3 +402,18 @@ test("a competing commit cannot separate acceptance evidence from Todo completio
assert.equal(((await loaded(store)).head.todos as JsonObject[])[0]!.done, false);
assert.equal((await store.readReceipt("complete")).status, "missing");
});
+
+
+test("prepared check review requires its exact Goal and local command effect", () => {
+ const proposal = {schema_version: "loopx_chat_action_proposal_v1", action_kind: "goal.acceptance.check",
+ status: "preview_ready", proposal_id: "proposal", expected_state_fingerprint: "current-basis",
+ permission_classification: "local_command_execution", normalized_parameters: {goal_id: "goal", document: {}},
+ context: {kind: "goal", goal_id: "goal"}, available_transitions: ["apply", "cancel"]};
+ assert.equal(compileActionReviewPlan(proposal).canApply, true);
+ for (const changed of [{permission_classification: "durable_write"},
+ {context: {kind: "goal", goal_id: "other"}}, {normalized_parameters: {goal_id: "goal"}},
+ {gate: {kind: "authority"}, status: "gated"}, {status: "stale"}]) {
+ assert.equal(compileActionReviewPlan({...proposal, ...changed}).canApply, false);
+ }
+ assert.equal(compileActionReviewPlan({...proposal, status: "applied", receipt: {projection_verified: true}}).canApply, false);
+});
diff --git a/tests/control_plane_ts/monitor_batch.test.ts b/tests/control_plane_ts/monitor_batch.test.ts
index 41cb208794..a3d11185fb 100644
--- a/tests/control_plane_ts/monitor_batch.test.ts
+++ b/tests/control_plane_ts/monitor_batch.test.ts
@@ -242,3 +242,39 @@ test("canonical fresh planning remains actor-owned", () => {
/actor to match owner/,
);
});
+
+
+test("canonical monitor discoveries default independently and accept per-role priorities", () => {
+ const source = request();
+ source.todos = source.todos.map(todo => ({...todo, priority: "P0"}));
+ for (const [extra, expected] of [
+ [{}, ["P1", "P1"]],
+ [{next_agent_priority: "P0", next_user_priority: "P3"}, ["P0", "P3"]],
+ ] as const) {
+ const input = {...source, intent: {...source.intent,
+ next_user_todo: "Review result", next_user_task_class: "user_action", ...extra}};
+ const first = planMonitorBatch(input);
+ const writeback = first.writeback;
+ assert.deepEqual((writeback.next_todos as JsonObject[]).map(todo => todo.priority), expected);
+ assert.deepEqual((writeback.successor_receipts as JsonObject[]).map(todo => todo.priority), expected);
+ }
+ for (const extra of [{next_agent_priority: "P5"}, {next_user_priority: "P0"},
+ {next_agent_priority: "P0", next_agent_todo: "[P1] Conflict"}]) {
+ assert.throws(() => planMonitorBatch({...source, intent: {...source.intent, ...extra}}));
+ }
+});
+
+test("historical unranked monitor receipts replay without applying the new default", () => {
+ const input = request();
+ const receipt = historicalReceipt(input);
+ const result = replay({...input, todos: []}, receipt);
+ assert.equal(result.replayed, true);
+ assert.deepEqual(result.mutations, []);
+ const writeback = result.writeback as JsonObject;
+ assert.equal((writeback.next_todos as JsonObject[])[0].priority, undefined);
+ assert.equal((writeback.next_todos as JsonObject[])[0].todo, "Deliver observed result");
+ for (const intent of [{...input.intent, next_agent_priority: "P0"},
+ {...input.intent, next_user_priority: "P3"}]) {
+ assert.throws(() => replay({...input, intent}, receipt), /request mismatch/);
+ }
+});
diff --git a/tests/control_plane_ts/quota_monitor_poll_commit.test.ts b/tests/control_plane_ts/quota_monitor_poll_commit.test.ts
index c7fe282049..8f252981bf 100644
--- a/tests/control_plane_ts/quota_monitor_poll_commit.test.ts
+++ b/tests/control_plane_ts/quota_monitor_poll_commit.test.ts
@@ -585,7 +585,7 @@ test("Todo commit fences the full material successor receipt", async (t) => {
target_key: "public-release:42",
result_hash: "approved-42",
material_change: true,
- next_agent_todo: "Advance the approved release.",
+ next_agent_todo: "[P1] Advance the approved release.",
next_action_kind: "ADVANCE_RELEASE",
next_task_repository: "https://github.com/owner/repo.git",
next_required_capabilities: ["filesystem-write"],
@@ -598,6 +598,7 @@ test("Todo commit fences the full material successor receipt", async (t) => {
const successor = {
todo_id: "todo_successor001",
+ priority: "P1",
role: "agent",
task_class: "advancement_task",
action_kind: "advance_release",
@@ -625,7 +626,7 @@ test("Todo commit fences the full material successor receipt", async (t) => {
todo_update: { ok: true },
next_todos: [{
...successor,
- todo: "Advance the approved release.",
+ todo: "[P1] Advance the approved release.",
}],
successor_receipts: [successor],
};
@@ -653,7 +654,7 @@ test("Todo commit fences the full material successor receipt", async (t) => {
...providerReceipt,
next_todos: [{
...successor,
- todo: "Advance the approved release.",
+ todo: "[P1] Advance the approved release.",
target_key: "public-release:42:other",
}],
},
@@ -661,6 +662,11 @@ test("Todo commit fences the full material successor receipt", async (t) => {
/agent next_todo target_key must match provider plan/,
);
+ for (const priority of ["P0", null]) {
+ await assert.rejects(() => evaluateQuotaMonitorPollCommit({...params, phase: "commit",
+ provider_receipt: {...providerReceipt, successor_receipts: [{...successor, priority}]}}),
+ /successor priority must match/);
+ }
const written = await evaluateQuotaMonitorPollCommit({
...params,
phase: "commit",
@@ -729,7 +735,7 @@ test("Todo commit rejects injected defaults in the material successor route", as
todo_id: "todo_public_monitor",
result_hash: "approved-default",
material_change: true,
- next_agent_todo: "Advance the approved release.",
+ next_agent_todo: "[P1] Advance the approved release.",
next_action_kind: "advance_release",
}),
});
@@ -738,6 +744,7 @@ test("Todo commit rejects injected defaults in the material successor route", as
.update("approved-default", "utf8").digest("hex").slice(0, 16)}`;
const successor = {
todo_id: "todo_successor002",
+ priority: "P1",
role: "agent",
task_class: "advancement_task",
action_kind: "advance_release",
@@ -760,7 +767,7 @@ test("Todo commit rejects injected defaults in the material successor route", as
next_due_at: null,
cadence: null,
todo_update: {},
- next_todos: [{ ...successor, todo: "Advance the approved release." }],
+ next_todos: [{ ...successor, todo: "[P1] Advance the approved release." }],
successor_receipts: [successor],
};
@@ -781,7 +788,7 @@ test("Todo commit rejects injected defaults in the material successor route", as
next_todos: [{
...successor,
...mutation,
- todo: "Advance the approved release.",
+ todo: "[P1] Advance the approved release.",
}],
},
}),
diff --git a/tests/control_plane_ts/replan_context.test.ts b/tests/control_plane_ts/replan_context.test.ts
index 7b344a0897..574f6bc37c 100644
--- a/tests/control_plane_ts/replan_context.test.ts
+++ b/tests/control_plane_ts/replan_context.test.ts
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import {createHash} from "node:crypto";
import {projectReplanContext, validateReplanContext} from "../../loopx/control_plane/work_items/replan_context.ts";
-import type {JsonObject} from "../../loopx/control_plane/effect_program.ts";
+import {settlementIdentityPayload, type JsonObject} from "../../loopx/control_plane/effect_program.ts";
const progress = {schema_version: "typed_progress_observation_v0", result_class: "unchanged",
surface_id: "surface-a", work_item_id: "todo-a", fingerprint: "same-work"};
@@ -130,6 +130,62 @@ function subsequent(position: number): JsonObject {
const strideRequest = {...request, stride_strategy: "evidence_conditioned_v1"};
+function autonomousReviewed(position = 0, obligation = "replan-first"): JsonObject {
+ const legacy = reviewed(position);
+ const source = legacy._source_facts as JsonObject;
+ const ack = source.autonomous_replan_ack as JsonObject;
+ const identity = settlementIdentityPayload({goal_id: "goal-a", agent_id: "agent-a",
+ turn_instance_id: String(source.turn_instance_id), replan_obligation_id: obligation});
+ return {...legacy, todo_id: undefined, _source_facts: {...source, todo_id: null,
+ replan_obligation_id: obligation, settlement_identity: identity,
+ autonomous_replan_ack: {...ack, semantic_delta: {...ack.semantic_delta, obligation_id: obligation}}}};
+}
+
+test("autonomous decisions return through their own binding and resolve without a fabricated Todo", () => {
+ const latest = autonomousReviewed(2);
+ const rows = [reviewed(), latest, subsequent(3)];
+ const context = projectReplanContext({...strideRequest, rows});
+ const follow = context.decision_followthrough as JsonObject;
+ assert.equal(follow.previous_decision.turn_instance_id, "turn-3");
+ assert.equal(follow.previous_decision.replan_obligation_id, "replan-first");
+ assert.equal(follow.previous_decision.todo_id, null);
+ assert.equal(follow.recorded_progress_count, 1);
+ assert.equal(follow.execution_and_goal_gain, "unknown");
+ assert.deepEqual(validateReplanContext(context), context);
+ const resolve = {...request, operation: "resolve", evidence_ref: follow.previous_decision.evidence_ref, rows};
+ assert.deepEqual(projectReplanContext(resolve).evidence, follow.previous_decision);
+ assert.throws(() => projectReplanContext({...resolve, rows: [autonomousReviewed(2, "replan-other")]}), /unavailable/);
+ const ordinary = projectReplanContext({...request, rows});
+ assert.deepEqual(projectReplanContext({...request, rows, stride_strategy: "off"}), ordinary);
+ assert.equal(ordinary.decision_followthrough, undefined);
+});
+
+test("autonomous followthrough rejects conflicting identities, bindings and acknowledgements", () => {
+ const valid = autonomousReviewed();
+ const source = valid._source_facts as JsonObject;
+ const identity = source.settlement_identity as JsonObject;
+ const ack = source.autonomous_replan_ack as JsonObject;
+ const corruptions = [
+ ...["schema_version", "goal_id", "agent_id", "turn_instance_id", "effect_id", "binding_kind",
+ "binding_id", "replan_obligation_id"].map(key => ({settlement_identity: {...identity, [key]: "wrong"}})),
+ {settlement_identity: {...identity, todo_id: "todo-conflict"}},
+ {todo_id: "todo-conflict"}, {goal_id: "wrong"}, {agent_id: "wrong"}, {turn_instance_id: "wrong"},
+ {replan_obligation_id: null}, {replan_obligation_id: "wrong"},
+ {autonomous_replan_ack: {...ack, recorded: false}},
+ {autonomous_replan_ack: {...ack, semantic_delta: {...ack.semantic_delta, obligation_id: "wrong"}}},
+ {autonomous_replan_ack: {...ack, semantic_delta: {...ack.semantic_delta, accepted: false}}},
+ {quota_spend_commit: {schema_version: "quota_spend_commit_receipt_v0", effect_id: identity.effect_id + "#quota_spend"}},
+ ];
+ for (const patch of corruptions) {
+ assert.equal(projectReplanContext({...strideRequest,
+ rows: [{...valid, _source_facts: {...source, ...patch}}]}).decision_followthrough, undefined, JSON.stringify(patch));
+ }
+ assert.equal(projectReplanContext({...strideRequest, rows: [{...valid, todo_id: "todo-conflict"}]}).decision_followthrough, undefined);
+ const corruptPath = autonomousReviewed(2);
+ corruptPath._source_facts.agent_vision.path_delta = {outcome: "replan"};
+ assert.equal(projectReplanContext({...strideRequest, rows: [reviewed(), corruptPath]}).decision_followthrough, undefined);
+});
+
test("a prior accepted path returns with attributable later observations, never a utility verdict", () => {
const decision = reviewed();
const later = subsequent(1);
diff --git a/tests/control_plane_ts/todo_create_reviewed.test.ts b/tests/control_plane_ts/todo_create_reviewed.test.ts
index ed956586b8..d69128580a 100644
--- a/tests/control_plane_ts/todo_create_reviewed.test.ts
+++ b/tests/control_plane_ts/todo_create_reviewed.test.ts
@@ -64,6 +64,7 @@ for (const provider of ["file", "sqlite", "postgresql"] as const) {
const current = {...request, expected_provider_revision: later.provider_revision};
const applied = await executeCoordinationTodoCreate(store, current);
assert.equal(applied.status, "applied");
+ assert.equal((applied.todo as Record).priority, "P1");
const accepted = await store.loadAuthority();
assert.equal(accepted.status, "loaded");
if (accepted.status !== "loaded") throw new Error("missing accepted head");
diff --git a/tests/control_plane_ts/todo_priority.test.ts b/tests/control_plane_ts/todo_priority.test.ts
index 6942c01e03..1798a8949e 100644
--- a/tests/control_plane_ts/todo_priority.test.ts
+++ b/tests/control_plane_ts/todo_priority.test.ts
@@ -1,10 +1,17 @@
+import {mkdtemp, rm} from "node:fs/promises";
+import {tmpdir} from "node:os";
+import {join} from "node:path";
+import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts";
+import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts";
+import {coordinationTodoReadModel} from "../../loopx/control_plane/coordination/coordination_projection.ts";
+import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts";
import type {JsonObject} from "../../loopx/control_plane/effect_program.ts";
-import {planCoordinationTodoCreate} from "../../loopx/control_plane/coordination/todo_create.ts";
+import {executeCoordinationTodoCreate, COORDINATION_TODO_CREATE_RECEIPT_SCHEMA, planCoordinationTodoCreate} from "../../loopx/control_plane/coordination/todo_create.ts";
import {TODO_DOMAIN_ITEM_SCHEMA, TODO_DOMAIN_READ_RECORD_SCHEMA} from "../../loopx/control_plane/coordination/coordination_state_contract.ts";
import {readFileSync} from "node:fs";
import assert from "node:assert/strict";
import test from "node:test";
-import {planTodoPriority, readTodoPriority, todoPriorityRank} from "../../loopx/control_plane/todos/priority.ts";
+import {planTodoCreationPriority, planTodoPriority, readTodoPriority, todoPriorityRank} from "../../loopx/control_plane/todos/priority.ts";
test("priority is declared metadata; prose is never scheduling input", () => {
assert.equal(readTodoPriority({text: "Investigate P0 parser behavior"}), null);
@@ -44,7 +51,7 @@ test("complex fixture priority vocabulary matches the compatibility codec", () =
});
-test("canonical create normalizes before duplicate matching and clears absent priority", () => {
+test("canonical create defaults omission to P1, normalizes duplicates and respects explicit null", () => {
const input = {goal_id: "priority-goal", actor_agent_id: null, registered_agents: [],
operation_id: "create-priority", dry_run: false, now: new Date("2026-09-20T00:00:00Z"),
todo: {schema_version: TODO_DOMAIN_ITEM_SCHEMA, todo_id: "new-todo", role: "agent",
@@ -60,4 +67,50 @@ test("canonical create normalizes before duplicate matching and clears absent pr
const unranked = planCoordinationTodoCreate({...input, todo: {...input.todo, priority: null}},
new Map(), TODO_DOMAIN_READ_RECORD_SCHEMA).todo as JsonObject;
assert.equal(unranked.priority, undefined); assert.equal(unranked.text, "A task");
+ const {priority: _omitted, ...todo} = input.todo;
+ const ordinary = planCoordinationTodoCreate({...input, todo},
+ new Map(), TODO_DOMAIN_READ_RECORD_SCHEMA).todo as JsonObject;
+ assert.equal(ordinary.priority, "P1");
+ assert.equal(ordinary.text, "[P1] A task");
});
+
+
+test("creation defaults never override a declared prefix or explicit clearing", () => {
+ assert.equal(planTodoCreationPriority({text: "Normal work"}).priority, "P1");
+ for (const priority of ["P0", "P1", "P2", "P3", "P4"]) {
+ assert.equal(planTodoCreationPriority({text: "Normal work", priority}).priority, priority);
+ assert.equal(planTodoCreationPriority({text: `[${priority}] Legacy work`}).priority, priority);
+ }
+ assert.equal(planTodoCreationPriority({text: "Unranked", priority: null}).priority, null);
+ assert.equal(planTodoCreationPriority({text: "Unranked", clear_priority: true}).priority, null);
+ assert.equal(planTodoPriority({text: "Historical work"}, {text: "Edited historical work"}).priority, null);
+ assert.throws(() => planTodoCreationPriority({text: "[P0] Conflict", priority: "P1"}), /conflict/);
+});
+
+
+for (const provider of ["file", "sqlite"] as const) {
+ test(`${provider}: an old creation receipt keeps its unranked result on upgrade`, async t => {
+ const root = await mkdtemp(join(tmpdir(), "loopx-priority-replay-"));
+ t.after(() => rm(root, {recursive: true, force: true}));
+ const store = provider === "file" ? new FileAuthorityStore(root, "goal-a") : new SqliteAuthorityStore(root, "goal-a");
+ const todo = {schema_version: TODO_DOMAIN_ITEM_SCHEMA, todo_id: "todo-old", role: "agent",
+ status: "open", done: false, archive_state: "active", text: "Historical unranked task"};
+ const request = {goal_id: "goal-a", operation_id: "old-create", actor_agent_id: null,
+ registered_agents: [], dry_run: false, todo, now: new Date("2026-01-01T00:00:00Z")};
+ const receipt = {schema_version: COORDINATION_TODO_CREATE_RECEIPT_SCHEMA,
+ operation_id: request.operation_id, goal_id: request.goal_id, todo_id: todo.todo_id, todo,
+ request_sha256: canonicalAuthoritySha256({goal_id: request.goal_id, todo,
+ actor_agent_id: null, dry_run: false})};
+ assert.equal((await store.commitAuthority({operation_id: "old-create", expected_provider_revision: null,
+ events: [], receipts: [receipt], next_projection: {goal_id: "goal-a", handoff_mode: "soft_claim",
+ todos: [todo], leases: [], todo_read_model: coordinationTodoReadModel([todo], TODO_DOMAIN_READ_RECORD_SCHEMA)}})).status, "applied");
+ const before = await store.loadAuthority();
+ const result = await executeCoordinationTodoCreate(store, request,
+ async () => {throw new Error("receipt replay must precede current-source admission");});
+ assert.equal(result.status, "replayed");
+ assert.deepEqual(result.todo, todo);
+ assert.deepEqual(await store.loadAuthority(), before);
+ const changed = await executeCoordinationTodoCreate(store, {...request, todo: {...todo, priority: "P0"}});
+ assert.equal(changed.reason_code, "coordination_operation_identity_mismatch");
+ });
+}
diff --git a/tests/opencode2_goal_worker.test.mjs b/tests/opencode2_goal_worker.test.mjs
index 35ba0687b9..df26cc1649 100644
--- a/tests/opencode2_goal_worker.test.mjs
+++ b/tests/opencode2_goal_worker.test.mjs
@@ -12,6 +12,7 @@ import {
createStateStore,
isTerminalNoFollowup,
leaseIsLive,
+ parseExecFailure,
parseWorkerArgs,
retryPlan,
runWorker,
@@ -624,6 +625,64 @@ test("createApiTransport probes the opencode2 api CLI shape", () => {
})
+test("parseExecFailure reads the HTTP status the opencode2 api CLI reports", () => {
+ const error = new Error("Command failed: opencode2 api post /api/session/x/wait")
+ error.stderr = "HTTP 404 Not Found"
+ assert.equal(parseExecFailure(error).status, 404)
+ assert.equal(parseExecFailure({ stderr: "status: 500 server error" }).status, 500)
+})
+
+
+test("createApiTransport falls back to the experimental wait route when the stable route is missing", async () => {
+ const paths = []
+ const execFileImpl = async (_bin, args) => {
+ paths.push(args[2])
+ if (args[2] === "/api/session/ses_test/wait") {
+ const error = new Error("Command failed")
+ error.stderr = "HTTP 404 Not Found"
+ throw error
+ }
+ return { stdout: "{}" }
+ }
+ const transport = createApiTransport({ execFileImpl })
+
+ assert.equal(await transport.waitForSession("ses_test"), true)
+ assert.deepEqual(paths, [
+ "/api/session/ses_test/wait",
+ "/api/experimental/session/ses_test/wait",
+ ])
+
+ paths.length = 0
+ assert.equal(await transport.waitForSession("ses_test"), true)
+ assert.deepEqual(paths, ["/api/experimental/session/ses_test/wait"])
+})
+
+
+test("createApiTransport keeps the stable wait route when the server serves it", async () => {
+ const paths = []
+ const execFileImpl = async (_bin, args) => {
+ paths.push(args[2])
+ return { stdout: "{}" }
+ }
+ const transport = createApiTransport({ execFileImpl })
+
+ assert.equal(await transport.waitForSession("ses_test"), true)
+ assert.deepEqual(paths, ["/api/session/ses_test/wait"])
+})
+
+
+test("createApiTransport surfaces a wait failure that is not a missing route", async () => {
+ const execFileImpl = async () => {
+ const error = new Error("Command failed")
+ error.stderr = "HTTP 500 Internal Server Error"
+ throw error
+ }
+ const transport = createApiTransport({ execFileImpl })
+
+ await assert.rejects(() => transport.waitForSession("ses_test"), /Command failed/)
+})
+
+
test("worker entry runs main() even when argv[1] goes through a symlinked path", async () => {
const workerFile = path.resolve(
path.join(import.meta.dirname, "..", "loopx", "opencode2_goal_mode", "opencode2-goal-worker.mjs"),
diff --git a/tests/presentation/test_codex_attached_conversation.py b/tests/presentation/test_codex_attached_conversation.py
new file mode 100644
index 0000000000..b37a248204
--- /dev/null
+++ b/tests/presentation/test_codex_attached_conversation.py
@@ -0,0 +1,669 @@
+"""Real HTTP + SQLite/JSONL + a disposable Desktop-owner Unix protocol peer."""
+
+import importlib.util
+import json
+from pathlib import Path
+import sqlite3
+import subprocess
+from types import SimpleNamespace
+
+import pytest
+
+from loopx.capabilities.native_chat import codex_attached as provider
+from loopx.capabilities.native_chat import codex_history as history
+
+_spec = importlib.util.spec_from_file_location(
+ "codex_attached_peer",
+ Path(__file__).resolve().parents[2]
+ / "examples/personal-workspace-browser/codex_attached_peer.py",
+)
+assert _spec and _spec.loader
+_peer = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(_peer)
+THREAD = _peer.THREAD
+append_message = _peer.append_message
+fixture = _peer.fixture
+
+
+def target(request):
+ status, result = request()
+ assert status == 200
+ binding = result["agents"][0]
+ return {
+ **binding,
+ "goal_id": "sample-goal",
+ }, f"?goal_id=sample-goal&agent_id=worker&binding_ref={binding['binding_ref']}"
+
+
+def body(binding, text="Owner follow-up"):
+ return {
+ "goal_id": "sample-goal",
+ "agent_id": "worker",
+ "binding_ref": binding["binding_ref"],
+ "operation_id": "same-operation",
+ "text": text,
+ }
+
+
+def opener_spy(monkeypatch, *, platform="darwin", failure=None):
+ calls = []
+
+ def launch(argv, **options):
+ calls.append((argv, options))
+ if failure:
+ raise failure
+
+ monkeypatch.setattr(
+ provider, "sys", SimpleNamespace(platform=platform), raising=False
+ )
+ monkeypatch.setattr(
+ provider,
+ "subprocess",
+ SimpleNamespace(run=launch, SubprocessError=subprocess.SubprocessError),
+ raising=False,
+ )
+ return calls
+
+
+def open_body(binding):
+ return {key: binding[key] for key in ("goal_id", "agent_id", "binding_ref")}
+
+
+def test_explicit_open_uses_bound_local_thread_without_executing(tmp_path, monkeypatch):
+ calls = opener_spy(monkeypatch)
+ with fixture(tmp_path) as (request, _, methods, inputs, _, _):
+ binding, query = target(request)
+ assert request(query=query)[0] == 200
+ assert calls == [] # Reading never opens another application.
+ status, result = request(
+ "POST", open_body(binding), query="", path="/api/chat/codex-attached/open"
+ )
+ assert status == 200
+ assert result == {"ok": True, "requested": True}
+ assert calls == [
+ (
+ [
+ "/usr/bin/open",
+ "-b",
+ "com.openai.codex",
+ f"codex://threads/{THREAD}",
+ ],
+ {"check": True, "timeout": 3, "capture_output": True},
+ )
+ ]
+ assert inputs == []
+ assert "thread-follower-start-turn" not in methods
+ assert not (request.server.runtime_root / "native_chat").exists()
+
+
+def test_reply_character_limit_accepts_full_unicode_input(tmp_path, monkeypatch):
+ with fixture(tmp_path) as (request, _, _, inputs, _, _):
+ binding, _ = target(request)
+ text = "验" * 24000
+ status, _ = request("POST", body(binding, text), query="")
+ assert status == 200
+ assert inputs[0]["turnStart"]["request"]["input"][0]["text"] == text
+ assert request("POST", body(binding, text + "验"), query="")[0] == 409
+ assert len(inputs) == 1
+
+
+def test_open_rejects_forged_stale_archived_and_cross_origin(tmp_path, monkeypatch):
+ calls = opener_spy(monkeypatch)
+ with fixture(tmp_path) as (request, _, _, _, registry, home):
+ binding, _ = target(request)
+ payload = open_body(binding)
+ for extra in ("url", "thread_id", "path", "command"):
+ assert (
+ request(
+ "POST",
+ {**payload, extra: "attacker"},
+ query="",
+ path="/api/chat/codex-attached/open",
+ )[0]
+ == 409
+ )
+ for changed in ({"agent_id": "other"}, {"binding_ref": "old"}):
+ assert (
+ request(
+ "POST",
+ {**payload, **changed},
+ query="",
+ path="/api/chat/codex-attached/open",
+ )[0]
+ == 409
+ )
+ assert (
+ request(
+ "POST",
+ payload,
+ query="",
+ origin="https://foreign.example",
+ path="/api/chat/codex-attached/open",
+ )[0]
+ == 403
+ )
+ with sqlite3.connect(home / "state_5.sqlite") as db:
+ db.execute("UPDATE threads SET archived=1")
+ assert (
+ request("POST", payload, query="", path="/api/chat/codex-attached/open")[0]
+ == 503
+ )
+ with sqlite3.connect(home / "state_5.sqlite") as db:
+ db.execute("UPDATE threads SET archived=0")
+ value = json.loads(registry.read_text())
+ value["goals"][0]["coordination"]["thread_agent_bindings"][0]["thread_id"] = (
+ "other"
+ )
+ registry.write_text(json.dumps(value))
+ assert (
+ request("POST", payload, query="", path="/api/chat/codex-attached/open")[0]
+ == 409
+ )
+ assert calls == []
+
+
+@pytest.mark.parametrize(
+ "platform,failure",
+ [
+ ("linux", None),
+ ("darwin", OSError("unavailable")),
+ ("darwin", subprocess.TimeoutExpired("open", 3)),
+ ("darwin", subprocess.CalledProcessError(1, "open")),
+ ],
+)
+def test_open_host_failure_is_explicit(tmp_path, monkeypatch, platform, failure):
+ calls = opener_spy(monkeypatch, platform=platform, failure=failure)
+ with fixture(tmp_path) as (request, _, _, inputs, _, _):
+ binding, _ = target(request)
+ status, result = request(
+ "POST", open_body(binding), query="", path="/api/chat/codex-attached/open"
+ )
+ assert status == 503
+ assert result["ok"] is False
+ assert inputs == []
+ assert len(calls) == (0 if platform == "linux" else 1)
+
+
+def test_open_validates_uuid_and_rechecks_binding_before_dispatch(
+ tmp_path, monkeypatch
+):
+ calls = opener_spy(monkeypatch)
+ for value in ("--args", "11111111111141118111111111111111", THREAD + "/other"):
+ with pytest.raises((ValueError, provider.DesktopUnavailable)):
+ provider.open_conversation(value)
+ with fixture(tmp_path) as (request, _, _, _, registry, _):
+ binding, _ = target(request)
+ from loopx.presentation import codex_attached_api as api
+
+ locate = api.locate_thread
+
+ def changed_during_lookup(thread_id):
+ result = locate(thread_id)
+ value = json.loads(registry.read_text())
+ value["goals"][0]["coordination"]["thread_agent_bindings"] = []
+ registry.write_text(json.dumps(value))
+ return result
+
+ monkeypatch.setattr(api, "locate_thread", changed_during_lookup)
+ assert (
+ request(
+ "POST",
+ open_body(binding),
+ query="",
+ path="/api/chat/codex-attached/open",
+ )[0]
+ == 409
+ )
+ assert calls == []
+
+
+def test_original_history_incremental_append_and_idempotent_owner_input(
+ tmp_path, monkeypatch
+):
+ with fixture(tmp_path) as (
+ request,
+ rollout,
+ methods,
+ inputs,
+ _,
+ _home,
+ ):
+ binding, query = target(request)
+ status, initial = request(query=query)
+ assert status == 200 and initial["can_reply"]
+ assert [
+ message["text"]
+ for message in initial["messages"]
+ if message["role"] != "activity"
+ ] == [
+ "Original request",
+ "Original result",
+ ]
+ assert request(query=query + "&revision=" + initial["revision"])[1]["unchanged"]
+ append_message(rollout, "assistant", "Later result")
+ assert (
+ request(query=query + "&revision=" + initial["revision"])[1]["messages"][
+ -1
+ ]["text"]
+ == "Later result"
+ )
+ assert request("POST", body(binding))[1]["status"] == "delivered"
+ assert request("POST", body(binding))[1]["turn_id"] == "original-native-turn"
+ assert (
+ request(query=query + "&operation_id=same-operation")[1]["status"]
+ == "delivered"
+ )
+ assert len(inputs) == 1
+ assert inputs[0]["turnStart"]["context"] == {
+ "attachments": [],
+ "commentAttachments": [],
+ "inheritThreadSettings": True,
+ "useAppServerPermissionDefault": True,
+ }
+ assert set(methods) == {
+ "initialize",
+ "thread-owner-discovery",
+ "thread-follower-start-turn",
+ }
+ status, rejected = request("POST", body(binding, "Changed content"))
+ assert status == 409
+ assert (
+ "input_not_submitted" not in rejected
+ ) # This id already caused an effect.
+
+
+def test_uncertain_delivery_never_replays_the_effect(tmp_path, monkeypatch):
+ with fixture(tmp_path, uncertain=True) as (
+ request,
+ _,
+ _,
+ inputs,
+ _,
+ _home,
+ ):
+ binding, query = target(request)
+ assert request("POST", body(binding))[1]["status"] == "pending"
+ assert request("POST", body(binding))[1]["status"] == "pending"
+ assert (
+ request(query=query + "&operation_id=same-operation")[1]["status"]
+ == "pending"
+ )
+ assert len(inputs) == 1
+
+
+@pytest.mark.parametrize("busy", [True, False], ids=["busy", "unknown"])
+def test_pre_dispatch_rejection_proves_no_input_and_creates_no_receipt(tmp_path, busy):
+ with fixture(tmp_path) as (request, rollout, methods, inputs, _, _):
+ binding, query = target(request)
+ rollout.write_text(
+ json.dumps({"type": "event_msg", "payload": {"type": "task_started"}})
+ + "\n"
+ if busy
+ else ""
+ )
+ status, rejected = request("POST", body(binding))
+ assert status == 409
+ assert rejected["ok"] is False
+ assert rejected["input_not_submitted"] is True
+ assert rejected["operation_id"] == "same-operation"
+ assert request(query=query + "&operation_id=same-operation")[0] == 404
+ assert inputs == [] and "thread-follower-start-turn" not in methods
+
+
+def test_busy_stale_binding_foreign_origin_and_unbound_agent_create_no_input(
+ tmp_path, monkeypatch
+):
+ with fixture(tmp_path) as (
+ request,
+ rollout,
+ _,
+ inputs,
+ registry,
+ _home,
+ ):
+ binding, query = target(request)
+ assert request("POST", body(binding), origin="https://example.com")[0] == 403
+ assert (
+ request(query=query.replace("agent_id=worker", "agent_id=foreign"))[0]
+ == 409
+ )
+ with rollout.open("a") as stream:
+ stream.write(
+ json.dumps(
+ {
+ "type": "event_msg",
+ "timestamp": "2026-01-01T00:00:02Z",
+ "payload": {"type": "task_started"},
+ }
+ )
+ + "\n"
+ )
+ assert request("POST", body(binding))[0] == 409
+ saved = json.loads(registry.read_text())
+ saved["goals"][0]["coordination"]["thread_agent_bindings"][0]["thread_id"] = (
+ "22222222-2222-4222-8222-222222222222"
+ )
+ registry.write_text(json.dumps(saved))
+ assert request(query=query)[0] == 409
+ assert request("POST", body(binding))[0] == 409
+ assert not inputs
+
+
+def test_owner_history_includes_tools_with_bounded_pages_and_offline_reading(
+ tmp_path, monkeypatch
+):
+ with fixture(tmp_path) as (request, rollout, _, _, _, home):
+ binding, query = target(request)
+ with rollout.open("a") as stream:
+ for _ in range(400):
+ stream.write(
+ json.dumps(
+ {
+ "type": "response_item",
+ "payload": {
+ "type": "function_call_output",
+ "output": "tool-result" * 512,
+ },
+ }
+ )
+ + "\n"
+ )
+ append_message(rollout, "assistant", "Visible recent result")
+ status, result = request(query=query)
+ assert status == 200 and result["bounded"] and result["before"] is not None
+ assert result["messages"][-1]["text"] == "Visible recent result"
+ assert result["messages"][0]["role"] == "tool"
+ assert len(result["messages"]) == 100
+ assert str(home) not in json.dumps(result)
+ (home / "ipc" / "ipc.sock").chmod(0o666)
+ status, readonly = request(query=query)
+ assert status == 200 and not readonly["can_reply"] and not readonly["connected"]
+ assert request("POST", body(binding))[0] == 503
+
+
+def test_history_pages_do_not_skip_messages_at_the_display_limit(tmp_path):
+ rollout = tmp_path / "history.jsonl"
+ for number in range(235):
+ append_message(rollout, "assistant", f"Message {number}")
+ newest = provider.read_messages(rollout)
+ middle = provider.read_messages(rollout, before=newest["before"])
+ oldest = provider.read_messages(rollout, before=middle["before"])
+ assert oldest["before"] is None
+ assert [
+ row["text"] for page in [oldest, middle, newest] for row in page["messages"]
+ ] == [f"Message {number}" for number in range(235)]
+
+
+def test_replaced_rollout_invalidates_revision_and_offset_identities(tmp_path):
+ rollout = tmp_path / "history.jsonl"
+ append_message(rollout, "assistant", "Old result")
+ old = provider.read_messages(rollout)
+ replacement = tmp_path / "replacement.jsonl"
+ append_message(replacement, "assistant", "New result")
+ replacement.replace(rollout)
+ new = provider.read_messages(rollout)
+ assert old["source_ref"] != new["source_ref"]
+ assert old["revision"] != new["revision"]
+ assert [row["text"] for row in new["messages"]] == ["New result"]
+
+
+def test_unbound_goal_does_not_probe_native_conversations(tmp_path, monkeypatch):
+ with fixture(tmp_path) as (request, _, methods, inputs, registry, _):
+ saved = json.loads(registry.read_text())
+ saved["goals"][0]["coordination"]["thread_agent_bindings"] = []
+ registry.write_text(json.dumps(saved))
+ assert request()[1] == {"ok": True, "agents": []}
+ assert not methods and not inputs
+
+
+def append_record(path, kind, payload):
+ with path.open("a") as stream:
+ stream.write(json.dumps({"type": kind, "payload": payload}) + "\n")
+
+
+def test_work_records_cover_tools_and_lifecycle_without_hidden_context(tmp_path):
+ path = tmp_path / "history.jsonl"
+ for role in ("system", "developer"):
+ append_message(path, role, "hidden instruction")
+ append_record(
+ path,
+ "response_item",
+ {
+ "type": "message",
+ "role": "assistant",
+ "channel": "analysis",
+ "content": [{"type": "output_text", "text": "hidden reasoning"}],
+ },
+ )
+ append_record(
+ path, "response_item", {"type": "reasoning", "summary": "hidden reasoning"}
+ )
+ append_record(path, "event_msg", {"type": "task_started"})
+ append_record(
+ path,
+ "response_item",
+ {
+ "type": "function_call",
+ "name": "exec",
+ "call_id": "call-1",
+ "arguments": "run validation",
+ },
+ )
+ append_record(
+ path,
+ "response_item",
+ {
+ "type": "function_call_output",
+ "call_id": "call-1",
+ "output": "exit 1: test failed",
+ },
+ )
+ append_record(
+ path,
+ "response_item",
+ {"type": "custom_tool_call", "name": "patch", "input": "repair input"},
+ )
+ append_record(
+ path,
+ "response_item",
+ {"type": "custom_tool_call_output", "output": "repair saved"},
+ )
+ append_record(path, "event_msg", {"type": "turn_aborted"})
+ rows = history.read_messages(path)["messages"]
+ assert [row["kind"] for row in rows] == [
+ "task_started",
+ "function_call",
+ "function_call_output",
+ "custom_tool_call",
+ "custom_tool_call_output",
+ "turn_aborted",
+ ]
+ assert rows[1]["call_id"] == rows[2]["call_id"] == "call-1"
+ assert rows[2]["text"] == "exit 1: test failed"
+ assert "hidden" not in json.dumps(rows)
+
+
+def test_full_unicode_content_is_available_in_bounded_http_pages(tmp_path):
+ with fixture(tmp_path) as (request, rollout, methods, inputs, _, _):
+ _, query = target(request)
+ # Larger than the history window, including astral Unicode characters.
+ text = "🧭验证\n" * 120000 + "END OF COMPLETE RESULT"
+ append_message(rollout, "assistant", text)
+ _, view = request(query=query)
+ row = view["messages"][-1]
+ assert row["truncated"] and len(row["text"]) == history.CONTENT_CHARS
+ offset, parts = len(row["text"]), [row["text"]]
+ detail = f"&entry_id={row['id']}&source_ref={view['source_ref']}&content_ref={row['content_ref']}"
+ while True:
+ status, page = request(query=query + detail + f"&content_offset={offset}")
+ assert status == 200
+ parts.append(page["text"])
+ offset = page["next_offset"]
+ if not page["has_more"]:
+ break
+ assert "".join(parts) == text
+ assert (
+ request(query=query + detail.replace(view["source_ref"], "stale"))[0] == 409
+ )
+ assert (
+ request(query=query + detail.replace(row["content_ref"], "stale"))[0] == 409
+ )
+ assert (
+ request(
+ query=query.replace("agent_id=worker", "agent_id=foreign") + detail
+ )[0]
+ == 409
+ )
+ assert not inputs and "thread-follower-start-turn" not in methods
+
+
+def test_forward_catchup_has_no_burst_gap_and_retries_partial_append(tmp_path):
+ path = tmp_path / "history.jsonl"
+ append_message(path, "assistant", "initial")
+ first = history.read_messages(path)
+ for index in range(235):
+ append_message(path, "assistant", f"new {index}")
+ raw = json.dumps(
+ {
+ "type": "response_item",
+ "payload": {
+ "type": "message",
+ "role": "assistant",
+ "content": [{"type": "output_text", "text": "partial complete"}],
+ },
+ }
+ ).encode()
+ with path.open("ab") as stream:
+ stream.write(raw[:40])
+ cursor, rows = first["after"], []
+ while True:
+ page = history.read_messages(
+ path, after=cursor, expected_source=first["source_ref"]
+ )
+ rows.extend(page["messages"])
+ cursor = page["after"]
+ if not page["has_more"]:
+ break
+ assert [row["text"] for row in rows] == [f"new {i}" for i in range(235)]
+ assert len({row["id"] for row in rows}) == 235
+ with path.open("ab") as stream:
+ stream.write(raw[40:] + b"\n")
+ assert [
+ row["text"] for row in history.read_messages(path, after=cursor)["messages"]
+ ] == ["partial complete"]
+ replacement = tmp_path / "new.jsonl"
+ append_message(replacement, "assistant", "replacement")
+ replacement.replace(path)
+ page = history.read_messages(
+ path, after=cursor, expected_source=first["source_ref"]
+ )
+ assert page["reset"] and [row["text"] for row in page["messages"]] == [
+ "replacement"
+ ]
+
+
+@pytest.mark.parametrize("read_mode", ["history", "content", "unchanged"])
+def test_reads_recheck_binding_after_native_lookup(tmp_path, monkeypatch, read_mode):
+ from loopx.presentation import codex_attached_api as api
+
+ with fixture(tmp_path) as (request, _, _, inputs, registry, _):
+ _, query = target(request)
+ _, view = request(query=query)
+ if read_mode == "content":
+ row = view["messages"][-1]
+ query += f"&entry_id={row['id']}&source_ref={view['source_ref']}&content_ref={row['content_ref']}"
+ elif read_mode == "unchanged":
+ query += "&revision=" + view["revision"]
+ locate = api.locate_thread
+
+ def changed_during_lookup(thread_id):
+ result = locate(thread_id)
+ saved = json.loads(registry.read_text())
+ saved["goals"][0]["coordination"]["thread_agent_bindings"] = []
+ registry.write_text(json.dumps(saved))
+ return result
+
+ monkeypatch.setattr(api, "locate_thread", changed_during_lookup)
+ status, result = request(query=query)
+ assert status == 409 and result["ok"] is False
+ assert "Original result" not in json.dumps(result)
+ assert not inputs
+
+
+def test_credential_fields_withheld_and_oversize_gap_is_not_silent(
+ tmp_path, monkeypatch
+):
+ path = tmp_path / "history.jsonl"
+ append_record(
+ path,
+ "response_item",
+ {"type": "function_call_output", "output": 'api_key="synthetic-value"'},
+ )
+ page = history.read_messages(path)
+ row = page["messages"][0]
+ assert row["redacted"] and "synthetic-value" not in json.dumps(row)
+ detail = history.read_content(
+ path,
+ entry_id=int(row["id"]),
+ offset=0,
+ expected_source=page["source_ref"],
+ content_ref=row["content_ref"],
+ )
+ assert detail["redacted"] and "synthetic-value" not in detail["text"]
+ monkeypatch.setattr(history, "RECORD_BYTES", 100)
+ page = history.read_messages(path)
+ assert page["messages"][0]["unavailable"] == "record_exceeds_read_limit"
+ assert page["after"] == path.stat().st_size
+ with pytest.raises(ValueError, match="unavailable"):
+ history.read_content(
+ path,
+ entry_id=int(row["id"]),
+ offset=0,
+ expected_source=page["source_ref"],
+ content_ref=row["content_ref"],
+ )
+
+
+def test_native_tool_text_blocks_are_readable_and_content_pages_are_lossless(tmp_path):
+ path = tmp_path / "history.jsonl"
+ text = "🧭验证\n" * 7000
+ append_record(
+ path,
+ "response_item",
+ {
+ "type": "custom_tool_call_output",
+ "output": [
+ {"type": "text", "text": text},
+ {"type": "text", "text": "Complete result"},
+ ],
+ },
+ )
+ page = history.read_messages(path)
+ row = page["messages"][0]
+ assert row["text"] == text[: history.CONTENT_CHARS]
+ remaining = history.read_content(
+ path,
+ entry_id=int(row["id"]),
+ offset=len(row["text"]),
+ expected_source=page["source_ref"],
+ content_ref=row["content_ref"],
+ )
+ assert row["text"] + remaining["text"] == text + "\nComplete result"
+ # Unknown blocks or extra metadata must not disappear in text normalization.
+ output = [
+ {"type": "text", "text": "visible", "annotations": ["source"]},
+ {"type": "image", "description": "unrendered"},
+ ]
+ append_record(
+ path, "response_item", {"type": "custom_tool_call_output", "output": output}
+ )
+ assert json.loads(history.read_messages(path)["messages"][-1]["text"]) == output
+ append_record(
+ path,
+ "response_item",
+ {
+ "type": "custom_tool_call_output",
+ "output": [{"type": "text", "text": "api_key=sk-" + "a" * 40}],
+ },
+ )
+ assert history.read_messages(path)["messages"][-1]["redacted"] is True
diff --git a/tests/test_chat_agent.py b/tests/test_chat_agent.py
index bacbcaba9f..a506f41b59 100644
--- a/tests/test_chat_agent.py
+++ b/tests/test_chat_agent.py
@@ -663,12 +663,13 @@ def run(*args, environment=env):
@pytest.mark.parametrize("resume", [False, True])
+@pytest.mark.parametrize("skills", ["absent", "directory", "symlink", "parent-symlink"])
@pytest.mark.parametrize("metadata,grant", [
("directory", "workspace_write"), ("directory", "workspace_read"),
("symlink", "workspace_write"), ("worktree", "workspace_write"),
])
def test_owner_project_git_writes_use_native_permissions_without_isolated_account(
- monkeypatch, tmp_path, resume, metadata, grant):
+ monkeypatch, tmp_path, resume, metadata, grant, skills):
from loopx.capabilities.native_chat.project_context import ChatProjectContexts
workspace = tmp_path / "workspace"
workspace.mkdir()
@@ -681,12 +682,24 @@ def test_owner_project_git_writes_use_native_permissions_without_isolated_accoun
git.symlink_to(foreign, target_is_directory=True)
else:
git.write_text(f"gitdir: {foreign}\n")
+ if skills == "parent-symlink":
+ (foreign / "skills").mkdir()
+ (workspace / ".codex").symlink_to(foreign, target_is_directory=True)
+ elif skills != "absent":
+ (workspace / ".codex").mkdir()
+ if skills == "directory":
+ (workspace / ".codex" / "skills").mkdir()
+ else:
+ (workspace / ".codex" / "skills").symlink_to(foreign, target_is_directory=True)
enabled = metadata == "directory" and grant == "workspace_write"
+ expected_roots = [str(git)] if enabled else []
+ if enabled and skills == "directory":
+ expected_roots.append(str(workspace / ".codex" / "skills"))
approved = str(workspace / "existing")
settings = {"writable_roots": [approved], "network_access": True, "exclude_slash_tmp": True}
process = _FakeAppServerProcess(config_response={"config": {"sandbox_workspace_write": settings}}, thread_response={
"thread": {"id": "thread-loopx-chat"},
- "sandbox": {"type": "workspaceWrite", "writableRoots": [approved, str(git)]},
+ "sandbox": {"type": "workspaceWrite", "writableRoots": [approved, *expected_roots]},
})
monkeypatch.setenv("SYNTHETIC_OWNER_ENV", "retained")
monkeypatch.setattr(chat_agent.shutil, "which", lambda _: "codex")
@@ -710,7 +723,7 @@ def test_owner_project_git_writes_use_native_permissions_without_isolated_accoun
assert start["sandbox"] == ("workspace-write" if grant == "workspace_write" else "read-only")
if enabled:
assert start["config"]["sandbox_workspace_write"] == {
- **settings, "writable_roots": [approved, str(git)]}
+ **settings, "writable_roots": [approved, *expected_roots]}
else:
assert "sandbox_workspace_write" not in start["config"]
assert host_config == original_config
@@ -731,16 +744,19 @@ def test_owner_project_git_writes_use_native_permissions_without_isolated_accoun
session.close()
-@pytest.mark.parametrize("fault", ["invalid-roots", "missing-root", "foreign-root", "wrong-policy", "invalid-native-roots"])
+@pytest.mark.parametrize("fault", ["invalid-roots", "missing-root", "foreign-root", "wrong-policy", "invalid-native-roots", "missing-skills-root"])
def test_owner_git_permissions_require_valid_configuration_and_native_readback(monkeypatch, tmp_path, fault):
from loopx.capabilities.native_chat.project_context import ChatProjectContexts
(tmp_path / ".git").mkdir()
+ if fault == "missing-skills-root":
+ (tmp_path / ".codex" / "skills").mkdir(parents=True)
process = _FakeAppServerProcess(
config_response={"config": {"sandbox_workspace_write": {"writable_roots": "invalid"}}
if fault == "invalid-roots" else {"sandbox_workspace_write": None}},
thread_response={"thread": {"id": "thread-loopx-chat"},
"sandbox": {"type": "readOnly" if fault == "wrong-policy" else "workspaceWrite",
"writableRoots": (str(tmp_path / ".git") if fault == "invalid-native-roots"
+ else [str(tmp_path / ".git")] if fault == "missing-skills-root"
else ["/other"] if fault == "foreign-root" else [])}})
monkeypatch.setattr(chat_agent.shutil, "which", lambda _: "codex")
monkeypatch.setattr(chat_agent.subprocess, "Popen", lambda *a, **kw: process)
diff --git a/tests/test_chat_server_cors.py b/tests/test_chat_server_cors.py
index 970c8a4ff8..9a13d17088 100644
--- a/tests/test_chat_server_cors.py
+++ b/tests/test_chat_server_cors.py
@@ -487,6 +487,43 @@ def test_chat_status_rejects_invalid_goal_activation_scope(monkeypatch) -> None:
server.server_close()
+def test_workspace_directory_reports_a_missing_registry_as_an_empty_workspace(tmp_path) -> None:
+ # A fresh install has no registry until the first Goal is created. The
+ # discovery read must answer with zero registered Goals, not a 500, so the
+ # home page can render its first-Goal state instead of a service failure.
+ server, thread = _start_server()
+ server.registry_path = tmp_path / ".loopx" / "registry.json"
+ try:
+ response = _request(server.server_address[1], method="GET", origin=None, path="/status.json?view=workspace-directory")
+ payload = json.loads(response.read().decode("utf-8"))
+ assert response.status == 200
+ assert payload["ok"] is True
+ assert payload["schema_version"] == "loopx_workspace_directory_v1"
+ assert payload["goals"] == []
+ finally:
+ server.shutdown()
+ thread.join(timeout=5)
+ server.server_close()
+
+
+def test_workspace_directory_still_fails_on_an_unreadable_registry(tmp_path) -> None:
+ # Only absence is an empty workspace; a corrupt registry stays a failure.
+ server, thread = _start_server()
+ registry_path = tmp_path / ".loopx" / "registry.json"
+ registry_path.parent.mkdir(parents=True)
+ registry_path.write_text("{not json", encoding="utf-8")
+ server.registry_path = registry_path
+ try:
+ response = _request(server.server_address[1], method="GET", origin=None, path="/status.json?view=workspace-directory")
+ payload = json.loads(response.read().decode("utf-8"))
+ assert response.status == 500
+ assert payload["ok"] is False
+ finally:
+ server.shutdown()
+ thread.join(timeout=5)
+ server.server_close()
+
+
def test_workspace_directory_skips_expensive_projection_and_respects_scope(monkeypatch) -> None:
registry = {"goals": [{"id": "alpha", "display_name": "Alpha"}, {"id": "beta"}]}
monkeypatch.setattr("loopx.chat_status_api.load_project_registry", lambda _: registry)
@@ -631,6 +668,7 @@ def fail(*_args, **_kwargs):
raise PermissionError("synthetic confidential diagnostic")
monkeypatch.setattr("loopx.chat_status_api.load_registry", fail)
+ monkeypatch.setattr("loopx.chat_status_api.load_project_registry", fail)
monkeypatch.setattr("loopx.chat_status_api.collect_status", fail)
server, thread = _start_server()
try:
diff --git a/tests/test_chat_session_active_turn.py b/tests/test_chat_session_active_turn.py
index de562fe739..57f6293ef4 100644
--- a/tests/test_chat_session_active_turn.py
+++ b/tests/test_chat_session_active_turn.py
@@ -934,6 +934,19 @@ def test_replay_after_process_loss_fails_an_unowned_started_turn(
assert restored is not None
assert restored["status"] == "ready"
assert restored["active_turn_id"] is None
+ # Reopening must explain the interruption to the live SSE observer
+ # and repeated event replay, without replaying a possibly effectful request.
+ failures = [event for event in store.events_after(session_id, str(turn["turn_id"]), None)
+ if event["kind"] == "turn.failed"]
+ assert len(failures) == 1
+ assert failures[0]["payload"]["message"] == replay["error"]
+ assert "restarted" in replay["error"]
+ assert "follow-up" in replay["error"]
+ runtime.submit_turn(session_id=session_id, client_turn_id="lost-running-worker",
+ message="do not leave this running", work_dir=tmp_path,
+ objective="sample objective")
+ assert [event for event in store.events_after(session_id, str(turn["turn_id"]), None)
+ if event["kind"] == "turn.failed"] == failures
def test_completed_turn_cannot_release_a_newer_active_turn(tmp_path: Path) -> None:
diff --git a/tests/test_chat_todo_priority.py b/tests/test_chat_todo_priority.py
index 2cfb2bff6a..0be9b4699e 100644
--- a/tests/test_chat_todo_priority.py
+++ b/tests/test_chat_todo_priority.py
@@ -32,3 +32,13 @@ def test_chat_create_keeps_priority_and_rejects_conflicting_marker(tmp_path):
with pytest.raises(ValueError, match="conflict"):
service.preview({**request, "idempotency_key": "priority-conflict",
"normalized_parameters": {"goal_id": GOAL_ID, "text": "[P1] Task", "priority": "P0"}})
+
+
+def test_chat_create_defaults_without_overriding_reviewed_priority(tmp_path):
+ project, _, service = _fixture(tmp_path)
+ request = {"action_kind": "todo.create", "summary": "Create ordinary task",
+ "normalized_parameters": {"goal_id": GOAL_ID, "text": "An ordinary task"},
+ "context": {}, "idempotency_key": "priority-default"}
+ preview = service.preview(request)
+ assert service.apply(preview["proposal_id"])["proposal"]["status"] == "applied"
+ assert "[P1] An ordinary task" in _todos(project)
diff --git a/tests/test_claude_goal_harden_settings_preservation.py b/tests/test_claude_goal_harden_settings_preservation.py
new file mode 100644
index 0000000000..2f98b1e881
--- /dev/null
+++ b/tests/test_claude_goal_harden_settings_preservation.py
@@ -0,0 +1,182 @@
+r"""`install.py --harden` must never destroy a settings.json it cannot read.
+
+`add_hardening` deep-merges the optional PreToolUse gate and statusline into the
+user's Claude Code `settings.json` and then rewrites that file whole. It used to
+read the file inside `try` and fall back to `cur = {}` on any exception, so a
+settings file that exists but does not parse was silently replaced by the
+hardening block alone: `permissions` (including `deny` rules), `env`, `model`,
+other hooks and MCP approvals were dropped, at exit status 0, with no warning
+and no backup. Ordinary ways to reach that state on a supported host include a
+UTF-8 BOM (Notepad's "UTF-8 with BOM"), a hand-added `//` comment or trailing
+comma, and a torn tail left by an interrupted write - the installer's own
+`write_text` truncated the file in place, so the next run destroyed the rest.
+
+The file is user-owned: LoopX did not create it. `loopx/slash_command_install.py`
+already fails closed on the same class of input (it returns `invalid_status` and
+writes nothing) and replaces the file with one `os.replace`. These tests pin the
+same boundary here.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+from loopx.claude_goal_mode.scripts import install as claude_install
+
+# Each entry is an ordinary on-disk state of a user-owned settings file that
+# `json.loads` rejects. The last one is what an interrupted write leaves behind.
+UNREADABLE_SETTINGS = (
+ ("utf8-bom", "\ufeff" + json.dumps({"permissions": {"deny": ["Read(~/.ssh/**)"]}})),
+ ("jsonc-comment", '{\n // mine\n "model": "opus"\n}'),
+ ("trailing-comma", '{"model": "opus",}'),
+ ("torn-tail", '{"model": "opus", "permissions": {"deny": ['),
+ ("empty", ""),
+)
+
+
+@pytest.mark.parametrize("label,raw", UNREADABLE_SETTINGS)
+def test_an_unreadable_settings_file_is_refused_and_left_byte_identical(
+ label: str, raw: str, tmp_path: Path
+) -> None:
+ settings = tmp_path / "settings.json"
+ settings.write_text(raw, encoding="utf-8")
+ before = settings.read_bytes()
+
+ with pytest.raises(claude_install.HardenSettingsUnreadable) as raised:
+ claude_install.add_hardening(dry=False, settings_path=settings)
+
+ assert str(settings) in str(raised.value)
+ assert settings.read_bytes() == before, f"{label}: settings.json was rewritten"
+
+
+def test_a_json_file_that_is_not_an_object_is_refused(tmp_path: Path) -> None:
+ settings = tmp_path / "settings.json"
+ settings.write_text('["not", "an", "object"]', encoding="utf-8")
+
+ with pytest.raises(claude_install.HardenSettingsUnreadable):
+ claude_install.add_hardening(dry=False, settings_path=settings)
+
+ assert settings.read_text(encoding="utf-8") == '["not", "an", "object"]'
+
+
+def test_a_readable_settings_file_keeps_every_key_it_had(tmp_path: Path) -> None:
+ """The refusal above must not turn into refusing to install at all."""
+
+ settings = tmp_path / "settings.json"
+ settings.write_text(
+ json.dumps(
+ {
+ "permissions": {"allow": ["Bash(ls:*)"], "deny": ["Read(~/.ssh/**)"]},
+ "env": {"ANTHROPIC_LOG": "1"},
+ "model": "opus",
+ "hooks": {
+ "PreToolUse": [
+ {"matcher": "Bash", "hooks": [
+ {"type": "command", "command": "my-own-guard.sh"}]},
+ ]
+ },
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ claude_install.add_hardening(dry=False, settings_path=settings)
+
+ written = json.loads(settings.read_text(encoding="utf-8"))
+ assert written["permissions"] == {
+ "allow": ["Bash(ls:*)"],
+ "deny": ["Read(~/.ssh/**)"],
+ }
+ assert written["env"] == {"ANTHROPIC_LOG": "1"}
+ assert written["model"] == "opus"
+ commands = [
+ hook.get("command")
+ for group in written["hooks"]["PreToolUse"]
+ for hook in group["hooks"]
+ ]
+ assert "my-own-guard.sh" in commands
+ assert any("goal_policy.py" in str(command) for command in commands)
+ assert written["statusLine"]["type"] == "command"
+
+
+def test_a_missing_settings_file_is_created_with_the_hardening_block(
+ tmp_path: Path,
+) -> None:
+ settings = tmp_path / "claude" / "settings.json"
+
+ claude_install.add_hardening(dry=False, settings_path=settings)
+
+ written = json.loads(settings.read_text(encoding="utf-8"))
+ assert sorted(written) == ["hooks", "statusLine"]
+
+
+def test_a_failed_write_leaves_the_previous_settings_intact(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """The replacement is one `os.replace`, not a truncate-in-place.
+
+ `write_text` opens the destination with `O_TRUNC`, so anything that stops
+ the process between that open and the last byte leaves the user's settings
+ as a partial document - and the next run used to finish the job by
+ replacing it. Refusing to read it is only half the boundary; the write must
+ also be able to fail without taking the old bytes with it.
+ """
+
+ settings = tmp_path / "settings.json"
+ original = json.dumps({"model": "opus", "permissions": {"deny": ["Read(~/.ssh/**)"]}})
+ settings.write_text(original, encoding="utf-8")
+
+ def refuse_replace(*_args, **_kwargs):
+ raise OSError("simulated interruption")
+
+ monkeypatch.setattr(claude_install.os, "replace", refuse_replace)
+
+ with pytest.raises(OSError):
+ claude_install.add_hardening(dry=False, settings_path=settings)
+
+ assert settings.read_text(encoding="utf-8") == original
+ assert list(tmp_path.glob("*.tmp")) == []
+
+
+def _symlinks_available(directory: Path) -> bool:
+ target = directory / "symlink-probe-target"
+ link = directory / "symlink-probe-link"
+ target.write_text("{}", encoding="utf-8")
+ try:
+ link.symlink_to(target)
+ except (OSError, NotImplementedError):
+ return False
+ finally:
+ link.unlink(missing_ok=True)
+ target.unlink(missing_ok=True)
+ return True
+
+
+def test_a_symlinked_settings_file_keeps_its_target(tmp_path: Path) -> None:
+ """A dotfiles-managed `settings.json` is the user's link, not ours to drop.
+
+ The atomic write ends in `os.replace`, which overwrites the destination
+ directory entry; a plain write used to follow the link and land in the
+ target file. The write therefore resolves the path first, so the merged
+ document reaches the file the user actually keeps in version control and
+ the link still names it afterwards.
+ """
+
+ if not _symlinks_available(tmp_path):
+ pytest.skip("this host cannot create symlinks")
+
+ target = tmp_path / "dotfiles" / "claude-settings.json"
+ target.parent.mkdir()
+ target.write_text(json.dumps({"model": "opus"}), encoding="utf-8")
+ settings = tmp_path / "settings.json"
+ settings.symlink_to(target)
+
+ claude_install.add_hardening(dry=False, settings_path=settings)
+
+ assert settings.is_symlink(), "the write must not replace the user's link"
+ written = json.loads(target.read_text(encoding="utf-8"))
+ assert written["model"] == "opus"
+ assert written["statusLine"]["type"] == "command"
diff --git a/tests/test_contract_scan_unreadable_directories.py b/tests/test_contract_scan_unreadable_directories.py
new file mode 100644
index 0000000000..2f01a21dea
--- /dev/null
+++ b/tests/test_contract_scan_unreadable_directories.py
@@ -0,0 +1,297 @@
+"""A scan that cannot list a directory must not report a clean boundary.
+
+`os.walk` swallows a listing failure, so `loopx check --scan-path ` used to
+print `public boundary scan clean: N files` and exit 0 for a tree whose only
+private file sat in a directory the process could not enter. The sibling
+`public_safe_outbound` scanner already fails closed on the same condition
+(`directory_unreadable`, #6476); the boundary owner now records the directory
+and keeps the scan from passing. A readable sibling is still scanned, and a
+directory that only fails to yield a file keeps its existing warning.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+
+import pytest
+
+from loopx.contract import check_contract, iter_scan_files, scan_public_boundary
+
+POSIX_READ_DENIAL = pytest.mark.skipif(
+ os.name == "nt" or os.geteuid() == 0,
+ reason="mode 000 read denial needs a non-root POSIX host",
+)
+
+
+def _private_line() -> str:
+ return "internal host " + ".".join(str(octet) for octet in (10, 0, 0, 7)) + "\n"
+
+
+def _registry(tmp_path: Path) -> Path:
+ registry_path = tmp_path / "registry.json"
+ registry_path.write_text(
+ json.dumps(
+ {
+ "schema_version": 1,
+ "common_runtime_root": str(tmp_path / "runtime"),
+ "goals": [],
+ }
+ ),
+ encoding="utf-8",
+ )
+ return registry_path
+
+
+@POSIX_READ_DENIAL
+def test_scan_records_directory_it_cannot_list(tmp_path: Path) -> None:
+ (tmp_path / "public.md").write_text("public text\n", encoding="utf-8")
+ locked = tmp_path / "notes"
+ locked.mkdir()
+ (locked / "private.md").write_text(_private_line(), encoding="utf-8")
+ locked.chmod(0o000)
+ try:
+ scanned = iter_scan_files(tmp_path, unreadable_directories=(unreadable := []))
+ boundary = scan_public_boundary([tmp_path])
+ finally:
+ locked.chmod(0o755)
+
+ assert scanned == sorted([tmp_path / "public.md"])
+ # `iter_scan_files` keeps the absolute directory identity and the reason;
+ # `scan_public_boundary` renders the "label: reason" form asserted below.
+ assert len(unreadable) == 1
+ assert unreadable[0] == (tmp_path / "notes", "Permission denied")
+ assert boundary["unreadable_directories"] == ["notes: Permission denied"]
+ assert boundary["unreadable_files"] == []
+ assert boundary["hits"] == []
+ assert boundary["ok"] is True
+ assert boundary["scanned_files"] == 1
+
+
+@POSIX_READ_DENIAL
+def test_scan_keeps_readable_sibling_of_unreadable_directory(tmp_path: Path) -> None:
+ locked = tmp_path / "notes"
+ locked.mkdir()
+ (locked / "private.md").write_text(_private_line(), encoding="utf-8")
+ leaky = tmp_path / "leaky.md"
+ leaky.write_text(_private_line(), encoding="utf-8")
+ locked.chmod(0o000)
+ try:
+ boundary = scan_public_boundary([tmp_path])
+ finally:
+ locked.chmod(0o755)
+
+ assert boundary["unreadable_directories"] == ["notes: Permission denied"]
+ assert [hit.rsplit(": ", 1)[-1] for hit in boundary["hits"]] == ["private_ip"]
+
+
+@POSIX_READ_DENIAL
+def test_scan_records_unreadable_directory_named_as_dependency_root(tmp_path: Path) -> None:
+ dependency = tmp_path / "node_modules"
+ dependency.mkdir()
+ (dependency / "private.md").write_text(_private_line(), encoding="utf-8")
+ dependency.chmod(0o000)
+ try:
+ boundary = scan_public_boundary([dependency])
+ finally:
+ dependency.chmod(0o755)
+
+ # Dependency pruning skips the walk, so the root itself is probed instead.
+ assert boundary["unreadable_directories"] == [".: Permission denied"]
+ assert iter_scan_files(dependency) == []
+
+
+@POSIX_READ_DENIAL
+def test_check_contract_fails_closed_on_unreadable_directory(tmp_path: Path) -> None:
+ registry_path = _registry(tmp_path)
+ (tmp_path / "public.md").write_text("public text\n", encoding="utf-8")
+ locked = tmp_path / "notes"
+ locked.mkdir()
+ (locked / "private.md").write_text(_private_line(), encoding="utf-8")
+ locked.chmod(0o000)
+ try:
+ contract = check_contract(
+ registry_path=registry_path,
+ runtime_root_override=str(tmp_path / "runtime"),
+ scan_roots=[tmp_path],
+ limit=20,
+ allow_missing_registry=True,
+ )
+ finally:
+ locked.chmod(0o755)
+
+ assert contract["ok"] is False
+ assert [
+ diagnostic["code"] for diagnostic in contract["error_diagnostics"]
+ ] == ["public_boundary_scan_directory_unreadable"]
+ assert "notes: Permission denied" in contract["error_diagnostics"][0]["message"]
+ assert contract["public_boundary_scan"]["unreadable_directories"] == [
+ "notes: Permission denied"
+ ]
+ assert contract["public_boundary_scan"]["ok"] is True
+
+
+def test_readable_tree_records_no_unreadable_directory(tmp_path: Path) -> None:
+ (tmp_path / "public.md").write_text("public text\n", encoding="utf-8")
+
+ boundary = scan_public_boundary([tmp_path])
+
+ assert boundary["unreadable_directories"] == []
+ assert boundary["ok"] is True
+
+
+def test_iter_scan_files_reports_the_denied_directory_and_reason(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """The collector holds the absolute directory identity, not a rendered label.
+
+ A directory the walk cannot list is reported through the walk's own `onerror`
+ callback here, so the internal shape the POSIX `chmod 000` arms assert stays
+ pinned on hosts that cannot set the mode bits.
+ """
+ from loopx import contract
+
+ (tmp_path / "public.md").write_text("public text\n", encoding="utf-8")
+ locked = tmp_path / "notes"
+ locked.mkdir()
+ (locked / "private.md").write_text(_private_line(), encoding="utf-8")
+ real_scandir = contract.os.scandir
+
+ def scandir_with_denied_directory(path, *args, **kwargs):
+ if str(path) == str(locked):
+ raise PermissionError(13, "Permission denied", str(locked))
+ return real_scandir(path, *args, **kwargs)
+
+ monkeypatch.setattr(contract.os, "scandir", scandir_with_denied_directory)
+ unreadable: list[tuple[Path, str]] = []
+
+ scanned = iter_scan_files(tmp_path, unreadable_directories=unreadable)
+
+ assert (locked / "private.md").is_file()
+ assert scanned == sorted([tmp_path / "public.md"])
+ # The identity is the directory itself; only the rendering adds the label.
+ assert [directory for directory, _ in unreadable] == [locked]
+ assert [reason for _, reason in unreadable] == ["Permission denied"]
+
+
+def test_a_denied_scan_root_is_reported_once(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """The root probe and the walk both observe the root; the caller sees one entry."""
+ import loopx.contract as contract
+
+ denied = tmp_path / "notes"
+ denied.mkdir()
+ (denied / "notes.md").write_text("ordinary text\n", encoding="utf-8")
+ real_scandir = contract.os.scandir
+ real_walk = contract.os.walk
+
+ def failing_scandir(path, *args, **kwargs):
+ if str(path) == str(denied):
+ raise PermissionError(13, "Permission denied", str(path))
+ return real_scandir(path, *args, **kwargs)
+
+ def walk_with_listing_failure(root: str, **kwargs):
+ onerror = kwargs.get("onerror")
+ if callable(onerror):
+ onerror(PermissionError(13, "Permission denied", str(root)))
+ return real_walk(root, **kwargs)
+
+ monkeypatch.setattr(contract.os, "scandir", failing_scandir)
+ monkeypatch.setattr(contract.os, "walk", walk_with_listing_failure)
+
+ boundary = scan_public_boundary([denied, denied])
+
+ assert boundary["unreadable_directories"] == [".: Permission denied"]
+
+
+def test_two_roots_report_their_own_unlistable_directory(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Two roots can each hold an unlistable `notes/`; both are reported."""
+ import loopx.contract as contract
+
+ one = tmp_path / "one"
+ two = tmp_path / "two"
+ for root in (one, two):
+ (root / "notes").mkdir(parents=True)
+ (root / "public.md").write_text("public text\n", encoding="utf-8")
+ real_walk = contract.os.walk
+
+ def walk_with_listing_failure(root: str, **kwargs):
+ onerror = kwargs.get("onerror")
+ denied = Path(root) / "notes"
+ if callable(onerror) and denied.is_dir():
+ onerror(PermissionError(13, "Permission denied", str(denied)))
+ return real_walk(root, **kwargs)
+
+ monkeypatch.setattr(contract.os, "walk", walk_with_listing_failure)
+
+ boundary = scan_public_boundary([one, two])
+
+ assert boundary["unreadable_directories"] == [
+ "notes: Permission denied",
+ "notes (2): Permission denied",
+ ]
+
+
+def test_walk_listing_failure_fails_the_scan_on_every_host(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """The walk error callback is the signal; a host without mode bits uses it too."""
+ import loopx.contract as contract
+
+ (tmp_path / "public.md").write_text("public text\n", encoding="utf-8")
+ locked = tmp_path / "notes"
+ locked.mkdir()
+ (locked / "notes.md").write_text("ordinary text\n", encoding="utf-8")
+ real_walk = contract.os.walk
+
+ def walk_with_listing_failure(root: str, *, onerror: object = None):
+ if str(root) == str(tmp_path) and callable(onerror):
+ onerror(PermissionError(13, "Permission denied", str(locked)))
+ return real_walk(root)
+
+ monkeypatch.setattr(contract.os, "walk", walk_with_listing_failure)
+
+ boundary = scan_public_boundary([tmp_path])
+
+ assert boundary["unreadable_directories"] == ["notes: Permission denied"]
+ assert boundary["unreadable_files"] == []
+ assert boundary["ok"] is True
+
+
+def test_check_contract_fails_closed_on_a_walk_listing_failure(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ import loopx.contract as contract
+
+ (tmp_path / "public.md").write_text("public text\n", encoding="utf-8")
+ locked = tmp_path / "notes"
+ locked.mkdir()
+ (locked / "notes.md").write_text("ordinary text\n", encoding="utf-8")
+ real_walk = contract.os.walk
+
+ def walk_with_listing_failure(root: str, *, onerror: object = None):
+ if str(root) == str(tmp_path) and callable(onerror):
+ onerror(PermissionError(13, "Permission denied", str(locked)))
+ return real_walk(root)
+
+ monkeypatch.setattr(contract.os, "walk", walk_with_listing_failure)
+
+ contract_payload = check_contract(
+ registry_path=_registry(tmp_path),
+ runtime_root_override=str(tmp_path / "runtime"),
+ scan_roots=[tmp_path],
+ limit=20,
+ allow_missing_registry=True,
+ )
+
+ assert contract_payload["ok"] is False
+ assert [
+ diagnostic["code"] for diagnostic in contract_payload["error_diagnostics"]
+ ] == ["public_boundary_scan_directory_unreadable"]
+ assert contract_payload["public_boundary_scan"]["unreadable_directories"] == [
+ "notes: Permission denied"
+ ]
diff --git a/tests/test_ego_source_reader.py b/tests/test_ego_source_reader.py
index 743a4dc585..415e7dd2f7 100644
--- a/tests/test_ego_source_reader.py
+++ b/tests/test_ego_source_reader.py
@@ -512,6 +512,52 @@ def test_redirect_fence_returns_no_text(configured, monkeypatch):
assert reader.read_public_url(URL) == {"ok": False, "error": "source_url_changed"}
+@pytest.mark.parametrize("image", [False, True])
+def test_same_origin_location_is_diagnostic_not_source_content(configured, monkeypatch, image):
+ calls = []
+ destination = "https://example.com/canonical-article?edition=2#section"
+ value = {"error": "source_url_changed", "observed_url": destination,
+ "requested_url": URL, "canonical_url": URL,
+ "text": "must not escape", "title": "unread", "image_data": "unread"}
+
+ def run(*args, **kwargs):
+ calls.append(args)
+ return response(value)
+
+ monkeypatch.setattr(reader.subprocess, "run", run)
+ if image:
+ content = reader.read_public_image(URL, 0)
+ assert len(content) == 1 and content[0].type == "text"
+ result = json.loads(content[0].text)
+ else:
+ result = reader.read_public_url(URL)
+ assert result == {"ok": False, "error": "source_url_changed", "observed_url": destination}
+ assert len(calls) == 1 # No automatic read of the reported destination.
+
+
+@pytest.mark.parametrize("image", [False, True])
+@pytest.mark.parametrize("changes", [
+ {"observed_url": "https://other.test/private?edition=2"},
+ {"observed_url": "https://user:secret@example.com/private"},
+ {"observed_url": "http://example.com/private"},
+ {"observed_url": True}, {"observed_url": URL},
+ {"requested_url": "https://example.com/another-request"},
+ {"canonical_url": "https://other.test/another-request"},
+ {"canonical_url": None},
+])
+def test_changed_location_requires_original_request_provenance(configured, monkeypatch, image, changes):
+ value = {"error": "source_url_changed", "observed_url": "https://example.com/canonical",
+ "requested_url": URL, "canonical_url": URL, **changes}
+ monkeypatch.setattr(reader.subprocess, "run", lambda *_a, **_k: response(value))
+ if image:
+ result = json.loads(reader.read_public_image(URL, 0)[0].text)
+ expected = "browser_image_result_invalid"
+ else:
+ result = reader.read_public_url(URL)
+ expected = "browser_read_result_invalid"
+ assert result == {"ok": False, "error": expected}
+
+
def test_browser_failures_are_sanitized(configured, monkeypatch):
monkeypatch.setattr(reader.subprocess, "run", lambda *a, **k:
subprocess.CompletedProcess([], 4, "", "private bootstrap output"))
@@ -903,10 +949,26 @@ def test_browser_equivalent_urls_keep_exact_resource_fence(configured, tmp_path,
@pytest.mark.parametrize("image", [False, True])
@pytest.mark.parametrize("redirect", ["https://example.com/different", "https://other.test/private"])
-def test_generated_scripts_reject_real_redirect_before_dom(configured, tmp_path, image, redirect):
+def test_generated_scripts_report_only_same_origin_redirect_location_before_dom(configured, tmp_path, image, redirect):
result, observation = run_generated_script(reader.ReaderConfig.from_environment(), URL,
image, tmp_path / "image.png", redirect=redirect)
- assert result == {"ok": False, "error": "source_url_changed"}
+ expected = {"ok": False, "error": "source_url_changed"}
+ if redirect == "https://example.com/different":
+ expected["observed_url"] = redirect
+ assert result == expected
+ assert observation == {"domReads": 0, "captures": 0}
+
+
+@pytest.mark.parametrize("image", [False, True])
+@pytest.mark.parametrize("url", [
+ "https://example.com/a/../article",
+ 'https://example.com/article?q="edition"',
+])
+def test_redirect_diagnostic_accepts_browser_normalized_target(configured, tmp_path, image, url):
+ destination = "https://example.com/canonical-article"
+ result, observation = run_generated_script(reader.ReaderConfig.from_environment(), url,
+ image, tmp_path / "image.png", redirect=destination)
+ assert result == {"ok": False, "error": "source_url_changed", "observed_url": destination}
assert observation == {"domReads": 0, "captures": 0}
@@ -918,7 +980,7 @@ def test_fragment_routes_are_fenced_before_text_or_pixels(configured, tmp_path,
url = "https://example.com/#/blog/article"
result, observation = run_generated_script(reader.ReaderConfig.from_environment(), url,
image, tmp_path / "image.png", redirect=redirect)
- assert result == {"ok": False, "error": "source_url_changed"}
+ assert result == {"ok": False, "error": "source_url_changed", "observed_url": redirect}
assert observation == {"domReads": 0, "captures": 0}
diff --git a/tests/test_independent_delegation_validation.py b/tests/test_independent_delegation_validation.py
index 2a95e0c059..d0c5bf0b21 100644
--- a/tests/test_independent_delegation_validation.py
+++ b/tests/test_independent_delegation_validation.py
@@ -317,3 +317,46 @@ def test_selected_unbound_work_cannot_fall_back_to_its_own_validator(service, mo
runner.execute("selected-unbound")
assert runner.read("selected-unbound")["status"] == "rejected"
assert not (root / "analyst" / "initial" / "host-invocations").exists()
+
+
+def test_team_consumes_only_current_exact_owner_oracle_receipt(service, monkeypatch):
+ from pathlib import Path
+ from loopx.control_plane.goals.acceptance import verify_goal_acceptance
+
+ root, runner = service
+ monkeypatch.setattr(runner, "_spawn", lambda _: None)
+ runner.start("analysis", "oracle-1", brief())
+ runner.execute("oracle-1")
+ assert wait(runner, "oracle-1")["status"] == "accepted"
+ binding = runner.binding("analysis", require_active=True)
+ workspace = Path(binding["workspace"])
+ # An owner explicitly selects this real project workspace. The public API
+ # cannot nominate an arbitrary path or manufacture a checker result.
+ registry = json.loads(runner.registry.read_text())
+ next(row for row in registry["goals"] if row["id"] == runner.goal_id)["repo"] = str(workspace)
+ runner.registry.write_text(json.dumps(registry))
+ (workspace / "expected.json").write_bytes((workspace / "output.json").read_bytes())
+ checker = workspace / "check.py"
+ checker.write_text("from pathlib import Path\nassert Path('output.json').read_bytes() == Path('expected.json').read_bytes()\n")
+ basis = inspect_goal_acceptance(registry_path=runner.registry, runtime_root=str(runner.root), goal_id=runner.goal_id)
+ document = {"scope": {"kind": "selected_work", "todo_ids": [binding["todo_id"]]}, "objective": "Check this execution's exact result",
+ "non_goals": [], "bindings": [{"todo_id": binding["todo_id"], "criterion_ids": ["oracle"]}], "criteria": [{
+ "id": "oracle", "description": "Owner-approved expected result", "validation_argv": [sys.executable, "check.py"],
+ "validation_timeout_seconds": 5, "validation_files": [{"path": "check.py", "sha256": sha256(checker.read_bytes()).hexdigest()}],
+ "oracle": {"kind": "owner_pinned_oracle", "source_id": "expected-result", "input_refs": ["expected.json"], "output_refs": ["output.json"]}}]}
+ configure_goal_acceptance(registry_path=runner.registry, runtime_root=str(runner.root), goal_id=runner.goal_id,
+ document=document, expected_provider_revision=basis["provider_revision"], execute=True)
+ before = runner.read("oracle-1")
+ assert before["validation"].get("oracle_checks") == []
+ checked = verify_goal_acceptance(registry_path=runner.registry, runtime_root=str(runner.root), goal_id=runner.goal_id, execute=True)
+ assert checked["checks_passed"] is True
+ current = runner.read("oracle-1")
+ evidence = current["validation"]["oracle_checks"][0]
+ assert evidence["source_id"] == "expected-result"
+ assert evidence["output_versions"] == [{"ref": "output.json", "sha256": current["artifacts"][0]["sha256"]}]
+ code, cli_read = cli(runner, "read", "--operation-id", "oracle-1")
+ assert code == 0 and cli_read["validation"]["oracle_checks"] == current["validation"]["oracle_checks"]
+ (workspace / "expected.json").write_text("unverified source input")
+ with pytest.raises(ValueError, match="acceptance rejected"):
+ runner.read("oracle-1")
+ assert (workspace / "host-invocations").read_text() == "1"
diff --git a/tests/test_lark_private_conversations.py b/tests/test_lark_private_conversations.py
index aea9d93605..1ef1b03cee 100644
--- a/tests/test_lark_private_conversations.py
+++ b/tests/test_lark_private_conversations.py
@@ -831,14 +831,29 @@ def test_group_outbound_source_verification_reads_fresh_envelopes_without_displa
runtime.close()
-def test_group_admission_source_reads_overlap_but_keep_rendered_model_input(ordinary): # noqa: F811
+def test_group_admission_source_reads_overlap_but_keep_rendered_model_input(ordinary, monkeypatch): # noqa: F811
_, runtime, provider, transport = connect_group(ordinary)
try:
root = provider.topic("overlap-root", "Explain the project")
event = provider.topic("overlap-follow", "@notes-app Explain config", root=root["message_id"])
provider.messages[event["message_id"]]["body"] = {
"content": json.dumps({"text": "@_user_1 Explain config"})}
- started = threading.Barrier(2)
+ started = threading.Barrier(3)
+ source_reading = threading.Event()
+ original_source = transport._source_message
+ original_observe = transport.bindings.observe
+
+ def source(*args, **kwargs):
+ source_reading.set()
+ try:
+ return original_source(*args, **kwargs)
+ finally:
+ source_reading.clear()
+
+ def observe(*args, **kwargs):
+ if source_reading.is_set():
+ started.wait(timeout=2)
+ return original_observe(*args, **kwargs)
def overlap(args, cwd=None, timeout=None):
if ("+messages-mget" in args or
@@ -847,6 +862,8 @@ def overlap(args, cwd=None, timeout=None):
return provider(args, cwd, timeout)
transport.runner = overlap
+ monkeypatch.setattr(transport, "_source_message", source)
+ monkeypatch.setattr(transport.bindings, "observe", observe)
assert transport.admit("notes-app", event)["status"] == "durably_accepted"
row = transport.core.pending()[0]
assert row["message"] == event["content"]
@@ -857,6 +874,36 @@ def overlap(args, cwd=None, timeout=None):
runtime.close()
+@pytest.mark.parametrize("change", ["bot_unavailable", "app_replaced", "binding_revoked"])
+def test_group_admission_joins_fresh_identity_and_core_rechecks_source_changes(ordinary, change): # noqa: F811
+ store, runtime, provider, transport = connect_group(ordinary)
+ try:
+ event = provider.topic("admission-change", "Explain the project")
+ binding = next(row for row in transport.bindings.read()["bindings"]
+ if row["transport_ref"] == "notes-app")
+ original_source = transport._group_source_message
+
+ def changed(profile, message_id):
+ message = original_source(profile, message_id)
+ if change == "bot_unavailable":
+ provider.bot_ready = False
+ elif change == "app_replaced":
+ provider.profile_apps[profile] = "cli_replacement"
+ else:
+ transport.bindings.disconnect(binding["binding_id"],
+ expected_revision=transport.bindings.read()["revision"])
+ return message
+
+ transport._group_source_message = changed
+ assert transport.admit("notes-app", event)["status"] in {
+ "source_verification_failed", "command_rejected"}
+ assert transport.core.pending() == []
+ assert store.list_sessions() == []
+ assert not provider.writes and not provider.reaction_creates
+ finally:
+ runtime.close()
+
+
@pytest.mark.parametrize("drift", [
"sender", "chat", "type", "root", "parent", "thread", "mention",
"root_sender", "root_parent", "root_chat", "app", "bot", "revoke",
diff --git a/tests/test_runtime_archive_concurrency.py b/tests/test_runtime_archive_concurrency.py
index cac9a2b0e4..af9809ea4b 100644
--- a/tests/test_runtime_archive_concurrency.py
+++ b/tests/test_runtime_archive_concurrency.py
@@ -9,8 +9,10 @@
import pytest
+from loopx import file_lock as file_lock_module
from loopx import runtime as runtime_module
from loopx.control_plane.coordination.shadow_management import (
+ runtime_artifact_lock_target,
shadow_maintenance_lock_target,
)
from loopx.control_plane.effect_runtime import (
@@ -20,7 +22,12 @@
from loopx.control_plane.goals.first_party_host_admission import (
FirstPartyHostGoalAdmission,
)
+from loopx.control_plane.goals.source_session_recreation import (
+ RecreateGoalRequest,
+ recreate_goal_instance,
+)
from loopx.control_plane.projects.registry_codec import (
+ load_project_registry,
source_session_registry_transaction,
)
from loopx.control_plane.quota import spend_commit
@@ -623,3 +630,102 @@ def archive() -> None:
assert isinstance(errors[0], ValueError)
assert "goal exists in registry" in str(errors[0])
assert source.is_dir()
+
+
+def test_archive_rejects_goal_instance_recreated_after_preflight(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ runtime_root = tmp_path / "runtime"
+ source = runtime_root / "goals" / GOAL_ID
+ source.mkdir(parents=True)
+ marker = source / "instance.txt"
+ marker.write_text("instance-a", encoding="utf-8")
+ source_registry = tmp_path / "project" / ".loopx" / "registry.json"
+ _write_source_registry(source_registry, runtime_root)
+ registry_path = runtime_root / "registry.global.json"
+ registry_path.write_text(
+ json.dumps(
+ {
+ "schema_version": "0.1",
+ "registry_role": "global-local",
+ "common_runtime_root": str(runtime_root),
+ "goals": [
+ {
+ "id": GOAL_ID,
+ "source_registry": str(source_registry),
+ }
+ ],
+ }
+ ),
+ encoding="utf-8",
+ )
+ archive_preflight_complete = threading.Event()
+ continue_archive = threading.Event()
+ archive_results: list[dict[str, Any]] = []
+ errors: list[BaseException] = []
+ actual_runtime_lock = file_lock_module.exclusive_cross_runtime_file_lock
+
+ @contextmanager
+ def paused_runtime_artifact_lock(
+ path: Path,
+ **kwargs: Any,
+ ) -> Iterator[Path]:
+ if path == runtime_artifact_lock_target(runtime_root, GOAL_ID):
+ archive_preflight_complete.set()
+ if not continue_archive.wait(timeout=10):
+ raise TimeoutError("archive preflight barrier was not released")
+ with actual_runtime_lock(path, **kwargs) as lock_path:
+ yield lock_path
+
+ monkeypatch.setattr(
+ file_lock_module,
+ "exclusive_cross_runtime_file_lock",
+ paused_runtime_artifact_lock,
+ )
+
+ def archive() -> None:
+ try:
+ archive_results.append(
+ runtime_module.archive_runtime_goal(
+ registry_path=registry_path,
+ runtime_root_override=None,
+ goal_id=GOAL_ID,
+ archive_root=None,
+ allow_registered=True,
+ execute=True,
+ )
+ )
+ except BaseException as error:
+ errors.append(error)
+
+ worker = threading.Thread(target=archive)
+ worker.start()
+ try:
+ assert archive_preflight_complete.wait(
+ timeout=5
+ ), f"archive failed before exact preflight: {errors!r}"
+ recreated = recreate_goal_instance(
+ RecreateGoalRequest(
+ registry_path=source_registry,
+ goal_id=GOAL_ID,
+ goal_instance_id=GOAL_INSTANCE_ID,
+ operation_id="recreate-during-runtime-archive",
+ )
+ )
+ instance_b = str(recreated["goal_ref"]["goal_instance_id"])
+ assert instance_b != GOAL_INSTANCE_ID
+ marker.write_text("instance-b", encoding="utf-8")
+ finally:
+ continue_archive.set()
+ worker.join(timeout=20)
+
+ assert not worker.is_alive()
+ assert archive_results == []
+ assert len(errors) == 1
+ assert isinstance(errors[0], ValueError)
+ assert "goal instance changed during archive preflight" in str(errors[0])
+ current_goal = load_project_registry(source_registry)["goals"][0]
+ assert current_goal["goal_instance_id"] == instance_b
+ assert marker.read_text(encoding="utf-8") == "instance-b"
+ assert not (runtime_root / "archived-goals").exists()
diff --git a/tests/test_state_recovery.py b/tests/test_state_recovery.py
index e1a0e4d3dd..23877ae432 100644
--- a/tests/test_state_recovery.py
+++ b/tests/test_state_recovery.py
@@ -16,7 +16,7 @@
import pytest
from loopx.state_backup import build_state_backup_plan, execute_state_backup_plan
-from loopx.state_recovery import StateRecoveryError, read_state_recovery_audit, render_state_recovery_markdown, verify_state_backup
+from loopx.control_plane.state_recovery_io import StateRecoveryError, read_state_recovery_audit, render_state_recovery_markdown, verify_state_backup
from tests.control_plane.canonical_authority_fixture import promoted_create_fixture
@@ -133,7 +133,7 @@ def test_valid_internal_symlink_stays_inert_and_chained_escape_is_refused(tmp_pa
def test_capacity_and_private_input_fail_before_publication(tmp_path, monkeypatch):
- import loopx.state_recovery as recovery
+ import loopx.control_plane.state_recovery_io as recovery
archive, manifest = _backup(tmp_path)
original = archive.stat().st_mode
@@ -185,7 +185,7 @@ def test_missing_selected_storage_is_unknown_without_creation_or_fallback(tmp_pa
archive, manifest = _backup(tmp_path, provider)
destination = tmp_path / "inert"
original = verify_state_backup(archive=archive, manifest=manifest, destination=destination, execute=True)
- from loopx.state_recovery import _audit_owners
+ from loopx.control_plane.state_recovery_io import _audit_owners
selected = destination / "data/runtime-root/authority" / f"{provider}-v0"
sqlite_files = [path.relative_to(destination / "data").as_posix() for path in selected.glob("*.sqlite")]
@@ -244,7 +244,7 @@ def test_sqlite_views_are_never_evaluated_as_authority_tables(tmp_path):
def test_offline_worker_timeout_does_not_leave_a_copy_or_stop_shared_runtime(tmp_path, monkeypatch):
- import loopx.state_recovery as recovery
+ import loopx.control_plane.state_recovery_io as recovery
from loopx.control_plane.effect_runtime import effect_runtime_result
archive, manifest = _backup(tmp_path)
@@ -378,7 +378,7 @@ def unexpected_decode(*_args):
@pytest.mark.parametrize("kind", [tarfile.XHDTYPE, tarfile.XGLTYPE, tarfile.SOLARIS_XHDTYPE])
def test_oversized_pax_variants_are_refused_without_reading_the_body(kind):
- from loopx.state_recovery import _scan
+ from loopx.control_plane.state_recovery_io import _scan
member = tarfile.TarInfo("oversized-pax")
member.type, member.size = kind, 128 * 1024 + 1
@@ -388,7 +388,7 @@ def test_oversized_pax_variants_are_refused_without_reading_the_body(kind):
def test_malformed_pax_record_length_is_a_bounded_archive_error():
- from loopx.state_recovery import _scan
+ from loopx.control_plane.state_recovery_io import _scan
payload = b"9" * 5000 + b" comment=invalid\n"
member = tarfile.TarInfo("malformed-pax")
@@ -400,7 +400,7 @@ def test_malformed_pax_record_length_is_a_bounded_archive_error():
def test_failed_publication_cleans_staging_even_when_destination_becomes_occupied(tmp_path, monkeypatch):
- import loopx.state_recovery as recovery
+ import loopx.control_plane.state_recovery_io as recovery
archive, manifest = _backup(tmp_path)
destination = tmp_path / "inert"
diff --git a/tests/test_status_missing_registry.py b/tests/test_status_missing_registry.py
new file mode 100644
index 0000000000..937bb9a1d0
--- /dev/null
+++ b/tests/test_status_missing_registry.py
@@ -0,0 +1,35 @@
+"""A missing registry is an empty workspace only where the caller says so."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from loopx.status import collect_status
+
+
+def _collect(tmp_path: Path, **overrides):
+ return collect_status(
+ registry_path=tmp_path / ".loopx" / "registry.json",
+ runtime_root_override=str(tmp_path / "runtime"),
+ scan_roots=[],
+ limit=5,
+ include_public_boundary_scan=False,
+ **overrides,
+ )
+
+
+def test_missing_registry_is_a_contract_error_by_default(tmp_path: Path) -> None:
+ payload = _collect(tmp_path)
+ assert payload["ok"] is False
+ assert payload["goal_count"] == 0
+ assert "registry file does not exist" in payload["contract"]["errors"]
+
+
+def test_missing_registry_is_a_warning_when_the_caller_allows_it(tmp_path: Path) -> None:
+ payload = _collect(tmp_path, allow_missing_registry=True)
+ assert payload["ok"] is True, payload["contract"]
+ assert payload["contract"]["ok"] is True
+ assert payload["contract"]["errors"] == []
+ assert any(line.startswith("registry file does not exist") for line in payload["contract"]["warnings"])
+ assert payload["goal_count"] == 0
+ assert payload["run_history"]["goals"] == []