diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index 90d90b9a41..2a1abfcedf 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -288,6 +288,11 @@ jobs: node-version: 22 cache: pnpm + - name: Setup Python (bundled loopx CLI) + uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Setup Bun uses: oven-sh/setup-bun@v2 with: diff --git a/.github/workflows/nightly-artifacts.yml b/.github/workflows/nightly-artifacts.yml index 924a5ebae3..e4cf7695eb 100644 --- a/.github/workflows/nightly-artifacts.yml +++ b/.github/workflows/nightly-artifacts.yml @@ -142,6 +142,11 @@ jobs: node-version: 22 package-manager-cache: false + - name: Setup Python (bundled loopx CLI) + uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable with: diff --git a/.gitignore b/.gitignore index c96f08e95c..ed2cf295be 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,9 @@ external/ .design/ .pnpm-store/ +# Generated by scripts/build-loopx.mjs (never commit the compiled binary) +src/apps/desktop/resources/loopx/ + # KMP shared mobile core and the platform apps that include it (Gradle). # Written per-directory rather than as a bare `local.properties` so the pattern # cannot accidentally hide a checked-in file elsewhere in the repo. local.properties diff --git a/Cargo.lock b/Cargo.lock index 64cedac935..f2b31d0ba4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6475,6 +6475,7 @@ dependencies = [ "hex", "hmac", "log", + "regex", "semver", "serde", "serde_json", @@ -6685,6 +6686,8 @@ dependencies = [ "chrono", "dirs 6.0.0", "dunce", + "encoding_rs", + "flate2", "fs2", "futures", "futures-util", diff --git a/MiniApp/Demo/git-graph/README.md b/MiniApp/Demo/git-graph/README.md index 84070c1dbf..b579c990df 100644 --- a/MiniApp/Demo/git-graph/README.md +++ b/MiniApp/Demo/git-graph/README.md @@ -28,7 +28,7 @@ This demo showcases OpenBitFun MiniApp's full-stack collaboration capability — 1. **UI → Bridge**: `app.call('git.log', { cwd, maxCount })` etc. via `window.app` (JSON-RPC) 2. **Bridge → Tauri**: postMessage intercepted by the host `useMiniAppBridge`, which calls `miniapp_worker_call` 3. **Tauri → Worker**: Rust writes the request to Worker stdin (JSON-RPC) -4. **Worker**: `worker_host.js` loads `source/worker.js`; exported handlers are invoked — primarily `git.graphData` (returns commits + refs + stashes + uncommitted in one response), plus `git.show`, `git.checkout`, `git.merge`, `git.push`, `git.stashPush`, and 20+ other methods — all backed by the `simple-git` npm package +4. **Worker**: `worker_host.cjs` loads `source/worker.js`; exported handlers are invoked — primarily `git.graphData` (returns commits + refs + stashes + uncommitted in one response), plus `git.show`, `git.checkout`, `git.merge`, `git.push`, `git.stashPush`, and 20+ other methods — all backed by the `simple-git` npm package 5. **Worker → Tauri → Bridge → UI**: response travels back via stderr → Rust → postMessage to iframe → UI refreshes graph and detail panel ### Directory Structure @@ -117,7 +117,7 @@ miniapps/git-graph/ 1. **UI → Bridge**:`app.call('git.log', { cwd, maxCount })` 等通过 `window.app` 发起 RPC 2. **Bridge → Tauri**:postMessage 被宿主 `useMiniAppBridge` 接收,调用 `miniapp_worker_call` 3. **Tauri → Worker**:Rust 将请求写入 Worker 进程 stdin(JSON-RPC) -4. **Worker**:`worker_host.js` 加载本目录 `source/worker.js`,其导出的处理函数被调用 — 主要是 `git.graphData`(一次返回提交 + refs + stash + 未提交变更),以及 `git.show`、`git.checkout`、`git.merge`、`git.push`、`git.stashPush` 等 20+ 个方法 — 均基于 `simple-git` npm 包 +4. **Worker**:`worker_host.cjs` 加载本目录 `source/worker.js`,其导出的处理函数被调用 — 主要是 `git.graphData`(一次返回提交 + refs + stash + 未提交变更),以及 `git.show`、`git.checkout`、`git.merge`、`git.push`、`git.stashPush` 等 20+ 个方法 — 均基于 `simple-git` npm 包 5. **Worker → Tauri → Bridge → UI**:响应经 stderr 回传 Rust,再 postMessage 回 iframe,UI 更新图谱与详情 ### 目录结构 diff --git a/MiniApp/Skills/miniapp-dev/SKILL.md b/MiniApp/Skills/miniapp-dev/SKILL.md index db6b79d124..96089dfb57 100644 --- a/MiniApp/Skills/miniapp-dev/SKILL.md +++ b/MiniApp/Skills/miniapp-dev/SKILL.md @@ -121,10 +121,10 @@ src/web-ui/src/flow_chat/tool-cards/MiniAppToolDisplay.tsx # InitMiniAppDispla ### Worker 宿主 ``` -src/apps/desktop/resources/worker_host.js +src/apps/desktop/resources/worker_host.cjs ``` -Node/Bun 标准脚本:从 argv 读策略 JSON,stdin 收 RPC、stderr 回响应,内置 fs/shell/net/os/storage dispatch + 加载用户 `source/worker.js` 自定义方法。 +Node/Bun 标准脚本:从 `BITFUN_WORKER_POLICY` 环境变量读策略 JSON(argv[2] 仅作手动运行兜底),stdin 收 RPC、stderr 回响应,内置 fs/shell/net/os/storage dispatch + 加载用户 `source/worker.js` 自定义方法。 ## MiniApp 数据模型 (V2) @@ -217,6 +217,14 @@ MiniApp 框架**只暴露下列能力**,没有任何"通用 OpenBitFun 后端 > 维护者:以后若新增 `app.openbitfun.*` / `app.workspace.*` 这类宿主直通通道,请同步更新本节,避免"文档说没有、代码偷偷加了"的不一致。 +### 内置产品私有扩展 + +源码、来源和运行域都由宿主验证的内置产品界面可以获得私有 namespace,但它不属于 +MiniApp 公共 API,也不会注入普通、导入或市场 MiniApp。当前仅 +`builtin-bitfun-loopx` 使用私有 `app.loopx` 连接持久宿主控制器;每次调用仍由宿主 +复核原始 bundle、非 draft、非本地覆盖和本地执行域。生成 MiniApp 不得探测、声明或 +模拟这些私有 namespace;需要复用的能力必须先形成产品无关、带权限合同的公开 API。 + ## window.app 运行时 API MiniApp UI 内通过 **window.app** 访问: diff --git a/MiniApp/Skills/miniapp-dev/api-reference.md b/MiniApp/Skills/miniapp-dev/api-reference.md index 846faa06f2..bbdc402aa0 100644 --- a/MiniApp/Skills/miniapp-dev/api-reference.md +++ b/MiniApp/Skills/miniapp-dev/api-reference.md @@ -115,6 +115,16 @@ app.platform // 'win32' | 'darwin' | 'linux' app.mode // 'hosted' ``` +### 内置产品私有扩展不属于公共 API + +宿主可以为源码和来源均通过校验的内置产品界面注入私有 namespace。此类 namespace +不会进入普通或市场 MiniApp 的编译结果,也不属于 `window.app` 公共能力合同。 +当前 `builtin-bitfun-loopx` 使用私有 `app.loopx` 连接持久宿主控制器;宿主在每次调用时 +还会校验内置 id、原始 bundle 内容、非 draft/非本地覆盖状态和执行域。 + +生成、导入和市场 MiniApp 不得声明、探测或依赖 `app.loopx`,也不得以自定义 Worker +模拟该控制器。需要类似能力时应先建立新的公开、产品无关且有权限合同的 MiniApp API。 + ### `app.fs.*` — 文件系统 需在 `permissions.fs` 中声明读写范围。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index cdbaadee1b..1b95a97761 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -67,3 +67,24 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## loopx + +- Project: loopx +- Source: https://github.com/huangruiteng/loopx +- License: Apache-2.0 +- Copyright: Copyright 2026 LoopX contributors + +BitFun bundles a compiled, self-contained build of the loopx CLI as a desktop +sidecar resource (`resources/loopx/`). It powers the built-in bitfun-loopx +MiniApp's issue-fixing loop and is built at packaging time by +`scripts/build-loopx.mjs` from the pinned upstream release recorded in +`resources/loopx/manifest.json` (version, commit, content hash, and build +toolchain). The upstream Apache-2.0 license, NOTICE, historical MIT license, and +trademark policy ship alongside the binary as `resources/loopx/LICENSE`, +`resources/loopx/NOTICE`, `resources/loopx/LICENSE-MIT`, and +`resources/loopx/TRADEMARKS.md` in binary release packages. When the bundled +sidecar is unavailable, the local Desktop may download the pinned source tag +into BitFun-managed storage; that checkout retains the same upstream compliance +files. The `loopx` name is used descriptively to refer to the upstream project; +bitfun-loopx is a third-party integration and is not a LoopX project release. diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index 930662b1f0..18f70f496f 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -27,9 +27,9 @@ OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, a - Generated per-item interaction audit: `docs/interactive-capabilities/technical/product-control-open-audit.json` - Generated low-level audit map: `docs/interactive-capabilities/technical/tauri-command-map.json` -说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **660** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 +说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **667** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 -Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **660** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. +Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **667** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. ## 控制边界 / Control boundary diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 6edcd0e16c..039c94af37 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -2,12 +2,12 @@ "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", "catalogDigest": "5473331e06ecd4bfafbf9b547d48b4fe1561ccf69d86fdfde51ea10dc919531c", - "commandCount": 660, + "commandCount": 667, "coverage": { - "commandCount": 660, + "commandCount": 667, "documentedCommandCount": 613, - "implementationCommandCount": 47, - "implementationDigest": "401fd0a5f64377e65573d1b0883fbfa844570096adfafd4c11a999d749ba5859" + "implementationCommandCount": 54, + "implementationDigest": "242daa957f208d78ed9ffcbb7e0694ef22f4c5569a0eeec4c50c985d4fb29ea8" }, "commands": [ { @@ -5766,6 +5766,104 @@ "signature": "fn miniapp_install_deps( state: State<'_, AppState>, app_id: String, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, + { + "id": "miniapp_loopx_action", + "moduleId": "miniapp_loopx", + "capabilityId": "feature.miniapps", + "capabilityIds": [ + "feature.miniapps" + ], + "documentedItemIds": [], + "visibility": "implementation", + "rustPath": "api::miniapp_loopx_api::miniapp_loopx_action", + "sourceFile": "src/apps/desktop/src/api/miniapp_loopx_api.rs", + "signature": "fn miniapp_loopx_action( app_state: State<'_, AppState>, controller: State<'_, LoopxControllerState>, request: MiniAppLoopxActionRequest, ) -> Result", + "remoteWorkspacePolicy": "RemoteUnsupported" + }, + { + "id": "miniapp_loopx_attach", + "moduleId": "miniapp_loopx", + "capabilityId": "feature.miniapps", + "capabilityIds": [ + "feature.miniapps" + ], + "documentedItemIds": [], + "visibility": "implementation", + "rustPath": "api::miniapp_loopx_api::miniapp_loopx_attach", + "sourceFile": "src/apps/desktop/src/api/miniapp_loopx_api.rs", + "signature": "fn miniapp_loopx_attach( app_state: State<'_, AppState>, controller: State<'_, LoopxControllerState>, request: MiniAppLoopxAttachRequest, ) -> Result", + "remoteWorkspacePolicy": "RemoteUnsupported" + }, + { + "id": "miniapp_loopx_create_task", + "moduleId": "miniapp_loopx", + "capabilityId": "feature.miniapps", + "capabilityIds": [ + "feature.miniapps" + ], + "documentedItemIds": [], + "visibility": "implementation", + "rustPath": "api::miniapp_loopx_api::miniapp_loopx_create_task", + "sourceFile": "src/apps/desktop/src/api/miniapp_loopx_api.rs", + "signature": "fn miniapp_loopx_create_task( app_state: State<'_, AppState>, controller: State<'_, LoopxControllerState>, request: MiniAppLoopxCreateTaskRequest, ) -> Result", + "remoteWorkspacePolicy": "RemoteUnsupported" + }, + { + "id": "miniapp_loopx_events_since", + "moduleId": "miniapp_loopx", + "capabilityId": "feature.miniapps", + "capabilityIds": [ + "feature.miniapps" + ], + "documentedItemIds": [], + "visibility": "implementation", + "rustPath": "api::miniapp_loopx_api::miniapp_loopx_events_since", + "sourceFile": "src/apps/desktop/src/api/miniapp_loopx_api.rs", + "signature": "fn miniapp_loopx_events_since( app_state: State<'_, AppState>, controller: State<'_, LoopxControllerState>, request: MiniAppLoopxEventsSinceRequest, ) -> Result", + "remoteWorkspacePolicy": "RemoteUnsupported" + }, + { + "id": "miniapp_loopx_list_models", + "moduleId": "miniapp_loopx", + "capabilityId": "feature.miniapps", + "capabilityIds": [ + "feature.miniapps" + ], + "documentedItemIds": [], + "visibility": "implementation", + "rustPath": "api::miniapp_loopx_api::miniapp_loopx_list_models", + "sourceFile": "src/apps/desktop/src/api/miniapp_loopx_api.rs", + "signature": "fn miniapp_loopx_list_models( app_state: State<'_, AppState>, request: MiniAppLoopxListModelsRequest, ) -> Result, String>", + "remoteWorkspacePolicy": "RemoteUnsupported" + }, + { + "id": "miniapp_loopx_resolve_intake", + "moduleId": "miniapp_loopx", + "capabilityId": "feature.miniapps", + "capabilityIds": [ + "feature.miniapps" + ], + "documentedItemIds": [], + "visibility": "implementation", + "rustPath": "api::miniapp_loopx_api::miniapp_loopx_resolve_intake", + "sourceFile": "src/apps/desktop/src/api/miniapp_loopx_api.rs", + "signature": "fn miniapp_loopx_resolve_intake( app_state: State<'_, AppState>, controller: State<'_, LoopxControllerState>, request: MiniAppLoopxResolveIntakeRequest, ) -> Result", + "remoteWorkspacePolicy": "RemoteUnsupported" + }, + { + "id": "miniapp_loopx_turn_output_since", + "moduleId": "miniapp_loopx", + "capabilityId": "feature.miniapps", + "capabilityIds": [ + "feature.miniapps" + ], + "documentedItemIds": [], + "visibility": "implementation", + "rustPath": "api::miniapp_loopx_api::miniapp_loopx_turn_output_since", + "sourceFile": "src/apps/desktop/src/api/miniapp_loopx_api.rs", + "signature": "fn miniapp_loopx_turn_output_since( app_state: State<'_, AppState>, controller: State<'_, LoopxControllerState>, request: MiniAppLoopxTurnOutputSinceRequest, ) -> Result", + "remoteWorkspacePolicy": "RemoteUnsupported" + }, { "id": "miniapp_market_browse", "moduleId": "miniapp_market", diff --git a/package.json b/package.json index d26285c777..c609838b85 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "verify:webkit-compatibility": "node scripts/verify-webkit-compatibility.cjs", "verify:webkit-compatibility:test": "node --test scripts/verify-webkit-compatibility.test.mjs", "build:web": "pnpm run appearance:contract-audit && node scripts/build-web-parallel.mjs && node scripts/generate-frontend-revision.mjs && pnpm run verify:monaco-assets && pnpm run verify:webkit-compatibility", + "build:loopx": "node scripts/build-loopx.mjs", "build:mobile-web": "pnpm --dir src/mobile-web build", "build:miniapp-market": "pnpm --dir src/miniapp-market-web build", "type-check:miniapp-market": "pnpm --dir src/miniapp-market-web type-check", diff --git a/scripts/build-loopx.mjs b/scripts/build-loopx.mjs new file mode 100644 index 0000000000..ae9d6f9436 --- /dev/null +++ b/scripts/build-loopx.mjs @@ -0,0 +1,288 @@ +#!/usr/bin/env node +// Build the bundled loopx CLI for the OpenBitFun desktop installer. +// +// Runs at BUILD time only (CI / packaging), never on user machines: fetches the +// pinned loopx source, compiles a self-contained onefile binary with +// PyInstaller, and stages it under src/apps/desktop/resources/loopx/ together +// with the compliance artifacts (Apache-2.0 LICENSE/NOTICE, historical +// LICENSE-MIT, TRADEMARKS.md, provenance +// manifest). The desktop bundles that directory as a sidecar resource and the +// bitfun-loopx MiniApp worker prefers the bundled binary at runtime, so end +// users need neither Python nor git nor network access to use loopx. +// +// loopx v1.0.1 is Apache-2.0 (Copyright 2026 LoopX contributors), pure-stdlib Python +// >= 3.11; PyInstaller's bootloader exception permits the bundled binary. + +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Keep in sync with the pin constants in openbitfun-services-integrations::miniapp::loopx_cli (LOOPX_PINNED_VERSION_TAG / LOOPX_PINNED_SOURCE_COMMIT): +// loopx's CLI JSON contract is the app's interface surface, so the bundled +// binary and the runtime vendor fallback must pin the same version. +export const LOOPX_VERSION = 'v1.0.1'; +const LOOPX_REPO = 'https://github.com/huangruiteng/loopx.git'; +const LOOPX_COMMIT = '7f2a020b18d1b5bb00da4044403ae72ddce2d743'; +const OUT_DIR = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + 'src', + 'apps', + 'desktop', + 'resources', + 'loopx', +); + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + buildLoopx().catch((err) => { + console.error(`build-loopx failed: ${err.message}`); + process.exit(1); + }); +}function sh(cmd, args, opts = {}) { + execFileSync(cmd, args, { stdio: 'inherit', ...opts }); +} + +function shOut(cmd, args, opts = {}) { + return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }) + .toString() + .trim(); +} + +function sha256Of(file) { + return createHash('sha256').update(readFileSync(file)).digest('hex'); +} + +function pickPython() { + for (const candidate of [process.env.PYTHON, 'python', 'python3'].filter(Boolean)) { + try { + const version = shOut(candidate, ['--version']); + const m = version.match(/Python\s+(\d+)\.(\d+)/); + if (m && (Number(m[1]) > 3 || (Number(m[1]) === 3 && Number(m[2]) >= 11))) { + return { exe: candidate, version: version.replace(/\s+/g, ' ').trim() }; + } + console.warn(`build-loopx: ${candidate} is ${version} (Python >= 3.11 required), skipping`); + } catch { + // not installed / not on PATH + } + } + throw new Error('Python >= 3.11 not found (set PYTHON to a usable interpreter)'); +} + +export async function buildLoopx({ + version = LOOPX_VERSION, + outDir = OUT_DIR, +} = {}) { + const python = pickPython(); + console.log(`build-loopx: python ${python.version} (${python.exe})`); + try { + shOut('git', ['--version']); + } catch { + throw new Error('git not found on PATH'); + } + + const work = mkdtempSync(path.join(tmpdir(), 'loopx-build-')); + const src = path.join(work, 'src'); + const venv = path.join(work, 'venv'); + const dist = path.join(work, 'dist'); + try { + console.log(`build-loopx: cloning ${LOOPX_REPO} @ ${version}`); + sh('git', ['clone', '--depth', '1', '--branch', version, LOOPX_REPO, src]); + const commit = shOut('git', ['-C', src, 'rev-parse', 'HEAD']); + if (commit !== LOOPX_COMMIT) { + throw new Error(`pinned commit mismatch: expected ${LOOPX_COMMIT}, checkout is ${commit}`); + } + const described = shOut('git', ['-C', src, 'describe', '--tags', '--exact-match']); + if (described !== version) { + throw new Error(`pinned tag mismatch: expected ${version}, checkout is ${described}`); + } + if ( + !existsSync(path.join(src, 'LICENSE')) + || !existsSync(path.join(src, 'NOTICE')) + || !existsSync(path.join(src, 'LICENSE-MIT')) + || !existsSync(path.join(src, 'loopx', 'entrypoint.py')) + ) { + throw new Error('checkout is missing compliance files or loopx/entrypoint.py'); + } + // Compliance files shipped next to the binary. The pinned revision decides + // which files exist (v1.0.x dropped TRADEMARKS.md), so stage what the + // checkout carries instead of hard-coding the full list. + const complianceFiles = readdirSync(src) + .filter((name) => /^(LICENSE|NOTICE|TRADEMARKS)/i.test(name)) + .map((name) => path.join(src, name)); + + console.log('build-loopx: creating build venv and installing PyInstaller'); + sh(python.exe, ['-m', 'venv', venv]); + const pip = process.platform === 'win32' + ? path.join(venv, 'Scripts', 'pip.exe') + : path.join(venv, 'bin', 'pip'); + const pyinstaller = process.platform === 'win32' + ? path.join(venv, 'Scripts', 'pyinstaller.exe') + : path.join(venv, 'bin', 'pyinstaller'); + // tzdata is required at RUNTIME on Windows: CPython's zoneinfo has no + // system database there, and LoopX's periodic-report machine defaults + // normalize a timezone while building the machine-configuration + // registry. Without the wheel the frozen sidecar aborts with + // `ZoneInfoNotFoundError: 'No time zone found with key UTC'` and every + // CLI call logs `periodic_report.timezone is unknown; post-writeback + // hooks are disabled` (live 2026-09-16, dynamic-workflows-lab run). + sh(pip, ['install', '--disable-pip-version-check', '--quiet', 'pyinstaller', 'tzdata']); + + const entry = path.join(src, '_loopx_bundle_entry.py'); + writeFileSync(entry, 'from loopx.entrypoint import main\nraise SystemExit(main())\n', 'utf8'); + + console.log('build-loopx: compiling onefile binary (PyInstaller)'); + // The workflow skills live in the loopx source tree at `skills/` and are + // shipped for pip wheels via package-data. PyInstaller only bundles what + // import analysis sees, so the skills data must be added explicitly. + // Under PyInstaller the modules resolve under the extraction root + // (sys._MEIPASS) and `workflow_skill_install.resolve_workflow_skill_source()` + // checks `/skills` first (Path(__file__).parents[1]/skills), + // so the destination must be the `skills` directory at the extraction root, + // not `share/loopx/skills`. If the pinned upstream layout ever changes this + // branch, keep the two in sync. + const addDataSeparator = process.platform === 'win32' ? ';' : ':'; + const skillsAddData = `${path.join(src, 'skills')}${addDataSeparator}skills`; + // LoopX v1.0.x moved the control plane core (coordination state, turn + // envelopes, vision checkpoints) to a managed TypeScript effect runtime. + // The Python sidecar starts it on demand with + // `node --experimental-strip-types effect_runtime_server.ts` and computes + // a source fingerprint by walking `loopx/control_plane/**` for .ts/.json + // files (effect_runtime._scan_runtime_source_files); a missing tree fails + // bootstrap with `packaged_runtime_source_unreadable`. PyInstaller import + // analysis cannot see data-only sources, so stage the .ts/.json subset + // into a shadow tree and add it as data at the same destination - staging + // a subset (not the whole directory) keeps compiled .py modules out of the + // data area, where loose sources could shadow the frozen modules. + const controlPlaneSrc = path.join(src, 'loopx', 'control_plane'); + const controlPlaneStage = path.join(work, 'control_plane_runtime'); + rmSync(controlPlaneStage, { recursive: true, force: true }); + let stagedRuntimeFiles = 0; + const stageRuntimeSources = (dir, rel) => { + mkdirSync(path.join(controlPlaneStage, rel), { recursive: true }); + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const relEntry = rel ? path.join(rel, entry.name) : entry.name; + if (entry.isDirectory()) { + stageRuntimeSources(path.join(dir, entry.name), relEntry); + } else if (entry.name.endsWith('.ts') || entry.name.endsWith('.json')) { + copyFileSync(path.join(dir, entry.name), path.join(controlPlaneStage, relEntry)); + stagedRuntimeFiles += 1; + } + } + }; + stageRuntimeSources(controlPlaneSrc, ''); + if (stagedRuntimeFiles === 0) { + throw new Error('pinned LoopX source has no control-plane TypeScript runtime files'); + } + console.log(`build-loopx: staged ${stagedRuntimeFiles} TypeScript runtime files`); + const runtimeAddData = `${controlPlaneStage}${addDataSeparator}${path.join('loopx', 'control_plane')}`; + sh(pyinstaller, [ + '--onefile', + '--name', 'loopx', + '--clean', + '--noconfirm', + // The pinned CLI shells out to `gh` with `subprocess.run(..., text=True)` + // and no explicit `encoding=`, so Python decodes the child's UTF-8 output + // with `locale.getpreferredencoding(False)`. On a Windows host whose ANSI + // code page is not UTF-8 (zh-CN / cp936) that raises + // `UnicodeDecodeError: 'gbk' codec can't decode byte 0x80`, the reader + // thread dies, `stdout` becomes None, and the caller surfaces the + // misleading `the JSON object must be str, bytes or bytearray, not + // NoneType`. `issue-fix workflow-plan --fetch-metadata` / + // `--fetch-candidate-evidence` fail on any non-ASCII GitHub content. + // + // `PYTHONUTF8=1` in the child environment cannot fix it: the PyInstaller + // bootloader pins `Py_UTF8Mode = 0` before `Py_Initialize()` and thereby + // overrides the environment variable (verified live 2026-09-10 - the + // frozen sidecar fails identically with and without those vars, while a + // normal CPython flips `getpreferredencoding` to utf-8 under + // `PYTHONUTF8=1`). Enabling UTF-8 mode in the frozen interpreter is the + // only build-side fix. + // + // Verified by rebuilding and re-running the repro below on a cp936 host; + // it must switch from exit=1 with the GBK traceback to the normal + // `{"ok": true, "schema_version": "issue_fix_workflow_plan_packet_v0"}`: + // loopx issue-fix workflow-plan \ + // --url "https://github.com///issues/" \ + // --fetch-metadata --format json --no-write-domain-state + // The upstream complement (explicit `encoding="utf-8", errors="replace"` + // on those subprocess calls) is tracked separately; neither replaces the + // other, because this one also covers the other sites. + '--python-option', 'X utf8=1', + // zoneinfo loads the timezone database through importlib.resources, + // which PyInstaller's import analysis cannot see; bundle both the + // package and its data. + '--hidden-import', 'tzdata', + '--collect-data', 'tzdata', + '--distpath', dist, + '--workpath', path.join(work, 'build'), + '--specpath', path.join(work, 'build'), + '--add-data', skillsAddData, + '--add-data', runtimeAddData, + path.basename(entry), + ], { cwd: src }); + + const binary = path.join(dist, process.platform === 'win32' ? 'loopx.exe' : 'loopx'); + if (!existsSync(binary)) throw new Error(`PyInstaller produced no binary at ${binary}`); + + console.log('build-loopx: staging into', outDir); + mkdirSync(outDir, { recursive: true }); + copyFileSync(binary, path.join(outDir, path.basename(binary))); + for (const file of complianceFiles) { + copyFileSync(file, path.join(outDir, path.basename(file))); + } + // tzdata ships under Apache-2.0; keep its license text next to the + // sidecar so the bundled timezone data keep their attribution. + const venvPython = process.platform === 'win32' + ? path.join(venv, 'Scripts', 'python.exe') + : path.join(venv, 'bin', 'python'); + const tzdataLicense = shOut(venvPython, [ + '-c', + 'import importlib.metadata as m; d = m.distribution("tzdata"); print(next((str(d.locate_file(f)) for f in (d.files or []) if f.name.lower().startswith("license")), ""))', + ]).trim(); + if (tzdataLicense) { + copyFileSync(tzdataLicense, path.join(outDir, 'LICENSE-tzdata')); + } else { + console.warn('build-loopx: tzdata license file not found in the wheel'); + } + + const pyinstallerVersion = shOut(pyinstaller, ['--version']); + const manifest = { + schema_version: 1, + name: 'loopx', + version, + source: LOOPX_REPO.replace(/\.git$/, ''), + commit, + license: 'Apache-2.0', + copyright: 'Copyright 2026 LoopX contributors', + sha256: `sha256:${sha256Of(path.join(outDir, path.basename(binary)))}`, + built_with: { + python: python.version, + pyinstaller: pyinstallerVersion, + }, + built_at: new Date().toISOString(), + }; + writeFileSync( + path.join(outDir, 'manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n`, + 'utf8', + ); + + const sizeMb = (statSync(path.join(outDir, path.basename(binary))).size / 1048576).toFixed(1); + console.log(`build-loopx: done — ${path.join(outDir, path.basename(binary))} (${sizeMb} MiB, loopx ${version} @ ${commit})`); + } finally { + rmSync(work, { recursive: true, force: true }); + } +} diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index 3310ef6a91..d70e426378 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -952,6 +952,11 @@ test('contract and AI adapter tests keep reviewed feature and failure-domain top path: 'tests/miniapp_contracts.rs', requiredFeatures: ['miniapp'], }, + { + name: 'loopx_contracts', + path: 'tests/loopx_contracts.rs', + requiredFeatures: ['miniapp'], + }, { name: 'legacy_migration_contracts', path: 'tests/legacy_migration_contracts.rs', diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index 70f1dc2ece..4ccfbad746 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -18,6 +18,9 @@ const SKIPPED_DIRECTORIES = new Set([ '.targets', '.tmp', '.worktrees', + // Local-only worktree roots (git-ignored); their manifests are historical + // snapshots and must not participate in boundary checks. + 'BitFun-worktrees', 'node_modules', 'target', ]); @@ -149,6 +152,7 @@ const SERVICES_INTEGRATIONS_TOKIO_FEATURES = new Map([ ['file-watch', ['rt', 'sync']], ['function-agents', ['fs', 'io-util', 'macros', 'rt', 'time']], ['mcp', ['fs', 'io-util', 'net', 'process', 'rt', 'sync', 'time']], + ['miniapp-loopx', ['fs', 'io-util', 'macros', 'process', 'rt', 'sync', 'time']], ['miniapp-storage', ['fs', 'time']], ['miniapp-runtime', ['fs', 'io-util', 'net', 'process', 'rt', 'sync', 'time']], ['miniapp-market', ['fs', 'io-util', 'net', 'process', 'rt', 'sync', 'time']], @@ -1060,6 +1064,7 @@ export function findServicesIntegrationsReqwestFeatureViolations(pkg) { ['announcement', ['reqwest/json']], ['browser-control', ['reqwest/json']], ['mcp', ['reqwest/json', 'reqwest/stream']], + ['miniapp-loopx', ['reqwest/json']], ['miniapp-market', ['reqwest/json', 'reqwest/query', 'reqwest/stream']], ['miniapp-runtime', ['reqwest/stream']], ['models-dev', ['reqwest/system-proxy']], diff --git a/scripts/core-boundaries/explicit-test-topology.mjs b/scripts/core-boundaries/explicit-test-topology.mjs index e3c93aa5ec..a76639ee48 100644 --- a/scripts/core-boundaries/explicit-test-topology.mjs +++ b/scripts/core-boundaries/explicit-test-topology.mjs @@ -54,6 +54,11 @@ export const servicesIntegrationsIntegrationTestTargets = [ { name: 'file_watch_contracts', path: 'tests/file_watch_contracts.rs' }, { name: 'function_agent_contracts', path: 'tests/function_agent_contracts.rs' }, { name: 'git_contracts', path: 'tests/git_contracts.rs' }, + { + name: 'miniapp_loopx_contracts', + path: 'tests/miniapp_loopx_contracts.rs', + requiredFeatures: ['miniapp-loopx'], + }, { name: 'mcp_contracts', path: 'tests/mcp_contracts.rs' }, { name: 'mcp_streamable_http_contracts', path: 'tests/mcp_streamable_http_contracts.rs' }, { name: 'remote_connect_contracts', path: 'tests/remote_connect_contracts.rs' }, @@ -202,6 +207,11 @@ export const productDomainsIntegrationTestTargets = [ path: 'tests/miniapp_contracts.rs', requiredFeatures: ['miniapp'], }, + { + name: 'loopx_contracts', + path: 'tests/loopx_contracts.rs', + requiredFeatures: ['miniapp'], + }, { name: 'legacy_migration_contracts', path: 'tests/legacy_migration_contracts.rs', diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 06104dff3a..ffe3af36d4 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -5,6 +5,7 @@ export const servicesReqwestOwnerFeatures = [ 'announcement', 'browser-control', 'mcp', + 'miniapp-loopx', 'miniapp-market', 'miniapp-runtime', 'models-dev', @@ -272,7 +273,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'anyhow', ownerFeatures: ['browser-control', 'deep-research', 'mcp', 'remote-connect', 'remote-persistence', 'remote-ssh', 'remote-ssh-concrete'] }, { depName: 'async-trait', - ownerFeatures: ['deep-research', 'git', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'script-tool-runtime', 'speech', 'web-tools', 'workspace-search'], + ownerFeatures: ['deep-research', 'git', 'mcp', 'miniapp-loopx', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'script-tool-runtime', 'speech', 'web-tools', 'workspace-search'], }, { depName: 'base64', @@ -284,7 +285,7 @@ export const optionalDependencyFeatureOwnerRules = [ depName: 'openbitfun-core-types', ownerFeatures: ['deep-research', 'remote-connect', 'remote-ssh-concrete', 'speech'], }, - { depName: 'openbitfun-product-domains', ownerFeatures: ['account-identity', 'canvas-runtime', 'function-agents', 'hook-import', 'miniapp-market', 'miniapp-runtime', 'miniapp-storage', 'plugin-source', 'remote-connect'] }, + { depName: 'openbitfun-product-domains', ownerFeatures: ['account-identity', 'canvas-runtime', 'function-agents', 'hook-import', 'miniapp-loopx', 'miniapp-market', 'miniapp-runtime', 'miniapp-storage', 'plugin-source', 'remote-connect'] }, { depName: 'openbitfun-runtime-ports', ownerFeatures: ['deep-research', 'git', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime', 'web-tools'] }, { depName: 'openbitfun-services-core', @@ -295,6 +296,7 @@ export const optionalDependencyFeatureOwnerRules = [ 'git', 'hook-import', 'mcp', + 'miniapp-loopx', 'miniapp-market', 'miniapp-runtime', 'models-dev', @@ -312,12 +314,12 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'bzip2', ownerFeatures: ['speech'] }, { depName: 'chrono', ownerFeatures: ['account-identity', 'git', 'miniapp-market', 'remote-connect', 'remote-ssh-concrete', 'review-platform', 'speech', 'web-tools'] }, { depName: 'dirs', ownerFeatures: ['account-identity', 'browser-control', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete'] }, - { depName: 'dunce', ownerFeatures: ['plugin-source', 'workspace-search'] }, + { depName: 'dunce', ownerFeatures: ['miniapp-loopx', 'plugin-source', 'workspace-search'] }, { depName: 'fs2', ownerFeatures: ['plugin-source', 'remote-persistence', 'remote-connect'] }, { depName: 'futures', ownerFeatures: ['mcp', 'remote-connect', 'review-platform'] }, { depName: 'futures-util', ownerFeatures: ['speech', 'web-tools'] }, { depName: 'git2', ownerFeatures: ['git'] }, - { depName: 'hex', ownerFeatures: ['hook-import', 'mcp', 'miniapp-market', 'plugin-source', 'remote-connect'] }, + { depName: 'hex', ownerFeatures: ['hook-import', 'mcp', 'miniapp-loopx', 'miniapp-market', 'plugin-source', 'remote-connect'] }, { depName: 'hostname', ownerFeatures: ['remote-connect', 'remote-persistence'] }, { depName: 'image', ownerFeatures: ['miniapp-market', 'remote-connect'] }, { depName: 'local-ip-address', ownerFeatures: ['remote-connect'] }, @@ -339,19 +341,19 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'rustls', ownerFeatures: ['remote-connect'] }, { depName: 'rustls-native-certs', ownerFeatures: ['remote-connect'] }, { depName: 'schannel', ownerFeatures: ['remote-connect'] }, - { depName: 'sha2', ownerFeatures: ['canvas-runtime', 'hook-import', 'mcp', 'miniapp-market', 'models-dev', 'plugin-source', 'remote-connect', 'remote-persistence', 'remote-ssh', 'review-platform', 'speech'] }, + { depName: 'sha2', ownerFeatures: ['canvas-runtime', 'hook-import', 'mcp', 'miniapp-loopx', 'miniapp-market', 'models-dev', 'plugin-source', 'remote-connect', 'remote-persistence', 'remote-ssh', 'review-platform', 'speech'] }, { depName: 'sherpa-onnx', ownerFeatures: ['speech'] }, { depName: 'shellexpand', ownerFeatures: ['remote-ssh-concrete'] }, { depName: 'sse-stream', ownerFeatures: ['mcp'] }, { depName: 'ssh_config', ownerFeatures: ['remote-ssh-concrete', 'ssh_config'] }, { depName: 'terminal-core', ownerFeatures: ['remote-ssh', 'remote-ssh-concrete'] }, { depName: 'tar', ownerFeatures: ['speech'] }, - { depName: 'thiserror', ownerFeatures: ['account-identity', 'browser-control', 'git', 'hook-import', 'miniapp-market', 'plugin-source', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'speech', 'web-tools', 'workspace-search'] }, + { depName: 'thiserror', ownerFeatures: ['account-identity', 'browser-control', 'git', 'hook-import', 'miniapp-loopx', 'miniapp-market', 'plugin-source', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'speech', 'web-tools', 'workspace-search'] }, { depName: 'tokio-tungstenite', ownerFeatures: ['remote-connect', 'speech-realtime'] }, - { depName: 'tokio-util', ownerFeatures: ['remote-ssh', 'speech'] }, + { depName: 'tokio-util', ownerFeatures: ['miniapp-loopx', 'remote-ssh', 'speech'] }, { depName: 'urlencoding', ownerFeatures: ['canvas-runtime', 'miniapp-market', 'remote-connect', 'review-platform'] }, - { depName: 'uuid', ownerFeatures: ['canvas-runtime', 'hook-import', 'miniapp-runtime', 'miniapp-storage', 'plugin-source', 'remote-connect', 'remote-ssh-concrete', 'speech'] }, - { depName: 'which', ownerFeatures: ['miniapp-runtime', 'remote-connect', 'script-tool-runtime', 'workspace-search'] }, + { depName: 'uuid', ownerFeatures: ['canvas-runtime', 'hook-import', 'miniapp-loopx', 'miniapp-runtime', 'miniapp-storage', 'plugin-source', 'remote-connect', 'remote-ssh-concrete', 'speech'] }, + { depName: 'which', ownerFeatures: ['miniapp-loopx', 'miniapp-runtime', 'remote-connect', 'script-tool-runtime', 'workspace-search'] }, { depName: 'windows', ownerFeatures: ['models-dev', 'plugin-source', 'remote-connect', 'remote-persistence', 'remote-ssh-concrete', 'review-platform'] }, { depName: 'x25519-dalek', ownerFeatures: ['remote-connect'] }, ], @@ -1347,6 +1349,7 @@ export const coreClosedFeatureProfileRules = [ 'openbitfun-product-domains/appearance-market', 'openbitfun-product-domains/miniapp', 'openbitfun-services-integrations/miniapp-runtime', + 'openbitfun-services-integrations/miniapp-loopx', 'openbitfun-services-integrations/miniapp-market', 'runtime-services', 'dep:reqwest', @@ -1787,6 +1790,7 @@ export const ownerCrateFeatureAssemblyRules = [ 'git', 'hook-import', 'miniapp-runtime', + 'miniapp-loopx', 'mcp', 'models-dev', 'plugin-source', diff --git a/scripts/desktop-tauri-build.mjs b/scripts/desktop-tauri-build.mjs index 39cc8a9eeb..d92fce1d5a 100644 --- a/scripts/desktop-tauri-build.mjs +++ b/scripts/desktop-tauri-build.mjs @@ -13,6 +13,7 @@ import { statSync, writeFileSync, } from 'fs'; +import { buildLoopx } from './build-loopx.mjs'; import { ensureFlashgrepBinary } from './prepare-flashgrep-resource.mjs'; import { extractProductConfigArg } from './product-customization/cli.mjs'; import { productBuildEnvironment } from './product-customization/projections.mjs'; @@ -55,6 +56,7 @@ async function main() { desktopDir, ); process.env.FLASHGREP_DAEMON_BIN = flashgrepBinary; + const loopxResourceDir = await prepareBundledLoopx(forward, desktopDir); // Tauri CLI reads CI and rejects numeric "1" (common in CI providers). process.env.CI = 'true'; if (process.platform === 'darwin' && requestsDmgBundle(forward)) { @@ -66,6 +68,7 @@ async function main() { const tauriConfig = prepareTauriConfig(join(desktopDir, 'tauri.conf.json'), { desktopDir, flashgrepBinary, + loopxResourceDir, resolution, releaseChannel, }); @@ -311,7 +314,7 @@ export function configureWindowsSigning(config, env = process.env, platform = pr export function prepareTauriConfig( baseConfigPath, - { desktopDir, flashgrepBinary, resolution, releaseChannel } + { desktopDir, flashgrepBinary, loopxResourceDir, resolution, releaseChannel } ) { const config = JSON.parse(readFileSync(baseConfigPath, 'utf8')); if (resolution) { @@ -324,6 +327,7 @@ export function prepareTauriConfig( } configureWindowsSigning(config); injectTargetFlashgrepResource(config, desktopDir, flashgrepBinary); + injectLoopxResource(config, loopxResourceDir); // The DeepSeek bridge is not a compile-time resource: cargo check and // desktop:dev must not require packages/dsh-acp/dist-profile. Official // packaging injects it here; frontend:build-all (beforeBuildCommand) @@ -427,6 +431,38 @@ function injectTargetFlashgrepResource(config, desktopDir, flashgrepBinary) { }; } +// The compiled loopx CLI sidecar is staged under resources/loopx/ and injected +// only when a real bundle is requested. tauri-build validates every resource +// path at cargo-build time, so a missing directory must never appear in the +// generated config. Desktop dev keeps the same layout via ensureLoopxSidecar +// in scripts/dev.cjs; when the sidecar is absent the runtime degrades to the +// fixed system `loopx` command (ExactPinned) instead of vendor/pip. +function injectLoopxResource(config, loopxResourceDir) { + const resources = { ...(config.bundle?.resources || {}) }; + delete resources['resources/loopx/']; + if (loopxResourceDir) { + resources['resources/loopx/'] = 'resources/loopx/'; + } + config.bundle = { + ...(config.bundle || {}), + resources, + }; +} + +async function prepareBundledLoopx(forwardArgs, desktopDir) { + if (forwardArgs.includes('--no-bundle')) { + return null; + } + console.log('[tauri-build] Building the bundled loopx CLI sidecar (scripts/build-loopx.mjs)'); + await buildLoopx(); + const loopxDir = join(desktopDir, 'resources', 'loopx'); + const bin = join(loopxDir, process.platform === 'win32' ? 'loopx.exe' : 'loopx'); + if (!existsSync(bin)) { + throw new Error(`bundled loopx CLI missing after build at ${bin}`); + } + return loopxDir; +} + function bundledFlashgrepResources(primaryBinary) { return primaryBinary ? [primaryBinary] : []; } diff --git a/scripts/desktop-tauri-build.test.mjs b/scripts/desktop-tauri-build.test.mjs index df26017690..28b5c11979 100644 --- a/scripts/desktop-tauri-build.test.mjs +++ b/scripts/desktop-tauri-build.test.mjs @@ -470,7 +470,9 @@ test('official packaging injects the DeepSeek profile resource', () => { mkdirSync(fixture, { recursive: true }); const baseConfig = join(fixture, 'tauri.conf.json'); writeFileSync(baseConfig, JSON.stringify({ - bundle: { resources: { 'resources/worker_host.js': 'resources/worker_host.js' } }, + bundle: { resources: { + 'resources/worker_host.js': 'resources/worker_host.js', + } }, })); try { const generated = prepareTauriConfig(baseConfig, { diff --git a/scripts/dev.cjs b/scripts/dev.cjs index 46387f1c1f..9cb900123d 100644 --- a/scripts/dev.cjs +++ b/scripts/dev.cjs @@ -7,6 +7,7 @@ const fs = require('fs'); const net = require('net'); +const os = require('os'); const { execSync, spawn } = require('child_process'); const path = require('path'); const { pathToFileURL } = require('url'); @@ -151,16 +152,22 @@ function spawnCommand(cmd, args, cwd = ROOT_DIR, envOverrides = {}, shell = fals */ function runCommandPrefixed(prefix, cmd, args, cwd = ROOT_DIR, envOverrides = {}) { return new Promise((resolve) => { - const child = spawn(cmd, args, { + const spawnOptions = { cwd, - shell: process.platform === 'win32', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, ...envOverrides, }, - }); + }; + const child = process.platform === 'win32' + ? spawn( + process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe', + ['/d', '/s', '/c', cmd, ...args], + spawnOptions, + ) + : spawn(cmd, args, spawnOptions); const forward = (stream, out) => { let buffered = ''; @@ -331,7 +338,7 @@ async function runDesktopTargetGc(profile = 'debug') { async function rebuildDesktopDebugBinary() { const buildEnv = { ...process.env, - CARGO_PROFILE_DEV_DEBUG: process.env.CARGO_PROFILE_DEV_DEBUG || '0', + CARGO_PROFILE_DEV_DEBUG: process.env.CARGO_PROFILE_DEV_DEBUG || 'line-tables-only', CARGO_PROFILE_DEV_INCREMENTAL: process.env.CARGO_PROFILE_DEV_INCREMENTAL || 'true', CARGO_PROFILE_DEV_CODEGEN_UNITS: process.env.CARGO_PROFILE_DEV_CODEGEN_UNITS || '256', }; @@ -557,8 +564,20 @@ async function startDesktopPreview() { printInfo(`Launching debug desktop binary: ${desktopBinary}`); + // Dev builds must never share the user data home with an installed (or any + // other) OpenBitFun build: durable stores like the agent coordination SQLite + // carry schema versions, and a newer build upgrading the shared database + // hard-rejects older builds (observed as every LoopX task entering + // recovery). `OPENBITFUN_USER_ROOT` is the documented data-root override; + // point it at the dedicated dev data home so cross-build schema collisions + // are structurally impossible. E2E runs use their own guarded roots and are + // unaffected. + const devUserRoot = path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'com.openbitfun.desktop.dev', 'openbitfun'); + printInfo(`Dev data root (OPENBITFUN_USER_ROOT): ${devUserRoot}`); + appProcess = spawnBackgroundCommand(desktopBinary, [], ROOT_DIR, { ...process.env, + OPENBITFUN_USER_ROOT: process.env.OPENBITFUN_USER_ROOT || devUserRoot, // Debug previews must upload the current workspace build. The adjacent // target/debug resource tree is only a build-time copy and can lag behind // mobile-web edits made while the desktop binary is being reused. @@ -583,6 +602,55 @@ async function startDesktopPreview() { await new Promise(() => {}); } +/** + * Ensure the bundled, compiled loopx CLI sidecar exists for desktop dev. + * + * The runtime prefers this sidecar (`CARGO_MANIFEST_DIR/resources/loopx`, + * see desktop app_state::resolve_bundled_loopx_dir), so desktop:dev mirrors + * the packaging build instead of silently falling back to a system `loopx` + * command. The staged manifest.json carries the exact pin and a sha256 of the + * binary: when both match (and the checksum is intact) the build is skipped in + * seconds; a pin change or corruption triggers a rebuild. A build failure is a + * warning, never a dev-start blocker — the existing system-command fallback in + * loopx_cli.rs stays as the degraded path. + */ +async function ensureLoopxSidecar() { + const helperUrl = pathToFileURL(path.join(__dirname, 'build-loopx.mjs')).href; + const helper = await import(helperUrl); + const loopxDir = path.join(ROOT_DIR, 'src', 'apps', 'desktop', 'resources', 'loopx'); + const manifestPath = path.join(loopxDir, 'manifest.json'); + const binaryName = process.platform === 'win32' ? 'loopx.exe' : 'loopx'; + const binaryPath = path.join(loopxDir, binaryName); + + try { + if (fs.existsSync(manifestPath) && fs.existsSync(binaryPath)) { + const { createHash } = require('node:crypto'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const expected = (manifest.sha256 || '').replace(/^sha256:/, ''); + const actual = createHash('sha256').update(fs.readFileSync(binaryPath)).digest('hex'); + if (manifest.version === helper.LOOPX_VERSION && expected && actual === expected) { + printInfo( + `loopx sidecar up to date (v${helper.LOOPX_VERSION}${manifest.commit ? ` @ ${manifest.commit}` : ''})` + ); + return { ok: true, code: 0, error: null }; + } + printInfo('loopx sidecar pin or checksum changed; rebuilding...'); + } else { + printInfo( + 'loopx sidecar missing; building bundled CLI (first desktop:dev run may take a while)...' + ); + } + await helper.buildLoopx(); + printSuccess(`loopx sidecar ready (v${helper.LOOPX_VERSION})`); + return { ok: true, code: 0, error: null }; + } catch (error) { + printWarning( + `loopx sidecar build skipped (${error.message}); dev will fall back to a system loopx command` + ); + return { ok: true, code: 0, error: null }; + } +} + /** * Main entry */ @@ -614,7 +682,8 @@ async function main() { let currentStep = 1; // Step 1: Run all independent preparation tasks in parallel. - // copy-monaco / generate-version / mobile-web / flashgrep / plugin-host have no + // copy-monaco / generate-version / mobile-web / flashgrep / plugin-host / loopx + // have no // dependencies on each other; each task's output is line-prefixed so the // interleaved logs stay attributable. The DeepSeek bridge is not prepared // here: it is not a compile-time Tauri resource. Official desktop:build @@ -623,7 +692,7 @@ async function main() { currentStep++, totalSteps, desktopMode - ? 'Prepare resources (parallel: monaco, version, mobile-web, flashgrep, plugin-host)' + ? 'Prepare resources (parallel: monaco, version, mobile-web, flashgrep, plugin-host, loopx)' : 'Prepare resources (parallel: monaco, version)' ); @@ -649,6 +718,10 @@ async function main() { hint: 'Hint: install Bun, then run `pnpm run plugin-host:prepare`', promise: runCommandPrefixed('plugin-host', 'pnpm', ['run', 'plugin-host:prepare']), }); + prepTasks.push({ + name: 'Prepare loopx CLI sidecar', + promise: ensureLoopxSidecar(), + }); prepTasks.push({ name: 'Prepare speech libraries', promise: (async () => { diff --git a/scripts/frontend-color-surface-registry.json b/scripts/frontend-color-surface-registry.json index dd7bf656a6..b4c9057e6c 100644 --- a/scripts/frontend-color-surface-registry.json +++ b/scripts/frontend-color-surface-registry.json @@ -208,6 +208,17 @@ "excludePaths": ["generated"] } }, + { + "id": "miniapp-bitfun-loopx", + "label": "BitFun LoopX MiniApp", + "kind": "miniapp", + "owner": "MiniApp public appearance contract", + "root": "src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx", + "audit": { + "engine": "miniapp", + "excludePaths": ["test"] + } + }, { "id": "miniapp-coding-selfie", "label": "Coding Selfie MiniApp", diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index 55312038fa..773b590c56 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -93,6 +93,7 @@ fn context_compression_tool_event( .. } => Some(ToolEventData::Completed { identity: ToolEventIdentity::direct(compression_id, "ContextCompression"), + params: None, result: serde_json::json!({ "compression_count": compression_count, "tokens_before": tokens_before, @@ -117,6 +118,7 @@ fn context_compression_tool_event( .. } => Some(ToolEventData::Failed { identity: ToolEventIdentity::direct(compression_id, "ContextCompression"), + params: None, error_detail: None, error: error.clone(), duration_ms: None, diff --git a/src/apps/desktop/AGENTS-CN.md b/src/apps/desktop/AGENTS-CN.md index 8c13e198e9..e5406c67cd 100644 --- a/src/apps/desktop/AGENTS-CN.md +++ b/src/apps/desktop/AGENTS-CN.md @@ -52,19 +52,38 @@ pnpm run prepare:dsh-profile # 可选:本地 DeepSeek Harness 会话 | 命令 | 使用场景 | |---|---| -| `pnpm run desktop:build:fast` | Debug 构建,不打包;手动测试时编译最快 | +| `pnpm run desktop:build:fast` | Debug 构建,不打包;用于编译验证。产物连 dev server 时 IPC 会被拒,见下方两种语义说明 | | `pnpm run desktop:build:release-fast` | 类 Release 构建,降低 LTO;需要 release 行为但无法等待完整 LTO 时使用 | | `pnpm run desktop:build:nsis:fast` | Windows 安装器,使用 `release-fast` profile;快速验证安装器 | 需要完整断点调试信息时设置 `CARGO_PROFILE_DEV_DEBUG=2`。默认 dev profile 保留行号信息, 同时减少 PDB 体积。 +### Debug 二进制有两种语义;desktop:build:fast 的产物连 dev server 时 IPC 全被拒 + +`target/debug/bitfun-desktop.exe` 因构建方式不同有两种 tauri 语义: + +- `cargo build -p bitfun-desktop`(`desktop:preview:debug` 内部重建也用这个):tauri dev 语义(`DEP_TAURI_DEV=true`),dev server origin `http://localhost:1422` 被信任,IPC 正常。 +- `desktop:build:fast` 执行 `tauri build`,会启用 `custom-protocol`:tauri production 语义,同一 origin 被视为 remote URL,ACL 拒绝所有 app 命令和 `plugin-log`。 + +Debug 构建总是导航到 `devUrl`(启动日志 `url_kind=external`),所以 `desktop:build:fast` 的产物 + dev server 会呈现"界面完整渲染但所有 invoke 被拒":`... not allowed. Plugin not found` 错误弹窗、会话列表加载失败、小应用列表为空(加载错误被吞成空列表)、会话日志目录里 `webview.log` 为 0 字节。不带 dev server 直接启动则表现为 `ERR_CONNECTION_REFUSED`。 + +`desktop:preview:debug` 按二进制 mtime 是否新于 tracked inputs 决定复用——`desktop:build:fast` 的产物同样会被复用。跑过 `desktop:build:fast` 之后,必须先 `cargo build -p bitfun-desktop`(或 `pnpm run desktop:preview:debug -- --force-rebuild`)再启动 preview,否则会复用坏二进制。 + +诊断捷径:UI 正常渲染 + `config/logs//` 下 `webview.log` 为 0 字节 = IPC 被 ACL 拒绝,是构建语义问题,不是数据问题;`BITFUN_USER_ROOT` 下的数据不受影响。 + +另外:内置 miniapp 资源(例如 `bitfun-loopx` 的 `ui.js`/`worker.js`)通过 `include_str!` 内嵌进 `openbitfun-product-domains`,改资源会连带重编 product-domains → assembly-core → desktop 链路,增量构建耗时几分钟属于正常。exe 自身报 `os error 5` 表示有实例仍在运行、exe 被锁定,见下方 GC 竞争一节。 + ## Target 缓存 GC `desktop:dev`(退出时)、`desktop:preview:debug`(关闭时)以及 `desktop:build*` 会裁剪过期的 `target/` 缓存代际。`incremental` 每个 crate/session 保留最新项;GC 根据 Cargo fingerprint JSON 区分 lib、test、bin、build-script 等构建单元,每个单元保留最新代际,并保留 Cargo 管理的 `invoked.timestamp` 在最近 24 小时内刷新过的全部代际,随后删除失去 fingerprint 的 `deps` 文件和 `build` 目录。忙碌检测只检查所选 profile 的 Cargo 锁文件,因此其他 worktree 的编译不会再阻止清理。手动执行:`pnpm run target:gc -- --profile debug`。禁用:`OPENBITFUN_TARGET_GC=0`;演练:`OPENBITFUN_TARGET_GC_DRY_RUN=1`;可用 `OPENBITFUN_TARGET_GC_MIN_AGE_HOURS` 调整安全窗口。 `release-fast` profile(`Cargo.toml`):继承 `release`,但关闭 LTO、`codegen-units` 提高到 16、启用增量编译。编译速度显著提升,代价是二进制体积增大和边际运行时性能下降。 +### 手动并发构建会与退出时 GC 竞争 + +杀掉 `bitfun-desktop.exe` 会结束 `desktop:dev` / `desktop:preview:debug` 会话,退出过程会执行 target GC。此时立即手动执行 `cargo build -p bitfun-desktop` 可能编译中途失败,报 `os error 3`(系统找不到指定的路径),原因是 GC 在构建写入时删除了 `target/debug/build` 或 `target/debug/incremental` 目录。`bitfun-desktop.exe` 自身报 `os error 5`(拒绝访问)则是应用仍在运行、exe 被锁定。两者都是暂时性的:确认 preview 会话(node `dev.cjs` + vite + exe)完全退出——杀掉 exe 后等几秒——然后直接重跑构建即可,无需 `cargo clean`。 + ## DevTools feature(模型规则) `devtools` Cargo feature 用于桌面端 UI/UX 调试。添加或修改调试相关代码时: diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index 63d11b46d4..896f163e9f 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -74,6 +74,21 @@ points under `src/apps/data-migrator`. Set `CARGO_PROFILE_DEV_DEBUG=2` when full breakpoint debug information is required. The default dev profile keeps line tables while reducing PDB size. +### Debug binaries have two semantics; a `desktop:build:fast` binary breaks IPC against the dev server + +`target/debug/bitfun-desktop.exe` can be built with two different tauri semantics: + +- `cargo build -p bitfun-desktop` (also what `desktop:preview:debug` builds internally): tauri dev semantics (`DEP_TAURI_DEV=true`). The dev server origin `http://localhost:1422` is trusted; IPC works. +- `desktop:build:fast` runs `tauri build`, which enables `custom-protocol`: tauri production semantics. The same origin is treated as a remote URL and the ACL denies every app command and `plugin-log`. + +Debug builds always navigate to `devUrl` (startup log `url_kind=external`), so running a `desktop:build:fast` binary against the dev server renders a fully working UI where every invoke is rejected: `... not allowed. Plugin not found` error toasts, session list failures, an empty miniapp catalog (the load error is swallowed into an empty list), and a 0-byte `webview.log` in the session log dir. Launching such a binary without the dev server shows `ERR_CONNECTION_REFUSED` instead. + +`desktop:preview:debug` reuses the existing binary whenever its mtime is newer than the tracked inputs — including a leftover `desktop:build:fast` binary. After running `desktop:build:fast`, run `cargo build -p bitfun-desktop` (or `pnpm run desktop:preview:debug -- --force-rebuild`) before the preview, or the broken binary is reused. + +Diagnosis shortcut: rendered UI + 0-byte `webview.log` under `config/logs//` means IPC was denied by the ACL — a build-semantics problem, not a data problem. Data under `BITFUN_USER_ROOT` is unaffected. + +Also note: builtin miniapp assets (for example the `bitfun-loopx` `ui.js`/`worker.js`) are embedded via `include_str!` into `openbitfun-product-domains`, so asset edits recompile the product-domains → assembly-core → desktop chain; several minutes for an incremental build is normal. `os error 5` on the exe itself means an instance is still running and locks it; see the GC-race section below. + ## Target cache GC `desktop:dev` (on exit), `desktop:preview:debug` (on shutdown), and `desktop:build*` prune stale `target/` cache generations. Incremental roots keep the latest crate/session. Cargo fingerprint JSON identifies distinct lib, test, bin, and build-script units; GC keeps the latest generation of each unit plus every generation whose Cargo-managed `invoked.timestamp` was refreshed within the last 24 hours, then removes orphaned `deps` files and `build` directories. Busy detection is scoped to Cargo lock files in the selected profile, so an unrelated worktree build does not suppress GC. Manual: `pnpm run target:gc -- --profile debug`. Disable with `OPENBITFUN_TARGET_GC=0`; dry-run with `OPENBITFUN_TARGET_GC_DRY_RUN=1`; adjust the grace window with `OPENBITFUN_TARGET_GC_MIN_AGE_HOURS`. diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index cf9039cf72..d030cec498 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -18,14 +18,14 @@ tauri-build = { workspace = true } serde_json = { workspace = true } [dependencies] -openbitfun-services-core = { path = "../../crates/services/services-core", features = ["pet-packages"] } +openbitfun-services-core = { path = "../../crates/services/services-core", features = ["pet-packages", "process-runtime"] } # Internal crates openbitfun-core = { path = "../../crates/assembly/core", features = ["product-full"] } openbitfun-relay-service = { path = "../../crates/services/relay-service" } openbitfun-agent-runtime = { path = "../../crates/execution/agent-runtime", features = ["agent-runtime"] } openbitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports", features = ["agent-api", "permission", "workspace-ports"] } -openbitfun-product-domains = { path = "../../crates/contracts/product-domains", features = ["appearance-market"] } -openbitfun-services-integrations = { path = "../../crates/services/services-integrations", features = ["canvas-runtime", "miniapp-market", "remote-ssh-concrete", "speech-realtime"] } +openbitfun-product-domains = { path = "../../crates/contracts/product-domains", features = ["appearance-market", "miniapp"] } +openbitfun-services-integrations = { path = "../../crates/services/services-integrations", features = ["canvas-runtime", "miniapp-market", "miniapp-loopx", "remote-ssh-concrete", "speech-realtime"] } openbitfun-core-types = { path = "../../crates/contracts/core-types" } openbitfun-agent-tools = { path = "../../crates/execution/tool-contracts", features = ["element-token"] } openbitfun-transport = { path = "../../crates/adapters/transport", features = ["tauri-adapter"] } diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index a2dd3cf1cd..0b0df34be7 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -189,27 +189,44 @@ impl AppState { let worker_host_path = match resolve_worker_host_path() { Some(p) => { - log::info!("Resolved worker_host.js at: {}", p.display()); + log::info!("Resolved worker host at: {}", p.display()); p } None => { log::warn!( - "worker_host.js not found in any candidate location; \ + "worker host not found in any candidate location; \ MiniApp Workers will not start" ); std::path::PathBuf::from("worker_host.js") } }; + // The bitfun-loopx MiniApp prefers a bundled, compiled loopx CLI + // sidecar (scripts/build-loopx.mjs, shipped via bundle.resources). + // Export its resource directory to JS workers when present. The native + // LoopX controller separately owns managed GitHub source installation + // and the exact-version system fallback used in development. + let loopx_resource_dir = match resolve_bundled_loopx_dir() { + Some(dir) => { + log::info!("Resolved bundled loopx CLI resource dir: {}", dir.display()); + Some(dir) + } + None => { + log::info!( + "Bundled LoopX CLI not found; the native controller will use managed source or the exact-version system fallback" + ); + None + } + }; let speech_service = Arc::new(SpeechService::new(SpeechStoragePaths::new( path_manager.speech_models_dir(), path_manager.speech_model_downloads_dir(), path_manager.speech_input_temp_dir(), ))); - let js_worker_pool = JsWorkerPool::new(path_manager, worker_host_path) + let js_worker_pool = JsWorkerPool::new(path_manager, worker_host_path, loopx_resource_dir) .ok() .map(Arc::new); if js_worker_pool.is_none() { - log::warn!("JsWorkerPool not initialized (missing worker_host.js or no Bun/Node)"); + log::warn!("JsWorkerPool not initialized (missing worker host or no Bun/Node)"); } let statistics = Arc::new(RwLock::new(AppStatistics { @@ -574,6 +591,9 @@ impl AppState { /// 4. `/../Resources/worker_host.js` — flat macOS layout fallback. /// 5. `/../lib//resources/worker_host.js` — typical Linux deb/AppImage. /// 6. `/../share//resources/worker_host.js` — alt Linux layout. +/// `.cjs` (not `.js`): the host is CommonJS and must stay that way regardless of +/// the nearest `package.json` ("type": "module" in this repo's root would make +/// Node treat a `.js` host as ESM and crash on `require`). fn resolve_worker_host_path() -> Option { let mut candidates: Vec = Vec::new(); @@ -616,3 +636,44 @@ fn resolve_worker_host_path() -> Option { candidates.into_iter().find(|p| p.exists()) } + +/// Resolve the directory hosting the bundled, compiled loopx CLI sidecar +/// (built by `scripts/build-loopx.mjs`, shipped via `bundle.resources`). The +/// layouts mirror `resolve_worker_host_path`, with the platform binary name +/// under a `loopx/` subdirectory. Returns the resource directory so the +/// worker pool can export it as `BITFUN_RESOURCE_DIR`. +pub(crate) fn resolve_bundled_loopx_dir() -> Option { + let bin_name = if cfg!(windows) { "loopx.exe" } else { "loopx" }; + let mut candidates: Vec = Vec::new(); + + candidates.push( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join("loopx"), + ); + + if let Ok(exe) = std::env::current_exe() { + if let Some(exe_dir) = exe.parent() { + candidates.push(exe_dir.join("resources").join("loopx")); + if let Some(parent) = exe_dir.parent() { + candidates.push(parent.join("Resources").join("resources").join("loopx")); + candidates.push(parent.join("Resources").join("loopx")); + if let Some(bin) = exe.file_name().and_then(|s| s.to_str()) { + candidates.push(parent.join("lib").join(bin).join("resources").join("loopx")); + candidates.push( + parent + .join("share") + .join(bin) + .join("resources") + .join("loopx"), + ); + } + } + } + } + + candidates + .into_iter() + .find(|dir| dir.join(bin_name).exists()) + .map(|dir| dir.parent().map(|p| p.to_path_buf()).unwrap_or(dir)) +} diff --git a/src/apps/desktop/src/api/miniapp_loopx_api.rs b/src/apps/desktop/src/api/miniapp_loopx_api.rs new file mode 100644 index 0000000000..79ed4df045 --- /dev/null +++ b/src/apps/desktop/src/api/miniapp_loopx_api.rs @@ -0,0 +1,290 @@ +use super::app_state::AppState; +use openbitfun_core::miniapp::ai_bridge::{ + available_models_for_permissions, MiniAppAiModelDescriptor, MiniAppAiModelInfo, +}; +use openbitfun_core::miniapp::{loopx::LoopxController, MiniAppCustomizationOriginKind, BUILTIN_APPS}; +use openbitfun_core::service::config::types::GlobalConfig; +use openbitfun_product_domains::miniapp::builtin::builtin_source_matches; +use openbitfun_product_domains::miniapp::loopx::{ + LoopxActionRequest, LoopxActionResponse, LoopxAttachRequest, LoopxAttachResponse, + LoopxCreateTaskRequest, LoopxCreateTaskResponse, LoopxEventsSinceRequest, + LoopxEventsSinceResponse, LoopxExecutionDomain, LoopxExecutionSupport, + LoopxResolveIntakeRequest, LoopxResolveIntakeResponse, LoopxTurnOutputSinceRequest, + LoopxTurnOutputSinceResponse, LOOPX_BUILTIN_APP_ID, +}; +use serde::Deserialize; +use std::sync::Arc; +use std::time::Instant; +use tauri::State; + +pub const LOOPX_UNSUPPORTED_EXECUTION_DOMAIN: &str = "unsupported_execution_domain"; + +pub struct LoopxControllerState { + pub controller: Arc, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppLoopxAttachRequest { + pub app_id: String, + #[serde(flatten)] + pub input: LoopxAttachRequest, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppLoopxResolveIntakeRequest { + pub app_id: String, + #[serde(flatten)] + pub input: LoopxResolveIntakeRequest, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppLoopxCreateTaskRequest { + pub app_id: String, + #[serde(flatten)] + pub input: LoopxCreateTaskRequest, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppLoopxActionRequest { + pub app_id: String, + #[serde(flatten)] + pub input: LoopxActionRequest, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppLoopxEventsSinceRequest { + pub app_id: String, + #[serde(flatten)] + pub input: LoopxEventsSinceRequest, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppLoopxTurnOutputSinceRequest { + pub app_id: String, + #[serde(flatten)] + pub input: LoopxTurnOutputSinceRequest, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppLoopxListModelsRequest { + pub app_id: String, +} + +async fn authorize_builtin(state: &AppState, app_id: &str) -> Result<(), String> { + if app_id != LOOPX_BUILTIN_APP_ID { + return Err("LoopX controller is available only to the built-in LoopX MiniApp".to_string()); + } + let builtin = BUILTIN_APPS + .iter() + .find(|app| app.id == LOOPX_BUILTIN_APP_ID) + .ok_or_else(|| "Built-in LoopX bundle is unavailable".to_string())?; + let app = state + .miniapp_manager + .get(app_id) + .await + .map_err(|error| format!("Failed to load built-in LoopX MiniApp: {error}"))?; + if !builtin_source_matches(&app.source, builtin) { + return Err("LoopX controller is disabled for modified MiniApp content".to_string()); + } + if let Some(metadata) = state + .miniapp_manager + .load_customization_metadata(app_id) + .await + .map_err(|error| format!("Failed to load LoopX customization metadata: {error}"))? + { + if metadata.local_override + || metadata.origin.kind != MiniAppCustomizationOriginKind::Builtin + || metadata.origin.builtin_id.as_deref() != Some(LOOPX_BUILTIN_APP_ID) + { + return Err("LoopX controller is disabled for a local MiniApp override".to_string()); + } + } + Ok(()) +} + +async fn is_remote_workspace(state: &AppState) -> bool { + state.remote_workspace.read().await.is_some() +} + +fn unsupported_error() -> String { + format!( + "{LOOPX_UNSUPPORTED_EXECUTION_DOMAIN}: LoopX currently supports only a local Desktop workspace" + ) +} + +/// List the host-configured chat models for the LoopX model picker. This is +/// gated on the same verified-builtin check as the controller bridge (not the +/// MiniApp AI permission), because the LoopX native agent selects the model. +#[tauri::command] +pub async fn miniapp_loopx_list_models( + app_state: State<'_, AppState>, + request: MiniAppLoopxListModelsRequest, +) -> Result, String> { + authorize_builtin(&app_state, &request.app_id).await?; + let global_config = app_state + .config_service + .get_config::(None) + .await + .map_err(|error| error.to_string())?; + let primary_id = global_config + .ai + .resolve_model_selection("primary") + .unwrap_or_default(); + let fast_id = global_config + .ai + .resolve_model_selection("fast") + .unwrap_or_default(); + let models = available_models_for_permissions( + global_config + .ai + .models + .iter() + .map(|model| MiniAppAiModelDescriptor { + id: model.id.clone(), + name: model.name.clone(), + model_name: model.model_name.clone(), + provider: model.provider.clone(), + enabled: model.enabled, + supports_text_chat: model.supports_text_generation(), + }), + &[], + &primary_id, + &fast_id, + ); + Ok(models) +} + +#[tauri::command] +pub async fn miniapp_loopx_attach( + app_state: State<'_, AppState>, + controller: State<'_, LoopxControllerState>, + request: MiniAppLoopxAttachRequest, +) -> Result { + authorize_builtin(&app_state, &request.app_id).await?; + if is_remote_workspace(&app_state).await { + return Ok(controller + .controller + .attach( + LoopxExecutionDomain::RemoteWorkspace, + LoopxExecutionSupport::UnsupportedExecutionDomain, + Some(unsupported_error()), + ) + .await); + } + if request.input.resume_detected { + let resume_controller = controller.controller.clone(); + tauri::async_runtime::spawn(async move { + if let Err(error) = resume_controller.handle_host_resume().await { + log::warn!("LoopX host resume reconciliation failed: {error}"); + } + }); + } + Ok(controller + .controller + .attach( + LoopxExecutionDomain::LocalDesktop, + LoopxExecutionSupport::Supported, + None, + ) + .await) +} + +#[tauri::command] +pub async fn miniapp_loopx_resolve_intake( + app_state: State<'_, AppState>, + controller: State<'_, LoopxControllerState>, + request: MiniAppLoopxResolveIntakeRequest, +) -> Result { + authorize_builtin(&app_state, &request.app_id).await?; + if is_remote_workspace(&app_state).await { + return Err(unsupported_error()); + } + controller.controller.resolve_intake(request.input).await +} + +#[tauri::command] +pub async fn miniapp_loopx_create_task( + app_state: State<'_, AppState>, + controller: State<'_, LoopxControllerState>, + request: MiniAppLoopxCreateTaskRequest, +) -> Result { + authorize_builtin(&app_state, &request.app_id).await?; + if is_remote_workspace(&app_state).await { + return Err(unsupported_error()); + } + controller.controller.create_tasks(request.input).await +} + +#[tauri::command] +pub async fn miniapp_loopx_action( + app_state: State<'_, AppState>, + controller: State<'_, LoopxControllerState>, + request: MiniAppLoopxActionRequest, +) -> Result { + let started_at = Instant::now(); + let action = request.input.action; + let request_id = request.input.client_request_id.clone(); + log::info!("LoopX action command received: action={action:?}, request_id={request_id}"); + let result = async { + authorize_builtin(&app_state, &request.app_id).await?; + if is_remote_workspace(&app_state).await { + return Err(unsupported_error()); + } + controller.controller.action(request.input).await + } + .await; + let duration_ms = openbitfun_core::util::elapsed_ms_u64(started_at); + match &result { + Ok(response) => log::info!( + "LoopX action command completed: action={action:?}, request_id={request_id}, status={:?}, duration_ms={duration_ms}", + response.status + ), + Err(error) => log::warn!( + "LoopX action command failed: action={action:?}, request_id={request_id}, duration_ms={duration_ms}, error={error}" + ), + } + result +} + +#[tauri::command] +pub async fn miniapp_loopx_events_since( + app_state: State<'_, AppState>, + controller: State<'_, LoopxControllerState>, + request: MiniAppLoopxEventsSinceRequest, +) -> Result { + authorize_builtin(&app_state, &request.app_id).await?; + if is_remote_workspace(&app_state).await { + return Err(unsupported_error()); + } + Ok(controller.controller.events_since(request.input).await) +} + +#[tauri::command] +pub async fn miniapp_loopx_turn_output_since( + app_state: State<'_, AppState>, + controller: State<'_, LoopxControllerState>, + request: MiniAppLoopxTurnOutputSinceRequest, +) -> Result { + authorize_builtin(&app_state, &request.app_id).await?; + if is_remote_workspace(&app_state).await { + return Err(unsupported_error()); + } + Ok(controller.controller.turn_output_since(request.input).await) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unsupported_execution_domain_is_stable() { + assert!(unsupported_error().starts_with(LOOPX_UNSUPPORTED_EXECUTION_DOMAIN)); + } +} diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 82a2b2d3a0..456a364be6 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -39,6 +39,7 @@ pub mod mcp_api; pub mod miniapp_agent_api; pub mod miniapp_api; pub mod miniapp_export_api; +pub mod miniapp_loopx_api; pub mod miniapp_market_api; pub mod pages_api; pub mod path_target; diff --git a/src/apps/desktop/src/crash_diagnostics.rs b/src/apps/desktop/src/crash_diagnostics.rs index d5890fd80e..a18766c6d2 100644 --- a/src/apps/desktop/src/crash_diagnostics.rs +++ b/src/apps/desktop/src/crash_diagnostics.rs @@ -94,6 +94,37 @@ struct DiagnosticMetadata { platform_crash_report_hints: Vec, } +/// Ensures a single GUI instance owns this data root. Two instances sharing +/// one root corrupt controller state and workspace lifecycle (for example +/// LoopX worktrees removed out from under live tasks). File locks are +/// released by the OS when the holder dies, so a crashed instance never +/// blocks the next launch. +pub fn acquire_single_instance_lock(session_log_dir: &Path) -> Result<(), String> { + let logs_root = session_log_dir.parent().unwrap_or(session_log_dir); + let lock_path = logs_root.join(".bitfun-instance.lock"); + let file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&lock_path) + .map_err(|error| { + format!( + "failed to open the instance lock {}: {error}", + lock_path.display() + ) + })?; + file.try_lock().map_err(|_| { + format!( + "another BitFun instance is already running for this data root (lock: {}); close that instance before starting a new one", + lock_path.display() + ) + })?; + // Deliberately leak the handle: the lock must outlive every other use of + // this data root for the whole process lifetime. + std::mem::forget(file); + Ok(()) +} + pub fn initialize_run_state(session_log_dir: PathBuf, startup_trace_id: &str) { let logs_root = session_log_dir .parent() diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 9de19979dc..6f83c32ebf 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -436,6 +436,13 @@ pub async fn run() { }; startup_trace.record_phase("native_process_start", "native"); crash_diagnostics::initialize_run_state(session_log_dir.clone(), &startup_trace_id); + if let Err(error) = crash_diagnostics::acquire_single_instance_lock(&session_log_dir) { + // A second instance sharing this data root would corrupt controller + // state and workspace lifecycle; refuse to start instead. + eprintln!("BitFun desktop exited: {error}"); + log::error!("{error}"); + return; + } setup_panic_hook(); // Install the rustls ring CryptoProvider as the process-level default early, @@ -665,10 +672,105 @@ pub async fn run() { let terminal_state = api::terminal_api::TerminalState::new(); let path_manager = get_path_manager_arc(); + // Managed runtimes installed by the LoopX environment surface must be + // visible to every child process (LoopX sidecar, git worktrees) for the + // rest of this session, including a restart that picks up a previous + // install, without mutating the user's system PATH. + openbitfun_services_core::managed_runtime::prepend_managed_runtime_path( + &path_manager.managed_runtimes_dir(), + ); let frontend_workbench = Arc::new(frontend_workbench::FrontendWorkbenchManager::new( &path_manager.user_data_dir(), )); + let loopx_resource_dir = api::app_state::resolve_bundled_loopx_dir(); + // Derived from the pinned version tag so a pin bump can never leave the + // adapter looking at a stale managed-source directory. + let managed_loopx_source_dir = path_manager + .miniapp_dir(openbitfun_product_domains::miniapp::loopx::LOOPX_BUILTIN_APP_ID) + .join("runtime") + .join(format!( + "loopx-source-{}", + openbitfun_services_integrations::miniapp::loopx_cli::LOOPX_PINNED_VERSION_TAG + )); + let mut loopx_cli_config = + openbitfun_services_integrations::miniapp::loopx_cli::LoopxCliAdapterConfig::packaged( + loopx_resource_dir.clone().unwrap_or_else(|| { + path_manager + .miniapp_dir(openbitfun_product_domains::miniapp::loopx::LOOPX_BUILTIN_APP_ID) + .join("missing-bundled-loopx") + }), + ) + .with_managed_source_dir(managed_loopx_source_dir) + .with_managed_runtime_root(path_manager.managed_runtimes_dir()); + if loopx_resource_dir.is_none() { + loopx_cli_config.system_fallback = + openbitfun_services_integrations::miniapp::loopx_cli::LoopxSystemFallbackPolicy::ExactPinned; + } + let loopx_cli_adapter = + openbitfun_services_integrations::miniapp::loopx_cli::LoopxCliProcessAdapter::new( + loopx_cli_config, + ); + let loopx_cli_adapter = match openbitfun_services_integrations::miniapp::loopx_github::GithubLoopxIntakeMetadataProvider::new() { + Ok(provider) => loopx_cli_adapter.with_intake_metadata_provider(Arc::new(provider)), + Err(error) => { + log::warn!("LoopX GitHub intake metadata is unavailable: {error}"); + loopx_cli_adapter + } + }; + let loopx_cli: Arc = + Arc::new(loopx_cli_adapter); + let loopx_workspace: Arc = + Arc::new( + openbitfun_services_integrations::miniapp::loopx_workspace::LoopxWorkspaceService::new( + openbitfun_services_integrations::miniapp::loopx_workspace::LoopxWorkspaceServiceConfig::new( + // Prefer a short home-based root: target repositories can + // contain paths near the Windows MAX_PATH limit, and a + // deep AppData prefix made worktree checkouts fail with + // "Filename too long" -> "Could not reset index file". + dirs::home_dir() + .map(|home| home.join(".bitfun").join("loopx-workspaces")) + .unwrap_or_else(|| { + path_manager + .miniapp_dir( + openbitfun_product_domains::miniapp::loopx::LOOPX_BUILTIN_APP_ID, + ) + .join("workspaces") + }), + std::path::PathBuf::from("git"), + ), + ), + ); + let loopx_agent: Arc = Arc::new( + openbitfun_core::miniapp::loopx::CoreLoopxAgentPort::new(coordinator.clone()), + ); + let loopx_controller = openbitfun_core::miniapp::loopx::LoopxController::load( + loopx_cli, + loopx_workspace, + loopx_agent, + openbitfun_core::miniapp::loopx::LoopxStateStore::new( + path_manager + .miniapp_dir(openbitfun_product_domains::miniapp::loopx::LOOPX_BUILTIN_APP_ID) + .join("loopx-controller-state.json"), + ), + ) + .await; + event_router.subscribe_internal( + "loopx_tasks".to_string(), + Arc::new(openbitfun_core::miniapp::loopx::LoopxEventSubscriber::new( + loopx_controller.clone(), + )), + ); + let loopx_controller_state = api::miniapp_loopx_api::LoopxControllerState { + controller: loopx_controller.clone(), + }; + let loopx_environment_controller = loopx_controller.clone(); + tokio::spawn(async move { + if let Err(error) = loopx_environment_controller.refresh_environment().await { + log::warn!("LoopX environment initialization failed: {error}"); + } + }); + let mut builder = tauri::Builder::default(); let frontend_protocol_manager = Arc::clone(&frontend_workbench); builder = builder.register_uri_scheme_protocol( @@ -715,6 +817,7 @@ pub async fn run() { .manage(desktop_runtime) .manage(coordinator_state) .manage(scheduler_state) + .manage(loopx_controller_state) .manage(path_manager) .manage(coordinator) .manage(scheduler) @@ -751,6 +854,30 @@ pub async fn run() { .setup(move |app| { let setup_started = Instant::now(); startup_trace.record_phase("tauri_setup_start", "native_setup"); + let mut loopx_events = app + .state::() + .controller + .subscribe(); + let loopx_event_handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + loop { + match loopx_events.recv().await { + Ok(event) => { + if let Err(error) = + loopx_event_handle.emit("miniapp://loopx-event", event) + { + log::warn!("Failed to emit LoopX task event: {error}"); + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + log::warn!( + "LoopX task event subscriber lagged; clients will replay by cursor: skipped={skipped}" + ); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }); #[cfg(target_os = "macos")] { app.on_menu_event(|app, event| { @@ -1830,6 +1957,13 @@ pub async fn run() { api::miniapp_agent_api::miniapp_agent_cancel, api::miniapp_agent_api::miniapp_agent_turn_text, api::miniapp_agent_api::miniapp_agent_cancel_stale_runs, + api::miniapp_loopx_api::miniapp_loopx_attach, + api::miniapp_loopx_api::miniapp_loopx_list_models, + api::miniapp_loopx_api::miniapp_loopx_resolve_intake, + api::miniapp_loopx_api::miniapp_loopx_create_task, + api::miniapp_loopx_api::miniapp_loopx_action, + api::miniapp_loopx_api::miniapp_loopx_events_since, + api::miniapp_loopx_api::miniapp_loopx_turn_output_since, api::miniapp_export_api::miniapp_render_slide_page, // Browser API (embedded webview) api::browser_api::browser_webview_eval, diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index c1587f507a..ce9cd18d88 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -374,6 +374,7 @@ tools-miniapp = [ "openbitfun-product-domains/appearance-market", "openbitfun-product-domains/miniapp", "openbitfun-services-integrations/miniapp-runtime", + "openbitfun-services-integrations/miniapp-loopx", "openbitfun-services-integrations/miniapp-market", "runtime-services", "dep:reqwest", diff --git a/src/crates/assembly/core/builtin_playbooks/im_send_message.yaml b/src/crates/assembly/core/builtin_playbooks/im_send_message.yaml index 6f0ab8f838..9e5ad035d6 100644 --- a/src/crates/assembly/core/builtin_playbooks/im_send_message.yaml +++ b/src/crates/assembly/core/builtin_playbooks/im_send_message.yaml @@ -32,14 +32,14 @@ parameters: - name: search_chord description: "Keyboard shortcut to focus the in-app search box. Default ['command','f'] (macOS)." required: false - default: ["command", "f"] + default: '["command", "f"]' - name: send_keys description: | Chord that submits the message in this app. Default ['return'] works for WeChat/iMessage/Telegram. Use ['command','return'] for Slack/Lark where Return inserts a newline. required: false - default: ["return"] + default: '["return"]' steps: - domain: system diff --git a/src/crates/assembly/core/builtin_skills/miniapp-dev/references/examples/demo-git-graph/README.md b/src/crates/assembly/core/builtin_skills/miniapp-dev/references/examples/demo-git-graph/README.md index 84070c1dbf..b579c990df 100644 --- a/src/crates/assembly/core/builtin_skills/miniapp-dev/references/examples/demo-git-graph/README.md +++ b/src/crates/assembly/core/builtin_skills/miniapp-dev/references/examples/demo-git-graph/README.md @@ -28,7 +28,7 @@ This demo showcases OpenBitFun MiniApp's full-stack collaboration capability — 1. **UI → Bridge**: `app.call('git.log', { cwd, maxCount })` etc. via `window.app` (JSON-RPC) 2. **Bridge → Tauri**: postMessage intercepted by the host `useMiniAppBridge`, which calls `miniapp_worker_call` 3. **Tauri → Worker**: Rust writes the request to Worker stdin (JSON-RPC) -4. **Worker**: `worker_host.js` loads `source/worker.js`; exported handlers are invoked — primarily `git.graphData` (returns commits + refs + stashes + uncommitted in one response), plus `git.show`, `git.checkout`, `git.merge`, `git.push`, `git.stashPush`, and 20+ other methods — all backed by the `simple-git` npm package +4. **Worker**: `worker_host.cjs` loads `source/worker.js`; exported handlers are invoked — primarily `git.graphData` (returns commits + refs + stashes + uncommitted in one response), plus `git.show`, `git.checkout`, `git.merge`, `git.push`, `git.stashPush`, and 20+ other methods — all backed by the `simple-git` npm package 5. **Worker → Tauri → Bridge → UI**: response travels back via stderr → Rust → postMessage to iframe → UI refreshes graph and detail panel ### Directory Structure @@ -117,7 +117,7 @@ miniapps/git-graph/ 1. **UI → Bridge**:`app.call('git.log', { cwd, maxCount })` 等通过 `window.app` 发起 RPC 2. **Bridge → Tauri**:postMessage 被宿主 `useMiniAppBridge` 接收,调用 `miniapp_worker_call` 3. **Tauri → Worker**:Rust 将请求写入 Worker 进程 stdin(JSON-RPC) -4. **Worker**:`worker_host.js` 加载本目录 `source/worker.js`,其导出的处理函数被调用 — 主要是 `git.graphData`(一次返回提交 + refs + stash + 未提交变更),以及 `git.show`、`git.checkout`、`git.merge`、`git.push`、`git.stashPush` 等 20+ 个方法 — 均基于 `simple-git` npm 包 +4. **Worker**:`worker_host.cjs` 加载本目录 `source/worker.js`,其导出的处理函数被调用 — 主要是 `git.graphData`(一次返回提交 + refs + stash + 未提交变更),以及 `git.show`、`git.checkout`、`git.merge`、`git.push`、`git.stashPush` 等 20+ 个方法 — 均基于 `simple-git` npm 包 5. **Worker → Tauri → Bridge → UI**:响应经 stderr 回传 Rust,再 postMessage 回 iframe,UI 更新图谱与详情 ### 目录结构 diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index a6557fa352..9be3702c32 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -4398,6 +4398,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet tool_call_id.clone(), TASK_TOOL_NAME, ), + params: None, result: data.clone(), result_for_assistant: Some(assistant_text.clone()), image_attachments: None, @@ -4420,6 +4421,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet tool_call_id.clone(), TASK_TOOL_NAME, ), + params: None, reason: error_text.clone(), duration_ms: Some(duration_ms), queue_wait_ms: None, @@ -4433,6 +4435,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet tool_call_id.clone(), TASK_TOOL_NAME, ), + params: None, error_detail: None, error: error_text.clone(), duration_ms: Some(duration_ms), @@ -6096,28 +6099,29 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .or(remote_ssh_host.as_deref()), ) .await?; - match self - .restore_session_from_storage_path(&restore_path, &session_id) - .await - { - Ok(_) => { - let restored_messages = self - .session_manager - .get_context_messages(&session_id) - .await?; - info!( - "Session history restored from persistence: session_id={}, messages: {} -> {}", - session_id, - context_messages.len(), - restored_messages.len() - ); - } - Err(e) => { - debug!( - "Failed to restore session history (may be new session): session_id={}, error={}", - session_id, e - ); - } + let persisted_metadata = self + .session_manager + .persistence_manager() + .load_session_metadata(&restore_path, &session_id) + .await?; + if persisted_metadata.is_none() { + debug!( + "Session history restore skipped for new session: session_id={}", + session_id + ); + } else { + self.restore_session_from_storage_path(&restore_path, &session_id) + .await?; + let restored_messages = self + .session_manager + .get_context_messages(&session_id) + .await?; + info!( + "Session history restored from persistence: session_id={}, messages: {} -> {}", + session_id, + context_messages.len(), + restored_messages.len() + ); } } diff --git a/src/crates/assembly/core/src/agentic/execution/round_executor.rs b/src/crates/assembly/core/src/agentic/execution/round_executor.rs index b516eb1965..617ab34fe8 100644 --- a/src/crates/assembly/core/src/agentic/execution/round_executor.rs +++ b/src/crates/assembly/core/src/agentic/execution/round_executor.rs @@ -1419,6 +1419,7 @@ impl RoundExecutor { tool_call.tool_id.clone(), tool_call.tool_name.clone(), ), + params: None, error_detail: None, error: format!("Tool arguments stream interrupted: {}", error), duration_ms: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs index 19e4aaec38..e7c76a5598 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs @@ -144,6 +144,11 @@ impl PlaybookTool { /// Try to parse a string as a native JSON type (number / bool), falling /// back to a JSON string. fn parse_typed_value(s: &str) -> Value { + if let Ok(value) = serde_json::from_str::(s) { + if value.is_array() || value.is_object() || value.is_null() { + return value; + } + } if let Ok(n) = s.parse::() { return json!(n); } diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs index 3f7ce61ec3..30586b08dc 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs @@ -189,6 +189,7 @@ impl ToolStateManager { confirmation_wait_ms, execution_ms, } => ToolStateEventKind::Completed { + params: Some(task.invocation.wire_arguments.clone()), result: result.content(), result_for_assistant: match result { crate::agentic::tools::framework::ToolResult::Result { @@ -220,6 +221,7 @@ impl ToolStateManager { confirmation_wait_ms, execution_ms, } => ToolStateEventKind::Failed { + params: Some(task.invocation.wire_arguments.clone()), error_detail: error_detail.clone(), error: error.clone(), duration_ms: *duration_ms, @@ -237,6 +239,7 @@ impl ToolStateManager { confirmation_wait_ms, execution_ms, } => ToolStateEventKind::Cancelled { + params: Some(task.invocation.wire_arguments.clone()), reason: reason.clone(), duration_ms: *duration_ms, queue_wait_ms: *queue_wait_ms, diff --git a/src/crates/assembly/core/src/miniapp/js_worker_pool.rs b/src/crates/assembly/core/src/miniapp/js_worker_pool.rs index dd16e31589..126db01664 100644 --- a/src/crates/assembly/core/src/miniapp/js_worker_pool.rs +++ b/src/crates/assembly/core/src/miniapp/js_worker_pool.rs @@ -29,11 +29,13 @@ impl JsWorkerPool { pub fn new( path_manager: Arc, worker_host_path: PathBuf, + resource_dir: Option, ) -> OpenBitFunResult { let event_sink: SharedMiniAppWorkerEventSink = Arc::new(CoreMiniAppWorkerEventSink); ServiceJsWorkerPool::new( path_manager.miniapps_dir(), worker_host_path, + resource_dir, Some(event_sink), ) .map(|inner| Self { inner }) @@ -183,6 +185,7 @@ impl JsWorkerPool { inner: ServiceJsWorkerPool::from_runtime( path_manager.miniapps_dir(), worker_host_path, + None, runtime, Some(Arc::new(CoreMiniAppWorkerEventSink)), ), diff --git a/src/crates/assembly/core/src/miniapp/loopx/AGENTS-CN.md b/src/crates/assembly/core/src/miniapp/loopx/AGENTS-CN.md new file mode 100644 index 0000000000..f4a5138ae0 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/AGENTS-CN.md @@ -0,0 +1,58 @@ +**English** | [中文](AGENTS.md) + +# LoopX 宿主子系统指南 + +范围:BitFun 对 LoopX 受控工作的宿主适配层,横跨两个 crate: + +- `src/crates/assembly/core/src/miniapp/loopx/` — controller(调度、结算应用、恢复)、agent 适配器、会话生命周期 +- `src/crates/services/services-integrations/src/miniapp/loopx_cli.rs` — 固定版本 CLI 适配器:guard/turn/settle 命令形状、turn 指令组装、结算证据验证 +- `src/crates/services/services-integrations/src/miniapp/loopx_workspace.rs` — worktree 生命周期(prepare/dispose/reset) + +Codex 运行 LoopX 不需要任何宿主定制逻辑;这一层是 BitFun 的产品差异所在, +因此其规则比通用 agent-loop 指引更严格。 + +## 结算契约 + +一个 LoopX turn 只有拿到**两件套回执**才算结算:与该 turn guard 绑定 +(选中的 todo 或 replan 义务)匹配的持久写回,以及同一 effect id 的配额 +花费回执。Prompt 指引要求 agent 产出两者;验证永远不信任文字声称。 + +## 宿主兜底原则 + +**Prompt 加固只能降低 agent 出错的概率;只有宿主侧补偿才能消灭这一类失败。** +多步收尾序列(写回 → 花费 → 终态 vision)依赖模型自愿执行每一步,实测 +有时不会。选择修复方式的规则: + +1. **机械的、可从宿主状态推导的步骤归宿主。** 如果 controller 已持有 + 确切值(guard 绑定、turn id、已记录 vision patch、spend 命令形状), + 由宿主生成或补偿该步骤,而不是让 agent 重新推导。例:settle 路径 + 自行补偿缺失的配额花费(`quota_spend_compensation_args`),因为 spend + 是幂等记账,不是语义声明。 +2. **语义步骤留给 agent,但宿主预先解析其输入。** 终态 vision packet 必须逐字 + 复用已记录的 durable 字段;宿主通过 CLI 自己的 status 投影查询已记录 + vision 并嵌入 turn 指令,让 agent 复制而不是创作。 +3. **永远不伪造证据。** 宿主补偿必须可审计且幂等;补偿的 spend 要记日志, + 永久缺失的回执要响亮降级(恢复卡片),绝不静默。 + +## 已知 agent 失败模式与防线 + +实测出现(2026-09-10 三 issue 实验)并已有防线——出现新模式时保持更新: + +| 失败模式 | 防线 | +| --- | --- | +| 终态 vision packet 改写了 durable 字段 → `outcome=replan` 与 `no_followup` 不可满足对 | 宿主逐字嵌入已记录 vision + 逐字符比对指令(`render_agent_reentry_instruction`) | +| turn 中途创建 successor todo 并用 `--todo-id` 结算绑定在 replan 义务上的 turn | work clause 中的 turn 作用域绑定规则;CLI 拒绝,下一个 turn 自愈 | +| 写回验证通过但跳过配额花费 | `verify_turn_settlement` 的宿主侧 spend 补偿;prompt 标记 spend 为 MANDATORY | +| 停止后 workspace 根目录重命名失败(残留句柄) | 有界退避的重命名重试 + turn 取消等待完全排空 | + +出现新失败模式时,先判断该步骤是机械的(宿主补偿)还是语义的(宿主预解析 +输入),然后在该层修复。不要只靠堆更多 prompt 文本来响应重复的 agent 错误。 + +## 验证 + +```bash +cargo test -p openbitfun-services-integrations --no-default-features --features miniapp-loopx --lib -- loopx_cli +cargo test -p openbitfun-services-integrations --no-default-features --features miniapp-loopx --test miniapp_loopx_contracts +``` + +修改 turn 指引时增改指令组装测试;修改补偿逻辑时增改结算测试。 diff --git a/src/crates/assembly/core/src/miniapp/loopx/AGENTS.md b/src/crates/assembly/core/src/miniapp/loopx/AGENTS.md new file mode 100644 index 0000000000..6b288a2b56 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/AGENTS.md @@ -0,0 +1,73 @@ +[中文](AGENTS-CN.md) | **English** + +# LoopX Host Subsystem Guide + +Scope: the BitFun host adaptation layer for LoopX-controlled work. This spans +two crates: + +- `src/crates/assembly/core/src/miniapp/loopx/` — controller (scheduling, + settlement application, recovery), agent adapter, session lifecycle +- `src/crates/services/services-integrations/src/miniapp/loopx_cli.rs` — + pinned-CLI adapter: guard/turn/settle command shapes, turn instruction + composition, settlement evidence verification +- `src/crates/services/services-integrations/src/miniapp/loopx_workspace.rs` — + worktree lifecycle (prepare/dispose/reset) + +Codex runs LoopX with no host-specific logic; this layer is BitFun's product +difference, so its rules are stricter than the generic agent-loop guidance. + +## Settlement contract + +A LoopX turn settles only on the **two-part receipt**: a durable writeback +(matching the turn's guard binding: selected todo or replan obligation) AND a +quota spend receipt for the same effect id. Prompt guidance asks the agent to +produce both; verification never trusts prose. + +## The host-fallback principle + +**Prompt hardening reduces the probability of an agent mistake; only host-side +compensation removes the failure class.** Multi-step closing sequences +(writeback → spend → terminal vision) rely on the model executing every step +voluntarily, and live runs show it sometimes does not. The rule for choosing a +fix: + +1. **Mechanical, derivable-from-host-state steps belong to the host.** If the + controller already holds the exact values (guard binding, turn id, recorded + vision patch, spend command shape), generate or compensate the step + host-side instead of asking the agent to re-derive it. Example: the settle + path compensates a missing quota spend itself (`quota_spend_compensation_args`) + because spend is idempotent bookkeeping, not a semantic claim. +2. **Semantic steps stay with the agent, but the host pre-resolves their + inputs.** The terminal vision packet must reuse recorded durable fields + verbatim; the host queries the recorded vision through the CLI's own status + projection and embeds it in the turn instruction, so the agent copies + instead of authoring. +3. **Never fabricate evidence.** Host compensation must be auditable and + idempotent; a compensated spend is logged, and a permanently missing + receipt degrades loudly (recovery card), never silently. + +## Known agent failure modes and their defenses + +Observed live (2026-09-10 three-issue experiment) and their current defenses — +keep this list current when a new mode appears: + +| Failure mode | Defense | +| --- | --- | +| Terminal vision packet reworded durable fields → unsatisfiable `outcome=replan` vs `no_followup` pair | Host embeds recorded vision verbatim + character-compare instruction (`render_agent_reentry_instruction`) | +| Successor todo created mid-turn and settled with `--todo-id` against a turn bound to the replan obligation | Turn-scoped binding rule in the work clause; CLI rejects, next turn self-heals | +| Writeback validated but quota spend skipped | Host-side spend compensation in `verify_turn_settlement`; prompt marks the spend MANDATORY | +| Workspace root rename fails after stop (lingering handles) | Rename retry with bounded backoff + turn-cancel waits for full drain | + +When a new failure mode appears, first ask whether the step is mechanical +(host-compensate) or semantic (host pre-resolves inputs), then fix at that +layer. Do not respond to a repeated agent mistake with more prompt text alone. + +## Verification + +```bash +cargo test -p openbitfun-services-integrations --no-default-features --features miniapp-loopx --lib -- loopx_cli +cargo test -p openbitfun-services-integrations --no-default-features --features miniapp-loopx --test miniapp_loopx_contracts +``` + +Add or extend instruction-composition tests when changing turn guidance, and +settlement tests when changing compensation logic. diff --git a/src/crates/assembly/core/src/miniapp/loopx/agent_adapter.rs b/src/crates/assembly/core/src/miniapp/loopx/agent_adapter.rs new file mode 100644 index 0000000000..be05904846 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/agent_adapter.rs @@ -0,0 +1,658 @@ +use super::tool_activity::project_tool_activity; +use crate::agentic::coordination::ConversationCoordinator; +use openbitfun_agent_runtime::permission::PERMISSION_MODE_CONTEXT_KEY; +use openbitfun_events::AgenticEvent; +use openbitfun_product_domains::miniapp::loopx::{ + required_permission_scopes_are_granted, LoopxAgentCancelRequest, LoopxAgentCancelResult, + LoopxAgentFinishRequest, LoopxAgentFinishResult, LoopxAgentOutputSinceRequest, + LoopxAgentOutputSinceResult, LoopxAgentPort, LoopxAgentProbeRequest, LoopxAgentProbeResult, + LoopxAgentResetRequest, LoopxAgentResetResult, LoopxAgentStartRequest, LoopxAgentStartResult, + LoopxHostFuture, LoopxHostPortError, LoopxHostPortErrorKind, LoopxTurnOutputEvent, + LoopxTurnOutputEventKind, LOOPX_BUILTIN_APP_ID, +}; +use openbitfun_runtime_ports::{ + AgentSessionCreateRequest, AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionSource, + AgentTurnCancellationPort, AgentTurnCancellationRequest, PermissionMode, +}; +use std::path::Path; +use std::sync::Arc; + +const LOOPX_AGENT_TYPE: &str = "agentic"; +const LOOPX_AGENT_CAPABILITIES: &[&str] = &[ + "filesystem_read", + "filesystem_write", + "shell", + "network", + "external_evidence_poll", +]; + +pub struct CoreLoopxAgentPort { + coordinator: Arc, +} + +impl CoreLoopxAgentPort { + pub fn new(coordinator: Arc) -> Self { + Self { coordinator } + } +} + +impl LoopxAgentPort for CoreLoopxAgentPort { + fn available_capabilities(&self) -> Vec { + LOOPX_AGENT_CAPABILITIES + .iter() + .map(|capability| (*capability).to_string()) + .collect() + } + + fn probe(&self, request: LoopxAgentProbeRequest) -> LoopxHostFuture<'_, LoopxAgentProbeResult> { + Box::pin(async move { + let config_service = crate::service::config::get_global_config_service() + .await + .map_err(|error| { + host_error( + LoopxHostPortErrorKind::Backend, + format!("LoopX Agent model configuration is unavailable: {error}"), + &request.operation_id, + ) + })?; + let global_config: crate::service::config::types::GlobalConfig = + config_service.get_config(None).await.map_err(|error| { + host_error( + LoopxHostPortErrorKind::Backend, + format!("LoopX Agent model configuration could not be read: {error}"), + &request.operation_id, + ) + })?; + let requested = request.model_id.as_deref().unwrap_or("auto").trim(); + let selector = if requested.is_empty() || matches!(requested, "auto" | "primary") { + "primary" + } else { + requested + }; + let model_id = global_config + .ai + .resolve_model_selection(selector) + .ok_or_else(|| { + host_error( + LoopxHostPortErrorKind::NotFound, + format!("LoopX Agent model '{selector}' is not configured or enabled"), + &request.operation_id, + ) + })?; + let model = global_config + .ai + .models + .iter() + .find(|model| model.id == model_id && model.enabled) + .ok_or_else(|| { + host_error( + LoopxHostPortErrorKind::NotFound, + format!("LoopX Agent model '{model_id}' is unavailable"), + &request.operation_id, + ) + })?; + if !model.supports_text_generation() { + return Err(host_error( + LoopxHostPortErrorKind::Unsupported, + format!("LoopX Agent model '{model_id}' does not support text chat"), + &request.operation_id, + )); + } + let supports_images = model.supports_image_understanding(); + Ok(LoopxAgentProbeResult { + model_id, + supports_images, + }) + }) + } + + fn start(&self, request: LoopxAgentStartRequest) -> LoopxHostFuture<'_, LoopxAgentStartResult> { + Box::pin(async move { + if request.worktree_path.trim().is_empty() { + return Err(host_error( + LoopxHostPortErrorKind::InvalidInput, + "LoopX Agent worktree path is required", + &request.operation_id, + )); + } + if !required_permission_scopes_are_granted(&request.granted_scopes) { + return Err(host_error( + LoopxHostPortErrorKind::InvalidInput, + "LoopX Agent cannot run headlessly without every required intake permission scope", + &request.operation_id, + )); + } + let turn_id = format!("loopx-turn-{}", uuid::Uuid::new_v4()); + let task_id = request.task_id.clone(); + let metadata = loopx_session_metadata(&request); + // Codex-parity session reuse: continue the goal's live agent + // conversation when the host kept one, so the pinned skill + // document, project context, and prior turn outcomes stay in the + // conversation instead of being re-read every turn. A missing + // session (host restart, discarded session) falls back to a fresh + // transient session; transient sessions are memory-resident and + // `submit_message` fails fast with NotFound in that case. + let reuse_session_id = request + .reuse_session_id + .as_deref() + .map(str::trim) + .unwrap_or_default() + .to_string(); + if !reuse_session_id.is_empty() { + match AgentSubmissionPort::submit_message( + self.coordinator.as_ref(), + AgentSubmissionRequest { + session_id: reuse_session_id.clone(), + message: request.instruction.clone(), + turn_id: Some(turn_id.clone()), + source: Some(AgentSubmissionSource::DesktopApi), + attachments: Vec::new(), + metadata: metadata.clone(), + }, + ) + .await + { + Ok(submitted) if submitted.accepted => { + log::info!( + "LoopX Agent turn accepted in reused session: task_id={}, session_id={}, turn_id={}", + task_id, + reuse_session_id, + submitted.turn_id + ); + return Ok(LoopxAgentStartResult { + session_id: reuse_session_id, + turn_id: submitted.turn_id, + }); + } + Ok(_) => { + log::warn!( + "LoopX Agent session reuse was not accepted; starting a fresh transient session: task_id={}, session_id={}", + task_id, + reuse_session_id + ); + } + Err(error) => { + log::warn!( + "LoopX Agent session reuse failed; starting a fresh transient session: task_id={}, session_id={}, error={}", + task_id, + reuse_session_id, + error + ); + } + } + } + let session_id = format!("loopx-{}", uuid::Uuid::new_v4()); + let created = AgentSubmissionPort::create_transient_session_with_id( + self.coordinator.as_ref(), + session_id.clone(), + AgentSessionCreateRequest { + session_name: format!("LoopX #{}", request.metadata.item.number), + agent_type: LOOPX_AGENT_TYPE.to_string(), + agent_route_key: None, + workspace_path: Some(request.worktree_path), + project_workspace_path: None, + execution_target: None, + workspace_id: None, + remote_connection_id: None, + remote_ssh_host: None, + model_id: (!request.model_id.trim().is_empty() && request.model_id != "auto") + .then_some(request.model_id), + metadata: metadata.clone(), + }, + ) + .await + .map_err(|error| map_port_error(error, &request.operation_id))?; + log::info!( + "LoopX transient Agent session created: task_id={}, session_id={}, requested_turn_id={}", + task_id, + created.session_id, + turn_id + ); + let submitted = AgentSubmissionPort::submit_message( + self.coordinator.as_ref(), + AgentSubmissionRequest { + session_id: created.session_id.clone(), + message: request.instruction, + turn_id: Some(turn_id.clone()), + source: Some(AgentSubmissionSource::DesktopApi), + attachments: Vec::new(), + metadata, + }, + ) + .await + .map_err(|error| map_port_error(error, &request.operation_id))?; + if !submitted.accepted { + let _ = self + .coordinator + .discard_transient_session( + Path::new(&created.workspace_path.unwrap_or_default()), + None, + None, + &created.session_id, + ) + .await; + return Err(host_error( + LoopxHostPortErrorKind::Conflict, + "LoopX Agent turn was not accepted", + &request.operation_id, + )); + } + log::info!( + "LoopX Agent turn accepted: task_id={}, session_id={}, turn_id={}", + task_id, + created.session_id, + submitted.turn_id + ); + Ok(LoopxAgentStartResult { + session_id: created.session_id, + turn_id: submitted.turn_id, + }) + }) + } + + fn cancel( + &self, + request: LoopxAgentCancelRequest, + ) -> LoopxHostFuture<'_, LoopxAgentCancelResult> { + Box::pin(async move { + // Deliberately leave `turn_id` empty so the port takes the + // active-turn path: the turn-id path only signals cancellation + // tokens and returns after a hardcoded 1.5s in-memory drain, while + // the active-turn path waits (bounded by `wait_timeout_ms`) until + // the execution engine reports the turn as fully stopped. The + // LoopX host tears the whole task down, so cancelling whichever + // turn is currently active for the session is exactly the target + // set; a task whose turn already settled reports no active turn + // and proceeds. Waiting matters because the workspace reset that + // follows renames the workspace root, and on Windows that fails + // with access denied while any agent child process still holds a + // handle or CWD inside the tree (live 2026-09-10). + let result = AgentTurnCancellationPort::cancel_turn( + self.coordinator.as_ref(), + AgentTurnCancellationRequest { + session_id: request.session_id, + turn_id: None, + source: Some(AgentSubmissionSource::DesktopApi), + requester_session_id: None, + reason: Some("LoopX task paused by the user".to_string()), + wait_timeout_ms: Some(10_000), + cancel_descendants: true, + }, + ) + .await + .map_err(|error| map_port_error(error, &request.operation_id))?; + Ok(LoopxAgentCancelResult { + target_operation_id: request.target_operation_id, + cancelled: result.requested, + }) + }) + } + + fn finish( + &self, + request: LoopxAgentFinishRequest, + ) -> LoopxHostFuture<'_, LoopxAgentFinishResult> { + Box::pin(async move { + let discarded = self + .coordinator + .discard_transient_session( + Path::new(&request.worktree_path), + None, + None, + &request.session_id, + ) + .await + .map_err(|error| { + host_error( + LoopxHostPortErrorKind::Backend, + error.to_string(), + &request.operation_id, + ) + })?; + log::info!( + "LoopX transient Agent session discarded: task_id={}, session_id={}, discarded={}", + request.task_id, + request.session_id, + discarded + ); + Ok(LoopxAgentFinishResult { + session_id: request.session_id, + discarded, + }) + }) + } + + fn reset(&self, request: LoopxAgentResetRequest) -> LoopxHostFuture<'_, LoopxAgentResetResult> { + Box::pin(async move { + let path_manager = + crate::infrastructure::try_get_path_manager_arc().map_err(|error| { + host_error( + LoopxHostPortErrorKind::Backend, + error.to_string(), + &request.operation_id, + ) + })?; + let root = crate::service::session_projection_store::runtime_event_log_dir( + path_manager.as_ref(), + ); + let mut entries = match tokio::fs::read_dir(&root).await { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(LoopxAgentResetResult::default()) + } + Err(error) => { + return Err(host_error( + LoopxHostPortErrorKind::Io, + format!("Failed to read LoopX runtime event directory: {error}"), + &request.operation_id, + )) + } + }; + let mut removed = 0_u32; + while let Some(entry) = entries.next_entry().await.map_err(|error| { + host_error( + LoopxHostPortErrorKind::Io, + format!("Failed to enumerate LoopX runtime event logs: {error}"), + &request.operation_id, + ) + })? { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if !name.starts_with("loopx-") || !name.ends_with(".jsonl") { + continue; + } + if !entry + .file_type() + .await + .map_err(|error| { + host_error( + LoopxHostPortErrorKind::Io, + format!("Failed to inspect LoopX runtime event log: {error}"), + &request.operation_id, + ) + })? + .is_file() + { + continue; + } + tokio::fs::remove_file(entry.path()) + .await + .map_err(|error| { + host_error( + LoopxHostPortErrorKind::Io, + format!("Failed to remove LoopX runtime event log: {error}"), + &request.operation_id, + ) + })?; + removed = removed.saturating_add(1); + } + Ok(LoopxAgentResetResult { + removed_runtime_event_logs: removed, + }) + }) + } + + fn output_since( + &self, + request: LoopxAgentOutputSinceRequest, + ) -> LoopxHostFuture<'_, LoopxAgentOutputSinceResult> { + Box::pin(async move { + let path_manager = + crate::infrastructure::try_get_path_manager_arc().map_err(|error| { + host_error( + LoopxHostPortErrorKind::Backend, + error.to_string(), + &request.operation_id, + ) + })?; + let root = crate::service::session_projection_store::runtime_event_log_dir( + path_manager.as_ref(), + ); + let page = crate::service::session_projection_store::read_runtime_events_since( + &root, + &request.session_id, + request.stream_id.as_deref(), + request.after_cursor, + request.limit, + ) + .map_err(|error| { + host_error(LoopxHostPortErrorKind::Io, error, &request.operation_id) + })?; + let Some(page) = page else { + return Ok(LoopxAgentOutputSinceResult { + next_cursor: request.after_cursor, + ..LoopxAgentOutputSinceResult::default() + }); + }; + let events = page + .events + .into_iter() + .filter_map(|record| { + turn_output_event(record.cursor, &request.turn_id, record.event) + }) + .collect(); + Ok(LoopxAgentOutputSinceResult { + stream_id: Some(page.stream_id), + events, + next_cursor: page.next_cursor, + has_more: page.has_more, + }) + }) + } +} + +fn turn_output_event( + cursor: u64, + expected_turn_id: &str, + event: AgenticEvent, +) -> Option { + if event.turn_id() != Some(expected_turn_id) { + return None; + } + let mut output = match event { + AgenticEvent::TextChunk { + turn_id, + round_id, + text, + .. + } => Some(LoopxTurnOutputEvent { + cursor, + turn_id, + round_id: Some(round_id), + kind: LoopxTurnOutputEventKind::Text, + text: Some(text), + ..LoopxTurnOutputEvent::default() + }), + AgenticEvent::ThinkingChunk { + turn_id, + round_id, + content, + is_end, + .. + } => Some(LoopxTurnOutputEvent { + cursor, + turn_id, + round_id: Some(round_id), + kind: LoopxTurnOutputEventKind::Thinking, + text: Some(content), + is_end, + ..LoopxTurnOutputEvent::default() + }), + AgenticEvent::ModelRoundStarted { + turn_id, + round_id, + effective_model_name, + .. + } => Some(LoopxTurnOutputEvent { + cursor, + turn_id, + round_id: Some(round_id), + kind: LoopxTurnOutputEventKind::ModelRoundStarted, + text: Some(format!("Model round started: {effective_model_name}")), + ..LoopxTurnOutputEvent::default() + }), + AgenticEvent::ModelRoundCompleted { + turn_id, + round_id, + duration_ms, + .. + } => Some(LoopxTurnOutputEvent { + cursor, + turn_id, + round_id: Some(round_id), + kind: LoopxTurnOutputEventKind::ModelRoundCompleted, + text: Some(match duration_ms { + Some(value) => format!("Model round completed in {value} ms"), + None => "Model round completed".to_string(), + }), + ..LoopxTurnOutputEvent::default() + }), + AgenticEvent::ToolEvent { + turn_id, + round_id, + tool_event, + .. + } => { + let activity = project_tool_activity(&tool_event)?; + let text = activity + .details + .get("summary") + .cloned() + .unwrap_or(activity.message); + Some(LoopxTurnOutputEvent { + cursor, + turn_id, + round_id: Some(round_id), + kind: LoopxTurnOutputEventKind::Tool, + text: Some(text), + tool_name: Some(activity.tool_name), + tool_state: Some(activity.state.to_string()), + ..LoopxTurnOutputEvent::default() + }) + } + _ => None, + }; + if let Some(value) = output.as_mut() { + value.at_ms = Some(now_millis()); + } + output +} + +fn now_millis() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as i64) + .unwrap_or_default() +} + +fn loopx_session_metadata( + request: &LoopxAgentStartRequest, +) -> serde_json::Map { + serde_json::Map::from_iter([ + ("surface".to_string(), serde_json::json!("miniapp_agent")), + ("appId".to_string(), serde_json::json!(LOOPX_BUILTIN_APP_ID)), + ( + "loopxTaskId".to_string(), + serde_json::json!(request.task_id.clone()), + ), + ( + "generation".to_string(), + serde_json::json!(request.generation), + ), + ( + "goalId".to_string(), + serde_json::json!(request.metadata.goal_id.clone()), + ), + ( + "loopxTurnId".to_string(), + serde_json::json!(request.metadata.loopx_turn_id.clone()), + ), + ( + PERMISSION_MODE_CONTEXT_KEY.to_string(), + serde_json::json!(PermissionMode::AutoApprove.as_str()), + ), + ]) +} + +fn map_port_error( + error: openbitfun_runtime_ports::PortError, + operation_id: &str, +) -> LoopxHostPortError { + host_error( + LoopxHostPortErrorKind::Backend, + error.to_string(), + operation_id, + ) +} + +fn host_error( + kind: LoopxHostPortErrorKind, + message: impl Into, + operation_id: &str, +) -> LoopxHostPortError { + LoopxHostPortError { + kind, + message: message.into(), + operation_id: Some(operation_id.to_string()), + retryable: false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openbitfun_events::{ToolEventData, ToolEventIdentity}; + use openbitfun_product_domains::miniapp::loopx::{ + LoopxAgentTurnMetadata, LoopxIssueKey, LoopxItemKind, LoopxRepositoryKey, + LOOPX_REQUIRED_PERMISSION_SCOPES, + }; + + #[test] + fn loopx_sessions_use_the_transient_miniapp_surface_contract() { + let request = LoopxAgentStartRequest { + task_id: "task-1".to_string(), + generation: 2, + granted_scopes: LOOPX_REQUIRED_PERMISSION_SCOPES.to_vec(), + metadata: LoopxAgentTurnMetadata { + goal_id: "goal-1".to_string(), + loopx_turn_id: "turn-1".to_string(), + item: LoopxIssueKey { + repository: LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }, + kind: LoopxItemKind::Issue, + number: 42, + }, + attempt: 1, + }, + ..LoopxAgentStartRequest::default() + }; + + let metadata = loopx_session_metadata(&request); + assert_eq!(metadata["surface"], serde_json::json!("miniapp_agent")); + assert_eq!(metadata["appId"], serde_json::json!(LOOPX_BUILTIN_APP_ID)); + assert_eq!( + metadata[PERMISSION_MODE_CONTEXT_KEY], + serde_json::json!(PermissionMode::AutoApprove.as_str()) + ); + } + + #[test] + fn live_output_omits_partial_tool_parameters() { + let event = AgenticEvent::ToolEvent { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::ParamsPartial { + identity: ToolEventIdentity::direct("tool-1", "ExecCommand"), + params: "{\"cmd\":\"cargo".to_string(), + }, + }; + + assert!(turn_output_event(1, "turn-1", event).is_none()); + } +} diff --git a/src/crates/assembly/core/src/miniapp/loopx/controller.rs b/src/crates/assembly/core/src/miniapp/loopx/controller.rs new file mode 100644 index 0000000000..58ea7d8e2f --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/controller.rs @@ -0,0 +1,6990 @@ +use super::tool_activity::ToolActivityProjection; +use super::{LoopxPersistedState, LoopxStateStore, LoopxTaskRuntimeRecord}; +use crate::util::elapsed_ms_u64; +use openbitfun_product_domains::miniapp::loopx::*; +use std::collections::{BTreeMap, HashMap}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::sync::{broadcast, mpsc, Mutex, RwLock}; + +const DEFAULT_AGENT_ID: &str = "bitfun-agent"; +const EVENT_CHANNEL_CAPACITY: usize = 256; +const INTAKE_PREVIEW_TTL_MS: i64 = 5 * 60 * 1000; +const MAX_INTAKE_PREVIEWS: usize = 64; +const MAX_AGENT_SUMMARY_CHARS: usize = 16_000; +const GOAL_RECONCILE_TTL_MS: i64 = 30_000; +const GOAL_RECONCILE_DEADLINE_MS: i64 = 30_000; +/// Default requeue delay for a waiting goal when the envelope projects no +/// cadence label at all (degraded or salvaged snapshots). Bounded so a task +/// can never sleep forever, but far above LoopX's own minimum cadence +/// (`active_work` wakes after 3 minutes) so an unlabeled wait never becomes a +/// hot poll of the control plane. +const WAIT_RESCHEDULE_FALLBACK_MS: u64 = 5 * 60 * 1000; +/// Backoff before re-driving after a retryable turn-build conflict. +const TURN_CONFLICT_RETRY_MS: u64 = 5_000; + +/// One-shot host note appended to the corrective turn instruction after a +/// NoDurableProgress settlement. The note routes the agent through the LoopX +/// CLI write boundary so settlement can validate the writeback; it never +/// fabricates goal state on the agent's behalf. +const LOOPX_DURABLE_COMPENSATION_NOTE: &str = "The previous turn finished, but LoopX settlement reported no validated durable progress. Re-submit the pending vision and resolution artifacts through the LoopX CLI write boundary (`loopx refresh-state`) so they are recorded inside the goal workspace; do not write these artifacts to paths outside the workspace such as the system temp directory. If the previous turn modified product source files under the worktree but did not commit them, commit those product changes to the task branch with a descriptive message before ending the turn (leave `.loopx`/`.codex` bookkeeping out of the commit). When the writeback receipts are confirmed, end the turn so settlement can validate them."; + +/// Environment boundary appended to every LoopX agent turn instruction. +/// +/// The pinned LoopX CLI provided by the host is the only authoritative source +/// for LoopX behavior, commands, flags, and schemas. Users may have LoopX +/// source checkouts elsewhere on the machine (any tree containing +/// `loopx/pyproject.toml`, a `loopx/capabilities/` layout, and so on); those +/// trees can be a different version than the pinned runtime, so treating them +/// as documentation derails the turn (observed live as a different-version +/// LoopX checkout steering a turn executed by the pinned CLI, including a +/// hallucinated capability path retried over a hundred times). The runtime +/// must work identically whether or not such a checkout exists. +const LOOPX_AGENT_ENVIRONMENT_BOUNDARY_NOTE: &str = "\n\n---\n[BitFun environment boundary] The LoopX runtime on this machine is the CLI binary provided by the BitFun host at a pinned version; it is the only authoritative source for LoopX behavior, commands, flags, and schemas. Consult `loopx --help`, the help of the exact subcommand, or artifacts inside the goal workspace instead. Do not read, grep, or follow any LoopX source checkout on this machine (for example any directory containing `loopx/pyproject.toml`, a `loopx/capabilities/` tree, or a similar source layout): such trees may be a different version than the pinned runtime and are not documentation. Do not unpack, extract, or reverse-engineer the pinned binary itself either, including its PyInstaller `_MEI*` temporary extraction directory, its embedded archive, or the executable bytes: the capability contracts you need are delivered as files under this worktree's `.loopx/` directory, and anything those files do not cover is a host-side blocker to report, not a puzzle to solve locally. Do not load, read, or follow any `loopx` or `loopx-*` entries from your skill catalog or from user-level skill directories (`~/.codex/skills`, `~/.agents/skills`): other LoopX installations of a different version may have placed them there, and the authoritative LoopX workflow documents for this task are ONLY the pinned files under this worktree's `.loopx/` directory that this instruction names - when a LoopX document tells you to load another `loopx-*` skill, read the matching seeded `.loopx/` file instead of resolving the skill name through the catalog. Never install, update, self-update, or repair the LoopX installation (for example `loopx update`, `loopx self-repair` install flows, `scripts/install-local.sh`, or `scripts/install-windows.ps1`): the BitFun host owns the pinned binary, and installation repair is a host concern, never a task action. GitHub EXTERNAL WRITES are owner-gated: do NOT run `git push` to a remote, `gh pr create`, `gh issue comment`, `gh pr merge/close`, or any other GitHub write unless this turn's contract explicitly carries that approval. LoopX plans external writes behind a user gate (`requires_user_gate_before_external_write`); a todo's text (for example \"open a PR\") is a plan description, NOT an authorization. Prepare the branch and local validation, record the publish recommendation in your report, and stop - the owner approves publication from the host UI. GitHub reads stay allowed, but use the `gh` CLI for ALL GitHub data (issues, PRs, comments, releases): direct WebFetch calls to github.com / api.github.com are rejected with HTTP 403 (observed repeatedly). If a file path you assumed does not exist, do not retry the same path; re-derive it from CLI help output or goal-workspace artifacts."; + +/// Host-side compensation for the pinned sidecar: the pinned LoopX CLI does +/// not bundle the workflow-skill markdown, so this exact CLI reference (help +/// output of the pinned version, captured at build time) is seeded into each +/// worktree as `.loopx/pinned-loopx-reference.md`; the agent reads it once +/// per session instead of the host reverse-engineering commands. +const LOOPX_PINNED_CLI_REFERENCE: &str = include_str!("resources/loopx-pinned-cli-reference.md"); + +/// Verbatim LoopX workflow-skill documents from the pinned v1.0.1 source +/// (`skills/loopx-project/SKILL.md` + `skills/loopx-self-repair/SKILL.md`). +/// This is the same first-party documentation a LoopX-style agent host (e.g. +/// the codex path) loads at session start - it is the ROOT-CAUSE fix for the +/// agent inventing packet shapes: the official docs contain the exact +/// `goal_vision_replan_contract_v0` / `vision_patch` schema and the +/// refresh-state closure flags, which `--help` output does not. Seeded into +/// each worktree alongside the CLI reference; the agent reads the file once +/// per session (mirroring the codex skill-loading mechanism) instead of the +/// host pasting the bytes into every instruction. +const LOOPX_PINNED_SKILLS_REFERENCE: &str = + include_str!("resources/loopx-pinned-skills-reference.md"); + +/// Additional official workflow-skill documents delivered per the custom-host +/// integration guide ("deliver loopx-project, loopx-pr-program, loopx-pr-review, +/// loopx-doc-registry and loopx-self-repair from the same LoopX revision"); +/// change-quality is additional and activated by goal policy, so it is seeded +/// but the agent reads it only when a quality-qualified step applies. +const LOOPX_PINNED_SKILL_DOC_REGISTRY: &str = + include_str!("resources/pinned-skill-loopx-doc-registry.md"); +const LOOPX_PINNED_SKILL_PR_PROGRAM: &str = + include_str!("resources/pinned-skill-loopx-pr-program.md"); +const LOOPX_PINNED_SKILL_PR_REVIEW: &str = + include_str!("resources/pinned-skill-loopx-pr-review.md"); +const LOOPX_PINNED_SKILL_CHANGE_QUALITY: &str = + include_str!("resources/pinned-skill-loopx-change-quality.md"); + +/// The pinned issue-fix capability reference: the CLI and payload contract for +/// every `loopx issue-fix` subcommand, including the `--*-json` inputs the +/// caller must supply (`issue_fix_candidate_resolution_v0`, +/// `issue_fix_repository_context_input_v0`, ...). +/// +/// This is a SECOND documentation layer, distinct from the workflow skills +/// above: the skills describe how to drive a goal, this describes the payload +/// schemas a caller has to author. Delivering only the skill layer leaves the +/// agent able to discover a required schema one field at a time from the CLI's +/// typed refusals (observed live 2026-09-10: eight rounds spent hand-editing a +/// `--candidate-resolution-json` payload because `rows[].kind` is documented +/// here and nowhere else the agent is permitted to read - the environment +/// boundary note forbids it from reading the LoopX source tree). +const LOOPX_PINNED_ISSUE_FIX_REFERENCE: &str = + include_str!("resources/loopx-pinned-issue-fix-reference.md"); + +/// The pinned issue-fix workflow contract: the ordered capability contract +/// (evidence -> admission -> feasibility -> plan) plus the route vocabulary +/// (`fix_pr` / `comment_only` / `triage_only`). Companion to the reference +/// above; the two are the capability-protocol layer for issue-fix goals. +const LOOPX_PINNED_ISSUE_FIX_WORKFLOW_CONTRACT: &str = + include_str!("resources/loopx-pinned-issue-fix-workflow-contract.md"); + +/// Host-authored extract of the issue-fix JSON payload contracts, because LoopX +/// documents part of them ONLY in source code: the `rows[].kind` vocabulary and +/// its per-kind `outcome` sets live in +/// `loopx/capabilities/issue_fix/candidate_preflight.py:25-29` and appear in no +/// markdown file - not even in the capability README. +/// +/// Seeding only LoopX's own documents therefore leaves the agent unable to +/// author a valid `--candidate-resolution-json`, which is exactly what happened +/// live on 2026-09-10 (eight rounds of typed refusals, each revealing one +/// field). This file carries the anchors it was taken from so a pin bump can be +/// re-verified. +const LOOPX_PINNED_ISSUE_FIX_PAYLOAD_CONTRACT: &str = + include_str!("resources/loopx-pinned-issue-fix-payload-contract.md"); + +/// Closing-ceremony order gleaned from live guard rejections on the pinned +/// CLI (observed 2026-09-07): a terminal no-follow-up completion request is +/// rejected with a typed refusal unless an accountable durable writeback and +/// the quota-spend receipt already exist, and the guard demanded the sequence +/// refresh-state -> quota spend-slot -> terminal. Minimal host facts for the +/// closing ceremony. The authoritative semantics (refresh-state / todo / +/// quota / vision packet schemas and ordering) come from the pinned official +/// skill document seeded in the worktree. Single source of truth (observed +/// 2026-09-08): agents actively execute a read directive in this note — it +/// must therefore only NAME the document and defer to the pointer section +/// above, which alone owns the read-once policy (fresh session: read once; +/// continued session: already loaded, reuse context). A read verb here would +/// re-read the 59KB document every turn of a reused session. +const LOOPX_CLOSING_CEREMONY_NOTE: &str = "\n\n---\n[BitFun host facts - closing ceremony]\n\ +- Closing-ceremony semantics (refresh-state / todo / quota / vision packet\n\ + schemas and ordering) are authoritative in `.loopx/pinned-loopx-skill.md`;\n\ + when to read that document is governed only by the pointer section above.\n\ +- On a TYPED refusal, apply exactly the parameter the CLI error names, then retry ONCE.\n\ + The budget is per COMMAND FAMILY, not per literal argv: re-running the same subcommand\n\ + with a progressively edited `--*-json` payload, or with one flag value changed to probe\n\ + what the CLI accepts, is still the same retry and still consumes the budget. Two ordered\n\ + attempts is the limit; after that, stop and report a blocker quoting the exact CLI error\n\ + text instead of continuing to guess. If the schema you need is not documented in the pinned\n\ + files this instruction names, say so in the blocker - that is a host documentation gap, not\n\ + something to brute-force.\n\ +- The runtime is project-local (`/.loopx/runtime`); never write to `~/.codex/loopx`."; + +/// Always-on host fact: the closing summary's verification state is rendered +/// from structured data only. The MiniApp never pattern-matches the agent's +/// prose (a documentation-only segment was once asked to "confirm the fix in a +/// real runtime"), so the agent must report what it actually verified as +/// fields, and stay silent when there was no runnable surface at all. +const LOOPX_SUMMARY_VERIFICATION_CONTRACT_NOTE: &str = "\n\n---\n[BitFun host fact - closing summary verification field]\n\ +The `loopx_summary_v1` block at the end of your final response drives the owner-facing UI, which reads STRUCTURED fields only: free-text is never pattern-matched. \ +Report this segment's verification as data in a `verification` object:\n\ +- `{\"requirement\":\"not_applicable\"}` - no runnable surface exists for this segment's change (for example a documentation-only edit).\n\ +- `{\"requirement\":\"automated\",\"surface\":\"\"}` - validated by an automated surface.\n\ +- `{\"requirement\":\"needs_human_e2e\",\"reason\":\"\"}` - acceptance needs a human to run or observe it (UI interaction, live credentials, and the like).\n\ +- `{\"requirement\":\"not_performed\",\"reason\":\"\"}` - validation was possible but was not performed.\n\ +`needs_human_e2e` and `not_performed` must carry a non-empty `reason`; the host renders it to the owner verbatim. Never claim a verification you did not perform, and do not describe verification only in prose.\n\ +The same block carries the OWNER-FACING conclusion: include `owner_summary` with 1-2 plain-language sentences in the owner's language (match the issue title/body; use Chinese when the issue is Chinese) covering what was done, where the work stands, and what the owner must do (or that no action is needed). Never put shell commands, file paths, plan/todo language, or reasoning about your own process there: the host renders `owner_summary` verbatim to the owner, while `next_step` and `decision` are moved into an internal-details disclosure."; + +/// Composes the final agent turn instruction: the CLI-provided turn +/// instruction, then the always-on environment boundary, then a short pointer +/// to the pinned LoopX reference file seeded in the worktree (the agent reads +/// it once per session, mirroring how a LoopX codex-style host loads its +/// workflow skills), then the minimal closing-ceremony host facts, then the +/// one-shot host note (if any) last so corrective guidance stays closest to +/// the end. `session_continuation` marks turns that continue the goal's live +/// agent session: the pointer then reminds the agent the references are +/// already in its conversation instead of asking for a fresh read. +fn compose_agent_turn_instruction( + instruction: String, + host_note: Option<&str>, + pinned_reference_path: Option<&str>, + session_continuation: bool, +) -> String { + let mut composed = instruction; + composed.push_str(LOOPX_AGENT_ENVIRONMENT_BOUNDARY_NOTE); + if let Some(reference_path) = pinned_reference_path { + if session_continuation { + composed.push_str("\n\n---\n[Pinned LoopX references - already loaded]\n"); + composed.push_str( + "This conversation continues an earlier turn of the same goal; the \ +pinned LoopX skill document you already read from `", + ); + composed.push_str(reference_path); + composed.push_str( + "` - and its sibling documents seeded under the same `.loopx/` \ +directory - remain the authoritative LoopX workflow references for this host. \ +Reuse them from your conversation context; re-read a file only if this \ +conversation was compacted and its content is no longer present.\n", + ); + } else { + composed.push_str("\n\n---\n[Pinned LoopX references - read exactly once]\n"); + composed.push_str("Read `"); + composed.push_str(reference_path); + composed.push_str( + "` ONCE before acting - it is the authoritative LoopX skill document \ +(refresh-state / todo / quota / vision packet schemas and ordering). Use it from your \ +conversation context afterwards; re-read only if this conversation was compacted. \ +A separate CLI help file (`.loopx/pinned-loopx-cli-help.md`) exists ONLY for verifying a \ +specific flag/argument when needed - do not read it up front. \ +The capability-level contracts the official skills do not document - candidate-evidence receipt shapes, resolution outcome enums, and the implementation-admission decision rule - are mirrored from the pinned CLI into `.loopx/capability-contracts.json`; read that file when a step needs those field shapes instead of probing `--help`, the installation, or the pinned executable. \ +Sibling skill documents of the same pinned revision are seeded alongside it: \ +`.loopx/loopx-doc-registry.md`, `.loopx/loopx-pr-program.md`, \ +`.loopx/loopx-pr-review.md`, and `.loopx/loopx-change-quality.md`; when a skill \ +document tells you to load another `loopx-*` skill, read the matching seeded file - \ +never resolve loopx skill names through your skill catalog or user-level skill \ +directories (they may hold a different LoopX version).\n\ +- Issue-fix payload contracts are seeded as `.loopx/loopx-issue-fix-payload-contract.md` \ +(the exact `--candidate-resolution-json` / `--candidate-preflight-json` / \ +`--repository-context-json` shapes, including the `rows[].kind` vocabulary that LoopX documents \ +only in source), `.loopx/loopx-issue-fix-reference.md` (the `loopx issue-fix` command map) and \ +`.loopx/loopx-issue-fix-workflow-contract.md` (the ordered contract and the `fix_pr` / \ +`comment_only` / `triage_only` routes). READ THE PAYLOAD CONTRACT BEFORE YOU HAND-AUTHOR ANY \ +PAYLOAD, and prefer the machine-readable `candidate_preflight.input_contract` and \ +`repository_context_input_contract` blocks that the live `workflow-plan` packet projects. Do not \ +reverse-engineer a payload from CLI refusals one field at a time; if a schema you need is absent \ +from both the live packet and these seeded files, that is a host documentation gap - report it as \ +a blocker rather than guessing.\n\ +- This host runs the `generic-cli / outer_controller / isolated-headless` runtime profile. \ +Any `codex_app` scheduler/ACK fields the skill document mentions are CONCEPTUAL ONLY for this \ +host; your actual scheduler hint comes from the packet you received - apply it as-is. \ +- `.loopx/agent-onboard-pack.json` (fresh per goal) holds your agent-type's canonical \ +doctor/bootstrap/quota/recheck command templates - prefer those forms over re-deriving them.\n", + ); + } + } + composed.push_str(LOOPX_CLOSING_CEREMONY_NOTE); + // Host-resolved input the agent must not spend rounds re-deriving. Kept + // separate from the closing-ceremony note so the pointer section stays the + // single owner of the read-once policy. + composed.push_str( + "\n\n---\n[BitFun host fact - item metadata]\n\ +The host already resolved this item's public metadata (number, state, title, labels, kind, url) \ +into `.loopx/issue-metadata.json` in this worktree. Reuse that file - for example as the \ +argument to `issue-fix workflow-plan --metadata-json` - instead of re-fetching the same metadata \ +with `gh issue view`. This is host-resolved input, not LoopX authority: LoopX still owns the \ +workflow plan and candidate admission, and anything you act on must be verified against the live \ +repository.", + ); + composed.push_str(LOOPX_SUMMARY_VERIFICATION_CONTRACT_NOTE); + if let Some(note) = host_note { + composed.push_str("\n\n---\n[BitFun host note] "); + composed.push_str(note); + } + composed +} + +/// `recovery_reason` for a Goal that is still Active after its plan ran dry: +/// no open todo, no waiting user decision, and no selected action remain, so +/// the host contract forbids fabricating a terminal transition. The task +/// parks with this explicit reason (instead of a generic execution failure) +/// so the recovery card can explain the plan is exhausted and guide the +/// owner: resume once the Goal gains a new todo or gate, or finish the +/// delivery manually from the task branch. +const LOOPX_PLAN_EXHAUSTED_REASON: &str = "plan_exhausted"; + +/// Owner-facing message persisted on the task when the RunNow frontier is +/// exhausted. Kept in English because the same text is host telemetry; the +/// MiniApp renders localized guidance keyed off `recovery_reason`. +const LOOPX_PLAN_EXHAUSTED_MESSAGE: &str = "LoopX goal is still active but its plan is exhausted: no open todo, no waiting user decision, and no selected action remain. BitFun does not fabricate a terminal Goal transition, so the task parks for an owner decision; the worktree, evidence, and any commits are preserved. Resume after the goal gains a new todo or gate (for example after an upstream PR merge), or finish the delivery manually from the task branch."; + +struct ScheduledTask { + task_id: String, +} + +/// One install slot per app-managed component. The environment panel lets the +/// owner repair Node.js, Git and the LoopX sidecar independently, so a running +/// repair only blocks its own row instead of serialising every remediation +/// behind one global guard. +#[derive(Debug, Default)] +struct RuntimeInstallSlots { + loopx: AtomicBool, + node: AtomicBool, + git: AtomicBool, +} + +impl RuntimeInstallSlots { + fn loopx(&self) -> &AtomicBool { + &self.loopx + } + + fn runtime(&self, runtime: LoopxManagedRuntimeKind) -> &AtomicBool { + match runtime { + LoopxManagedRuntimeKind::Node => &self.node, + LoopxManagedRuntimeKind::Git => &self.git, + } + } +} + +struct InProgressGuard<'a>(&'a AtomicBool); + +impl Drop for InProgressGuard<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +#[derive(Default)] +struct BufferedProgress(StdMutex>); + +impl BufferedProgress { + fn take(&self) -> Vec { + std::mem::take( + &mut *self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()), + ) + } +} + +impl LoopxCliProgressSink for BufferedProgress { + fn report(&self, progress: LoopxCliProgress) { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(progress); + } +} + +pub struct LoopxController { + cli: Arc, + workspace: Arc, + agent: Arc, + agent_capabilities: Vec, + store: LoopxStateStore, + state: RwLock, + mutation_lock: Mutex<()>, + reconcile_lock: Mutex<()>, + previews: RwLock>, + active_tasks: Mutex>, + active_repositories: Mutex>, + event_sender: broadcast::Sender, + task_sender: mpsc::UnboundedSender, + load_error: RwLock>, + install_in_progress: RuntimeInstallSlots, + reset_in_progress: AtomicBool, +} + +impl LoopxController { + pub async fn load( + cli: Arc, + workspace: Arc, + agent: Arc, + store: LoopxStateStore, + ) -> Arc { + let now = now_ms(); + let (mut persisted, load_error) = match store.load().await { + Ok(Some(state)) => (state, None), + Ok(None) => (LoopxPersistedState::new(now), None), + Err(error) => (LoopxPersistedState::new(now), Some(error)), + }; + // The platform capability is derived from the host OS, never from a + // persisted snapshot written by an older build. + persisted.environment.runtime_install_supported = + Some(managed_runtime_install_supported()); + let restart_changed = load_error.is_none() && persisted.apply_restart_policy(now); + let (event_sender, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY); + let (task_sender, mut task_receiver) = mpsc::unbounded_channel::(); + let agent_capabilities = agent.available_capabilities(); + let controller = Arc::new(Self { + cli, + workspace, + agent, + agent_capabilities, + store, + state: RwLock::new(persisted), + mutation_lock: Mutex::new(()), + reconcile_lock: Mutex::new(()), + previews: RwLock::new(HashMap::new()), + active_tasks: Mutex::new(HashMap::new()), + active_repositories: Mutex::new(HashMap::new()), + event_sender, + task_sender, + load_error: RwLock::new(load_error), + install_in_progress: RuntimeInstallSlots::default(), + reset_in_progress: AtomicBool::new(false), + }); + if restart_changed { + if let Err(error) = controller.persist_current().await { + *controller.load_error.write().await = Some(error); + } + } + let task_runner = Arc::clone(&controller); + tokio::spawn(async move { + while let Some(scheduled) = task_receiver.recv().await { + let task_runner = Arc::clone(&task_runner); + tokio::spawn(async move { + if !task_runner.reserve_scheduled_task(&scheduled.task_id).await { + return; + } + loop { + let result = task_runner.drive_task(scheduled.task_id.clone()).await; + if let Err(error) = result { + let _ = task_runner.fail_task(&scheduled.task_id, error).await; + } + if !task_runner.release_scheduled_task(&scheduled.task_id).await { + break; + } + if !task_runner.reserve_scheduled_task(&scheduled.task_id).await { + break; + } + } + }); + } + }); + controller.enqueue_ready_tasks_after_load().await; + controller + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.event_sender.subscribe() + } + + pub async fn attach( + &self, + execution_domain: LoopxExecutionDomain, + execution_support: LoopxExecutionSupport, + unsupported_reason: Option, + ) -> LoopxAttachResponse { + let environment_ready = + self.state.read().await.environment.status == LoopxEnvironmentStatus::Ready; + if execution_support == LoopxExecutionSupport::Supported + && environment_ready + && self.load_error.read().await.is_none() + { + self.reconcile_goal_projections(false).await; + } + let state = self.state.read().await; + let mut snapshot = state.snapshot( + execution_domain, + execution_support, + unsupported_reason, + now_ms(), + ); + if let Some(error) = self.load_error.read().await.clone() { + snapshot.execution_support = LoopxExecutionSupport::UnsupportedExecutionDomain; + snapshot.unsupported_reason = Some(error); + snapshot.environment.status = LoopxEnvironmentStatus::Blocked; + } + LoopxAttachResponse { snapshot } + } + + /// Re-hydrates LoopX-owned projections after the trusted Desktop surface + /// observes a suspend/resume clock discontinuity. Active Agent turns are + /// preserved: Windows can resume their subprocess tree successfully, so + /// this path invalidates stale clients and refreshes only read-only host + /// facts instead of manufacturing a failure or duplicate turn. + pub async fn handle_host_resume(self: &Arc) -> Result<(), String> { + if self.reset_in_progress.load(Ordering::Acquire) { + return Ok(()); + } + { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let start_cursor = state.cursor; + state.revision = state.revision.saturating_add(1); + // A host resume is an implicit suite continue: the user is back and + // the run should re-arm, so the durable stop flag clears here. + state.suspended = false; + state.append_event(LoopxEvent { + kind: LoopxEventKind::SnapshotInvalidated, + level: LoopxEventLevel::Info, + source: LoopxEventSource::Controller, + message: "Host resumed; refreshing LoopX projections".to_string(), + occurred_at: now_ms(), + ..LoopxEvent::default() + }); + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + self.broadcast_new_events(&persisted, start_cursor); + } + + if let Err(error) = self.refresh_environment().await { + log::warn!("LoopX environment refresh after host resume failed: {error}"); + } + self.reconcile_goal_projections(true).await; + Ok(()) + } + + /// Refresh the read-only LoopX Goal projection before presenting persisted + /// host jobs. Failures preserve the last local projection and are surfaced + /// in logs; they never manufacture a Goal transition or local fallback. + async fn reconcile_goal_projections(&self, force: bool) { + let Ok(_reconcile) = self.reconcile_lock.try_lock() else { + return; + }; + let now = now_ms(); + let candidates = { + let state = self.state.read().await; + state + .tasks + .iter() + .filter(|task| { + task.goal_id.as_deref().is_some_and(|id| !id.is_empty()) + && task + .workspace_path + .as_deref() + .is_some_and(|path| !path.is_empty()) + && !matches!( + task.state, + LoopxTaskState::Preparing + | LoopxTaskState::Running + | LoopxTaskState::Cancelling + | LoopxTaskState::Aborted + | LoopxTaskState::Archived + ) + // Passive states change through explicit host actions. Re- + // inspecting them on every UI attach only spawns sidecar + // processes that can time out. Suspend/resume keeps the force + // path so an externally changed Goal is still repaired. + && (force + || (task.state == LoopxTaskState::WaitingForUser + && task.pending_gate_id.is_none()) + || !matches!( + task.state, + LoopxTaskState::WaitingForUser + | LoopxTaskState::Completed + | LoopxTaskState::Stopped + | LoopxTaskState::Failed + | LoopxTaskState::RecoveryRequired + )) + && (force + || task.goal_state.is_none() + || now.saturating_sub(task.updated_at) >= GOAL_RECONCILE_TTL_MS) + }) + .filter_map(|task| { + let runtime = state.runtime.get(&task.task_id)?.clone(); + // The reconcile throttle is tracked per task on the runtime + // record, not via `updated_at`: progress events from the + // reconcile itself must not restart the window, otherwise + // every UI attach spawns a fresh sidecar probe. + let throttle_ok = force + || task.goal_state.is_none() + || runtime + .last_goal_reconcile_at_ms + .map(|at| now.saturating_sub(at) >= GOAL_RECONCILE_TTL_MS) + .unwrap_or(true); + (throttle_ok && !runtime.registry_path.is_empty()) + .then(|| (task.clone(), runtime)) + }) + .collect::>() + }; + + for (task, runtime) in candidates { + let mut context = self.goal_context(&task, &runtime); + context.call.operation_id = + format!("reconcile-goal-{}-{}", task.task_id, uuid::Uuid::new_v4()); + context.call.deadline_at = Some(now_ms().saturating_add(GOAL_RECONCILE_DEADLINE_MS)); + let progress = BufferedProgress::default(); + let result = self + .cli + .inspect_goal( + LoopxCliInspectGoalRequest { + context, + goal_id: task.goal_id.clone().unwrap_or_default(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + }, + &progress, + ) + .await; + if let Err(error) = self.record_progress(progress.take()).await { + log::warn!( + "Failed to persist LoopX reconciliation progress: task_id={}, error={}", + task.task_id, + error + ); + } + let snapshot = match result { + Ok(snapshot) => snapshot, + Err(error) => { + log::warn!( + "LoopX Goal reconciliation failed: task_id={}, goal_id={}, error={}", + task.task_id, + task.goal_id.as_deref().unwrap_or("unknown"), + error + ); + continue; + } + }; + // Record the attempt regardless of outcome so a chatty UI attach + // cadence cannot turn reconciliation into a sidecar hot loop. + // Bookkeeping write: this deliberately does not bump the state + // revision — background reconciliation must never invalidate the + // expected revision of a pending UI action (for example the + // repository recovery button). + let reconciled_at = now_ms(); + { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + if let Some(runtime) = state.runtime.get_mut(&task.task_id) { + runtime.last_goal_reconcile_at_ms = Some(reconciled_at); + } + let persisted = state.clone(); + drop(state); + if let Err(error) = self.store.save(&persisted).await { + log::warn!( + "Failed to persist LoopX reconcile throttle: task_id={}, error={}", + task.task_id, + error + ); + } + } + if let Err(error) = self.apply_goal_projection(&task, &snapshot).await { + log::warn!( + "Failed to apply LoopX Goal projection: task_id={}, goal_id={}, error={}", + task.task_id, + snapshot.goal_id, + error + ); + } + } + } + + pub async fn events_since(&self, request: LoopxEventsSinceRequest) -> LoopxEventsSinceResponse { + self.state.read().await.events_since( + &request.stream_id, + request.after_cursor, + request.limit, + ) + } + + pub async fn turn_output_since( + &self, + request: LoopxTurnOutputSinceRequest, + ) -> LoopxTurnOutputSinceResponse { + let (task, runtime) = { + let state = self.state.read().await; + let Some(task) = state + .tasks + .iter() + .find(|task| task.task_id == request.task_id) + else { + return LoopxTurnOutputSinceResponse { + status: LoopxTurnOutputStatus::TaskNotFound, + task_id: request.task_id, + message: Some("LoopX task was not found".to_string()), + ..LoopxTurnOutputSinceResponse::default() + }; + }; + let runtime = state + .runtime + .get(&task.task_id) + .cloned() + .unwrap_or_default(); + (task.clone(), runtime) + }; + + if task.state != LoopxTaskState::Running || task.phase != LoopxPhase::AgentRunning { + return LoopxTurnOutputSinceResponse { + status: LoopxTurnOutputStatus::NotRunning, + task_id: task.task_id, + turn_id: task.current_turn_id, + message: Some("LoopX task does not have an active Agent turn".to_string()), + ..LoopxTurnOutputSinceResponse::default() + }; + } + let Some(session_id) = runtime.session_id else { + return LoopxTurnOutputSinceResponse { + status: LoopxTurnOutputStatus::OutputUnavailable, + task_id: task.task_id, + turn_id: task.current_turn_id, + message: Some("LoopX Agent session output is unavailable".to_string()), + ..LoopxTurnOutputSinceResponse::default() + }; + }; + let Some(turn_id) = runtime + .agent_turn_id + .clone() + .or_else(|| task.current_turn_id.clone()) + else { + return LoopxTurnOutputSinceResponse { + status: LoopxTurnOutputStatus::OutputUnavailable, + task_id: task.task_id, + message: Some("LoopX Agent turn output is unavailable".to_string()), + ..LoopxTurnOutputSinceResponse::default() + }; + }; + if request + .turn_id + .as_deref() + .is_some_and(|requested| requested != turn_id) + { + return LoopxTurnOutputSinceResponse { + status: LoopxTurnOutputStatus::StaleTurn, + task_id: task.task_id, + turn_id: Some(turn_id), + message: Some("LoopX task moved to a different Agent turn".to_string()), + ..LoopxTurnOutputSinceResponse::default() + }; + } + + match self + .agent + .output_since(LoopxAgentOutputSinceRequest { + operation_id: format!("output-agent-{}", uuid::Uuid::new_v4()), + session_id, + turn_id: turn_id.clone(), + stream_id: request.stream_id, + after_cursor: request.after_cursor, + limit: request.limit, + }) + .await + { + Ok(page) => LoopxTurnOutputSinceResponse { + status: LoopxTurnOutputStatus::Current, + task_id: task.task_id, + turn_id: Some(turn_id), + stream_id: page.stream_id, + events: page.events, + next_cursor: page.next_cursor, + has_more: page.has_more, + message: None, + }, + Err(error) => LoopxTurnOutputSinceResponse { + status: LoopxTurnOutputStatus::OutputUnavailable, + task_id: task.task_id, + turn_id: Some(turn_id), + message: Some(error.to_string()), + ..LoopxTurnOutputSinceResponse::default() + }, + } + } + + pub async fn refresh_environment(self: &Arc) -> Result<(), String> { + self.ensure_writable().await?; + let probe_id = uuid::Uuid::new_v4(); + self.mark_environment_checking().await?; + let progress = BufferedProgress::default(); + let handshake = self.cli.handshake( + LoopxCliHandshakeRequest { + call: LoopxCliCallContext { + operation_id: format!("environment-sidecar-{probe_id}"), + deadline_at: None, + }, + ..LoopxCliHandshakeRequest::default() + }, + &progress, + ); + let workspace = self.workspace.probe(LoopxWorkspaceProbeRequest { + operation_id: format!("environment-workspace-{probe_id}"), + repository: None, + }); + let agent = self.agent.probe(LoopxAgentProbeRequest { + operation_id: format!("environment-agent-{probe_id}"), + model_id: Some("auto".to_string()), + }); + let node_runtime = self.cli.probe_node_runtime( + LoopxCliProbeNodeRuntimeRequest { + call: LoopxCliCallContext { + operation_id: format!("environment-node-{probe_id}"), + deadline_at: None, + }, + }, + &progress, + ); + let github_auth = self.probe_github_auth(); + let (handshake, workspace, agent, github_auth, node_runtime) = + tokio::join!(handshake, workspace, agent, github_auth, node_runtime); + let node_runtime = match node_runtime { + Ok(fact) => fact, + Err(error) => LoopxNodeRuntimeFact { + available: false, + version: None, + minimum_version: String::new(), + detail: Some(format!("Node.js probe failed: {error}")), + }, + }; + self.record_progress(progress.take()).await?; + self.commit_environment(handshake, workspace, agent, github_auth, node_runtime) + .await?; + Ok(()) + } + + async fn probe_github_auth(&self) -> LoopxGithubAuthProbe { + let operation_id = format!("github-auth-{}", uuid::Uuid::new_v4()); + match self + .cli + .probe_github_auth(LoopxGithubAuthProbeRequest { + call: LoopxCliCallContext { + operation_id, + deadline_at: None, + }, + }) + .await + { + Ok(probe) => probe, + Err(error) => LoopxGithubAuthProbe { + authenticated: false, + detail: Some(format!("GitHub auth probe failed: {error}")), + ..LoopxGithubAuthProbe::default() + }, + } + } + + pub async fn resolve_intake( + &self, + request: LoopxResolveIntakeRequest, + ) -> Result { + self.ensure_writable().await?; + let target = parse_loopx_intake(&request.input).map_err(|error| error.to_string())?; + let probe_id = uuid::Uuid::new_v4(); + let repository = target.repository().clone(); + let model_id = request.model_id; + let progress = BufferedProgress::default(); + let resolved = self.cli.resolve_intake( + LoopxCliResolveIntakeRequest { + call: LoopxCliCallContext { + operation_id: format!("resolve-metadata-{probe_id}"), + deadline_at: None, + }, + input: request.input, + target: target.clone(), + }, + &progress, + ); + let workspace_probe = self.workspace.probe(LoopxWorkspaceProbeRequest { + operation_id: format!("resolve-workspace-{probe_id}"), + repository: Some(repository), + }); + let agent_probe = self.agent.probe(LoopxAgentProbeRequest { + operation_id: format!("resolve-agent-{probe_id}"), + model_id: Some(model_id.clone()), + }); + let (resolved, workspace_probe, agent_probe) = + tokio::join!(resolved, workspace_probe, agent_probe); + self.record_progress(progress.take()).await?; + let resolved = resolved.map_err(|error| error.to_string())?; + let scopes = LOOPX_REQUIRED_PERMISSION_SCOPES.to_vec(); + let model = match agent_probe { + Ok(probe) => LoopxModelCapability { + model_id, + available: true, + supports_images: probe.supports_images, + detail: Some(format!("Resolved Agent model: {}", probe.model_id)), + }, + Err(error) => LoopxModelCapability { + model_id, + available: false, + supports_images: false, + detail: Some(error.to_string()), + }, + }; + let workspace = match workspace_probe { + Ok(probe) => LoopxWorkspacePreview { + disposition: LoopxWorkspaceDisposition::CloneRequired, + path: None, + repository_verified: probe.repository_verified, + detail: Some(format!( + "{}; repository access verified", + probe + .git_version + .unwrap_or_else(|| "Git available".to_string()) + )), + }, + Err(error) => LoopxWorkspacePreview { + disposition: LoopxWorkspaceDisposition::Unavailable, + path: None, + repository_verified: false, + detail: Some(error.to_string()), + }, + }; + let fingerprint = build_intake_fingerprint( + &resolved.target, + &resolved.candidates, + None, + &model.model_id, + &scopes, + ); + let preview_resolved_at = if resolved.resolved_at > 0 { + resolved.resolved_at + } else { + now_ms() + }; + let expires_at = preview_resolved_at.saturating_add(INTAKE_PREVIEW_TTL_MS); + let preview = LoopxIntakePreview { + fingerprint: fingerprint.clone(), + target: resolved.target, + repository: resolved.repository, + workspace, + candidates: resolved.candidates, + truncated: resolved.truncated, + model, + permission_scopes: scopes, + resolved_at: preview_resolved_at, + expires_at: Some(expires_at), + }; + let mut previews = self.previews.write().await; + prune_intake_previews(&mut previews, now_ms()); + previews.insert(fingerprint, preview.clone()); + prune_intake_previews(&mut previews, now_ms()); + Ok(LoopxResolveIntakeResponse { preview }) + } + + pub async fn create_tasks( + self: &Arc, + request: LoopxCreateTaskRequest, + ) -> Result { + self.ensure_writable().await?; + if request.client_request_id.trim().is_empty() { + return Err("clientRequestId is required".to_string()); + } + if !managed_runtime_install_supported() { + return Err(MANAGED_RUNTIME_PLATFORM_UNSUPPORTED_DETAIL.to_string()); + } + if self.state.read().await.suspended { + return Err("LoopX is stopped; resume the suite before creating tasks".to_string()); + } + let selected = request + .selected_items + .iter() + .cloned() + .collect::>(); + if selected.is_empty() { + return Err("Select at least one issue or pull request".to_string()); + } + { + let state = self.state.read().await; + if state.has_processed_request(&request.client_request_id) { + return Ok(LoopxCreateTaskResponse { + outcomes: existing_outcomes(&state, &selected), + snapshot_revision: state.revision, + }); + } + } + let preview = match { + let mut previews = self.previews.write().await; + prune_intake_previews(&mut previews, now_ms()); + previews.get(&request.preview_fingerprint).cloned() + } { + Some(preview) => preview, + None => { + let state = self.state.read().await; + if state.has_processed_request(&request.client_request_id) { + return Ok(LoopxCreateTaskResponse { + outcomes: existing_outcomes(&state, &selected), + snapshot_revision: state.revision, + }); + } + return Err("Intake preview is missing or stale; resolve it again".to_string()); + } + }; + if selected.iter().any(|key| { + !preview + .candidates + .iter() + .any(|candidate| &candidate.key == key) + }) { + return Err("Selected item was not present in the intake preview".to_string()); + } + if preview.workspace.disposition == LoopxWorkspaceDisposition::Unavailable + || !preview.workspace.repository_verified + { + return Err(preview.workspace.detail.clone().unwrap_or_else(|| { + "The repository workspace did not pass live Git verification".to_string() + })); + } + if !preview.model.available { + return Err(preview + .model + .detail + .clone() + .unwrap_or_else(|| "The selected Agent model is unavailable".to_string())); + } + if request.granted_scopes.iter().any(|scope| { + !preview.permission_scopes.contains(scope) || !intake_scope_is_pregrantable(*scope) + }) { + return Err("Intake includes a permission scope that was not previewed".to_string()); + } + if !required_permission_scopes_are_granted(&request.granted_scopes) { + return Err( + "All LoopX permission scopes shown in intake are required for an autonomous issue-fix task" + .to_string(), + ); + } + + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let start_cursor = state.cursor; + if state.has_processed_request(&request.client_request_id) { + return Ok(LoopxCreateTaskResponse { + outcomes: existing_outcomes(&state, &selected), + snapshot_revision: state.revision, + }); + } + let existing = state + .tasks + .iter() + .map(|task| LoopxExistingTask { + task_id: task.task_id.clone(), + identity: task.identity.clone(), + state: task.state, + }) + .collect::>(); + let batch_id = (selected.len() > 1).then(|| uuid::Uuid::new_v4().to_string()); + let now = now_ms(); + let mut outcomes = Vec::new(); + let mut created_task_ids = Vec::new(); + for key in selected { + let candidate = preview + .candidates + .iter() + .find(|candidate| candidate.key == key) + .expect("selected candidates were validated before mutation"); + match decide_task_dedup(&key, candidate.state, &existing, request.retry_terminal) { + LoopxDedupDecision::OpenExisting { task_id } => { + outcomes.push(LoopxCreateTaskOutcome { + item: key, + kind: LoopxCreateTaskOutcomeKind::OpenedExisting, + task_id: Some(task_id), + ..LoopxCreateTaskOutcome::default() + }) + } + LoopxDedupDecision::RequireExplicitRetry { + previous_task_id, + next_attempt, + } => outcomes.push(LoopxCreateTaskOutcome { + item: key, + kind: LoopxCreateTaskOutcomeKind::RetryConfirmationRequired, + task_id: Some(previous_task_id), + attempt: Some(next_attempt), + ..LoopxCreateTaskOutcome::default() + }), + LoopxDedupDecision::ClosedNoop => outcomes.push(LoopxCreateTaskOutcome { + item: key, + kind: LoopxCreateTaskOutcomeKind::ClosedNoop, + ..LoopxCreateTaskOutcome::default() + }), + LoopxDedupDecision::NeedsLiveVerification => { + outcomes.push(LoopxCreateTaskOutcome { + item: key, + kind: LoopxCreateTaskOutcomeKind::NeedsLiveVerification, + ..LoopxCreateTaskOutcome::default() + }) + } + LoopxDedupDecision::CreateAttempt { attempt } => { + let task_id = uuid::Uuid::new_v4().to_string(); + let operation_id = format!("prepare-{task_id}-1"); + let task = LoopxTaskSnapshot { + task_id: task_id.clone(), + batch_id: batch_id.clone(), + identity: LoopxTaskIdentity { + item: key.clone(), + attempt, + title: candidate.title.clone(), + description: candidate.description.clone(), + state: candidate.state, + labels: candidate.labels.clone(), + }, + generation: 1, + revision: 1, + agent_id: Some(DEFAULT_AGENT_ID.to_string()), + state: LoopxTaskState::Preparing, + phase: LoopxPhase::PreparingWorkspace, + model_id: Some(request.model_id.clone()), + granted_scopes: request.granted_scopes.clone(), + created_at: now, + updated_at: now, + ..LoopxTaskSnapshot::default() + }; + state.runtime.insert( + task_id.clone(), + LoopxTaskRuntimeRecord { + operation_id, + ..LoopxTaskRuntimeRecord::default() + }, + ); + state.tasks.push(task); + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + task_id: Some(task_id.clone()), + generation: Some(1), + revision: Some(1), + kind: LoopxEventKind::TaskCreated, + source: LoopxEventSource::Controller, + phase: Some(LoopxPhase::PreparingWorkspace), + message: "LoopX task reserved before workspace preparation".to_string(), + important: true, + occurred_at: now, + ..LoopxEvent::default() + }); + outcomes.push(LoopxCreateTaskOutcome { + item: key, + kind: LoopxCreateTaskOutcomeKind::Created, + task_id: Some(task_id.clone()), + attempt: Some(attempt), + ..LoopxCreateTaskOutcome::default() + }); + created_task_ids.push(task_id); + } + } + } + state.record_processed_request(request.client_request_id); + let snapshot_revision = state.revision; + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + drop(_mutation); + self.broadcast_new_events(&persisted, start_cursor); + + // Enqueue only the first created task per repository. The rest stay + // Preparing/Queued and are chained by schedule_next_for_repository + // after each settlement, keeping execution order deterministic + // (creation order) instead of letting concurrent drives race for the + // repository slot (which could start the last-created task first). + let mut enqueued_repositories: std::collections::HashSet = + std::collections::HashSet::new(); + for task_id in &created_task_ids { + let repository_id = { + let state = self.state.read().await; + match state.tasks.iter().find(|task| &task.task_id == task_id) { + Some(task) => task.identity.item.repository.canonical_id(), + None => continue, + } + }; + if !enqueued_repositories.insert(repository_id) { + continue; + } + self.enqueue_task(task_id.clone(), Duration::ZERO)?; + } + Ok(LoopxCreateTaskResponse { + outcomes, + snapshot_revision, + }) + } + + pub async fn action( + self: &Arc, + request: LoopxActionRequest, + ) -> Result { + self.ensure_writable().await?; + if request.action == LoopxActionKind::RetryEnvironment { + self.refresh_environment().await?; + return Ok(LoopxActionResponse { + current_revision: self.state.read().await.revision, + ..LoopxActionResponse::default() + }); + } + if request.action == LoopxActionKind::InstallNodeRuntime { + return self + .start_runtime_install(&request, LoopxManagedRuntimeKind::Node) + .await; + } + if request.action == LoopxActionKind::InstallGitRuntime { + return self + .start_runtime_install(&request, LoopxManagedRuntimeKind::Git) + .await; + } + if request.action == LoopxActionKind::InstallLoopx { + return self.start_loopx_install(&request).await; + } + if request.action == LoopxActionKind::ResumeRepository { + return self.resume_repository(&request).await; + } + if request.action == LoopxActionKind::ResetAll { + return self.reset_all(&request).await; + } + if request.action == LoopxActionKind::PauseAll { + return self.pause_all(&request).await; + } + if request.action == LoopxActionKind::ResumeAll { + return self.resume_all(&request).await; + } + if request.action == LoopxActionKind::Unsupported { + return Err("Unsupported LoopX action".to_string()); + } + let task_id = request + .task_id + .clone() + .ok_or_else(|| "taskId is required".to_string())?; + let (task, runtime) = { + let state = self.state.read().await; + if state.has_processed_request(&request.client_request_id) { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: state.revision, + task: state + .tasks + .iter() + .find(|task| task.task_id == task_id) + .cloned(), + ..LoopxActionResponse::default() + }); + } + let task = state + .tasks + .iter() + .find(|task| task.task_id == task_id) + .cloned() + .ok_or_else(|| "LoopX task not found".to_string())?; + if request.action == LoopxActionKind::Resume + && matches!( + task.state, + LoopxTaskState::Preparing | LoopxTaskState::Queued | LoopxTaskState::Running + ) + { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: task.revision, + task: Some(task), + message: Some("Task is already queued or running".to_string()), + }); + } + if task.revision != request.expected_revision { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::RevisionConflict, + current_revision: task.revision, + task: Some(task), + message: Some("Task changed; refresh before applying the action".to_string()), + }); + } + ( + task, + state.runtime.get(&task_id).cloned().unwrap_or_default(), + ) + }; + + match request.action { + LoopxActionKind::Pause => { + self.pause_task(&task, &runtime, &request.client_request_id) + .await + } + LoopxActionKind::Abort => { + self.abort_task(&task, &runtime, &request.client_request_id) + .await + } + LoopxActionKind::Resume => self.resume_task(&task, &request.client_request_id).await, + LoopxActionKind::ResumeRepository + | LoopxActionKind::ResetAll + | LoopxActionKind::PauseAll + | LoopxActionKind::ResumeAll + | LoopxActionKind::InstallLoopx + | LoopxActionKind::InstallNodeRuntime + | LoopxActionKind::InstallGitRuntime => unreachable!(), + LoopxActionKind::Approve | LoopxActionKind::Reject => { + self.answer_gate(&task, &runtime, &request).await + } + LoopxActionKind::Archive => { + let response = self + .transition_action( + &task_id, + LoopxTaskState::Archived, + LoopxPhase::Finished, + &request.client_request_id, + ) + .await?; + // Explicit user action: archive is the only workflow that + // destroys the task worktree (and its bare repository when + // the last worktree is gone). Terminal states keep their + // worktrees so the user can inspect agent output first. + self.dispose_task_workspace(&task).await; + Ok(response) + } + LoopxActionKind::Restore => { + self.transition_action( + &task_id, + LoopxTaskState::RecoveryRequired, + LoopxPhase::Recovering, + &request.client_request_id, + ) + .await + } + LoopxActionKind::RetryEnvironment | LoopxActionKind::Unsupported => unreachable!(), + } + } + + async fn start_loopx_install( + self: &Arc, + request: &LoopxActionRequest, + ) -> Result { + let started_at = Instant::now(); + if request.client_request_id.trim().is_empty() { + return Err("clientRequestId is required".to_string()); + } + { + let state = self.state.read().await; + if state.has_processed_request(&request.client_request_id) { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: state.revision, + message: Some("LoopX installation request was already applied".to_string()), + ..LoopxActionResponse::default() + }); + } + if state.environment.core.sidecar.status == LoopxEnvironmentFactStatus::Available { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: state.revision, + message: Some("A compatible LoopX runtime is already available".to_string()), + ..LoopxActionResponse::default() + }); + } + } + if self + .install_in_progress + .loopx() + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: self.state.read().await.revision, + message: Some("LoopX installation is already running".to_string()), + ..LoopxActionResponse::default() + }); + } + let current_revision = match self.mark_loopx_installing(&request.client_request_id).await { + Ok(revision) => revision, + Err(error) => { + self.install_in_progress.loopx().store(false, Ordering::Release); + return Err(error); + } + }; + log::info!( + "LoopX installation state persisted: request_id={}, revision={}, duration_ms={}", + request.client_request_id, + current_revision, + elapsed_ms_u64(started_at) + ); + let request_id = request.client_request_id.clone(); + let controller = Arc::clone(self); + tokio::spawn(async move { + let _install_guard = InProgressGuard(controller.install_in_progress.loopx()); + log::info!("LoopX installation background task started: request_id={request_id}"); + if let Err(error) = controller.run_loopx_install(&request_id).await { + log::error!( + "LoopX managed source installation failed: request_id={request_id}, error={error}" + ); + let _ = controller.mark_loopx_install_failed(&error).await; + } + }); + Ok(LoopxActionResponse { + status: LoopxActionStatus::Applied, + current_revision, + message: Some("LoopX installation started".to_string()), + ..LoopxActionResponse::default() + }) + } + + async fn run_loopx_install(self: &Arc, request_id: &str) -> Result<(), String> { + let progress = BufferedProgress::default(); + let operation_id = format!("install-loopx-{}", uuid::Uuid::new_v4()); + let started_at = Instant::now(); + log::info!( + "LoopX installation service call started: request_id={request_id}, operation_id={operation_id}" + ); + let result = self + .cli + .install_managed_source( + LoopxCliInstallManagedSourceRequest { + call: LoopxCliCallContext { + operation_id: operation_id.clone(), + deadline_at: None, + }, + }, + &progress, + ) + .await; + self.record_progress(progress.take()).await?; + let installed = result.map_err(|error| error.to_string())?; + log::info!( + "LoopX installation service call completed: request_id={request_id}, operation_id={operation_id}, version={}, source={}, commit={}, duration_ms={}", + installed.loopx_version, + installed.source_repository, + installed.source_commit, + elapsed_ms_u64(started_at) + ); + self.refresh_environment().await?; + Ok(()) + } + + async fn mark_loopx_installing(&self, request_id: &str) -> Result { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let checked_at = Some(now_ms()); + state.environment.revision = state.environment.revision.saturating_add(1); + state.environment.status = LoopxEnvironmentStatus::Checking; + state.environment.checked_at = checked_at; + state.environment.core.sidecar = LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Checking, + version: Some(LOOPX_PINNED_VERSION.to_string()), + detail: Some("Downloading runtime files from the official GitHub source".to_string()), + checked_at, + ..LoopxEnvironmentFact::default() + }; + state.record_processed_request(request_id.to_string()); + state.revision = state.revision.saturating_add(1); + let current_revision = state.revision; + let start_cursor = state.cursor; + state.append_event(LoopxEvent { + kind: LoopxEventKind::EnvironmentChanged, + source: LoopxEventSource::System, + message: "LoopX managed source installation started".to_string(), + occurred_at: now_ms(), + ..LoopxEvent::default() + }); + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + self.broadcast_new_events(&persisted, start_cursor); + Ok(current_revision) + } + + async fn mark_loopx_install_failed(&self, error: &str) -> Result<(), String> { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let checked_at = Some(now_ms()); + state.environment.revision = state.environment.revision.saturating_add(1); + state.environment.status = LoopxEnvironmentStatus::Blocked; + state.environment.checked_at = checked_at; + state.environment.core.sidecar = unavailable_loopx_environment_fact(error, checked_at); + state.revision = state.revision.saturating_add(1); + let start_cursor = state.cursor; + state.append_event(LoopxEvent { + kind: LoopxEventKind::EnvironmentChanged, + level: LoopxEventLevel::Error, + source: LoopxEventSource::System, + message: format!("LoopX managed source installation failed: {error}"), + important: true, + occurred_at: now_ms(), + ..LoopxEvent::default() + }); + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + self.broadcast_new_events(&persisted, start_cursor); + Ok(()) + } + + async fn start_runtime_install( + self: &Arc, + request: &LoopxActionRequest, + runtime: LoopxManagedRuntimeKind, + ) -> Result { + let started_at = Instant::now(); + if request.client_request_id.trim().is_empty() { + return Err("clientRequestId is required".to_string()); + } + if !managed_runtime_install_supported() { + return Err(MANAGED_RUNTIME_PLATFORM_UNSUPPORTED_DETAIL.to_string()); + } + { + let state = self.state.read().await; + if state.has_processed_request(&request.client_request_id) { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: state.revision, + message: Some("Runtime installation request was already applied".to_string()), + ..LoopxActionResponse::default() + }); + } + if environment_fact_for(&state.environment.core, runtime).status + == LoopxEnvironmentFactStatus::Available + { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: state.revision, + message: Some("The requested runtime is already available".to_string()), + ..LoopxActionResponse::default() + }); + } + } + if self + .install_in_progress + .runtime(runtime) + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: self.state.read().await.revision, + message: Some(format!( + "{} runtime installation is already running", + runtime_label(runtime) + )), + ..LoopxActionResponse::default() + }); + } + let current_revision = match self + .mark_runtime_installing(runtime, &request.client_request_id) + .await + { + Ok(revision) => revision, + Err(error) => { + self.install_in_progress.runtime(runtime).store(false, Ordering::Release); + return Err(error); + } + }; + log::info!( + "LoopX managed runtime installation state persisted: request_id={}, runtime={:?}, revision={}, duration_ms={}", + request.client_request_id, + runtime, + current_revision, + elapsed_ms_u64(started_at) + ); + let request_id = request.client_request_id.clone(); + let controller = Arc::clone(self); + tokio::spawn(async move { + let _install_guard = InProgressGuard(controller.install_in_progress.runtime(runtime)); + if let Err(error) = controller.run_runtime_install(runtime, &request_id).await { + log::error!( + "LoopX managed runtime installation failed: request_id={request_id}, runtime={runtime:?}, error={error}" + ); + let _ = controller.mark_runtime_install_failed(runtime, &error).await; + } + }); + Ok(LoopxActionResponse { + status: LoopxActionStatus::Applied, + current_revision, + message: Some("Runtime installation started".to_string()), + ..LoopxActionResponse::default() + }) + } + + async fn run_runtime_install( + self: &Arc, + runtime: LoopxManagedRuntimeKind, + request_id: &str, + ) -> Result<(), String> { + let progress = BufferedProgress::default(); + let operation_id = format!("install-runtime-{}", uuid::Uuid::new_v4()); + let started_at = Instant::now(); + let result = self + .cli + .install_managed_runtime( + LoopxCliInstallRuntimeRequest { + call: LoopxCliCallContext { + operation_id: operation_id.clone(), + deadline_at: None, + }, + runtime, + }, + &progress, + ) + .await; + self.record_progress(progress.take()).await?; + let installed = result.map_err(|error| error.to_string())?; + log::info!( + "LoopX managed runtime installation completed: request_id={request_id}, runtime={runtime:?}, version={}, install_path={}, duration_ms={}", + installed.version, + installed.install_path, + elapsed_ms_u64(started_at) + ); + self.refresh_environment().await?; + Ok(()) + } + + async fn mark_runtime_installing( + &self, + runtime: LoopxManagedRuntimeKind, + request_id: &str, + ) -> Result { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let checked_at = Some(now_ms()); + state.environment.revision = state.environment.revision.saturating_add(1); + state.environment.status = LoopxEnvironmentStatus::Checking; + state.environment.checked_at = checked_at; + { + let fact = environment_fact_for_mut(&mut state.environment.core, runtime); + *fact = checking_runtime_environment_fact( + format!("Downloading the pinned {} runtime", runtime_label(runtime)), + checked_at, + ); + } + state.record_processed_request(request_id.to_string()); + state.revision = state.revision.saturating_add(1); + let current_revision = state.revision; + let start_cursor = state.cursor; + state.append_event(LoopxEvent { + kind: LoopxEventKind::EnvironmentChanged, + source: LoopxEventSource::System, + message: format!("{} runtime installation started", runtime_label(runtime)), + occurred_at: now_ms(), + ..LoopxEvent::default() + }); + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + self.broadcast_new_events(&persisted, start_cursor); + Ok(current_revision) + } + + async fn mark_runtime_install_failed( + &self, + runtime: LoopxManagedRuntimeKind, + error: &str, + ) -> Result<(), String> { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let checked_at = Some(now_ms()); + state.environment.revision = state.environment.revision.saturating_add(1); + state.environment.checked_at = checked_at; + { + let fact = environment_fact_for_mut(&mut state.environment.core, runtime); + *fact = runtime_unavailable_fact(runtime, error, checked_at); + } + state.environment.status = + derive_environment_status(&state.environment.core, &state.environment.optional); + state.revision = state.revision.saturating_add(1); + let start_cursor = state.cursor; + state.append_event(LoopxEvent { + kind: LoopxEventKind::EnvironmentChanged, + level: LoopxEventLevel::Error, + source: LoopxEventSource::System, + message: format!("{} runtime installation failed: {error}", runtime_label(runtime)), + important: true, + occurred_at: now_ms(), + ..LoopxEvent::default() + }); + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + self.broadcast_new_events(&persisted, start_cursor); + Ok(()) + } + + pub async fn handle_agent_terminal( + self: &Arc, + turn_id: &str, + status: LoopxAgentTurnStatus, + summary: Option, + blocks_repository: bool, + ) -> Result<(), String> { + let (task, runtime) = { + let state = self.state.read().await; + let Some((task_id, runtime)) = state + .runtime + .iter() + .find(|(_, runtime)| runtime.agent_turn_id.as_deref() == Some(turn_id)) + else { + return Ok(()); + }; + let Some(task) = state.tasks.iter().find(|task| &task.task_id == task_id) else { + return Ok(()); + }; + (task.clone(), runtime.clone()) + }; + if task.state != LoopxTaskState::Running { + return Ok(()); + } + log::info!( + "LoopX Agent terminal handling started: task_id={}, goal_id={}, agent_turn_id={}, status={:?}", + task.task_id, + task.goal_id.as_deref().unwrap_or("unknown"), + turn_id, + status + ); + if let Some(summary) = summary + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let bounded = bounded_agent_summary(summary); + let structured = parse_structured_summary(Some(summary)); + self.mutate_task(&task.task_id, None, |current, _| { + if current.generation != task.generation { + return; + } + current.last_agent_summary = Some(bounded); + current.structured_summary = structured; + current.last_agent_summary_at = Some(now_ms()); + current.revision = current.revision.saturating_add(1); + }) + .await?; + } + self.update_task_phase( + &task.task_id, + task.generation, + LoopxPhase::ValidatingProgress, + "Agent turn ended; verifying LoopX-owned durable settlement", + ) + .await?; + let progress = BufferedProgress::default(); + let settlement_started = Instant::now(); + let result = self + .cli + .verify_turn_settlement( + LoopxCliSettleTurnRequest { + context: self.goal_context(&task, &runtime), + goal_id: task.goal_id.clone().unwrap_or_default(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + turn_id: runtime.loopx_turn_id.clone().unwrap_or_default(), + settlement_token: runtime.settlement_token.clone().unwrap_or_default(), + expected_durable_revision: runtime + .expected_durable_revision + .clone() + .unwrap_or_default(), + agent_status: status, + }, + &progress, + ) + .await; + match &result { + Ok(settlement) => log::info!( + "LoopX turn settlement completed: task_id={}, goal_id={}, loopx_turn_id={}, status={:?}, duration_ms={}", + task.task_id, + task.goal_id.as_deref().unwrap_or("unknown"), + settlement.turn_id, + settlement.status, + settlement_started.elapsed().as_millis(), + ), + Err(error) => log::warn!( + "LoopX turn settlement failed: task_id={}, goal_id={}, duration_ms={}, error={}", + task.task_id, + task.goal_id.as_deref().unwrap_or("unknown"), + settlement_started.elapsed().as_millis(), + error + ), + } + self.record_progress(progress.take()).await?; + // Codex-parity session policy: the agent session is NOT discarded + // unconditionally after a turn. `apply_settlement` decides from the + // final task state whether the goal's live agent session is kept for + // the next turn (the same conversation continues, mirroring how the + // LoopX codex host resumes `codex exec` sessions across turns of one + // goal) or discarded. Only a settlement-verification failure discards + // it here, because the task fails outright in that path. + match result { + Err(error) => { + self.discard_agent_session(&task, &runtime).await; + self.fail_task(&task.task_id, error.to_string()).await + } + Ok(settlement) => { + self.apply_settlement( + &task, + settlement, + status, + summary.as_deref(), + blocks_repository, + ) + .await + } + } + } + + pub(super) async fn handle_agent_activity(&self, turn_id: &str) -> Result<(), String> { + let task_id = { + let state = self.state.read().await; + state + .runtime + .iter() + .find(|(_, runtime)| runtime.agent_turn_id.as_deref() == Some(turn_id)) + .map(|(task_id, _)| task_id.clone()) + }; + let Some(task_id) = task_id else { + return Ok(()); + }; + self.mutate_task(&task_id, None, |task, _| { + if task.state != LoopxTaskState::Running { + return; + } + task.last_output_at = Some(now_ms()); + task.revision = task.revision.saturating_add(1); + }) + .await?; + Ok(()) + } + + pub(super) async fn handle_agent_tool_activity( + &self, + turn_id: &str, + activity: ToolActivityProjection, + ) -> Result<(), String> { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let Some(task_id) = state + .runtime + .iter() + .find(|(_, runtime)| runtime.agent_turn_id.as_deref() == Some(turn_id)) + .map(|(task_id, _)| task_id.clone()) + else { + return Ok(()); + }; + let Some(task_index) = state.tasks.iter().position(|task| task.task_id == task_id) else { + return Ok(()); + }; + if state.tasks[task_index].state != LoopxTaskState::Running { + return Ok(()); + } + + let now = now_ms(); + { + let task = &mut state.tasks[task_index]; + task.last_output_at = Some(now); + task.updated_at = now; + task.current_tool = activity.current_tool.clone(); + task.revision = task.revision.saturating_add(1); + } + let updated = state.tasks[task_index].clone(); + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + task_id: Some(updated.task_id.clone()), + generation: Some(updated.generation), + revision: Some(updated.revision), + kind: LoopxEventKind::Log, + level: if activity.important { + LoopxEventLevel::Error + } else { + LoopxEventLevel::Info + }, + source: LoopxEventSource::Agent, + phase: Some(updated.phase), + message: activity.message, + important: activity.important, + tool_name: Some(activity.tool_name), + details: activity.details, + occurred_at: now, + ..LoopxEvent::default() + }); + let persisted = state.clone(); + let event = persisted.events.last().cloned(); + drop(state); + self.store.save(&persisted).await?; + if let Some(event) = event { + let _ = self.event_sender.send(event); + } + Ok(()) + } + + async fn drive_task(self: &Arc, task_id: String) -> Result<(), String> { + if self.state.read().await.suspended { + // Suite is stopped: leave the task parked in its queue slot. The + // resume path re-enqueues parked tasks. + return Ok(()); + } + let task = self.task(&task_id).await?; + if !matches!( + task.state, + LoopxTaskState::Preparing | LoopxTaskState::Queued + ) { + return Ok(()); + } + if !self.reserve_repository(&task).await { + self.transition_task( + &task_id, + task.generation, + LoopxTaskState::Queued, + LoopxPhase::Queued, + "Another task for this repository is running", + ) + .await?; + return Ok(()); + } + // The goal binding survives restarts, but the workspace directory + // may not (removed by a concurrent instance, a reset, or manual + // cleanup). A deleted worktree also loses its `.loopx/registry.json`, + // so re-running prepare alone would leave the goal disconnected from + // a fresh project registry. Unbind first; the prepare + plan_item + + // create_goal flow below re-adds the worktree and reconnects the same + // deterministic goal id, and the frontier (including pending gates) + // resurfaces from LoopX. + if task_has_bound_goal(&task) && bound_workspace_missing(&task) { + log::warn!( + "LoopX bound workspace is missing, re-preparing and reconnecting the goal: task_id={} goal={} path={}", + task.task_id, + task.goal_id.as_deref().unwrap_or("-"), + task.workspace_path.as_deref().unwrap_or("-"), + ); + self.mutate_task(&task_id, None, |current, current_runtime| { + if current.generation != task.generation { + return; + } + current.goal_id = None; + current.goal_state = None; + current.pending_gate_id = None; + current.pending_gate_message = None; + current.pending_gate_action_kind = None; + current.current_turn_id = None; + current.current_tool = None; + current.current_todo = None; + current.monitor_wait = None; + current.settlement = LoopxSettlementSummary::default(); + current.revision = current.revision.saturating_add(1); + current_runtime.expected_durable_revision = None; + current_runtime.loopx_turn_id = None; + current_runtime.settlement_token = None; + current_runtime.session_id = None; + current_runtime.agent_turn_id = None; + }) + .await?; + } + let workspace_result = self + .workspace + .prepare(LoopxWorkspacePrepareRequest { + operation_id: format!("workspace-{}-{}", task.task_id, task.generation), + task_id: task.task_id.clone(), + item: task.identity.item.clone(), + }) + .await; + let workspace = workspace_result.map_err(|error| error.to_string())?; + if !workspace.repository_verified { + return Err("Prepared worktree does not match the requested repository".to_string()); + } + self.bind_workspace(&task_id, task.generation, &workspace) + .await?; + let task = self.task(&task_id).await?; + if task_has_bound_goal(&task) { + return self.drive_turn(task_id).await; + } + let runtime = self.runtime(&task_id).await; + let progress = BufferedProgress::default(); + let intake = self + .cli + .plan_item( + LoopxCliPlanItemRequest { + context: self.goal_context(&task, &runtime), + item: task.identity.item.clone(), + title: task.identity.title.clone(), + state: task.identity.state, + labels: task.identity.labels.clone(), + }, + &progress, + ) + .await + .map_err(|error| error.to_string())?; + self.record_progress(progress.take()).await?; + let goal_id = goal_id_for(&task.identity); + let progress = BufferedProgress::default(); + let created = self + .cli + .create_goal( + LoopxCliCreateGoalRequest { + context: self.goal_context(&task, &runtime), + goal_id: goal_id.clone(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + intake, + granted_scopes: task.granted_scopes.clone(), + }, + &progress, + ) + .await + .map_err(|error| error.to_string())?; + self.record_progress(progress.take()).await?; + let created_goal_id = created.goal_id.clone(); + self.bind_goal(&task_id, task.generation, created).await?; + log::info!( + "LoopX goal created: task_id={} goal={} agent={} worktree={}", + task_id, + created_goal_id, + task.agent_id.as_deref().unwrap_or("bitfun-agent"), + task.workspace_path.as_deref().unwrap_or("-"), + ); + self.drive_turn(task_id).await + } + + /// Mirrors a terminal state already reported by the authoritative LoopX + /// Goal and advances the next task in the repository queue. + async fn complete_projected_goal( + self: &Arc, + task: &LoopxTaskSnapshot, + message: &str, + ) -> Result<(), String> { + let updated = self + .transition_task( + &task.task_id, + task.generation, + LoopxTaskState::Completed, + LoopxPhase::Finished, + message, + ) + .await?; + self.record_current_todo(&updated.task_id, updated.generation, None) + .await?; + self.schedule_next_for_repository( + &updated.identity.item.repository.canonical_id(), + Some(&updated.task_id), + ) + .await; + Ok(()) + } + + async fn drive_turn(self: &Arc, task_id: String) -> Result<(), String> { + let task = self.task(&task_id).await?; + let runtime = self.runtime(&task_id).await; + let progress = BufferedProgress::default(); + let inspected = self + .cli + .inspect_goal( + LoopxCliInspectGoalRequest { + context: self.goal_context(&task, &runtime), + goal_id: task.goal_id.clone().unwrap_or_default(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + }, + &progress, + ) + .await + .map_err(|error| error.to_string())?; + self.record_progress(progress.take()).await?; + let selected = inspected.selected_todo.as_ref(); + log::info!( + "LoopX inspect goal: task_id={} goal={} decision={:?} state={:?} open_todos={} waiting_user={} selected_todo={} selected_kind={} claimed_by={} revision={} cadence={} over_budget={}", + task.task_id, + inspected.goal_id, + inspected.run_decision, + inspected.state, + inspected.open_todo_count, + inspected.waiting_user_todo_count, + selected.map(|t| t.todo_id.as_str()).unwrap_or("-"), + selected.map(|t| t.action_kind.as_str()).unwrap_or("-"), + selected.map(|t| t.claimed_by.as_str()).unwrap_or("-"), + inspected.durable_revision, + inspected.scheduler_cadence.as_deref().unwrap_or("-"), + inspected.envelope_over_budget, + ); + self.record_goal_state(&task, inspected.state).await?; + self.record_current_todo(&task_id, task.generation, inspected.selected_todo.clone()) + .await?; + // Monitoring projection: between two checks of a monitor todo the + // envelope reports `cadence=monitor_wait` with no selected todo and no + // open todos, so nothing else in the task record says this goal is + // still being watched. Persist it (with the scheduler's own delay) so + // the rail keeps showing "monitoring" until the PR is merged/closed + // and the goal ends. + self.record_monitor_wait( + &task_id, + task.generation, + monitoring_wait_projection(&inspected), + ) + .await?; + match inspected.run_decision { + LoopxCliRunDecision::Wait => { + if inspected.state == LoopxCliGoalState::Archived { + return self + .complete_projected_goal(&task, "LoopX Goal was archived") + .await; + } + self.transition_task( + &task_id, + task.generation, + LoopxTaskState::Queued, + LoopxPhase::Queued, + "LoopX is waiting before the next bounded turn", + ) + .await?; + self.schedule_next_for_repository( + &task.identity.item.repository.canonical_id(), + Some(&task_id), + ) + .await; + // The compacted v1.0.x envelope deliberately carries only + // cadence labels, not numeric intervals (those live in the + // quota decision detail), so the host owns translating the + // label into a concrete requeue delay: an explicit numeric + // hint wins when a future pin emits one, otherwise the + // cadence class maps onto the pinned scheduler's own initial + // interval, and a label-less snapshot falls back to a bounded + // poll that can never sleep forever. + let delay = wait_requeue_delay_ms(&inspected); + log::info!( + "LoopX wait requeue: task_id={} goal={} delay_ms={}", + task_id, + inspected.goal_id, + delay, + ); + self.enqueue_task(task_id, Duration::from_millis(delay))?; + Ok(()) + } + LoopxCliRunDecision::WaitingForUser => { + self.waiting_user_frontier(&task, &runtime, &inspected) + .await + } + LoopxCliRunDecision::Complete => { + return self + .complete_projected_goal(&task, "LoopX goal completed") + .await; + } + LoopxCliRunDecision::Failed => { + self.fail_task(&task_id, "LoopX reported a failed goal".to_string()) + .await + } + LoopxCliRunDecision::RunNow => { + self.sync_concurrent_user_gate(&task, inspected.pending_user_gate.as_ref()) + .await?; + // A selected publish/merge todo is not independent agent work + // while the typed owner gate that authorizes it is open. Do not + // drive the agent through the same no-op turn; park and surface + // the gate until the owner answers it. + if inspected.pending_user_gate.is_some() + && selected_todo_requires_owner_gate(inspected.selected_todo.as_ref()) + { + log::info!( + "LoopX selected agent todo is owner-gated; parking for the owner decision: task_id={} goal={} todo={} gate={}", + task_id, + inspected.goal_id, + inspected + .selected_todo + .as_ref() + .map(|todo| todo.todo_id.as_str()) + .unwrap_or("-"), + inspected + .pending_user_gate + .as_ref() + .map(|gate| gate.gate_id.as_str()) + .unwrap_or("-"), + ); + return self + .waiting_user_frontier(&task, &runtime, &inspected) + .await; + } + // The contradiction witness is the envelope's own action + // projection, not the `open_count` scalar: the counter is a + // claim-scoped summary that can legitimately be zero while + // `action.selected_todo` still names an open, claimed todo. + // Only refuse when the envelope itself asserts there is + // nothing to do; `quota should-run --turn-envelope` remains + // the authoritative execution gate either way. + let has_selected_todo = inspected + .selected_todo + .as_ref() + .is_some_and(|todo| !todo.todo_id.is_empty()); + if run_now_is_frontier_contradiction( + inspected.open_todo_count, + inspected.waiting_user_todo_count, + has_selected_todo, + ) { + // Runtime-data correction (2026-09-05 five-issue run, + // re-verified against the pinned v1.0.1): the CLI does NOT + // treat a todo-less `RunNow` frontier as terminal. When + // every todo is done or blocked + // and the goal vision is still open, it projects + // `should_run=true` plus an autonomous replan obligation + // and expects the host to drive one bounded replan turn + // bound to that obligation: the agent then writes back a + // successor todo, a typed terminal outcome (for example + // `coverage_backed_no_followup`, after which the goal + // projects Complete), or a concrete blocker. The quota + // guard admits exactly that turn and the settlement + // validates it by the `autonomous_replan` effect id. + // Parking here stranded every task of the five-issue run + // before any goal could close. + let replan_frontier = todoless_run_now_frontier( + inspected.pending_replan_obligation_id.as_deref(), + ); + if replan_frontier == TodolessRunNowFrontier::DriveReplanTurn { + log::info!( + "LoopX plan exhausted with an open autonomous replan obligation; driving one replan turn: task_id={} goal={} obligation={}", + task_id, + inspected.goal_id, + inspected.pending_replan_obligation_id.as_deref().unwrap_or("-"), + ); + // Fall through to the normal turn build below: the + // guard produces the `AutonomousReplan` binding and + // the re-entry instruction carries the replan flags. + } else { + // True contradiction: no open todo, no waiting user + // decision, no selected action, and no replan + // obligation the CLI would let the host drive. Before + // parking, check the durable todos for an owner- + // decision wait the agent encoded as a BLOCKED + // publication todo (observed live 2026-09-11: the + // agent prepared the publish step, blocked its own + // todo with reason "publishing needs the repository + // owner's explicit decision", and the frontier then + // projected empty — the CLI counts neither blocked + // todos nor them as user gates, so the task parked + // plan-exhausted with the fix ready and no way to + // approve it). A blocked publication todo IS an + // owner decision: project it as the publish approval + // card instead of the plan-exhausted error. + if task.state == LoopxTaskState::RecoveryRequired + && task.recovery_reason.as_deref() == Some(LOOPX_PLAN_EXHAUSTED_REASON) + { + // Already parked with this exact diagnosis; do not + // churn another transition/event on a re-drive. + return Ok(()); + } + if let Some(todo) = self + .find_blocked_publication_todo(&task, &runtime, &inspected.goal_id) + .await + { + return self + .park_publication_approval(&task, &inspected.goal_id, &todo) + .await; + } + // Park with an explicit reason so the recovery card can + // explain the plan is exhausted and guide the owner. + // The task keeps its worktree, evidence, and commits, + // and the repository slot yields to queued siblings. + return self.park_plan_exhausted(&task, &inspected.goal_id).await; + } + } + // An over-budget envelope no longer gates driving: the + // continuation authority is the live quota decision (see + // `quota_probe_args`), which keeps projecting should_run and + // the selected todo past the 8192-byte compaction budget. + // The flag stays informational for telemetry. + if inspected.envelope_over_budget { + log::warn!( + "LoopX turn envelope is over the compaction budget; continuing from the quota decision: task_id={} goal={}", + task.task_id, + inspected.goal_id, + ); + } + // User-gated frontier (live 2026-09-09, issue 3): the envelope + // projects should_run=true through the `agent_with_user_gate` + // fallback (the agent is asked to surface the owner decision), + // but no agent work item remains - no selected todo and no + // replan obligation, so the guard correctly refuses a + // settlement binding and a turn cannot be built. The BitFun + // UI already surfaces the gate, so driving a model turn just + // to repeat the request is waste: park as waiting for the + // owner instead of failing the task. + if inspected.selected_todo.is_none() + && inspected.pending_replan_obligation_id.is_none() + && inspected.waiting_user_todo_count > 0 + { + log::info!( + "LoopX frontier is user-gated with no agent work item; parking for the owner decision: task_id={} goal={} waiting_user={}", + task.task_id, + inspected.goal_id, + inspected.waiting_user_todo_count, + ); + return self + .waiting_user_frontier(&task, &runtime, &inspected) + .await; + } + if task.state == LoopxTaskState::RecoveryRequired { + // Restart-interrupted runs land here; the owner decides + // via the explicit recovery action in the UI (nothing + // silent, nothing forged, worktree and evidence kept). + return Ok(()); + } + // v1.0.1 owns the monitor cadence itself: a monitor todo is + // projected RunNow only when its `next_due_at` has passed + // (`monitor_due`), and an unchanged monitor writeback is + // rejected unless it advances the schedule. The host must not + // interpose a second hold clock on top of that decision. + let progress = BufferedProgress::default(); + let built = self + .cli + .build_turn( + LoopxCliBuildTurnRequest { + context: self.goal_context(&task, &runtime), + goal_id: task.goal_id.clone().unwrap_or_default(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + expected_durable_revision: inspected.durable_revision, + }, + &progress, + ) + .await; + let turn = match built { + Ok(turn) => turn, + Err(error) if error.kind == LoopxCliErrorKind::Conflict && error.retryable => { + // Transient durable-state race: a concurrent bootstrap + // or global-registry sync landed between this task's + // inspect and its quota guard. The envelope is healthy, + // so requeue with a short backoff instead of failing + // the host job (mirrors the envelope-over-budget + // degradation; the next drive re-reads fresh state). + let message = format!( + "LoopX durable state changed while building the turn ({}); requeueing with backoff", + error.message + ); + log::warn!( + "LoopX turn build conflict, requeueing: task_id={} goal={} detail={}", + task_id, + task.goal_id.as_deref().unwrap_or("-"), + error.message + ); + let updated = self + .transition_task( + &task_id, + task.generation, + LoopxTaskState::Queued, + LoopxPhase::RetryBackoff, + &message, + ) + .await?; + self.record_progress(progress.take()).await?; + self.append_task_event( + &updated, + LoopxEventKind::StateChanged, + &message, + false, + ) + .await?; + self.schedule_next_for_repository( + &task.identity.item.repository.canonical_id(), + Some(&task_id), + ) + .await; + self.enqueue_task(task_id, Duration::from_millis(TURN_CONFLICT_RETRY_MS))?; + return Ok(()); + } + Err(error) => return Err(error.to_string()), + }; + self.record_progress(progress.take()).await?; + self.bind_turn(&task, &turn).await?; + let host_note = self.take_pending_host_note(&task.task_id).await; + if host_note.is_some() { + log::info!( + "LoopX host note appended to turn instruction: task_id={}", + task.task_id, + ); + } + // The pinned LoopX skill document is seeded into the worktree + // (`.loopx/pinned-loopx-skill.md`) and the agent is pointed at + // it; it reads the authoritative skill doc once per session + // like a LoopX codex-style host, and consults the separate + // CLI help file on demand. + let pinned_reference_path = task + .workspace_path + .as_deref() + .map(|workspace| format!("{workspace}\\.loopx\\pinned-loopx-skill.md")); + let agent_instruction = compose_agent_turn_instruction( + turn.agent_instruction, + host_note.as_deref(), + pinned_reference_path.as_deref(), + // A kept session continues the same conversation, so the + // reference pointer must not ask for a fresh read. + runtime.session_id.is_some(), + ); + log::info!( + "LoopX turn built, starting agent: task_id={} goal={} turn={} deadline_ms={:?} instruction_bytes={}", + task.task_id, + turn.goal_id, + turn.turn_id, + turn.deadline_at, + agent_instruction.len(), + ); + let started = self + .agent + .start(LoopxAgentStartRequest { + operation_id: format!("agent-{}-{}", task.task_id, task.generation), + task_id: task.task_id.clone(), + generation: task.generation, + worktree_path: task.workspace_path.clone().unwrap_or_default(), + instruction: agent_instruction, + model_id: task.model_id.clone().unwrap_or_else(|| "auto".to_string()), + granted_scopes: task.granted_scopes.clone(), + // Codex-parity: continue the goal's live agent session + // when the previous settled turn kept it (the runtime + // record clears the id whenever the session is + // discarded, and a stale id falls back to a fresh + // session inside the port). + reuse_session_id: runtime.session_id.clone(), + metadata: LoopxAgentTurnMetadata { + goal_id: task.goal_id.clone().unwrap_or_default(), + loopx_turn_id: turn.turn_id, + item: task.identity.item.clone(), + attempt: task.identity.attempt, + }, + }) + .await + .map_err(|error| error.to_string())?; + self.bind_agent_run(&task, started).await + } + } + } + + /// Handles a frontier whose next unlock is an owner decision: either a + /// typed user gate (approval card, with read-only/reuse-merge + /// auto-answers) or an owner action outside the host (external review + /// queue). Reached both from the explicit `WaitingForUser` decision and + /// from the user-gated `RunNow` fallback that has no agent work item. + async fn waiting_user_frontier( + self: &Arc, + task: &LoopxTaskSnapshot, + runtime: &LoopxTaskRuntimeRecord, + inspected: &LoopxCliGoalSnapshot, + ) -> Result<(), String> { + let task_id = task.task_id.clone(); + let Some(gate) = inspected.pending_user_gate.clone() else { + // Owner action outside the host (live 2026-09-08, issue 2: the + // agent opened PR #4 and LoopX projected the owner review/merge + // queue as an open user todo without a typed user_gate). Park as + // waiting: no approval card, the owner acts on the external + // surface, the slot yields. + return self + .park_waiting_owner_action(task, inspected.waiting_user_summary.as_deref()) + .await; + }; + if is_read_only_user_gate(gate.action_kind.as_deref(), Some(gate.message.as_str())) { + match self + .auto_answer_gate( + &task, + &runtime, + &gate, + LoopxCliGateDecision::Approve, + "Auto-approved by BitFun: read-only public issue content access.".to_string(), + format!( + "Read-only user gate auto-approved by BitFun: {}", + gate.message + ), + ) + .await + { + Ok(()) => return Ok(()), + Err(error) => { + // Interactive approval stays available as the + // fallback when the automatic answer fails. + log::warn!( + "LoopX read-only gate auto-approval failed, falling back to interactive approval: task_id={} gate={} error={}", + task.task_id, + gate.gate_id, + error + ); + } + } + } + if is_reuse_merge_user_gate(gate.action_kind.as_deref(), &gate.message) { + let repository = task.identity.item.repository.clone(); + match self + .cli + .viewer_merge_authority(&self.goal_context(&task, &runtime), &repository) + .await + { + // Authority confirmed or unknown: leave the decision + // to the owner. + Ok(Some(true)) | Ok(None) => {} + Ok(Some(false)) => { + let pr_label = reuse_merge_pr_label(&gate.message); + match self + .auto_answer_gate( + &task, + &runtime, + &gate, + LoopxCliGateDecision::Reject, + format!( + "Auto-rejected by BitFun: the authenticated GitHub identity has no merge authority for {}; the agent must propose an alternative route (track the upstream PR, or an independent patch).", + repository.label() + ), + format!( + "Merge gate auto-rejected: no merge authority for {} ({}); the agent will need an alternative route", + repository.label(), + pr_label + ), + ) + .await + { + Ok(()) => return Ok(()), + Err(error) => log::warn!( + "LoopX merge-gate auto-reject failed, falling back to interactive: task_id={} gate={} error={}", + task.task_id, + gate.gate_id, + error + ), + } + } + Err(error) => { + log::warn!( + "LoopX merge authority probe failed, surfacing gate interactively: task_id={} gate={} error={}", + task.task_id, + gate.gate_id, + error + ); + } + } + } + let LoopxCliUserGate { + gate_id, + message, + action_kind, + } = gate; + let durable_revision = inspected.durable_revision.clone(); + let updated = self + .mutate_task(&task_id, None, |current, current_runtime| { + if current.generation != task.generation { + return; + } + current.state = LoopxTaskState::WaitingForUser; + current.phase = LoopxPhase::WaitingForApproval; + current.pending_gate_id = Some(gate_id.clone()); + current.pending_gate_message = Some(message.clone()); + current.pending_gate_action_kind = action_kind.clone(); + current.revision = current.revision.saturating_add(1); + current_runtime.expected_durable_revision = Some(durable_revision.clone()); + }) + .await?; + let mut details = BTreeMap::new(); + details.insert("gateId".to_string(), gate_id.clone()); + if let Some(action_kind) = action_kind.clone() { + details.insert("actionKind".to_string(), action_kind); + } + self.append_task_event_with_details( + &updated, + LoopxEventKind::ApprovalRequired, + &message, + true, + details, + ) + .await?; + self.schedule_next_for_repository( + &task.identity.item.repository.canonical_id(), + Some(&task_id), + ) + .await; + Ok(()) + } + + /// Best-effort discard of a task's live agent session: clears the + /// runtime record's session binding (so the next started turn opens a + /// fresh transient session instead of trying to reuse a discarded one) + /// and tears the session itself down. Failures are logged only; LoopX + /// durable state is never affected by host-side session hygiene. + async fn discard_agent_session( + self: &Arc, + task: &LoopxTaskSnapshot, + runtime: &LoopxTaskRuntimeRecord, + ) { + let Some(session_id) = runtime.session_id.clone() else { + return; + }; + let generation = task.generation; + let _ = self + .mutate_task(&task.task_id, None, |current, runtime| { + if current.generation != generation { + return; + } + runtime.session_id = None; + }) + .await; + let finish_result = self + .agent + .finish(LoopxAgentFinishRequest { + operation_id: format!("finish-agent-{}", uuid::Uuid::new_v4()), + task_id: task.task_id.clone(), + generation: task.generation, + worktree_path: task.workspace_path.clone().unwrap_or_default(), + session_id, + turn_id: runtime.agent_turn_id.clone().unwrap_or_default(), + }) + .await; + match &finish_result { + Ok(finish) => log::info!( + "LoopX transient Agent session finished: task_id={}, session_id={}, discarded={}", + task.task_id, + finish.session_id, + finish.discarded + ), + Err(error) => log::warn!( + "LoopX transient Agent session cleanup failed: task_id={}, error={}", + task.task_id, + error + ), + } + } + + /// Best-effort teardown of a task's agent run (cancel then finish). A stale + /// session — for example one persisted before a host restart — must not + /// abort pause or reset. The local record owns only host-job cleanup; LoopX + /// remains authoritative for Goal lifecycle, so teardown failures are + /// logged and the operator action continues. + async fn teardown_agent_run( + self: &Arc, + task: &LoopxTaskSnapshot, + runtime: &LoopxTaskRuntimeRecord, + ) { + let (Some(session_id), Some(turn_id)) = + (runtime.session_id.as_ref(), runtime.agent_turn_id.as_ref()) + else { + return; + }; + // The session is being torn down: drop the record binding too, so a + // later requeue cannot hand the stale id to the session-reuse path. + let generation = task.generation; + let _ = self + .mutate_task(&task.task_id, None, |current, runtime| { + if current.generation != generation { + return; + } + runtime.session_id = None; + }) + .await; + if let Err(error) = self + .agent + .cancel(LoopxAgentCancelRequest { + operation_id: format!("teardown-agent-{}", uuid::Uuid::new_v4()), + target_operation_id: runtime.operation_id.clone(), + task_id: task.task_id.clone(), + generation: task.generation, + session_id: session_id.clone(), + turn_id: turn_id.clone(), + }) + .await + { + log::warn!( + "LoopX agent cancel skipped for task {}: {}", + task.task_id, + error + ); + } + if let Err(error) = self + .agent + .finish(LoopxAgentFinishRequest { + operation_id: format!("teardown-agent-finish-{}", uuid::Uuid::new_v4()), + task_id: task.task_id.clone(), + generation: task.generation, + worktree_path: task.workspace_path.clone().unwrap_or_default(), + session_id: session_id.clone(), + turn_id: turn_id.clone(), + }) + .await + { + log::warn!( + "LoopX agent finish skipped for task {}: {}", + task.task_id, + error + ); + } + } + + /// Suite-level stop: pauses every active agent turn held by this host and + /// sets the durable suspension flag so intake and scheduling hold until the + /// user explicitly resumes the suite. Waiting-for-user gates are left + /// intact: they reflect a decision the owner still owes, and resume re-arms + /// them. There is deliberately no per-task stop; stopping is suite-scoped. + async fn pause_all( + self: &Arc, + request: &LoopxActionRequest, + ) -> Result { + self.ensure_writable().await?; + let paused_task_ids = { + let state = self.state.read().await; + state + .tasks + .iter() + .filter(|task| { + matches!( + task.state, + LoopxTaskState::Preparing + | LoopxTaskState::Queued + | LoopxTaskState::Running + ) + }) + .map(|task| task.task_id.clone()) + .collect::>() + }; + let mut paused = 0usize; + for task_id in paused_task_ids { + let (task, runtime) = { + let state = self.state.read().await; + let Some(task) = state.tasks.iter().find(|t| t.task_id == task_id).cloned() else { + continue; + }; + ( + task, + state.runtime.get(&task_id).cloned().unwrap_or_default(), + ) + }; + if task.state == LoopxTaskState::Running { + if self + .pause_task(&task, &runtime, &request.client_request_id) + .await + .is_ok() + { + paused += 1; + } + } else { + // Queued/Preparing: nothing is executing yet; park them without + // touching any agent run so resume can re-arm them per task. + self.transition_task( + &task.task_id, + task.generation, + LoopxTaskState::Stopped, + LoopxPhase::Finished, + "Suite stopped before the task started", + ) + .await?; + paused += 1; + } + } + { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let start_cursor = state.cursor; + if !state.suspended { + state.suspended = true; + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + kind: LoopxEventKind::SnapshotInvalidated, + level: LoopxEventLevel::Info, + source: LoopxEventSource::Controller, + message: format!( + "LoopX suite stopped; intake and scheduling are held until resume (paused {paused} task(s))" + ), + occurred_at: now_ms(), + ..LoopxEvent::default() + }); + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + self.broadcast_new_events(&persisted, start_cursor); + } + } + Ok(LoopxActionResponse { + status: LoopxActionStatus::Applied, + current_revision: self.state.read().await.revision, + message: Some("LoopX suite stopped".to_string()), + ..LoopxActionResponse::default() + }) + } + + /// Suite-level continue: clears the durable suspension flag, refreshes + /// host projections exactly like a host resume, and re-enqueues tasks that + /// were parked by the stop. + async fn resume_all( + self: &Arc, + _request: &LoopxActionRequest, + ) -> Result { + self.ensure_writable().await?; + { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let start_cursor = state.cursor; + if state.suspended { + state.suspended = false; + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + kind: LoopxEventKind::SnapshotInvalidated, + level: LoopxEventLevel::Info, + source: LoopxEventSource::Controller, + message: "LoopX suite resumed; intake and scheduling re-enabled".to_string(), + occurred_at: now_ms(), + ..LoopxEvent::default() + }); + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + self.broadcast_new_events(&persisted, start_cursor); + } + } + if let Err(error) = self.refresh_environment().await { + log::warn!("LoopX environment refresh after suite resume failed: {error}"); + } + self.reconcile_goal_projections(true).await; + self.enqueue_ready_tasks_after_load().await; + Ok(LoopxActionResponse { + status: LoopxActionStatus::Applied, + current_revision: self.state.read().await.revision, + message: Some("LoopX suite resumed".to_string()), + ..LoopxActionResponse::default() + }) + } + + async fn pause_task( + self: &Arc, + task: &LoopxTaskSnapshot, + runtime: &LoopxTaskRuntimeRecord, + request_id: &str, + ) -> Result { + self.transition_task( + &task.task_id, + task.generation, + LoopxTaskState::Cancelling, + LoopxPhase::Cancelling, + "Cancelling the active LoopX task", + ) + .await?; + self.teardown_agent_run(task, runtime).await; + let progress = BufferedProgress::default(); + let _ = self + .cli + .cancel( + LoopxCliCancelRequest { + call: LoopxCliCallContext { + operation_id: format!("cancel-cli-{}", uuid::Uuid::new_v4()), + deadline_at: None, + }, + target_operation_id: runtime.operation_id.clone(), + }, + &progress, + ) + .await; + self.record_progress(progress.take()).await?; + let response = self + .transition_action( + &task.task_id, + LoopxTaskState::Stopped, + LoopxPhase::Finished, + request_id, + ) + .await?; + self.schedule_next_for_repository( + &task.identity.item.repository.canonical_id(), + Some(&task.task_id), + ) + .await; + Ok(response) + } + + async fn abort_task( + self: &Arc, + task: &LoopxTaskSnapshot, + runtime: &LoopxTaskRuntimeRecord, + request_id: &str, + ) -> Result { + self.transition_task( + &task.task_id, + task.generation, + LoopxTaskState::Cancelling, + LoopxPhase::Cancelling, + "Aborting the active LoopX task", + ) + .await?; + self.teardown_agent_run(task, runtime).await; + let progress = BufferedProgress::default(); + let _ = self + .cli + .cancel( + LoopxCliCancelRequest { + call: LoopxCliCallContext { + operation_id: format!("abort-cli-{}", uuid::Uuid::new_v4()), + deadline_at: None, + }, + target_operation_id: runtime.operation_id.clone(), + }, + &progress, + ) + .await; + self.record_progress(progress.take()).await?; + let response = self + .transition_action( + &task.task_id, + LoopxTaskState::Aborted, + LoopxPhase::Finished, + request_id, + ) + .await?; + self.schedule_next_for_repository( + &task.identity.item.repository.canonical_id(), + Some(&task.task_id), + ) + .await; + Ok(response) + } + + async fn resume_task( + self: &Arc, + task: &LoopxTaskSnapshot, + request_id: &str, + ) -> Result { + if self.state.read().await.suspended { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Rejected, + current_revision: task.revision, + task: Some(task.clone()), + message: Some("LoopX suite is stopped; resume the suite first".to_string()), + }); + } + if !matches!( + task.state, + LoopxTaskState::Stopped + | LoopxTaskState::Failed + | LoopxTaskState::RecoveryRequired + | LoopxTaskState::WaitingForUser + ) { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Rejected, + current_revision: task.revision, + task: Some(task.clone()), + message: Some( + "Only stopped, failed, recovery-required, or waiting tasks can resume" + .to_string(), + ), + }); + } + let updated = self + .mutate_task(&task.task_id, Some(request_id), |task, runtime| { + task.generation = task.generation.saturating_add(1); + task.revision = task.revision.saturating_add(1); + task.state = LoopxTaskState::Queued; + task.phase = LoopxPhase::Recovering; + task.current_turn_id = None; + task.pending_gate_id = None; + task.pending_gate_message = None; + task.pending_gate_action_kind = None; + task.error = None; + task.recovery_reason = None; + runtime.operation_id = format!("resume-{}-{}", task.task_id, task.generation); + runtime.session_id = None; + runtime.agent_turn_id = None; + runtime.loopx_turn_id = None; + runtime.settlement_token = None; + runtime.expected_durable_revision = None; + }) + .await?; + let task_id = task.task_id.clone(); + self.enqueue_task(task_id, Duration::ZERO)?; + Ok(LoopxActionResponse { + current_revision: updated.revision, + task: Some(updated), + ..LoopxActionResponse::default() + }) + } + + async fn resume_repository( + self: &Arc, + request: &LoopxActionRequest, + ) -> Result { + let repository = request + .repository + .as_ref() + .ok_or_else(|| "repository is required for resume_repository".to_string())?; + let repository_id = repository.canonical_id(); + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + if state.has_processed_request(&request.client_request_id) { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: state.revision, + message: Some("Repository resume was already applied".to_string()), + ..LoopxActionResponse::default() + }); + } + if state.revision != request.expected_revision { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::RevisionConflict, + current_revision: state.revision, + message: Some( + "Task list changed; refresh before resuming the repository".to_string(), + ), + ..LoopxActionResponse::default() + }); + } + + let task_indexes = state + .tasks + .iter() + .enumerate() + .filter_map(|(index, task)| { + is_repository_recovery_candidate(task, &repository_id).then_some(index) + }) + .collect::>(); + let start_cursor = state.cursor; + let now = now_ms(); + let mut task_ids = Vec::with_capacity(task_indexes.len()); + for task_index in task_indexes { + let task_id = state.tasks[task_index].task_id.clone(); + let mut runtime = state.runtime.remove(&task_id).unwrap_or_default(); + { + let task = &mut state.tasks[task_index]; + task.generation = task.generation.saturating_add(1); + task.revision = task.revision.saturating_add(1); + task.state = LoopxTaskState::Queued; + task.phase = LoopxPhase::Recovering; + task.current_turn_id = None; + task.error = None; + task.recovery_reason = None; + task.updated_at = now; + runtime.operation_id = format!("resume-{}-{}", task.task_id, task.generation); + runtime.session_id = None; + runtime.agent_turn_id = None; + runtime.loopx_turn_id = None; + runtime.settlement_token = None; + runtime.expected_durable_revision = None; + } + let updated = state.tasks[task_index].clone(); + state.runtime.insert(task_id.clone(), runtime); + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + task_id: Some(task_id.clone()), + generation: Some(updated.generation), + revision: Some(updated.revision), + kind: LoopxEventKind::StateChanged, + source: LoopxEventSource::Controller, + phase: Some(LoopxPhase::Recovering), + message: "Task queued by repository resume".to_string(), + occurred_at: now, + ..LoopxEvent::default() + }); + task_ids.push(task_id); + } + state.record_processed_request(request.client_request_id.clone()); + let resumed_count = task_ids.len(); + let current_revision = state.revision; + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + drop(_mutation); + self.broadcast_new_events(&persisted, start_cursor); + for task_id in task_ids { + self.enqueue_task(task_id, Duration::ZERO)?; + } + Ok(LoopxActionResponse { + current_revision, + message: Some(format!("Queued {resumed_count} repository tasks")), + ..LoopxActionResponse::default() + }) + } + + async fn reset_all( + self: &Arc, + request: &LoopxActionRequest, + ) -> Result { + if self + .reset_in_progress + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: self.state.read().await.revision, + message: Some("LoopX reset is already in progress".to_string()), + ..LoopxActionResponse::default() + }); + } + let _reset_guard = InProgressGuard(&self.reset_in_progress); + let (tasks, runtimes, previous_stream_id, environment) = { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + if state.has_processed_request(&request.client_request_id) { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: state.revision, + message: Some("LoopX reset was already applied".to_string()), + ..LoopxActionResponse::default() + }); + } + if state.revision != request.expected_revision { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::RevisionConflict, + current_revision: state.revision, + message: Some("LoopX state changed; refresh before resetting".to_string()), + ..LoopxActionResponse::default() + }); + } + let tasks = state.tasks.clone(); + let runtimes = state.runtime.clone(); + let environment = state.environment.clone(); + for task in &mut state.tasks { + if matches!( + task.state, + LoopxTaskState::Preparing + | LoopxTaskState::Queued + | LoopxTaskState::Running + | LoopxTaskState::Cancelling + ) { + task.state = LoopxTaskState::Cancelling; + task.phase = LoopxPhase::Cancelling; + task.revision = task.revision.saturating_add(1); + task.updated_at = now_ms(); + } + } + state.record_processed_request(request.client_request_id.clone()); + state.revision = state.revision.saturating_add(1); + let persisted = state.clone(); + let previous_stream_id = state.stream_id.clone(); + drop(state); + self.store.save(&persisted).await?; + (tasks, runtimes, previous_stream_id, environment) + }; + + for task in &tasks { + let runtime = runtimes.get(&task.task_id).cloned().unwrap_or_default(); + self.teardown_agent_run(task, &runtime).await; + if !runtime.operation_id.trim().is_empty() { + let progress = BufferedProgress::default(); + let _ = self + .cli + .cancel( + LoopxCliCancelRequest { + call: LoopxCliCallContext { + operation_id: format!("reset-cli-{}", uuid::Uuid::new_v4()), + deadline_at: None, + }, + target_operation_id: runtime.operation_id.clone(), + }, + &progress, + ) + .await; + } + let _ = self + .workspace + .cancel(LoopxWorkspaceCancelRequest { + operation_id: format!("reset-workspace-{}", uuid::Uuid::new_v4()), + target_operation_id: format!("workspace-{}-{}", task.task_id, task.generation), + task_id: task.task_id.clone(), + }) + .await; + } + + let workspace_reset_deferred = match self + .workspace + .reset(LoopxWorkspaceResetRequest { + operation_id: format!("reset-workspaces-{}", uuid::Uuid::new_v4()), + }) + .await + { + Ok(_) => None, + Err(error) => { + let message = error.to_string(); + log::warn!( + "LoopX workspace reset is blocked by lingering handles; controller reset will continue and workspace cleanup will retry in the background: {}", + message + ); + let workspace = Arc::clone(&self.workspace); + tokio::spawn(async move { + for attempt in 0..60u32 { + let delay_secs = if attempt < 12 { 5 } else { 30 }; + tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await; + let operation_id = + format!("reset-workspaces-retry-{}", uuid::Uuid::new_v4()); + match workspace + .reset(LoopxWorkspaceResetRequest { operation_id }) + .await + { + Ok(_) => { + log::info!( + "Deferred LoopX workspace cleanup succeeded: attempt={}", + attempt + 1 + ); + return; + } + Err(retry_error) => log::warn!( + "Deferred LoopX workspace cleanup is still blocked: attempt={}, error={}", + attempt + 1, + retry_error + ), + } + } + log::warn!( + "Deferred LoopX workspace cleanup exhausted retries; workspaces remain on disk for manual cleanup" + ); + }); + Some(message) + } + }; + let goal_ids = tasks + .iter() + .map(|task| goal_id_for(&task.identity)) + .collect::>() + .into_iter() + .collect::>(); + let reset_goals = if goal_ids.is_empty() { + LoopxCliResetGoalsResult::default() + } else { + let progress = BufferedProgress::default(); + let result = self + .cli + .reset_goals( + LoopxCliResetGoalsRequest { + call: LoopxCliCallContext { + operation_id: format!("reset-goals-{}", uuid::Uuid::new_v4()), + deadline_at: None, + }, + goal_ids, + }, + &progress, + ) + .await + .map_err(|error| error.to_string())?; + self.record_progress(progress.take()).await?; + result + }; + log::info!( + "LoopX reset goal cleanup completed: requested={}, retired={}, already_absent={}, archived={}, missing_runtime={}", + reset_goals.requested_goal_ids.len(), + reset_goals.retired_goal_ids.len(), + reset_goals.already_absent_goal_ids.len(), + reset_goals.archived_goal_ids.len(), + reset_goals.missing_runtime_goal_ids.len() + ); + self.agent + .reset(LoopxAgentResetRequest { + operation_id: format!("reset-history-{}", uuid::Uuid::new_v4()), + }) + .await + .map_err(|error| error.to_string())?; + + let mut fresh = LoopxPersistedState::new(now_ms()); + fresh.environment = environment; + self.store.clear().await?; + { + let _mutation = self.mutation_lock.lock().await; + *self.state.write().await = fresh.clone(); + self.active_tasks.lock().await.clear(); + self.active_repositories.lock().await.clear(); + self.previews.write().await.clear(); + *self.load_error.write().await = None; + } + let _ = self.event_sender.send(LoopxEvent { + stream_id: fresh.stream_id, + cursor: 0, + kind: LoopxEventKind::SnapshotInvalidated, + level: LoopxEventLevel::Info, + source: LoopxEventSource::Controller, + message: format!("LoopX reset replaced stream {previous_stream_id}"), + occurred_at: now_ms(), + ..LoopxEvent::default() + }); + Ok(LoopxActionResponse { + current_revision: fresh.revision, + message: Some(match workspace_reset_deferred { + Some(warning) => format!( + "Cleared {} LoopX tasks, {} global goal routes, runtime state, and persisted controller state; managed workspace cleanup is still running in the background and will retry ({warning})", + tasks.len(), + reset_goals.retired_goal_ids.len() + + reset_goals.already_absent_goal_ids.len() + ), + None => format!( + "Cleared {} LoopX tasks, {} global goal routes, managed workspaces, runtime state, and persisted controller state", + tasks.len(), + reset_goals.retired_goal_ids.len() + + reset_goals.already_absent_goal_ids.len() + ), + }), + ..LoopxActionResponse::default() + }) + } + + /// Applies the owner's decision to a blocked publication todo projected + /// as the publish approval. See [`Self::park_publication_approval`] for + /// why this cannot reuse the typed gate answer. + async fn answer_blocked_publication( + self: &Arc, + task: &LoopxTaskSnapshot, + runtime: &LoopxTaskRuntimeRecord, + request: &LoopxActionRequest, + todo: &LoopxCliTodoSummary, + ) -> Result { + if request.action == LoopxActionKind::Approve { + let progress = BufferedProgress::default(); + let unblocked = self + .cli + .unblock_todo( + LoopxCliUnblockTodoRequest { + context: self.goal_context(task, runtime), + goal_id: task.goal_id.clone().unwrap_or_default(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + todo_id: todo.todo_id.clone(), + note: "Owner approved publishing from the BitFun host UI; the blocked publication todo is re-opened for execution.".to_string(), + }, + &progress, + ) + .await; + self.record_progress(progress.take()).await?; + let unblocked = unblocked.map_err(|error| error.to_string())?; + if !unblocked.applied { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Rejected, + current_revision: task.revision, + task: Some(task.clone()), + message: Some("LoopX did not apply the unblock".to_string()), + }); + } + log::info!( + "LoopX owner approved the blocked publication todo; re-opening it and requeueing: task_id={} todo={}", + task.task_id, + todo.todo_id + ); + let generation = task.generation; + self.mutate_task(&task.task_id, None, |current, current_runtime| { + if current.generation != generation { + return; + } + current.revision = current.revision.saturating_add(1); + current.pending_gate_id = None; + current.pending_gate_message = None; + current.pending_gate_action_kind = None; + current_runtime.expected_durable_revision = + Some(unblocked.durable_revision.clone()); + }) + .await?; + let updated = self.task(&task.task_id).await?; + self.append_task_event( + &updated, + LoopxEventKind::StateChanged, + "Owner approved publishing; the publication todo was re-opened and the task requeued", + true, + ) + .await?; + let mut response = self + .transition_action( + &task.task_id, + LoopxTaskState::Queued, + LoopxPhase::Queued, + &request.client_request_id, + ) + .await?; + response.message = Some(gate_decision_applied_message( + true, + task.pending_gate_message.as_deref(), + )); + self.enqueue_task(task.task_id.clone(), Duration::ZERO)?; + return Ok(response); + } + // Reject: the todo stays blocked (the agent's own encoding of the + // undelivered publication), the decline is remembered so the + // approval never resurfaces on later drives, and the task parks with + // a clear owner-action message. + log::info!( + "LoopX owner declined the blocked publication todo: task_id={} todo={}", + task.task_id, + todo.todo_id + ); + let generation = task.generation; + self.mutate_task( + &task.task_id, + Some(&request.client_request_id), + |current, current_runtime| { + if current.generation != generation { + return; + } + current.revision = current.revision.saturating_add(1); + current.pending_gate_id = None; + current.pending_gate_message = None; + current.pending_gate_action_kind = None; + current_runtime.declined_publication_todo_id = Some(todo.todo_id.clone()); + }, + ) + .await?; + let updated = self.task(&task.task_id).await?; + self.append_task_event( + &updated, + LoopxEventKind::StateChanged, + "Owner declined publishing; the prepared fix stays on the local task branch", + true, + ) + .await?; + let parked = self + .park_waiting_owner_action( + &updated, + Some("The owner declined publication. The prepared fix and its branch are preserved in the task workspace; resume or archive the task when you decide differently"), + ) + .await; + parked?; + Ok(LoopxActionResponse { + status: LoopxActionStatus::Applied, + current_revision: updated.revision, + task: Some(updated), + message: Some( + "Rejection applied; the prepared fix and its branch are preserved in the task workspace" + .to_string(), + ), + }) + } + + async fn answer_gate( + self: &Arc, + task: &LoopxTaskSnapshot, + runtime: &LoopxTaskRuntimeRecord, + request: &LoopxActionRequest, + ) -> Result { + if bound_workspace_missing(task) { + // A dead workspace cannot answer gates: the CLI spawn would fail + // with an invalid-directory error. The next drive re-prepares the + // workspace and reconnects the goal, then the gate resurfaces. + return Err( + "LoopX workspace is missing for this task; it will be re-prepared on the next run — retry the approval after the task leaves recovery and re-raises the gate" + .to_string(), + ); + } + let gate_id = request + .gate_id + .clone() + .ok_or_else(|| "gateId is required".to_string())?; + // A blocked publication todo projected as the publish approval (see + // `park_publication_approval`) reaches this handler with the blocked + // todo's id. It is not a typed user gate, so `todo complete + // --decision-outcome` would be rejected by the CLI + // ("decision_outcome is only valid when completing a user_gate"). + // Approve = unblock the todo with an attributed note and requeue; the + // unblocked todo IS the successor, so no materialization is needed. + // Reject = record the decline (never re-raise the same approval) and + // park with a clear owner-action message. + if task.pending_gate_id.as_deref() == Some(gate_id.as_str()) { + let progress = BufferedProgress::default(); + let listed = self + .cli + .list_todos( + LoopxCliListTodosRequest { + context: self.goal_context(task, runtime), + goal_id: task.goal_id.clone().unwrap_or_default(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + status: Some("blocked".to_string()), + }, + &progress, + ) + .await; + self.record_progress(progress.take()).await?; + let blocked = listed.ok().and_then(|result| { + blocked_publication_todo(&result.todos, None) + .filter(|todo| todo.todo_id == gate_id) + .cloned() + }); + if let Some(todo) = blocked { + return self + .answer_blocked_publication(&task, &runtime, &request, &todo) + .await; + } + } + let progress = BufferedProgress::default(); + let result = self + .cli + .answer_gate( + LoopxCliAnswerGateRequest { + context: self.goal_context(task, runtime), + goal_id: task.goal_id.clone().unwrap_or_default(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + gate_id, + decision: if request.action == LoopxActionKind::Approve { + LoopxCliGateDecision::Approve + } else { + LoopxCliGateDecision::Reject + }, + note: request.note.clone(), + granted_scope: None, + }, + &progress, + ) + .await + .map_err(|error| error.to_string())?; + self.record_progress(progress.take()).await?; + if !result.applied { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Rejected, + current_revision: task.revision, + task: Some(task.clone()), + message: Some("LoopX did not apply the gate decision".to_string()), + }); + } + self.record_goal_state(task, result.goal_state).await?; + self.mutate_task(&task.task_id, None, |current, current_runtime| { + if current.generation != task.generation { + return; + } + current.revision = current.revision.saturating_add(1); + current_runtime.expected_durable_revision = Some(result.durable_revision.clone()); + }) + .await?; + // An approved gate is an owner instruction to act. When answering it + // leaves the goal with nothing runnable — the agent authored the + // gate without a successor todo (observed live 2026-09-11 on + // xielixing/dynamic-workflows-lab#2: the publish gate was approved, + // the frontier went empty, and the task parked plan-exhausted with + // the promised PR never created) — the host materializes the + // approved decision as one durable agent todo so the promised + // action actually gets driven. The todo text quotes the gate's own + // message with the approval prefix: the agent reads the owner's + // decision, not host-invented semantics. The host never fabricates + // progress, completions, or terminal state; a rejected gate needs + // no successor because the owner asked for the opposite branch. + if request.action == LoopxActionKind::Approve { + let gate_message = task.pending_gate_message.clone().unwrap_or_else(|| { + "execute the approved action from the decision request".to_string() + }); + let inspect_progress = BufferedProgress::default(); + let inspected = self + .cli + .inspect_goal( + LoopxCliInspectGoalRequest { + context: self.goal_context(task, runtime), + goal_id: task.goal_id.clone().unwrap_or_default(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + }, + &inspect_progress, + ) + .await; + self.record_progress(inspect_progress.take()).await?; + let needs_materialization = match inspected { + Ok(goal) => approved_gate_needs_materialized_successor(&goal), + Err(error) => { + log::warn!( + "LoopX post-approval Goal inspection failed; skipping approved-action materialization: task_id={} error={}", + task.task_id, + error + ); + false + } + }; + if needs_materialization { + let text = approved_action_todo_text(&gate_message); + let add_progress = BufferedProgress::default(); + let added = self + .cli + .add_todo( + LoopxCliAddTodoRequest { + context: self.goal_context(task, runtime), + goal_id: task.goal_id.clone().unwrap_or_default(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + text, + }, + &add_progress, + ) + .await; + self.record_progress(add_progress.take()).await?; + match added { + Ok(added) if added.applied => { + log::info!( + "LoopX approved gate had no successor; materialized the approved action as a durable todo: task_id={} goal={} revision={}", + task.task_id, + added.goal_id, + added.durable_revision + ); + self.append_task_event( + task, + LoopxEventKind::StateChanged, + "Approved gate had no successor todo; the approved action was registered as the next agent todo", + false, + ) + .await?; + self.mutate_task(&task.task_id, None, |current, current_runtime| { + if current.generation != task.generation { + return; + } + current_runtime.expected_durable_revision = + Some(added.durable_revision.clone()); + }) + .await?; + } + Ok(_) => { + // Not applied: fall through; the next drive parks with + // the plan-exhausted guidance for the owner. + } + Err(error) => { + log::warn!( + "LoopX approved-action todo materialization failed; the task may park plan-exhausted: task_id={} error={}", + task.task_id, + error + ); + } + } + } + } + let mut response = self + .transition_action( + &task.task_id, + LoopxTaskState::Queued, + LoopxPhase::Queued, + &request.client_request_id, + ) + .await?; + response.message = Some(gate_decision_applied_message( + request.action == LoopxActionKind::Approve, + task.pending_gate_message.as_deref(), + )); + self.enqueue_task(task.task_id.clone(), Duration::ZERO)?; + Ok(response) + } + + /// Generic durable gate answer used by the automatic approvers. The + /// decision is recorded as a durable task event so the surface stays + /// auditable. + async fn auto_answer_gate( + self: &Arc, + task: &LoopxTaskSnapshot, + runtime: &LoopxTaskRuntimeRecord, + gate: &LoopxCliUserGate, + decision: LoopxCliGateDecision, + note: String, + event_message: String, + ) -> Result<(), String> { + log::info!( + "LoopX auto-answering user gate: task_id={} goal={} gate={} decision={:?} kind={:?}", + task.task_id, + task.goal_id.as_deref().unwrap_or("-"), + gate.gate_id, + decision, + gate.action_kind, + ); + let progress = BufferedProgress::default(); + let result = self + .cli + .answer_gate( + LoopxCliAnswerGateRequest { + context: self.goal_context(task, runtime), + goal_id: task.goal_id.clone().unwrap_or_default(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + gate_id: gate.gate_id.clone(), + decision, + note: Some(note), + granted_scope: None, + }, + &progress, + ) + .await; + self.record_progress(progress.take()).await?; + let result = result.map_err(|error| error.to_string())?; + if !result.applied { + return Err("LoopX did not apply the automatic gate answer".to_string()); + } + self.record_goal_state(task, result.goal_state).await?; + self.mutate_task(&task.task_id, None, |current, current_runtime| { + if current.generation != task.generation { + return; + } + current.revision = current.revision.saturating_add(1); + current.pending_gate_id = None; + current.pending_gate_message = None; + current.pending_gate_action_kind = None; + current_runtime.expected_durable_revision = Some(result.durable_revision.clone()); + }) + .await?; + let updated = self.task(&task.task_id).await?; + let mut details = BTreeMap::new(); + details.insert("gateId".to_string(), gate.gate_id.clone()); + if let Some(kind) = gate.action_kind.clone() { + details.insert("actionKind".to_string(), kind); + } + details.insert("autoAnswered".to_string(), "true".to_string()); + self.append_task_event_with_details( + &updated, + LoopxEventKind::StateChanged, + &event_message, + true, + details, + ) + .await?; + self.schedule_next_for_repository( + &task.identity.item.repository.canonical_id(), + Some(&task.task_id), + ) + .await; + self.enqueue_task(task.task_id.clone(), Duration::ZERO)?; + Ok(()) + } + + /// Takes the one-shot host note (if any) so the next agent instruction + /// carries it exactly once. + async fn take_pending_host_note(&self, task_id: &str) -> Option { + let note = self.runtime(task_id).await.pending_host_note.clone(); + if note.is_some() { + self.mutate_task(task_id, None, |_current, runtime| { + runtime.pending_host_note = None; + }) + .await + .ok(); + } + note + } + + async fn apply_settlement( + self: &Arc, + task: &LoopxTaskSnapshot, + settlement: LoopxCliSettleTurnResult, + agent_status: LoopxAgentTurnStatus, + failure_summary: Option<&str>, + blocks_repository: bool, + ) -> Result<(), String> { + let post_settlement_goal = + if inspects_goal_after_settlement(agent_status, settlement.status) { + let runtime = self.runtime(&task.task_id).await; + let progress = BufferedProgress::default(); + let inspected = self + .cli + .inspect_goal( + LoopxCliInspectGoalRequest { + context: self.goal_context(task, &runtime), + goal_id: task.goal_id.clone().unwrap_or_default(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + }, + &progress, + ) + .await; + self.record_progress(progress.take()).await?; + match inspected { + Ok(snapshot) => Some(snapshot), + Err(error) => { + log::warn!( + "LoopX post-settlement Goal inspection failed: task_id={}, error={}", + task.task_id, + error + ); + None + } + } + } else { + None + }; + // A NoDurableProgress settlement after a healthy agent turn usually + // means the workflow produced its artifacts outside the CLI write + // boundary (for example files under the system temp directory), so + // settlement could not validate them. Schedule exactly one corrective + // turn that re-submits the pending writebacks before parking the task + // for interactive recovery. The corrective turn is a normal turn with + // an explicit host note; nothing is forged and every step is recorded + // as a task event. The authoritative Goal projection outranks this + // receipt-driven path: when it already reports a user gate, a + // terminal completion, or a goal failure, there is nothing durable + // left to re-submit and the task settles from the projection + // instead (single decision rule for every settlement status). + let compensation_already_attempted = self + .runtime(&task.task_id) + .await + .durable_compensation_pending; + let compensate_durable_writeback = should_compensate_durable_writeback( + agent_status, + settlement.status, + compensation_already_attempted, + post_settlement_goal.as_ref(), + ); + let final_state = if compensate_durable_writeback { + LoopxTaskState::Queued + } else if parks_after_failed_compensation( + agent_status, + settlement.status, + compensation_already_attempted, + post_settlement_goal.as_ref(), + ) { + // The one-shot compensation turn already ran and still could not + // produce validated durable progress, while the projection shows + // the frontier unchanged (RunNow/Wait). Park for interactive + // recovery instead of requeueing: an automatic re-drive here + // could loop forever without an owner decision. When the + // projection DOES decide (gate/terminal/failed), the branch + // below settles from it. + LoopxTaskState::RecoveryRequired + } else { + task_state_after_settlement( + agent_status, + settlement.status, + post_settlement_goal.as_ref(), + ) + }; + let phase = phase_after_settlement(final_state); + // Codex-parity session policy: a healthy completed turn whose task + // continues (more turns queued for this goal, or a user gate the + // session itself asked about) keeps the agent session so the next + // turn continues the same conversation — the pinned skill document + // and project context stay loaded instead of being re-read every + // turn. Terminal (Completed), recovery, and failed turns discard + // the session: a fresh context is the safer recovery surface and + // nothing durable is lost (LoopX goal state remains authoritative). + let keep_agent_session = agent_status == LoopxAgentTurnStatus::Completed + && matches!( + final_state, + LoopxTaskState::Queued | LoopxTaskState::WaitingForUser + ); + // Captured before the mutation below: when the session is not kept, + // the mutation clears `runtime.session_id` first, and the discard + // call still needs the id to tear the live session down. + let session_runtime = self.runtime(&task.task_id).await; + let updated = self + .mutate_task(&task.task_id, None, |task, runtime| { + task.state = final_state; + task.phase = phase; + task.recovery_reason = if final_state == LoopxTaskState::RecoveryRequired { + Some( + recovery_reason_after_settlement( + agent_status, + settlement.status, + post_settlement_goal.as_ref(), + ) + .to_string(), + ) + } else { + None + }; + task.goal_state = post_settlement_goal + .as_ref() + .map(|goal| goal.state) + .or_else(|| { + Some(match settlement.status { + LoopxCliSettlementStatus::GoalCompleted => LoopxCliGoalState::Completed, + _ => LoopxCliGoalState::Active, + }) + }); + task.revision = task.revision.saturating_add(1); + task.current_turn_id = None; + let pending_gate = post_settlement_goal + .as_ref() + .and_then(|goal| goal.pending_user_gate.as_ref()); + task.pending_gate_id = pending_gate.map(|gate| gate.gate_id.clone()); + task.pending_gate_message = pending_gate.map(|gate| gate.message.clone()); + task.pending_gate_action_kind = + pending_gate.and_then(|gate| gate.action_kind.clone()); + task.deadline_at = None; + task.error = (agent_status == LoopxAgentTurnStatus::Failed) + .then(|| failure_summary.unwrap_or("Agent turn failed").to_string()); + task.settlement = LoopxSettlementSummary { + turn_id: Some(settlement.turn_id.clone()), + receipt_id: Some(settlement.receipt_id.clone()), + durable_revision: Some(settlement.after_revision.clone()), + settled_at: Some(now_ms()), + }; + if !keep_agent_session { + runtime.session_id = None; + } + runtime.agent_turn_id = None; + if compensate_durable_writeback { + runtime.durable_compensation_pending = true; + runtime.pending_host_note = Some(LOOPX_DURABLE_COMPENSATION_NOTE.to_string()); + } + // A settled turn proves the quota contract works again; the + // one-shot compensation allowance must re-arm for a future + // unrelated NoDurableProgress episode. + if matches!( + settlement.status, + LoopxCliSettlementStatus::Settled + | LoopxCliSettlementStatus::AlreadySettled + | LoopxCliSettlementStatus::GoalCompleted + ) { + runtime.durable_compensation_pending = false; + } + runtime.expected_durable_revision = Some( + post_settlement_goal + .as_ref() + .map(|goal| goal.durable_revision.clone()) + .unwrap_or_else(|| settlement.after_revision.clone()), + ); + }) + .await?; + log::info!( + "LoopX task settlement applied: task_id={}, goal_id={}, final_state={:?}, phase={:?}, settlement_status={:?}", + updated.task_id, + updated.goal_id.as_deref().unwrap_or("unknown"), + updated.state, + updated.phase, + settlement.status + ); + if keep_agent_session { + log::info!( + "LoopX Agent session kept for the goal's next turn: task_id={}, session_id={:?}", + task.task_id, + session_runtime.session_id + ); + } else { + self.discard_agent_session(&task, &session_runtime).await; + } + // Loud, auditable degradation for the false-negative settlement: + // the durable writeback validated and the Goal projection decided + // the next state, but the turn's quota spend receipt is permanently + // missing. The host neither retries nor fabricates the receipt; the + // loss is recorded as a task event so it stays visible in the + // timeline and telemetry. Cancelled/interrupted turns and failed + // post-settlement inspections keep the loud recovery card instead. + if agent_status == LoopxAgentTurnStatus::Completed + && settlement.status == LoopxCliSettlementStatus::RetryRequired + && post_settlement_goal.is_some() + { + let message = format!( + "LoopX settlement for turn {} reported RetryRequired: the durable writeback validated but the quota spend receipt is missing; the task continues from the authoritative Goal projection ({:?}). The receipt is not retried or fabricated by the host.", + settlement.turn_id, + updated.state + ); + log::warn!( + "LoopX settlement quota receipt missing, continuing from goal projection: task_id={} turn={}", + updated.task_id, + settlement.turn_id + ); + self.append_task_event(&updated, LoopxEventKind::SettlementRecorded, &message, true) + .await?; + } + let yielded_repository; + if agent_status == LoopxAgentTurnStatus::Failed { + let reason = failure_summary.unwrap_or("Agent turn failed"); + self.append_task_event(&updated, LoopxEventKind::StateChanged, reason, true) + .await?; + if blocks_repository { + self.pause_repository_after_failure(&updated, reason) + .await?; + } + } else { + if final_state == LoopxTaskState::WaitingForUser { + let Some(gate) = post_settlement_goal + .as_ref() + .and_then(|goal| goal.pending_user_gate.as_ref()) + else { + // Owner action outside the host: park as waiting with a + // human explanation instead of failing a finished task + // (live 2026-09-08, issue 2: PR opened, review/merge + // queue projected without a typed user_gate). + return self + .park_waiting_owner_action( + &updated, + post_settlement_goal + .as_ref() + .and_then(|goal| goal.waiting_user_summary.as_deref()), + ) + .await; + }; + // Read-only gates are policy answers, not consent: the owner + // decided that reading public issue content never needs a + // human, so answer them here exactly like the drive-turn + // inspector does (same durable boundary, host-attributed + // note). Interactive approval stays the fallback on failure. + if is_read_only_user_gate(gate.action_kind.as_deref(), Some(gate.message.as_str())) + { + let runtime = self.runtime(&task.task_id).await; + match self + .auto_answer_gate( + &updated, + &runtime, + gate, + LoopxCliGateDecision::Approve, + "Auto-approved by BitFun: read-only public issue content access." + .to_string(), + format!( + "Read-only user gate auto-approved by BitFun after settlement: {}", + gate.message + ), + ) + .await + { + Ok(()) => return Ok(()), + Err(error) => { + log::warn!( + "LoopX read-only gate auto-approval after settlement failed, falling back to interactive approval: task_id={} gate={} error={}", + task.task_id, + gate.gate_id, + error + ); + } + } + } + let mut details = BTreeMap::new(); + details.insert("gateId".to_string(), gate.gate_id.clone()); + if let Some(action_kind) = gate.action_kind.clone() { + details.insert("actionKind".to_string(), action_kind); + } + self.append_task_event_with_details( + &updated, + LoopxEventKind::ApprovalRequired, + &gate.message, + true, + details, + ) + .await?; + } else { + let (kind, message, important) = if compensate_durable_writeback { + ( + LoopxEventKind::StateChanged, + "LoopX durable writeback was not validated; scheduling one corrective turn to re-submit pending artifacts via the CLI write boundary", + true, + ) + } else { + match final_state { + LoopxTaskState::Completed => ( + LoopxEventKind::SettlementRecorded, + "LoopX goal completed", + false, + ), + LoopxTaskState::RecoveryRequired => ( + LoopxEventKind::StateChanged, + if settlement.status == LoopxCliSettlementStatus::RetryRequired { + "LoopX writeback validated but the quota spend settlement is missing; resume retries the turn with the current quota contract" + } else { + "LoopX turn requires recovery after settlement" + }, + true, + ), + _ => ( + LoopxEventKind::SettlementRecorded, + "LoopX turn settlement recorded", + false, + ), + } + }; + self.append_task_event(&updated, kind, message, important) + .await?; + } + if sticky_continue_after_settlement( + final_state, + post_settlement_goal.as_ref().map(|goal| goal.run_decision), + ) { + // Depth-first repository lane: the segment settled cleanly and + // the Goal is still runnable (RunNow), so the same task keeps + // the repository slot and continues with its next bounded + // segment instead of yielding to the next queued issue. The + // slot is intentionally not released here; reserve_repository + // accepts the same owner on the next drive. + self.enqueue_task(updated.task_id.clone(), Duration::ZERO)?; + } else { + yielded_repository = self + .schedule_next_for_repository( + &updated.identity.item.repository.canonical_id(), + Some(&updated.task_id), + ) + .await; + if yielded_repository { + self.suppress_pending_task_rerun(&updated.task_id).await; + } else if matches!( + final_state, + LoopxTaskState::RecoveryRequired | LoopxTaskState::WaitingForUser + ) { + // Nothing queued could take the freed slot. Surface what the + // remaining repository tasks are stuck in so a stalled line + // shows up in the log instead of silent idling. + let stalled: Vec = { + let state = self.state.read().await; + state + .tasks + .iter() + .filter(|task| { + task.task_id != updated.task_id + && task.identity.item.repository.canonical_id() + == updated.identity.item.repository.canonical_id() + && !matches!( + task.state, + LoopxTaskState::Completed + | LoopxTaskState::Archived + | LoopxTaskState::Stopped + ) + }) + .map(|task| format!("{} {:?}", task.task_id, task.state)) + .collect() + }; + log::warn!( + "LoopX repository queue stalled after parking task {}: remaining non-terminal tasks {:?}", + updated.task_id, + stalled + ); + } + if should_requeue_after_settlement(final_state, yielded_repository) { + let task_id = task.task_id.clone(); + // The next bounded turn is admitted by the quota guard, + // so a parked-and-requeued task re-drives immediately. + self.enqueue_task(task_id, Duration::ZERO)?; + } + } + } + Ok(()) + } + + /// Parks a task whose LoopX goal waits on an OWNER ACTION outside the + /// host: an open user todo without a typed `user_gate` (for example the + /// owner review/merge queue entry recorded after the agent opened a PR). + /// There is no host-answerable approval card - the owner acts on the + /// external surface (GitHub) and the goal gains new work (or is resumed) + /// afterwards. The repository slot yields to queued siblings while the + /// task waits. + async fn park_waiting_owner_action( + self: &Arc, + task: &LoopxTaskSnapshot, + summary: Option<&str>, + ) -> Result<(), String> { + let message = match summary { + // The todo text is the authoritative description of the action, so + // do not append a PR-shaped example here: a parked goal may be + // waiting on something that has no pull request at all (observed + // live 2026-09-10, issue #3: the owner was asked to add `.loopx/` + // to `.gitignore` and the message told them to "review or merge the + // pull request on GitHub", which does not exist). + Some(text) => format!( + "LoopX is waiting for an owner action outside this host: {text}. Finish that action on the surface it names; the task continues when the goal gains new work, or use Resume after acting." + ), + None => "LoopX is waiting for an owner action outside this host. Finish the pending owner decision on the external surface (for example GitHub); the task continues when the goal gains new work, or use Resume after acting." + .to_string(), + }; + let generation = task.generation; + let updated = self + .mutate_task(&task.task_id, None, |current, _| { + if current.generation != generation { + return; + } + current.state = LoopxTaskState::WaitingForUser; + current.phase = LoopxPhase::WaitingForApproval; + current.pending_gate_id = None; + current.pending_gate_message = Some(message.clone()); + current.pending_gate_action_kind = None; + current.revision = current.revision.saturating_add(1); + }) + .await?; + log::info!( + "LoopX goal waits on an owner action outside the host; task parked: task_id={} summary={:?}", + task.task_id, + summary + ); + self.append_task_event(&updated, LoopxEventKind::StateChanged, &message, true) + .await?; + self.schedule_next_for_repository( + &task.identity.item.repository.canonical_id(), + Some(&task.task_id), + ) + .await; + Ok(()) + } + + async fn sync_concurrent_user_gate( + &self, + task: &LoopxTaskSnapshot, + gate: Option<&LoopxCliUserGate>, + ) -> Result<(), String> { + let unchanged = task.pending_gate_id.as_deref() == gate.map(|gate| gate.gate_id.as_str()) + && task.pending_gate_message.as_deref() == gate.map(|gate| gate.message.as_str()) + && task.pending_gate_action_kind.as_deref() + == gate.and_then(|gate| gate.action_kind.as_deref()); + if unchanged { + return Ok(()); + } + + let updated = self + .mutate_task(&task.task_id, None, |current, _| { + if current.generation != task.generation { + return; + } + current.pending_gate_id = gate.map(|gate| gate.gate_id.clone()); + current.pending_gate_message = gate.map(|gate| gate.message.clone()); + current.pending_gate_action_kind = gate.and_then(|gate| gate.action_kind.clone()); + current.revision = current.revision.saturating_add(1); + }) + .await?; + + if let Some(gate) = gate { + let mut details = BTreeMap::new(); + details.insert("gateId".to_string(), gate.gate_id.clone()); + if let Some(action_kind) = gate.action_kind.clone() { + details.insert("actionKind".to_string(), action_kind); + } + self.append_task_event_with_details( + &updated, + LoopxEventKind::ApprovalRequired, + &gate.message, + true, + details, + ) + .await?; + } + Ok(()) + } + + async fn pause_repository_after_failure( + &self, + failed_task: &LoopxTaskSnapshot, + reason: &str, + ) -> Result<(), String> { + let repository_id = failed_task.identity.item.repository.canonical_id(); + let message = format!( + "Repository queue paused after Issue #{} failed: {}", + failed_task.identity.item.number, + reason.chars().take(700).collect::() + ); + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let start_cursor = state.cursor; + let now = now_ms(); + let mut paused = Vec::new(); + for task in &mut state.tasks { + if task.task_id == failed_task.task_id + || task.identity.item.repository.canonical_id() != repository_id + || task.state != LoopxTaskState::Queued + { + continue; + } + task.state = LoopxTaskState::RecoveryRequired; + task.phase = LoopxPhase::Recovering; + task.error = Some(message.clone()); + task.recovery_reason = Some("repository_paused".to_string()); + task.current_turn_id = None; + task.deadline_at = None; + task.revision = task.revision.saturating_add(1); + task.updated_at = now; + paused.push(task.clone()); + } + for task in &paused { + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + task_id: Some(task.task_id.clone()), + generation: Some(task.generation), + revision: Some(task.revision), + kind: LoopxEventKind::StateChanged, + level: LoopxEventLevel::Error, + source: LoopxEventSource::Controller, + phase: Some(LoopxPhase::Recovering), + message: message.clone(), + important: true, + occurred_at: now, + ..LoopxEvent::default() + }); + } + state.environment.core.agent_model.status = LoopxEnvironmentFactStatus::Degraded; + state.environment.core.agent_model.detail = Some(reason.to_string()); + state.environment.core.agent_model.checked_at = Some(now); + state.environment.status = + derive_environment_status(&state.environment.core, &state.environment.optional); + state.environment.revision = state.environment.revision.saturating_add(1); + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + kind: LoopxEventKind::EnvironmentChanged, + level: LoopxEventLevel::Error, + source: LoopxEventSource::System, + message: "Agent model runtime failed; repository queue paused".to_string(), + important: true, + occurred_at: now, + ..LoopxEvent::default() + }); + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + drop(_mutation); + self.broadcast_new_events(&persisted, start_cursor); + Ok(()) + } + + async fn reserve_repository(&self, task: &LoopxTaskSnapshot) -> bool { + let repo = task.identity.item.repository.canonical_id(); + let mut active = self.active_repositories.lock().await; + match active.get(&repo) { + Some(owner) => owner == &task.task_id, + None => { + active.insert(repo, task.task_id.clone()); + true + } + } + } + + async fn mark_environment_checking(&self) -> Result<(), String> { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let checked_at = Some(now_ms()); + state.environment.revision = state.environment.revision.saturating_add(1); + state.environment.status = LoopxEnvironmentStatus::Checking; + state.environment.checked_at = checked_at; + state.environment.runtime_install_supported = Some(managed_runtime_install_supported()); + state.environment.core.sidecar = checking_environment_fact(checked_at); + state.environment.core.node_runtime = checking_environment_fact(checked_at); + state.environment.core.git_worktree = checking_environment_fact(checked_at); + state.environment.core.agent_model = checking_environment_fact(checked_at); + state.environment.optional.github_auth = checking_environment_fact(checked_at); + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + kind: LoopxEventKind::EnvironmentChanged, + source: LoopxEventSource::System, + message: "LoopX environment validation started".to_string(), + occurred_at: now_ms(), + ..LoopxEvent::default() + }); + let persisted = state.clone(); + let event = persisted.events.last().cloned(); + drop(state); + self.store.save(&persisted).await?; + if let Some(event) = event { + let _ = self.event_sender.send(event); + } + Ok(()) + } + + async fn commit_environment( + &self, + handshake: LoopxCliResult, + workspace: LoopxHostResult, + agent: LoopxHostResult, + github_auth: LoopxGithubAuthProbe, + node_runtime_probe: LoopxNodeRuntimeFact, + ) -> Result<(), String> { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let checked_at = Some(now_ms()); + let (sidecar, python_fallback, node_runtime) = match handshake { + Ok(manifest) => { + let node_runtime = loopx_node_environment_fact(&manifest.node_runtime, checked_at); + let python_fallback = + if manifest.executable.source == LoopxCliSource::PythonFallback { + LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Available, + version: Some("Python 3.11+".to_string()), + detail: Some( + "Managed LoopX source runs in isolated Python mode".to_string(), + ), + checked_at, + ..LoopxEnvironmentFact::default() + } + } else { + LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Unknown, + detail: Some("Not required by the selected LoopX runtime".to_string()), + checked_at, + ..LoopxEnvironmentFact::default() + } + }; + ( + LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Available, + version: Some(manifest.loopx_version), + detail: Some(manifest.executable.identity), + checked_at, + ..LoopxEnvironmentFact::default() + }, + python_fallback, + node_runtime, + ) + } + Err(error) + if matches!( + error.kind, + LoopxCliErrorKind::NotFound | LoopxCliErrorKind::VersionMismatch + ) => + { + ( + unavailable_loopx_environment_fact(error.to_string(), checked_at), + LoopxEnvironmentFact::default(), + loopx_node_environment_fact(&node_runtime_probe, checked_at), + ) + } + Err(error) => ( + unavailable_environment_fact(error.to_string(), checked_at), + LoopxEnvironmentFact::default(), + loopx_node_environment_fact(&node_runtime_probe, checked_at), + ), + }; + let git_worktree = match workspace { + Ok(probe) => LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Available, + version: probe.git_version, + detail: Some(format!("Writable workspace root: {}", probe.workspace_root)), + checked_at, + ..LoopxEnvironmentFact::default() + }, + Err(error) => git_worktree_environment_fact(error.to_string(), checked_at), + }; + let agent_model = match agent { + Ok(probe) => LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Available, + version: Some(probe.model_id), + detail: Some("Configured Agent model is enabled for text chat".to_string()), + checked_at, + ..LoopxEnvironmentFact::default() + }, + Err(error) => unavailable_environment_fact(error.to_string(), checked_at), + }; + let github_auth = LoopxEnvironmentFact { + status: github_auth_fact_status(&github_auth), + detail: github_auth.detail, + checked_at, + ..LoopxEnvironmentFact::default() + }; + state.environment.revision = state.environment.revision.saturating_add(1); + state.environment.checked_at = checked_at; + state.environment.runtime_install_supported = Some(managed_runtime_install_supported()); + state.environment.core.sidecar = sidecar; + state.environment.core.node_runtime = node_runtime; + state.environment.core.git_worktree = git_worktree; + state.environment.core.agent_model = agent_model; + state.environment.optional.python_fallback = python_fallback; + state.environment.optional.github_auth = github_auth; + state.environment.status = + derive_environment_status(&state.environment.core, &state.environment.optional); + let status = state.environment.status; + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + kind: LoopxEventKind::EnvironmentChanged, + level: if status == LoopxEnvironmentStatus::Blocked { + LoopxEventLevel::Error + } else { + LoopxEventLevel::Info + }, + source: LoopxEventSource::System, + message: format!("LoopX environment validation finished with status {status:?}"), + important: status == LoopxEnvironmentStatus::Blocked, + occurred_at: now_ms(), + ..LoopxEvent::default() + }); + let persisted = state.clone(); + let event = persisted.events.last().cloned(); + drop(state); + self.store.save(&persisted).await?; + if let Some(event) = event { + let _ = self.event_sender.send(event); + } + Ok(()) + } + + async fn record_progress(&self, progress: Vec) -> Result<(), String> { + if progress.is_empty() { + return Ok(()); + } + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let start_cursor = state.cursor; + for item in progress { + if is_normal_process_lifecycle_message(&item.message) { + continue; + } + state.append_event(LoopxEvent { + task_id: item.task_id, + kind: LoopxEventKind::Progress, + source: LoopxEventSource::Sidecar, + message: item.message, + occurred_at: item.occurred_at, + ..LoopxEvent::default() + }); + } + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + self.broadcast_new_events(&persisted, start_cursor); + Ok(()) + } + + async fn bind_workspace( + &self, + task_id: &str, + generation: u64, + workspace: &LoopxWorkspacePrepareResult, + ) -> Result<(), String> { + self.mutate_task(task_id, None, |task, runtime| { + if task.generation != generation { + return; + } + task.workspace_path = Some(workspace.worktree_path.clone()); + task.phase = LoopxPhase::CreatingGoal; + task.revision = task.revision.saturating_add(1); + runtime.registry_path = workspace.registry_path.clone(); + // Seed the pinned LoopX references into the worktree as TWO files + // so the agent loads only the authoritative skill document up + // front and consults the CLI help text on demand: + // - pinned-loopx-skill.md: official workflow-skill documents (the + // exact schemas/flags; must-read once per session). + // - pinned-loopx-cli-help.md: generator `--help` text (only when + // the agent needs to verify a specific flag; NOT preloaded). + // 2026-09-08: a single 125KB blob caused the agent to read it 3x + // and bloat the context (skill text after the help text, so the + // helpful part was buried), which made each turn slower. + // NOTE: create the `.loopx` directory first - at this point of the + // prepare flow bootstrap has not run yet, so it may not exist. + let reference_dir = std::path::Path::new(&workspace.worktree_path).join(".loopx"); + let _ = std::fs::create_dir_all(&reference_dir); + let _ = std::fs::write( + reference_dir.join("pinned-loopx-skill.md"), + LOOPX_PINNED_SKILLS_REFERENCE, + ); + let _ = std::fs::write( + reference_dir.join("pinned-loopx-cli-help.md"), + LOOPX_PINNED_CLI_REFERENCE, + ); + // Deliver the remaining official workflow-skill documents of the + // pinned revision (custom-host guide: the host delivers the full + // skill set from the same revision; the agent reads the one that + // applies to the active step). + let _ = std::fs::write( + reference_dir.join("loopx-doc-registry.md"), + LOOPX_PINNED_SKILL_DOC_REGISTRY, + ); + let _ = std::fs::write( + reference_dir.join("loopx-pr-program.md"), + LOOPX_PINNED_SKILL_PR_PROGRAM, + ); + let _ = std::fs::write( + reference_dir.join("loopx-pr-review.md"), + LOOPX_PINNED_SKILL_PR_REVIEW, + ); + let _ = std::fs::write( + reference_dir.join("loopx-change-quality.md"), + LOOPX_PINNED_SKILL_CHANGE_QUALITY, + ); + // The capability-protocol layer for issue-fix goals. Seeded next to + // the workflow skills because the two answer different questions: + // the skills say what to do, these say what shape the CLI accepts. + // Both are named by the turn instruction - the environment boundary + // note scopes document authority to the pinned files this + // instruction names, so seeding without naming would be inert. + let _ = std::fs::write( + reference_dir.join("loopx-issue-fix-reference.md"), + LOOPX_PINNED_ISSUE_FIX_REFERENCE, + ); + let _ = std::fs::write( + reference_dir.join("loopx-issue-fix-workflow-contract.md"), + LOOPX_PINNED_ISSUE_FIX_WORKFLOW_CONTRACT, + ); + // The host-authored half of the capability-payload layer: the + // schemas LoopX keeps only in source code. + let _ = std::fs::write( + reference_dir.join("loopx-issue-fix-payload-contract.md"), + LOOPX_PINNED_ISSUE_FIX_PAYLOAD_CONTRACT, + ); + // The host already resolved this item's public metadata through its + // own GitHub intake provider, which uses a Rust HTTP client and is + // therefore unaffected by the sidecar's locale-dependent `gh` + // decoding. Persist the exact object shape + // `issue-fix workflow-plan --metadata-json` accepts, so the agent + // does not spend one round on `gh issue view` and a further round + // plumbing the JSON back in (observed live 2026-09-10: issue #2's + // first turn ran 65 tool calls across 52 model rounds). + // + // This is host-resolved INPUT, not LoopX authority: LoopX still owns + // the workflow plan, candidate admission, and every todo. The agent + // must re-verify anything it acts on against the live repository; + // the file only saves it from re-deriving metadata the host already + // has. Absent title/labels (legacy or partial intakes) are written + // empty rather than fabricated. + let item_metadata = serde_json::json!({ + "number": task.identity.item.number, + "state": match task.identity.state { + LoopxRemoteItemState::Open => "open", + LoopxRemoteItemState::Closed | LoopxRemoteItemState::Merged => "closed", + LoopxRemoteItemState::Unknown => "unknown", + }, + "title": task.identity.title.clone(), + "labels": task.identity.labels.clone(), + "kind": match task.identity.item.kind { + LoopxItemKind::Issue => "issue", + LoopxItemKind::PullRequest => "pull_request", + }, + "url": task.identity.item.canonical_url(), + }); + if let Ok(serialized) = serde_json::to_vec_pretty(&item_metadata) { + let _ = std::fs::write(reference_dir.join("issue-metadata.json"), serialized); + } + }) + .await + .map(|_| ()) + } + + async fn bind_goal( + &self, + task_id: &str, + generation: u64, + goal: LoopxCliCreateGoalResult, + ) -> Result<(), String> { + self.mutate_task(task_id, None, |task, runtime| { + if task.generation != generation { + return; + } + task.goal_id = Some(goal.goal_id.clone()); + task.goal_state = Some(LoopxCliGoalState::Active); + task.state = LoopxTaskState::Queued; + task.phase = LoopxPhase::InspectingGoal; + task.revision = task.revision.saturating_add(1); + runtime.expected_durable_revision = Some(goal.durable_revision.clone()); + }) + .await + .map(|_| ()) + } + + async fn record_goal_state( + &self, + task: &LoopxTaskSnapshot, + goal_state: LoopxCliGoalState, + ) -> Result<(), String> { + if task.goal_state == Some(goal_state) { + return Ok(()); + } + self.mutate_task(&task.task_id, None, |current, _| { + if current.generation != task.generation { + return; + } + current.goal_state = Some(goal_state); + current.revision = current.revision.saturating_add(1); + }) + .await + .map(|_| ()) + } + + /// Persists the bounded LoopX frontier-todo projection for UI display. + /// The projection is written only when it actually changes so heartbeat + /// polling does not churn the durable revision. + async fn record_current_todo( + &self, + task_id: &str, + generation: u64, + todo: Option, + ) -> Result<(), String> { + self.mutate_task(task_id, None, |current, _| { + if current.generation != generation || current.current_todo == todo { + return; + } + current.current_todo = todo; + current.revision = current.revision.saturating_add(1); + }) + .await + .map(|_| ()) + } + + async fn record_monitor_wait( + &self, + task_id: &str, + generation: u64, + monitor_wait: Option, + ) -> Result<(), String> { + self.mutate_task(task_id, None, |current, _| { + if current.generation != generation || current.monitor_wait == monitor_wait { + return; + } + current.monitor_wait = monitor_wait; + current.revision = current.revision.saturating_add(1); + }) + .await + .map(|_| ()) + } + + async fn apply_goal_projection( + &self, + expected: &LoopxTaskSnapshot, + goal: &LoopxCliGoalSnapshot, + ) -> Result<(), String> { + let projection = project_host_task_from_goal(expected.state, expected.phase, goal.state); + let preserve_pending_gate = preserve_unanswered_local_gate(expected, goal); + let pending_gate_id = if preserve_pending_gate { + expected.pending_gate_id.as_deref() + } else { + goal.pending_user_gate + .as_ref() + .map(|gate| gate.gate_id.as_str()) + }; + let pending_gate_message = if preserve_pending_gate { + expected.pending_gate_message.as_deref() + } else { + goal.pending_user_gate + .as_ref() + .map(|gate| gate.message.as_str()) + }; + let pending_gate_action_kind = if preserve_pending_gate { + expected.pending_gate_action_kind.as_deref() + } else { + goal.pending_user_gate + .as_ref() + .and_then(|gate| gate.action_kind.as_deref()) + }; + if expected.goal_state == Some(goal.state) + && expected.state == projection.state + && expected.phase == projection.phase + && expected.pending_gate_id.as_deref() == pending_gate_id + && expected.pending_gate_message.as_deref() == pending_gate_message + && expected.pending_gate_action_kind.as_deref() == pending_gate_action_kind + { + return Ok(()); + } + + let host_state_changed = expected.state != projection.state; + let updated = self + .mutate_task(&expected.task_id, None, |task, runtime| { + if task.generation != expected.generation { + return; + } + let preserve_pending_gate = preserve_unanswered_local_gate(task, goal); + let current = project_host_task_from_goal(task.state, task.phase, goal.state); + task.goal_state = Some(goal.state); + task.state = current.state; + task.phase = current.phase; + if current.state == LoopxTaskState::Completed { + task.current_todo = None; + task.monitor_wait = None; + } + // The authoritative Goal projection is healthy again: a stale + // environment-level error (for example a coordination store + // schema rejection from a cross-build data home) must not keep + // resurfacing on a task that is demonstrably running. + if !matches!( + current.state, + LoopxTaskState::RecoveryRequired | LoopxTaskState::Failed + ) { + task.error = None; + } + if !preserve_pending_gate { + task.pending_gate_id = goal + .pending_user_gate + .as_ref() + .map(|gate| gate.gate_id.clone()); + task.pending_gate_message = goal + .pending_user_gate + .as_ref() + .map(|gate| gate.message.clone()); + task.pending_gate_action_kind = goal + .pending_user_gate + .as_ref() + .and_then(|gate| gate.action_kind.clone()); + } + task.revision = task.revision.saturating_add(1); + runtime.expected_durable_revision = Some(goal.durable_revision.clone()); + if task.state.is_terminal() { + task.current_turn_id = None; + task.current_tool = None; + task.deadline_at = None; + task.retry_at = None; + } + }) + .await?; + + if host_state_changed { + self.append_task_event( + &updated, + LoopxEventKind::SnapshotInvalidated, + "BitFun host task reconciled with authoritative LoopX Goal state", + false, + ) + .await?; + if updated.state == LoopxTaskState::Queued { + self.enqueue_task(updated.task_id.clone(), Duration::ZERO)?; + } + } + Ok(()) + } + + async fn bind_turn( + &self, + task: &LoopxTaskSnapshot, + turn: &LoopxCliBuildTurnResult, + ) -> Result<(), String> { + let generation = task.generation; + self.mutate_task(&task.task_id, None, |task, runtime| { + if task.generation != generation { + return; + } + task.phase = LoopxPhase::StartingAgent; + task.deadline_at = turn.deadline_at; + task.current_turn_id = Some(turn.turn_id.clone()); + task.revision = task.revision.saturating_add(1); + runtime.loopx_turn_id = Some(turn.turn_id.clone()); + runtime.settlement_token = Some(turn.settlement_token.clone()); + runtime.expected_durable_revision = Some(turn.durable_revision.clone()); + }) + .await + .map(|_| ()) + } + + async fn bind_agent_run( + &self, + task: &LoopxTaskSnapshot, + run: LoopxAgentStartResult, + ) -> Result<(), String> { + let updated = self + .mutate_task(&task.task_id, None, |task, runtime| { + task.state = LoopxTaskState::Running; + task.phase = LoopxPhase::AgentRunning; + task.current_turn_id = Some(run.turn_id.clone()); + task.last_output_at = Some(now_ms()); + task.revision = task.revision.saturating_add(1); + runtime.session_id = Some(run.session_id.clone()); + runtime.agent_turn_id = Some(run.turn_id.clone()); + }) + .await?; + self.append_task_event( + &updated, + LoopxEventKind::StateChanged, + "Agent turn started", + false, + ) + .await + } + + async fn transition_task( + &self, + task_id: &str, + generation: u64, + state: LoopxTaskState, + phase: LoopxPhase, + message: &str, + ) -> Result { + let updated = self + .mutate_task(task_id, None, |task, _| { + if task.generation != generation { + return; + } + task.state = state; + task.phase = phase; + if state != LoopxTaskState::WaitingForUser { + task.pending_gate_id = None; + task.pending_gate_message = None; + task.pending_gate_action_kind = None; + } + task.revision = task.revision.saturating_add(1); + }) + .await?; + self.append_task_event( + &updated, + LoopxEventKind::StateChanged, + message, + state == LoopxTaskState::RecoveryRequired, + ) + .await?; + Ok(updated) + } + + async fn transition_action( + &self, + task_id: &str, + state: LoopxTaskState, + phase: LoopxPhase, + request_id: &str, + ) -> Result { + let updated = self + .mutate_task(task_id, Some(request_id), |task, _| { + task.state = state; + task.phase = phase; + task.recovery_reason = if state == LoopxTaskState::RecoveryRequired { + Some("manual_restore".to_string()) + } else { + None + }; + if state != LoopxTaskState::WaitingForUser { + task.pending_gate_id = None; + task.pending_gate_message = None; + task.pending_gate_action_kind = None; + } + task.revision = task.revision.saturating_add(1); + }) + .await?; + Ok(LoopxActionResponse { + current_revision: updated.revision, + task: Some(updated), + ..LoopxActionResponse::default() + }) + } + + async fn update_task_phase( + &self, + task_id: &str, + generation: u64, + phase: LoopxPhase, + message: &str, + ) -> Result<(), String> { + let updated = self + .mutate_task(task_id, None, |task, _| { + if task.generation != generation { + return; + } + task.phase = phase; + task.revision = task.revision.saturating_add(1); + }) + .await?; + self.append_task_event(&updated, LoopxEventKind::PhaseChanged, message, false) + .await + } + + async fn fail_task(self: &Arc, task_id: &str, error: String) -> Result<(), String> { + log::error!("LoopX task failed: task_id={} error={}", task_id, error); + let updated = self + .mutate_task(task_id, None, |task, _| { + let workspace_was_never_prepared = task.workspace_path.is_none() + && task.goal_id.is_none() + && task.current_turn_id.is_none(); + task.state = if workspace_was_never_prepared { + LoopxTaskState::Failed + } else { + LoopxTaskState::RecoveryRequired + }; + task.phase = if workspace_was_never_prepared { + LoopxPhase::Finished + } else { + LoopxPhase::Recovering + }; + task.pending_gate_id = None; + task.pending_gate_message = None; + task.pending_gate_action_kind = None; + task.error = Some(error.clone()); + task.recovery_reason = Some("execution_failure".to_string()); + task.deadline_at = None; + task.revision = task.revision.saturating_add(1); + }) + .await?; + self.append_task_event(&updated, LoopxEventKind::StateChanged, &error, true) + .await?; + self.schedule_next_for_repository( + &updated.identity.item.repository.canonical_id(), + Some(&updated.task_id), + ) + .await; + Ok(()) + } + + /// Parks a task whose Goal is still Active after its plan ran dry. This + /// is the mandated recovery for the `RunNow + 0 open todo` frontier + /// contradiction: the host never fabricates a terminal Goal transition. + /// Unlike [`Self::fail_task`] it records an explicit, stable reason + /// (`plan_exhausted`) so the recovery card can offer targeted guidance + /// instead of a generic execution failure, and it still yields the + /// repository slot to queued sibling issues. + async fn park_plan_exhausted( + self: &Arc, + task: &LoopxTaskSnapshot, + goal_id: &str, + ) -> Result<(), String> { + log::warn!( + "LoopX plan exhausted, parking for owner decision: task_id={} goal={}", + task.task_id, + goal_id + ); + let message = LOOPX_PLAN_EXHAUSTED_MESSAGE; + let updated = self + .mutate_task(&task.task_id, None, |current, _| { + current.state = LoopxTaskState::RecoveryRequired; + current.phase = LoopxPhase::Recovering; + current.recovery_reason = Some(LOOPX_PLAN_EXHAUSTED_REASON.to_string()); + current.error = Some(message.to_string()); + current.pending_gate_id = None; + current.pending_gate_message = None; + current.pending_gate_action_kind = None; + current.deadline_at = None; + current.revision = current.revision.saturating_add(1); + }) + .await?; + self.append_task_event(&updated, LoopxEventKind::StateChanged, message, true) + .await?; + self.schedule_next_for_repository( + &updated.identity.item.repository.canonical_id(), + Some(&updated.task_id), + ) + .await; + Ok(()) + } + + /// Reads the goal's durable todos and returns the blocked publication + /// todo waiting on the owner's decision, if any. Typed match only + /// (`status=blocked`, agent advancement todo, publish-family + /// `action_kind`); declined todos are excluded so a rejected approval + /// does not resurface forever. Read failures degrade to `None` (the + /// caller then parks plan-exhausted as before). + async fn find_blocked_publication_todo( + self: &Arc, + task: &LoopxTaskSnapshot, + runtime: &LoopxTaskRuntimeRecord, + goal_id: &str, + ) -> Option { + let declined = runtime.declined_publication_todo_id.clone(); + let progress = BufferedProgress::default(); + let listed = self + .cli + .list_todos( + LoopxCliListTodosRequest { + context: self.goal_context(task, runtime), + goal_id: goal_id.to_string(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + status: Some("blocked".to_string()), + }, + &progress, + ) + .await; + self.record_progress(progress.take()).await.ok()?; + let todos = match listed { + Ok(result) => result.todos, + Err(error) => { + log::warn!( + "LoopX blocked-todo read failed; parking plan-exhausted without the publication projection: task_id={} error={}", + task.task_id, + error + ); + return None; + } + }; + blocked_publication_todo(&todos, declined.as_deref()).cloned() + } + + /// Parks a task whose plan ran dry on a BLOCKED publication todo as the + /// publish approval wait: the durable todo's own text becomes the + /// approval message and its typed ids become the pending-gate projection + /// so the console renders the same publish approval card a typed user + /// gate gets. Approve unblocks the todo (see `answer_gate`); Reject + /// records the decline and parks with a clear message. + async fn park_publication_approval( + self: &Arc, + task: &LoopxTaskSnapshot, + goal_id: &str, + todo: &LoopxCliTodoSummary, + ) -> Result<(), String> { + let message = blocked_publication_approval_message(&todo.text); + log::info!( + "LoopX plan exhausted on a blocked publication todo; projecting the publish approval instead: task_id={} goal={} todo={}", + task.task_id, + goal_id, + todo.todo_id + ); + let generation = task.generation; + let updated = self + .mutate_task(&task.task_id, None, |current, _| { + if current.generation != generation { + return; + } + current.state = LoopxTaskState::WaitingForUser; + current.phase = LoopxPhase::WaitingForApproval; + current.recovery_reason = None; + current.error = None; + current.pending_gate_id = Some(todo.todo_id.clone()); + current.pending_gate_action_kind = Some(todo.action_kind.clone()); + current.pending_gate_message = Some(message.clone()); + current.revision = current.revision.saturating_add(1); + }) + .await?; + let mut details = BTreeMap::new(); + details.insert("gateId".to_string(), todo.todo_id.clone()); + if !todo.action_kind.is_empty() { + details.insert("actionKind".to_string(), todo.action_kind.clone()); + } + self.append_task_event_with_details( + &updated, + LoopxEventKind::ApprovalRequired, + &message, + true, + details, + ) + .await?; + self.schedule_next_for_repository( + &updated.identity.item.repository.canonical_id(), + Some(&updated.task_id), + ) + .await; + Ok(()) + } + + /// Best-effort cleanup of the task's on-disk worktree. Called only from + /// the explicit Archive action; failure is recorded as an event, never + /// fatal to the transition. + async fn dispose_task_workspace(self: &Arc, task: &LoopxTaskSnapshot) { + if task.workspace_path.is_none() { + return; + } + let progress = BufferedProgress::default(); + let result = self + .workspace + .dispose(LoopxWorkspaceDisposeRequest { + operation_id: format!("dispose-{}", uuid::Uuid::new_v4()), + task_id: task.task_id.clone(), + item: task.identity.item.clone(), + }) + .await + .map_err(|error| error.message.clone()); + self.record_progress(progress.take()).await.ok(); + let important = result.is_err(); + let message = match result { + Ok(disposed) if disposed.removed => { + "Archived task worktree cleaned up (disk space released)".to_string() + } + Ok(_) => "Archived task had no managed worktree to clean up".to_string(), + Err(error) => { + // Keep the archive transition; surface cleanup failure. + format!("Failed to clean up archived task worktree: {error}") + } + }; + let _ = self + .append_task_event(task, LoopxEventKind::StateChanged, &message, important) + .await; + } + + async fn schedule_next_for_repository( + self: &Arc, + repository_id: &str, + exclude_task_id: Option<&str>, + ) -> bool { + if let Some(owner) = exclude_task_id { + let mut active = self.active_repositories.lock().await; + if active.get(repository_id).map(String::as_str) == Some(owner) { + active.remove(repository_id); + } + } + if self.state.read().await.suspended { + return false; + } + let next = { + let state = self.state.read().await; + state + .tasks + .iter() + .find(|task| { + // Preparing joins Queued as schedulable: a reserved task + // whose drive never completed would otherwise stall the + // whole repository line once the running slot frees up. + // Re-driving it is safe — reserve_repository bounces the + // task back to Queued when the slot is still taken. + matches!( + task.state, + LoopxTaskState::Queued | LoopxTaskState::Preparing + ) && task.identity.item.repository.canonical_id() == repository_id + && exclude_task_id != Some(task.task_id.as_str()) + }) + .map(|task| task.task_id.clone()) + }; + if let Some(task_id) = next { + self.enqueue_task(task_id, Duration::ZERO).is_ok() + } else { + false + } + } + + async fn enqueue_ready_tasks_after_load(&self) { + if self.load_error.read().await.is_some() { + return; + } + if self.state.read().await.suspended { + return; + } + let task_ids = { + let state = self.state.read().await; + state + .tasks + .iter() + .filter(|task| task.state == LoopxTaskState::Queued) + .map(|task| task.task_id.clone()) + .collect::>() + }; + for task_id in task_ids { + let _ = self.enqueue_task(task_id, Duration::ZERO); + } + } + + fn enqueue_task(&self, task_id: String, delay: Duration) -> Result<(), String> { + if !delay.is_zero() { + let sender = self.task_sender.clone(); + tokio::spawn(async move { + tokio::time::sleep(delay).await; + let _ = sender.send(ScheduledTask { task_id }); + }); + return Ok(()); + } + self.task_sender + .send(ScheduledTask { task_id }) + .map_err(|_| "LoopX controller task runner is unavailable".to_string()) + } + + async fn reserve_scheduled_task(&self, task_id: &str) -> bool { + let mut active = self.active_tasks.lock().await; + match active.get_mut(task_id) { + Some(pending) => { + *pending = true; + false + } + None => { + active.insert(task_id.to_string(), false); + true + } + } + } + + async fn release_scheduled_task(&self, task_id: &str) -> bool { + self.active_tasks + .lock() + .await + .remove(task_id) + .unwrap_or(false) + } + + async fn suppress_pending_task_rerun(&self, task_id: &str) { + if let Some(pending) = self.active_tasks.lock().await.get_mut(task_id) { + *pending = false; + } + } + + async fn mutate_task( + &self, + task_id: &str, + request_id: Option<&str>, + update: impl FnOnce(&mut LoopxTaskSnapshot, &mut LoopxTaskRuntimeRecord), + ) -> Result { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let task_index = state + .tasks + .iter() + .position(|task| task.task_id == task_id) + .ok_or_else(|| "LoopX task not found".to_string())?; + let mut runtime = state.runtime.remove(task_id).unwrap_or_default(); + update(&mut state.tasks[task_index], &mut runtime); + state.tasks[task_index].updated_at = now_ms(); + let updated = state.tasks[task_index].clone(); + state.runtime.insert(task_id.to_string(), runtime); + state.revision = state.revision.saturating_add(1); + if let Some(request_id) = request_id { + state.record_processed_request(request_id.to_string()); + } + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + Ok(updated) + } + + async fn append_task_event( + &self, + task: &LoopxTaskSnapshot, + kind: LoopxEventKind, + message: &str, + important: bool, + ) -> Result<(), String> { + self.append_task_event_with_details(task, kind, message, important, BTreeMap::new()) + .await + } + + async fn append_task_event_with_details( + &self, + task: &LoopxTaskSnapshot, + kind: LoopxEventKind, + message: &str, + important: bool, + details: BTreeMap, + ) -> Result<(), String> { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + state.append_event(LoopxEvent { + task_id: Some(task.task_id.clone()), + generation: Some(task.generation), + revision: Some(task.revision), + kind, + level: if kind == LoopxEventKind::ApprovalRequired { + LoopxEventLevel::Warning + } else if important { + LoopxEventLevel::Error + } else { + LoopxEventLevel::Info + }, + source: LoopxEventSource::Controller, + phase: Some(task.phase), + message: message.to_string(), + important, + details, + occurred_at: now_ms(), + ..LoopxEvent::default() + }); + let persisted = state.clone(); + let event = persisted.events.last().cloned(); + drop(state); + self.store.save(&persisted).await?; + if let Some(event) = event { + let _ = self.event_sender.send(event); + } + Ok(()) + } + + async fn task(&self, task_id: &str) -> Result { + self.state + .read() + .await + .tasks + .iter() + .find(|task| task.task_id == task_id) + .cloned() + .ok_or_else(|| "LoopX task not found".to_string()) + } + + async fn runtime(&self, task_id: &str) -> LoopxTaskRuntimeRecord { + self.state + .read() + .await + .runtime + .get(task_id) + .cloned() + .unwrap_or_default() + } + + async fn ensure_writable(&self) -> Result<(), String> { + match self.load_error.read().await.clone() { + Some(error) => Err(error), + None => Ok(()), + } + } + + async fn persist_current(&self) -> Result<(), String> { + let state = self.state.read().await.clone(); + self.store.save(&state).await + } + + fn broadcast_new_events(&self, state: &LoopxPersistedState, after_cursor: u64) { + for event in state + .events + .iter() + .filter(|event| event.cursor > after_cursor) + { + let _ = self.event_sender.send(event.clone()); + } + } + + fn goal_context( + &self, + task: &LoopxTaskSnapshot, + runtime: &LoopxTaskRuntimeRecord, + ) -> LoopxCliGoalContext { + LoopxCliGoalContext { + call: LoopxCliCallContext { + operation_id: runtime.operation_id.clone(), + deadline_at: task.deadline_at, + }, + task_id: task.task_id.clone(), + generation: task.generation, + worktree_path: task.workspace_path.clone().unwrap_or_default(), + registry_path: runtime.registry_path.clone(), + available_capabilities: self.agent_capabilities.clone(), + } + } +} + +fn is_normal_process_lifecycle_message(message: &str) -> bool { + matches!( + message, + "Starting LoopX process" | "LoopX process exited successfully" + ) +} + +fn task_has_bound_goal(task: &LoopxTaskSnapshot) -> bool { + task.goal_id + .as_deref() + .is_some_and(|goal_id| !goal_id.trim().is_empty()) +} + +/// The bound goal's workspace directory is gone from disk; the task must +/// re-run the prepare + connect flow instead of spawning CLI processes +/// against an invalid working directory. +fn bound_workspace_missing(task: &LoopxTaskSnapshot) -> bool { + task.workspace_path + .as_deref() + .map(|path| !std::path::Path::new(path).exists()) + .unwrap_or(false) +} + +fn is_repository_recovery_candidate(task: &LoopxTaskSnapshot, repository_id: &str) -> bool { + decide_repository_recovery_candidate(task, repository_id) +} +/// Whether `apply_settlement` should re-inspect the durable Goal after a +/// settlement before deciding the task's next state. +/// +/// `Settled`/`AlreadySettled` keep today's behavior (any non-failed agent +/// status). `RetryRequired` from a COMPLETED turn is the pinned CLI's +/// false-negative settlement: the durable writeback matched but the quota +/// spend receipt is missing (observed live 2026-09-05 on dsh-desktop#830, +/// whose final onboarding turn closed the goal vision but skipped +/// `quota spend-slot`), so the authoritative Goal projection — not the +/// missing bookkeeping receipt — decides what happens next, and a +/// corrective turn is not an option because a terminal frontier refuses +/// the quota guard. `RetryRequired` from a cancelled or interrupted turn +/// keeps the explicit recovery path: the owner interrupted that turn, so +/// the host does not silently re-drive it. A failed agent turn and +/// `NoDurableProgress` (no validated writeback) keep their existing paths. +fn inspects_goal_after_settlement( + agent_status: LoopxAgentTurnStatus, + settlement_status: LoopxCliSettlementStatus, +) -> bool { + match settlement_status { + LoopxCliSettlementStatus::Settled | LoopxCliSettlementStatus::AlreadySettled => { + agent_status != LoopxAgentTurnStatus::Failed + } + // A completed turn's settlement receipt is bookkeeping; the durable + // Goal projection is the authority. NoDurableProgress joins + // RetryRequired here (observed live 2026-09-11 on + // xielixing/dynamic-workflows-lab): when the writeback landed + // durably but receipt validation failed, only the projection can + // distinguish "nothing to re-submit" from "work genuinely + // missing". Cancelled/interrupted turns keep the explicit + // recovery path: the owner interrupted that turn, so the host + // does not silently re-drive it from the projection. + LoopxCliSettlementStatus::RetryRequired | LoopxCliSettlementStatus::NoDurableProgress => { + agent_status == LoopxAgentTurnStatus::Completed + } + LoopxCliSettlementStatus::GoalCompleted => false, + } +} + +/// Whether the authoritative Goal projection reports an outcome that only +/// the owner or the goal itself can move past — a user gate, a terminal +/// completion, or a goal-level failure. Whenever this holds, the projection +/// decides the task's next state and a receipt-driven compensation turn has +/// nothing durable left to re-submit (observed live 2026-09-11, +/// xielixing/dynamic-workflows-lab: closure turns whose writeback landed +/// durably still got a receipt-level refusal and were re-driven once for +/// nothing). This is the single decision rule for every settlement status: +/// the durable projection outranks receipts; receipts are bookkeeping. +fn projection_decides_next_state(goal: Option<&LoopxCliGoalSnapshot>) -> bool { + matches!( + goal.map(|goal| goal.run_decision), + Some( + LoopxCliRunDecision::WaitingForUser + | LoopxCliRunDecision::Complete + | LoopxCliRunDecision::Failed + ) + ) +} + +/// Whether a healthy-turn settlement should schedule the one-shot durable +/// compensation turn. The Goal projection is consulted first: when it +/// already reports an owner-facing or terminal outcome there is nothing +/// durable left to re-submit, so the task settles from the projection +/// instead of burning another agent turn. A failed agent turn never +/// compensates (explicit recovery), and the compensation allowance is +/// one-shot per episode (`durable_compensation_pending`). +fn should_compensate_durable_writeback( + agent_status: LoopxAgentTurnStatus, + settlement_status: LoopxCliSettlementStatus, + compensation_already_attempted: bool, + post_settlement_goal: Option<&LoopxCliGoalSnapshot>, +) -> bool { + if agent_status == LoopxAgentTurnStatus::Failed { + return false; + } + if settlement_status != LoopxCliSettlementStatus::NoDurableProgress { + return false; + } + if compensation_already_attempted { + return false; + } + !projection_decides_next_state(post_settlement_goal) +} + +/// Whether a NoDurableProgress settlement whose one-shot compensation turn +/// already ran should park the task for interactive recovery instead of +/// requeueing. An unchanged frontier (RunNow/Wait) must not be re-driven +/// automatically — that could loop forever without an owner decision — +/// while a deciding projection (gate/terminal/failed) still settles the +/// task from the goal state via `task_state_after_settlement`. +fn parks_after_failed_compensation( + agent_status: LoopxAgentTurnStatus, + settlement_status: LoopxCliSettlementStatus, + compensation_already_attempted: bool, + post_settlement_goal: Option<&LoopxCliGoalSnapshot>, +) -> bool { + agent_status != LoopxAgentTurnStatus::Failed + && settlement_status == LoopxCliSettlementStatus::NoDurableProgress + && compensation_already_attempted + && !projection_decides_next_state(post_settlement_goal) +} + +/// Whether an approved gate left the goal with no driver for its promised +/// action: the CLI projects `RunNow` but no open todo, no user gate, and no +/// replan obligation remains (observed live 2026-09-11, +/// xielixing/dynamic-workflows-lab#2: the agent authored the publish gate +/// without a successor todo, the approval consumed the last todo, and the +/// task parked plan-exhausted with the promised PR never created). Terminal, +/// failed, waiting, and monitor-wait projections are left untouched: only +/// the "runnable but nothing runnable" contradiction needs a materialized +/// successor for the owner's approved decision. +fn approved_gate_needs_materialized_successor(goal: &LoopxCliGoalSnapshot) -> bool { + goal.run_decision == LoopxCliRunDecision::RunNow + && goal.open_todo_count == 0 + && goal.waiting_user_todo_count == 0 + && goal.pending_user_gate.is_none() + && goal.pending_replan_obligation_id.is_none() +} + +/// Human-readable confirmation for a user gate answer. The gate's own +/// message is echoed so the applied notice names the exact decision the +/// owner just made. Approval only releases the task to run the approved +/// action (the agent turn performs it afterwards), so the wording states +/// what happens next instead of claiming an outcome that has not happened +/// yet. +fn gate_decision_applied_message(approved: bool, gate_message: Option<&str>) -> String { + let subject = gate_message + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("the requested owner decision"); + if approved { + format!("Approval applied; the task will continue with the approved action: {subject}") + } else { + format!("Rejection applied; the task will continue without the declined action: {subject}") + } +} + +/// Owner-facing text for the materialized successor todo of an approved +/// gate. Quotes the gate's own message with an approval prefix so the agent +/// executes the owner's decision verbatim; bounded to keep the pinned CLI's +/// todo text budget safe. +fn approved_action_todo_text(gate_message: &str) -> String { + let mut text = format!( + "[P0] The owner APPROVED this decision request; execute the approved branch of it now: {}", + gate_message.trim() + ); + if text.chars().count() > 400 { + text = text.chars().take(400).collect(); + } + text +} + +/// The blocked publication todo the plan ran dry on, if any. Typed match +/// only: a blocked agent advancement todo whose `action_kind` names a +/// publication/publish step is the agent's own encoding of "publishing needs +/// the owner's decision" (observed live 2026-09-11, +/// xielixing/dynamic-workflows-lab#2: `action_kind=issue_fix_pr_publication`, +/// blocked with reason "publishing upstream is an external write that needs +/// the repository owner's explicit decision"). Free-text reasons are never +/// parsed; `declined_todo_id` excludes an approval the owner already turned +/// down so it does not resurface on every drive. +fn blocked_publication_todo<'a>( + todos: &'a [LoopxCliTodoSummary], + declined_todo_id: Option<&str>, +) -> Option<&'a LoopxCliTodoSummary> { + todos.iter().find(|todo| { + todo.status.eq_ignore_ascii_case("blocked") + && todo.role.eq_ignore_ascii_case("agent") + && todo.task_class.eq_ignore_ascii_case("advancement_task") + && (todo.action_kind.contains("publication") || todo.action_kind.contains("publish")) + && declined_todo_id != Some(todo.todo_id.as_str()) + }) +} + +/// Owner-facing approval message projected from the blocked publication +/// todo's own text: the owner decides on the step the agent named, not on +/// host-invented semantics. +fn blocked_publication_approval_message(todo_text: &str) -> String { + let trimmed = todo_text.trim(); + let mut message = format!( + "[P0] Approve publishing the prepared fix: {trimmed}. The agent prepared this step and blocked it pending the repository owner's decision." + ); + if message.chars().count() > 480 { + message = message.chars().take(480).collect(); + } + message +} + +/// Decides the task state after a turn settlement. When the post-settlement +/// Goal inspection produced a snapshot, the CLI's authoritative projection +/// wins: this covers normal settled turns and the false-negative +/// `RetryRequired` settlement (validated writeback, missing quota receipt; +/// see [`inspects_goal_after_settlement`]). Without a snapshot (inspection +/// failed or not attempted), the settlement status alone decides — missing +/// durable progress or a missing receipt then parks in explicit recovery. +fn task_state_after_settlement( + agent_status: LoopxAgentTurnStatus, + settlement_status: LoopxCliSettlementStatus, + post_settlement_goal: Option<&LoopxCliGoalSnapshot>, +) -> LoopxTaskState { + if agent_status == LoopxAgentTurnStatus::Failed { + return LoopxTaskState::RecoveryRequired; + } + if let Some(goal) = post_settlement_goal { + return match goal.run_decision { + LoopxCliRunDecision::WaitingForUser => LoopxTaskState::WaitingForUser, + LoopxCliRunDecision::Complete => LoopxTaskState::Completed, + LoopxCliRunDecision::Failed => LoopxTaskState::RecoveryRequired, + LoopxCliRunDecision::RunNow | LoopxCliRunDecision::Wait => LoopxTaskState::Queued, + }; + } + match settlement_status { + LoopxCliSettlementStatus::GoalCompleted => LoopxTaskState::Completed, + LoopxCliSettlementStatus::Settled | LoopxCliSettlementStatus::AlreadySettled => { + LoopxTaskState::Queued + } + LoopxCliSettlementStatus::NoDurableProgress | LoopxCliSettlementStatus::RetryRequired => { + LoopxTaskState::RecoveryRequired + } + } +} + +/// Human-facing recovery reason for a task parked by settlement. Distinct +/// values keep the console copy honest: a rejected or missing writeback +/// (`NoDurableProgress`) must not be reported as "writeback verified, quota +/// receipt missing" (`RetryRequired`). Observed live 2026-09-11 on +/// xielixing/dynamic-workflows-lab#2: three rejected typed writebacks were +/// displayed as a missing quota receipt because every settlement-path +/// `RecoveryRequired` shared one `settlement_unverified` reason. A failed +/// agent turn reports execution failure (matching the non-settlement +/// failure path), and a failed Goal projection keeps the generic wording. +fn recovery_reason_after_settlement( + agent_status: LoopxAgentTurnStatus, + settlement_status: LoopxCliSettlementStatus, + post_settlement_goal: Option<&LoopxCliGoalSnapshot>, +) -> &'static str { + if agent_status == LoopxAgentTurnStatus::Failed { + return "execution_failure"; + } + if let Some(goal) = post_settlement_goal { + if goal.run_decision == LoopxCliRunDecision::Failed { + return "settlement_unverified"; + } + } + match settlement_status { + LoopxCliSettlementStatus::NoDurableProgress => "settlement_no_progress", + LoopxCliSettlementStatus::RetryRequired => "settlement_receipt_missing", + _ => "settlement_unverified", + } +} + +/// Pure witness for the RunNow frontier contradiction described in +/// `drive_turn`: the envelope must itself assert there is nothing to do. +fn run_now_is_frontier_contradiction( + open_todo_count: u32, + waiting_user_todo_count: u32, + has_selected_todo: bool, +) -> bool { + open_todo_count == 0 && waiting_user_todo_count == 0 && !has_selected_todo +} + +/// What the host does with a todo-less `RunNow` frontier. Runtime-data +/// correction (2026-09-05 five-issue run, re-verified against v1.0.1): the +/// pinned CLI projects `should_run=true` with an autonomous replan obligation +/// when the plan runs +/// dry, and expects the host to drive one bounded replan turn bound to that +/// obligation — parking there stranded every task of that run. Only a +/// todo-less frontier WITHOUT an open obligation is a contract contradiction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TodolessRunNowFrontier { + /// Drive one autonomous replan turn bound to the open obligation: the + /// agent writes back a successor todo, a typed terminal outcome, or a + /// concrete blocker; settlement validates by the `autonomous_replan` + /// effect id and the CLI's replan stall threshold bounds no-op cycles. + DriveReplanTurn, + /// No actionable frontier remains: park with `plan_exhausted` for an + /// owner decision. + Park, +} + +fn todoless_run_now_frontier(pending_replan_obligation_id: Option<&str>) -> TodolessRunNowFrontier { + if pending_replan_obligation_id.is_some_and(|value| !value.trim().is_empty()) { + TodolessRunNowFrontier::DriveReplanTurn + } else { + TodolessRunNowFrontier::Park + } +} + +/// Read-only LoopX user gates: public issue/comment reads are agent work, +/// not an owner decision. LoopX may project these gates without a typed +/// `action_kind`, so the envelope message is the fallback signal; typed +/// write/publish/merge gates always stay interactive. +fn is_read_only_user_gate(action_kind: Option<&str>, message: Option<&str>) -> bool { + if let Some(kind) = action_kind.map(str::trim) { + if kind == "approve_github_issue_body_or_comment_read" + || (kind.starts_with("approve_") && kind.ends_with("_read")) + { + return true; + } + } + let Some(message) = message.map(str::trim) else { + return false; + }; + if message.is_empty() { + return false; + } + let lower = message.to_ascii_lowercase(); + let mentions_read = lower.contains("gated read") + || lower.contains("读取") + || lower + .split(|ch: char| !ch.is_ascii_alphanumeric()) + .any(|word| matches!(word, "read" | "reads" | "reading")); + let mentions_public_content = lower.contains("issue body") + || lower.contains("comment bod") + || lower.contains("maintainer comment") + || lower.contains("public issue") + || lower.contains("正文") + || lower.contains("评论") + || lower.contains("议题"); + let mentions_external_write = lower.contains("publish") + || lower.contains("merge") + || lower.contains("pull request") + || lower.contains("push") + || lower.contains("comment on") + || lower.contains("close issue") + || lower.contains("production") + || lower.contains("发布") + || lower.contains("合并") + || lower.contains("推送") + || lower.contains("关闭"); + mentions_read && mentions_public_content && !mentions_external_write +} + +/// Reuse-existing-PR merge gates. LoopX may project these without a typed +/// action kind, so the envelope message carries the semantics. +fn is_reuse_merge_user_gate(action_kind: Option<&str>, message: &str) -> bool { + let kind = action_kind + .map(str::trim) + .unwrap_or_default() + .to_ascii_lowercase(); + let message_lower = message.to_ascii_lowercase(); + kind.contains("merge") + || kind.contains("reuse") + || message_lower.contains("merge pr #") + || message_lower.contains("reuse existing pr") +} + +/// Agent todos that publish or merge external state are owner-gated. LoopX +/// can keep projecting one as `RunNow` after the agent's typed user gate is +/// open; driving it again only burns turns because the agent cannot perform +/// the external write. The host parks and surfaces the gate instead. +/// +/// Live 2026-09-16: issue #11 re-ran `issue_fix_publish_prepared_update` +/// four times while its publish gate and merge decision stayed open. +fn selected_todo_requires_owner_gate( + todo: Option<&LoopxCurrentTodo>, +) -> bool { + let Some(todo) = todo else { + return false; + }; + let kind = todo.action_kind.trim().to_ascii_lowercase(); + kind.starts_with("issue_fix_publish") + || kind.starts_with("issue_fix_merge") + || kind.starts_with("issue_fix_external_write") +} + +fn reuse_merge_pr_label(message: &str) -> String { + let lower = message.to_ascii_lowercase(); + let index = match lower.find("pr #") { + Some(index) => index + 3, + None => return "the referenced PR".to_string(), + }; + let digits: String = message[index..] + .chars() + .take_while(char::is_ascii_digit) + .collect(); + if digits.is_empty() { + "the referenced PR".to_string() + } else { + format!("PR #{digits}") + } +} + +fn phase_after_settlement(state: LoopxTaskState) -> LoopxPhase { + match state { + LoopxTaskState::Completed => LoopxPhase::Finished, + LoopxTaskState::RecoveryRequired => LoopxPhase::Recovering, + LoopxTaskState::WaitingForUser => LoopxPhase::WaitingForApproval, + _ => LoopxPhase::Queued, + } +} + +fn should_requeue_after_settlement(final_state: LoopxTaskState, yielded_repository: bool) -> bool { + final_state == LoopxTaskState::Queued && !yielded_repository +} + +/// Depth-first repository lane: after a cleanly settled segment, the same task +/// keeps the slot and continues while its Goal is still runnable. Yield to the +/// next queued issue only when the Goal actually paused (user gate, cadence +/// wait, terminal, recovery) or the post-settlement inspection failed to +/// project a decision — a re-drive re-inspects and parks at the gate, so +/// treating an unknown decision as runnable is self-correcting. A monitor +/// successor needs no special case on v1.0.1: LoopX projects it RunNow only +/// when the monitor is actually due, and as a cadence wait otherwise. +fn sticky_continue_after_settlement( + final_state: LoopxTaskState, + post_settlement_run_decision: Option, +) -> bool { + if final_state != LoopxTaskState::Queued { + return false; + } + match post_settlement_run_decision { + None => true, + Some(decision) => matches!(decision, LoopxCliRunDecision::RunNow), + } +} + +/// Requeue delay for a waiting goal, translated from the pinned LoopX v1.0.1 +/// scheduler projection. The compacted turn envelope carries the decision's +/// `cadence_class` label but omits numeric intervals (they live in the quota +/// decision detail the envelope compacts away), so the host maps the label +/// onto the pinned scheduler's own initial intervals +/// (`loopx/control_plane/scheduler/scheduler_hint.py`: active_work 3m, +/// monitor_wait 15m host floor, human_gate 30m, quiet_wait 30m, +/// unchanged_noop 60m, agent_scope_wait 10m). Unknown or missing labels fall +/// back to a bounded poll so a waiting goal can never sleep forever or +/// hot-poll. +fn wait_requeue_delay_ms(snapshot: &LoopxCliGoalSnapshot) -> u64 { + match snapshot.scheduler_cadence.as_deref() { + Some("active_work") => 3 * 60 * 1000, + Some("monitor_wait") => 15 * 60 * 1000, + Some("human_gate") => 30 * 60 * 1000, + Some("quiet_wait") => 30 * 60 * 1000, + Some("unchanged_noop") => 60 * 60 * 1000, + Some("agent_scope_wait") => 10 * 60 * 1000, + _ => WAIT_RESCHEDULE_FALLBACK_MS, + } +} + +fn goal_id_for(identity: &LoopxTaskIdentity) -> String { + let item = &identity.item; + let kind = match item.kind { + LoopxItemKind::Issue => "issue", + LoopxItemKind::PullRequest => "pr", + }; + let suffix = if identity.attempt > 1 { + format!("-{}", identity.attempt) + } else { + String::new() + }; + format!( + "bfx-{}-{}-{kind}-{}{}", + item.repository.owner, item.repository.repository, item.number, suffix + ) +} + +fn existing_outcomes( + state: &LoopxPersistedState, + selected: &std::collections::BTreeSet, +) -> Vec { + selected + .iter() + .map(|item| { + let task = state + .tasks + .iter() + .filter(|task| &task.identity.item == item) + .max_by_key(|task| task.identity.attempt); + LoopxCreateTaskOutcome { + item: item.clone(), + kind: LoopxCreateTaskOutcomeKind::OpenedExisting, + task_id: task.map(|task| task.task_id.clone()), + attempt: task.map(|task| task.identity.attempt), + ..LoopxCreateTaskOutcome::default() + } + }) + .collect() +} + +fn prune_intake_previews(previews: &mut HashMap, now: i64) { + previews.retain(|_, preview| intake_preview_is_fresh(preview, now)); + if previews.len() <= MAX_INTAKE_PREVIEWS { + return; + } + + let mut by_age = previews + .iter() + .map(|(fingerprint, preview)| (fingerprint.clone(), preview.resolved_at)) + .collect::>(); + by_age.sort_by_key(|(_, resolved_at)| *resolved_at); + let excess = previews.len().saturating_sub(MAX_INTAKE_PREVIEWS); + for (fingerprint, _) in by_age.into_iter().take(excess) { + previews.remove(&fingerprint); + } +} + +fn intake_preview_is_fresh(preview: &LoopxIntakePreview, now: i64) -> bool { + match preview.expires_at { + Some(expires_at) => expires_at > now, + None => false, + } +} + +/// Monitoring projection for the rail, or `None` when the goal is not in a +/// monitoring wait. The next-check timestamp comes from the same host-owned +/// requeue delay the scheduler uses, so the displayed time is not a second, +/// invented cadence. +fn monitoring_wait_projection( + inspected: &LoopxCliGoalSnapshot, +) -> Option { + let cadence = inspected.scheduler_cadence.as_deref().unwrap_or_default(); + if cadence != "monitor_wait" { + return None; + } + Some(LoopxMonitorWait { + cadence: cadence.to_string(), + due_at_ms: Some(now_ms().saturating_add(wait_requeue_delay_ms(inspected) as i64)), + }) +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +fn bounded_agent_summary(summary: &str) -> String { + let total = summary.chars().count(); + if total <= MAX_AGENT_SUMMARY_CHARS { + return summary.to_string(); + } + // Keep the tail: the structured summary contract places its fenced + // `loopx_summary_v1` JSON at the end of the response, and a head-keeping + // bound would decapitate exactly that block (see the subscriber's + // `append_bounded_text` for the matching tail-keeping rule). + let tail: String = summary + .chars() + .skip(total - MAX_AGENT_SUMMARY_CHARS) + .collect(); + format!("[Summary truncated by LoopX host; head cut]\n\n{tail}") +} + +fn github_auth_fact_status(probe: &LoopxGithubAuthProbe) -> LoopxEnvironmentFactStatus { + if probe.authenticated { + LoopxEnvironmentFactStatus::Available + } else if probe.rate_limit_remaining.is_some() { + LoopxEnvironmentFactStatus::Degraded + } else { + LoopxEnvironmentFactStatus::Unavailable + } +} + +/// Maps the CLI adapter's Node.js probe onto the environment fact surface. +/// A missing or too-old Node BLOCKS the environment (the pinned v1.0.x control +/// plane fail-closes bootstrap without it), with a concrete remediation that +/// names the minimum version instead of a generic failure. +fn loopx_node_environment_fact( + probe: &openbitfun_product_domains::miniapp::loopx::LoopxNodeRuntimeFact, + checked_at: Option, +) -> LoopxEnvironmentFact { + LoopxEnvironmentFact { + status: if probe.available { + LoopxEnvironmentFactStatus::Available + } else { + LoopxEnvironmentFactStatus::Unavailable + }, + version: probe.version.clone(), + detail: probe.detail.clone(), + remediation: (!probe.available).then(|| { + let detail = if managed_runtime_install_supported() { + "Install the app-managed Node.js runtime, or install Node.js 22.6+ from https://nodejs.org, then re-check this environment" + } else { + MANAGED_RUNTIME_PLATFORM_UNSUPPORTED_DETAIL + }; + detail.to_string() + }), + checked_at, + remediation_action: if probe.available || !managed_runtime_install_supported() { + LoopxEnvironmentRemediationAction::None + } else { + LoopxEnvironmentRemediationAction::InstallNode + }, + ..LoopxEnvironmentFact::default() + } +} + +fn runtime_label(runtime: LoopxManagedRuntimeKind) -> &'static str { + match runtime { + LoopxManagedRuntimeKind::Node => "Node.js", + LoopxManagedRuntimeKind::Git => "Git", + } +} + +fn environment_fact_for( + core: &LoopxCoreEnvironmentFacts, + runtime: LoopxManagedRuntimeKind, +) -> &LoopxEnvironmentFact { + match runtime { + LoopxManagedRuntimeKind::Node => &core.node_runtime, + LoopxManagedRuntimeKind::Git => &core.git_worktree, + } +} + +fn environment_fact_for_mut( + core: &mut LoopxCoreEnvironmentFacts, + runtime: LoopxManagedRuntimeKind, +) -> &mut LoopxEnvironmentFact { + match runtime { + LoopxManagedRuntimeKind::Node => &mut core.node_runtime, + LoopxManagedRuntimeKind::Git => &mut core.git_worktree, + } +} + +fn runtime_unavailable_fact( + runtime: LoopxManagedRuntimeKind, + detail: &str, + checked_at: Option, +) -> LoopxEnvironmentFact { + let remediation = + "Retry the app-managed install; if it keeps failing, install the runtime manually" + .to_string(); + let action = match runtime { + LoopxManagedRuntimeKind::Node => LoopxEnvironmentRemediationAction::InstallNode, + LoopxManagedRuntimeKind::Git => LoopxEnvironmentRemediationAction::InstallGit, + }; + LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Unavailable, + detail: Some(detail.to_string()), + remediation: Some(remediation), + remediation_action: action, + checked_at, + ..LoopxEnvironmentFact::default() + } +} + +/// Git worktree availability. A missing Git binary is a first-class +/// remediation (app-managed portable Git) instead of a generic failure. +fn git_worktree_environment_fact( + detail: impl Into, + checked_at: Option, +) -> LoopxEnvironmentFact { + let detail = detail.into(); + let git_missing = git_missing_from_detail(&detail); + // App-managed portable runtimes are Windows-only for now; macOS/Linux + // users are explicitly told to use the system package manager instead of + // being shown a one-click action that cannot work yet. + let managed_git_available = managed_runtime_install_supported() && git_missing; + let remediation = if managed_git_available { + Some( + "Install app-managed portable Git, or install Git with your package manager, then re-check this environment" + .to_string(), + ) + } else if git_missing { + Some(MANAGED_RUNTIME_PLATFORM_UNSUPPORTED_DETAIL.to_string()) + } else { + None + }; + LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Unavailable, + detail: Some(detail), + remediation, + remediation_action: if managed_git_available { + LoopxEnvironmentRemediationAction::InstallGit + } else { + LoopxEnvironmentRemediationAction::None + }, + checked_at, + ..LoopxEnvironmentFact::default() + } +} + +fn git_missing_from_detail(detail: &str) -> bool { + let lower = detail.to_ascii_lowercase(); + if !lower.contains("git") { + return false; + } + ["could not start", "not found", "no such file", "cannot find", "os error 2"] + .iter() + .any(|marker| lower.contains(marker)) +} + +fn checking_runtime_environment_fact( + detail: impl Into, + checked_at: Option, +) -> LoopxEnvironmentFact { + LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Checking, + detail: Some(detail.into()), + checked_at, + ..LoopxEnvironmentFact::default() + } +} + +fn checking_environment_fact(checked_at: Option) -> LoopxEnvironmentFact { + LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Checking, + checked_at, + ..LoopxEnvironmentFact::default() + } +} + + +fn unavailable_environment_fact( + detail: impl Into, + checked_at: Option, +) -> LoopxEnvironmentFact { + LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Unavailable, + detail: Some(detail.into()), + checked_at, + ..LoopxEnvironmentFact::default() + } +} + +/// The MiniApp labels the LoopX install action with the pinned version, which it +/// reads out of the fact detail in the `expected loopx ` form. A version +/// mismatch already carries it; a missing or failed sidecar does not, so add it +/// here instead of leaving the install button without a version. +fn loopx_expected_version_detail(detail: impl Into) -> String { + let detail = detail.into(); + if detail.to_ascii_lowercase().contains("expected loopx") { + detail + } else { + format!("{detail} (expected loopx {LOOPX_PINNED_VERSION})") + } +} + +fn unavailable_loopx_environment_fact( + detail: impl Into, + checked_at: Option, +) -> LoopxEnvironmentFact { + LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Unavailable, + detail: Some(loopx_expected_version_detail(detail)), + remediation: Some( + "Download the pinned LoopX source from GitHub into BitFun-managed storage".to_string(), + ), + remediation_action: LoopxEnvironmentRemediationAction::InstallLoopx, + checked_at, + ..LoopxEnvironmentFact::default() + } +} + +/// Reconciliation may replace a local gate with a durable gate projection, but +/// it must never infer approval from an active Goal. Only an explicit gate +/// answer transitions the host task away from WaitingForUser. +fn preserve_unanswered_local_gate(task: &LoopxTaskSnapshot, goal: &LoopxCliGoalSnapshot) -> bool { + task.state == LoopxTaskState::WaitingForUser + // Either an unanswered typed gate or the owner-action-wait message + // parked by `park_waiting_owner_action` (gate id deliberately None: + // the decision happens outside this host, e.g. merging a PR on + // GitHub). Without the message arm, reconciliation wiped the parked + // wait text whenever the goal projected no typed gate, and the UI + // fell back to rendering the latest ANSWERED approval event as if it + // were live (observed live 2026-09-11, dynamic-workflows-lab#2: + // the answered publish gate popped up again over the merge wait). + && (task.pending_gate_id.is_some() || task.pending_gate_message.is_some()) + && goal.pending_user_gate.is_none() + && !matches!( + goal.state, + LoopxCliGoalState::Completed | LoopxCliGoalState::Failed | LoopxCliGoalState::Archived + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn agent_turn_instruction_always_carries_the_environment_boundary() { + // The pinned LoopX runtime must not be steered by LoopX source + // checkouts that happen to exist on the user's machine: the boundary + // note is part of every turn instruction, first turn included. + let composed = compose_agent_turn_instruction("turn body".to_string(), None, None, false); + assert!(composed.starts_with("turn body")); + assert!(composed.contains("[BitFun environment boundary]")); + assert!(composed.contains("loopx/pyproject.toml")); + assert!(!composed.contains("[BitFun host note]")); + } + + #[test] + fn agent_turn_instruction_keeps_host_note_after_the_boundary() { + let composed = compose_agent_turn_instruction( + "turn body".to_string(), + Some("corrective guidance"), + None, + false, + ); + let boundary = composed + .find("[BitFun environment boundary]") + .expect("boundary note present"); + let host_note = composed + .find("[BitFun host note]") + .expect("host note present"); + assert!(boundary < host_note); + assert!(composed.ends_with("corrective guidance")); + } + + #[test] + fn fresh_session_turn_instruction_asks_for_a_one_time_reference_read() { + let composed = compose_agent_turn_instruction( + "turn body".to_string(), + None, + Some(r"C:\wt\.loopx\pinned-loopx-skill.md"), + false, + ); + assert!(composed.contains("[Pinned LoopX references - read exactly once]")); + assert!(composed.contains("pinned-loopx-skill.md")); + // The stale filename from the 2026-09-08 run must not come back: it + // cost every turn one failed Read plus a recovery reasoning round. + assert!(!composed.contains("pinned-loopx-reference.md")); + // Cross-skill references resolve to the seeded sibling files, never + // through the skill catalog (user-level `~/.codex/skills` may hold a + // different LoopX version - observed 0.5.3 copies on this machine). + assert!(composed.contains(".loopx/loopx-doc-registry.md")); + assert!(composed.contains(".loopx/loopx-pr-program.md")); + assert!(composed.contains(".loopx/loopx-pr-review.md")); + assert!(composed.contains(".loopx/loopx-change-quality.md")); + assert!(composed.contains("never resolve loopx skill names through your skill catalog")); + // Single source of truth for the read policy: exactly one read + // directive per fresh instruction (the pointer). The closing-ceremony + // note below names the same file but must not issue its own read + // instruction - agents execute it literally (observed 2026-09-08) + // and a reused session would re-read the 59KB document every turn. + assert_eq!(composed.matches("Read `").count(), 1); + assert!(composed.contains("[BitFun host facts - closing ceremony]")); + assert!(composed.contains("governed only by the pointer section above")); + } + + #[test] + fn continued_session_turn_instruction_reuses_the_loaded_reference() { + let composed = compose_agent_turn_instruction( + "turn body".to_string(), + None, + Some(r"C:\wt\.loopx\pinned-loopx-skill.md"), + true, + ); + assert!(composed.contains("[Pinned LoopX references - already loaded]")); + assert!(composed.contains("remain the authoritative")); + assert!(composed.contains("sibling documents")); + assert!(!composed.contains("read exactly once]")); + // No read directive at all on a continued turn: the document is + // already in the conversation, and the closing-ceremony note defers + // to the pointer instead of re-issuing a read. + assert_eq!(composed.matches("Read `").count(), 0); + assert!(composed.contains("pinned-loopx-skill.md")); + } + + #[test] + fn turn_instruction_blocks_loopx_skill_catalog_and_installer_drift() { + // Version-drift guard: the environment boundary must forbid (a) the + // user-level loopx-* skill copies a different LoopX install may have + // placed in ~/.codex/skills / ~/.agents/skills, and (b) installer / + // self-update flows the pinned skill documents describe - the host + // owns the pinned binary. Without this, a session can load + // different-version docs against the pinned runtime and, with session + // reuse, carry the contradiction across every following turn. + let composed = compose_agent_turn_instruction("turn body".to_string(), None, None, false); + assert!(composed.contains("[BitFun environment boundary]")); + assert!(composed.contains("loopx-*` entries from your skill catalog")); + assert!(composed.contains("~/.codex/skills")); + assert!(composed + .contains("Never install, update, self-update, or repair the LoopX installation")); + } + + #[test] + fn run_now_with_a_selected_todo_is_not_a_frontier_contradiction() { + // Regression: the outer-controller turn plan can report + // open_count = 0 while `action.selected_todo` still names an open, + // agent-claimed todo (observed on the huangruiteng/loopx issue-3859 + // goal). The contradiction witness is the envelope's action + // projection, not the scalar counter. + assert!(!run_now_is_frontier_contradiction(0, 0, true)); + assert!(run_now_is_frontier_contradiction(0, 0, false)); + assert!(!run_now_is_frontier_contradiction(2, 0, false)); + assert!(!run_now_is_frontier_contradiction(0, 1, false)); + } + + #[test] + fn todoless_run_now_frontier_drives_a_replan_turn_only_with_an_obligation() { + // Runtime-data correction (2026-09-05 five-issue run): the pinned CLI + // projects the plan-exhausted frontier as RunNow + replan obligation, + // expecting the host to drive one replan turn. Parking there stranded + // all five tasks in recovery before any goal could close. + assert_eq!( + todoless_run_now_frontier(Some("replan-d4066f99c1ea4b11")), + TodolessRunNowFrontier::DriveReplanTurn, + ); + // A todo-less RunNow frontier without an obligation is the real + // contract contradiction: park for an owner decision. + assert_eq!( + todoless_run_now_frontier(None), + TodolessRunNowFrontier::Park, + ); + assert_eq!( + todoless_run_now_frontier(Some("")), + TodolessRunNowFrontier::Park, + ); + assert_eq!( + todoless_run_now_frontier(Some(" ")), + TodolessRunNowFrontier::Park, + ); + } + + #[test] + fn retry_required_from_a_completed_turn_recovers_from_the_goal_projection() { + // The false-negative settlement (observed live on dsh-desktop#830): + // a completed turn validated its durable writeback but skipped the + // quota spend. The Goal projection — not the missing receipt — must + // decide the next state, because a terminal frontier refuses the + // quota guard and no corrective turn can run. + assert!(inspects_goal_after_settlement( + LoopxAgentTurnStatus::Completed, + LoopxCliSettlementStatus::RetryRequired, + )); + // Cancelled/interrupted turns keep the explicit recovery path: the + // owner interrupted that turn, so the host must not silently + // re-drive it from the projection. + assert!(!inspects_goal_after_settlement( + LoopxAgentTurnStatus::Cancelled, + LoopxCliSettlementStatus::RetryRequired, + )); + assert!(!inspects_goal_after_settlement( + LoopxAgentTurnStatus::Interrupted, + LoopxCliSettlementStatus::RetryRequired, + )); + // Settled turns keep today's projection-driven handling for any + // non-failed agent status; failed turns and missing writebacks keep + // their existing explicit paths. A completed turn with + // NoDurableProgress now also inspects the projection: only the + // durable goal state can distinguish a receipt-level refusal from + // genuinely missing work (2026-09-11 live observation). + assert!(inspects_goal_after_settlement( + LoopxAgentTurnStatus::Cancelled, + LoopxCliSettlementStatus::Settled, + )); + assert!(!inspects_goal_after_settlement( + LoopxAgentTurnStatus::Failed, + LoopxCliSettlementStatus::Settled, + )); + assert!(inspects_goal_after_settlement( + LoopxAgentTurnStatus::Completed, + LoopxCliSettlementStatus::NoDurableProgress, + )); + assert!(!inspects_goal_after_settlement( + LoopxAgentTurnStatus::Cancelled, + LoopxCliSettlementStatus::NoDurableProgress, + )); + } + + #[test] + fn projection_outranks_receipts_across_settlement_statuses() { + // The single decision rule: whenever the authoritative Goal + // projection reports a user gate, a terminal completion, or a goal + // failure, it decides the task's next state and the receipt-driven + // compensation turn is skipped — the writeback (or its durable + // equivalent) already landed. + let complete_goal = LoopxCliGoalSnapshot { + run_decision: LoopxCliRunDecision::Complete, + ..LoopxCliGoalSnapshot::default() + }; + // First NoDurableProgress episode + terminal projection: no + // compensation turn, task completes from the projection. + assert!(!should_compensate_durable_writeback( + LoopxAgentTurnStatus::Completed, + LoopxCliSettlementStatus::NoDurableProgress, + false, + Some(&complete_goal), + )); + assert_eq!( + task_state_after_settlement( + LoopxAgentTurnStatus::Completed, + LoopxCliSettlementStatus::NoDurableProgress, + Some(&complete_goal), + ), + LoopxTaskState::Completed + ); + // Same for a user gate. + let gate_goal = LoopxCliGoalSnapshot { + run_decision: LoopxCliRunDecision::WaitingForUser, + ..LoopxCliGoalSnapshot::default() + }; + assert!(!should_compensate_durable_writeback( + LoopxAgentTurnStatus::Completed, + LoopxCliSettlementStatus::NoDurableProgress, + false, + Some(&gate_goal), + )); + // An unchanged frontier (RunNow) still earns the one-shot + // compensation turn, and a failed turn never compensates. + let run_now_goal = LoopxCliGoalSnapshot { + run_decision: LoopxCliRunDecision::RunNow, + ..LoopxCliGoalSnapshot::default() + }; + assert!(should_compensate_durable_writeback( + LoopxAgentTurnStatus::Completed, + LoopxCliSettlementStatus::NoDurableProgress, + false, + Some(&run_now_goal), + )); + assert!(!should_compensate_durable_writeback( + LoopxAgentTurnStatus::Failed, + LoopxCliSettlementStatus::NoDurableProgress, + false, + Some(&run_now_goal), + )); + // The allowance is one-shot per episode. + assert!(!should_compensate_durable_writeback( + LoopxAgentTurnStatus::Completed, + LoopxCliSettlementStatus::NoDurableProgress, + true, + Some(&run_now_goal), + )); + // After the compensation turn ran, an unchanged frontier parks for + // interactive recovery (no automatic re-drive loop) — the pure state + // function alone returns Queued for a RunNow snapshot, which is why + // `parks_after_failed_compensation` intercepts the exhausted + // episode in `apply_settlement` before delegating — while a + // deciding projection still settles the task from the goal state. + assert!(parks_after_failed_compensation( + LoopxAgentTurnStatus::Completed, + LoopxCliSettlementStatus::NoDurableProgress, + true, + Some(&run_now_goal), + )); + assert!(!parks_after_failed_compensation( + LoopxAgentTurnStatus::Completed, + LoopxCliSettlementStatus::NoDurableProgress, + true, + Some(&complete_goal), + )); + assert!(!parks_after_failed_compensation( + LoopxAgentTurnStatus::Completed, + LoopxCliSettlementStatus::NoDurableProgress, + false, + Some(&run_now_goal), + )); + assert_eq!( + task_state_after_settlement( + LoopxAgentTurnStatus::Completed, + LoopxCliSettlementStatus::NoDurableProgress, + Some(&run_now_goal), + ), + LoopxTaskState::Queued + ); + assert_eq!( + task_state_after_settlement( + LoopxAgentTurnStatus::Completed, + LoopxCliSettlementStatus::NoDurableProgress, + Some(&complete_goal), + ), + LoopxTaskState::Completed + ); + assert_eq!( + task_state_after_settlement( + LoopxAgentTurnStatus::Completed, + LoopxCliSettlementStatus::NoDurableProgress, + None, + ), + LoopxTaskState::RecoveryRequired + ); + } + + #[test] + fn approved_gate_materializes_a_successor_only_on_the_undrivable_frontier() { + // The observed defect: RunNow + nothing runnable after an approval. + let undrivable = LoopxCliGoalSnapshot { + run_decision: LoopxCliRunDecision::RunNow, + open_todo_count: 0, + waiting_user_todo_count: 0, + ..LoopxCliGoalSnapshot::default() + }; + assert!(approved_gate_needs_materialized_successor(&undrivable)); + // A frontier with any driver stays untouched: an open todo, a user + // gate, a replan obligation, or a terminal/waiting projection all + // either drive a turn on their own or already reflect the owner's + // next move. + assert!(!approved_gate_needs_materialized_successor( + &LoopxCliGoalSnapshot { + run_decision: LoopxCliRunDecision::RunNow, + open_todo_count: 1, + ..LoopxCliGoalSnapshot::default() + } + )); + assert!(!approved_gate_needs_materialized_successor( + &LoopxCliGoalSnapshot { + run_decision: LoopxCliRunDecision::RunNow, + pending_user_gate: Some(LoopxCliUserGate { + gate_id: "todo-owner".to_string(), + message: "Owner decision required".to_string(), + ..LoopxCliUserGate::default() + }), + ..LoopxCliGoalSnapshot::default() + } + )); + assert!(!approved_gate_needs_materialized_successor( + &LoopxCliGoalSnapshot { + run_decision: LoopxCliRunDecision::RunNow, + pending_replan_obligation_id: Some("replan-1".to_string()), + ..LoopxCliGoalSnapshot::default() + } + )); + assert!(!approved_gate_needs_materialized_successor( + &LoopxCliGoalSnapshot { + run_decision: LoopxCliRunDecision::Complete, + ..LoopxCliGoalSnapshot::default() + } + )); + // The materialized text quotes the gate message under an approval + // prefix and stays inside the todo text budget. + let text = approved_action_todo_text( + "Decide whether to publish the fix: push branch codex/issue-2-fix, or ask for changes first.", + ); + assert!(text.starts_with("[P0] The owner APPROVED this decision request;")); + assert!(text.contains("push branch codex/issue-2-fix")); + assert!(text.chars().count() <= 400); + let long = "x".repeat(600); + assert_eq!(approved_action_todo_text(&long).chars().count(), 400); + } + + #[test] + fn blocked_publication_todo_matches_typed_fields_only() { + // The observed encoding (2026-09-11, dynamic-workflows-lab#2): + // a blocked advancement todo whose action_kind names the publication + // step is the agent's "publishing needs the owner" wait. + let blocked_publish = LoopxCliTodoSummary { + todo_id: "todo_pub".to_string(), + role: "agent".to_string(), + status: "blocked".to_string(), + task_class: "advancement_task".to_string(), + action_kind: "issue_fix_pr_publication".to_string(), + text: "Publish the validated one-line README greeting fix".to_string(), + }; + assert_eq!( + blocked_publication_todo(std::slice::from_ref(&blocked_publish), None), + Some(&blocked_publish) + ); + // A declined approval never resurfaces. + assert_eq!( + blocked_publication_todo(std::slice::from_ref(&blocked_publish), Some("todo_pub")), + None + ); + // Open todos, user todos, and non-publish blocked todos are not + // owner-decision waits. + let open_publish = LoopxCliTodoSummary { + status: "open".to_string(), + ..blocked_publish.clone() + }; + assert_eq!(blocked_publication_todo(&[open_publish], None), None); + let blocked_other = LoopxCliTodoSummary { + action_kind: "issue_fix_branch_validation".to_string(), + ..blocked_publish.clone() + }; + assert_eq!(blocked_publication_todo(&[blocked_other], None), None); + let user_todo = LoopxCliTodoSummary { + role: "user".to_string(), + ..blocked_publish.clone() + }; + assert_eq!(blocked_publication_todo(&[user_todo], None), None); + // The approval message quotes the todo text under the publish prefix. + let message = blocked_publication_approval_message("Publish the fix for issue #2"); + assert!(message.starts_with("[P0] Approve publishing the prepared fix:")); + assert!(message.contains("Publish the fix for issue #2")); + } + + #[test] + fn recovery_reason_distinguishes_rejected_writebacks_from_missing_receipts() { + // Observed live 2026-09-11 on xielixing/dynamic-workflows-lab#2: a + // NoDurableProgress park (three rejected typed writebacks) was shown + // to the user as "writeback verified, quota receipt missing". The + // reason must carry the actual failure mode so the console copy can + // describe it honestly. + assert_eq!( + recovery_reason_after_settlement( + LoopxAgentTurnStatus::Completed, + LoopxCliSettlementStatus::NoDurableProgress, + None, + ), + "settlement_no_progress" + ); + assert_eq!( + recovery_reason_after_settlement( + LoopxAgentTurnStatus::Completed, + LoopxCliSettlementStatus::RetryRequired, + None, + ), + "settlement_receipt_missing" + ); + // A failed agent turn reports execution failure, matching the + // non-settlement failure path's reason vocabulary. + assert_eq!( + recovery_reason_after_settlement( + LoopxAgentTurnStatus::Failed, + LoopxCliSettlementStatus::Settled, + None, + ), + "execution_failure" + ); + // A failed Goal projection keeps the generic settlement wording. + let failed_goal = LoopxCliGoalSnapshot { + run_decision: LoopxCliRunDecision::Failed, + ..LoopxCliGoalSnapshot::default() + }; + assert_eq!( + recovery_reason_after_settlement( + LoopxAgentTurnStatus::Completed, + LoopxCliSettlementStatus::Settled, + Some(&failed_goal), + ), + "settlement_unverified" + ); + } + + #[test] + fn goal_ids_are_per_item_and_attempt() { + let identity = LoopxTaskIdentity { + item: LoopxIssueKey { + repository: LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }, + kind: LoopxItemKind::Issue, + number: 42, + }, + attempt: 2, + ..Default::default() + }; + assert_eq!(goal_id_for(&identity), "bfx-owner-repo-issue-42-2"); + } + + #[test] + fn repository_recovery_includes_all_resumable_tasks() { + let repository = LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }; + let task = |state| LoopxTaskSnapshot { + identity: LoopxTaskIdentity { + item: LoopxIssueKey { + repository: repository.clone(), + kind: LoopxItemKind::Issue, + number: 42, + }, + ..LoopxTaskIdentity::default() + }, + state, + ..LoopxTaskSnapshot::default() + }; + let repository_id = repository.canonical_id(); + + assert!(is_repository_recovery_candidate( + &task(LoopxTaskState::RecoveryRequired), + &repository_id, + )); + assert!(is_repository_recovery_candidate( + &task(LoopxTaskState::Failed), + &repository_id, + )); + assert!(is_repository_recovery_candidate( + &task(LoopxTaskState::Stopped), + &repository_id, + )); + } + + #[test] + fn agent_summary_projection_is_bounded_on_character_boundaries() { + let summary = "界".repeat(MAX_AGENT_SUMMARY_CHARS + 1); + let bounded = bounded_agent_summary(&summary); + + // The bound keeps the TAIL (the structured summary fence lives at the + // end of the response), so the marker leads and the full quota of + // characters survives behind it. + assert!(bounded.starts_with("[Summary truncated by LoopX host; head cut]")); + assert_eq!(bounded.matches('界').count(), MAX_AGENT_SUMMARY_CHARS); + } + + #[test] + fn settled_task_does_not_self_requeue_after_yielding_repository() { + assert!(!should_requeue_after_settlement( + LoopxTaskState::Queued, + true, + )); + assert!(should_requeue_after_settlement( + LoopxTaskState::Queued, + false, + )); + assert!(!should_requeue_after_settlement( + LoopxTaskState::Completed, + false, + )); + } + + #[test] + fn depth_first_sticky_continues_only_for_runnable_goals() { + // Cleanly settled + still runnable: keep the slot, continue deep. + assert!(sticky_continue_after_settlement( + LoopxTaskState::Queued, + Some(LoopxCliRunDecision::RunNow), + )); + // Unknown post-settlement decision (inspection failed): continue — the + // re-drive re-inspects and parks at a gate, so this is self-correcting. + assert!(sticky_continue_after_settlement( + LoopxTaskState::Queued, + None + )); + // Cadence wait: yield the slot to the next queued issue. + assert!(!sticky_continue_after_settlement( + LoopxTaskState::Queued, + Some(LoopxCliRunDecision::Wait), + )); + assert!(!sticky_continue_after_settlement( + LoopxTaskState::Queued, + Some(LoopxCliRunDecision::WaitingForUser), + )); + // Terminal or parked states always yield. + assert!(!sticky_continue_after_settlement( + LoopxTaskState::Completed, + Some(LoopxCliRunDecision::RunNow), + )); + assert!(!sticky_continue_after_settlement( + LoopxTaskState::RecoveryRequired, + Some(LoopxCliRunDecision::RunNow), + )); + assert!(!sticky_continue_after_settlement( + LoopxTaskState::WaitingForUser, + None, + )); + } + + #[test] + fn wait_requeue_delay_follows_the_pinned_cadence_labels() { + let snapshot = |cadence: Option<&str>| LoopxCliGoalSnapshot { + scheduler_cadence: cadence.map(str::to_string), + ..LoopxCliGoalSnapshot::default() + }; + // v1.0.1 cadence labels map onto the pinned scheduler's initial + // intervals (scheduler_hint.py: active 3m, monitor floor 15m, human + // gate 30m, quiet 30m, unchanged 60m, agent-scope 10m). + assert_eq!( + wait_requeue_delay_ms(&snapshot(Some("active_work"))), + 3 * 60 * 1000 + ); + assert_eq!( + wait_requeue_delay_ms(&snapshot(Some("monitor_wait"))), + 15 * 60 * 1000 + ); + assert_eq!( + wait_requeue_delay_ms(&snapshot(Some("human_gate"))), + 30 * 60 * 1000 + ); + assert_eq!( + wait_requeue_delay_ms(&snapshot(Some("quiet_wait"))), + 30 * 60 * 1000 + ); + assert_eq!( + wait_requeue_delay_ms(&snapshot(Some("unchanged_noop"))), + 60 * 60 * 1000 + ); + assert_eq!( + wait_requeue_delay_ms(&snapshot(Some("agent_scope_wait"))), + 10 * 60 * 1000 + ); + // Label-less degraded snapshots fall back to the bounded poll. + assert_eq!( + wait_requeue_delay_ms(&snapshot(None)), + WAIT_RESCHEDULE_FALLBACK_MS + ); + // Unknown labels fall back instead of guessing. + assert_eq!( + wait_requeue_delay_ms(&snapshot(Some("future_cadence"))), + WAIT_RESCHEDULE_FALLBACK_MS + ); + } + + #[test] + fn post_settlement_gate_is_projected_before_requeue() { + let goal = LoopxCliGoalSnapshot { + state: LoopxCliGoalState::WaitingForUser, + run_decision: LoopxCliRunDecision::WaitingForUser, + pending_user_gate: Some(LoopxCliUserGate { + gate_id: "todo-owner".to_string(), + message: "Owner decision required".to_string(), + ..LoopxCliUserGate::default() + }), + ..LoopxCliGoalSnapshot::default() + }; + + let state = task_state_after_settlement( + LoopxAgentTurnStatus::Completed, + LoopxCliSettlementStatus::Settled, + Some(&goal), + ); + assert_eq!(state, LoopxTaskState::WaitingForUser); + assert_eq!( + phase_after_settlement(state), + LoopxPhase::WaitingForApproval + ); + assert!(!should_requeue_after_settlement(state, false)); + } + + #[test] + fn successful_process_lifecycle_messages_are_not_persisted_as_task_events() { + assert!(is_normal_process_lifecycle_message( + "Starting LoopX process" + )); + assert!(is_normal_process_lifecycle_message( + "LoopX process exited successfully" + )); + assert!(!is_normal_process_lifecycle_message( + "LoopX process exited with an error" + )); + assert!(!is_normal_process_lifecycle_message( + "Building a fresh LoopX custom-runner turn contract" + )); + } + + #[test] + fn resumed_tasks_with_a_goal_skip_intake_and_goal_creation() { + assert!(task_has_bound_goal(&LoopxTaskSnapshot { + goal_id: Some("goal-42".to_string()), + ..LoopxTaskSnapshot::default() + })); + assert!(!task_has_bound_goal(&LoopxTaskSnapshot::default())); + assert!(!task_has_bound_goal(&LoopxTaskSnapshot { + goal_id: Some(" ".to_string()), + ..LoopxTaskSnapshot::default() + })); + } + + #[test] + fn reconciliation_cannot_clear_an_unanswered_local_gate() { + let task = LoopxTaskSnapshot { + state: LoopxTaskState::WaitingForUser, + phase: LoopxPhase::WaitingForApproval, + pending_gate_id: Some("todo_owner_review".to_string()), + ..LoopxTaskSnapshot::default() + }; + let active_goal = LoopxCliGoalSnapshot { + state: LoopxCliGoalState::Active, + ..LoopxCliGoalSnapshot::default() + }; + assert!(preserve_unanswered_local_gate(&task, &active_goal)); + + let answered_task = LoopxTaskSnapshot { + state: LoopxTaskState::Queued, + ..task.clone() + }; + assert!(!preserve_unanswered_local_gate( + &answered_task, + &active_goal, + )); + + let completed_goal = LoopxCliGoalSnapshot { + state: LoopxCliGoalState::Completed, + ..active_goal + }; + assert!(!preserve_unanswered_local_gate(&task, &completed_goal)); + } + + #[test] + fn reconciliation_cannot_clear_an_owner_action_wait_message() { + // The owner-action park (observed live 2026-09-11, + // dynamic-workflows-lab#2 after PR #6 was opened): no typed gate id, + // but the wait message names the action the owner must take outside + // this host. Reconciliation must keep it while the goal stays active + // without a typed gate, so the UI shows the real wait instead of + // resurrecting the latest answered approval event. + let parked = LoopxTaskSnapshot { + state: LoopxTaskState::WaitingForUser, + phase: LoopxPhase::WaitingForApproval, + pending_gate_message: Some( + "LoopX is waiting for an owner action outside this host: review and merge PR #6" + .to_string(), + ), + ..LoopxTaskSnapshot::default() + }; + let active_goal = LoopxCliGoalSnapshot { + state: LoopxCliGoalState::Active, + ..LoopxCliGoalSnapshot::default() + }; + assert!(preserve_unanswered_local_gate(&parked, &active_goal)); + + // A newly projected typed gate wins over the parked message: the + // goal now carries the authoritative question. + let gated_goal = LoopxCliGoalSnapshot { + state: LoopxCliGoalState::Active, + pending_user_gate: Some(LoopxCliUserGate { + gate_id: "todo-new".to_string(), + message: "New decision".to_string(), + ..LoopxCliUserGate::default() + }), + ..active_goal.clone() + }; + assert!(!preserve_unanswered_local_gate(&parked, &gated_goal)); + + // A terminal goal clears the wait together with its state change. + let completed_goal = LoopxCliGoalSnapshot { + state: LoopxCliGoalState::Completed, + ..active_goal + }; + assert!(!preserve_unanswered_local_gate(&parked, &completed_goal,)); + } + + #[test] + fn gate_decision_applied_message_names_the_decision() { + // The applied notice must identify the exact decision the owner just + // made (observed live 2026-09-12: the approve notice only said + // "Action applied", leaving the owner without confirmation of which + // production action - publish, merge - had been released). + let approved = gate_decision_applied_message( + true, + Some("Decide whether to merge pull request 7 into main"), + ); + assert!(approved + .starts_with("Approval applied; the task will continue with the approved action: ",)); + assert!(approved.contains("merge pull request 7")); + + let rejected = gate_decision_applied_message(false, Some(" ")); + assert!(rejected.starts_with( + "Rejection applied; the task will continue without the declined action: ", + )); + assert!(rejected.ends_with("the requested owner decision")); + } + #[test] + fn independently_probed_node_never_stays_checking_when_the_engine_is_missing() { + // P0 regression: the engine handshake can fail before its own Node + // probe runs, so the controller probes Node independently. That fact + // must settle to a real status, never `Checking`. + let probe = LoopxNodeRuntimeFact { + available: false, + version: None, + minimum_version: "22.6.0".to_string(), + detail: Some("Node.js was not found on PATH".to_string()), + }; + let fact = loopx_node_environment_fact(&probe, Some(42)); + assert_eq!(fact.status, LoopxEnvironmentFactStatus::Unavailable); + assert_ne!(fact.status, LoopxEnvironmentFactStatus::Checking); + } + #[test] + fn read_only_user_gates_accept_untyped_public_content_reads() { + // Live 2026-09-12: the CLI projected a public-content read gate + // without a typed action kind; the message is the only signal the + // host gets, and reads must not park the task for owner approval. + let message = "Approve a gated read before LoopX uses GitHub issue body, comment bodies, timeline events, or raw provider payloads for gcwing/openbitfun issues_2274, so the maintainer-comment disposition can be resolved."; + assert!(is_read_only_user_gate(None, Some(message))); + assert!(is_read_only_user_gate( + Some("approve_github_issue_body_or_comment_read"), + None, + )); + assert!(is_read_only_user_gate( + None, + Some("读取 Issue 正文与维护者评论"), + )); + assert!(!is_read_only_user_gate( + None, + Some("Approve publishing the fix as a pull request"), + )); + assert!(!is_read_only_user_gate( + None, + Some("Approve a gated read, then publish the pull request"), + )); + } + #[cfg(windows)] + fn git_missing_detail_becomes_an_install_git_remediation() { + let fact = git_worktree_environment_fact( + "workspace Git command could not start: program not found", + Some(42), + ); + assert_eq!(fact.status, LoopxEnvironmentFactStatus::Unavailable); + assert_eq!( + fact.remediation_action, + LoopxEnvironmentRemediationAction::InstallGit + ); + assert!(fact.remediation.is_some()); + } + + #[test] + fn git_missing_markers_are_detected_without_a_platform_install() { + assert!(git_missing_from_detail( + "workspace Git command could not start: program not found" + )); + assert!(!git_missing_from_detail("workspace root is not writable")); + } + + #[test] + fn unrelated_workspace_failure_keeps_a_generic_git_fact() { + let fact = git_worktree_environment_fact("workspace root is not writable", Some(42)); + assert_eq!( + fact.remediation_action, + LoopxEnvironmentRemediationAction::None + ); + assert!(fact.remediation.is_none()); + } + + #[test] + fn missing_node_offers_the_app_managed_install_action() { + let probe = LoopxNodeRuntimeFact { + available: false, + version: None, + minimum_version: "22.6.0".to_string(), + detail: Some("Node.js was not found on PATH".to_string()), + }; + let fact = loopx_node_environment_fact(&probe, Some(42)); + assert_eq!( + fact.remediation_action, + LoopxEnvironmentRemediationAction::InstallNode + ); + assert!(fact.remediation.is_some()); + } + + #[test] + fn available_node_has_no_remediation_action() { + let probe = LoopxNodeRuntimeFact { + available: true, + version: Some("v24.21.0".to_string()), + minimum_version: "22.6.0".to_string(), + detail: None, + }; + let fact = loopx_node_environment_fact(&probe, Some(42)); + assert_eq!( + fact.remediation_action, + LoopxEnvironmentRemediationAction::None + ); + assert!(fact.remediation.is_none()); + } + + #[test] + fn platform_capability_is_projected_from_the_host_os() { + assert_eq!(managed_runtime_install_supported(), cfg!(windows)); + } + + #[cfg(not(windows))] + #[test] + fn missing_node_has_no_install_action_off_windows() { + let probe = LoopxNodeRuntimeFact { + available: false, + version: None, + minimum_version: "22.6.0".to_string(), + detail: Some("Node.js was not found on PATH".to_string()), + }; + let fact = loopx_node_environment_fact(&probe, Some(42)); + assert_eq!( + fact.remediation_action, + LoopxEnvironmentRemediationAction::None + ); + assert!(fact + .remediation + .as_deref() + .is_some_and(|detail| detail.contains("Windows-only"))); + } + + #[test] + fn runtime_install_slots_are_independent() { + use std::sync::atomic::Ordering; + + let slots = RuntimeInstallSlots::default(); + + // Repairing one component must not claim the others: the owner can + // start Node.js, Git and the LoopX sidecar from the same panel. + assert!(!slots + .runtime(LoopxManagedRuntimeKind::Node) + .swap(true, Ordering::AcqRel)); + assert!(!slots + .runtime(LoopxManagedRuntimeKind::Git) + .swap(true, Ordering::AcqRel)); + assert!(!slots.loopx().swap(true, Ordering::AcqRel)); + + // The same component still cannot be claimed twice. + assert!(slots + .runtime(LoopxManagedRuntimeKind::Node) + .swap(true, Ordering::AcqRel)); + assert!(slots.loopx().swap(true, Ordering::AcqRel)); + } +} diff --git a/src/crates/assembly/core/src/miniapp/loopx/mod.rs b/src/crates/assembly/core/src/miniapp/loopx/mod.rs new file mode 100644 index 0000000000..7ba2797640 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/mod.rs @@ -0,0 +1,10 @@ +mod agent_adapter; +mod controller; +mod store; +mod subscriber; +mod tool_activity; + +pub use agent_adapter::CoreLoopxAgentPort; +pub use controller::LoopxController; +pub use store::{LoopxPersistedState, LoopxStateStore, LoopxTaskRuntimeRecord}; +pub use subscriber::LoopxEventSubscriber; diff --git a/src/crates/assembly/core/src/miniapp/loopx/resources/loopx-pinned-cli-reference.md b/src/crates/assembly/core/src/miniapp/loopx/resources/loopx-pinned-cli-reference.md new file mode 100644 index 0000000000..2a52821ae0 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/resources/loopx-pinned-cli-reference.md @@ -0,0 +1,1134 @@ +# LoopX v1.0.1 pinned CLI reference (host-provided) + +> Generated from the exact pinned CLI supplied by the BitFun host. This is authoritative for LoopX behavior, commands, flags, and schemas on this machine; do not consult other LoopX source checkouts or installed versions. + + +## loopx bootstrap --help + +usage: -c bootstrap [-h] [--project PROJECT] [--goal-id GOAL_ID] + [--fork-goal FORK_GOAL] [--objective OBJECTIVE] + [--display-name DISPLAY_NAME] [--domain DOMAIN] + [--role {controller,subagent}] + [--parent-goal-id PARENT_GOAL_ID] + [--state-file STATE_FILE] [--goal-doc GOAL_DOC] + [--adapter-kind ADAPTER_KIND] + [--adapter-status ADAPTER_STATUS] + [--next-probe NEXT_PROBE] [--spawn-allowed] + [--max-children MAX_CHILDREN] + [--allowed-domain ALLOWED_DOMAIN] + [--write-scope WRITE_SCOPE] [--fine-grained] + [--execution-minimum-scale EXECUTION_MINIMUM_SCALE] + [--execution-must-include EXECUTION_MUST_INCLUDE] + [--execution-small-streak-threshold EXECUTION_SMALL_STREAK_THRESHOLD] + [--execution-outcome-marker EXECUTION_OUTCOME_MARKER] + [--execution-surface-only-hint EXECUTION_SURFACE_ONLY_HINT] + [--execution-surface-streak-threshold EXECUTION_SURFACE_STREAK_THRESHOLD] + [--execution-outcome-must-advance EXECUTION_OUTCOME_MUST_ADVANCE] + [--no-onboarding-scan] + [--onboarding-connection-validation {agent,provider-prevalidated}] + [--accept-onboarding-agent-todos] + [--begin-autonomous-advance] + [--codex-app-heartbeat {ask,yes,no}] + [--onboarding-max-commits ONBOARDING_MAX_COMMITS] + [--onboarding-max-status-paths ONBOARDING_MAX_STATUS_PATHS] + [--onboarding-max-top-level-files ONBOARDING_MAX_TOP_LEVEL_FILES] + [--force] [--preserve-todos] [--replace-state] [--dry-run] + [--no-global-sync] + +options: + -h, --help show this help message and exit + --project PROJECT Project directory to connect. + --goal-id GOAL_ID Stable goal id. Defaults to -goal. + --fork-goal FORK_GOAL + Create a new forked goal id instead of reusing an + existing global goal route. + --objective OBJECTIVE + Initial goal objective. + --display-name DISPLAY_NAME + Public display title for the goal. When omitted, a + public-safe title is derived from the objective; the + project name remains the fallback. + --domain DOMAIN Goal domain label. + --role {controller,subagent} + --parent-goal-id PARENT_GOAL_ID + Parent goal id when --role subagent. + --state-file STATE_FILE + Active goal state path, relative to project unless + absolute. + --goal-doc GOAL_DOC Primary goal document path, relative to project unless + absolute. + --adapter-kind ADAPTER_KIND + --adapter-status ADAPTER_STATUS + --next-probe NEXT_PROBE + Optional project-specific pre-tick command. + --spawn-allowed Declare that this controller may spawn child agents. + --max-children MAX_CHILDREN + --allowed-domain ALLOWED_DOMAIN + Allowed child work domain. Repeatable. + --write-scope WRITE_SCOPE + Allowed write scope such as docs/**. Repeatable. + --fine-grained Persist one-small-checkpoint-per-turn execution with + evidence-driven replanning after each completed Todo. + --execution-minimum-scale EXECUTION_MINIMUM_SCALE + Minimum delivery scale after repeated small follow- + through. + --execution-must-include EXECUTION_MUST_INCLUDE + Required delivery component. Repeatable; defaults to + artifact, validation, and state writeback. + --execution-small-streak-threshold EXECUTION_SMALL_STREAK_THRESHOLD + Repeated small-scale streak that triggers the delivery + contract. + --execution-outcome-marker EXECUTION_OUTCOME_MARKER + Classification substring that counts as primary + outcome/evidence progress. Repeatable. + --execution-surface-only-hint EXECUTION_SURFACE_ONLY_HINT + Classification substring that counts as surface-only + progress unless an outcome marker is present. + Repeatable. + --execution-surface-streak-threshold EXECUTION_SURFACE_STREAK_THRESHOLD + Surface-progress streak that triggers the outcome- + floor contract. + --execution-outcome-must-advance EXECUTION_OUTCOME_MUST_ADVANCE + Outcome/evidence floor label that future delivery must + advance. Repeatable. + --no-onboarding-scan Skip the fast first-connect repository scan and todo + candidate proposal. + --onboarding-connection-validation {agent,provider-prevalidated} + Choose who validates the project connection. The + default 'agent' may create a loopx-check Todo; + 'provider-prevalidated' records provider ownership and + omits that agent Todo. + --accept-onboarding-agent-todos + Write all proposed onboarding agent todos into the + initial active state. + --begin-autonomous-advance + Record that Codex may begin from accepted onboarding + agent todos after the quota guard permits work. + --codex-app-heartbeat {ask,yes,no} + Codex App recurring heartbeat choice for onboarding. + Default ask creates a user gate; yes/no records an + explicit operator decision for headless setup. + --onboarding-max-commits ONBOARDING_MAX_COMMITS + Maximum recent commits sampled by the fast onboarding + scan. + --onboarding-max-status-paths ONBOARDING_MAX_STATUS_PATHS + Maximum git status lines sampled by the fast + onboarding scan. + --onboarding-max-top-level-files ONBOARDING_MAX_TOP_LEVEL_FILES + Maximum top-level names sampled by the fast onboarding + scan. + --force Replace existing goal entry or state file. + --preserve-todos With --force, preserve the existing active state file + instead of replacing its todos. + --replace-state Allow replacing an existing global route for the same + goal id. Writes a global registry backup before + changing the route. + --dry-run Show planned writes without changing files. + --no-global-sync Do not merge this project registry into the shared + global registry. + + +## loopx register-agent --help + +usage: -c register-agent [-h] --goal-id GOAL_ID --agent-id AGENT_ID + [--require-new] [--execute] + +options: + -h, --help show this help message and exit + --goal-id GOAL_ID Goal id already present in the global registry. + --agent-id AGENT_ID Public-safe agent id to add. Repeatable; comma- + separated values are also accepted. + --require-new Fail when any requested id is already registered. + Fresh-agent onboarding uses this to prevent accidental + takeover; ordinary registration remains idempotent + without the flag. + --execute Write the source registry and sync it globally. Without + this flag, preview only. + + +## loopx todo --help + +usage: -c todo [-h] [--format {markdown,json}] --goal-id GOAL_ID + [--role {user,agent}] [--text TEXT] [--follow-up FOLLOWUPS] + [--todo-id TODO_ID] [--claim-operation-id CLAIM_OPERATION_ID] + [--turn-instance-id TURN_INSTANCE_ID] + [--completion-identity-key COMPLETION_IDENTITY_KEY] + [--replan-obligation-id REPLAN_OBLIGATION_ID] + [--status {open,done,blocked,deferred}] [--note NOTE] + [--evidence EVIDENCE] [--validation-command VALIDATION_COMMAND] + [--validation-label VALIDATION_LABEL] + [--validation-command-json VALIDATION_COMMAND_JSON] + [--validation-timeout-seconds VALIDATION_TIMEOUT_SECONDS] + [--reason REASON] [--authority-reason AUTHORITY_REASON] + [--task-class {advancement_task,continuous_monitor,user_gate,user_action,blocker}] + [--action-kind ACTION_KIND] [--task-domain TASK_DOMAIN] + [--capability-binding-ref CAPABILITY_BINDING_REF] + [--task-repository TASK_REPOSITORY] + [--continuation-policy {independent_handoff,same_agent_non_delivery}] + [--required-write-scope REQUIRED_WRITE_SCOPES] + [--required-capability REQUIRED_CAPABILITIES] + [--target-capability TARGET_CAPABILITIES] + [--capability-gap-status {found,fixed,real_callsite_verified}] + [--explore-result-node-ref EXPLORE_RESULT_NODE_REFS] + [--clear-explore-result-node-refs] + [--decision-scope DECISION_SCOPE] + [--required-decision-scope REQUIRED_DECISION_SCOPES] + [--decision-outcome {approve,reject,cancel}] + [--claimed-by CLAIMED_BY] + [--task-lease-idempotency-key TASK_LEASE_IDEMPOTENCY_KEY] + [--task-lease-expected-version TASK_LEASE_EXPECTED_VERSION] + [--bound-agent BOUND_AGENT] [--goal-bound] + [--blocks-agent BLOCKS_AGENT] [--clear-blocks-agent] + [--excluded-agent EXCLUDED_AGENTS] [--clear-excluded-agents] + [--global-gate] [--clear-global-gate] + [--unblocks-todo-id UNBLOCKS_TODO_ID] + [--successor-todo-id SUCCESSOR_TODO_IDS] + [--resume-when RESUME_WHEN] [--clear-resume-when] + [--target-key MONITOR_TARGET_KEY] [--cadence CADENCE] + [--next-due-at NEXT_DUE_AT] [--expires-at EXPIRES_AT] + [--watch-only] [--clear-claim] [--no-follow-up] + [--next-agent-todo NEXT_AGENT_TODO] + [--next-user-todo NEXT_USER_TODO] + [--next-user-task-class {user_gate,user_action}] + [--next-claimed-by NEXT_CLAIMED_BY] [--self-merged] + [--next-task-class {advancement_task,continuous_monitor,blocker}] + [--next-action-kind NEXT_ACTION_KIND] + [--next-task-repository NEXT_TASK_REPOSITORY] + [--next-required-capability NEXT_REQUIRED_CAPABILITIES] + [--next-continuation-policy {independent_handoff,same_agent_non_delivery}] + [--next-excluded-agent NEXT_EXCLUDED_AGENTS] + [--max-active-done MAX_ACTIVE_DONE] [--agent-id AGENT_ID] + [--from {recent-repo,issues-prs,failing-checks,todo-markers,complexity-hotspots,loopx-deferred,docs-smokes}] + [--limit TODO_LIMIT] [--thin] + [--trigger {user-requested,post-connect,no-runnable-todo,repo-changed,quality-watch}] + [--project PROJECT] [--state-file STATE_FILE] [--dry-run] + [--execute] [--provider-revision PROVIDER_REVISION] + [{add,list,claim,update,complete,supersede,archive-completed,suggest,capture-followups,project-markdown}] + +Manage goal todos. The options below are the union for every todo command; +each option's help names the commands that accept it, and unsupported +combinations fail before state is read or written. + +positional arguments: + {add,list,claim,update,complete,supersede,archive-completed,suggest,capture-followups,project-markdown} + Use add to append a checkbox todo, claim to soft-claim + by registered agent id, list to read projected todos, + update/complete/supersede to transition by todo_id, or + archive-completed to move older completed todos into + Completed Work Archive. Use suggest to generate an + agent-facing candidate todo analysis prompt without + writing state. Use capture-followups to record a + capped public-safe unclaimed follow-up batch. + +options: + -h, --help show this help message and exit + --format {markdown,json} + Output format for this subcommand. Equivalent to + global --format before the command. + --goal-id GOAL_ID Goal id whose active state should receive the todo. + --role {user,agent} Todo owner. Required for add; optional todo_id search + scope for lifecycle commands. Defaults to agent for + archive-completed. + --text TEXT Todo text. Required for add; keep it short and public- + safe enough for local status. + --follow-up FOLLOWUPS + For capture-followups, append one public-safe agent + follow-up todo. Repeat up to the requested batch. + --todo-id TODO_ID Structured todo id from status/quota, such as + todo_ab12cd34ef56. + --claim-operation-id CLAIM_OPERATION_ID + For todo claim on promoted canonical authority only, + reuse this public-safe operation id across retries. + Changed intent with the same id is rejected; receipt + replay proves historical acceptance, not current lease + ownership. Omit to retain a fresh operation id per + invocation. + --turn-instance-id TURN_INSTANCE_ID + For todo complete, bind the lifecycle receipt to the + original turn-scoped quota guard and reuse it on + retries. + --completion-identity-key COMPLETION_IDENTITY_KEY + For todo complete --no-follow-up lifecycle reentry, + reuse the exact completion identity projected by + LoopX. This is not a quota turn id and cannot be + combined with --turn-instance-id. + --replan-obligation-id REPLAN_OBLIGATION_ID + For todo add, bind one newly selected runnable + advancement successor to the exact open replan + obligation. Requires --action-kind and a stable + --target-key or --explore-result-node-ref. The Todo + write becomes the semantic receipt; no follow-up ACK + command is required. + --status {open,done,blocked,deferred} + For todo add/update, set the lifecycle status. + --note NOTE Public-safe note to attach to a lifecycle transition. + --evidence EVIDENCE Public-safe evidence pointer or short result for + complete/update. + --validation-command VALIDATION_COMMAND + Caller-approved validation command (no shell) to run + before a todo's completion commits, e.g. 'pytest -q + tests/test_x.py'. Set on `todo add`; completion runs + it independently and blocks on a non-zero exit. + --validation-label VALIDATION_LABEL + Optional public-safe label for the validation receipt. + --validation-command-json VALIDATION_COMMAND_JSON + Trusted JSON string array (argv form, no shell + parsing) for the completion validation command, e.g. + '["pytest","-q","tests/test_x.py"]'. Mutually + exclusive with --validation-command; set on `todo + add`. + --validation-timeout-seconds VALIDATION_TIMEOUT_SECONDS + Per-todo timeout for the caller-approved validation + command. Only meaningful with --validation-command or + --validation-command-json on `todo add`; must be 1-29 + so a timed-out validation still produces a typed + receipt inside the 30s outer subprocess budget. + Defaults to 20. + --reason REASON Public-safe reason for blocked/deferred/supersede + transitions. + --authority-reason AUTHORITY_REASON + For a delegated lifecycle override, record the public- + safe reason. Required when the matching + coordination.todo_lifecycle_authority grant sets + requires_reason=true. + --task-class {advancement_task,continuous_monitor,user_gate,user_action,blocker} + For todo add/update, explicitly register the routing + lane. Use advancement_task for executable delivery + work; user_gate for blocking owner/controller + decisions; user_action for non-blocking user-visible + todos; continuous_monitor and blocker are non- + executable lanes. + --action-kind ACTION_KIND + For todo add, optional public-safe action token such + as run_eval, rebuild_score, compact_blocker_writeback, + or monitor. + --task-domain TASK_DOMAIN + For agent todo add/update, declare the bounded + responsibility domain used by adaptive child + admission, such as code, docs, or validation. + --capability-binding-ref CAPABILITY_BINDING_REF + For agent todo add, persist the opaque capability + admission binding projected by a validated capability + packet. + --task-repository TASK_REPOSITORY + For agent todo add/update, declare the credential-free + Git repository identity that owns the task, such as + git:github.com/owner/repo. This selects workspace + isolation; it does not grant write permission. + --continuation-policy {independent_handoff,same_agent_non_delivery} + Closed completion/handoff policy for this todo. + action_kind remains an extensible domain token; + defaults to independent_handoff. + --required-write-scope REQUIRED_WRITE_SCOPES + For todo add/update, declare a required relative write + scope such as src/** or runners/openviking/**. Repeat + for multiple scopes. + --required-capability REQUIRED_CAPABILITIES + For todo add/update, declare an execution capability + such as shell, filesystem_write, network, + benchmark_runner, or external_evidence_poll. Repeat + for multiple capabilities. + --target-capability TARGET_CAPABILITIES + For todo add/update, declare a capability this todo is + building, repairing, materializing, or parity- + checking. On complete, pair it with --capability-gap- + status to close that lifecycle. This is not a hard + execution prerequisite. + --capability-gap-status {found,fixed,real_callsite_verified} + For agent todo add/update/complete, append an + auditable capability-gap lifecycle event. Requires + --target-capability; the todo_id is the stable gap id. + --explore-result-node-ref EXPLORE_RESULT_NODE_REFS + For todo add/update, link an explicit public-safe + Explore result node id. Repeat for multiple nodes; + analysis resolves only these links. + --clear-explore-result-node-refs + For todo update, remove all explicit Explore result + node links. + --decision-scope DECISION_SCOPE + For user_gate add/update, declare the concrete + decision as kind:granularity:scope_key, for example + direction:action:benchmark_target. + --required-decision-scope REQUIRED_DECISION_SCOPES + For agent todo add/update, declare a required decision + scope as kind:granularity:scope_key. Repeat for + multiple scopes. + --decision-outcome {approve,reject,cancel} + For todo complete on a user_gate, record the explicit + owner decision. Only approve consumes authority and + resumes linked work. + --claimed-by CLAIMED_BY + For agent todo add/claim/update, assign the soft + execution owner to a registered public-safe agent id + such as codex-main-control. This names the assignment + target, not the lifecycle actor; multi-agent lifecycle + commands still require --agent-id. User todos use + --bound-agent or --goal-bound instead. + --task-lease-idempotency-key TASK_LEASE_IDEMPOTENCY_KEY + For todo claim on promoted hard-lease authority, + atomically acquire the canonical lease and claim; for + complete and supersede, prove the execution instance + that owns the active lease. + --task-lease-expected-version TASK_LEASE_EXPECTED_VERSION + For promoted todo claim, optionally compare-and-set + the canonical lease version; for complete and + supersede, supply the active lease version when it is + effective. + --bound-agent BOUND_AGENT + For user todo add/update, bind reminder delivery and + post-response continuation to one registered agent + lane. This is not a gate. + --goal-bound For user todo add/update, explicitly bind the item to + the whole goal instead of one agent lane. + --blocks-agent BLOCKS_AGENT + For user_gate add/update, scope the gate to one + registered agent. + --clear-blocks-agent For todo update, remove the existing blocks_agent + field. + --excluded-agent EXCLUDED_AGENTS + For agent todo add/update, exclude one registered peer + from claiming or executing the todo. Repeat for + multiple peers. + --clear-excluded-agents + For todo update, remove all executor exclusions from + the todo. + --global-gate For todo add/update on role=user task-class=user_gate, + explicitly mark that the gate blocks every registered + agent. Prefer --blocks-agent or --agent-id when only + one lane is waiting. + --clear-global-gate For todo update on a user_gate, remove global_gate. In + a multi-agent goal, provide --blocks-agent in the same + update so the gate retains an explicit lane scope. + --unblocks-todo-id UNBLOCKS_TODO_ID + For todo add/update, link this todo to the blocked + todo it unblocks, for example todo_ab12cd34ef56. + Completing an exactly linked user_gate also consumes + the target required decision scopes covered by that + gate. + --successor-todo-id SUCCESSOR_TODO_IDS + For todo update/complete, link an existing successor + todo to the current todo. Repeat for multiple + successors. + --resume-when RESUME_WHEN + For deferred todo add/update, or for an open + advancement todo update paired with --successor-todo- + id, declare a machine-readable resume condition such + as todo_done:todo_ab12cd34ef56, + monitor_changed:todo_monitor123, pr_merged:#532, or + capacity_available:short_pool. monitor_changed binds + the monitor's current material-change generation and + resumes only after it advances; the waiting + advancement todo must remain status=open and pair with + an independent runnable --successor-todo-id. + --clear-resume-when For todo update, remove the existing resume condition + after its successor replan has made the todo runnable. + --target-key MONITOR_TARGET_KEY, --monitor-target-key MONITOR_TARGET_KEY + For agent todo add/update, declare a stable public- + safe execution target key. --monitor-target-key + remains a compatibility alias. + --cadence CADENCE For agent continuous_monitor add/update, declare the + monitor cadence, such as 30m, 2h, or 1d. + --next-due-at NEXT_DUE_AT + For agent continuous_monitor add/update, declare the + next due ISO timestamp; due monitor scheduling is + based on this field. + --expires-at EXPIRES_AT + For agent continuous_monitor add/update, declare the + ISO timestamp after which the monitor is no longer due + and must not catch up. + --watch-only For agent continuous_monitor add/update, declare an + intentionally unbounded liveness watch. Watch-only + monitors remain schedulable but do not drive + autonomous replan or block goal convergence. + --clear-claim For todo update, remove the soft claimed_by owner from + the todo. + --no-follow-up For todo update/complete, record a structured no- + follow-up rationale when a completed todo + intentionally has no successor. + --next-agent-todo NEXT_AGENT_TODO + For complete/supersede, atomically add or update the + next agent todo. + --next-user-todo NEXT_USER_TODO + For complete/supersede, atomically add or update the + next user todo. + --next-user-task-class {user_gate,user_action} + Required with --next-user-todo: user_gate for a + blocking owner decision or user_action for a visible + reminder that must not block the bound agent lane. + --next-claimed-by NEXT_CLAIMED_BY + For complete/supersede with --next-agent-todo, soft- + claim the successor todo for a registered agent. + Independent handoffs remain unclaimed unless + explicitly assigned, while same-agent non-delivery + continuations keep the current owner. Use --self- + merged with --evidence for an eligible same-agent + delivery. + --self-merged For todo complete, record that a small validated + change was self-merged; requires --evidence. + --next-task-class {advancement_task,continuous_monitor,blocker} + Task class for --next-agent-todo. Defaults to + advancement_task. + --next-action-kind NEXT_ACTION_KIND + Action kind for --next-agent-todo. + --next-task-repository NEXT_TASK_REPOSITORY + Credential-free Git repository identity for --next- + agent-todo, such as git:github.com/owner/repo. + --next-required-capability NEXT_REQUIRED_CAPABILITIES + Execution capability required by --next-agent-todo. + Repeat for multiple capabilities. + --next-continuation-policy {independent_handoff,same_agent_non_delivery} + Continuation policy for --next-agent-todo. + --next-excluded-agent NEXT_EXCLUDED_AGENTS + For complete/supersede with --next-agent-todo, exclude + one registered peer from claiming or executing the + successor. Repeat for multiple peers. + --max-active-done MAX_ACTIVE_DONE + For archive-completed, keep this many completed todos + in the active section. The default leaves a small + buffer below the status warning threshold. + --agent-id AGENT_ID For user todo add, mark the authoring registered agent + and bind the user response continuation to that lane; + for user_gate, the gate also blocks this agent when + --blocks-agent is omitted. For + claim/update/complete/supersede, attribute the + lifecycle actor; registered multi-agent goals require + it unless an exact linked user_gate decision_scope + supplies the typed owner/controller override. For + list/suggest, select the project agent lane. Agent + todo add intentionally does not accept this option; + use --claimed-by to assign execution, or omit both + options to leave the todo unclaimed. + --from {recent-repo,issues-prs,failing-checks,todo-markers,complexity-hotspots,loopx-deferred,docs-smokes} + For todo suggest, include a source lane for agent + analysis. Repeat for multiple lanes. + --limit TODO_LIMIT For todo suggest, maximum candidate count; values + above 5 are clamped to 5. For todo list, explicit per- + section cold-path cap: keep the top N todos of each + role section after filtering; must be an integer >= 1, + and the payload discloses the truncation via + explicit_limit. + --thin For todo list, return the explicit field-only + projection and omit detail lanes; returns at most two + items per role, and --limit can lower but not expand + that bound. + --trigger {user-requested,post-connect,no-runnable-todo,repo-changed,quality-watch} + For todo suggest, why this candidate queue is being + requested. + --project PROJECT Project root. Defaults to the registry goal repo. + --state-file STATE_FILE + Active goal state path. Defaults to the registry goal + state_file. + --dry-run Preview the active-state edit without writing. + --execute For archive-completed or project-markdown, write the + active-state edit. + --provider-revision PROVIDER_REVISION + For project-markdown, exact canonical authority + revision rendered into the Todo section markers. + + +## loopx refresh-state --help + +usage: -c refresh-state [-h] [--format {markdown,json}] --goal-id GOAL_ID + [--project PROJECT] [--state-file STATE_FILE] + [--classification CLASSIFICATION] + [--recommended-action RECOMMENDED_ACTION] + [--next-action NEXT_ACTION] + [--delivery-batch-scale {test_only,single_surface,multi_surface,implementation,single_segment,bounded_segment}] + [--delivery-outcome {surface_only,outcome_gap,outcome_progress,primary_goal_outcome}] + [--delivery-boundary {in_flight_continuation,semantic_closeout}] + [--delivery-workspace-path DELIVERY_WORKSPACE_PATH] + [--todo-id TODO_ID] + [--replan-obligation-id REPLAN_OBLIGATION_ID] + [--turn-instance-id TURN_INSTANCE_ID] + [--autonomous-replan-recorded] + [--progress-result-class {advanced,unchanged,blocked,exploration_exhausted,no_followup}] + [--progress-surface-id PROGRESS_SURFACE_ID] + [--progress-hypothesis-id PROGRESS_HYPOTHESIS_ID] + [--progress-probe-kind PROGRESS_PROBE_KIND] + [--progress-blocker-id PROGRESS_BLOCKER_ID] + [--progress-coverage-scope-id PROGRESS_COVERAGE_SCOPE_ID] + [--progress-evidence-id PROGRESS_EVIDENCE_IDS] + [--progress-coverage-complete] + [--repair-delta-kind {effective_action,interaction_contract,runnable_todo_set,user_gate,blocker,successor_or_supersede,capability_gate,monitor_target,active_state_next_action,goal_vision_patch,goal_boundary_projection,no_followup,watch_lane_continuation,exploration_exhausted}] + [--agent-vision-json AGENT_VISION_JSON] + [--vision-state VISION_STATE] + [--vision-summary VISION_SUMMARY] + [--vision-role-scope VISION_ROLE_SCOPE] + [--vision-acceptance VISION_ACCEPTANCE] + [--vision-advancement-policy {as_needed,repeat_until_closed}] + [--vision-replan-trigger VISION_REPLAN_TRIGGER] + [--vision-dreaming-policy VISION_DREAMING_POLICY] + [--vision-last-patch VISION_LAST_PATCH] + [--vision-todo-delta VISION_TODO_DELTA] + [--vision-unchanged-reason VISION_UNCHANGED_REASON] + [--agent-id AGENT_ID] + [--available-capability AVAILABLE_CAPABILITIES] + [--agent-lane AGENT_LANE] + [--progress-scope {goal,agent_lane}] + [--usage-codex-session USAGE_CODEX_SESSION] + [--usage-json USAGE_JSON] [--dry-run] + [--no-global-sync] [--suppress-external-sinks] + +options: + -h, --help show this help message and exit + --format {markdown,json} + Output format for this subcommand. Equivalent to + global --format before the command. + --goal-id GOAL_ID Goal id whose active state should be refreshed. + --project PROJECT Project root. Defaults to the registry goal repo. + --state-file STATE_FILE + Active goal state path. Defaults to the registry goal + state_file. + --classification CLASSIFICATION + Refresh run classification. Defaults to + state_refreshed. + --recommended-action RECOMMENDED_ACTION + Local-control next action. Private project refs are + allowed; inline secrets are rejected. Defaults to: + inspect refreshed active goal state and continue the + next bounded progress segment + --next-action NEXT_ACTION + Explicitly update the active state's durable ## Next + Action before appending the refresh run. Without this + flag, --recommended-action only describes the run + record. + --delivery-batch-scale {test_only,single_surface,multi_surface,implementation,single_segment,bounded_segment} + Explicit delivery scale for this refresh run; missing + scale stays unknown. Accepts canonical scales plus + single_segment/bounded_segment aliases for + single_surface. + --delivery-outcome {surface_only,outcome_gap,outcome_progress,primary_goal_outcome} + Optional explicit outcome-floor signal for this + refresh run. + --delivery-boundary {in_flight_continuation,semantic_closeout} + Typed semantic boundary for vision checkpointing. + Defaults to semantic_closeout; in_flight_continuation + is valid only for an open agent-bound Todo reporting + outcome_progress. + --delivery-workspace-path DELIVERY_WORKSPACE_PATH + Local git worktree that produced this accountable + delivery. Use when refresh-state must run from a + separate registry checkout; the local path is + validated but is not persisted. + --todo-id TODO_ID Selected Todo from the original turn-scoped quota + guard. Requires --turn-instance-id and an accountable + delivery outcome. + --replan-obligation-id REPLAN_OBLIGATION_ID + Autonomous replan obligation from the original turn- + scoped quota guard. Requires --turn-instance-id and an + accountable delivery outcome; cannot be combined with + --todo-id. + --turn-instance-id TURN_INSTANCE_ID + Stable quota guard turn id for settlement writeback. + Reuse the same value on retries. + --autonomous-replan-recorded + Mark this refresh as the explicit autonomous replan + ACK. Use only after the agent has performed and + written back the bounded replan slice. + --progress-result-class {advanced,unchanged,blocked,exploration_exhausted,no_followup} + Typed result for this bounded work slice. Semantics + come only from this enum and stable identifiers, never + from classification prose. + --progress-surface-id PROGRESS_SURFACE_ID + --progress-hypothesis-id PROGRESS_HYPOTHESIS_ID + --progress-probe-kind PROGRESS_PROBE_KIND + --progress-blocker-id PROGRESS_BLOCKER_ID + --progress-coverage-scope-id PROGRESS_COVERAGE_SCOPE_ID + --progress-evidence-id PROGRESS_EVIDENCE_IDS + --progress-coverage-complete + --repair-delta-kind {effective_action,interaction_contract,runnable_todo_set,user_gate,blocker,successor_or_supersede,capability_gate,monitor_target,active_state_next_action,goal_vision_patch,goal_boundary_projection,no_followup,watch_lane_continuation,exploration_exhausted} + Machine-visible frontier changed by this repair/replan + ACK. Repeat for multiple deltas. Without a delta, + --autonomous-replan-recorded is stored as + replan_noop/repair_noop and does not clear the + obligation. + --agent-vision-json AGENT_VISION_JSON + Path to a complete generated + goal_vision_replan_contract_v0 update. The CLI + enforces budgets; any autonomous replan that changes + durable mainline fields requires goal_path_delta_v0. + --vision-state VISION_STATE + Optional lower snake_case lifecycle state for an + inline goal_vision_replan_contract_v0 patch. Closure + aliases such as satisfied and vision_satisfied + normalize to vision_closed; custom states remain open + until explicitly closed. + --vision-summary VISION_SUMMARY + Inline bounded vision_summary for a field-level patch + merged into the current agent's latest active vision. + --vision-role-scope VISION_ROLE_SCOPE + Inline bounded role_scope for the current agent's + vision patch. + --vision-acceptance VISION_ACCEPTANCE + Inline bounded acceptance_summary for the current + agent's vision patch. + --vision-advancement-policy {as_needed,repeat_until_closed} + Whether open acceptance needs advancement only as + needed or must keep a runnable advancement frontier + until the vision closes. + --vision-replan-trigger VISION_REPLAN_TRIGGER + Inline bounded replan_trigger_summary that quota can + project as an acceptance gap. + --vision-dreaming-policy VISION_DREAMING_POLICY + Inline bounded dreaming_policy for the current agent's + vision patch. + --vision-last-patch VISION_LAST_PATCH + Inline bounded last_patch_summary for the current + agent's vision patch. + --vision-todo-delta VISION_TODO_DELTA + Compact todo delta for an inline vision patch. Repeat + for multiple deltas. + --vision-unchanged-reason VISION_UNCHANGED_REASON + Compact reason why a required vision checkpoint is + intentionally unchanged. + --agent-id AGENT_ID Registered agent id for agent-lane state refreshes. + When set, the refresh is visible in run history but + does not replace goal-level status. + --available-capability AVAILABLE_CAPABILITIES + Preserve one observed public-safe runtime capability + from the scoped quota decision. Repeatable; this + context does not grant authority or change refresh- + state write scope. + --agent-lane AGENT_LANE + Public-safe lane label for --agent-id scoped + refreshes, such as productization_frontstage. + --progress-scope {goal,agent_lane} + Refresh scope. In multi-agent goals, use agent_lane + for per-agent runnable status, or goal with any + registered peer for durable goal-level status/Next + Action. + --usage-codex-session USAGE_CODEX_SESSION + Path to the local Codex session rollout JSONL that + produced this run. Only aggregate token_count totals, + the model id, and event timestamps are read; prompts, + completions, and tool output never enter run history. + The session must be bound explicitly; when the rollout + is unknown, omit the flag and usage stays unknown. + Cannot be combined with --usage-json. + --usage-json USAGE_JSON + Inline JSON object with a provider-neutral per-run + usage measurement: input_tokens, output_tokens, + provider, model, source_snapshot_id, plus optional + cache_tokens/cost_usd/duration_ms. Must be strict + JSON; malformed, negative, or non-finite usage fails + the refresh closed. Cannot be combined with --usage- + codex-session. + --dry-run Print the refresh payload without appending. + --no-global-sync Do not refresh the shared global registry after + writing the state run. + --suppress-external-sinks + Keep enabled local projections active but suppress + configured external sink writes for this refresh. + Pending sink digests remain retryable. + + +## loopx quota --help + +usage: -c quota [-h] [--goal-id GOAL_ID] [--agent-id AGENT_ID] + [--available-capability AVAILABLE_CAPABILITIES] + [--include-detail {scheduler,agent-todos,user-todos,goal-boundary,vision,decisions,all}] + [--verbose] + [--codex-app-current-rrule CODEX_APP_CURRENT_RRULE] + [--runtime-profile {ark_managed_agent_goal,codex_app_heartbeat,codex_app_ssh_goal,codex_cli,claude_code,kunluncode,generic_cli,outer_controller}] + [-A] + [-H {ark_managed_agent,codex_app,codex_app_ssh,codex_cli,generic_cli,claude_code,local_scheduler}] + [-O {host_automation,agent_cli_loop,goal_runtime,outer_controller,none}] + [-M {interactive,isolated_headless,hosted_automation}] + [--turn-envelope] [--turn-instance-id TURN_INSTANCE_ID] + [--begin-turn] [--replan-obligation-id REPLAN_OBLIGATION_ID] + [--slots SLOTS] + [--source {adapter,controller,heartbeat,visible-goal}] + [--void-generated-at VOID_GENERATED_AT] + [--reason-summary REASON_SUMMARY] [--todo-id TODO_ID] + [--target-key TARGET_KEY] [--result-hash RESULT_HASH] + [--material-change] [--cadence CADENCE] + [--next-due-at NEXT_DUE_AT] + [--next-agent-todo NEXT_AGENT_TODO] + [--next-action-kind NEXT_ACTION_KIND] + [--next-task-repository NEXT_TASK_REPOSITORY] + [--next-required-capability NEXT_REQUIRED_CAPABILITIES] + [--next-continuation-policy {independent_handoff,same_agent_non_delivery}] + [--next-target-key NEXT_TARGET_KEY] + [--next-user-todo NEXT_USER_TODO] + [--next-user-task-class {user_gate,user_action}] + [--next-claimed-by NEXT_CLAIMED_BY] [--surface SURFACE] + [--state-key STATE_KEY] [--applied-rrule APPLIED_RRULE] + [--failed-rrule FAILED_RRULE] + [--failure-kind {host_tool_failure,timeout,rejected,unavailable}] + [--reset-token RESET_TOKEN] + [--identity-signature IDENTITY_SIGNATURE] + [--host-match-observed] [--use-current-hint] [--dry-run] + [--execute] [--record-host-poll] [--scan-root SCAN_ROOT] + [--scan-path SCAN_PATH] [--use-projection-cache] + [--write-projection-cache] + [--projection-cache-ttl-seconds PROJECTION_CACHE_TTL_SECONDS] + [--limit LIMIT] + [{status,plan,should-run,monitor-poll,scheduler-ack,scheduler-ack-current,scheduler-fail-current,spend-slot,void-slot}] + +positional arguments: + {status,plan,should-run,monitor-poll,scheduler-ack,scheduler-ack-current,scheduler-fail-current,spend-slot,void-slot} + Use status for all groups, plan for next-turn groups, + should-run for one goal, monitor-poll for no-spend + quiet poll evidence, scheduler-ack for successful + Codex App RRULE state, scheduler-fail-current to + suppress a repeated failed host update pair, spend- + slot for accounting, or void-slot for a non- + destructive accounting correction. + +options: + -h, --help show this help message and exit + --goal-id GOAL_ID Goal id to check. Required for one-goal quota + commands, including should-run, scheduler ACK/failure, + spend, and void. + --agent-id AGENT_ID Registered agent id for `quota should-run` and scoped + quota accounting commands; suppresses identity-upgrade + warnings and records the identity on appended + monitor/scheduler/spend/void events. + --available-capability AVAILABLE_CAPABILITIES + For `quota should-run`, `quota monitor-poll`, `quota + scheduler-ack`, `quota scheduler-ack-current`, and + `quota spend-slot`, declare a capability available in + this current agent environment. Repeat the same + declarations for commands that recompute should-run; + basic local shell/filesystem capabilities are assumed. + --include-detail {scheduler,agent-todos,user-todos,goal-boundary,vision,decisions,all} + Include one command-specific cold-path detail section. + For `quota should-run`: scheduler, agent-todos, user- + todos, goal-boundary, or vision. For `quota monitor- + poll`: decisions. Repeat for multiple sections or use + `all`. + --verbose Include the raw exception detail in failure payloads + for maintainer diagnosis. Off by default so the public + failure payload stays path-free. + --codex-app-current-rrule CODEX_APP_CURRENT_RRULE + Current RRULE observed from the active Codex App + heartbeat. For `quota should-run`, this reconciles + host reality with LoopX's last scheduler ACK so a + stale ACK cannot suppress a required update. + --runtime-profile {ark_managed_agent_goal,codex_app_heartbeat,codex_app_ssh_goal,codex_cli,claude_code,kunluncode,generic_cli,outer_controller} + Explicit scheduler runtime shortcut for a known host + boundary. Cannot be combined with --host-surface, + --scheduler-owner, or --execution-mode. + -A, --codex-app Compact explicit alias for --runtime-profile + codex_app_heartbeat. Cannot be combined with another + scheduler runtime or execution context. + -H {ark_managed_agent,codex_app,codex_app_ssh,codex_cli,generic_cli,claude_code,local_scheduler}, --host-surface {ark_managed_agent,codex_app,codex_app_ssh,codex_cli,generic_cli,claude_code,local_scheduler} + Host surface that will consume this scheduler + projection. + -O {host_automation,agent_cli_loop,goal_runtime,outer_controller,none}, --scheduler-owner {host_automation,agent_cli_loop,goal_runtime,outer_controller,none} + Runtime that owns the next cadence decision. + -M {interactive,isolated_headless,hosted_automation}, --execution-mode {interactive,isolated_headless,hosted_automation} + Execution mode paired with --host-surface and + --scheduler-owner. + --turn-envelope For `quota should-run`, return the additive bounded + TurnEnvelope view. The default full decision remains + unchanged. + --turn-instance-id TURN_INSTANCE_ID + Stable heartbeat settlement id for `quota should-run`, + `quota monitor-poll`, scheduler ACK/failure follow- + ups, and `quota spend-slot`. The guard persists one + idempotent receipt; reuse the same id through monitor + writeback, scheduler handoff, refresh-state, spend, + and retries. + --begin-turn For an initial Codex App `quota should-run`, mint and + persist one new Turn identity. Any explicit Todo- + selection command returned by the guard reuses the + minted identity. Cannot be combined with --turn- + instance-id or --todo-id. + --replan-obligation-id REPLAN_OBLIGATION_ID + Typed autonomous replan obligation binding for `quota + spend-slot`. Use the exact value projected by the + original turn-scoped guard; cannot be combined with + --todo-id. + --slots SLOTS Slots to account for `quota spend-slot`. + --source {adapter,controller,heartbeat,visible-goal} + Source label for `quota spend-slot`. + --void-generated-at VOID_GENERATED_AT + generated_at timestamp of the quota_slot_spent run to + void. + --reason-summary REASON_SUMMARY + Public-safe reason for `quota void-slot`. + --todo-id TODO_ID For Codex App `quota should-run`, select one currently + projected eligible action through typed same-turn + qualification; otherwise name the accountable Todo + settlement target. + --target-key TARGET_KEY + Stable monitor target key for `quota monitor-poll` + metadata writeback. + --result-hash RESULT_HASH + Public-safe result hash observed by `quota monitor- + poll`. + --material-change Mark a monitor poll as a material transition instead + of unchanged evidence. + --cadence CADENCE Monitor cadence used to compute the next due + timestamp, e.g. 30m, 2h, or 1d. + --next-due-at NEXT_DUE_AT + Explicit ISO timestamp for the next monitor poll. + --next-agent-todo NEXT_AGENT_TODO + Independent runnable advancement_task emitted when a + monitor poll uses --material-change; the + continuous_monitor remains observe-only. + --next-action-kind NEXT_ACTION_KIND + Explicit action kind for a material monitor's --next- + agent-todo successor. + --next-task-repository NEXT_TASK_REPOSITORY + Credential-free Git repository identity for a material + monitor's --next-agent-todo successor. + --next-required-capability NEXT_REQUIRED_CAPABILITIES + Execution capability required by a material monitor's + --next-agent-todo successor. Repeat for multiple + capabilities. + --next-continuation-policy {independent_handoff,same_agent_non_delivery} + Continuation policy for a material monitor's --next- + agent-todo successor. Defaults to independent_handoff. + --next-target-key NEXT_TARGET_KEY + Stable public-safe target key for a material monitor's + --next-agent-todo successor. Defaults to a + deterministic monitor-transition key. + --next-user-todo NEXT_USER_TODO + User follow-up todo to add when `--material-change` is + set. + --next-user-task-class {user_gate,user_action} + Required with monitor-poll `--next-user-todo`: + user_gate for a blocking owner decision or user_action + for a visible reminder that must not block the bound + agent lane. + --next-claimed-by NEXT_CLAIMED_BY + Registered agent id to claim the `--next-agent-todo` + follow-up. + --surface SURFACE Scheduler surface for scheduler ACK/failure commands; + defaults to codex_app. + --state-key STATE_KEY + Scheduler state key for scheduler ACK/failure + commands. + --applied-rrule APPLIED_RRULE + RRULE successfully applied by the host before `quota + scheduler-ack --execute`. + --failed-rrule FAILED_RRULE + RRULE whose host update failed before `quota + scheduler-fail-current --execute`. + --failure-kind {host_tool_failure,timeout,rejected,unavailable} + Bounded public-safe failure category for scheduler- + fail-current. + --reset-token RESET_TOKEN + Optional reset token to validate before scheduler ack. + --identity-signature IDENTITY_SIGNATURE + Optional identity signature to validate before + scheduler ack. + --host-match-observed + A bound scheduler hint has authoritative host proof + from a successful update or matching readback, so + persist its exact reset-token/identity binding. + --use-current-hint For `quota scheduler-ack`, resolve reset token and + identity signature from the latest quota should-run + scheduler hint; `scheduler-ack-current` sets this + automatically. + --dry-run Keep quota accounting or scheduler-state writes as + preview-only. This is the default. + --execute Execute the quota accounting write or no-spend + scheduler-state ack. + --record-host-poll For `quota should-run`, record a compact host poll + receipt beside the goal state file so stale-loop + projections can distinguish a live polling driver from + one that died mid-wait. + --scan-root SCAN_ROOT + Public files to scan for obvious private material. + Defaults to the LoopX install root. + --scan-path SCAN_PATH + Specific public file or directory to scan. Repeatable. + Overrides --scan-root when set. + --use-projection-cache + Read a fresh status_projection_cache_v0 snapshot + before building quota decisions. Misses and expired + snapshots fall back to full status collection. + --write-projection-cache + Write the status projection cache after a full quota + status collection. + --projection-cache-ttl-seconds PROJECTION_CACHE_TTL_SECONDS + Freshness window for --use-projection-cache. Defaults + to 120 seconds. + --limit LIMIT + + +## loopx issue-fix --help + +usage: -c issue-fix [-h] + {repository-memory-sync,promote-discovered-issue,workflow-plan,feasibility,pr-lifecycle,pr-gate-reconcile,pr-review-reconcile,pr-review-reconcile-acked,pr-review-ack,outcome,metrics,metrics-supplement,repository-snapshot,reviewer-plan,reviewer-request,reviewer-notification-drain,reviewer-feedback-inbox,acceptance-fixture,repo-branch-fixture,caller-repo-branch} + ... + +positional arguments: + {repository-memory-sync,promote-discovered-issue,workflow-plan,feasibility,pr-lifecycle,pr-gate-reconcile,pr-review-reconcile,pr-review-reconcile-acked,pr-review-ack,outcome,metrics,metrics-supplement,repository-snapshot,reviewer-plan,reviewer-request,reviewer-notification-drain,reviewer-feedback-inbox,acceptance-fixture,repo-branch-fixture,caller-repo-branch} + repository-memory-sync + Plan or explicitly execute a bounded public resource + sync through the reusable context-provider module. + promote-discovered-issue + Create or reuse a canonical public issue for an agent- + discovered defect, verify the PR closing reference, + and reconcile placeholder domain state. + workflow-plan Plan the full issue-fix workflow from public metadata + to ordered LoopX todos, validation, and PR review + packet readiness without writes. + feasibility Select exactly one fix_pr, comment_only, or + triage_only route from compact public-safe agent + observations. + pr-lifecycle Project a public PR lifecycle observation into a + successor, monitor-continuation, user-gate, or no- + follow-up transition. + pr-gate-reconcile Reconcile a merge-scoped user gate against compact + public PR lifecycle state before notifying the owner. + pr-review-reconcile + Complete one exact nonblocking PR review user_action + only after owner acknowledgement and a compact + terminal PR observation. + pr-review-reconcile-acked + Reconcile current PR review user_actions from + persisted exact owner acknowledgement bindings. + pr-review-ack Persist one typed owner acknowledgement receipt with + an exact goal/todo/agent/GitHub PR binding for later + reconciliation. + outcome Compose one public-safe issue-fix status/output + projection from existing feasibility and optional PR + lifecycle state. + metrics Compose a read-only baseline, attributable output + inventory, repository delta, and missing-data + projection from existing issue-fix domain state. + metrics-supplement Compose public-safe supplemental counts from existing + issue-fix domain state and explicit bounded event or + memory evidence. + repository-snapshot + Collect a compact public GitHub repository snapshot + for issue-fix metrics and optionally retain one + material snapshot per day. + reviewer-plan Recommend reviewers from caller-approved repository + ownership evidence without requesting external review. + reviewer-request Select the top requestable non-author reviewer and, + with explicit external-write authority, verify a + formal request or its permission-only comment + fallback. + reviewer-notification-drain + Drain one bounded batch of due reviewer notifications + from the grouped review-required state bucket, one PR + per message. + reviewer-feedback-inbox + Drain or acknowledge the generic Lark event inbox + bound to a configured issue-fix reviewer group. + acceptance-fixture Run a deterministic fix loop: failing repro, minimal + patch, focused validation, and PR-review-ready + artifact. + repo-branch-fixture + Run the fix loop through a temporary git repo issue + branch: branch, repro, patch, validation, and PR + evidence. + caller-repo-branch Prepare or execute an explicitly approved local repo + issue branch workflow without external comments, PR + creation, or merge. + +options: + -h, --help show this help message and exit + + +## loopx status --help + +usage: -c status [-h] [--format {markdown,json}] [--scan-root SCAN_ROOT] + [--scan-path SCAN_PATH] [--limit LIMIT] [--goal-id GOAL_ID] + [--agent-id AGENT_ID] + [--available-capability AVAILABLE_CAPABILITIES] + [--include-task-graph] [--use-projection-cache] + [--write-projection-cache] + [--projection-cache-ttl-seconds PROJECTION_CACHE_TTL_SECONDS] + +options: + -h, --help show this help message and exit + --format {markdown,json} + Output format for this subcommand. Equivalent to + global --format before the command. + --scan-root SCAN_ROOT + Public files to scan for obvious private material. + Defaults to the LoopX install root. + --scan-path SCAN_PATH + Specific public file or directory to scan. Repeatable. + Overrides --scan-root when set. + --limit LIMIT + --goal-id GOAL_ID Optional goal id to focus the status projection. The + default remains the global dashboard/status view. + --agent-id AGENT_ID Registered agent id for adding agent-lane next-action + projection to matching status queue items. + --available-capability AVAILABLE_CAPABILITIES + Declare a capability available in the current + execution envelope. Repeat for multiple capabilities; + capability-gated status fields remain absent by + default. + --include-task-graph Include the optional task_graph_projection_v0 on + status items. Default status output keeps this graph + on the cold path to stay inside the dashboard hot-path + budget. + --use-projection-cache + Read a fresh status_projection_cache_v0 snapshot + before running the full status collector. Misses and + expired snapshots fall back to the full collector. + --write-projection-cache + Write the collected status projection to the cache + after a full collection. + --projection-cache-ttl-seconds PROJECTION_CACHE_TTL_SECONDS + Freshness window for --use-projection-cache. Defaults + to 120 seconds. + + +## loopx start-goal --help + +usage: -c start-goal [-h] [--guided] [--project PROJECT] [--goal-id GOAL_ID] + [--display-name DISPLAY_NAME] [--agent-id AGENT_ID] + [--thread-id THREAD_ID] [--new-peer] [--cli-bin CLI_BIN] + [--host-surface {codex-app,codex-app-ssh,codex-ide-plugin,codex-cli-tui,claude-code,opencode,opencode2,traex-cli,pi,gemini-cli,cursor-agent,zcode,agy,deepseek-harness,deepseek-harness-native,ark-managed-agent,shell,other-agent}] + [--available-capability AVAILABLE_CAPABILITIES] + [--capability-route {issue-fix}] [--fine-grained] + (--goal-text GOAL_TEXT | --slash-command-arguments SLASH_COMMAND_ARGUMENTS) + [--include-command-pack-detail] + +options: + -h, --help show this help message and exit + --guided Required for now: render the guided dry-run + transaction packet. + --project PROJECT Project directory to inspect. + --goal-id GOAL_ID Goal id. Defaults to -goal. + --display-name DISPLAY_NAME + Public display title for the goal. When omitted, a + public-safe title is derived from the goal text; the + project name only remains as a fallback. + --agent-id AGENT_ID Explicit registered LoopX identity for an ongoing + session or exact user-requested takeover. When + omitted, a bound thread identity is reused when + available; otherwise new onboarding defaults to fresh + registration. + --thread-id THREAD_ID + Stable opaque host thread id used to reuse the bound + agent lane. Codex App defaults to the ambient + CODEX_THREAD_ID when available. + --new-peer Explicitly request a fresh agent identity for this + host thread. + --cli-bin CLI_BIN LoopX CLI binary name embedded in generated commands. + --host-surface {codex-app,codex-app-ssh,codex-ide-plugin,codex-cli-tui,claude-code,opencode,opencode2,traex-cli,pi,gemini-cli,cursor-agent,zcode,agy,deepseek-harness,deepseek-harness-native,ark-managed-agent,shell,other-agent} + Exact host surface that will own loop activation after + todo writeback. When omitted, start-goal returns a + read-only host selection gate. + --available-capability AVAILABLE_CAPABILITIES + Capability available in this host loop. Repeat for + multiple capabilities. + --capability-route {issue-fix} + Explicit product capability route for this goal start. + Goal text never selects a capability route. + --fine-grained Persist fine-grained planning for this goal: small + verifiable checkpoint Todos executed in coherent + evidence-driven turn slices. + --goal-text GOAL_TEXT + Exact goal text to plan before todo writeback. + --slash-command-arguments SLASH_COMMAND_ARGUMENTS + Complete visible /loopx arguments. The CLI consumes + only an optional leading --fine-grained and + --capability-route switches and treats the remainder + as goal text. Use --slash-command- + arguments='' when the value begins with --. + --include-command-pack-detail + Include the complete nested bootstrap command pack. + The default guided projection keeps the actionable + transaction and advertises this cold path. diff --git a/src/crates/assembly/core/src/miniapp/loopx/resources/loopx-pinned-issue-fix-payload-contract.md b/src/crates/assembly/core/src/miniapp/loopx/resources/loopx-pinned-issue-fix-payload-contract.md new file mode 100644 index 0000000000..8cabfe8149 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/resources/loopx-pinned-issue-fix-payload-contract.md @@ -0,0 +1,143 @@ +# BitFun host extract — LoopX issue-fix JSON payload contract + +This file is **authored by the BitFun host**, not copied from LoopX documentation. +It exists because several payload schemas that an issue-fix caller must supply are +documented **only in LoopX source code**, and the BitFun environment boundary forbids +the agent from reading a LoopX source checkout. + +Pinned revision: LoopX `v1.0.1`, commit `7f2a020b18d1b5bb00da4044403ae72ddce2d743`. +Every clause below cites the source anchor it was taken from. If the pin is bumped, +re-verify those anchors; a moved line means this extract is stale. + +--- + +## 1. `--candidate-resolution-json` (`issue_fix_candidate_resolution_v0`) + +Source: `loopx/capabilities/issue_fix/candidate_preflight.py:14`, `:25-29`, `:282-341`; +worked example in `examples/issue-fix-candidate-preflight-smoke.py:71-84`. + +**This flag may only be passed together with `--fetch-candidate-evidence`** +(`loopx/capabilities/issue_fix/cli.py:1008`). LoopX rejects the pair otherwise so that a +stale source binding cannot survive. + +### Top-level shape + +```json +{ + "schema_version": "issue_fix_candidate_resolution_v0", + "repo": "/", + "issue_ref": "", + "rows": [ ... ], + "raw_content_captured": false +} +``` + +- `repo` and `issue_ref` must match the evidence receipts (`candidate_preflight.py:282-285`). +- `raw_content_captured` must be literally `false` (`:286-287`). This is the field the live + run got wrong first: `candidate_resolution must keep raw_content_captured=false`. + +### `rows[]` — required fields + +Each row is an object with exactly these four fields (`:299-311`): + +| field | rule | +|---|---| +| `kind` | one of the three kinds below (`:299-301`) | +| `ref` | compact public-safe reference, **max 220 chars** (`:302`, `_safe_ref` `:68-72`) | +| `revision` | the source revision this row is bound to (`:309`) | +| `outcome` | must belong to that kind's outcome set (`:303`) | + +Duplicate rows are rejected (`:306-308`). + +### The `kind` vocabulary and its allowed `outcome` values + +This table is the whole point of this file — it appears in **no** LoopX markdown document. +Source: `candidate_preflight.py:25-29`. + +| `kind` | allowed `outcome` values | `revision` must equal | +|---|---|---| +| `pr_revision` | `implementation`, `not_implementation` | the **live** source revision of that PR (`:312-319`) | +| `closed_pr` | `retry_new_implementation`, `comment_only`, `skip` | the **closed** source PR revision (`:320-327`) | +| `maintainer_comment` | `non_blocking`, `comment_only`, `skip` | the comment's `updatedAt` revision (`:328-335`) | + +Any other `kind` produces `candidate_resolution.rows[N].kind is unsupported`. + +### Worked example (verbatim shape from the pinned smoke fixture) + +```json +{ + "schema_version": "issue_fix_candidate_resolution_v0", + "repo": "owner/name", + "issue_ref": "#3005", + "rows": [ + { + "kind": "pr_revision", + "ref": "pull_2999", + "revision": "<40-char head revision>", + "outcome": "implementation" + } + ], + "raw_content_captured": false +} +``` + +--- + +## 2. `--candidate-preflight-json` (`issue_fix_candidate_preflight_input_v0`) + +Source: `candidate_preflight.py:10-12`, contract emitted by `:32-65`, surfaced in the +`workflow-plan` packet at `:624` as `candidate_preflight.input_contract`. + +The contract LoopX itself projects is authoritative and machine-readable — **prefer reading +`candidate_preflight.input_contract` from the live `workflow-plan` packet over hand-writing +this**. It states: + +- `required_before_implementation: true` +- `required_evidence_fields`: `numeric_pr_evidence`, `semantic_pr_evidence`, + `maintainer_comment_evidence` (`_EVIDENCE_QUERY_SCOPES`, `:19-23`) +- each evidence receipt needs `repo`, `issue_ref`, `query_scope`, `complete`, `truncated`, `rows` +- `negative_result_rule`: **rows may be empty only when `complete=true` and `truncated=false`** +- `semantic_evidence_rule`: cross-references require an `issue_fix_candidate_resolution_v0` + bound to the current revision +- `decision_rule`: only `admitted + proceed` may start a new implementation + +Evidence query scopes (`:19-23`): + +| evidence field | `query_scope` | +|---|---| +| `numeric_pr_evidence` | `issue_specific_all_states` | +| `semantic_pr_evidence` | `issue_specific_current_revision` | +| `maintainer_comment_evidence` | `issue_specific_comment_metadata` | + +--- + +## 3. `--repository-context-json` (`issue_fix_repository_context_input_v0`) + +Source: `loopx/capabilities/issue_fix/repository_context.py:14-16`, `_INPUT_FIELDS` `:44`, +`_SOURCE_FIELDS` `:45-54`, `SOURCE_KINDS` `:19`, enums `:30-40`, contract with a +`minimal_example` at `:62-87`, validation `:117-160`. + +- Top-level fields are **exactly** `schema_version`, `repository_revision`, `sources`. + Unknown fields are rejected (`:117-160`). +- Absolute and home-relative paths are rejected. +- `freshness: current` requires `repository_revision` to be present. +- Sources whose kind is `external_expert` or `memory_retrieval` must stay `advisory`. +- Required support aspects are `change_scope`, `reproduction`, `validation`. + +The live `workflow-plan` packet projects this contract as +`repository_context_input_contract` (`workflow_plan.py:714`) — **read that instead of +constructing the shape from memory.** + +--- + +## 4. Practical rules + +1. **Read the contract LoopX projects** (`candidate_preflight.input_contract`, + `repository_context_input_contract`) out of the live packet before authoring anything. + Those two blocks are self-describing and always current for the pinned build. +2. **Only the `rows[].kind` vocabulary in section 1 is not projected anywhere.** That is + why this file exists. +3. Do **not** attempt to discover a schema by iterating against CLI refusals: the budget is + one corrective attempt, and every refusal reveals a single field. +4. If a payload you need is absent from both the live packet contracts and this file, that + is a host documentation gap — report it as a blocker with the exact CLI error text. diff --git a/src/crates/assembly/core/src/miniapp/loopx/resources/loopx-pinned-issue-fix-reference.md b/src/crates/assembly/core/src/miniapp/loopx/resources/loopx-pinned-issue-fix-reference.md new file mode 100644 index 0000000000..7f1e709e42 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/resources/loopx-pinned-issue-fix-reference.md @@ -0,0 +1,1313 @@ +# Issue-Fix Capability + +[中文](README.zh-CN.md) · [Capability index](../README.md) · +[State Kernel/domain-state case study (中文)](docs/state-kernel-domain-state-case-study.zh-CN.md) · +[Workflow contract](docs/protocols/issue-fix-workflow-contract-v0.md) · +[Discovered issue promotion](docs/protocols/issue-fix-discovered-issue-promotion-v0.md) · +[Acceptance loop](docs/protocols/issue-fix-acceptance-loop-v0.md) · +[Ark Managed Agent qualification](../../../docs/reference/protocols/ark-managed-agent-issue-fix-qualification-v0.md) · +[Reviewer recommendation](docs/protocols/issue-fix-reviewer-recommendation-v0.md) · +[Reviewer request](docs/protocols/issue-fix-reviewer-request-v0.md) · +[Reviewer notification sinks](docs/protocols/issue-fix-reviewer-notification-sinks-v0.md) · +[Lark feedback inbox](../../extensions/lark/docs/lark-event-inbox.md) + +Issue-fix is LoopX's product path for turning a public repository issue into a +small, validated, reviewable pull request and then keeping that PR moving until +its lifecycle has a clear outcome. The capability is designed for a +long-running issue-to-PR employee, not for a one-shot code generator: LoopX +keeps goal state, todos, authority, repository evidence, validation, reviewer +routing, monitors, human gates, and terminal closeout outside any single chat +turn. + +The core product outcome is a focused fix PR when the issue is suitable. A +public comment or justified triage remains useful for rejecting unsuitable +candidates or recording a concrete blocker, but it is not a substitute for the +fix-PR path when that path is feasible. + +## What LoopX Provides Underneath + +You do not need to know LoopX before using this capability. The shortest mental +model is: a coding agent can inspect and change a repository, while LoopX is the +local-first control plane that remembers what the agent is trying to achieve, +decides what may run next, exposes progress to people, and keeps the work alive +across chat turns and external waits. + +GitHub remains the source of truth for issues, code, checks, reviews, and merge +state. LoopX adds the missing employee-control layer between a host agent and +GitHub: + +| LoopX foundation | What it contributes to issue/PR fixing | +| --- | --- | +| Durable goal state | Keeps the objective, acceptance target, current status, next action, and compact outcome evidence after one model turn ends. | +| Todo ownership and routing | Separates agent work from concrete human decisions; records priority, `claimed_by`, blockers, successors, handoffs, and monitor work so two agents do not silently do the same task. | +| Kanban/status projection | Projects the same todo truth into a human-visible board or dashboard without making the board a second state machine. People can see who owns the issue, what was produced, and what is waiting. | +| Quota and scheduler policy | Uses `quota should-run` to decide whether a bounded work segment should run now, wait, repair state, or stay quiet. Unchanged polling backs off and does not count as delivery progress. | +| Authority and interaction gates | Separates technical capability from permission. Private material, public comments, push, PR creation, review requests, merge, and production actions can each require explicit recorded authority. | +| Evidence and repository context | Pins conclusions to a repository revision, source trust, freshness, repo-relative references, reproduction, and validation. Compact evidence survives; raw logs, credentials, and private bodies do not leak into public state. | +| Replan and handoff contracts | Converts CI failure, reviewer correction, missing information, or a stale branch into a runnable successor, a concrete blocker, or a scoped human question instead of losing the correction in chat. | +| Continuous monitors | Watches CI, review, mergeability, maintainer comments, stale branches, merged, and closed states; writes back only material transitions and terminates with an explicit outcome. | +| Event-backed wait and resume | Converts authoritative external transitions such as a merged PR into idempotent public-safe rollout events. Todos waiting on `resume_when=pr_merged:#123` become runnable through normal status/quota projection instead of relying on chat memory or directly executing code from a webhook. | +| Public/private boundary checks | Scans public artifacts and keeps local paths, credentials, runtime state, raw transcripts, tool logs, and private evidence out of commits and PRs. | + +The issue-fix capability composes these generic foundations into domain packets +and CLI commands. The host agent still reads code, edits the worktree, runs +tests, and performs separately authorized GitHub actions. This division is what +turns “generate a patch once” into a visible, resumable issue-to-PR employee: + +```text +public issue + -> durable goal and claimed todo + -> revision-pinned evidence and reproduction + -> focused patch and validation + -> explainable reviewer route and authority gate + -> PR monitor and material-transition replan + -> merged/closed evidence and idempotent rollout event + -> resumed successor, next issue, or explicit no-follow-up +``` + +## Product Position + +LoopX is the control plane, not the coding model or GitHub itself. + +| Layer | Responsibility | +| --- | --- | +| Host agent/runtime | Read code, reproduce the bug, edit files, run tests, and perform explicitly authorized git/GitHub actions. | +| Issue-fix capability | Build public-safe workflow, feasibility, repository-context, reviewer, validation, and PR-lifecycle packets. | +| LoopX kernel | Persist goal/todo ownership, quota, authority, evidence, monitor, replan, and human-interaction state. | +| Repository/GitHub | Remain authoritative for code, policy, CI, review, mergeability, and terminal PR state. | +| Human maintainer | Own design judgment, repository policy, sensitive/private context, and any action outside recorded authority. | + +The issue-fix packet builders do not silently publish. A host agent may create +or update a PR only when the current LoopX boundary records that authority and +repository policy allows it. Merge remains a separate decision unless it is +explicitly authorized. + +## End-To-End Design + +```mermaid +flowchart LR + I["Public issue candidates"] --> S["Selection and feasibility"] + S --> C["Revision-pinned repository context"] + C --> R["Reproduction"] + R --> F["Focused patch and regression test"] + F --> V["Layered validation"] + V --> O["Reviewer recommendation"] + O --> P["Authority-gated PR publication"] + P --> M["CI/review/mergeability monitor"] + M --> T["Merged/closed terminal closeout"] + T --> E["Idempotent rollout event"] + E --> N["Resume successor, next issue, or no-follow-up"] + H["Human judgment"] --> S + H --> O + H --> P + H --> M + LX["LoopX goal/todo/quota/evidence"] --> S + LX --> C + LX --> V + LX --> M + E --> LX +``` + +### 1. Candidate selection + +The first round should select one issue. Prefer public open issues with a +traceback, failing test, minimal reproduction, bounded change scope, and a +repository-native focused validation surface. Avoid issues that require +private data, credentials, production systems, large design debates, or broad +semantic changes. + +Every candidate should receive one explicit route: + +- `fix_pr`: reproduction and validation are credible and scope is bounded; +- `comment_only`: a public clarification or diagnosis adds value, but a safe + patch is not ready; +- `triage_only`: evidence is insufficient, scope is oversized, or following up + would not add value. + +The long-running employee's primary acceptance target is `fix_pr`; the other +routes protect quality and maintainer attention. + +### 2. Repository-grounded understanding + +The authority order is: + +1. current checkout evidence; +2. repository-scoped historical memory; +3. external expert or bot advice. + +Read repository policy, architecture, nearby source and tests, validation +commands, and recent related fixes at the pinned revision. Compact this into +`issue_fix_repository_context_input_v0`, including revision, repo-relative +source references, evidence aspect, source trust, and freshness. Memory and +expert conclusions are advisory until verified in the current checkout. + +### 3. Reproduction before modification + +Separate four outcomes instead of flattening every failure into a product bug: + +- product bug reproduced; +- test or fixture bug; +- environment/dependency failure; +- report remains under-specified or cannot currently be reproduced. + +When possible, make the existing focused test fail for the reported contract +before changing production code. Preserve compact pass/fail and command-label +evidence, not raw logs or local paths. + +### 4. Focused patch and regression proof + +Use a clean worktree and branch from the latest approved base revision. Keep +the patch small, explainable, and consistent with nearby repository patterns. +Add or adjust a focused test that would fail without the fix. Expand validation +only in proportion to risk. + +### 5. Reviewer recommendation and default request + +Reviewer selection is part of the control plane because a correct patch can +still stall when the wrong person is asked to review it. LoopX now provides: + +```bash +loopx issue-fix reviewer-plan \ + --repo-path /path/to/approved/repo \ + --repo owner/repo \ + --base-ref origin/main \ + --exclude-reviewer @pull-request-author \ + --exclude-author-name "PR Author Git Name" \ + --reviewer-sources-json reviewer-sources.json \ + --execute \ + --format json +``` + +After the PR exists, a host with standing `external_review_request` or +`publish` authority should notify the default reviewer directly: + +```bash +loopx issue-fix reviewer-request \ + --url https://github.com/owner/repo/pull/123 \ + --repo-path /path/to/approved/repo \ + --base-ref origin/main \ + --reviewer-sources-json reviewer-sources.json \ + --notification-sinks-json local-private-notification-sinks.json \ + --execute \ + --format json +``` + +The current evidence order is deliberately conservative: + +1. repository `CODEOWNERS` matches for each changed path; +2. caller-verified public maintainer maps whose most-specific path route names + a primary contact; +3. commit history for the exact changed path; +4. nearest module-directory history when a new file has no usable path + history; +5. maintainer-map fallback or cross-module contacts when no scoped route + applies or the primary contact is excluded. + +The packet ranks candidates with source kinds, reason codes, changed-path +coverage, history counts, recency, confidence, public `source_refs`, compact +matched-route evidence, and whether a GitHub handle is actually requestable. +It never captures the maintainer-map body or commit email addresses, never +records the local repo path, and `reviewer-plan` never sends a review request. +`reviewer-request` fetches the live PR author, existing review requests, +completed reviews, LoopX-marked reviewer comments, and live comments that +explicitly mention a reviewer and ask for review; excludes them +automatically; and asks the top remaining requestable candidate. It first uses +a formal GitHub review request. Only when GitHub confirms that this action lacks +permission does it fall back to one concise PR comment mentioning the same +reviewer. The command reads the PR again and verifies either provider state or +the fallback comment's semantic review intent plus public URL before claiming +success. A retry recognizes either a legacy marker or a bounded explicit review-request comment and +sends no duplicate comment; ordinary mentions and discussion do not suppress a +request. Network and unknown provider errors remain blockers rather than +triggering comments. History is +read at the base revision so feature-branch commits do not recommend the +author; `--exclude-author-name` covers unresolved git-name aliases. +The permission fallback is reviewer-facing product copy, not an internal +receipt. It names the linked issue and compact PR-title change summary, points +to the PR description for motivation, validation, and risk, and does not expose +an idempotency marker. +The workflow plan also projects an `issue_fix_pr_description_contract_v0` +template adapted from the PR-review five-block structure. Code changes add two +reviewer-context sections: the smallest key-code or pseudocode slice, and a +post-fix reproduction using the repository CLI or focused code/test surface. +Motivation, approach, concrete changes, validation, and main-branch +risk/uncovered scope remain required. An infographic is optional only for a +complex change and never replaces textual evidence. The reviewer's verdict +section remains review-only and is not authored into the PR description. + +Issue-backed PRs also carry an explicit `关联 Issue` / `Related Issues` block. +For a complete fix, the builder defaults to one standalone `Fixes #N` line per +issue (or `Fixes owner/repository#N` across repositories). For partial work it +uses `Related to #N`, which creates a normal reference without promising +automatic closure. GitHub accepts the `close`, `fix`, and `resolve` keyword +families, including their documented inflections, but LoopX normalizes them to +`Closes`, `Fixes`, or `Resolves` for stable output. Closing references require +an explicit assertion that the PR targets the default branch, because GitHub +ignores closing keywords on other base branches. The functional block is +applied after semantic preferences and PR lifecycle should verify it through +`closingIssuesReferences`. Closing keywords in commit messages can close an +issue, but GitHub does not then list the containing PR as the linked PR, so the +Issue Fix format keeps the keyword in the PR body rather than relying on commit +copy. Comments are not part of this closing contract. See GitHub's +[linked-issue contract](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue). +When a human confirms that an unresolved git display name belongs to a specific +GitHub account, `--identity-map-json` records that compact mapping as verified +identity evidence and reranks the same repository-native contribution evidence. + +`--notification-sinks-json` optionally adds a parallel reviewer channel. A +configured GitHub request and configured Lark notification are independent +obligations: LoopX attempts both, while the permission-only GitHub comment +remains a fallback for the GitHub request itself. The first Lark adapter uses an explicitly +named, project-dedicated Lark/Feishu bot profile to mention the same reviewer in +an approved group and read the message back. It rejects default/shared bot +identities, never selects a different reviewer, and never copies the local bot +profile, destination, member mapping, or raw provider response into public +state. A stable hashed receipt prevents duplicate sends across retries. + +When a connected goal explicitly enables the agent-scoped Reward Memory +experiment for `reviewer_artifact.summary` and its v1 config sets +`automation.automatic_recall=true`, `reviewer-request` invokes the shared +automatic hook and previews the application independently of secondary-sink +availability. With the flag off it performs zero provider calls. This +lets a fixer verify recall, current-artifact identity, and the proposed concise +Chinese summary with zero external writes while per-PR secondary notifications +are paused. A configured secondary notification additionally requires that +verified application receipt before sending. The caller must identify itself +with `--agent-id` and supply the summary plus its application reasoning: + +```bash +loopx issue-fix reviewer-request \ + --url https://github.com/owner/repo/pull/123 \ + --repo-path /path/to/approved/repo \ + --goal-id example-goal \ + --project /path/to/connected/project \ + --agent-id registered-fixer \ + --reviewer-summary '修复请求生命周期中的重复通知,并保留回读验证。' \ + --reviewer-summary-reasoning '当前 PR 与召回的幂等通知经验作用域一致。' \ + --execute \ + --format json +``` + +This is a thin consumer of the shared Reward Memory core, not an Issue Fix +memory implementation. It verifies the exact surface, current PR identity, +current-artifact check, memory readback, attribution digests, and non-empty +summary before the secondary send. It never infers another peer's agent id and +does not hard-code OpenViking: the experiment resolves the surface's explicit +corpus set from the goal's ignored project provider config and uses that +surface's recall profile. Compatible corpora are attempted in declared order +until the first exact hit, with one query per corpus at this function boundary; +telemetry records the bounded provider calls. It never scans unrelated project +corpora. Read +authority combines the normalized corpus read-authority +kind with `standing_policy.authority_source_ref`; it is never inferred from a +provider or repository name. A successful no-sink preview is returned as +`reviewer_artifact_reward_memory_preview` and remains read-only. The canonical +GitHub reviewer request remains fail-open and runs first; only the explicitly +configured secondary notification fails closed when this receipt is missing or +stale. + +A second, independent surface can be enabled at +`reviewer_notification.before_send`. Immediately before the configured Lark +CLI adapter would send, LoopX performs one bounded recall and accepts only one +exact, structured `hard_policy` delivery window. A passed application receipt +feeds that policy into the existing send-or-queue path; the sink continues to +own deduplication, restart-safe queue receipts, provider execution, and remote +readback. Disabled or unavailable recall, no compatible memory, and conflicting +policies all fail open to the caller's current sink policy. If neither source +provides a policy, delivery is unrestricted. This surface does not create a +generic preflight router or grant notification authority. + +Long-running goals can register the local-private sink pointer once with +`configure-goal --issue-fix-reviewer-notification-config`. Subsequent +`reviewer-request --goal-id ... --project ...` calls discover it automatically, +use explicit reader/user and sender/bot profiles without changing the machine +default, verify mapped reviewer `open_id` values with the sender app before +sending, and persist only new +`sha256:` receipts in the PR lifecycle row. If that row is missing, execute +mode auto-materializes it from a fresh compact GitHub lifecycle read before +the external notification. Restart/retry returns `already_notified`; no +notification ledger or public config path is added. + +The same local-private sink config may reference a generic +`lark_event_inbox_config_v0`. When a reviewer group is configured, issue-fix +auto-binds that inbox through `reviewer-feedback-inbox`; host collection runs +without an agent, and heartbeats periodically drain messages addressed to the +dedicated bot. A message is acknowledged only after its PR/todo/vision effect +or no-follow-up rationale is written back. + +`--reviewer-sources-json` is the bridge for repository-specific public routing +knowledge. The host reads an approved public source, such as a maintainer-map +issue or repository document, and supplies only stable source id, public URL, +trust, freshness, observation time, path-prefix/glob routes, and +primary/fallback handles. LoopX +does not fetch or persist the raw page. The output keeps the URL beside each +candidate so a maintainer can audit why that person was selected. + +`CODEOWNERS` remains the strongest repository-native signal. Commit volume is +only evidence of familiarity; it is not proof of maintainership, availability, +or review authority. See the [reviewer recommendation +contract](protocols/issue-fix-reviewer-recommendation-v0.md) for scoring, +identity, and future-signal details, and the [reviewer request +contract](protocols/issue-fix-reviewer-request-v0.md) for the external-write, +idempotency, and verification rules. Secondary delivery, dedicated-bot +isolation, local-private identity mapping, and readback are defined by the +[reviewer notification sink +contract](protocols/issue-fix-reviewer-notification-sinks-v0.md). + +### 6. PR publication and public-write boundary + +Before an external write, prepare a public-safe package containing: + +- problem and root cause; +- bounded diff summary; +- focused and expanded validation; +- risk and omissions; +- reviewer evidence; +- PR body or comment draft. + +PR creation, public comments, push, merge, and publish are external writes. +The host agent may perform only the actions covered by current boundary +authority. Reviewer notification is also an external write, but a standing +`external_review_request` or `publish` authority lets the agent perform the +formal request and its permission-only comment fallback automatically without +another user prompt. This does not authorize arbitrary comments. +Recommendation packets themselves remain read-only. + +### 7. Continuous PR lifecycle + +After a PR exists, create a `continuous_monitor` todo with a stable target and +cadence. `loopx issue-fix pr-lifecycle` projects compact public PR metadata +into one of four decisions: + +- `runnable_successor`: CI failed, review requested changes, or the branch + needs an actionable replan; +- `monitor_continuation`: checks/review are still pending or nothing material + changed; +- `user_gate`: an explicit human decision is required; +- `no_followup`: the PR is merged or closed and the monitor can terminate. + +With `--fetch-metadata`, a `CHANGES_REQUESTED` observation also reads a compact +review-response summary: thread counts, the latest changes-requested time, and +the head commit time. LoopX waits for re-review instead of creating another +patch successor only when at least one fetched thread exists, every fetched +thread is resolved, pagination is complete, and the head commit is newer than +the review. Missing, partial, or older evidence fails closed to the actionable +replan route. Review and thread bodies are never captured. + +Identical polls should not create work, consume delivery quota, or spam the +maintainer. Material transitions must produce a successor, concrete blocker, +or structured no-follow-up; the agent must not stop silently in monitor-only +state. + +When a review or public maintainer comment contains a concrete correction, the +host compacts it into `issue_fix_maintainer_correction_input_v0` and passes it +to `pr-lifecycle`. The compact input keeps only the correction kind, a public +source reference, a bounded summary, and one of: verification plus PR update +path, a concrete ambiguity question, or missing authority scopes. It never +copies the raw review/comment body. + +With `--execute-transition`, an `actionable_patch` creates exactly one +`issue_fix_maintainer_correction_patch` todo claimed by the registered agent. +`semantic_ambiguity` and `missing_authority` create a concrete user gate that +blocks that same agent. `unchanged` creates no todo. The normalized correction +fingerprint and deterministic todo text make retries idempotent, while terminal +merged/closed state still takes precedence over late feedback. + +When a connected lifecycle writeback observes `MERGED`, LoopX also appends one +repository-qualified, public-safe, idempotent `pr_merge` rollout event. This is +the event consumed by todo resume projection. A dependent todo such as +`resume_when=pr_merged:#123` then becomes `resume_ready=true` when its GitHub +`task_repository` matches the event repository; a cross-repository dependency +must use `resume_when=pr_merged:owner/repo#123`. Missing repository identity +fails closed with an ambiguity diagnostic. The next `status` / `quota +should-run` pass can select a matched todo as ordinary runnable work. +Replaying the same merged observation reuses the stable event id and creates no +second transition. + +This is deliberately event-backed rather than webhook-code coupling: + +```text +GitHub reports MERGED + -> issue-fix lifecycle persists terminal evidence + -> LoopX emits idempotent pr_merge rollout event + -> resume_when projection becomes ready + -> quota selects the successor on a later bounded turn +``` + +The merged PR does not directly execute an arbitrary callback, and the rollout +event does not grant new write authority. [LoopX PR +#1883](https://github.com/huangruiteng/loopx/pull/1883) is the implementation +and regression evidence for this contract. + +### 8. Terminal closeout and repeatability + +At merged/closed state, persist compact lifecycle evidence, close the monitor, +sync the management surface, record residual risk, and choose one of: + +- next issue selection; +- a concrete rollout/follow-up todo; +- a blocker or superseding route; +- structured no-follow-up. + +One merged PR proves a delivery slice. Repeating the loop on independent issues +tests whether the system is a durable employee rather than a scripted demo. + +## Public GitHub Signal Provider + +Issue-fix owns the body-free public GitHub probe and reply-monitor provider in +`loopx.capabilities.issue_fix.github_public`. Existing CLI callers keep using +the compatibility commands: + +```bash +loopx value-connectors github-public-probe \ + --url https://github.com/owner/repo/issues/1 \ + --fetch-metadata \ + --format json + +loopx value-connectors github-reply-monitor \ + --issue-url https://github.com/owner/repo/issues/1 \ + --after-comment-url https://github.com/owner/repo/issues/1#issuecomment-123 \ + --fetch-metadata \ + --format json +``` + +The CLI name and packet schemas remain stable. Only implementation ownership +moved: probes and monitor signals now evolve with the issue-to-PR outcome they +feed, while connector installation and generic approval planning remain in the +compatibility facade. + +## Implemented Surfaces + +| Surface | Command or path | Current responsibility | +| --- | --- | --- | +| AgentLoop host entry | `/loopx Fix `, guided start/command pack | Hand the same objective to Codex, Claude Code, or another host agent; LoopX constrains the delivery protocol without binding one model. | +| State kernel | `loopx todo`, `quota`, `refresh-state`, scheduler/monitor | Persist ownership, authority, bounded compute, replan, wait/resume, and terminal closeout. | +| OpenViking memory hook | `--repository-memory-*`, `repository-memory-sync` | Retrieve bounded advisory evidence from a stable rolling default-branch index; allow decision influence only after current-checkout verification, and authorize manual resource sync and reusable-knowledge writeback separately. | +| Semantic preference hook | `loopx semantic-preference recall`, stateless receipt | Optionally recall workspace-scoped user/reviewer preferences before a configured surface; the domain applies them, while LoopX retains only compact application evidence rather than raw memory. | +| Reward-memory experiment | `loopx configure-goal --reward-memory-config ... --reward-memory-agent ...`, `loopx reward-memory experiment-status`, `run_issue_fix_patch_planning_reward_memory`, `run_issue_fix_reviewer_artifact_reward_memory`, `run_issue_fix_reviewer_notification_automatic_reward_memory` | Default off. Allow one registered fixer lane to use an ignored provider binding and exact reviewed surfaces such as `issue_fix.patch_planning`, `reviewer_artifact.summary`, and `reviewer_notification.before_send`; OpenViking is the current provider, not a global dependency. Planning stays fail-open. Reviewer-artifact application remains previewable with no sink and zero external writes. The pre-send surface accepts only a verified structured hard-policy receipt and reuses the existing send/queue/readback path; disabled, unavailable, or empty recall adds no time restriction. The canonical GitHub request remains unaffected. | +| Generic inbound feedback | `loopx lark-inbox` collector/install/status/drain | Run a project-configured host collector independently of the agent process, durably project bounded inbound events, and require domain writeback before ACK; outbound messages remain a separate configured authority. | +| Issue-fix domain state | `loopx/domain_packs/issue_fix.py`, `issue-fix outcome` | Retain candidate preflight, feasibility, PR lifecycle, compact delivery evidence, and stable outcomes inside the existing goal rather than a parallel workflow ledger. | +| Candidate preflight | `loopx issue-fix workflow-plan --fetch-candidate-evidence --goal-id ` | Before patch planning, persist strict issue-specific evidence. Missing evidence projects `evidence_required`; cross-references, closed PRs, and maintainer comments project source-bound verification successors. Only `admitted + proceed` enters feasibility. `--candidate-resolution-json` binds compact outcomes to the current PR head or comment `updatedAt` revision; `--candidate-preflight-json` remains the provider-neutral adapter/test seam. | +| Workflow plan | `loopx issue-fix workflow-plan` | Compose body-free metadata, intake, branch plan, validation label, ordered todo previews, gates, and PR-readiness blockers. | +| Repository context | `--repository-context-json` | Pin policy, architecture, change-scope, reproduction, and validation evidence with trust and freshness. | +| Feasibility | `loopx issue-fix feasibility` | Select exactly one `fix_pr`, `comment_only`, or `triage_only` route and optionally persist compact domain state. | +| Discovered issue promotion | [`loopx issue-fix promote-discovered-issue`](docs/protocols/issue-fix-discovered-issue-promotion-v0.md) | After a real defect is reproduced during adjacent work, require open-and-closed duplicate-search evidence, create or reuse one canonical public issue under `publish` authority, verify the PR closing reference, and atomically replace the `discovered-*` placeholder so Kanban and metrics retain one case. | +| Reviewer plan | `loopx issue-fix reviewer-plan` | Rank explainable reviewer candidates from CODEOWNERS, caller-verified public maintainer maps, and changed-path/module history without requesting review. | +| Reviewer notification | `loopx issue-fix reviewer-request` | Under standing authority, exclude the live PR author and existing coverage, request the top candidate, fall back to one verified `@reviewer` comment only on permission denial, and avoid duplicates. | +| PR lifecycle | `loopx issue-fix pr-lifecycle` | Project CI, review, merge state, draft, merged, and closed signals into monitor transitions. | +| Merge-triggered resume | `pr_merge` rollout event + todo `resume_when` | Turn connected terminal merge evidence into one idempotent event so blocked/deferred successors become runnable through status/quota. | +| Maintainer correction | `loopx issue-fix pr-lifecycle --maintainer-correction-json ... --execute-transition` | Turn bounded public review feedback into one claimed patch successor, a concrete user gate, or a quiet unchanged poll. | +| Metrics projection | [`loopx issue-fix metrics`](docs/protocols/issue-fix-metrics-projection-v0.md) | Keep repository baseline separate from attributable agent output, combine existing feasibility/PR lifecycle rows with caller-supplied public snapshots, and report deltas, ratios, inventory, and missing data without another ledger. | +| Repository snapshot | `loopx issue-fix repository-snapshot` | Explicitly collect bounded public GitHub stock/flow and known issue/PR state; optionally retain only material daily changes in the existing issue-fix domain state. | +| Metrics supplement | `loopx issue-fix metrics-supplement` | Derive screened issues, triage outcomes, automatic terminal closeouts, complete-coverage first-push CI, and explicit memory evidence from existing issue-fix state; compose coverage-gated human interventions and typed capability-gap todo transitions from existing rollout evidence, while accepting a compact event batch for other lifecycle counts and preserving honest missing-data semantics. | +| Explore progress graph | `explore_graph.enabled` + material `refresh-state` | Idempotently project material issue selection, reproduction, PR publication/terminal state, capability-gap lifecycle, and todo supersession into the two delivery/capability graph lanes; update configured row/visual sinks only when their semantic digests change. | +| Acceptance fixture | `loopx issue-fix acceptance-fixture` | Prove failure-before, minimal patch, and pass-after in a deterministic fixture. | +| Git branch fixture | `loopx issue-fix repo-branch-fixture` | Exercise the same repair contract through a temporary git branch. | +| Caller repo branch | `loopx issue-fix caller-repo-branch` | Inspect an approved local repo, require a current local base snapshot before creating an issue branch, and run caller-declared validation without implicit remote refresh. | +| Content bridge | `loopx content-ops issue-fix-*` | Reuse body-free public metadata/intake boundaries. | +| Visible projection | `status`, `lark-kanban`, dashboard | Derive human-visible issue work, outcomes, gates, and Monthly Impact from the same kernel/domain state without becoming a second source of truth. | +| Projection source reconcile | `lark-kanban sync-projection --reconcile-source` | Keep normal sync non-destructive; only a caller-attested complete source snapshot may preview and explicitly retire remote orphan rows plus stale local record mappings within that exact namespace. | + +The capability module lives at `loopx/capabilities/issue_fix/`; domain-state +rows live in the existing issue-fix domain pack rather than a parallel context +ledger. + +### Automatic progress graph + +When a goal enables `explore_graph.enabled`, each material `refresh-state` +transaction composes a public-safe Explore projection from issue-fix domain +state, todo metadata, and rollout events, then runs configured sinks. Stable +result ids make retries idempotent. Poll timestamps and unchanged monitor +observations are excluded from the semantic digest, so they do not rewrite the +graph. A configured row sink advances its digest only after row/result-id +readback verifies the write. An authorized refresh returns a failed delivery +postcondition when sync/readback fails, so the closeout cannot call the remote +board current. + +If the current run is allowed to update local LoopX state but external writes +are temporarily forbidden, use `refresh-state --suppress-external-sinks`. +Canonical issue-fix/Explore projection still runs locally; configured row and +visual sink digests do not advance and remain retryable on a later authorized +refresh. The local refresh may succeed, but the unsatisfied postcondition must +become a concrete authorized-sync successor before final delivery. + +`explore_graph.enabled` and `explore_harness.enabled` are independent switches. +The graph is an operator projection and may be on while the harness remains +off; the harness is a separate opt-in worker-planning facility. LoopX still +treats issue-fix domain state and rollout events as facts, while the graph +presents two connected stories even between PRs: repository delivery and +reusable agent capability improvement. + +The canonical Base rows and owner-facing Docx stage boards are separate +configured sinks. A project may place the Docx as a root-level resource in the +same Base as the Kanban, then register its first whiteboard and Docx token with +`loopx explore feishu-visual-configure`. Every bounded Evidence Stage gets a +matching document section and independent whiteboard. The issue-fix projection +groups PR delivery and LoopX capability nodes into two lanes on each stage and +draws their real cross-lane relations; a single-lane project remains a natural +single-lane board. Capacity is configurable from 10 to 20, defaulting to 14. +Automatic sync records independent row and visual digests, so a successful row +update can never be reported as visual publication. A failed stage-board update +remains runnable and retries without rewriting unchanged Nodes, Edges, or +Findings. + +## Truth And Evidence Model + +### Revision-pinned repository context + +Repository context should answer: + +| Question | Required evidence | +| --- | --- | +| What revision is authoritative? | Full base revision and branch relationship. | +| What can change? | Repo-relative source/test references and nearby patterns. | +| How is the issue reproduced? | Focused command or compact observed contract. | +| How is the fix validated? | Repository-native focused validation and risk-based expansion. | +| Which source is trusted? | Repository policy/current code first; memory/expert sources marked advisory. | +| Is the evidence fresh? | Revision or timestamp tied to the current checkout. | + +### Public-safe evidence + +Packets preserve compact classifications and references. They do not preserve: + +- raw issue/comment bodies by default; +- raw validation, git, provider, or expert output; +- local absolute paths; +- credentials or private material; +- transcript/tool capture or automatic memory writeback without an approved + isolation boundary. + +### Environment vs product attribution + +An unavailable dependency, killed process, or missing service is environment +evidence. It may block a validation surface without refuting the product bug. +Conversely, a failing legacy test does not prove the new patch caused the +failure; compare the pinned base and changed hunks before attribution. + +## Reviewer Routing Contract + +The reviewer recommendation layer separates three concepts: + +1. **ownership evidence**: CODEOWNERS, caller-verified public maintainer maps, + and path/module contribution history; +2. **review recommendation**: explainable ranked candidates; +3. **review request**: a default post-PR action governed by repository policy + and explicit or standing boundary authority. + +Current scoring gives CODEOWNERS matches dominant weight. A current verified +maintainer-map primary contact ranks above history-only familiarity, while map +fallback contacts rank below primary routing. Trust and freshness reduce map +weight. A new file falls back to its nearest module directory only when no +non-excluded exact-path history is usable. The packet exposes matched routes, +source links, and reason codes instead of presenting a score as authority. + +The default policy requests one top requestable candidate when authority is +active. Existing requested or completed review counts toward that limit. The +request is complete only after provider readback confirms it. + +Important safeguards: + +- fetch and exclude the live PR author, existing reviewers, and explicitly + unavailable reviewers; +- do not expose commit email addresses; +- do not treat bots, anonymous identities, or unresolved names as requestable; +- cap candidates and show path coverage; +- retain public source references while rejecting local/private source URLs and + raw maintainer-map bodies; +- keep team handles distinct from individual handles; +- respect required-review and branch-protection policy outside the ranking; +- never infer merge authority from reviewer familiarity. + +Planned signals, added only with real call sites and public-safe evidence: + +- automatic discovery of checked-in package/module maintainer metadata beyond + caller-supplied source packets; +- recent review participation and accepted-review history; +- reviewer load, stale request detection, and fallback routing; +- bus-factor/risk hints when one person dominates a critical module; +- GitHub identity resolution for public git authors without noreply handles; +- explicit repository allow/deny lists and team membership verification. + +## Human Interaction Model + +Humans should be interrupted for decisions, not routine progress. Typical +concrete user gates are: + +- private reproduction material or credentials are required; +- architecture or behavior scope is genuinely ambiguous; +- repository policy requires a specific reviewer or owner approval; +- public write authority is missing; +- maintainer feedback changes the intended behavior; +- merge or production authority is not recorded. + +CI pending, unchanged monitor polls, routine reviewer evidence collection, and +repository-native focused validation remain agent work. A visible Kanban can +project todo ownership, status, evidence, blockers, and outputs without becoming +a second source of truth. + +## Public OpenViking Usage And Evidence + +OpenViking is both the public repository used for the first sustained issue-fix +pilot and an optional repository-memory provider behind the generic LoopX +context-provider boundary. The integration does not make OpenViking the source +of truth for a patch: current checkout source and tests still outrank retrieved +knowledge. + +### Three OpenViking knowledge lanes + +The integration keeps these lanes separate: + +| Lane | What is stored and read | How Issue-Fix may use it | +| --- | --- | --- | +| Rolling repository resources | A low-frequency watched public default branch for architecture, modules, files, and current patterns. | Advisory navigation only; every used hit is re-read from the current checkout before it can influence reproduction, scope, patch, or validation. | +| Revision-stamped learning cards | Compact reusable knowledge learned while fixing a PR: symptom, root cause, violated invariant, repair pattern, validation, observed revision, and applicability boundary. | Historical hypotheses that may be stale; retrieval alone has zero authority, and decision influence is recorded only after current-checkout confirmation. | +| Workspace-scoped user memory | Stable reviewer/user preferences such as PR language, section structure, and response style. | Recalled only for configured surfaces such as `issue_fix.pr_description`; raw semantic content stays with the provider and LoopX writes a stateless hashed receipt through existing evidence/state. | + +These lanes are not interchangeable. Repository resources answer “where and +how does current public `main` work?”, learning cards answer “what did a prior +fix teach at a named revision?”, and user memory answers “how should this +reviewer-facing artifact be presented?”. + +Representative public issue/PR cases now cover independent code paths: + +| Public case | Focused outcome | Capability evidence | +| --- | --- | --- | +| [issue #3102](https://github.com/volcengine/OpenViking/issues/3102) → [merged PR #3115](https://github.com/volcengine/OpenViking/pull/3115) | Send `peer_id` for OpenClaw session messages. | First end-to-end fix, focused validation, publication, review, merge monitor, terminal closeout, and Kanban outcome. | +| [issue #3090](https://github.com/volcengine/OpenViking/issues/3090) → [merged PR #3121](https://github.com/volcengine/OpenViking/pull/3121) | Accept sparse indexed rerank results. | A second independent module, repository-native reviewer routing, review notification fallback, and repeated lifecycle handling. | +| [issue #3124](https://github.com/volcengine/OpenViking/issues/3124) → [merged PR #3148](https://github.com/volcengine/OpenViking/pull/3148) | Show configured VLM identity before usage telemetry exists. | Reusable knowledge distilled from a validated outcome and recovered by a future-style symptom query without falsely claiming decision influence. | +| [issue #3152](https://github.com/volcengine/OpenViking/issues/3152) → [PR #3176](https://github.com/volcengine/OpenViking/pull/3176) | Anchor user-scoped nested resource writes at the direct parent. | First fresh-issue rolling-index dogfood with decision influence and staleness recorded separately from retrieval volume. | + +An earlier learning-card validation used a revision-scoped public +`viking://resources/.../` namespace. After the #3148 delivery +commit was proven to be an ancestor of the pinned revision, LoopX wrote one +`issue_fix_reusable_knowledge_input_v0` fact containing symptom, reproduction, +root cause, violated invariant, repair pattern, focused validation, and +applicability boundaries. A query equivalent to “configured model missing from +status when usage telemetry is empty” returned the knowledge overview and body; +an exact read recovered the causal and boundary fields. That proves +discoverability, not future patch value, so decision influence remains zero +until a different issue actually uses the result. + +The first fresh-issue rolling-index dogfood was deliberately mixed rather than +reported as a blanket success. For issue #3152, a symptom/module query located +the relevant source and nearby tests, and a later validation query recovered +the focused test surface. A causal query was weak and did not determine the +patch. Every used locator was re-read and confirmed in the current checkout at +revision `5bfa9b617ecff478f825ca435a35bc4222b30582`; the reproduction and code +change were derived from that checkout. The resulting accounting is therefore: +useful `change_scope` and `validation` influence, zero memory patch authority, +and no stale result allowed into the compact repository context. This measured +positive-but-mixed result keeps rolling-main retrieval optional and fail-open +until repeated independent issues show stronger value. + +The pilot has also produced generic LoopX fixes: [PR +#1784](https://github.com/huangruiteng/loopx/pull/1784) established early +control-plane groundwork, [PR +#1883](https://github.com/huangruiteng/loopx/pull/1883) made merged PR evidence +resume dependent todos, and [PR +#1887](https://github.com/huangruiteng/loopx/pull/1887) separated reusable +repository knowledge from audit-only delivery outcomes. Later slices added the +provider-neutral semantic-preference hook in [PR +#1991](https://github.com/huangruiteng/loopx/pull/1991), independent automatic +Explore Graph activation in [PR +#1995](https://github.com/huangruiteng/loopx/pull/1995), and the generic +host-managed Lark event collector lifecycle in [PR +#2000](https://github.com/huangruiteng/loopx/pull/2000). The merged LoopX +revision and focused smokes, not the pilot narrative, remain authoritative. + +## Roadmap + +### Current stage + +- public metadata and route selection; +- repository-context provenance; +- deterministic and caller-repo repair artifacts; +- focused validation evidence; +- reviewer recommendation from CODEOWNERS, public repository-declared routing + sources, and repository-native contribution evidence; +- authority-gated, idempotent reviewer notification with formal-request-first, + permission-only comment fallback, and PR readback; +- PR lifecycle projection and provider-neutral maintainer-correction succession; +- idempotent `pr_merge` event projection and todo `resume_when` recovery; +- issue/outcome Kanban projection, repository snapshots, attributable impact + metrics, and `Monthly Impact` rows; +- rolling-default-branch OpenViking retrieval, one fresh-issue measured + dogfood, and explicit reusable-knowledge writeback with honest + decision-influence accounting; +- an explicit, default-off `build_issue_fix_pr_description()` boundary for + reviewer-facing descriptions. When configured, it performs at most one + `issue_fix.pr_description` recall, passes results only to a caller-supplied + applier, preserves the base description on fail-open or unattributed changes, + and returns a stateless compact receipt for existing evidence/state writeback. + Independently, its deterministic issue-reference block runs after semantic + prose: complete fixes use `Fixes`, partial work uses `Related to`, and closing + metadata requires explicit default-branch targeting; +- goal-scoped `explore_graph.enabled` projection at material refresh boundaries, + independent from `explore_harness.enabled`, with separate row and visual sink + digests; +- generic host-managed Lark inbound collection with install/status/health and + durable inbox drain/ACK; domains configure routing, while outbound remains a + separate authority; +- LoopX todo/quota/monitor/Kanban integration through the host agent. + +### Next stage + +- trigger the goal-default reviewer request directly from PR-ready transitions; +- resolve public GitHub identities and repository teams without leaking email; +- make publication authority visible per external action; +- make unchanged lifecycle observations physically idempotent everywhere; +- repeat two-stage repository-memory retrieval on independent fresh issues and record + confirmed/refuted/stale results plus concrete reproduction, scope, patch, or + validation influence; +- add revision-lineage supersession and stale quarantine for reusable knowledge; +- add a reusable terminal acceptance report across repeated issues. + +### Longer-term stage + +- multi-repository issue portfolios with bounded concurrency; +- maintainer preference learning from public accepted/rejected outcomes; +- reviewer load balancing and bus-factor awareness; +- decide packaged-default memory behavior only after repeated fresh-issue + decision influence with no harmful stale guidance; +- Open Knowledge Format interoperability after the repository-context contract + stabilizes; +- bounded multi-repository reporting and portfolio rollups over the implemented + daily snapshot and Monthly Impact projection. + +## Success Metrics + +Track outcomes, not agent activity: + +- selected issues that reach a focused PR; +- focused PRs accepted or merged; +- failure-before/pass-after proof rate; +- unrelated regression rate; +- time from issue selection to review-ready and terminal state; +- number and type of human interventions; +- reviewer recommendation acceptance/override rate; +- unchanged monitor polls skipped; +- public/private boundary incidents; +- LoopX generic gaps fixed or converted into concrete claimed todos. + +`loopx issue-fix metrics` is the read-only reporting seam for these measures. +The period-start repository snapshot describes repository stock only; agent +output starts at zero and is attributed from the goal's existing feasibility +and PR lifecycle rows. The current public snapshot supplies repository flow and +may refresh current PR/issue state without rewriting lifecycle history. Optional +supplement counts cover evidence that is not yet native to those rows, such as +human interventions, first-push CI, capability deltas, and memory leverage. +Absent evidence is emitted as `not_available` plus a reason code, never as zero. +Memory impact deliberately separates `memory_retrievals`, +`memory_verified_decision_influence`, `memory_verified_patch_influence`, and +`memory_stale_results`; retrieving or confirming a result does not by itself +prove that it changed an issue-fix decision. +The same packet exposes stable `impact_rows`; the generic Lark sink maps them to +the `Monthly Impact` view with baseline, current, delta, ratio lineage, source, +freshness, and missing-data columns. Capability impact keeps found, fixed, and +real-callsite-verified gaps as separate rows so delivery volume is not confused +with product-path proof. + +## Conversational `/loopx` Entry + +On a host with the LoopX slash entry, start the long-running goal directly: + +```text +/loopx --capability-route issue-fix Fix https://github.com/owner/repo/issues/123 +``` + +For a manually integrated host, run `loopx bootstrap-command-pack --project .` +and pass the same complete arguments once through +`loopx start-goal --guided --project . --slash-command-arguments="..."`. +Typed callers that already own separate fields may instead pass +`--capability-route issue-fix` with `--goal-text`; the CLI owns route parsing in +both forms. + +The explicit route switch does not bypass issue selection, authority, or +validation. Without it, goal text never activates issue-fix. With it, the +guided transaction creates the goal/todo/host-loop route from which the +capability-owned admission commands below can be executed. + +## Feasibility Decision + +`loopx issue-fix feasibility` selects exactly one of `fix_pr`, `comment_only`, +or `triage_only`. A `fix_pr` decision requires bounded change scope plus a +named reproduction and validation surface. The compact decision belongs in the +existing issue-fix domain state before writing todos for the chosen route; it +does not create a parallel workflow ledger. + +## Repository Context + +Both workflow planning and feasibility accept +`--repository-context-json `. The input must pin the +current revision and keep source references repo-relative. Current checkout +evidence remains authoritative; memory and expert conclusions stay advisory +until verified. The public +[OpenViking pilot handoff](docs/openviking-pilot-handoff.md) shows how the real +pilot applies that evidence order without introducing a repository-specific +control path. + +They also accept either `--repository-memory-json +` or a configured context provider. The +provider path is deliberately layered: the reusable LoopX context-provider +module owns OpenViking CLI/version/service preflight, bounded explicit +`search -> read`, time/result caps, fail-open errors, and authority-gated +resource sync. Issue-fix owns the domain query, stable repository scope, +mapping retrieved resources back to repo-relative files, and exact current +checkout verification. There is no repository-name special case. + +Set `LOOPX_ISSUE_FIX_REPOSITORY_MEMORY_PROVIDER_CONFIG` to a local-private +`issue_fix_repository_memory_provider_config_v0` file, or pass +`--repository-memory-provider-json`. When the configured provider, public +scope, current revision, and caller-approved checkout are available, +`workflow-plan` and `feasibility` run the provider by default. An explicit +`--repository-memory-json` still overrides the environment default. LoopX +hashes provider references, keeps every memory source advisory, allows patch +influence only for canonical-text exact matches or parser chunks whose +non-empty lines match the current checkout at least 98% (transport line +endings and one terminal newline are normalised), and +persists only the compact hook projection in the existing repository context. +Unverified hits contribute counts only; their summaries are not persisted. +Provider unavailability, empty retrieval, or a missing checkout is fail-open; +raw memory bodies, automatic transcript capture, private namespaces, +credentials, and provider config paths are never retained. + +Set `repository_identity` when the provider scope belongs to one canonical +repository. LoopX normalises that identity and the checkout's Git `origin` +before any provider call. A missing or different origin produces a compact +`repository_identity_unavailable` or `repository_identity_mismatch` result: +read-only Issue Fix delivery fails open without memory, while resource sync +and validated-outcome writeback remain blocked with zero provider writes. +Only identity digests enter provider receipts. A stale `pinned` revision is +handled the same way for retrieval (`provider_revision_mismatch`) instead of +turning the optional provider into a whole-workflow exception. + +The default long-running setup uses one stable provider-managed index for the +public default branch. The current checkout revision is supplied by the +issue-fix caller for verification; it is not encoded into the provider scope: + +```json +{ + "schema_version": "issue_fix_repository_memory_provider_config_v0", + "enabled": true, + "provider": "openviking", + "namespace": "public-repository", + "visibility": "public", + "repository_identity": "git:github.com/owner/repo", + "revision_policy": "rolling_default_branch", + "scope_ref": "viking://resources/public-repository/owner-repo/main", + "max_results": 3, + "timeout_seconds": 15, + "sync_timeout_seconds": 180, + "resource_references": ["src/module.py", "tests/test_module.py"], + "service_ownership_receipt_path": ".loopx/context-provider-service.json", + "writeback_enabled": false, + "writeback_scope_ref": "viking://resources/public-repository/owner-repo/outcomes/", + "workspace_scope": "owner-repo", + "peer_scope": "issue-fix-agent" +} +``` + +The provider owns refresh cadence. For OpenViking this can be a low-frequency +native full-repository watch on public `main`: the first import builds the +index, and later runs reconcile the same stable target. LoopX does not derive a +new resource scope per checkout, persist an active revision, or block retrieval +on an activation receipt. A hit from the rolling index remains advisory: LoopX +maps it to a repo-relative file and verifies it against the current checkout +before it can influence reproduction, change scope, patch, or validation. +Unverified or stale hits remain counts only. + +Retrieval and a provider-owned watch do not need a LoopX process lease. A +LoopX-triggered rolling sync is different because it can start a long external +write from a short-lived agent host. Before that write, LoopX requires a local +`context_provider_service_ownership_receipt_v0` from a persistent external +service or supervisor. The receipt names the provider, an opaque service +identity, its generation, and a live process id. LoopX reads it before and +after the sync, never publishes its path or process id, and blocks with zero +writes when ownership is absent. If the generation or process changes during +the call, the result is `restart_detected_no_resume`: completed or pending +writes and elapsed time remain recorded as an additional attempt rather than +being reported as resumed progress. This contract is provider-neutral and +does not make LoopX a provider process manager. + +`pinned` remains available for an intentionally immutable corpus. In that +compatibility mode, `repository_revision` must match the caller checkout and +the revision must appear in `scope_ref`: + +```json +{ + "schema_version": "issue_fix_repository_memory_provider_config_v0", + "enabled": true, + "provider": "openviking", + "namespace": "public-repository", + "visibility": "public", + "repository_identity": "git:github.com/owner/repo", + "revision_policy": "pinned", + "scope_ref": "viking://resources/public-repository/owner-repo/", + "repository_revision": "", + "resource_references": ["src/module.py", "tests/test_module.py"] +} +``` + +Resource indexing is intentionally separate from retrieval. Use +`loopx issue-fix repository-memory-sync` to preview a bounded set of +repo-relative public files only for an explicit manual sync; add `--execute` +only after the provider-resource write is authorized. The rolling default path +normally relies on the provider watch instead. A transport failure after an +explicit provider commit is reconciled by bounded target readback before any +retry. Retrieval and resource sync use separate bounded timeouts because +semantic indexing can legitimately take longer than read-only search. + +Validated-outcome writeback is a separate, default-off hook. It runs only when +the caller explicitly adds `--write-repository-memory`, the local provider +config independently sets `writeback_enabled: true`, delivery evidence says +`completed`, validation says `passed`, the delivery evidence has a stable +`recorded_at`, the outcome revision matches the configured public resource +scope, and `--repo-path` proves with git that delivery `commit_ref` is an +ancestor of that pinned revision. Divergent, missing, or unresolved commits +block before the provider is called. Squash flows should record the final +merge/squash commit, not a superseded feature-branch commit. The checkout path +and raw git output are never retained. LoopX writes one distilled fact containing +revision, provenance, freshness, public outputs, risks, a stable supersession +key, and explicit workspace/peer scopes. A content hash selects the immutable +target, so an identical retry reads and accepts the existing fact without a +second write; conflicting content stops instead of overwriting. Raw +transcripts, tool logs/results, expert answers, credentials, private material, +and captured local paths are rejected. The provider packet retains only opaque +refs and compact receipts. + +An outcome without `reusable_knowledge` remains an audit fact: it proves what +was delivered, but it is not promoted as patch guidance. The compatibility +`issue_fix_reusable_knowledge_input_v0` contract remains available for existing +callers. New terminal outcomes should use +`issue_fix_repository_learning_card_input_v0`; LoopX accepts it only after the +issue-fix stage is merged, comment-published, or triage-complete, and writes it +to the separate `repository-learning-cards` collection. Both contracts require: + +- a searchable symptom signature and a focused reproduction contract; +- the checkout-verified root cause and violated invariant; +- the repair pattern, focused validation contract, and repository-relative + verification references; +- explicit applicability and non-applicability boundaries. + +A repository learning card additionally requires explicit confidence, +repo-relative affected modules, bounded invalidation conditions, a +revalidation contract, and `current_checkout_verification_required: true`. +The stored card combines those fields with the source revision, outcome +`observed_at`, public evidence URLs, validation result, commit, and provenance +already enforced by the writeback envelope. Writeback also stores SHA-256 +digests of the cited verification references, never their raw contents. On +retrieval, LoopX exposes only bounded card metadata and marks the hit confirmed +when every cited file still has the same digest in the current checkout; +missing or changed references leave it unverified. The card is therefore +searchable as a historical hypothesis but never self-authorizing: retrieval +starts with zero decision influence, and a later issue must inspect the stated +invalidation conditions and complete the revalidation contract before +recording reproduction, scope, patch, or validation influence. + +This distinction prevents PR titles, changed-file lists, and passing-test +labels from being mistaken for reusable diagnosis. Confirmation against the +current checkout also does not by itself prove value. A retrieval records +decision influence only when it names the concrete decision it changed +(`reproduction`, `change_scope`, `patch`, or `validation`); provider retrieval +alone records zero influence. + +Use retrieval at three bounded points. Before diagnosis, search by symptom and +module to discover candidate incidents. After reproducing locally, search by +the observed causal path or invariant and confirm or refute each hit in the +current checkout. Before closeout, search the changed module and invariant for +prior validation surfaces and negative boundaries. Repository source, tests, +and current documentation remain authoritative throughout. + +Do not write whole source files, raw issue or PR discussions, transcripts, +tool output, unverified hypotheses, reviewer identity mappings, or LoopX +control-plane state as reusable repository knowledge. Current source belongs +in the rolling repository resource index; reviewer routing comes from live +repository ownership signals; LoopX operating lessons remain in LoopX state. + +The OpenViking adapter deliberately uses deterministic `viking://resources/` +writeback for this first contract. It does not call experimental `ov +add-memory`, because that command creates a fresh session and currently accepts +no idempotency key. Conversation/session capture therefore remains out of +scope and requires a separate owner decision even when validated-outcome +writeback is enabled. + +Default enablement is an evidence decision rather than an installation side +effect. A project should first dogfood the hook across several independent +issue/context runs and a restart boundary. Make it a packaged default only +when it repeatedly changes a concrete issue-fix decision with novel, +checkout-verified evidence and without stale, misleading, or boundary-unsafe +retrieval. Retrieval count alone is not success; otherwise keep it explicit +opt-in with the same fail-open behavior. + +## PR Lifecycle Monitor + +After publication, `loopx issue-fix pr-lifecycle` and a `continuous_monitor` +todo keep CI, review, maintainer correction, mergeability, stale branch, and +terminal status visible. Publication, review requests, merge, and access to +private material remain explicit gates. Each material transition must yield a +`runnable_successor`, concrete blocker, or structured no-follow-up; unchanged +polls remain quiet and do not spend delivery quota. + +Pass `--issue-ref` when persisting PR lifecycle state. This explicit public-safe +link lets the outcome read model join the PR to its issue without guessing from +branch names, titles, or text. + +To convert bounded feedback into durable work, supply a compact correction and +explicitly execute the transition: + +```bash +loopx issue-fix pr-lifecycle \ + --url https://github.com/owner/repo/pull/123 \ + --issue-ref issues_100 \ + --metadata-json pr-metadata.json \ + --maintainer-correction-json correction.json \ + --goal-id issue-fix-goal \ + --project /path/to/approved/repo \ + --claimed-by issue-fix-agent \ + --execute-transition \ + --format json +``` + +The correction source is provider-neutral: any public HTTPS or repo-relative +reference may be used, while the current PR monitor remains the authoritative +lifecycle source. Exact retries neither add another successor nor rewrite the +same lifecycle row. + +## Status And Output View + +Todo cards answer **what the agent should do next**. They do not, by +themselves, answer **what happened to one issue**. `loopx issue-fix outcome` +fills that read-model gap without creating another ledger or lifecycle state +machine. It derives one stable `issue_fix_outcome_projection_v0` case from the +existing feasibility row, revision-pinned repository context, optional compact +delivery evidence, and optional PR lifecycle row. + +Compact delivery evidence uses `outcome_status=in_progress|completed|blocked` +and `validation_status=passed|failed|partial|not_run`. Terminal PR state still +takes precedence, while an explicit blocked delivery remains visible over a +non-terminal wait such as pending CI. + +A delivery that names `commit_ref` cannot be projected as validated or +publication-ready merely because its JSON says `passed` or `completed`. For +those states, writeback requires `--repo-path` plus a full +`--repository-ref` under `refs/heads`, `refs/remotes`, or `refs/tags`. LoopX +checks that the checkout has a GitHub remote matching `--repo`, resolves the +pinned repository revision and commit objects, proves ancestry, and requires +the recovery ref to resolve exactly to the pinned revision. The persisted +`issue_fix_repository_commit_evidence_v0` keeps the full object ids, recovery +ref, and a clone-stable repository fingerprint, but no checkout path, remote +URL, or raw git output. Legacy evidence without that proof remains visible but +is downgraded to `validation=unverified` and +`stage=delivery_evidence_unverified` until it is re-resolved. + +The case card exposes the selected route and current stage; issue and PR links; +repository revision and context fingerprint; reproduction and validation +status; repo-relative changed files and commit ref when explicitly supplied; +checks, review, mergeability, and terminal result; remaining risks; and the next +action. Missing delivery evidence remains `declared` or unknown—PR existence is +never treated as proof that focused validation passed. + +The packet is directly consumable by `loopx lark-kanban sync-projection`. +Execution todos remain separate cards, while the stable outcome card is keyed +by repository and issue. A merged, closed, or triaged terminal card remains +visible by default so the board shows outputs instead of only active work. +Shared sinks continue to apply the existing local-path, private-link, and +private-reference redaction boundary. + +The default `loopx lark-kanban sync-loopx-todos` path also derives all issue +outcomes from the goal's existing feasibility and PR lifecycle domain state and +upserts them beside todo rows. A feasibility row therefore appears as issue work +even before a PR exists; a PR enriches that row only when its lifecycle +observation carries the matching `repo` and explicit `issue_ref`. Numeric issue +aliases (`#123`, `issue_123`, `issues/123`) canonicalize to `issues_123` on +write and when reading legacy rows, so equivalent explicit links cannot silently +fall into the unlinked count. The command's `--limit` applies only to active todo +rows; all derived outcome rows remain in scope, and the receipt exposes the +split through `limit_policy`. This automatic closeout projection adds no outcome +ledger or second state machine. + +Supplying `--delivery-evidence-json` alone is a read-only preview. Add +`--write-delivery-evidence` after focused validation to store its validated, +public-safe compact form inside the existing feasibility row. Later default +outcome and Kanban syncs then retain the validation, changed files, commit, output +links, and risks instead of falling back to the feasibility declaration. The +write flag rejects an ad hoc `--feasibility-json` source so the destination is +always the stable goal-scoped row. +Passed or completed evidence that includes a commit also needs the approved +checkout and recoverable ref described above. A missing, divergent, or stale +commit fails before ledger mutation. Repeating the same material proof is an +unchanged, no-write operation even when its verification timestamp is newer. + +The Lark adapter renders this as a first-class issue dimension rather than +only flattening the packet into `Evidence`. Outcome rows set +`Work Item Type=Issue Fix` and populate `Repository`, `Issue`, `Pull Request`, +`Route`, `Stage`, `Validation`, `Outcome`, and `Context Tags`. The bounded +multi-select tags expose route, stage, reproduction/validation status, test +changes, multi-file scope, and grounded repository context without copying +free-form evidence. `Issue Fix Outcomes` provides the table view; `Issue Fix +Kanban` groups the same rows by `Stage`. Existing boards gain the missing fields +and views through idempotent `lark-kanban setup --execute` schema reconciliation. + +## Commands + +```bash +# Preview the complete issue-fix workflow. +loopx issue-fix workflow-plan \ + --url https://github.com/owner/repo/issues/123 \ + --repo-path /path/to/approved/repo \ + --repository-context-json context.json \ + --repository-memory-json compact-search-read-result.json \ + --fetch-candidate-evidence \ + --goal-id example-goal \ + --validation-label "focused unit test" \ + --format json + +# Or configure the reusable OpenViking provider once. The config stays local +# and binds one stable public default-branch scope; checkout revision is +# supplied separately for verification. +export LOOPX_ISSUE_FIX_REPOSITORY_MEMORY_PROVIDER_CONFIG=/path/to/provider.json +loopx issue-fix workflow-plan \ + --url https://github.com/owner/repo/issues/123 \ + --repo-path /path/to/approved/repo \ + --repository-context-json context.json \ + --repository-memory-query "affected module reproduction validation" \ + --fetch-candidate-evidence \ + --goal-id example-goal \ + --validation-label "focused unit test" \ + --format json + +# Low-level provider preflight. Normal Issue-Fix callers use +# build_issue_fix_pr_description() so recall, fail-open, and receipt attribution +# stay on one explicit artifact boundary. Semantic content remains provider-owned. +loopx semantic-preference recall \ + --project . \ + --config .loopx/config/semantic-preference.json \ + --surface issue_fix.pr_description \ + --context repository=owner/repo \ + --execute \ + --format json + +# If inbound feedback is configured, install the generic host collector once, +# inspect health, and drain durable events before acknowledging them. +loopx lark-inbox collector-install \ + --project . --config .loopx/config/lark/collector.json --execute --format json +loopx lark-inbox collector-status \ + --project . --config .loopx/config/lark/collector.json \ + --probe-event-bus --format json +loopx lark-inbox drain \ + --project . --config .loopx/config/lark/event-inbox.json --format json + +# Select one route and persist compact goal-scoped feasibility state. +loopx issue-fix feasibility \ + --url https://github.com/owner/repo/issues/123 \ + --reproduction-status confirmed \ + --reproduction-label "focused contract repro" \ + --scope-class bounded \ + --validation-label "focused unit test" \ + --repository-context-json context.json \ + --repository-memory-json compact-search-read-result.json \ + --goal-id example-goal \ + --format json + +# Promote a reproducible defect found during real work into one canonical issue. +# The structured input records open/closed duplicate-search evidence and the +# revision-pinned public facts; retries do not create another issue or row. +loopx issue-fix promote-discovered-issue \ + --goal-id example-goal \ + --project /path/to/connected/project \ + --promotion-json discovered-issue-promotion.json \ + --execute \ + --format json + +# Project repository impact and attributed outputs without writing state. +loopx issue-fix metrics \ + --goal-id public-issue-fix-goal \ + --project /path/to/connected/project \ + --repo owner/repo \ + --repository-baseline-json baseline.json \ + --repository-current-json current.json \ + --supplement-json optional-public-counts.json \ + --format json + +# Recommend reviewers without requesting external review. +loopx issue-fix reviewer-plan \ + --repo-path /path/to/approved/repo \ + --repo owner/repo \ + --base-ref origin/main \ + --exclude-reviewer @pull-request-author \ + --exclude-author-name "PR Author Git Name" \ + --reviewer-sources-json reviewer-sources.json \ + --execute \ + --format json + +# Notify the default top non-author reviewer and verify the formal request or permission fallback. +loopx issue-fix reviewer-request \ + --url https://github.com/owner/repo/pull/456 \ + --repo-path /path/to/approved/repo \ + --base-ref origin/main \ + --reviewer-sources-json reviewer-sources.json \ + --goal-id example-goal \ + --project /path/to/approved/repo \ + --execute \ + --format json + +# Project PR lifecycle into LoopX continuation state. +loopx issue-fix pr-lifecycle \ + --url https://github.com/owner/repo/pull/456 \ + --issue-ref issues_123 \ + --fetch-metadata \ + --goal-id example-goal \ + --format json + +# Derive one issue status/output projection from existing domain state. +loopx issue-fix outcome \ + --goal-id example-goal \ + --project /path/to/approved/repo \ + --repo owner/repo \ + --issue-ref issues_123 \ + --pr-ref pull_456 \ + --delivery-evidence-json delivery-evidence.json \ + --write-delivery-evidence \ + --repository-memory-provider-json provider.json \ + --write-repository-memory \ + --repo-path /path/to/approved/repo \ + --repository-ref refs/remotes/origin/main \ + --agent-id codex-issue-fix \ + --format json +``` + +## Validation + +```bash +python3 examples/issue-fix-capability-guide-smoke.py +python3 examples/issue-fix-reviewer-recommendation-smoke.py +python3 examples/issue-fix-reviewer-request-smoke.py +python3 examples/issue-fix-reviewer-notification-sink-smoke.py +python3 examples/issue-fix-workflow-plan-smoke.py +python3 examples/issue-fix-workflow-contract-smoke.py +python3 examples/issue-fix-repository-context-smoke.py +python3 examples/issue-fix-repository-memory-smoke.py +python3 examples/issue-fix-validated-memory-writeback-smoke.py +python3 examples/issue-fix-feasibility-smoke.py +python3 examples/issue-fix-discovered-issue-promotion-smoke.py +python3 examples/issue-fix-pr-lifecycle-smoke.py +python3 examples/issue-fix-metrics-projection-smoke.py +python3 examples/issue-fix-repository-snapshot-smoke.py +python3 examples/issue-fix-metrics-supplement-smoke.py +python3 examples/issue-fix-capability-gap-metrics-smoke.py +python3 examples/issue-fix-maintainer-correction-smoke.py +python3 examples/issue-fix-outcome-projection-smoke.py +python3 examples/issue-fix-explore-projection-smoke.py +python3 examples/issue-fix-acceptance-loop-smoke.py +loopx canary premerge --from-git-diff +``` + +## Non-Goals + +- LoopX does not bypass repository review or branch protection. +- Reviewer recommendation is not reviewer assignment or availability proof. +- The capability does not default to automatic merge or production actions. +- It does not store raw transcripts, tool logs, expert answers, credentials, or + private issue material in public state. +- It does not add repository-specific branches such as `if repo == ...` to the + generic control plane. diff --git a/src/crates/assembly/core/src/miniapp/loopx/resources/loopx-pinned-issue-fix-workflow-contract.md b/src/crates/assembly/core/src/miniapp/loopx/resources/loopx-pinned-issue-fix-workflow-contract.md new file mode 100644 index 0000000000..40b1a02fe0 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/resources/loopx-pinned-issue-fix-workflow-contract.md @@ -0,0 +1,265 @@ +# issue_fix_workflow_contract_v0 + +`issue_fix_workflow_contract_v0` ties the existing issue-fix surfaces into one +GitHub issue fix workflow. It is a product contract, not a new state store: +LoopX still uses metadata preview, intake packets, LoopX todos, caller-approved +repo branches, validation evidence, review packets, and explicit gates as the +source of truth. + +## User Story + +A user gives LoopX a public GitHub issue or PR signal and an approved local +repository context. LoopX should classify the issue, decompose the work into +owner/user gates and agent todos, prepare or claim an issue branch, run the +declared validation, and emit a PR-review-ready packet. LoopX must not read raw +issue bodies, raw comments, private repro material, create external comments, +open PRs, merge, publish, or run destructive git without an explicit gate. + +## Workflow Stages + +1. **Candidate preflight:** reconcile prior issue-fix domain state, all-state + closing PR references, cross-references, and maintainer-comment metadata + before projecting patch-planning work. Without source evidence, admission is + `evidence_required`, the final route is absent, and the candidate is not + runnable. + Each PR evidence field is an issue-specific query receipt carrying + `repo`, `issue_ref`, `query_scope`, `complete`, `truncated`, and `rows`. + Each field accepts one receipt object, not a list: + `numeric_pr_evidence.query_scope` is `issue_specific_all_states`, + `semantic_pr_evidence.query_scope` is `issue_specific_current_revision`, + and `maintainer_comment_evidence.query_scope` is + `issue_specific_comment_metadata`. + Empty rows are valid only for complete, non-truncated receipts. Every + returned row must be parseable and issue-scoped; malformed rows invalidate + the receipt rather than disappearing into a false negative. + `--fetch-candidate-evidence` invokes the bounded built-in public GitHub + collector; `--candidate-preflight-json` remains the provider-neutral + adapter/test seam. Admission is `evidence_required`, + `verification_required`, `admitted`, or `terminal`; only final admission + exposes `proceed`, `reuse_existing_pr`, `comment_only`, or `skip`. + Cross-references, closed PRs, and maintainer comments project typed + successors instead of masquerading as final `comment_only`. + `issue_fix_candidate_resolution_v0` is the single compact resolution input: + every row must match current source evidence. PR resolution binds the exact + head revision, and maintainer-comment resolution binds the comment + `updatedAt` revision. A changed source therefore invalidates stale resolution. + Comment content remains behind the provider-content gate and only its + compact disposition may enter the resolution receipt. +2. **Metadata preview:** build `github_issue_metadata_preview_v0` from a public + URL, compact reference, mocked metadata, or caller-approved metadata fetch. + Allowed fields are repo, issue or PR number, state, title summary, labels, + updated timestamp, author association, comment count, and permalink. Body, + comment, timeline, event, raw, and provider response fields are gated. +3. **Intake classification:** build `issue_fix_intake_v0` with issue class, + code-context route candidates, owner/user gate projections, and ordered + agent todo candidates. The first screen must name `waiting_on`, top agent + todo, top gate when present, and next safe action. +4. **Repository context:** build `issue_fix_repository_context_v0` from a + pinned repository revision plus compact source refs. Current authoritative + or verified repository evidence may ground change scope, reproduction, and + validation. Stale memory and external experts remain advisory. The context + projects missing reads but does not introduce another lifecycle state, + authorize external writes, or override feasibility routing. +5. **Workflow plan:** build `issue_fix_workflow_plan_packet_v0` to compose the + metadata preview, intake, branch dry-run, validation label, ordered LoopX + todo writeback preview, resolution route candidates, gate preview, post-PR + lifecycle monitor plan, and PR-review readiness blockers. This stage does + not write todos. It writes only the candidate preflight receipt when a goal + id or explicit ledger path is present; `--no-write-domain-state` keeps that + receipt preview-only. +6. **Feasibility checkpoint:** build `issue_fix_feasibility_v0` from compact + public-safe agent observations only after candidate preflight returns + `proceed`. The decision must select exactly one + `fix_pr`, `comment_only`, or `triage_only` route. `fix_pr` requires bounded + scope plus named reproduction and validation surfaces; planned reproduction + projects confirmation work before patch work. With a goal id, the compact + decision writes issue-fix domain state by default. +7. **LoopX todo writeback:** for a non-proceed candidate, write only the + successor or no-follow-up projected by candidate preflight. For `proceed`, + write the single route-specific successor projected by feasibility. Preserve + priority and planner order. User todos represent concrete external-write, + private-material, merge, publish, or repository-policy gates. +8. **Caller repo branch:** use `issue_fix_caller_repo_branch_packet_v0` only + after the caller provides an approved local git repo, base branch, issue + branch policy, and validation command. Dry-run mode must not inspect the + repo. Execute mode may inspect the approved repo and create or claim a + `codex/` issue branch, but must refuse branch switches from dirty state. +9. **Validation:** record focused validation as pass/fail, exit code, and + public-safe label. Validation stdout, stderr, local paths, and raw git output + stay out of the packet. A validated fix should prove failing-before and + passing-after evidence when that repro path is available. When delivery + names a commit and reports `passed` or `completed`, writeback must resolve + the declared repository revision and commit in the caller-approved checkout, + prove commit ancestry, and retain `issue_fix_repository_commit_evidence_v0` + with a matching repository fingerprint plus a full recoverable branch, tag, + or remote ref. Missing or stale commit proof fails before state mutation; + legacy unproved rows project as `unverified`, not publication-ready. +10. **PR review packet:** emit `issue_fix_pr_review_packet_v0` only when branch, + validation, and repo-relative changed-file evidence are sufficient for human + review. Its `issue_fix_pr_description_contract_v0` keeps the PR-review + motivation/approach/change/validation/risk structure, requires a compact key + code or pseudocode section for code changes, and requires a post-fix + repository CLI or focused code/test reproduction when applicable. Optional + infographics are limited to complex changes and cannot replace textual + evidence. Issue-backed changes add one functional reference block after any + semantic-preference rewrite: use `Fixes #N` for a complete fix targeting the + default branch, and `Related to #N` for partial work. Use full syntax for + every issue and verify closing references through GitHub + `closingIssuesReferences`. The packet is review evidence, not external + publication authority. +11. **PR lifecycle monitor:** after a PR exists, use + `issue_fix_pr_lifecycle_monitor_v0` to project compact public PR state into + exactly one of `runnable_successor`, `monitor_continuation`, `user_gate`, or + `no_followup`. Terminal PR states such as `MERGED` and `CLOSED` take + precedence over stale review metadata. Failed checks, requested changes, and + stale merge states create runnable successors instead of `monitor_quiet_skip`. + The command writes compact domain state by default when a `--goal-id` or + `--ledger-path` is provided, and `--no-write-domain-state` keeps it + preview-only. Persisted lifecycle state should carry an explicit public-safe + `issue_ref`; numeric aliases such as `#123`, `issue_123`, and `issues/123` + canonicalize to `issues_123` before writeback. Outcome projection applies + the same rule to legacy rows, but must not infer the issue from a branch + name, PR title, or prose. Its + `issue_fix_pr_grouped_monitor_projection_v1` assigns each open PR to a + repository lifecycle-state bucket. Materialize at most one + `continuous_monitor` for each nonempty bucket, upsert/remove PR membership + as state changes, and complete empty buckets. Never create one monitor per + PR. Material PR work remains a one-shot advancement todo, and reviewer + notifications remain one PR per message. `pr-lifecycle + --execute-transition --goal-id --claimed-by ` performs this + reconciliation through the generic todo API; `--monitor-cadence` controls + the schedule and defaults to `30m`. A quiet replay with unchanged bucket + membership is idempotent. The monitor poll lane, rather than repeated PR + lifecycle execution, owns later cadence advancement. The creating issue-fix + agent remains the monitor's `claimed_by` owner across turns; another peer + cannot update, retire, reopen, or poll that monitor without explicit Todo + lifecycle authority. + With a public PR URL, `--execute-transition` fetches compact public metadata + automatically unless `--metadata-json` supplies a deterministic fixture. +11. **Gate handling:** surface concrete gates instead of silently blocking. Safe + metadata-only triage, public-code search, and focused smoke drafting may + continue when those gates do not cover the selected action. +12. **Outcome projection:** use `issue_fix_outcome_projection_v0` to derive one + stable operator-facing case from the existing feasibility row, repository + context, optional `issue_fix_delivery_evidence_input_v0`, and optional PR + lifecycle row. This projection writes no source state and creates no parallel + workflow state machine. It must keep unknown delivery evidence explicit, + retain terminal outputs, derive only bounded public-safe `context_tags`, and + remain consumable by generic projection sinks. + Default goal-level Kanban sync derives an + `issue_fix_outcome_collection_projection_v0` from all feasibility rows and + explicitly linked lifecycle rows before upserting issue outcome cards. + +## Public-Safe Boundary + +Packets in this workflow must preserve these boundary flags: + +- `issue_body_captured: false` +- `comment_bodies_captured: false` +- `response_payload_captured` or `response_payloads_captured: false` +- `local_paths_captured: false` +- `external_writes_performed: false` +- `destructive_git_used: false` + +`private_repo_state_read` is `false` for preview, intake, fixtures, and +caller-repo dry-runs. It may be `true` only for caller-approved +`caller-repo-branch --execute`, and even then local paths, raw validation +output, raw git output, and credentials must not be recorded. + +## Todo And Gate Shape + +Issue-fix todo plans should be small and ordered. For a clear bounded bug, use +the minimum sufficient plan rather than management filler: + +- `[P0] Reproduce or classify the issue from public metadata and approved code + context.` +- `[P0] Patch the selected issue branch and rerun the caller-declared + validation.` +- `[P1] Prepare the PR review packet with repo-relative changed files, + validation labels, and remaining gates.` +- `[P2] Monitor the PR lifecycle and project CI, review, merge, or stale-branch + changes into a successor, gate, continuation, or no-follow-up.` + +When several todos have the same priority, planner order plus LoopX write order +is the tie-breaker. Do not infer a gate from prose alone: write it as a user +todo or operator gate with the concrete action it blocks. + +Resolution routes must stay explicit. `fix_pr` is appropriate only when a +focused repro or validation plan is available. `comment_only` should produce a +public-safe maintainer comment packet but still needs an explicit external-write +gate. `triage_only` is valid when the issue lacks enough public evidence for a +useful patch or comment. + +## Domain State + +Issue-fix domain state is a project-local read model for compact decisions and +long-running monitors: + +```text +.loopx/domain-state//issue_fix/candidate-preflight.jsonl +.loopx/domain-state//issue_fix/feasibility.jsonl +.loopx/domain-state//issue_fix/pr-lifecycle.jsonl +``` + +Candidate preflight and feasibility rows are keyed by `repo` and `issue_ref`; +PR lifecycle rows are keyed by `repo` and `pr_ref`. They may store compact +observations, decisions, and fingerprints. Candidate preflight retains the +source receipts and prior-work disposition that decide whether feasibility is +legal. A feasibility observation may include one compact +`issue_fix_repository_context_v0` projection so its repository revision, +source refs, coverage, expert policy, and memory policy survive across turns. +Domain state must not store issue bodies, comment bodies, raw +provider payloads, raw logs, local paths, credentials, or destructive-git +output. Public packet validation remains the behavior contract; domain state +only keeps the agent from forgetting its latest compact decision. + +## Ready Criteria + +An issue-fix workflow is PR-review-ready only when all of these are true: + +- metadata/intake preserved body-free and comment-free boundaries; +- accepted todos or gates were written to LoopX state, not left in chat; +- the issue branch is created or claimed inside the caller-approved repo; +- the declared validation ran and passed, or the packet clearly says review is + not ready yet; +- changed files are repo-relative and bounded; +- no external issue comment, PR creation, merge, publish, production action, or + destructive git action occurred. + +`issue_fix_workflow_plan_packet_v0` also projects a +`repository_context_input_contract` with the accepted top-level/source fields +and a minimal example. Hosts should construct feasibility input from that +contract instead of copying the normalized `issue_fix_repository_context_v0` +output shape. + +## Related Schemas + +- `github_issue_metadata_preview_v0` +- `content_ops_issue_fix_metadata_preview_packet_v0` +- `content_ops_issue_fix_intake_packet_v0` +- `issue_fix_intake_v0` +- `issue_fix_workflow_plan_packet_v0` +- `issue_fix_candidate_preflight_v0` +- `issue_fix_candidate_resolution_v0` +- `issue_fix_candidate_successor_v0` +- `issue_fix_candidate_preflight_domain_state_projection_v0` +- `issue_fix_repository_context_input_v0` +- `issue_fix_repository_context_v0` +- `issue_fix_repository_context_effect_v0` +- `issue_fix_feasibility_v0` +- `issue_fix_feasibility_observation_v0` +- `issue_fix_feasibility_decision_v0` +- `issue_fix_feasibility_domain_state_projection_v0` +- `issue_fix_pr_lifecycle_monitor_v0` +- `issue_fix_pr_grouped_monitor_projection_v1` +- `issue_fix_pr_lifecycle_transition_v0` +- `issue_fix_pr_lifecycle_domain_state_projection_v0` +- `issue_fix_delivery_evidence_input_v0` +- `issue_fix_repository_commit_evidence_v0` +- `issue_fix_outcome_case_v0` +- `issue_fix_outcome_projection_v0` +- `issue_fix_outcome_collection_projection_v0` +- `loopx_todo_writeback_preview_v0` +- `issue_fix_caller_repo_branch_packet_v0` +- `issue_fix_validated_fix_artifact_v0` +- `issue_fix_pr_review_packet_v0` diff --git a/src/crates/assembly/core/src/miniapp/loopx/resources/loopx-pinned-skills-reference.md b/src/crates/assembly/core/src/miniapp/loopx/resources/loopx-pinned-skills-reference.md new file mode 100644 index 0000000000..bc88de22f9 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/resources/loopx-pinned-skills-reference.md @@ -0,0 +1,1212 @@ +# [1] loopx-project/SKILL.md +--- + +--- +name: loopx-project +description: Use when connecting a repository or project goal document to LoopX, maintaining project-local goal state, refreshing stale dashboard status, syncing local projects into the shared global registry, or diagnosing LoopX CLI/PATH/status/history issues across multiple repos. For registering durable project materials such as Lark/wiki/design docs, prefer the narrower loopx-doc-registry skill. +--- + +# LoopX Project Workflow + +Use this skill when the task mentions LoopX, loopx, a project goal +document, multi-project dashboard/status, stale latest run, +`.loopx/registry.json`, `.codex/goals`, `refresh-state`, +`sync-global`, or connecting a new repo. If the task is mainly about reading, +remembering, recording, indexing, or registering a durable project material, +load `loopx-doc-registry` and use that narrower workflow first. + +LoopX has two layers: + +- **Project-local state**: each repo owns `.loopx/registry.json` and + `.codex/goals//ACTIVE_GOAL_STATE.md`. +- **Shared local control plane**: `~/.codex/loopx` stores run history and + `registry.global.json` for multi-project status. + +Do not manually copy one project's registry entry into another project. Local +`connect` and `refresh-state` should sync into the shared global registry +automatically. + +## Slash Command Fallback + +When the visible user message is exactly a LoopX slash command or starts with a +LoopX slash command plus arguments, do not treat it as ordinary chat. + +Recognized project-local goal-start command: + +- `/loopx ` +- `/loopx --capability-route issue-fix ` + +Recognized repo-review commands: + +- `/loopx-pr-review` +- `/loopx-pr-review