diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index 034fbeec6b..8542e10e08 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -267,6 +267,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 e9f22ef1fc..0b72369bf9 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 d8159bba7b..98dcc459eb 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 9f7cca751c..50ac050c55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6200,6 +6200,7 @@ dependencies = [ "hex", "hmac", "log", + "regex", "semver", "serde", "serde_json", @@ -6406,6 +6407,7 @@ dependencies = [ "chrono", "dirs 6.0.0", "dunce", + "encoding_rs", "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 09eac55a30..cfbdb1dc00 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` -说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **666** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 +说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **673** 个 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 **666** 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 **673** 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 b44ab57775..02382259f9 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": "d7a7419ddd673eb733ae8bd33dbb3dcd3b4d1ce0acfc953067a3403fe26d6699", - "commandCount": 666, + "commandCount": 673, "coverage": { - "commandCount": 666, + "commandCount": 673, "documentedCommandCount": 633, - "implementationCommandCount": 33, - "implementationDigest": "35539d9c1510287cb47f4a68fe35859b78f93bb06cd66b86e58d878a48d8c509" + "implementationCommandCount": 40, + "implementationDigest": "c0a1edc760154624bd17f742f82fb66d08af6e8e2b32e99829e92ed6a75cd75e" }, "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_auth_poll", "moduleId": "miniapp_market", diff --git a/package.json b/package.json index 0e7048af6a..577bf22ced 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,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..72fb8fa64f --- /dev/null +++ b/scripts/build-loopx.mjs @@ -0,0 +1,234 @@ +#!/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 v0.5.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'); + sh(pip, ['install', '--disable-pip-version-check', '--quiet', 'pyinstaller']); + + 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', + '--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))); + } + + 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 9a6d82aee3..f99c5a72a1 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -920,6 +920,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: 'plugin_source_contracts', path: 'tests/plugin_source_contracts.rs', diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index 04cb9911e3..81b67d0ce0 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', ]); @@ -141,6 +144,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-runtime', ['fs', 'io-util', 'net', 'process', 'rt', 'sync', 'time']], ['miniapp-market', ['fs', 'io-util', 'net', 'process', 'rt', 'sync', 'time']], ['plugin-source', ['fs', 'rt', 'sync', 'time']], @@ -1018,6 +1022,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 50e4e2cc54..fcf7360e1f 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' }, @@ -201,6 +206,11 @@ export const productDomainsIntegrationTestTargets = [ path: 'tests/miniapp_contracts.rs', requiredFeatures: ['miniapp'], }, + { + name: 'loopx_contracts', + path: 'tests/loopx_contracts.rs', + requiredFeatures: ['miniapp'], + }, { name: 'plugin_source_contracts', path: 'tests/plugin_source_contracts.rs', diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index bd9ae23f97..d40c55993d 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -4,6 +4,7 @@ export const servicesReqwestOwnerFeatures = [ 'announcement', 'browser-control', 'mcp', + 'miniapp-loopx', 'miniapp-market', 'miniapp-runtime', 'models-dev', @@ -262,7 +263,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'anyhow', ownerFeatures: ['browser-control', 'deep-research', 'mcp', 'remote-connect', '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', @@ -274,7 +275,7 @@ export const optionalDependencyFeatureOwnerRules = [ depName: 'openbitfun-core-types', ownerFeatures: ['deep-research', 'remote-connect', 'speech'], }, - { depName: 'openbitfun-product-domains', ownerFeatures: ['canvas-runtime', 'function-agents', 'hook-import', 'miniapp-market', 'miniapp-runtime', 'plugin-source'] }, + { depName: 'openbitfun-product-domains', ownerFeatures: ['canvas-runtime', 'function-agents', 'hook-import', 'miniapp-loopx', 'miniapp-market', 'miniapp-runtime', 'plugin-source'] }, { depName: 'openbitfun-runtime-ports', ownerFeatures: ['deep-research', 'git', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime', 'web-tools'] }, { depName: 'openbitfun-services-core', @@ -284,6 +285,7 @@ export const optionalDependencyFeatureOwnerRules = [ 'git', 'hook-import', 'mcp', + 'miniapp-loopx', 'miniapp-market', 'miniapp-runtime', 'models-dev', @@ -300,12 +302,12 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'bzip2', ownerFeatures: ['speech'] }, { depName: 'chrono', ownerFeatures: ['git', 'miniapp-market', 'remote-connect', 'remote-ssh-concrete', 'review-platform', 'speech', 'web-tools'] }, { depName: 'dirs', ownerFeatures: ['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'] }, { 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'] }, { depName: 'image', ownerFeatures: ['miniapp-market', 'remote-connect'] }, { depName: 'local-ip-address', ownerFeatures: ['remote-connect'] }, @@ -327,19 +329,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-ssh', 'review-platform', 'speech'] }, + { depName: 'sha2', ownerFeatures: ['canvas-runtime', 'hook-import', 'mcp', 'miniapp-loopx', 'miniapp-market', 'models-dev', 'plugin-source', 'remote-connect', '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: ['browser-control', 'git', 'hook-import', 'miniapp-market', 'plugin-source', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'speech', 'web-tools', 'workspace-search'] }, + { depName: 'thiserror', ownerFeatures: ['browser-control', 'git', 'hook-import', 'miniapp-loopx', 'miniapp-market', 'plugin-source', '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', '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', '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', 'review-platform'] }, { depName: 'x25519-dalek', ownerFeatures: ['remote-connect'] }, ], @@ -1728,6 +1730,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 c2196f9d78..c73fccd481 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 { extractProductConfigArg } from './product-customization/cli.mjs'; import { productBuildEnvironment } from './product-customization/projections.mjs'; import { resolveProductDefinition } from './product-customization/resolver.mjs'; @@ -55,6 +56,7 @@ async function main() { preparePluginHost(); // Flashgrep distribution is temporarily suspended. const flashgrepBinary = null; + 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, }); @@ -253,7 +256,7 @@ export function prepareMacOSFlashgrepForSigning( export function prepareTauriConfig( baseConfigPath, - { desktopDir, flashgrepBinary, resolution, releaseChannel } + { desktopDir, flashgrepBinary, loopxResourceDir, resolution, releaseChannel } ) { const config = JSON.parse(readFileSync(baseConfigPath, 'utf8')); if (resolution) { @@ -265,6 +268,7 @@ export function prepareTauriConfig( config.identifier = resolution.assembly.bundleId; } 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) @@ -368,6 +372,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) { if (!primaryBinary) return []; const binaries = [primaryBinary]; diff --git a/scripts/desktop-tauri-build.test.mjs b/scripts/desktop-tauri-build.test.mjs index 0826f10e22..1cb77d479d 100644 --- a/scripts/desktop-tauri-build.test.mjs +++ b/scripts/desktop-tauri-build.test.mjs @@ -402,7 +402,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 9a48d5c484..92c8a4ea57 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'); @@ -150,15 +151,21 @@ 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', 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 = ''; @@ -327,7 +334,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', }; @@ -553,8 +560,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. @@ -579,6 +598,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 */ @@ -608,7 +676,7 @@ async function main() { let currentStep = 1; // Step 1: Run all independent preparation tasks in parallel. - // copy-monaco / generate-version / mobile-web / plugin-host have no + // copy-monaco / generate-version / mobile-web / 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 @@ -617,7 +685,7 @@ async function main() { currentStep++, totalSteps, desktopMode - ? 'Prepare resources (parallel: monaco, version, mobile-web, plugin-host)' + ? 'Prepare resources (parallel: monaco, version, mobile-web, plugin-host, loopx)' : 'Prepare resources (parallel: monaco, version)' ); @@ -643,6 +711,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: 'Build mobile-web', promise: runCommandPrefixed('mobile-web', 'node', ['scripts/mobile-web-build.cjs', '--install']), diff --git a/src/apps/desktop/AGENTS-CN.md b/src/apps/desktop/AGENTS-CN.md index 2654f56db6..b7f79f2f2c 100644 --- a/src/apps/desktop/AGENTS-CN.md +++ b/src/apps/desktop/AGENTS-CN.md @@ -50,19 +50,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 187f2ca93a..b54828e86a 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -67,6 +67,21 @@ pnpm run prepare:dsh-profile # optional: local DeepSeek Harness sessions 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 634c939780..4a7d3d619d 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -23,8 +23,8 @@ openbitfun-core = { path = "../../crates/assembly/core", features = ["product-fu 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 cc115b422c..c245daeb72 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 38bb045244..e38dd4d3b7 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -35,6 +35,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 d113f3e0d9..eb0689207b 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -589,6 +589,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, @@ -822,6 +829,88 @@ pub async fn run() { &path_manager.user_data_dir(), )); + let loopx_resource_dir = api::app_state::resolve_bundled_loopx_dir(); + let managed_loopx_source_dir = path_manager + .miniapp_dir(openbitfun_product_domains::miniapp::loopx::LOOPX_BUILTIN_APP_ID) + .join("runtime") + .join("loopx-source-v0.5.1"); + 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); + 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( @@ -876,6 +965,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) @@ -912,6 +1002,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| { @@ -1986,6 +2100,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 2f7cbdd23a..ecad3600b6 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -357,6 +357,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 c11c3edb36..07ff3fa49e 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -4443,6 +4443,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, @@ -4465,6 +4466,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, @@ -4478,6 +4480,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: error_text.clone(), duration_ms: Some(duration_ms), queue_wait_ms: None, @@ -6107,28 +6110,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 2a55a2542d..2699d98856 100644 --- a/src/crates/assembly/core/src/agentic/execution/round_executor.rs +++ b/src/crates/assembly/core/src/agentic/execution/round_executor.rs @@ -1411,6 +1411,7 @@ impl RoundExecutor { tool_call.tool_id.clone(), tool_call.tool_name.clone(), ), + params: None, error: format!("Tool arguments stream interrupted: {}", error), duration_ms: None, queue_wait_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 4f10f8907d..9b4f3d9cbd 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 { @@ -219,6 +220,7 @@ impl ToolStateManager { confirmation_wait_ms, execution_ms, } => ToolStateEventKind::Failed { + params: Some(task.invocation.wire_arguments.clone()), error: error.clone(), duration_ms: *duration_ms, queue_wait_ms: *queue_wait_ms, @@ -235,6 +237,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/agent_adapter.rs b/src/crates/assembly/core/src/miniapp/loopx/agent_adapter.rs new file mode 100644 index 0000000000..42bb858551 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/agent_adapter.rs @@ -0,0 +1,635 @@ +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 { + let result = AgentTurnCancellationPort::cancel_turn( + self.coordinator.as_ref(), + AgentTurnCancellationRequest { + session_id: request.session_id, + turn_id: Some(request.turn_id), + source: Some(AgentSubmissionSource::DesktopApi), + requester_session_id: None, + reason: Some("LoopX task paused by the user".to_string()), + wait_timeout_ms: Some(5_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; + } + 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, + } +} + +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..a199119fc3 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/controller.rs @@ -0,0 +1,5212 @@ +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; +/// LoopX 0.5.1 exposes only cadence labels for the outer-controller profile, +/// so the host supplies the concrete wait interval for waiting goals. +const WAIT_RESCHEDULE_FALLBACK_MS: u64 = 60_000; +/// Minimum spacing between consecutive monitor-class re-check turns for one +/// goal. The pinned LoopX v0.5.1 envelope carries no numeric monitor_wait +/// cadence, so a freshly created successor tracking todo is immediately +/// `RunNow`; without this floor the host would drive back-to-back re-check +/// turns that re-verify an external state that cannot have changed. The +/// anchor is the goal's last durable settlement time, not a new host counter. +/// Matches the documented upstream monitor_wait host floor (15 minutes); +/// numeric scheduler hints from a newer pin still take priority through the +/// `Wait` branch's `scheduler_hint_ms` path. +const MONITOR_COMPAT_INTERVAL_MS: u64 = 15 * 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 as a LoopX 0.5.3 checkout +/// steering a turn executed by the pinned 0.5.1 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 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 v0.5.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"); + +/// Closing-ceremony order gleaned from live guard rejections on the pinned +/// v0.5.1 (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 and retry ONCE;\n\ + do not retry the same argv, do not reorder steps, and report a blocker after two ordered attempts.\n\ +- The runtime is project-local (`/.loopx/runtime`); never write to `~/.codex/loopx`."; + +/// 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. \ +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\ +- 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); + 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, +} + +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: AtomicBool, + 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)), + }; + 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: AtomicBool::new(false), + 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 github_auth = self.probe_github_auth(); + let (handshake, workspace, agent, github_auth) = + tokio::join!(handshake, workspace, agent, github_auth); + self.record_progress(progress.take()).await?; + self.commit_environment(handshake, workspace, agent, github_auth) + .await?; + self.reconcile_goal_projections(true).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 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::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 => 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 + .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.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); + 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(()) + } + + 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={}, scheduler_hint_ms={:?}", + task.task_id, + task.goal_id.as_deref().unwrap_or("unknown"), + settlement.turn_id, + settlement.status, + settlement_started.elapsed().as_millis(), + settlement.scheduler_hint_ms + ), + 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.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={} hint_ms={:?} 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_hint_ms, + 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?; + 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; + // loopx 0.5.1 never emits a numeric scheduler hint, and a + // waiting goal with no requeue would sleep forever. The host + // owns the heartbeat cadence: honor an explicit hint when one + // exists, otherwise fall back to a bounded polling interval. + let delay = inspected + .scheduler_hint_ms + .unwrap_or(WAIT_RESCHEDULE_FALLBACK_MS); + 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 => { + let Some(gate) = inspected.pending_user_gate 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). The old behavior failed the whole inspection + // and parked a fully finished task as recovery_required. + // Park as waiting instead: 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()) { + match self + .auto_answer_gate( + &task, + &runtime, + &gate, + LoopxCliGateDecision::Approve, + "Auto-approved by BitFun: read-only public issue metadata 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(()) + } + 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?; + // The contradiction witness is the envelope's own action + // projection, not the `open_count` scalar: in the pinned + // v0.5.1 outer-controller payload that counter comes from the + // agent-claim-scoped hot-lane summary and 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): the + // pinned CLI v0.5.1 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. 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. + 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(()); + } + return self.park_plan_exhausted(&task, &inspected.goal_id).await; + } + } + if inspected.envelope_over_budget { + let message = "LoopX turn envelope exceeded its compaction budget (route contract_error); the Goal durable state for this Issue must shrink before work can resume. BitFun keeps the task queued and retries with backoff."; + let updated = self + .transition_task( + &task_id, + task.generation, + LoopxTaskState::Queued, + LoopxPhase::Queued, + message, + ) + .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; + self.enqueue_task(task_id, Duration::from_millis(WAIT_RESCHEDULE_FALLBACK_MS))?; + return Ok(()); + } + 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(()); + } + if let Some(todo) = inspected.selected_todo.as_ref() { + if is_loopx_monitor_action(&todo.action_kind) { + if let Some(hold_ms) = + monitor_recheck_hold_ms(task.settlement.settled_at, now_ms()) + { + // v0.5.1 compatibility cadence: the monitor todo + // is projected RunNow, but the external state it + // watches was verified by the turn that just + // settled. Park the re-check (yielding the + // repository slot to queued sibling issues) and + // re-drive after the remaining interval. + let message = format!( + "LoopX monitor re-check held back by the host compatibility cadence; next re-check in {} seconds", + hold_ms / 1000 + ); + log::info!( + "LoopX monitor recheck held: task_id={} goal={} action={} hold_ms={}", + task_id, + inspected.goal_id, + todo.action_kind, + hold_ms, + ); + let updated = self + .transition_task( + &task_id, + task.generation, + LoopxTaskState::Queued, + LoopxPhase::Queued, + &message, + ) + .await?; + self.schedule_next_for_repository( + &updated.identity.item.repository.canonical_id(), + Some(&task_id), + ) + .await; + self.enqueue_task(task_id, Duration::from_millis(hold_ms))?; + return Ok(()); + } + } + } + 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 + } + } + } + + /// 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 + ) { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Rejected, + current_revision: task.revision, + task: Some(task.clone()), + message: Some( + "Only stopped, failed, or recovery-required 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; + } + + self.workspace + .reset(LoopxWorkspaceResetRequest { + operation_id: format!("reset-workspaces-{}", uuid::Uuid::new_v4()), + }) + .await + .map_err(|error| error.to_string())?; + 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(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() + }) + } + + 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())?; + 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?; + let response = self + .transition_action( + &task.task_id, + LoopxTaskState::Queued, + LoopxPhase::Queued, + &request.client_request_id, + ) + .await?; + 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. + let compensate_durable_writeback = agent_status != LoopxAgentTurnStatus::Failed + && settlement.status == LoopxCliSettlementStatus::NoDurableProgress + && !self + .runtime(&task.task_id) + .await + .durable_compensation_pending; + let final_state = if compensate_durable_writeback { + LoopxTaskState::Queued + } 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("settlement_unverified".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()) { + 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 metadata 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), + post_settlement_goal + .as_ref() + .and_then(|goal| goal.selected_todo.as_ref()) + .map(|todo| todo.action_kind.as_str()), + ) { + // 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(); + let delay = settlement.scheduler_hint_ms.unwrap_or(0); + self.enqueue_task(task_id, Duration::from_millis(delay))?; + } + } + } + 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 { + Some(text) => format!( + "LoopX is waiting for an owner action outside this host: {text}. Finish that action (for example review or merge the pull request on GitHub); 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.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, + ) -> 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(), + checking_environment_fact(checked_at), + ) + } + Err(error) => ( + unavailable_environment_fact(error.to_string(), checked_at), + LoopxEnvironmentFact::default(), + checking_environment_fact(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) => unavailable_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.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, + ); + }) + .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 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; + } + // 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(()) + } + + /// 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 + } + LoopxCliSettlementStatus::RetryRequired => agent_status == LoopxAgentTurnStatus::Completed, + LoopxCliSettlementStatus::NoDurableProgress | LoopxCliSettlementStatus::GoalCompleted => { + false + } + } +} + +/// 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 + } + } +} + +/// 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): the pinned CLI v0.5.1 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 metadata access is +/// agent work, not an owner decision. New read-only gate kinds must be +/// added here deliberately; external-write gates always stay interactive. +fn is_read_only_user_gate(action_kind: Option<&str>) -> bool { + let Some(kind) = action_kind.map(str::trim) else { + return false; + }; + kind == "approve_github_issue_body_or_comment_read" + || (kind.starts_with("approve_") && kind.ends_with("_read")) +} + +/// 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") +} + +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- +/// class successor always yields: it waits on an external event the agent +/// cannot advance, so holding the slot would starve sibling issues while the +/// drive-time compatibility cadence spaces the re-checks. +fn sticky_continue_after_settlement( + final_state: LoopxTaskState, + post_settlement_run_decision: Option, + post_settlement_selected_action: Option<&str>, +) -> bool { + if final_state != LoopxTaskState::Queued { + return false; + } + if post_settlement_selected_action.is_some_and(is_loopx_monitor_action) { + return false; + } + match post_settlement_run_decision { + None => true, + Some(decision) => matches!(decision, LoopxCliRunDecision::RunNow), + } +} + +/// v0.5.1 compatibility cadence for monitor-class re-checks: when the goal's +/// last durable settlement happened less than [`MONITOR_COMPAT_INTERVAL_MS`] +/// ago, hold the re-check back for the remaining interval. `None` means run +/// now (no settlement anchor yet — e.g. a resumed or fresh goal — or the +/// interval already elapsed). The anchor is durable settlement evidence, not +/// a host-side convergence counter. +fn monitor_recheck_hold_ms(settled_at: Option, now: i64) -> Option { + let settled_at = settled_at?; + // Clamp at zero: a settlement timestamp in the future (clock skew) + // must hold the full interval, not interval + skew (`saturating_sub` + // only saturates at the i64 boundary, so the explicit `.max(0)` is + // required; caught by monitor_recheck_hold_anchors_on_last_settlement). + let elapsed = now.saturating_sub(settled_at).max(0); + if elapsed < MONITOR_COMPAT_INTERVAL_MS as i64 { + Some((MONITOR_COMPAT_INTERVAL_MS as i64 - elapsed).max(0) as u64) + } else { + None + } +} + +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, + } +} + +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 mut chars = summary.chars(); + let bounded = chars + .by_ref() + .take(MAX_AGENT_SUMMARY_CHARS) + .collect::(); + if chars.next().is_some() { + format!("{bounded}\n\n[Summary truncated by LoopX host]") + } else { + bounded + } +} + +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(|| { + "Install Node.js from https://nodejs.org (or via your package manager), then re-check this environment" + .to_string() + }), + 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() + } +} + +fn unavailable_loopx_environment_fact( + detail: impl Into, + checked_at: Option, +) -> LoopxEnvironmentFact { + LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Unavailable, + detail: Some(detail.into()), + 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 + && task.pending_gate_id.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 0.5.3 + // docs against a 0.5.1 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 pinned v0.5.1 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. + 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, + )); + } + + #[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); + + assert!(bounded.ends_with("[Summary truncated by LoopX host]")); + 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), + Some("issue_fix_collect_candidate_evidence"), + )); + // 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, + None, + )); + // Cadence wait: yield the slot to the next queued issue. + assert!(!sticky_continue_after_settlement( + LoopxTaskState::Queued, + Some(LoopxCliRunDecision::Wait), + None, + )); + assert!(!sticky_continue_after_settlement( + LoopxTaskState::Queued, + Some(LoopxCliRunDecision::WaitingForUser), + None, + )); + // Terminal or parked states always yield. + assert!(!sticky_continue_after_settlement( + LoopxTaskState::Completed, + Some(LoopxCliRunDecision::RunNow), + None, + )); + assert!(!sticky_continue_after_settlement( + LoopxTaskState::RecoveryRequired, + Some(LoopxCliRunDecision::RunNow), + None, + )); + assert!(!sticky_continue_after_settlement( + LoopxTaskState::WaitingForUser, + None, + None, + )); + } + + #[test] + fn depth_first_sticky_yields_monitor_successors_even_when_runnable() { + // v0.5.1 projects a freshly created successor tracking todo RunNow + // immediately; the sticky lane must not let it hold the repository + // slot. It yields and the drive-time compatibility cadence spaces the + // re-checks. + assert!(!sticky_continue_after_settlement( + LoopxTaskState::Queued, + Some(LoopxCliRunDecision::RunNow), + Some("issue_fix_track_pr_merge_readiness"), + )); + assert!(!sticky_continue_after_settlement( + LoopxTaskState::Queued, + None, + Some("issue_fix_pr_state_open_monitor"), + )); + // Real work successors still keep the slot and continue deep. + assert!(sticky_continue_after_settlement( + LoopxTaskState::Queued, + Some(LoopxCliRunDecision::RunNow), + Some("issue_fix_implementation"), + )); + } + + #[test] + fn monitor_recheck_hold_anchors_on_last_settlement() { + let now = 10_000_000_i64; + // No settlement anchor (fresh or resumed goal): run now. + assert_eq!(monitor_recheck_hold_ms(None, now), None); + // Settled 3 seconds ago: hold back the remaining interval. + assert_eq!( + monitor_recheck_hold_ms(Some(now - 3_000), now), + Some(MONITOR_COMPAT_INTERVAL_MS - 3_000) + ); + // Exactly one interval old (or older): run now. + assert_eq!( + monitor_recheck_hold_ms(Some(now - MONITOR_COMPAT_INTERVAL_MS as i64), now), + None + ); + assert_eq!( + monitor_recheck_hold_ms(Some(now - 60 * MONITOR_COMPAT_INTERVAL_MS as i64), now), + None + ); + // Clock skew (settlement timestamp in the future): hold the full + // interval instead of spinning. + assert_eq!( + monitor_recheck_hold_ms(Some(now + 5_000), now), + Some(MONITOR_COMPAT_INTERVAL_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)); + } +} 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-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